mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Compare commits
36 Commits
main
...
server-rew
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a171bc2783 | ||
|
|
242920316b | ||
|
|
2fc64b3e8b | ||
|
|
02ca403e24 | ||
|
|
17e87178fc | ||
|
|
dd6c1e7567 | ||
|
|
0ab80528fe | ||
|
|
e07ef046a6 | ||
|
|
71b74914a3 | ||
|
|
f65f41ad17 | ||
|
|
df4569fe17 | ||
|
|
38743dc8b8 | ||
|
|
9d1e7764e6 | ||
|
|
fa1d2012e1 | ||
|
|
8168e1366a | ||
|
|
ea92fb4c01 | ||
|
|
fc476a1683 | ||
|
|
5946ca1b7a | ||
|
|
6c4fdedcd1 | ||
|
|
1001b7f887 | ||
|
|
86dbdbddf8 | ||
|
|
d46fb013ac | ||
|
|
8ffd00eb47 | ||
|
|
853155600a | ||
|
|
ad9bc911b6 | ||
|
|
2235b8178b | ||
|
|
e37a37bcf9 | ||
|
|
af4665e7c9 | ||
|
|
a42ed79003 | ||
|
|
334be5f7cc | ||
|
|
4c1e4691be | ||
|
|
4fd4997e60 | ||
|
|
6eb63ce9f8 | ||
|
|
01b7b8dbba | ||
|
|
e060bc7cc5 | ||
|
|
d691619b98 |
@@ -22,6 +22,8 @@ Now you can open the codebase in [IDEA](https://www.jetbrains.com/idea/download/
|
||||
|
||||
### Java (server)
|
||||
|
||||
Before contributing to the server, read [server/README.md](server/README.md) for an overview of its architecture and design guidelines.
|
||||
|
||||
The Java code is built with `gradle`, a CLI tool that manages java projects and their
|
||||
dependencies.
|
||||
- You can run the server by running `./gradlew run` in your IDE's terminal.
|
||||
|
||||
@@ -22,6 +22,8 @@ Latest setup instructions are [in our docs](https://docs.slimevr.dev/server/inde
|
||||
## Building & Contributing
|
||||
For information on building and contributing to the codebase, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
For an overview of the server architecture and design guidelines, see [server/README.md](server/README.md).
|
||||
|
||||
## Translating
|
||||
|
||||
Translation is done via Pontoon at [i18n.slimevr.dev](https://i18n.slimevr.dev/). Please join our [Discord translation forum](https://discord.com/channels/817184208525983775/1050413434249949235) to coordinate.
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
|
||||
perSystem = { pkgs, ... }:
|
||||
let
|
||||
java = pkgs.javaPackages.compiler.temurin-bin.jdk-24;
|
||||
|
||||
runtimeLibs = pkgs: (with pkgs; [
|
||||
jdk17
|
||||
java
|
||||
|
||||
alsa-lib at-spi2-atk at-spi2-core cairo cups dbus expat
|
||||
gdk-pixbuf glib gtk3 libdrm libgbm libglvnd libnotify
|
||||
@@ -33,8 +35,8 @@
|
||||
name = "slimevr-env";
|
||||
targetPkgs = runtimeLibs;
|
||||
profile = ''
|
||||
export JAVA_HOME=${pkgs.jdk17}
|
||||
export PATH="${pkgs.jdk17}/bin:$PATH"
|
||||
export JAVA_HOME=${java}
|
||||
export PATH="${java}/bin:$PATH"
|
||||
|
||||
# Tell electron-builder to use system tools instead of downloading them
|
||||
export USE_SYSTEM_FPM=true
|
||||
|
||||
@@ -20,3 +20,4 @@ buildconfigVersion=6.0.7
|
||||
# We should probably stop using grgit, see:
|
||||
# https://andrewoberstar.com/posts/2024-04-02-dont-commit-to-grgit/
|
||||
grgitVersion=5.3.3
|
||||
wireVersion=5.3.1
|
||||
|
||||
316
server/README.md
Normal file
316
server/README.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# SlimeVR Server — Design Guidelines
|
||||
|
||||
This document explains the architectural choices made in the server rewrite and how to extend the system correctly.
|
||||
|
||||
---
|
||||
|
||||
## Core Principle: Reducers and State
|
||||
|
||||
Every major part of this server — a tracker, a device, a UDP connection, a SolarXR session — manages state the same way: immutable data, typed actions, and pure reducer functions that transform one into the other.
|
||||
|
||||
This is not accidental. It gives us:
|
||||
- **Predictability**: state only changes through known, enumerated actions
|
||||
- **Observability**: any code can `collect` the `StateFlow` and react to changes
|
||||
- **Concurrency safety**: `StateFlow.update` is atomic; two concurrent dispatches never corrupt state
|
||||
|
||||
---
|
||||
|
||||
## The Context System
|
||||
|
||||
The `Context<S, A>` type (`context/context.kt`) is the building block of every module:
|
||||
|
||||
```kotlin
|
||||
class Context<S, in A>(
|
||||
val state: StateFlow<S>, // current state, readable by anyone
|
||||
val scope: CoroutineScope, // lifetime of this module
|
||||
) {
|
||||
fun dispatch(action: A)
|
||||
fun dispatchAll(actions: List<A>)
|
||||
}
|
||||
```
|
||||
|
||||
`Context.create` wires everything together:
|
||||
1. Takes an `initialState` and a list of **behaviours**
|
||||
2. On each `dispatch`, folds all behaviours' `reduce` over the current state
|
||||
3. Publishes the new state on the `StateFlow`
|
||||
|
||||
**Never mutate state directly.** Always go through `dispatch`.
|
||||
|
||||
---
|
||||
|
||||
## Behaviours: Splitting Concerns
|
||||
|
||||
A `Behaviour` is an interface with two methods, both with no-op defaults:
|
||||
|
||||
```kotlin
|
||||
interface Behaviour<S, A, C> {
|
||||
fun reduce(state: S, action: A): S = state
|
||||
fun observe(receiver: C) {}
|
||||
}
|
||||
```
|
||||
|
||||
- **`reduce`**: Pure function. Handles the actions it cares about, returns the rest unchanged. Override only if the behaviour needs to modify state.
|
||||
- **`observe`**: Called once at construction. Launches coroutines, registers event listeners, subscribes to other state flows. Override only if the behaviour has side effects.
|
||||
|
||||
The type parameter `C` is what the observer receives. For modules where the behaviour only needs the context, `C = Context<S, A>`. For modules where behaviours need access to the full service object (its `send` method, dispatchers, etc.), `C` is the service class itself:
|
||||
|
||||
```kotlin
|
||||
// Observer receives only the context
|
||||
typealias DeviceBehaviour = Behaviour<DeviceState, DeviceActions, DeviceContext>
|
||||
|
||||
// Observer receives the full connection object
|
||||
typealias UDPConnectionBehaviour = Behaviour<UDPConnectionState, UDPConnectionActions, UDPConnection>
|
||||
```
|
||||
|
||||
Every module follows the same construction pattern:
|
||||
|
||||
```kotlin
|
||||
val behaviours = listOf(BehaviourA, BehaviourB, BehaviourC)
|
||||
|
||||
val context = Context.create(
|
||||
initialState = ...,
|
||||
scope = scope,
|
||||
behaviours = behaviours,
|
||||
)
|
||||
|
||||
val module = MyModule(context, ...)
|
||||
behaviours.forEach { it.observe(module) } // or it.observe(context) for basic modules
|
||||
```
|
||||
|
||||
This is where observers are started. Order matters for reducers (applied top-to-bottom), but rarely matters for observers.
|
||||
|
||||
---
|
||||
|
||||
## Behaviour File Layout
|
||||
|
||||
Behaviours live in their own `behaviours.kt` file, separate from the module they belong to, within the same package:
|
||||
|
||||
```
|
||||
udp/
|
||||
├── behaviours.kt ← PacketBehaviour, PingBehaviour, HandshakeBehaviour, …
|
||||
├── connection.kt ← UDPConnection class, state, actions, typealias
|
||||
└── packets.kt ← packet type definitions
|
||||
```
|
||||
|
||||
Group behaviours that share the same receiver type in a single file. Behaviours with dependencies on external services (e.g. `SerialBehaviour`, `FirmwareBehaviour`) are standalone classes — one per file is fine when they have distinct concerns.
|
||||
|
||||
---
|
||||
|
||||
## Stateless vs. Stateful Behaviours
|
||||
|
||||
**Stateless behaviours** (no dependencies at construction time) are `object`s:
|
||||
|
||||
```kotlin
|
||||
object PacketBehaviour : UDPConnectionBehaviour {
|
||||
override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) {
|
||||
is UDPConnectionActions.LastPacket -> state.copy(...)
|
||||
else -> state
|
||||
}
|
||||
override fun observe(receiver: UDPConnection) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Behaviours with dependencies** are classes, constructed at the call site:
|
||||
|
||||
```kotlin
|
||||
class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour {
|
||||
override fun observe(receiver: SolarXRConnection) { ... }
|
||||
}
|
||||
|
||||
// At the call site:
|
||||
listOf(
|
||||
DataFeedInitBehaviour,
|
||||
FirmwareBehaviour(firmwareManager),
|
||||
SerialBehaviour(serialServer),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are `sealed interface`s with `data class` variants. This means:
|
||||
|
||||
- The compiler enforces exhaustive `when` expressions in reducers
|
||||
- No stringly-typed dispatch
|
||||
- Refactors are caught at compile time
|
||||
|
||||
Use `data class Update(val transform: State.() -> State)` when you need a flexible "update anything" action (see `TrackerActions`, `DeviceActions`). Use specific named actions when the action has semantic meaning that other behaviours need to pattern-match on (see `UDPConnectionActions.Handshake`).
|
||||
|
||||
---
|
||||
|
||||
## The PacketDispatcher Pattern
|
||||
|
||||
`PacketDispatcher<T>` routes incoming messages to typed listeners without a giant `when` block:
|
||||
|
||||
```kotlin
|
||||
dispatcher.on<SensorInfo> { packet -> /* only called for SensorInfo */ }
|
||||
dispatcher.onAny { packet -> /* called for everything */ }
|
||||
dispatcher.emit(packet) // routes to correct listeners
|
||||
```
|
||||
|
||||
Use this wherever you have a stream of heterogeneous messages (UDP packets, SolarXR messages). Each behaviour registers its own listener in its `observe` — the dispatcher is passed as part of the module struct.
|
||||
|
||||
---
|
||||
|
||||
## Coroutines and Lifetime
|
||||
|
||||
- Every module is given a `CoroutineScope` at creation. Cancelling that scope tears down all coroutines the module launched.
|
||||
- Observers should use `receiver.context.scope.launch { ... }` so their work is scoped to the module.
|
||||
- Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher.
|
||||
- **Avoid `runBlocking`** inside observers or handlers — it blocks the coroutine thread. The one acceptable use is synchronous listener registration before a scope is started.
|
||||
|
||||
---
|
||||
|
||||
## State vs. Out-of-Band Data
|
||||
|
||||
Not everything belongs in `StateFlow`. Two good examples:
|
||||
|
||||
- `VRServer.handleCounter` is an `AtomicInteger` — not in state — because nothing needs to react to it changing, and `incrementAndGet()` is faster and simpler than a dispatch round-trip.
|
||||
- `UDPTrackerServer` has no `Context` at all. Its connection map is a plain `MutableMap` internal to the server loop. Nothing outside the loop reads it, so there is no reason to wrap it in a state machine.
|
||||
|
||||
Rule of thumb: put data in state if **any other code needs to react to it changing**. If it's purely an implementation detail owned by one place, keep it plain.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Module
|
||||
|
||||
To add a new major section of the server (say, a HID device connection):
|
||||
|
||||
1. **Define the state**:
|
||||
```kotlin
|
||||
data class HIDConnectionState(
|
||||
val deviceId: Int?,
|
||||
val connected: Boolean,
|
||||
)
|
||||
```
|
||||
|
||||
2. **Define sealed actions**:
|
||||
```kotlin
|
||||
sealed interface HIDConnectionActions {
|
||||
data class Connected(val deviceId: Int) : HIDConnectionActions
|
||||
data object Disconnected : HIDConnectionActions
|
||||
}
|
||||
```
|
||||
|
||||
3. **Create type aliases** (keeps signatures readable):
|
||||
```kotlin
|
||||
typealias HIDConnectionContext = Context<HIDConnectionState, HIDConnectionActions>
|
||||
typealias HIDConnectionBehaviour = Behaviour<HIDConnectionState, HIDConnectionActions, HIDConnection>
|
||||
```
|
||||
|
||||
4. **Define the module class** (holds context + extra runtime state):
|
||||
```kotlin
|
||||
class HIDConnection(
|
||||
val context: HIDConnectionContext,
|
||||
val serverContext: VRServer,
|
||||
private val onSend: suspend (ByteArray) -> Unit,
|
||||
) {
|
||||
suspend fun send(bytes: ByteArray) = onSend(bytes)
|
||||
}
|
||||
```
|
||||
|
||||
5. **Write behaviours** in a separate `behaviours.kt` file:
|
||||
```kotlin
|
||||
object HIDHandshakeBehaviour : HIDConnectionBehaviour {
|
||||
override fun reduce(state: HIDConnectionState, action: HIDConnectionActions) = when (action) {
|
||||
is HIDConnectionActions.Connected -> state.copy(deviceId = action.deviceId, connected = true)
|
||||
is HIDConnectionActions.Disconnected -> state.copy(connected = false)
|
||||
}
|
||||
override fun observe(receiver: HIDConnection) {
|
||||
// launch coroutines, subscribe to events, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. **Write a `companion object { fun create() }`**:
|
||||
```kotlin
|
||||
companion object {
|
||||
fun create(serverContext: VRServer, scope: CoroutineScope, send: suspend (ByteArray) -> Unit): HIDConnection {
|
||||
val behaviours = listOf(HIDHandshakeBehaviour, ...)
|
||||
val context = Context.create(initialState = ..., scope = scope, behaviours = behaviours)
|
||||
val conn = HIDConnection(context, serverContext, send)
|
||||
behaviours.forEach { it.observe(conn) }
|
||||
return conn
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Behaviour to an Existing Module
|
||||
|
||||
Find the `create` function, add your behaviour to the `behaviours` list. That's it. The behaviour's `reduce` and `observe` are automatically picked up.
|
||||
|
||||
Example: adding battery tracking to a HID connection requires only adding a `HIDBatteryBehaviour` to the list — nothing else changes.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New UDP Packet Type
|
||||
|
||||
1. Add the packet class and its `read` function in `udp/packets.kt`
|
||||
2. In a behaviour's `observe`, register a listener:
|
||||
```kotlin
|
||||
receiver.packetEvents.on<MyNewPacket> { event ->
|
||||
// handle it
|
||||
}
|
||||
```
|
||||
3. In `udp/server.kt`, route the new packet type to `emit`.
|
||||
|
||||
---
|
||||
|
||||
## IPC
|
||||
|
||||
There are three IPC sockets, each serving a distinct client:
|
||||
|
||||
| Socket | Client | Payload encoding |
|
||||
|---|---|---|
|
||||
| `SlimeVRDriver` | OpenVR driver | Protobuf (Wire) |
|
||||
| `SlimeVRInput` | External feeder | Protobuf (Wire) |
|
||||
| `SlimeVRRpc` | SolarXR RPC | FlatBuffers (solarxr-protocol) |
|
||||
|
||||
### Wire framing
|
||||
|
||||
All three sockets share the same framing: a **LE u32 length** prefix (which includes the 4-byte header itself) followed by the raw payload bytes.
|
||||
|
||||
### Transport / protocol split
|
||||
|
||||
Platform files (`linux.kt`, `windows.kt`) own the transport layer — accepting connections, reading frames, and producing a `Flow<ByteArray>` + a `send` function. The protocol handlers in `protocol.kt` are plain `suspend fun`s that consume those two abstractions and know nothing about Unix sockets or named pipes.
|
||||
|
||||
This means the same handler runs on Linux (Unix domain sockets) and Windows (named pipes) without any changes.
|
||||
|
||||
### Connection lifetime
|
||||
|
||||
Each client runs in its own `launch` block. When the socket disconnects, the coroutine scope is cancelled and everything inside cleans up automatically.
|
||||
|
||||
### What each handler does
|
||||
|
||||
- **Driver** (`handleDriverConnection`): on connect, sends the protocol version and streams `TrackerAdded` + `Position` messages for every non-driver tracker. Receives user actions from the driver (resets, etc.).
|
||||
- **Feeder** (`handleFeederConnection`): receives `TrackerAdded` messages to create new devices and trackers, then `Position` updates to drive their rotation.
|
||||
- **SolarXR** (`handleSolarXRConnection`): creates a `SolarXRConnection` and forwards all incoming FlatBuffers messages to it.
|
||||
|
||||
---
|
||||
|
||||
## What Goes Where
|
||||
|
||||
| Location | Purpose |
|
||||
|---|---|
|
||||
| `server/core` | Protocol-agnostic business logic (trackers, devices, config, SolarXR) |
|
||||
| `server/desktop` | Platform-specific entry point, IPC socket wiring, platform abstractions |
|
||||
| `context/context.kt` | The `Context` / `Behaviour` primitives — do not add domain logic here |
|
||||
| `udp/` | Everything specific to the SlimeVR UDP wire protocol |
|
||||
| `solarxr/` | SolarXR WebSocket server + FlatBuffers message handling |
|
||||
| `config/` | JSON config read/write with autosave; no business logic |
|
||||
| `firmware/` | OTA update and serial flash logic; interacts with devices over the network, independent of the UDP tracker protocol |
|
||||
|
||||
---
|
||||
|
||||
## Style Conventions
|
||||
|
||||
- **Limit OOP to strictly necessary cases.** Prefer plain functions, function types, and data classes. Avoid classes and inheritance unless there is a genuine need for encapsulated mutable state or polymorphism. A single-method interface should almost always be a function type instead (`() -> Unit`, `suspend (String) -> Unit`, etc.). When in doubt, write a function.
|
||||
- **Prefer plain functions over extension functions.** Only use extension functions when the receiver type is genuinely the primary subject and the function would be confusing without it.
|
||||
- Behaviours are `object`s (no dependencies) or `class`es (with dependencies), defined in a dedicated `behaviours.kt` file in the same package as the module they belong to.
|
||||
- Module creation lives in `companion object { fun create(...) }`.
|
||||
- State data classes use `copy(...)` inside reducers and `Update { copy(...) }` actions — never expose a `MutableStateFlow` directly.
|
||||
- **Never use `var` in a state data class** — state must be immutable, all fields `val`. Using `var` in any data class is almost certainly a design mistake; if you need mutable fields, prefer a plain class or rethink the structure.
|
||||
- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead.
|
||||
@@ -22,12 +22,12 @@ plugins {
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
}
|
||||
}
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ val deleteTempKeyStore = tasks.register<Delete>("deleteTempKeyStore") {
|
||||
|
||||
tasks.withType<KotlinCompile> {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
jvmTarget.set(JvmTarget.JVM_22)
|
||||
freeCompilerArgs.set(listOf("-Xvalue-classes"))
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_24
|
||||
targetCompatibility = JavaVersion.VERSION_24
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ configure<com.diffplug.gradle.spotless.SpotlessExtension> {
|
||||
",dev.slimevr.tracking.trackers.*,dev.slimevr.desktop.platform.ProtobufMessages.*" +
|
||||
",solarxr_protocol.rpc.*,kotlinx.coroutines.*,com.illposed.osc.*,android.app.*",
|
||||
"ij_kotlin_allow_trailing_comma" to true,
|
||||
"ktlint_standard_filename" to "disabled",
|
||||
)
|
||||
val ktlintVersion = "1.8.0"
|
||||
kotlinGradle {
|
||||
|
||||
@@ -14,20 +14,19 @@ plugins {
|
||||
`java-library`
|
||||
}
|
||||
|
||||
// FIXME: Please replace these to Java 11 as that's what they actually are
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
}
|
||||
}
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
}
|
||||
}
|
||||
tasks.withType<KotlinCompile> {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
jvmTarget.set(JvmTarget.JVM_24)
|
||||
freeCompilerArgs.set(listOf("-Xvalue-classes"))
|
||||
}
|
||||
}
|
||||
@@ -60,24 +59,23 @@ allprojects {
|
||||
dependencies {
|
||||
implementation(project(":solarxr-protocol"))
|
||||
|
||||
// This dependency is used internally,
|
||||
// and not exposed to consumers on their own compile classpath.
|
||||
implementation("com.google.flatbuffers:flatbuffers-java:22.10.26")
|
||||
implementation("commons-cli:commons-cli:1.11.0")
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.21.0")
|
||||
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.0")
|
||||
|
||||
implementation("com.github.jonpeterson:jackson-module-model-versioning:1.2.2")
|
||||
implementation("org.apache.commons:commons-math3:3.6.1")
|
||||
implementation("org.apache.commons:commons-lang3:3.20.0")
|
||||
implementation("org.apache.commons:commons-collections4:4.5.0")
|
||||
|
||||
implementation("com.illposed.osc:javaosc-core:0.9")
|
||||
implementation("org.java-websocket:Java-WebSocket:1.+")
|
||||
implementation("com.melloware:jintellitype:1.+")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("com.mayakapps.kache:kache:2.1.1")
|
||||
implementation("io.klogging:klogging:0.11.7")
|
||||
implementation("io.klogging:slf4j-klogging:0.11.7")
|
||||
|
||||
val ktor_version = "3.4.1"
|
||||
implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
|
||||
implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
|
||||
implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version")
|
||||
implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktor_version")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktor_version")
|
||||
implementation("io.ktor:ktor-utils:$ktor_version")
|
||||
|
||||
api("com.github.loucass003:EspflashKotlin:v0.11.0")
|
||||
|
||||
@@ -92,6 +90,7 @@ dependencies {
|
||||
testImplementation(platform("org.junit:junit-bom:6.0.2"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.junit.platform:junit-platform-launcher")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package dev.slimevr
|
||||
|
||||
import com.melloware.jintellitype.HotkeyListener
|
||||
import com.melloware.jintellitype.JIntellitype
|
||||
import dev.slimevr.config.KeybindingsConfig
|
||||
import dev.slimevr.tracking.trackers.TrackerUtils
|
||||
import io.eiren.util.OperatingSystem
|
||||
import io.eiren.util.OperatingSystem.Companion.currentPlatform
|
||||
import io.eiren.util.ann.AWTThread
|
||||
import io.eiren.util.logging.LogManager
|
||||
|
||||
class Keybinding @AWTThread constructor(val server: VRServer) : HotkeyListener {
|
||||
val config: KeybindingsConfig = server.configManager.vrConfig.keybindings
|
||||
|
||||
init {
|
||||
if (currentPlatform != OperatingSystem.WINDOWS) {
|
||||
LogManager
|
||||
.info(
|
||||
"[Keybinding] Currently only supported on Windows. Keybindings will be disabled.",
|
||||
)
|
||||
} else {
|
||||
try {
|
||||
if (JIntellitype.getInstance() != null) {
|
||||
JIntellitype.getInstance().addHotKeyListener(this)
|
||||
|
||||
val fullResetBinding = config.fullResetBinding
|
||||
JIntellitype.getInstance()
|
||||
.registerHotKey(FULL_RESET, fullResetBinding)
|
||||
LogManager.info("[Keybinding] Bound full reset to $fullResetBinding")
|
||||
|
||||
val yawResetBinding = config.yawResetBinding
|
||||
JIntellitype.getInstance()
|
||||
.registerHotKey(YAW_RESET, yawResetBinding)
|
||||
LogManager.info("[Keybinding] Bound yaw reset to $yawResetBinding")
|
||||
|
||||
val mountingResetBinding = config.mountingResetBinding
|
||||
JIntellitype.getInstance()
|
||||
.registerHotKey(MOUNTING_RESET, mountingResetBinding)
|
||||
LogManager.info("[Keybinding] Bound reset mounting to $mountingResetBinding")
|
||||
|
||||
val feetMountingResetBinding = config.feetMountingResetBinding
|
||||
JIntellitype.getInstance()
|
||||
.registerHotKey(FEET_MOUNTING_RESET, feetMountingResetBinding)
|
||||
LogManager.info("[Keybinding] Bound feet reset mounting to $feetMountingResetBinding")
|
||||
|
||||
val pauseTrackingBinding = config.pauseTrackingBinding
|
||||
JIntellitype.getInstance()
|
||||
.registerHotKey(PAUSE_TRACKING, pauseTrackingBinding)
|
||||
LogManager.info("[Keybinding] Bound pause tracking to $pauseTrackingBinding")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
LogManager
|
||||
.warning(
|
||||
"[Keybinding] JIntellitype initialization failed. Keybindings will be disabled. Try restarting your computer.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AWTThread
|
||||
override fun onHotKey(identifier: Int) {
|
||||
when (identifier) {
|
||||
FULL_RESET -> server.scheduleResetTrackersFull(RESET_SOURCE_NAME, config.fullResetDelay)
|
||||
|
||||
YAW_RESET -> server.scheduleResetTrackersYaw(RESET_SOURCE_NAME, config.yawResetDelay)
|
||||
|
||||
MOUNTING_RESET -> server.scheduleResetTrackersMounting(
|
||||
RESET_SOURCE_NAME,
|
||||
config.mountingResetDelay,
|
||||
)
|
||||
|
||||
FEET_MOUNTING_RESET -> server.scheduleResetTrackersMounting(
|
||||
RESET_SOURCE_NAME,
|
||||
config.feetMountingResetDelay,
|
||||
TrackerUtils.feetsBodyParts,
|
||||
)
|
||||
|
||||
PAUSE_TRACKING ->
|
||||
server
|
||||
.scheduleTogglePauseTracking(
|
||||
RESET_SOURCE_NAME,
|
||||
config.pauseTrackingDelay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RESET_SOURCE_NAME = "Keybinding"
|
||||
|
||||
private const val FULL_RESET = 1
|
||||
private const val YAW_RESET = 2
|
||||
private const val MOUNTING_RESET = 3
|
||||
private const val FEET_MOUNTING_RESET = 4
|
||||
private const val PAUSE_TRACKING = 5
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package dev.slimevr
|
||||
|
||||
data class NetworkInfo(
|
||||
val name: String?,
|
||||
val description: String?,
|
||||
val category: NetworkCategory?,
|
||||
val connectivity: Set<ConnectivityFlags>?,
|
||||
val connected: Boolean?,
|
||||
)
|
||||
|
||||
/**
|
||||
* @see <a href="https://learn.microsoft.com/en-us/windows/win32/api/netlistmgr/ne-netlistmgr-nlm_network_category">NLM_NETWORK_CATEGORY enumeration (netlistmgr.h)</a>
|
||||
*/
|
||||
enum class NetworkCategory(val value: Int) {
|
||||
PUBLIC(0),
|
||||
PRIVATE(1),
|
||||
DOMAIN_AUTHENTICATED(2),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int) = values().find { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see <a href="https://learn.microsoft.com/en-us/windows/win32/api/netlistmgr/ne-netlistmgr-nlm_connectivity">NLM_CONNECTIVITY enumeration (netlistmgr.h)</a>
|
||||
*/
|
||||
enum class ConnectivityFlags(val value: Int) {
|
||||
DISCONNECTED(0),
|
||||
IPV4_NOTRAFFIC(0x1),
|
||||
IPV6_NOTRAFFIC(0x2),
|
||||
IPV4_SUBNET(0x10),
|
||||
IPV4_LOCALNETWORK(0x20),
|
||||
IPV4_INTERNET(0x40),
|
||||
IPV6_SUBNET(0x100),
|
||||
IPV6_LOCALNETWORK(0x200),
|
||||
IPV6_INTERNET(0x400),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): Set<ConnectivityFlags> = if (value == 0) {
|
||||
setOf(DISCONNECTED)
|
||||
} else {
|
||||
values().filter { it != DISCONNECTED && (value and it.value) != 0 }.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class NetworkProfileChecker {
|
||||
abstract val isSupported: Boolean
|
||||
abstract val publicNetworks: List<NetworkInfo>
|
||||
}
|
||||
|
||||
class NetworkProfileCheckerStub : NetworkProfileChecker() {
|
||||
override val isSupported: Boolean
|
||||
get() = false
|
||||
override val publicNetworks: List<NetworkInfo>
|
||||
get() = listOf()
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package dev.slimevr;
|
||||
|
||||
public enum NetworkProtocol {
|
||||
OWO_LEGACY,
|
||||
SLIMEVR_RAW,
|
||||
SLIMEVR_FLATBUFFER,
|
||||
SLIMEVR_WEBSOCKET
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
package dev.slimevr
|
||||
|
||||
import com.jme3.system.NanoTimer
|
||||
import dev.slimevr.autobone.AutoBoneHandler
|
||||
import dev.slimevr.bridge.Bridge
|
||||
import dev.slimevr.bridge.ISteamVRBridge
|
||||
import dev.slimevr.config.ConfigManager
|
||||
import dev.slimevr.firmware.FirmwareUpdateHandler
|
||||
import dev.slimevr.firmware.SerialFlashingHandler
|
||||
import dev.slimevr.games.vrchat.VRCConfigHandler
|
||||
import dev.slimevr.games.vrchat.VRCConfigHandlerStub
|
||||
import dev.slimevr.games.vrchat.VRChatConfigManager
|
||||
import dev.slimevr.guards.ServerGuards
|
||||
import dev.slimevr.osc.OSCHandler
|
||||
import dev.slimevr.osc.OSCRouter
|
||||
import dev.slimevr.osc.VMCHandler
|
||||
import dev.slimevr.osc.VRCOSCHandler
|
||||
import dev.slimevr.posestreamer.BVHRecorder
|
||||
import dev.slimevr.protocol.ProtocolAPI
|
||||
import dev.slimevr.protocol.rpc.settings.RPCSettingsHandler
|
||||
import dev.slimevr.reset.ResetHandler
|
||||
import dev.slimevr.reset.ResetTimerManager
|
||||
import dev.slimevr.reset.resetTimer
|
||||
import dev.slimevr.serial.ProvisioningHandler
|
||||
import dev.slimevr.serial.SerialHandler
|
||||
import dev.slimevr.serial.SerialHandlerStub
|
||||
import dev.slimevr.setup.HandshakeHandler
|
||||
import dev.slimevr.setup.TapSetupHandler
|
||||
import dev.slimevr.status.StatusSystem
|
||||
import dev.slimevr.tracking.processor.HumanPoseManager
|
||||
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
|
||||
import dev.slimevr.tracking.trackers.*
|
||||
import dev.slimevr.tracking.trackers.udp.TrackersUDPServer
|
||||
import dev.slimevr.trackingchecklist.TrackingChecklistManager
|
||||
import dev.slimevr.util.ann.VRServerThread
|
||||
import dev.slimevr.websocketapi.WebSocketVRBridge
|
||||
import io.eiren.util.ann.ThreadSafe
|
||||
import io.eiren.util.ann.ThreadSecure
|
||||
import io.eiren.util.collections.FastList
|
||||
import io.eiren.util.logging.LogManager
|
||||
import solarxr_protocol.datatypes.TrackerIdT
|
||||
import solarxr_protocol.rpc.ResetType
|
||||
import java.util.*
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.function.Consumer
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
typealias BridgeProvider = (
|
||||
server: VRServer,
|
||||
computedTrackers: List<Tracker>,
|
||||
) -> Sequence<Bridge>
|
||||
|
||||
const val SLIMEVR_IDENTIFIER = "dev.slimevr.SlimeVR"
|
||||
|
||||
class VRServer @JvmOverloads constructor(
|
||||
bridgeProvider: BridgeProvider = { _, _ -> sequence {} },
|
||||
serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() },
|
||||
flashingHandlerProvider: (VRServer) -> SerialFlashingHandler? = { _ -> null },
|
||||
vrcConfigHandlerProvider: (VRServer) -> VRCConfigHandler = { _ -> VRCConfigHandlerStub() },
|
||||
networkProfileProvider: (VRServer) -> NetworkProfileChecker = { _ -> NetworkProfileCheckerStub() },
|
||||
acquireMulticastLock: () -> Any? = { null },
|
||||
@JvmField val configManager: ConfigManager,
|
||||
) : Thread("VRServer") {
|
||||
|
||||
@JvmField
|
||||
val humanPoseManager: HumanPoseManager
|
||||
private val trackers: MutableList<Tracker> = FastList()
|
||||
val trackersServer: TrackersUDPServer
|
||||
private val bridges: MutableList<Bridge> = FastList()
|
||||
private val tasks: Queue<Runnable> = LinkedBlockingQueue()
|
||||
private val newTrackersConsumers: MutableList<Consumer<Tracker>> = FastList()
|
||||
private val trackerStatusListeners: MutableList<TrackerStatusListener> = FastList()
|
||||
private val onTick: MutableList<Runnable> = FastList()
|
||||
private val lock = acquireMulticastLock()
|
||||
val oSCRouter: OSCRouter
|
||||
|
||||
@JvmField
|
||||
val vrcOSCHandler: VRCOSCHandler
|
||||
val vMCHandler: VMCHandler
|
||||
|
||||
@JvmField
|
||||
val deviceManager: DeviceManager
|
||||
|
||||
@JvmField
|
||||
val bvhRecorder: BVHRecorder
|
||||
|
||||
@JvmField
|
||||
val serialHandler: SerialHandler
|
||||
|
||||
var serialFlashingHandler: SerialFlashingHandler?
|
||||
|
||||
val firmwareUpdateHandler: FirmwareUpdateHandler
|
||||
|
||||
val vrcConfigManager: VRChatConfigManager
|
||||
|
||||
@JvmField
|
||||
val autoBoneHandler: AutoBoneHandler
|
||||
|
||||
@JvmField
|
||||
val tapSetupHandler: TapSetupHandler
|
||||
|
||||
@JvmField
|
||||
val protocolAPI: ProtocolAPI
|
||||
private val timer = Timer()
|
||||
private val resetTimerManager = ResetTimerManager()
|
||||
val fpsTimer = NanoTimer()
|
||||
|
||||
@JvmField
|
||||
val provisioningHandler: ProvisioningHandler
|
||||
|
||||
@JvmField
|
||||
val resetHandler: ResetHandler
|
||||
|
||||
@JvmField
|
||||
val statusSystem = StatusSystem()
|
||||
|
||||
@JvmField
|
||||
val handshakeHandler = HandshakeHandler()
|
||||
|
||||
val trackingChecklistManager: TrackingChecklistManager
|
||||
|
||||
val networkProfileChecker: NetworkProfileChecker
|
||||
|
||||
val serverGuards = ServerGuards()
|
||||
|
||||
init {
|
||||
// UwU
|
||||
deviceManager = DeviceManager(this)
|
||||
serialHandler = serialHandlerProvider(this)
|
||||
serialFlashingHandler = flashingHandlerProvider(this)
|
||||
provisioningHandler = ProvisioningHandler(this)
|
||||
resetHandler = ResetHandler()
|
||||
tapSetupHandler = TapSetupHandler()
|
||||
humanPoseManager = HumanPoseManager(this)
|
||||
// AutoBone requires HumanPoseManager first
|
||||
autoBoneHandler = AutoBoneHandler(this)
|
||||
firmwareUpdateHandler = FirmwareUpdateHandler(this)
|
||||
vrcConfigManager = VRChatConfigManager(this, vrcConfigHandlerProvider(this))
|
||||
networkProfileChecker = networkProfileProvider(this)
|
||||
trackingChecklistManager = TrackingChecklistManager(this)
|
||||
protocolAPI = ProtocolAPI(this)
|
||||
val computedTrackers = humanPoseManager.computedTrackers
|
||||
|
||||
// Start server for SlimeVR trackers
|
||||
val trackerPort = configManager.vrConfig.server.trackerPort
|
||||
LogManager.info("Starting the tracker server on port $trackerPort...")
|
||||
trackersServer = TrackersUDPServer(
|
||||
trackerPort,
|
||||
"Sensors UDP server",
|
||||
) { tracker: Tracker -> registerTracker(tracker) }
|
||||
|
||||
// Start bridges and WebSocket server
|
||||
for (bridge in bridgeProvider(this, computedTrackers) + sequenceOf(WebSocketVRBridge(computedTrackers, this))) {
|
||||
tasks.add(Runnable { bridge.startBridge() })
|
||||
bridges.add(bridge)
|
||||
}
|
||||
|
||||
// Initialize OSC handlers
|
||||
vrcOSCHandler = VRCOSCHandler(
|
||||
this,
|
||||
configManager.vrConfig.vrcOSC,
|
||||
computedTrackers,
|
||||
)
|
||||
vMCHandler = VMCHandler(
|
||||
this,
|
||||
humanPoseManager,
|
||||
configManager.vrConfig.vmc,
|
||||
)
|
||||
|
||||
// Initialize OSC router
|
||||
val oscHandlers = FastList<OSCHandler>()
|
||||
oscHandlers.add(vrcOSCHandler)
|
||||
oscHandlers.add(vMCHandler)
|
||||
oSCRouter = OSCRouter(configManager.vrConfig.oscRouter, oscHandlers)
|
||||
bvhRecorder = BVHRecorder(this)
|
||||
for (tracker in computedTrackers) {
|
||||
registerTracker(tracker)
|
||||
}
|
||||
|
||||
instance = this
|
||||
}
|
||||
|
||||
fun hasBridge(bridgeClass: Class<out Bridge?>): Boolean {
|
||||
for (bridge in bridges) {
|
||||
if (bridgeClass.isAssignableFrom(bridge.javaClass)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FIXME: Code using this function normally uses this to get the SteamVR driver but
|
||||
// that's because we first save the SteamVR driver bridge and then the feeder in the array.
|
||||
// Not really a great thing to have.
|
||||
@ThreadSafe
|
||||
fun <E : Bridge?> getVRBridge(bridgeClass: Class<E>): E? {
|
||||
for (bridge in bridges) {
|
||||
if (bridgeClass.isAssignableFrom(bridge.javaClass)) {
|
||||
return bridgeClass.cast(bridge)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun addOnTick(runnable: Runnable) {
|
||||
onTick.add(runnable)
|
||||
}
|
||||
|
||||
@ThreadSafe
|
||||
fun addNewTrackerConsumer(consumer: Consumer<Tracker>) {
|
||||
queueTask {
|
||||
newTrackersConsumers.add(consumer)
|
||||
for (tracker in trackers) {
|
||||
consumer.accept(tracker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ThreadSafe
|
||||
fun trackerUpdated(tracker: Tracker?) {
|
||||
queueTask {
|
||||
humanPoseManager.trackerUpdated(tracker)
|
||||
updateSkeletonModel()
|
||||
refreshTrackersDriftCompensationEnabled()
|
||||
configManager.vrConfig.writeTrackerConfig(tracker)
|
||||
configManager.saveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@ThreadSafe
|
||||
fun addSkeletonUpdatedCallback(consumer: Consumer<HumanSkeleton>) {
|
||||
queueTask { humanPoseManager.addSkeletonUpdatedCallback(consumer) }
|
||||
}
|
||||
|
||||
@VRServerThread
|
||||
override fun run() {
|
||||
trackersServer.start()
|
||||
while (true) {
|
||||
// final long start = System.currentTimeMillis();
|
||||
fpsTimer.update()
|
||||
do {
|
||||
val task = tasks.poll() ?: break
|
||||
task.run()
|
||||
} while (true)
|
||||
for (task in onTick) {
|
||||
task.run()
|
||||
}
|
||||
for (bridge in bridges) {
|
||||
bridge.dataRead()
|
||||
}
|
||||
for (tracker in trackers) {
|
||||
tracker.tick(fpsTimer.timePerFrame)
|
||||
}
|
||||
humanPoseManager.update()
|
||||
for (bridge in bridges) {
|
||||
bridge.dataWrite()
|
||||
}
|
||||
vrcOSCHandler.update()
|
||||
vMCHandler.update()
|
||||
// final long time = System.currentTimeMillis() - start;
|
||||
try {
|
||||
sleep(1) // 1000Hz
|
||||
} catch (error: InterruptedException) {
|
||||
LogManager.info("VRServer thread interrupted")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ThreadSafe
|
||||
fun queueTask(r: Runnable) {
|
||||
tasks.add(r)
|
||||
}
|
||||
|
||||
@VRServerThread
|
||||
private fun trackerAdded(tracker: Tracker) {
|
||||
humanPoseManager.trackerAdded(tracker)
|
||||
updateSkeletonModel()
|
||||
if (tracker.isComputed) {
|
||||
vMCHandler.addComputedTracker(tracker)
|
||||
}
|
||||
refreshTrackersDriftCompensationEnabled()
|
||||
}
|
||||
|
||||
@ThreadSecure
|
||||
fun registerTracker(tracker: Tracker) {
|
||||
configManager.vrConfig.readTrackerConfig(tracker)
|
||||
queueTask {
|
||||
trackers.add(tracker)
|
||||
trackerAdded(tracker)
|
||||
for (tc in newTrackersConsumers) {
|
||||
tc.accept(tracker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ThreadSafe
|
||||
fun updateSkeletonModel() {
|
||||
queueTask {
|
||||
humanPoseManager.updateSkeletonModelFromServer()
|
||||
vrcOSCHandler.setHeadTracker(TrackerUtils.getTrackerForSkeleton(trackers, TrackerPosition.HEAD))
|
||||
if (this.getVRBridge(ISteamVRBridge::class.java)?.updateShareSettingsAutomatically() == true) {
|
||||
RPCSettingsHandler.sendSteamVRUpdatedSettings(protocolAPI, protocolAPI.rpcHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetTrackersFull(resetSourceName: String?, bodyParts: List<Int> = ArrayList()) {
|
||||
queueTask { humanPoseManager.resetTrackersFull(resetSourceName, bodyParts) }
|
||||
}
|
||||
|
||||
fun resetTrackersYaw(resetSourceName: String?, bodyParts: List<Int> = TrackerUtils.allBodyPartsButFingers) {
|
||||
queueTask { humanPoseManager.resetTrackersYaw(resetSourceName, bodyParts) }
|
||||
}
|
||||
|
||||
fun resetTrackersMounting(resetSourceName: String?, bodyParts: List<Int>? = null) {
|
||||
queueTask { humanPoseManager.resetTrackersMounting(resetSourceName, bodyParts) }
|
||||
}
|
||||
|
||||
fun clearTrackersMounting(resetSourceName: String?) {
|
||||
queueTask { humanPoseManager.clearTrackersMounting(resetSourceName) }
|
||||
}
|
||||
|
||||
fun getPauseTracking(): Boolean = humanPoseManager.getPauseTracking()
|
||||
|
||||
fun setPauseTracking(pauseTracking: Boolean, sourceName: String?) {
|
||||
queueTask {
|
||||
humanPoseManager.setPauseTracking(pauseTracking, sourceName)
|
||||
// Toggle trackers as they don't toggle when tracking is paused
|
||||
if (this.getVRBridge(ISteamVRBridge::class.java)?.updateShareSettingsAutomatically() == true) {
|
||||
RPCSettingsHandler.sendSteamVRUpdatedSettings(protocolAPI, protocolAPI.rpcHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePauseTracking(sourceName: String?) {
|
||||
queueTask {
|
||||
humanPoseManager.togglePauseTracking(sourceName)
|
||||
// Toggle trackers as they don't toggle when tracking is paused
|
||||
if (this.getVRBridge(ISteamVRBridge::class.java)?.updateShareSettingsAutomatically() == true) {
|
||||
RPCSettingsHandler.sendSteamVRUpdatedSettings(protocolAPI, protocolAPI.rpcHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleResetTrackersFull(resetSourceName: String?, delay: Long, bodyParts: List<Int> = ArrayList()) {
|
||||
resetTimer(
|
||||
resetTimerManager,
|
||||
delay,
|
||||
onTick = { progress ->
|
||||
resetHandler.sendStarted(ResetType.Full, bodyParts, progress, delay.toInt())
|
||||
},
|
||||
onComplete = {
|
||||
queueTask {
|
||||
humanPoseManager.resetTrackersFull(resetSourceName, bodyParts)
|
||||
resetHandler.sendFinished(ResetType.Full, bodyParts, delay.toInt())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun scheduleResetTrackersYaw(resetSourceName: String?, delay: Long, bodyParts: List<Int> = TrackerUtils.allBodyPartsButFingers) {
|
||||
resetTimer(
|
||||
resetTimerManager,
|
||||
delay,
|
||||
onTick = { progress ->
|
||||
resetHandler.sendStarted(ResetType.Yaw, bodyParts, progress, delay.toInt())
|
||||
},
|
||||
onComplete = {
|
||||
queueTask {
|
||||
humanPoseManager.resetTrackersYaw(resetSourceName, bodyParts)
|
||||
resetHandler.sendFinished(ResetType.Yaw, bodyParts, delay.toInt())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun scheduleResetTrackersMounting(resetSourceName: String?, delay: Long, bodyParts: List<Int>? = null) {
|
||||
resetTimer(
|
||||
resetTimerManager,
|
||||
delay,
|
||||
onTick = { progress ->
|
||||
resetHandler.sendStarted(ResetType.Mounting, bodyParts, progress, delay.toInt())
|
||||
},
|
||||
onComplete = {
|
||||
queueTask {
|
||||
humanPoseManager.resetTrackersMounting(resetSourceName, bodyParts)
|
||||
resetHandler.sendFinished(ResetType.Mounting, bodyParts, delay.toInt())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun scheduleSetPauseTracking(pauseTracking: Boolean, sourceName: String?, delay: Long) {
|
||||
timer.schedule(delay) {
|
||||
queueTask { humanPoseManager.setPauseTracking(pauseTracking, sourceName) }
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleTogglePauseTracking(sourceName: String?, delay: Long) {
|
||||
timer.schedule(delay) {
|
||||
queueTask { humanPoseManager.togglePauseTracking(sourceName) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setLegTweaksEnabled(value: Boolean) {
|
||||
queueTask { humanPoseManager.setLegTweaksEnabled(value) }
|
||||
}
|
||||
|
||||
fun setSkatingReductionEnabled(value: Boolean) {
|
||||
queueTask { humanPoseManager.setSkatingCorrectionEnabled(value) }
|
||||
}
|
||||
|
||||
fun setFloorClipEnabled(value: Boolean) {
|
||||
queueTask { humanPoseManager.setFloorClipEnabled(value) }
|
||||
}
|
||||
|
||||
val trackersCount: Int
|
||||
get() = trackers.size
|
||||
val allTrackers: List<Tracker>
|
||||
get() = FastList(trackers)
|
||||
|
||||
fun getTrackerById(id: TrackerIdT): Tracker? {
|
||||
for (tracker in trackers) {
|
||||
if (tracker.trackerNum != id.trackerNum) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle synthetic devices
|
||||
if (id.deviceId == null && tracker.device == null) {
|
||||
return tracker
|
||||
}
|
||||
if (tracker.device != null && id.deviceId != null && id.deviceId.id == tracker.device.id) {
|
||||
// This is a physical tracker, and both device id and the
|
||||
// tracker num match
|
||||
return tracker
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun clearTrackersDriftCompensation() {
|
||||
for (t in allTrackers) {
|
||||
if (t.isImu()) {
|
||||
t.resetsHandler.clearDriftCompensation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshTrackersDriftCompensationEnabled() {
|
||||
for (t in allTrackers) {
|
||||
if (t.isImu()) {
|
||||
t.resetsHandler.refreshDriftCompensationEnabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun trackerStatusChanged(tracker: Tracker, oldStatus: TrackerStatus, newStatus: TrackerStatus) {
|
||||
trackerStatusListeners.forEach { it.onTrackerStatusChanged(tracker, oldStatus, newStatus) }
|
||||
}
|
||||
|
||||
fun addTrackerStatusListener(listener: TrackerStatusListener) {
|
||||
trackerStatusListeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeTrackerStatusListener(listener: TrackerStatusListener) {
|
||||
trackerStatusListeners.removeIf { listener == it }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val nextLocalTrackerId = AtomicInteger()
|
||||
lateinit var instance: VRServer
|
||||
private set
|
||||
|
||||
val instanceInitialized: Boolean
|
||||
get() = ::instance.isInitialized
|
||||
|
||||
@JvmStatic
|
||||
fun getNextLocalTrackerId(): Int = nextLocalTrackerId.incrementAndGet()
|
||||
|
||||
@JvmStatic
|
||||
val currentLocalTrackerId: Int
|
||||
get() = nextLocalTrackerId.get()
|
||||
}
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
package dev.slimevr.autobone
|
||||
|
||||
import dev.slimevr.SLIMEVR_IDENTIFIER
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.autobone.errors.*
|
||||
import dev.slimevr.config.AutoBoneConfig
|
||||
import dev.slimevr.config.SkeletonConfig
|
||||
import dev.slimevr.poseframeformat.PfrIO
|
||||
import dev.slimevr.poseframeformat.PfsIO
|
||||
import dev.slimevr.poseframeformat.PoseFrames
|
||||
import dev.slimevr.tracking.processor.HumanPoseManager
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigManager
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
|
||||
import dev.slimevr.tracking.trackers.TrackerRole
|
||||
import io.eiren.util.OperatingSystem
|
||||
import io.eiren.util.StringUtils
|
||||
import io.eiren.util.collections.FastList
|
||||
import io.eiren.util.logging.LogManager
|
||||
import io.github.axisangles.ktmath.Vector3
|
||||
import org.apache.commons.lang3.tuple.Pair
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.function.Consumer
|
||||
import java.util.function.Function
|
||||
import kotlin.math.*
|
||||
|
||||
class AutoBone(private val server: VRServer) {
|
||||
// This is filled by loadConfigValues()
|
||||
val offsets = EnumMap<SkeletonConfigOffsets, Float>(
|
||||
SkeletonConfigOffsets::class.java,
|
||||
)
|
||||
val adjustOffsets = FastList(
|
||||
arrayOf(
|
||||
SkeletonConfigOffsets.HEAD,
|
||||
SkeletonConfigOffsets.NECK,
|
||||
SkeletonConfigOffsets.UPPER_CHEST,
|
||||
SkeletonConfigOffsets.CHEST,
|
||||
SkeletonConfigOffsets.WAIST,
|
||||
SkeletonConfigOffsets.HIP,
|
||||
// HIPS_WIDTH now works when using body proportion error! It's not the
|
||||
// best still, but it is somewhat functional
|
||||
SkeletonConfigOffsets.HIPS_WIDTH,
|
||||
SkeletonConfigOffsets.UPPER_LEG,
|
||||
SkeletonConfigOffsets.LOWER_LEG,
|
||||
),
|
||||
)
|
||||
|
||||
var estimatedHeight: Float = 1f
|
||||
|
||||
// The total height of the normalized adjusted offsets
|
||||
var adjustedHeightNormalized: Float = 1f
|
||||
|
||||
// #region Error functions
|
||||
var slideError = SlideError()
|
||||
var offsetSlideError = OffsetSlideError()
|
||||
var footHeightOffsetError = FootHeightOffsetError()
|
||||
var bodyProportionError = BodyProportionError()
|
||||
var heightError = HeightError()
|
||||
var positionError = PositionError()
|
||||
var positionOffsetError = PositionOffsetError()
|
||||
// #endregion
|
||||
|
||||
val globalConfig: AutoBoneConfig = server.configManager.vrConfig.autoBone
|
||||
val globalSkeletonConfig: SkeletonConfig = server.configManager.vrConfig.skeleton
|
||||
|
||||
init {
|
||||
loadConfigValues()
|
||||
}
|
||||
|
||||
private fun loadConfigValues() {
|
||||
// Remove all previous values
|
||||
offsets.clear()
|
||||
|
||||
// Get current or default skeleton configs
|
||||
val skeleton = server.humanPoseManager
|
||||
// Still compensate for a null skeleton, as it may not be initialized yet
|
||||
val getOffset: Function<SkeletonConfigOffsets, Float> =
|
||||
if (skeleton != null) {
|
||||
Function { key: SkeletonConfigOffsets -> skeleton.getOffset(key) }
|
||||
} else {
|
||||
val defaultConfig = SkeletonConfigManager(false)
|
||||
Function { config: SkeletonConfigOffsets ->
|
||||
defaultConfig.getOffset(config)
|
||||
}
|
||||
}
|
||||
for (bone in adjustOffsets) {
|
||||
val offset = getOffset.apply(bone)
|
||||
if (offset > 0f) {
|
||||
offsets[bone] = offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyConfig(
|
||||
humanPoseManager: HumanPoseManager,
|
||||
offsets: Map<SkeletonConfigOffsets, Float> = this.offsets,
|
||||
) {
|
||||
for ((offset, value) in offsets) {
|
||||
humanPoseManager.setOffset(offset, value)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun applyAndSaveConfig(humanPoseManager: HumanPoseManager? = this.server.humanPoseManager): Boolean {
|
||||
if (humanPoseManager == null) return false
|
||||
applyConfig(humanPoseManager)
|
||||
humanPoseManager.saveConfig()
|
||||
server.configManager.saveConfig()
|
||||
LogManager.info("[AutoBone] Configured skeleton bone lengths")
|
||||
return true
|
||||
}
|
||||
|
||||
fun calcTargetHmdHeight(
|
||||
frames: PoseFrames,
|
||||
config: AutoBoneConfig = globalConfig,
|
||||
): Float {
|
||||
val targetHeight: Float
|
||||
// Get the current skeleton from the server
|
||||
val humanPoseManager = server.humanPoseManager
|
||||
// Still compensate for a null skeleton, as it may not be initialized yet
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (config.useSkeletonHeight && humanPoseManager != null) {
|
||||
// If there is a skeleton available, calculate the target height
|
||||
// from its configs
|
||||
targetHeight = humanPoseManager.userHeightFromConfig
|
||||
LogManager
|
||||
.warning(
|
||||
"[AutoBone] Target height loaded from skeleton (Make sure you reset before running!): $targetHeight",
|
||||
)
|
||||
} else {
|
||||
// Otherwise if there is no skeleton available, attempt to get the
|
||||
// max HMD height from the recording
|
||||
val hmdHeight = frames.maxHmdHeight
|
||||
if (hmdHeight <= MIN_HEIGHT) {
|
||||
LogManager
|
||||
.warning(
|
||||
"[AutoBone] Max headset height detected (Value seems too low, did you not stand up straight while measuring?): $hmdHeight",
|
||||
)
|
||||
} else {
|
||||
LogManager.info("[AutoBone] Max headset height detected: $hmdHeight")
|
||||
}
|
||||
|
||||
// Estimate target height from HMD height
|
||||
targetHeight = hmdHeight
|
||||
}
|
||||
return targetHeight
|
||||
}
|
||||
|
||||
private fun updateRecordingScale(step: PoseFrameStep<AutoBoneStep>, scale: Float) {
|
||||
step.framePlayer1.setScales(scale)
|
||||
step.framePlayer2.setScales(scale)
|
||||
step.skeleton1.update()
|
||||
step.skeleton2.update()
|
||||
}
|
||||
|
||||
fun filterFrames(frames: PoseFrames, step: PoseFrameStep<AutoBoneStep>) {
|
||||
// Calculate the initial frame errors and recording stats
|
||||
val frameErrors = FloatArray(frames.maxFrameCount)
|
||||
val frameStats = StatsCalculator()
|
||||
val recordingStats = StatsCalculator()
|
||||
for (i in 0 until frames.maxFrameCount) {
|
||||
frameStats.reset()
|
||||
for (j in 0 until frames.maxFrameCount) {
|
||||
if (i == j) continue
|
||||
|
||||
step.setCursors(
|
||||
i,
|
||||
j,
|
||||
updatePlayerCursors = true,
|
||||
)
|
||||
|
||||
frameStats.addValue(getErrorDeriv(step))
|
||||
}
|
||||
frameErrors[i] = frameStats.mean
|
||||
recordingStats.addValue(frameStats.mean)
|
||||
// LogManager.info("[AutoBone] Frame: ${i + 1}, Mean error: ${frameStats.mean} (SD ${frameStats.standardDeviation})")
|
||||
}
|
||||
LogManager.info("[AutoBone] Full recording mean error: ${frameStats.mean} (SD ${frameStats.standardDeviation})")
|
||||
|
||||
// Remove outlier frames
|
||||
val sdMult = 1.4f
|
||||
val mean = recordingStats.mean
|
||||
val sd = recordingStats.standardDeviation * sdMult
|
||||
for (i in frameErrors.size - 1 downTo 0) {
|
||||
val err = frameErrors[i]
|
||||
if (err < mean - sd || err > mean + sd) {
|
||||
for (frameHolder in frames.frameHolders) {
|
||||
frameHolder.frames.removeAt(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
step.maxFrameCount = frames.maxFrameCount
|
||||
|
||||
// Calculate and print the resulting recording stats
|
||||
recordingStats.reset()
|
||||
for (i in 0 until frames.maxFrameCount) {
|
||||
frameStats.reset()
|
||||
for (j in 0 until frames.maxFrameCount) {
|
||||
if (i == j) continue
|
||||
|
||||
step.setCursors(
|
||||
i,
|
||||
j,
|
||||
updatePlayerCursors = true,
|
||||
)
|
||||
|
||||
frameStats.addValue(getErrorDeriv(step))
|
||||
}
|
||||
recordingStats.addValue(frameStats.mean)
|
||||
}
|
||||
LogManager.info("[AutoBone] Full recording after mean error: ${frameStats.mean} (SD ${frameStats.standardDeviation})")
|
||||
}
|
||||
|
||||
@Throws(AutoBoneException::class)
|
||||
fun processFrames(
|
||||
frames: PoseFrames,
|
||||
config: AutoBoneConfig = globalConfig,
|
||||
skeletonConfig: SkeletonConfig = globalSkeletonConfig,
|
||||
epochCallback: Consumer<Epoch>? = null,
|
||||
): AutoBoneResults {
|
||||
check(frames.frameHolders.isNotEmpty()) { "Recording has no trackers." }
|
||||
check(frames.maxFrameCount > 0) { "Recording has no frames." }
|
||||
|
||||
// Load current values for adjustable configs
|
||||
loadConfigValues()
|
||||
|
||||
// Set the target heights either from config or calculate them
|
||||
val targetHmdHeight = if (skeletonConfig.userHeight > MIN_HEIGHT) {
|
||||
skeletonConfig.userHeight
|
||||
} else {
|
||||
calcTargetHmdHeight(frames, config)
|
||||
}
|
||||
check(targetHmdHeight > MIN_HEIGHT) { "Configured height ($targetHmdHeight) is too small (<= $MIN_HEIGHT)." }
|
||||
|
||||
// Set up the current state, making all required players and setting up the
|
||||
// skeletons appropriately
|
||||
val step = PoseFrameStep<AutoBoneStep>(
|
||||
config = config,
|
||||
serverConfig = server.configManager,
|
||||
frames = frames,
|
||||
preEpoch = { step ->
|
||||
// Set the current adjust rate based on the current epoch
|
||||
step.data.adjustRate = decayFunc(step.config.initialAdjustRate, step.config.adjustRateDecay, step.epoch)
|
||||
},
|
||||
onStep = this::step,
|
||||
postEpoch = { step -> epoch(step, epochCallback) },
|
||||
randomSeed = config.randSeed,
|
||||
data = AutoBoneStep(
|
||||
targetHmdHeight = targetHmdHeight,
|
||||
adjustRate = 1f,
|
||||
),
|
||||
)
|
||||
|
||||
// Normalize the skeletons and get the normalized height for adjusted offsets
|
||||
scaleSkeleton(step.skeleton1)
|
||||
scaleSkeleton(step.skeleton2)
|
||||
adjustedHeightNormalized = sumAdjustedHeightOffsets(step.skeleton1)
|
||||
|
||||
// Normalize offsets based on the initial normalized skeleton
|
||||
scaleOffsets()
|
||||
|
||||
// Apply the initial normalized config values
|
||||
applyConfig(step.skeleton1)
|
||||
applyConfig(step.skeleton2)
|
||||
|
||||
// Initialize normalization to the set target height (also updates skeleton)
|
||||
estimatedHeight = targetHmdHeight
|
||||
updateRecordingScale(step, 1f / targetHmdHeight)
|
||||
|
||||
if (config.useFrameFiltering) {
|
||||
filterFrames(frames, step)
|
||||
}
|
||||
|
||||
// Iterate frames now that it's set up
|
||||
PoseFrameIterator.iterateFrames(step)
|
||||
|
||||
// Scale the normalized offsets to the estimated height for the final result
|
||||
for (entry in offsets.entries) {
|
||||
entry.setValue(entry.value * estimatedHeight)
|
||||
}
|
||||
|
||||
LogManager
|
||||
.info(
|
||||
"[AutoBone] Target height: ${step.data.targetHmdHeight}, Final height: $estimatedHeight",
|
||||
)
|
||||
if (step.data.errorStats.mean > config.maxFinalError) {
|
||||
throw AutoBoneException("The final epoch error value (${step.data.errorStats.mean}) has exceeded the maximum allowed value (${config.maxFinalError}).")
|
||||
}
|
||||
|
||||
return AutoBoneResults(
|
||||
estimatedHeight,
|
||||
step.data.targetHmdHeight,
|
||||
offsets,
|
||||
)
|
||||
}
|
||||
|
||||
private fun epoch(
|
||||
step: PoseFrameStep<AutoBoneStep>,
|
||||
epochCallback: Consumer<Epoch>? = null,
|
||||
) {
|
||||
val config = step.config
|
||||
val epoch = step.epoch
|
||||
|
||||
// Calculate average error over the epoch
|
||||
if (epoch <= 0 || epoch >= config.numEpochs - 1 || (epoch + 1) % config.printEveryNumEpochs == 0) {
|
||||
LogManager
|
||||
.info(
|
||||
"[AutoBone] Epoch: ${epoch + 1}, Mean error: ${step.data.errorStats.mean} (SD ${step.data.errorStats.standardDeviation}), Adjust rate: ${step.data.adjustRate}",
|
||||
)
|
||||
LogManager
|
||||
.info(
|
||||
"[AutoBone] Target height: ${step.data.targetHmdHeight}, Estimated height: $estimatedHeight",
|
||||
)
|
||||
}
|
||||
|
||||
if (epochCallback != null) {
|
||||
// Scale the normalized offsets to the estimated height for the callback
|
||||
val scaledOffsets = EnumMap(offsets)
|
||||
for (entry in scaledOffsets.entries) {
|
||||
entry.setValue(entry.value * estimatedHeight)
|
||||
}
|
||||
epochCallback.accept(Epoch(epoch + 1, config.numEpochs, step.data.errorStats, scaledOffsets))
|
||||
}
|
||||
}
|
||||
|
||||
private fun step(step: PoseFrameStep<AutoBoneStep>) {
|
||||
// Pull frequently used variables out of trainingStep to reduce call length
|
||||
val skeleton1 = step.skeleton1
|
||||
val skeleton2 = step.skeleton2
|
||||
|
||||
// Scaling each step used to mean enforcing the target height, so keep that
|
||||
// behaviour to retain predictability
|
||||
if (!step.config.scaleEachStep) {
|
||||
// Try to estimate a new height by calculating the height with the lowest
|
||||
// error between adding or subtracting from the height
|
||||
val maxHeight = step.data.targetHmdHeight + 0.2f
|
||||
val minHeight = step.data.targetHmdHeight - 0.2f
|
||||
|
||||
step.data.hmdHeight = estimatedHeight
|
||||
val heightErrorDeriv = getErrorDeriv(step)
|
||||
val heightAdjust = errorFunc(heightErrorDeriv) * step.data.adjustRate
|
||||
|
||||
val negHeight = (estimatedHeight - heightAdjust).coerceIn(minHeight, maxHeight)
|
||||
updateRecordingScale(step, 1f / negHeight)
|
||||
step.data.hmdHeight = negHeight
|
||||
val negHeightErrorDeriv = getErrorDeriv(step)
|
||||
|
||||
val posHeight = (estimatedHeight + heightAdjust).coerceIn(minHeight, maxHeight)
|
||||
updateRecordingScale(step, 1f / posHeight)
|
||||
step.data.hmdHeight = posHeight
|
||||
val posHeightErrorDeriv = getErrorDeriv(step)
|
||||
|
||||
if (negHeightErrorDeriv < heightErrorDeriv && negHeightErrorDeriv < posHeightErrorDeriv) {
|
||||
estimatedHeight = negHeight
|
||||
// Apply the negative height scale
|
||||
updateRecordingScale(step, 1f / negHeight)
|
||||
} else if (posHeightErrorDeriv < heightErrorDeriv) {
|
||||
estimatedHeight = posHeight
|
||||
// The last estimated height set was the positive adjustment, so no need to apply it again
|
||||
} else {
|
||||
// Reset to the initial scale
|
||||
updateRecordingScale(step, 1f / estimatedHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the heights used for error calculations
|
||||
step.data.hmdHeight = estimatedHeight
|
||||
|
||||
val errorDeriv = getErrorDeriv(step)
|
||||
val error = errorFunc(errorDeriv)
|
||||
|
||||
// In case of fire
|
||||
if (java.lang.Float.isNaN(error) || java.lang.Float.isInfinite(error)) {
|
||||
// Extinguish
|
||||
LogManager
|
||||
.warning(
|
||||
"[AutoBone] Error value is invalid, resetting variables to recover",
|
||||
)
|
||||
// Reset adjustable config values
|
||||
loadConfigValues()
|
||||
|
||||
// Reset error sum values
|
||||
step.data.errorStats.reset()
|
||||
|
||||
// Continue on new data
|
||||
return
|
||||
}
|
||||
|
||||
// Store the error count for logging purposes
|
||||
step.data.errorStats.addValue(errorDeriv)
|
||||
val adjustVal = error * step.data.adjustRate
|
||||
|
||||
// If there is no adjustment whatsoever, skip this
|
||||
if (adjustVal == 0f) {
|
||||
return
|
||||
}
|
||||
|
||||
val slideL = skeleton2.getComputedTracker(TrackerRole.LEFT_FOOT).position -
|
||||
skeleton1.getComputedTracker(TrackerRole.LEFT_FOOT).position
|
||||
val slideLLen = slideL.len()
|
||||
val slideLUnit: Vector3? = if (slideLLen > MIN_SLIDE_DIST) slideL / slideLLen else null
|
||||
|
||||
val slideR = skeleton2.getComputedTracker(TrackerRole.RIGHT_FOOT).position -
|
||||
skeleton1.getComputedTracker(TrackerRole.RIGHT_FOOT).position
|
||||
val slideRLen = slideR.len()
|
||||
val slideRUnit: Vector3? = if (slideRLen > MIN_SLIDE_DIST) slideR / slideRLen else null
|
||||
|
||||
val intermediateOffsets = EnumMap(offsets)
|
||||
for (entry in intermediateOffsets.entries) {
|
||||
// Skip adjustment if the epoch is before starting (for logging only) or
|
||||
// if there are no BoneTypes for this value
|
||||
if (step.epoch < 0 || entry.key.affectedOffsets.isEmpty()) {
|
||||
break
|
||||
}
|
||||
val originalLength = entry.value
|
||||
|
||||
// Calculate the total effect of the bone based on change in rotation
|
||||
val slideDot = BoneContribution.getSlideDot(
|
||||
skeleton1,
|
||||
skeleton2,
|
||||
entry.key,
|
||||
slideLUnit,
|
||||
slideRUnit,
|
||||
)
|
||||
val dotLength = originalLength * slideDot
|
||||
|
||||
// Scale by the total effect of the bone
|
||||
val curAdjustVal = adjustVal * -dotLength
|
||||
if (curAdjustVal == 0f) {
|
||||
continue
|
||||
}
|
||||
|
||||
val newLength = originalLength + curAdjustVal
|
||||
// No small or negative numbers!!! Bad algorithm!
|
||||
if (newLength < 0.01f) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply new offset length
|
||||
skeleton1.setOffset(entry.key, newLength)
|
||||
skeleton2.setOffset(entry.key, newLength)
|
||||
scaleSkeleton(skeleton1, onlyAdjustedHeight = true)
|
||||
scaleSkeleton(skeleton2, onlyAdjustedHeight = true)
|
||||
|
||||
// Update the skeleton poses for the new offset length
|
||||
skeleton1.update()
|
||||
skeleton2.update()
|
||||
|
||||
val newErrorDeriv = getErrorDeriv(step)
|
||||
if (newErrorDeriv < errorDeriv) {
|
||||
// Apply the adjusted length to the current adjusted offsets
|
||||
entry.setValue(newLength)
|
||||
}
|
||||
|
||||
// Reset the skeleton values to minimize bias in other variables, it's applied later
|
||||
applyConfig(skeleton1)
|
||||
applyConfig(skeleton2)
|
||||
}
|
||||
|
||||
// Update the offsets from the adjusted ones
|
||||
offsets.putAll(intermediateOffsets)
|
||||
|
||||
// Normalize the scale, it will be upscaled to the target height later
|
||||
// We only need to scale height offsets, as other offsets are not affected by height
|
||||
scaleOffsets(onlyHeightOffsets = true)
|
||||
|
||||
// Apply the normalized offsets to the skeleton
|
||||
applyConfig(skeleton1)
|
||||
applyConfig(skeleton2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums only the adjusted height offsets of the provided HumanPoseManager
|
||||
*/
|
||||
private fun sumAdjustedHeightOffsets(humanPoseManager: HumanPoseManager): Float {
|
||||
var sum = 0f
|
||||
SkeletonConfigManager.HEIGHT_OFFSETS.forEach {
|
||||
if (!adjustOffsets.contains(it)) return@forEach
|
||||
sum += humanPoseManager.getOffset(it)
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums only the height offsets of the provided offset map
|
||||
*/
|
||||
private fun sumHeightOffsets(offsets: EnumMap<SkeletonConfigOffsets, Float> = this.offsets): Float {
|
||||
var sum = 0f
|
||||
SkeletonConfigManager.HEIGHT_OFFSETS.forEach {
|
||||
sum += offsets[it] ?: return@forEach
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
private fun scaleSkeleton(humanPoseManager: HumanPoseManager, targetHeight: Float = 1f, onlyAdjustedHeight: Boolean = false) {
|
||||
// Get the scale to apply for the appropriate offsets
|
||||
val scale = if (onlyAdjustedHeight) {
|
||||
// Only adjusted height offsets
|
||||
val adjHeight = sumAdjustedHeightOffsets(humanPoseManager)
|
||||
// Remove the constant from the target, leaving only the target for adjusted height offsets
|
||||
val adjTarget = targetHeight - (humanPoseManager.userHeightFromConfig - adjHeight)
|
||||
// Return only the scale for adjusted offsets
|
||||
adjTarget / adjHeight
|
||||
} else {
|
||||
targetHeight / humanPoseManager.userHeightFromConfig
|
||||
}
|
||||
|
||||
val offsets = if (onlyAdjustedHeight) SkeletonConfigManager.HEIGHT_OFFSETS else SkeletonConfigOffsets.values
|
||||
for (offset in offsets) {
|
||||
if (onlyAdjustedHeight && !adjustOffsets.contains(offset)) continue
|
||||
humanPoseManager.setOffset(offset, humanPoseManager.getOffset(offset) * scale)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scaleOffsets(offsets: EnumMap<SkeletonConfigOffsets, Float> = this.offsets, targetHeight: Float = adjustedHeightNormalized, onlyHeightOffsets: Boolean = false) {
|
||||
// Get the scale to apply for the appropriate offsets
|
||||
val scale = targetHeight / sumHeightOffsets(offsets)
|
||||
|
||||
for (entry in offsets.entries) {
|
||||
if (onlyHeightOffsets && !SkeletonConfigManager.HEIGHT_OFFSETS.contains(entry.key)) continue
|
||||
entry.setValue(entry.value * scale)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(AutoBoneException::class)
|
||||
private fun getErrorDeriv(step: PoseFrameStep<AutoBoneStep>): Float {
|
||||
val config = step.config
|
||||
var sumError = 0f
|
||||
if (config.slideErrorFactor > 0f) {
|
||||
sumError += slideError.getStepError(step) * config.slideErrorFactor
|
||||
}
|
||||
if (config.offsetSlideErrorFactor > 0f) {
|
||||
sumError += (
|
||||
offsetSlideError.getStepError(step) *
|
||||
config.offsetSlideErrorFactor
|
||||
)
|
||||
}
|
||||
if (config.footHeightOffsetErrorFactor > 0f) {
|
||||
sumError += (
|
||||
footHeightOffsetError.getStepError(step) *
|
||||
config.footHeightOffsetErrorFactor
|
||||
)
|
||||
}
|
||||
if (config.bodyProportionErrorFactor > 0f) {
|
||||
sumError += (
|
||||
bodyProportionError.getStepError(step) *
|
||||
config.bodyProportionErrorFactor
|
||||
)
|
||||
}
|
||||
if (config.heightErrorFactor > 0f) {
|
||||
sumError += heightError.getStepError(step) * config.heightErrorFactor
|
||||
}
|
||||
if (config.positionErrorFactor > 0f) {
|
||||
sumError += (
|
||||
positionError.getStepError(step) *
|
||||
config.positionErrorFactor
|
||||
)
|
||||
}
|
||||
if (config.positionOffsetErrorFactor > 0f) {
|
||||
sumError += (
|
||||
positionOffsetError.getStepError(step) *
|
||||
config.positionOffsetErrorFactor
|
||||
)
|
||||
}
|
||||
return sumError
|
||||
}
|
||||
|
||||
val lengthsString: String
|
||||
get() {
|
||||
val configInfo = StringBuilder()
|
||||
offsets.forEach { (key, value) ->
|
||||
if (configInfo.isNotEmpty()) {
|
||||
configInfo.append(", ")
|
||||
}
|
||||
configInfo
|
||||
.append(key.configKey)
|
||||
.append(": ")
|
||||
.append(StringUtils.prettyNumber(value * 100f, 2))
|
||||
}
|
||||
return configInfo.toString()
|
||||
}
|
||||
|
||||
fun saveRecording(frames: PoseFrames, recordingFile: File) {
|
||||
if (saveDir.isDirectory || saveDir.mkdirs()) {
|
||||
LogManager
|
||||
.info("[AutoBone] Exporting frames to \"${recordingFile.path}\"...")
|
||||
if (PfsIO.tryWriteToFile(recordingFile, frames)) {
|
||||
LogManager
|
||||
.info(
|
||||
"[AutoBone] Done exporting! Recording can be found at \"${recordingFile.path}\".",
|
||||
)
|
||||
} else {
|
||||
LogManager
|
||||
.severe(
|
||||
"[AutoBone] Failed to export the recording to \"${recordingFile.path}\".",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LogManager
|
||||
.severe(
|
||||
"[AutoBone] Failed to create the recording directory \"${saveDir.path}\".",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveRecording(frames: PoseFrames, recordingFileName: String) {
|
||||
saveRecording(frames, File(saveDir, recordingFileName))
|
||||
}
|
||||
|
||||
fun saveRecording(frames: PoseFrames) {
|
||||
var recordingFile: File
|
||||
var recordingIndex = 1
|
||||
do {
|
||||
recordingFile = File(saveDir, "ABRecording${recordingIndex++}.pfs")
|
||||
} while (recordingFile.exists())
|
||||
saveRecording(frames, recordingFile)
|
||||
}
|
||||
|
||||
fun loadRecordings(): FastList<Pair<String, PoseFrames>> {
|
||||
val recordings = FastList<Pair<String, PoseFrames>>()
|
||||
|
||||
loadDir.listFiles()?.forEach { file ->
|
||||
if (!file.isFile) return@forEach
|
||||
|
||||
val frames = if (file.name.endsWith(".pfs", ignoreCase = true)) {
|
||||
LogManager.info("[AutoBone] Loading PFS recording from \"${file.path}\"...")
|
||||
PfsIO.tryReadFromFile(file)
|
||||
} else if (file.name.endsWith(".pfr", ignoreCase = true)) {
|
||||
LogManager.info("[AutoBone] Loading PFR recording from \"${file.path}\"...")
|
||||
PfrIO.tryReadFromFile(file)
|
||||
} else {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
if (frames == null) {
|
||||
LogManager.severe("[AutoBone] Failed to load recording from \"${file.path}\".")
|
||||
} else {
|
||||
recordings.add(Pair.of(file.name, frames))
|
||||
LogManager.info("[AutoBone] Loaded recording from \"${file.path}\".")
|
||||
}
|
||||
}
|
||||
|
||||
return recordings
|
||||
}
|
||||
|
||||
inner class Epoch(
|
||||
val epoch: Int,
|
||||
val totalEpochs: Int,
|
||||
val epochError: StatsCalculator,
|
||||
val configValues: EnumMap<SkeletonConfigOffsets, Float>,
|
||||
) {
|
||||
override fun toString(): String = "Epoch: $epoch, Epoch error: $epochError"
|
||||
}
|
||||
|
||||
inner class AutoBoneResults(
|
||||
val finalHeight: Float,
|
||||
val targetHeight: Float,
|
||||
val configValues: EnumMap<SkeletonConfigOffsets, Float>,
|
||||
) {
|
||||
val heightDifference: Float
|
||||
get() = abs(targetHeight - finalHeight)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MIN_HEIGHT = 0.4f
|
||||
const val MIN_SLIDE_DIST = 0.002f
|
||||
const val AUTOBONE_FOLDER = "AutoBone Recordings"
|
||||
const val LOADAUTOBONE_FOLDER = "Load AutoBone Recordings"
|
||||
|
||||
// FIXME: Won't work on iOS and Android, maybe fix resolveConfigDirectory more than this
|
||||
val saveDir = File(
|
||||
OperatingSystem.resolveConfigDirectory(SLIMEVR_IDENTIFIER)?.resolve(
|
||||
AUTOBONE_FOLDER,
|
||||
)?.toString() ?: AUTOBONE_FOLDER,
|
||||
)
|
||||
val loadDir = File(
|
||||
OperatingSystem.resolveConfigDirectory(SLIMEVR_IDENTIFIER)?.resolve(
|
||||
LOADAUTOBONE_FOLDER,
|
||||
)?.toString() ?: LOADAUTOBONE_FOLDER,
|
||||
)
|
||||
|
||||
// Mean square error function
|
||||
private fun errorFunc(errorDeriv: Float): Float = 0.5f * (errorDeriv * errorDeriv)
|
||||
|
||||
private fun decayFunc(initialAdjustRate: Float, adjustRateDecay: Float, epoch: Int): Float = if (epoch >= 0) initialAdjustRate / (1 + (adjustRateDecay * epoch)) else 0.0f
|
||||
|
||||
val SYMM_CONFIGS = arrayOf(
|
||||
SkeletonConfigOffsets.HIPS_WIDTH,
|
||||
SkeletonConfigOffsets.SHOULDERS_WIDTH,
|
||||
SkeletonConfigOffsets.SHOULDERS_DISTANCE,
|
||||
SkeletonConfigOffsets.UPPER_ARM,
|
||||
SkeletonConfigOffsets.LOWER_ARM,
|
||||
SkeletonConfigOffsets.UPPER_LEG,
|
||||
SkeletonConfigOffsets.LOWER_LEG,
|
||||
SkeletonConfigOffsets.FOOT_LENGTH,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
package dev.slimevr.autobone
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.autobone.AutoBone.AutoBoneResults
|
||||
import dev.slimevr.autobone.AutoBone.Companion.loadDir
|
||||
import dev.slimevr.autobone.errors.AutoBoneException
|
||||
import dev.slimevr.poseframeformat.PoseFrames
|
||||
import dev.slimevr.poseframeformat.PoseRecorder
|
||||
import dev.slimevr.poseframeformat.PoseRecorder.RecordingProgress
|
||||
import dev.slimevr.poseframeformat.trackerdata.TrackerFrameData
|
||||
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigManager
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
|
||||
import io.eiren.util.StringUtils
|
||||
import io.eiren.util.collections.FastList
|
||||
import io.eiren.util.logging.LogManager
|
||||
import org.apache.commons.lang3.tuple.Pair
|
||||
import java.util.*
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
class AutoBoneHandler(private val server: VRServer) {
|
||||
private val poseRecorder: PoseRecorder = PoseRecorder(server)
|
||||
private val autoBone: AutoBone = AutoBone(server)
|
||||
|
||||
private val recordingLock = ReentrantLock()
|
||||
private var recordingThread: Thread? = null
|
||||
private val saveRecordingLock = ReentrantLock()
|
||||
private var saveRecordingThread: Thread? = null
|
||||
private val autoBoneLock = ReentrantLock()
|
||||
private var autoBoneThread: Thread? = null
|
||||
|
||||
private val listeners = CopyOnWriteArrayList<AutoBoneListener>()
|
||||
|
||||
fun addListener(listener: AutoBoneListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: AutoBoneListener) {
|
||||
listeners.removeIf { listener == it }
|
||||
}
|
||||
|
||||
private fun announceProcessStatus(
|
||||
processType: AutoBoneProcessType,
|
||||
message: String? = null,
|
||||
current: Long = -1L,
|
||||
total: Long = -1L,
|
||||
eta: Float = -1f,
|
||||
completed: Boolean = false,
|
||||
success: Boolean = true,
|
||||
) {
|
||||
listeners.forEach {
|
||||
it.onAutoBoneProcessStatus(
|
||||
processType,
|
||||
message,
|
||||
current,
|
||||
total,
|
||||
eta,
|
||||
completed,
|
||||
success,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(AutoBoneException::class)
|
||||
private fun processFrames(frames: PoseFrames): AutoBoneResults = autoBone
|
||||
.processFrames(frames) { epoch ->
|
||||
listeners.forEach { listener -> listener.onAutoBoneEpoch(epoch) }
|
||||
}
|
||||
|
||||
fun startProcessByType(processType: AutoBoneProcessType?): Boolean {
|
||||
when (processType) {
|
||||
AutoBoneProcessType.RECORD -> startRecording()
|
||||
|
||||
AutoBoneProcessType.SAVE -> saveRecording()
|
||||
|
||||
AutoBoneProcessType.PROCESS -> processRecording()
|
||||
|
||||
else -> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
recordingLock.withLock {
|
||||
// Prevent running multiple times
|
||||
if (recordingThread != null) {
|
||||
return
|
||||
}
|
||||
recordingThread = thread(start = true) { startRecordingThread() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRecordingThread() {
|
||||
try {
|
||||
if (poseRecorder.isReadyToRecord) {
|
||||
announceProcessStatus(AutoBoneProcessType.RECORD, "Recording...")
|
||||
|
||||
// ex. 1000 samples at 20 ms per sample is 20 seconds
|
||||
val sampleCount = autoBone.globalConfig.sampleCount
|
||||
val sampleRate = autoBone.globalConfig.sampleRateMs / 1000f
|
||||
// Calculate total time in seconds
|
||||
val totalTime: Float = sampleCount * sampleRate
|
||||
|
||||
val framesFuture = poseRecorder
|
||||
.startFrameRecording(
|
||||
sampleCount,
|
||||
sampleRate,
|
||||
) { progress: RecordingProgress ->
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.RECORD,
|
||||
current = progress.frame.toLong(),
|
||||
total = progress.totalFrames.toLong(),
|
||||
eta = totalTime - (progress.frame * totalTime / progress.totalFrames),
|
||||
)
|
||||
}
|
||||
val frames = framesFuture.get()
|
||||
LogManager.info("[AutoBone] Done recording!")
|
||||
|
||||
// Save a recurring recording for users to send as debug info
|
||||
announceProcessStatus(AutoBoneProcessType.RECORD, "Saving recording...")
|
||||
autoBone.saveRecording(frames, "LastABRecording.pfs")
|
||||
if (autoBone.globalConfig.saveRecordings) {
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.RECORD,
|
||||
"Saving recording (from config option)...",
|
||||
)
|
||||
autoBone.saveRecording(frames)
|
||||
}
|
||||
listeners.forEach { listener: AutoBoneListener -> listener.onAutoBoneRecordingEnd(frames) }
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.RECORD,
|
||||
"Done recording!",
|
||||
completed = true,
|
||||
success = true,
|
||||
)
|
||||
} else {
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.RECORD,
|
||||
"The server is not ready to record",
|
||||
completed = true,
|
||||
success = false,
|
||||
)
|
||||
LogManager.severe("[AutoBone] Unable to record...")
|
||||
return
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.RECORD,
|
||||
"Recording failed: ${e.message}",
|
||||
completed = true,
|
||||
success = false,
|
||||
)
|
||||
LogManager.severe("[AutoBone] Failed recording!", e)
|
||||
} finally {
|
||||
recordingThread = null
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRecording() {
|
||||
if (poseRecorder.isRecording) {
|
||||
poseRecorder.stopFrameRecording()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRecording() {
|
||||
if (poseRecorder.isRecording) {
|
||||
poseRecorder.cancelFrameRecording()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveRecording() {
|
||||
saveRecordingLock.withLock {
|
||||
// Prevent running multiple times
|
||||
if (saveRecordingThread != null) {
|
||||
return
|
||||
}
|
||||
saveRecordingThread = thread(start = true) { saveRecordingThread() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveRecordingThread() {
|
||||
try {
|
||||
val framesFuture = poseRecorder.framesAsync
|
||||
if (framesFuture != null) {
|
||||
announceProcessStatus(AutoBoneProcessType.SAVE, "Waiting for recording...")
|
||||
val frames = framesFuture.get()
|
||||
check(frames.frameHolders.isNotEmpty()) { "Recording has no trackers." }
|
||||
check(frames.maxFrameCount > 0) { "Recording has no frames." }
|
||||
announceProcessStatus(AutoBoneProcessType.SAVE, "Saving recording...")
|
||||
autoBone.saveRecording(frames)
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.SAVE,
|
||||
"Recording saved!",
|
||||
completed = true,
|
||||
success = true,
|
||||
)
|
||||
} else {
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.SAVE,
|
||||
"No recording found",
|
||||
completed = true,
|
||||
success = false,
|
||||
)
|
||||
LogManager.severe("[AutoBone] Unable to save, no recording was done...")
|
||||
return
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.SAVE,
|
||||
"Failed to save recording: ${e.message}",
|
||||
completed = true,
|
||||
success = false,
|
||||
)
|
||||
LogManager.severe("[AutoBone] Failed to save recording!", e)
|
||||
} finally {
|
||||
saveRecordingThread = null
|
||||
}
|
||||
}
|
||||
|
||||
fun processRecording() {
|
||||
autoBoneLock.withLock {
|
||||
// Prevent running multiple times
|
||||
if (autoBoneThread != null) {
|
||||
return
|
||||
}
|
||||
autoBoneThread = thread(start = true) { processRecordingThread() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun processRecordingThread() {
|
||||
try {
|
||||
announceProcessStatus(AutoBoneProcessType.PROCESS, "Loading recordings...")
|
||||
val frameRecordings = autoBone.loadRecordings()
|
||||
if (!frameRecordings.isEmpty()) {
|
||||
LogManager.info("[AutoBone] Done loading frames!")
|
||||
} else {
|
||||
val framesFuture = poseRecorder.framesAsync
|
||||
if (framesFuture != null) {
|
||||
announceProcessStatus(AutoBoneProcessType.PROCESS, "Waiting for recording...")
|
||||
val frames = framesFuture.get()
|
||||
frameRecordings.add(Pair.of("<Recording>", frames))
|
||||
} else {
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.PROCESS,
|
||||
"No recordings found...",
|
||||
completed = true,
|
||||
success = false,
|
||||
)
|
||||
LogManager
|
||||
.severe(
|
||||
"[AutoBone] No recordings found in \"${loadDir.path}\" and no recording was done...",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
announceProcessStatus(AutoBoneProcessType.PROCESS, "Processing recording(s)...")
|
||||
LogManager.info("[AutoBone] Processing frames...")
|
||||
val errorStats = StatsCalculator()
|
||||
val offsetStats = EnumMap<SkeletonConfigOffsets, StatsCalculator>(
|
||||
SkeletonConfigOffsets::class.java,
|
||||
)
|
||||
val skeletonConfigManagerBuffer = SkeletonConfigManager(false)
|
||||
for ((key, value) in frameRecordings) {
|
||||
LogManager.info("[AutoBone] Processing frames from \"$key\"...")
|
||||
// Output tracker info for the recording
|
||||
printTrackerInfo(value.frameHolders)
|
||||
|
||||
// Actually process the recording
|
||||
val autoBoneResults = processFrames(value)
|
||||
LogManager.info("[AutoBone] Done processing!")
|
||||
|
||||
// #region Stats/Values
|
||||
// Accumulate height error
|
||||
errorStats.addValue(autoBoneResults.heightDifference)
|
||||
|
||||
// Accumulate length values
|
||||
for (offset in autoBoneResults.configValues) {
|
||||
val statCalc = offsetStats.getOrPut(offset.key) {
|
||||
StatsCalculator()
|
||||
}
|
||||
// Multiply by 100 to get cm
|
||||
statCalc.addValue(offset.value * 100f)
|
||||
}
|
||||
|
||||
// Calculate and output skeleton ratios
|
||||
skeletonConfigManagerBuffer.setOffsets(autoBoneResults.configValues)
|
||||
printSkeletonRatios(skeletonConfigManagerBuffer)
|
||||
|
||||
LogManager.info("[AutoBone] Length values: ${autoBone.lengthsString}")
|
||||
}
|
||||
// Length value stats
|
||||
val averageLengthVals = StringBuilder()
|
||||
offsetStats.forEach { (key, value) ->
|
||||
if (averageLengthVals.isNotEmpty()) {
|
||||
averageLengthVals.append(", ")
|
||||
}
|
||||
averageLengthVals
|
||||
.append(key.configKey)
|
||||
.append(": ")
|
||||
.append(StringUtils.prettyNumber(value.mean, 2))
|
||||
.append(" (SD ")
|
||||
.append(StringUtils.prettyNumber(value.standardDeviation, 2))
|
||||
.append(")")
|
||||
}
|
||||
LogManager.info("[AutoBone] Average length values: $averageLengthVals")
|
||||
|
||||
// Height error stats
|
||||
LogManager
|
||||
.info(
|
||||
"[AutoBone] Average height error: ${
|
||||
StringUtils.prettyNumber(errorStats.mean, 6)
|
||||
} (SD ${StringUtils.prettyNumber(errorStats.standardDeviation, 6)})",
|
||||
)
|
||||
// #endregion
|
||||
listeners.forEach { listener: AutoBoneListener -> listener.onAutoBoneEnd(autoBone.offsets) }
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.PROCESS,
|
||||
"Done processing!",
|
||||
completed = true,
|
||||
success = true,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
announceProcessStatus(
|
||||
AutoBoneProcessType.PROCESS,
|
||||
"Processing failed: ${e.message}",
|
||||
completed = true,
|
||||
success = false,
|
||||
)
|
||||
LogManager.severe("[AutoBone] Failed adjustment!", e)
|
||||
} finally {
|
||||
autoBoneThread = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun printTrackerInfo(trackers: FastList<TrackerFrames>) {
|
||||
val trackerInfo = StringBuilder()
|
||||
for (tracker in trackers) {
|
||||
val frame = tracker?.tryGetFrame(0) ?: continue
|
||||
|
||||
// Add a comma if this is not the first item listed
|
||||
if (trackerInfo.isNotEmpty()) {
|
||||
trackerInfo.append(", ")
|
||||
}
|
||||
|
||||
trackerInfo.append(frame.tryGetTrackerPosition()?.designation ?: "unassigned")
|
||||
|
||||
// Represent the data flags
|
||||
val trackerFlags = StringBuilder()
|
||||
if (frame.hasData(TrackerFrameData.ROTATION)) {
|
||||
trackerFlags.append("R")
|
||||
}
|
||||
if (frame.hasData(TrackerFrameData.POSITION)) {
|
||||
trackerFlags.append("P")
|
||||
}
|
||||
if (frame.hasData(TrackerFrameData.ACCELERATION)) {
|
||||
trackerFlags.append("A")
|
||||
}
|
||||
if (frame.hasData(TrackerFrameData.RAW_ROTATION)) {
|
||||
trackerFlags.append("r")
|
||||
}
|
||||
|
||||
// If there are data flags, print them in brackets after the designation
|
||||
if (trackerFlags.isNotEmpty()) {
|
||||
trackerInfo.append(" (").append(trackerFlags).append(")")
|
||||
}
|
||||
}
|
||||
LogManager.info("[AutoBone] (${trackers.size} trackers) [$trackerInfo]")
|
||||
}
|
||||
|
||||
private fun printSkeletonRatios(skeleton: SkeletonConfigManager) {
|
||||
val neckLength = skeleton.getOffset(SkeletonConfigOffsets.NECK)
|
||||
val upperChestLength = skeleton.getOffset(SkeletonConfigOffsets.UPPER_CHEST)
|
||||
val chestLength = skeleton.getOffset(SkeletonConfigOffsets.CHEST)
|
||||
val waistLength = skeleton.getOffset(SkeletonConfigOffsets.WAIST)
|
||||
val hipLength = skeleton.getOffset(SkeletonConfigOffsets.HIP)
|
||||
val torsoLength = upperChestLength + chestLength + waistLength + hipLength
|
||||
val hipWidth = skeleton.getOffset(SkeletonConfigOffsets.HIPS_WIDTH)
|
||||
val legLength = skeleton.getOffset(SkeletonConfigOffsets.UPPER_LEG) +
|
||||
skeleton.getOffset(SkeletonConfigOffsets.LOWER_LEG)
|
||||
val lowerLegLength = skeleton.getOffset(SkeletonConfigOffsets.LOWER_LEG)
|
||||
|
||||
val neckTorso = neckLength / torsoLength
|
||||
val chestTorso = (upperChestLength + chestLength) / torsoLength
|
||||
val torsoWaist = hipWidth / torsoLength
|
||||
val legTorso = legLength / torsoLength
|
||||
val legBody = legLength / (torsoLength + neckLength)
|
||||
val kneeLeg = lowerLegLength / legLength
|
||||
|
||||
LogManager.info(
|
||||
"[AutoBone] Ratios: [{Neck-Torso: ${
|
||||
StringUtils.prettyNumber(neckTorso)}}, {Chest-Torso: ${
|
||||
StringUtils.prettyNumber(chestTorso)}}, {Torso-Waist: ${
|
||||
StringUtils.prettyNumber(torsoWaist)}}, {Leg-Torso: ${
|
||||
StringUtils.prettyNumber(legTorso)}}, {Leg-Body: ${
|
||||
StringUtils.prettyNumber(legBody)}}, {Knee-Leg: ${
|
||||
StringUtils.prettyNumber(kneeLeg)}}]",
|
||||
)
|
||||
}
|
||||
|
||||
fun applyValues() {
|
||||
autoBone.applyAndSaveConfig()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package dev.slimevr.autobone
|
||||
|
||||
import dev.slimevr.autobone.AutoBone.Epoch
|
||||
import dev.slimevr.poseframeformat.PoseFrames
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
|
||||
import java.util.*
|
||||
|
||||
interface AutoBoneListener {
|
||||
fun onAutoBoneProcessStatus(
|
||||
processType: AutoBoneProcessType,
|
||||
message: String?,
|
||||
current: Long,
|
||||
total: Long,
|
||||
eta: Float,
|
||||
completed: Boolean,
|
||||
success: Boolean,
|
||||
)
|
||||
|
||||
fun onAutoBoneRecordingEnd(recording: PoseFrames)
|
||||
fun onAutoBoneEpoch(epoch: Epoch)
|
||||
fun onAutoBoneEnd(configValues: EnumMap<SkeletonConfigOffsets, Float>)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package dev.slimevr.autobone
|
||||
|
||||
enum class AutoBoneProcessType(val id: Int) {
|
||||
NONE(0),
|
||||
RECORD(1),
|
||||
SAVE(2),
|
||||
PROCESS(3),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun getById(id: Int): AutoBoneProcessType? = byId[id]
|
||||
}
|
||||
}
|
||||
|
||||
private val byId = AutoBoneProcessType.values().associateBy { it.id }
|
||||
@@ -1,13 +0,0 @@
|
||||
package dev.slimevr.autobone
|
||||
|
||||
class AutoBoneStep(
|
||||
var hmdHeight: Float = 1f,
|
||||
val targetHmdHeight: Float = 1f,
|
||||
var adjustRate: Float = 0f,
|
||||
) {
|
||||
|
||||
val errorStats = StatsCalculator()
|
||||
|
||||
val heightOffset: Float
|
||||
get() = targetHmdHeight - hmdHeight
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package dev.slimevr.autobone
|
||||
|
||||
import dev.slimevr.autobone.AutoBone.Companion.MIN_SLIDE_DIST
|
||||
import dev.slimevr.autobone.AutoBone.Companion.SYMM_CONFIGS
|
||||
import dev.slimevr.tracking.processor.BoneType
|
||||
import dev.slimevr.tracking.processor.HumanPoseManager
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
|
||||
import io.github.axisangles.ktmath.Vector3
|
||||
|
||||
object BoneContribution {
|
||||
/**
|
||||
* Computes the local tail position of the bone after rotation.
|
||||
*/
|
||||
fun getBoneLocalTail(
|
||||
skeleton: HumanPoseManager,
|
||||
boneType: BoneType,
|
||||
): Vector3 {
|
||||
val bone = skeleton.getBone(boneType)
|
||||
return bone.getTailPosition() - bone.getPosition()
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the direction of the bone tail's movement between skeletons 1 and 2.
|
||||
*/
|
||||
fun getBoneLocalTailDir(
|
||||
skeleton1: HumanPoseManager,
|
||||
skeleton2: HumanPoseManager,
|
||||
boneType: BoneType,
|
||||
): Vector3? {
|
||||
val boneOff = getBoneLocalTail(skeleton2, boneType) - getBoneLocalTail(skeleton1, boneType)
|
||||
val boneOffLen = boneOff.len()
|
||||
// If the offset is approx 0, just return null so it can be easily ignored
|
||||
return if (boneOffLen > MIN_SLIDE_DIST) boneOff / boneOffLen else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts how much the provided config should be affecting the slide offsets
|
||||
* of the left and right ankles.
|
||||
*/
|
||||
fun getSlideDot(
|
||||
skeleton1: HumanPoseManager,
|
||||
skeleton2: HumanPoseManager,
|
||||
config: SkeletonConfigOffsets,
|
||||
slideL: Vector3?,
|
||||
slideR: Vector3?,
|
||||
): Float {
|
||||
var slideDot = 0f
|
||||
// Used for right offset if not a symmetric bone
|
||||
var boneOffL: Vector3? = null
|
||||
|
||||
// Treat null as 0
|
||||
if (slideL != null) {
|
||||
boneOffL = getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[0])
|
||||
|
||||
// Treat null as 0
|
||||
if (boneOffL != null) {
|
||||
slideDot += slideL.dot(boneOffL)
|
||||
}
|
||||
}
|
||||
|
||||
// Treat null as 0
|
||||
if (slideR != null) {
|
||||
// IMPORTANT: This assumption for acquiring BoneType only works if
|
||||
// SkeletonConfigOffsets is set up to only affect one BoneType, make sure no
|
||||
// changes to SkeletonConfigOffsets goes against this assumption, please!
|
||||
val boneOffR = if (SYMM_CONFIGS.contains(config)) {
|
||||
getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[1])
|
||||
} else if (slideL != null) {
|
||||
// Use cached offset if slideL was used
|
||||
boneOffL
|
||||
} else {
|
||||
// Compute offset if missing because of slideL
|
||||
getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[0])
|
||||
}
|
||||
|
||||
// Treat null as 0
|
||||
if (boneOffR != null) {
|
||||
slideDot += slideR.dot(boneOffR)
|
||||
}
|
||||
}
|
||||
|
||||
return slideDot / 2f
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package dev.slimevr.autobone
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
object PoseFrameIterator {
|
||||
fun <T> iterateFrames(
|
||||
step: PoseFrameStep<T>,
|
||||
) {
|
||||
check(step.frames.frameHolders.isNotEmpty()) { "Recording has no trackers." }
|
||||
check(step.maxFrameCount > 0) { "Recording has no frames." }
|
||||
|
||||
// Epoch loop, each epoch is one full iteration over the full dataset
|
||||
for (epoch in (if (step.config.calcInitError) -1 else 0) until step.config.numEpochs) {
|
||||
// Set the current epoch to process
|
||||
step.epoch = epoch
|
||||
// Process the epoch
|
||||
epoch(step)
|
||||
}
|
||||
}
|
||||
|
||||
private fun randomIndices(count: Int, random: Random): IntArray {
|
||||
val randIndices = IntArray(count)
|
||||
|
||||
var zeroPos = -1
|
||||
for (i in 0 until count) {
|
||||
var index = random.nextInt(count)
|
||||
if (i > 0) {
|
||||
while (index == zeroPos || randIndices[index] > 0) {
|
||||
index = random.nextInt(count)
|
||||
}
|
||||
} else {
|
||||
zeroPos = index
|
||||
}
|
||||
randIndices[index] = i
|
||||
}
|
||||
|
||||
return randIndices
|
||||
}
|
||||
|
||||
private fun <T> epoch(step: PoseFrameStep<T>) {
|
||||
val config = step.config
|
||||
val frameCount = step.maxFrameCount
|
||||
|
||||
// Perform any setup that needs to be done before the current epoch
|
||||
step.preEpoch?.accept(step)
|
||||
|
||||
val randIndices = if (config.randomizeFrameOrder) {
|
||||
randomIndices(step.maxFrameCount, step.random)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Iterate over the frames using a cursor and an offset for comparing
|
||||
// frames a certain number of frames apart
|
||||
var cursorOffset = config.minDataDistance
|
||||
while (cursorOffset <= config.maxDataDistance &&
|
||||
cursorOffset < frameCount
|
||||
) {
|
||||
var frameCursor = 0
|
||||
while (frameCursor < frameCount - cursorOffset) {
|
||||
val frameCursor2 = frameCursor + cursorOffset
|
||||
|
||||
// Then set the frame cursors and apply them to both skeletons
|
||||
if (config.randomizeFrameOrder && randIndices != null) {
|
||||
step
|
||||
.setCursors(
|
||||
randIndices[frameCursor],
|
||||
randIndices[frameCursor2],
|
||||
updatePlayerCursors = true,
|
||||
)
|
||||
} else {
|
||||
step.setCursors(
|
||||
frameCursor,
|
||||
frameCursor2,
|
||||
updatePlayerCursors = true,
|
||||
)
|
||||
}
|
||||
|
||||
// Process the iteration
|
||||
step.onStep.accept(step)
|
||||
|
||||
// Move on to the next iteration
|
||||
frameCursor += config.cursorIncrement
|
||||
}
|
||||
cursorOffset++
|
||||
}
|
||||
|
||||
step.postEpoch?.accept(step)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package dev.slimevr.autobone
|
||||
|
||||
import dev.slimevr.config.AutoBoneConfig
|
||||
import dev.slimevr.config.ConfigManager
|
||||
import dev.slimevr.poseframeformat.PoseFrames
|
||||
import dev.slimevr.poseframeformat.player.TrackerFramesPlayer
|
||||
import dev.slimevr.tracking.processor.HumanPoseManager
|
||||
import java.util.function.Consumer
|
||||
import kotlin.random.Random
|
||||
|
||||
class PoseFrameStep<T>(
|
||||
val config: AutoBoneConfig,
|
||||
/** The config to initialize skeletons. */
|
||||
serverConfig: ConfigManager? = null,
|
||||
val frames: PoseFrames,
|
||||
/** The consumer run before each epoch. */
|
||||
val preEpoch: Consumer<PoseFrameStep<T>>? = null,
|
||||
/** The consumer run for each step. */
|
||||
val onStep: Consumer<PoseFrameStep<T>>,
|
||||
/** The consumer run after each epoch. */
|
||||
val postEpoch: Consumer<PoseFrameStep<T>>? = null,
|
||||
/** The current epoch. */
|
||||
var epoch: Int = 0,
|
||||
/** The current frame cursor position in [frames] for skeleton1. */
|
||||
var cursor1: Int = 0,
|
||||
/** The current frame cursor position in [frames] for skeleton2. */
|
||||
var cursor2: Int = 0,
|
||||
randomSeed: Long = 0,
|
||||
val data: T,
|
||||
) {
|
||||
var maxFrameCount = frames.maxFrameCount
|
||||
|
||||
val framePlayer1 = TrackerFramesPlayer(frames)
|
||||
val framePlayer2 = TrackerFramesPlayer(frames)
|
||||
|
||||
val trackers1 = framePlayer1.trackers.toList()
|
||||
val trackers2 = framePlayer2.trackers.toList()
|
||||
|
||||
val skeleton1 = HumanPoseManager(trackers1)
|
||||
val skeleton2 = HumanPoseManager(trackers2)
|
||||
|
||||
val random = Random(randomSeed)
|
||||
|
||||
init {
|
||||
// Load server configs into the skeleton
|
||||
if (serverConfig != null) {
|
||||
skeleton1.loadFromConfig(serverConfig)
|
||||
skeleton2.loadFromConfig(serverConfig)
|
||||
}
|
||||
// Disable leg tweaks and IK solver, these will mess with the resulting positions
|
||||
skeleton1.setLegTweaksEnabled(false)
|
||||
skeleton2.setLegTweaksEnabled(false)
|
||||
}
|
||||
|
||||
fun setCursors(cursor1: Int, cursor2: Int, updatePlayerCursors: Boolean) {
|
||||
this.cursor1 = cursor1
|
||||
this.cursor2 = cursor2
|
||||
|
||||
if (updatePlayerCursors) {
|
||||
updatePlayerCursors()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePlayerCursors() {
|
||||
framePlayer1.setCursors(cursor1)
|
||||
framePlayer2.setCursors(cursor2)
|
||||
skeleton1.update()
|
||||
skeleton2.update()
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package dev.slimevr.autobone
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
/**
|
||||
* This is a stat calculator based on Welford's online algorithm
|
||||
* https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford%27s_online_algorithm
|
||||
*/
|
||||
class StatsCalculator {
|
||||
private var count = 0
|
||||
var mean = 0f
|
||||
private set
|
||||
private var m2 = 0f
|
||||
|
||||
fun reset() {
|
||||
count = 0
|
||||
mean = 0f
|
||||
m2 = 0f
|
||||
}
|
||||
|
||||
fun addValue(newValue: Float) {
|
||||
count += 1
|
||||
val delta = newValue - mean
|
||||
mean += delta / count
|
||||
val delta2 = newValue - mean
|
||||
m2 += delta * delta2
|
||||
}
|
||||
|
||||
val variance: Float
|
||||
get() = if (count < 1) {
|
||||
Float.NaN
|
||||
} else {
|
||||
m2 / count
|
||||
}
|
||||
val sampleVariance: Float
|
||||
get() = if (count < 2) {
|
||||
Float.NaN
|
||||
} else {
|
||||
m2 / (count - 1)
|
||||
}
|
||||
val standardDeviation: Float
|
||||
get() = sqrt(variance)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package dev.slimevr.autobone.errors
|
||||
|
||||
class AutoBoneException : Exception {
|
||||
constructor()
|
||||
constructor(message: String?) : super(message)
|
||||
constructor(cause: Throwable?) : super(cause)
|
||||
constructor(message: String?, cause: Throwable?) : super(message, cause)
|
||||
constructor(
|
||||
message: String?,
|
||||
cause: Throwable?,
|
||||
enableSuppression: Boolean,
|
||||
writableStackTrace: Boolean,
|
||||
) : super(message, cause, enableSuppression, writableStackTrace)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package dev.slimevr.autobone.errors
|
||||
|
||||
import dev.slimevr.autobone.AutoBoneStep
|
||||
import dev.slimevr.autobone.PoseFrameStep
|
||||
import dev.slimevr.autobone.errors.proportions.ProportionLimiter
|
||||
import dev.slimevr.tracking.processor.HumanPoseManager
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigManager
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
|
||||
import kotlin.math.*
|
||||
|
||||
// The distance from average human proportions
|
||||
class BodyProportionError : IAutoBoneError {
|
||||
@Throws(AutoBoneException::class)
|
||||
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float = getBodyProportionError(
|
||||
step.skeleton1,
|
||||
// Skeletons are now normalized to reduce bias, so height is always 1
|
||||
1f,
|
||||
)
|
||||
|
||||
fun getBodyProportionError(humanPoseManager: HumanPoseManager, fullHeight: Float): Float {
|
||||
var sum = 0f
|
||||
for (limiter in proportionLimits) {
|
||||
sum += abs(limiter.getProportionError(humanPoseManager, fullHeight))
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The headset height is not the full height! This value compensates for the
|
||||
// offset from the headset height to the user full height
|
||||
// From Drillis and Contini (1966)
|
||||
@JvmField
|
||||
var eyeHeightToHeightRatio = 0.936f
|
||||
|
||||
val defaultHeight = SkeletonConfigManager.HEIGHT_OFFSETS.sumOf {
|
||||
it.defaultValue.toDouble()
|
||||
}.toFloat()
|
||||
|
||||
private fun makeLimiter(
|
||||
offset: SkeletonConfigOffsets,
|
||||
range: Float,
|
||||
scaleByHeight: Boolean = true,
|
||||
) = ProportionLimiter(
|
||||
if (scaleByHeight) {
|
||||
offset.defaultValue / defaultHeight
|
||||
} else {
|
||||
offset.defaultValue
|
||||
},
|
||||
offset,
|
||||
range,
|
||||
scaleByHeight,
|
||||
)
|
||||
|
||||
// "Expected" are values from Drillis and Contini (1966)
|
||||
// Default are values from experimentation by the SlimeVR community
|
||||
|
||||
/**
|
||||
* Proportions are based off the headset height (or eye height), not the total height of the user.
|
||||
* To use the total height of the user, multiply it by [eyeHeightToHeightRatio] and use that in the limiters.
|
||||
*/
|
||||
val proportionLimits = arrayOf<ProportionLimiter>(
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.HEAD,
|
||||
0.01f,
|
||||
scaleByHeight = false,
|
||||
),
|
||||
// Expected: 0.052
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.NECK,
|
||||
0.002f,
|
||||
),
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.SHOULDERS_WIDTH,
|
||||
0.04f,
|
||||
scaleByHeight = false,
|
||||
),
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.UPPER_ARM,
|
||||
0.02f,
|
||||
),
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.LOWER_ARM,
|
||||
0.02f,
|
||||
),
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.UPPER_CHEST,
|
||||
0.01f,
|
||||
),
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.CHEST,
|
||||
0.01f,
|
||||
),
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.WAIST,
|
||||
0.05f,
|
||||
),
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.HIP,
|
||||
0.01f,
|
||||
),
|
||||
// Expected: 0.191
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.HIPS_WIDTH,
|
||||
0.04f,
|
||||
scaleByHeight = false,
|
||||
),
|
||||
// Expected: 0.245
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.UPPER_LEG,
|
||||
0.02f,
|
||||
),
|
||||
// Expected: 0.246 (0.285 including below ankle, could use a separate
|
||||
// offset?)
|
||||
makeLimiter(
|
||||
SkeletonConfigOffsets.LOWER_LEG,
|
||||
0.02f,
|
||||
),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
val proportionLimitMap = proportionLimits.associateBy { it.skeletonConfigOffset }
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package dev.slimevr.autobone.errors
|
||||
|
||||
import dev.slimevr.autobone.AutoBoneStep
|
||||
import dev.slimevr.autobone.PoseFrameStep
|
||||
import dev.slimevr.tracking.processor.BoneType
|
||||
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
|
||||
import io.github.axisangles.ktmath.Vector3
|
||||
import kotlin.math.*
|
||||
|
||||
// The offset between the height both feet at one instant and over time
|
||||
class FootHeightOffsetError : IAutoBoneError {
|
||||
@Throws(AutoBoneException::class)
|
||||
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float = getSlideError(
|
||||
step.skeleton1.skeleton,
|
||||
step.skeleton2.skeleton,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun getSlideError(skeleton1: HumanSkeleton, skeleton2: HumanSkeleton): Float = getFootHeightError(
|
||||
skeleton1.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(),
|
||||
skeleton1.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(),
|
||||
skeleton2.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(),
|
||||
skeleton2.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(),
|
||||
)
|
||||
|
||||
fun getFootHeightError(
|
||||
leftFoot1: Vector3,
|
||||
rightFoot1: Vector3,
|
||||
leftFoot2: Vector3,
|
||||
rightFoot2: Vector3,
|
||||
): Float {
|
||||
val lFoot1Y = leftFoot1.y
|
||||
val rFoot1Y = rightFoot1.y
|
||||
val lFoot2Y = leftFoot2.y
|
||||
val rFoot2Y = rightFoot2.y
|
||||
|
||||
// Compute all combinations of heights
|
||||
val dist1 = abs(lFoot1Y - rFoot1Y)
|
||||
val dist2 = abs(lFoot1Y - lFoot2Y)
|
||||
val dist3 = abs(lFoot1Y - rFoot2Y)
|
||||
val dist4 = abs(rFoot1Y - lFoot2Y)
|
||||
val dist5 = abs(rFoot1Y - rFoot2Y)
|
||||
val dist6 = abs(lFoot2Y - rFoot2Y)
|
||||
|
||||
// Divide by 12 (6 values * 2 to halve) to halve and average, it's
|
||||
// halved because you want to approach a midpoint, not the other point
|
||||
return (dist1 + dist2 + dist3 + dist4 + dist5 + dist6) / 12f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package dev.slimevr.autobone.errors
|
||||
|
||||
import dev.slimevr.autobone.AutoBoneStep
|
||||
import dev.slimevr.autobone.PoseFrameStep
|
||||
import kotlin.math.*
|
||||
|
||||
// The difference from the current height to the target height
|
||||
class HeightError : IAutoBoneError {
|
||||
@Throws(AutoBoneException::class)
|
||||
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float = getHeightError(
|
||||
step.data.hmdHeight,
|
||||
step.data.targetHmdHeight,
|
||||
)
|
||||
|
||||
fun getHeightError(currentHeight: Float, targetHeight: Float): Float = abs(targetHeight - currentHeight)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package dev.slimevr.autobone.errors
|
||||
|
||||
import dev.slimevr.autobone.AutoBoneStep
|
||||
import dev.slimevr.autobone.PoseFrameStep
|
||||
|
||||
interface IAutoBoneError {
|
||||
@Throws(AutoBoneException::class)
|
||||
fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package dev.slimevr.autobone.errors
|
||||
|
||||
import dev.slimevr.autobone.AutoBoneStep
|
||||
import dev.slimevr.autobone.PoseFrameStep
|
||||
import dev.slimevr.tracking.processor.BoneType
|
||||
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
|
||||
import io.github.axisangles.ktmath.Vector3
|
||||
import kotlin.math.*
|
||||
|
||||
// The change in distance between both of the ankles over time
|
||||
class OffsetSlideError : IAutoBoneError {
|
||||
@Throws(AutoBoneException::class)
|
||||
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float = getSlideError(
|
||||
step.skeleton1.skeleton,
|
||||
step.skeleton2.skeleton,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun getSlideError(skeleton1: HumanSkeleton, skeleton2: HumanSkeleton): Float = getSlideError(
|
||||
skeleton1.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(),
|
||||
skeleton1.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(),
|
||||
skeleton2.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(),
|
||||
skeleton2.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(),
|
||||
)
|
||||
|
||||
fun getSlideError(
|
||||
leftFoot1: Vector3,
|
||||
rightFoot1: Vector3,
|
||||
leftFoot2: Vector3,
|
||||
rightFoot2: Vector3,
|
||||
): Float {
|
||||
val slideDist1 = (rightFoot1 - leftFoot1).len()
|
||||
val slideDist2 = (rightFoot2 - leftFoot2).len()
|
||||
val slideDist3 = (rightFoot2 - leftFoot1).len()
|
||||
val slideDist4 = (rightFoot1 - leftFoot2).len()
|
||||
|
||||
// Compute all combinations of distances
|
||||
val dist1 = abs(slideDist1 - slideDist2)
|
||||
val dist2 = abs(slideDist1 - slideDist3)
|
||||
val dist3 = abs(slideDist1 - slideDist4)
|
||||
val dist4 = abs(slideDist2 - slideDist3)
|
||||
val dist5 = abs(slideDist2 - slideDist4)
|
||||
val dist6 = abs(slideDist3 - slideDist4)
|
||||
|
||||
// Divide by 12 (6 values * 2 to halve) to halve and average, it's
|
||||
// halved because you want to approach a midpoint, not the other point
|
||||
return (dist1 + dist2 + dist3 + dist4 + dist5 + dist6) / 12f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package dev.slimevr.autobone.errors
|
||||
|
||||
import dev.slimevr.autobone.AutoBoneStep
|
||||
import dev.slimevr.autobone.PoseFrameStep
|
||||
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
|
||||
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
|
||||
|
||||
// The distance of any points to the corresponding absolute position
|
||||
class PositionError : IAutoBoneError {
|
||||
@Throws(AutoBoneException::class)
|
||||
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float {
|
||||
val trackers = step.frames.frameHolders
|
||||
return (
|
||||
(
|
||||
getPositionError(
|
||||
trackers,
|
||||
step.cursor1,
|
||||
step.skeleton1.skeleton,
|
||||
) +
|
||||
getPositionError(
|
||||
trackers,
|
||||
step.cursor2,
|
||||
step.skeleton2.skeleton,
|
||||
)
|
||||
) /
|
||||
2f
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getPositionError(
|
||||
trackers: List<TrackerFrames>,
|
||||
cursor: Int,
|
||||
skeleton: HumanSkeleton,
|
||||
): Float {
|
||||
var offset = 0f
|
||||
var offsetCount = 0
|
||||
for (tracker in trackers) {
|
||||
val trackerFrame = tracker.tryGetFrame(cursor) ?: continue
|
||||
val position = trackerFrame.tryGetPosition() ?: continue
|
||||
val trackerRole = trackerFrame.tryGetTrackerPosition()?.trackerRole ?: continue
|
||||
|
||||
try {
|
||||
val computedTracker = skeleton.getComputedTracker(trackerRole)
|
||||
|
||||
offset += (position - computedTracker.position).len()
|
||||
offsetCount++
|
||||
} catch (_: Exception) {
|
||||
// Ignore unsupported positions
|
||||
}
|
||||
}
|
||||
return if (offsetCount > 0) offset / offsetCount else 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package dev.slimevr.autobone.errors
|
||||
|
||||
import dev.slimevr.autobone.AutoBoneStep
|
||||
import dev.slimevr.autobone.PoseFrameStep
|
||||
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
|
||||
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
|
||||
import kotlin.math.*
|
||||
|
||||
// The difference between offset of absolute position and the corresponding point over time
|
||||
class PositionOffsetError : IAutoBoneError {
|
||||
@Throws(AutoBoneException::class)
|
||||
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float {
|
||||
val trackers = step.frames.frameHolders
|
||||
return getPositionOffsetError(
|
||||
trackers,
|
||||
step.cursor1,
|
||||
step.cursor2,
|
||||
step.skeleton1.skeleton,
|
||||
step.skeleton2.skeleton,
|
||||
)
|
||||
}
|
||||
|
||||
fun getPositionOffsetError(
|
||||
trackers: List<TrackerFrames>,
|
||||
cursor1: Int,
|
||||
cursor2: Int,
|
||||
skeleton1: HumanSkeleton,
|
||||
skeleton2: HumanSkeleton,
|
||||
): Float {
|
||||
var offset = 0f
|
||||
var offsetCount = 0
|
||||
for (tracker in trackers) {
|
||||
val trackerFrame1 = tracker.tryGetFrame(cursor1) ?: continue
|
||||
val position1 = trackerFrame1.tryGetPosition() ?: continue
|
||||
val trackerRole1 = trackerFrame1.tryGetTrackerPosition()?.trackerRole ?: continue
|
||||
|
||||
val trackerFrame2 = tracker.tryGetFrame(cursor2) ?: continue
|
||||
val position2 = trackerFrame2.tryGetPosition() ?: continue
|
||||
val trackerRole2 = trackerFrame2.tryGetTrackerPosition()?.trackerRole ?: continue
|
||||
|
||||
try {
|
||||
val computedTracker1 = skeleton1.getComputedTracker(trackerRole1)
|
||||
val computedTracker2 = skeleton2.getComputedTracker(trackerRole2)
|
||||
|
||||
val dist1 = (position1 - computedTracker1.position).len()
|
||||
val dist2 = (position2 - computedTracker2.position).len()
|
||||
offset += abs(dist2 - dist1)
|
||||
offsetCount++
|
||||
} catch (_: Exception) {
|
||||
// Ignore unsupported positions
|
||||
}
|
||||
}
|
||||
return if (offsetCount > 0) offset / offsetCount else 0f
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package dev.slimevr.autobone.errors
|
||||
|
||||
import dev.slimevr.autobone.AutoBoneStep
|
||||
import dev.slimevr.autobone.PoseFrameStep
|
||||
import dev.slimevr.tracking.processor.Bone
|
||||
import dev.slimevr.tracking.processor.BoneType
|
||||
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
|
||||
|
||||
// The change in position of the ankle over time
|
||||
class SlideError : IAutoBoneError {
|
||||
@Throws(AutoBoneException::class)
|
||||
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float = getSlideError(
|
||||
step.skeleton1.skeleton,
|
||||
step.skeleton2.skeleton,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun getSlideError(skeleton1: HumanSkeleton, skeleton2: HumanSkeleton): Float {
|
||||
// Calculate and average between both feet
|
||||
return (
|
||||
getSlideError(skeleton1, skeleton2, BoneType.LEFT_LOWER_LEG) +
|
||||
getSlideError(skeleton1, skeleton2, BoneType.RIGHT_LOWER_LEG)
|
||||
) /
|
||||
2f
|
||||
}
|
||||
|
||||
fun getSlideError(
|
||||
skeleton1: HumanSkeleton,
|
||||
skeleton2: HumanSkeleton,
|
||||
bone: BoneType,
|
||||
): Float {
|
||||
// Calculate and average between both feet
|
||||
return getSlideError(
|
||||
skeleton1.getBone(bone),
|
||||
skeleton2.getBone(bone),
|
||||
)
|
||||
}
|
||||
|
||||
fun getSlideError(bone1: Bone, bone2: Bone): Float {
|
||||
// Return the midpoint distance
|
||||
return (bone2.getTailPosition() - bone1.getTailPosition()).len() / 2f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package dev.slimevr.autobone.errors.proportions
|
||||
|
||||
import dev.slimevr.tracking.processor.HumanPoseManager
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
|
||||
import kotlin.math.*
|
||||
|
||||
class ProportionLimiter {
|
||||
val targetRatio: Float
|
||||
val skeletonConfigOffset: SkeletonConfigOffsets
|
||||
val scaleByHeight: Boolean
|
||||
|
||||
val positiveRange: Float
|
||||
val negativeRange: Float
|
||||
|
||||
/**
|
||||
* @param targetRatio The bone to height ratio to target
|
||||
* @param skeletonConfigOffset The SkeletonConfigOffset to use for the length
|
||||
* @param range The range from the target ratio to accept (ex. 0.1)
|
||||
* @param scaleByHeight True if the bone length will be scaled by the height
|
||||
*/
|
||||
constructor(
|
||||
targetRatio: Float,
|
||||
skeletonConfigOffset: SkeletonConfigOffsets,
|
||||
range: Float,
|
||||
scaleByHeight: Boolean = true,
|
||||
) {
|
||||
this.targetRatio = targetRatio
|
||||
this.skeletonConfigOffset = skeletonConfigOffset
|
||||
this.scaleByHeight = scaleByHeight
|
||||
|
||||
// Handle if someone puts in a negative value
|
||||
val absRange = abs(range)
|
||||
positiveRange = absRange
|
||||
negativeRange = -absRange
|
||||
}
|
||||
|
||||
/**
|
||||
* @param targetRatio The bone to height ratio to target
|
||||
* @param skeletonConfigOffset The SkeletonConfigOffset to use for the length
|
||||
* @param positiveRange The positive range from the target ratio to accept
|
||||
* (ex. 0.1)
|
||||
* @param negativeRange The negative range from the target ratio to accept
|
||||
* (ex. -0.1)
|
||||
* @param scaleByHeight True if the bone length will be scaled by the height
|
||||
*/
|
||||
constructor(
|
||||
targetRatio: Float,
|
||||
skeletonConfigOffset: SkeletonConfigOffsets,
|
||||
positiveRange: Float,
|
||||
negativeRange: Float,
|
||||
scaleByHeight: Boolean = true,
|
||||
) {
|
||||
// If the positive range is less than the negative range, something is wrong
|
||||
require(positiveRange >= negativeRange) { "positiveRange must not be less than negativeRange" }
|
||||
|
||||
this.targetRatio = targetRatio
|
||||
this.skeletonConfigOffset = skeletonConfigOffset
|
||||
this.scaleByHeight = scaleByHeight
|
||||
|
||||
this.positiveRange = positiveRange
|
||||
this.negativeRange = negativeRange
|
||||
}
|
||||
|
||||
fun getProportionError(humanPoseManager: HumanPoseManager, height: Float): Float {
|
||||
val boneLength = humanPoseManager.getOffset(skeletonConfigOffset)
|
||||
val ratioOffset = if (scaleByHeight) {
|
||||
targetRatio - boneLength / height
|
||||
} else {
|
||||
targetRatio - boneLength
|
||||
}
|
||||
|
||||
// If the range is exceeded, return the offset from the range limit
|
||||
if (ratioOffset > positiveRange) {
|
||||
return ratioOffset - positiveRange
|
||||
} else if (ratioOffset < negativeRange) {
|
||||
return ratioOffset - negativeRange
|
||||
}
|
||||
return 0f
|
||||
}
|
||||
}
|
||||
18
server/core/src/main/java/dev/slimevr/behaviours.kt
Normal file
18
server/core/src/main/java/dev/slimevr/behaviours.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package dev.slimevr
|
||||
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
object BaseBehaviour : VRServerBehaviour {
|
||||
override fun reduce(state: VRServerState, action: VRServerActions) = when (action) {
|
||||
is VRServerActions.NewTracker -> state.copy(trackers = state.trackers + (action.trackerId to action.context))
|
||||
is VRServerActions.NewDevice -> state.copy(devices = state.devices + (action.deviceId to action.context))
|
||||
}
|
||||
|
||||
override fun observe(receiver: VRServer) {
|
||||
receiver.context.state.distinctUntilChangedBy { it.trackers.size }.onEach {
|
||||
println("tracker list size changed")
|
||||
}.launchIn(receiver.context.scope)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package dev.slimevr.bridge
|
||||
|
||||
import dev.slimevr.tracking.trackers.Tracker
|
||||
import dev.slimevr.tracking.trackers.TrackerRole
|
||||
import dev.slimevr.util.ann.VRServerThread
|
||||
|
||||
/**
|
||||
* Bridge handles sending and receiving tracker data between SlimeVR and other
|
||||
* systems like VR APIs (SteamVR, OpenXR, etc), apps and protocols (VMC,
|
||||
* WebSocket, TIP). It can create and manage tracker received from the **remote
|
||||
* side** or send shared **local trackers** to the other side.
|
||||
*/
|
||||
interface Bridge {
|
||||
@VRServerThread
|
||||
fun dataRead()
|
||||
|
||||
@VRServerThread
|
||||
fun dataWrite()
|
||||
|
||||
/**
|
||||
* Adds shared tracker to the bridge. Bridge should notify the other side of
|
||||
* this tracker, if it's the type of tracker this bridge serves, and start
|
||||
* sending data each update
|
||||
*
|
||||
* @param tracker
|
||||
*/
|
||||
@VRServerThread
|
||||
fun addSharedTracker(tracker: Tracker?)
|
||||
|
||||
/**
|
||||
* Removes tracker from a bridge. If the other side supports tracker
|
||||
* removal, bridge should notify it and stop sending new data. If it doesn't
|
||||
* support tracker removal, the bridge can either stop sending new data, or
|
||||
* keep sending it if it's available.
|
||||
*
|
||||
* @param tracker
|
||||
*/
|
||||
@VRServerThread
|
||||
fun removeSharedTracker(tracker: Tracker?)
|
||||
|
||||
@VRServerThread
|
||||
fun startBridge()
|
||||
|
||||
fun isConnected(): Boolean
|
||||
}
|
||||
|
||||
interface ISteamVRBridge : Bridge {
|
||||
fun getShareSetting(role: TrackerRole): Boolean
|
||||
|
||||
fun changeShareSettings(role: TrackerRole?, share: Boolean)
|
||||
|
||||
fun updateShareSettingsAutomatically(): Boolean
|
||||
|
||||
fun getAutomaticSharedTrackers(): Boolean
|
||||
fun setAutomaticSharedTrackers(value: Boolean)
|
||||
|
||||
fun getBridgeConfigKey(): String
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package dev.slimevr.bridge;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
|
||||
@Retention(value = RetentionPolicy.SOURCE)
|
||||
public @interface BridgeThread {
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
class AutoBoneConfig {
|
||||
var cursorIncrement = 2
|
||||
var minDataDistance = 1
|
||||
var maxDataDistance = 1
|
||||
var numEpochs = 50
|
||||
var printEveryNumEpochs = 25
|
||||
var initialAdjustRate = 10.0f
|
||||
var adjustRateDecay = 1.0f
|
||||
var slideErrorFactor = 1.0f
|
||||
var offsetSlideErrorFactor = 0.0f
|
||||
var footHeightOffsetErrorFactor = 0.0f
|
||||
var bodyProportionErrorFactor = 0.05f
|
||||
var heightErrorFactor = 0.0f
|
||||
var positionErrorFactor = 0.0f
|
||||
var positionOffsetErrorFactor = 0.0f
|
||||
var calcInitError = false
|
||||
var randomizeFrameOrder = true
|
||||
var scaleEachStep = true
|
||||
var sampleCount = 1500
|
||||
var sampleRateMs = 20L
|
||||
var saveRecordings = false
|
||||
var useSkeletonHeight = false
|
||||
var randSeed = 4L
|
||||
var useFrameFiltering = false
|
||||
var maxFinalError = 0.03f
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package dev.slimevr.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdKeySerializers;
|
||||
import dev.slimevr.config.serializers.BooleanMapDeserializer;
|
||||
import dev.slimevr.tracking.trackers.TrackerRole;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
public class BridgeConfig {
|
||||
|
||||
@JsonDeserialize(using = BooleanMapDeserializer.class)
|
||||
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class)
|
||||
public Map<String, Boolean> trackers = new HashMap<>();
|
||||
public boolean automaticSharedTrackersToggling = true;
|
||||
|
||||
public BridgeConfig() {
|
||||
}
|
||||
|
||||
public boolean getBridgeTrackerRole(TrackerRole role, boolean def) {
|
||||
return trackers.getOrDefault(role.name().toLowerCase(), def);
|
||||
}
|
||||
|
||||
public void setBridgeTrackerRole(TrackerRole role, boolean val) {
|
||||
this.trackers.put(role.name().toLowerCase(), val);
|
||||
}
|
||||
|
||||
public Map<String, Boolean> getTrackers() {
|
||||
return trackers;
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package dev.slimevr.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
|
||||
import com.github.jonpeterson.jackson.module.versioning.VersioningModule;
|
||||
import dev.slimevr.config.serializers.QuaternionDeserializer;
|
||||
import dev.slimevr.config.serializers.QuaternionSerializer;
|
||||
import io.eiren.util.ann.ThreadSafe;
|
||||
import io.eiren.util.logging.LogManager;
|
||||
import io.github.axisangles.ktmath.ObjectQuaternion;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Comparator;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
|
||||
public class ConfigManager {
|
||||
|
||||
private final String configPath;
|
||||
|
||||
private final ObjectMapper om;
|
||||
|
||||
private VRConfig vrConfig;
|
||||
|
||||
|
||||
public ConfigManager(String configPath) {
|
||||
this.configPath = configPath;
|
||||
om = new ObjectMapper(new YAMLFactory().disable(YAMLGenerator.Feature.SPLIT_LINES));
|
||||
om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
om.registerModule(new VersioningModule());
|
||||
SimpleModule quaternionModule = new SimpleModule();
|
||||
quaternionModule.addSerializer(ObjectQuaternion.class, new QuaternionSerializer());
|
||||
quaternionModule.addDeserializer(ObjectQuaternion.class, new QuaternionDeserializer());
|
||||
om.registerModule(quaternionModule);
|
||||
}
|
||||
|
||||
public void loadConfig() {
|
||||
try {
|
||||
this.vrConfig = om
|
||||
.readValue(new FileInputStream(configPath), VRConfig.class);
|
||||
} catch (FileNotFoundException e) {
|
||||
// Config file didn't exist, is not an error
|
||||
} catch (IOException e) {
|
||||
// Log the exception
|
||||
LogManager.severe("Config failed to load: " + e);
|
||||
// Make a backup of the erroneous config
|
||||
backupConfig();
|
||||
}
|
||||
|
||||
if (this.vrConfig == null) {
|
||||
this.vrConfig = new VRConfig();
|
||||
}
|
||||
}
|
||||
|
||||
static public void atomicMove(Path from, Path to) throws IOException {
|
||||
try {
|
||||
// Atomic move to overwrite
|
||||
Files.move(from, to, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException | FileAlreadyExistsException e) {
|
||||
// Atomic move not supported or does not replace, try just replacing
|
||||
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
public void backupConfig() {
|
||||
Path cfgFile = Paths.get(configPath);
|
||||
Path tmpBakCfgFile = Paths.get(configPath + ".bak.tmp");
|
||||
Path bakCfgFile = Paths.get(configPath + ".bak");
|
||||
|
||||
try {
|
||||
Files
|
||||
.copy(
|
||||
cfgFile,
|
||||
tmpBakCfgFile,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.COPY_ATTRIBUTES
|
||||
);
|
||||
LogManager.info("Made a backup copy of config to \"" + tmpBakCfgFile + "\"");
|
||||
} catch (IOException e) {
|
||||
LogManager
|
||||
.severe(
|
||||
"Unable to make backup copy of config from \""
|
||||
+ cfgFile
|
||||
+ "\" to \""
|
||||
+ tmpBakCfgFile
|
||||
+ "\"",
|
||||
e
|
||||
);
|
||||
return; // Abort write
|
||||
}
|
||||
|
||||
try {
|
||||
atomicMove(tmpBakCfgFile, bakCfgFile);
|
||||
} catch (IOException e) {
|
||||
LogManager
|
||||
.severe(
|
||||
"Unable to move backup config from \""
|
||||
+ tmpBakCfgFile
|
||||
+ "\" to \""
|
||||
+ bakCfgFile
|
||||
+ "\"",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ThreadSafe
|
||||
public synchronized void saveConfig() {
|
||||
Path tmpCfgFile = Paths.get(configPath + ".tmp");
|
||||
Path cfgFile = Paths.get(configPath);
|
||||
|
||||
// Serialize config
|
||||
try {
|
||||
// delete accidental folder caused by PR
|
||||
// https://github.com/SlimeVR/SlimeVR-Server/pull/1176
|
||||
var cfgFileMaybeFolder = cfgFile.toFile();
|
||||
if (cfgFileMaybeFolder.isDirectory()) {
|
||||
try (Stream<Path> pathStream = Files.walk(cfgFile)) {
|
||||
// Can't use .toList() on Android
|
||||
var list = pathStream
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.collect(Collectors.toList());
|
||||
for (var path : list) {
|
||||
Files.delete(path);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LogManager
|
||||
.severe(
|
||||
"Unable to delete folder that has same name as the config file on path \""
|
||||
+ cfgFile
|
||||
+ "\""
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
var cfgFolder = cfgFile.toAbsolutePath().getParent().toFile();
|
||||
if (!cfgFolder.exists() && !cfgFolder.mkdirs()) {
|
||||
LogManager
|
||||
.severe("Unable to create folders for config on path \"" + cfgFile + "\"");
|
||||
return;
|
||||
}
|
||||
om.writeValue(tmpCfgFile.toFile(), this.vrConfig);
|
||||
} catch (IOException e) {
|
||||
LogManager.severe("Unable to write serialized config to \"" + tmpCfgFile + "\"", e);
|
||||
return; // Abort write
|
||||
}
|
||||
|
||||
// Overwrite old config
|
||||
try {
|
||||
atomicMove(tmpCfgFile, cfgFile);
|
||||
} catch (IOException e) {
|
||||
LogManager
|
||||
.severe(
|
||||
"Unable to move new config from \"" + tmpCfgFile + "\" to \"" + cfgFile + "\"",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public void resetConfig() {
|
||||
this.vrConfig = new VRConfig();
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
public VRConfig getVrConfig() {
|
||||
return vrConfig;
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
package dev.slimevr.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.*;
|
||||
import com.github.jonpeterson.jackson.module.versioning.VersionedModelConverter;
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets;
|
||||
import dev.slimevr.tracking.trackers.TrackerPosition;
|
||||
import io.eiren.util.logging.LogManager;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
public class CurrentVRConfigConverter implements VersionedModelConverter {
|
||||
|
||||
@Override
|
||||
public ObjectNode convert(
|
||||
ObjectNode modelData,
|
||||
String modelVersion,
|
||||
String targetModelVersion,
|
||||
JsonNodeFactory nodeFactory
|
||||
) {
|
||||
try {
|
||||
int version = Integer.parseInt(modelVersion);
|
||||
|
||||
// Configs with old versions need a migration to the latest config
|
||||
if (version < 2) {
|
||||
// Move zoom to the window config
|
||||
ObjectNode windowNode = (ObjectNode) modelData.get("window");
|
||||
DoubleNode zoomNode = (DoubleNode) modelData.get("zoom");
|
||||
if (windowNode != null && zoomNode != null) {
|
||||
windowNode.set("zoom", zoomNode);
|
||||
modelData.remove("zoom");
|
||||
}
|
||||
|
||||
// Change trackers list to map
|
||||
ArrayNode oldTrackersNode = modelData.withArray("trackers");
|
||||
if (oldTrackersNode != null) {
|
||||
var trackersIter = oldTrackersNode.iterator();
|
||||
ObjectNode trackersNode = nodeFactory.objectNode();
|
||||
while (trackersIter.hasNext()) {
|
||||
JsonNode node = trackersIter.next();
|
||||
JsonNode resultNode = TrackerConfig.toV2(node, nodeFactory);
|
||||
trackersNode.set(node.get("name").asText(), resultNode);
|
||||
}
|
||||
modelData.set("trackers", trackersNode);
|
||||
}
|
||||
|
||||
// Rename bridge to bridges
|
||||
ObjectNode bridgeNode = (ObjectNode) modelData.get("bridge");
|
||||
if (bridgeNode != null) {
|
||||
modelData.set("bridges", bridgeNode);
|
||||
modelData.remove("bridge");
|
||||
}
|
||||
|
||||
// Move body to skeleton (and merge it to current skeleton)
|
||||
ObjectNode bodyNode = (ObjectNode) modelData.get("body");
|
||||
if (bodyNode != null) {
|
||||
var bodyIter = bodyNode.fields();
|
||||
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
|
||||
if (skeletonNode == null) {
|
||||
skeletonNode = nodeFactory.objectNode();
|
||||
}
|
||||
|
||||
ObjectNode offsetsNode = nodeFactory.objectNode();
|
||||
while (bodyIter.hasNext()) {
|
||||
Map.Entry<String, JsonNode> node = bodyIter.next();
|
||||
// Filter only number values because other types would
|
||||
// be stuff that didn't get migrated correctly before
|
||||
if (node.getValue().isNumber()) {
|
||||
offsetsNode.set(node.getKey(), node.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
// Fix calibration wolf typos
|
||||
offsetsNode.set("shouldersWidth", bodyNode.get("shoulersWidth"));
|
||||
offsetsNode.set("shouldersDistance", bodyNode.get("shoulersDistance"));
|
||||
offsetsNode.remove("shoulersWidth");
|
||||
offsetsNode.remove("shoulersDistance");
|
||||
skeletonNode.set("offsets", offsetsNode);
|
||||
modelData.set("skeleton", skeletonNode);
|
||||
modelData.remove("body");
|
||||
}
|
||||
}
|
||||
if (version < 3) {
|
||||
// Check for out-of-bound filtering amount
|
||||
ObjectNode filtersNode = (ObjectNode) modelData.get("filters");
|
||||
if (filtersNode != null && filtersNode.get("amount").floatValue() > 2f) {
|
||||
filtersNode.set("amount", new FloatNode(0.2f));
|
||||
}
|
||||
}
|
||||
if (version < 4) {
|
||||
// Change mountingRotation to mountingOrientation
|
||||
ObjectNode oldTrackersNode = (ObjectNode) modelData.get("trackers");
|
||||
if (oldTrackersNode != null) {
|
||||
var trackersIter = oldTrackersNode.iterator();
|
||||
var fieldNamesIter = oldTrackersNode.fieldNames();
|
||||
ObjectNode trackersNode = nodeFactory.objectNode();
|
||||
String fieldName;
|
||||
while (trackersIter.hasNext()) {
|
||||
ObjectNode node = (ObjectNode) trackersIter.next();
|
||||
fieldName = fieldNamesIter.next();
|
||||
node.set("mountingOrientation", node.get("mountingRotation"));
|
||||
node.remove("mountingRotation");
|
||||
trackersNode.set(fieldName, node);
|
||||
}
|
||||
modelData.set("trackers", trackersNode);
|
||||
}
|
||||
}
|
||||
if (version < 5) {
|
||||
// Migrate old skeleton offsets to new ones
|
||||
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
|
||||
if (skeletonNode != null) {
|
||||
ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets");
|
||||
if (offsetsNode != null) {
|
||||
// torsoLength, chestDistance and waistDistance become
|
||||
// chestLength, waistLength and hipLength.
|
||||
float torsoLength = SkeletonConfigOffsets.CHEST.defaultValue
|
||||
+ SkeletonConfigOffsets.WAIST.defaultValue
|
||||
+ SkeletonConfigOffsets.HIP.defaultValue;
|
||||
float chestDistance = SkeletonConfigOffsets.CHEST.defaultValue;
|
||||
float waistDistance = SkeletonConfigOffsets.HIP.defaultValue;
|
||||
JsonNode torsoNode = offsetsNode.get("torsoLength");
|
||||
if (torsoNode != null)
|
||||
torsoLength = torsoNode.floatValue();
|
||||
JsonNode chestNode = offsetsNode.get("chestDistance");
|
||||
if (chestNode != null)
|
||||
chestDistance = chestNode.floatValue();
|
||||
JsonNode waistNode = offsetsNode.get("waistDistance");
|
||||
if (waistNode != null)
|
||||
waistDistance = waistNode.floatValue();
|
||||
offsetsNode.set("chestLength", offsetsNode.get("chestDistance"));
|
||||
offsetsNode
|
||||
.set(
|
||||
"waistLength",
|
||||
new FloatNode(torsoLength - chestDistance - waistDistance)
|
||||
);
|
||||
offsetsNode.set("hipLength", offsetsNode.get("waistDistance"));
|
||||
offsetsNode.remove("torsoLength");
|
||||
offsetsNode.remove("chestDistance");
|
||||
offsetsNode.remove("waistDistance");
|
||||
|
||||
// legsLength and kneeHeight become
|
||||
// upperLegLength and lowerLegLength
|
||||
float legsLength = SkeletonConfigOffsets.UPPER_LEG.defaultValue
|
||||
+ SkeletonConfigOffsets.LOWER_LEG.defaultValue;
|
||||
float kneeHeight = SkeletonConfigOffsets.LOWER_LEG.defaultValue;
|
||||
JsonNode legsNode = offsetsNode.get("legsLength");
|
||||
if (legsNode != null)
|
||||
legsLength = legsNode.floatValue();
|
||||
JsonNode kneesNode = offsetsNode.get("kneeHeight");
|
||||
if (kneesNode != null)
|
||||
kneeHeight = kneesNode.floatValue();
|
||||
offsetsNode.set("upperLegLength", new FloatNode(legsLength - kneeHeight));
|
||||
offsetsNode.set("lowerLegLength", new FloatNode(kneeHeight));
|
||||
offsetsNode.remove("legsLength");
|
||||
offsetsNode.remove("kneeHeight");
|
||||
|
||||
skeletonNode.set("offsets", offsetsNode);
|
||||
modelData.set("skeleton", skeletonNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (version < 6) {
|
||||
// Migrate controllers offsets to hands offsets
|
||||
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
|
||||
if (skeletonNode != null) {
|
||||
ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets");
|
||||
if (offsetsNode != null) {
|
||||
offsetsNode.set("handDistanceY", offsetsNode.get("controllerDistanceY"));
|
||||
offsetsNode.set("handDistanceZ", offsetsNode.get("controllerDistanceZ"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (version < 7) {
|
||||
// Chest, hip, and elbow offsets now go the opposite direction
|
||||
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
|
||||
if (skeletonNode != null) {
|
||||
ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets");
|
||||
if (offsetsNode != null) {
|
||||
JsonNode chestNode = offsetsNode.get("chestOffset");
|
||||
if (chestNode != null)
|
||||
offsetsNode.set("chestOffset", new FloatNode(-chestNode.floatValue()));
|
||||
JsonNode hipNode = offsetsNode.get("hipOffset");
|
||||
if (hipNode != null)
|
||||
offsetsNode.set("hipOffset", new FloatNode(-hipNode.floatValue()));
|
||||
JsonNode elbowNode = offsetsNode.get("elbowOffset");
|
||||
if (elbowNode != null)
|
||||
offsetsNode.set("elbowOffset", new FloatNode(-elbowNode.floatValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (version < 8) {
|
||||
// reset > fullReset, quickReset > yawReset
|
||||
ObjectNode keybindingsNode = (ObjectNode) modelData.get("keybindings");
|
||||
if (keybindingsNode != null) {
|
||||
JsonNode fullResetNode = keybindingsNode.get("resetBinding");
|
||||
if (fullResetNode != null)
|
||||
keybindingsNode.set("fullResetBinding", fullResetNode);
|
||||
JsonNode yawResetNode = keybindingsNode.get("quickResetBinding");
|
||||
if (yawResetNode != null)
|
||||
keybindingsNode.set("yawResetBinding", yawResetNode);
|
||||
JsonNode mountingResetNode = keybindingsNode.get("resetMountingBinding");
|
||||
if (mountingResetNode != null)
|
||||
keybindingsNode.set("mountingResetBinding", mountingResetNode);
|
||||
|
||||
JsonNode fullDelayNode = keybindingsNode.get("resetDelay");
|
||||
if (fullDelayNode != null)
|
||||
keybindingsNode.set("fullResetDelay", fullDelayNode);
|
||||
JsonNode yawDelayNode = keybindingsNode.get("quickResetDelay");
|
||||
if (yawDelayNode != null)
|
||||
keybindingsNode.set("yawResetDelay", yawDelayNode);
|
||||
JsonNode mountingDelayNode = keybindingsNode.get("resetMountingDelay");
|
||||
if (mountingDelayNode != null)
|
||||
keybindingsNode.set("mountingResetDelay", mountingDelayNode);
|
||||
}
|
||||
|
||||
ObjectNode tapDetectionNode = (ObjectNode) modelData.get("tapDetection");
|
||||
if (tapDetectionNode != null) {
|
||||
tapDetectionNode.set("yawResetDelay", tapDetectionNode.get("quickResetDelay"));
|
||||
tapDetectionNode.set("fullResetDelay", tapDetectionNode.get("resetDelay"));
|
||||
tapDetectionNode
|
||||
.set("yawResetEnabled", tapDetectionNode.get("quickResetEnabled"));
|
||||
tapDetectionNode.set("fullResetEnabled", tapDetectionNode.get("resetEnabled"));
|
||||
tapDetectionNode.set("yawResetTaps", tapDetectionNode.get("quickResetTaps"));
|
||||
tapDetectionNode.set("fullResetTaps", tapDetectionNode.get("resetTaps"));
|
||||
}
|
||||
}
|
||||
if (version < 9) {
|
||||
// split chest into 2 offsets
|
||||
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
|
||||
if (skeletonNode != null) {
|
||||
ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets");
|
||||
if (offsetsNode != null) {
|
||||
JsonNode chestNode = offsetsNode.get("chestLength");
|
||||
if (chestNode != null) {
|
||||
offsetsNode
|
||||
.set("chestLength", new FloatNode(chestNode.floatValue() / 2f));
|
||||
offsetsNode
|
||||
.set(
|
||||
"upperChestLength",
|
||||
new FloatNode(chestNode.floatValue() / 2f)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (version < 10) {
|
||||
// Change default AutoBone recording length from 20 to 30
|
||||
// seconds
|
||||
ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone");
|
||||
if (autoBoneNode != null) {
|
||||
JsonNode sampleCountNode = autoBoneNode.get("sampleCount");
|
||||
if (sampleCountNode != null && sampleCountNode.intValue() == 1000) {
|
||||
autoBoneNode.set("sampleCount", new IntNode(1500));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (version < 11) {
|
||||
// Sets HMD's designation to "body:head"
|
||||
ObjectNode trackersNode = (ObjectNode) modelData.get("trackers");
|
||||
if (trackersNode != null) {
|
||||
ObjectNode HMDNode = (ObjectNode) trackersNode.get("HMD");
|
||||
if (HMDNode != null) {
|
||||
HMDNode
|
||||
.set(
|
||||
"designation",
|
||||
new TextNode(TrackerPosition.HEAD.getDesignation())
|
||||
);
|
||||
trackersNode.set("HMD", HMDNode);
|
||||
modelData.set("trackers", trackersNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (version < 12) {
|
||||
// Update AutoBone defaults
|
||||
ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone");
|
||||
if (autoBoneNode != null) {
|
||||
JsonNode offsetSlideNode = autoBoneNode.get("offsetSlideErrorFactor");
|
||||
if (offsetSlideNode != null && offsetSlideNode.floatValue() == 2.0f) {
|
||||
autoBoneNode.set("offsetSlideErrorFactor", new FloatNode(1.0f));
|
||||
}
|
||||
JsonNode bodyProportionsNode = autoBoneNode.get("bodyProportionErrorFactor");
|
||||
if (bodyProportionsNode != null && bodyProportionsNode.floatValue() == 0.825f) {
|
||||
autoBoneNode.set("bodyProportionErrorFactor", new FloatNode(0.25f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 13) {
|
||||
ObjectNode oldTrackersNode = (ObjectNode) modelData.get("trackers");
|
||||
if (oldTrackersNode != null) {
|
||||
var fieldNamesIter = oldTrackersNode.fieldNames();
|
||||
String trackerId;
|
||||
final String macAddressRegex = "udp://((?:[a-zA-Z\\d]{2}:){5}[a-zA-Z\\d]{2})/0";
|
||||
final Pattern pattern = Pattern.compile(macAddressRegex);
|
||||
while (fieldNamesIter.hasNext()) {
|
||||
trackerId = fieldNamesIter.next();
|
||||
var matcher = pattern.matcher(trackerId);
|
||||
if (!matcher.find())
|
||||
continue;
|
||||
|
||||
modelData.withArray("knownDevices").add(matcher.group(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 14) {
|
||||
// Update AutoBone defaults
|
||||
ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone");
|
||||
if (autoBoneNode != null) {
|
||||
// Move HMD height to skeleton
|
||||
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
|
||||
if (skeletonNode != null) {
|
||||
JsonNode targetHmdHeight = autoBoneNode.get("targetHmdHeight");
|
||||
if (targetHmdHeight != null) {
|
||||
skeletonNode.set("hmdHeight", targetHmdHeight);
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode offsetSlideNode = autoBoneNode.get("offsetSlideErrorFactor");
|
||||
JsonNode slideNode = autoBoneNode.get("slideErrorFactor");
|
||||
if (
|
||||
offsetSlideNode != null
|
||||
&& slideNode != null
|
||||
&& offsetSlideNode.floatValue() == 1.0f
|
||||
&& slideNode.floatValue() == 0.0f
|
||||
) {
|
||||
autoBoneNode.set("offsetSlideErrorFactor", new FloatNode(0.0f));
|
||||
autoBoneNode.set("slideErrorFactor", new FloatNode(1.0f));
|
||||
}
|
||||
JsonNode bodyProportionsNode = autoBoneNode.get("bodyProportionErrorFactor");
|
||||
if (bodyProportionsNode != null && bodyProportionsNode.floatValue() == 0.25f) {
|
||||
autoBoneNode.set("bodyProportionErrorFactor", new FloatNode(0.05f));
|
||||
}
|
||||
JsonNode numEpochsNode = autoBoneNode.get("numEpochs");
|
||||
if (numEpochsNode != null && numEpochsNode.intValue() == 100) {
|
||||
autoBoneNode.set("numEpochs", new IntNode(50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 15) {
|
||||
ObjectNode checklistNode = (ObjectNode) modelData.get("trackingChecklist");
|
||||
if (checklistNode != null) {
|
||||
ArrayNode ignoredStepsArray = (ArrayNode) checklistNode.get("ignoredStepsIds");
|
||||
if (ignoredStepsArray != null)
|
||||
ignoredStepsArray.removeAll();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LogManager.severe("Error during config migration: " + e);
|
||||
}
|
||||
|
||||
return modelData;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
|
||||
class DriftCompensationConfig {
|
||||
|
||||
// Is drift compensation enabled
|
||||
var enabled = false
|
||||
|
||||
// Is drift prediction enabled
|
||||
var prediction = false
|
||||
|
||||
// Amount of drift compensation applied
|
||||
var amount = 0.8f
|
||||
|
||||
// Max resets for the calculated average drift
|
||||
var maxResets = 6
|
||||
|
||||
fun updateTrackersDriftCompensation() {
|
||||
for (t in VRServer.instance.allTrackers) {
|
||||
if (t.isImu()) {
|
||||
t.resetsHandler.readDriftCompensationConfig(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
|
||||
class FiltersConfig {
|
||||
|
||||
// Type of filtering applied (none, smoothing or prediction)
|
||||
var type = "prediction"
|
||||
|
||||
// Amount/Intensity of the specified filtering (0 to 1)
|
||||
var amount = 0.2f
|
||||
|
||||
fun updateTrackersFilters() {
|
||||
for (tracker in VRServer.instance.allTrackers) {
|
||||
if (tracker.allowFiltering) {
|
||||
tracker.filteringHandler.readFilteringConfig(this, tracker.getRotation())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
|
||||
class HIDConfig {
|
||||
var trackersOverHID = false
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package dev.slimevr.config;
|
||||
|
||||
public class KeybindingsConfig {
|
||||
|
||||
private String fullResetBinding = "CTRL+ALT+SHIFT+Y";
|
||||
|
||||
private String yawResetBinding = "CTRL+ALT+SHIFT+U";
|
||||
|
||||
private String mountingResetBinding = "CTRL+ALT+SHIFT+I";
|
||||
|
||||
private String feetMountingResetBinding = "CTRL+ALT+SHIFT+P";
|
||||
|
||||
private String pauseTrackingBinding = "CTRL+ALT+SHIFT+O";
|
||||
|
||||
private long fullResetDelay = 0L;
|
||||
|
||||
private long yawResetDelay = 0L;
|
||||
|
||||
private long mountingResetDelay = 0L;
|
||||
|
||||
private long feetMountingResetDelay = 0L;
|
||||
|
||||
private long pauseTrackingDelay = 0L;
|
||||
|
||||
|
||||
public KeybindingsConfig() {
|
||||
}
|
||||
|
||||
public String getFullResetBinding() {
|
||||
return fullResetBinding;
|
||||
}
|
||||
|
||||
public String getYawResetBinding() {
|
||||
return yawResetBinding;
|
||||
}
|
||||
|
||||
public String getMountingResetBinding() {
|
||||
return mountingResetBinding;
|
||||
}
|
||||
|
||||
public String getFeetMountingResetBinding() {
|
||||
return feetMountingResetBinding;
|
||||
}
|
||||
|
||||
public String getPauseTrackingBinding() {
|
||||
return pauseTrackingBinding;
|
||||
}
|
||||
|
||||
public long getFullResetDelay() {
|
||||
return fullResetDelay;
|
||||
}
|
||||
|
||||
public void setFullResetDelay(long delay) {
|
||||
fullResetDelay = delay;
|
||||
}
|
||||
|
||||
public long getYawResetDelay() {
|
||||
return yawResetDelay;
|
||||
}
|
||||
|
||||
public void setYawResetDelay(long delay) {
|
||||
yawResetDelay = delay;
|
||||
}
|
||||
|
||||
public long getMountingResetDelay() {
|
||||
return mountingResetDelay;
|
||||
}
|
||||
|
||||
public void setMountingResetDelay(long delay) {
|
||||
mountingResetDelay = delay;
|
||||
}
|
||||
|
||||
public long getFeetMountingResetDelay() {
|
||||
return feetMountingResetDelay;
|
||||
}
|
||||
|
||||
public void setFeetMountingResetDelay(long delay) {
|
||||
feetMountingResetDelay = delay;
|
||||
}
|
||||
|
||||
public long getPauseTrackingDelay() {
|
||||
return pauseTrackingDelay;
|
||||
}
|
||||
|
||||
public void setPauseTrackingDelay(long delay) {
|
||||
pauseTrackingDelay = delay;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
class LegTweaksConfig {
|
||||
var correctionStrength = 0.3f
|
||||
var alwaysUseFloorclip = false
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
open class OSCConfig {
|
||||
|
||||
// Are the OSC receiver and sender enabled?
|
||||
var enabled = false
|
||||
|
||||
// Port to receive OSC messages from
|
||||
var portIn = 0
|
||||
|
||||
// Port to send out OSC messages at
|
||||
var portOut = 0
|
||||
|
||||
// Address to send out OSC messages at
|
||||
var address = "127.0.0.1"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package dev.slimevr.config;
|
||||
|
||||
public class OverlayConfig {
|
||||
|
||||
private boolean isMirrored = false;
|
||||
private boolean isVisible = false;
|
||||
|
||||
|
||||
public boolean isMirrored() {
|
||||
return isMirrored;
|
||||
}
|
||||
|
||||
public boolean isVisible() {
|
||||
return isVisible;
|
||||
}
|
||||
|
||||
public void setMirrored(boolean mirrored) {
|
||||
isMirrored = mirrored;
|
||||
}
|
||||
|
||||
public void setVisible(boolean visible) {
|
||||
isVisible = visible;
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
|
||||
enum class ArmsResetModes(val id: Int) {
|
||||
// Upper arm going back and forearm going forward
|
||||
BACK(0),
|
||||
|
||||
// Arms going forward
|
||||
FORWARD(1),
|
||||
|
||||
// Arms going up to the sides into a tpose
|
||||
TPOSE_UP(2),
|
||||
|
||||
// Arms going down to the sides from a tpose
|
||||
TPOSE_DOWN(3),
|
||||
;
|
||||
|
||||
companion object {
|
||||
val values = entries.toTypedArray()
|
||||
|
||||
@JvmStatic
|
||||
fun fromId(id: Int): ArmsResetModes? {
|
||||
for (filter in values) {
|
||||
if (filter.id == id) return filter
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class MountingMethods(val id: Int) {
|
||||
MANUAL(0),
|
||||
AUTOMATIC(1),
|
||||
;
|
||||
|
||||
companion object {
|
||||
val values = MountingMethods.entries.toTypedArray()
|
||||
|
||||
@JvmStatic
|
||||
fun fromId(id: Int): MountingMethods? {
|
||||
for (filter in values) {
|
||||
if (filter.id == id) return filter
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ResetsConfig {
|
||||
|
||||
// Always reset mounting for feet
|
||||
var resetMountingFeet = false
|
||||
|
||||
// Reset mode used for the arms
|
||||
var mode = ArmsResetModes.BACK
|
||||
|
||||
// Yaw reset smoothing time in seconds
|
||||
var yawResetSmoothTime = 0.0f
|
||||
|
||||
// Save automatic mounting reset calibration
|
||||
var saveMountingReset = false
|
||||
|
||||
// Reset the HMD's pitch upon full reset
|
||||
var resetHmdPitch = false
|
||||
|
||||
var lastMountingMethod = MountingMethods.AUTOMATIC
|
||||
|
||||
var yawResetDelay = 0.0f
|
||||
var fullResetDelay = 3.0f
|
||||
var mountingResetDelay = 3.0f
|
||||
|
||||
fun updateTrackersResetsSettings() {
|
||||
for (t in VRServer.instance.allTrackers) {
|
||||
t.resetsHandler.readResetConfig(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.tracking.trackers.udp.MagnetometerStatus
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
||||
class ServerConfig {
|
||||
val trackerPort: Int = 6969
|
||||
|
||||
var useMagnetometerOnAllTrackers: Boolean = false
|
||||
private set
|
||||
|
||||
private val magMutex = Mutex()
|
||||
suspend fun defineMagOnAllTrackers(state: Boolean) = coroutineScope {
|
||||
magMutex.lock()
|
||||
try {
|
||||
if (useMagnetometerOnAllTrackers == state) return@coroutineScope
|
||||
|
||||
VRServer.instance.deviceManager.devices.filter { it.magSupport }.map {
|
||||
async {
|
||||
// Not using 255 as it sometimes could make one of the sensors go into
|
||||
// error mode (if there is more than one sensor inside the device)
|
||||
if (!state) {
|
||||
val trackers = it.trackers.filterValues {
|
||||
it.magStatus != MagnetometerStatus.NOT_SUPPORTED
|
||||
}
|
||||
// if(trackers.size == it.trackers.size) {
|
||||
// it.setMag(false)
|
||||
// } else {
|
||||
trackers.map { (_, t) ->
|
||||
async { it.setMag(false, t.trackerNum) }
|
||||
}.awaitAll()
|
||||
// }
|
||||
return@async
|
||||
}
|
||||
|
||||
// val every = it.trackers.all { (_, t) -> t.config.shouldHaveMagEnabled == true
|
||||
// && t.magStatus != MagnetometerStatus.NOT_SUPPORTED }
|
||||
// if (every) {
|
||||
// it.setMag(true)
|
||||
// return@async
|
||||
// }
|
||||
|
||||
it.trackers.filterValues {
|
||||
it.config.shouldHaveMagEnabled == true &&
|
||||
it.magStatus != MagnetometerStatus.NOT_SUPPORTED
|
||||
}
|
||||
.map { (_, t) ->
|
||||
async {
|
||||
// FIXME: Tracker gets restarted after each setMag, what will happen for devices with 3 trackers?
|
||||
it.setMag(true, t.trackerNum)
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
useMagnetometerOnAllTrackers = state
|
||||
VRServer.instance.configManager.saveConfig()
|
||||
} finally {
|
||||
magMutex.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package dev.slimevr.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdKeySerializers;
|
||||
import dev.slimevr.config.serializers.BooleanMapDeserializer;
|
||||
import dev.slimevr.config.serializers.FloatMapDeserializer;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
public class SkeletonConfig {
|
||||
|
||||
@JsonDeserialize(using = BooleanMapDeserializer.class)
|
||||
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class)
|
||||
public Map<String, Boolean> toggles = new HashMap<>();
|
||||
|
||||
@JsonDeserialize(using = FloatMapDeserializer.class)
|
||||
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class)
|
||||
public Map<String, Float> values = new HashMap<>();
|
||||
|
||||
@JsonDeserialize(using = FloatMapDeserializer.class)
|
||||
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class)
|
||||
public Map<String, Float> offsets = new HashMap<>();
|
||||
|
||||
private float hmdHeight = 0f;
|
||||
private float floorHeight = 0f;
|
||||
|
||||
public Map<String, Boolean> getToggles() {
|
||||
return toggles;
|
||||
}
|
||||
|
||||
public Map<String, Float> getOffsets() {
|
||||
return offsets;
|
||||
}
|
||||
|
||||
public Map<String, Float> getValues() {
|
||||
return values;
|
||||
}
|
||||
|
||||
public float getHmdHeight() {
|
||||
return hmdHeight;
|
||||
}
|
||||
|
||||
public void setHmdHeight(float hmdHeight) {
|
||||
this.hmdHeight = hmdHeight;
|
||||
}
|
||||
|
||||
public float getFloorHeight() {
|
||||
return floorHeight;
|
||||
}
|
||||
|
||||
public void setFloorHeight(float hmdHeight) {
|
||||
this.floorHeight = hmdHeight;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public float getUserHeight() {
|
||||
return hmdHeight - floorHeight;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
|
||||
class StayAlignedConfig {
|
||||
|
||||
/**
|
||||
* Apply yaw correction
|
||||
*/
|
||||
var enabled = false
|
||||
|
||||
/**
|
||||
* Temporarily hide the yaw correction from Stay Aligned.
|
||||
*
|
||||
* Players can enable this to compare to when Stay Aligned is not enabled. Useful to
|
||||
* verify if Stay Aligned improved the situation. Also useful to prevent players
|
||||
* from saying "Stay Aligned screwed up my trackers!!" when it's actually a tracker
|
||||
* that is drifting extremely badly.
|
||||
*
|
||||
* Do not serialize to config so that when the server restarts, it is always false.
|
||||
*/
|
||||
@JsonIgnore
|
||||
var hideYawCorrection = false
|
||||
|
||||
/**
|
||||
* Standing relaxed pose
|
||||
*/
|
||||
val standingRelaxedPose = StayAlignedRelaxedPoseConfig()
|
||||
|
||||
/**
|
||||
* Sitting relaxed pose
|
||||
*/
|
||||
val sittingRelaxedPose = StayAlignedRelaxedPoseConfig()
|
||||
|
||||
/**
|
||||
* Flat relaxed pose
|
||||
*/
|
||||
val flatRelaxedPose = StayAlignedRelaxedPoseConfig()
|
||||
|
||||
/**
|
||||
* Whether setup has been completed
|
||||
*/
|
||||
var setupComplete = false
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
class StayAlignedRelaxedPoseConfig {
|
||||
|
||||
/**
|
||||
* Whether Stay Aligned should adjust the tracker yaws when the player is in this
|
||||
* pose.
|
||||
*/
|
||||
var enabled = false
|
||||
|
||||
/**
|
||||
* Angle between the upper leg yaw and the center yaw.
|
||||
*/
|
||||
var upperLegAngleInDeg = 0.0f
|
||||
|
||||
/**
|
||||
* Angle between the lower leg yaw and the center yaw.
|
||||
*/
|
||||
var lowerLegAngleInDeg = 0.0f
|
||||
|
||||
/**
|
||||
* Angle between the foot and the center yaw.
|
||||
*/
|
||||
var footAngleInDeg = 0.0f
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import com.jme3.math.FastMath
|
||||
|
||||
// handles the tap detection config
|
||||
// this involves the number of taps, the delay, and whether or not the feature is enabled
|
||||
// for each reset type
|
||||
class TapDetectionConfig {
|
||||
var yawResetDelay = 0.2f
|
||||
var fullResetDelay = 1.0f
|
||||
var mountingResetDelay = 1.0f
|
||||
var yawResetEnabled = true
|
||||
var fullResetEnabled = true
|
||||
var mountingResetEnabled = true
|
||||
var setupMode = false
|
||||
var yawResetTaps = 2
|
||||
set(yawResetTaps) {
|
||||
field = yawResetTaps.coerceIn(2, 10)
|
||||
}
|
||||
var fullResetTaps = 3
|
||||
set(fullResetTaps) {
|
||||
field = fullResetTaps.coerceIn(2, 10)
|
||||
}
|
||||
var mountingResetTaps = 3
|
||||
set(mountingResetTaps) {
|
||||
field = mountingResetTaps.coerceIn(2, 10)
|
||||
}
|
||||
var numberTrackersOverThreshold = 1
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.tracking.trackers.Tracker
|
||||
import io.github.axisangles.ktmath.ObjectQuaternion
|
||||
|
||||
class TrackerConfig {
|
||||
var customName: String? = null
|
||||
var designation: String? = null
|
||||
|
||||
@get:JvmName("isHide")
|
||||
var hide: Boolean = false
|
||||
var adjustment: ObjectQuaternion? = null
|
||||
var mountingOrientation: ObjectQuaternion? = null
|
||||
var mountingResetOrientation: ObjectQuaternion? = null
|
||||
var allowDriftCompensation: Boolean? = null
|
||||
|
||||
/**
|
||||
* Only checked if [ServerConfig.useMagnetometerOnAllTrackers] enabled
|
||||
*/
|
||||
var shouldHaveMagEnabled: Boolean? = null
|
||||
|
||||
constructor()
|
||||
|
||||
constructor(tracker: Tracker) {
|
||||
this.designation = if (tracker.trackerPosition != null) tracker.trackerPosition!!.designation else null
|
||||
this.customName = tracker.customName
|
||||
allowDriftCompensation = tracker.isImu()
|
||||
shouldHaveMagEnabled = tracker.isImu()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun toV2(v1: JsonNode, factory: JsonNodeFactory): JsonNode {
|
||||
val node = factory.objectNode()
|
||||
if (v1.has("customName")) node.set<JsonNode>("customName", v1["customName"])
|
||||
if (v1.has("designation")) node.set<JsonNode>("designation", v1["designation"])
|
||||
if (v1.has("hide")) node.set<JsonNode>("hide", v1["hide"])
|
||||
if (v1.has("mountingRotation")) node.set<JsonNode>("mountingRotation", v1["mountingRotation"])
|
||||
if (v1.has("adjustment")) node.set<JsonNode>("adjustment", v1["adjustment"])
|
||||
return node
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Tracker.config: TrackerConfig
|
||||
get() = VRServer.instance.configManager.vrConfig.getTracker(this)
|
||||
@@ -1,5 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
class TrackingChecklistConfig {
|
||||
val ignoredStepsIds: MutableList<Int> = mutableListOf()
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
class VMCConfig : OSCConfig() {
|
||||
|
||||
// Anchor the tracking at the hip?
|
||||
var anchorHip = true
|
||||
|
||||
// JSON part of the VRM to be used
|
||||
var vrmJson: String? = null
|
||||
|
||||
// Mirror the tracking before sending it (turn left <=> turn right, left leg <=> right leg)
|
||||
var mirrorTracking = false
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
class VRCConfig {
|
||||
// List of fields ignored in vrc warnings - @see VRCConfigValidity
|
||||
val mutedWarnings: MutableList<String> = mutableListOf()
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize
|
||||
import com.fasterxml.jackson.databind.ser.std.StdKeySerializers
|
||||
import dev.slimevr.config.serializers.BooleanMapDeserializer
|
||||
import dev.slimevr.tracking.trackers.TrackerRole
|
||||
import java.util.*
|
||||
|
||||
class VRCOSCConfig : OSCConfig() {
|
||||
|
||||
// Which trackers' data to send
|
||||
@JsonDeserialize(using = BooleanMapDeserializer::class)
|
||||
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer::class)
|
||||
var trackers: MutableMap<String, Boolean> = HashMap()
|
||||
|
||||
var oscqueryEnabled: Boolean = true
|
||||
|
||||
fun getOSCTrackerRole(role: TrackerRole, def: Boolean): Boolean = trackers.getOrDefault(role.name.lowercase(Locale.getDefault()), def)
|
||||
|
||||
fun setOSCTrackerRole(role: TrackerRole, `val`: Boolean) {
|
||||
trackers[role.name.lowercase(Locale.getDefault())] = `val`
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize
|
||||
import com.fasterxml.jackson.databind.ser.std.StdKeySerializers
|
||||
import com.github.jonpeterson.jackson.module.versioning.JsonVersionedModel
|
||||
import dev.slimevr.config.serializers.BridgeConfigMapDeserializer
|
||||
import dev.slimevr.config.serializers.TrackerConfigMapDeserializer
|
||||
import dev.slimevr.tracking.trackers.Tracker
|
||||
import dev.slimevr.tracking.trackers.TrackerRole
|
||||
|
||||
@JsonVersionedModel(
|
||||
currentVersion = "15",
|
||||
defaultDeserializeToVersion = "15",
|
||||
toCurrentConverterClass = CurrentVRConfigConverter::class,
|
||||
)
|
||||
class VRConfig {
|
||||
val server: ServerConfig = ServerConfig()
|
||||
|
||||
val filters: FiltersConfig = FiltersConfig()
|
||||
|
||||
val driftCompensation: DriftCompensationConfig = DriftCompensationConfig()
|
||||
|
||||
val oscRouter: OSCConfig = OSCConfig()
|
||||
|
||||
val vrcOSC: VRCOSCConfig = VRCOSCConfig()
|
||||
|
||||
@get:JvmName("getVMC")
|
||||
val vmc: VMCConfig = VMCConfig()
|
||||
|
||||
val autoBone: AutoBoneConfig = AutoBoneConfig()
|
||||
|
||||
val keybindings: KeybindingsConfig = KeybindingsConfig()
|
||||
|
||||
val skeleton: SkeletonConfig = SkeletonConfig()
|
||||
|
||||
val legTweaks: LegTweaksConfig = LegTweaksConfig()
|
||||
|
||||
val tapDetection: TapDetectionConfig = TapDetectionConfig()
|
||||
|
||||
val resetsConfig: ResetsConfig = ResetsConfig()
|
||||
|
||||
val stayAlignedConfig = StayAlignedConfig()
|
||||
|
||||
val hidConfig = HIDConfig()
|
||||
|
||||
@JsonDeserialize(using = TrackerConfigMapDeserializer::class)
|
||||
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer::class)
|
||||
private val trackers: MutableMap<String, TrackerConfig> = HashMap()
|
||||
|
||||
@JsonDeserialize(using = BridgeConfigMapDeserializer::class)
|
||||
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer::class)
|
||||
private val bridges: MutableMap<String, BridgeConfig> = HashMap()
|
||||
|
||||
val knownDevices: MutableSet<String> = mutableSetOf()
|
||||
|
||||
val overlay: OverlayConfig = OverlayConfig()
|
||||
|
||||
val trackingChecklist: TrackingChecklistConfig = TrackingChecklistConfig()
|
||||
|
||||
val vrcConfig: VRCConfig = VRCConfig()
|
||||
|
||||
init {
|
||||
// Initialize default settings for OSC Router
|
||||
oscRouter.portIn = 9002
|
||||
oscRouter.portOut = 9000
|
||||
|
||||
// Initialize default settings for VRC OSC
|
||||
vrcOSC.portIn = 9001
|
||||
vrcOSC.portOut = 9000
|
||||
vrcOSC
|
||||
.setOSCTrackerRole(
|
||||
TrackerRole.WAIST,
|
||||
vrcOSC.getOSCTrackerRole(TrackerRole.WAIST, true),
|
||||
)
|
||||
vrcOSC
|
||||
.setOSCTrackerRole(
|
||||
TrackerRole.LEFT_FOOT,
|
||||
vrcOSC.getOSCTrackerRole(TrackerRole.WAIST, true),
|
||||
)
|
||||
vrcOSC
|
||||
.setOSCTrackerRole(
|
||||
TrackerRole.RIGHT_FOOT,
|
||||
vrcOSC.getOSCTrackerRole(TrackerRole.WAIST, true),
|
||||
)
|
||||
|
||||
// Initialize default settings for VMC
|
||||
vmc.portIn = 39540
|
||||
vmc.portOut = 39539
|
||||
}
|
||||
|
||||
fun getTrackers(): Map<String, TrackerConfig> = trackers
|
||||
|
||||
fun getBridges(): Map<String, BridgeConfig> = bridges
|
||||
|
||||
fun hasTrackerByName(name: String): Boolean = trackers.containsKey(name)
|
||||
|
||||
fun getTracker(tracker: Tracker): TrackerConfig {
|
||||
var config = trackers[tracker.name]
|
||||
if (config == null) {
|
||||
config = TrackerConfig(tracker)
|
||||
trackers[tracker.name] = config
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
fun readTrackerConfig(tracker: Tracker) {
|
||||
if (tracker.userEditable) {
|
||||
val config = getTracker(tracker)
|
||||
tracker.readConfig(config)
|
||||
if (tracker.isImu()) tracker.resetsHandler.readDriftCompensationConfig(driftCompensation)
|
||||
tracker.resetsHandler.readResetConfig(resetsConfig)
|
||||
if (tracker.allowReset) {
|
||||
tracker.saveMountingResetOrientation(config)
|
||||
}
|
||||
if (tracker.allowFiltering) {
|
||||
tracker
|
||||
.filteringHandler
|
||||
.readFilteringConfig(filters, tracker.getRotation())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun writeTrackerConfig(tracker: Tracker?) {
|
||||
if (tracker?.userEditable == true) {
|
||||
val tc = getTracker(tracker)
|
||||
tracker.writeConfig(tc)
|
||||
}
|
||||
}
|
||||
|
||||
fun getBridge(bridgeKey: String): BridgeConfig {
|
||||
var config = bridges[bridgeKey]
|
||||
if (config == null) {
|
||||
config = BridgeConfig()
|
||||
bridges[bridgeKey] = config
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
fun isKnownDevice(mac: String?): Boolean = knownDevices.contains(mac)
|
||||
|
||||
fun addKnownDevice(mac: String): Boolean = knownDevices.add(mac)
|
||||
|
||||
fun forgetKnownDevice(mac: String): Boolean = knownDevices.remove(mac)
|
||||
}
|
||||
22
server/core/src/main/java/dev/slimevr/config/behaviours.kt
Normal file
22
server/core/src/main/java/dev/slimevr/config/behaviours.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
object DefaultGlobalConfigBehaviour : GlobalConfigBehaviour {
|
||||
override fun reduce(state: GlobalConfigState, action: GlobalConfigActions) = when (action) {
|
||||
is GlobalConfigActions.SetUserProfile -> state.copy(selectedUserProfile = action.name)
|
||||
is GlobalConfigActions.SetSettingsProfile -> state.copy(selectedSettingsProfile = action.name)
|
||||
}
|
||||
}
|
||||
|
||||
object DefaultSettingsBehaviour : SettingsBehaviour {
|
||||
override fun reduce(state: SettingsState, action: SettingsActions) = when (action) {
|
||||
is SettingsActions.Update -> state.copy(data = action.transform(state.data))
|
||||
is SettingsActions.LoadProfile -> action.newState
|
||||
}
|
||||
}
|
||||
|
||||
object DefaultUserBehaviour : UserConfigBehaviour {
|
||||
override fun reduce(state: UserConfigState, action: UserConfigActions) = when (action) {
|
||||
is UserConfigActions.Update -> state.copy(data = action.transform(state.data))
|
||||
is UserConfigActions.LoadProfile -> action.newState
|
||||
}
|
||||
}
|
||||
91
server/core/src/main/java/dev/slimevr/config/io.kt
Normal file
91
server/core/src/main/java/dev/slimevr/config/io.kt
Normal file
@@ -0,0 +1,91 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
val jsonConfig = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
suspend fun atomicWriteFile(file: File, content: String) = withContext(Dispatchers.IO) {
|
||||
file.parentFile?.mkdirs()
|
||||
val tmp = File(file.parent, "${file.name}.tmp")
|
||||
tmp.writeText(content)
|
||||
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
|
||||
Unit
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> loadFileWithBackup(file: File, default: T, crossinline deserialize: (String) -> T): T = withContext(Dispatchers.IO) {
|
||||
if (!file.exists()) {
|
||||
atomicWriteFile(file, jsonConfig.encodeToString(default))
|
||||
return@withContext default
|
||||
}
|
||||
|
||||
try {
|
||||
deserialize(file.readText())
|
||||
} catch (e: Exception) {
|
||||
System.err.println("Failed to load ${file.absolutePath}: ${e.message}")
|
||||
if (file.exists()) {
|
||||
try {
|
||||
val bakTmp = File(file.parent, "${file.name}.bak.tmp")
|
||||
file.copyTo(bakTmp, overwrite = true)
|
||||
Files.move(
|
||||
bakTmp.toPath(),
|
||||
File(file.parent, "${file.name}.bak").toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
} catch (e2: Exception) {
|
||||
System.err.println("Failed to back up corrupted file: ${e2.message}")
|
||||
}
|
||||
}
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches a debounced autosave coroutine. Skips the initial state (already on
|
||||
* disk at start time) and any state that was already successfully persisted.
|
||||
* Cancel and restart to switch profiles. the new job treats the current state
|
||||
* as already saved.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
fun <S> launchAutosave(
|
||||
scope: CoroutineScope,
|
||||
state: StateFlow<S>,
|
||||
toFile: (S) -> File,
|
||||
serialize: (S) -> String,
|
||||
): Job {
|
||||
var lastSaved = state.value
|
||||
return merge(state.debounce(500L), state.sample(2000L))
|
||||
.distinctUntilChanged()
|
||||
.filter { it != lastSaved }
|
||||
.onEach { s ->
|
||||
try {
|
||||
val file = toFile(s)
|
||||
atomicWriteFile(file, serialize(s))
|
||||
lastSaved = s
|
||||
println("Saved ${file.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
System.err.println("Failed to save: ${e.message}")
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
90
server/core/src/main/java/dev/slimevr/config/module.kt
Normal file
90
server/core/src/main/java/dev/slimevr/config/module.kt
Normal file
@@ -0,0 +1,90 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import dev.slimevr.context.Behaviour
|
||||
import dev.slimevr.context.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
|
||||
private const val GLOBAL_CONFIG_VERSION = 1
|
||||
|
||||
@Serializable
|
||||
data class GlobalConfigState(
|
||||
val selectedUserProfile: String = "default",
|
||||
val selectedSettingsProfile: String = "default",
|
||||
val version: Int = GLOBAL_CONFIG_VERSION,
|
||||
)
|
||||
|
||||
sealed interface GlobalConfigActions {
|
||||
data class SetUserProfile(val name: String) : GlobalConfigActions
|
||||
data class SetSettingsProfile(val name: String) : GlobalConfigActions
|
||||
}
|
||||
|
||||
typealias GlobalConfigContext = Context<GlobalConfigState, GlobalConfigActions>
|
||||
typealias GlobalConfigBehaviour = Behaviour<GlobalConfigState, GlobalConfigActions, GlobalConfigContext>
|
||||
|
||||
private fun migrateGlobalConfig(json: JsonObject): JsonObject {
|
||||
val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
return when {
|
||||
// add migration branches here as: version < N -> migrateGlobalConfig(...)
|
||||
else -> json
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAndMigrateGlobalConfig(raw: String): GlobalConfigState {
|
||||
val json = jsonConfig.parseToJsonElement(raw).jsonObject
|
||||
return jsonConfig.decodeFromJsonElement(migrateGlobalConfig(json))
|
||||
}
|
||||
|
||||
class AppConfig(
|
||||
val globalContext: GlobalConfigContext,
|
||||
val userConfig: UserConfig,
|
||||
val settings: Settings,
|
||||
) {
|
||||
suspend fun switchUserProfile(name: String) {
|
||||
globalContext.dispatch(GlobalConfigActions.SetUserProfile(name))
|
||||
userConfig.swap(name)
|
||||
}
|
||||
|
||||
suspend fun switchSettingsProfile(name: String) {
|
||||
globalContext.dispatch(GlobalConfigActions.SetSettingsProfile(name))
|
||||
settings.swap(name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
suspend fun create(scope: CoroutineScope, configFolder: File): AppConfig {
|
||||
val initialGlobal = loadFileWithBackup(File(configFolder, "global.json"), GlobalConfigState()) {
|
||||
parseAndMigrateGlobalConfig(it)
|
||||
}
|
||||
|
||||
val behaviours = listOf(DefaultGlobalConfigBehaviour)
|
||||
val globalContext = Context.create(
|
||||
initialState = initialGlobal,
|
||||
scope = scope,
|
||||
behaviours = behaviours,
|
||||
)
|
||||
behaviours.forEach { it.observe(globalContext) }
|
||||
|
||||
launchAutosave(
|
||||
scope = scope,
|
||||
state = globalContext.state,
|
||||
toFile = { File(configFolder, "global.json") },
|
||||
serialize = { jsonConfig.encodeToString(it) },
|
||||
)
|
||||
|
||||
val userConfig = UserConfig.create(scope, configFolder, initialGlobal.selectedUserProfile)
|
||||
val settings = Settings.create(scope, configFolder, initialGlobal.selectedSettingsProfile)
|
||||
|
||||
return AppConfig(
|
||||
globalContext = globalContext,
|
||||
userConfig = userConfig,
|
||||
settings = settings,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package dev.slimevr.config.serializers;
|
||||
|
||||
/**
|
||||
* This class allows the use of the utility super class MapDeserializer that
|
||||
* takes the Value of a map as its Generic parameter. It is so you can use that
|
||||
* class in a @JsonDeserialize annotation on the Map field inside the config
|
||||
* instance
|
||||
*
|
||||
* @see dev.slimevr.config.VRConfig
|
||||
*/
|
||||
public class BooleanMapDeserializer extends MapDeserializer<Boolean> {
|
||||
public BooleanMapDeserializer() {
|
||||
super(Boolean.class);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package dev.slimevr.config.serializers;
|
||||
|
||||
import dev.slimevr.config.BridgeConfig;
|
||||
|
||||
|
||||
/**
|
||||
* This class allows the use of the utility super class MapDeserializer that
|
||||
* takes the Value of a map as its Generic parameter. It is so you can use that
|
||||
* class in a @JsonDeserialize annotation on the Map field inside the config
|
||||
* instance
|
||||
*
|
||||
* @see dev.slimevr.config.VRConfig
|
||||
*/
|
||||
public class BridgeConfigMapDeserializer extends MapDeserializer<BridgeConfig> {
|
||||
public BridgeConfigMapDeserializer() {
|
||||
super(BridgeConfig.class);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package dev.slimevr.config.serializers;
|
||||
|
||||
/**
|
||||
* This class allows the use of the utility super class MapDeserializer that
|
||||
* takes the Value of a map as its Generic parameter. It is so you can use that
|
||||
* class in a @JsonDeserialize annotation on the Map field inside the config
|
||||
* instance
|
||||
*
|
||||
* @see dev.slimevr.config.VRConfig
|
||||
*/
|
||||
public class FloatMapDeserializer extends MapDeserializer<Float> {
|
||||
public FloatMapDeserializer() {
|
||||
super(Float.class);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package dev.slimevr.config.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.type.MapType;
|
||||
import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
|
||||
|
||||
/**
|
||||
* This class is a utility class that allows to write Map serializers easily to
|
||||
* be used in the VRConfig (@see {@link dev.slimevr.config.VRConfig})
|
||||
*
|
||||
* @see BooleanMapDeserializer to see how it is used
|
||||
*/
|
||||
public abstract class MapDeserializer<T> extends JsonDeserializer<HashMap<String, T>> {
|
||||
|
||||
private final Class<T> valueClass;
|
||||
|
||||
public MapDeserializer(Class<T> valueClass) {
|
||||
super();
|
||||
this.valueClass = valueClass;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HashMap<String, T> deserialize(JsonParser p, DeserializationContext dc)
|
||||
throws IOException {
|
||||
TypeFactory typeFactory = dc.getTypeFactory();
|
||||
MapType mapType = typeFactory
|
||||
.constructMapType(HashMap.class, String.class, valueClass);
|
||||
return dc.readValue(p, mapType);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package dev.slimevr.config.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JacksonException;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.github.axisangles.ktmath.ObjectQuaternion;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
|
||||
public class QuaternionDeserializer extends JsonDeserializer<ObjectQuaternion> {
|
||||
@Override
|
||||
public ObjectQuaternion deserialize(JsonParser p, DeserializationContext ctxt)
|
||||
throws IOException, JacksonException {
|
||||
JsonNode node = p.getCodec().readTree(p);
|
||||
|
||||
return new ObjectQuaternion(
|
||||
(float) node.get("w").asDouble(),
|
||||
(float) node.get("x").asDouble(),
|
||||
(float) node.get("y").asDouble(),
|
||||
(float) node.get("z").asDouble()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package dev.slimevr.config.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import io.github.axisangles.ktmath.ObjectQuaternion;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
|
||||
public class QuaternionSerializer extends JsonSerializer<ObjectQuaternion> {
|
||||
|
||||
@Override
|
||||
public void serialize(ObjectQuaternion value, JsonGenerator gen, SerializerProvider serializers)
|
||||
throws IOException {
|
||||
gen.writeStartObject();
|
||||
gen.writeNumberField("x", value.getX());
|
||||
gen.writeNumberField("y", value.getY());
|
||||
gen.writeNumberField("z", value.getZ());
|
||||
gen.writeNumberField("w", value.getW());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package dev.slimevr.config.serializers;
|
||||
|
||||
import dev.slimevr.config.TrackerConfig;
|
||||
|
||||
|
||||
/**
|
||||
* This class allows the use of the utility super class MapDeserializer that
|
||||
* takes the Value of a map as its Generic parameter. It is so you can use that
|
||||
* class in a @JsonDeserialize annotation on the Map field inside the config
|
||||
* instance
|
||||
*
|
||||
* @see dev.slimevr.config.VRConfig
|
||||
*/
|
||||
public class TrackerConfigMapDeserializer extends MapDeserializer<TrackerConfig> {
|
||||
public TrackerConfigMapDeserializer() {
|
||||
super(TrackerConfig.class);
|
||||
}
|
||||
}
|
||||
93
server/core/src/main/java/dev/slimevr/config/settings.kt
Normal file
93
server/core/src/main/java/dev/slimevr/config/settings.kt
Normal file
@@ -0,0 +1,93 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import dev.slimevr.context.Behaviour
|
||||
import dev.slimevr.context.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
|
||||
private const val SETTINGS_CONFIG_VERSION = 1
|
||||
|
||||
@Serializable
|
||||
data class SettingsConfigState(
|
||||
val trackerPort: Int = 6969,
|
||||
val mutedVRCWarnings: List<String> = listOf(),
|
||||
val version: Int = SETTINGS_CONFIG_VERSION,
|
||||
)
|
||||
|
||||
private fun migrateSettingsConfig(json: JsonObject): JsonObject {
|
||||
val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
return when {
|
||||
// add migration branches here as: version < N -> migrateSettingsConfig(...)
|
||||
else -> json
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAndMigrateSettingsConfig(raw: String): SettingsConfigState {
|
||||
val json = jsonConfig.parseToJsonElement(raw).jsonObject
|
||||
return jsonConfig.decodeFromJsonElement(migrateSettingsConfig(json))
|
||||
}
|
||||
|
||||
data class SettingsState(
|
||||
val data: SettingsConfigState,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
sealed interface SettingsActions {
|
||||
data class Update(val transform: SettingsConfigState.() -> SettingsConfigState) : SettingsActions
|
||||
data class LoadProfile(val newState: SettingsState) : SettingsActions
|
||||
}
|
||||
|
||||
typealias SettingsContext = Context<SettingsState, SettingsActions>
|
||||
typealias SettingsBehaviour = Behaviour<SettingsState, SettingsActions, Settings>
|
||||
|
||||
class Settings(
|
||||
val context: SettingsContext,
|
||||
private val scope: CoroutineScope,
|
||||
private val settingsDir: File,
|
||||
) {
|
||||
private var autosaveJob: Job = startAutosave()
|
||||
|
||||
private fun startAutosave() = launchAutosave(
|
||||
scope = scope,
|
||||
state = context.state,
|
||||
toFile = { state -> File(settingsDir, "${state.name}.json") },
|
||||
serialize = { state -> jsonConfig.encodeToString(state.data) },
|
||||
)
|
||||
|
||||
suspend fun swap(newName: String) {
|
||||
autosaveJob.cancelAndJoin()
|
||||
|
||||
val newData = loadFileWithBackup(File(settingsDir, "$newName.json"), SettingsConfigState()) {
|
||||
parseAndMigrateSettingsConfig(it)
|
||||
}
|
||||
val newState = SettingsState(name = newName, data = newData)
|
||||
context.dispatch(SettingsActions.LoadProfile(newState))
|
||||
|
||||
autosaveJob = startAutosave()
|
||||
}
|
||||
|
||||
companion object {
|
||||
suspend fun create(scope: CoroutineScope, configDir: File, name: String): Settings {
|
||||
val settingsDir = File(configDir, "settings")
|
||||
|
||||
val initialData = loadFileWithBackup(File(settingsDir, "$name.json"), SettingsConfigState()) {
|
||||
parseAndMigrateSettingsConfig(it)
|
||||
}
|
||||
val initialState = SettingsState(name = name, data = initialData)
|
||||
|
||||
val behaviours = listOf(DefaultSettingsBehaviour)
|
||||
val context = Context.create(initialState = initialState, scope = scope, behaviours = behaviours)
|
||||
val settings = Settings(context, scope = scope, settingsDir = settingsDir)
|
||||
behaviours.forEach { it.observe(settings) }
|
||||
return settings
|
||||
}
|
||||
}
|
||||
}
|
||||
92
server/core/src/main/java/dev/slimevr/config/user.kt
Normal file
92
server/core/src/main/java/dev/slimevr/config/user.kt
Normal file
@@ -0,0 +1,92 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import dev.slimevr.context.Behaviour
|
||||
import dev.slimevr.context.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
|
||||
private const val USER_CONFIG_VERSION = 1
|
||||
|
||||
@Serializable
|
||||
data class UserConfigData(
|
||||
val userHeight: Float = 1.6f,
|
||||
val version: Int = USER_CONFIG_VERSION,
|
||||
)
|
||||
|
||||
private fun migrateUserConfig(json: JsonObject): JsonObject {
|
||||
val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
return when {
|
||||
// add migration branches here as: version < N -> migrateUserConfig(...)
|
||||
else -> json
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAndMigrateUserConfig(raw: String): UserConfigData {
|
||||
val json = jsonConfig.parseToJsonElement(raw).jsonObject
|
||||
return jsonConfig.decodeFromJsonElement(migrateUserConfig(json))
|
||||
}
|
||||
|
||||
data class UserConfigState(
|
||||
val data: UserConfigData,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
sealed interface UserConfigActions {
|
||||
data class Update(val transform: UserConfigData.() -> UserConfigData) : UserConfigActions
|
||||
data class LoadProfile(val newState: UserConfigState) : UserConfigActions
|
||||
}
|
||||
|
||||
typealias UserConfigContext = Context<UserConfigState, UserConfigActions>
|
||||
typealias UserConfigBehaviour = Behaviour<UserConfigState, UserConfigActions, UserConfig>
|
||||
|
||||
class UserConfig(
|
||||
val context: UserConfigContext,
|
||||
private val scope: CoroutineScope,
|
||||
private val userConfigDir: File,
|
||||
) {
|
||||
private var autosaveJob: Job = startAutosave()
|
||||
|
||||
private fun startAutosave() = launchAutosave(
|
||||
scope = scope,
|
||||
state = context.state,
|
||||
toFile = { state -> File(userConfigDir, "${state.name}.json") },
|
||||
serialize = { state -> jsonConfig.encodeToString(state.data) },
|
||||
)
|
||||
|
||||
suspend fun swap(newName: String) {
|
||||
autosaveJob.cancelAndJoin()
|
||||
|
||||
val newData = loadFileWithBackup(File(userConfigDir, "$newName.json"), UserConfigData()) {
|
||||
parseAndMigrateUserConfig(it)
|
||||
}
|
||||
val newState = UserConfigState(name = newName, data = newData)
|
||||
context.dispatch(UserConfigActions.LoadProfile(newState))
|
||||
|
||||
autosaveJob = startAutosave()
|
||||
}
|
||||
|
||||
companion object {
|
||||
suspend fun create(scope: CoroutineScope, configDir: File, name: String): UserConfig {
|
||||
val userConfigDir = File(configDir, "user")
|
||||
|
||||
val initialData = loadFileWithBackup(File(userConfigDir, "$name.json"), UserConfigData()) {
|
||||
parseAndMigrateUserConfig(it)
|
||||
}
|
||||
val initialState = UserConfigState(name = name, data = initialData)
|
||||
|
||||
val behaviours = listOf(DefaultUserBehaviour)
|
||||
val context = Context.create(initialState = initialState, scope = scope, behaviours = behaviours)
|
||||
val userConfig = UserConfig(context, scope = scope, userConfigDir = userConfigDir)
|
||||
behaviours.forEach { it.observe(userConfig) }
|
||||
return userConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
46
server/core/src/main/java/dev/slimevr/context/context.kt
Normal file
46
server/core/src/main/java/dev/slimevr/context/context.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package dev.slimevr.context
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
interface Behaviour<S, A, C> {
|
||||
fun reduce(state: S, action: A): S = state
|
||||
fun observe(receiver: C) {}
|
||||
}
|
||||
|
||||
class Context<S, in A>(
|
||||
private val mutableStateFlow: MutableStateFlow<S>,
|
||||
private val applyAction: (S, A) -> S,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
val state: StateFlow<S> = mutableStateFlow.asStateFlow()
|
||||
|
||||
fun dispatch(action: A) {
|
||||
mutableStateFlow.update {
|
||||
applyAction(it, action)
|
||||
}
|
||||
}
|
||||
|
||||
fun dispatchAll(actions: List<A>) {
|
||||
mutableStateFlow.update { currentState ->
|
||||
actions.fold(currentState) { s, action -> applyAction(s, action) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun <S, A> create(
|
||||
initialState: S,
|
||||
scope: CoroutineScope,
|
||||
behaviours: List<Behaviour<S, A, *>>,
|
||||
): Context<S, A> {
|
||||
val mutableStateFlow = MutableStateFlow(initialState)
|
||||
val applyAction: (S, A) -> S = { currentState, action ->
|
||||
behaviours.fold(currentState) { s, b -> b.reduce(s, action) }
|
||||
}
|
||||
return Context(mutableStateFlow, applyAction, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
server/core/src/main/java/dev/slimevr/device/behaviours.kt
Normal file
14
server/core/src/main/java/dev/slimevr/device/behaviours.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package dev.slimevr.device
|
||||
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
object DeviceStatsBehaviour : DeviceBehaviour {
|
||||
override fun reduce(state: DeviceState, action: DeviceActions) = if (action is DeviceActions.Update) action.transform(state) else state
|
||||
|
||||
override fun observe(receiver: DeviceContext) {
|
||||
receiver.state.onEach {
|
||||
// AppLogger.device.info("Device state changed", it)
|
||||
}.launchIn(receiver.scope)
|
||||
}
|
||||
}
|
||||
76
server/core/src/main/java/dev/slimevr/device/module.kt
Normal file
76
server/core/src/main/java/dev/slimevr/device/module.kt
Normal file
@@ -0,0 +1,76 @@
|
||||
package dev.slimevr.device
|
||||
|
||||
import dev.slimevr.context.Behaviour
|
||||
import dev.slimevr.context.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import solarxr_protocol.datatypes.TrackerStatus
|
||||
import solarxr_protocol.datatypes.hardware_info.BoardType
|
||||
import solarxr_protocol.datatypes.hardware_info.McuType
|
||||
|
||||
enum class DeviceOrigin {
|
||||
DRIVER,
|
||||
FEEDER,
|
||||
UDP,
|
||||
HID,
|
||||
}
|
||||
|
||||
data class DeviceState(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val address: String,
|
||||
val macAddress: String?,
|
||||
val batteryLevel: Float,
|
||||
val batteryVoltage: Float,
|
||||
val ping: Long?,
|
||||
val signalStrength: Int?,
|
||||
val firmware: String?,
|
||||
val boardType: BoardType,
|
||||
val mcuType: McuType,
|
||||
val protocolVersion: Int,
|
||||
val status: TrackerStatus,
|
||||
val origin: DeviceOrigin,
|
||||
)
|
||||
|
||||
sealed interface DeviceActions {
|
||||
data class Update(val transform: DeviceState.() -> DeviceState) : DeviceActions
|
||||
}
|
||||
|
||||
typealias DeviceContext = Context<DeviceState, DeviceActions>
|
||||
typealias DeviceBehaviour = Behaviour<DeviceState, DeviceActions, DeviceContext>
|
||||
|
||||
class Device(
|
||||
val context: DeviceContext,
|
||||
) {
|
||||
companion object {
|
||||
fun create(
|
||||
scope: CoroutineScope,
|
||||
id: Int,
|
||||
address: String,
|
||||
macAddress: String? = null,
|
||||
origin: DeviceOrigin,
|
||||
protocolVersion: Int,
|
||||
): Device {
|
||||
val deviceState = DeviceState(
|
||||
id = id,
|
||||
name = "Device $id",
|
||||
batteryLevel = 0f,
|
||||
batteryVoltage = 0f,
|
||||
origin = origin,
|
||||
address = address,
|
||||
macAddress = macAddress,
|
||||
protocolVersion = protocolVersion,
|
||||
ping = null,
|
||||
signalStrength = null,
|
||||
status = TrackerStatus.DISCONNECTED,
|
||||
mcuType = McuType.Other,
|
||||
boardType = BoardType.UNKNOWN,
|
||||
firmware = null,
|
||||
)
|
||||
|
||||
val behaviours = listOf(DeviceStatsBehaviour)
|
||||
val context = Context.create(initialState = deviceState, scope = scope, behaviours = behaviours)
|
||||
behaviours.forEach { it.observe(context) }
|
||||
return Device(context = context)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
server/core/src/main/java/dev/slimevr/event-dispatcher.kt
Normal file
33
server/core/src/main/java/dev/slimevr/event-dispatcher.kt
Normal file
@@ -0,0 +1,33 @@
|
||||
package dev.slimevr
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class EventDispatcher<T : Any>(private val keyOf: (T) -> KClass<*> = { it::class }) {
|
||||
@Volatile var listeners: Map<KClass<*>, List<suspend (T) -> Unit>> = emptyMap()
|
||||
|
||||
@Volatile private var globalListeners: List<suspend (T) -> Unit> = emptyList()
|
||||
|
||||
fun register(key: KClass<*>, callback: suspend (T) -> Unit) {
|
||||
synchronized(this) {
|
||||
val updated = listeners.toMutableMap()
|
||||
updated[key] = (updated[key] ?: emptyList()) + callback
|
||||
listeners = updated
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <reified P : T> on(crossinline callback: suspend (P) -> Unit) {
|
||||
register(P::class) { callback(it as P) }
|
||||
}
|
||||
|
||||
fun onAny(callback: suspend (T) -> Unit) {
|
||||
synchronized(this) {
|
||||
globalListeners = globalListeners + callback
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun emit(event: T) {
|
||||
globalListeners.forEach { it(event) }
|
||||
listeners[keyOf(event)]?.forEach { it(event) }
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package dev.slimevr.filtering;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
|
||||
/**
|
||||
* If you use this code, please consider notifying isak at du-preez dot com with
|
||||
* a brief description of your application.
|
||||
* <p>
|
||||
* This is free and unencumbered software released into the public domain.
|
||||
* Anyone is free to copy, modify, publish, use, compile, sell, or distribute
|
||||
* this software, either in source code form or as a compiled binary, for any
|
||||
* purpose, commercial or non-commercial, and by any means.
|
||||
*/
|
||||
|
||||
public class CircularArrayList<E> extends AbstractList<E> implements RandomAccess {
|
||||
|
||||
private final int n; // buffer length
|
||||
private final List<E> buf; // a List implementing RandomAccess
|
||||
private int head = 0;
|
||||
private int tail = 0;
|
||||
|
||||
public CircularArrayList(int capacity) {
|
||||
n = capacity + 1;
|
||||
buf = new ArrayList<>(Collections.nCopies(n, null));
|
||||
}
|
||||
|
||||
public int capacity() {
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
private int wrapIndex(int i) {
|
||||
int m = i % n;
|
||||
if (m < 0) { // java modulus can be negative
|
||||
m += n;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// This method is O(n) but will never be called if the
|
||||
// CircularArrayList is used in its typical/intended role.
|
||||
private void shiftBlock(int startIndex, int endIndex) {
|
||||
assert (endIndex > startIndex);
|
||||
for (int i = endIndex - 1; i >= startIndex; i--) {
|
||||
set(i + 1, get(i));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return tail - head + (tail < head ? n : 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public E get(int i) {
|
||||
if (i < 0 || i >= size()) {
|
||||
throw new IndexOutOfBoundsException();
|
||||
}
|
||||
return buf.get(wrapIndex(head + i));
|
||||
}
|
||||
|
||||
public E getLatest() {
|
||||
return buf.get(wrapIndex(head + size() - 1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public E set(int i, E e) {
|
||||
if (i < 0 || i >= size()) {
|
||||
throw new IndexOutOfBoundsException();
|
||||
}
|
||||
return buf.set(wrapIndex(head + i), e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void add(int i, E e) {
|
||||
int s = size();
|
||||
if (s == n - 1) {
|
||||
throw new IllegalStateException(
|
||||
"CircularArrayList is filled to capacity. "
|
||||
+ "(You may want to remove from front"
|
||||
+ " before adding more to back.)"
|
||||
);
|
||||
}
|
||||
if (i < 0 || i > s) {
|
||||
throw new IndexOutOfBoundsException();
|
||||
}
|
||||
tail = wrapIndex(tail + 1);
|
||||
if (i < s) {
|
||||
shiftBlock(i, s);
|
||||
}
|
||||
set(i, e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public E remove(int i) {
|
||||
int s = size();
|
||||
if (i < 0 || i >= s) {
|
||||
throw new IndexOutOfBoundsException();
|
||||
}
|
||||
E e = get(i);
|
||||
if (i > 0) {
|
||||
shiftBlock(0, i);
|
||||
}
|
||||
head = wrapIndex(head + 1);
|
||||
return e;
|
||||
}
|
||||
|
||||
public E removeLast() {
|
||||
int s = size();
|
||||
if (0 == s) {
|
||||
throw new IndexOutOfBoundsException();
|
||||
}
|
||||
E e = get(0);
|
||||
head = wrapIndex(head + 1);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package dev.slimevr.filtering
|
||||
|
||||
import com.jme3.system.NanoTimer
|
||||
import dev.slimevr.VRServer
|
||||
import io.github.axisangles.ktmath.Quaternion
|
||||
import io.github.axisangles.ktmath.Quaternion.Companion.IDENTITY
|
||||
|
||||
// influences the range of smoothFactor.
|
||||
private const val SMOOTH_MULTIPLIER = 42f
|
||||
private const val SMOOTH_MIN = 11f
|
||||
|
||||
// influences the range of predictFactor
|
||||
private const val PREDICT_MULTIPLIER = 15f
|
||||
private const val PREDICT_MIN = 10f
|
||||
|
||||
// how many past rotations are used for prediction.
|
||||
private const val PREDICT_BUFFER = 6
|
||||
|
||||
class QuaternionMovingAverage(
|
||||
val type: TrackerFilters,
|
||||
var amount: Float = 0f,
|
||||
initialRotation: Quaternion = IDENTITY,
|
||||
) {
|
||||
var filteredQuaternion = IDENTITY
|
||||
var filteringImpact = 0f
|
||||
private var smoothFactor = 0f
|
||||
private var predictFactor = 0f
|
||||
private var rotBuffer: CircularArrayList<Quaternion>? = null
|
||||
private var latestQuaternion = IDENTITY
|
||||
private var smoothingQuaternion = IDENTITY
|
||||
private val fpsTimer = if (VRServer.instanceInitialized) VRServer.instance.fpsTimer else NanoTimer()
|
||||
private var timeSinceUpdate = 0f
|
||||
|
||||
init {
|
||||
// amount should range from 0 to 1.
|
||||
// GUI should clamp it from 0.01 (1%) or 0.1 (10%)
|
||||
// to 1 (100%).
|
||||
amount = amount.coerceAtLeast(0f)
|
||||
if (type == TrackerFilters.SMOOTHING) {
|
||||
// lower smoothFactor = more smoothing
|
||||
smoothFactor = SMOOTH_MULTIPLIER * (1 - amount.coerceAtMost(1f)) + SMOOTH_MIN
|
||||
// Totally a hack
|
||||
if (amount > 1) {
|
||||
smoothFactor /= amount
|
||||
}
|
||||
}
|
||||
if (type == TrackerFilters.PREDICTION) {
|
||||
// higher predictFactor = more prediction
|
||||
predictFactor = PREDICT_MULTIPLIER * amount + PREDICT_MIN
|
||||
rotBuffer = CircularArrayList(PREDICT_BUFFER)
|
||||
}
|
||||
|
||||
// We have no reference at the start, so just use the initial rotation
|
||||
resetQuats(initialRotation, initialRotation)
|
||||
}
|
||||
|
||||
// Runs at up to 1000hz. We use a timer to make it framerate-independent
|
||||
// since it runs a bit below 1000hz in practice.
|
||||
@Synchronized
|
||||
fun update() {
|
||||
if (type == TrackerFilters.PREDICTION) {
|
||||
val rotBuf = rotBuffer
|
||||
if (rotBuf != null && rotBuf.isNotEmpty()) {
|
||||
// Applies the past rotations to the current rotation
|
||||
val predictRot = rotBuf.fold(latestQuaternion) { buf, rot -> buf * rot }
|
||||
|
||||
// Calculate how much to slerp
|
||||
// Limit slerp by a reasonable amount so low TPS doesn't break tracking
|
||||
val amt = (predictFactor * fpsTimer.timePerFrame).coerceAtMost(1f)
|
||||
|
||||
// Slerps the target rotation to that predicted rotation by amt
|
||||
filteredQuaternion = filteredQuaternion.interpQ(predictRot, amt)
|
||||
}
|
||||
} else if (type == TrackerFilters.SMOOTHING) {
|
||||
// Make it framerate-independent
|
||||
timeSinceUpdate += fpsTimer.timePerFrame
|
||||
|
||||
// Calculate the slerp factor based off the smoothFactor and smoothingCounter
|
||||
// limit to 1 to not overshoot
|
||||
val amt = (smoothFactor * timeSinceUpdate).coerceAtMost(1f)
|
||||
|
||||
// Smooth towards the target rotation by the slerp factor
|
||||
filteredQuaternion = smoothingQuaternion.interpQ(latestQuaternion, amt)
|
||||
}
|
||||
|
||||
filteringImpact = latestQuaternion.angleToR(filteredQuaternion)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addQuaternion(q: Quaternion) {
|
||||
val oldQ = latestQuaternion
|
||||
val newQ = q.twinNearest(oldQ)
|
||||
latestQuaternion = newQ
|
||||
|
||||
if (type == TrackerFilters.PREDICTION) {
|
||||
if (rotBuffer!!.size == rotBuffer!!.capacity()) {
|
||||
rotBuffer?.removeLast()
|
||||
}
|
||||
|
||||
// Gets and stores the rotation between the last 2 quaternions
|
||||
rotBuffer?.add(oldQ.inv().times(newQ))
|
||||
} else if (type == TrackerFilters.SMOOTHING) {
|
||||
timeSinceUpdate = 0f
|
||||
smoothingQuaternion = filteredQuaternion
|
||||
} else {
|
||||
// No filtering; just keep track of rotations (for going over 180 degrees)
|
||||
filteredQuaternion = newQ
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns the quaternion space of [q] to the [reference] and sets the latest
|
||||
* [filteredQuaternion] immediately
|
||||
*/
|
||||
@Synchronized
|
||||
fun resetQuats(q: Quaternion, reference: Quaternion) {
|
||||
// Assume a rotation within 180 degrees of the reference
|
||||
// TODO: Currently the reference is the headset, this restricts all trackers to
|
||||
// have at most a 180 degree rotation from the HMD during a reset, we can
|
||||
// probably do better using a hierarchy
|
||||
val rot = q.twinNearest(reference)
|
||||
rotBuffer?.clear()
|
||||
latestQuaternion = rot
|
||||
filteredQuaternion = rot
|
||||
addQuaternion(rot)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package dev.slimevr.filtering
|
||||
|
||||
import java.util.*
|
||||
|
||||
enum class TrackerFilters(val id: Int, val configKey: String) {
|
||||
NONE(0, "none"),
|
||||
SMOOTHING(1, "smoothing"),
|
||||
PREDICTION(2, "prediction"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val byConfigkey: MutableMap<String, TrackerFilters> = HashMap()
|
||||
|
||||
init {
|
||||
for (configVal in values()) {
|
||||
byConfigkey[configVal.configKey.lowercase(Locale.getDefault())] =
|
||||
configVal
|
||||
}
|
||||
}
|
||||
|
||||
val values = values()
|
||||
|
||||
@JvmStatic
|
||||
fun fromId(id: Int): TrackerFilters? {
|
||||
for (filter in values) {
|
||||
if (filter.id == id) return filter
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getByConfigkey(configKey: String?): TrackerFilters? = if (configKey == null) null else byConfigkey[configKey.lowercase(Locale.getDefault())]
|
||||
}
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import com.mayakapps.kache.KacheStrategy
|
||||
import dev.llelievr.espflashkotlin.Flasher
|
||||
import dev.llelievr.espflashkotlin.FlashingProgressListener
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.serial.ProvisioningListener
|
||||
import dev.slimevr.serial.ProvisioningStatus
|
||||
import dev.slimevr.serial.SerialPort
|
||||
import dev.slimevr.tracking.trackers.Tracker
|
||||
import dev.slimevr.tracking.trackers.TrackerStatus
|
||||
import dev.slimevr.tracking.trackers.TrackerStatusListener
|
||||
import dev.slimevr.tracking.trackers.udp.UDPDevice
|
||||
import io.eiren.util.logging.LogManager
|
||||
import kotlinx.coroutines.*
|
||||
import solarxr_protocol.rpc.FirmwarePartT
|
||||
import solarxr_protocol.rpc.FirmwareUpdateRequestT
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.stream.Collectors
|
||||
import kotlin.concurrent.scheduleAtFixedRate
|
||||
|
||||
data class DownloadedFirmwarePart(
|
||||
val firmware: ByteArray,
|
||||
val offset: Long?,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as DownloadedFirmwarePart
|
||||
|
||||
if (!firmware.contentEquals(other.firmware)) return false
|
||||
if (offset != other.offset) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = firmware.contentHashCode()
|
||||
result = 31 * result + (offset?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class FirmwareUpdateHandler(private val server: VRServer) :
|
||||
TrackerStatusListener,
|
||||
ProvisioningListener,
|
||||
SerialRebootListener {
|
||||
|
||||
private val updateTickTimer = Timer("StatusUpdateTimer")
|
||||
private val runningJobs: MutableList<Job> = CopyOnWriteArrayList()
|
||||
private val watchRestartQueue: MutableList<Pair<UpdateDeviceId<*>, () -> Unit>> =
|
||||
CopyOnWriteArrayList()
|
||||
private val updatingDevicesStatus: MutableMap<UpdateDeviceId<*>, UpdateStatusEvent<*>> =
|
||||
ConcurrentHashMap()
|
||||
private val listeners: MutableList<FirmwareUpdateListener> = CopyOnWriteArrayList()
|
||||
private val mainScope: CoroutineScope = CoroutineScope(SupervisorJob())
|
||||
private var clearJob: Deferred<Unit>? = null
|
||||
|
||||
private var serialRebootHandler: SerialRebootHandler = SerialRebootHandler(watchRestartQueue, server, this)
|
||||
|
||||
fun addListener(channel: FirmwareUpdateListener) {
|
||||
listeners.add(channel)
|
||||
}
|
||||
|
||||
fun removeListener(channel: FirmwareUpdateListener) {
|
||||
listeners.removeIf { channel == it }
|
||||
}
|
||||
|
||||
init {
|
||||
server.addTrackerStatusListener(this)
|
||||
server.provisioningHandler.addListener(this)
|
||||
server.serialHandler.addListener(serialRebootHandler)
|
||||
|
||||
this.updateTickTimer.scheduleAtFixedRate(0, 1000) {
|
||||
checkUpdateTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startOtaUpdate(
|
||||
part: DownloadedFirmwarePart,
|
||||
deviceId: UpdateDeviceId<Int>,
|
||||
): Unit = suspendCancellableCoroutine { c ->
|
||||
val udpDevice: UDPDevice? =
|
||||
(server.deviceManager.devices.find { device -> device is UDPDevice && device.id == deviceId.id }) as UDPDevice?
|
||||
|
||||
if (udpDevice == null) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND,
|
||||
),
|
||||
)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val task = OTAUpdateTask(
|
||||
part.firmware,
|
||||
deviceId,
|
||||
udpDevice.ipAddress,
|
||||
::onStatusChange,
|
||||
)
|
||||
c.invokeOnCancellation {
|
||||
task.cancel()
|
||||
}
|
||||
task.run()
|
||||
}
|
||||
|
||||
private fun startSerialUpdate(
|
||||
firmwares: Array<DownloadedFirmwarePart>,
|
||||
deviceId: UpdateDeviceId<String>,
|
||||
needManualReboot: Boolean,
|
||||
ssid: String,
|
||||
password: String,
|
||||
) {
|
||||
// Can't use .toList() on Android
|
||||
val serialPort = this.server.serialHandler.knownPorts.collect(Collectors.toList())
|
||||
.find { port -> deviceId.id == port.portLocation }
|
||||
|
||||
if (serialPort == null) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val flashingHandler = this.server.serialFlashingHandler
|
||||
|
||||
if (flashingHandler == null) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_UNSUPPORTED_METHOD,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val flasher = Flasher(flashingHandler)
|
||||
|
||||
for (part in firmwares) {
|
||||
if (part.offset == null) {
|
||||
error("Offset is empty")
|
||||
}
|
||||
flasher.addBin(part.firmware, part.offset.toInt())
|
||||
}
|
||||
|
||||
flasher.addProgressListener(object : FlashingProgressListener {
|
||||
override fun progress(progress: Float) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.UPLOADING,
|
||||
(progress * 100).toInt(),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.SYNCING_WITH_MCU,
|
||||
),
|
||||
)
|
||||
flasher.flash(serialPort)
|
||||
if (needManualReboot) {
|
||||
if (watchRestartQueue.find { it.first == deviceId } != null) {
|
||||
LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping")
|
||||
}
|
||||
|
||||
onStatusChange(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.NEED_MANUAL_REBOOT))
|
||||
server.serialHandler.openSerial(deviceId.id, false)
|
||||
watchRestartQueue.add(
|
||||
Pair(deviceId) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.REBOOTING,
|
||||
),
|
||||
)
|
||||
server.provisioningHandler.start(
|
||||
ssid,
|
||||
password,
|
||||
serialPort.portLocation,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
onStatusChange(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.REBOOTING))
|
||||
server.provisioningHandler.start(ssid, password, serialPort.portLocation)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.severe("[FirmwareUpdateHandler] Upload failed", e)
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_UPLOAD_FAILED,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun queueFirmwareUpdate(
|
||||
request: FirmwareUpdateRequestT,
|
||||
deviceId: UpdateDeviceId<*>,
|
||||
) = mainScope.launch {
|
||||
val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method")
|
||||
|
||||
clearJob?.await()
|
||||
if (method == FirmwareUpdateMethod.OTA) {
|
||||
if (watchRestartQueue.find { it.first == deviceId } != null) {
|
||||
LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping")
|
||||
}
|
||||
|
||||
val udpDevice: UDPDevice? =
|
||||
(server.deviceManager.devices.find { device -> device is UDPDevice && device.id == deviceId.id }) as UDPDevice?
|
||||
if (udpDevice === null) {
|
||||
error("invalid state - device does not exist")
|
||||
}
|
||||
|
||||
if (udpDevice.protocolVersion <= 20) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.NEED_MANUAL_REBOOT,
|
||||
),
|
||||
)
|
||||
watchRestartQueue.add(
|
||||
Pair(deviceId) {
|
||||
mainScope.launch {
|
||||
startFirmwareUpdateJob(
|
||||
request,
|
||||
deviceId,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
startFirmwareUpdateJob(
|
||||
request,
|
||||
deviceId,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (updatingDevicesStatus[deviceId] != null) {
|
||||
LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping")
|
||||
return@launch
|
||||
}
|
||||
|
||||
startFirmwareUpdateJob(
|
||||
request,
|
||||
deviceId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelUpdates() {
|
||||
val oldClearJob = clearJob
|
||||
clearJob = mainScope.async {
|
||||
oldClearJob?.await()
|
||||
watchRestartQueue.clear()
|
||||
runningJobs.forEach { it.cancelAndJoin() }
|
||||
runningJobs.clear()
|
||||
LogManager.info("[FirmwareUpdateHandler] Update jobs canceled")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFirmwareParts(request: FirmwareUpdateRequestT): ArrayList<FirmwarePartT> {
|
||||
val parts = ArrayList<FirmwarePartT>()
|
||||
val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method")
|
||||
when (method) {
|
||||
FirmwareUpdateMethod.OTA -> {
|
||||
val updateReq = request.method.asOTAFirmwareUpdate()
|
||||
parts.add(updateReq.firmwarePart)
|
||||
}
|
||||
|
||||
FirmwareUpdateMethod.SERIAL -> {
|
||||
val updateReq = request.method.asSerialFirmwareUpdate()
|
||||
parts.addAll(updateReq.firmwarePart)
|
||||
}
|
||||
|
||||
FirmwareUpdateMethod.NONE -> error("Method should not be NONE")
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
private suspend fun startFirmwareUpdateJob(
|
||||
request: FirmwareUpdateRequestT,
|
||||
deviceId: UpdateDeviceId<*>,
|
||||
) = coroutineScope {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.DOWNLOADING,
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
val toDownloadParts = getFirmwareParts(request)
|
||||
val firmwareParts = try {
|
||||
withTimeoutOrNull(30_000) {
|
||||
toDownloadParts.map {
|
||||
val firmware = downloadFirmware(it.url, it.digest)
|
||||
DownloadedFirmwarePart(
|
||||
firmware,
|
||||
it.offset,
|
||||
)
|
||||
}.toTypedArray()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED,
|
||||
),
|
||||
)
|
||||
LogManager.severe("[FirmwareUpdateHandler] Unable to download firmware", e)
|
||||
return@coroutineScope
|
||||
}
|
||||
|
||||
val job = launch {
|
||||
withTimeout(2 * 60 * 1000) {
|
||||
if (firmwareParts.isNullOrEmpty()) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED,
|
||||
),
|
||||
)
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method")
|
||||
when (method) {
|
||||
FirmwareUpdateMethod.NONE -> error("unsupported method")
|
||||
|
||||
FirmwareUpdateMethod.OTA -> {
|
||||
if (deviceId.id !is Int) {
|
||||
error("invalid state, the device id is not an int")
|
||||
}
|
||||
if (firmwareParts.size > 1) {
|
||||
error("invalid state, ota only use one firmware file")
|
||||
}
|
||||
startOtaUpdate(
|
||||
firmwareParts.first(),
|
||||
UpdateDeviceId(
|
||||
FirmwareUpdateMethod.OTA,
|
||||
deviceId.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
FirmwareUpdateMethod.SERIAL -> {
|
||||
val req = request.method.asSerialFirmwareUpdate()
|
||||
if (deviceId.id !is String) {
|
||||
error("invalid state, the device id is not a string")
|
||||
}
|
||||
startSerialUpdate(
|
||||
firmwareParts,
|
||||
UpdateDeviceId(
|
||||
FirmwareUpdateMethod.SERIAL,
|
||||
deviceId.id,
|
||||
),
|
||||
req.needManualReboot,
|
||||
req.ssid,
|
||||
req.password,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
runningJobs.add(job)
|
||||
} catch (e: Exception) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
if (e is TimeoutCancellationException) FirmwareUpdateStatus.ERROR_TIMEOUT else FirmwareUpdateStatus.ERROR_UNKNOWN,
|
||||
),
|
||||
)
|
||||
if (e !is TimeoutCancellationException) {
|
||||
LogManager.severe("[FirmwareUpdateHandler] Update process timed out", e)
|
||||
e.printStackTrace()
|
||||
}
|
||||
return@coroutineScope
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> onStatusChange(event: UpdateStatusEvent<T>) {
|
||||
this.updatingDevicesStatus[event.deviceId] = event
|
||||
|
||||
if (event.status == FirmwareUpdateStatus.DONE || event.status.isError()) {
|
||||
this.updatingDevicesStatus.remove(event.deviceId)
|
||||
|
||||
// we remove the device from the restart queue
|
||||
val queuedDevice = watchRestartQueue.find { it.first.id == event.deviceId }
|
||||
if (queuedDevice != null) {
|
||||
watchRestartQueue.remove(queuedDevice)
|
||||
if (event.deviceId.type == FirmwareUpdateMethod.SERIAL && server.serialHandler.isConnected) {
|
||||
server.serialHandler.closeSerial()
|
||||
}
|
||||
}
|
||||
|
||||
// We make sure to stop the provisioning routine if the tracker is done
|
||||
// flashing
|
||||
if (event.deviceId.type == FirmwareUpdateMethod.SERIAL) {
|
||||
this.server.provisioningHandler.stop()
|
||||
}
|
||||
}
|
||||
listeners.forEach { l -> l.onUpdateStatusChange(event) }
|
||||
}
|
||||
|
||||
private fun checkUpdateTimeout() {
|
||||
updatingDevicesStatus.forEach { (id, device) ->
|
||||
// if more than 30s between two events, consider the update as stuck
|
||||
// We do not timeout on the Downloading step as it has it own timeout
|
||||
// We do not timeout on the Done step as it is the end of the update process
|
||||
if (!device.status.isError() &&
|
||||
!intArrayOf(FirmwareUpdateStatus.DONE.id, FirmwareUpdateStatus.DOWNLOADING.id).contains(device.status.id) &&
|
||||
System.currentTimeMillis() - device.time > 30 * 1000
|
||||
) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
id,
|
||||
FirmwareUpdateStatus.ERROR_TIMEOUT,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this only works for OTA trackers as the device id
|
||||
// only exists when the usb connection is created
|
||||
override fun onTrackerStatusChanged(
|
||||
tracker: Tracker,
|
||||
oldStatus: TrackerStatus,
|
||||
newStatus: TrackerStatus,
|
||||
) {
|
||||
val device = tracker.device
|
||||
if (device !is UDPDevice) return
|
||||
|
||||
if (oldStatus == TrackerStatus.DISCONNECTED && newStatus == TrackerStatus.OK) {
|
||||
val queuedDevice = watchRestartQueue.find { it.first.id == device.id }
|
||||
|
||||
if (queuedDevice != null) {
|
||||
queuedDevice.second() // we start the queued update task
|
||||
watchRestartQueue.remove(queuedDevice) // then we remove it from the queue
|
||||
return
|
||||
}
|
||||
|
||||
// We can only filter OTA method here as the device id is only provided when using Wi-Fi
|
||||
val deviceStatusKey =
|
||||
updatingDevicesStatus.keys.find { it.type == FirmwareUpdateMethod.OTA && it.id == device.id }
|
||||
?: return
|
||||
val updateStatus = updatingDevicesStatus[deviceStatusKey] ?: return
|
||||
// We check for the reconnection of the tracker, once the tracker reconnected we notify the user that the update is completed
|
||||
if (updateStatus.status == FirmwareUpdateStatus.REBOOTING) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
updateStatus.deviceId,
|
||||
FirmwareUpdateStatus.DONE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProvisioningStatusChange(
|
||||
status: ProvisioningStatus,
|
||||
port: SerialPort?,
|
||||
) {
|
||||
fun update(s: FirmwareUpdateStatus) {
|
||||
val deviceStatusKey =
|
||||
updatingDevicesStatus.keys.find { it.type == FirmwareUpdateMethod.SERIAL && it.id == port?.portLocation }
|
||||
?: return
|
||||
val updateStatus = updatingDevicesStatus[deviceStatusKey] ?: return
|
||||
onStatusChange(UpdateStatusEvent(updateStatus.deviceId, s))
|
||||
}
|
||||
|
||||
when (status) {
|
||||
ProvisioningStatus.PROVISIONING -> update(FirmwareUpdateStatus.PROVISIONING)
|
||||
ProvisioningStatus.DONE -> update(FirmwareUpdateStatus.DONE)
|
||||
ProvisioningStatus.CONNECTION_ERROR, ProvisioningStatus.COULD_NOT_FIND_SERVER -> update(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSerialDeviceReconnect(deviceHandle: Pair<UpdateDeviceId<*>, () -> Unit>) {
|
||||
deviceHandle.second()
|
||||
watchRestartQueue.remove(deviceHandle)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadFirmware(url: String, expectedDigest: String): ByteArray {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
val chunk = ByteArray(4096)
|
||||
var bytesRead: Int
|
||||
val stream: InputStream = URL(url).openStream()
|
||||
while (stream.read(chunk).also { bytesRead = it } > 0) {
|
||||
outputStream.write(chunk, 0, bytesRead)
|
||||
}
|
||||
|
||||
val downloadedData = outputStream.toByteArray()
|
||||
|
||||
if (!verifyChecksum(downloadedData, expectedDigest)) {
|
||||
error("Checksum verification failed for $url")
|
||||
}
|
||||
|
||||
return downloadedData
|
||||
}
|
||||
|
||||
fun verifyChecksum(data: ByteArray, expectedDigest: String): Boolean {
|
||||
val parts = expectedDigest.split(":", limit = 2)
|
||||
if (parts.size != 2) {
|
||||
error("Invalid digest format. Expected 'algorithm:hash' got $expectedDigest")
|
||||
}
|
||||
|
||||
val algorithm = parts[0].uppercase().replace("-", "")
|
||||
val expectedHash = parts[1].lowercase()
|
||||
|
||||
val messageDigest = MessageDigest.getInstance(algorithm)
|
||||
val actualHash = messageDigest.digest(data).joinToString("") {
|
||||
"%02x".format(it)
|
||||
}
|
||||
|
||||
return actualHash == expectedHash
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
interface FirmwareUpdateListener {
|
||||
fun onUpdateStatusChange(event: UpdateStatusEvent<*>)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
enum class FirmwareUpdateMethod(val id: Byte) {
|
||||
NONE(solarxr_protocol.rpc.FirmwareUpdateMethod.NONE),
|
||||
OTA(solarxr_protocol.rpc.FirmwareUpdateMethod.OTAFirmwareUpdate),
|
||||
SERIAL(solarxr_protocol.rpc.FirmwareUpdateMethod.SerialFirmwareUpdate),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun getById(id: Byte): FirmwareUpdateMethod? = byId[id]
|
||||
}
|
||||
}
|
||||
|
||||
private val byId = FirmwareUpdateMethod.entries.associateBy { it.id }
|
||||
@@ -1,29 +0,0 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
enum class FirmwareUpdateStatus(val id: Int) {
|
||||
DOWNLOADING(solarxr_protocol.rpc.FirmwareUpdateStatus.DOWNLOADING),
|
||||
AUTHENTICATING(solarxr_protocol.rpc.FirmwareUpdateStatus.AUTHENTICATING),
|
||||
UPLOADING(solarxr_protocol.rpc.FirmwareUpdateStatus.UPLOADING),
|
||||
SYNCING_WITH_MCU(solarxr_protocol.rpc.FirmwareUpdateStatus.SYNCING_WITH_MCU),
|
||||
REBOOTING(solarxr_protocol.rpc.FirmwareUpdateStatus.REBOOTING),
|
||||
NEED_MANUAL_REBOOT(solarxr_protocol.rpc.FirmwareUpdateStatus.NEED_MANUAL_REBOOT),
|
||||
PROVISIONING(solarxr_protocol.rpc.FirmwareUpdateStatus.PROVISIONING),
|
||||
DONE(solarxr_protocol.rpc.FirmwareUpdateStatus.DONE),
|
||||
ERROR_DEVICE_NOT_FOUND(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND),
|
||||
ERROR_TIMEOUT(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_TIMEOUT),
|
||||
ERROR_DOWNLOAD_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED),
|
||||
ERROR_AUTHENTICATION_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED),
|
||||
ERROR_UPLOAD_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UPLOAD_FAILED),
|
||||
ERROR_PROVISIONING_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED),
|
||||
ERROR_UNSUPPORTED_METHOD(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UNSUPPORTED_METHOD),
|
||||
ERROR_UNKNOWN(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UNKNOWN),
|
||||
;
|
||||
|
||||
fun isError(): Boolean = id in ERROR_DEVICE_NOT_FOUND.id..ERROR_UNKNOWN.id
|
||||
|
||||
companion object {
|
||||
fun getById(id: Int): FirmwareUpdateStatus? = byId[id]
|
||||
}
|
||||
}
|
||||
|
||||
private val byId = FirmwareUpdateStatus.entries.associateBy { it.id }
|
||||
@@ -1,205 +0,0 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import io.eiren.util.logging.LogManager
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.*
|
||||
import java.util.function.Consumer
|
||||
import kotlin.math.min
|
||||
|
||||
class OTAUpdateTask(
|
||||
private val firmware: ByteArray,
|
||||
private val deviceId: UpdateDeviceId<Int>,
|
||||
private val deviceIp: InetAddress,
|
||||
private val statusCallback: Consumer<UpdateStatusEvent<Int>>,
|
||||
) {
|
||||
private val receiveBuffer: ByteArray = ByteArray(38)
|
||||
var socketServer: ServerSocket? = null
|
||||
var uploadSocket: Socket? = null
|
||||
var authSocket: DatagramSocket? = null
|
||||
var canceled: Boolean = false
|
||||
|
||||
@Throws(NoSuchAlgorithmException::class)
|
||||
private fun bytesToMd5(bytes: ByteArray): String {
|
||||
val md5 = MessageDigest.getInstance("MD5")
|
||||
md5.update(bytes)
|
||||
val digest = md5.digest()
|
||||
val md5str = StringBuilder()
|
||||
for (b in digest) {
|
||||
md5str.append(String.format("%02x", b))
|
||||
}
|
||||
return md5str.toString()
|
||||
}
|
||||
|
||||
private fun authenticate(localPort: Int): Boolean {
|
||||
try {
|
||||
DatagramSocket().use { socket ->
|
||||
authSocket = socket
|
||||
statusCallback.accept(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.AUTHENTICATING))
|
||||
LogManager.info("[OTAUpdate] Sending OTA invitation to: $deviceIp")
|
||||
|
||||
val fileMd5 = bytesToMd5(firmware)
|
||||
val message = "$FLASH $localPort ${firmware.size} $fileMd5\n"
|
||||
|
||||
socket.send(DatagramPacket(message.toByteArray(), message.length, deviceIp, PORT))
|
||||
socket.soTimeout = 10000
|
||||
|
||||
val authPacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
|
||||
socket.receive(authPacket)
|
||||
|
||||
val data = String(authPacket.data, 0, authPacket.length)
|
||||
|
||||
// if we received OK directly from the MCU, we do not need to authenticate
|
||||
if (data == "OK") return true
|
||||
|
||||
val args = data.split(" ")
|
||||
|
||||
// The expected auth payload should look like "AUTH AUTH_TOKEN"
|
||||
// if we have less than those two args it means that we are in an invalid state
|
||||
if (args.size != 2 || args[0] != "AUTH") return false
|
||||
|
||||
LogManager.info("[OTAUpdate] Authenticating...")
|
||||
|
||||
val authToken = args[1]
|
||||
val signature = bytesToMd5(UUID.randomUUID().toString().toByteArray())
|
||||
val hashedPassword = bytesToMd5(PASSWORD.toByteArray())
|
||||
val resultText = "$hashedPassword:$authToken:$signature"
|
||||
val payload = bytesToMd5(resultText.toByteArray())
|
||||
|
||||
val authMessage = "$AUTH $signature $payload\n"
|
||||
|
||||
socket.soTimeout = 10000
|
||||
socket.send(
|
||||
DatagramPacket(
|
||||
authMessage.toByteArray(),
|
||||
authMessage.length,
|
||||
deviceIp,
|
||||
PORT,
|
||||
),
|
||||
)
|
||||
|
||||
val authResponsePacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
|
||||
socket.receive(authResponsePacket)
|
||||
|
||||
val authResponse = String(authResponsePacket.data, 0, authResponsePacket.length)
|
||||
|
||||
return authResponse == "OK"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.severe("OTA Authentication exception", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun upload(serverSocket: ServerSocket): Boolean {
|
||||
var connection: Socket? = null
|
||||
try {
|
||||
LogManager.info("[OTAUpdate] Starting on: ${serverSocket.localPort}")
|
||||
LogManager.info("[OTAUpdate] Waiting for device...")
|
||||
|
||||
connection = serverSocket.accept()
|
||||
this.uploadSocket = connection
|
||||
connection.setSoTimeout(1000)
|
||||
val dos = DataOutputStream(connection.getOutputStream())
|
||||
val dis = DataInputStream(connection.getInputStream())
|
||||
|
||||
LogManager.info("[OTAUpdate] Upload size: ${firmware.size} bytes")
|
||||
var offset = 0
|
||||
val chunkSize = 2048
|
||||
while (offset != firmware.size && !canceled) {
|
||||
statusCallback.accept(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.UPLOADING,
|
||||
((offset.toDouble() / firmware.size) * 100).toInt(),
|
||||
),
|
||||
)
|
||||
|
||||
val chunkLen = min(chunkSize, (firmware.size - offset))
|
||||
dos.write(firmware, offset, chunkLen)
|
||||
dos.flush()
|
||||
offset += chunkLen
|
||||
|
||||
// Those skipped bytes are the size written to the MCU. We do not really need that information,
|
||||
// so we simply skip it.
|
||||
// The reason those bytes are skipped here is to not have to skip all of them when checking
|
||||
// for the OK response. Saving time
|
||||
val bytesSkipped = dis.skipBytes(4)
|
||||
// Replicate behaviour of .skipNBytes()
|
||||
if (bytesSkipped != 4) {
|
||||
throw IOException("Unexpected number of bytes skipped: $bytesSkipped")
|
||||
}
|
||||
}
|
||||
if (canceled) return false
|
||||
|
||||
LogManager.info("[OTAUpdate] Waiting for result...")
|
||||
// We set the timeout of the connection bigger as it can take some time for the MCU
|
||||
// to confirm that everything is ok
|
||||
connection.setSoTimeout(10000)
|
||||
val responseBytes = dis.readBytes()
|
||||
val response = String(responseBytes)
|
||||
|
||||
return response.contains("OK")
|
||||
} catch (e: Exception) {
|
||||
LogManager.severe("Unable to upload the firmware using ota", e)
|
||||
return false
|
||||
} finally {
|
||||
connection?.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun run() {
|
||||
ServerSocket(0).use { serverSocket ->
|
||||
socketServer = serverSocket
|
||||
if (!authenticate(serverSocket.localPort)) {
|
||||
statusCallback.accept(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!upload(serverSocket)) {
|
||||
statusCallback.accept(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_UPLOAD_FAILED,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
statusCallback.accept(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.REBOOTING,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
canceled = true
|
||||
socketServer?.close()
|
||||
authSocket?.close()
|
||||
uploadSocket?.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FLASH = 0
|
||||
private const val PORT = 8266
|
||||
private const val PASSWORD = "SlimeVR-OTA"
|
||||
private const val AUTH = 200
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import dev.llelievr.espflashkotlin.FlasherSerialInterface
|
||||
|
||||
interface SerialFlashingHandler : FlasherSerialInterface
|
||||
@@ -1,66 +0,0 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.serial.SerialListener
|
||||
import dev.slimevr.serial.SerialPort
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
interface SerialRebootListener {
|
||||
fun onSerialDeviceReconnect(deviceHandle: Pair<UpdateDeviceId<*>, () -> Unit>)
|
||||
}
|
||||
|
||||
/**
|
||||
* This class watch for a serial device to disconnect then reconnect.
|
||||
* This is used to watch the user progress through the firmware update process
|
||||
*/
|
||||
class SerialRebootHandler(
|
||||
private val watchRestartQueue: MutableList<Pair<UpdateDeviceId<*>, () -> Unit>>,
|
||||
private val server: VRServer,
|
||||
// Could be moved to a list of listeners later
|
||||
private val serialRebootListener: SerialRebootListener,
|
||||
) : SerialListener {
|
||||
|
||||
private var currentPort: SerialPort? = null
|
||||
private val disconnectedDevices: MutableList<SerialPort> = CopyOnWriteArrayList()
|
||||
|
||||
override fun onSerialConnected(port: SerialPort) {
|
||||
currentPort = port
|
||||
}
|
||||
|
||||
override fun onSerialDisconnected() {
|
||||
currentPort = null
|
||||
}
|
||||
|
||||
override fun onSerialLog(str: String, ignored: Boolean) {
|
||||
if (str.contains("starting up...")) {
|
||||
val foundPort = watchRestartQueue.find { it.first.id == currentPort?.portLocation }
|
||||
if (foundPort != null) {
|
||||
disconnectedDevices.remove(currentPort)
|
||||
serialRebootListener.onSerialDeviceReconnect(foundPort)
|
||||
// once the restart detected we close the connection
|
||||
if (server.serialHandler.isConnected) {
|
||||
server.serialHandler.closeSerial()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewSerialDevice(port: SerialPort) {
|
||||
val foundPort = watchRestartQueue.find { it.first.id == port.portLocation }
|
||||
if (foundPort != null && disconnectedDevices.contains(port)) {
|
||||
disconnectedDevices.remove(port)
|
||||
serialRebootListener.onSerialDeviceReconnect(foundPort)
|
||||
// once the restart detected we close the connection
|
||||
if (server.serialHandler.isConnected) {
|
||||
server.serialHandler.closeSerial()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSerialDeviceDeleted(port: SerialPort) {
|
||||
val foundPort = watchRestartQueue.find { it.first.id == port.portLocation }
|
||||
if (foundPort != null) {
|
||||
disconnectedDevices.add(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
data class UpdateDeviceId<T>(
|
||||
val type: FirmwareUpdateMethod,
|
||||
val id: T,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as UpdateDeviceId<*>
|
||||
|
||||
if (type != other.type) return false
|
||||
if (id != other.id) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = type.hashCode()
|
||||
result = 31 * result + (id?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
data class UpdateStatusEvent<T>(
|
||||
val deviceId: UpdateDeviceId<T>,
|
||||
val status: FirmwareUpdateStatus,
|
||||
val progress: Int = 0,
|
||||
val time: Long = System.currentTimeMillis(),
|
||||
)
|
||||
19
server/core/src/main/java/dev/slimevr/firmware/behaviours.kt
Normal file
19
server/core/src/main/java/dev/slimevr/firmware/behaviours.kt
Normal file
@@ -0,0 +1,19 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
object FirmwareManagerBaseBehaviour : FirmwareManagerBehaviour {
|
||||
override fun reduce(state: FirmwareManagerState, action: FirmwareManagerActions) = when (action) {
|
||||
is FirmwareManagerActions.UpdateJob -> state.copy(
|
||||
jobs = state.jobs +
|
||||
(
|
||||
action.portLocation to FirmwareJobStatus(
|
||||
portLocation = action.portLocation,
|
||||
firmwareDeviceId = action.firmwareDeviceId,
|
||||
status = action.status,
|
||||
progress = action.progress,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
is FirmwareManagerActions.RemoveJob -> state.copy(jobs = state.jobs - action.portLocation)
|
||||
}
|
||||
}
|
||||
48
server/core/src/main/java/dev/slimevr/firmware/download.kt
Normal file
48
server/core/src/main/java/dev/slimevr/firmware/download.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
|
||||
data class DownloadedFirmwarePart(
|
||||
val data: ByteArray,
|
||||
val offset: Int,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as DownloadedFirmwarePart
|
||||
if (!data.contentEquals(other.data)) return false
|
||||
return offset == other.offset
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = data.contentHashCode()
|
||||
result = 31 * result + offset
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadFirmware(url: String, digest: String): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
val chunk = ByteArray(4096)
|
||||
URL(url).openStream().use { stream ->
|
||||
while (true) {
|
||||
val read = stream.read(chunk)
|
||||
if (read <= 0) break
|
||||
output.write(chunk, 0, read)
|
||||
}
|
||||
}
|
||||
val data = output.toByteArray()
|
||||
check(verifyChecksum(data, digest)) { "Checksum verification failed for $url" }
|
||||
return data
|
||||
}
|
||||
|
||||
fun verifyChecksum(data: ByteArray, expectedDigest: String): Boolean {
|
||||
val parts = expectedDigest.split(":", limit = 2)
|
||||
check(parts.size == 2) { "Invalid digest format '$expectedDigest', expected 'algorithm:hash'" }
|
||||
val algorithm = parts[0].uppercase().replace("-", "")
|
||||
val expectedHash = parts[1].lowercase()
|
||||
val actualHash = MessageDigest.getInstance(algorithm).digest(data).joinToString("") { "%02x".format(it) }
|
||||
return actualHash == expectedHash
|
||||
}
|
||||
127
server/core/src/main/java/dev/slimevr/firmware/module.kt
Normal file
127
server/core/src/main/java/dev/slimevr/firmware/module.kt
Normal file
@@ -0,0 +1,127 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.context.Behaviour
|
||||
import dev.slimevr.context.Context
|
||||
import dev.slimevr.serial.SerialServer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.launch
|
||||
import solarxr_protocol.datatypes.DeviceIdTable
|
||||
import solarxr_protocol.rpc.FirmwarePart
|
||||
import solarxr_protocol.rpc.FirmwareUpdateDeviceId
|
||||
import solarxr_protocol.rpc.FirmwareUpdateStatus
|
||||
import solarxr_protocol.rpc.SerialDevicePort
|
||||
|
||||
data class FirmwareJobStatus(
|
||||
val portLocation: String,
|
||||
val firmwareDeviceId: FirmwareUpdateDeviceId,
|
||||
val status: FirmwareUpdateStatus,
|
||||
val progress: Int = 0,
|
||||
)
|
||||
|
||||
data class FirmwareManagerState(
|
||||
val jobs: Map<String, FirmwareJobStatus>,
|
||||
)
|
||||
|
||||
sealed interface FirmwareManagerActions {
|
||||
data class UpdateJob(
|
||||
val portLocation: String,
|
||||
val firmwareDeviceId: FirmwareUpdateDeviceId,
|
||||
val status: FirmwareUpdateStatus,
|
||||
val progress: Int = 0,
|
||||
) : FirmwareManagerActions
|
||||
|
||||
data class RemoveJob(val portLocation: String) : FirmwareManagerActions
|
||||
}
|
||||
|
||||
typealias FirmwareManagerContext = Context<FirmwareManagerState, FirmwareManagerActions>
|
||||
typealias FirmwareManagerBehaviour = Behaviour<FirmwareManagerState, FirmwareManagerActions, FirmwareManagerContext>
|
||||
|
||||
class FirmwareManager(
|
||||
val context: FirmwareManagerContext,
|
||||
private val serialServer: SerialServer,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val runningJobs = mutableMapOf<String, Job>()
|
||||
|
||||
suspend fun flash(
|
||||
portLocation: String,
|
||||
parts: List<FirmwarePart>,
|
||||
needManualReboot: Boolean,
|
||||
ssid: String?,
|
||||
password: String?,
|
||||
server: VRServer,
|
||||
) {
|
||||
runningJobs[portLocation]?.cancelAndJoin()
|
||||
runningJobs[portLocation] = scope.launch {
|
||||
doSerialFlash(
|
||||
portLocation = portLocation,
|
||||
parts = parts,
|
||||
needManualReboot = needManualReboot,
|
||||
ssid = ssid,
|
||||
password = password,
|
||||
serialServer = serialServer,
|
||||
server = server,
|
||||
onStatus = { status, progress ->
|
||||
context.dispatch(
|
||||
FirmwareManagerActions.UpdateJob(
|
||||
portLocation = portLocation,
|
||||
firmwareDeviceId = SerialDevicePort(port = portLocation),
|
||||
status = status,
|
||||
progress = progress,
|
||||
),
|
||||
)
|
||||
},
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun otaFlash(
|
||||
deviceIp: String,
|
||||
firmwareDeviceId: FirmwareUpdateDeviceId,
|
||||
part: FirmwarePart,
|
||||
server: VRServer,
|
||||
) {
|
||||
runningJobs[deviceIp]?.cancelAndJoin()
|
||||
runningJobs[deviceIp] = scope.launch {
|
||||
doOtaFlash(
|
||||
deviceIp = deviceIp,
|
||||
deviceId = (firmwareDeviceId as? DeviceIdTable)?.id ?: error("device id should exist"),
|
||||
part = part,
|
||||
server = server,
|
||||
onStatus = { status, progress ->
|
||||
context.dispatch(
|
||||
FirmwareManagerActions.UpdateJob(
|
||||
portLocation = deviceIp,
|
||||
firmwareDeviceId = firmwareDeviceId,
|
||||
status = status,
|
||||
progress = progress,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cancelAll() {
|
||||
runningJobs.values.forEach { it.cancelAndJoin() }
|
||||
runningJobs.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(serialServer: SerialServer, scope: CoroutineScope): FirmwareManager {
|
||||
val behaviours = listOf(FirmwareManagerBaseBehaviour)
|
||||
val context = Context.create(
|
||||
initialState = FirmwareManagerState(jobs = mapOf()),
|
||||
scope = scope,
|
||||
behaviours = behaviours,
|
||||
)
|
||||
val manager = FirmwareManager(context = context, serialServer = serialServer, scope = scope)
|
||||
behaviours.forEach { it.observe(context) }
|
||||
return manager
|
||||
}
|
||||
}
|
||||
}
|
||||
179
server/core/src/main/java/dev/slimevr/firmware/ota.kt
Normal file
179
server/core/src/main/java/dev/slimevr/firmware/ota.kt
Normal file
@@ -0,0 +1,179 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
import io.ktor.network.selector.SelectorManager
|
||||
import io.ktor.network.sockets.BoundDatagramSocket
|
||||
import io.ktor.network.sockets.Datagram
|
||||
import io.ktor.network.sockets.InetSocketAddress
|
||||
import io.ktor.network.sockets.aSocket
|
||||
import io.ktor.utils.io.core.buildPacket
|
||||
import io.ktor.utils.io.core.readText
|
||||
import io.ktor.utils.io.core.writeFully
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import solarxr_protocol.datatypes.DeviceId
|
||||
import solarxr_protocol.datatypes.TrackerStatus
|
||||
import solarxr_protocol.rpc.FirmwarePart
|
||||
import solarxr_protocol.rpc.FirmwareUpdateStatus
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.ServerSocket
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import kotlin.math.min
|
||||
|
||||
private const val OTA_PORT = 8266
|
||||
private const val OTA_PASSWORD = "SlimeVR-OTA"
|
||||
private const val OTA_CHUNK_SIZE = 2048
|
||||
|
||||
private fun bytesToMd5(bytes: ByteArray): String = MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
|
||||
private suspend fun sendDatagram(socket: BoundDatagramSocket, message: String, target: InetSocketAddress) = socket.send(Datagram(buildPacket { writeFully(message.toByteArray()) }, target))
|
||||
|
||||
/**
|
||||
* Sends the OTA invitation over UDP and performs the optional AUTH challenge-response.
|
||||
* Returns true if authentication succeeded (or was not required).
|
||||
*/
|
||||
private suspend fun otaAuthenticate(
|
||||
selectorManager: SelectorManager,
|
||||
deviceIp: String,
|
||||
localPort: Int,
|
||||
firmware: ByteArray,
|
||||
): Boolean {
|
||||
val fileMd5 = bytesToMd5(firmware)
|
||||
val target = InetSocketAddress(deviceIp, OTA_PORT)
|
||||
|
||||
aSocket(selectorManager).udp().bind(InetSocketAddress("0.0.0.0", 0)).use { socket ->
|
||||
sendDatagram(socket, "0 $localPort ${firmware.size} $fileMd5\n", target)
|
||||
|
||||
val responseData = withTimeout(10_000) { socket.receive() }.packet.readText()
|
||||
if (responseData == "OK") return true
|
||||
|
||||
val args = responseData.split(" ")
|
||||
if (args.size != 2 || args[0] != "AUTH") return false
|
||||
|
||||
val authToken = args[1]
|
||||
val signature = bytesToMd5(UUID.randomUUID().toString().toByteArray())
|
||||
val hashedPassword = bytesToMd5(OTA_PASSWORD.toByteArray())
|
||||
val payload = bytesToMd5("$hashedPassword:$authToken:$signature".toByteArray())
|
||||
|
||||
sendDatagram(socket, "200 $signature $payload\n", target)
|
||||
|
||||
val authResponseData = withTimeout(10_000) { socket.receive() }.packet.readText()
|
||||
return authResponseData == "OK"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a TCP connection from the device and streams the firmware in chunks.
|
||||
* Returns true if the device confirmed a successful flash with "OK".
|
||||
*/
|
||||
private suspend fun otaUpload(
|
||||
tcpServer: ServerSocket,
|
||||
firmware: ByteArray,
|
||||
onProgress: suspend (Int) -> Unit,
|
||||
): Boolean {
|
||||
val socket = withContext(Dispatchers.IO) { tcpServer.accept() }
|
||||
return socket.use {
|
||||
socket.soTimeout = 1_000
|
||||
val dos = DataOutputStream(socket.getOutputStream())
|
||||
val dis = DataInputStream(socket.getInputStream())
|
||||
|
||||
var offset = 0
|
||||
while (offset < firmware.size) {
|
||||
onProgress(((offset.toDouble() / firmware.size) * 100).toInt())
|
||||
|
||||
val chunkLen = min(OTA_CHUNK_SIZE, firmware.size - offset)
|
||||
withContext(Dispatchers.IO) {
|
||||
dos.write(firmware, offset, chunkLen)
|
||||
dos.flush()
|
||||
}
|
||||
offset += chunkLen
|
||||
|
||||
val bytesSkipped = withContext(Dispatchers.IO) { dis.skipBytes(4) }
|
||||
if (bytesSkipped != 4) throw IOException("Unexpected bytes skipped: $bytesSkipped")
|
||||
}
|
||||
|
||||
socket.soTimeout = 10_000
|
||||
val response = withContext(Dispatchers.IO) { dis.readBytes().decodeToString() }
|
||||
response.contains("OK")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun doOtaFlash(
|
||||
deviceIp: String,
|
||||
deviceId: DeviceId,
|
||||
part: FirmwarePart,
|
||||
server: VRServer,
|
||||
onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit,
|
||||
) {
|
||||
onStatus(FirmwareUpdateStatus.DOWNLOADING, 0)
|
||||
|
||||
val firmware = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val url = part.url ?: error("missing url")
|
||||
val digest = part.digest ?: error("missing digest")
|
||||
downloadFirmware(url, digest)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED, 0)
|
||||
return
|
||||
}
|
||||
|
||||
onStatus(FirmwareUpdateStatus.AUTHENTICATING, 0)
|
||||
|
||||
SelectorManager(Dispatchers.IO).use { selectorManager ->
|
||||
// Bind TCP server first so we know which port to advertise in the invitation
|
||||
ServerSocket(0).use { tcpServer ->
|
||||
tcpServer.soTimeout = 30_000
|
||||
val localPort = tcpServer.localPort
|
||||
|
||||
if (!otaAuthenticate(selectorManager, deviceIp, localPort, firmware)) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED, 0)
|
||||
return
|
||||
}
|
||||
|
||||
val uploaded = runCatching {
|
||||
otaUpload(tcpServer, firmware) { progress ->
|
||||
onStatus(FirmwareUpdateStatus.UPLOADING, progress)
|
||||
}
|
||||
}
|
||||
|
||||
if (uploaded.isFailure) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_UPLOAD_FAILED, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onStatus(FirmwareUpdateStatus.REBOOTING, 0)
|
||||
|
||||
// Wait for the device to come back online after reboot.
|
||||
// flatMapLatest switches to the matched device's own state flow so that status changes,
|
||||
// which don't emit a new VRServerState, are also observed.
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
val connected = withTimeoutOrNull(60_000) {
|
||||
server.context.state
|
||||
.flatMapLatest { state ->
|
||||
val device = state.devices.values.find { it.context.state.value.id.toUByte() == deviceId.id }
|
||||
device?.context?.state?.map { it.status != TrackerStatus.DISCONNECTED } ?: flowOf(false)
|
||||
}
|
||||
.filter { it }
|
||||
.first()
|
||||
}
|
||||
|
||||
if (connected == null) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_TIMEOUT, 0)
|
||||
return
|
||||
}
|
||||
|
||||
onStatus(FirmwareUpdateStatus.DONE, 0)
|
||||
}
|
||||
203
server/core/src/main/java/dev/slimevr/firmware/serial.kt
Normal file
203
server/core/src/main/java/dev/slimevr/firmware/serial.kt
Normal file
@@ -0,0 +1,203 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import dev.llelievr.espflashkotlin.Flasher
|
||||
import dev.llelievr.espflashkotlin.FlashingProgressListener
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.serial.SerialConnection
|
||||
import dev.slimevr.serial.SerialServer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import solarxr_protocol.datatypes.TrackerStatus
|
||||
import solarxr_protocol.rpc.FirmwarePart
|
||||
import solarxr_protocol.rpc.FirmwareUpdateStatus
|
||||
|
||||
private val MAC_REGEX = Regex("mac: (([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2})", RegexOption.IGNORE_CASE)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun doSerialFlash(
|
||||
portLocation: String,
|
||||
parts: List<FirmwarePart>,
|
||||
needManualReboot: Boolean,
|
||||
ssid: String?,
|
||||
password: String?,
|
||||
serialServer: SerialServer,
|
||||
server: VRServer,
|
||||
onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
onStatus(FirmwareUpdateStatus.DOWNLOADING, 0)
|
||||
|
||||
val downloadedParts = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
parts.map { part ->
|
||||
val url = part.url ?: error("missing url")
|
||||
val digest = part.digest ?: error("missing digest")
|
||||
DownloadedFirmwarePart(
|
||||
data = downloadFirmware(url, digest),
|
||||
offset = part.offset.toInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED, 0)
|
||||
return
|
||||
}
|
||||
|
||||
onStatus(FirmwareUpdateStatus.SYNCING_WITH_MCU, 0)
|
||||
|
||||
val handler = serialServer.openForFlashing(portLocation) ?: run {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, 0)
|
||||
return
|
||||
}
|
||||
|
||||
val flasher = Flasher(handler)
|
||||
for (part in downloadedParts) {
|
||||
flasher.addBin(part.data, part.offset)
|
||||
}
|
||||
flasher.addProgressListener(
|
||||
object : FlashingProgressListener {
|
||||
override fun progress(progress: Float) {
|
||||
scope.launch { onStatus(FirmwareUpdateStatus.UPLOADING, (progress * 100).toInt()) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val runFlasher = runCatching {
|
||||
withContext(Dispatchers.IO) { flasher.flash(portLocation) }
|
||||
}
|
||||
|
||||
if (runFlasher.isFailure) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_UPLOAD_FAILED, 0)
|
||||
return
|
||||
}
|
||||
|
||||
doSerialFlashPostFlash(
|
||||
portLocation = portLocation,
|
||||
needManualReboot = needManualReboot,
|
||||
ssid = ssid,
|
||||
password = password,
|
||||
serialServer = serialServer,
|
||||
server = server,
|
||||
onStatus = onStatus,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the post-flash provisioning phase: reconnects the serial console,
|
||||
* reads the device MAC address, sends Wi-Fi credentials, and waits for the
|
||||
* tracker to appear on the network.
|
||||
*
|
||||
* Separated from [doSerialFlash] so it can also be exercised independently for
|
||||
* unit tests
|
||||
*/
|
||||
internal suspend fun doSerialFlashPostFlash(
|
||||
portLocation: String,
|
||||
needManualReboot: Boolean,
|
||||
ssid: String?,
|
||||
password: String?,
|
||||
serialServer: SerialServer,
|
||||
server: VRServer,
|
||||
onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit,
|
||||
) {
|
||||
onStatus(
|
||||
if (needManualReboot) {
|
||||
FirmwareUpdateStatus.NEED_MANUAL_REBOOT
|
||||
} else {
|
||||
FirmwareUpdateStatus.REBOOTING
|
||||
},
|
||||
0,
|
||||
)
|
||||
|
||||
serialServer.openConnection(portLocation)
|
||||
val serialConn = serialServer.context.state.value.connections[portLocation]
|
||||
if (serialConn == null) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, 0)
|
||||
return
|
||||
}
|
||||
if (serialConn !is SerialConnection.Console) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_UNKNOWN, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (needManualReboot) {
|
||||
// wait for the device to reboot
|
||||
val rebooted = withTimeoutOrNull(60_000) {
|
||||
serialConn.context.state.map { it.logLines }
|
||||
.filter { logLines -> logLines.any { "starting up" in it.lowercase() } }
|
||||
.first()
|
||||
}
|
||||
|
||||
if (rebooted == null) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_TIMEOUT, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// get MAC address by sending GET INFO and parsing the response
|
||||
serialConn.handle.writeCommand("GET INFO")
|
||||
|
||||
val macAddress = withTimeoutOrNull(10_000) {
|
||||
serialConn.context.state.map { it.logLines }.mapNotNull { logLines ->
|
||||
logLines.firstNotNullOfOrNull { line ->
|
||||
MAC_REGEX.find(line)?.groupValues?.get(1)?.uppercase()
|
||||
}
|
||||
}.first()
|
||||
}
|
||||
|
||||
if (macAddress == null) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, 0)
|
||||
return
|
||||
}
|
||||
|
||||
// provision with Wi-Fi credentials
|
||||
if (ssid == null || password == null) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, 0)
|
||||
return
|
||||
}
|
||||
|
||||
onStatus(FirmwareUpdateStatus.PROVISIONING, 0)
|
||||
serialConn.handle.writeCommand("SET WIFI \"$ssid\" \"$password\"\n")
|
||||
|
||||
// Wait for Wi-Fi to connect ("looking for the server")
|
||||
val provisioned = withTimeoutOrNull(30_000) {
|
||||
serialConn.context.state.map { it.logLines }.filter { logLines ->
|
||||
logLines.any {
|
||||
"looking for the server" in it.lowercase() || "searching for the server" in it.lowercase()
|
||||
}
|
||||
}.first()
|
||||
}
|
||||
|
||||
if (provisioned == null) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, 0)
|
||||
return
|
||||
}
|
||||
|
||||
// wait for the tracker with that MAC to connect to the server via UDP
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val connected = withTimeoutOrNull(60_000) {
|
||||
server.context.state
|
||||
.flatMapLatest { state ->
|
||||
val device = state.devices.values.find { it.context.state.value.macAddress?.uppercase() == macAddress }
|
||||
device?.context?.state?.map { it.status != TrackerStatus.DISCONNECTED } ?: flowOf(false)
|
||||
}
|
||||
.filter { it }
|
||||
.first()
|
||||
}
|
||||
|
||||
if (connected == null) {
|
||||
onStatus(FirmwareUpdateStatus.ERROR_TIMEOUT, 0)
|
||||
return
|
||||
}
|
||||
|
||||
onStatus(FirmwareUpdateStatus.DONE, 0)
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package dev.slimevr.games.vrchat
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigToggles
|
||||
import dev.slimevr.tracking.trackers.TrackerPosition
|
||||
import dev.slimevr.tracking.trackers.TrackerUtils
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.math.*
|
||||
|
||||
enum class VRCTrackerModel(val value: Int, val id: Int) {
|
||||
UNKNOWN(-1, solarxr_protocol.rpc.VRCTrackerModel.UNKNOWN),
|
||||
SPHERE(0, solarxr_protocol.rpc.VRCTrackerModel.SPHERE),
|
||||
SYSTEM(1, solarxr_protocol.rpc.VRCTrackerModel.SYSTEM),
|
||||
BOX(2, solarxr_protocol.rpc.VRCTrackerModel.BOX),
|
||||
AXIS(3, solarxr_protocol.rpc.VRCTrackerModel.AXIS),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val byValue = VRCTrackerModel.entries.associateBy { it.value }
|
||||
|
||||
fun getByValue(value: Int): VRCTrackerModel? = byValue[value]
|
||||
}
|
||||
}
|
||||
|
||||
enum class VRCSpineMode(val value: Int, val id: Int) {
|
||||
UNKNOWN(-1, solarxr_protocol.rpc.VRCSpineMode.UNKNOWN),
|
||||
LOCK_HIP(0, solarxr_protocol.rpc.VRCSpineMode.LOCK_HIP),
|
||||
LOCK_HEAD(1, solarxr_protocol.rpc.VRCSpineMode.LOCK_HEAD),
|
||||
LOCK_BOTH(2, solarxr_protocol.rpc.VRCSpineMode.LOCK_BOTH),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val byValue = VRCSpineMode.entries.associateBy { it.value }
|
||||
|
||||
fun getByValue(value: Int): VRCSpineMode? = byValue[value]
|
||||
}
|
||||
}
|
||||
|
||||
enum class VRCAvatarMeasurementType(val value: Int, val id: Int) {
|
||||
UNKNOWN(-1, solarxr_protocol.rpc.VRCAvatarMeasurementType.UNKNOWN),
|
||||
ARM_SPAN(0, solarxr_protocol.rpc.VRCAvatarMeasurementType.ARM_SPAN),
|
||||
HEIGHT(1, solarxr_protocol.rpc.VRCAvatarMeasurementType.HEIGHT),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val byValue = VRCAvatarMeasurementType.entries.associateBy { it.value }
|
||||
|
||||
fun getByValue(value: Int): VRCAvatarMeasurementType? = byValue[value]
|
||||
}
|
||||
}
|
||||
|
||||
data class VRCConfigValues(
|
||||
val legacyMode: Boolean,
|
||||
val shoulderTrackingDisabled: Boolean,
|
||||
val shoulderWidthCompensation: Boolean,
|
||||
val userHeight: Double,
|
||||
val calibrationRange: Double,
|
||||
val calibrationVisuals: Boolean,
|
||||
val trackerModel: VRCTrackerModel,
|
||||
val spineMode: VRCSpineMode,
|
||||
val avatarMeasurementType: VRCAvatarMeasurementType,
|
||||
)
|
||||
|
||||
data class VRCConfigRecommendedValues(
|
||||
val legacyMode: Boolean,
|
||||
val shoulderTrackingDisabled: Boolean,
|
||||
val shoulderWidthCompensation: Boolean,
|
||||
val userHeight: Double,
|
||||
val calibrationRange: Double,
|
||||
val calibrationVisuals: Boolean,
|
||||
val trackerModel: VRCTrackerModel,
|
||||
val spineMode: Array<VRCSpineMode>,
|
||||
val avatarMeasurementType: VRCAvatarMeasurementType,
|
||||
)
|
||||
|
||||
data class VRCConfigValidity(
|
||||
val legacyModeOk: Boolean,
|
||||
val shoulderTrackingOk: Boolean,
|
||||
val shoulderWidthCompensationOk: Boolean,
|
||||
val userHeightOk: Boolean,
|
||||
val calibrationRangeOk: Boolean,
|
||||
val calibrationVisualsOk: Boolean,
|
||||
val trackerModelOk: Boolean,
|
||||
val spineModeOk: Boolean,
|
||||
val avatarMeasurementTypeOk: Boolean,
|
||||
)
|
||||
|
||||
abstract class VRCConfigHandler {
|
||||
abstract val isSupported: Boolean
|
||||
abstract fun initHandler(onChange: (config: VRCConfigValues) -> Unit)
|
||||
}
|
||||
|
||||
class VRCConfigHandlerStub : VRCConfigHandler() {
|
||||
override val isSupported: Boolean
|
||||
get() = false
|
||||
|
||||
override fun initHandler(onChange: (config: VRCConfigValues) -> Unit) {}
|
||||
}
|
||||
|
||||
interface VRCConfigListener {
|
||||
fun onChange(validity: VRCConfigValidity, values: VRCConfigValues, recommended: VRCConfigRecommendedValues, muted: List<String>)
|
||||
}
|
||||
|
||||
class VRChatConfigManager(val server: VRServer, private val handler: VRCConfigHandler) {
|
||||
|
||||
private val listeners: MutableList<VRCConfigListener> = CopyOnWriteArrayList()
|
||||
var currentValues: VRCConfigValues? = null
|
||||
var currentValidity: VRCConfigValidity? = null
|
||||
|
||||
val isSupported: Boolean
|
||||
get() = handler.isSupported
|
||||
|
||||
init {
|
||||
handler.initHandler(::onChange)
|
||||
}
|
||||
|
||||
fun toggleMuteWarning(key: String) {
|
||||
val keys = VRCConfigValidity::class.java.declaredFields.asSequence().map { p -> p.name }
|
||||
if (!keys.contains(key)) return
|
||||
|
||||
if (!server.configManager.vrConfig.vrcConfig.mutedWarnings.contains(key)) {
|
||||
server.configManager.vrConfig.vrcConfig.mutedWarnings.add(key)
|
||||
} else {
|
||||
server.configManager.vrConfig.vrcConfig.mutedWarnings.remove(key)
|
||||
}
|
||||
|
||||
server.configManager.saveConfig()
|
||||
|
||||
val recommended = recommendedValues()
|
||||
val validity = currentValidity ?: return
|
||||
val values = currentValues ?: return
|
||||
listeners.forEach {
|
||||
it.onChange(
|
||||
validity,
|
||||
values,
|
||||
recommended,
|
||||
server.configManager.vrConfig.vrcConfig.mutedWarnings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* shoulderTrackingDisabled should be true if:
|
||||
* The user isn't tracking their whole arms from their controllers:
|
||||
* forceArmsFromHMD is enabled || the user doesn't have hand trackers with position || the user doesn't have lower arms trackers || the user doesn't have upper arm trackers
|
||||
* And the user isn't tracking their arms from their HMD or doesn't have both shoulders:
|
||||
* (forceArmsFromHMD is disabled && user has hand trackers with position) || user is missing a shoulder tracker
|
||||
*/
|
||||
fun recommendedValues(): VRCConfigRecommendedValues {
|
||||
val forceArmsFromHMD = server.humanPoseManager.getToggle(SkeletonConfigToggles.FORCE_ARMS_FROM_HMD)
|
||||
|
||||
val hasLeftHandWithPosition = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_HAND)?.hasPosition ?: false
|
||||
val hasRightHandWithPosition = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_HAND)?.hasPosition ?: false
|
||||
|
||||
val isMissingAnArmTracker = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_LOWER_ARM) == null ||
|
||||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_LOWER_ARM) == null ||
|
||||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_UPPER_ARM) == null ||
|
||||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_UPPER_ARM) == null
|
||||
val isMissingAShoulderTracker = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_SHOULDER) == null ||
|
||||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_SHOULDER) == null
|
||||
|
||||
return VRCConfigRecommendedValues(
|
||||
legacyMode = false,
|
||||
shoulderTrackingDisabled =
|
||||
((forceArmsFromHMD || !hasLeftHandWithPosition || !hasRightHandWithPosition) || isMissingAnArmTracker) && // Not tracking shoulders from hands
|
||||
((!forceArmsFromHMD && hasLeftHandWithPosition && hasRightHandWithPosition) || isMissingAShoulderTracker), // Not tracking shoulders from HMD
|
||||
userHeight = server.humanPoseManager.realUserHeight.toDouble(),
|
||||
calibrationRange = 0.2,
|
||||
trackerModel = VRCTrackerModel.AXIS,
|
||||
spineMode = arrayOf(VRCSpineMode.LOCK_HIP, VRCSpineMode.LOCK_HEAD),
|
||||
calibrationVisuals = true,
|
||||
avatarMeasurementType = VRCAvatarMeasurementType.HEIGHT,
|
||||
shoulderWidthCompensation = true,
|
||||
)
|
||||
}
|
||||
|
||||
fun addListener(listener: VRCConfigListener) {
|
||||
listeners.add(listener)
|
||||
val values = currentValues ?: return
|
||||
val recommended = recommendedValues()
|
||||
val validity = checkValidity(values, recommended)
|
||||
listener.onChange(validity, values, recommended, server.configManager.vrConfig.vrcConfig.mutedWarnings)
|
||||
}
|
||||
|
||||
fun removeListener(listener: VRCConfigListener) {
|
||||
listeners.removeIf { l -> l === listener }
|
||||
}
|
||||
|
||||
fun checkValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedValues): VRCConfigValidity = VRCConfigValidity(
|
||||
legacyModeOk = values.legacyMode == recommended.legacyMode,
|
||||
shoulderTrackingOk = values.shoulderTrackingDisabled == recommended.shoulderTrackingDisabled,
|
||||
spineModeOk = recommended.spineMode.contains(values.spineMode),
|
||||
trackerModelOk = values.trackerModel == recommended.trackerModel,
|
||||
calibrationRangeOk = abs(values.calibrationRange - recommended.calibrationRange) < 0.1,
|
||||
userHeightOk = abs(server.humanPoseManager.realUserHeight - values.userHeight) < 0.1,
|
||||
calibrationVisualsOk = values.calibrationVisuals == recommended.calibrationVisuals,
|
||||
avatarMeasurementTypeOk = values.avatarMeasurementType == recommended.avatarMeasurementType,
|
||||
shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation,
|
||||
)
|
||||
|
||||
fun forceUpdate() {
|
||||
val values = currentValues
|
||||
if (values != null) {
|
||||
this.onChange(values)
|
||||
}
|
||||
}
|
||||
|
||||
fun onChange(values: VRCConfigValues) {
|
||||
val recommended = recommendedValues()
|
||||
val validity = checkValidity(values, recommended)
|
||||
currentValidity = validity
|
||||
currentValues = values
|
||||
listeners.forEach {
|
||||
it.onChange(validity, values, recommended, server.configManager.vrConfig.vrcConfig.mutedWarnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package dev.slimevr.guards
|
||||
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
class ServerGuards {
|
||||
|
||||
var canDoMounting: Boolean = false
|
||||
var canDoYawReset: Boolean = false
|
||||
var canDoUserHeightCalibration: Boolean = false
|
||||
|
||||
private val timer = Timer()
|
||||
private var mountingTimeoutTask: TimerTask? = null
|
||||
|
||||
fun onFullReset() {
|
||||
canDoMounting = true
|
||||
canDoYawReset = true
|
||||
mountingTimeoutTask?.cancel()
|
||||
mountingTimeoutTask = timer.schedule(MOUNTING_RESET_TIMEOUT) {
|
||||
canDoMounting = false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MOUNTING_RESET_TIMEOUT = 2 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
@file:OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
|
||||
package dev.slimevr.heightcalibration
|
||||
|
||||
import io.github.axisangles.ktmath.Vector3
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import solarxr_protocol.rpc.UserHeightCalibrationStatus
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
internal const val SAMPLE_INTERVAL_MS = 16L
|
||||
|
||||
private const val FLOOR_ALPHA = 0.1f
|
||||
private const val HMD_ALPHA = 0.1f
|
||||
|
||||
private const val CONTROLLER_STABILITY_THRESHOLD = 0.005f
|
||||
internal const val CONTROLLER_STABILITY_DURATION = 300_000_000L
|
||||
|
||||
private const val HMD_STABILITY_THRESHOLD = 0.003f
|
||||
internal const val HEAD_STABILITY_DURATION = 600_000_000L
|
||||
|
||||
internal const val MAX_FLOOR_Y = 0.10f
|
||||
internal const val HMD_RISE_THRESHOLD = 1.2f
|
||||
internal const val HEIGHT_MIN = 1.4f
|
||||
internal const val HEIGHT_MAX = 1.936f
|
||||
|
||||
private val HEAD_ANGLE_THRESHOLD = cos((PI / 180.0) * 15.0)
|
||||
private val CONTROLLER_ANGLE_THRESHOLD = cos((PI / 180.0) * 45.0)
|
||||
|
||||
internal const val TIMEOUT_MS = 30_000L
|
||||
|
||||
private fun UserHeightCalibrationStatus.isTerminal() = when (this) {
|
||||
UserHeightCalibrationStatus.DONE,
|
||||
UserHeightCalibrationStatus.ERROR_TOO_HIGH,
|
||||
UserHeightCalibrationStatus.ERROR_TOO_SMALL,
|
||||
-> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun isControllerPointingDown(snapshot: TrackerSnapshot): Boolean {
|
||||
val forward = snapshot.rotation.sandwich(Vector3.NEG_Z)
|
||||
return (forward dot Vector3.NEG_Y) >= CONTROLLER_ANGLE_THRESHOLD
|
||||
}
|
||||
|
||||
private fun isHmdLeveled(snapshot: TrackerSnapshot): Boolean {
|
||||
val up = snapshot.rotation.sandwich(Vector3.POS_Y)
|
||||
return (up dot Vector3.POS_Y) >= HEAD_ANGLE_THRESHOLD
|
||||
}
|
||||
|
||||
object CalibrationBehaviour : HeightCalibrationBehaviourType {
|
||||
override fun reduce(state: HeightCalibrationState, action: HeightCalibrationActions) = when (action) {
|
||||
is HeightCalibrationActions.Update -> state.copy(
|
||||
status = action.status,
|
||||
currentHeight = action.currentHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun runCalibrationSession(
|
||||
context: HeightCalibrationContext,
|
||||
hmdUpdates: kotlinx.coroutines.flow.Flow<TrackerSnapshot>,
|
||||
controllerUpdates: kotlinx.coroutines.flow.Flow<TrackerSnapshot>,
|
||||
clock: () -> Long = System::nanoTime,
|
||||
) {
|
||||
var currentFloorLevel = Float.MAX_VALUE
|
||||
var currentHeight = 0f
|
||||
var floorStableStart: Long? = null
|
||||
var heightStableStart: Long? = null
|
||||
|
||||
var floorFiltered: Vector3? = null
|
||||
var floorEnergyEma = 0f
|
||||
var hmdFiltered: Vector3? = null
|
||||
var hmdEnergyEma = 0f
|
||||
|
||||
fun dispatch(status: UserHeightCalibrationStatus, height: Float = currentHeight) {
|
||||
currentHeight = height
|
||||
context.dispatch(HeightCalibrationActions.Update(status, height))
|
||||
}
|
||||
|
||||
dispatch(UserHeightCalibrationStatus.RECORDING_FLOOR)
|
||||
|
||||
withTimeoutOrNull(TIMEOUT_MS) {
|
||||
// Floor phase: collect controller updates until the floor level is locked in
|
||||
controllerUpdates
|
||||
.sample(SAMPLE_INTERVAL_MS)
|
||||
.takeWhile { context.state.value.status != UserHeightCalibrationStatus.WAITING_FOR_RISE }
|
||||
.collect { snapshot ->
|
||||
val now = clock()
|
||||
|
||||
if (snapshot.position.y > MAX_FLOOR_Y) {
|
||||
floorStableStart = null
|
||||
floorFiltered = null
|
||||
floorEnergyEma = 0f
|
||||
return@collect
|
||||
}
|
||||
|
||||
if (!isControllerPointingDown(snapshot)) {
|
||||
dispatch(UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH)
|
||||
floorStableStart = null
|
||||
floorFiltered = null
|
||||
floorEnergyEma = 0f
|
||||
return@collect
|
||||
}
|
||||
|
||||
val pos = snapshot.position
|
||||
val prev = floorFiltered ?: pos
|
||||
val newFiltered = prev * (1f - FLOOR_ALPHA) + pos * FLOOR_ALPHA
|
||||
floorFiltered = newFiltered
|
||||
currentFloorLevel = minOf(currentFloorLevel, pos.y)
|
||||
|
||||
val dev = pos - newFiltered
|
||||
floorEnergyEma = floorEnergyEma * (1f - FLOOR_ALPHA) + (dev dot dev) * FLOOR_ALPHA
|
||||
|
||||
if (sqrt(floorEnergyEma) > CONTROLLER_STABILITY_THRESHOLD) {
|
||||
floorStableStart = null
|
||||
floorFiltered = null
|
||||
floorEnergyEma = 0f
|
||||
return@collect
|
||||
}
|
||||
|
||||
val stableStart = floorStableStart ?: now.also { floorStableStart = it }
|
||||
if (now - stableStart >= CONTROLLER_STABILITY_DURATION) {
|
||||
dispatch(UserHeightCalibrationStatus.WAITING_FOR_RISE)
|
||||
}
|
||||
}
|
||||
|
||||
// Height phase: collect HMD updates until a terminal status is reached
|
||||
hmdUpdates
|
||||
.sample(SAMPLE_INTERVAL_MS)
|
||||
.takeWhile { !context.state.value.status.isTerminal() }
|
||||
.collect { snapshot ->
|
||||
val now = clock()
|
||||
val relativeY = snapshot.position.y - currentFloorLevel
|
||||
|
||||
if (relativeY <= HMD_RISE_THRESHOLD) {
|
||||
dispatch(UserHeightCalibrationStatus.WAITING_FOR_RISE, relativeY)
|
||||
heightStableStart = null
|
||||
hmdFiltered = null
|
||||
hmdEnergyEma = 0f
|
||||
return@collect
|
||||
}
|
||||
|
||||
if (!isHmdLeveled(snapshot)) {
|
||||
dispatch(UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK, relativeY)
|
||||
heightStableStart = null
|
||||
hmdFiltered = null
|
||||
hmdEnergyEma = 0f
|
||||
return@collect
|
||||
}
|
||||
|
||||
dispatch(UserHeightCalibrationStatus.RECORDING_HEIGHT, relativeY)
|
||||
|
||||
val pos = snapshot.position
|
||||
val prev = hmdFiltered ?: pos
|
||||
val newFiltered = prev * (1f - HMD_ALPHA) + pos * HMD_ALPHA
|
||||
hmdFiltered = newFiltered
|
||||
|
||||
val dev = pos - newFiltered
|
||||
hmdEnergyEma = hmdEnergyEma * (1f - HMD_ALPHA) + (dev dot dev) * HMD_ALPHA
|
||||
|
||||
if (sqrt(hmdEnergyEma) > HMD_STABILITY_THRESHOLD) {
|
||||
heightStableStart = null
|
||||
hmdFiltered = null
|
||||
hmdEnergyEma = 0f
|
||||
return@collect
|
||||
}
|
||||
|
||||
val stableStart = heightStableStart ?: now.also { heightStableStart = it }
|
||||
if (now - stableStart >= HEAD_STABILITY_DURATION) {
|
||||
val finalStatus = when {
|
||||
relativeY < HEIGHT_MIN -> UserHeightCalibrationStatus.ERROR_TOO_SMALL
|
||||
relativeY > HEIGHT_MAX -> UserHeightCalibrationStatus.ERROR_TOO_HIGH
|
||||
else -> UserHeightCalibrationStatus.DONE
|
||||
}
|
||||
dispatch(finalStatus, relativeY)
|
||||
|
||||
// TODO (when DONE): persist height to config, update user proportions, clear mounting reset flags
|
||||
}
|
||||
}
|
||||
} ?: dispatch(UserHeightCalibrationStatus.ERROR_TIMEOUT)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
|
||||
package dev.slimevr.heightcalibration
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.context.Behaviour
|
||||
import dev.slimevr.context.Context
|
||||
import io.github.axisangles.ktmath.Quaternion
|
||||
import io.github.axisangles.ktmath.Vector3
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import solarxr_protocol.datatypes.BodyPart
|
||||
import solarxr_protocol.rpc.UserHeightCalibrationStatus
|
||||
|
||||
data class TrackerSnapshot(val position: Vector3, val rotation: Quaternion)
|
||||
|
||||
data class HeightCalibrationState(
|
||||
val status: UserHeightCalibrationStatus,
|
||||
val currentHeight: Float,
|
||||
)
|
||||
|
||||
sealed interface HeightCalibrationActions {
|
||||
data class Update(val status: UserHeightCalibrationStatus, val currentHeight: Float) : HeightCalibrationActions
|
||||
}
|
||||
|
||||
typealias HeightCalibrationContext = Context<HeightCalibrationState, HeightCalibrationActions>
|
||||
typealias HeightCalibrationBehaviourType = Behaviour<HeightCalibrationState, HeightCalibrationActions, HeightCalibrationManager>
|
||||
|
||||
val INITIAL_HEIGHT_CALIBRATION_STATE = HeightCalibrationState(
|
||||
status = UserHeightCalibrationStatus.NONE,
|
||||
currentHeight = 0f,
|
||||
)
|
||||
|
||||
class HeightCalibrationManager(
|
||||
val context: HeightCalibrationContext,
|
||||
val serverContext: VRServer,
|
||||
) {
|
||||
private var sessionJob: Job? = null
|
||||
|
||||
// These Flows do nothing until the calibration use collect on it
|
||||
val hmdUpdates: Flow<TrackerSnapshot> = serverContext.context.state
|
||||
.flatMapLatest { state ->
|
||||
val hmd = state.trackers.values
|
||||
.find { it.context.state.value.bodyPart == BodyPart.HEAD && it.context.state.value.position != null }
|
||||
?: return@flatMapLatest emptyFlow()
|
||||
hmd.context.state.map { s ->
|
||||
TrackerSnapshot(
|
||||
position = s.position ?: error("head (or HMD) will always have a position in this case"),
|
||||
rotation = s.rawRotation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val controllerUpdates: Flow<TrackerSnapshot> = serverContext.context.state
|
||||
.flatMapLatest { state ->
|
||||
val controllers = state.trackers.values.filter {
|
||||
val bodyPart = it.context.state.value.bodyPart
|
||||
(bodyPart == BodyPart.LEFT_HAND || bodyPart == BodyPart.RIGHT_HAND) && it.context.state.value.position != null
|
||||
}
|
||||
if (controllers.isEmpty()) return@flatMapLatest emptyFlow()
|
||||
combine(
|
||||
controllers.map { controller ->
|
||||
controller.context.state.map { s ->
|
||||
val position = s.position ?: error("hands (or Controller) will always have a position in this case")
|
||||
TrackerSnapshot(position = position, rotation = s.rawRotation)
|
||||
}
|
||||
},
|
||||
) { snapshots -> snapshots.minByOrNull { it.position.y }!! }
|
||||
}
|
||||
|
||||
fun start() {
|
||||
sessionJob?.cancel()
|
||||
sessionJob = context.scope.launch { runCalibrationSession(context, hmdUpdates, controllerUpdates) }
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
sessionJob?.cancel()
|
||||
sessionJob = null
|
||||
context.dispatch(HeightCalibrationActions.Update(UserHeightCalibrationStatus.NONE, 0f))
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
serverContext: VRServer,
|
||||
scope: CoroutineScope,
|
||||
): HeightCalibrationManager {
|
||||
val behaviours = listOf(CalibrationBehaviour)
|
||||
val context = Context.create(
|
||||
initialState = INITIAL_HEIGHT_CALIBRATION_STATE,
|
||||
scope = scope,
|
||||
behaviours = behaviours,
|
||||
)
|
||||
return HeightCalibrationManager(context = context, serverContext = serverContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
171
server/core/src/main/java/dev/slimevr/hid/behaviours.kt
Normal file
171
server/core/src/main/java/dev/slimevr/hid/behaviours.kt
Normal file
@@ -0,0 +1,171 @@
|
||||
package dev.slimevr.hid
|
||||
|
||||
import dev.slimevr.AppLogger
|
||||
import dev.slimevr.VRServerActions
|
||||
import dev.slimevr.device.Device
|
||||
import dev.slimevr.device.DeviceActions
|
||||
import dev.slimevr.device.DeviceOrigin
|
||||
import dev.slimevr.tracker.Tracker
|
||||
import dev.slimevr.tracker.TrackerActions
|
||||
import solarxr_protocol.datatypes.TrackerStatus
|
||||
|
||||
object HIDRegistrationBehaviour : HIDReceiverBehaviour {
|
||||
override fun reduce(state: HIDReceiverState, action: HIDReceiverActions) = when (action) {
|
||||
is HIDReceiverActions.DeviceRegistered -> state.copy(
|
||||
trackers = state.trackers +
|
||||
(
|
||||
action.hidId to HIDTrackerRecord(
|
||||
hidId = action.hidId,
|
||||
address = action.address,
|
||||
deviceId = action.deviceId,
|
||||
trackerId = null,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
else -> state
|
||||
}
|
||||
|
||||
override fun observe(receiver: HIDReceiver) {
|
||||
receiver.packetEvents.onPacket<HIDDeviceRegister> { packet ->
|
||||
val state = receiver.context.state.value
|
||||
val existing = state.trackers[packet.hidId]
|
||||
if (existing != null) return@onPacket
|
||||
|
||||
val existingDevice = receiver.serverContext.context.state.value.devices.values
|
||||
.find { it.context.state.value.macAddress == packet.address && it.context.state.value.origin == DeviceOrigin.HID }
|
||||
|
||||
if (existingDevice != null) {
|
||||
receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, existingDevice.context.state.value.id))
|
||||
AppLogger.hid.info("Reconnected HID device ${packet.address} (hidId=${packet.hidId})")
|
||||
return@onPacket
|
||||
}
|
||||
|
||||
val deviceId = receiver.serverContext.nextHandle()
|
||||
val device = Device.create(
|
||||
scope = receiver.serverContext.context.scope,
|
||||
id = deviceId,
|
||||
address = packet.address,
|
||||
macAddress = packet.address,
|
||||
origin = DeviceOrigin.HID,
|
||||
protocolVersion = 0,
|
||||
)
|
||||
receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device))
|
||||
receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, deviceId))
|
||||
AppLogger.hid.info("Registered HID device ${packet.address} (hidId=${packet.hidId})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object HIDDeviceInfoBehaviour : HIDReceiverBehaviour {
|
||||
override fun reduce(state: HIDReceiverState, action: HIDReceiverActions): HIDReceiverState = when (action) {
|
||||
is HIDReceiverActions.TrackerRegistered -> {
|
||||
val existing = state.trackers[action.hidId] ?: return state
|
||||
state.copy(trackers = state.trackers + (action.hidId to existing.copy(trackerId = action.trackerId)))
|
||||
}
|
||||
|
||||
else -> state
|
||||
}
|
||||
|
||||
override fun observe(receiver: HIDReceiver) {
|
||||
receiver.packetEvents.onPacket<HIDDeviceInfo> { packet ->
|
||||
val device = receiver.getDevice(packet.hidId) ?: return@onPacket
|
||||
|
||||
device.context.dispatch(
|
||||
DeviceActions.Update {
|
||||
copy(
|
||||
boardType = packet.boardType,
|
||||
mcuType = packet.mcuType,
|
||||
firmware = packet.firmware,
|
||||
batteryLevel = packet.batteryLevel,
|
||||
batteryVoltage = packet.batteryVoltage,
|
||||
signalStrength = packet.rssi,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val tracker = receiver.getTracker(packet.hidId)
|
||||
if (tracker == null) {
|
||||
val deviceState = device.context.state.value
|
||||
|
||||
val existingTracker = receiver.serverContext.context.state.value.trackers.values
|
||||
.find { it.context.state.value.hardwareId == deviceState.address && it.context.state.value.origin == DeviceOrigin.HID }
|
||||
|
||||
if (existingTracker != null) {
|
||||
receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, existingTracker.context.state.value.id))
|
||||
existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) })
|
||||
} else {
|
||||
val trackerId = receiver.serverContext.nextHandle()
|
||||
val newTracker = Tracker.create(
|
||||
scope = receiver.serverContext.context.scope,
|
||||
id = trackerId,
|
||||
deviceId = deviceState.id,
|
||||
sensorType = packet.imuType,
|
||||
hardwareId = deviceState.address,
|
||||
origin = DeviceOrigin.HID,
|
||||
)
|
||||
receiver.serverContext.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker))
|
||||
receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId))
|
||||
AppLogger.hid.info("Registered HID tracker for device ${deviceState.address} (hidId=${packet.hidId})")
|
||||
}
|
||||
|
||||
device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) })
|
||||
} else {
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object HIDRotationBehaviour : HIDReceiverBehaviour {
|
||||
override fun observe(receiver: HIDReceiver) {
|
||||
receiver.packetEvents.onPacket<HIDRotation> { packet ->
|
||||
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationBattery> { packet ->
|
||||
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationMag> { packet ->
|
||||
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationButton> { packet ->
|
||||
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object HIDBatteryBehaviour : HIDReceiverBehaviour {
|
||||
override fun observe(receiver: HIDReceiver) {
|
||||
receiver.packetEvents.onPacket<HIDRotationBattery> { packet ->
|
||||
receiver.getDevice(packet.hidId)?.context?.dispatch(
|
||||
DeviceActions.Update {
|
||||
copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationButton> { packet ->
|
||||
receiver.getDevice(packet.hidId)?.context?.dispatch(
|
||||
DeviceActions.Update { copy(signalStrength = packet.rssi) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object HIDStatusBehaviour : HIDReceiverBehaviour {
|
||||
override fun observe(receiver: HIDReceiver) {
|
||||
receiver.packetEvents.onPacket<HIDStatus> { packet ->
|
||||
if (receiver.getTracker(packet.hidId) == null) return@onPacket
|
||||
receiver.getDevice(packet.hidId)?.context?.dispatch(
|
||||
DeviceActions.Update { copy(status = packet.status, signalStrength = packet.rssi) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
120
server/core/src/main/java/dev/slimevr/hid/module.kt
Normal file
120
server/core/src/main/java/dev/slimevr/hid/module.kt
Normal file
@@ -0,0 +1,120 @@
|
||||
package dev.slimevr.hid
|
||||
|
||||
import dev.slimevr.EventDispatcher
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.context.Behaviour
|
||||
import dev.slimevr.context.Context
|
||||
import dev.slimevr.device.Device
|
||||
import dev.slimevr.device.DeviceActions
|
||||
import dev.slimevr.tracker.Tracker
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import solarxr_protocol.datatypes.TrackerStatus
|
||||
|
||||
const val HID_TRACKER_RECEIVER_VID = 0x1209
|
||||
const val HID_TRACKER_RECEIVER_PID = 0x7690
|
||||
const val HID_TRACKER_PID = 0x7692
|
||||
|
||||
// --- State and actions ---
|
||||
|
||||
data class HIDTrackerRecord(
|
||||
val hidId: Int,
|
||||
val address: String,
|
||||
val deviceId: Int,
|
||||
val trackerId: Int?,
|
||||
)
|
||||
|
||||
data class HIDReceiverState(
|
||||
val serialNumber: String,
|
||||
val trackers: Map<Int, HIDTrackerRecord>,
|
||||
)
|
||||
|
||||
sealed interface HIDReceiverActions {
|
||||
data class DeviceRegistered(val hidId: Int, val address: String, val deviceId: Int) : HIDReceiverActions
|
||||
data class TrackerRegistered(val hidId: Int, val trackerId: Int) : HIDReceiverActions
|
||||
}
|
||||
|
||||
typealias HIDReceiverContext = Context<HIDReceiverState, HIDReceiverActions>
|
||||
typealias HIDReceiverBehaviour = Behaviour<HIDReceiverState, HIDReceiverActions, HIDReceiver>
|
||||
typealias HIDPacketDispatcher = EventDispatcher<HIDPacket>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <reified T : HIDPacket> HIDPacketDispatcher.onPacket(crossinline callback: suspend (T) -> Unit) {
|
||||
register(T::class) { callback(it as T) }
|
||||
}
|
||||
|
||||
class HIDReceiver(
|
||||
val context: HIDReceiverContext,
|
||||
val serverContext: VRServer,
|
||||
val packetEvents: HIDPacketDispatcher,
|
||||
) {
|
||||
fun getDevice(hidId: Int): Device? {
|
||||
val record = context.state.value.trackers[hidId] ?: return null
|
||||
return serverContext.getDevice(record.deviceId)
|
||||
}
|
||||
|
||||
fun getTracker(hidId: Int): Tracker? {
|
||||
val record = context.state.value.trackers[hidId] ?: return null
|
||||
val trackerId = record.trackerId ?: return null
|
||||
return serverContext.getTracker(trackerId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
serialNumber: String,
|
||||
data: Flow<ByteArray>,
|
||||
serverContext: VRServer,
|
||||
scope: CoroutineScope,
|
||||
): HIDReceiver {
|
||||
val behaviours = listOf(
|
||||
HIDRegistrationBehaviour,
|
||||
HIDDeviceInfoBehaviour,
|
||||
HIDRotationBehaviour,
|
||||
HIDBatteryBehaviour,
|
||||
HIDStatusBehaviour,
|
||||
)
|
||||
|
||||
val context = Context.create(
|
||||
initialState = HIDReceiverState(serialNumber = serialNumber, trackers = mapOf()),
|
||||
scope = scope,
|
||||
behaviours = behaviours,
|
||||
)
|
||||
|
||||
val dispatcher = HIDPacketDispatcher()
|
||||
|
||||
val receiver = HIDReceiver(
|
||||
context = context,
|
||||
serverContext = serverContext,
|
||||
packetEvents = dispatcher,
|
||||
)
|
||||
|
||||
behaviours.forEach { it.observe(receiver) }
|
||||
|
||||
data
|
||||
.onEach { report -> parseHIDPackets(report).forEach { dispatcher.emit(it) } }
|
||||
.launchIn(scope)
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
for (record in context.state.value.trackers.values) {
|
||||
serverContext.getDevice(record.deviceId)?.context?.dispatch(
|
||||
DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return receiver
|
||||
}
|
||||
}
|
||||
}
|
||||
202
server/core/src/main/java/dev/slimevr/hid/packets.kt
Normal file
202
server/core/src/main/java/dev/slimevr/hid/packets.kt
Normal file
@@ -0,0 +1,202 @@
|
||||
package dev.slimevr.hid
|
||||
|
||||
import io.github.axisangles.ktmath.Quaternion
|
||||
import io.github.axisangles.ktmath.Quaternion.Companion.fromRotationVector
|
||||
import io.github.axisangles.ktmath.Vector3
|
||||
import solarxr_protocol.datatypes.TrackerStatus
|
||||
import solarxr_protocol.datatypes.hardware_info.BoardType
|
||||
import solarxr_protocol.datatypes.hardware_info.ImuType
|
||||
import solarxr_protocol.datatypes.hardware_info.McuType
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
private const val HID_PACKET_SIZE = 16
|
||||
|
||||
private val AXES_OFFSET = fromRotationVector(-PI.toFloat() / 2f, 0f, 0f)
|
||||
|
||||
sealed interface HIDPacket {
|
||||
val hidId: Int
|
||||
}
|
||||
|
||||
/** Receiver associates a wireless tracker ID with its 6-byte address (type 255). */
|
||||
data class HIDDeviceRegister(override val hidId: Int, val address: String) : HIDPacket
|
||||
|
||||
/** Board/MCU/firmware/battery + IMU type for tracker registration (type 0). */
|
||||
data class HIDDeviceInfo(
|
||||
override val hidId: Int,
|
||||
val imuType: ImuType,
|
||||
val boardType: BoardType,
|
||||
val mcuType: McuType,
|
||||
val firmware: String,
|
||||
val batteryLevel: Float,
|
||||
val batteryVoltage: Float,
|
||||
val rssi: Int,
|
||||
) : HIDPacket
|
||||
|
||||
/** Full-precision Q15 quaternion + Q7 acceleration (type 1). */
|
||||
data class HIDRotation(
|
||||
override val hidId: Int,
|
||||
val rotation: Quaternion,
|
||||
val acceleration: Vector3,
|
||||
) : HIDPacket
|
||||
|
||||
/** Compact exp-map quaternion + Q7 acceleration + battery level + rssi (type 2). */
|
||||
data class HIDRotationBattery(
|
||||
override val hidId: Int,
|
||||
val rotation: Quaternion,
|
||||
val acceleration: Vector3,
|
||||
val batteryLevel: Float,
|
||||
val batteryVoltage: Float,
|
||||
val rssi: Int,
|
||||
) : HIDPacket
|
||||
|
||||
/** Tracker status report + rssi (type 3). */
|
||||
data class HIDStatus(
|
||||
override val hidId: Int,
|
||||
val status: TrackerStatus,
|
||||
val rssi: Int,
|
||||
) : HIDPacket
|
||||
|
||||
/** Full-precision Q15 quaternion + Q10 magnetometer (type 4). */
|
||||
data class HIDRotationMag(
|
||||
override val hidId: Int,
|
||||
val rotation: Quaternion,
|
||||
val magnetometer: Vector3,
|
||||
) : HIDPacket
|
||||
|
||||
/** Button state + compact exp-map quaternion + Q7 acceleration + rssi (type 7). */
|
||||
data class HIDRotationButton(
|
||||
override val hidId: Int,
|
||||
val button: Int,
|
||||
val rotation: Quaternion,
|
||||
val acceleration: Vector3,
|
||||
val rssi: Int,
|
||||
) : HIDPacket
|
||||
|
||||
private fun readLE16Signed(data: ByteArray, offset: Int): Int = data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt()
|
||||
|
||||
private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion {
|
||||
val scale = 1f / 32768f
|
||||
val x = readLE16Signed(data, offset).toShort().toFloat() * scale
|
||||
val y = readLE16Signed(data, offset + 2).toShort().toFloat() * scale
|
||||
val z = readLE16Signed(data, offset + 4).toShort().toFloat() * scale
|
||||
val w = readLE16Signed(data, offset + 6).toShort().toFloat() * scale
|
||||
return AXES_OFFSET * Quaternion(w, x, y, z)
|
||||
}
|
||||
|
||||
private fun decodeExpMapQuat(data: ByteArray, offset: Int): Quaternion {
|
||||
val buf = ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int.toUInt()
|
||||
val vx = ((buf and 1023u).toFloat() / 1024f) * 2f - 1f
|
||||
val vy = ((buf shr 10 and 2047u).toFloat() / 2048f) * 2f - 1f
|
||||
val vz = ((buf shr 21 and 2047u).toFloat() / 2048f) * 2f - 1f
|
||||
val d = vx * vx + vy * vy + vz * vz
|
||||
val invSqrtD = 1f / sqrt(d + 1e-6f)
|
||||
val a = (PI.toFloat() / 2f) * d * invSqrtD
|
||||
val s = sin(a)
|
||||
val k = s * invSqrtD
|
||||
return AXES_OFFSET * Quaternion(cos(a), k * vx, k * vy, k * vz)
|
||||
}
|
||||
|
||||
private fun decodeAccel(data: ByteArray, offset: Int): Vector3 {
|
||||
val scale = 1f / 128f
|
||||
return Vector3(
|
||||
readLE16Signed(data, offset).toShort().toFloat() * scale,
|
||||
readLE16Signed(data, offset + 2).toShort().toFloat() * scale,
|
||||
readLE16Signed(data, offset + 4).toShort().toFloat() * scale,
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeBattery(raw: Int): Float = if (raw == 128) 1f else (raw and 127).toFloat()
|
||||
|
||||
private fun decodeBatteryVoltage(raw: Int): Float = (raw.toFloat() + 245f) / 100f
|
||||
|
||||
private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? {
|
||||
val packetType = data[i].toUByte().toInt()
|
||||
val hidId = data[i + 1].toUByte().toInt()
|
||||
|
||||
return when (packetType) {
|
||||
255 -> {
|
||||
val addr = ByteBuffer.wrap(data, i + 2, 8).order(ByteOrder.LITTLE_ENDIAN).long and 0x0000_FFFF_FFFF_FFFFL
|
||||
HIDDeviceRegister(hidId, "%012X".format(addr))
|
||||
}
|
||||
|
||||
0 -> {
|
||||
val batt = data[i + 2].toUByte().toInt()
|
||||
val battV = data[i + 3].toUByte().toInt()
|
||||
val brdId = data[i + 5].toUByte().toInt()
|
||||
val mcuId = data[i + 6].toUByte().toInt()
|
||||
val imuId = data[i + 8].toUByte().toInt()
|
||||
val fwDate = data[i + 11].toUByte().toInt() shl 8 or data[i + 10].toUByte().toInt()
|
||||
val fwMajor = data[i + 12].toUByte().toInt()
|
||||
val fwMinor = data[i + 13].toUByte().toInt()
|
||||
val fwPatch = data[i + 14].toUByte().toInt()
|
||||
val rssi = data[i + 15].toUByte().toInt()
|
||||
val fwYear = 2020 + (fwDate shr 9 and 127)
|
||||
val fwMonth = fwDate shr 5 and 15
|
||||
val fwDay = fwDate and 31
|
||||
HIDDeviceInfo(
|
||||
hidId = hidId,
|
||||
imuType = ImuType.fromValue(imuId.toUShort()) ?: ImuType.Other,
|
||||
boardType = BoardType.fromValue(brdId.toUShort()) ?: BoardType.UNKNOWN,
|
||||
mcuType = McuType.fromValue(mcuId.toUShort()) ?: McuType.Other,
|
||||
firmware = "%04d-%02d-%02d %d.%d.%d".format(fwYear, fwMonth, fwDay, fwMajor, fwMinor, fwPatch),
|
||||
batteryLevel = decodeBattery(batt),
|
||||
batteryVoltage = decodeBatteryVoltage(battV),
|
||||
rssi = -rssi,
|
||||
)
|
||||
}
|
||||
|
||||
1 -> HIDRotation(
|
||||
hidId = hidId,
|
||||
rotation = decodeQ15Quat(data, i + 2),
|
||||
acceleration = decodeAccel(data, i + 10),
|
||||
)
|
||||
|
||||
2 -> HIDRotationBattery(
|
||||
hidId = hidId,
|
||||
rotation = decodeExpMapQuat(data, i + 5),
|
||||
acceleration = decodeAccel(data, i + 9),
|
||||
batteryLevel = decodeBattery(data[i + 2].toUByte().toInt()),
|
||||
batteryVoltage = decodeBatteryVoltage(data[i + 3].toUByte().toInt()),
|
||||
rssi = -data[i + 15].toUByte().toInt(),
|
||||
)
|
||||
|
||||
3 -> HIDStatus(
|
||||
hidId = hidId,
|
||||
status = TrackerStatus.fromValue((data[i + 2].toUByte().toInt() + 1).toUByte()) ?: TrackerStatus.OK,
|
||||
rssi = -data[i + 15].toUByte().toInt(),
|
||||
)
|
||||
|
||||
4 -> {
|
||||
val scaleMag = 1000f / 1024f
|
||||
HIDRotationMag(
|
||||
hidId = hidId,
|
||||
rotation = decodeQ15Quat(data, i + 2),
|
||||
magnetometer = Vector3(
|
||||
readLE16Signed(data, i + 10).toShort().toFloat() * scaleMag,
|
||||
readLE16Signed(data, i + 12).toShort().toFloat() * scaleMag,
|
||||
readLE16Signed(data, i + 14).toShort().toFloat() * scaleMag,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
7 -> HIDRotationButton(
|
||||
hidId = hidId,
|
||||
button = data[i + 2].toUByte().toInt(),
|
||||
rotation = decodeExpMapQuat(data, i + 5),
|
||||
acceleration = decodeAccel(data, i + 9),
|
||||
rssi = -data[i + 15].toUByte().toInt(),
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun parseHIDPackets(data: ByteArray): List<HIDPacket> {
|
||||
if (data.size % HID_PACKET_SIZE != 0) return emptyList()
|
||||
return (0 until data.size / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) }
|
||||
}
|
||||
29
server/core/src/main/java/dev/slimevr/logger.kt
Normal file
29
server/core/src/main/java/dev/slimevr/logger.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package dev.slimevr
|
||||
|
||||
import io.klogging.Level
|
||||
import io.klogging.config.loggingConfiguration
|
||||
import io.klogging.logger
|
||||
import io.klogging.rendering.RENDER_SIMPLE
|
||||
import io.klogging.sending.STDOUT
|
||||
|
||||
object AppLogger {
|
||||
val tracker = logger("Tracker")
|
||||
val device = logger("Device")
|
||||
val udp = logger("UDPConnection")
|
||||
val solarxr = logger("SolarXR")
|
||||
val hid = logger("HID")
|
||||
val serial = logger("Serial")
|
||||
val firmware = logger("Firmware")
|
||||
val vrc = logger("VRChat")
|
||||
|
||||
init {
|
||||
loggingConfiguration {
|
||||
sink("stdout", RENDER_SIMPLE, STDOUT)
|
||||
logging {
|
||||
fromMinLevel(Level.INFO) {
|
||||
toSink("stdout")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package dev.slimevr.math
|
||||
|
||||
import com.jme3.math.FastMath
|
||||
import io.github.axisangles.ktmath.Quaternion
|
||||
import io.github.axisangles.ktmath.Vector3
|
||||
import kotlin.math.*
|
||||
|
||||
/**
|
||||
* An angle between [-PI, PI).
|
||||
*/
|
||||
@JvmInline
|
||||
value class Angle(private val rad: Float) {
|
||||
|
||||
fun toRad() = rad
|
||||
|
||||
fun toDeg() = rad * FastMath.RAD_TO_DEG
|
||||
|
||||
operator fun unaryPlus() = this
|
||||
|
||||
operator fun unaryMinus() = Angle(normalize(-rad))
|
||||
|
||||
operator fun plus(other: Angle) = Angle(normalize(rad + other.rad))
|
||||
|
||||
operator fun minus(other: Angle) = Angle(normalize(rad - other.rad))
|
||||
|
||||
operator fun times(scale: Float) = Angle(normalize(rad * scale))
|
||||
|
||||
operator fun div(scale: Float) = Angle(normalize(rad / scale))
|
||||
|
||||
operator fun compareTo(other: Angle) = rad.compareTo(other.rad)
|
||||
|
||||
override fun toString() = "${toDeg()} deg"
|
||||
|
||||
companion object {
|
||||
val ZERO = Angle(0.0f)
|
||||
|
||||
fun ofRad(rad: Float) = Angle(normalize(rad))
|
||||
|
||||
fun ofDeg(deg: Float) = Angle(normalize(deg * FastMath.DEG_TO_RAD))
|
||||
|
||||
// Angle between two vectors
|
||||
fun absBetween(a: Vector3, b: Vector3) = Angle(normalize(a.angleTo(b)))
|
||||
|
||||
// Angle between two rotations in rotation space
|
||||
fun absBetween(a: Quaternion, b: Quaternion) = Angle(normalize(a.angleToR(b)))
|
||||
|
||||
/**
|
||||
* Normalizes an angle to [-PI, PI)
|
||||
*/
|
||||
private fun normalize(rad: Float): Float {
|
||||
// Normalize to [0, 2*PI)
|
||||
val r =
|
||||
if (rad < 0.0f || rad >= FastMath.TWO_PI) {
|
||||
rad - floor(rad * FastMath.INV_TWO_PI) * FastMath.TWO_PI
|
||||
} else {
|
||||
rad
|
||||
}
|
||||
|
||||
// Normalize to [-PI, PI)
|
||||
return if (r > FastMath.PI) {
|
||||
r - FastMath.TWO_PI
|
||||
} else {
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package dev.slimevr.math
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
/**
|
||||
* Averages angles by summing vectors.
|
||||
*
|
||||
* See https://www.themathdoctors.org/averaging-angles/
|
||||
*/
|
||||
class AngleAverage {
|
||||
|
||||
private var sumX = 0.0f
|
||||
private var sumY = 0.0f
|
||||
|
||||
/**
|
||||
* Adds another angle to the average.
|
||||
*/
|
||||
fun add(angle: Angle, weight: Float = 1.0f) {
|
||||
sumX += cos(angle.toRad()) * weight
|
||||
sumY += sin(angle.toRad()) * weight
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the average angle.
|
||||
*/
|
||||
fun toAngle(): Angle = if (isEmpty()) {
|
||||
Angle.ZERO
|
||||
} else {
|
||||
Angle.ofRad(atan2(sumY, sumX))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there are any angles to average.
|
||||
*/
|
||||
fun isEmpty() = sumX == 0.0f && sumY == 0.0f
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user