Compare commits

..

101 Commits

Author SHA1 Message Date
lucas lelievre
181c6599b7 Increment versionCodeOffset from 4 to 5 2026-03-31 21:10:00 +02:00
gorbit99
fb77d3cf8a Downgrade JavaOSC version (#1801) 2026-03-31 21:02:01 +02:00
lucas lelievre
1a4b19a5e1 Make sure tags are fetched for version number checks 2026-03-30 05:42:47 +02:00
lucas lelievre
a9f553729e Electron fixes macos (#1797) 2026-03-26 08:09:10 +01:00
lucas lelievre
ed96742680 Electron fixes after RC feedbacks (#1784) 2026-03-26 05:15:41 +01:00
lucas lelievre
5e7816d72d New Pontoon translations (#1674) 2026-03-17 11:06:50 +01:00
SlimeVR-bot
abab38e422 Pontoon/GUI: Update Czech (cs)
Co-authored-by: Cheezik <cheatistv@email.cz> (cs)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
7835b17379 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
30612a866b Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
bba574ce86 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
6d3d725b6c Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
948bc06542 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
8f4ee3268d Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
8f97bd997b Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
18f6c9c24f Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
c7bdd041f2 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
f75a011fb3 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
Co-authored-by: R4MOS <alexandrerrangel99@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
555764914d Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
e4be98c7e7 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
701ce9dc0a Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
3f950cc11d Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
d8ce34a962 Pontoon/GUI: Update Czech (cs)
Co-authored-by: Cheezik <cheatistv@email.cz> (cs)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
ef9d5e7862 Pontoon/GUI: Update Czech (cs)
Co-authored-by: Cheezik <cheatistv@email.cz> (cs)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
e3f06eff55 Pontoon/GUI: Update Czech (cs)
Co-authored-by: Cheezik <cheatistv@email.cz> (cs)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
7a062b7d7b Pontoon/GUI: Update Czech (cs)
Co-authored-by: Cheezik <cheatistv@email.cz> (cs)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
121f3297ae Pontoon/GUI: Update Czech (cs)
Co-authored-by: Cheezik <cheatistv@email.cz> (cs)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
9951f00979 Pontoon/GUI: Update Czech (cs)
Co-authored-by: Cheezik <cheatistv@email.cz> (cs)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
d8bb744ce4 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
4db342b4ae Pontoon/GUI: Update Czech (cs)
Co-authored-by: Cheezik <cheatistv@email.cz> (cs)
Co-authored-by: Pesky12 <avianearts@gmail.com> (cs)
Co-authored-by: Jeeko <d.safarik06@gmail.com> (cs)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
e9f96e6d21 Pontoon/GUI: Update Czech (cs)
Co-authored-by: Cheezik <cheatistv@email.cz> (cs)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
56c6ebdadf Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
4f941a5892 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
bf69046efe Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
cdcdb1b443 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
f875e9df4d Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
12dd408f0b Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
5e53bae9dc Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
b19e190004 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
06c8bdc81a Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
62338250e8 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
1ff79ebb13 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
2f9678d882 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
94d70cbe55 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
f5c26f97aa Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:36 +00:00
SlimeVR-bot
b367c7d3d6 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
c9cae35946 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
Co-authored-by: R4MOS <alexandrerrangel99@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
a895b0b583 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: R4MOS <alexandrerrangel99@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
5321c25bb2 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
b39691b879 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
74e7f02668 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
09e1510298 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
0d95a731e3 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
7d5706520b Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
e887b3153d Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
a7aa897fad Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
29e2fd863b Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
330cac26ec Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
ca9195ba97 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
908270ffff Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
942fbcf6f6 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
dbc6bae898 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
8482802375 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
fdd3614204 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
706a2780d9 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
97a90076a4 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
ef85b8f3e1 Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
83700a1d1a Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
36a363f8ed Pontoon/GUI: Update Brazilian Portuguese (pt-BR)
Co-authored-by: inex <rui.bisneto@gmail.com> (pt-BR)
Co-authored-by: R4MOS <alexandrerrangel99@gmail.com> (pt-BR)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
1c8381337a Pontoon/GUI: Update French (fr)
Co-authored-by: Erimel <loukalemire@gmail.com> (fr)
Co-authored-by: Nicalay2 <Habbo.rieranicolas08@gmail.com> (fr)
Co-authored-by: Natsuko <maisonasse@gmail.com> (fr)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
f698b27be5 Pontoon/GUI: Update French (fr)
Co-authored-by: Erimel <loukalemire@gmail.com> (fr)
Co-authored-by: Nicalay2 <Habbo.rieranicolas08@gmail.com> (fr)
Co-authored-by: Natsuko <maisonasse@gmail.com> (fr)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
f2767cf3bc Pontoon/GUI: Update French (fr)
Co-authored-by: Natsuko <maisonasse@gmail.com> (fr)
Co-authored-by: Erimel <loukalemire@gmail.com> (fr)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
fe873729b6 Pontoon/GUI: Update French (fr)
Co-authored-by: Nicalay2 <Habbo.rieranicolas08@gmail.com> (fr)
Co-authored-by: Natsuko <maisonasse@gmail.com> (fr)
Co-authored-by: Erimel <loukalemire@gmail.com> (fr)
Co-authored-by: Naoto <naotomuramasa@gmail.com> (fr)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
031b35eb06 Pontoon/GUI: Update French (fr)
Co-authored-by: Erimel <loukalemire@gmail.com> (fr)
Co-authored-by: Nicalay2 <Habbo.rieranicolas08@gmail.com> (fr)
Co-authored-by: Natsuko <maisonasse@gmail.com> (fr)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
bcc604cf63 Pontoon/GUI: Update Traditional Chinese (zh-Hant)
Co-authored-by: Meow Wei <medicalwei@gmail.com> (zh-Hant)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
adf01eed16 Pontoon/GUI: Update Traditional Chinese (zh-Hant)
Co-authored-by: Meow Wei <medicalwei@gmail.com> (zh-Hant)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
bd1750f252 Pontoon/GUI: Update Traditional Chinese (zh-Hant)
Co-authored-by: Meow Wei <medicalwei@gmail.com> (zh-Hant)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
49adf0ee84 Pontoon/GUI: Update German (de)
Co-authored-by: TheDevMinerTV <devminer@devminer.xyz> (de)
Co-authored-by: NichtTube <Marvin-semmler@gmx.net> (de)
Co-authored-by: unlogisch <unlogisch@gmx.ch> (de)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
04a2fa72a7 Pontoon/GUI: Update German (de)
Co-authored-by: TheDevMinerTV <devminer@devminer.xyz> (de)
Co-authored-by: NichtTube <Marvin-semmler@gmx.net> (de)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
064c41d419 Pontoon/GUI: Update German (de)
Co-authored-by: NichtTube <Marvin-semmler@gmx.net> (de)
Co-authored-by: TheDevMinerTV <devminer@devminer.xyz> (de)
Co-authored-by: unlogisch <unlogisch@gmx.ch> (de)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
7c92023af7 Pontoon/GUI: Update Simplified Chinese (zh-Hans)
Co-authored-by: nekomona <nekomona@163.com> (zh-Hans)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
ac01b75342 Pontoon/GUI: Update Thai (th)
Co-authored-by: YumeTomo <Sodnoobe@gmail.com> (th)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
95a7801a50 Pontoon/GUI: Update Dutch (nl)
Co-authored-by: Vyolex <25586367+Vyolex@users.noreply.github.com> (nl)
Co-authored-by: Nola <stefsecretdesecret@gmail.com> (nl)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
db1ec5d024 Pontoon/GUI: Update Dutch (nl)
Co-authored-by: Vyolex <25586367+Vyolex@users.noreply.github.com> (nl)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
a96cd8a38f Pontoon/GUI: Update Dutch (nl)
Co-authored-by: Nola <stefsecretdesecret@gmail.com> (nl)
Co-authored-by: Vyolex <25586367+Vyolex@users.noreply.github.com> (nl)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
45d5789685 Pontoon/GUI: Update Dutch (nl)
Co-authored-by: Nola <stefsecretdesecret@gmail.com> (nl)
Co-authored-by: Vyolex <25586367+Vyolex@users.noreply.github.com> (nl)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
4b08123a61 Pontoon/GUI: Update Latinamerican Spanish (es-419)
Co-authored-by: Uriel <imurx@proton.me> (es-419)
Co-authored-by: MaxEvil <xicovalle@gmail.com> (es-419)
Co-authored-by: Alejandro <moctezumaalejandro25@gmail.com> (es-419)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
bfc99ab02c Pontoon/GUI: Update Latinamerican Spanish (es-419)
Co-authored-by: MaxEvil <xicovalle@gmail.com> (es-419)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
6eb8a18430 Pontoon/GUI: Update Latinamerican Spanish (es-419)
Co-authored-by: MaxEvil <xicovalle@gmail.com> (es-419)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
489b8e6549 Pontoon/GUI: Update Latinamerican Spanish (es-419)
Co-authored-by: MaxEvil <xicovalle@gmail.com> (es-419)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
7bb2ecfff1 Pontoon/GUI: Update Latinamerican Spanish (es-419)
Co-authored-by: MaxEvil <xicovalle@gmail.com> (es-419)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
59b4b34840 Pontoon/GUI: Update Latinamerican Spanish (es-419)
Co-authored-by: MaxEvil <xicovalle@gmail.com> (es-419)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
9597888902 Pontoon/GUI: Update Latinamerican Spanish (es-419)
Co-authored-by: MaxEvil <xicovalle@gmail.com> (es-419)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
dbfcc8ba0a Pontoon/GUI: Update Latinamerican Spanish (es-419)
Co-authored-by: MaxEvil <xicovalle@gmail.com> (es-419)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
fd8e9fba83 Pontoon/GUI: Update Latinamerican Spanish (es-419)
Co-authored-by: MaxEvil <xicovalle@gmail.com> (es-419)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
f65d1828fe Pontoon/GUI: Update Spain Spanish (es-ES)
Co-authored-by: Baddie <baddie@femboy.gay> (es-ES)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
c32601809b Pontoon/GUI: Update Spain Spanish (es-ES)
Co-authored-by: Baddie <baddie@femboy.gay> (es-ES)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
fdf86a1e56 Pontoon/GUI: Update Thai (th)
Co-authored-by: YumeTomo <Sodnoobe@gmail.com> (th)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
71908523f9 Pontoon/GUI: Update Thai (th)
Co-authored-by: YumeTomo <Sodnoobe@gmail.com> (th)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
2b7d678321 Pontoon/GUI: Update Simplified Chinese (zh-Hans)
Co-authored-by: 寂听 <jiting@jtcat.com> (zh-Hans)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
5d64fa8369 Pontoon/GUI: Update Traditional Chinese (zh-Hant)
Co-authored-by: Meow Wei <medicalwei@gmail.com> (zh-Hant)
2026-03-12 01:26:35 +00:00
SlimeVR-bot
fe6bb4534c Pontoon/GUI: Update Traditional Chinese (zh-Hant)
Co-authored-by: Meow Wei <medicalwei@gmail.com> (zh-Hant)
2026-03-12 01:26:35 +00:00
347 changed files with 44590 additions and 7195 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 lajuster pour la capture de mouvement, ajustez correctement la longueur des bras et modifiez-la jusquà ce que votre
Pour lajuster 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 lajuster 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 nest assigné
reset-error-no_fingers_tracker = Aucun traqueur de doigts n'est assigné
reset-error-no_feet_tracker = Aucun capteur de pieds nest 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 quils 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 dutiliser 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 lavant. 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 lorientation 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 lavant.
@@ -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é. Lutilisez-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 navez 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 nest 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 nest 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

View File

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

View File

@@ -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 = เพิ่ม

View File

@@ -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 = 新增

View File

@@ -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 = 新增

View File

@@ -81,7 +81,7 @@ export function TopBar({
}
if (config?.useTray && !dontTray) {
electron.api.minimize();
electron.api.hide();
} else if (
config?.connectedTrackersWarning &&
connectedIMUTrackers.filter(

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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