mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Compare commits
101 Commits
server-rew
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
181c6599b7 | ||
|
|
fb77d3cf8a | ||
|
|
1a4b19a5e1 | ||
|
|
a9f553729e | ||
|
|
ed96742680 | ||
|
|
5e7816d72d | ||
|
|
abab38e422 | ||
|
|
7835b17379 | ||
|
|
30612a866b | ||
|
|
bba574ce86 | ||
|
|
6d3d725b6c | ||
|
|
948bc06542 | ||
|
|
8f4ee3268d | ||
|
|
8f97bd997b | ||
|
|
18f6c9c24f | ||
|
|
c7bdd041f2 | ||
|
|
f75a011fb3 | ||
|
|
555764914d | ||
|
|
e4be98c7e7 | ||
|
|
701ce9dc0a | ||
|
|
3f950cc11d | ||
|
|
d8ce34a962 | ||
|
|
ef9d5e7862 | ||
|
|
e3f06eff55 | ||
|
|
7a062b7d7b | ||
|
|
121f3297ae | ||
|
|
9951f00979 | ||
|
|
d8bb744ce4 | ||
|
|
4db342b4ae | ||
|
|
e9f96e6d21 | ||
|
|
56c6ebdadf | ||
|
|
4f941a5892 | ||
|
|
bf69046efe | ||
|
|
cdcdb1b443 | ||
|
|
f875e9df4d | ||
|
|
12dd408f0b | ||
|
|
5e53bae9dc | ||
|
|
b19e190004 | ||
|
|
06c8bdc81a | ||
|
|
62338250e8 | ||
|
|
1ff79ebb13 | ||
|
|
2f9678d882 | ||
|
|
94d70cbe55 | ||
|
|
f5c26f97aa | ||
|
|
b367c7d3d6 | ||
|
|
c9cae35946 | ||
|
|
a895b0b583 | ||
|
|
5321c25bb2 | ||
|
|
b39691b879 | ||
|
|
74e7f02668 | ||
|
|
09e1510298 | ||
|
|
0d95a731e3 | ||
|
|
7d5706520b | ||
|
|
e887b3153d | ||
|
|
a7aa897fad | ||
|
|
29e2fd863b | ||
|
|
330cac26ec | ||
|
|
ca9195ba97 | ||
|
|
908270ffff | ||
|
|
942fbcf6f6 | ||
|
|
dbc6bae898 | ||
|
|
8482802375 | ||
|
|
fdd3614204 | ||
|
|
706a2780d9 | ||
|
|
97a90076a4 | ||
|
|
ef85b8f3e1 | ||
|
|
83700a1d1a | ||
|
|
36a363f8ed | ||
|
|
1c8381337a | ||
|
|
f698b27be5 | ||
|
|
f2767cf3bc | ||
|
|
fe873729b6 | ||
|
|
031b35eb06 | ||
|
|
bcc604cf63 | ||
|
|
adf01eed16 | ||
|
|
bd1750f252 | ||
|
|
49adf0ee84 | ||
|
|
04a2fa72a7 | ||
|
|
064c41d419 | ||
|
|
7c92023af7 | ||
|
|
ac01b75342 | ||
|
|
95a7801a50 | ||
|
|
db1ec5d024 | ||
|
|
a96cd8a38f | ||
|
|
45d5789685 | ||
|
|
4b08123a61 | ||
|
|
bfc99ab02c | ||
|
|
6eb8a18430 | ||
|
|
489b8e6549 | ||
|
|
7bb2ecfff1 | ||
|
|
59b4b34840 | ||
|
|
9597888902 | ||
|
|
dbfcc8ba0a | ||
|
|
fd8e9fba83 | ||
|
|
f65d1828fe | ||
|
|
c32601809b | ||
|
|
fdf86a1e56 | ||
|
|
71908523f9 | ||
|
|
2b7d678321 | ||
|
|
5d64fa8369 | ||
|
|
fe6bb4534c |
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
@@ -34,6 +34,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Get tags
|
||||
run: git fetch --tags origin --recurse-submodules=no --force
|
||||
- name: Setup PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup Node
|
||||
@@ -71,6 +73,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Get tags
|
||||
run: git fetch --tags origin --recurse-submodules=no --force
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
@@ -98,6 +102,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Get tags
|
||||
run: git fetch --tags origin --recurse-submodules=no --force
|
||||
- name: Setup PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup Node
|
||||
@@ -129,6 +135,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Get tags
|
||||
run: git fetch --tags origin --recurse-submodules=no --force
|
||||
- name: Setup PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup Node
|
||||
@@ -201,6 +209,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Get tags
|
||||
run: git fetch --tags origin --recurse-submodules=no --force
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
@@ -231,6 +241,24 @@ jobs:
|
||||
name: release-android
|
||||
path: SlimeVR-android.apk
|
||||
|
||||
- name: Build Google Play release bundle
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: ./gradlew :server:android:bundleRelease
|
||||
env:
|
||||
ANDROID_STORE_FILE: ${{ secrets.ANDROID_GPLAY_STORE_FILE }}
|
||||
ANDROID_STORE_PASSWD: ${{ secrets.ANDROID_GPLAY_STORE_PASSWD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_GPLAY_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWD: ${{ secrets.ANDROID_GPLAY_KEY_PASSWD }}
|
||||
|
||||
- name: Upload the Google Play artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
# Artifact name
|
||||
name: 'SlimeVR-Android-GPDev' # optional, default is artifact
|
||||
# A file, directory or wildcard pattern that describes what to upload
|
||||
path: server/android/build/outputs/bundle/release/*
|
||||
|
||||
create-release:
|
||||
name: Finalize Release Draft
|
||||
needs: [package-desktop, bundle-android, build-server-jar, build-gui-frontend]
|
||||
|
||||
@@ -22,8 +22,6 @@ Now you can open the codebase in [IDEA](https://www.jetbrains.com/idea/download/
|
||||
|
||||
### Java (server)
|
||||
|
||||
Before contributing to the server, read [server/README.md](server/README.md) for an overview of its architecture and design guidelines.
|
||||
|
||||
The Java code is built with `gradle`, a CLI tool that manages java projects and their
|
||||
dependencies.
|
||||
- You can run the server by running `./gradlew run` in your IDE's terminal.
|
||||
|
||||
@@ -22,8 +22,6 @@ Latest setup instructions are [in our docs](https://docs.slimevr.dev/server/inde
|
||||
## Building & Contributing
|
||||
For information on building and contributing to the codebase, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
For an overview of the server architecture and design guidelines, see [server/README.md](server/README.md).
|
||||
|
||||
## Translating
|
||||
|
||||
Translation is done via Pontoon at [i18n.slimevr.dev](https://i18n.slimevr.dev/). Please join our [Discord translation forum](https://discord.com/channels/817184208525983775/1050413434249949235) to coordinate.
|
||||
|
||||
@@ -12,10 +12,8 @@
|
||||
|
||||
perSystem = { pkgs, ... }:
|
||||
let
|
||||
java = pkgs.javaPackages.compiler.temurin-bin.jdk-24;
|
||||
|
||||
runtimeLibs = pkgs: (with pkgs; [
|
||||
java
|
||||
jdk17
|
||||
|
||||
alsa-lib at-spi2-atk at-spi2-core cairo cups dbus expat
|
||||
gdk-pixbuf glib gtk3 libdrm libgbm libglvnd libnotify
|
||||
@@ -35,8 +33,8 @@
|
||||
name = "slimevr-env";
|
||||
targetPkgs = runtimeLibs;
|
||||
profile = ''
|
||||
export JAVA_HOME=${java}
|
||||
export PATH="${java}/bin:$PATH"
|
||||
export JAVA_HOME=${pkgs.jdk17}
|
||||
export PATH="${pkgs.jdk17}/bin:$PATH"
|
||||
|
||||
# Tell electron-builder to use system tools instead of downloading them
|
||||
export USE_SYSTEM_FPM=true
|
||||
|
||||
@@ -20,4 +20,3 @@ 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
|
||||
|
||||
@@ -14,7 +14,7 @@ import { IPC_CHANNELS } from '../shared';
|
||||
import path, { dirname, join } from 'path';
|
||||
import open from 'open';
|
||||
import trayIcon from '../resources/icons/icon.png?asset';
|
||||
import appleTrayIcon from '../resources/icons/appleTrayIcon.png?asset';
|
||||
import appleTrayIcon from '../resources/icons/Square30x30Logo.png?asset';
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
import { getPlatform, handleIpc, isPortAvailable } from './utils';
|
||||
import {
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
getWindowStateFile,
|
||||
} from './paths';
|
||||
import { stores } from './store';
|
||||
import { logger } from './logger';
|
||||
import { closeLogger, logger } from './logger';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
@@ -36,11 +36,16 @@ import { ServerStatusEvent } from 'electron/preload/interface';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { MenuItem } from 'electron/main';
|
||||
|
||||
// Fixes colors looking washed on linux
|
||||
// Might affect hdr
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('disable-features', 'WaylandWpColorManagerV1');
|
||||
app.commandLine.appendSwitch('force-color-profile', 'srgb');
|
||||
}
|
||||
|
||||
app.setPath('userData', getGuiDataFolder())
|
||||
app.setPath('sessionData', join(getGuiDataFolder(), 'electron'))
|
||||
app.setPath('userData', getGuiDataFolder());
|
||||
app.setPath('sessionData', join(getGuiDataFolder(), 'electron'));
|
||||
|
||||
// Register custom protocol to handle asset paths with leading slashes
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'app',
|
||||
@@ -268,6 +273,9 @@ function createWindow() {
|
||||
case 'close':
|
||||
mainWindow?.close();
|
||||
break;
|
||||
case 'hide':
|
||||
mainWindow?.hide();
|
||||
break;
|
||||
case 'minimize':
|
||||
mainWindow?.minimize();
|
||||
break;
|
||||
@@ -339,8 +347,7 @@ function createWindow() {
|
||||
menu.append(new MenuItem({ label: 'Copy', role: 'copy' }));
|
||||
menu.append(new MenuItem({ label: 'Paste', role: 'paste' }));
|
||||
|
||||
if (mainWindow)
|
||||
menu.popup({ window: mainWindow });
|
||||
if (mainWindow) menu.popup({ window: mainWindow });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,7 +360,7 @@ const checkEnvironmentVariables = () => {
|
||||
'SlimeVR',
|
||||
`You have environment variables ${set.join(', ')} set, which may cause the SlimeVR Server to fail to launch properly.`
|
||||
);
|
||||
app.exit(0);
|
||||
app.quit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -380,36 +387,60 @@ const spawnServer = async () => {
|
||||
'SlimeVR',
|
||||
`Couldn't find a compatible Java version, please download Java 17 or higher`
|
||||
);
|
||||
app.exit(0);
|
||||
app.quit()
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ javaBin, serverJar }, 'Found Java and server jar');
|
||||
|
||||
const process = spawn(javaBin, ['-Xmx128M', '-jar', serverJar, 'run']);
|
||||
|
||||
process.stdout?.on('data', (message) => {
|
||||
mainWindow?.webContents.send(IPC_CHANNELS.SERVER_STATUS, {
|
||||
message: message.toString(),
|
||||
type: 'stdout',
|
||||
} satisfies ServerStatusEvent);
|
||||
const platform = getPlatform();
|
||||
const serverWorkdir = getServerDataFolder()
|
||||
const serverProcess = spawn(javaBin, ['-Xmx128M', '-jar', serverJar, 'run'], {
|
||||
cwd: serverWorkdir,
|
||||
shell: false,
|
||||
env:
|
||||
platform === 'windows'
|
||||
? {
|
||||
...process.env,
|
||||
APPDATA: app.getPath('appData'),
|
||||
LOCALAPPDATA: process.env['USERPROFILE'] ? path.join(process.env['USERPROFILE'], 'AppData', 'Local') : undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
process.stderr?.on('data', (message) => {
|
||||
mainWindow?.webContents.send(IPC_CHANNELS.SERVER_STATUS, {
|
||||
message: message.toString(),
|
||||
type: 'stderr',
|
||||
} satisfies ServerStatusEvent);
|
||||
const sendToWindow = (event: ServerStatusEvent) => {
|
||||
if (mainWindow && !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.SERVER_STATUS, event);
|
||||
}
|
||||
};
|
||||
|
||||
serverProcess.stdout?.on('data', (message) => {
|
||||
sendToWindow({ message: message.toString(), type: 'stdout' });
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on('data', (message) => {
|
||||
sendToWindow({ message: message.toString(), type: 'stderr' });
|
||||
});
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
logger.info({ err }, 'Error launching the java server');
|
||||
if (!isQuitting) app.quit();
|
||||
})
|
||||
|
||||
serverProcess.on('exit', () => {
|
||||
logger.info('Server process exiting');
|
||||
})
|
||||
|
||||
const exited = new Promise<void>((resolve) => serverProcess.once('exit', resolve));
|
||||
|
||||
return {
|
||||
process: process,
|
||||
close: () => {
|
||||
process.kill('SIGTERM');
|
||||
},
|
||||
process: serverProcess,
|
||||
close: () => serverProcess.kill(),
|
||||
waitForExit: () => exited,
|
||||
};
|
||||
};
|
||||
|
||||
let isQuitting = false;
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Register protocol handler for app:// scheme to handle assets with leading slashes
|
||||
protocol.handle('app', (request) => {
|
||||
@@ -426,23 +457,21 @@ app.whenReady().then(async () => {
|
||||
logger.info('SlimeVR started!');
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
app.quit();
|
||||
});
|
||||
|
||||
process.on('exit', () => {
|
||||
server?.close();
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
app.on('before-quit', async (event) => {
|
||||
if (isQuitting) return;
|
||||
isQuitting = true;
|
||||
event.preventDefault();
|
||||
logger.info('App quitting, saving...');
|
||||
server?.close();
|
||||
await server?.waitForExit();
|
||||
stores.settings.save();
|
||||
stores.cache.save();
|
||||
|
||||
discordPresence.destroy();
|
||||
|
||||
await saveWindowState();
|
||||
await closeLogger();
|
||||
app.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,3 +24,11 @@ const transport = pino.transport({
|
||||
});
|
||||
|
||||
export const logger = pino(transport);
|
||||
|
||||
export const closeLogger = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
logger.flush(() => {
|
||||
transport.once('close', resolve);
|
||||
transport.end();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,12 +103,12 @@ export const findSystemJRE = async (sharedDir: string) => {
|
||||
export const findServerJar = () => {
|
||||
const paths = [
|
||||
options.path ? path.resolve(options.path) : undefined,
|
||||
app.isPackaged ? path.resolve(process.resourcesPath) : undefined,
|
||||
// AppImage passes the fakeroot in `APPDIR` env var.
|
||||
process.env['APPDIR']
|
||||
? path.resolve(join(process.env['APPDIR'], 'usr/share/slimevr/'))
|
||||
: undefined,
|
||||
path.dirname(app.getPath('exe')),
|
||||
|
||||
// For flatpack container
|
||||
path.resolve('/app/share/slimevr/'),
|
||||
path.resolve('/usr/share/slimevr/'),
|
||||
|
||||
@@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
openUrl: (url) => ipcRenderer.invoke(IPC_CHANNELS.OPEN_URL, url),
|
||||
osStats: () => ipcRenderer.invoke(IPC_CHANNELS.OS_STATS),
|
||||
close: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_ACTIONS, 'close'),
|
||||
hide: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_ACTIONS, 'hide'),
|
||||
minimize: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_ACTIONS, 'minimize'),
|
||||
maximize: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_ACTIONS, 'maximize'),
|
||||
getStorage: async (type) => {
|
||||
|
||||
1
gui/electron/preload/interface.d.ts
vendored
1
gui/electron/preload/interface.d.ts
vendored
@@ -43,6 +43,7 @@ export interface IElectronAPI {
|
||||
openLogsFolder: () => Promise<void>;
|
||||
openConfigFolder: () => Promise<void>;
|
||||
close: () => void;
|
||||
hide: () => void;
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
showDecorations: (decorations: boolean) => void;
|
||||
|
||||
@@ -25,7 +25,7 @@ export const IPC_CHANNELS = {
|
||||
export interface IpcInvokeMap {
|
||||
[IPC_CHANNELS.OPEN_URL]: (url: string) => void;
|
||||
[IPC_CHANNELS.OS_STATS]: () => Promise<OSStats>;
|
||||
[IPC_CHANNELS.WINDOW_ACTIONS]: (action: 'close' | 'minimize' | 'maximize') => void;
|
||||
[IPC_CHANNELS.WINDOW_ACTIONS]: (action: 'close' | 'minimize' | 'maximize' | 'hide') => void;
|
||||
[IPC_CHANNELS.LOG]: (type: 'info' | 'error' | 'warn', ...args: unknown[]) => void;
|
||||
[IPC_CHANNELS.OPEN_DIALOG]: (
|
||||
options: OpenDialogOptions
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
websocket-connecting = Připojování k serveru
|
||||
websocket-connection_lost = Ztraceno spojení se serverem. Pokouším se znovu připojit...
|
||||
websocket-connection_lost-desc = Vypadá to že SlimeVR server spadl. Zkontrolujte záznamy protokolů a restartuje aplikaci
|
||||
websocket-timedout = Nelze se připojit k serveru
|
||||
websocket-timedout = Nepodařilo se připojit k serveru
|
||||
websocket-timedout-desc = Vypadá to že buď vypršel časový limit SlimeVR serveru, a nebo došlo k zhroucení. Zkontrolujte záznamy protokolů a restartuje aplikaci
|
||||
websocket-error-close = Ukončit SlimeVR
|
||||
websocket-error-logs = Otevření složku s záznamy protokolů
|
||||
@@ -33,6 +33,10 @@ tips-failed_webgl = Načtení WebGL selhalo.
|
||||
|
||||
## Units
|
||||
|
||||
unit-meter = Metr
|
||||
unit-foot = Foot
|
||||
unit-inch = Palec
|
||||
unit-cm = cm
|
||||
|
||||
## Body parts
|
||||
|
||||
@@ -73,6 +77,8 @@ board_type-WEMOSD1MINI = Wemos D1 Mini
|
||||
board_type-TTGO_TBASE = TTGO T-Base
|
||||
board_type-ESP01 = ESP-01
|
||||
board_type-SLIMEVR = SlimeVR
|
||||
board_type-SLIMEVR_DEV = SlimeVR Dev Board
|
||||
board_type-SLIMEVR_V1_2 = SlimeVR v1.2
|
||||
board_type-LOLIN_C3_MINI = Lolin C3 Mini
|
||||
board_type-BEETLE32C3 = Beetle ESP32-C3
|
||||
board_type-ESP32C3DEVKITM1 = Espressif ESP32-C3 DevKitM-1
|
||||
@@ -84,6 +90,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3
|
||||
board_type-HARITORA = Haritora
|
||||
board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1
|
||||
board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR vývojářská IMU rukavice
|
||||
board_type-GESTURES = Gesta
|
||||
board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini
|
||||
board_type-GENERIC_NRF = Obecné nRF
|
||||
board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly
|
||||
board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly
|
||||
|
||||
## Proportions
|
||||
|
||||
@@ -104,7 +115,7 @@ skeleton_bone-LOWER_LEG = Délka dolní části nohy
|
||||
skeleton_bone-FOOT_LENGTH = Délka chodidla
|
||||
skeleton_bone-FOOT_LENGTH-desc =
|
||||
Toto je vzdálenost mezi vaši kotníky a prsty na nohou.
|
||||
Pro upravení, Chodtě po špičkách dokud vaše virtuální nohy nezůstanou na místě.
|
||||
Pro upravení, Choďte po špičkách dokud vaše virtuální nohy nezůstanou na místě.
|
||||
skeleton_bone-FOOT_SHIFT = Odsazení chodidla
|
||||
skeleton_bone-SKELETON_OFFSET = Odsazení kostry
|
||||
skeleton_bone-SHOULDERS_DISTANCE = Vzdálenost ramen
|
||||
@@ -129,7 +140,11 @@ reset-reset_all_warning_default-v2 =
|
||||
Jste si jistí že to chcete udělat?
|
||||
reset-full = Plný Reset
|
||||
reset-mounting = Znovu nastavit nasazení
|
||||
reset-mounting-feet = Obnovit pozice nasazení nohou
|
||||
reset-mounting-fingers = Obnovit pozice nasazení prstů
|
||||
reset-yaw = Rychlý reset
|
||||
reset-error-no_feet_tracker = Žádný tracker nohou nebyl přiřazen
|
||||
reset-error-no_fingers_tracker = Žádné trackery prstů nebyly přiřazeny
|
||||
|
||||
## Serial detection stuff
|
||||
|
||||
@@ -149,11 +164,14 @@ navbar-trackers_assign = Přiřazení trackerů
|
||||
navbar-mounting = Kalibrace nasazení
|
||||
navbar-onboarding = Průvodce nastavením
|
||||
navbar-settings = Nastavení
|
||||
navbar-connect_trackers = Připojte Trackery
|
||||
|
||||
## Biovision hierarchy recording
|
||||
|
||||
bvh-start_recording = Nahrát BVH
|
||||
bvh-stop_recording = Uložit BVH záznam
|
||||
bvh-recording = Nahrávání...
|
||||
bvh-save_title = Uložit BVH záznam
|
||||
|
||||
## Tracking pause
|
||||
|
||||
@@ -194,7 +212,7 @@ widget-imu_visualizer-rotation_raw = Nezpracované
|
||||
widget-imu_visualizer-rotation_preview = Náhled
|
||||
widget-imu_visualizer-acceleration = Akcelerace
|
||||
widget-imu_visualizer-position = Pozice
|
||||
widget-imu_visualizer-stay_aligned = Zůstaň Srovaný (Stay Aligned)
|
||||
widget-imu_visualizer-stay_aligned = Zůstaň Srovnaný (Stay Aligned)
|
||||
|
||||
## Widget: Skeleton Visualizer
|
||||
|
||||
@@ -217,12 +235,13 @@ tracker-table-column-name = Název
|
||||
tracker-table-column-type = Typ
|
||||
tracker-table-column-battery = Baterie
|
||||
tracker-table-column-ping = Ping
|
||||
tracker-table-column-packet_loss = Ztráta Paketů
|
||||
tracker-table-column-tps = TPS
|
||||
tracker-table-column-temperature = Teplota °C
|
||||
tracker-table-column-linear-acceleration = Akcel. X/Y/Z
|
||||
tracker-table-column-rotation = Rotace X/Y/Z
|
||||
tracker-table-column-position = Pozice X/Y/Z
|
||||
tracker-table-column-stay_aligned = Zůstaň Srovaný (Stay Aligned)
|
||||
tracker-table-column-stay_aligned = Zůstaň Srovnaný (Stay Aligned)
|
||||
tracker-table-column-url = URL
|
||||
|
||||
## Tracker rotation
|
||||
@@ -258,6 +277,9 @@ tracker-infos-magnetometer-status-v1 =
|
||||
[ENABLED] Povoleno
|
||||
*[NOT_SUPPORTED] Není podporováno
|
||||
}
|
||||
tracker-infos-packet_loss = Ztráta Paketů
|
||||
tracker-infos-packets_lost = Pakety Ztraceny
|
||||
tracker-infos-packets_received = Pakety Přijaty
|
||||
|
||||
## Tracker settings
|
||||
|
||||
@@ -288,10 +310,16 @@ tracker-settings-name_section-label = Název trackeru
|
||||
tracker-settings-forget = Zapomenout tracker
|
||||
tracker-settings-forget-description = Odebere tracker z SlimeVR Serveru a zabrání jeho opětovnému připojení do té doby, dokud nebude server restarován. Konfigurace trackeru nebude ztracena.
|
||||
tracker-settings-forget-label = Zapomenout tracker
|
||||
tracker-settings-update-unavailable-v2 = Žádné vydání nebyla nalezena
|
||||
tracker-settings-update-incompatible = Nelze aktualizovat. Nekompatibilní deska nebo verze firmwaru
|
||||
tracker-settings-update-low-battery = Nelze provést aktualizaci. Baterie má méně než 50%
|
||||
tracker-settings-update-up_to_date = Aktuální
|
||||
tracker-settings-update-blocked = Není dostupná aktualizace. Žádná jiná verze není k dispozici
|
||||
tracker-settings-update = Aktualizovat nyní
|
||||
tracker-settings-update-title = Verze Firmwareu
|
||||
tracker-settings-current-version = Současný
|
||||
tracker-settings-latest-version = Nejnovější
|
||||
tracker-settings-build-date = Datum sestavení
|
||||
|
||||
## Tracker part card info
|
||||
|
||||
@@ -357,16 +385,20 @@ mounting_selection_menu-close = Zavřít
|
||||
|
||||
settings-sidebar-title = Nastavení
|
||||
settings-sidebar-general = Obecné
|
||||
settings-sidebar-steamvr = SteamVR
|
||||
settings-sidebar-tracker_mechanics = Mechanika trackerů
|
||||
settings-sidebar-stay_aligned = Zůstaň Srovaný (Stay Aligned)
|
||||
settings-sidebar-stay_aligned = Zůstaň Srovnaný (Stay Aligned)
|
||||
settings-sidebar-fk_settings = Nastavení trackování
|
||||
settings-sidebar-gesture_control = Ovládání gesty
|
||||
settings-sidebar-interface = Rozhraní
|
||||
settings-sidebar-osc_router = OSC router
|
||||
settings-sidebar-osc_trackers = VRChat OSC tracker
|
||||
settings-sidebar-osc_vmc = VMC
|
||||
settings-sidebar-utils = Nástroje
|
||||
settings-sidebar-serial = Sériová konzole
|
||||
settings-sidebar-appearance = Vzhled
|
||||
settings-sidebar-home = Domovská obrazovka
|
||||
settings-sidebar-checklist = Přehled trackování
|
||||
settings-sidebar-notifications = Notifikace
|
||||
settings-sidebar-behavior = Chování
|
||||
settings-sidebar-firmware-tool = Nástroj pro DIY firmware
|
||||
@@ -452,18 +484,25 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description =
|
||||
Použití magnetometr na všech trackerech které pro to mají kompatibilní firmware, snížení drifutu v stailních magnetických prostředích.
|
||||
Může být vypnuto pro jednotivé trackery v jejich nastaveních. <b> Prosíme nevypínejte žádný z trackerů při přepínání tohoto nastavení! </b>
|
||||
settings-general-tracker_mechanics-use_mag_on_all_trackers-label = Použít magnetometru na trackerech
|
||||
settings-stay_aligned = Zůstaň Srovaný (Stay Aligned)
|
||||
settings-stay_aligned-description = Zůstaň Srovaný redukuje drift pomocí postupného upravování vašich trackerů do vaší relaxůjící pózy.
|
||||
settings-stay_aligned-setup-label = Nastavte Zůstaň Sronaný
|
||||
settings-stay_aligned-setup-description = Musíte dokončit "Nastvení Zůstaň Srovaný" pro zapnutí Zůstaň Srovnaný.
|
||||
settings-general-tracker_mechanics-trackers_over_usb = Trackery přes USB
|
||||
settings-stay_aligned = Zůstaň Srovnaný (Stay Aligned)
|
||||
settings-stay_aligned-description = Zůstaň Srovnaný (Stay Aligned) redukuje drift pomocí postupného upravování vašich trackerů do vaší relaxůjící pózy.
|
||||
settings-stay_aligned-setup-label = Nastavte Zůstaň Srovnaný (Stay Aligned)
|
||||
settings-stay_aligned-setup-description = Musíte dokončit "Nastavení Zůstaň Srovnaný" pro zapnutí Zůstaň Srovnaný.
|
||||
settings-stay_aligned-warnings-drift_compensation = ⚠ Prosím vypněte Kompenzaci Driftu! Kompenzace driftu bude narušovat funkčnost Zůstaň Srovnaný.
|
||||
settings-stay_aligned-enabled-label = Upravit trackery
|
||||
settings-stay_aligned-hide_yaw_correction-label = Skrýt ladění (pro srovnání s vypnutým Zůstaň Srovnaný)
|
||||
settings-stay_aligned-general-label = Obecné
|
||||
settings-stay_aligned-relaxed_poses-label = Relaxovací Póza
|
||||
settings-stay_aligned-relaxed_poses-description = Zůstaň Srovnaný používá vaše uvolněné pózy k udržení srovnání trackerů. K aktualizaci těchto póz použijte "Nastavte Zůstaň Srovnaný".
|
||||
settings-stay_aligned-relaxed_poses-standing = Upravit trackery při stoje
|
||||
settings-stay_aligned-relaxed_poses-sitting = Upravit pozici trackerů při sezení na židli
|
||||
settings-stay_aligned-relaxed_poses-flat = Upravte pozici trackerů při sezení na zemi, nebo ležení na zádech
|
||||
settings-stay_aligned-relaxed_poses-save_pose = Uložit pózu
|
||||
settings-stay_aligned-relaxed_poses-reset_pose = Obnovit pózu
|
||||
settings-stay_aligned-relaxed_poses-close = Zavřít
|
||||
settings-stay_aligned-debug-label = Ladění
|
||||
settings-stay_aligned-debug-description = Při nahlašování problémů s Zůstaň Srovnaný, prosím zahrňte vaše nastavení.
|
||||
settings-stay_aligned-debug-copy-label = Zkopírovat nastavení do schránky
|
||||
|
||||
## FK/Tracking settings
|
||||
@@ -472,7 +511,7 @@ settings-general-fk_settings = Nastavení trackování
|
||||
# Floor clip:
|
||||
# why the name - came from the idea of noclip in video games, but is the opposite where clipping to the floor is a desired feature
|
||||
# definition - Prevents the foot trackers from going lower than they where when a reset was performed
|
||||
settings-general-fk_settings-leg_tweak-floor_clip = Podlahovej clip
|
||||
settings-general-fk_settings-leg_tweak-floor_clip = Clip podlahy
|
||||
# Skating correction:
|
||||
# why the name - without this enabled the feet will often slide across the ground as if your skating across the ground,
|
||||
# since this largely prevents this it corrects for it hence skating correction (note this may be renamed to sliding correction)
|
||||
@@ -486,11 +525,14 @@ settings-general-fk_settings-leg_tweak-floor_clip-description = Připnutí k pod
|
||||
settings-general-fk_settings-leg_tweak-toe_snap-description = Přichycení špiček se pokouší odhadnout rotaci vašich chodidel v případě, že nepoužíváte trackery chodidel.
|
||||
settings-general-fk_settings-leg_tweak-foot_plant-description = Narovnání chodidla při dotyku narovnává chodidla tak, aby byla rovnoběžně se zemí.
|
||||
settings-general-fk_settings-leg_fk = Sledování nohou
|
||||
settings-general-fk_settings-leg_fk-reset_mounting_feet-v1 = Vynutit kalibraci nasazení pro trackery nohou
|
||||
settings-general-fk_settings-enforce_joint_constraints = Limity kostry
|
||||
settings-general-fk_settings-enforce_joint_constraints-enforce_constraints = Prosazování omezení
|
||||
settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description = Zabránit rotaci kloubům za jejich limit
|
||||
settings-general-fk_settings-enforce_joint_constraints-correct_constraints = Opravit pomocí omezení
|
||||
settings-general-fk_settings-enforce_joint_constraints-correct_constraints-description = Opravit rotaci kloubů, když překročí svůj limit
|
||||
settings-general-fk_settings-ik = Data pozice
|
||||
settings-general-fk_settings-ik-use_position = Použít Data pozice
|
||||
settings-general-fk_settings-arm_fk = Trackování ramen
|
||||
settings-general-fk_settings-arm_fk-description = Vynutit sledování rukou z VR headsetu, i když jsou k dispozici údaje o poloze rukou z trackerů.
|
||||
settings-general-fk_settings-arm_fk-force_arms = Vynutit ruce z VR Headsetu
|
||||
@@ -614,6 +656,9 @@ settings-interface-behavior-error_tracking-description_v2 =
|
||||
|
||||
Aby jsme mohli poskytnout nejlepší zážitek uživatelům, schromažďujeme proto anonymizované zprávy o chybých, metriky výkon a informace o operačním systém. To nám pomáhá zjištovat chyby a problémy s SlimeVR. Tyto matriky jsou schromažďovány prostřednictvím Sentry.io.
|
||||
settings-interface-behavior-error_tracking-label = Odeslat chyby vývojářům
|
||||
settings-interface-behavior-bvh_directory = Cesta pro uložení BVH záznamů
|
||||
settings-interface-behavior-bvh_directory-description = Vyberte cestu k uložení záznamů BHV. namísto toho, abyste pokaždé vybírali, kam je uložit.
|
||||
settings-interface-behavior-bvh_directory-label = Lokace pro BVH nahrávky
|
||||
|
||||
## Serial settings
|
||||
|
||||
@@ -624,7 +669,7 @@ settings-serial-description =
|
||||
Může být užitečné, pokud potřebujete zjistit, zda se firmware chová špatně.
|
||||
settings-serial-connection_lost = Ztráta připojení k seriálu, Připojení se obnovuje...
|
||||
settings-serial-reboot = Restartovat
|
||||
settings-serial-factory_reset = Obnovení továrního nastavení
|
||||
settings-serial-factory_reset = Obnovení do továrního nastavení
|
||||
# This cares about multilines
|
||||
# <b>text</b> means that the text should be bold
|
||||
settings-serial-factory_reset-warning =
|
||||
@@ -637,6 +682,10 @@ settings-serial-auto_dropdown_item = Auto
|
||||
settings-serial-get_wifi_scan = Skenovat WiFi
|
||||
settings-serial-file_type = Prostý text
|
||||
settings-serial-save_logs = Uložit jako soubor
|
||||
settings-serial-send_command = Odeslat
|
||||
settings-serial-send_command-placeholder = Příkaz...
|
||||
settings-serial-send_command-warning-ok = Vím, co dělám!
|
||||
settings-serial-send_command-warning-cancel = Zrušit
|
||||
|
||||
## OSC router settings
|
||||
|
||||
@@ -729,6 +778,7 @@ settings-osc-vmc-mirror_tracking-label = Zrcadlení trackování
|
||||
|
||||
## Common OSC settings
|
||||
|
||||
settings-osc-common-network-port_banned_error = Port { $port } nelze použít!
|
||||
|
||||
## Advanced settings
|
||||
|
||||
@@ -765,9 +815,14 @@ settings-utils-advanced-open_logs-label = Otevřít složku
|
||||
|
||||
## Home Screen
|
||||
|
||||
settings-home-list-layout = Uspořádání seznamu trackerů
|
||||
settings-home-list-layout-desc = Vyberte jedno z možných uspořádání domovské obrazovky.
|
||||
settings-home-list-layout-grid = Mřížka
|
||||
settings-home-list-layout-table = Tabulka
|
||||
|
||||
## Tracking Checlist
|
||||
|
||||
settings-tracking_checklist-active_steps = Aktivní kroky
|
||||
|
||||
## Setup/onboarding menu
|
||||
|
||||
@@ -784,6 +839,7 @@ onboarding-setup_warning-cancel = Pokračovat v nastavení
|
||||
## Wi-Fi setup
|
||||
|
||||
onboarding-wifi_creds-back = Zpět na úvod
|
||||
onboarding-wifi_creds-v2 = Trackey používající Wi-Fi
|
||||
onboarding-wifi_creds-skip = Přeskočit nastavení Wi-Fi
|
||||
onboarding-wifi_creds-submit = Odeslat!
|
||||
onboarding-wifi_creds-ssid =
|
||||
@@ -793,6 +849,8 @@ onboarding-wifi_creds-ssid-required = Je vyžadován název sítě Wi-Fi
|
||||
onboarding-wifi_creds-password =
|
||||
.label = Heslo
|
||||
.placeholder = Zadejte heslo
|
||||
onboarding-wifi_creds-dongle-title = Trackery používající dongle
|
||||
onboarding-wifi_creds-dongle-continue = Pokračovat s donglem
|
||||
|
||||
## Mounting setup
|
||||
|
||||
@@ -814,7 +872,7 @@ onboarding-reset_tutorial-1 =
|
||||
|
||||
## Setup start
|
||||
|
||||
onboarding-home = Vítejte k SlimeVR
|
||||
onboarding-home = Vítejte ve SlimeVR
|
||||
onboarding-home-start = Pusťme se do toho!
|
||||
|
||||
## Setup done
|
||||
@@ -885,6 +943,7 @@ onboarding-assignment_tutorial-done = Nachystal jsem samolepky a pásky!
|
||||
onboarding-assign_trackers-back = Zpět na přihlašovací údaje Wi-Fi
|
||||
onboarding-assign_trackers-title = Přiřazení trackerů
|
||||
onboarding-assign_trackers-description = Vyberte, na jakou končetinu každý tracker patří. Klikněte na místo, kam chcete umístit tracker
|
||||
onboarding-assign_trackers-unassign_all = Zrušit přiřazení všech trackerů
|
||||
# Look at translation of onboarding-connect_tracker-connected_trackers on how to use plurals
|
||||
# $assigned (Number) - Trackers that have been assigned a body part
|
||||
# $trackers (Number) - Trackers connected to the server
|
||||
@@ -932,7 +991,7 @@ onboarding-assign_trackers-warning-LEFT_FOOT =
|
||||
|
||||
## Tracker mounting method choose
|
||||
|
||||
onboarding-choose_mounting = Jakou metodu nasazení trackerů použít?
|
||||
onboarding-choose_mounting = Jakou metodu nasazení trackerů chcete použít?
|
||||
# Multiline text
|
||||
onboarding-choose_mounting-description = Správná orientace nasazení zajistí přesné sledování trackerů na těle.
|
||||
onboarding-choose_mounting-auto_mounting = Automatická detekce nasazení
|
||||
@@ -975,12 +1034,15 @@ onboarding-automatic_mounting-mounting_reset-step-0 = 1. Dřepněte si, jako př
|
||||
onboarding-automatic_mounting-mounting_reset-step-1 = 2. Stiskněte tlačítko "Resetovat nasazení trackerů" a vyčkejte 3 sekundy. Orientace nasazení trackerů se nastaví na základní hodnoty.
|
||||
onboarding-automatic_mounting-preparation-title = Příprava
|
||||
onboarding-automatic_mounting-preparation-v2-step-0 = 1. Stiskněte tlačítko pro "Plný Reset"
|
||||
onboarding-automatic_mounting-preparation-v2-step-2 = 3. Zůstaňte v pozici, dokud 3s časovač neskončí.
|
||||
onboarding-automatic_mounting-put_trackers_on-title = Nasaďte si trackery
|
||||
onboarding-automatic_mounting-put_trackers_on-description = Pro kalibraci směru nasazení použijeme právě přiřazené trackery. Nasaďte si prosím všechny trackery. Můžete zkontrolovat jejich umístění na obrázku vpravo.
|
||||
onboarding-automatic_mounting-put_trackers_on-next = Mám nasazené všechny trackery
|
||||
onboarding-automatic_mounting-return-home = Hotovo
|
||||
|
||||
## Tracker manual proportions setupa
|
||||
|
||||
onboarding-manual_proportions-back-scaled = Jít zpět na Škálování Proporcí
|
||||
onboarding-manual_proportions-title = Manuální proporce těla
|
||||
onboarding-manual_proportions-fine_tuning_button = Automatické jemné doladění proporcí
|
||||
onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Pro použití automatického jemného lazení, prosím připojte VR headset
|
||||
@@ -1081,27 +1143,57 @@ onboarding-automatic_proportions-smol_warning-cancel = Jít zpět
|
||||
|
||||
## User height calibration
|
||||
|
||||
onboarding-user_height-title = Jaká je vaše výška?
|
||||
onboarding-user_height-calculate = Vypočítejte mou výšku automaticky
|
||||
onboarding-user_height-next_step = Uložit a pokračovat
|
||||
onboarding-user_height-manual-proportions = Manuální Proporce
|
||||
onboarding-user_height-calibration-title = Průběh kalibrace
|
||||
onboarding-user_height-calibration-WAITING_FOR_RISE = Postavte se zpátky
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Ujistěte se, že je vaše hlava ve vodorovné pozici
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = Nedívejte se na podlahu
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = Nedívej se příliš vysoko
|
||||
onboarding-user_height-calibration-RECORDING_HEIGHT = Znovu se postavte a nehýbejte se!
|
||||
onboarding-user_height-calibration-DONE = Úspěch!
|
||||
onboarding-user_height-calibration-ERROR_TIMEOUT = Časový limit kalibrace vypršel, zkuste to znovu.
|
||||
onboarding-user_height-calibration-error = Kalibrace selhala
|
||||
|
||||
## Stay Aligned setup
|
||||
|
||||
onboarding-stay_aligned-title = Zůstaň Srovaný!
|
||||
onboarding-stay_aligned-description = Nakonfigurujte Zustaň Srovnaný, aby byly vaše trackery srovnáný.
|
||||
onboarding-stay_aligned-title = Zůstaň Srovnaný!
|
||||
onboarding-stay_aligned-description = Nakonfigurujte Zůstaň Srovnaný, aby byly vaše trackery srovnány.
|
||||
onboarding-stay_aligned-put_trackers_on-title = Nasaďte si trackery
|
||||
onboarding-stay_aligned-put_trackers_on-trackers_warning = Aktuálně máte méně než 5 připojených a přiřazených trackerů! Toto je minimální počet trackerů potřebné pro správné fungování funkce Zůstaň Srovnaný.
|
||||
onboarding-stay_aligned-put_trackers_on-next = Mám nasazené všechny trackery
|
||||
onboarding-stay_aligned-verify_mounting-title = Zkotrolujte nasazení
|
||||
onboarding-stay_aligned-verify_mounting-step-0 = Zůstaň Srovnaný vyžaduje dobré nasazení. V opačném případě nebudete mít nejlepší zážitek s Zůstaň Srovnaný.
|
||||
onboarding-stay_aligned-verify_mounting-step-1 = 1. Pohybujte se ve stoje.
|
||||
onboarding-stay_aligned-verify_mounting-step-2 = 2. Posaďte se a pohybujte nohama a chodidly.
|
||||
onboarding-stay_aligned-verify_mounting-redo_mounting = Předělat kalibraci nasazení
|
||||
onboarding-stay_aligned-preparation-title = Příprava
|
||||
onboarding-stay_aligned-preparation-tip = Ujistěte se, že stojíte vzpřímeně. koukáte vpřed a máte ruce podél těla.
|
||||
onboarding-stay_aligned-relaxed_poses-standing-title = Uvolněná pozice ve stoje
|
||||
onboarding-stay_aligned-relaxed_poses-standing-step-0 = 1. Stůjte v pohodlné pozici. Relaxujte!
|
||||
onboarding-stay_aligned-relaxed_poses-standing-step-1-v2 = 2. Zmáčkněte tlačítko "Uložit pózu"
|
||||
onboarding-stay_aligned-relaxed_poses-sitting-title = Uvolněná póza při sezení v židli
|
||||
onboarding-stay_aligned-relaxed_poses-sitting-step-0 = 1. Posaďte se do pohodlné pozice, Relaxujte!
|
||||
onboarding-stay_aligned-relaxed_poses-sitting-step-1-v2 = 2. Zmáčkněte tlačítko "Uložit pózu"
|
||||
onboarding-stay_aligned-relaxed_poses-flat-title = Uvolněná pozice při sezení na zemi
|
||||
onboarding-stay_aligned-relaxed_poses-flat-step-1-v2 = 2. Zmáčkněte tlačítko "Uložit pózu"
|
||||
onboarding-stay_aligned-relaxed_poses-skip_step = Přeskočit
|
||||
onboarding-stay_aligned-done-title = Zustaň Srovnaný zapnuto!
|
||||
onboarding-stay_aligned-done-title = Zůstaň Srovnaný zapnuto!
|
||||
onboarding-stay_aligned-done-description = Váš nastavení Zůstaň Srovnaný je dokončeno!
|
||||
onboarding-stay_aligned-done-description-2 = Vaše nastavení je dokončeno! Pokud chcete vaše pózy znovu zkalibrovat, můžete proces zopakovat.
|
||||
onboarding-stay_aligned-previous_step = Předchozí
|
||||
onboarding-stay_aligned-next_step = Další
|
||||
onboarding-stay_aligned-restart = Restart
|
||||
onboarding-stay_aligned-done = Hotovo
|
||||
onboarding-stay_aligned-manual_mounting-done = Hotovo
|
||||
|
||||
## Home
|
||||
|
||||
home-no_trackers = Nebyly zjištěny ani přiřazeny žádné trackery
|
||||
home-settings = Nastavení domovské stránky
|
||||
home-settings-close = Zavřít
|
||||
|
||||
## Trackers Still On notification
|
||||
|
||||
@@ -1137,8 +1229,28 @@ firmware_tool = Nástroj pro DIY firmwere
|
||||
firmware_tool-description = Umožní vám konfigurovat a flashovat vaše DIY trackery
|
||||
firmware_tool-not_available = Jejda, nástroj pro firmware není v momentální chvíli k dispozici, Vraťte se později!
|
||||
firmware_tool-not_compatible = Nástroj pro firmware není kompatibilní s touhle verzí serveru. Aktualizujte prosím svůj server.
|
||||
firmware_tool-select_source = Vyberte firmware k flashování
|
||||
firmware_tool-select_source-error = Nelze načíst Zdroje
|
||||
firmware_tool-select_source-board_type = Typ desky
|
||||
firmware_tool-select_source-firmware = Zdrojový kód firmwaru
|
||||
firmware_tool-select_source-version = Verze firmwaru
|
||||
firmware_tool-select_source-official = Oficiální
|
||||
firmware_tool-select_source-dev = Vývojářské
|
||||
firmware_tool-select_source-not_selected = Nebyl vybrán žádný zdroj
|
||||
firmware_tool-board_defaults = Nekonfigurujte vaší desku
|
||||
firmware_tool-board_defaults-add = Přidat
|
||||
firmware_tool-board_defaults-reset = Restartovat do výchozího nastavení
|
||||
firmware_tool-board_defaults-error-required = Povinné pole
|
||||
firmware_tool-board_defaults-error-format = Neplatný formát
|
||||
firmware_tool-board_defaults-error-format-number = Není číslo
|
||||
firmware_tool-flash_method_step = Metoda flashování
|
||||
firmware_tool-flash_method_step-description = Prosím zvolte metodu flashování, kterou chcete použít
|
||||
firmware_tool-flash_method_step-ota-v2 =
|
||||
.label = Wi-Fi
|
||||
.description = Použijte "wireless" metodu. Vaše trackery budou používát Wi-Fi pro aktualizování jejich firmweru. Funguje pouze u trackerů, které již byly nastaveny.
|
||||
firmware_tool-flash_method_step-serial-v2 =
|
||||
.label = USB
|
||||
.description = Použíjte USB kabel k aktualizování vaších trackerů
|
||||
firmware_tool-flashbtn_step = Stiskněte tlačítko bootu btn
|
||||
firmware_tool-flashbtn_step-description = Než přejdeme na další krok, je tady pár věcí které musíte udělat
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR = Vypněte tracker, vyndejte z obalu (jestli v nějakém je), Připojte USB kabel k tomuto počítači a poté následujte jeden z kroků revize odpovídající k vaší verzi desky trackeru SlimeVR:
|
||||
@@ -1147,8 +1259,10 @@ firmware_tool-flashbtn_step-board_OTHER =
|
||||
Ve většině případů to znamená stisknutí boot tlačítka na desce trakeru před tím než začne proces flashování.
|
||||
Pokud procesu flashování vyprší čas hned na začátku flashování, to nejspíš znamená že tracker nebyl v řežimu bootloaderu
|
||||
Podívejte se prosím na instrukce procesu flashování pro desku vašeho zařízení, aby jste zjistili jak se dostat do režimu bootloaderu
|
||||
firmware_tool-flash_method_ota-title = Flashování přes Wi-Fi
|
||||
firmware_tool-flash_method_ota-devices = Byla detekována zařízení s OTA:
|
||||
firmware_tool-flash_method_ota-no_devices = Nebyly nalezeny žádné zákadní desky které by mohly být aktualizované pomocí OTA, prosím ujistěte se že jste zvolily správný typ základní desky
|
||||
firmware_tool-flash_method_serial-title = Flashování přes USB
|
||||
firmware_tool-flash_method_serial-wifi = Přihlašovací údaje Wi-Fi:
|
||||
firmware_tool-flash_method_serial-devices-label = Detekována Sériová Zařízení:
|
||||
firmware_tool-flash_method_serial-devices-placeholder = Vyberte sériové zařízení
|
||||
@@ -1157,12 +1271,16 @@ firmware_tool-build_step = Sestavování
|
||||
firmware_tool-build_step-description = Firmwere se sestavuje, čekejte prosím
|
||||
firmware_tool-flashing_step = Flashování
|
||||
firmware_tool-flashing_step-description = Probíhá flashování vašich trackerů, prosím postupujte dle instrukcí na obrazovce
|
||||
firmware_tool-flashing_step-warning-v2 = Během procesu nahrávání prosíme NEVYPÍNEJTE ani NEODPOJUJTE vaše trackery pokud k tomu nejste vyzváni, učiněním můžete způsobit že deska trackeru se stane nefunkční.
|
||||
firmware_tool-flashing_step-flash_more = Flashnout více trackerů
|
||||
firmware_tool-flashing_step-exit = Odejít
|
||||
|
||||
## firmware tool build status
|
||||
|
||||
firmware_tool-build-QUEUED = Čekání na sestavení...
|
||||
firmware_tool-build-CREATING_BUILD_FOLDER = Vytváření složky pro sestavení
|
||||
firmware_tool-build-DOWNLOADING_SOURCE = Stahování zdrojového kódu
|
||||
firmware_tool-build-EXTRACTING_SOURCE = Extrahování zdrojového kódu
|
||||
firmware_tool-build-BUILDING = Sestavování firmweru
|
||||
firmware_tool-build-SAVING = Ukládání sestavení
|
||||
firmware_tool-build-DONE = Sestavení dokončeno
|
||||
@@ -1171,6 +1289,7 @@ firmware_tool-build-ERROR = Nepodařilo se sestavit firmwere
|
||||
## Firmware update status
|
||||
|
||||
firmware_update-status-DOWNLOADING = Stahování firmwaru
|
||||
firmware_update-status-NEED_MANUAL_REBOOT-v2 = Vypněte a znovu zapněte tracker prosím
|
||||
firmware_update-status-AUTHENTICATING = Autentifikování s mcu
|
||||
firmware_update-status-UPLOADING = Nahrávání firmwaru
|
||||
firmware_update-status-SYNCING_WITH_MCU = Synchronizace s MCU
|
||||
@@ -1195,7 +1314,7 @@ firmware_update-no_devices = Prosím ujistěte se, že tracker který chcete akt
|
||||
firmware_update-changelog-title = Aktualizování na { $version }
|
||||
firmware_update-looking_for_devices = Hledání zařízení pro aktualizaci
|
||||
firmware_update-retry = Opakovat
|
||||
firmware_update-update = Aktualizovat Zvolený/é Tracker/y
|
||||
firmware_update-update = Aktualizovat Zvolené Trackery
|
||||
firmware_update-exit = Odejít
|
||||
|
||||
## Tray Menu
|
||||
@@ -1225,10 +1344,15 @@ unknown_device-modal-description =
|
||||
Chcete jej připojit k SlimeVR?
|
||||
unknown_device-modal-confirm = Jasně!
|
||||
unknown_device-modal-forget = Ignoruj
|
||||
# VRChat config warnings
|
||||
vrc_config-page-title = Varování VRChat konfigurace
|
||||
vrc_config-page-desc = Tato stránka slouží k zobrazení vašeho aktuálního stavu nastavení ve VRChat. přesněji, nástavní které jsou nekompatibilní s SlimeVR. Je silně doporučeno poupravit všechny chybné nastavení které jsou zde zobrazeny pro nejlepší zážitek s SlimeVR.
|
||||
vrc_config-page-help = Nemůžete najít specifické nastavení?
|
||||
vrc_config-page-help-desc = Podívejte se na naší <a>dokumentaci k tomuto tématu!</a>
|
||||
vrc_config-page-big_menu = Sledování & IK (Velké Menu)
|
||||
vrc_config-page-big_menu-desc = Nastavení souvicející s IK ve velké nabídce nastavení
|
||||
vrc_config-page-wrist_menu = Sledování & IK (Zápěstní menu)
|
||||
vrc_config-page-wrist_menu-desc = Nastavení související s IK najdete v malém (zápěstním) menu
|
||||
vrc_config-on = Zapnuto
|
||||
vrc_config-off = Vypnuto
|
||||
vrc_config-invalid = Máte špatně nakonfigurované VRChat nastavení!
|
||||
@@ -1241,6 +1365,7 @@ vrc_config-mute-btn = Ztlumení
|
||||
vrc_config-unmute-btn = Zrušit ztlumení
|
||||
vrc_config-legacy_mode = Použít starší řešení IK
|
||||
vrc_config-disable_shoulder_tracking = Vypnout sledování ramen
|
||||
vrc_config-shoulder_width_compensation = Kompenzace Šířky Ramen
|
||||
vrc_config-spine_mode = Režim páteře FTB
|
||||
vrc_config-tracker_model = Model FBT trackeru
|
||||
vrc_config-avatar_measurement_type = Meření avataru
|
||||
@@ -1272,3 +1397,28 @@ error_collection_modal-cancel = Nesouhlasím
|
||||
|
||||
## Tracking checklist section
|
||||
|
||||
tracking_checklist-settings-close = Zavřít
|
||||
tracking_checklist-status-incomplete = Nejste připraveni používat SlimeVR!
|
||||
tracking_checklist-status-complete = Jste připravení k použití SlimeVR
|
||||
tracking_checklist-FULL_RESET = Proveďte plné obnovení
|
||||
tracking_checklist-STEAMVR_DISCONNECTED = SteamVR není zapnut
|
||||
tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR není zapnut. Používáte ho pro VR?
|
||||
tracking_checklist-STEAMVR_DISCONNECTED-open = Spusťte SteamVR
|
||||
tracking_checklist-TRACKERS_REST_CALIBRATION = Kalibrujte vaše trackery
|
||||
tracking_checklist-TRACKER_ERROR = Trackery s chybami
|
||||
tracking_checklist-VRCHAT_SETTINGS = Nakonfigurujte nastavení VRChat
|
||||
tracking_checklist-VRCHAT_SETTINGS-open = Přejít k varování ve VRChat
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC = Změňte profil sítě
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Otevřete Ovládací Panel
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED = Nakonfigurujte Zůstaň Srovnaný
|
||||
tracking_checklist-ignore = Ignorovat
|
||||
preview-mocap_mode_soon = Režim Mocap (brzy™)
|
||||
preview-disable_render = Vypnout vykreslování
|
||||
preview-disabled_render = Vykreslování vypnuto
|
||||
toolbar-mounting_calibration = Kalibrace nasazení
|
||||
toolbar-mounting_calibration-default = Tělo
|
||||
toolbar-mounting_calibration-feet = Chodidla
|
||||
toolbar-mounting_calibration-fingers = Prsty
|
||||
toolbar-drift_reset = Restartování driftu
|
||||
toolbar-assigned_trackers = { $count } trackery/ů přiřazeno
|
||||
toolbar-unassigned_trackers = { $count } trackey/ů nepřiřazeno
|
||||
|
||||
@@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3
|
||||
board_type-HARITORA = Haritora
|
||||
board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1
|
||||
board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev-IMU-Handschuh
|
||||
board_type-GESTURES = Gesten
|
||||
board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini
|
||||
board_type-GENERIC_NRF = Generisches nRF
|
||||
board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly
|
||||
board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly
|
||||
|
||||
## Proportions
|
||||
|
||||
@@ -180,6 +185,8 @@ reset-mounting-fingers = Fingerkalibrierung
|
||||
reset-yaw = Horizontaler Reset
|
||||
reset-error-no_feet_tracker = Kein Fußtracker zugewiesen
|
||||
reset-error-no_fingers_tracker = Kein Fingertracker zugewiesen
|
||||
reset-error-mounting-need_full_reset = Ein vollständiger Reset ist vor der Tracker-Ausrichtung erforderlich.
|
||||
reset-error-yaw-need_full_reset = Für den Yaw-Reset ist ein vollständiger Reset erforderlich.
|
||||
|
||||
## Serial detection stuff
|
||||
|
||||
@@ -199,6 +206,7 @@ navbar-trackers_assign = Tracker-Zuordnung
|
||||
navbar-mounting = Tracker-Ausrichtung
|
||||
navbar-onboarding = Einrichtungs-Assistent
|
||||
navbar-settings = Einstellungen
|
||||
navbar-connect_trackers = Tracker verbinden
|
||||
|
||||
## Biovision hierarchy recording
|
||||
|
||||
@@ -269,6 +277,7 @@ tracker-table-column-name = Name
|
||||
tracker-table-column-type = Typ
|
||||
tracker-table-column-battery = Batterie
|
||||
tracker-table-column-ping = Latenz
|
||||
tracker-table-column-packet_loss = Paketverlust
|
||||
tracker-table-column-tps = TPS
|
||||
tracker-table-column-temperature = Temp. °C
|
||||
tracker-table-column-linear-acceleration = Beschleunigung X/Y/Z
|
||||
@@ -310,6 +319,9 @@ tracker-infos-magnetometer-status-v1 =
|
||||
[ENABLED] Angeschalten
|
||||
*[NOT_SUPPORTED] Nicht unterstützt
|
||||
}
|
||||
tracker-infos-packet_loss = Paketverlust
|
||||
tracker-infos-packets_lost = Pakete verloren
|
||||
tracker-infos-packets_received = Pakete empfangen
|
||||
|
||||
## Tracker settings
|
||||
|
||||
@@ -347,6 +359,9 @@ tracker-settings-update-up_to_date = Auf dem neusten Stand
|
||||
tracker-settings-update-blocked = Update nicht verfügbar. Weitere Veröffentlichungen sind nicht verfügbar.
|
||||
tracker-settings-update = Jetzt aktualisieren
|
||||
tracker-settings-update-title = Firmware-Version
|
||||
tracker-settings-current-version = Aktuelle
|
||||
tracker-settings-latest-version = Aktuelleste
|
||||
tracker-settings-build-date = Herstellungsdatum
|
||||
|
||||
## Tracker part card info
|
||||
|
||||
@@ -511,14 +526,19 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description =
|
||||
Verwendet das Magnetometer auf allen Trackern, die über eine kompatible Firmware verfügen, um den Drift in stabilen magnetischen Umgebungen zu reduzieren.
|
||||
Kann pro Tracker in den Einstellungen des Trackers deaktiviert werden. <b>Bitte schalten Sie keinen der Tracker aus, während Sie dies umschalten!</b>
|
||||
settings-general-tracker_mechanics-use_mag_on_all_trackers-label = Magnetometer auf Trackern verwenden
|
||||
settings-general-tracker_mechanics-trackers_over_usb = Tracker über USB
|
||||
settings-general-tracker_mechanics-trackers_over_usb-enabled-label = Erlaube HID-Tracker eine USB-Direktverbindung
|
||||
settings-stay_aligned = Stay Aligned
|
||||
settings-stay_aligned-description = Stay Aligned reduziert Drift, indem es deine Tracker schrittweise an deine entspannten Posen anpasst.
|
||||
settings-stay_aligned-setup-label = Stay Aligned einrichten
|
||||
settings-stay_aligned-setup-description = Sie müssen Stay Aligned einrichten, um es zu aktivieren.
|
||||
settings-stay_aligned-warnings-drift_compensation = ⚠ Bitte schalten Sie die Driftkompensation aus! Diese steht in Konflikt mit Stay Aligned.
|
||||
settings-stay_aligned-enabled-label = Tracker anpassen
|
||||
settings-stay_aligned-hide_yaw_correction-label = Anpassung ausblenden (zum Vergleich ohne Stay Aligned)
|
||||
settings-stay_aligned-general-label = Allgemein
|
||||
settings-stay_aligned-relaxed_poses-label = Entspannte Posen
|
||||
settings-stay_aligned-relaxed_poses-standing = Tracker im Stehen anpassen
|
||||
settings-stay_aligned-relaxed_poses-sitting = Tracker anpassen, während du auf einem Stuhl sitzt
|
||||
settings-stay_aligned-relaxed_poses-save_pose = Pose speichern
|
||||
settings-stay_aligned-relaxed_poses-reset_pose = Pose zurücksetzen
|
||||
settings-stay_aligned-relaxed_poses-close = Schließen
|
||||
@@ -546,6 +566,8 @@ settings-general-fk_settings-leg_tweak-floor_clip-description = Bodenclip kann d
|
||||
settings-general-fk_settings-leg_tweak-toe_snap-description = Zehen-Ausrichtung versucht, die Rotation Ihrer Füße zu erraten, wenn keine Fuß-Tracker verwendet werden.
|
||||
settings-general-fk_settings-leg_tweak-foot_plant-description = Fußkorrektur richtet Ihre Füße parallel zum Boden aus, wenn sie den Boden berühren.
|
||||
settings-general-fk_settings-leg_fk = Beintracking
|
||||
settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1 = Erzwinge Fußausrichtungs-Kalibrierung während der Körperausrichtungs-Kalibrierung.
|
||||
settings-general-fk_settings-leg_fk-reset_mounting_feet-v1 = Fuß-Ausrichtung kalibrieren
|
||||
settings-general-fk_settings-enforce_joint_constraints = Gelenkgrenzen
|
||||
settings-general-fk_settings-enforce_joint_constraints-enforce_constraints = Grenzen erzwingen
|
||||
settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description = Verhindert, dass sich Gelenke über ihre Grenzen hinaus drehen
|
||||
@@ -845,10 +867,12 @@ settings-utils-advanced-open_logs-label = Ordner öffnen
|
||||
|
||||
settings-home-list-layout = Layout der Tracker-Liste
|
||||
settings-home-list-layout-desc = Wählen Sie eines der möglichen Startbildschirm-Layouts aus
|
||||
settings-home-list-layout-grid = Raster
|
||||
settings-home-list-layout-table = Tabelle
|
||||
|
||||
## Tracking Checlist
|
||||
|
||||
settings-tracking_checklist-active_steps = Aktive Schritte
|
||||
|
||||
## Setup/onboarding menu
|
||||
|
||||
@@ -863,6 +887,7 @@ onboarding-setup_warning-cancel = Einrichtung fortsetzen
|
||||
## Wi-Fi setup
|
||||
|
||||
onboarding-wifi_creds-back = Zurück zur Einführung
|
||||
onboarding-wifi_creds-v2 = Tracker mit WLAN
|
||||
onboarding-wifi_creds-skip = WLAN-Zugangsdaten überspringen
|
||||
onboarding-wifi_creds-submit = Weiter!
|
||||
onboarding-wifi_creds-ssid =
|
||||
@@ -872,6 +897,8 @@ onboarding-wifi_creds-ssid-required = WLAN-Name ist erforderlich
|
||||
onboarding-wifi_creds-password =
|
||||
.label = Passwort
|
||||
.placeholder = Passwort eingeben
|
||||
onboarding-wifi_creds-dongle-title = Tracker mit einem Dongle
|
||||
onboarding-wifi_creds-dongle-continue = Fahre mit einem Dongle fort
|
||||
|
||||
## Mounting setup
|
||||
|
||||
@@ -970,6 +997,7 @@ onboarding-assignment_tutorial-done = Ich habe Aufkleber und Bänder angebracht!
|
||||
onboarding-assign_trackers-back = Zurück zu den WLAN-Zugangsdaten
|
||||
onboarding-assign_trackers-title = Tracker zuweisen
|
||||
onboarding-assign_trackers-description = Wählen Sie nun aus, welcher Tracker wo befestigt ist. Klicken Sie auf einen Ort, an dem der Tracker platziert ist.
|
||||
onboarding-assign_trackers-unassign_all = Alle Trackerzuweisungen aufheben
|
||||
# Look at translation of onboarding-connect_tracker-connected_trackers on how to use plurals
|
||||
# $assigned (Number) - Trackers that have been assigned a body part
|
||||
# $trackers (Number) - Trackers connected to the server
|
||||
@@ -1115,6 +1143,9 @@ onboarding-automatic_mounting-mounting_reset-title = Befestigungs-Reset
|
||||
onboarding-automatic_mounting-mounting_reset-step-0 = 1. Beugen Sie sich in die "Skifahren"-Pose mit gebeugten Beinen, geneigtem Oberkörper und gebeugten Armen.
|
||||
onboarding-automatic_mounting-mounting_reset-step-1 = 2. Drücken Sie die Schaltfläche "Befestigungs-Reset" und warten Sie 3 Sekunden, bevor die Drehungen der Tracker gesetzt werden.
|
||||
onboarding-automatic_mounting-preparation-title = Vorbereitung
|
||||
onboarding-automatic_mounting-preparation-v2-step-0 = 1. Drücke den Knopf "Kompletter Reset".
|
||||
onboarding-automatic_mounting-preparation-v2-step-1 = 2. Stehe aufrecht mit den Armen an den Seiten. Schaue unbedingt nach vorne.
|
||||
onboarding-automatic_mounting-preparation-v2-step-2 = 3. Halte die Position, bis 3 Sekunden abgelaufen sind.
|
||||
onboarding-automatic_mounting-put_trackers_on-title = Legen Sie Ihre Tracker an
|
||||
onboarding-automatic_mounting-put_trackers_on-description = Um die Drehung der Tracker zu kalibrieren, werden die Tracker verwendet, welche Sie gerade zugewiesen haben. Ziehen Sie alle Ihre Tracker an, in der Abbildung rechts können sie sehen um welchen Tracker es sich handelt.
|
||||
onboarding-automatic_mounting-put_trackers_on-next = Ich habe alle meine Tracker angelegt
|
||||
@@ -1128,6 +1159,7 @@ onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Bitte schlie
|
||||
onboarding-manual_proportions-export = Proportionen exportieren
|
||||
onboarding-manual_proportions-import = Proportionen importieren
|
||||
onboarding-manual_proportions-file_type = Körperproportions-Datei
|
||||
onboarding-manual_proportions-grouped_proportions = Gruppierte Proportionen
|
||||
onboarding-manual_proportions-all_proportions = Alle Proportionen
|
||||
onboarding-manual_proportions-estimated_height = Geschätzte Benutzergröße
|
||||
|
||||
@@ -1216,10 +1248,26 @@ onboarding-automatic_proportions-smol_warning-cancel = Zurück
|
||||
|
||||
## User height calibration
|
||||
|
||||
onboarding-user_height-title = Wie groß bist du?
|
||||
onboarding-user_height-description = Wir brauchen deine Größe, um deine Körperproportionen zu berechnen und deine Bewegungen genau darzustellen. Du kannst dies entweder SlimeVR berechnen lassen oder deine Höhe manuell eingeben.
|
||||
onboarding-user_height-calculate = Berechne meine Körpergröße automatisch
|
||||
onboarding-user_height-next_step = Fortfahren und speichern
|
||||
onboarding-user_height-manual-proportions = Manuelle Körperproportionen
|
||||
onboarding-user_height-calibration-title = Kalibrierungsfortschritt
|
||||
onboarding-user_height-calibration-WAITING_FOR_RISE = Steh wieder auf
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK = Steh wieder auf und schau nach vorne
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Achte darauf, dass dein Kopf waagerecht ist
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = Schauen sie nicht auf den Boden
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = Schauen sie nicht zu hoch nach oben
|
||||
onboarding-user_height-calibration-WAITING_FOR_CONTROLLER_PITCH = Achten sie darauf, dass der Controller nach unten zeigt
|
||||
onboarding-user_height-calibration-RECORDING_HEIGHT = Steh wieder auf und steh still!
|
||||
onboarding-user_height-calibration-DONE = Erfolg!
|
||||
onboarding-user_height-calibration-ERROR_TIMEOUT = Die Kalibrierung ist abgelaufen, versuche es nochmal.
|
||||
onboarding-user_height-calibration-ERROR_TOO_HIGH = Die erkannte Benutzerhöhe ist zu hoch, versuche es erneut.
|
||||
onboarding-user_height-calibration-error = Kalibrierung fehlgeschlagen
|
||||
onboarding-user_height-reset-warning =
|
||||
<b>Achtung:</b> Die Proportionen werden zurückgesetzt und auf Basis deiner Körpergröße neu berechnet.
|
||||
Bist du dir sicher?
|
||||
|
||||
## Stay Aligned setup
|
||||
|
||||
@@ -1230,20 +1278,34 @@ onboarding-stay_aligned-put_trackers_on-description = Um Ihre Ruheposen zu speic
|
||||
onboarding-stay_aligned-put_trackers_on-trackers_warning = Sie haben derzeit weniger als 5 Tracker verbunden und zugewiesen! Dies ist die Mindestanzahl an Trackern, die erforderlich sind, damit Stay Aligned richtig funktioniert.
|
||||
onboarding-stay_aligned-put_trackers_on-next = Ich habe alle meine Tracker angelegt
|
||||
onboarding-stay_aligned-verify_mounting-title = Tracker-Ausrichtung
|
||||
onboarding-stay_aligned-verify_mounting-step-1 = 1. Bewege dich im Stehen.
|
||||
onboarding-stay_aligned-verify_mounting-step-2 = 2. Setz dich hin und bewege deine Beine und Füße.
|
||||
onboarding-stay_aligned-verify_mounting-step-3 = 3. Wenn deine Tracker nicht an der richtigen Stelle sind, drücke "Ausrichtungskalibrierung wiederholen".
|
||||
onboarding-stay_aligned-verify_mounting-redo_mounting = Tracker-Ausrichtungskalibrierung wiederholen
|
||||
onboarding-stay_aligned-preparation-title = Vorbereitung
|
||||
onboarding-stay_aligned-preparation-tip = Achten Sie darauf, aufrecht zu stehen. Schauen Sie nach vorne und lassen Sie die Arme an den Seiten hängen.
|
||||
onboarding-stay_aligned-relaxed_poses-standing-title = Entspannte Stehpose
|
||||
onboarding-stay_aligned-relaxed_poses-standing-step-0 = 1. Nehmen Sie eine bequeme Haltung ein. Entspannen Sie sich!
|
||||
onboarding-stay_aligned-relaxed_poses-standing-step-1-v2 = 2. Drücken Sie die Taste „Pose speichern“.
|
||||
onboarding-stay_aligned-relaxed_poses-sitting-title = Entspannte Im-Stuhl-sitzen-Pose
|
||||
onboarding-stay_aligned-relaxed_poses-sitting-step-0 = 1. Nehme eine bequeme Haltung ein. Entspanne dich!
|
||||
onboarding-stay_aligned-relaxed_poses-sitting-step-1-v2 = 2. Drücke die Taste „Pose speichern“.
|
||||
onboarding-stay_aligned-relaxed_poses-flat-title = Entspannte Sitzposition auf dem Boden
|
||||
onboarding-stay_aligned-relaxed_poses-flat-step-0 = 1. Setz dich mit den Beinen nach vorne auf den Boden. Entspann dich!
|
||||
onboarding-stay_aligned-relaxed_poses-flat-step-1-v2 = 2. Drücke die Taste „Pose speichern“.
|
||||
onboarding-stay_aligned-relaxed_poses-skip_step = Überspringen
|
||||
onboarding-stay_aligned-done-title = Stay aligned aktiviert!
|
||||
onboarding-stay_aligned-done-description = Dein Stay Aligned-Setup ist komplett!
|
||||
onboarding-stay_aligned-previous_step = Zurück
|
||||
onboarding-stay_aligned-next_step = Weiter
|
||||
onboarding-stay_aligned-restart = Neu starten
|
||||
onboarding-stay_aligned-done = Fertig
|
||||
onboarding-stay_aligned-manual_mounting-done = Fertig
|
||||
|
||||
## Home
|
||||
|
||||
home-no_trackers = Keine Tracker erkannt oder zugewiesen
|
||||
home-settings = Startseiten-Einstellungen
|
||||
home-settings-close = Schließen
|
||||
|
||||
## Trackers Still On notification
|
||||
@@ -1280,12 +1342,19 @@ firmware_tool = DIY Firmware-Tool
|
||||
firmware_tool-description = Erlaubt ihnen das Konfigurieren und Flashen von DIY Trackern
|
||||
firmware_tool-not_available = Das Firmware Tool ist im Moment nicht verfügbar. Versuche sie später erneut!
|
||||
firmware_tool-not_compatible = Das Firmware Tool ist nicht mit dieser Version des Servers kompatibel. Bitte den Server aktualisieren!
|
||||
firmware_tool-select_source = Wähle die Firmware zum Flashen aus
|
||||
firmware_tool-select_source-description = Wähle die Firmware aus, die du auf deinem Board flashen möchtest
|
||||
firmware_tool-select_source-error = Quellen konnten nicht geladen werden
|
||||
firmware_tool-select_source-board_type = Boardtyp
|
||||
firmware_tool-select_source-firmware = Firmware-Quelle
|
||||
firmware_tool-select_source-version = Firmware-Version
|
||||
firmware_tool-select_source-official = Offiziell
|
||||
firmware_tool-select_source-dev = Dev
|
||||
firmware_tool-select_source-not_selected = Keine Quelle ausgewählt
|
||||
firmware_tool-select_source-no_boards = Keine verfügbaren Boards für diese Quelle
|
||||
firmware_tool-select_source-no_versions = Keine verfügbaren Versionen für diese Quelle
|
||||
firmware_tool-board_defaults = Konfigurieren Sie Ihr Board
|
||||
firmware_tool-board_defaults-description = Stelle die Pins oder Einstellungen relativ zu deiner Hardware ein
|
||||
firmware_tool-board_defaults-add = Hinzufügen
|
||||
firmware_tool-board_defaults-reset = Auf Standard zurücksetzen
|
||||
firmware_tool-board_defaults-error-required = Erforderliches Feld
|
||||
@@ -1398,7 +1467,12 @@ unknown_device-modal-forget = Ignorieren
|
||||
# VRChat config warnings
|
||||
vrc_config-page-title = VRChat Konfigurations-Warnungen
|
||||
vrc_config-page-desc = Diese Seite zeigt den Zustand deiner VRChat-Einstellungen und zeigt, welche Einstellungen mit SlimeVR inkompatibel sind. Es wird dringend empfohlen, alle hier angezeigten Warnungen zu beheben, um das beste Nutzererlebnis mit SlimeVR zu gewährleisten.
|
||||
vrc_config-page-help = Kannst du die Einstellungen nicht finden?
|
||||
vrc_config-page-help-desc = Schauen Sie sich unsere <a>Dokumentation zu diesem Thema</a> an!
|
||||
vrc_config-page-big_menu = Tracking & IK (Großes Menü)
|
||||
vrc_config-page-big_menu-desc = Einstellungen im Zusammenhang mit IK im großen Einstellungsmenü
|
||||
vrc_config-page-wrist_menu = Tracking & IK (Handgelenkmenü)
|
||||
vrc_config-page-wrist_menu-desc = Einstellungen im Zusammenhang mit IK im kleinen Einstellungsmenü (Handgelenkmenü)
|
||||
vrc_config-on = An
|
||||
vrc_config-off = Aus
|
||||
vrc_config-invalid = Sie haben falsch konfigurierte VRChat-Einstellungen!
|
||||
@@ -1409,13 +1483,23 @@ vrc_config-current_value = Aktueller Wert
|
||||
vrc_config-mute = Warnung stummschalten
|
||||
vrc_config-mute-btn = Stummschalten
|
||||
vrc_config-unmute-btn = Stummschaltung aufheben
|
||||
vrc_config-legacy_mode = Verwende Legacy IK Solving
|
||||
vrc_config-disable_shoulder_tracking = Schultertracking deaktivieren
|
||||
vrc_config-shoulder_width_compensation = Schulterbreitenkompensation
|
||||
vrc_config-spine_mode = FBT-Wirbelsäulenmodus
|
||||
vrc_config-tracker_model = FBT-Trackermodell
|
||||
vrc_config-avatar_measurement_type = Avatar-Messung
|
||||
vrc_config-calibration_range = Kalibrierungsbereich
|
||||
vrc_config-calibration_visuals = Display-Kalibrierungsvisualisierungen
|
||||
vrc_config-user_height = Echte Benutzergröße
|
||||
vrc_config-spine_mode-UNKNOWN = Unbekannt
|
||||
vrc_config-spine_mode-LOCK_BOTH = Beide sperren
|
||||
vrc_config-spine_mode-LOCK_HEAD = Kopf sperren
|
||||
vrc_config-spine_mode-LOCK_HIP = Hüfte sperren
|
||||
vrc_config-tracker_model-UNKNOWN = Unbekannt
|
||||
vrc_config-tracker_model-AXIS = Achse
|
||||
vrc_config-tracker_model-BOX = Box
|
||||
vrc_config-tracker_model-SPHERE = Sphäre
|
||||
vrc_config-tracker_model-SYSTEM = System
|
||||
vrc_config-avatar_measurement_type-UNKNOWN = Unbekannt
|
||||
vrc_config-avatar_measurement_type-HEIGHT = Höhe
|
||||
@@ -1433,22 +1517,41 @@ error_collection_modal-cancel = Ich will nicht
|
||||
|
||||
## Tracking checklist section
|
||||
|
||||
tracking_checklist = Tracking-Checkliste
|
||||
tracking_checklist-settings = Einstellungen der Tracking-Checkliste
|
||||
tracking_checklist-settings-close = Schließen
|
||||
tracking_checklist-status-incomplete = Du bist nicht darauf vorbereitet, SlimeVR zu benutzen!
|
||||
tracking_checklist-status-partial =
|
||||
{ $count ->
|
||||
[one] Sie haben 1 Warnung!
|
||||
*[other] Sie haben { $count } Warnungen!
|
||||
}
|
||||
tracking_checklist-status-complete = Du bist bereit, SlimeVR zu nutzen!
|
||||
tracking_checklist-MOUNTING_CALIBRATION = Tracker-Ausrichtung durchführen
|
||||
tracking_checklist-FEET_MOUNTING_CALIBRATION = Führe eine Fußmontage-Kalibrierung durch
|
||||
tracking_checklist-FULL_RESET = Führe einen vollständigen Reset durch
|
||||
tracking_checklist-FULL_RESET-desc = Manche Tracker benötigen eine erneute Kalibrierung.
|
||||
tracking_checklist-STEAMVR_DISCONNECTED = SteamVR läuft nicht
|
||||
tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR läuft nicht. Nutzen sie es für VR?
|
||||
tracking_checklist-STEAMVR_DISCONNECTED-open = SteamVR starten
|
||||
tracking_checklist-TRACKERS_REST_CALIBRATION = Kalibriere deine Tracker
|
||||
tracking_checklist-TRACKERS_REST_CALIBRATION-desc = Sie haben keine Tracker-Kalibrierung durchgeführt. Bitte lassen Sie Ihre Tracker (gelb markiert) für einige Sekunden auf einer stabilen Oberfläche ruhen.
|
||||
tracking_checklist-TRACKER_ERROR = Tracker mit Fehlern
|
||||
tracking_checklist-TRACKER_ERROR-desc = Einige deiner Tracker haben einen Fehler. Bitte starte die gelb markierten Tracker neu.
|
||||
tracking_checklist-VRCHAT_SETTINGS = VRChat-Einstellungen konfigurieren
|
||||
tracking_checklist-VRCHAT_SETTINGS-desc = Du hast die VRChat-Einstellungen falsch konfiguriert! Das kann sich negativ auf dein Tracking auswirken.
|
||||
tracking_checklist-VRCHAT_SETTINGS-open = Gehen sie zu den VRChat-Warnungen
|
||||
tracking_checklist-UNASSIGNED_HMD = VR-Headset nicht dem Kopf zugewiesen
|
||||
tracking_checklist-UNASSIGNED_HMD-desc = Das VR-Headset sollte als Kopf-Tracker zugewiesen sein.
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC = Ändere dein Netzwerkprofil
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC-desc =
|
||||
{ $count ->
|
||||
[one] Dein Netzwerkprofil ist derzeit auf Öffentlich ({ $adapters }) eingestellt. Dies wird für das ordnungsgemäße Funktionieren von SlimeVR nicht empfohlen. <PublicFixLink>Hier erfährst du, wie du das beheben kannst.</PublicFixLink>
|
||||
*[other] Einige deiner Netzwerkadapter sind auf Öffentlich eingestellt:¶{ $adapters }¶Das wird nicht empfohlen, damit SlimeVR ordnungsgemäß funktioniert.¶<PublicFixLink>Hier erfährst du, wie du das beheben kannst.</PublicFixLink>
|
||||
}
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Kontrollpanel öffnen
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED = Stay Aligned konfigurieren
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED-desc = Zeichne die Stay Aligned-Posen auf, um Drift zu reduzieren
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED-open = Öffne den Stay Aligned Assistent
|
||||
tracking_checklist-ignore = Ignorieren
|
||||
preview-mocap_mode_soon = Mocap-Modus (Bald™)
|
||||
@@ -1458,5 +1561,6 @@ toolbar-mounting_calibration = Tracker-Ausrichtung
|
||||
toolbar-mounting_calibration-default = Körper
|
||||
toolbar-mounting_calibration-feet = Füße
|
||||
toolbar-mounting_calibration-fingers = Finger
|
||||
toolbar-drift_reset = Drift-Reset
|
||||
toolbar-assigned_trackers = { $count } Tracker zugewiesen
|
||||
toolbar-unassigned_trackers = { $count } Tracker nicht zugewiesen
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
websocket-connecting = Cargando...
|
||||
websocket-connection_lost = ¡El servidor falló!
|
||||
websocket-connection_lost-desc = Parece que el servidor de SlimeVR ha dejado de funcionar. Revise los registros y reinicie el programa.
|
||||
websocket-timedout = No se ha podido conectar al servidor.
|
||||
websocket-timedout = No se ha podido conectar al servidor
|
||||
websocket-timedout-desc = Parece que el servidor de SlimeVR ha dejado de funcionar o se agotó el tiempo de espera de la conexión. Revise los registros y reinicie el programa
|
||||
websocket-error-close = Salir de SlimeVR
|
||||
websocket-error-logs = Abrir la carpeta de registros
|
||||
@@ -33,6 +33,10 @@ tips-failed_webgl = Fallo al inicializar WebGL.
|
||||
|
||||
## Units
|
||||
|
||||
unit-meter = Metro
|
||||
unit-foot = Pie
|
||||
unit-inch = Pulgada
|
||||
unit-cm = cm
|
||||
|
||||
## Body parts
|
||||
|
||||
@@ -111,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3
|
||||
board_type-HARITORA = Haritora
|
||||
board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1
|
||||
board_type-GLOVE_IMU_SLIMEVR_DEV = Guante SlimeVR Dev IMU
|
||||
board_type-GESTURES = Gestos
|
||||
board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini
|
||||
board_type-GENERIC_NRF = nRF Genérico
|
||||
board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly
|
||||
board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly
|
||||
|
||||
## Proportions
|
||||
|
||||
@@ -252,6 +261,10 @@ reset-mounting = Reinicio de montura
|
||||
reset-mounting-feet = Restablecer montura de los pies
|
||||
reset-mounting-fingers = Restablecer montura de los dedos
|
||||
reset-yaw = Reinicio horizontal
|
||||
reset-error-no_feet_tracker = Tracker de pie sin asignar
|
||||
reset-error-no_fingers_tracker = Tracker de dedos sin asignar
|
||||
reset-error-mounting-need_full_reset = Es necesario un reinicio completo antes de montar
|
||||
reset-error-yaw-need_full_reset = Es necesario un reinicio completo antes del reinicio horizontal
|
||||
|
||||
## Serial detection stuff
|
||||
|
||||
@@ -271,10 +284,12 @@ navbar-trackers_assign = Asignación de sensores
|
||||
navbar-mounting = Calibración de montura
|
||||
navbar-onboarding = Asistente de configuración
|
||||
navbar-settings = Ajustes
|
||||
navbar-connect_trackers = Conectar Trackers
|
||||
|
||||
## Biovision hierarchy recording
|
||||
|
||||
bvh-start_recording = Grabar BVH
|
||||
bvh-stop_recording = Guardar grabación BVH
|
||||
bvh-recording = Grabando...
|
||||
bvh-save_title = Guardar grabación BVH
|
||||
|
||||
@@ -418,6 +433,9 @@ tracker-settings-update-up_to_date = Actualizado
|
||||
tracker-settings-update-blocked = Actualización no disponible. No hay otras versiones disponibles
|
||||
tracker-settings-update = Actualizar ahora
|
||||
tracker-settings-update-title = Versión del firmware
|
||||
tracker-settings-current-version = Actual
|
||||
tracker-settings-latest-version = Último
|
||||
tracker-settings-build-date = Fecha de fabricación
|
||||
|
||||
## Tracker part card info
|
||||
|
||||
@@ -495,6 +513,8 @@ settings-sidebar-osc_vmc = VMC
|
||||
settings-sidebar-utils = Utilidades
|
||||
settings-sidebar-serial = Consola serial
|
||||
settings-sidebar-appearance = Apariencia
|
||||
settings-sidebar-home = Pantalla de Inicio
|
||||
settings-sidebar-checklist = Lista de Tracking
|
||||
settings-sidebar-notifications = Notificaciones
|
||||
settings-sidebar-behavior = Comportamiento
|
||||
settings-sidebar-firmware-tool = Herramienta de firmware DIY
|
||||
@@ -651,7 +671,7 @@ settings-general-fk_settings-skeleton_settings-extended_spine_model = Modelo ext
|
||||
settings-general-fk_settings-skeleton_settings-extended_pelvis_model = Modelo extendido del pelvis
|
||||
settings-general-fk_settings-skeleton_settings-extended_knees_model = Modelo extendido de la rodilla
|
||||
settings-general-fk_settings-skeleton_settings-ratios = Radios del esqueleto
|
||||
settings-general-fk_settings-skeleton_settings-ratios-description = Cambia los valores de los ajustes del esqueleto. Podes llegar a necesitar reajustar tus proporciones después de cambiar estos valores.
|
||||
settings-general-fk_settings-skeleton_settings-ratios-description = Cambia los valores de los ajustes del esqueleto. Podrías llegar a necesitar reajustar tus proporciones después de cambiar estos valores.
|
||||
settings-general-fk_settings-skeleton_settings-impute_waist_from_chest_hip = Imputar de la cintura al pecho hasta la cadera
|
||||
settings-general-fk_settings-skeleton_settings-impute_waist_from_chest_legs = Imputar de la cintura al pecho hasta las piernas
|
||||
settings-general-fk_settings-skeleton_settings-impute_hip_from_chest_legs = Imputar de la cadera al pecho hasta las piernas
|
||||
@@ -925,9 +945,15 @@ settings-utils-advanced-open_logs-label = Abrir carpeta
|
||||
|
||||
## Home Screen
|
||||
|
||||
settings-home-list-layout = Diseño de la lista de Trackers
|
||||
settings-home-list-layout-desc = Selecciona uno de los posibles diseños de la pantalla de inicio
|
||||
settings-home-list-layout-grid = Cuadrícula
|
||||
settings-home-list-layout-table = Tabla
|
||||
|
||||
## Tracking Checlist
|
||||
|
||||
settings-tracking_checklist-active_steps = Pasos Activos
|
||||
settings-tracking_checklist-active_steps-desc = Lista de todos los pasos en la lista de tracking. Puedes elegir desactivar pasos específicos.
|
||||
|
||||
## Setup/onboarding menu
|
||||
|
||||
@@ -944,6 +970,13 @@ onboarding-setup_warning-cancel = Continuar configuración
|
||||
## Wi-Fi setup
|
||||
|
||||
onboarding-wifi_creds-back = Volver a la introducción
|
||||
onboarding-wifi_creds-v2 = Trackers utilizando Wi-Fi
|
||||
# This cares about multilines
|
||||
onboarding-wifi_creds-description-v2 =
|
||||
La mayoría de trackers (como los trackers oficiales de SlimeVR) utilizan Wi-Fi para conectar al servidor.
|
||||
Por favor utiliza las credenciales de la red Wi-Fi donde tu dispositivo esta actualmente conectado.
|
||||
|
||||
¡Asegúrate de utilizar una conexión Wi-Fi 2.4Ghz para tus trackers!
|
||||
onboarding-wifi_creds-skip = Saltar ajustes de Wi-Fi
|
||||
onboarding-wifi_creds-submit = ¡Enviar!
|
||||
onboarding-wifi_creds-ssid =
|
||||
@@ -953,12 +986,16 @@ onboarding-wifi_creds-ssid-required = Se requiere el nombre del Wi-Fi
|
||||
onboarding-wifi_creds-password =
|
||||
.label = Contraseña
|
||||
.placeholder = Ingresa la contraseña
|
||||
onboarding-wifi_creds-dongle-title = Trackers utilizando un dongle
|
||||
onboarding-wifi_creds-dongle-description = ¡Si tus trackers llegaron con un dongle, conéctalo a tu dispositivo y deberías estar listo para usar!
|
||||
onboarding-wifi_creds-dongle-wip = Esta sección es un trabajo en progreso. Una página dedicada para administrar trackers que se conectan via dongle sera hecha pronto.
|
||||
onboarding-wifi_creds-dongle-continue = Continuar con un dongle
|
||||
|
||||
## Mounting setup
|
||||
|
||||
onboarding-reset_tutorial-back = Volver a la calibración de montura
|
||||
onboarding-reset_tutorial = Reiniciar tutorial
|
||||
onboarding-reset_tutorial-explanation = Mientras estés usando tus trackers, estos pueden empezar a desalinearse por el drift horizontal del IMU, o porque los moviste físicamente. Hay varias formas de arreglar este tipo de problemas.
|
||||
onboarding-reset_tutorial-explanation = Mientras estés usando tus trackers, estos pueden empezar a desalinearse por el desvío horizontal del IMU, o porque los moviste físicamente. Hay varias formas de arreglar este tipo de problemas.
|
||||
onboarding-reset_tutorial-skip = Saltar paso
|
||||
# Cares about multiline
|
||||
onboarding-reset_tutorial-0 =
|
||||
@@ -969,8 +1006,8 @@ onboarding-reset_tutorial-0 =
|
||||
onboarding-reset_tutorial-1 =
|
||||
Toca { $taps } veces el tracker resaltado para activar el reinicio completo.
|
||||
|
||||
Se requiere que estas de forma parada (pose en i). Esto tiene un delay de 3 segundos (configurable) antes de que actualmente suceda.
|
||||
Esto reinicia completamente la posición y rotación de todos tus sensores, debería de arreglar la mayoría de tus problemas.
|
||||
Se requiere que estés de pie (pose en i). Esto tiene una demora de 3 segundos (configurable) antes de que realmente suceda.
|
||||
Esto reinicia completamente la posición y rotación de todos tus trackers. Debería de arreglar la mayoría de los problemas.
|
||||
# Cares about multiline
|
||||
onboarding-reset_tutorial-2 =
|
||||
Toca { $taps } veces el tracker resaltado para activar el reinicio de montura.
|
||||
@@ -1058,6 +1095,7 @@ onboarding-assignment_tutorial-done = ¡Puse las correas y stickers!
|
||||
onboarding-assign_trackers-back = Volver a las credenciales Wi-Fi
|
||||
onboarding-assign_trackers-title = Asignación de sensores
|
||||
onboarding-assign_trackers-description = Debes escoger dónde van los sensores. Has clic en la ubicación donde quieras colocar un sensor
|
||||
onboarding-assign_trackers-unassign_all = Des-asignar todos los trackers
|
||||
# Look at translation of onboarding-connect_tracker-connected_trackers on how to use plurals
|
||||
# $assigned (Number) - Trackers that have been assigned a body part
|
||||
# $trackers (Number) - Trackers connected to the server
|
||||
@@ -1203,6 +1241,8 @@ onboarding-automatic_mounting-done-restart = Volver al inicio
|
||||
onboarding-automatic_mounting-mounting_reset-title = Reinicio de montura
|
||||
onboarding-automatic_mounting-mounting_reset-step-0 = 1. Arrodíllate en una posición de «esquiar» con tus piernas dobladas, la parte superior de tu cuerpo inclinada hacia adelante, y tus brazos doblados.
|
||||
onboarding-automatic_mounting-mounting_reset-step-1 = 2. Presiona el botón «Reinicio de montura» y espera 3 segundos hasta que se reinicie la montura.
|
||||
onboarding-automatic_mounting-mounting_reset-feet-step-0 = 1. Párate de puntillas con ambos pies apuntando hacia el frente. Alternativamente puedes hacerlo sentándote en una silla.
|
||||
onboarding-automatic_mounting-mounting_reset-feet-step-1 = 2. Presiona el botón "Calibración de pies" y espera por 3 segundos hasta que la orientación de los trackers se reinicie.
|
||||
onboarding-automatic_mounting-preparation-title = Preparación
|
||||
onboarding-automatic_mounting-preparation-v2-step-0 = 1. Presiona el botón «Reinicio completo».
|
||||
onboarding-automatic_mounting-preparation-v2-step-1 = 2. Párate recto con los brazos a tus lados. Asegúrate de mirar hacia adelante.
|
||||
@@ -1214,6 +1254,7 @@ onboarding-automatic_mounting-return-home = Hecho
|
||||
|
||||
## Tracker manual proportions setupa
|
||||
|
||||
onboarding-manual_proportions-back-scaled = Regresar a Proporciones Escaladas
|
||||
onboarding-manual_proportions-title = Proporciones de cuerpo manuales
|
||||
onboarding-manual_proportions-fine_tuning_button = Ajustar automáticamente las proporciones
|
||||
onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Por favor conecte un visor VR para utilizar el ajuste automático
|
||||
@@ -1313,6 +1354,30 @@ onboarding-automatic_proportions-smol_warning-cancel = Volver
|
||||
|
||||
## User height calibration
|
||||
|
||||
onboarding-user_height-title = ¿Cuál es tu altura?
|
||||
onboarding-user_height-description = Necesitamos tu altura para calcular tus proporciones corporales y representar tus movimientos de manera precisa. Puedes dejar que SlimeVR lo calcule, o puedes ingresar tu altura manualmente.
|
||||
onboarding-user_height-need_head_tracker = Un casco y controles con rastreo posicional son requeridos para realizar la calibración.
|
||||
onboarding-user_height-calculate = Calcular mi altura automáticamente
|
||||
onboarding-user_height-next_step = Continuar y guardar
|
||||
onboarding-user_height-manual-proportions = Proporciones Manuales
|
||||
onboarding-user_height-calibration-title = Progreso de Calibración
|
||||
onboarding-user_height-calibration-RECORDING_FLOOR = Toca el suelo con la punta de tu control
|
||||
onboarding-user_height-calibration-WAITING_FOR_RISE = Vuelve a pararte
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK = Vuelve a pararte y mira hacia adelante
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Asegúrate de que tu cabeza este derecha
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = No mires al suelo
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = No mires demasiado arriba
|
||||
onboarding-user_height-calibration-WAITING_FOR_CONTROLLER_PITCH = Asegúrate que el control este apuntando hacia abajo
|
||||
onboarding-user_height-calibration-RECORDING_HEIGHT = ¡Vuelve a pararte y no te muevas!
|
||||
onboarding-user_height-calibration-DONE = ¡Éxito!
|
||||
onboarding-user_height-calibration-ERROR_TIMEOUT = Calibración agotada, inténtalo de nuevo.
|
||||
onboarding-user_height-calibration-ERROR_TOO_HIGH = La altura del usuario detectada es demasiado alta, inténtalo de nuevo.
|
||||
onboarding-user_height-calibration-ERROR_TOO_SMALL = La altura del usuario detectada es demasiado baja. Asegúrate de pararte derecho y mirar hacia el frente al final de la calibración.
|
||||
onboarding-user_height-calibration-error = Calibración Fallida
|
||||
onboarding-user_height-manual-tip = Mientras ajustas tu altura, intenta poses distintas y ve como el esqueleto se ajusta a tu cuerpo.
|
||||
onboarding-user_height-reset-warning =
|
||||
<b>Peligro:</b> Esto reiniciará tus proporciones para ser basadas en tu altura.
|
||||
¿Seguro quieres hacer esto?
|
||||
|
||||
## Stay Aligned setup
|
||||
|
||||
@@ -1351,6 +1416,7 @@ onboarding-stay_aligned-done = Hecho
|
||||
## Home
|
||||
|
||||
home-no_trackers = No hay sensores detectados o asignados
|
||||
home-settings = Ajustes de la Página de Inicio
|
||||
home-settings-close = Cerrar
|
||||
|
||||
## Trackers Still On notification
|
||||
@@ -1417,6 +1483,9 @@ firmware_tool-flash_method_step-serial-v2 =
|
||||
firmware_tool-flashbtn_step = Presione el botón de boot
|
||||
firmware_tool-flashbtn_step-description = Antes de pasar al siguiente paso, hay algunas cosas que debe hacer
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR = Apague el sensor, retire la carcasa (si la hay), conecte un cable USB a esta computadora y, a continuación, realice uno de los siguientes pasos de acuerdo con la revisión de la placa SlimeVR:
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR-r11-v2 = Enciende el tracker mientras haces corto en el segundo pad rectangular de FLASH desde el borde en la parte superior de la placa con el protector metálico del microcontrolador. El LED del tracker debería hacer un parpadeo breve.
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR-r12-v2 = Enciende el tracker mientras haces corto en pad circular de FLASH en la parte superior de la placa con el protector metálico del microcontrolador. El LED del tracker debería hacer un parpadeo breve.
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR-r14-v2 = Enciende el tracker mientras pulsas el botón FLASH en la parte superior de la placa. El LED del tracker deberia hacer un parpadeo breve.
|
||||
firmware_tool-flashbtn_step-board_OTHER =
|
||||
Antes de flashear, probablemente tendrá que poner el sensor en modo bootloader.
|
||||
La mayoría de las veces, esto significa presionar el botón de boot en la placa antes de que comience el proceso de flasheo. Si el proceso de flasheo se agota al comienzo, probablemente significa que el sensor no estaba en modo bootloader.
|
||||
@@ -1432,7 +1501,7 @@ firmware_tool-flash_method_serial-no_devices = No se han detectado dispositivos
|
||||
firmware_tool-build_step = Compilando
|
||||
firmware_tool-build_step-description = El firmware se está compilando, por favor espere
|
||||
firmware_tool-flashing_step = Flasheando
|
||||
firmware_tool-flashing_step-description = Sus sensores se están flasheando, por favor siga las instrucciones en la pantalla
|
||||
firmware_tool-flashing_step-description = Sus trackers se están flasheando, por favor siga las instrucciones en la pantalla
|
||||
firmware_tool-flashing_step-warning-v2 = No desconectes o apagues el tracker durante el proceso de subida a menos que se te indique, puede causar que tu placa quede inutilizable.
|
||||
firmware_tool-flashing_step-flash_more = Flashear más sensores
|
||||
firmware_tool-flashing_step-exit = Salir
|
||||
@@ -1559,9 +1628,59 @@ error_collection_modal-cancel = No quiero
|
||||
|
||||
## Tracking checklist section
|
||||
|
||||
tracking_checklist = Lista de Tracking
|
||||
tracking_checklist-settings = Ajustes de la Lista de Tracking
|
||||
tracking_checklist-settings-close = Cerrar
|
||||
tracking_checklist-status-incomplete = ¡No estás listo para usar SlimeVR!
|
||||
tracking_checklist-status-partial =
|
||||
{ $count ->
|
||||
[one] ¡Tienes 1 advertencia!
|
||||
[many] ¡Tienes { $count } advertencias!
|
||||
*[other] { "" }
|
||||
}
|
||||
tracking_checklist-status-complete = ¡Estás listo para usar SlimeVR!
|
||||
tracking_checklist-MOUNTING_CALIBRATION = Realizar una calibración de montura
|
||||
tracking_checklist-FEET_MOUNTING_CALIBRATION = Realizar una calibración de montura de los pies
|
||||
tracking_checklist-FULL_RESET = Realizar un reinicio completo
|
||||
tracking_checklist-FULL_RESET-desc = Algunos trackers necesitan realizar un reinicio.
|
||||
tracking_checklist-STEAMVR_DISCONNECTED = SteamVR no se está ejecutando
|
||||
tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR no se esta ejecutando. ¿Lo estas usando para VR?
|
||||
tracking_checklist-STEAMVR_DISCONNECTED-open = Abrir SteamVR
|
||||
tracking_checklist-TRACKERS_REST_CALIBRATION = Calibra tus trackers
|
||||
tracking_checklist-TRACKERS_REST_CALIBRATION-desc = No realizaste una calibración para los trackers. Por favor deja reposar tus trackers (resaltados en amarillo) en una superficie estable por unos segundos.
|
||||
tracking_checklist-TRACKER_ERROR = Trackers con Errores
|
||||
tracking_checklist-TRACKER_ERROR-desc = Algunos de tus trackers tienen un error. Por favor reinicia el tracker resaltado en amarillo.
|
||||
tracking_checklist-VRCHAT_SETTINGS = Configurar ajustes de VRChat
|
||||
tracking_checklist-VRCHAT_SETTINGS-desc = ¡Tienes ajustes mal puestos en VRChat! Esto puede impactar negativamente tu tracking.
|
||||
tracking_checklist-VRCHAT_SETTINGS-open = Ir a Advertencias de VRChat
|
||||
tracking_checklist-UNASSIGNED_HMD = Casco VR sin asignar a Cabeza
|
||||
tracking_checklist-UNASSIGNED_HMD-desc = El casco VR debería estar asignado como un tracker de cabeza.
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC = Cambia tu perfil de red
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC-desc =
|
||||
{ $count ->
|
||||
[one]
|
||||
Tu perfil de red esta actualmente configurado como Público ({ $adapters }).
|
||||
Esto no es recomendado para el correcto funcionamiento de SlimeVR.
|
||||
<PublicFixLink>Ve como arreglarlo aquí</PublicFixLink>
|
||||
[many]
|
||||
Algunos de tus adaptadores de red están configurados como públicos:
|
||||
{ $adapters }
|
||||
Esto no es recomendado para el correcto funcionamiento de SlimeVR.
|
||||
<PublicFixLink>Ve como arreglarlo aquí</PublicFixLink>
|
||||
*[other] { "" }
|
||||
}
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Abrir Panel de Control
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED = Configurar Stay Aligned
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED-desc = Graba las poses de Stay Aligned para reducir el desvío
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED-open = Abrir el ayudante de Stay Aligned
|
||||
tracking_checklist-ignore = Ignorar
|
||||
preview-mocap_mode_soon = Modo Mocap (Pronto™)
|
||||
preview-disable_render = Desactivar renderizado
|
||||
preview-disabled_render = Renderizado desactivado
|
||||
toolbar-mounting_calibration = Calibración de montura
|
||||
toolbar-mounting_calibration-default = Cuerpo
|
||||
toolbar-mounting_calibration-feet = Pies
|
||||
toolbar-mounting_calibration-fingers = Dedos
|
||||
toolbar-drift_reset = Reinicio de Desviación
|
||||
toolbar-assigned_trackers = { $count } trackers asignados
|
||||
toolbar-unassigned_trackers = { $count } trackers sin asignar
|
||||
|
||||
@@ -33,6 +33,10 @@ tips-failed_webgl = No se pudo iniciar WebGL.
|
||||
|
||||
## Units
|
||||
|
||||
unit-meter = Metro
|
||||
unit-foot = Pie
|
||||
unit-inch = Pulgada
|
||||
unit-cm = cm
|
||||
|
||||
## Body parts
|
||||
|
||||
@@ -241,10 +245,12 @@ navbar-trackers_assign = Asignación de trackers
|
||||
navbar-mounting = Calibración de montura
|
||||
navbar-onboarding = Asistente de Configuración
|
||||
navbar-settings = Configuración
|
||||
navbar-connect_trackers = Conectar Trackers
|
||||
|
||||
## Biovision hierarchy recording
|
||||
|
||||
bvh-start_recording = Grabar BVH
|
||||
bvh-stop_recording = Guardar grabación BVH
|
||||
bvh-recording = Grabando...
|
||||
bvh-save_title = Guardar grabación BVH
|
||||
|
||||
@@ -381,6 +387,7 @@ tracker-settings-name_section-label = Nombre del tracker
|
||||
tracker-settings-forget = Olvidar tracker
|
||||
tracker-settings-forget-description = Elimina el tracker del servidor SlimeVR y evita que se conecte a él hasta que se reinicie el servidor. La configuración del tracker no se perderá.
|
||||
tracker-settings-forget-label = Olvidar tracker
|
||||
tracker-settings-update-incompatible = No se puede actualizar. Versión de placa o firmware incompatible
|
||||
tracker-settings-update-low-battery = No se puede actualizar. Batería inferior al 50%
|
||||
tracker-settings-update-up_to_date = Actualizado
|
||||
tracker-settings-update-blocked = Actualización no disponible. No hay otras versiones disponibles
|
||||
@@ -451,6 +458,7 @@ mounting_selection_menu-close = Cerrar
|
||||
|
||||
settings-sidebar-title = Configuración
|
||||
settings-sidebar-general = General
|
||||
settings-sidebar-steamvr = SteamVR
|
||||
settings-sidebar-tracker_mechanics = Mecánicas del tracker
|
||||
settings-sidebar-stay_aligned = Mantener Alineado
|
||||
settings-sidebar-fk_settings = Configuración del tracking
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
## Websocket (server) status
|
||||
|
||||
websocket-connecting = Connexion au serveur
|
||||
websocket-connecting = Chargement...
|
||||
websocket-connection_lost = Connexion avec le serveur perdue. Reconnexion...
|
||||
websocket-connection_lost-desc = Il semble que le serveur SlimeVR ait planté. Vérifiez les logs et redémarrez le programme.
|
||||
websocket-timedout = Impossible de se connecter au serveur
|
||||
@@ -33,7 +33,7 @@ tips-failed_webgl = Échec de l'initialisation de WebGL.
|
||||
|
||||
## Units
|
||||
|
||||
unit-meter = Metre
|
||||
unit-meter = Mètre
|
||||
unit-foot = Pied
|
||||
unit-inch = Pouce
|
||||
unit-cm = cm
|
||||
@@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3
|
||||
board_type-HARITORA = Haritora
|
||||
board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1
|
||||
board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev IMU Glove
|
||||
board_type-GESTURES = Gestes
|
||||
board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini
|
||||
board_type-GENERIC_NRF = nRF Générique
|
||||
board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly
|
||||
board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly
|
||||
|
||||
## Proportions
|
||||
|
||||
@@ -225,7 +230,7 @@ skeleton_bone-LOWER_ARM-desc =
|
||||
skeleton_bone-HAND_Y = Distance Y des mains
|
||||
skeleton_bone-HAND_Y-desc =
|
||||
Ceci est la distance verticale entre vos poignets et le milieu de vos main.
|
||||
Pour l’ajuster pour la capture de mouvement, ajustez correctement la longueur des bras et modifiez-la jusqu’à ce que votre
|
||||
Pour l’ajuster pour la capture de mouvement, ajustez correctement la longueur des bras et modifiez-la jusqu’à ce que vos
|
||||
capteurs de main soient alignés verticalement avec le milieu de vos mains.
|
||||
Pour l’ajuster pour le suivi des coudes à partir de vos manettes, réglez la longueur des bras à 0 et
|
||||
modifiez-la jusqu’à ce que vos capteurs de coude soient alignés verticalement avec vos poignets.
|
||||
@@ -256,9 +261,10 @@ reset-mounting = Réinitialiser l'alignement
|
||||
reset-mounting-feet = Réinitialiser l'alignement des pieds
|
||||
reset-mounting-fingers = Réinitialiser l'alignement des doigts
|
||||
reset-yaw = Réinitialisation horizontale
|
||||
reset-error-no_feet_tracker = Aucun traqueur de pieds n’est assigné
|
||||
reset-error-no_fingers_tracker = Aucun traqueur de doigts n'est assigné
|
||||
reset-error-no_feet_tracker = Aucun capteur de pieds n’est assigné
|
||||
reset-error-no_fingers_tracker = Aucun capteur de doigts n'est assigné
|
||||
reset-error-mounting-need_full_reset = Nécessite une réinitialisation complète avant de le monter
|
||||
reset-error-yaw-need_full_reset = Nécessite une réinitialisation complète avant une réinitialisation horizontale
|
||||
|
||||
## Serial detection stuff
|
||||
|
||||
@@ -278,6 +284,7 @@ navbar-trackers_assign = Attribution des capteurs
|
||||
navbar-mounting = Alignement des capteurs
|
||||
navbar-onboarding = Assistant de configuration
|
||||
navbar-settings = Réglages
|
||||
navbar-connect_trackers = Connecter les capteurs
|
||||
|
||||
## Biovision hierarchy recording
|
||||
|
||||
@@ -348,6 +355,7 @@ tracker-table-column-name = Nom
|
||||
tracker-table-column-type = Type
|
||||
tracker-table-column-battery = Batterie
|
||||
tracker-table-column-ping = Ping
|
||||
tracker-table-column-packet_loss = Pertes de paquets
|
||||
tracker-table-column-tps = TPS
|
||||
tracker-table-column-temperature = Temp. °C
|
||||
tracker-table-column-linear-acceleration = Accél. X/Y/Z
|
||||
@@ -389,6 +397,9 @@ tracker-infos-magnetometer-status-v1 =
|
||||
[ENABLED] Activé
|
||||
*[NOT_SUPPORTED] Non pris en charge
|
||||
}
|
||||
tracker-infos-packet_loss = Pertes de paquets
|
||||
tracker-infos-packets_lost = Paquets perdus
|
||||
tracker-infos-packets_received = Paquets reçus
|
||||
|
||||
## Tracker settings
|
||||
|
||||
@@ -426,6 +437,9 @@ tracker-settings-update-up_to_date = À jour
|
||||
tracker-settings-update-blocked = Mise à jour non disponible. Aucune autre version disponible
|
||||
tracker-settings-update = Mettre à jour maintenant
|
||||
tracker-settings-update-title = Version du micrologiciel
|
||||
tracker-settings-current-version = Actuel
|
||||
tracker-settings-latest-version = Dernière version
|
||||
tracker-settings-build-date = Date de build
|
||||
|
||||
## Tracker part card info
|
||||
|
||||
@@ -504,6 +518,7 @@ settings-sidebar-utils = Utilitaires
|
||||
settings-sidebar-serial = Console série
|
||||
settings-sidebar-appearance = Apparence
|
||||
settings-sidebar-home = Ecran d'accueil
|
||||
settings-sidebar-checklist = Checklist de suivi
|
||||
settings-sidebar-notifications = Notifications
|
||||
settings-sidebar-behavior = Comportement
|
||||
settings-sidebar-firmware-tool = Outil de micrologiciel DIY
|
||||
@@ -589,6 +604,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description =
|
||||
Utilise le magnétomètre sur tous les capteurs dotés d'un micrologiciel compatible, réduisant ainsi la dérive dans des environnements magnétiques stables.
|
||||
Peut être désactivé par capteur dans les paramètres du capteur. <b>Ne fermez aucun des capteurs en changeant cette option !</b>
|
||||
settings-general-tracker_mechanics-use_mag_on_all_trackers-label = Utiliser le magnétomètre sur les capteurs
|
||||
settings-general-tracker_mechanics-trackers_over_usb = Capteurs via USB
|
||||
settings-general-tracker_mechanics-trackers_over_usb-description = Permet de recevoir des données de suivi HID via USB. Assurez-vous que les capteurs connectés ont <b>la connexion via HID</b> activée !
|
||||
settings-general-tracker_mechanics-trackers_over_usb-enabled-label = Permettre aux capteurs HID de se connecter directement via USB
|
||||
settings-stay_aligned = Garder Aligné
|
||||
settings-stay_aligned-description = Garder Aligné réduit la dérive en ajustant progressivement vos capteurs pour qu’ils correspondent à vos postures détendues.
|
||||
settings-stay_aligned-setup-label = Configurer Garder Aligné
|
||||
@@ -851,7 +869,7 @@ settings-osc-vrchat-network-port_out =
|
||||
settings-osc-vrchat-network-address = Adresse réseau
|
||||
settings-osc-vrchat-network-address-description-v1 = Choisissez l'adresse à laquelle envoyer des données. Peut être laissé intact pour VRChat.
|
||||
settings-osc-vrchat-network-address-placeholder = Adresse IP VRChat
|
||||
settings-osc-vrchat-network-trackers = capteurs
|
||||
settings-osc-vrchat-network-trackers = Capteurs
|
||||
settings-osc-vrchat-network-trackers-description = Sélectionner quels capteurs envoyer via OSC.
|
||||
settings-osc-vrchat-network-trackers-chest = Poitrine
|
||||
settings-osc-vrchat-network-trackers-hip = Hanche
|
||||
@@ -931,11 +949,15 @@ settings-utils-advanced-open_logs-label = Ouvrir le dossier
|
||||
|
||||
## Home Screen
|
||||
|
||||
settings-home-list-layout = Disposition de la liste des capteurs
|
||||
settings-home-list-layout-desc = Sélectionnez l'une des dispositions possibles de l'écran d'accueil
|
||||
settings-home-list-layout-grid = Grille
|
||||
settings-home-list-layout-table = Tableau
|
||||
|
||||
## Tracking Checlist
|
||||
|
||||
settings-tracking_checklist-active_steps = Etapes actives
|
||||
settings-tracking_checklist-active_steps-desc = Liste de toutes les étapes de la checklist de suivi. Vous pouvez choisir de désactiver certaines étapes.
|
||||
|
||||
## Setup/onboarding menu
|
||||
|
||||
@@ -952,6 +974,13 @@ onboarding-setup_warning-cancel = Continuer la configuration
|
||||
## Wi-Fi setup
|
||||
|
||||
onboarding-wifi_creds-back = Retour à l'introduction
|
||||
onboarding-wifi_creds-v2 = Capteurs utilisant le Wi-Fi
|
||||
# This cares about multilines
|
||||
onboarding-wifi_creds-description-v2 =
|
||||
La plupart des capteurs (comme les capteurs officiels SlimeVR) utilisent le Wi-Fi pour se connecter au serveur.
|
||||
Veuillez utiliser les identifiants du réseau Wi-Fi auquel votre appareil est actuellement connecté.
|
||||
|
||||
Assurez-vous d’utiliser une connexion Wi-Fi 2,4 GHz pour vos capteurs !
|
||||
onboarding-wifi_creds-skip = Passer configuration Wi-Fi
|
||||
onboarding-wifi_creds-submit = Valider
|
||||
onboarding-wifi_creds-ssid =
|
||||
@@ -961,6 +990,10 @@ onboarding-wifi_creds-ssid-required = Le nom du Wi-Fi est requis
|
||||
onboarding-wifi_creds-password =
|
||||
.label = Mot de passe du Wi-Fi
|
||||
.placeholder = Mot de passe
|
||||
onboarding-wifi_creds-dongle-title = Capteurs utilisant un dongle
|
||||
onboarding-wifi_creds-dongle-description = Si vos capteurs ont été livrés avec un dongle, branchez-le à votre appareil et vous devriez être prêt !
|
||||
onboarding-wifi_creds-dongle-wip = Cette section est en cours de développement. Une page dédiée à la gestion des capteurs connectés via un dongle sera bientôt créée.
|
||||
onboarding-wifi_creds-dongle-continue = Continuer avec un dongle
|
||||
|
||||
## Mounting setup
|
||||
|
||||
@@ -1066,6 +1099,7 @@ onboarding-assignment_tutorial-done = J'ai mis les autocollants et les sangles !
|
||||
onboarding-assign_trackers-back = Revenir aux identifiants Wi-Fi
|
||||
onboarding-assign_trackers-title = Attribuer des capteurs
|
||||
onboarding-assign_trackers-description = Choisissons où mettre chaque capteur.
|
||||
onboarding-assign_trackers-unassign_all = Désattribuer tout les capteurs
|
||||
# Look at translation of onboarding-connect_tracker-connected_trackers on how to use plurals
|
||||
# $assigned (Number) - Trackers that have been assigned a body part
|
||||
# $trackers (Number) - Trackers connected to the server
|
||||
@@ -1210,6 +1244,8 @@ onboarding-automatic_mounting-done-restart = Retourner au début
|
||||
onboarding-automatic_mounting-mounting_reset-title = Réinitialisation de l'alignement
|
||||
onboarding-automatic_mounting-mounting_reset-step-0 = 1. Accroupissez-vous dans une pose de "ski" avec les jambes pliées, le haut du corps incliné vers l'avant et les bras pliés.
|
||||
onboarding-automatic_mounting-mounting_reset-step-1 = 2. Appuyez sur le bouton "Réinitialiser l'alignement" et attendez 3 secondes avant que l'alignement des capteurs se calibre.
|
||||
onboarding-automatic_mounting-mounting_reset-feet-step-0 = 1. Mettez-vous sur la pointe des pieds, les deux pieds pointés vers l’avant. Vous pouvez aussi le faire assis sur une chaise.
|
||||
onboarding-automatic_mounting-mounting_reset-feet-step-1 = 2. Appuyez sur le bouton « Calibration des pieds » et attendez 3 secondes avant que l’orientation de l'alignement des capteurs ne se réinitialise.
|
||||
onboarding-automatic_mounting-preparation-title = Préparation
|
||||
onboarding-automatic_mounting-preparation-v2-step-0 = 1. Appuyez sur le bouton « Réinitialisation complète ».
|
||||
onboarding-automatic_mounting-preparation-v2-step-1 = 2. Tenez-vous droit debout, les bras le long du corps. Assurez-vous de regarder vers l’avant.
|
||||
@@ -1221,6 +1257,7 @@ onboarding-automatic_mounting-return-home = Terminé
|
||||
|
||||
## Tracker manual proportions setupa
|
||||
|
||||
onboarding-manual_proportions-back-scaled = Retour aux proportions mises à l'échelle
|
||||
onboarding-manual_proportions-title = Proportions manuelles du corps
|
||||
onboarding-manual_proportions-fine_tuning_button = Automatiquement ajuster les proportions
|
||||
onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Veuillez connecter un casque VR pour utiliser l'ajustement automatique
|
||||
@@ -1321,9 +1358,29 @@ onboarding-automatic_proportions-smol_warning-cancel = Retour
|
||||
## User height calibration
|
||||
|
||||
onboarding-user_height-title = Quelle est votre taille ?
|
||||
onboarding-user_height-description = Nous avons besoin de votre taille pour calculer les proportions de votre corps ainsi que pour représenter précisément vos mouvements. Vous pouvez laisser SlimeVR la calculer ou entrer votre taille manuellement.
|
||||
onboarding-user_height-need_head_tracker = Un casque VR (ou capteur de tête) et des manettes à position absolue sont nécessaires pour calculer votre taille.
|
||||
onboarding-user_height-calculate = Calculer ma taille automatiquement
|
||||
onboarding-user_height-next_step = Continuer et enregistrer
|
||||
onboarding-user_height-manual-proportions = Proportions manuelles
|
||||
onboarding-user_height-calibration-title = Progression de la calibration
|
||||
onboarding-user_height-calibration-RECORDING_FLOOR = Touchez le sol avec l'extrémité de votre contrôleur
|
||||
onboarding-user_height-calibration-WAITING_FOR_RISE = Relevez-vous
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK = Relevez-vous et regardez droit devant vous
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Assurez-vous que votre tête est bien droite
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = Ne regardez pas vers le sol
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = Ne regardez pas trop haut
|
||||
onboarding-user_height-calibration-WAITING_FOR_CONTROLLER_PITCH = Assurez-vous que votre manette pointe vers le bas
|
||||
onboarding-user_height-calibration-RECORDING_HEIGHT = Relevez-vous et restez immobile !
|
||||
onboarding-user_height-calibration-DONE = Succès !
|
||||
onboarding-user_height-calibration-ERROR_TIMEOUT = Délais de calibration expiré, veuillez réessayer.
|
||||
onboarding-user_height-calibration-ERROR_TOO_HIGH = La taille détectée est trop grande, veuillez réessayez.
|
||||
onboarding-user_height-calibration-ERROR_TOO_SMALL = La taille détectée est trop petite. Veuillez rester droit et regardez devant vous à la fin de la calibration.
|
||||
onboarding-user_height-calibration-error = Calibration échouée
|
||||
onboarding-user_height-manual-tip = En ajustant votre taille, essayez différentes poses et regardez comment le squelette suit vos mouvements.
|
||||
onboarding-user_height-reset-warning =
|
||||
<b>Attention :</b> Cette action réinitialisera vos proportions pour être basées sur votre taille.
|
||||
Êtes-vous sûr de vouloir continuer ?
|
||||
|
||||
## Stay Aligned setup
|
||||
|
||||
@@ -1358,10 +1415,13 @@ onboarding-stay_aligned-previous_step = Précédent
|
||||
onboarding-stay_aligned-next_step = Prochain
|
||||
onboarding-stay_aligned-restart = Recommencer
|
||||
onboarding-stay_aligned-done = Fait
|
||||
onboarding-stay_aligned-manual_mounting-done = Terminé
|
||||
|
||||
## Home
|
||||
|
||||
home-no_trackers = Aucun capteur détecté ou attribué
|
||||
home-settings = Paramètres de la page d'accueil
|
||||
home-settings-close = Fermer
|
||||
|
||||
## Trackers Still On notification
|
||||
|
||||
@@ -1406,6 +1466,9 @@ firmware_tool-select_source-firmware = Source du micrologiciel
|
||||
firmware_tool-select_source-version = Version du micrologiciel
|
||||
firmware_tool-select_source-official = Officiel
|
||||
firmware_tool-select_source-dev = Dev
|
||||
firmware_tool-select_source-not_selected = Aucune source sélectionnée
|
||||
firmware_tool-select_source-no_boards = Aucune carte disponible pour cette source
|
||||
firmware_tool-select_source-no_versions = Aucune version disponible pour cette source
|
||||
firmware_tool-board_defaults = Configurez votre carte
|
||||
firmware_tool-board_defaults-description = Réglez les broches ou réglages pour votre matériel
|
||||
firmware_tool-board_defaults-add = Ajouter
|
||||
@@ -1427,6 +1490,9 @@ firmware_tool-flash_method_step-serial-v2 =
|
||||
firmware_tool-flashbtn_step = Appuyez sur le bouton boot
|
||||
firmware_tool-flashbtn_step-description = Avant de passer à l'étape suivante, il y a quelques choses que vous devez faire
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR = Éteignez le capteur, retirez le boîtier (s'il y en a un), connectez un câble USB à votre ordinateur, puis effectuez l'une des étapes suivantes en fonction de la révision de votre carte SlimeVR :
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR-r11-v2 = Allumez le capteur tout en court-circuitant le second pad FLASH rectangulaire à partir du bord en haut de la carte jusqu’à la protection métallique du microcontrôleur. La LED du capteur devrait faire un clignotement rapide.
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR-r12-v2 = Allumez le capteur tout en court-circuitant le pad FLASH circulaire sur le dessus de la carte à la protection métallique du microcontrôleur. La LED du capteur devrait faire un clignotement rapide.
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR-r14-v2 = Allumez le capteur tout en appuyant sur le bouton FLASH sur le dessus de la carte. La LED du capteur devrait faire un clignotement brièvement.
|
||||
firmware_tool-flashbtn_step-board_OTHER =
|
||||
Avant de flash le capteur, vous devrez probablement le mettre en mode bootloader.
|
||||
La plupart du temps, il s'agit d'appuyer sur le bouton boot de la carte avant que le processus de flash ne commence.
|
||||
@@ -1569,3 +1635,54 @@ error_collection_modal-cancel = Je ne veux pas
|
||||
|
||||
## Tracking checklist section
|
||||
|
||||
tracking_checklist = Checklist de suivi
|
||||
tracking_checklist-settings = Paramètres de lachecklist de suivi
|
||||
tracking_checklist-settings-close = Fermer
|
||||
tracking_checklist-status-incomplete = Vous n’êtes pas prêt à utiliser SlimeVR !
|
||||
tracking_checklist-status-partial =
|
||||
{ $count ->
|
||||
[one] Vous avez 1 avertissement !
|
||||
*[other] Vous avez { $count } avertissements !
|
||||
}
|
||||
tracking_checklist-status-complete = Vous êtes prêt à utiliser SlimeVR !
|
||||
tracking_checklist-MOUNTING_CALIBRATION = Effectuer une calibration de l'alignement
|
||||
tracking_checklist-FEET_MOUNTING_CALIBRATION = Effectuer une calibration de l'alignement des pieds
|
||||
tracking_checklist-FULL_RESET = Faire une réinitialisation complète
|
||||
tracking_checklist-FULL_RESET-desc = Certains capteurs nécessitent une réinitialisation.
|
||||
tracking_checklist-STEAMVR_DISCONNECTED = SteamVR n'est pas lancé
|
||||
tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR n'est pas lancé. L’utilisez-vous pour la VR ?
|
||||
tracking_checklist-STEAMVR_DISCONNECTED-open = Lancer SteamVR
|
||||
tracking_checklist-TRACKERS_REST_CALIBRATION = Calibrer vos capteurs
|
||||
tracking_checklist-TRACKERS_REST_CALIBRATION-desc = Vous n’avez pas fait de calibration de capteur. Veuillez laisser vos capteurs (surlignés en jaune) reposer sur une surface stable pendant quelques seconds.
|
||||
tracking_checklist-TRACKER_ERROR = Capteurs avec erreur
|
||||
tracking_checklist-TRACKER_ERROR-desc = Certains de vos capteurs ont une erreur. Veuillez redémarrer les capteurs surlignés en jaune.
|
||||
tracking_checklist-VRCHAT_SETTINGS = Configurez les paramètres de VRChat
|
||||
tracking_checklist-VRCHAT_SETTINGS-desc = Vous avez mal configuré les paramètres de VRChat ! Cela peut dégrader votre suivi.
|
||||
tracking_checklist-VRCHAT_SETTINGS-open = Aller sur les avertissements de VRChat
|
||||
tracking_checklist-UNASSIGNED_HMD = Casque VR non attribué à la tête
|
||||
tracking_checklist-UNASSIGNED_HMD-desc = Le casque VR devrait être attribué en tant que capteur de la tête.
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC = Modifier votre profil de réseau
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC-desc =
|
||||
{ $count ->
|
||||
[one] Votre profil de réseau est actuellement défini comme étant public. Ce n’est pas recommandé pour le fonctionnement correct de SlimeVR. <PublicFixLink>Voyez comment y remédier ici.</PublicFixLink>
|
||||
*[other]
|
||||
Certains de vos adaptateurs réseau sont réglés sur public :
|
||||
{ $adapters }
|
||||
Ce n’est pas recommandé pour que SlimeVR fonctionne correctement.
|
||||
<PublicFixLink>Voyez comment y remédier ici.</PublicFixLink>
|
||||
}
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Ouvrir le panneau de configuration
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED = Configurer Garder Aligné
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED-desc = Enregistrez les poses Garder Aligné pour réduire la dérive
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED-open = Ouvrir l'assistant de Garder Aligné
|
||||
tracking_checklist-ignore = Ignorer
|
||||
preview-mocap_mode_soon = Mode Mocap (Bientôt™)
|
||||
preview-disable_render = Désactiver le rendu
|
||||
preview-disabled_render = Rendu désactivé
|
||||
toolbar-mounting_calibration = Calibration de l'alignement
|
||||
toolbar-mounting_calibration-default = Corps
|
||||
toolbar-mounting_calibration-feet = Pieds
|
||||
toolbar-mounting_calibration-fingers = Doigts
|
||||
toolbar-drift_reset = Réinitialisation de la dérive
|
||||
toolbar-assigned_trackers = { $count } capteurs assignés
|
||||
toolbar-unassigned_trackers = { $count } capteurs non assignés
|
||||
|
||||
@@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3
|
||||
board_type-HARITORA = Haritora
|
||||
board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1
|
||||
board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev IMU Handschoen
|
||||
board_type-GESTURES = Gebaren
|
||||
board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini
|
||||
board_type-GENERIC_NRF = Generic nRF
|
||||
board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly
|
||||
board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly
|
||||
|
||||
## Proportions
|
||||
|
||||
@@ -255,8 +260,8 @@ reset-mounting-fingers = Reset vingermontage
|
||||
reset-yaw = Yaw Reset
|
||||
reset-error-no_feet_tracker = Geen voet-tracker toegewezen
|
||||
reset-error-no_fingers_tracker = Geen vingertracker toegewezen
|
||||
reset-error-mounting-need_full_reset = U heeft een volledige reset nodig voordat u de montagekalibratie kunt uitvoeren.
|
||||
reset-error-yaw-need_full_reset = U heeft een volledige reset nodig voordat u de yaw reset kunt uitvoeren.
|
||||
reset-error-mounting-need_full_reset = Je hebt een volledige reset nodig voordat je een montagekalibratie kunt uitvoeren.
|
||||
reset-error-yaw-need_full_reset = Je hebt een volledige reset nodig voordat je een yaw reset kunt uitvoeren.
|
||||
|
||||
## Serial detection stuff
|
||||
|
||||
@@ -276,6 +281,7 @@ navbar-trackers_assign = Tracker-toewijzing
|
||||
navbar-mounting = Montage-kalibratie
|
||||
navbar-onboarding = Installatiewizard
|
||||
navbar-settings = Instellingen
|
||||
navbar-connect_trackers = Verbind Trackers
|
||||
|
||||
## Biovision hierarchy recording
|
||||
|
||||
@@ -346,6 +352,7 @@ tracker-table-column-name = Naam
|
||||
tracker-table-column-type = Type
|
||||
tracker-table-column-battery = Batterij
|
||||
tracker-table-column-ping = Ping
|
||||
tracker-table-column-packet_loss = Pakketverlies
|
||||
tracker-table-column-tps = TPS
|
||||
tracker-table-column-temperature = Temp. °C
|
||||
tracker-table-column-linear-acceleration = Accel. X/Y/Z
|
||||
@@ -387,6 +394,9 @@ tracker-infos-magnetometer-status-v1 =
|
||||
[ENABLED] Ingeschakeld
|
||||
*[NOT_SUPPORTED] Niet ondersteund
|
||||
}
|
||||
tracker-infos-packet_loss = Pakketverlies
|
||||
tracker-infos-packets_lost = Verloren pakketten
|
||||
tracker-infos-packets_received = Ontvangen pakketten
|
||||
|
||||
## Tracker settings
|
||||
|
||||
@@ -404,8 +414,8 @@ tracker-settings-drift_compensation_section-edit = Laat drift compensatie toe
|
||||
tracker-settings-use_mag = Sta de magnetometer toe op deze tracker.
|
||||
# Multiline!
|
||||
tracker-settings-use_mag-description =
|
||||
Wilt u dat deze tracker de magnetometer gebruikt om drift te verminderen wanneer de magnetometer is toegestaan? <b>Zet de tracker niet uit terwijl u dit aan of uit zet.</b>
|
||||
U moet eerst de magnetometer toestemming geven,<magSetting>click hier om naar de instellingen te gaan</magSetting>.
|
||||
Wilt je dat deze tracker de magnetometer gebruikt om drift te verminderen wanneer de magnetometer is toegestaan? <b>Zet de tracker niet uit terwijl je dit aan of uit zet.</b>
|
||||
Je moet eerst de magnetometer toestemming geven,<magSetting>click hier om naar de instellingen te gaan</magSetting>.
|
||||
tracker-settings-use_mag-label = Laat magnetometer toe
|
||||
# The .<name> means it's an attribute and it's related to the top key.
|
||||
# In this case that is the settings for the assignment section.
|
||||
@@ -423,6 +433,9 @@ tracker-settings-update-up_to_date = Up to date.
|
||||
tracker-settings-update-blocked = Update is niet beschikbaar. Er zijn geen andere versies beschikbaar.
|
||||
tracker-settings-update = Werk nu bij.
|
||||
tracker-settings-update-title = Firmware versie
|
||||
tracker-settings-current-version = Actueel
|
||||
tracker-settings-latest-version = Nieuwste
|
||||
tracker-settings-build-date = Creatiedatum
|
||||
|
||||
## Tracker part card info
|
||||
|
||||
@@ -583,6 +596,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description =
|
||||
Gebruikt magnetometer op alle trackers die er een compatibele firmware voor hebben, waardoor drift in stabiele magnetische omgevingen wordt verminderd.
|
||||
Je kan dit per individuele tracker uit zetten in de instellingen van de tracker. <b>Sluit geen van de trackers af terwijl u dit in- en uitschakelt!</b>
|
||||
settings-general-tracker_mechanics-use_mag_on_all_trackers-label = Gebruik magnetometer op de trackers
|
||||
settings-general-tracker_mechanics-trackers_over_usb = Trackers via USB
|
||||
settings-general-tracker_mechanics-trackers_over_usb-description = Maakt het mogelijk om HID-trackergegevens via USB te ontvangen. Zorg ervoor dat verbonden trackers <b>"verbinding over HID"</b> hebben ingeschakeld!
|
||||
settings-general-tracker_mechanics-trackers_over_usb-enabled-label = Laat HID-trackers direct via USB verbinden
|
||||
settings-stay_aligned = Blijf in lijn
|
||||
settings-stay_aligned-description = Blijf in lijn vermindert drift door je trackers geleidelijk aan te passen zodat ze overeenkomen met je ontspannen houdingen.
|
||||
settings-stay_aligned-setup-label = Blijf in lijn instellen
|
||||
@@ -634,7 +650,7 @@ settings-general-fk_settings-enforce_joint_constraints-correct_constraints = Cor
|
||||
settings-general-fk_settings-enforce_joint_constraints-correct_constraints-description = Corrigeer gewrichtsrotaties wanneer ze hun limiet overschrijden
|
||||
settings-general-fk_settings-ik = Positie gegevens
|
||||
settings-general-fk_settings-ik-use_position = Positiegegevens gebruiken
|
||||
settings-general-fk_settings-ik-use_position-description = Maakt gebruik van positiegegevens mogelijk van de trackers die deze leveren. Waneer u dit inschakelt, zorg er voor dat u een volledige reset doet en in het spel opnieuw kalibreert.
|
||||
settings-general-fk_settings-ik-use_position-description = Maakt gebruik van positiegegevens mogelijk van de trackers die deze leveren. Zorg er voor dat je een volledige reset doet en opnieuw kalibreert in het spel wanneer je dit inschakelt.
|
||||
settings-general-fk_settings-arm_fk = Arm tracking
|
||||
settings-general-fk_settings-arm_fk-description = Verander de manier waarop de armen worden getrackt.
|
||||
settings-general-fk_settings-arm_fk-force_arms = Dwing armen vanuit HMD
|
||||
@@ -755,9 +771,9 @@ settings-general-interface-discord_presence-message =
|
||||
}
|
||||
settings-interface-behavior-error_tracking = Foutverzameling via Sentry.io
|
||||
settings-interface-behavior-error_tracking-description_v2 =
|
||||
<h1>Geeft u toestemming voor het verzamelen van geanonimiseerde foutgegevens?</h1>
|
||||
<h1>Geef je toestemming voor het verzamelen van geanonimiseerde foutgegevens?</h1>
|
||||
|
||||
<b>We verzamelen geen persoonlijke informatie</b> zoals uw IP-adres of draadloze inloggegevens. SlimeVR hecht veel waarde aan uw privacy!
|
||||
<b>We verzamelen geen persoonlijke informatie</b> zoals jouw IP-adres of draadloze inloggegevens. SlimeVR hecht veel waarde aan je privacy!
|
||||
|
||||
Om de beste gebruikerservaring te bieden, verzamelen we geanonimiseerde foutrapporten, prestatiestatistieken en informatie over het besturingssysteem. Dit helpt ons bij het detecteren van fouten en problemen met SlimeVR. Deze statistieken worden verzameld via Sentry.io.
|
||||
settings-interface-behavior-error_tracking-label = Stuur fouten naar de ontwikkelaars
|
||||
@@ -904,14 +920,14 @@ settings-utils-advanced-reset-all-label = Alles resetten
|
||||
settings-utils-advanced-reset_warning =
|
||||
{ $type ->
|
||||
[gui]
|
||||
<b>Waarschuwing</b>Hiermee worden al uw GUI instellingen teruggezet naar de standaardinstellingen.
|
||||
Weet u zeker dat u dit wilt doen?
|
||||
<b>Waarschuwing</b>Hiermee worden al je GUI instellingen teruggezet naar de standaardinstellingen.
|
||||
Weet je zeker dat je dit wilt doen?
|
||||
[server]
|
||||
<b>Waarschuwing</b>Hiermee worden al uw tracking instellingen teruggezet naar de standaardinstellingen.
|
||||
Weet u zeker dat u dit wilt doen?
|
||||
<b>Waarschuwing</b>Hiermee worden al je tracking instellingen teruggezet naar de standaardinstellingen.
|
||||
Weet je zeker dat je dit wilt doen?
|
||||
*[all]
|
||||
<b>Waarschuwing:</b> Hiermee worden al uw instellingen teruggezet naar de standaardinstellingen.
|
||||
Weet u zeker dat u dit wilt doen?
|
||||
<b>Waarschuwing:</b> Hiermee worden al je instellingen teruggezet naar de standaardinstellingen.
|
||||
Weet je zeker dat je dit wilt doen?
|
||||
}
|
||||
settings-utils-advanced-reset_warning-reset = Instellingen resetten
|
||||
settings-utils-advanced-reset_warning-cancel = Annuleren
|
||||
@@ -949,6 +965,13 @@ onboarding-setup_warning-cancel = Doorgaan met setupgids
|
||||
## Wi-Fi setup
|
||||
|
||||
onboarding-wifi_creds-back = Ga terug naar de introductie
|
||||
onboarding-wifi_creds-v2 = Trackers die Wi-Fi gebruiken
|
||||
# This cares about multilines
|
||||
onboarding-wifi_creds-description-v2 =
|
||||
De meeste trackers (zoals de officiële SlimeVR-trackers) gebruiken Wi-Fi om verbinding te maken met de server.
|
||||
Gebruik de inloggegevens van het Wi-Fi-netwerk waarmee je apparaat momenteel is verbonden.
|
||||
|
||||
Zorg ervoor dat je een 2,4GHz-Wi-Fi-verbinding gebruikt voor jouw trackers!
|
||||
onboarding-wifi_creds-skip = WiFi-instellingen overslaan
|
||||
onboarding-wifi_creds-submit = Verzenden!
|
||||
onboarding-wifi_creds-ssid =
|
||||
@@ -958,6 +981,10 @@ onboarding-wifi_creds-ssid-required = Wi-Fi-naam is vereist
|
||||
onboarding-wifi_creds-password =
|
||||
.label = Paswoord
|
||||
.placeholder = Vul paswoord in
|
||||
onboarding-wifi_creds-dongle-title = Trackers met een dongle
|
||||
onboarding-wifi_creds-dongle-description = Als je trackers met een dongle zijn geleverd, steek die dan in je apparaat en je bent klaar om te beginnen!
|
||||
onboarding-wifi_creds-dongle-wip = Dit gedeelte is nog in ontwikkeling. Er komt binnenkort een aparte pagina om trackers te beheren die via een dongle verbinden.
|
||||
onboarding-wifi_creds-dongle-continue = Ga verder met een dongle
|
||||
|
||||
## Mounting setup
|
||||
|
||||
@@ -1206,7 +1233,7 @@ onboarding-automatic_mounting-done-restart = Terug naar start
|
||||
onboarding-automatic_mounting-mounting_reset-title = Montage-reset
|
||||
onboarding-automatic_mounting-mounting_reset-step-0 = 1. Ga staan in een "skie"-houding met gebogen benen, je bovenlichaam naar voren gekanteld en armen gebogen.
|
||||
onboarding-automatic_mounting-mounting_reset-step-1 = 2. Druk op de knop "Reset montage" en wacht 3 seconden voordat de montagerichtingen van de trackers opnieuw worden ingesteld.
|
||||
onboarding-automatic_mounting-mounting_reset-feet-step-0 = 1. Sta op uw tenen met beide voeten naar voren gericht. u kunt het ook zittend op een stoel doen.
|
||||
onboarding-automatic_mounting-mounting_reset-feet-step-0 = 1. Sta op je tenen met beide voeten naar voren gericht. Je kunt het ook zittend op een stoel doen.
|
||||
onboarding-automatic_mounting-mounting_reset-feet-step-1 = 2. Druk op de knop "Voetkalibratie" en wacht 3 seconden voordat de montageoriëntaties van de trackers gereset worden.
|
||||
onboarding-automatic_mounting-preparation-title = Voorbereiding
|
||||
onboarding-automatic_mounting-preparation-v2-step-0 = 1. Druk op de knop "Volledige reset".
|
||||
@@ -1246,26 +1273,26 @@ onboarding-automatic_proportions-requirements-title = Vereisten
|
||||
# Each line of text is a different list item
|
||||
onboarding-automatic_proportions-requirements-descriptionv2 = Je hebt voldaan aan de minimale vereisten om je voeten te tracken (over het algemeen 5 trackers). Je hebt je trackers en headset aan en draagt ze. Je trackers en headset zijn verbonden met de SlimeVR server en werken naar behoren (zonder haperingen, loskoppelingen etc.). Je headset stuurt positiedata naar de SlimeVR server (dit vereist doorgaans dat SteamVR draait en verbonden is met SlimeVR via de SlimeVR SteamVR-driver). De tracking werkt en registreert je bewegingen nauwkeurig (je hebt bijvoorbeeld een volledige reset uitgevoerd en de trackers bewegen in de juiste richting bij schoppen, bukken, zitten etc.).
|
||||
onboarding-automatic_proportions-requirements-next = Ik heb de vereisten gelezen
|
||||
onboarding-automatic_proportions-check_height-title-v3 = Meet de hoogte van uw headset
|
||||
onboarding-automatic_proportions-check_height-description-v2 = De hoogte van uw headset (HMD) moet iets minder zijn dan uw volledige lengte, aangezien headsets uw ooghoogte meten. Deze meting wordt gebruikt als basis voor uw lichaamsverhoudingen.
|
||||
onboarding-automatic_proportions-check_height-title-v3 = Meet de hoogte van je headset
|
||||
onboarding-automatic_proportions-check_height-description-v2 = De hoogte van je headset (HMD) moet iets minder zijn dan jouw volledige lengte, aangezien headsets je ooghoogte meten. Deze meting wordt gebruikt als basis voor je lichaamsverhoudingen.
|
||||
# All the text is in bold!
|
||||
onboarding-automatic_proportions-check_height-calculation_warning-v3 = Begin met meten terwijl je <u>rechtop</u> staat om je lengte te meten. Let erop dat je je handen niet hoger dan je headset tilt, want dat kan de meting beïnvloeden!
|
||||
onboarding-automatic_proportions-check_height-guardian_tip = Als je een losse VR-bril gebruikt, zorg er dan voor dat je guardian/veilige zone is ingeschakeld zodat je lengte correct is gekalibreerd!
|
||||
# Context is that the height is unknown
|
||||
onboarding-automatic_proportions-check_height-unknown = Onbekend
|
||||
# Shows an element below it
|
||||
onboarding-automatic_proportions-check_height-hmd_height2 = De hoogte van uw headset is:
|
||||
onboarding-automatic_proportions-check_height-hmd_height2 = De hoogte van je headset is:
|
||||
onboarding-automatic_proportions-check_height-measure-start = Begin met meten
|
||||
onboarding-automatic_proportions-check_height-measure-stop = Stoppen met meten
|
||||
onboarding-automatic_proportions-check_height-measure-reset = Probeer opnieuw te meten
|
||||
onboarding-automatic_proportions-check_height-next_step = Ze zijn goed
|
||||
onboarding-automatic_proportions-check_floor_height-title = Meet uw vloerhoogte (optioneel)
|
||||
onboarding-automatic_proportions-check_floor_height-description = In sommige gevallen wordt uw vloerhoogte mogelijk niet correct ingesteld door uw headset, waardoor de hoogte van de headset hoger wordt gemeten dan zou moeten. U kunt de "hoogte" van uw vloer meten om de hoogte van uw headset te corrigeren.
|
||||
onboarding-automatic_proportions-check_floor_height-title = Meet je vloerhoogte (optioneel)
|
||||
onboarding-automatic_proportions-check_floor_height-description = In sommige gevallen wordt je vloerhoogte mogelijk niet correct ingesteld door je headset, waardoor de hoogte van de headset hoger wordt gemeten dan zou moeten. Je kunt de "hoogte" van je vloer meten om de hoogte van je headset te corrigeren.
|
||||
# All the text is in bold!
|
||||
onboarding-automatic_proportions-check_floor_height-calculation_warning-v2 = Begin met meten en zet een controller op je vloer om de hoogte te meten. Als je zeker weet dat je vloerhoogte klopt, kun je deze stap overslaan.
|
||||
# Shows an element below it
|
||||
onboarding-automatic_proportions-check_floor_height-floor_height = Uw vloerhoogte is:
|
||||
onboarding-automatic_proportions-check_floor_height-full_height = Uw geschatte volledige lengte is:
|
||||
onboarding-automatic_proportions-check_floor_height-floor_height = Je vloerhoogte is:
|
||||
onboarding-automatic_proportions-check_floor_height-full_height = Je geschatte volledige lengte is:
|
||||
onboarding-automatic_proportions-check_floor_height-measure-start = Begin met meten
|
||||
onboarding-automatic_proportions-check_floor_height-measure-stop = Stoppen met meten
|
||||
onboarding-automatic_proportions-check_floor_height-measure-reset = Probeer opnieuw te meten
|
||||
@@ -1306,7 +1333,7 @@ onboarding-automatic_proportions-error_modal-v2 =
|
||||
<docs>Bekijk de documentatie</docs> of word lid van onze <discord>Discord</discord> voor hulp ^_^
|
||||
onboarding-automatic_proportions-error_modal-confirm = Begrepen!
|
||||
onboarding-automatic_proportions-smol_warning =
|
||||
Uw ingestelde lengte van { $height } is lager dan de toegestane minimumlengte van { $minHeight }.
|
||||
Jouw ingestelde lengte van { $height } is lager dan de toegestane minimumlengte van { $minHeight }.
|
||||
<b>Voer de metingen opnieuw uit en controleer of ze correct zijn.</b>
|
||||
onboarding-automatic_proportions-smol_warning-cancel = Ga terug
|
||||
|
||||
@@ -1317,6 +1344,25 @@ onboarding-user_height-description = We hebben je lengte nodig om je lichaamspro
|
||||
onboarding-user_height-need_head_tracker = Voor de kalibratie zijn een headset en controllers met positionele tracking vereist.
|
||||
onboarding-user_height-calculate = Bereken mijn lengte automatisch
|
||||
onboarding-user_height-next_step = Doorgaan en opslaan
|
||||
onboarding-user_height-manual-proportions = Handmatige lichaamsverhoudingen
|
||||
onboarding-user_height-calibration-title = Vooruitgang van de kalibratie
|
||||
onboarding-user_height-calibration-RECORDING_FLOOR = Raak de vloer aan met de punt van je controller
|
||||
onboarding-user_height-calibration-WAITING_FOR_RISE = Sta weer op
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK = Sta weer op en kijk vooruit
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Zorg dat je hoofd vlak staat
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = Kijk niet naar de vloer
|
||||
onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = Kijk niet te veel omhoog
|
||||
onboarding-user_height-calibration-WAITING_FOR_CONTROLLER_PITCH = Zorg dat de controller naar beneden wijst
|
||||
onboarding-user_height-calibration-RECORDING_HEIGHT = Sta weer op en blijf stilstaan!
|
||||
onboarding-user_height-calibration-DONE = Gelukt!
|
||||
onboarding-user_height-calibration-ERROR_TIMEOUT = Kalibratie sessie is verlopen, probeer het opnieuw.
|
||||
onboarding-user_height-calibration-ERROR_TOO_HIGH = De gedetecteerde gebruikershoogte is te hoog, probeer het opnieuw.
|
||||
onboarding-user_height-calibration-ERROR_TOO_SMALL = De gedetecteerde gebruikerslengte is te klein. Zorg dat je voor het einde van de kalibratie rechtop staat en naar voren kijkt.
|
||||
onboarding-user_height-calibration-error = Kalibratie mislukt
|
||||
onboarding-user_height-manual-tip = Tijdens het aanpassen van je lengte kan je verschillende poses proberen en kijken hoe het skelet met jouw lichaam overeenkomt.
|
||||
onboarding-user_height-reset-warning =
|
||||
<b>Waarschuwing:</b> Dit zet je verhoudingen terug op basis van jouw lengte.
|
||||
Weet je zeker dat je dit wilt doen?
|
||||
|
||||
## Stay Aligned setup
|
||||
|
||||
@@ -1351,6 +1397,7 @@ onboarding-stay_aligned-previous_step = Vorige
|
||||
onboarding-stay_aligned-next_step = Volgende
|
||||
onboarding-stay_aligned-restart = Herstarten
|
||||
onboarding-stay_aligned-done = Klaar
|
||||
onboarding-stay_aligned-manual_mounting-done = Klaar
|
||||
|
||||
## Home
|
||||
|
||||
@@ -1397,20 +1444,50 @@ firmware_tool = DIY firmware-tool
|
||||
firmware_tool-description = Hiermee kan je uw DIY-trackers configureren en flashen
|
||||
firmware_tool-not_available = Oeps, de firmwaretool is momenteel niet beschikbaar. Kom later terug!
|
||||
firmware_tool-not_compatible = De firmwaretool is niet compatibel met deze versie van de server. Gelieve te updaten!
|
||||
firmware_tool-select_source = Selecteer de firmware die u wilt flashen
|
||||
firmware_tool-select_source-description = Selecteer de firmware die u op uw bord wilt flashen
|
||||
firmware_tool-select_source = Selecteer de firmware die je wilt flashen
|
||||
firmware_tool-select_source-description = Selecteer de firmware die je op jouw bord wilt flashen
|
||||
firmware_tool-select_source-error = Kan bronnen niet laden
|
||||
firmware_tool-select_source-board_type = Type bord
|
||||
firmware_tool-select_source-firmware = Firmware-bron
|
||||
firmware_tool-select_source-version = Firmware versie
|
||||
firmware_tool-select_source-official = Officieel
|
||||
firmware_tool-select_source-dev = Ontwikkelaar
|
||||
firmware_tool-select_source-not_selected = Geen bron geselecteerd
|
||||
firmware_tool-select_source-no_boards = Geen beschikbare borden voor deze bron
|
||||
firmware_tool-select_source-no_versions = Geen beschikbare versies voor deze bron
|
||||
firmware_tool-board_defaults = Configureer je bord
|
||||
firmware_tool-board_defaults-description = Stel de pinnen of instellingen in ten opzichte van jouw hardware
|
||||
firmware_tool-board_defaults-add = Toevoegen
|
||||
firmware_tool-board_defaults-reset = Reset naar standaard
|
||||
firmware_tool-board_defaults-error-required = Verplicht veld
|
||||
firmware_tool-board_defaults-error-format = Ongeldig formaat
|
||||
firmware_tool-board_defaults-error-format-number = Is geen nummer
|
||||
firmware_tool-flash_method_step = Flashing methode
|
||||
firmware_tool-flash_method_step-description = Kies de flashingsmethode die je wilt gebruiken
|
||||
firmware_tool-flash_method_step-ota-v2 =
|
||||
.label = Wi-Fi
|
||||
.description = Gebruik de over-the-air methode. Jouw tracker zal via wifi de firmware bijwerken. Werkt alleen op trackers die al zijn ingesteld.
|
||||
firmware_tool-flash_method_step-ota-info =
|
||||
We gebruiken jouw wifi-inloggegevens om de tracker te flashen en te bevestigen dat alles correct werkte.
|
||||
<b>We slaan je wifi-gegevens niet op!</b>
|
||||
firmware_tool-flash_method_step-serial-v2 =
|
||||
.label = USB
|
||||
.description = Gebruik een USB kabel om jouw tracker up te daten.
|
||||
firmware_tool-flashbtn_step = Druk op de bootknop
|
||||
firmware_tool-flashbtn_step-description = Voordat u naar de volgende stap gaat, zijn er een paar dingen die u moet doen.
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR = Zet de tracker uit, verwijder de behuizing (indien aanwezig), verbind een USB-kabel met deze computer en voer vervolgens een van de volgende stappen uit, afhankelijk van de revisie van uw SlimeVR-board:
|
||||
firmware_tool-flashbtn_step-description = Voordat je naar de volgende stap gaat, zijn er een paar dingen die je moet doen.
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR = Zet de tracker uit, verwijder de behuizing (indien aanwezig), verbind een USB-kabel met deze computer en voer vervolgens een van de volgende stappen uit, afhankelijk van de revisie van je SlimeVR-bord:
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR-r11-v2 = Zet de tracker aan terwijl je het tweede rechthoekige FLASH-contact vlak bij de rand aan de bovenkant van de printplaat kortsluit tot het metalen schild van de microcontroller. De LED van de tracker zou kort moeten knipperen.
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR-r12-v2 = Zet de tracker aan terwijl je het ronde FLASH-contact aan de bovenkant van de printplaat kortsluit tot het metalen schild van de microcontroller. De LED van de tracker zou kort moeten knipperen.
|
||||
firmware_tool-flashbtn_step-board_SLIMEVR-r14-v2 = Zet de tracker aan terwijl je de FLASH-knop aan de bovenkant van de printplaat ingedrukt houdt. De LED van de tracker zou kort moeten knipperen.
|
||||
firmware_tool-flashbtn_step-board_OTHER =
|
||||
Voordat u gaat flashen, moet de tracker waarschijnlijk in de bootloader-modus worden gezet.
|
||||
Voordat je gaat flashen, moet de tracker waarschijnlijk in de bootloader-modus worden gezet.
|
||||
Meestal betekent dit het indrukken van de bootknop op het board voordat het flashproces begint.
|
||||
Als het flashproces time-out bij het begin van het flashen, betekent dit waarschijnlijk dat de tracker niet in de bootloader-modus stond.
|
||||
Raadpleeg de flitsinstructies van uw board om te weten hoe u de bootloader-modus inschakelt.
|
||||
Als het flashproces verloopt bij het begin van het flashen, betekent dit waarschijnlijk dat de tracker niet in de bootloader-modus stond.
|
||||
Raadpleeg de flashing-instructies van je board om te weten hoe je de bootloader-modus inschakelt.
|
||||
firmware_tool-flash_method_ota-title = Flashen over Wi-Fi
|
||||
firmware_tool-flash_method_ota-devices = Gedetecteerde OTA-apparaten:
|
||||
firmware_tool-flash_method_ota-no_devices = Er zijn geen boards die via OTA bijgewerkt kunnen worden, zorg ervoor dat u het juiste boardtype heeft geselecteerd.
|
||||
firmware_tool-flash_method_ota-no_devices = Er zijn geen boards die via OTA bijgewerkt kunnen worden, zorg ervoor dat je het juiste boardtype heeft geselecteerd.
|
||||
firmware_tool-flash_method_serial-title = Flashen over USB
|
||||
firmware_tool-flash_method_serial-wifi = Wi-Fi-gegevens:
|
||||
firmware_tool-flash_method_serial-devices-label = Gedetecteerde serial apparaten:
|
||||
firmware_tool-flash_method_serial-devices-placeholder = Selecteer een serieel apparaat
|
||||
@@ -1425,7 +1502,10 @@ firmware_tool-flashing_step-exit = Sluit
|
||||
|
||||
## firmware tool build status
|
||||
|
||||
firmware_tool-build-QUEUED = Wachten om te maken....
|
||||
firmware_tool-build-CREATING_BUILD_FOLDER = De buildmap maken
|
||||
firmware_tool-build-DOWNLOADING_SOURCE = Broncode wordt gedownload
|
||||
firmware_tool-build-EXTRACTING_SOURCE = Broncode wordt uitgepakt
|
||||
firmware_tool-build-BUILDING = Firmware wordt gebouwd
|
||||
firmware_tool-build-SAVING = De build opslaan
|
||||
firmware_tool-build-DONE = Build voltooid
|
||||
@@ -1540,6 +1620,31 @@ error_collection_modal-cancel = Ik wil het niet
|
||||
|
||||
## Tracking checklist section
|
||||
|
||||
tracking_checklist = Tracking Checklist
|
||||
tracking_checklist-settings = Instellingen voor trackingchecklists
|
||||
tracking_checklist-settings-close = Sluiten
|
||||
tracking_checklist-status-incomplete = U bent niet voorbereid om SlimeVR te gebruiken!
|
||||
tracking_checklist-status-partial =
|
||||
{ $count ->
|
||||
[one] U heeft 1 waarschuwing!
|
||||
*[other] U heeft { $count } waarschuwingen!
|
||||
}
|
||||
tracking_checklist-status-complete = U bent klaar om SlimeVR te gebruiken!
|
||||
tracking_checklist-MOUNTING_CALIBRATION = Voer een montagekalibratie uit
|
||||
tracking_checklist-FEET_MOUNTING_CALIBRATION = Voer een voetmontage-kalibratie uit
|
||||
tracking_checklist-FULL_RESET = Voer een volledige reset uit
|
||||
tracking_checklist-FULL_RESET-desc = Sommige trackers hebben een reset nodig
|
||||
tracking_checklist-STEAMVR_DISCONNECTED = SteamVR draait niet
|
||||
tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR draait niet. Gebruik je het voor VR?
|
||||
tracking_checklist-STEAMVR_DISCONNECTED-open = Open SteamVR
|
||||
tracking_checklist-TRACKERS_REST_CALIBRATION = Kalibreer je trackers
|
||||
tracking_checklist-TRACKERS_REST_CALIBRATION-desc = Je hebt geen tracker kalibratie uitgevoerd. Laat je Slimes (gemarkeerd met geel) rusten op een stabiele ondergrond voor een paar secondes.
|
||||
tracking_checklist-TRACKER_ERROR = Trackers met fouten
|
||||
tracking_checklist-TRACKER_ERROR-desc = Sommige van je trackers hebben een fout. Herstart de tracker die in het geel zijn gemarkeerd aub.
|
||||
tracking_checklist-VRCHAT_SETTINGS = Configureer VRChat-instellingen
|
||||
tracking_checklist-VRCHAT_SETTINGS-desc = Je hebt enkele VRchat-instellingen verkeerd geconfigureerd! Dit kan jouw trackingervaring negatief beïnvloeden.
|
||||
tracking_checklist-VRCHAT_SETTINGS-open = Ga naar VRChat Waarschuwingen
|
||||
tracking_checklist-UNASSIGNED_HMD = VR-headset niet toegewezen aan Hoofd
|
||||
tracking_checklist-UNASSIGNED_HMD-desc = De VR-headset moet worden toegewezen als hoofdtracker.
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC = Verander je netwerkprofiel
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC-desc =
|
||||
@@ -1547,12 +1652,12 @@ tracking_checklist-NETWORK_PROFILE_PUBLIC-desc =
|
||||
[one]
|
||||
Uw netwerk-profiel is op dit moment of publiek ingesteld ({ $adapters })
|
||||
Dit wordt niet aanbevolen voor een goede werking van SlimeVR
|
||||
<PublicFixLink>Hiet leest u hoe u dit kunt oplossen</PublicFixLink>
|
||||
<PublicFixLink>Hier lees je hoe je dit kan oplossen</PublicFixLink>
|
||||
*[other]
|
||||
Sommige van uw netwerkadapters staan ingesteld op openbaar:
|
||||
Sommige van je netwerkadapters staan ingesteld op openbaar:
|
||||
{ $adapters }.
|
||||
Dit wordt niet aanbevolen voor een goede werking van SlimeVR.
|
||||
<PublicFixLink>Hier leest u hoe u dit kunt oplossen.</PublicFixLink>
|
||||
<PublicFixLink>Hier lees je hoe je dit kan oplossen.</PublicFixLink>
|
||||
}
|
||||
tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Open Configuratiescherm
|
||||
tracking_checklist-STAY_ALIGNED_CONFIGURED = Configureer Blijf in lijn
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3
|
||||
board_type-HARITORA = Haritora
|
||||
board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1
|
||||
board_type-GLOVE_IMU_SLIMEVR_DEV = บอร์ดพัฒนาถุงมือ IMU SlimeVR
|
||||
board_type-GESTURES = ท่าทางสัมผัส
|
||||
board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini
|
||||
board_type-GENERIC_NRF = บอร์ด NRF ทั่วไป
|
||||
board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly
|
||||
board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly
|
||||
|
||||
## Proportions
|
||||
|
||||
@@ -276,7 +281,7 @@ navbar-home = หน้าหลัก
|
||||
navbar-body_proportions = สัดส่วนร่างกาย
|
||||
navbar-trackers_assign = กำหนดแทร็กเกอร์
|
||||
navbar-mounting = ตั้งศูนย์การติดตั้ง
|
||||
navbar-onboarding = ตัวช่วยตั้งค่าโปรแกรม
|
||||
navbar-onboarding = ตัวช่วยการตั้งค่า
|
||||
navbar-settings = ตั้งค่า
|
||||
navbar-connect_trackers = เชื่อมต่อแทร็กเกอร์
|
||||
|
||||
@@ -349,6 +354,7 @@ tracker-table-column-name = ชื่อ
|
||||
tracker-table-column-type = ชนิด
|
||||
tracker-table-column-battery = แบตเตอรี่
|
||||
tracker-table-column-ping = Ping
|
||||
tracker-table-column-packet_loss = สูญเสียแพ็คเก็ต
|
||||
tracker-table-column-tps = TPS
|
||||
tracker-table-column-temperature = อุณหภูมิ °C
|
||||
tracker-table-column-linear-acceleration = ความเร่ง X/Y/Z
|
||||
@@ -390,6 +396,9 @@ tracker-infos-magnetometer-status-v1 =
|
||||
[ENABLED] เปิดใช้งาน
|
||||
*[NOT_SUPPORTED] ไม่รองรับ
|
||||
}
|
||||
tracker-infos-packet_loss = สูญเสียแพ็คเก็ต
|
||||
tracker-infos-packets_lost = สูญเสียแพ็คเก็ต
|
||||
tracker-infos-packets_received = ได้รับแพ็คเก็ต
|
||||
|
||||
## Tracker settings
|
||||
|
||||
@@ -428,6 +437,7 @@ tracker-settings-update = อัปเดตทันที
|
||||
tracker-settings-update-title = เวอร์ชันเฟิร์มแวร์
|
||||
tracker-settings-current-version = ปัจจุบัน
|
||||
tracker-settings-latest-version = ล่าสุด
|
||||
tracker-settings-build-date = วันที่สร้าง
|
||||
|
||||
## Tracker part card info
|
||||
|
||||
@@ -593,6 +603,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description =
|
||||
ใช้เซ็นเซอร์สนามแม่เหล็กบนแทร็กเกอร์ทั้งหมดที่มีเฟิร์มแวร์ที่เข้ากันได้ ซึ่งช่วยลดดริฟท์ในสภาพแวดล้อมที่มีสนามแม่เหล็กคงที่
|
||||
สามารถปิดการใช้งานสำหรับแทร็กเกอร์แต่ละตัวได้ในการตั้งค่าของแทร็กเกอร์ <b>โปรดอย่าปิดแทร็กเกอร์ ในขณะที่กำลังสลับการตั้งค่านี้!</b>
|
||||
settings-general-tracker_mechanics-use_mag_on_all_trackers-label = ใช้เซ็นเซอร์สนามแม่เหล็กกับแทร็กเกอร์
|
||||
settings-general-tracker_mechanics-trackers_over_usb = ต่อแทร็กเกอร์ผ่าน USB
|
||||
settings-general-tracker_mechanics-trackers_over_usb-description = เปิดใช้งานการรับข้อมูลแทร็กเกอร์แบบ HID ผ่านสาย USB ตรวจสอบว่าแทร็กเกอร์ของคุณได้เปิด <b>การเชื่อมต่อผ่าน HID</b> เอาไว้!
|
||||
settings-general-tracker_mechanics-trackers_over_usb-enabled-label = เปิดให้แทร็กเกอร์แบบ HID ต่อโดยตรงผ่านสาย USB
|
||||
settings-stay_aligned = Stay Aligned
|
||||
settings-stay_aligned-description = Stay Aligned จะลดดริฟท์โดยค่อยๆ ปรับแทร็กเกอร์ให้เข้ากับท่าทางผ่อนคลายของคุณ
|
||||
settings-stay_aligned-setup-label = ตั้งค่า Stay Aligned
|
||||
@@ -896,8 +909,8 @@ settings-utils-advanced = ขั้นสูง
|
||||
settings-utils-advanced-reset-gui = รีเซ็ตตั้งค่า GUI
|
||||
settings-utils-advanced-reset-gui-description = คืนค่าการตั้งค่าเริ่มต้นสำหรับอินเทอร์เฟซ
|
||||
settings-utils-advanced-reset-gui-label = รีเซ็ต GUI
|
||||
settings-utils-advanced-reset-server = รีเซ็ตการตั้งค่าการติดตาม
|
||||
settings-utils-advanced-reset-server-description = คืนค่าการจับตำแหน่งทั้งหมดเป็นค่าเริ่มต้น
|
||||
settings-utils-advanced-reset-server = รีเซ็ตการตั้งค่าแทร็กเกอร์
|
||||
settings-utils-advanced-reset-server-description = คืนค่าเกี่ยวกับแทร็กเกอร์เป็นค่าเริ่มต้น
|
||||
settings-utils-advanced-reset-server-label = รีเซ็ตการจับตำแหน่ง
|
||||
settings-utils-advanced-reset-all = รีเซ็ตการตั้งค่าทั้งหมด
|
||||
settings-utils-advanced-reset-all-description = คืนค่าการตั้งค่าเริ่มต้นสำหรับทั้งอินเทอร์เฟซและการจับตำแหน่ง
|
||||
@@ -1368,6 +1381,7 @@ onboarding-stay_aligned-previous_step = ก่อนหน้า
|
||||
onboarding-stay_aligned-next_step = ต่อไป
|
||||
onboarding-stay_aligned-restart = เริ่มใหม่
|
||||
onboarding-stay_aligned-done = เสร็จแล้ว
|
||||
onboarding-stay_aligned-manual_mounting-done = เสร็จแล้ว
|
||||
|
||||
## Home
|
||||
|
||||
@@ -1418,6 +1432,9 @@ firmware_tool-select_source-firmware = แหล่งที่มาของ
|
||||
firmware_tool-select_source-version = เวอร์ชันของเฟิร์มแวร์
|
||||
firmware_tool-select_source-official = ทางการ
|
||||
firmware_tool-select_source-dev = รุ่นพัฒนา
|
||||
firmware_tool-select_source-not_selected = ยังไม่ได้กำหนดแหล่งเฟิร์มแวร์
|
||||
firmware_tool-select_source-no_boards = ไม่มีเฟิร์มแวร์บอร์ดสำหรับแหล่งนี้
|
||||
firmware_tool-select_source-no_versions = ไม่มีเวอร์ชั่นที่ใช้ได้สำหรับแหล่งนี้
|
||||
firmware_tool-board_defaults = กำหนดค่าบอร์ดของคุณ
|
||||
firmware_tool-board_defaults-description = ตั้งค่า Pin หรือการตั้งค่าที่เกี่ยวข้องกับฮาร์ดแวร์ของคุณ
|
||||
firmware_tool-board_defaults-add = เพิ่ม
|
||||
|
||||
@@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3
|
||||
board_type-HARITORA = Haritora
|
||||
board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1
|
||||
board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR开发版IMU手套
|
||||
board_type-GESTURES = 手势
|
||||
board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini
|
||||
board_type-GENERIC_NRF = nRF系列
|
||||
board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR蝴蝶 开发版
|
||||
board_type-SLIMEVR_BUTTERFLY = SlimeVR蝴蝶
|
||||
|
||||
## Proportions
|
||||
|
||||
@@ -262,7 +267,7 @@ serial_detection-close = 关闭
|
||||
|
||||
## Navigation bar
|
||||
|
||||
navbar-home = 主页
|
||||
navbar-home = 主界面
|
||||
navbar-body_proportions = 身体比例
|
||||
navbar-trackers_assign = 追踪器分配
|
||||
navbar-mounting = 佩戴校准
|
||||
@@ -275,7 +280,7 @@ navbar-connect_trackers = 连接追踪器
|
||||
bvh-start_recording = 录制 BVH 文件
|
||||
bvh-stop_recording = 保存 BVH 记录
|
||||
bvh-recording = 录制中...
|
||||
bvh-save_title = 保存BVH记录
|
||||
bvh-save_title = 保存 BVH 记录
|
||||
|
||||
## Tracking pause
|
||||
|
||||
@@ -339,6 +344,7 @@ tracker-table-column-name = 名字
|
||||
tracker-table-column-type = 类型
|
||||
tracker-table-column-battery = 电量
|
||||
tracker-table-column-ping = 延迟
|
||||
tracker-table-column-packet_loss = 丢包
|
||||
tracker-table-column-tps = TPS
|
||||
tracker-table-column-temperature = 温度 °C
|
||||
tracker-table-column-linear-acceleration = 加速度 X/Y/Z
|
||||
@@ -380,6 +386,9 @@ tracker-infos-magnetometer-status-v1 =
|
||||
[ENABLED] 已启用
|
||||
*[NOT_SUPPORTED] 不支持
|
||||
}
|
||||
tracker-infos-packet_loss = 丢包
|
||||
tracker-infos-packets_lost = 包丢失
|
||||
tracker-infos-packets_received = 包已接收
|
||||
|
||||
## Tracker settings
|
||||
|
||||
@@ -419,6 +428,7 @@ tracker-settings-update = 立即更新
|
||||
tracker-settings-update-title = 固件版本
|
||||
tracker-settings-current-version = 当前版本
|
||||
tracker-settings-latest-version = 最新版本
|
||||
tracker-settings-build-date = 生成日期
|
||||
|
||||
## Tracker part card info
|
||||
|
||||
@@ -526,7 +536,7 @@ settings-general-steamvr-trackers-right_elbow = 右手肘
|
||||
settings-general-steamvr-trackers-left_hand = 左手
|
||||
settings-general-steamvr-trackers-right_hand = 右手
|
||||
settings-general-steamvr-trackers-tracker_toggling = 自动开关追踪器
|
||||
settings-general-steamvr-trackers-tracker_toggling-description = 根据当前已分配的追踪器,自动选择可用的SteamVR虚拟追踪器
|
||||
settings-general-steamvr-trackers-tracker_toggling-description = 根据当前已分配的追踪器,自动选择可用的 SteamVR 虚拟追踪器
|
||||
settings-general-steamvr-trackers-tracker_toggling-label = 自动开关追踪器
|
||||
settings-general-steamvr-trackers-hands-warning =
|
||||
<b>警告:</b>开启手部虚拟追踪器将覆盖手柄的追踪信息。
|
||||
@@ -583,6 +593,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description =
|
||||
在所有有固件支持的追踪器上启用磁力计,在磁场稳定的环境中可以减轻飘移。
|
||||
可以在个别追踪器上禁用本功能。<b>切换此选项时请勿关闭任何一个追踪器的电源!</b>
|
||||
settings-general-tracker_mechanics-use_mag_on_all_trackers-label = 在追踪器上启用磁力计
|
||||
settings-general-tracker_mechanics-trackers_over_usb = 通过USB连接的追踪器
|
||||
settings-general-tracker_mechanics-trackers_over_usb-description = 通过USB接收HID追踪器数据。清确保连接的追踪器启用了 <b>通过HID连接</b> 功能!
|
||||
settings-general-tracker_mechanics-trackers_over_usb-enabled-label = 允许HID追踪器通过USB直接连接
|
||||
settings-stay_aligned = 持续校准
|
||||
settings-stay_aligned-description = 持续校准会逐渐将追踪器对齐到设置的放松姿势,减少追踪器漂移的影响
|
||||
settings-stay_aligned-setup-label = 配置持续校准
|
||||
@@ -753,9 +766,9 @@ settings-interface-behavior-error_tracking-description_v2 =
|
||||
|
||||
为了提供最佳用户体验,我们会收集匿名错误报告、性能指标和操作系统信息。这有助于我们检测 SlimeVR 的错误和问题。这些指标将通过 Sentry.io 收集。
|
||||
settings-interface-behavior-error_tracking-label = 向开发人员发送错误信息
|
||||
settings-interface-behavior-bvh_directory = BVH记录保存目录
|
||||
settings-interface-behavior-bvh_directory-description = 选择保存BVH记录文件的目录
|
||||
settings-interface-behavior-bvh_directory-label = BVH记录保存目录
|
||||
settings-interface-behavior-bvh_directory = BVH 记录保存目录
|
||||
settings-interface-behavior-bvh_directory-description = 选择保存 BVH 记录文件的目录
|
||||
settings-interface-behavior-bvh_directory-label = BVH 记录保存目录
|
||||
|
||||
## Serial settings
|
||||
|
||||
@@ -1371,6 +1384,7 @@ onboarding-stay_aligned-previous_step = 上一步
|
||||
onboarding-stay_aligned-next_step = 下一步
|
||||
onboarding-stay_aligned-restart = 重新开始
|
||||
onboarding-stay_aligned-done = 完成
|
||||
onboarding-stay_aligned-manual_mounting-done = 完成
|
||||
|
||||
## Home
|
||||
|
||||
@@ -1421,6 +1435,9 @@ firmware_tool-select_source-firmware = 固件来源
|
||||
firmware_tool-select_source-version = 固件版本
|
||||
firmware_tool-select_source-official = 官方
|
||||
firmware_tool-select_source-dev = 开发版
|
||||
firmware_tool-select_source-not_selected = 未选择来源
|
||||
firmware_tool-select_source-no_boards = 此来源无可用的开发板
|
||||
firmware_tool-select_source-no_versions = 此来源无可用的版本
|
||||
firmware_tool-board_defaults = 配置电路板
|
||||
firmware_tool-board_defaults-description = 设置引脚与其他和硬件相关的配置
|
||||
firmware_tool-board_defaults-add = 新增
|
||||
|
||||
@@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3
|
||||
board_type-HARITORA = Haritora
|
||||
board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1
|
||||
board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev IMU 手套
|
||||
board_type-GESTURES = litten Yº by Gestures
|
||||
board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini
|
||||
board_type-GENERIC_NRF = 通用 nRF
|
||||
board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly
|
||||
board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly
|
||||
|
||||
## Proportions
|
||||
|
||||
@@ -345,6 +350,7 @@ tracker-table-column-name = 名稱
|
||||
tracker-table-column-type = 類型
|
||||
tracker-table-column-battery = 電量
|
||||
tracker-table-column-ping = Ping
|
||||
tracker-table-column-packet_loss = 封包遺失
|
||||
tracker-table-column-tps = TPS
|
||||
tracker-table-column-temperature = 溫度 ℃
|
||||
tracker-table-column-linear-acceleration = 加速度 X/Y/Z
|
||||
@@ -386,6 +392,9 @@ tracker-infos-magnetometer-status-v1 =
|
||||
[ENABLED] 已啟用
|
||||
*[NOT_SUPPORTED] 不支援
|
||||
}
|
||||
tracker-infos-packet_loss = 封包遺失
|
||||
tracker-infos-packets_lost = 已遺失封包
|
||||
tracker-infos-packets_received = 已接收封包
|
||||
|
||||
## Tracker settings
|
||||
|
||||
@@ -425,6 +434,7 @@ tracker-settings-update = 立即更新
|
||||
tracker-settings-update-title = 韌體版本
|
||||
tracker-settings-current-version = 目前版本
|
||||
tracker-settings-latest-version = 最新版本
|
||||
tracker-settings-build-date = 建置日期
|
||||
|
||||
## Tracker part card info
|
||||
|
||||
@@ -589,6 +599,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description =
|
||||
在所有有韌體支援的追蹤器上使用磁力計,在磁場穩定的環境中可以減緩偏移。
|
||||
開啟此選項後,可以個別在追蹤器選項內停用磁力計。<b>切換此選項時請勿關閉任何一個追蹤器的電源!</b>
|
||||
settings-general-tracker_mechanics-use_mag_on_all_trackers-label = 在追蹤器上啟用磁力計
|
||||
settings-general-tracker_mechanics-trackers_over_usb = 透過 USB 連接的追蹤器
|
||||
settings-general-tracker_mechanics-trackers_over_usb-description = 透過 USB 接收 HID 追蹤器的資料,請確保連接的追蹤器已啟用<b>「透過 HID 連接」</b>的功能。
|
||||
settings-general-tracker_mechanics-trackers_over_usb-enabled-label = 允許 HID 追蹤器透過 USB 直接連接
|
||||
settings-stay_aligned = 持續校正
|
||||
settings-stay_aligned-description = 持續校正功能會逐漸調整追蹤器以對齊到設定的放鬆姿態,進而減少追蹤器偏移的影響。
|
||||
settings-stay_aligned-setup-label = 設定持續校正
|
||||
@@ -947,6 +960,13 @@ onboarding-setup_warning-cancel = 繼續設定
|
||||
## Wi-Fi setup
|
||||
|
||||
onboarding-wifi_creds-back = 返回簡介
|
||||
onboarding-wifi_creds-v2 = 透過 Wi-Fi 連接
|
||||
# This cares about multilines
|
||||
onboarding-wifi_creds-description-v2 =
|
||||
大多數的追蹤器(例如官方的 SlimeVR 追蹤器)使用 Wi-Fi 連接伺服器程式。
|
||||
請輸入目前設備連接的網路的 Wi-Fi 憑證。
|
||||
|
||||
請確保輸入的是 2.4 GHz 頻道的 Wi-Fi 憑證。
|
||||
onboarding-wifi_creds-skip = 跳過 Wi-Fi 設定
|
||||
onboarding-wifi_creds-submit = 送出!
|
||||
onboarding-wifi_creds-ssid =
|
||||
@@ -956,6 +976,10 @@ onboarding-wifi_creds-ssid-required = 必須填寫 Wi-Fi 名稱
|
||||
onboarding-wifi_creds-password =
|
||||
.label = 密碼
|
||||
.placeholder = 輸入密碼
|
||||
onboarding-wifi_creds-dongle-title = 透過接收器連接
|
||||
onboarding-wifi_creds-dongle-description = 如果你的追蹤器有接收器,將其插入你的裝置即可開始使用。
|
||||
onboarding-wifi_creds-dongle-wip = 本部分目前仍在開發階段,將來會推出管理接收器連接追蹤器的專屬頁面。
|
||||
onboarding-wifi_creds-dongle-continue = 使用接收器繼續
|
||||
|
||||
## Mounting setup
|
||||
|
||||
@@ -1362,6 +1386,7 @@ onboarding-stay_aligned-previous_step = 上一步
|
||||
onboarding-stay_aligned-next_step = 下一步
|
||||
onboarding-stay_aligned-restart = 重新開始
|
||||
onboarding-stay_aligned-done = 完成
|
||||
onboarding-stay_aligned-manual_mounting-done = 完成
|
||||
|
||||
## Home
|
||||
|
||||
@@ -1412,6 +1437,9 @@ firmware_tool-select_source-firmware = 韌體來源
|
||||
firmware_tool-select_source-version = 韌體版本
|
||||
firmware_tool-select_source-official = 正式版
|
||||
firmware_tool-select_source-dev = 開發版
|
||||
firmware_tool-select_source-not_selected = 未選擇來源
|
||||
firmware_tool-select_source-no_boards = 此來源沒有可用的開發板
|
||||
firmware_tool-select_source-no_versions = 此來源沒有可用的版本
|
||||
firmware_tool-board_defaults = 設定電路板
|
||||
firmware_tool-board_defaults-description = 設定與硬體相關的腳位或配置
|
||||
firmware_tool-board_defaults-add = 新增
|
||||
|
||||
@@ -81,7 +81,7 @@ export function TopBar({
|
||||
}
|
||||
|
||||
if (config?.useTray && !dontTray) {
|
||||
electron.api.minimize();
|
||||
electron.api.hide();
|
||||
} else if (
|
||||
config?.connectedTrackersWarning &&
|
||||
connectedIMUTrackers.filter(
|
||||
|
||||
316
server/README.md
316
server/README.md
@@ -1,316 +0,0 @@
|
||||
# SlimeVR Server — Design Guidelines
|
||||
|
||||
This document explains the architectural choices made in the server rewrite and how to extend the system correctly.
|
||||
|
||||
---
|
||||
|
||||
## Core Principle: Reducers and State
|
||||
|
||||
Every major part of this server — a tracker, a device, a UDP connection, a SolarXR session — manages state the same way: immutable data, typed actions, and pure reducer functions that transform one into the other.
|
||||
|
||||
This is not accidental. It gives us:
|
||||
- **Predictability**: state only changes through known, enumerated actions
|
||||
- **Observability**: any code can `collect` the `StateFlow` and react to changes
|
||||
- **Concurrency safety**: `StateFlow.update` is atomic; two concurrent dispatches never corrupt state
|
||||
|
||||
---
|
||||
|
||||
## The Context System
|
||||
|
||||
The `Context<S, A>` type (`context/context.kt`) is the building block of every module:
|
||||
|
||||
```kotlin
|
||||
class Context<S, in A>(
|
||||
val state: StateFlow<S>, // current state, readable by anyone
|
||||
val scope: CoroutineScope, // lifetime of this module
|
||||
) {
|
||||
fun dispatch(action: A)
|
||||
fun dispatchAll(actions: List<A>)
|
||||
}
|
||||
```
|
||||
|
||||
`Context.create` wires everything together:
|
||||
1. Takes an `initialState` and a list of **behaviours**
|
||||
2. On each `dispatch`, folds all behaviours' `reduce` over the current state
|
||||
3. Publishes the new state on the `StateFlow`
|
||||
|
||||
**Never mutate state directly.** Always go through `dispatch`.
|
||||
|
||||
---
|
||||
|
||||
## Behaviours: Splitting Concerns
|
||||
|
||||
A `Behaviour` is an interface with two methods, both with no-op defaults:
|
||||
|
||||
```kotlin
|
||||
interface Behaviour<S, A, C> {
|
||||
fun reduce(state: S, action: A): S = state
|
||||
fun observe(receiver: C) {}
|
||||
}
|
||||
```
|
||||
|
||||
- **`reduce`**: Pure function. Handles the actions it cares about, returns the rest unchanged. Override only if the behaviour needs to modify state.
|
||||
- **`observe`**: Called once at construction. Launches coroutines, registers event listeners, subscribes to other state flows. Override only if the behaviour has side effects.
|
||||
|
||||
The type parameter `C` is what the observer receives. For modules where the behaviour only needs the context, `C = Context<S, A>`. For modules where behaviours need access to the full service object (its `send` method, dispatchers, etc.), `C` is the service class itself:
|
||||
|
||||
```kotlin
|
||||
// Observer receives only the context
|
||||
typealias DeviceBehaviour = Behaviour<DeviceState, DeviceActions, DeviceContext>
|
||||
|
||||
// Observer receives the full connection object
|
||||
typealias UDPConnectionBehaviour = Behaviour<UDPConnectionState, UDPConnectionActions, UDPConnection>
|
||||
```
|
||||
|
||||
Every module follows the same construction pattern:
|
||||
|
||||
```kotlin
|
||||
val behaviours = listOf(BehaviourA, BehaviourB, BehaviourC)
|
||||
|
||||
val context = Context.create(
|
||||
initialState = ...,
|
||||
scope = scope,
|
||||
behaviours = behaviours,
|
||||
)
|
||||
|
||||
val module = MyModule(context, ...)
|
||||
behaviours.forEach { it.observe(module) } // or it.observe(context) for basic modules
|
||||
```
|
||||
|
||||
This is where observers are started. Order matters for reducers (applied top-to-bottom), but rarely matters for observers.
|
||||
|
||||
---
|
||||
|
||||
## Behaviour File Layout
|
||||
|
||||
Behaviours live in their own `behaviours.kt` file, separate from the module they belong to, within the same package:
|
||||
|
||||
```
|
||||
udp/
|
||||
├── behaviours.kt ← PacketBehaviour, PingBehaviour, HandshakeBehaviour, …
|
||||
├── connection.kt ← UDPConnection class, state, actions, typealias
|
||||
└── packets.kt ← packet type definitions
|
||||
```
|
||||
|
||||
Group behaviours that share the same receiver type in a single file. Behaviours with dependencies on external services (e.g. `SerialBehaviour`, `FirmwareBehaviour`) are standalone classes — one per file is fine when they have distinct concerns.
|
||||
|
||||
---
|
||||
|
||||
## Stateless vs. Stateful Behaviours
|
||||
|
||||
**Stateless behaviours** (no dependencies at construction time) are `object`s:
|
||||
|
||||
```kotlin
|
||||
object PacketBehaviour : UDPConnectionBehaviour {
|
||||
override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) {
|
||||
is UDPConnectionActions.LastPacket -> state.copy(...)
|
||||
else -> state
|
||||
}
|
||||
override fun observe(receiver: UDPConnection) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Behaviours with dependencies** are classes, constructed at the call site:
|
||||
|
||||
```kotlin
|
||||
class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour {
|
||||
override fun observe(receiver: SolarXRConnection) { ... }
|
||||
}
|
||||
|
||||
// At the call site:
|
||||
listOf(
|
||||
DataFeedInitBehaviour,
|
||||
FirmwareBehaviour(firmwareManager),
|
||||
SerialBehaviour(serialServer),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are `sealed interface`s with `data class` variants. This means:
|
||||
|
||||
- The compiler enforces exhaustive `when` expressions in reducers
|
||||
- No stringly-typed dispatch
|
||||
- Refactors are caught at compile time
|
||||
|
||||
Use `data class Update(val transform: State.() -> State)` when you need a flexible "update anything" action (see `TrackerActions`, `DeviceActions`). Use specific named actions when the action has semantic meaning that other behaviours need to pattern-match on (see `UDPConnectionActions.Handshake`).
|
||||
|
||||
---
|
||||
|
||||
## The PacketDispatcher Pattern
|
||||
|
||||
`PacketDispatcher<T>` routes incoming messages to typed listeners without a giant `when` block:
|
||||
|
||||
```kotlin
|
||||
dispatcher.on<SensorInfo> { packet -> /* only called for SensorInfo */ }
|
||||
dispatcher.onAny { packet -> /* called for everything */ }
|
||||
dispatcher.emit(packet) // routes to correct listeners
|
||||
```
|
||||
|
||||
Use this wherever you have a stream of heterogeneous messages (UDP packets, SolarXR messages). Each behaviour registers its own listener in its `observe` — the dispatcher is passed as part of the module struct.
|
||||
|
||||
---
|
||||
|
||||
## Coroutines and Lifetime
|
||||
|
||||
- Every module is given a `CoroutineScope` at creation. Cancelling that scope tears down all coroutines the module launched.
|
||||
- Observers should use `receiver.context.scope.launch { ... }` so their work is scoped to the module.
|
||||
- Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher.
|
||||
- **Avoid `runBlocking`** inside observers or handlers — it blocks the coroutine thread. The one acceptable use is synchronous listener registration before a scope is started.
|
||||
|
||||
---
|
||||
|
||||
## State vs. Out-of-Band Data
|
||||
|
||||
Not everything belongs in `StateFlow`. Two good examples:
|
||||
|
||||
- `VRServer.handleCounter` is an `AtomicInteger` — not in state — because nothing needs to react to it changing, and `incrementAndGet()` is faster and simpler than a dispatch round-trip.
|
||||
- `UDPTrackerServer` has no `Context` at all. Its connection map is a plain `MutableMap` internal to the server loop. Nothing outside the loop reads it, so there is no reason to wrap it in a state machine.
|
||||
|
||||
Rule of thumb: put data in state if **any other code needs to react to it changing**. If it's purely an implementation detail owned by one place, keep it plain.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Module
|
||||
|
||||
To add a new major section of the server (say, a HID device connection):
|
||||
|
||||
1. **Define the state**:
|
||||
```kotlin
|
||||
data class HIDConnectionState(
|
||||
val deviceId: Int?,
|
||||
val connected: Boolean,
|
||||
)
|
||||
```
|
||||
|
||||
2. **Define sealed actions**:
|
||||
```kotlin
|
||||
sealed interface HIDConnectionActions {
|
||||
data class Connected(val deviceId: Int) : HIDConnectionActions
|
||||
data object Disconnected : HIDConnectionActions
|
||||
}
|
||||
```
|
||||
|
||||
3. **Create type aliases** (keeps signatures readable):
|
||||
```kotlin
|
||||
typealias HIDConnectionContext = Context<HIDConnectionState, HIDConnectionActions>
|
||||
typealias HIDConnectionBehaviour = Behaviour<HIDConnectionState, HIDConnectionActions, HIDConnection>
|
||||
```
|
||||
|
||||
4. **Define the module class** (holds context + extra runtime state):
|
||||
```kotlin
|
||||
class HIDConnection(
|
||||
val context: HIDConnectionContext,
|
||||
val serverContext: VRServer,
|
||||
private val onSend: suspend (ByteArray) -> Unit,
|
||||
) {
|
||||
suspend fun send(bytes: ByteArray) = onSend(bytes)
|
||||
}
|
||||
```
|
||||
|
||||
5. **Write behaviours** in a separate `behaviours.kt` file:
|
||||
```kotlin
|
||||
object HIDHandshakeBehaviour : HIDConnectionBehaviour {
|
||||
override fun reduce(state: HIDConnectionState, action: HIDConnectionActions) = when (action) {
|
||||
is HIDConnectionActions.Connected -> state.copy(deviceId = action.deviceId, connected = true)
|
||||
is HIDConnectionActions.Disconnected -> state.copy(connected = false)
|
||||
}
|
||||
override fun observe(receiver: HIDConnection) {
|
||||
// launch coroutines, subscribe to events, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. **Write a `companion object { fun create() }`**:
|
||||
```kotlin
|
||||
companion object {
|
||||
fun create(serverContext: VRServer, scope: CoroutineScope, send: suspend (ByteArray) -> Unit): HIDConnection {
|
||||
val behaviours = listOf(HIDHandshakeBehaviour, ...)
|
||||
val context = Context.create(initialState = ..., scope = scope, behaviours = behaviours)
|
||||
val conn = HIDConnection(context, serverContext, send)
|
||||
behaviours.forEach { it.observe(conn) }
|
||||
return conn
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Behaviour to an Existing Module
|
||||
|
||||
Find the `create` function, add your behaviour to the `behaviours` list. That's it. The behaviour's `reduce` and `observe` are automatically picked up.
|
||||
|
||||
Example: adding battery tracking to a HID connection requires only adding a `HIDBatteryBehaviour` to the list — nothing else changes.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New UDP Packet Type
|
||||
|
||||
1. Add the packet class and its `read` function in `udp/packets.kt`
|
||||
2. In a behaviour's `observe`, register a listener:
|
||||
```kotlin
|
||||
receiver.packetEvents.on<MyNewPacket> { event ->
|
||||
// handle it
|
||||
}
|
||||
```
|
||||
3. In `udp/server.kt`, route the new packet type to `emit`.
|
||||
|
||||
---
|
||||
|
||||
## IPC
|
||||
|
||||
There are three IPC sockets, each serving a distinct client:
|
||||
|
||||
| Socket | Client | Payload encoding |
|
||||
|---|---|---|
|
||||
| `SlimeVRDriver` | OpenVR driver | Protobuf (Wire) |
|
||||
| `SlimeVRInput` | External feeder | Protobuf (Wire) |
|
||||
| `SlimeVRRpc` | SolarXR RPC | FlatBuffers (solarxr-protocol) |
|
||||
|
||||
### Wire framing
|
||||
|
||||
All three sockets share the same framing: a **LE u32 length** prefix (which includes the 4-byte header itself) followed by the raw payload bytes.
|
||||
|
||||
### Transport / protocol split
|
||||
|
||||
Platform files (`linux.kt`, `windows.kt`) own the transport layer — accepting connections, reading frames, and producing a `Flow<ByteArray>` + a `send` function. The protocol handlers in `protocol.kt` are plain `suspend fun`s that consume those two abstractions and know nothing about Unix sockets or named pipes.
|
||||
|
||||
This means the same handler runs on Linux (Unix domain sockets) and Windows (named pipes) without any changes.
|
||||
|
||||
### Connection lifetime
|
||||
|
||||
Each client runs in its own `launch` block. When the socket disconnects, the coroutine scope is cancelled and everything inside cleans up automatically.
|
||||
|
||||
### What each handler does
|
||||
|
||||
- **Driver** (`handleDriverConnection`): on connect, sends the protocol version and streams `TrackerAdded` + `Position` messages for every non-driver tracker. Receives user actions from the driver (resets, etc.).
|
||||
- **Feeder** (`handleFeederConnection`): receives `TrackerAdded` messages to create new devices and trackers, then `Position` updates to drive their rotation.
|
||||
- **SolarXR** (`handleSolarXRConnection`): creates a `SolarXRConnection` and forwards all incoming FlatBuffers messages to it.
|
||||
|
||||
---
|
||||
|
||||
## What Goes Where
|
||||
|
||||
| Location | Purpose |
|
||||
|---|---|
|
||||
| `server/core` | Protocol-agnostic business logic (trackers, devices, config, SolarXR) |
|
||||
| `server/desktop` | Platform-specific entry point, IPC socket wiring, platform abstractions |
|
||||
| `context/context.kt` | The `Context` / `Behaviour` primitives — do not add domain logic here |
|
||||
| `udp/` | Everything specific to the SlimeVR UDP wire protocol |
|
||||
| `solarxr/` | SolarXR WebSocket server + FlatBuffers message handling |
|
||||
| `config/` | JSON config read/write with autosave; no business logic |
|
||||
| `firmware/` | OTA update and serial flash logic; interacts with devices over the network, independent of the UDP tracker protocol |
|
||||
|
||||
---
|
||||
|
||||
## Style Conventions
|
||||
|
||||
- **Limit OOP to strictly necessary cases.** Prefer plain functions, function types, and data classes. Avoid classes and inheritance unless there is a genuine need for encapsulated mutable state or polymorphism. A single-method interface should almost always be a function type instead (`() -> Unit`, `suspend (String) -> Unit`, etc.). When in doubt, write a function.
|
||||
- **Prefer plain functions over extension functions.** Only use extension functions when the receiver type is genuinely the primary subject and the function would be confusing without it.
|
||||
- Behaviours are `object`s (no dependencies) or `class`es (with dependencies), defined in a dedicated `behaviours.kt` file in the same package as the module they belong to.
|
||||
- Module creation lives in `companion object { fun create(...) }`.
|
||||
- State data classes use `copy(...)` inside reducers and `Update { copy(...) }` actions — never expose a `MutableStateFlow` directly.
|
||||
- **Never use `var` in a state data class** — state must be immutable, all fields `val`. Using `var` in any data class is almost certainly a design mistake; if you need mutable fields, prefer a plain class or rethink the structure.
|
||||
- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead.
|
||||
@@ -22,12 +22,12 @@ plugins {
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
}
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ val deleteTempKeyStore = tasks.register<Delete>("deleteTempKeyStore") {
|
||||
|
||||
tasks.withType<KotlinCompile> {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_22)
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
freeCompilerArgs.set(listOf("-Xvalue-classes"))
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ android {
|
||||
|
||||
// adds an offset of the version code as we might do apk releases in the middle of actual
|
||||
// releases if we failed on bundling or stuff
|
||||
val versionCodeOffset = 4
|
||||
val versionCodeOffset = 5
|
||||
// Defines the version number of your app.
|
||||
versionCode = (extra["gitVersionCode"] as? Int)?.plus(versionCodeOffset) ?: 0
|
||||
|
||||
@@ -217,7 +217,7 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_24
|
||||
targetCompatibility = JavaVersion.VERSION_24
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ configure<com.diffplug.gradle.spotless.SpotlessExtension> {
|
||||
",dev.slimevr.tracking.trackers.*,dev.slimevr.desktop.platform.ProtobufMessages.*" +
|
||||
",solarxr_protocol.rpc.*,kotlinx.coroutines.*,com.illposed.osc.*,android.app.*",
|
||||
"ij_kotlin_allow_trailing_comma" to true,
|
||||
"ktlint_standard_filename" to "disabled",
|
||||
)
|
||||
val ktlintVersion = "1.8.0"
|
||||
kotlinGradle {
|
||||
|
||||
@@ -14,19 +14,20 @@ plugins {
|
||||
`java-library`
|
||||
}
|
||||
|
||||
// FIXME: Please replace these to Java 11 as that's what they actually are
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
}
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
}
|
||||
tasks.withType<KotlinCompile> {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_24)
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
freeCompilerArgs.set(listOf("-Xvalue-classes"))
|
||||
}
|
||||
}
|
||||
@@ -59,23 +60,24 @@ 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.illposed.osc:javaosc-core:0.9")
|
||||
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.8")
|
||||
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")
|
||||
|
||||
@@ -90,7 +92,6 @@ 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 {
|
||||
|
||||
96
server/core/src/main/java/dev/slimevr/Keybinding.kt
Normal file
96
server/core/src/main/java/dev/slimevr/Keybinding.kt
Normal file
@@ -0,0 +1,96 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.slimevr;
|
||||
|
||||
public enum NetworkProtocol {
|
||||
OWO_LEGACY,
|
||||
SLIMEVR_RAW,
|
||||
SLIMEVR_FLATBUFFER,
|
||||
SLIMEVR_WEBSOCKET
|
||||
}
|
||||
487
server/core/src/main/java/dev/slimevr/VRServer.kt
Normal file
487
server/core/src/main/java/dev/slimevr/VRServer.kt
Normal file
@@ -0,0 +1,487 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
698
server/core/src/main/java/dev/slimevr/autobone/AutoBone.kt
Normal file
698
server/core/src/main/java/dev/slimevr/autobone/AutoBone.kt
Normal file
@@ -0,0 +1,698 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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>)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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 }
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
58
server/core/src/main/java/dev/slimevr/bridge/Bridge.kt
Normal file
58
server/core/src/main/java/dev/slimevr/bridge/Bridge.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package dev.slimevr.bridge;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
|
||||
@Retention(value = RetentionPolicy.SOURCE)
|
||||
public @interface BridgeThread {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
176
server/core/src/main/java/dev/slimevr/config/ConfigManager.java
Normal file
176
server/core/src/main/java/dev/slimevr/config/ConfigManager.java
Normal file
@@ -0,0 +1,176 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
|
||||
class HIDConfig {
|
||||
var trackersOverHID = false
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
class LegTweaksConfig {
|
||||
var correctionStrength = 0.3f
|
||||
var alwaysUseFloorclip = false
|
||||
}
|
||||
16
server/core/src/main/java/dev/slimevr/config/OSCConfig.kt
Normal file
16
server/core/src/main/java/dev/slimevr/config/OSCConfig.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
78
server/core/src/main/java/dev/slimevr/config/ResetsConfig.kt
Normal file
78
server/core/src/main/java/dev/slimevr/config/ResetsConfig.kt
Normal file
@@ -0,0 +1,78 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
66
server/core/src/main/java/dev/slimevr/config/ServerConfig.kt
Normal file
66
server/core/src/main/java/dev/slimevr/config/ServerConfig.kt
Normal file
@@ -0,0 +1,66 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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)
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
class TrackingChecklistConfig {
|
||||
val ignoredStepsIds: MutableList<Int> = mutableListOf()
|
||||
}
|
||||
13
server/core/src/main/java/dev/slimevr/config/VMCConfig.kt
Normal file
13
server/core/src/main/java/dev/slimevr/config/VMCConfig.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
class VRCConfig {
|
||||
// List of fields ignored in vrc warnings - @see VRCConfigValidity
|
||||
val mutedWarnings: MutableList<String> = mutableListOf()
|
||||
}
|
||||
24
server/core/src/main/java/dev/slimevr/config/VRCOSCConfig.kt
Normal file
24
server/core/src/main/java/dev/slimevr/config/VRCOSCConfig.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
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`
|
||||
}
|
||||
}
|
||||
145
server/core/src/main/java/dev/slimevr/config/VRConfig.kt
Normal file
145
server/core/src/main/java/dev/slimevr/config/VRConfig.kt
Normal file
@@ -0,0 +1,145 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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())]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
interface FirmwareUpdateListener {
|
||||
fun onUpdateStatusChange(event: UpdateStatusEvent<*>)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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 }
|
||||
@@ -0,0 +1,29 @@
|
||||
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 }
|
||||
205
server/core/src/main/java/dev/slimevr/firmware/OTAUpdateTask.kt
Normal file
205
server/core/src/main/java/dev/slimevr/firmware/OTAUpdateTask.kt
Normal file
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import dev.llelievr.espflashkotlin.FlasherSerialInterface
|
||||
|
||||
interface SerialFlashingHandler : FlasherSerialInterface
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user