Compare commits

..

36 Commits

Author SHA1 Message Date
loucass003
a171bc2783 More udp tracker data 2026-04-01 01:45:58 +02:00
loucass003
242920316b Packets bundling support 2026-03-30 14:22:44 +02:00
loucass003
2fc64b3e8b Lint 2026-03-30 11:24:49 +02:00
loucass003
02ca403e24 More data for udp trackers 2026-03-30 11:24:21 +02:00
loucass003
17e87178fc Remove some console warnings 2026-03-30 05:39:49 +02:00
loucass003
dd6c1e7567 Lint 2026-03-30 05:23:37 +02:00
loucass003
0ab80528fe More position data for devices 2026-03-30 05:23:31 +02:00
loucass003
e07ef046a6 batch dispatch together 2026-03-30 05:23:14 +02:00
loucass003
71b74914a3 Better logs and ping id sequence for udp devices 2026-03-30 05:22:55 +02:00
loucass003
f65f41ad17 User height calibration (Non tested) 2026-03-29 05:35:48 +02:00
loucass003
df4569fe17 Format 2026-03-27 05:58:46 +01:00
loucass003
38743dc8b8 organise more stuff 2026-03-27 05:55:10 +01:00
loucass003
9d1e7764e6 Re organise project 2026-03-27 05:29:41 +01:00
loucass003
fa1d2012e1 Make vrc config save mute settings to config 2026-03-27 02:53:26 +01:00
loucass003
8168e1366a VRC settings (Non tested on windows) 2026-03-27 02:24:08 +01:00
loucass003
ea92fb4c01 Basic HID support 2026-03-26 22:41:47 +01:00
loucass003
fc476a1683 Proper handling of the reconnect on ota and serial 2026-03-26 19:05:46 +01:00
loucass003
5946ca1b7a Better udp and feeder connection handling 2026-03-26 18:44:51 +01:00
loucass003
6c4fdedcd1 Make ota and serial check for the device status 2026-03-26 17:53:29 +01:00
loucass003
1001b7f887 Cleanup 2026-03-26 17:34:49 +01:00
loucass003
86dbdbddf8 OTA updates + Packet processing optimisations + code cleanup 2026-03-26 14:44:39 +01:00
loucass003
d46fb013ac Unit tests + Fix start datafeed behaviour 2026-03-26 09:19:58 +01:00
loucass003
8ffd00eb47 Serial + Serial Flasher 2026-03-25 15:11:07 +01:00
loucass003
853155600a Small design guidelines update 2026-03-25 01:05:51 +01:00
loucass003
ad9bc911b6 Basic codestyle doc + rename stuff to match it 2026-03-25 00:30:34 +01:00
Sapphire
2235b8178b Join datafeed timers on separate coroutine 2026-03-24 15:40:54 -05:00
loucass003
e37a37bcf9 Small optimisations / fixes to make sure couroutines do well and prevent dataraces 2026-03-24 02:17:22 +01:00
loucass003
af4665e7c9 IPC interface for linux (tested) and windows (untested) - Solarxr / driver 2026-03-24 01:21:47 +01:00
loucass003
a42ed79003 Basic config system 2026-03-23 04:37:43 +01:00
loucass003
334be5f7cc Solaxr datafeed out 2026-03-20 03:35:17 +01:00
loucass003
4c1e4691be Solarxr start 2026-03-19 17:43:15 +01:00
loucass003
4fd4997e60 Logger test 2026-03-19 03:55:09 +01:00
loucass003
6eb63ce9f8 Tracker data 2026-03-19 02:56:32 +01:00
loucass003
01b7b8dbba basic handshake 2026-03-18 16:38:20 +01:00
loucass003
e060bc7cc5 Trying to get the basic shape of the state system out. Testing it with the udp part of the server to confirm the implementation choices 2026-03-17 21:22:56 +01:00
loucass003
d691619b98 Burn everything and start fresh 2026-03-17 11:37:27 +01:00
329 changed files with 6817 additions and 43025 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View 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.

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -1,8 +0,0 @@
package dev.slimevr;
public enum NetworkProtocol {
OWO_LEGACY,
SLIMEVR_RAW,
SLIMEVR_FLATBUFFER,
SLIMEVR_WEBSOCKET
}

View File

@@ -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()
}
}

View File

@@ -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,
)
}
}

View File

@@ -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()
}
}

View File

@@ -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>)
}

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 }
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View 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)
}
}

View File

@@ -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
}

View File

@@ -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 {
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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())
}
}
}
}

View File

@@ -1,7 +0,0 @@
package dev.slimevr.config
import com.fasterxml.jackson.annotation.JsonIgnore
class HIDConfig {
var trackersOverHID = false
}

View File

@@ -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;
}
}

View File

@@ -1,6 +0,0 @@
package dev.slimevr.config
class LegTweaksConfig {
var correctionStrength = 0.3f
var alwaysUseFloorclip = false
}

View File

@@ -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"
}

View File

@@ -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;
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -1,5 +0,0 @@
package dev.slimevr.config
class TrackingChecklistConfig {
val ignoredStepsIds: MutableList<Int> = mutableListOf()
}

View File

@@ -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
}

View File

@@ -1,6 +0,0 @@
package dev.slimevr.config
class VRCConfig {
// List of fields ignored in vrc warnings - @see VRCConfigValidity
val mutedWarnings: MutableList<String> = mutableListOf()
}

View File

@@ -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`
}
}

View File

@@ -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)
}

View 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
}
}

View 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)
}

View 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,
)
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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()
);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View 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
}
}
}

View 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
}
}
}

View 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)
}
}
}

View 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)
}
}

View 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)
}
}
}

View 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) }
}
}

View File

@@ -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;
}
}

View File

@@ -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)
}
}

View File

@@ -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())]
}
}

View File

@@ -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
}

View File

@@ -1,5 +0,0 @@
package dev.slimevr.firmware
interface FirmwareUpdateListener {
fun onUpdateStatusChange(event: UpdateStatusEvent<*>)
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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
}
}

View File

@@ -1,5 +0,0 @@
package dev.slimevr.firmware
import dev.llelievr.espflashkotlin.FlasherSerialInterface
interface SerialFlashingHandler : FlasherSerialInterface

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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(),
)

View 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)
}
}

View 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
}

View 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
}
}
}

View 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)
}

View 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)
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View 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) },
)
}
}
}

View 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
}
}
}

View 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) }
}

View 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")
}
}
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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