Compare commits

...

55 Commits

Author SHA1 Message Date
Uriel
a088145963 Merge branch 'main' into onboarding-usage-step 2025-01-22 18:05:32 +01:00
Erimel
181ba089c2 Attempt to fix moving average quaternions not resetting properly (#1278) 2025-01-13 23:24:22 +01:00
Yao Wei
394c1dd438 i18n: improve SlimeVR tracker flashing instruction (#1276) 2025-01-07 18:12:34 +01:00
JovannMC
2ff6e99385 Fix "trackers still on" when closing via tray (#1265)
Co-authored-by: Eiren Rain <Eirenliel@users.noreply.github.com>
Co-authored-by: lucas lelievre <loucass003@gmail.com>
2025-01-06 15:16:26 +01:00
Uriel
3614612ac2 Fix CI bugs that appeared recently (#1272) 2025-01-03 01:29:21 +03:00
Butterscotch!
dfeb4e79a4 AutoBone bone contribution fix & cleanup (#1249) 2025-01-03 01:28:31 +03:00
Erimel
a606c5a375 Fix over 180 degrees rotations in HumanSkeleton (#1277) 2025-01-02 22:24:56 +01:00
Butterscotch!
4e698b693c Don't filter non-temporal trackers (#1255) 2025-01-03 00:19:10 +03:00
Uriel
0eb81eec04 Disable update notifications for pnpm 2024-12-20 23:54:26 +01:00
Butterscotch!
35f5d132c8 Use jSerialComm 2.11.1-SNAPSHOT (#1269) 2024-12-20 17:06:37 +01:00
Uriel
02cc2496f0 Fix eslint error 2024-12-20 15:34:53 +01:00
loucass003
d84eaa203a Add more feedback 2024-12-20 00:48:23 +01:00
Uriel
e0d372e9bb fix translation files key naming 2024-12-20 00:00:32 +01:00
Uriel
b7a54f6aef fix bugs and stuff 2024-12-19 21:54:42 +01:00
lucas lelievre
9d65477af7 Automatic firmware update for official trackers (#1241)
Co-authored-by: ImUrX <urielfontan2002@gmail.com>
Co-authored-by: Uriel <imurx@proton.me>
Co-authored-by: Butterscotch! <bscotchvanilla@gmail.com>
Co-authored-by: Eiren Rain <Eirenliel@users.noreply.github.com>
2024-12-19 20:03:43 +02:00
lucas lelievre
73cdc890f2 Firmware tool (#880)
Co-authored-by: ImUrX <urielfontan2002@gmail.com>
Co-authored-by: Uriel <imurx@proton.me>
2024-12-19 19:35:54 +02:00
Uriel
75cf328aea keep improving on some pages 2024-12-17 19:08:55 +01:00
Vocaloid Fan
e8afb49685 Refactor + Check for env variables (#1261) 2024-12-12 17:05:54 -03:00
Uriel
c2fe9541dc add mocap vmc setting 2024-12-12 15:10:28 +01:00
Uriel
9c9c5524a5 Merge branch 'main' into onboarding-usage-step 2024-12-12 14:26:07 +01:00
Uriel
bc487f8655 Fix Appimage crashing for people (#1253) 2024-12-11 17:09:17 +02:00
Uriel
2708b5a15b Small improvements on packaging for Tauri (#1254) 2024-12-11 17:08:17 +02:00
Uriel
fa74a748ac Start adding strings of stuff 2024-12-10 19:25:59 +01:00
Vocaloid Fan
b52e705dc4 Fix Rust logging by disabling ANSI for file output. (#1256) 2024-12-08 12:33:54 -03:00
Eiren Rain
1a5584bcfc Fix SolarXR on main 2024-12-07 12:22:22 +01:00
Uriel
b90da87602 mocap data choose 2024-12-02 19:53:59 +01:00
Uriel
40b2da34b2 continue working in the data choose page 2024-12-02 19:53:58 +01:00
Uriel
da133c086f Merge branch 'main' into onboarding-usage-step 2024-12-02 19:45:26 +01:00
Uriel
92b7ca7bda modify how it looks 2024-11-27 14:53:37 +01:00
Erimel
8238f569c6 Flex resistance and angle support (#1242) 2024-11-27 01:11:19 +03:00
abb128
1051d0c5e5 Improve VRM model file input (#1246) 2024-11-21 21:48:17 +02:00
Collin
8fe2540f91 Potentially fix weird hip issues (#1244) 2024-11-21 21:46:53 +02:00
sctanf
6232903691 make HID Device a slime (#1235) 2024-11-21 21:46:34 +02:00
Uriel
96fa13aea9 improve buttons 2024-11-21 20:20:43 +01:00
Uriel
6a8786b241 Merge branch 'main' into onboarding-usage-step 2024-11-20 21:14:39 +01:00
Uriel
819d2fc3b8 Fix error on labeler 2024-11-20 21:14:10 +01:00
Uriel
8bdee03164 add head tracking choose page 2024-11-20 21:10:48 +01:00
Uriel
fa10e2d73a Merge branch 'main' into onboarding-usage-step 2024-11-19 19:42:01 +01:00
Uriel
1385403817 improve translations 2024-11-04 22:43:48 +01:00
Uriel
5f60e59e51 Merge branch 'main' into onboarding-usage-step 2024-11-04 17:23:34 +01:00
Uriel
c341534166 fix images again 2024-10-30 19:33:30 +01:00
Uriel
957e81c37a commit the code that uses it 2024-10-29 22:24:01 +01:00
Uriel
c0269155db add images for radio button 2024-10-29 22:22:07 +01:00
Uriel
945b8b392d use voltage inside tracker settings page for showing charging 2024-10-28 21:49:45 +01:00
Uriel
0c9a016598 Merge branch 'main' into onboarding-usage-step 2024-10-28 21:24:24 +01:00
Uriel
583e335fc7 fix status not working correctly 2024-10-28 18:29:36 +01:00
Uriel
986ef8d4b4 add standalone usage guide step 2024-10-25 23:23:10 +02:00
Uriel
97fb9dd098 fix bugs 2024-10-25 18:13:50 +02:00
Uriel
db80609379 fix progress 2024-10-24 01:01:03 +02:00
Uriel
bb5b9f3bfe add check and warning 2024-10-23 23:48:19 +02:00
Uriel
085ba25559 add image 2024-10-23 20:27:03 +02:00
Uriel
682300e707 add vrusagechoose 2024-10-23 19:50:50 +02:00
Uriel
af7f13e8bd Merge branch 'main' into onboarding-usage-step 2024-10-21 15:25:32 +02:00
Uriel
e530cb5327 Merge branch 'main' into onboarding-usage-step 2024-10-14 18:34:33 +02:00
Uriel
6b8e4c961e start adding usage step on onboarding 2024-10-08 18:12:55 +02:00
156 changed files with 11527 additions and 1301 deletions

2
.github/labeler.yml vendored
View File

@@ -13,7 +13,7 @@
"Area: GUI":
- all:
- changed-files:
- all-globs-to-any-files: ["gui/**/*", "!gui/public/i18n/**/*"]
- all-globs-to-any-file: ["gui/**/*", "!gui/public/i18n/**/*"]
"Area: Hardware Protocol":
- changed-files:
- any-glob-to-any-file: "server/core/src/main/java/dev/slimevr/tracking/trackers/udp/**"

View File

@@ -144,7 +144,7 @@ jobs:
bundle-linux:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs: [build, test]
if: contains(fromJSON('["workflow_dispatch", "create"]'), github.event_name)
steps:
@@ -160,14 +160,25 @@ jobs:
- name: Set up Linux dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: libgtk-3-dev webkit2gtk-4.1 libappindicator3-dev librsvg2-dev patchelf libfuse2
packages: |
build-essential curl wget file libssl-dev libgtk-3-dev libappindicator3-dev librsvg2-dev
# Increment to invalidate the cache
version: 1.0
version: 2.0
# Enables a workaround to attempt to run pre and post install scripts
execute_install_scripts: true
# Disables uploading logs as a build artifact
debug: false
- name: Set up specific Linux versioned dependencies
run: |
sudo apt-get update && sudo apt-get install -y \
libwebkit2gtk-4.1-0=2.44.0-2 \
libwebkit2gtk-4.1-dev=2.44.0-2 \
libjavascriptcoregtk-4.1-0=2.44.0-2 \
libjavascriptcoregtk-4.1-dev=2.44.0-2 \
gir1.2-javascriptcoregtk-4.1=2.44.0-2 \
gir1.2-webkit2-4.1=2.44.0-2;
- name: Cache cargo dependencies
uses: Swatinem/rust-cache@v2
with:
@@ -274,7 +285,7 @@ jobs:
./bundle_dmg.sh --volname SlimeVR --icon slimevr 180 170 --app-drop-link 480 170 \
--window-size 660 400 --hide-extension ../macos/SlimeVR.app \
--volicon ../macos/SlimeVR.app/Contents/Resources/icon.icns --skip-jenkins \
--eula ../../../../LICENSE-MIT slimevr.dmg ../macos/SlimeVR.app
--eula ../../../../../LICENSE-MIT slimevr.dmg ../macos/SlimeVR.app
- uses: actions/upload-artifact@v4
with:

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
update-notifier=false

189
Cargo.lock generated
View File

@@ -1837,6 +1837,24 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333"
dependencies = [
"futures-util",
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.9"
@@ -2000,6 +2018,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "0.4.8"
@@ -3124,6 +3151,55 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684"
dependencies = [
"bytes",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "quinn-proto"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6"
dependencies = [
"bytes",
"rand 0.8.5",
"ring",
"rustc-hash",
"rustls",
"slab",
"thiserror",
"tinyvec",
"tracing",
]
[[package]]
name = "quinn-udp"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5a626c6807713b15cac82a6acaccd6043c9a5408c24baae07611fec3f243da"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "quote"
version = "1.0.37"
@@ -3283,6 +3359,7 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"ipnet",
"js-sys",
@@ -3291,11 +3368,16 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower-service",
"url",
@@ -3303,6 +3385,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
"windows-registry",
]
@@ -3329,12 +3412,33 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "ring"
version = "0.17.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.15",
"libc",
"spin",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3357,6 +3461,46 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustls"
version = "0.23.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b"
[[package]]
name = "rustls-webpki"
version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "ryu"
version = "1.0.18"
@@ -3692,6 +3836,7 @@ dependencies = [
"discord-sdk",
"flexi_logger",
"glob",
"itertools",
"libloading 0.8.5",
"log",
"log-panics",
@@ -3780,6 +3925,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -3824,6 +3975,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -4397,6 +4554,17 @@ dependencies = [
"syn 2.0.79",
]
[[package]]
name = "tokio-rustls"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
dependencies = [
"rustls",
"rustls-pki-types",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.12"
@@ -4681,6 +4849,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.2"
@@ -4996,6 +5170,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.26.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.33.0"
@@ -5589,6 +5772,12 @@ dependencies = [
"syn 2.0.79",
]
[[package]]
name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
[[package]]
name = "zvariant"
version = "4.0.0"

View File

@@ -65,6 +65,7 @@ work. If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
</provides>
<releases>
<release version="0.13.2" date="2024-11-06"><url>https://github.com/SlimeVR/SlimeVR-Server/releases/tag/v0.13.2</url></release>
<release version="0.13.1" date="2024-11-05"><url>https://github.com/SlimeVR/SlimeVR-Server/releases/tag/v0.13.1</url></release>
<release version="0.13.1~rc.3" type="development" date="2024-10-31"><url>https://github.com/SlimeVR/SlimeVR-Server/releases/tag/v0.13.1-rc.3</url></release>
<release version="0.13.1~rc.2" type="development" date="2024-10-26"><url>https://github.com/SlimeVR/SlimeVR-Server/releases/tag/v0.13.1-rc.2</url></release>

View File

@@ -92,7 +92,7 @@
harfbuzz
libffi
libsoup_3
openssl
openssl.dev
pango
pkg-config
treefmt

8
gui/.env Normal file
View File

@@ -0,0 +1,8 @@
VITE_FIRMWARE_TOOL_URL=https://fw-tool-api.slimevr.io
VITE_FIRMWARE_TOOL_S3_URL=https://fw-tool-bucket.slimevr.io
FIRMWARE_TOOL_SCHEMA_URL=https://fw-tool-api.slimevr.io/api-json
# VITE_FIRMWARE_TOOL_URL=http://localhost:3000
# VITE_FIRMWARE_TOOL_S3_URL=http://localhost:9000
# FIRMWARE_TOOL_SCHEMA_URL=http://localhost:3000/api-json

View File

@@ -1,51 +0,0 @@
{
"env": {
"browser": true,
"es2021": true,
"jest": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@dword-design/import-alias/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["react-hooks", "@typescript-eslint"],
"rules": {
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"spaced-comment": "error",
"quotes": ["error", "single"],
"no-duplicate-imports": "error",
"no-inline-styles": "off",
"@typescript-eslint/no-explicit-any": "off",
"react/no-unescaped-entities": "off",
"camelcase": "error",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"@dword-design/import-alias/prefer-alias": [
"error",
{
"alias": {
"@": "./src/"
}
}
]
},
"settings": {
"import/resolver": {
"typescript": {}
},
"react": {
"version": "detect"
}
}
}

1
gui/.gitignore vendored
View File

@@ -28,6 +28,7 @@ yarn-error.log*
# vite
/dist
/stats.html
vite.config.ts.timestamp*
# eslint
.eslintcache

View File

@@ -1,5 +1,5 @@
export default {
'**/*.{ts,tsx}': () => 'tsc -p tsconfig.json --noEmit',
'**/*.{js,jsx,ts,tsx}': 'eslint --max-warnings=0 --cache --fix',
'src/**/*.{js,jsx,ts,tsx}': 'eslint --max-warnings=0 --no-warn-ignored --cache --fix',
'**/*.{js,jsx,ts,tsx,css,md,json}': 'prettier --write',
};

79
gui/eslint.config.js Normal file
View File

@@ -0,0 +1,79 @@
import { FlatCompat } from '@eslint/eslintrc';
import eslint from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
const compat = new FlatCompat();
export const gui = [
eslint.configs.recommended,
...tseslint.configs.recommended,
...compat.extends('plugin:@dword-design/import-alias/recommended'),
...compat.plugins('eslint-plugin-react-hooks'),
// Add import-alias rule inside compat because plugin doesn't like flat configs
...compat.config({
rules: {
'@dword-design/import-alias/prefer-alias': [
'error',
{
alias: {
'@': './src/',
},
},
],
},
}),
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.jest,
},
},
files: ['src/**/*.{js,jsx,ts,tsx,json}'],
plugins: {
'@typescript-eslint': tseslint.plugin,
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'spaced-comment': 'error',
quotes: ['error', 'single'],
'no-duplicate-imports': 'error',
'no-inline-styles': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'react/no-unescaped-entities': 'off',
camelcase: 'error',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
},
settings: {
'import/resolver': {
typescript: {},
},
react: {
version: 'detect',
},
},
},
// Global ignore
{
ignores: ['**/firmware-tool-api/'],
},
];
export default gui;

View File

@@ -0,0 +1,28 @@
import {
generateSchemaTypes,
generateReactQueryComponents,
} from '@openapi-codegen/typescript';
import { defineConfig } from '@openapi-codegen/cli';
import dotenv from 'dotenv';
dotenv.config()
export default defineConfig({
firmwareTool: {
from: {
source: 'url',
url: process.env.FIRMWARE_TOOL_SCHEMA_URL ?? 'http://localhost:3000/api-json',
},
outputDir: 'src/firmware-tool-api',
to: async (context) => {
const filenamePrefix = 'firmwareTool';
const { schemasFiles } = await generateSchemaTypes(context, {
filenamePrefix,
});
await generateReactQueryComponents(context, {
filenamePrefix,
schemasFiles,
});
},
},
});

View File

@@ -2,13 +2,17 @@
"name": "slimevr-ui",
"version": "0.5.1",
"private": true,
"type": "module",
"dependencies": {
"@fluent/bundle": "^0.18.0",
"@fluent/react": "^0.15.2",
"@fontsource/poppins": "^5.1.0",
"@formatjs/intl-localematcher": "^0.2.32",
"@hookform/resolvers": "^3.6.0",
"@react-three/drei": "^9.114.3",
"@react-three/fiber": "^8.17.10",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query": "^5.48.0",
"@tauri-apps/api": "^2.0.2",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
@@ -26,15 +30,18 @@
"react-error-boundary": "^4.0.13",
"react-helmet": "^6.1.0",
"react-hook-form": "^7.53.0",
"react-markdown": "^9.0.1",
"react-modal": "^3.16.1",
"react-responsive": "^10.0.0",
"react-router-dom": "^6.26.2",
"remark-gfm": "^4.0.0",
"semver": "^7.6.3",
"solarxr-protocol": "file:../solarxr-protocol",
"three": "^0.163.0",
"ts-pattern": "^5.4.0",
"typescript": "^5.6.3",
"use-double-tap": "^1.3.6"
"use-double-tap": "^1.3.6",
"yup": "^1.4.0"
},
"scripts": {
"start": "vite --force",
@@ -46,10 +53,14 @@
"lint:fix": "tsc --noEmit && eslint --fix --max-warnings=0 \"src/**/*.{js,jsx,ts,tsx,json}\" && pnpm run format",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,md,json}\"",
"preview-vite": "vite preview",
"javaversion-build": "cd src-tauri/src/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class"
"javaversion-build": "cd src-tauri/src/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class",
"gen:javaversion": "cd src-tauri/src/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class",
"gen:firmware-tool": "openapi-codegen gen firmwareTool"
},
"devDependencies": {
"@dword-design/eslint-plugin-import-alias": "^4.0.9",
"@openapi-codegen/cli": "^2.0.2",
"@openapi-codegen/typescript": "^8.0.2",
"@tailwindcss/forms": "^0.5.9",
"@tauri-apps/cli": "^2.0.2",
"@types/file-saver": "^2.0.7",
@@ -64,6 +75,7 @@
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-import-resolver-typescript": "^3.6.3",
@@ -71,12 +83,15 @@
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^4.6.2",
"globals": "^15.10.0",
"prettier": "^3.3.3",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.79.4",
"spdx-satisfies": "^5.0.1",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwindcss": "^3.4.13",
"vite": "^5.4.8"
"ts-xor": "^1.3.0",
"vite": "^5.4.8",
"typescript-eslint": "^8.8.0"
}
}

View File

@@ -44,8 +44,8 @@ body_part-LEFT_HAND = Left hand
body_part-LEFT_UPPER_LEG = Left thigh
body_part-LEFT_LOWER_LEG = Left ankle
body_part-LEFT_FOOT = Left foot
body_part-LEFT_THUMB_METACARPAL = Left thumb metacarpal
body_part-LEFT_THUMB_PROXIMAL = Left thumb proximal
body_part-LEFT_THUMB_INTERMEDIATE = Left thumb intermediate
body_part-LEFT_THUMB_DISTAL = Left thumb distal
body_part-LEFT_INDEX_PROXIMAL = Left index proximal
body_part-LEFT_INDEX_INTERMEDIATE = Left index intermediate
@@ -59,8 +59,8 @@ body_part-LEFT_RING_DISTAL = Left ring distal
body_part-LEFT_LITTLE_PROXIMAL = Left little proximal
body_part-LEFT_LITTLE_INTERMEDIATE = Left little intermediate
body_part-LEFT_LITTLE_DISTAL = Left little distal
body_part-RIGHT_THUMB_METACARPAL = Right thumb metacarpal
body_part-RIGHT_THUMB_PROXIMAL = Right thumb proximal
body_part-RIGHT_THUMB_INTERMEDIATE = Right thumb intermediate
body_part-RIGHT_THUMB_DISTAL = Right thumb distal
body_part-RIGHT_INDEX_PROXIMAL = Right index proximal
body_part-RIGHT_INDEX_INTERMEDIATE = Right index intermediate
@@ -75,6 +75,19 @@ body_part-RIGHT_LITTLE_PROXIMAL = Right little proximal
body_part-RIGHT_LITTLE_INTERMEDIATE = Right little intermediate
body_part-RIGHT_LITTLE_DISTAL = Right little distal
## BoardType
board_type-UNKNOWN = Unknown
board_type-NODEMCU = NodeMCU
board_type-CUSTOM = Custom Board
board_type-WROOM32 = WROOM32
board_type-WEMOSD1MINI = Wemos D1 Mini
board_type-TTGO_TBASE = TTGO T-Base
board_type-ESP01 = ESP-01
board_type-SLIMEVR = SlimeVR
board_type-LOLIN_C3_MINI = Lolin C3 Mini
board_type-BEETLE32C3 = Beetle ESP32-C3
board_type-ES32C3DEVKITM1 = Espressif ESP32-C3 DevKitM-1
## Proportions
skeleton_bone-NONE = None
skeleton_bone-HEAD = Head Shift
@@ -214,6 +227,7 @@ tracker-infos-url = Tracker URL
tracker-infos-version = Firmware Version
tracker-infos-hardware_rev = Hardware Revision
tracker-infos-hardware_identifier = Hardware ID
tracker-infos-data_support = Data support
tracker-infos-imu = IMU Sensor
tracker-infos-board_type = Main board
tracker-infos-network_version = Protocol Version
@@ -252,6 +266,11 @@ tracker-settings-name_section-label = Tracker name
tracker-settings-forget = Forget tracker
tracker-settings-forget-description = Removes the tracker from the SlimeVR Server and prevent it from connecting to it until the server is restarted. The configuration of the tracker won't be lost.
tracker-settings-forget-label = Forget tracker
tracker-settings-update-unavailable = Cannot be updated (DIY)
tracker-settings-update-up_to_date = Up to date
tracker-settings-update-available = { $versionName } is now available
tracker-settings-update = Update now
tracker-settings-update-title = Firmware version
## Tracker part card info
tracker-part_card-no_name = No name
@@ -324,6 +343,7 @@ settings-sidebar-utils = Utilities
settings-sidebar-serial = Serial console
settings-sidebar-appearance = Appearance
settings-sidebar-notifications = Notifications
settings-sidebar-firmware-tool = DIY Firmware Tool
settings-sidebar-advanced = Advanced
## SteamVR settings
@@ -590,12 +610,14 @@ settings-osc-router-network-address-placeholder = IPV4 address
## OSC VRChat settings
settings-osc-vrchat = VRChat OSC Trackers
# This cares about multilines
settings-osc-vrchat-description-v1 =
settings-osc-vrchat-description-v2 =
Change settings specific to the OSC Trackers standard used for sending
tracking data to applications without SteamVR (ex. Quest standalone).
# This cares about multilines
settings-osc-vrchat-description-guide =
Make sure to enable OSC in VRChat via the Action Menu under OSC > Enabled.
To allow receiving HMD and controller data from VRChat, go in your main menu's
settings under Tracking & IK > Allow Sending Head and Wrist VR Tracking OSC Data.
To allow receiving HMD and controller data from VRChat, go in your main menu's settings under Tracking & IK > Allow Sending Head and Wrist VR Tracking OSC Data.
settings-osc-vrchat-enable = Enable
settings-osc-vrchat-enable-description = Toggle the sending and receiving of data.
settings-osc-vrchat-enable-label = Enable
@@ -640,11 +662,7 @@ settings-osc-vmc-network-address-description = Choose which address to send out
settings-osc-vmc-network-address-placeholder = IPV4 address
settings-osc-vmc-vrm = VRM Model
settings-osc-vmc-vrm-description = Load a VRM model to allow head anchor and enable a higher compatibility with other applications.
settings-osc-vmc-vrm-model_unloaded = No model loaded
settings-osc-vmc-vrm-model_loaded = { $titled ->
*[false] Untitled model loaded
[true] Model loaded: { $name }
}
settings-osc-vmc-vrm-untitled_model = Untitled model
settings-osc-vmc-vrm-file_select = Drag & drop a model to use, or <u>browse</u>
settings-osc-vmc-anchor_hip = Anchor at hips
settings-osc-vmc-anchor_hip-description = Anchor the tracking at the hips, useful for seated VTubing. If disabling, load a VRM model.
@@ -702,6 +720,7 @@ onboarding-wifi_creds-submit = Submit!
onboarding-wifi_creds-ssid =
.label = Wi-Fi name
.placeholder = Enter Wi-Fi name
onboarding-wifi_creds-ssid-required = Wi-Fi name is required
onboarding-wifi_creds-password =
.label = Password
.placeholder = Enter password
@@ -751,6 +770,7 @@ onboarding-connect_tracker-issue-serial = I'm having trouble connecting!
onboarding-connect_tracker-usb = USB Tracker
onboarding-connect_tracker-connection_status-none = Looking for trackers
onboarding-connect_tracker-connection_status-serial_init = Connecting to serial device
onboarding-connect_tracker-connection_status-obtaining_mac_address = Obtaining the tracker mac address
onboarding-connect_tracker-connection_status-provisioning = Sending Wi-Fi credentials
onboarding-connect_tracker-connection_status-connecting = Trying to connect to Wi-Fi
onboarding-connect_tracker-connection_status-looking_for_server = Looking for server
@@ -789,6 +809,79 @@ onboarding-assignment_tutorial-second_step-v2 = 2. Attach the strap to your trac
onboarding-assignment_tutorial-second_step-continuation-v2 = The velcro side for the extension should be facing up like the following image:
onboarding-assignment_tutorial-done = I put stickers and straps!
## Usage reason choose
onboarding-usage-choose = What are you gonna use SlimeVR for?
onboarding-usage-choose-description = What are you gonna use SlimeVR for?
onboarding-usage-choose-option-title = { $mode ->
*[VR] Virtual Reality
[VTUBING] VTuber
[MOCAP] Motion Capture
}
onboarding-usage-choose-option-label = { $mode ->
*[VR] For use with games and applications that use a headset
[VTUBING] For use with VTubing programs that use the VMC protocol
[MOCAP] For recording a whole body with precise tracking.
}
onboarding-usage-choose-option-description = { $mode ->
*[VR] Users of SteamVR or VR programs that use OSC can select this option to get right to it.
[VTUBING] VTubing programs work with SlimeVR using VMC (Virtual Motion Capture), this is what to pick for that!
[MOCAP] Many 3D programs can record live mocap for use in animation, and BVH recording is also supported directly in the app.
}
## VR usage choose
onboarding-usage-vr-choose = Choose your VR setup
onboarding-usage-vr-choose-description = There are different ways to connect SlimeVR to your virtual reality setup! You can decide which you will use here.
onboarding-usage-vr-choose-steamvr = I use SteamVR
onboarding-usage-vr-choose-steamvr-label = For PCVR
# uses multiline
onboarding-usage-vr-choose-steamvr-description =
SlimeVR emulates SteamVR trackers using the rotational data of the trackers and a human skeleton model, so SteamVR games and programs can use it for full body tracking.
SteamVR must be installed and a headset or positional head tracker connected to the SlimeVR server to use this method.
onboarding-usage-vr-choose-steamvr-warning = The SteamVR driver is currently not connected, <b>please turn on SteamVR</b> or check <docs>the docs for more info</docs>.
onboarding-usage-vr-choose-standalone = I use standalone
onboarding-usage-vr-choose-standalone-label = For VRChat Quest/Pico users
onboarding-usage-vr-choose-standalone-description =
Standalone use connects through OSC instead of SteamVR to provide full body tracking with SlimeVR.
Any PC that can run SlimeVR server can function like this, as well as phones, which are the recommended ways for best ergonomics.
onboarding-usage-vr-standalone-title = Setting up VRChat
onboarding-usage-vr-standalone-next = Done!
## Mocap head usage choose
onboarding-usage-mocap-head_choose = What kind of head tracking do you want?
onboarding-usage-mocap-head_choose-description = You can use either a tracker or a headset for the head!
onboarding-usage-mocap-head_choose-standalone = SlimeVR head tracker
onboarding-usage-mocap-head_choose-standalone-label = Use an IMU tracker for tracking position
onboarding-usage-mocap-head_choose-standalone-description =
This enables head tracking using a head mounted SlimeVR tracker.
This is much less precise in the way that if you walk and return to your starting point, you won't be on the same place on the recording.
onboarding-usage-mocap-head_choose-standalone-button = Use IMU tracker
onboarding-usage-mocap-head_choose-steamvr = SteamVR head tracking
onboarding-usage-mocap-head_choose-steamvr-label = Use an HMD or a positional tracker for precision
onboarding-usage-mocap-head_choose-steamvr-description =
Most accurate way to track the head, using true positional data as reference.
This allows for the best quality motion capture recordings as well as movements that require both feet to leave the floor at the same time.
onboarding-usage-mocap-head_choose-steamvr-button = Use SteamVR
## Mocap data mode choose
onboarding-usage-mocap-data_choose = What kind of data format to use?
onboarding-usage-mocap-data_choose-description = description
onboarding-usage-mocap-data_choose-option-title = { $mode ->
*[BVH] BVH
[STEAMVR] SteamVR
[VMC] VMC
}
onboarding-usage-mocap-data_choose-option-label = { $mode ->
*[BVH] Natively supported on most animation programs
[STEAMVR] For programs that support OpenVR as a source of data
[VMC] Popular data protocol for VTubing
}
## Tracker assignment setup
onboarding-assign_trackers-back = Go Back to Wi-Fi Credentials
onboarding-assign_trackers-title = Assign trackers
@@ -1043,6 +1136,165 @@ status_system-StatusSteamVRDisconnected = { $type ->
status_system-StatusTrackerError = The { $trackerName } tracker has an error.
status_system-StatusUnassignedHMD = The VR headset should be assigned as a head tracker.
## Firmware tool globals
firmware_tool-next_step = Next Step
firmware_tool-previous_step = Previous Step
firmware_tool-ok = Looks good
firmware_tool-retry = Retry
firmware_tool-loading = Loading...
## Firmware tool Steps
firmware_tool = DIY Firmware tool
firmware_tool-description =
Allows you to configure and flash your DIY trackers
firmware_tool-not_available = Oops, the firmware tool is not available at the moment. Come back later!
firmware_tool-not_compatible = The firmware tool is not compatible with this version of the server. Please update your server!
firmware_tool-board_step = Select your Board
firmware_tool-board_step-description = Select one of the boards listed below.
firmware_tool-board_pins_step = Check the pins
firmware_tool-board_pins_step-description =
Please verify that the selected pins are correct.
If you followed the SlimeVR documentation the defaults values should be correct
firmware_tool-board_pins_step-enable_led = Enable LED
firmware_tool-board_pins_step-led_pin =
.label = LED Pin
.placeholder = Enter the pin address of the LED
firmware_tool-board_pins_step-battery_type = Select the battery type
firmware_tool-board_pins_step-battery_type-BAT_EXTERNAL = External battery
firmware_tool-board_pins_step-battery_type-BAT_INTERNAL = Internal battery
firmware_tool-board_pins_step-battery_type-BAT_INTERNAL_MCP3021 = Internal MCP3021
firmware_tool-board_pins_step-battery_type-BAT_MCP3021 = MCP3021
firmware_tool-board_pins_step-battery_sensor_pin =
.label = Battery sensor Pin
.placeholder = Enter the pin address of battery sensor
firmware_tool-board_pins_step-battery_resistor =
.label = Battery Resistor (Ohms)
.placeholder = Enter the value of battery resistor
firmware_tool-board_pins_step-battery_shield_resistor-0 =
.label = Battery Shield R1 (Ohms)
.placeholder = Enter the value of Battery Shield R1
firmware_tool-board_pins_step-battery_shield_resistor-1 =
.label = Battery Shield R2 (Ohms)
.placeholder = Enter the value of Battery Shield R2
firmware_tool-add_imus_step = Declare your IMUs
firmware_tool-add_imus_step-description =
Please add the IMUs that your tracker has
If you followed the SlimeVR documentation the defaults values should be correct
firmware_tool-add_imus_step-imu_type-label = IMU type
firmware_tool-add_imus_step-imu_type-placeholder = Select the type of IMU
firmware_tool-add_imus_step-imu_rotation =
.label = IMU Rotation (deg)
.placeholder = Rotation angle of the IMU
firmware_tool-add_imus_step-scl_pin =
.label = SCL Pin
.placeholder = Pin address of SCL
firmware_tool-add_imus_step-sda_pin =
.label = SDA Pin
.placeholder = Pin address of SDA
firmware_tool-add_imus_step-int_pin =
.label = INT Pin
.placeholder = Pin address of INT
firmware_tool-add_imus_step-optional_tracker =
.label = Optional tracker
firmware_tool-add_imus_step-show_less = Show Less
firmware_tool-add_imus_step-show_more = Show More
firmware_tool-add_imus_step-add_more = Add more IMUs
firmware_tool-select_firmware_step = Select the firmware version
firmware_tool-select_firmware_step-description =
Please choose what version of the firmware you want to use
firmware_tool-select_firmware_step-show-third-party =
.label = Show third party firmwares
firmware_tool-flash_method_step = Flashing Method
firmware_tool-flash_method_step-description =
Please select the flashing method you want to use
firmware_tool-flash_method_step-ota =
.label = OTA
.description = Use the over the air method. Your tracker will use the Wi-Fi to update it's firmware. Works only on already setup trackers.
firmware_tool-flash_method_step-serial =
.label = Serial
.description = Use a USB cable to update your tracker.
firmware_tool-flashbtn_step = Press the boot btn
firmware_tool-flashbtn_step-description = Before going into the next step there is a few things you need to do
firmware_tool-flashbtn_step-board_SLIMEVR = Turn off the tracker, remove the case (if any), connect a USB cable to this computer, then do one of the following steps according to your SlimeVR board revision:
firmware_tool-flashbtn_step-board_SLIMEVR-r11 = Turn on the tracker while shorting the second rectangular FLASH pad from the edge on the top side of the board, and the metal shield of the microcontroller
firmware_tool-flashbtn_step-board_SLIMEVR-r12 = Turn on the tracker while shorting the circular FLASH pad on the top side of the board, and the metal shield of the microcontroller
firmware_tool-flashbtn_step-board_SLIMEVR-r14 = Turn on the tracker while pushing in the FLASH button on the top side of the board
firmware_tool-flashbtn_step-board_OTHER = Before flashing you will probably need to put the tracker into bootloader mode.
Most of the time it means pressing the boot button on the board before the flashing process starts.
If the flashing process timeout at the begining of the flashing it probably means that the tracker was not in bootloader mode
Please refer to the flashing instructions of your board to know how to turn on the boatloader mode
firmware_tool-flash_method_ota-devices = Detected OTA Devices:
firmware_tool-flash_method_ota-no_devices = There are no boards that can be updated using OTA, make sure you selected the correct board type
firmware_tool-flash_method_serial-wifi = Wi-Fi Credentials:
firmware_tool-flash_method_serial-devices-label = Detected Serial Devices:
firmware_tool-flash_method_serial-devices-placeholder = Select a serial device
firmware_tool-flash_method_serial-no_devices = There are no compatible serial devices detected, make sure the tracker is plugged in
firmware_tool-build_step = Building
firmware_tool-build_step-description =
The firmware is building, please wait
firmware_tool-flashing_step = Flashing
firmware_tool-flashing_step-description =
Your trackers are flashing, please follow the instructions on the screen
firmware_tool-flashing_step-warning = Do not unplug or restart the tracker during the upload process unless told to, it may make your board unusable
firmware_tool-flashing_step-flash_more = Flash more trackers
firmware_tool-flashing_step-exit = Exit
## firmware tool build status
firmware_tool-build-CREATING_BUILD_FOLDER = Creating the build folder
firmware_tool-build-DOWNLOADING_FIRMWARE = Downloading the firmware
firmware_tool-build-EXTRACTING_FIRMWARE = Extracting the firmware
firmware_tool-build-SETTING_UP_DEFINES = Configuring the defines
firmware_tool-build-BUILDING = Building the firmware
firmware_tool-build-SAVING = Saving the build
firmware_tool-build-DONE = Build Complete
firmware_tool-build-ERROR = Unable to build the firmware
## Firmware update status
firmware_update-status-DOWNLOADING = Downloading the firmware
firmware_update-status-NEED_MANUAL_REBOOT = Waiting for the user to reboot the tracker
firmware_update-status-AUTHENTICATING = Authenticating with the mcu
firmware_update-status-UPLOADING = Uploading the firmware
firmware_update-status-SYNCING_WITH_MCU = Syncing with the mcu
firmware_update-status-REBOOTING = Rebooting the tracker
firmware_update-status-PROVISIONING = Setting Wi-Fi credentials
firmware_update-status-DONE = Update complete!
firmware_update-status-ERROR_DEVICE_NOT_FOUND = Could not find the device
firmware_update-status-ERROR_TIMEOUT = The update process timed out
firmware_update-status-ERROR_DOWNLOAD_FAILED = Could not download the firmware
firmware_update-status-ERROR_AUTHENTICATION_FAILED = Could not authenticate with the mcu
firmware_update-status-ERROR_UPLOAD_FAILED = Could not upload the firmware
firmware_update-status-ERROR_PROVISIONING_FAILED = Could not set the Wi-Fi credentials
firmware_update-status-ERROR_UNSUPPORTED_METHOD = The update method is not supported
firmware_update-status-ERROR_UNKNOWN = Unknown error
## Dedicated Firmware Update Page
firmware_update-title = Firmware update
firmware_update-devices = Available Devices
firmware_update-devices-description = Please select the trackers you want to update to the latest version of SlimeVR firmware
firmware_update-no_devices = Plase make sure that the trackers you want to update are ON and connected to the Wi-Fi!
firmware_update-changelog-title = Updating to {$version}
firmware_update-looking_for_devices = Looking for devices to update...
firmware_update-retry = Retry
firmware_update-update = Update Selected Trackers
## Tray Menu
tray_menu-show = Show
tray_menu-hide = Hide

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

View File

@@ -28,7 +28,7 @@ shadow-rs = "0.35"
[dependencies]
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tauri = { version = "2.0", features = ["devtools", "tray-icon", "image-png"] }
tauri = { version = "2.0", features = ["devtools", "tray-icon", "image-png", "rustls-tls"] }
tauri-runtime = "2.0"
tauri-plugin-dialog = "2.0"
tauri-plugin-fs = "2.0"
@@ -53,6 +53,7 @@ rfd = { version = "0.15", features = ["gtk3"], default-features = false }
dirs-next = "2.0.0"
discord-sdk = "0.3.6"
tokio = { version = "1.37.0", features = ["time"] }
itertools = "0.13.0"
[target.'cfg(windows)'.dependencies]
win32job = "1"

View File

@@ -1,4 +1,5 @@
#![cfg_attr(all(not(debug_assertions), windows), windows_subsystem = "windows")]
use std::env;
use std::panic;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
@@ -68,84 +69,125 @@ fn main() -> Result<()> {
let tauri_context = tauri::generate_context!();
// Set up loggers and global handlers
let _logger = {
use flexi_logger::{
Age, Cleanup, Criterion, Duplicate, FileSpec, Logger, Naming, WriteMode,
};
use tauri::Error;
// Based on https://docs.rs/tauri/2.0.0-alpha.10/src/tauri/path/desktop.rs.html#238-256
#[cfg(target_os = "macos")]
let path = dirs_next::home_dir().ok_or(Error::UnknownPath).map(|dir| {
dir.join("Library/Logs")
.join(&tauri_context.config().identifier)
});
#[cfg(not(target_os = "macos"))]
let path = dirs_next::data_dir()
.ok_or(Error::UnknownPath)
.map(|dir| dir.join(&tauri_context.config().identifier).join("logs"));
Logger::try_with_env_or_str("info")?
.log_to_file(
FileSpec::default().directory(path.expect("We need a log dir")),
)
.format_for_files(util::logger_format)
.format_for_stderr(util::logger_format)
.rotate(
Criterion::Age(Age::Day),
Naming::Timestamps,
Cleanup::KeepLogFiles(2),
)
.duplicate_to_stderr(Duplicate::All)
.write_mode(WriteMode::BufferAndFlush)
.start()?
};
let _logger = setup_logger(&tauri_context);
// Ensure child processes die when spawned on windows
// and then check for WebView2's existence
#[cfg(windows)]
{
use crate::util::webview2_exists;
use win32job::{ExtendedLimitInfo, Job};
setup_webview2()?;
let mut info = ExtendedLimitInfo::new();
info.limit_kill_on_job_close();
let job = Job::create_with_limit_info(&mut info).expect("Failed to create Job");
job.assign_current_process()
.expect("Failed to assign current process to Job");
// We don't do anything with the job anymore, but we shouldn't drop it because that would
// terminate our process tree. So we intentionally leak it instead.
std::mem::forget(job);
if !webview2_exists() {
// This makes a dialog appear which let's you press Ok or Cancel
// If you press Ok it will open the SlimeVR installer documentation
use rfd::{
MessageButtons, MessageDialog, MessageDialogResult, MessageLevel,
};
let confirm = MessageDialog::new()
.set_title("SlimeVR")
.set_description("Couldn't find WebView2 installed. You can install it with the SlimeVR installer")
.set_buttons(MessageButtons::OkCancel)
.set_level(MessageLevel::Error)
.show();
if confirm == MessageDialogResult::Ok {
open::that("https://docs.slimevr.dev/server-setup/installing-and-connecting.html#install-the-latest-slimevr-installer").unwrap();
}
return Ok(());
}
}
// Check for environment variables that can affect the server, and if so, warn in log and GUI
check_environment_variables();
// Spawn server process
let exit_flag = Arc::new(AtomicBool::new(false));
let backend = Arc::new(Mutex::new(Option::<CommandChild>::None));
let backend_termination = backend.clone();
let run_path = get_launch_path(cli);
let server_info = if let Some(p) = run_path {
let server_info = execute_server(cli)?;
let build_result = setup_tauri(
tauri_context,
server_info,
exit_flag.clone(),
backend.clone(),
);
tauri_build_result(build_result, exit_flag, backend);
Ok(())
}
fn setup_logger(context: &tauri::Context) -> Result<flexi_logger::LoggerHandle> {
use flexi_logger::{
Age, Cleanup, Criterion, Duplicate, FileSpec, Logger, Naming, WriteMode,
};
use tauri::Error;
// Based on https://docs.rs/tauri/2.0.0-alpha.10/src/tauri/path/desktop.rs.html#238-256
#[cfg(target_os = "macos")]
let path = dirs_next::home_dir()
.ok_or(Error::UnknownPath)
.map(|dir| dir.join("Library/Logs").join(&context.config().identifier));
#[cfg(not(target_os = "macos"))]
let path = dirs_next::data_dir()
.ok_or(Error::UnknownPath)
.map(|dir| dir.join(&context.config().identifier).join("logs"));
Ok(Logger::try_with_env_or_str("info")?
.log_to_file(FileSpec::default().directory(path.expect("We need a log dir")))
.format_for_files(|w, now, record| util::logger_format(w, now, record, false))
.format_for_stderr(|w, now, record| util::logger_format(w, now, record, true))
.rotate(
Criterion::Age(Age::Day),
Naming::Timestamps,
Cleanup::KeepLogFiles(2),
)
.duplicate_to_stderr(Duplicate::All)
.write_mode(WriteMode::BufferAndFlush)
.start()?)
}
#[cfg(windows)]
fn setup_webview2() -> Result<()> {
use crate::util::webview2_exists;
use win32job::{ExtendedLimitInfo, Job};
let mut info = ExtendedLimitInfo::new();
info.limit_kill_on_job_close();
let job = Job::create_with_limit_info(&mut info).expect("Failed to create Job");
job.assign_current_process()
.expect("Failed to assign current process to Job");
// We don't do anything with the job anymore, but we shouldn't drop it because that would
// terminate our process tree. So we intentionally leak it instead.
std::mem::forget(job);
if !webview2_exists() {
// This makes a dialog appear which let's you press Ok or Cancel
// If you press Ok it will open the SlimeVR installer documentation
use rfd::{MessageButtons, MessageDialog, MessageDialogResult, MessageLevel};
let confirm = MessageDialog::new()
.set_title("SlimeVR")
.set_description("Couldn't find WebView2 installed. You can install it with the SlimeVR installer")
.set_buttons(MessageButtons::OkCancel)
.set_level(MessageLevel::Error)
.show();
if confirm == MessageDialogResult::Ok {
open::that("https://docs.slimevr.dev/server-setup/installing-and-connecting.html#install-the-latest-slimevr-installer").unwrap();
}
}
Ok(())
}
fn check_environment_variables() {
use itertools::Itertools;
const ENVS_TO_CHECK: &[&str] = &["_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"];
let checked_envs = ENVS_TO_CHECK
.into_iter()
.filter_map(|e| {
let Ok(data) = env::var(e) else {
return None;
};
log::warn!("{e} is set to: {data}");
Some(e)
})
.join(", ");
if !checked_envs.is_empty() {
rfd::MessageDialog::new()
.set_title("SlimeVR")
.set_description(format!("You have environment variables {} set, which may cause the SlimeVR Server to fail to launch properly.", checked_envs))
.set_level(rfd::MessageLevel::Warning)
.show();
}
}
fn execute_server(
cli: Cli,
) -> Result<Option<(std::ffi::OsString, std::path::PathBuf)>> {
use const_format::formatcp;
if let Some(p) = get_launch_path(cli) {
log::info!("Server found on path: {}", p.to_str().unwrap());
// Check if any Java already installed is compatible
@@ -155,19 +197,29 @@ fn main() -> Result<()> {
.then(|| jre.into_os_string())
.or_else(|| valid_java_paths().first().map(|x| x.0.to_owned()));
let Some(java_bin) = java_bin else {
show_error(&format!("Couldn't find a compatible Java version, please download Java {} or higher", MINIMUM_JAVA_VERSION));
return Ok(());
show_error(formatcp!(
"Couldn't find a compatible Java version, please download Java {} or higher",
MINIMUM_JAVA_VERSION
));
return Ok(None);
};
log::info!("Using Java binary: {:?}", java_bin);
Some((java_bin, p))
Ok(Some((java_bin, p)))
} else {
log::warn!("No server found. We will not start the server.");
None
};
Ok(None)
}
}
fn setup_tauri(
context: tauri::Context,
server_info: Option<(std::ffi::OsString, std::path::PathBuf)>,
exit_flag: Arc<AtomicBool>,
backend: Arc<Mutex<Option<CommandChild>>>,
) -> Result<tauri::App, tauri::Error> {
let exit_flag_terminated = exit_flag.clone();
let build_result = tauri::Builder::default()
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_os::init())
@@ -277,7 +329,14 @@ fn main() -> Result<()> {
// WindowEvent::Resized(_) => std::thread::sleep(std::time::Duration::from_nanos(1)),
_ => (),
})
.build(tauri_context);
.build(context)
}
fn tauri_build_result(
build_result: Result<tauri::App, tauri::Error>,
exit_flag: Arc<AtomicBool>,
backend: Arc<Mutex<Option<CommandChild>>>,
) {
match build_result {
Ok(app) => {
app.run(move |app_handle, event| match event {
@@ -291,7 +350,7 @@ fn main() -> Result<()> {
Err(e) => log::error!("failed to save window state: {}", e),
}
let mut lock = backend_termination.lock().unwrap();
let mut lock = backend.lock().unwrap();
let Some(ref mut child) = *lock else { return };
let write_result = child.write(b"exit\n");
match write_result {
@@ -335,6 +394,4 @@ fn main() -> Result<()> {
show_error(&error.to_string());
}
}
Ok(())
}

View File

@@ -1,7 +1,7 @@
use std::{collections::HashMap, sync::Mutex};
use tauri::{
image::Image,
include_image,
menu::{Menu, MenuBuilder, MenuItemBuilder, MenuItemKind},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Emitter, Manager, Runtime, State,
@@ -107,10 +107,10 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
.icon_as_template(true)
.menu_on_left_click(false)
.icon(if cfg!(target_os = "macos") {
Image::from_bytes(include_bytes!("../icons/appleTrayIcon.png"))
include_image!("icons/appleTrayIcon.png")
} else {
Image::from_bytes(include_bytes!("../icons/128x128.png"))
}?)
include_image!("icons/128x128.png")
})
.on_menu_event(move |app, event| match event.id.as_ref() {
"quit" => app.emit("try-close", "tray").unwrap(),
"toggle" => {

View File

@@ -107,7 +107,8 @@ pub fn show_error(text: &str) -> bool {
.set_description(text)
.set_buttons(MessageButtons::Ok)
.set_level(MessageLevel::Error)
.show() == MessageDialogResult::Ok
.show()
== MessageDialogResult::Ok
}
#[cfg(mobile)]
@@ -222,20 +223,26 @@ pub fn logger_format(
w: &mut dyn std::io::Write,
_now: &mut DeferredNow,
record: &Record,
ansi: bool,
) -> Result<(), std::io::Error> {
let level = record.level();
let module_path = record.module_path().unwrap_or("<unnamed>");
// optionally print target
// Optionally print target
let target = if module_path.starts_with(record.target()) {
"".to_string()
} else {
format!(", {}", record.target())
};
write!(
w,
"{} [{}{target}] {}",
style(level).paint(level.to_string()),
record.module_path().unwrap_or("<unnamed>"),
style(level).paint(record.args().to_string())
)
// A toggle for ansi formatting, mainly disabled for file logs, but enabled for terminal logs.
if ansi {
write!(
w,
"{} [{}{target}] {}",
style(level).paint(level.to_string()),
module_path,
style(level).paint(record.args().to_string())
)
} else {
write!(w, "{} [{}{target}] {}", level, module_path, record.args())
}
}

View File

@@ -51,11 +51,22 @@ import { useBreakpoint, useIsTauri } from './hooks/breakpoint';
import { VRModePage } from './components/vr-mode/VRModePage';
import { InterfaceSettings } from './components/settings/pages/InterfaceSettings';
import { error, log } from './utils/logging';
import { FirmwareToolSettings } from './components/firmware-tool/FirmwareTool';
import { AppLayout } from './AppLayout';
import { Preload } from './components/Preload';
import { UnknownDeviceModal } from './components/UnknownDeviceModal';
import { useDiscordPresence } from './hooks/discord-presence';
import { EmptyLayout } from './components/EmptyLayout';
import { AdvancedSettings } from './components/settings/pages/AdvancedSettings';
import { FirmwareUpdate } from './components/firmware-update/FirmwareUpdate';
import { UsageChoose } from './components/onboarding/pages/usage-reason/UsageChoose';
import { VRUsageChoose } from './components/onboarding/pages/usage-reason/VRUsageChoose';
import { StandaloneUsageSetup } from './components/onboarding/pages/usage-reason/StandaloneUsageSetup';
import { HeadTrackingChoose } from './components/onboarding/pages/usage-reason/HeadTrackingChoose';
import { MocapDataChoose } from './components/onboarding/pages/usage-reason/MocapDataChoose';
import { MocapVMCSetup } from './components/onboarding/pages/usage-reason/MocapVMCSetup';
import { MocapBVHSetup } from './components/onboarding/pages/usage-reason/MocapBVHSetup';
import { MocapSteamSetup } from './components/onboarding/pages/usage-reason/MocapSteamSetup';
export const GH_REPO = 'SlimeVR/SlimeVR-Server';
export const VersionContext = createContext('');
@@ -81,6 +92,14 @@ function Layout() {
</MainLayout>
}
/>
<Route
path="/firmware-update"
element={
<MainLayout isMobile={isMobile} widgets={false}>
<FirmwareUpdate />
</MainLayout>
}
/>
<Route
path="/vr-mode"
element={
@@ -105,6 +124,7 @@ function Layout() {
</SettingsLayout>
}
>
<Route path="firmware-tool" element={<FirmwareToolSettings />} />
<Route path="trackers" element={<GeneralSettings />} />
<Route path="serial" element={<Serial />} />
<Route path="osc/router" element={<OSCRouterSettings />} />
@@ -132,6 +152,21 @@ function Layout() {
path="assign-tutorial"
element={<AssignmentTutorialPage />}
/>
<Route path="usage">
<Route path="choose" element={<UsageChoose />} />
<Route path="vr/choose" element={<VRUsageChoose />} />
<Route path="vr/standalone" element={<StandaloneUsageSetup />} />
<Route path="mocap/data/choose" element={<MocapDataChoose />} />
<Route
path="mocap/head-choose"
element={<HeadTrackingChoose />}
/>
<Route path="mocap/data/vmc" element={<MocapVMCSetup />} />
<Route path="mocap/data/bvh" element={<MocapBVHSetup />} />
<Route path="mocap/data/steamvr" element={<MocapSteamSetup />} />
</Route>
<Route path="trackers-assign" element={<TrackersAssignPage />} />
<Route path="enter-vr" element={<EnterVRPage />} />
<Route path="mounting/choose" element={<MountingChoose />}></Route>
@@ -272,19 +307,16 @@ export default function App() {
<VersionContext.Provider value={updateFound}>
<div className="h-full w-full text-standard bg-background-80 text-background-10">
<Preload />
<div className="flex-col h-full">
{!websocketAPI.isConnected && (
<>
<TopBar></TopBar>
<div className="flex w-full h-full justify-center items-center p-2">
{websocketAPI.isFirstConnection
? l10n.getString('websocket-connecting')
: l10n.getString('websocket-connection_lost')}
</div>
</>
)}
{websocketAPI.isConnected && <Layout></Layout>}
</div>
{!websocketAPI.isConnected && (
<EmptyLayout>
<div className="flex w-full h-full justify-center items-center p-2">
{websocketAPI.isFirstConnection
? l10n.getString('websocket-connecting')
: l10n.getString('websocket-connection_lost')}
</div>
</EmptyLayout>
)}
{websocketAPI.isConnected && <Layout></Layout>}
</div>
</VersionContext.Provider>
</StatusProvider>

View File

@@ -0,0 +1,7 @@
.empty-layout {
display: grid;
grid-template:
't' var(--topbar-h)
'c' calc(100% - var(--topbar-h))
/ 100%;
}

View File

@@ -0,0 +1,16 @@
import { ReactNode } from 'react';
import { TopBar } from './TopBar';
import './EmptyLayout.scss';
export function EmptyLayout({ children }: { children: ReactNode }) {
return (
<div className="empty-layout h-full">
<div style={{ gridArea: 't' }}>
<TopBar></TopBar>
</div>
<div style={{ gridArea: 'c' }} className="mt-2 relative">
{children}
</div>
</div>
);
}

View File

@@ -39,12 +39,6 @@ export function SerialDetectionModal() {
const openWifi = () => {
setShowWifiForm(true);
// if (!hasWifiCreds) {
// setShowWifiForm(true);
// } else {
// closeModal();
// nav('/onboarding/connect-trackers', { state: { alonePage: true } });
// }
};
const modalWifiSubmit = (form: WifiFormData) => {
@@ -58,7 +52,11 @@ export function SerialDetectionModal() {
({ device }: NewSerialDeviceResponseT) => {
if (
config?.watchNewDevices &&
!['/settings/serial', '/onboarding/connect-trackers'].includes(pathname)
![
'/settings/serial',
'/onboarding/connect-trackers',
'/settings/firmware-tool',
].includes(pathname)
) {
setOpen(device);
}

View File

@@ -290,7 +290,9 @@ export function TopBar({
await invoke('update_tray_text');
} else if (
config?.connectedTrackersWarning &&
connectedIMUTrackers.length > 0
connectedIMUTrackers.filter(
(t) => t.tracker.status !== TrackerStatus.TIMED_OUT
).length > 0
) {
setConnectedTrackerWarning(true);
} else {

View File

@@ -25,7 +25,9 @@ export function UnknownDeviceModal() {
RpcMessage.UnknownDeviceHandshakeNotification,
({ macAddress }: UnknownDeviceHandshakeNotificationT) => {
if (
['/onboarding/connect-trackers'].includes(pathname) ||
['/onboarding/connect-trackers', '/settings/firmware-tool'].includes(
pathname
) ||
state.ignoredTrackers.has(macAddress as string) ||
(currentTracker !== null && currentTracker !== macAddress)
)

View File

@@ -1,11 +1,13 @@
import { open } from '@tauri-apps/plugin-shell';
import { ReactNode } from 'react';
export function A({ href, children }: { href: string; children?: ReactNode }) {
export function A({ href, children }: { href?: string; children?: ReactNode }) {
return (
<a
href="javascript:void(0)"
onClick={() => open(href).catch(() => window.open(href, '_blank'))}
onClick={() =>
href && open(href).catch(() => window.open(href, '_blank'))
}
className="underline"
>
{children}

View File

@@ -87,10 +87,10 @@ export const mapPart: Record<
<UpperLegIcon width={width} flipped></UpperLegIcon>
),
[BodyPart.WAIST]: ({ width }) => <WaistIcon width={width}></WaistIcon>,
[BodyPart.LEFT_THUMB_PROXIMAL]: ({ width }) => (
[BodyPart.LEFT_THUMB_METACARPAL]: ({ width }) => (
<FingersIcon width={width}></FingersIcon>
),
[BodyPart.LEFT_THUMB_INTERMEDIATE]: ({ width }) => (
[BodyPart.LEFT_THUMB_PROXIMAL]: ({ width }) => (
<FingersIcon width={width}></FingersIcon>
),
[BodyPart.LEFT_THUMB_DISTAL]: ({ width }) => (
@@ -132,10 +132,10 @@ export const mapPart: Record<
[BodyPart.LEFT_LITTLE_DISTAL]: ({ width }) => (
<FingersIcon width={width}></FingersIcon>
),
[BodyPart.RIGHT_THUMB_PROXIMAL]: ({ width }) => (
[BodyPart.RIGHT_THUMB_METACARPAL]: ({ width }) => (
<FingersIcon width={width}></FingersIcon>
),
[BodyPart.RIGHT_THUMB_INTERMEDIATE]: ({ width }) => (
[BodyPart.RIGHT_THUMB_PROXIMAL]: ({ width }) => (
<FingersIcon width={width}></FingersIcon>
),
[BodyPart.RIGHT_THUMB_DISTAL]: ({ width }) => (

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import React, { ReactNode, useMemo } from 'react';
import { NavLink } from 'react-router-dom';
import { LoaderIcon, SlimeState } from './icon/LoaderIcon';
import { XOR } from 'ts-xor';
function ButtonContent({
loading,
@@ -36,6 +37,25 @@ function ButtonContent({
);
}
type ButtonBaseParams = {
children?: ReactNode;
icon?: ReactNode;
variant: 'primary' | 'secondary' | 'tertiary' | 'quaternary';
loading?: boolean;
rounded?: boolean;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'>;
type ButtonNavigateParams = { to: string; state?: any } & ButtonBaseParams;
type ButtonScriptParams = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
} & ButtonBaseParams;
type ButtonSubmitParams = { type: 'submit' } & ButtonBaseParams;
type ButtonParams = XOR<
ButtonNavigateParams,
ButtonScriptParams,
ButtonSubmitParams
>;
export function Button({
children,
variant,
@@ -46,15 +66,7 @@ export function Button({
icon,
rounded = false,
...props
}: {
children?: ReactNode;
icon?: ReactNode;
variant: 'primary' | 'secondary' | 'tertiary' | 'quaternary';
to?: string;
loading?: boolean;
rounded?: boolean;
state?: any;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
}: ButtonParams) {
const classes = useMemo(() => {
const variantsMap = {
primary: classNames({

View File

@@ -2,6 +2,10 @@ import classNames from 'classnames';
import { useMemo } from 'react';
import { Control, Controller } from 'react-hook-form';
export const CHECKBOX_CLASSES = classNames(
'bg-background-50 border-background-50 rounded-md w-5 h-5 text-accent-background-30 focus:border-accent-background-40 focus:ring-transparent focus:ring-offset-transparent focus:outline-transparent'
);
export function CheckBox({
label,
variant = 'checkbox',
@@ -25,9 +29,7 @@ export function CheckBox({
const classes = useMemo(() => {
const vriantsMap = {
checkbox: {
checkbox: classNames(
'bg-background-50 border-background-50 rounded-md w-5 h-5 text-accent-background-30 focus:border-accent-background-40 focus:ring-transparent focus:ring-offset-transparent focus:outline-transparent'
),
checkbox: CHECKBOX_CLASSES,
toggle: '',
pin: '',
},

View File

@@ -1,9 +1,17 @@
import { Localized } from '@fluent/react';
import classNames from 'classnames';
import { forwardRef, useMemo, useState } from 'react';
import {
forwardRef,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { Control, Controller, UseControllerProps } from 'react-hook-form';
import { FileIcon } from './icon/FileIcon';
import { UploadFileIcon } from './icon/UploadFileIcon';
import { Typography } from './Typography';
import { CloseIcon } from './icon/CloseIcon';
interface InputProps {
variant?: 'primary' | 'secondary';
@@ -11,6 +19,80 @@ interface InputProps {
name: string;
}
const FileInputContentBlank = ({
isDragging,
label,
}: {
isDragging: boolean;
label: string;
}) => {
return (
<div
className={classNames(
'flex justify-center w-full h-32 px-4 transition border-2',
'border-background-20 rounded-md appearance-none cursor-pointer',
'hover:border-accent-background-20 focus:outline-none',
'border-dashed'
)}
>
<span className="flex items-center space-x-2 pointer-events-none">
<UploadFileIcon isDragging={isDragging} />
<div>
<Localized
id={label}
elems={{
u: <span className="underline text-background-20"></span>,
}}
>
<Typography>
Drop files to attach, or{' '}
<span className="underline text-background-20">browse</span>
</Typography>
</Localized>
</div>
</span>
</div>
);
};
const FileInputContentFile = ({
importedFileName,
onClearPicker,
}: {
importedFileName: string;
onClearPicker: () => any;
}) => {
return (
<div
className={classNames(
'flex flex-col w-full transition border-2',
'border-background-20 rounded-md appearance-none cursor-pointer',
'hover:border-accent-background-20 focus:outline-none'
)}
>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 px-4">
<FileIcon />
<span>{importedFileName}</span>
</div>
<span className="flex-grow"></span>
<a
href="#"
className="h-12 w-12 hover:bg-accent-background-20 cursor-pointer"
onClick={() => {
onClearPicker();
}}
>
<CloseIcon
className="stroke-background-20 hover:stroke-background-90"
size={48}
/>
</a>
</div>
</div>
);
};
export const FileInputInside = forwardRef<
HTMLInputElement,
{
@@ -22,44 +104,36 @@ export const FileInputInside = forwardRef<
value: FileList;
onChange: (...event: any[]) => void;
name: string;
importedFileName: string | null;
}
>(function AppInput(
{
label = 'tips-file_select',
name,
onChange,
variant = 'primary',
accept,
capture,
multiple = false,
importedFileName,
},
ref
) {
const classes = useMemo(() => {
const variantsMap = {
primary: classNames('bg-background-60 border-background-60'),
secondary: classNames('bg-background-50 border-background-50'),
};
const innerRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => innerRef.current!);
return classNames(
variantsMap[variant],
'w-full focus:ring-transparent focus:ring-offset-transparent',
'focus:outline-transparent rounded-md bg-background-60 border-background-60',
'focus:border-accent-background-40 placeholder:text-background-30 text-standard',
'relative hidden'
);
}, [variant]);
const acceptList = useMemo(() => accept.split(/, ?/), [accept]);
const [isDragging, setDragging] = useState(false);
const isFileImported = importedFileName !== null && !isDragging;
const onClearPicker = () => {
onChange([]);
innerRef.current!.value = '';
};
return (
<label
className={classNames(
'flex justify-center w-full h-32 px-4 transition border-2',
'border-background-20 border-dashed rounded-md appearance-none cursor-pointer',
'hover:border-accent-background-20 focus:outline-none'
)}
onClick={() => typeof ref !== 'function' && ref?.current?.click()}
onDragOver={(ev) => ev.preventDefault()}
onDrop={(ev) => {
ev.preventDefault();
@@ -86,30 +160,20 @@ export const FileInputInside = forwardRef<
setDragging(false);
}}
>
<span className="flex items-center space-x-2 pointer-events-none">
<FileIcon isDragging={isDragging} />
<div>
<Localized
id={label}
elems={{
u: <span className="underline text-background-20"></span>,
}}
>
<Typography>
Drop files to attach, or{' '}
<span className="underline text-background-20">browse</span>
</Typography>
</Localized>
</div>
</span>
{isFileImported
? FileInputContentFile({ importedFileName, onClearPicker })
: FileInputContentBlank({ isDragging, label })}
<input
type="file"
className={classNames(classes)}
className="hidden"
onChange={(ev) => {
onChange(ev.target.files);
if (ev.target.files?.length) {
onChange(ev.target.files);
}
}}
name={name}
ref={ref}
ref={innerRef}
accept={accept}
multiple={multiple}
capture={capture}
@@ -127,6 +191,7 @@ export const FileInput = ({
accept,
multiple,
capture,
importedFileName,
}: {
rules: UseControllerProps<any>['rules'];
control: Control<any>;
@@ -137,6 +202,7 @@ export const FileInput = ({
* Use a translation key!
**/
label?: string;
importedFileName: string | null;
} & InputProps &
Partial<HTMLInputElement>) => {
return (
@@ -155,6 +221,7 @@ export const FileInput = ({
accept={accept}
capture={capture}
multiple={multiple}
importedFileName={importedFileName}
></FileInputInside>
)}
/>

View File

@@ -98,7 +98,7 @@ export const InputInside = forwardRef<
></input>
{type === 'password' && (
<div
className="fill-background-10 absolute inset-y-0 right-0 pr-6 z-10 my-auto w-[16px] h-[16px]"
className="fill-background-10 absolute inset-y-0 right-0 pr-6 z-10 my-auto w-[16px] h-[16px] cursor-pointer"
onClick={togglePassword}
>
<EyeIcon width={16} closed={forceText}></EyeIcon>

View File

@@ -0,0 +1,70 @@
import { useRef, useState } from 'react';
import { PlayCircleIcon } from './icon/PlayIcon';
import { useDebouncedEffect } from '@/hooks/timeout';
import classNames from 'classnames';
export function PausableVideo({
src,
poster,
restartOnPause = false,
autoPlay = false,
}: {
src?: string;
poster?: string;
restartOnPause?: boolean;
autoPlay?: boolean;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [paused, setPaused] = useState(!autoPlay);
const [atStart, setAtStart] = useState(true);
function toggleVideo() {
if (!videoRef.current) return;
if (videoRef.current.paused) {
videoRef.current.play();
} else {
videoRef.current.pause();
if (restartOnPause) {
videoRef.current.currentTime = 0;
}
setAtStart(videoRef.current.currentTime === 0);
}
setPaused(videoRef.current.paused);
}
useDebouncedEffect(
() => {
if (paused) videoRef.current?.pause();
},
[paused],
250
);
return (
<button className="relative appearance-none" onClick={toggleVideo}>
<div
className={classNames(
'absolute w-[100px] h-[100px] top-0 bottom-0 left-0 right-0 m-auto',
'fill-background-20',
paused && !atStart && 'opacity-50'
)}
hidden={!paused}
>
<PlayCircleIcon width={100}></PlayCircleIcon>
</div>
<video
preload="auto"
ref={videoRef}
src={src}
poster={poster}
className="min-w-[12rem] w-[30rem]"
muted
loop
playsInline
autoPlay={autoPlay}
controls={false}
></video>
</button>
);
}

View File

@@ -7,12 +7,14 @@ export function ProgressBar({
height = 10,
colorClass = 'bg-accent-background-20',
animated = false,
bottom = false,
}: {
progress: number;
parts?: number;
height?: number;
colorClass?: string;
animated?: boolean;
bottom?: boolean;
}) {
return (
<div className="flex w-full flex-row gap-2">
@@ -25,6 +27,7 @@ export function ProgressBar({
colorClass={colorClass}
animated={animated}
parts={parts}
bottom={bottom}
></Bar>
))}
</div>
@@ -38,6 +41,7 @@ export function Bar({
height,
animated,
colorClass,
bottom,
}: {
index: number;
progress: number;
@@ -45,6 +49,7 @@ export function Bar({
height: number;
colorClass: string;
animated: boolean;
bottom: boolean;
}) {
const value = useMemo(
() => Math.min(Math.max((progress * parts) / 1 - index, 0), 1),
@@ -52,12 +57,16 @@ export function Bar({
);
return (
<div
className="flex relative flex-grow bg-background-50 rounded-lg overflow-hidden"
className={classNames(
'flex relative flex-grow bg-background-50 rounded-lg overflow-hidden',
bottom && 'rounded-t-none'
)}
style={{ height: `${height}px` }}
>
<div
className={classNames(
'rounded-lg overflow-hidden absolute top-0',
'overflow-hidden absolute top-0',
bottom ? 'rounded-none' : 'rounded-lg',
animated && 'transition-[width,background-color]',
colorClass
)}

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames';
import { Control, Controller } from 'react-hook-form';
import { Typography } from './Typography';
import { ReactNode } from 'react';
import { ReactNode, useMemo } from 'react';
export function Radio({
control,
@@ -12,6 +12,7 @@ export function Radio({
children,
// input props
disabled,
variant = 'secondary',
...props
}: {
control: Control<any>;
@@ -20,19 +21,36 @@ export function Radio({
value: string;
description?: string | null;
children?: ReactNode;
variant?: 'secondary' | 'none';
} & React.HTMLProps<HTMLInputElement>) {
const variantClasses = useMemo(() => {
const variantsMap = {
secondary: classNames({
'bg-background-60 hover:bg-background-50': !disabled,
'bg-background-80': disabled,
}),
none: '',
};
return variantsMap[variant];
}, [variant, disabled]);
return (
<Controller
control={control}
name={name}
render={({ field: { onChange, ref, name, value: checked } }) => (
<label
className={classNames('w-full p-3 rounded-md flex gap-3 border-2', {
'border-accent-background-30': value == checked,
'border-transparent': value != checked,
'bg-background-60 cursor-pointer hover:bg-background-50': !disabled,
'bg-background-80 cursor-not-allowed': disabled,
})}
className={classNames(
'w-full rounded-md flex gap-3 border-2 group/radio',
variantClasses,
{
'border-accent-background-30': value == checked,
'border-transparent': value != checked,
'cursor-pointer': !disabled,
'cursor-not-allowed': disabled,
'p-3': variant !== 'none',
}
)}
>
<input
type="radio"
@@ -48,7 +66,7 @@ export function Radio({
checked={value == checked}
{...props}
/>
<div className="flex flex-col gap-2 pointer-events-none">
<div className="flex flex-col gap-2 pointer-events-none w-full">
{children ? children : <Typography bold>{label}</Typography>}
{description && (
<Typography variant="standard" color="secondary">

View File

@@ -64,7 +64,7 @@ export function WarningBox({
>
<WarningIcon></WarningIcon>
</div>
<div className="flex flex-col">
<div className="flex flex-col justify-center">
<Typography
color="text-background-60"
whitespace={whitespace ? 'whitespace-pre-line' : undefined}

View File

@@ -54,7 +54,7 @@ export function Typography({
tag,
{
className: classNames([
'transition-colors',
'transition-colors hyphens-auto',
variant === 'mobile-title' &&
'xs:text-main-title mobile:text-section-title',
variant === 'main-title' && 'text-main-title',

View File

@@ -0,0 +1,138 @@
import classNames from 'classnames';
import { CheckIcon } from './icon/CheckIcon';
import { Typography } from './Typography';
import {
FC,
ReactNode,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useElemSize } from '@/hooks/layout';
import { useDebouncedEffect } from '@/hooks/timeout';
export function VerticalStep({
active,
index,
children,
title,
}: {
active: number;
index: number;
children: ReactNode;
title: string;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const refTop = useRef<HTMLLIElement | null>(null);
const [shouldAnimate, setShouldAnimate] = useState(false);
const { height } = useElemSize(ref);
const isSelected = active === index;
const isPrevious = active > index;
useEffect(() => {
if (!refTop.current) return;
if (isSelected)
setTimeout(() => {
if (!refTop.current) return;
refTop.current.scrollIntoView({ behavior: 'smooth' });
}, 500);
}, [isSelected]);
useLayoutEffect(() => {
setShouldAnimate(true);
}, [active]);
// Make it so it wont try to animate the size
// if we are not changing active step
useDebouncedEffect(
() => {
setShouldAnimate(false);
},
[active],
1000
);
return (
<li className="mb-10 scroll-m-4" ref={refTop}>
<span
className={classNames(
'absolute flex items-center justify-center w-8 h-8 rounded-full -left-4 transition-colors fill-background-10',
{
'bg-accent-background-20': isSelected || isPrevious,
'bg-background-40': !isSelected && !isPrevious,
}
)}
>
{isPrevious ? (
<CheckIcon></CheckIcon>
) : (
<Typography variant="section-title">{index + 1}</Typography>
)}
</span>
<div className="ml-7 pt-1.5">
<div className="px-1">
<Typography variant="section-title">{title}</Typography>
</div>
<div
style={{ height: !isSelected ? 0 : height }}
className={classNames('overflow-clip px-1', {
'duration-500 transition-[height]': shouldAnimate,
})}
>
<div ref={ref}>{children}</div>
</div>
</div>
</li>
);
}
type VerticalStepComponentType = FC<{
nextStep: () => void;
prevStep: () => void;
goTo: (id: string) => void;
isActive: boolean;
}>;
export type VerticalStep = {
title: string;
id?: string;
component: VerticalStepComponentType;
};
export default function VerticalStepper({ steps }: { steps: VerticalStep[] }) {
const [currStep, setStep] = useState(0);
const nextStep = () => {
if (currStep + 1 === steps.length) return;
setStep(currStep + 1);
};
const prevStep = () => {
if (currStep - 1 < 0) return;
setStep(currStep - 1);
};
const goTo = (id: string) => {
const step = steps.findIndex(({ id: stepId }) => stepId === id);
if (step === -1) throw new Error('step not found');
setStep(step);
};
return (
<ol className="relative border-l border-gray-700 text-gray-400">
{steps.map(({ title, component: StepComponent }, index) => (
<VerticalStep active={currStep} index={index} title={title} key={index}>
<StepComponent
nextStep={nextStep}
prevStep={prevStep}
goTo={goTo}
isActive={currStep === index}
></StepComponent>
</VerticalStep>
))}
</ol>
);
}

View File

@@ -1,7 +1,5 @@
import classNames from 'classnames';
export function CloseIcon({
className,
className = 'stroke-window-icon',
size = 35,
}: {
className?: string;
@@ -11,7 +9,7 @@ export function CloseIcon({
<svg
width={size}
height={size}
className={classNames('stroke-window-icon', className)}
className={className}
viewBox="0 0 31 29"
fill="none"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,12 +1,4 @@
import classNames from 'classnames';
export function FileIcon({
width = 24,
isDragging = false,
}: {
width?: number;
isDragging?: boolean;
}) {
export function FileIcon({ width = 24 }: { width?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -14,11 +6,10 @@ export function FileIcon({
width={width}
height="24"
fill="currentColor"
className={classNames('transition-transform', isDragging && 'scale-150')}
>
<path
fillRule="evenodd"
d="M5.625 1.5H9a3.75 3.75 0 013.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 013.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 01-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875zm6.905 9.97a.75.75 0 00-1.06 0l-3 3a.75.75 0 101.06 1.06l1.72-1.72V18a.75.75 0 001.5 0v-4.19l1.72 1.72a.75.75 0 101.06-1.06l-3-3z"
d="M 5.625 1.5 L 9 1.5 C 11.071 1.5 12.75 3.179 12.75 5.25 L 12.75 7.125 C 12.75 8.161 13.59 9 14.625 9 L 16.5 9 C 18.571 9 20.25 10.679 20.25 12.75 L 20.25 20.625 C 20.25 21.66 19.41 22.5 18.375 22.5 L 5.625 22.5 C 4.589 22.5 3.75 21.661 3.75 20.625 L 3.75 3.375 C 3.75 2.339 4.59 1.5 5.625 1.5 Z"
clipRule="evenodd"
/>
<path d="M14.25 5.25a5.23 5.23 0 00-1.279-3.434 9.768 9.768 0 016.963 6.963A5.23 5.23 0 0016.5 7.5h-1.875a.375.375 0 01-.375-.375V5.25z" />

View File

@@ -0,0 +1,27 @@
import classNames from 'classnames';
export function UploadFileIcon({
width = 24,
isDragging = false,
}: {
width?: number;
isDragging?: boolean;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={width}
height="24"
fill="currentColor"
className={classNames('transition-transform', isDragging && 'scale-150')}
>
<path
fillRule="evenodd"
d="M5.625 1.5H9a3.75 3.75 0 013.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 013.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 01-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875zm6.905 9.97a.75.75 0 00-1.06 0l-3 3a.75.75 0 101.06 1.06l1.72-1.72V18a.75.75 0 001.5 0v-4.19l1.72 1.72a.75.75 0 101.06-1.06l-3-3z"
clipRule="evenodd"
/>
<path d="M14.25 5.25a5.23 5.23 0 00-1.279-3.434 9.768 9.768 0 016.963 6.963A5.23 5.23 0 0016.5 7.5h-1.875a.375.375 0 01-.375-.375V5.25z" />
</svg>
);
}

View File

@@ -0,0 +1,308 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { useFirmwareTool } from '@/hooks/firmware-tool';
import { Button } from '@/components/commons/Button';
import { Control, useForm } from 'react-hook-form';
import {
CreateImuConfigDTO,
Imudto,
} from '@/firmware-tool-api/firmwareToolSchemas';
import { Dropdown } from '@/components/commons/Dropdown';
import { TrashIcon } from '@/components/commons/icon/TrashIcon';
import { Input } from '@/components/commons/Input';
import {
ArrowDownIcon,
ArrowUpIcon,
} from '@/components/commons/icon/ArrowIcons';
import { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { useElemSize } from '@/hooks/layout';
import { useGetFirmwaresImus } from '@/firmware-tool-api/firmwareToolComponents';
import { CheckBox } from '@/components/commons/Checkbox';
function IMUCard({
control,
imuTypes,
hasIntPin,
index,
onDelete,
}: {
imuTypes: Imudto[];
hasIntPin: boolean;
control: Control<{ imus: CreateImuConfigDTO[] }, any>;
index: number;
onDelete: () => void;
}) {
const { l10n } = useLocalization();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
const { height } = useElemSize(ref);
return (
<div className="rounded-lg flex flex-col text-background-10">
<div className="flex gap-3 p-4 shadow-md bg-background-50 rounded-md">
<div className="bg-accent-background-40 rounded-full h-8 w-9 mt-[28px] flex flex-col items-center justify-center">
<Typography variant="section-title" bold>
{index + 1}
</Typography>
</div>
<div className={'w-full flex flex-col gap-2'}>
<div className="grid xs-settings:grid-cols-2 mobile-settings:grid-cols-1 gap-3 fill-background-10">
<label className="flex flex-col justify-end gap-1">
<Localized id="firmware_tool-add_imus_step-imu_type-label"></Localized>
<Dropdown
control={control}
name={`imus[${index}].type`}
items={imuTypes.map(({ type }) => ({
label: type.split('_').slice(1).join(' '),
value: type,
}))}
variant="secondary"
maxHeight="25vh"
placeholder={l10n.getString(
'firmware_tool-add_imus_step-imu_type-placeholder'
)}
direction="down"
display="block"
></Dropdown>
</label>
<Localized
id="firmware_tool-add_imus_step-imu_rotation"
attrs={{ label: true, placeholder: true }}
>
<Input
control={control}
rules={{
required: true,
}}
type="number"
name={`imus[${index}].rotation`}
variant="primary"
label="Rotation Degree"
placeholder="Rotation Degree"
autocomplete="off"
></Input>
</Localized>
</div>
<div
className="duration-500 transition-[height] overflow-hidden"
style={{ height: open ? height : 0 }}
>
<div
ref={ref}
className="grid xs-settings:grid-cols-2 mobile-settings:grid-cols-1 gap-2"
>
<Localized
id="firmware_tool-add_imus_step-scl_pin"
attrs={{ label: true, placeholder: true }}
>
<Input
control={control}
rules={{ required: true }}
type="text"
name={`imus[${index}].sclPin`}
variant="primary"
autocomplete="off"
></Input>
</Localized>
<Localized
id="firmware_tool-add_imus_step-sda_pin"
attrs={{ label: true, placeholder: true }}
>
<Input
control={control}
rules={{ required: true }}
type="text"
name={`imus[${index}].sdaPin`}
variant="primary"
label="SDA Pin"
placeholder="SDA Pin"
autocomplete="off"
></Input>
</Localized>
{hasIntPin && (
<Localized
id="firmware_tool-add_imus_step-int_pin"
attrs={{ label: true, placeholder: true }}
>
<Input
control={control}
rules={{ required: true }}
type="text"
name={`imus[${index}].intPin`}
variant="primary"
autocomplete="off"
></Input>
</Localized>
)}
<label className="flex flex-col justify-end gap-1 md:pt-3 sm:pt-3">
<Localized
id="firmware_tool-add_imus_step-optional_tracker"
attrs={{ label: true }}
>
<CheckBox
control={control}
name={`imus[${index}].optional`}
variant="toggle"
color="tertiary"
label=""
></CheckBox>
</Localized>
</label>
</div>
</div>
</div>
<div className="flex flex-col items-center mt-[25px] fill-background-10">
<Button variant="quaternary" rounded onClick={onDelete}>
<TrashIcon size={15}></TrashIcon>
</Button>
</div>
</div>
<div
className="items-center flex justify-center hover:bg-background-60 bg-background-80 -mt-0.5 transition-colors duration-300 fill-background-10 rounded-b-lg pt-1 pb-0.5"
onClick={() => setOpen(!open)}
>
<Typography>
{l10n.getString(
open
? 'firmware_tool-add_imus_step-show_less'
: 'firmware_tool-add_imus_step-show_more'
)}
</Typography>
{!open && <ArrowDownIcon></ArrowDownIcon>}
{open && <ArrowUpIcon></ArrowUpIcon>}
</div>
</div>
);
}
export function AddImusStep({
nextStep,
prevStep,
isActive,
}: {
nextStep: () => void;
prevStep: () => void;
goTo: (id: string) => void;
isActive: boolean;
}) {
const { l10n } = useLocalization();
const {
isStepLoading: isLoading,
newConfig,
defaultConfig,
updateImus,
} = useFirmwareTool();
const {
control,
formState: { isValid: isValidState },
reset,
watch,
} = useForm<{ imus: CreateImuConfigDTO[] }>({
defaultValues: {
imus: [],
},
reValidateMode: 'onChange',
mode: 'onChange',
});
useEffect(() => {
reset({
imus: newConfig?.imusConfig || [],
});
}, [isActive]);
const { isFetching, data: imuTypes } = useGetFirmwaresImus({});
const isAckchuallyLoading = isFetching || isLoading;
const form = watch();
const addImu = () => {
if (!newConfig || !defaultConfig) throw new Error('unreachable');
const imuPinToAdd =
defaultConfig.imuDefaults[form.imus.length ?? 0] ??
defaultConfig.imuDefaults[0];
const imuTypeToAdd: CreateImuConfigDTO['type'] =
form.imus[0]?.type ?? 'IMU_BNO085';
reset({
imus: [...form.imus, { ...imuPinToAdd, type: imuTypeToAdd }],
});
};
const deleteImu = (index: number) => {
reset({ imus: form.imus.filter((_, i) => i !== index) });
};
return (
<>
<div className="flex flex-col w-full">
<div className="flex flex-col gap-4">
<Typography color="secondary">
{l10n.getString('firmware_tool-board_pins_step-description')}
</Typography>
</div>
<div className="my-4 flex flex-col gap-4">
{!isAckchuallyLoading && imuTypes && newConfig && (
<>
<div className="flex flex-col gap-3">
<div
className={classNames(
'grid gap-2 px-2',
form.imus.length > 1
? 'md:grid-cols-2 mobile-settings:grid-cols-1'
: 'grid-cols-1'
)}
>
{form.imus.map((imu, index) => (
<IMUCard
control={control}
imuTypes={imuTypes}
key={`${index}:${imu.type}`}
hasIntPin={
imuTypes?.find(({ type: t }) => t == imu.type)
?.hasIntPin ?? false
}
index={index}
onDelete={() => deleteImu(index)}
></IMUCard>
))}
</div>
<div className="flex justify-center">
<Localized id="firmware_tool-add_imus_step-add_more">
<Button variant="primary" onClick={addImu}></Button>
</Localized>
</div>
</div>
<div className="flex justify-between">
<Localized id="firmware_tool-previous_step">
<Button variant="tertiary" onClick={prevStep}></Button>
</Localized>
<Localized id="firmware_tool-next_step">
<Button
variant="primary"
disabled={!isValidState || form.imus.length === 0}
onClick={() => {
updateImus(form.imus);
nextStep();
}}
></Button>
</Localized>
</div>
</>
)}
{isAckchuallyLoading && (
<div className="flex justify-center flex-col items-center gap-3 h-44">
<LoaderIcon slimeState={SlimeState.JUMPY}></LoaderIcon>
<Localized id="firmware_tool-loading">
<Typography color="secondary"></Typography>
</Localized>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,198 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { useFirmwareTool } from '@/hooks/firmware-tool';
import { Button } from '@/components/commons/Button';
import { useForm } from 'react-hook-form';
import { Input } from '@/components/commons/Input';
import { useEffect } from 'react';
import { CheckBox } from '@/components/commons/Checkbox';
import { CreateBoardConfigDTO } from '@/firmware-tool-api/firmwareToolSchemas';
import { Dropdown } from '@/components/commons/Dropdown';
import classNames from 'classnames';
import { useGetFirmwaresBatteries } from '@/firmware-tool-api/firmwareToolComponents';
export type BoardPinsForm = Omit<CreateBoardConfigDTO, 'type'>;
export function BoardPinsStep({
nextStep,
prevStep,
}: {
nextStep: () => void;
prevStep: () => void;
}) {
const { l10n } = useLocalization();
const {
isStepLoading: isLoading,
defaultConfig,
updatePins,
} = useFirmwareTool();
const { isFetching, data: batteryTypes } = useGetFirmwaresBatteries({});
const { reset, control, watch, formState } = useForm<BoardPinsForm>({
reValidateMode: 'onChange',
defaultValues: {
batteryResistances: [0, 0, 0],
},
mode: 'onChange',
});
const formValue = watch();
const ledEnabled = watch('enableLed');
const batteryType = watch('batteryType');
useEffect(() => {
if (!defaultConfig) return;
const { type, ...resetConfig } = defaultConfig.boardConfig;
reset({
...resetConfig,
});
}, [defaultConfig]);
return (
<>
<div className="flex flex-col w-full justify-between text-background-10">
<div className="flex flex-col gap-4">
<Typography color="secondary">
{l10n.getString('firmware_tool-board_pins_step-description')}
</Typography>
</div>
<div className="my-4 p-2">
{!isLoading && !isFetching && batteryTypes && (
<form className="flex flex-col gap-2">
<div className="grid xs-settings:grid-cols-2 mobile-settings:grid-cols-1 gap-2">
<label className="flex flex-col justify-end">
{/* Allows to have the right spacing at the top of the checkbox */}
<CheckBox
control={control}
color="tertiary"
name="enableLed"
variant="toggle"
outlined
label={l10n.getString(
'firmware_tool-board_pins_step-enable_led'
)}
></CheckBox>
</label>
<Localized
id="firmware_tool-board_pins_step-led_pin"
attrs={{ placeholder: true, label: true }}
>
<Input
control={control}
rules={{ required: true }}
type="text"
name="ledPin"
variant="secondary"
disabled={!ledEnabled}
></Input>
</Localized>
</div>
<div
className={classNames(
batteryType === 'BAT_EXTERNAL' &&
'bg-background-80 p-2 rounded-md',
'transition-all duration-500 flex-col flex gap-2'
)}
>
<Dropdown
control={control}
name="batteryType"
variant="primary"
placeholder={l10n.getString(
'firmware_tool-board_pins_step-battery_type'
)}
direction="up"
display="block"
items={batteryTypes.map((battery) => ({
label: l10n.getString(
'firmware_tool-board_pins_step-battery_type-' + battery
),
value: battery,
}))}
></Dropdown>
{batteryType === 'BAT_EXTERNAL' && (
<div className="grid grid-cols-2 gap-2">
<Localized
id="firmware_tool-board_pins_step-battery_sensor_pin"
attrs={{ placeholder: true, label: true }}
>
<Input
control={control}
rules={{ required: true }}
type="text"
name="batteryPin"
variant="secondary"
></Input>
</Localized>
<Localized
id="firmware_tool-board_pins_step-battery_resistor"
attrs={{ placeholder: true, label: true }}
>
<Input
control={control}
rules={{ required: true, min: 0 }}
type="number"
name="batteryResistances[0]"
variant="secondary"
label="Battery Resistor"
placeholder="Battery Resistor"
></Input>
</Localized>
<Localized
id="firmware_tool-board_pins_step-battery_shield_resistor-0"
attrs={{ placeholder: true, label: true }}
>
<Input
control={control}
rules={{ required: true, min: 0 }}
type="number"
name="batteryResistances[1]"
variant="secondary"
></Input>
</Localized>
<Localized
id="firmware_tool-board_pins_step-battery_shield_resistor-1"
attrs={{ placeholder: true, label: true }}
>
<Input
control={control}
rules={{ required: true, min: 0 }}
type="number"
name="batteryResistances[2]"
variant="secondary"
></Input>
</Localized>
</div>
)}
</div>
</form>
)}
{(isLoading || isFetching) && (
<div className="flex justify-center flex-col items-center gap-3 h-44">
<LoaderIcon slimeState={SlimeState.JUMPY}></LoaderIcon>
<Localized id="firmware_tool-loading">
<Typography color="secondary"></Typography>
</Localized>
</div>
)}
</div>
<div className="flex justify-between">
<Localized id="firmware_tool-previous_step">
<Button variant="tertiary" onClick={prevStep}></Button>
</Localized>
<Localized id="firmware_tool-ok">
<Button
variant="primary"
disabled={Object.keys(formState.errors).length !== 0}
onClick={() => {
updatePins(formValue);
nextStep();
}}
></Button>
</Localized>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,111 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { fetchPostFirmwaresBuild } from '@/firmware-tool-api/firmwareToolComponents';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { useFirmwareTool } from '@/hooks/firmware-tool';
import {
BuildResponseDTO,
CreateBuildFirmwareDTO,
} from '@/firmware-tool-api/firmwareToolSchemas';
import { useEffect, useMemo } from 'react';
import { firmwareToolBaseUrl } from '@/firmware-tool-api/firmwareToolFetcher';
import { Button } from '@/components/commons/Button';
export function BuildStep({
isActive,
goTo,
nextStep,
}: {
nextStep: () => void;
prevStep: () => void;
goTo: (id: string) => void;
isActive: boolean;
}) {
const { l10n } = useLocalization();
const { isGlobalLoading, newConfig, setBuildStatus, buildStatus } =
useFirmwareTool();
const startBuild = async () => {
try {
const res = await fetchPostFirmwaresBuild({
body: newConfig as CreateBuildFirmwareDTO,
});
setBuildStatus(res);
if (res.status !== 'DONE') {
const events = new EventSource(
`${firmwareToolBaseUrl}/firmwares/build-status/${res.id}`
);
events.onmessage = ({ data }) => {
const buildEvent: BuildResponseDTO = JSON.parse(data);
setBuildStatus(buildEvent);
};
}
} catch (e) {
console.error(e);
setBuildStatus({ id: '', status: 'ERROR' });
}
};
useEffect(() => {
if (!isActive) return;
startBuild();
}, [isActive]);
useEffect(() => {
if (!isActive) return;
if (buildStatus.status === 'DONE') {
nextStep();
}
}, [buildStatus]);
const hasPendingBuild = useMemo(
() => !['DONE', 'ERROR'].includes(buildStatus.status),
[buildStatus.status]
);
return (
<>
<div className="flex flex-col w-full">
<div className="flex flex-grow flex-col gap-4">
<Typography color="secondary">
{l10n.getString('firmware_tool-build_step-description')}
</Typography>
</div>
<div className="my-4">
{!isGlobalLoading && (
<div className="flex justify-center flex-col items-center gap-3 h-44">
<LoaderIcon
slimeState={
buildStatus.status !== 'ERROR'
? SlimeState.JUMPY
: SlimeState.SAD
}
></LoaderIcon>
<Typography variant="section-title" color="secondary">
{l10n.getString('firmware_tool-build-' + buildStatus.status)}
</Typography>
</div>
)}
{isGlobalLoading && (
<div className="flex justify-center flex-col items-center gap-3 h-44">
<LoaderIcon slimeState={SlimeState.JUMPY}></LoaderIcon>
<Localized id="firmware_tool-loading">
<Typography color="secondary"></Typography>
</Localized>
</div>
)}
</div>
<div className="flex justify-end">
<Localized id="firmware_tool-retry">
<Button
variant="secondary"
disabled={hasPendingBuild}
onClick={() => goTo('FlashingMethod')}
></Button>
</Localized>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,132 @@
import { CHECKBOX_CLASSES } from '@/components/commons/Checkbox';
import { ProgressBar } from '@/components/commons/ProgressBar';
import { Typography } from '@/components/commons/Typography';
import { firmwareUpdateErrorStatus } from '@/hooks/firmware-tool';
import { useLocalization } from '@fluent/react';
import classNames from 'classnames';
import { Control, Controller } from 'react-hook-form';
import {
FirmwareUpdateStatus,
TrackerStatus as TrackerStatusEnum,
} from 'solarxr-protocol';
import { TrackerStatus } from '@/components/tracker/TrackerStatus';
interface DeviceCardProps {
deviceNames: string[];
status?: FirmwareUpdateStatus;
online?: boolean | null;
}
interface DeviceCardControlProps {
control?: Control<any>;
name?: string;
progress?: number;
disabled?: boolean;
}
export function DeviceCardContent({ deviceNames, status }: DeviceCardProps) {
const { l10n } = useLocalization();
return (
<div className="p-2 flex h-full gap-2 justify-between flex-col">
<div className="flex flex-row flex-wrap gap-2 items-center h-full">
{deviceNames.map((name) => (
<span
key={name}
className="p-1 px-3 rounded-l-full rounded-r-full bg-background-40"
>
<Typography>{name}</Typography>
</span>
))}
</div>
{status !== undefined ? (
<Typography color="secondary">
{l10n.getString(
'firmware_update-status-' + FirmwareUpdateStatus[status]
)}
</Typography>
) : (
<Typography> </Typography> // placeholder so the size of the component does not change if there is no status
)}
</div>
);
}
export function DeviceCardControl({
control,
name,
progress,
disabled = false,
online = null,
...props
}: DeviceCardControlProps & DeviceCardProps) {
return (
<div
className={classNames(
'rounded-md bg-background-60 h-[86px] pt-2 flex flex-col justify-between border-2 relative',
props.status &&
firmwareUpdateErrorStatus.includes(props.status) &&
'border-status-critical',
props.status === FirmwareUpdateStatus.DONE && 'border-status-success',
(!props.status ||
(props.status !== FirmwareUpdateStatus.DONE &&
!firmwareUpdateErrorStatus.includes(props.status))) &&
'border-transparent'
)}
>
{control && name ? (
<Controller
control={control}
name={name}
render={({ field: { onChange, value, ref } }) => (
<label className="flex flex-row gap-2 px-4 h-full">
<div className="flex justify-center flex-col">
<input
ref={ref}
onChange={onChange}
className={CHECKBOX_CLASSES}
checked={value || false}
type="checkbox"
disabled={disabled}
></input>
</div>
<div className="w-full">
<DeviceCardContent {...props}></DeviceCardContent>
</div>
</label>
)}
></Controller>
) : (
<div className="px-2 h-full">
<DeviceCardContent {...props}></DeviceCardContent>
</div>
)}
<div
className={classNames(
'align-bottom',
props.status != FirmwareUpdateStatus.UPLOADING ||
progress === undefined
? 'opacity-0'
: 'opacity-100'
)}
>
<ProgressBar
progress={progress || 0}
bottom
height={6}
colorClass="bg-accent-background-20"
></ProgressBar>
</div>
{online !== null && (
<div className="absolute top-2 right-2">
<TrackerStatus
status={
online ? TrackerStatusEnum.OK : TrackerStatusEnum.DISCONNECTED
}
></TrackerStatus>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
FirmwareToolContextC,
useFirmwareToolContext,
} from '@/hooks/firmware-tool';
import { AddImusStep } from './AddImusStep';
import { SelectBoardStep } from './SelectBoardStep';
import { BoardPinsStep } from './BoardPinsStep';
import VerticalStepper from '@/components/commons/VerticalStepper';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { Button } from '@/components/commons/Button';
import { SelectFirmwareStep } from './SelectFirmwareStep';
import { BuildStep } from './BuildStep';
import { FlashingMethodStep } from './FlashingMethodStep';
import { FlashingStep } from './FlashingStep';
import { FlashBtnStep } from './FlashBtnStep';
import { FirmwareUpdateMethod } from 'solarxr-protocol';
import { useMemo } from 'react';
function FirmwareToolContent() {
const { l10n } = useLocalization();
const context = useFirmwareToolContext();
const { isError, isGlobalLoading: isLoading, retry, isCompatible } = context;
const steps = useMemo(() => {
const steps = [
{
id: 'SelectBoard',
component: SelectBoardStep,
title: l10n.getString('firmware_tool-board_step'),
},
{
component: BoardPinsStep,
title: l10n.getString('firmware_tool-board_pins_step'),
},
{
component: AddImusStep,
title: l10n.getString('firmware_tool-add_imus_step'),
},
{
id: 'SelectFirmware',
component: SelectFirmwareStep,
title: l10n.getString('firmware_tool-select_firmware_step'),
},
{
component: FlashingMethodStep,
id: 'FlashingMethod',
title: l10n.getString('firmware_tool-flash_method_step'),
},
{
component: BuildStep,
title: l10n.getString('firmware_tool-build_step'),
},
{
component: FlashingStep,
title: l10n.getString('firmware_tool-flashing_step'),
},
];
if (
context.defaultConfig?.needBootPress &&
context.selectedDevices?.find(
({ type }) => type === FirmwareUpdateMethod.SerialFirmwareUpdate
)
) {
steps.splice(5, 0, {
component: FlashBtnStep,
title: l10n.getString('firmware_tool-flashbtn_step'),
});
}
return steps;
}, [context.defaultConfig?.needBootPress, context.selectedDevices, l10n]);
return (
<FirmwareToolContextC.Provider value={context}>
<div className="flex flex-col bg-background-70 p-4 rounded-md">
<Typography variant="main-title">
{l10n.getString('firmware_tool')}
</Typography>
<div className="flex flex-col pt-2 pb-4">
<>
{l10n
.getString('firmware_tool-description')
.split('\n')
.map((line, i) => (
<Typography color="secondary" key={i}>
{line}
</Typography>
))}
</>
</div>
<div className="m-4 h-full">
{isError && (
<div className="w-full flex flex-col justify-center items-center gap-3 h-full">
<LoaderIcon slimeState={SlimeState.SAD}></LoaderIcon>
{!isCompatible ? (
<Localized id="firmware_tool-not_compatible">
<Typography variant="section-title"></Typography>
</Localized>
) : (
<Localized id="firmware_tool-not_available">
<Typography variant="section-title"></Typography>
</Localized>
)}
<Localized id="firmware_tool-retry">
<Button variant="primary" onClick={retry}></Button>
</Localized>
</div>
)}
{isLoading && (
<div className="w-full flex flex-col justify-center items-center gap-3 h-full">
<LoaderIcon slimeState={SlimeState.JUMPY}></LoaderIcon>
<Localized id="firmware_tool-loading">
<Typography variant="section-title"></Typography>
</Localized>
</div>
)}
{!isError && !isLoading && <VerticalStepper steps={steps} />}
</div>
</div>
</FirmwareToolContextC.Provider>
);
}
export function FirmwareToolSettings() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false, // default: true
},
},
});
return (
<QueryClientProvider client={queryClient}>
<FirmwareToolContent />
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,86 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { Button } from '@/components/commons/Button';
import {
boardTypeToFirmwareToolBoardType,
useFirmwareTool,
} from '@/hooks/firmware-tool';
import { BoardType } from 'solarxr-protocol';
export function FlashBtnStep({
nextStep,
}: {
nextStep: () => void;
prevStep: () => void;
goTo: (id: string) => void;
isActive: boolean;
}) {
const { l10n } = useLocalization();
const { defaultConfig } = useFirmwareTool();
return (
<>
<div className="flex flex-col w-full">
<div className="flex flex-grow flex-col gap-4">
<Typography color="secondary">
{l10n.getString('firmware_tool-flashbtn_step-description')}
</Typography>
{defaultConfig?.boardConfig.type ===
boardTypeToFirmwareToolBoardType[BoardType.SLIMEVR] ? (
<>
<Typography variant="standard" whitespace="whitespace-pre">
{l10n.getString('firmware_tool-flashbtn_step-board_SLIMEVR')}
</Typography>
<div className="gap-2 grid lg:grid-cols-3 md:grid-cols-2 mobile:grid-cols-1">
<div className="bg-background-80 p-2 rounded-lg gap-2 flex flex-col justify-between">
<Typography variant="main-title">R11</Typography>
<Typography variant="standard">
{l10n.getString(
'firmware_tool-flashbtn_step-board_SLIMEVR-r11'
)}
</Typography>
<img src="/images/R11_board_reset.webp"></img>
</div>
<div className="bg-background-80 p-2 rounded-lg gap-2 flex flex-col justify-between">
<Typography variant="main-title">R12</Typography>
<Typography variant="standard">
{l10n.getString(
'firmware_tool-flashbtn_step-board_SLIMEVR-r12'
)}
</Typography>
<img src="/images/R12_board_reset.webp"></img>
</div>
<div className="bg-background-80 p-2 rounded-lg gap-2 flex flex-col justify-between">
<Typography variant="main-title">R14</Typography>
<Typography variant="standard">
{l10n.getString(
'firmware_tool-flashbtn_step-board_SLIMEVR-r14'
)}
</Typography>
<img src="/images/R14_board_reset_sw.webp"></img>
</div>
</div>
</>
) : (
<>
<Typography variant="standard" whitespace="whitespace-pre">
{l10n.getString('firmware_tool-flashbtn_step-board_OTHER')}
</Typography>
</>
)}
<div className="flex justify-end">
<Localized id="firmware_tool-next_step">
<Button
variant="primary"
onClick={() => {
nextStep();
}}
></Button>
</Localized>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,421 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import {
boardTypeToFirmwareToolBoardType,
useFirmwareTool,
} from '@/hooks/firmware-tool';
import { Control, UseFormReset, UseFormWatch, useForm } from 'react-hook-form';
import { Radio } from '@/components/commons/Radio';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { useEffect, useLayoutEffect, useState } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import {
BoardType,
DeviceDataT,
FirmwareUpdateMethod,
NewSerialDeviceResponseT,
RpcMessage,
SerialDeviceT,
SerialDevicesRequestT,
SerialDevicesResponseT,
TrackerStatus,
} from 'solarxr-protocol';
import { Button } from '@/components/commons/Button';
import { useAppContext } from '@/hooks/app';
import { Input } from '@/components/commons/Input';
import { Dropdown } from '@/components/commons/Dropdown';
import { useOnboarding } from '@/hooks/onboarding';
import { DeviceCardControl } from './DeviceCard';
import { getTrackerName } from '@/hooks/tracker';
import { ObjectSchema, object, string } from 'yup';
interface FlashingMethodForm {
flashingMethod?: string;
serial?: {
selectedDevicePort: string;
ssid: string;
password?: string;
};
ota?: {
selectedDevices: { [key: string]: boolean };
};
}
function SerialDevicesList({
control,
watch,
reset,
}: {
control: Control<FlashingMethodForm>;
watch: UseFormWatch<FlashingMethodForm>;
reset: UseFormReset<FlashingMethodForm>;
}) {
const { l10n } = useLocalization();
const { selectDevices } = useFirmwareTool();
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const [devices, setDevices] = useState<Record<string, SerialDeviceT>>({});
const { state, setWifiCredentials } = useOnboarding();
useLayoutEffect(() => {
sendRPCPacket(RpcMessage.SerialDevicesRequest, new SerialDevicesRequestT());
selectDevices(null);
reset({
flashingMethod: FirmwareUpdateMethod.SerialFirmwareUpdate.toString(),
serial: {
...state.wifi,
selectedDevicePort: undefined,
},
ota: undefined,
});
}, []);
useRPCPacket(
RpcMessage.SerialDevicesResponse,
(res: SerialDevicesResponseT) => {
setDevices((old) =>
res.devices.reduce(
(curr, device) => ({
...curr,
[device?.port?.toString() ?? 'unknown']: device,
}),
old
)
);
}
);
useRPCPacket(
RpcMessage.NewSerialDeviceResponse,
({ device }: NewSerialDeviceResponseT) => {
if (device?.port)
setDevices((old) => ({
...old,
[device?.port?.toString() ?? 'unknown']: device,
}));
}
);
const serialValues = watch('serial');
useEffect(() => {
if (!serialValues) {
selectDevices(null);
return;
}
setWifiCredentials(serialValues.ssid, serialValues.password);
if (
serialValues.selectedDevicePort &&
devices[serialValues.selectedDevicePort]
) {
selectDevices([
{
type: FirmwareUpdateMethod.SerialFirmwareUpdate,
deviceId: serialValues.selectedDevicePort,
deviceNames: [
devices[serialValues.selectedDevicePort].name?.toString() ??
'unknown',
],
},
]);
} else {
selectDevices(null);
}
}, [JSON.stringify(serialValues), devices]);
return (
<>
<Localized id="firmware_tool-flash_method_serial-wifi">
<Typography variant="section-title"></Typography>
</Localized>
<div className="grid xs-settings:grid-cols-2 mobile-settings:grid-cols-1 gap-3 text-background-10">
<Localized
id="onboarding-wifi_creds-ssid"
attrs={{ placeholder: true, label: true }}
>
<Input
control={control}
name="serial.ssid"
label="SSID"
variant="secondary"
/>
</Localized>
<Localized
id="onboarding-wifi_creds-password"
attrs={{ placeholder: true, label: true }}
>
<Input
control={control}
name="serial.password"
type="password"
variant="secondary"
/>
</Localized>
</div>
<Localized id="firmware_tool-flash_method_serial-devices-label">
<Typography variant="section-title"></Typography>
</Localized>
{Object.keys(devices).length === 0 ? (
<Localized id="firmware_tool-flash_method_serial-no_devices">
<Typography variant="standard" color="secondary"></Typography>
</Localized>
) : (
<Dropdown
control={control}
name="serial.selectedDevicePort"
items={Object.keys(devices).map((port) => ({
label: devices[port].name?.toString() ?? 'unknown',
value: port,
}))}
placeholder={l10n.getString(
'firmware_tool-flash_method_serial-devices-placeholder'
)}
display="block"
direction="down"
></Dropdown>
)}
</>
);
}
function OTADevicesList({
control,
watch,
reset,
}: {
control: Control<FlashingMethodForm>;
watch: UseFormWatch<FlashingMethodForm>;
reset: UseFormReset<FlashingMethodForm>;
}) {
const { l10n } = useLocalization();
const { selectDevices, newConfig } = useFirmwareTool();
const { state } = useAppContext();
const devices =
state.datafeed?.devices.filter(({ trackers, hardwareInfo }) => {
// We make sure the device is not one of these types
if (
hardwareInfo?.officialBoardType === BoardType.SLIMEVR_LEGACY ||
hardwareInfo?.officialBoardType === BoardType.SLIMEVR_DEV ||
hardwareInfo?.officialBoardType === BoardType.CUSTOM
)
return false;
// if the device has no trackers it is prob misconfigured so we skip for safety
if (trackers.length <= 0) return false;
// We make sure that the tracker is in working condition before doing ota as an error (that could be hardware)
// could cause an error during the update
if (!trackers.every(({ status }) => status === TrackerStatus.OK))
return false;
const boardType = hardwareInfo?.officialBoardType ?? BoardType.UNKNOWN;
return (
boardTypeToFirmwareToolBoardType[boardType] ===
newConfig?.boardConfig?.type
);
}) || [];
const deviceNames = ({ trackers }: DeviceDataT) =>
trackers
.map(({ info }) => getTrackerName(l10n, info))
.filter((i): i is string => !!i);
const selectedDevices = watch('ota.selectedDevices');
useLayoutEffect(() => {
reset({
flashingMethod: FirmwareUpdateMethod.OTAFirmwareUpdate.toString(),
ota: {
selectedDevices: devices.reduce(
(curr, { id }) => ({ ...curr, [id?.id ?? 0]: false }),
{}
),
},
serial: undefined,
});
selectDevices(null);
}, []);
useEffect(() => {
if (selectedDevices) {
selectDevices(
Object.keys(selectedDevices)
.filter((d) => selectedDevices[d])
.map((id) => id.substring('id-'.length))
.map((id) => {
const device = devices.find(
({ id: dId }) => id === dId?.id.toString()
);
if (!device) throw new Error('no device found');
return {
type: FirmwareUpdateMethod.OTAFirmwareUpdate,
deviceId: id,
deviceNames: deviceNames(device),
};
})
);
}
}, [JSON.stringify(selectedDevices)]);
return (
<>
<Localized id="firmware_tool-flash_method_ota-devices">
<Typography variant="section-title"></Typography>
</Localized>
{devices.length === 0 && (
<Localized id="firmware_tool-flash_method_ota-no_devices">
<Typography color="secondary"></Typography>
</Localized>
)}
<div className="grid xs-settings:grid-cols-2 mobile-settings:grid-cols-1 gap-2">
{devices.map((device) => (
<DeviceCardControl
control={control}
key={device.id?.id ?? 0}
name={`ota.selectedDevices.id-${device.id?.id ?? 0}`}
deviceNames={deviceNames(device)}
></DeviceCardControl>
))}
</div>
</>
);
}
export function FlashingMethodStep({
nextStep,
prevStep,
}: {
nextStep: () => void;
prevStep: () => void;
isActive: boolean;
}) {
const { l10n } = useLocalization();
const { isGlobalLoading, selectedDevices } = useFirmwareTool();
const {
control,
watch,
reset,
formState: { isValid },
} = useForm<FlashingMethodForm>({
reValidateMode: 'onChange',
mode: 'onChange',
resolver: yupResolver(
object({
flashingMethod: string().optional(),
serial: object().when('flashingMethod', {
is: FirmwareUpdateMethod.SerialFirmwareUpdate.toString(),
then: (s) =>
s
.shape({
selectedDevicePort: string().required(),
ssid: string().required(
l10n.getString('onboarding-wifi_creds-ssid-required')
),
password: string(),
})
.required(),
otherwise: (s) => s.optional(),
}),
ota: object().when('flashingMethod', {
is: FirmwareUpdateMethod.OTAFirmwareUpdate.toString(),
then: (s) =>
s
.shape({
selectedDevices: object(),
})
.required(),
otherwise: (s) => s.optional(),
}),
}) as ObjectSchema<FlashingMethodForm>
),
});
const flashingMethod = watch('flashingMethod');
return (
<>
<div className="flex flex-col w-full">
<div className="flex flex-grow flex-col gap-4">
<Typography color="secondary">
{l10n.getString('firmware_tool-flash_method_step-description')}
</Typography>
</div>
<div className="my-4">
{!isGlobalLoading && (
<div className="flex flex-col gap-3">
<div className="grid xs-settings:grid-cols-2 mobile-settings:grid-cols-1 gap-3">
<Localized
id="firmware_tool-flash_method_step-ota"
attrs={{ label: true, description: true }}
>
<Radio
control={control}
name="flashingMethod"
value={FirmwareUpdateMethod.OTAFirmwareUpdate.toString()}
label=""
></Radio>
</Localized>
<Localized
id="firmware_tool-flash_method_step-serial"
attrs={{ label: true, description: true }}
>
<Radio
control={control}
name="flashingMethod"
value={FirmwareUpdateMethod.SerialFirmwareUpdate.toString()}
label=""
></Radio>
</Localized>
</div>
{flashingMethod ===
FirmwareUpdateMethod.SerialFirmwareUpdate.toString() && (
<SerialDevicesList
control={control}
watch={watch}
reset={reset}
></SerialDevicesList>
)}
{flashingMethod ===
FirmwareUpdateMethod.OTAFirmwareUpdate.toString() && (
<OTADevicesList
control={control}
watch={watch}
reset={reset}
></OTADevicesList>
)}
<div className="flex justify-between">
<Localized id="firmware_tool-previous_step">
<Button variant="secondary" onClick={prevStep}></Button>
</Localized>
<Localized id="firmware_tool-next_step">
<Button
variant="primary"
disabled={
!isValid ||
selectedDevices === null ||
selectedDevices.length === 0
}
onClick={nextStep}
></Button>
</Localized>
</div>
</div>
)}
{isGlobalLoading && (
<div className="flex justify-center flex-col items-center gap-3 h-44">
<LoaderIcon slimeState={SlimeState.JUMPY}></LoaderIcon>
<Localized id="firmware_tool-loading">
<Typography color="secondary"></Typography>
</Localized>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,220 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import {
SelectedDevice,
firmwareUpdateErrorStatus,
getFlashingRequests,
useFirmwareTool,
} from '@/hooks/firmware-tool';
import { useEffect, useMemo, useState } from 'react';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import {
DeviceIdTableT,
FirmwareUpdateMethod,
FirmwareUpdateStatus,
FirmwareUpdateStatusResponseT,
FirmwareUpdateStopQueuesRequestT,
RpcMessage,
} from 'solarxr-protocol';
import { useOnboarding } from '@/hooks/onboarding';
import { DeviceCardControl } from './DeviceCard';
import { WarningBox } from '@/components/commons/TipBox';
import { Button } from '@/components/commons/Button';
import { useNavigate } from 'react-router-dom';
import { firmwareToolS3BaseUrl } from '@/firmware-tool-api/firmwareToolFetcher';
export function FlashingStep({
goTo,
isActive,
}: {
nextStep: () => void;
prevStep: () => void;
goTo: (id: string) => void;
isActive: boolean;
}) {
const nav = useNavigate();
const { l10n } = useLocalization();
const { selectedDevices, buildStatus, selectDevices, defaultConfig } =
useFirmwareTool();
const { state: onboardingState } = useOnboarding();
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const [status, setStatus] = useState<{
[key: string]: {
status: FirmwareUpdateStatus;
type: FirmwareUpdateMethod;
progress: number;
deviceNames: string[];
};
}>({});
const clear = () => {
setStatus({});
sendRPCPacket(
RpcMessage.FirmwareUpdateStopQueuesRequest,
new FirmwareUpdateStopQueuesRequestT()
);
};
const queueFlashing = (selectedDevices: SelectedDevice[]) => {
clear();
if (!buildStatus.firmwareFiles)
throw new Error('invalid state - no firmware files');
const requests = getFlashingRequests(
selectedDevices,
buildStatus.firmwareFiles.map(({ url, ...fields }) => ({
url: `${firmwareToolS3BaseUrl}/${url}`,
...fields,
})),
onboardingState,
defaultConfig
);
requests.forEach((req) => {
sendRPCPacket(RpcMessage.FirmwareUpdateRequest, req);
});
};
useEffect(() => {
if (!isActive) return;
if (!selectedDevices)
throw new Error('invalid state - no selected devices');
queueFlashing(selectedDevices);
return () => clear();
}, [isActive]);
useRPCPacket(
RpcMessage.FirmwareUpdateStatusResponse,
(data: FirmwareUpdateStatusResponseT) => {
if (!data.deviceId) throw new Error('no device id');
const id =
data.deviceId instanceof DeviceIdTableT
? data.deviceId.id?.id
: data.deviceId.port;
if (!id) throw new Error('invalid device id');
const selectedDevice = selectedDevices?.find(
({ deviceId }) => deviceId == id.toString()
);
// We skip the status as it can be old trackers still sending status
if (!selectedDevice) return;
setStatus((last) => ({
...last,
[id.toString()]: {
progress: data.progress / 100,
status: data.status,
type: selectedDevice.type,
deviceNames: selectedDevice.deviceNames,
},
}));
}
);
const trackerWithErrors = useMemo(
() =>
Object.keys(status).filter((id) =>
firmwareUpdateErrorStatus.includes(status[id].status)
),
[status, firmwareUpdateErrorStatus]
);
const retryError = () => {
const devices = trackerWithErrors.map((id) => {
const device = status[id];
return {
type: device.type,
deviceId: id,
deviceNames: device.deviceNames,
};
});
selectDevices(devices);
queueFlashing(devices);
};
const hasPendingTrackers = useMemo(
() =>
Object.keys(status).filter((id) =>
[
FirmwareUpdateStatus.NEED_MANUAL_REBOOT,
FirmwareUpdateStatus.DOWNLOADING,
FirmwareUpdateStatus.AUTHENTICATING,
FirmwareUpdateStatus.REBOOTING,
FirmwareUpdateStatus.SYNCING_WITH_MCU,
FirmwareUpdateStatus.UPLOADING,
FirmwareUpdateStatus.PROVISIONING,
].includes(status[id].status)
).length > 0,
[status]
);
const shouldShowRebootWarning = useMemo(
() =>
Object.keys(status).find((id) =>
[
FirmwareUpdateStatus.REBOOTING,
FirmwareUpdateStatus.UPLOADING,
].includes(status[id].status)
),
[status]
);
return (
<>
<div className="flex flex-col w-full">
<div className="flex flex-grow flex-col gap-4">
<Typography color="secondary">
{l10n.getString('firmware_tool-flashing_step-description')}
</Typography>
</div>
<div className="my-4 flex gap-2 flex-col">
{shouldShowRebootWarning && (
<Localized id="firmware_tool-flashing_step-warning">
<WarningBox>Warning</WarningBox>
</Localized>
)}
{Object.keys(status).map((id) => {
const val = status[id];
return (
<DeviceCardControl
status={val.status}
progress={val.progress}
key={id}
deviceNames={val.deviceNames}
></DeviceCardControl>
);
})}
<div className="flex gap-2 self-end">
<Localized id="firmware_tool-retry">
<Button
variant="secondary"
disabled={trackerWithErrors.length === 0}
onClick={retryError}
></Button>
</Localized>
<Localized id="firmware_tool-flashing_step-flash_more">
<Button
variant="secondary"
disabled={hasPendingTrackers}
onClick={() => goTo('FlashingMethod')}
></Button>
</Localized>
<Localized id="firmware_tool-flashing_step-exit">
<Button
variant="primary"
onClick={() => {
clear();
nav('/');
}}
></Button>
</Localized>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,95 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import {
firmwareToolToBoardType,
useFirmwareTool,
} from '@/hooks/firmware-tool';
import { CreateBoardConfigDTO } from '@/firmware-tool-api/firmwareToolSchemas';
import classNames from 'classnames';
import { Button } from '@/components/commons/Button';
import { useGetFirmwaresBoards } from '@/firmware-tool-api/firmwareToolComponents';
import { BoardType } from 'solarxr-protocol';
export function SelectBoardStep({
nextStep,
goTo,
}: {
nextStep: () => void;
prevStep: () => void;
goTo: (id: string) => void;
}) {
const { l10n } = useLocalization();
const { selectBoard, newConfig, defaultConfig } = useFirmwareTool();
const { isFetching, data: boards } = useGetFirmwaresBoards({});
return (
<>
<div className="flex flex-col w-full">
<div className="flex flex-grow flex-col gap-4">
<Typography color="secondary">
{l10n.getString('firmware_tool-board_step-description')}
</Typography>
</div>
<div className="my-4">
{!isFetching && (
<div className="gap-2 flex flex-col">
<div className="grid sm:grid-cols-2 mobile-settings:grid-cols-1 gap-2">
{boards?.map((board) => (
<div
key={board}
className={classNames(
'p-3 rounded-md hover:bg-background-50',
{
'bg-background-50 text-background-10':
newConfig?.boardConfig?.type === board,
'bg-background-60':
newConfig?.boardConfig?.type !== board,
}
)}
onClick={() => {
selectBoard(board as CreateBoardConfigDTO['type']);
}}
>
{l10n.getString(
`board_type-${
BoardType[
firmwareToolToBoardType[
board as CreateBoardConfigDTO['type']
] ?? BoardType.UNKNOWN
]
}`
)}
</div>
))}
</div>
<div className="flex justify-end">
<Localized id="firmware_tool-next_step">
<Button
variant="primary"
disabled={!newConfig?.boardConfig?.type}
onClick={() => {
if (defaultConfig?.shouldOnlyUseDefaults) {
goTo('SelectFirmware');
} else {
nextStep();
}
}}
></Button>
</Localized>
</div>
</div>
)}
{isFetching && (
<div className="flex justify-center flex-col items-center gap-3 h-44">
<LoaderIcon slimeState={SlimeState.JUMPY}></LoaderIcon>
<Localized id="firmware_tool-loading">
<Typography color="secondary"></Typography>
</Localized>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,120 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { useGetFirmwaresVersions } from '@/firmware-tool-api/firmwareToolComponents';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { useFirmwareTool } from '@/hooks/firmware-tool';
import classNames from 'classnames';
import { Button } from '@/components/commons/Button';
import { useMemo } from 'react';
import { CheckBox } from '@/components/commons/Checkbox';
import { useForm } from 'react-hook-form';
export function SelectFirmwareStep({
nextStep,
prevStep,
goTo,
}: {
nextStep: () => void;
prevStep: () => void;
goTo: (id: string) => void;
}) {
const { l10n } = useLocalization();
const { selectVersion, newConfig, defaultConfig } = useFirmwareTool();
const { isFetching, data: firmwares } = useGetFirmwaresVersions({});
const { control, watch } = useForm<{ thirdParty: boolean }>({});
const showThirdParty = watch('thirdParty');
const getName = (name: string) => {
return showThirdParty ? name : name.substring(name.indexOf('/') + 1);
};
const filteredFirmwares = useMemo(() => {
return firmwares?.filter(
({ name }) => name.split('/')[0] === 'SlimeVR' || showThirdParty
);
}, [firmwares, showThirdParty]);
return (
<>
<div className="flex flex-col w-full">
<div className="flex justify-between items-center mobile:flex-col gap-4">
<Typography color="secondary">
{l10n.getString('firmware_tool-select_firmware_step-description')}
</Typography>
<div>
<Localized
id="firmware_tool-select_firmware_step-show-third-party"
attrs={{ label: true }}
>
<CheckBox
control={control}
name="thirdParty"
label="Show third party firmwares"
></CheckBox>
</Localized>
</div>
</div>
<div className="my-4">
{!isFetching && (
<div className="flex flex-col gap-4">
<div className="xs-settings:max-h-96 xs-settings:overflow-y-auto xs-settings:px-2">
<div className="grid sm:grid-cols-2 mobile-settings:grid-cols-1 gap-2">
{filteredFirmwares?.map((firmware) => (
<div
key={firmware.id}
className={classNames(
'p-3 rounded-md hover:bg-background-50',
{
'bg-background-50 text-background-10':
newConfig?.version === firmware.name,
'bg-background-60':
newConfig?.version !== firmware.name,
}
)}
onClick={() => {
selectVersion(firmware.name);
}}
>
{getName(firmware.name)}
</div>
))}
</div>
</div>
<div className="flex justify-between">
<Localized id="firmware_tool-previous_step">
<Button
variant="tertiary"
onClick={() => {
if (defaultConfig?.shouldOnlyUseDefaults) {
goTo('SelectBoard');
} else {
prevStep();
}
}}
></Button>
</Localized>
<Localized id="firmware_tool-next_step">
<Button
variant="primary"
disabled={!newConfig?.version}
onClick={nextStep}
></Button>
</Localized>
</div>
</div>
)}
{isFetching && (
<div className="flex justify-center flex-col items-center gap-3 h-44">
<LoaderIcon slimeState={SlimeState.JUMPY}></LoaderIcon>
<Localized id="firmware_tool-loading">
<Typography color="secondary"></Typography>
</Localized>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,402 @@
import { Localized, ReactLocalization, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { getTrackerName } from '@/hooks/tracker';
import { ComponentProps, useEffect, useMemo, useState } from 'react';
import {
BoardType,
DeviceDataT,
DeviceIdTableT,
FirmwareUpdateMethod,
FirmwareUpdateStatus,
FirmwareUpdateStatusResponseT,
FirmwareUpdateStopQueuesRequestT,
HardwareInfoT,
RpcMessage,
TrackerStatus,
} from 'solarxr-protocol';
import semver from 'semver';
import classNames from 'classnames';
import { Button } from '@/components/commons/Button';
import Markdown from 'react-markdown';
import remark from 'remark-gfm';
import { WarningBox } from '@/components/commons/TipBox';
import { FirmwareRelease, useAppContext } from '@/hooks/app';
import { DeviceCardControl } from '@/components/firmware-tool/DeviceCard';
import { Control, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import {
firmwareUpdateErrorStatus,
getFlashingRequests,
SelectedDevice,
} from '@/hooks/firmware-tool';
import { yupResolver } from '@hookform/resolvers/yup';
import { object } from 'yup';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { A } from '@/components/commons/A';
export function checkForUpdate(
currentFirmwareRelease: FirmwareRelease,
hardwareInfo: HardwareInfoT
) {
return (
// TODO: This is temporary, end goal is to support all board types
hardwareInfo.officialBoardType === BoardType.SLIMEVR &&
semver.valid(currentFirmwareRelease.version) &&
semver.valid(hardwareInfo.firmwareVersion?.toString() ?? 'none') &&
semver.lt(
hardwareInfo.firmwareVersion?.toString() ?? 'none',
currentFirmwareRelease.version
)
);
}
interface FirmwareUpdateForm {
selectedDevices: { [key: string]: boolean };
}
interface UpdateStatus {
status: FirmwareUpdateStatus;
type: FirmwareUpdateMethod;
progress: number;
deviceNames: string[];
}
const deviceNames = ({ trackers }: DeviceDataT, l10n: ReactLocalization) =>
trackers
.map(({ info }) => getTrackerName(l10n, info))
.filter((i): i is string => !!i);
const DeviceList = ({
control,
devices,
}: {
control: Control<any>;
devices: DeviceDataT[];
}) => {
const { l10n } = useLocalization();
return devices.map((device, index) => (
<DeviceCardControl
key={index}
control={control}
name={`selectedDevices.${device.id?.id ?? 0}`}
deviceNames={deviceNames(device, l10n)}
/>
));
};
const StatusList = ({ status }: { status: Record<string, UpdateStatus> }) => {
const statusKeys = Object.keys(status);
return statusKeys.map((id, index) => {
const val = status[id];
if (!val) throw new Error('there should always be a val');
const { state } = useAppContext();
const device = state.datafeed?.devices.find(
({ id: dId }) => id === dId?.id.toString()
);
return (
<DeviceCardControl
status={val.status}
progress={val.progress}
key={index}
deviceNames={val.deviceNames}
online={device?.trackers.some(
({ status }) => status === TrackerStatus.OK
)}
></DeviceCardControl>
);
});
};
const MarkdownLink = (props: ComponentProps<'a'>) => (
<A href={props.href}>{props.children}</A>
);
export function FirmwareUpdate() {
const navigate = useNavigate();
const { l10n } = useLocalization();
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const [selectedDevices, setSelectedDevices] = useState<SelectedDevice[]>([]);
const { state, currentFirmwareRelease } = useAppContext();
const [status, setStatus] = useState<Record<string, UpdateStatus>>({});
const devices =
state.datafeed?.devices.filter(
({ trackers, hardwareInfo }) =>
trackers.length > 0 &&
currentFirmwareRelease &&
hardwareInfo &&
checkForUpdate(currentFirmwareRelease, hardwareInfo) &&
trackers.every(({ status }) => status === TrackerStatus.OK)
) || [];
useRPCPacket(
RpcMessage.FirmwareUpdateStatusResponse,
(data: FirmwareUpdateStatusResponseT) => {
if (!data.deviceId) throw new Error('no device id');
const id =
data.deviceId instanceof DeviceIdTableT
? data.deviceId.id?.id
: data.deviceId.port;
if (!id) throw new Error('invalid device id');
const selectedDevice = selectedDevices?.find(
({ deviceId }) => deviceId === id.toString()
);
// We skip the status as it can be old trackers still sending status
if (!selectedDevice) return;
setStatus((last) => ({
...last,
[id.toString()]: {
progress: data.progress / 100,
status: data.status,
type: selectedDevice.type,
deviceNames: selectedDevice.deviceNames,
},
}));
}
);
const {
control,
watch,
reset,
formState: { isValid },
} = useForm<FirmwareUpdateForm>({
reValidateMode: 'onChange',
mode: 'onChange',
defaultValues: {
selectedDevices: devices.reduce(
(curr, { id }) => ({ ...curr, [id?.id ?? 0]: false }),
{}
),
},
resolver: yupResolver(
object({
selectedDevices: object().test(
'at-least-one-true',
'At least one field must be true',
(value) => {
if (typeof value !== 'object' || value === null) return false;
return Object.values(value).some((val) => val === true);
}
),
})
),
});
const selectedDevicesForm = watch('selectedDevices');
const clear = () => {
setStatus({});
sendRPCPacket(
RpcMessage.FirmwareUpdateStopQueuesRequest,
new FirmwareUpdateStopQueuesRequestT()
);
};
useEffect(() => {
if (!currentFirmwareRelease) {
navigate('/');
return;
}
return () => {
clear();
};
}, []);
const queueFlashing = (selectedDevices: SelectedDevice[]) => {
clear();
const firmwareFile = currentFirmwareRelease?.firmwareFile;
if (!firmwareFile) throw new Error('invalid state - no firmware file');
const requests = getFlashingRequests(
selectedDevices,
[{ isFirmware: true, firmwareId: '', url: firmwareFile, offset: 0 }],
{ wifi: undefined, alonePage: false, progress: 0 }, // we do not use serial
null // we do not use serial
);
requests.forEach((req) => {
sendRPCPacket(RpcMessage.FirmwareUpdateRequest, req);
});
};
const trackerWithErrors = useMemo(
() =>
Object.keys(status).filter((id) =>
firmwareUpdateErrorStatus.includes(status[id].status)
),
[status]
);
const hasPendingTrackers = useMemo(
() =>
Object.keys(status).filter((id) =>
[
FirmwareUpdateStatus.NEED_MANUAL_REBOOT,
FirmwareUpdateStatus.DOWNLOADING,
FirmwareUpdateStatus.AUTHENTICATING,
FirmwareUpdateStatus.REBOOTING,
FirmwareUpdateStatus.SYNCING_WITH_MCU,
FirmwareUpdateStatus.UPLOADING,
FirmwareUpdateStatus.PROVISIONING,
].includes(status[id].status)
).length > 0,
[status]
);
const shouldShowRebootWarning = useMemo(
() =>
Object.keys(status).find((id) =>
[
FirmwareUpdateStatus.REBOOTING,
FirmwareUpdateStatus.UPLOADING,
].includes(status[id].status)
),
[status]
);
const retryError = () => {
const devices = trackerWithErrors.map((id) => {
const device = status[id];
return {
type: device.type,
deviceId: id,
deviceNames: device.deviceNames,
};
});
reset({
selectedDevices: devices.reduce(
(curr, { deviceId }) => ({ ...curr, [deviceId]: true }),
{}
),
});
queueFlashing(devices);
};
const startUpdate = () => {
const selectedDevices = Object.keys(selectedDevicesForm)
.filter((d) => selectedDevicesForm[d])
.map((id) => {
const device = devices.find(({ id: dId }) => id === dId?.id.toString());
if (!device) throw new Error('no device found');
return {
type: FirmwareUpdateMethod.OTAFirmwareUpdate,
deviceId: id,
deviceNames: deviceNames(device, l10n),
};
});
if (!selectedDevices)
throw new Error('invalid state - no selected devices');
setSelectedDevices(selectedDevices);
queueFlashing(selectedDevices);
};
const canStartUpdate =
isValid &&
devices.length !== 0 &&
!hasPendingTrackers &&
trackerWithErrors.length === 0;
const canRetry =
isValid && devices.length !== 0 && trackerWithErrors.length !== 0;
const statusKeys = Object.keys(status);
return (
<div className="flex flex-col p-4 w-full items-center justify-center">
<div className="mobile:w-full w-10/12 h-full flex flex-col gap-2">
<Localized id="firmware_update-title">
<Typography variant="main-title"></Typography>
</Localized>
<div className="grid md:grid-cols-2 xs:grid-cols-1 gap-5">
<div className="flex flex-col gap-2">
<Localized id="firmware_update-devices">
<Typography variant="section-title"></Typography>
</Localized>
<Localized id="firmware_update-devices-description">
<Typography variant="standard" color="secondary"></Typography>
</Localized>
<div className="flex flex-col gap-4 overflow-y-auto xs:max-h-[530px]">
{devices.length === 0 &&
!hasPendingTrackers &&
statusKeys.length == 0 && (
<Localized id="firmware_update-no_devices">
<WarningBox>Warning</WarningBox>
</Localized>
)}
{shouldShowRebootWarning && (
<Localized id="firmware_tool-flashing_step-warning">
<WarningBox>Warning</WarningBox>
</Localized>
)}
<div className="flex flex-col gap-4 h-full">
{statusKeys.length > 0 ? (
<StatusList status={status}></StatusList>
) : (
<DeviceList control={control} devices={devices}></DeviceList>
)}
{devices.length === 0 && statusKeys.length === 0 && (
<div
className={classNames(
'rounded-xl bg-background-60 justify-center flex-col items-center flex pb-10 py-5 gap-5'
)}
>
<LoaderIcon slimeState={SlimeState.JUMPY}></LoaderIcon>
<Localized id="firmware_update-looking_for_devices">
<Typography></Typography>
</Localized>
</div>
)}
</div>
</div>
</div>
<div className="h-fit w-full flex flex-col gap-2">
<Localized
id="firmware_update-changelog-title"
vars={{ version: currentFirmwareRelease?.name ?? 'unknown' }}
>
<Typography variant="main-title"></Typography>
</Localized>
<div className="overflow-y-scroll max-h-[430px] md:h-[430px] bg-background-60 rounded-lg p-4">
<Markdown
remarkPlugins={[remark]}
components={{ a: MarkdownLink }}
className={classNames(
'w-full text-sm prose-xl prose text-background-10 prose-h1:text-background-10',
'prose-h2:text-background-10 prose-a:text-background-20 prose-strong:text-background-10',
'prose-code:text-background-20'
)}
>
{currentFirmwareRelease?.changelog}
</Markdown>
</div>
</div>
</div>
<div className="flex justify-end pb-2 gap-2 mobile:flex-col">
<Localized id="firmware_update-retry">
<Button
variant="secondary"
disabled={!canRetry}
onClick={retryError}
></Button>
</Localized>
<Localized id="firmware_update-update">
<Button
variant="primary"
disabled={!canStartUpdate}
onClick={startUpdate}
></Button>
</Localized>
</div>
</div>
</div>
);
}

View File

@@ -51,7 +51,7 @@ export function Home() {
<div className="h-full overflow-y-auto">
<div
className={classNames(
'px-2 pt-2 gap-3 w-full grid md:grid-cols-2 mobile:grid-cols-1',
'px-3 pt-3 gap-3 w-full grid md:grid-cols-2 mobile:grid-cols-1',
filteredStatuses.filter(([, status]) => status.prioritized)
.length === 0 && 'hidden'
)}
@@ -70,7 +70,7 @@ export function Home() {
</Localized>
))}
</div>
<div className="overflow-y-auto flex flex-col gap-2">
<div className="overflow-y-auto flex flex-col gap-3">
{trackers.length === 0 && (
<div className="flex px-5 pt-5 justify-center">
<Typography variant="standard">
@@ -80,7 +80,7 @@ export function Home() {
)}
{!config?.debug && trackers.length > 0 && (
<div className="grid sm:grid-cols-1 md:grid-cols-2 gap-3 px-2 my-2">
<div className="grid sm:grid-cols-1 md:grid-cols-2 gap-4 px-5 my-5">
{trackers.map(({ tracker, device }, index) => (
<TrackerCard
key={index}
@@ -88,6 +88,7 @@ export function Home() {
device={device}
onClick={() => sendToSettings(tracker)}
smol
showUpdates
interactable
warning={Object.values(statuses).some((status) =>
trackerStatusRelated(tracker, status)

View File

@@ -3,11 +3,9 @@ import classNames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
AddUnknownDeviceRequestT,
RpcMessage,
StartWifiProvisioningRequestT,
StopWifiProvisioningRequestT,
UnknownDeviceHandshakeNotificationT,
WifiProvisioningStatus,
WifiProvisioningStatusResponseT,
} from 'solarxr-protocol';
@@ -97,15 +95,6 @@ export function ConnectTrackersPage() {
}
);
useRPCPacket(
RpcMessage.UnknownDeviceHandshakeNotification,
({ macAddress }: UnknownDeviceHandshakeNotificationT) =>
sendRPCPacket(
RpcMessage.AddUnknownDeviceRequest,
new AddUnknownDeviceRequestT(macAddress)
)
);
const isError =
provisioningStatus === WifiProvisioningStatus.CONNECTION_ERROR ||
provisioningStatus === WifiProvisioningStatus.COULD_NOT_FIND_SERVER;

View File

@@ -33,7 +33,7 @@ export function EnterVRPage() {
</div>
<div className="w-full py-4 flex flex-row">
<div className="flex flex-grow">
<Button variant="secondary" to="/" onClick={skipSetup}>
<Button variant="secondary" onClick={skipSetup}>
{l10n.getString('onboarding-skip')}
</Button>
</div>

View File

@@ -13,7 +13,7 @@ export function HomePage() {
return (
<>
<div className="flex flex-col gap-5 h-full items-center w-full justify-center px-4">
<div className="flex relative flex-col gap-5 h-full items-center w-full justify-center px-4 overflow-clip">
<div className="flex flex-col gap-5 items-center z-10 scale-150 mb-20">
<SlimeVRIcon></SlimeVRIcon>
<Typography variant="mobile-title">

View File

@@ -1,16 +1,14 @@
import { useOnboarding } from '@/hooks/onboarding';
import { useLocalization } from '@fluent/react';
import { useState } from 'react';
import { SkipSetupWarningModal } from '@/components/onboarding/SkipSetupWarningModal';
import classNames from 'classnames';
import { Typography } from '@/components/commons/Typography';
import { Button } from '@/components/commons/Button';
export function MountingChoose() {
const { l10n } = useLocalization();
const { applyProgress, skipSetup, state } = useOnboarding();
const { applyProgress, state } = useOnboarding();
const [animated, setAnimated] = useState(false);
const [showWarning, setShowWarning] = useState(false);
applyProgress(0.65);
@@ -137,11 +135,6 @@ export function MountingChoose() {
)}
</div>
</div>
<SkipSetupWarningModal
accept={skipSetup}
onClose={() => setShowWarning(false)}
isOpen={showWarning}
></SkipSetupWarningModal>
</>
);
}

View File

@@ -98,10 +98,10 @@ export const mapPart: Record<
<FootIcon width={width} flipped></FootIcon>
),
[BodyPart.WAIST]: ({ width }) => <FootIcon width={width}></FootIcon>,
[BodyPart.LEFT_THUMB_PROXIMAL]: ({ width }) => (
[BodyPart.LEFT_THUMB_METACARPAL]: ({ width }) => (
<FingersIcon width={width}></FingersIcon>
),
[BodyPart.LEFT_THUMB_INTERMEDIATE]: ({ width }) => (
[BodyPart.LEFT_THUMB_PROXIMAL]: ({ width }) => (
<FingersIcon width={width}></FingersIcon>
),
[BodyPart.LEFT_THUMB_DISTAL]: ({ width }) => (
@@ -143,10 +143,10 @@ export const mapPart: Record<
[BodyPart.LEFT_LITTLE_DISTAL]: ({ width }) => (
<FingersIcon width={width}></FingersIcon>
),
[BodyPart.RIGHT_THUMB_PROXIMAL]: ({ width }) => (
[BodyPart.RIGHT_THUMB_METACARPAL]: ({ width }) => (
<FingersIcon width={width}></FingersIcon>
),
[BodyPart.RIGHT_THUMB_INTERMEDIATE]: ({ width }) => (
[BodyPart.RIGHT_THUMB_PROXIMAL]: ({ width }) => (
<FingersIcon width={width}></FingersIcon>
),
[BodyPart.RIGHT_THUMB_DISTAL]: ({ width }) => (

View File

@@ -247,7 +247,7 @@ export function TrackersAssignPage() {
}
);
applyProgress(0.5);
applyProgress(0.55);
const { closeChokerWarning, tryOpenChokerWarning, shouldShowChokerWarn } =
useChokerWarning({

View File

@@ -0,0 +1,224 @@
import { DOCS_SITE } from '@/App';
import { A } from '@/components/commons/A';
import { Button } from '@/components/commons/Button';
import { WarningBox } from '@/components/commons/TipBox';
import { Typography } from '@/components/commons/Typography';
import { useOnboarding } from '@/hooks/onboarding';
import { useStatusContext } from '@/hooks/status-system';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { Localized, useLocalization } from '@fluent/react';
import classNames from 'classnames';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ChangeSettingsRequestT,
ModelSettingsT,
ModelTogglesT,
RpcMessage,
SettingsRequestT,
SettingsResponseT,
StatusData,
StatusSteamVRDisconnectedT,
} from 'solarxr-protocol';
export function HeadTrackingChoose() {
const { l10n } = useLocalization();
const { applyProgress, state } = useOnboarding();
const { statuses } = useStatusContext();
const [animated, setAnimated] = useState(false);
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const fetchedSettings = useRef<ModelTogglesT | null>(null);
const navigate = useNavigate();
useEffect(() => {
sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT());
}, []);
const toggleMocap = (bool: boolean) => {
const settings = new ChangeSettingsRequestT();
const modelSettings = new ModelSettingsT();
modelSettings.toggles = fetchedSettings.current ?? new ModelTogglesT();
modelSettings.toggles.selfLocalization = bool;
settings.modelSettings = modelSettings;
sendRPCPacket(RpcMessage.ChangeSettingsRequest, settings);
};
useRPCPacket(
RpcMessage.SettingsResponse,
(oldSettings: SettingsResponseT) => {
fetchedSettings.current = oldSettings.modelSettings?.toggles ?? null;
toggleMocap(false);
}
);
const missingSteamVr = useMemo(
() =>
Object.values(statuses).some(
(x) =>
x.dataType === StatusData.StatusSteamVRDisconnected &&
(x.data as StatusSteamVRDisconnectedT).bridgeSettingsName ===
'steamvr'
),
[statuses]
);
applyProgress(0.55);
return (
<div className="flex flex-col gap-5 h-full items-center w-full xs:justify-center relative overflow-y-auto px-4 pb-4">
<div className="flex flex-col gap-4 justify-center">
<div className="xs:w-10/12 xs:max-w-[666px]">
<Typography variant="main-title">
{l10n.getString('onboarding-usage-mocap-head_choose')}
</Typography>
<Typography
variant="standard"
color="secondary"
whitespace="whitespace-pre-line"
>
{l10n.getString('onboarding-usage-mocap-head_choose-description')}
</Typography>
</div>
<div
className={classNames(
'grid xs:grid-cols-2 w-full xs:flex-row mobile:flex-col gap-4 [&>div]:grow'
)}
>
<div
className={classNames(
'rounded-lg p-4 flex',
!state.alonePage && 'bg-background-70',
state.alonePage && 'bg-background-60'
)}
>
<div className="flex flex-col gap-4">
<div className="flex flex-grow flex-col gap-4 max-w-sm">
<div>
<Typography variant="main-title" bold>
{l10n.getString(
'onboarding-usage-mocap-head_choose-standalone'
)}
</Typography>
<Typography variant="vr-accessible" italic>
{l10n.getString(
'onboarding-usage-mocap-head_choose-standalone-label'
)}
</Typography>
</div>
<div>
<Typography
color="secondary"
whitespace="whitespace-pre-line"
>
{l10n.getString(
'onboarding-usage-mocap-head_choose-standalone-description'
)}
</Typography>
</div>
</div>
<Button
variant={!state.alonePage ? 'secondary' : 'tertiary'}
className="self-start mt-auto"
onClick={() => {
toggleMocap(true);
navigate('/onboarding/usage/mocap/data/choose', {
state: { alonePage: state.alonePage },
});
}}
>
{l10n.getString(
'onboarding-usage-mocap-head_choose-standalone-button'
)}
</Button>
</div>
</div>
<div
className={classNames(
'rounded-lg p-4 flex flex-row relative',
!state.alonePage && 'bg-background-70',
state.alonePage && 'bg-background-60'
)}
>
<div className="flex flex-col gap-4">
<div className="flex flex-grow flex-col gap-4 max-w-sm">
<div>
<img
onMouseEnter={() => setAnimated(() => true)}
onAnimationEnd={() => setAnimated(() => false)}
src="/images/nighty-vr-sitting.webp"
className={classNames(
'absolute w-[150px] -right-8 -top-36',
animated && 'animate-[bounce_1s_1]'
)}
></img>
<Typography variant="main-title" bold>
{l10n.getString(
'onboarding-usage-mocap-head_choose-steamvr'
)}
</Typography>
<Typography variant="vr-accessible" italic>
{l10n.getString(
'onboarding-usage-mocap-head_choose-steamvr-label'
)}
</Typography>
</div>
<div className="flex flex-col gap-3">
<Typography
color="secondary"
whitespace="whitespace-pre-line"
>
{l10n.getString(
'onboarding-usage-mocap-head_choose-steamvr-description'
)}
</Typography>
{
// TODO: Add a button to open SteamVR via tauri's open()
missingSteamVr && (
<Localized
id="onboarding-usage-vr-choose-steamvr-warning"
elems={{
docs: (
<A
href={`${DOCS_SITE}/common-issues.html#the-trackers-are-connected-to-the-slimevr-server-but-arent-turning-up-on-steam`}
></A>
),
b: <b></b>,
}}
>
<WarningBox>SteamVR driver not connected</WarningBox>
</Localized>
)
}
</div>
</div>
<Button
variant={'primary'}
to="/onboarding/usage/mocap/data/choose"
className="self-start mt-auto"
state={{ alonePage: state.alonePage }}
disabled={missingSteamVr}
>
{l10n.getString(
'onboarding-usage-mocap-head_choose-steamvr-button'
)}
</Button>
</div>
</div>
</div>
<Button
variant="secondary"
className="self-start"
to="/onboarding/usage/choose"
>
{l10n.getString('onboarding-previous_step')}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export function MocapBVHSetup() {
return <div className="bg-background-70 w-[512px] rounded-md"></div>;
}

View File

@@ -0,0 +1,107 @@
import { useOnboarding } from '@/hooks/onboarding';
import { useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { useForm } from 'react-hook-form';
import { Radio } from '@/components/commons/Radio';
import classNames from 'classnames';
import { useEffect, useMemo } from 'react';
import { Button } from '@/components/commons/Button';
import { useNavigate } from 'react-router-dom';
export enum MocapDataType {
BVH = 'BVH',
STEAMVR = 'STEAMVR',
VMC = 'VMC',
}
const TYPE_TO_NAV = {
[MocapDataType.BVH]: '/onboarding/usage/mocap/data/bvh',
[MocapDataType.STEAMVR]: '/onboarding/usage/mocap/data/steamvr',
[MocapDataType.VMC]: '/onboarding/usage/mocap/data/vmc',
};
export function MocapDataChoose() {
const { l10n } = useLocalization();
const { applyProgress } = useOnboarding();
const navigate = useNavigate();
const { control, watch } = useForm<{
usageReason?: MocapDataType;
}>();
const usageReason = watch('usageReason');
const ItemContent = ({ mode }: { mode: MocapDataType }) => (
<>
<div
className={classNames(
'flex bg-background-60 py-2 px-4 group-hover/radio:bg-background-50 rounded-t-md'
)}
>
<Typography variant="main-title">
{l10n.getString('onboarding-usage-mocap-data_choose-option-title', {
mode: MocapDataType[mode],
})}
</Typography>
</div>
<div className="flex flex-col bg-background-70 group-hover/radio:bg-background-60 rounded-b-md py-2 px-4">
<Typography>
{l10n.getString('onboarding-usage-mocap-data_choose-option-label', {
mode: MocapDataType[mode],
})}
</Typography>
</div>
</>
);
const usages = useMemo(
() =>
Object.values(MocapDataType).map((mode) => (
<Radio
key={mode}
name="usageReason"
control={control}
value={mode.toString()}
variant="none"
className="hidden"
>
<div>
<ItemContent mode={mode}></ItemContent>
</div>
</Radio>
)),
[control, l10n]
);
useEffect(
() => usageReason && navigate(TYPE_TO_NAV[usageReason]),
[usageReason]
);
applyProgress(0.6);
return (
<div className="flex flex-col gap-5 h-full items-center w-full justify-center">
<div className="flex flex-col w-full overflow-y-auto px-4 py-4 xs:items-center">
<div className="flex mobile:flex-col xs:gap-8 mobile:gap-4 mobile:pb-4 w-full min-h-0 justify-center">
<div className="flex flex-col xs:max-w-sm gap-3 justify-center">
<Typography variant="main-title">
{l10n.getString('onboarding-usage-mocap-data_choose')}
</Typography>
<Typography color="secondary">
{l10n.getString('onboarding-usage-mocap-data_choose-description')}
</Typography>
{usages}
<div className="flex flex-row">
<Button
variant="secondary"
to="/onboarding/usage/mocap/head-choose"
>
{l10n.getString('onboarding-previous_step')}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export function MocapSteamSetup() {
return <div className="bg-background-70 w-[512px] rounded-md"></div>;
}

View File

@@ -0,0 +1,233 @@
import { CheckBox } from '@/components/commons/Checkbox';
import { FileInput } from '@/components/commons/FileInput';
import { VMCIcon } from '@/components/commons/icon/VMCIcon';
import { Input } from '@/components/commons/Input';
import { Typography } from '@/components/commons/Typography';
import {
DEFAULT_VMC_VALUES,
parseVRMFile,
VMCSettingsForm,
} from '@/components/settings/pages/VMCSettings';
import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { Localized, useLocalization } from '@fluent/react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import {
ChangeSettingsRequestT,
OSCSettingsT,
RpcMessage,
SettingsRequestT,
SettingsResponseT,
VMCOSCSettingsT,
} from 'solarxr-protocol';
export function MocapVMCSetup() {
const { l10n } = useLocalization();
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const [modelName, setModelName] = useState<string | null>(null);
const { reset, control, watch, handleSubmit } = useForm<VMCSettingsForm>({
defaultValues: {
...DEFAULT_VMC_VALUES,
vmc: { oscSettings: { enabled: true }, anchorHip: false },
},
});
const onSubmit = async (values: VMCSettingsForm) => {
const settings = new ChangeSettingsRequestT();
if (values.vmc) {
const vmcOsc = new VMCOSCSettingsT();
vmcOsc.oscSettings = Object.assign(new OSCSettingsT(), {
...values.vmc.oscSettings,
vmc: { oscSettings: { enabled: true }, anchorHip: false },
});
if (values.vmc.vrmJson !== undefined) {
if (values.vmc.vrmJson.length > 0) {
vmcOsc.vrmJson = await parseVRMFile(values.vmc.vrmJson[0]);
if (vmcOsc.vrmJson) {
setModelName(
JSON.parse(vmcOsc.vrmJson)?.extensions?.VRM?.meta?.title || ''
);
}
} else {
vmcOsc.vrmJson = '';
setModelName(null);
}
}
vmcOsc.anchorHip = values.vmc.anchorHip;
vmcOsc.mirrorTracking = values.vmc.mirrorTracking;
settings.vmcOsc = vmcOsc;
}
sendRPCPacket(RpcMessage.ChangeSettingsRequest, settings);
};
useEffect(() => {
const subscription = watch(() => handleSubmit(onSubmit)());
return () => subscription.unsubscribe();
}, []);
useEffect(() => {
sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT());
}, []);
useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => {
const formData: VMCSettingsForm = DEFAULT_VMC_VALUES;
if (settings.vmcOsc) {
if (settings.vmcOsc.oscSettings) {
formData.vmc.oscSettings.enabled = settings.vmcOsc.oscSettings.enabled;
if (settings.vmcOsc.oscSettings.portIn)
formData.vmc.oscSettings.portIn = settings.vmcOsc.oscSettings.portIn;
if (settings.vmcOsc.oscSettings.portOut)
formData.vmc.oscSettings.portOut =
settings.vmcOsc.oscSettings.portOut;
if (settings.vmcOsc.oscSettings.address)
formData.vmc.oscSettings.address =
settings.vmcOsc.oscSettings.address.toString();
}
const vrmJson = settings.vmcOsc.vrmJson?.toString();
if (vrmJson) {
setModelName(JSON.parse(vrmJson)?.extensions?.VRM?.meta?.title || '');
}
formData.vmc.anchorHip = settings.vmcOsc.anchorHip;
formData.vmc.mirrorTracking = settings.vmcOsc.mirrorTracking;
}
reset(formData);
});
return (
<div className="bg-background-70 sm:w-[512px] rounded-md overflow-scroll">
<SettingsPagePaneLayout icon={<VMCIcon></VMCIcon>} id="vmc">
<Typography variant="main-title">
{l10n.getString('settings-osc-vmc')}
</Typography>
<div className="flex flex-col pt-2 pb-4">
<>
{l10n
.getString('settings-osc-vmc-description')
.split('\n')
.map((line, i) => (
<Typography color="secondary" key={i}>
{line}
</Typography>
))}
</>
</div>
<Typography bold>
{l10n.getString('settings-osc-vmc-network')}
</Typography>
<div className="flex flex-col pb-2">
<>
{l10n
.getString('settings-osc-vmc-network-description')
.split('\n')
.map((line, i) => (
<Typography color="secondary" key={i}>
{line}
</Typography>
))}
</>
</div>
<div className="grid grid-cols-2 gap-3 pb-5">
<Localized
id="settings-osc-vmc-network-port_in"
attrs={{ placeholder: true, label: true }}
>
<Input
type="number"
control={control}
name="vmc.oscSettings.portIn"
rules={{ required: true }}
placeholder="9002"
label=""
></Input>
</Localized>
<Localized
id="settings-osc-vmc-network-port_out"
attrs={{ placeholder: true, label: true }}
>
<Input
type="number"
control={control}
name="vmc.oscSettings.portOut"
rules={{ required: true }}
placeholder="9000"
label=""
></Input>
</Localized>
</div>
<Typography bold>
{l10n.getString('settings-osc-vmc-network-address')}
</Typography>
<div className="flex flex-col pb-2">
<Typography color="secondary">
{l10n.getString('settings-osc-vmc-network-address-description')}
</Typography>
</div>
<div className="grid gap-3 pb-5">
<Input
type="text"
control={control}
name="vmc.oscSettings.address"
rules={{
required: true,
pattern: /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/i,
}}
placeholder={l10n.getString(
'settings-osc-vmc-network-address-placeholder'
)}
label=""
></Input>
</div>
<Typography bold>{l10n.getString('settings-osc-vmc-vrm')}</Typography>
<div className="flex flex-col pb-2">
<Typography color="secondary">
{l10n.getString('settings-osc-vmc-vrm-description')}
</Typography>
</div>
<div className="grid gap-3 pb-5">
<FileInput
control={control}
name="vmc.vrmJson"
rules={{
required: false,
}}
value="help"
importedFileName={
// if modelname is an empty string, it's an untitled model
modelName === ''
? l10n.getString('settings-osc-vmc-vrm-untitled_model')
: modelName
}
label="settings-osc-vmc-vrm-file_select"
accept="model/gltf-binary, model/gltf+json, model/vrml, .vrm, .glb, .gltf"
></FileInput>
{/* For some reason, linux (GNOME) is detecting the VRM file is a VRML */}
</div>
<Typography bold>
{l10n.getString('settings-osc-vmc-mirror_tracking')}
</Typography>
<div className="flex flex-col pb-2">
<Typography color="secondary">
{l10n.getString('settings-osc-vmc-mirror_tracking-description')}
</Typography>
</div>
<div className="grid grid-cols-2 gap-3 pb-5">
<CheckBox
variant="toggle"
outlined
control={control}
name="vmc.mirrorTracking"
label={l10n.getString('settings-osc-vmc-mirror_tracking-label')}
/>
</div>
</SettingsPagePaneLayout>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { Button } from '@/components/commons/Button';
import { PausableVideo } from '@/components/commons/PausableVideo';
import { Typography } from '@/components/commons/Typography';
import { useOnboarding } from '@/hooks/onboarding';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { VRCHAT_OSC_VIDEO } from '@/utils/tauri';
import { useLocalization } from '@fluent/react';
import classNames from 'classnames';
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ChangeSettingsRequestT,
OSCSettingsT,
RpcMessage,
SettingsRequestT,
SettingsResponseT,
VRCOSCSettingsT,
} from 'solarxr-protocol';
export function StandaloneUsageSetup() {
const { applyProgress, state } = useOnboarding();
const { l10n } = useLocalization();
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const fetchedSettings = useRef<OSCSettingsT | null>(null);
const navigate = useNavigate();
useEffect(() => {
sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT());
}, []);
const toggleVrc = (bool: boolean) => {
const oldOscSettings = fetchedSettings.current;
const settings = new ChangeSettingsRequestT();
const vrcOsc = new VRCOSCSettingsT();
vrcOsc.oscSettings = new OSCSettingsT(
bool,
oldOscSettings?.portIn,
oldOscSettings?.portOut,
oldOscSettings?.address
);
settings.vrcOsc = vrcOsc;
sendRPCPacket(RpcMessage.ChangeSettingsRequest, settings);
};
useRPCPacket(
RpcMessage.SettingsResponse,
(oldSettings: SettingsResponseT) => {
fetchedSettings.current = oldSettings.vrcOsc?.oscSettings ?? null;
toggleVrc(true);
}
);
applyProgress(0.6);
return (
<div className="flex items-center justify-center h-full w-full mobile:pt-10">
<div className="flex mobile:flex-col items-center xs:justify-center gap-5">
<div className="mb-auto w-[512px] flex flex-col gap-2">
<Typography variant="main-title">
{l10n.getString('onboarding-usage-vr-standalone-title')}
</Typography>
<Typography color="secondary" whitespace="whitespace-pre-line">
{l10n.getString('settings-osc-vrchat-description-guide')}
</Typography>
<div className="flex pt-2">
<Button
variant={!state.alonePage ? 'secondary' : 'tertiary'}
className="self-start mt-auto"
onClick={() => {
toggleVrc(false);
navigate('/onboarding/usage/vr/choose', {
state: { alonePage: state.alonePage },
});
}}
>
{l10n.getString('onboarding-previous_step')}
</Button>
<Button
variant="primary"
to=""
className="ml-auto"
state={{ alonePage: state.alonePage }}
>
{l10n.getString('onboarding-usage-vr-standalone-next')}
</Button>
</div>
</div>
<div
className={classNames(
'flex gap-5 w-10/12 max-w-[600px] items-center'
)}
>
<div className="rounded-lg overflow-hidden aspect-square">
<PausableVideo src={VRCHAT_OSC_VIDEO} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { useOnboarding } from '@/hooks/onboarding';
import { useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { useForm } from 'react-hook-form';
import { Radio } from '@/components/commons/Radio';
import { Button } from '@/components/commons/Button';
import classNames from 'classnames';
import { useMemo } from 'react';
enum UsageReason {
VR,
VTUBING,
MOCAP,
}
interface UsageInfo {
path: string;
image: string;
}
const REASON_TO_PATH: Record<UsageReason, UsageInfo> = {
[UsageReason.MOCAP]: {
path: '/onboarding/usage/mocap/head-choose',
image: '/images/usage-mocap.webp',
},
[UsageReason.VR]: {
path: '/onboarding/usage/vr/choose',
image: '/images/usage-vr.webp',
},
[UsageReason.VTUBING]: {
path: '/onboarding/usage/vtubing/choose',
image: '/images/usage-vtuber.webp',
},
};
export function UsageChoose() {
const { l10n } = useLocalization();
const { applyProgress } = useOnboarding();
const { control, watch } = useForm<{
usageReason: UsageReason;
}>({
defaultValues: {
usageReason: UsageReason.VR,
},
});
const usageReason = watch('usageReason');
const ItemContent = ({ mode }: { mode: UsageReason }) => (
<>
<div
className={classNames(
'flex bg-background-60 py-2 px-4 group-hover/radio:bg-background-50 rounded-t-md'
)}
>
<Typography variant="main-title">
{l10n.getString('onboarding-usage-choose-option-title', {
mode: UsageReason[mode],
})}
</Typography>
</div>
<div className="flex flex-col bg-background-70 group-hover/radio:bg-background-60 rounded-b-md py-2 px-4">
<Typography>
{l10n.getString('onboarding-usage-choose-option-label', {
mode: UsageReason[mode],
})}
</Typography>
<Typography variant="standard" color="secondary">
{l10n.getString('onboarding-usage-choose-option-description', {
mode: UsageReason[mode],
})}
</Typography>
</div>
</>
);
const usages = useMemo(
() =>
Object.values(UsageReason)
.filter(checkIfUsageReason)
.map((mode) => (
<Radio
key={mode}
name="usageReason"
control={control}
value={mode.toString()}
variant="none"
className="hidden"
>
<div>
<ItemContent mode={mode}></ItemContent>
</div>
</Radio>
)),
[control, l10n]
);
applyProgress(0.5);
return (
<div className="flex flex-col gap-5 h-full items-center w-full justify-center">
<div className="flex flex-col w-full overflow-y-auto px-4 xs:items-center">
<div className="flex mobile:flex-col xs:gap-8 mobile:gap-4 mobile:pb-4 w-full justify-center">
<div className="flex flex-col xs:max-w-sm gap-3 justify-center">
<Typography variant="main-title">
{l10n.getString('onboarding-usage-choose')}
</Typography>
<Typography color="secondary">
{l10n.getString('onboarding-usage-choose-description')}
</Typography>
{usages}
<div className="flex flex-row">
<Button variant="secondary" to="/onboarding/assign-tutorial">
{l10n.getString('onboarding-previous_step')}
</Button>
<Button
variant="primary"
to={REASON_TO_PATH[usageReason].path}
className="ml-auto"
>
{l10n.getString('onboarding-enter_vr-ready')}
</Button>
</div>
</div>
<div className="flex flex-col justify-center">
<img
className="mobile:hidden rounded-3xl"
src={REASON_TO_PATH[usageReason].image}
width="496"
></img>
</div>
</div>
</div>
</div>
);
}
function checkIfUsageReason(val: any): val is UsageReason {
return typeof val === 'number';
}

View File

@@ -0,0 +1,171 @@
import { DOCS_SITE } from '@/App';
import { A } from '@/components/commons/A';
import { Button } from '@/components/commons/Button';
import { WarningBox } from '@/components/commons/TipBox';
import { Typography } from '@/components/commons/Typography';
import { useOnboarding } from '@/hooks/onboarding';
import { useStatusContext } from '@/hooks/status-system';
import { Localized, useLocalization } from '@fluent/react';
import classNames from 'classnames';
import { useMemo, useState } from 'react';
import { StatusData, StatusSteamVRDisconnectedT } from 'solarxr-protocol';
export function VRUsageChoose() {
const { l10n } = useLocalization();
const { applyProgress, state } = useOnboarding();
const { statuses } = useStatusContext();
const [animated, setAnimated] = useState(false);
const missingSteamVr = useMemo(
() =>
Object.values(statuses).some(
(x) =>
x.dataType === StatusData.StatusSteamVRDisconnected &&
(x.data as StatusSteamVRDisconnectedT).bridgeSettingsName ===
'steamvr'
),
[statuses]
);
applyProgress(0.55);
return (
<div className="flex flex-col gap-5 h-full items-center w-full xs:justify-center relative overflow-y-auto px-4 pb-4">
<div className="flex flex-col gap-4 justify-center">
<div className="xs:w-10/12 xs:max-w-[666px]">
<Typography variant="main-title">
{l10n.getString('onboarding-usage-vr-choose')}
</Typography>
<Typography
variant="standard"
color="secondary"
whitespace="whitespace-pre-line"
>
{l10n.getString('onboarding-usage-vr-choose-description')}
</Typography>
</div>
<div
className={classNames(
'grid xs:grid-cols-2 w-full xs:flex-row mobile:flex-col gap-4 [&>div]:grow'
)}
>
<div
className={classNames(
'rounded-lg p-4 flex',
!state.alonePage && 'bg-background-70',
state.alonePage && 'bg-background-60'
)}
>
<div className="flex flex-col gap-4">
<div className="flex flex-grow flex-col gap-4 max-w-sm">
<div>
<Typography variant="main-title" bold>
{l10n.getString('onboarding-usage-vr-choose-standalone')}
</Typography>
<Typography variant="vr-accessible" italic>
{l10n.getString(
'onboarding-usage-vr-choose-standalone-label'
)}
</Typography>
</div>
<div>
<Typography
color="secondary"
whitespace="whitespace-pre-line"
>
{l10n.getString(
'onboarding-usage-vr-choose-standalone-description'
)}
</Typography>
</div>
</div>
<Button
variant={!state.alonePage ? 'secondary' : 'tertiary'}
to={'/onboarding/usage/vr/standalone'}
className="self-start mt-auto"
state={{ alonePage: state.alonePage }}
>
{l10n.getString('onboarding-usage-vr-choose-standalone')}
</Button>
</div>
</div>
<div
className={classNames(
'rounded-lg p-4 flex flex-row relative',
!state.alonePage && 'bg-background-70',
state.alonePage && 'bg-background-60'
)}
>
<div className="flex flex-col gap-4">
<div className="flex flex-grow flex-col gap-4 max-w-sm">
<div>
<img
onMouseEnter={() => setAnimated(() => true)}
onAnimationEnd={() => setAnimated(() => false)}
src="/images/vrslimes.webp"
className={classNames(
'absolute w-[150px] -right-8 -top-16',
animated && 'animate-[bounce_1s_1]'
)}
></img>
<Typography variant="main-title" bold>
{l10n.getString('onboarding-usage-vr-choose-steamvr')}
</Typography>
<Typography variant="vr-accessible" italic>
{l10n.getString('onboarding-usage-vr-choose-steamvr-label')}
</Typography>
</div>
<div className="flex flex-col gap-3">
<Typography
color="secondary"
whitespace="whitespace-pre-line"
>
{l10n.getString(
'onboarding-usage-vr-choose-steamvr-description'
)}
</Typography>
{
// TODO: Add a button to open SteamVR via tauri's open()
missingSteamVr && (
<Localized
id="onboarding-usage-vr-choose-steamvr-warning"
elems={{
docs: (
<A
href={`${DOCS_SITE}/common-issues.html#the-trackers-are-connected-to-the-slimevr-server-but-arent-turning-up-on-steam`}
></A>
),
b: <b></b>,
}}
>
<WarningBox>SteamVR driver not connected</WarningBox>
</Localized>
)
}
</div>
</div>
<Button
variant={'primary'}
to="/onboarding/mounting/manual"
className="self-start mt-auto"
state={{ alonePage: state.alonePage }}
disabled={missingSteamVr}
>
{l10n.getString('onboarding-usage-vr-choose-steamvr')}
</Button>
</div>
</div>
</div>
<Button
variant="secondary"
className="self-start"
to="/onboarding/usage/choose"
>
{l10n.getString('onboarding-previous_step')}
</Button>
</div>
</div>
);
}

View File

@@ -40,6 +40,10 @@ export function SettingSelectorMobile() {
label: l10n.getString('settings-sidebar-serial'),
value: { url: '/settings/serial' },
},
{
label: l10n.getString('settings-sidebar-firmware-tool'),
value: { url: '/settings/firmware-tool' },
},
{
label: l10n.getString('settings-sidebar-advanced'),
value: { url: '/settings/advanced' },
@@ -99,7 +103,7 @@ export function SettingsLayout({ children }: { children: ReactNode }) {
<div style={{ gridArea: 'n' }}>
<Navbar></Navbar>
</div>
<div style={{ gridArea: 's' }} className="my-2">
<div style={{ gridArea: 's' }} className="my-2 mobile:hidden">
<SettingsSidebar></SettingsSidebar>
</div>
<div

View File

@@ -100,6 +100,9 @@ export function SettingsSidebar() {
<SettingsLink to="/settings/serial">
{l10n.getString('settings-sidebar-serial')}
</SettingsLink>
<SettingsLink to="/settings/firmware-tool">
{l10n.getString('settings-sidebar-firmware-tool')}
</SettingsLink>
</div>
<div className="flex flex-col gap-2">
<SettingsLink to="/settings/advanced">

View File

@@ -22,7 +22,7 @@ import {
} from '@/components/settings/SettingsPageLayout';
import { error } from '@/utils/logging';
interface VMCSettingsForm {
export interface VMCSettingsForm {
vmc: {
oscSettings: {
enabled: boolean;
@@ -36,7 +36,7 @@ interface VMCSettingsForm {
};
}
const defaultValues = {
export const DEFAULT_VMC_VALUES = {
vmc: {
oscSettings: {
enabled: false,
@@ -53,15 +53,9 @@ export function VMCSettings() {
const { l10n } = useLocalization();
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const [modelName, setModelName] = useState<string | null>(null);
const [flashLoaded, setFlashLoaded] = useState(false);
const toggleFlash = (bool: boolean) => {
setFlashLoaded(bool);
setTimeout(() => setFlashLoaded(!bool), 1000);
};
const { reset, control, watch, handleSubmit } = useForm<VMCSettingsForm>({
defaultValues,
defaultValues: DEFAULT_VMC_VALUES,
});
const onSubmit = async (values: VMCSettingsForm) => {
@@ -74,11 +68,17 @@ export function VMCSettings() {
new OSCSettingsT(),
values.vmc.oscSettings
);
if (values.vmc.vrmJson?.length) {
vmcOsc.vrmJson = await parseVRMFile(values.vmc.vrmJson[0]);
if (vmcOsc.vrmJson) {
toggleFlash(true);
setModelName(JSON.parse(vmcOsc.vrmJson).extensions.VRM.meta.title);
if (values.vmc.vrmJson !== undefined) {
if (values.vmc.vrmJson.length > 0) {
vmcOsc.vrmJson = await parseVRMFile(values.vmc.vrmJson[0]);
if (vmcOsc.vrmJson) {
setModelName(
JSON.parse(vmcOsc.vrmJson)?.extensions?.VRM?.meta?.title || ''
);
}
} else {
vmcOsc.vrmJson = '';
setModelName(null);
}
}
vmcOsc.anchorHip = values.vmc.anchorHip;
@@ -99,7 +99,7 @@ export function VMCSettings() {
}, []);
useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => {
const formData: VMCSettingsForm = defaultValues;
const formData: VMCSettingsForm = DEFAULT_VMC_VALUES;
if (settings.vmcOsc) {
if (settings.vmcOsc.oscSettings) {
formData.vmc.oscSettings.enabled = settings.vmcOsc.oscSettings.enabled;
@@ -114,7 +114,7 @@ export function VMCSettings() {
}
const vrmJson = settings.vmcOsc.vrmJson?.toString();
if (vrmJson) {
setModelName(JSON.parse(vrmJson).extensions.VRM.meta.title);
setModelName(JSON.parse(vrmJson)?.extensions?.VRM?.meta?.title || '');
}
formData.vmc.anchorHip = settings.vmcOsc.anchorHip;
@@ -236,16 +236,6 @@ export function VMCSettings() {
{l10n.getString('settings-osc-vmc-vrm-description')}
</Typography>
</div>
<div className="flex flex-col pb-2">
<Typography color={flashLoaded ? 'primary' : 'secondary'}>
{modelName === null
? l10n.getString('settings-osc-vmc-vrm-model_unloaded')
: l10n.getString('settings-osc-vmc-vrm-model_loaded', {
name: modelName,
titled: (!!modelName).toString(),
})}
</Typography>
</div>
<div className="grid gap-3 pb-5">
<FileInput
control={control}
@@ -254,6 +244,12 @@ export function VMCSettings() {
required: false,
}}
value="help"
importedFileName={
// if modelname is an empty string, it's an untitled model
modelName === ''
? l10n.getString('settings-osc-vmc-vrm-untitled_model')
: modelName
}
label="settings-osc-vmc-vrm-file_select"
accept="model/gltf-binary, model/gltf+json, model/vrml, .vrm, .glb, .gltf"
></FileInput>
@@ -303,7 +299,7 @@ export function VMCSettings() {
const gltfHeaderStart = 0;
const gltfHeaderEnd = 20;
async function parseVRMFile(vrm: File): Promise<string | null> {
export async function parseVRMFile(vrm: File): Promise<string | null> {
const headerView = new DataView(
await vrm.slice(gltfHeaderStart, gltfHeaderEnd).arrayBuffer()
);

View File

@@ -131,8 +131,13 @@ export function VRCOSCSettings() {
<div className="flex flex-col pt-2 pb-4">
<>
{l10n
.getString('settings-osc-vrchat-description-v1')
.getString('settings-osc-vrchat-description-v2')
.split('\n')
.concat(
l10n
.getString('settings-osc-vrchat-description-guide')
.split('\n')
)
.map((line, i) => (
<Typography color="secondary" key={i}>
{line}

View File

@@ -11,6 +11,10 @@ import { TrackerStatus } from './TrackerStatus';
import classNames from 'classnames';
import { useTracker } from '@/hooks/tracker';
import { BodyPartIcon } from '@/components/commons/BodyPartIcon';
import { DownloadIcon } from '@/components/commons/icon/DownloadIcon';
import { Link } from 'react-router-dom';
import { useAppContext } from '@/hooks/app';
import { checkForUpdate } from '@/components/firmware-update/FirmwareUpdate';
function TrackerBig({
device,
@@ -41,6 +45,7 @@ function TrackerBig({
<>
{device.hardwareStatus.batteryPctEstimate && (
<TrackerBattery
voltage={device.hardwareStatus.batteryVoltage}
value={device.hardwareStatus.batteryPctEstimate / 100}
disabled={tracker.status === TrackerStatusEnum.DISCONNECTED}
/>
@@ -89,6 +94,7 @@ function TrackerSmol({
<div className="flex flex-col justify-center items-center">
{device.hardwareStatus.batteryPctEstimate && (
<TrackerBattery
voltage={device.hardwareStatus.batteryVoltage}
value={device.hardwareStatus.batteryPctEstimate / 100}
disabled={tracker.status === TrackerStatusEnum.DISCONNECTED}
/>
@@ -120,6 +126,7 @@ export function TrackerCard({
bg = 'bg-background-60',
shakeHighlight = true,
warning = false,
showUpdates = false,
}: {
tracker: TrackerDataT;
device?: DeviceDataT;
@@ -130,33 +137,51 @@ export function TrackerCard({
shakeHighlight?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>;
warning?: boolean;
showUpdates?: boolean;
}) {
const { currentFirmwareRelease } = useAppContext();
const { useVelocity } = useTracker(tracker);
const velocity = useVelocity();
return (
<div
onClick={onClick}
className={classNames(
'rounded-lg overflow-hidden',
interactable && 'hover:bg-background-50 cursor-pointer',
outlined && 'outline outline-2 outline-accent-background-40',
warning && 'border-status-warning border-solid border-2',
bg
)}
style={
shakeHighlight
? {
boxShadow: `0px 0px ${Math.floor(velocity * 8)}px ${Math.floor(
velocity * 8
)}px rgb(var(--accent-background-30))`,
}
: {}
}
>
{smol && <TrackerSmol tracker={tracker} device={device}></TrackerSmol>}
{!smol && <TrackerBig tracker={tracker} device={device}></TrackerBig>}
<div className="relative">
<div
onClick={onClick}
className={classNames(
'rounded-lg overflow-hidden',
interactable && 'hover:bg-background-50 cursor-pointer',
outlined && 'outline outline-2 outline-accent-background-40',
warning && 'border-status-warning border-solid border-2',
bg
)}
style={
shakeHighlight
? {
boxShadow: `0px 0px ${Math.floor(velocity * 8)}px ${Math.floor(
velocity * 8
)}px rgb(var(--accent-background-30))`,
}
: {}
}
>
{smol && <TrackerSmol tracker={tracker} device={device}></TrackerSmol>}
{!smol && <TrackerBig tracker={tracker} device={device}></TrackerBig>}
</div>
{showUpdates &&
tracker.status !== TrackerStatusEnum.DISCONNECTED &&
currentFirmwareRelease &&
device?.hardwareInfo &&
checkForUpdate(currentFirmwareRelease, device.hardwareInfo) && (
<Link to="/firmware-update" className="absolute right-5 -top-2.5">
<div className="relative">
<div className="absolute rounded-full h-6 w-6 left-1 top-1 bg-accent-background-10 animate-[ping_2s_linear_infinite]"></div>
<div className="absolute rounded-full h-8 w-8 hover:bg-background-40 hover:cursor-pointer bg-background-50 justify-center flex items-center">
<DownloadIcon width={15}></DownloadIcon>
</div>
</div>
</Link>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useLocalization } from '@fluent/react';
import { Localized, useLocalization } from '@fluent/react';
import classNames from 'classnames';
import { IPv4 } from 'ip-num/IPNumber';
import { useEffect, useMemo, useState } from 'react';
@@ -6,11 +6,13 @@ import { useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import {
AssignTrackerRequestT,
BoardType,
BodyPart,
ForgetDeviceRequestT,
ImuType,
MagnetometerStatus,
RpcMessage,
TrackerDataType,
} from 'solarxr-protocol';
import { useDebouncedEffect } from '@/hooks/timeout';
import { useTrackerFromId } from '@/hooks/tracker';
@@ -35,6 +37,8 @@ import { TrackerCard } from './TrackerCard';
import { Quaternion } from 'three';
import { useAppContext } from '@/hooks/app';
import { MagnetometerToggleSetting } from '@/components/settings/pages/MagnetometerToggleSetting';
import semver from 'semver';
import { checkForUpdate } from '@/components/firmware-update/FirmwareUpdate';
const rotationsLabels: [Quaternion, string][] = [
[rotationToQuatMap.BACK, 'tracker-rotation-back'],
@@ -148,6 +152,26 @@ export function TrackerSettingsPage() {
}
}, [firstLoad]);
const boardType = useMemo(() => {
if (tracker?.device?.hardwareInfo?.officialBoardType) {
return l10n.getString(
'board_type-' +
BoardType[
tracker?.device?.hardwareInfo?.officialBoardType ??
BoardType.UNKNOWN
]
);
} else if (tracker?.device?.hardwareInfo?.boardType) {
return tracker?.device?.hardwareInfo?.boardType;
} else {
return '--';
}
}, [
tracker?.device?.hardwareInfo?.officialBoardType,
tracker?.device?.hardwareInfo?.boardType,
l10n,
]);
const macAddress = useMemo(() => {
if (
/(?:[a-zA-Z\d]{2}:){5}[a-zA-Z\d]{2}/.test(
@@ -160,6 +184,18 @@ export function TrackerSettingsPage() {
return null;
}, [tracker?.device?.hardwareInfo?.hardwareIdentifier]);
const { currentFirmwareRelease } = useAppContext();
const needUpdate =
currentFirmwareRelease &&
tracker?.device?.hardwareInfo &&
checkForUpdate(currentFirmwareRelease, tracker?.device?.hardwareInfo);
const updateUnavailable =
tracker?.device?.hardwareInfo?.officialBoardType !== BoardType.SLIMEVR ||
!semver.valid(
tracker?.device?.hardwareInfo?.firmwareVersion?.toString() ?? 'none'
);
return (
<form
className="h-full overflow-y-auto"
@@ -187,21 +223,55 @@ export function TrackerSettingsPage() {
shakeHighlight={false}
></TrackerCard>
)}
{/* <div className="flex flex-col bg-background-70 p-3 rounded-lg gap-2">
<Typography bold>Firmware version</Typography>
<div className="flex gap-2">
<Typography color="secondary">
{tracker?.device?.hardwareInfo?.firmwareVersion}
</Typography>
<Typography color="secondary">-</Typography>
<Typography color="text-accent-background-10">
Up to date
</Typography>
{
<div className="flex flex-col bg-background-70 p-3 rounded-lg gap-2">
<Localized id="tracker-settings-update-title">
<Typography variant="section-title">
Firmware version
</Typography>
</Localized>
<div className="flex gap-2">
<Typography color="secondary">
v{tracker?.device?.hardwareInfo?.firmwareVersion}
</Typography>
<Typography color="secondary">-</Typography>
{updateUnavailable && (
<Localized id="tracker-settings-update-unavailable">
<Typography>Cannot be updated (DIY)</Typography>
</Localized>
)}
{!updateUnavailable && (
<>
{!needUpdate && (
<Localized id="tracker-settings-update-up_to_date">
<Typography>Up to date</Typography>
</Localized>
)}
{needUpdate && (
<Localized
id="tracker-settings-update-available"
vars={{ versionName: currentFirmwareRelease?.name }}
>
<Typography color="text-accent-background-10">
New version available
</Typography>
</Localized>
)}
</>
)}
</div>
<Localized id="tracker-settings-update">
<Button
variant={needUpdate ? 'primary' : 'secondary'}
disabled={!needUpdate}
to="/firmware-update"
>
Update now
</Button>
</Localized>
</div>
<Button variant="primary" disabled>
Update now
</Button>
</div> */}
}
<div className="flex flex-col bg-background-70 p-3 rounded-lg gap-2 overflow-x-auto">
<div className="flex justify-between">
<Typography color="secondary">
@@ -236,22 +306,6 @@ export function TrackerSettingsPage() {
).toString()}
</Typography>
</div>
<div className="flex justify-between">
<Typography color="secondary">
{l10n.getString('tracker-infos-version')}
</Typography>
<Typography>
{tracker?.device?.hardwareInfo?.firmwareVersion || '--'}
</Typography>
</div>
{/* <div className="flex justify-between">
<Typography color="secondary">
{l10n.getString('tracker-infos-hardware_rev')}
</Typography>
<Typography>
{tracker?.device?.hardwareInfo?.hardwareRevision || '--'}
</Typography>
</div> */}
<div className="flex justify-between">
<Typography color="secondary">
{l10n.getString('tracker-infos-hardware_identifier')}
@@ -260,6 +314,16 @@ export function TrackerSettingsPage() {
{tracker?.device?.hardwareInfo?.hardwareIdentifier || '--'}
</Typography>
</div>
<div className="flex justify-between">
<Typography color="secondary">
{l10n.getString('tracker-infos-data_support')}
</Typography>
<Typography>
{tracker?.tracker.info?.dataSupport
? TrackerDataType[tracker?.tracker.info?.dataSupport]
: '--'}
</Typography>
</div>
<div className="flex justify-between">
<Typography color="secondary">
{l10n.getString('tracker-infos-imu')}
@@ -274,9 +338,7 @@ export function TrackerSettingsPage() {
<Typography color="secondary">
{l10n.getString('tracker-infos-board_type')}
</Typography>
<Typography>
{tracker?.device?.hardwareInfo?.boardType || '--'}
</Typography>
<Typography>{boardType}</Typography>
</div>
<div className="flex justify-between">
<Typography color="secondary">

View File

@@ -45,7 +45,8 @@ const displayColumns: { [k: string]: boolean } = {
};
const isSlime = ({ device }: FlatDeviceTracker) =>
device?.hardwareInfo?.manufacturer === 'SlimeVR';
device?.hardwareInfo?.manufacturer === 'SlimeVR' ||
device?.hardwareInfo?.manufacturer === 'HID Device';
const getDeviceName = ({ device }: FlatDeviceTracker) =>
device?.customName?.toString() || '';

View File

@@ -0,0 +1,659 @@
/**
* Generated by @openapi-codegen
*
* @version 0.0.1
*/
import * as reactQuery from '@tanstack/react-query';
import { useFirmwareToolContext, FirmwareToolContext } from './firmwareToolContext';
import type * as Fetcher from './firmwareToolFetcher';
import { firmwareToolFetch } from './firmwareToolFetcher';
import type * as Schemas from './firmwareToolSchemas';
export type GetIsCompatibleVersionPathParams = {
version: string;
};
export type GetIsCompatibleVersionError = Fetcher.ErrorWrapper<undefined>;
export type GetIsCompatibleVersionVariables = {
pathParams: GetIsCompatibleVersionPathParams;
} & FirmwareToolContext['fetcherOptions'];
/**
* Is this api compatible with the server version given
*/
export const fetchGetIsCompatibleVersion = (
variables: GetIsCompatibleVersionVariables,
signal?: AbortSignal
) =>
firmwareToolFetch<
Schemas.VerionCheckResponse,
GetIsCompatibleVersionError,
undefined,
{},
{},
GetIsCompatibleVersionPathParams
>({ url: '/is-compatible/{version}', method: 'get', ...variables, signal });
/**
* Is this api compatible with the server version given
*/
export const useGetIsCompatibleVersion = <TData = Schemas.VerionCheckResponse>(
variables: GetIsCompatibleVersionVariables,
options?: Omit<
reactQuery.UseQueryOptions<
Schemas.VerionCheckResponse,
GetIsCompatibleVersionError,
TData
>,
'queryKey' | 'queryFn' | 'initialData'
>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useFirmwareToolContext(options);
return reactQuery.useQuery<
Schemas.VerionCheckResponse,
GetIsCompatibleVersionError,
TData
>({
queryKey: queryKeyFn({
path: '/is-compatible/{version}',
operationId: 'getIsCompatibleVersion',
variables,
}),
queryFn: ({ signal }) =>
fetchGetIsCompatibleVersion({ ...fetcherOptions, ...variables }, signal),
...options,
...queryOptions,
});
};
export type GetFirmwaresError = Fetcher.ErrorWrapper<undefined>;
export type GetFirmwaresResponse = Schemas.FirmwareDTO[];
export type GetFirmwaresVariables = FirmwareToolContext['fetcherOptions'];
/**
* List all the built firmwares
*/
export const fetchGetFirmwares = (
variables: GetFirmwaresVariables,
signal?: AbortSignal
) =>
firmwareToolFetch<GetFirmwaresResponse, GetFirmwaresError, undefined, {}, {}, {}>({
url: '/firmwares',
method: 'get',
...variables,
signal,
});
/**
* List all the built firmwares
*/
export const useGetFirmwares = <TData = GetFirmwaresResponse>(
variables: GetFirmwaresVariables,
options?: Omit<
reactQuery.UseQueryOptions<GetFirmwaresResponse, GetFirmwaresError, TData>,
'queryKey' | 'queryFn' | 'initialData'
>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useFirmwareToolContext(options);
return reactQuery.useQuery<GetFirmwaresResponse, GetFirmwaresError, TData>({
queryKey: queryKeyFn({
path: '/firmwares',
operationId: 'getFirmwares',
variables,
}),
queryFn: ({ signal }) =>
fetchGetFirmwares({ ...fetcherOptions, ...variables }, signal),
...options,
...queryOptions,
});
};
export type PostFirmwaresBuildError = Fetcher.ErrorWrapper<{
status: 400;
payload: Schemas.VersionNotFoundExeption;
}>;
export type PostFirmwaresBuildVariables = {
body: Schemas.CreateBuildFirmwareDTO;
} & FirmwareToolContext['fetcherOptions'];
/**
* Build a firmware from the requested configuration
*/
export const fetchPostFirmwaresBuild = (
variables: PostFirmwaresBuildVariables,
signal?: AbortSignal
) =>
firmwareToolFetch<
Schemas.BuildResponseDTO,
PostFirmwaresBuildError,
Schemas.CreateBuildFirmwareDTO,
{},
{},
{}
>({ url: '/firmwares/build', method: 'post', ...variables, signal });
/**
* Build a firmware from the requested configuration
*/
export const usePostFirmwaresBuild = (
options?: Omit<
reactQuery.UseMutationOptions<
Schemas.BuildResponseDTO,
PostFirmwaresBuildError,
PostFirmwaresBuildVariables
>,
'mutationFn'
>
) => {
const { fetcherOptions } = useFirmwareToolContext();
return reactQuery.useMutation<
Schemas.BuildResponseDTO,
PostFirmwaresBuildError,
PostFirmwaresBuildVariables
>({
mutationFn: (variables: PostFirmwaresBuildVariables) =>
fetchPostFirmwaresBuild({ ...fetcherOptions, ...variables }),
...options,
});
};
export type GetFirmwaresBuildStatusIdPathParams = {
id: string;
};
export type GetFirmwaresBuildStatusIdError = Fetcher.ErrorWrapper<undefined>;
export type GetFirmwaresBuildStatusIdVariables = {
pathParams: GetFirmwaresBuildStatusIdPathParams;
} & FirmwareToolContext['fetcherOptions'];
/**
* Get the build status of a firmware
* This is a SSE (Server Sent Event)
* you can use the web browser api to check for the build status and update the ui in real time
*/
export const fetchGetFirmwaresBuildStatusId = (
variables: GetFirmwaresBuildStatusIdVariables,
signal?: AbortSignal
) =>
firmwareToolFetch<
Schemas.ObservableType,
GetFirmwaresBuildStatusIdError,
undefined,
{},
{},
GetFirmwaresBuildStatusIdPathParams
>({
url: '/firmwares/build-status/{id}',
method: 'get',
...variables,
signal,
});
/**
* Get the build status of a firmware
* This is a SSE (Server Sent Event)
* you can use the web browser api to check for the build status and update the ui in real time
*/
export const useGetFirmwaresBuildStatusId = <TData = Schemas.ObservableType>(
variables: GetFirmwaresBuildStatusIdVariables,
options?: Omit<
reactQuery.UseQueryOptions<
Schemas.ObservableType,
GetFirmwaresBuildStatusIdError,
TData
>,
'queryKey' | 'queryFn' | 'initialData'
>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useFirmwareToolContext(options);
return reactQuery.useQuery<
Schemas.ObservableType,
GetFirmwaresBuildStatusIdError,
TData
>({
queryKey: queryKeyFn({
path: '/firmwares/build-status/{id}',
operationId: 'getFirmwaresBuildStatusId',
variables,
}),
queryFn: ({ signal }) =>
fetchGetFirmwaresBuildStatusId({ ...fetcherOptions, ...variables }, signal),
...options,
...queryOptions,
});
};
export type GetFirmwaresBoardsError = Fetcher.ErrorWrapper<undefined>;
export type GetFirmwaresBoardsResponse = string[];
export type GetFirmwaresBoardsVariables = FirmwareToolContext['fetcherOptions'];
/**
* List all the possible board types
*/
export const fetchGetFirmwaresBoards = (
variables: GetFirmwaresBoardsVariables,
signal?: AbortSignal
) =>
firmwareToolFetch<
GetFirmwaresBoardsResponse,
GetFirmwaresBoardsError,
undefined,
{},
{},
{}
>({ url: '/firmwares/boards', method: 'get', ...variables, signal });
/**
* List all the possible board types
*/
export const useGetFirmwaresBoards = <TData = GetFirmwaresBoardsResponse>(
variables: GetFirmwaresBoardsVariables,
options?: Omit<
reactQuery.UseQueryOptions<
GetFirmwaresBoardsResponse,
GetFirmwaresBoardsError,
TData
>,
'queryKey' | 'queryFn' | 'initialData'
>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useFirmwareToolContext(options);
return reactQuery.useQuery<
GetFirmwaresBoardsResponse,
GetFirmwaresBoardsError,
TData
>({
queryKey: queryKeyFn({
path: '/firmwares/boards',
operationId: 'getFirmwaresBoards',
variables,
}),
queryFn: ({ signal }) =>
fetchGetFirmwaresBoards({ ...fetcherOptions, ...variables }, signal),
...options,
...queryOptions,
});
};
export type GetFirmwaresVersionsError = Fetcher.ErrorWrapper<undefined>;
export type GetFirmwaresVersionsResponse = Schemas.ReleaseDTO[];
export type GetFirmwaresVersionsVariables = FirmwareToolContext['fetcherOptions'];
/**
* List all the possible versions to build a firmware from
*/
export const fetchGetFirmwaresVersions = (
variables: GetFirmwaresVersionsVariables,
signal?: AbortSignal
) =>
firmwareToolFetch<
GetFirmwaresVersionsResponse,
GetFirmwaresVersionsError,
undefined,
{},
{},
{}
>({ url: '/firmwares/versions', method: 'get', ...variables, signal });
/**
* List all the possible versions to build a firmware from
*/
export const useGetFirmwaresVersions = <TData = GetFirmwaresVersionsResponse>(
variables: GetFirmwaresVersionsVariables,
options?: Omit<
reactQuery.UseQueryOptions<
GetFirmwaresVersionsResponse,
GetFirmwaresVersionsError,
TData
>,
'queryKey' | 'queryFn' | 'initialData'
>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useFirmwareToolContext(options);
return reactQuery.useQuery<
GetFirmwaresVersionsResponse,
GetFirmwaresVersionsError,
TData
>({
queryKey: queryKeyFn({
path: '/firmwares/versions',
operationId: 'getFirmwaresVersions',
variables,
}),
queryFn: ({ signal }) =>
fetchGetFirmwaresVersions({ ...fetcherOptions, ...variables }, signal),
...options,
...queryOptions,
});
};
export type GetFirmwaresImusError = Fetcher.ErrorWrapper<undefined>;
export type GetFirmwaresImusResponse = Schemas.Imudto[];
export type GetFirmwaresImusVariables = FirmwareToolContext['fetcherOptions'];
/**
* List all the possible imus to use
*/
export const fetchGetFirmwaresImus = (
variables: GetFirmwaresImusVariables,
signal?: AbortSignal
) =>
firmwareToolFetch<
GetFirmwaresImusResponse,
GetFirmwaresImusError,
undefined,
{},
{},
{}
>({ url: '/firmwares/imus', method: 'get', ...variables, signal });
/**
* List all the possible imus to use
*/
export const useGetFirmwaresImus = <TData = GetFirmwaresImusResponse>(
variables: GetFirmwaresImusVariables,
options?: Omit<
reactQuery.UseQueryOptions<GetFirmwaresImusResponse, GetFirmwaresImusError, TData>,
'queryKey' | 'queryFn' | 'initialData'
>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useFirmwareToolContext(options);
return reactQuery.useQuery<GetFirmwaresImusResponse, GetFirmwaresImusError, TData>({
queryKey: queryKeyFn({
path: '/firmwares/imus',
operationId: 'getFirmwaresImus',
variables,
}),
queryFn: ({ signal }) =>
fetchGetFirmwaresImus({ ...fetcherOptions, ...variables }, signal),
...options,
...queryOptions,
});
};
export type GetFirmwaresBatteriesError = Fetcher.ErrorWrapper<undefined>;
export type GetFirmwaresBatteriesResponse = string[];
export type GetFirmwaresBatteriesVariables = FirmwareToolContext['fetcherOptions'];
/**
* List all the battery types
*/
export const fetchGetFirmwaresBatteries = (
variables: GetFirmwaresBatteriesVariables,
signal?: AbortSignal
) =>
firmwareToolFetch<
GetFirmwaresBatteriesResponse,
GetFirmwaresBatteriesError,
undefined,
{},
{},
{}
>({ url: '/firmwares/batteries', method: 'get', ...variables, signal });
/**
* List all the battery types
*/
export const useGetFirmwaresBatteries = <TData = GetFirmwaresBatteriesResponse>(
variables: GetFirmwaresBatteriesVariables,
options?: Omit<
reactQuery.UseQueryOptions<
GetFirmwaresBatteriesResponse,
GetFirmwaresBatteriesError,
TData
>,
'queryKey' | 'queryFn' | 'initialData'
>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useFirmwareToolContext(options);
return reactQuery.useQuery<
GetFirmwaresBatteriesResponse,
GetFirmwaresBatteriesError,
TData
>({
queryKey: queryKeyFn({
path: '/firmwares/batteries',
operationId: 'getFirmwaresBatteries',
variables,
}),
queryFn: ({ signal }) =>
fetchGetFirmwaresBatteries({ ...fetcherOptions, ...variables }, signal),
...options,
...queryOptions,
});
};
export type GetFirmwaresDefaultConfigBoardPathParams = {
board:
| 'BOARD_SLIMEVR'
| 'BOARD_NODEMCU'
| 'BOARD_WROOM32'
| 'BOARD_WEMOSD1MINI'
| 'BOARD_TTGO_TBASE'
| 'BOARD_ESP01'
| 'BOARD_LOLIN_C3_MINI'
| 'BOARD_BEETLE32C3'
| 'BOARD_ES32C3DEVKITM1';
};
export type GetFirmwaresDefaultConfigBoardError = Fetcher.ErrorWrapper<undefined>;
export type GetFirmwaresDefaultConfigBoardVariables = {
pathParams: GetFirmwaresDefaultConfigBoardPathParams;
} & FirmwareToolContext['fetcherOptions'];
/**
* Gives the default pins / configuration of a given board
*/
export const fetchGetFirmwaresDefaultConfigBoard = (
variables: GetFirmwaresDefaultConfigBoardVariables,
signal?: AbortSignal
) =>
firmwareToolFetch<
Schemas.DefaultBuildConfigDTO,
GetFirmwaresDefaultConfigBoardError,
undefined,
{},
{},
GetFirmwaresDefaultConfigBoardPathParams
>({
url: '/firmwares/default-config/{board}',
method: 'get',
...variables,
signal,
});
/**
* Gives the default pins / configuration of a given board
*/
export const useGetFirmwaresDefaultConfigBoard = <
TData = Schemas.DefaultBuildConfigDTO,
>(
variables: GetFirmwaresDefaultConfigBoardVariables,
options?: Omit<
reactQuery.UseQueryOptions<
Schemas.DefaultBuildConfigDTO,
GetFirmwaresDefaultConfigBoardError,
TData
>,
'queryKey' | 'queryFn' | 'initialData'
>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useFirmwareToolContext(options);
return reactQuery.useQuery<
Schemas.DefaultBuildConfigDTO,
GetFirmwaresDefaultConfigBoardError,
TData
>({
queryKey: queryKeyFn({
path: '/firmwares/default-config/{board}',
operationId: 'getFirmwaresDefaultConfigBoard',
variables,
}),
queryFn: ({ signal }) =>
fetchGetFirmwaresDefaultConfigBoard({ ...fetcherOptions, ...variables }, signal),
...options,
...queryOptions,
});
};
export type GetFirmwaresIdPathParams = {
id: string;
};
export type GetFirmwaresIdError = Fetcher.ErrorWrapper<{
status: 404;
payload: Schemas.HttpException;
}>;
export type GetFirmwaresIdVariables = {
pathParams: GetFirmwaresIdPathParams;
} & FirmwareToolContext['fetcherOptions'];
/**
* Get the inforamtions about a firmware from its id
* also provide more informations than the simple list, like pins and imus and files
*/
export const fetchGetFirmwaresId = (
variables: GetFirmwaresIdVariables,
signal?: AbortSignal
) =>
firmwareToolFetch<
Schemas.FirmwareDetailDTO,
GetFirmwaresIdError,
undefined,
{},
{},
GetFirmwaresIdPathParams
>({ url: '/firmwares/{id}', method: 'get', ...variables, signal });
/**
* Get the inforamtions about a firmware from its id
* also provide more informations than the simple list, like pins and imus and files
*/
export const useGetFirmwaresId = <TData = Schemas.FirmwareDetailDTO>(
variables: GetFirmwaresIdVariables,
options?: Omit<
reactQuery.UseQueryOptions<Schemas.FirmwareDetailDTO, GetFirmwaresIdError, TData>,
'queryKey' | 'queryFn' | 'initialData'
>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useFirmwareToolContext(options);
return reactQuery.useQuery<Schemas.FirmwareDetailDTO, GetFirmwaresIdError, TData>({
queryKey: queryKeyFn({
path: '/firmwares/{id}',
operationId: 'getFirmwaresId',
variables,
}),
queryFn: ({ signal }) =>
fetchGetFirmwaresId({ ...fetcherOptions, ...variables }, signal),
...options,
...queryOptions,
});
};
export type GetHealthError = Fetcher.ErrorWrapper<undefined>;
export type GetHealthVariables = FirmwareToolContext['fetcherOptions'];
/**
* Gives the status of the api
* this endpoint will always return true
*/
export const fetchGetHealth = (variables: GetHealthVariables, signal?: AbortSignal) =>
firmwareToolFetch<boolean, GetHealthError, undefined, {}, {}, {}>({
url: '/health',
method: 'get',
...variables,
signal,
});
/**
* Gives the status of the api
* this endpoint will always return true
*/
export const useGetHealth = <TData = boolean>(
variables: GetHealthVariables,
options?: Omit<
reactQuery.UseQueryOptions<boolean, GetHealthError, TData>,
'queryKey' | 'queryFn' | 'initialData'
>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useFirmwareToolContext(options);
return reactQuery.useQuery<boolean, GetHealthError, TData>({
queryKey: queryKeyFn({
path: '/health',
operationId: 'getHealth',
variables,
}),
queryFn: ({ signal }) =>
fetchGetHealth({ ...fetcherOptions, ...variables }, signal),
...options,
...queryOptions,
});
};
export type QueryOperation =
| {
path: '/is-compatible/{version}';
operationId: 'getIsCompatibleVersion';
variables: GetIsCompatibleVersionVariables;
}
| {
path: '/firmwares';
operationId: 'getFirmwares';
variables: GetFirmwaresVariables;
}
| {
path: '/firmwares/build-status/{id}';
operationId: 'getFirmwaresBuildStatusId';
variables: GetFirmwaresBuildStatusIdVariables;
}
| {
path: '/firmwares/boards';
operationId: 'getFirmwaresBoards';
variables: GetFirmwaresBoardsVariables;
}
| {
path: '/firmwares/versions';
operationId: 'getFirmwaresVersions';
variables: GetFirmwaresVersionsVariables;
}
| {
path: '/firmwares/imus';
operationId: 'getFirmwaresImus';
variables: GetFirmwaresImusVariables;
}
| {
path: '/firmwares/batteries';
operationId: 'getFirmwaresBatteries';
variables: GetFirmwaresBatteriesVariables;
}
| {
path: '/firmwares/default-config/{board}';
operationId: 'getFirmwaresDefaultConfigBoard';
variables: GetFirmwaresDefaultConfigBoardVariables;
}
| {
path: '/firmwares/{id}';
operationId: 'getFirmwaresId';
variables: GetFirmwaresIdVariables;
}
| {
path: '/health';
operationId: 'getHealth';
variables: GetHealthVariables;
};

View File

@@ -0,0 +1,99 @@
import type { QueryKey, UseQueryOptions } from '@tanstack/react-query';
import { QueryOperation } from './firmwareToolComponents';
export type FirmwareToolContext = {
fetcherOptions: {
/**
* Headers to inject in the fetcher
*/
headers?: {};
/**
* Query params to inject in the fetcher
*/
queryParams?: {};
};
queryOptions: {
/**
* Set this to `false` to disable automatic refetching when the query mounts or changes query keys.
* Defaults to `true`.
*/
enabled?: boolean;
};
/**
* Query key manager.
*/
queryKeyFn: (operation: QueryOperation) => QueryKey;
};
/**
* Context injected into every react-query hook wrappers
*
* @param queryOptions options from the useQuery wrapper
*/
export function useFirmwareToolContext<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
_queryOptions?: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>
): FirmwareToolContext {
return {
fetcherOptions: {},
queryOptions: {},
queryKeyFn,
};
}
export const queryKeyFn = (operation: QueryOperation) => {
const queryKey: unknown[] = hasPathParams(operation)
? operation.path
.split('/')
.filter(Boolean)
.map((i) => resolvePathParam(i, operation.variables.pathParams))
: operation.path.split('/').filter(Boolean);
if (hasQueryParams(operation)) {
queryKey.push(operation.variables.queryParams);
}
if (hasBody(operation)) {
queryKey.push(operation.variables.body);
}
return queryKey;
};
// Helpers
const resolvePathParam = (key: string, pathParams: Record<string, string>) => {
if (key.startsWith('{') && key.endsWith('}')) {
return pathParams[key.slice(1, -1)];
}
return key;
};
const hasPathParams = (
operation: QueryOperation
): operation is QueryOperation & {
variables: { pathParams: Record<string, string> };
} => {
return Boolean((operation.variables as any).pathParams);
};
const hasBody = (
operation: QueryOperation
): operation is QueryOperation & {
variables: { body: Record<string, unknown> };
} => {
return Boolean((operation.variables as any).body);
};
const hasQueryParams = (
operation: QueryOperation
): operation is QueryOperation & {
variables: { queryParams: Record<string, unknown> };
} => {
return Boolean((operation.variables as any).queryParams);
};

View File

@@ -0,0 +1,109 @@
import { FirmwareToolContext } from './firmwareToolContext';
export const firmwareToolBaseUrl =
import.meta.env.VITE_FIRMWARE_TOOL_URL ?? 'http://localhost:3000';
export const firmwareToolS3BaseUrl =
import.meta.env.VITE_FIRMWARE_TOOL_S3_URL ?? 'http://localhost:9099';
export type ErrorWrapper<TError> = TError | { status: 'unknown'; payload: string };
export type FirmwareToolFetcherOptions<TBody, THeaders, TQueryParams, TPathParams> = {
url: string;
method: string;
body?: TBody;
headers?: THeaders;
queryParams?: TQueryParams;
pathParams?: TPathParams;
signal?: AbortSignal;
} & FirmwareToolContext['fetcherOptions'];
export async function firmwareToolFetch<
TData,
TError,
TBody extends {} | FormData | undefined | null,
THeaders extends {},
TQueryParams extends {},
TPathParams extends {},
>({
url,
method,
body,
headers,
pathParams,
queryParams,
signal,
}: FirmwareToolFetcherOptions<
TBody,
THeaders,
TQueryParams,
TPathParams
>): Promise<TData> {
try {
const requestHeaders: HeadersInit = {
'Content-Type': 'application/json',
...headers,
};
/**
* As the fetch API is being used, when multipart/form-data is specified
* the Content-Type header must be deleted so that the browser can set
* the correct boundary.
* https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object
*/
if (requestHeaders['Content-Type'].toLowerCase().includes('multipart/form-data')) {
delete requestHeaders['Content-Type'];
}
const response = await window.fetch(
`${firmwareToolBaseUrl}${resolveUrl(url, queryParams, pathParams)}`,
{
signal,
method: method.toUpperCase(),
body: body
? body instanceof FormData
? body
: JSON.stringify(body)
: undefined,
headers: requestHeaders,
}
);
if (!response.ok) {
let error: ErrorWrapper<TError>;
try {
error = await response.json();
} catch (e) {
error = {
status: 'unknown' as const,
payload:
e instanceof Error ? `Unexpected error (${e.message})` : 'Unexpected error',
};
}
throw error;
}
if (response.headers.get('content-type')?.includes('json')) {
return await response.json();
} else {
// if it is not a json response, assume it is a blob and cast it to TData
return (await response.blob()) as unknown as TData;
}
} catch (e) {
let errorObject: Error = {
name: 'unknown' as const,
message: e instanceof Error ? `Network error (${e.message})` : 'Network error',
stack: e as string,
};
throw errorObject;
}
}
const resolveUrl = (
url: string,
queryParams: Record<string, string> = {},
pathParams: Record<string, string> = {}
) => {
let query = new URLSearchParams(queryParams).toString();
if (query) query = `?${query}`;
return url.replace(/\{\w*\}/g, (key) => pathParams[key.slice(1, -1)]) + query;
};

View File

@@ -0,0 +1,608 @@
/**
* Generated by @openapi-codegen
*
* @version 0.0.1
*/
export type VerionCheckResponse = {
success: boolean;
reason?: {
message: string;
versions: string;
};
};
/**
* Root object declaring a built firmware
* this object contains:
* - the status of the build
* - the the repository and commit used as source
*/
export type FirmwareDTO = {
/**
* UUID of the firmware
*
* @format uuid
*/
id: string;
/**
* Id of the firmware version used.
* Usually the commit id of the source
* used to build the firmware
*/
releaseId: string;
/**
* Current status of the build
* this value will change during the build
* process
*
* BUILDING -> DONE \\ the firmwrare is build and ready
* -> FAILED \\ the build failled and will be garbage collected
*/
buildStatus:
| 'CREATING_BUILD_FOLDER'
| 'DOWNLOADING_FIRMWARE'
| 'EXTRACTING_FIRMWARE'
| 'SETTING_UP_DEFINES'
| 'BUILDING'
| 'SAVING'
| 'DONE'
| 'ERROR';
/**
* The repository and branch used as source of the firmware
*/
buildVersion: string;
/**
* The date of creation of this firmware build
*
* @format date-time
*/
createdAt: string;
};
export type BuildResponseDTO = {
/**
* Id of the firmware
*
* @format uuid
*/
id: string;
/**
* Build status of the firmware
*/
status:
| 'CREATING_BUILD_FOLDER'
| 'DOWNLOADING_FIRMWARE'
| 'EXTRACTING_FIRMWARE'
| 'SETTING_UP_DEFINES'
| 'BUILDING'
| 'SAVING'
| 'DONE'
| 'ERROR';
/**
* List of built firmware files, only set if the build succeeded
*/
firmwareFiles?: FirmwareFileDTO[];
};
export type FirmwareFileDTO = {
/**
* Url to the file
*/
url: string;
/**
* Address of the partition
*/
offset: number;
/**
* Is this file the main firmware
*/
isFirmware: boolean;
/**
* Id of the linked firmware
*
* @format uuid
*/
firmwareId: string;
};
export type CreateBuildFirmwareDTO = {
/**
* Repository of the firmware used
*/
version: string;
/**
* Board config, used to declare the pins used by the board
*/
boardConfig: CreateBoardConfigDTO;
/**
* Imu config, list of all the imus used and their pins
*
* @minItems 1
*/
imusConfig: CreateImuConfigDTO[];
};
export type CreateBoardConfigDTO = {
/**
* Type of the board
*/
type:
| 'BOARD_SLIMEVR'
| 'BOARD_NODEMCU'
| 'BOARD_WROOM32'
| 'BOARD_WEMOSD1MINI'
| 'BOARD_TTGO_TBASE'
| 'BOARD_ESP01'
| 'BOARD_LOLIN_C3_MINI'
| 'BOARD_BEETLE32C3'
| 'BOARD_ES32C3DEVKITM1';
/**
* Pin address of the indicator LED
*/
ledPin: string;
/**
* Is the indicator LED enabled
*/
enableLed: boolean;
/**
* Is the led inverted
*/
ledInverted: boolean;
/**
* Pin address of the battery indicator
*/
batteryPin: string;
/**
* Type of battery
*/
batteryType: 'BAT_EXTERNAL' | 'BAT_INTERNAL' | 'BAT_MCP3021' | 'BAT_INTERNAL_MCP3021';
/**
* Array of the different battery resistors, [indicator, SHIELD_R1, SHIELD_R2]
*
* @minItems 3
* @maxItems 3
*/
batteryResistances: number[];
};
export type CreateImuConfigDTO = {
/**
* Type of the imu
*/
type:
| 'IMU_BNO085'
| 'IMU_MPU9250'
| 'IMU_MPU6500'
| 'IMU_BNO080'
| 'IMU_BNO055'
| 'IMU_BNO086'
| 'IMU_MPU6050'
| 'IMU_BMI160'
| 'IMU_ICM20948'
| 'IMU_BMI270';
/**
* Pin address of the imu int pin
* not all imus use it
*/
intPin: string | null;
/**
* Rotation of the imu in degrees
*/
rotation: number;
/**
* Pin address of the scl pin
*/
sclPin: string;
/**
* Pin address of the sda pin
*/
sdaPin: string;
/**
* Is this imu optionnal
* Allows for extensions to be unplugged
*/
optional: boolean;
};
export type VersionNotFoundExeption = {
cause: void;
name: string;
message: string;
stack?: string;
};
/**
* A representation of any set of values over any amount of time. This is the most basic building block
* of RxJS.
*/
export type ObservableType = {
/**
* @deprecated true
*/
source?: Observableany;
/**
* @deprecated true
*/
operator?: OperatoranyType;
};
/**
* A representation of any set of values over any amount of time. This is the most basic building block
* of RxJS.
*/
export type Observableany = {
/**
* @deprecated true
*/
source?: Observableany;
/**
* @deprecated true
*/
operator?: Operatoranyany;
};
/**
* *
*/
export type Operatoranyany = {};
/**
* *
*/
export type OperatoranyType = {};
export type ReleaseDTO = {
/**
* id of the release, usually the commit id
*/
id: string;
/**
* url of the release
*/
url: string;
/**
* name of the release
*/
name: string;
/**
* url of the source archive
*/
zipball_url: string;
/**
* Is this release a pre release
*/
prerelease: boolean;
/**
* Is this release a draft
*/
draft: boolean;
};
export type Imudto = {
/**
* Type of the imu
*/
type:
| 'IMU_BNO085'
| 'IMU_MPU9250'
| 'IMU_MPU6500'
| 'IMU_BNO080'
| 'IMU_BNO055'
| 'IMU_BNO086'
| 'IMU_MPU6050'
| 'IMU_BMI160'
| 'IMU_ICM20948'
| 'IMU_BMI270';
/**
* Does that imu type require a int pin
*/
hasIntPin: boolean;
/**
* First address of the imu
*/
imuStartAddress: number;
/**
* Increment of the address for each new imus
*/
addressIncrement: number;
};
export type DefaultBuildConfigDTO = {
/**
* Default config of the selected board
* contains all the default pins information about the selected board
*/
boardConfig: CreateBoardConfigDTO;
/**
* Inform the flashing utility that the user need to press the boot (or Flash) button
* on the tracker
*/
needBootPress?: boolean;
/**
* Inform the flashing utility that the board will need a reboot after
* being flashed
*/
needManualReboot?: boolean;
/**
* Will use the default values and skip the customisation options
*/
shouldOnlyUseDefaults?: boolean;
/**
* List of the possible imus pins, usually only two items will be sent
*
* @minItems 1
*/
imuDefaults: IMUDefaultDTO[];
/**
* Gives the offset of the firmare file in the eeprom. Used for flashing
*/
application_offset: number;
};
export type IMUDefaultDTO = {
/**
* Type of the imu
*/
type?:
| 'IMU_BNO085'
| 'IMU_MPU9250'
| 'IMU_MPU6500'
| 'IMU_BNO080'
| 'IMU_BNO055'
| 'IMU_BNO086'
| 'IMU_MPU6050'
| 'IMU_BMI160'
| 'IMU_ICM20948'
| 'IMU_BMI270';
/**
* Pin address of the imu int pin
* not all imus use it
*/
intPin: string | null;
/**
* Rotation of the imu in degrees
*/
rotation?: number;
/**
* Pin address of the scl pin
*/
sclPin: string;
/**
* Pin address of the sda pin
*/
sdaPin: string;
/**
* Is this imu optionnal
* Allows for extensions to be unplugged
*/
optional: boolean;
};
export type BoardConfigDTONullable = {
/**
* Unique id of the board config, used for relations
*
* @format uuid
*/
id: string;
/**
* Type of the board
*/
type:
| 'BOARD_SLIMEVR'
| 'BOARD_NODEMCU'
| 'BOARD_WROOM32'
| 'BOARD_WEMOSD1MINI'
| 'BOARD_TTGO_TBASE'
| 'BOARD_ESP01'
| 'BOARD_LOLIN_C3_MINI'
| 'BOARD_BEETLE32C3'
| 'BOARD_ES32C3DEVKITM1';
/**
* Pin address of the indicator LED
*/
ledPin: string;
/**
* Is the indicator LED enabled
*/
enableLed: boolean;
/**
* Is the led inverted
*/
ledInverted: boolean;
/**
* Pin address of the battery indicator
*/
batteryPin: string;
/**
* Type of battery
*/
batteryType: 'BAT_EXTERNAL' | 'BAT_INTERNAL' | 'BAT_MCP3021' | 'BAT_INTERNAL_MCP3021';
/**
* Array of the different battery resistors, [indicator, SHIELD_R1, SHIELD_R2]
*
* @minItems 3
* @maxItems 3
*/
batteryResistances: number[];
/**
* Id of the linked firmware, used for relations
*
* @format uuid
*/
firmwareId: string;
};
export type FirmwareDetailDTO = {
/**
* Pins informations about the board
*/
boardConfig: BoardConfigDTONullable;
/**
* List of the declared imus, and their pin configuration
*
* @minItems 1
*/
imusConfig: ImuConfigDTO[];
/**
* List of the built files / partitions with their url and offsets
*/
firmwareFiles: FirmwareFileDTO[];
/**
* UUID of the firmware
*
* @format uuid
*/
id: string;
/**
* Id of the firmware version used.
* Usually the commit id of the source
* used to build the firmware
*/
releaseId: string;
/**
* Current status of the build
* this value will change during the build
* process
*
* BUILDING -> DONE \\ the firmwrare is build and ready
* -> FAILED \\ the build failled and will be garbage collected
*/
buildStatus:
| 'CREATING_BUILD_FOLDER'
| 'DOWNLOADING_FIRMWARE'
| 'EXTRACTING_FIRMWARE'
| 'SETTING_UP_DEFINES'
| 'BUILDING'
| 'SAVING'
| 'DONE'
| 'ERROR';
/**
* The repository and branch used as source of the firmware
*/
buildVersion: string;
/**
* The date of creation of this firmware build
*
* @format date-time
*/
createdAt: string;
};
export type BoardConfigDTO = {
/**
* Unique id of the board config, used for relations
*
* @format uuid
*/
id: string;
/**
* Type of the board
*/
type:
| 'BOARD_SLIMEVR'
| 'BOARD_NODEMCU'
| 'BOARD_WROOM32'
| 'BOARD_WEMOSD1MINI'
| 'BOARD_TTGO_TBASE'
| 'BOARD_ESP01'
| 'BOARD_LOLIN_C3_MINI'
| 'BOARD_BEETLE32C3'
| 'BOARD_ES32C3DEVKITM1';
/**
* Pin address of the indicator LED
*/
ledPin: string;
/**
* Is the indicator LED enabled
*/
enableLed: boolean;
/**
* Is the led inverted
*/
ledInverted: boolean;
/**
* Pin address of the battery indicator
*/
batteryPin: string;
/**
* Type of battery
*/
batteryType: 'BAT_EXTERNAL' | 'BAT_INTERNAL' | 'BAT_MCP3021' | 'BAT_INTERNAL_MCP3021';
/**
* Array of the different battery resistors, [indicator, SHIELD_R1, SHIELD_R2]
*
* @minItems 3
* @maxItems 3
*/
batteryResistances: number[];
/**
* Id of the linked firmware, used for relations
*
* @format uuid
*/
firmwareId: string;
};
export type ImuConfigDTO = {
/**
* Unique id of the config
* this probably will never be shown to the user as it is moslty use for relations
*
* @format uuid
*/
id: string;
/**
* Type of the imu
*/
type:
| 'IMU_BNO085'
| 'IMU_MPU9250'
| 'IMU_MPU6500'
| 'IMU_BNO080'
| 'IMU_BNO055'
| 'IMU_BNO086'
| 'IMU_MPU6050'
| 'IMU_BMI160'
| 'IMU_ICM20948'
| 'IMU_BMI270';
/**
* Rotation of the imu in degrees
*/
rotation: number;
/**
* Pin address of the imu int pin
* not all imus use it
*/
intPin: string | null;
/**
* Pin address of the scl pin
*/
sclPin: string;
/**
* Pin address of the sda pin
*/
sdaPin: string;
/**
* Is this imu optionnal
* Allows for extensions to be unplugged
*/
optional: boolean;
/**
* id of the linked firmware, used for relations
*
* @format uuid
*/
firmwareId: string;
};
/**
* Defines the base Nest HTTP exception, which is handled by the default
* Exceptions Handler.
*/
export type HttpException = {
cause: void;
name: string;
message: string;
stack?: string;
};

View File

@@ -0,0 +1,15 @@
type ComputeRange<
N extends number,
Result extends Array<unknown> = [],
> = Result['length'] extends N
? Result
: ComputeRange<N, [...Result, Result['length']]>;
export type ClientErrorStatus = Exclude<
ComputeRange<500>[number],
ComputeRange<400>[number]
>;
export type ServerErrorStatus = Exclude<
ComputeRange<600>[number],
ComputeRange<500>[number]
>;

View File

@@ -6,6 +6,7 @@ import {
useEffect,
useMemo,
useReducer,
useState,
} from 'react';
import {
BoneT,
@@ -23,6 +24,14 @@ import { useConfig } from './config';
import { useDataFeedConfig } from './datafeed-config';
import { useWebsocketAPI } from './websocket-api';
import { error } from '@/utils/logging';
import { cacheWrap } from './cache';
export interface FirmwareRelease {
name: string;
version: string;
changelog: string;
firmwareFile: string;
}
export interface FlatDeviceTracker {
device?: DeviceDataT;
@@ -39,6 +48,7 @@ export interface AppState {
}
export interface AppContext {
currentFirmwareRelease: FirmwareRelease | null;
state: AppState;
trackers: FlatDeviceTracker[];
dispatch: Dispatch<AppStateAction>;
@@ -69,6 +79,8 @@ export function useProvideAppContext(): AppContext {
datafeed: new DataFeedUpdateT(),
ignoredTrackers: new Set(),
});
const [currentFirmwareRelease, setCurrentFirmwareRelease] =
useState<FirmwareRelease | null>(null);
useEffect(() => {
if (isConnected) {
@@ -115,7 +127,55 @@ export function useProvideAppContext(): AppContext {
}
});
useEffect(() => {
const fetchCurrentFirmwareRelease = async () => {
const releases: any[] | null = JSON.parse(
await cacheWrap(
'firmware-releases',
() =>
fetch('https://api.github.com/repos/SlimeVR/SlimeVR-Tracker-ESP/releases')
.then((res) => res.text())
.catch(() => 'null'),
1000 * 60 * 60
)
);
if (!releases) return null;
const firstRelease = releases.find(
(release) =>
release.prerelease === false &&
release.assets &&
release.assets.find(
(asset: any) =>
asset.name === 'BOARD_SLIMEVR-firmware.bin' && asset.browser_download_url
)
);
let version = firstRelease.tag_name;
if (version.charAt(0) === 'v') {
version = version.substring(1);
}
if (firstRelease) {
return {
name: firstRelease.name,
version,
changelog: firstRelease.body,
firmwareFile: firstRelease.assets.find(
(asset: any) =>
asset.name === 'BOARD_SLIMEVR-firmware.bin' && asset.browser_download_url
).browser_download_url,
};
} else {
return null;
}
};
fetchCurrentFirmwareRelease().then((res) => setCurrentFirmwareRelease(res));
}, []);
return {
currentFirmwareRelease,
state,
trackers,
dispatch,

View File

@@ -3,9 +3,8 @@ import { useMediaQuery } from 'react-responsive';
import tailwindConfig from '../../tailwind.config';
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpoints = tailwindConfig.theme.screens;
type BreakpointKey = keyof typeof breakpoints;
type BreakpointKey = keyof typeof tailwindConfig.theme.screens;
export function useBreakpoint<K extends BreakpointKey>(breakpointKey: K) {
// FIXME There is a flickering issue caused by this, because isMobile is not resolved fast enough

68
gui/src/hooks/cache.ts Normal file
View File

@@ -0,0 +1,68 @@
import { isTauri } from '@tauri-apps/api/core';
import { createStore } from '@tauri-apps/plugin-store';
interface CrossStorage {
set(key: string, value: string): Promise<void>;
get(key: string): Promise<string | null>;
delete(key: string): Promise<boolean>;
}
const localStore: CrossStorage = {
get: async (key) => localStorage.getItem(`slimevr-cache/${key}`),
set: async (key, value) => localStorage.setItem(`slimevr-cache/${key}`, value),
delete: async (key) => {
localStorage.removeItem(`slimevr-cache/${key}`);
return true;
},
};
const store: CrossStorage = isTauri()
? await createStore('gui-cache.dat', { autoSave: 100 as never })
: localStore;
export async function cacheGet(key: string): Promise<string | null> {
const itemStr = await store.get(key);
if (!itemStr) {
return null;
}
const item = JSON.parse(itemStr);
const now = new Date();
if (now.getTime() > item.expiry) {
await store.delete(key);
return null;
}
return item.value;
}
export async function cacheSet(key: string, value: unknown, ttl: number | undefined) {
const now = new Date();
const item = {
value,
expiry: ttl ? now.getTime() + ttl : 0,
};
await store.set(key, JSON.stringify(item));
}
export async function cacheWrap(
key: string,
orDefault: () => Promise<string>,
ttl: number | undefined
) {
const realItem = await store.get(key);
if (!realItem) {
const defaultItem = await orDefault();
await cacheSet(key, defaultItem, ttl);
return defaultItem;
} else {
return (await cacheGet(key))!;
}
}
export async function cacheDelete(key: string) {
await store.delete(key);
}

View File

@@ -0,0 +1,252 @@
import { createContext, useContext, useState } from 'react';
import {
fetchGetFirmwaresDefaultConfigBoard,
useGetHealth,
useGetIsCompatibleVersion,
} from '@/firmware-tool-api/firmwareToolComponents';
import {
BuildResponseDTO,
CreateBoardConfigDTO,
CreateBuildFirmwareDTO,
DefaultBuildConfigDTO,
FirmwareFileDTO,
} from '@/firmware-tool-api/firmwareToolSchemas';
import { BoardPinsForm } from '@/components/firmware-tool/BoardPinsStep';
import { DeepPartial } from 'react-hook-form';
import {
BoardType,
DeviceIdT,
FirmwarePartT,
FirmwareUpdateMethod,
FirmwareUpdateRequestT,
FirmwareUpdateStatus,
OTAFirmwareUpdateT,
SerialDevicePortT,
SerialFirmwareUpdateT,
} from 'solarxr-protocol';
import { OnboardingContext } from './onboarding';
export type PartialBuildFirmware = DeepPartial<CreateBuildFirmwareDTO>;
export type FirmwareBuildStatus = BuildResponseDTO;
export type SelectedDevice = {
type: FirmwareUpdateMethod;
deviceId: string | number;
deviceNames: string[];
};
export const boardTypeToFirmwareToolBoardType: Record<
Exclude<
BoardType,
// This boards will not be handled by the firmware tool.
// These are either impossible to compile automatically or deprecated
BoardType.CUSTOM | BoardType.SLIMEVR_DEV | BoardType.SLIMEVR_LEGACY
>,
CreateBoardConfigDTO['type'] | null
> = {
[BoardType.UNKNOWN]: null,
[BoardType.NODEMCU]: 'BOARD_NODEMCU',
[BoardType.WROOM32]: 'BOARD_WROOM32',
[BoardType.WEMOSD1MINI]: 'BOARD_WEMOSD1MINI',
[BoardType.TTGO_TBASE]: 'BOARD_TTGO_TBASE',
[BoardType.ESP01]: 'BOARD_ESP01',
[BoardType.SLIMEVR]: 'BOARD_SLIMEVR',
[BoardType.LOLIN_C3_MINI]: 'BOARD_LOLIN_C3_MINI',
[BoardType.BEETLE32C3]: 'BOARD_BEETLE32C3',
[BoardType.ES32C3DEVKITM1]: 'BOARD_ES32C3DEVKITM1',
};
export const firmwareToolToBoardType: Record<CreateBoardConfigDTO['type'], BoardType> =
Object.fromEntries(
Object.entries(boardTypeToFirmwareToolBoardType).map((a) => a.reverse())
);
export const firmwareUpdateErrorStatus = [
FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED,
FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND,
FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED,
FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED,
FirmwareUpdateStatus.ERROR_TIMEOUT,
FirmwareUpdateStatus.ERROR_UNKNOWN,
FirmwareUpdateStatus.ERROR_UNSUPPORTED_METHOD,
FirmwareUpdateStatus.ERROR_UPLOAD_FAILED,
];
export interface FirmwareToolContext {
selectBoard: (boardType: CreateBoardConfigDTO['type']) => Promise<void>;
selectVersion: (version: CreateBuildFirmwareDTO['version']) => void;
updatePins: (form: BoardPinsForm) => void;
updateImus: (imus: CreateBuildFirmwareDTO['imusConfig']) => void;
setBuildStatus: (buildStatus: FirmwareBuildStatus) => void;
selectDevices: (device: SelectedDevice[] | null) => void;
retry: () => void;
buildStatus: FirmwareBuildStatus;
defaultConfig: DefaultBuildConfigDTO | null;
newConfig: PartialBuildFirmware | null;
selectedDevices: SelectedDevice[] | null;
isStepLoading: boolean;
isGlobalLoading: boolean;
isCompatible: boolean;
isError: boolean;
}
export const FirmwareToolContextC = createContext<FirmwareToolContext>(
undefined as any
);
export function useFirmwareTool() {
const context = useContext<FirmwareToolContext>(FirmwareToolContextC);
if (!context) {
throw new Error('useFirmwareTool must be within a FirmwareToolContext Provider');
}
return context;
}
export function useFirmwareToolContext(): FirmwareToolContext {
const [defaultConfig, setDefaultConfig] = useState<DefaultBuildConfigDTO | null>(
null
);
const [selectedDevices, selectDevices] = useState<SelectedDevice[] | null>(null);
const [newConfig, setNewConfig] = useState<PartialBuildFirmware>({});
const [isLoading, setLoading] = useState(false);
const { isError, isLoading: isInitialLoading, refetch } = useGetHealth({});
const compatibilityCheckEnabled = !!__VERSION_TAG__;
const { isLoading: isCompatibilityLoading, data: compatibilityData } =
useGetIsCompatibleVersion(
{ pathParams: { version: __VERSION_TAG__ } },
{ enabled: compatibilityCheckEnabled }
);
const [buildStatus, setBuildStatus] = useState<FirmwareBuildStatus>({
status: 'CREATING_BUILD_FOLDER',
id: '',
});
return {
selectBoard: async (boardType: CreateBoardConfigDTO['type']) => {
setLoading(true);
const boardDefaults = await fetchGetFirmwaresDefaultConfigBoard({
pathParams: { board: boardType },
});
setDefaultConfig(boardDefaults);
if (boardDefaults.shouldOnlyUseDefaults) {
setNewConfig((currConfig) => ({
...currConfig,
...boardDefaults,
imusConfig: boardDefaults.imuDefaults,
}));
} else {
setNewConfig((currConfig) => ({
...currConfig,
boardConfig: { ...currConfig.boardConfig, type: boardType },
imusConfig: [],
}));
}
setLoading(false);
},
updatePins: (form: BoardPinsForm) => {
setNewConfig((currConfig) => {
return {
...currConfig,
imusConfig: [...(currConfig?.imusConfig || [])],
boardConfig: {
...currConfig.boardConfig,
...form,
},
};
});
},
updateImus: (imus: CreateBuildFirmwareDTO['imusConfig']) => {
setNewConfig((currConfig) => {
return {
...currConfig,
imusConfig: imus.map(({ rotation, ...fields }) => ({
...fields,
rotation: Number(rotation),
})), // Make sure that the rotation is handled as number
};
});
},
retry: async () => {
setLoading(true);
await refetch();
setLoading(false);
},
selectVersion: (version: CreateBuildFirmwareDTO['version']) => {
setNewConfig((currConfig) => ({ ...currConfig, version }));
},
setBuildStatus,
selectDevices,
selectedDevices,
buildStatus,
defaultConfig,
newConfig,
isStepLoading: isLoading,
isGlobalLoading: isInitialLoading || isCompatibilityLoading,
isCompatible: !compatibilityCheckEnabled || (compatibilityData?.success ?? false),
isError: isError || (!compatibilityData?.success && compatibilityCheckEnabled),
};
}
export const getFlashingRequests = (
devices: SelectedDevice[],
firmwareFiles: FirmwareFileDTO[],
onboardingState: OnboardingContext['state'],
defaultConfig: DefaultBuildConfigDTO | null
) => {
const firmware = firmwareFiles.find(({ isFirmware }) => isFirmware);
if (!firmware) throw new Error('invalid state - no firmware to find');
const requests = [];
for (const device of devices) {
switch (device.type) {
case FirmwareUpdateMethod.OTAFirmwareUpdate: {
const dId = new DeviceIdT();
dId.id = +device.deviceId;
const part = new FirmwarePartT();
part.offset = 0;
part.url = firmware.url;
const method = new OTAFirmwareUpdateT();
method.deviceId = dId;
method.firmwarePart = part;
const req = new FirmwareUpdateRequestT();
req.method = method;
req.methodType = FirmwareUpdateMethod.OTAFirmwareUpdate;
requests.push(req);
break;
}
case FirmwareUpdateMethod.SerialFirmwareUpdate: {
const id = new SerialDevicePortT();
id.port = device.deviceId.toString();
if (!onboardingState.wifi?.ssid || !onboardingState.wifi?.password)
throw new Error('invalid state, wifi should be set');
const method = new SerialFirmwareUpdateT();
method.deviceId = id;
method.ssid = onboardingState.wifi.ssid;
method.password = onboardingState.wifi.password;
method.needManualReboot = defaultConfig?.needManualReboot ?? false;
method.firmwarePart = firmwareFiles.map(({ offset, url }) => {
const part = new FirmwarePartT();
part.offset = offset;
part.url = url;
return part;
});
const req = new FirmwareUpdateRequestT();
req.method = method;
req.methodType = FirmwareUpdateMethod.SerialFirmwareUpdate;
requests.push(req);
break;
}
default: {
throw new Error('unsupported flashing method');
}
}
}
return requests;
};

View File

@@ -16,7 +16,7 @@ interface OnboardingState {
export interface OnboardingContext {
state: OnboardingState;
applyProgress: (value: number) => void;
setWifiCredentials: (ssid: string, password: string) => void;
setWifiCredentials: (ssid: string, password?: string) => void;
skipSetup: () => void;
}
@@ -68,8 +68,8 @@ export function useProvideOnboarding(): OnboardingContext {
dispatch({ type: 'progress', value });
}, []);
},
setWifiCredentials: (ssid: string, password: string) => {
dispatch({ type: 'wifi-creds', ssid, password });
setWifiCredentials: (ssid: string, password?: string) => {
dispatch({ type: 'wifi-creds', ssid, password: password ?? '' });
},
skipSetup: () => {
setConfig({ doneOnboarding: true });

View File

@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { BodyPart, TrackerDataT, TrackerStatus } from 'solarxr-protocol';
import { BodyPart, TrackerDataT, TrackerInfoT, TrackerStatus } from 'solarxr-protocol';
import { QuaternionFromQuatT, QuaternionToEulerDegrees } from '@/maths/quaternion';
import { useAppContext } from './app';
import { useLocalization } from '@fluent/react';
import { ReactLocalization, useLocalization } from '@fluent/react';
import { useDataFeedConfig } from './datafeed-config';
import { Quaternion, Vector3 } from 'three';
import { Vector3FromVec3fT } from '@/maths/vector3';
@@ -36,18 +36,19 @@ export function useTrackers() {
};
}
export function getTrackerName(l10n: ReactLocalization, info: TrackerInfoT | null) {
if (info?.customName) return info?.customName;
if (info?.bodyPart) return l10n.getString('body_part-' + BodyPart[info?.bodyPart]);
return info?.displayName || 'NONE';
}
export function useTracker(tracker: TrackerDataT) {
const { l10n } = useLocalization();
const { feedMaxTps } = useDataFeedConfig();
return {
useName: () =>
useMemo(() => {
if (tracker.info?.customName) return tracker.info?.customName;
if (tracker.info?.bodyPart)
return l10n.getString('body_part-' + BodyPart[tracker.info?.bodyPart]);
return tracker.info?.displayName || 'NONE';
}, [tracker.info]),
useMemo(() => getTrackerName(l10n, tracker.info), [tracker.info, l10n]),
useRawRotationEulerDegrees: () =>
useMemo(() => QuaternionToEulerDegrees(tracker?.rotation), [tracker.rotation]),
useRefAdjRotationEulerDegrees: () =>

View File

@@ -17,6 +17,12 @@ body {
}
@media (-webkit-animation) {
img.uncrisp {
// Webkit moment https://stackoverflow.com/questions/7908168/image-resize-gives-slight-brief-pixelation-in-webkit-browsers
// doesn't happen in newer versions, weird.
-webkit-transform: translate3d(0, 0, 0);
}
body {
font-family: var(--font-name), 'Noto Sans CJK', sans-serif, 'Twemoji Webkit',
emoji;
@@ -84,7 +90,7 @@ body {
}
:root {
overflow: hidden;
// overflow: hidden; -- NEVER EVER BRING THIS BACK <3
background: theme('colors.background.20');
--navbar-w: 101px;
@@ -382,6 +388,14 @@ body {
background: theme('colors.background.60');
}
.bg-background-60::-webkit-scrollbar-thumb:hover {
background: theme('colors.background.40');
}
.bg-background-60 {
scrollbar-color: theme('colors.background.50') transparent;
}
.dropdown-scroll {
scrollbar-color: theme('colors.background.40') theme('colors.background.50');
}

View File

@@ -223,8 +223,8 @@ export class BoneKind extends Bone {
case BodyPart.LEFT_HIP:
case BodyPart.RIGHT_HIP:
return new Color('pink');
case BodyPart.LEFT_THUMB_METACARPAL:
case BodyPart.LEFT_THUMB_PROXIMAL:
case BodyPart.LEFT_THUMB_INTERMEDIATE:
case BodyPart.LEFT_THUMB_DISTAL:
case BodyPart.LEFT_INDEX_PROXIMAL:
case BodyPart.LEFT_INDEX_INTERMEDIATE:
@@ -238,8 +238,8 @@ export class BoneKind extends Bone {
case BodyPart.LEFT_LITTLE_PROXIMAL:
case BodyPart.LEFT_LITTLE_INTERMEDIATE:
case BodyPart.LEFT_LITTLE_DISTAL:
case BodyPart.RIGHT_THUMB_METACARPAL:
case BodyPart.RIGHT_THUMB_PROXIMAL:
case BodyPart.RIGHT_THUMB_INTERMEDIATE:
case BodyPart.RIGHT_THUMB_DISTAL:
case BodyPart.RIGHT_INDEX_PROXIMAL:
case BodyPart.RIGHT_INDEX_INTERMEDIATE:
@@ -307,7 +307,7 @@ export class BoneKind extends Bone {
return [BodyPart.RIGHT_HAND];
case BodyPart.LEFT_HAND:
return [
BodyPart.LEFT_THUMB_PROXIMAL,
BodyPart.LEFT_THUMB_METACARPAL,
BodyPart.LEFT_INDEX_PROXIMAL,
BodyPart.LEFT_MIDDLE_PROXIMAL,
BodyPart.LEFT_RING_PROXIMAL,
@@ -315,16 +315,16 @@ export class BoneKind extends Bone {
];
case BodyPart.RIGHT_HAND:
return [
BodyPart.RIGHT_THUMB_PROXIMAL,
BodyPart.RIGHT_THUMB_METACARPAL,
BodyPart.RIGHT_INDEX_PROXIMAL,
BodyPart.RIGHT_MIDDLE_PROXIMAL,
BodyPart.RIGHT_RING_PROXIMAL,
BodyPart.RIGHT_LITTLE_PROXIMAL,
];
case BodyPart.LEFT_THUMB_METACARPAL:
return [BodyPart.LEFT_THUMB_PROXIMAL];
case BodyPart.LEFT_THUMB_PROXIMAL:
return [BodyPart.LEFT_THUMB_INTERMEDIATE];
case BodyPart.LEFT_THUMB_INTERMEDIATE:
return [BodyPart.LEFT_THUMB_DISTAL];
case BodyPart.LEFT_THUMB_DISTAL:
return [];
@@ -352,9 +352,9 @@ export class BoneKind extends Bone {
return [BodyPart.LEFT_LITTLE_DISTAL];
case BodyPart.LEFT_LITTLE_DISTAL:
return [];
case BodyPart.RIGHT_THUMB_METACARPAL:
return [BodyPart.RIGHT_THUMB_PROXIMAL];
case BodyPart.RIGHT_THUMB_PROXIMAL:
return [BodyPart.RIGHT_THUMB_INTERMEDIATE];
case BodyPart.RIGHT_THUMB_INTERMEDIATE:
return [BodyPart.RIGHT_THUMB_DISTAL];
case BodyPart.RIGHT_THUMB_DISTAL:
return [];
@@ -434,12 +434,12 @@ export class BoneKind extends Bone {
case BodyPart.RIGHT_HAND:
return BodyPart.RIGHT_LOWER_ARM;
case BodyPart.LEFT_THUMB_PROXIMAL:
case BodyPart.LEFT_THUMB_METACARPAL:
return BodyPart.LEFT_HAND;
case BodyPart.LEFT_THUMB_INTERMEDIATE:
return BodyPart.LEFT_THUMB_PROXIMAL;
case BodyPart.LEFT_THUMB_PROXIMAL:
return BodyPart.LEFT_THUMB_METACARPAL;
case BodyPart.LEFT_THUMB_DISTAL:
return BodyPart.LEFT_THUMB_INTERMEDIATE;
return BodyPart.LEFT_THUMB_PROXIMAL;
case BodyPart.LEFT_INDEX_PROXIMAL:
return BodyPart.LEFT_HAND;
case BodyPart.LEFT_INDEX_INTERMEDIATE:
@@ -464,12 +464,12 @@ export class BoneKind extends Bone {
return BodyPart.LEFT_LITTLE_PROXIMAL;
case BodyPart.LEFT_LITTLE_DISTAL:
return BodyPart.LEFT_LITTLE_INTERMEDIATE;
case BodyPart.RIGHT_THUMB_PROXIMAL:
case BodyPart.RIGHT_THUMB_METACARPAL:
return BodyPart.RIGHT_HAND;
case BodyPart.RIGHT_THUMB_INTERMEDIATE:
return BodyPart.RIGHT_THUMB_PROXIMAL;
case BodyPart.RIGHT_THUMB_PROXIMAL:
return BodyPart.RIGHT_THUMB_METACARPAL;
case BodyPart.RIGHT_THUMB_DISTAL:
return BodyPart.RIGHT_THUMB_INTERMEDIATE;
return BodyPart.RIGHT_THUMB_PROXIMAL;
case BodyPart.RIGHT_INDEX_PROXIMAL:
return BodyPart.RIGHT_HAND;
case BodyPart.RIGHT_INDEX_INTERMEDIATE:

View File

@@ -14,6 +14,7 @@ export async function fetchResourceUrl(url: string) {
// FIXME: For some fucking reason, you can't top-level await on a react component file
// on Chromium on developments builds specifically -Uriel
export const AUTOBONE_VIDEO = await fetchResourceUrl('/videos/autobone.webm');
export const VRCHAT_OSC_VIDEO = await fetchResourceUrl('/videos/vrchatosc.webm');
export const isTrayAvailable =
isTauri() && (await invoke<boolean>('is_tray_available'));

View File

@@ -1,5 +1,6 @@
import plugin from 'tailwindcss/plugin';
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
import gradient from 'tailwind-gradient-mask-image';
import type { Config } from 'tailwindcss';
@@ -150,7 +151,7 @@ const colors = {
700: '#b3b3b3',
900: '#d8d8d8',
},
'asexual': {
asexual: {
100: '#000000',
200: '#A3A3A3',
300: '#FFFFFF',
@@ -162,9 +163,11 @@ const config = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
screens: {
'mobile-settings': { raw: 'not (min-width: 900px)' },
nsmol: { raw: 'not (min-width: 525px)' },
smol: '525px',
mobile: { raw: 'not (min-width: 800px)' },
'xs-settings': '900px',
xs: '800px',
nsm: { raw: 'not (min-width: 900px)' },
sm: '900px',
@@ -245,6 +248,7 @@ const config = {
plugins: [
forms,
gradient,
typography,
plugin(function ({ addUtilities }) {
const textConfig = (fontSize: any, fontWeight: any) => ({
fontSize,

View File

@@ -10,7 +10,9 @@ const versionTag = execSync('git --no-pager tag --sort -taggerdate --points-at H
.split('\n')[0]
.trim();
// If not empty then it's not clean
const gitClean = execSync('git status --porcelain').toString() ? false : true;
const gitCleanString = execSync('git status --porcelain').toString();
const gitClean = gitCleanString ? false : true;
if (!gitClean) console.log('Git is dirty because of:\n' + gitCleanString);
console.log(`version is ${versionTag || commitHash}${gitClean ? '' : '-dirty'}`);
@@ -21,7 +23,7 @@ export function i18nHotReload(): PluginOption {
handleHotUpdate({ file, server }) {
if (file.endsWith('.ftl')) {
console.log('Fluent files updated');
server.ws.send({
server.hot.send({
type: 'custom',
event: 'locales-update',
});

3636
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,12 +57,8 @@ tasks.withType<Javadoc> {
options.encoding = "UTF-8"
}
allprojects {
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
}
repositories {
google()
}
dependencies {

View File

@@ -90,10 +90,16 @@ class AndroidSerialHandler(val activity: AppCompatActivity) :
listeners.forEach { it.onNewSerialDevice(port) }
}
private fun onDeviceDel(port: SerialPortWrapper) {
listeners.forEach { it.onSerialDeviceDeleted(port) }
}
private fun detectNewPorts() {
val differences = knownPorts.asSequence() - lastKnownPorts
val addDifferences = knownPorts.asSequence() - lastKnownPorts
val delDifferences = lastKnownPorts - knownPorts.asSequence().toSet()
lastKnownPorts = knownPorts.asSequence().toSet()
differences.forEach { onNewDevice(it) }
addDifferences.forEach { onNewDevice(it) }
delDifferences.forEach { onDeviceDel(it) }
}
override fun addListener(channel: SerialListener) {
@@ -226,12 +232,18 @@ class AndroidSerialHandler(val activity: AppCompatActivity) :
}
}
override fun write(buff: ByteArray) {
usbIoManager?.writeAsync(buff)
}
@Synchronized
override fun setWifi(ssid: String, passwd: String) {
writeSerial("SET WIFI \"${ssid}\" \"${passwd}\"")
addLog("-> SET WIFI \"$ssid\" \"${passwd.replace(".".toRegex(), "*")}\"\n")
}
override fun getCurrentPort(): SlimeSerialPort? = this.currentPort
private fun addLog(str: String) {
LogManager.info("[Serial] $str")
listeners.forEach { it.onSerialLog(str) }

Some files were not shown because too many files have changed in this diff Show More