mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Merge branch 'main' into onboarding-usage-step
This commit is contained in:
2
.github/workflows/gradle.yaml
vendored
2
.github/workflows/gradle.yaml
vendored
@@ -285,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:
|
||||
|
||||
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -2018,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"
|
||||
@@ -3188,7 +3197,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3827,6 +3836,7 @@ dependencies = [
|
||||
"discord-sdk",
|
||||
"flexi_logger",
|
||||
"glob",
|
||||
"itertools",
|
||||
"libloading 0.8.5",
|
||||
"log",
|
||||
"log-panics",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
harfbuzz
|
||||
libffi
|
||||
libsoup_3
|
||||
openssl
|
||||
openssl.dev
|
||||
pango
|
||||
pkg-config
|
||||
treefmt
|
||||
|
||||
8
gui/.env
Normal file
8
gui/.env
Normal 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
|
||||
@@ -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
1
gui/.gitignore
vendored
@@ -28,6 +28,7 @@ yarn-error.log*
|
||||
# vite
|
||||
/dist
|
||||
/stats.html
|
||||
vite.config.ts.timestamp*
|
||||
|
||||
# eslint
|
||||
.eslintcache
|
||||
|
||||
@@ -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
79
gui/eslint.config.js
Normal 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;
|
||||
28
gui/openapi-codegen.config.ts
Normal file
28
gui/openapi-codegen.config.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,6 +83,7 @@
|
||||
"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",
|
||||
@@ -78,6 +91,7 @@
|
||||
"tailwind-gradient-mask-image": "^1.2.0",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"ts-xor": "^1.3.0",
|
||||
"vite": "^5.4.8"
|
||||
"vite": "^5.4.8",
|
||||
"typescript-eslint": "^8.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -253,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
|
||||
@@ -325,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
|
||||
@@ -701,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
|
||||
@@ -750,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
|
||||
@@ -1115,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
|
||||
|
||||
BIN
gui/public/images/R11_board_reset.webp
Normal file
BIN
gui/public/images/R11_board_reset.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 450 KiB |
BIN
gui/public/images/R12_board_reset.webp
Normal file
BIN
gui/public/images/R12_board_reset.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 578 KiB |
BIN
gui/public/images/R14_board_reset_sw.webp
Normal file
BIN
gui/public/images/R14_board_reset_sw.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 428 KiB |
@@ -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"
|
||||
|
||||
@@ -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,88 +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(|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()?
|
||||
};
|
||||
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
|
||||
@@ -159,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())
|
||||
@@ -281,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 {
|
||||
@@ -295,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 {
|
||||
@@ -339,6 +394,4 @@ fn main() -> Result<()> {
|
||||
show_error(&error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -51,11 +51,14 @@ 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';
|
||||
@@ -89,6 +92,14 @@ function Layout() {
|
||||
</MainLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/firmware-update"
|
||||
element={
|
||||
<MainLayout isMobile={isMobile} widgets={false}>
|
||||
<FirmwareUpdate />
|
||||
</MainLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/vr-mode"
|
||||
element={
|
||||
@@ -113,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 />} />
|
||||
@@ -295,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>
|
||||
|
||||
7
gui/src/components/EmptyLayout.scss
Normal file
7
gui/src/components/EmptyLayout.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.empty-layout {
|
||||
display: grid;
|
||||
grid-template:
|
||||
't' var(--topbar-h)
|
||||
'c' calc(100% - var(--topbar-h))
|
||||
/ 100%;
|
||||
}
|
||||
16
gui/src/components/EmptyLayout.tsx
Normal file
16
gui/src/components/EmptyLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
138
gui/src/components/commons/VerticalStepper.tsx
Normal file
138
gui/src/components/commons/VerticalStepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
gui/src/components/firmware-tool/AddImusStep.tsx
Normal file
308
gui/src/components/firmware-tool/AddImusStep.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
198
gui/src/components/firmware-tool/BoardPinsStep.tsx
Normal file
198
gui/src/components/firmware-tool/BoardPinsStep.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
111
gui/src/components/firmware-tool/BuildStep.tsx
Normal file
111
gui/src/components/firmware-tool/BuildStep.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
gui/src/components/firmware-tool/DeviceCard.tsx
Normal file
132
gui/src/components/firmware-tool/DeviceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
gui/src/components/firmware-tool/FirmwareTool.tsx
Normal file
140
gui/src/components/firmware-tool/FirmwareTool.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
gui/src/components/firmware-tool/FlashBtnStep.tsx
Normal file
86
gui/src/components/firmware-tool/FlashBtnStep.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
421
gui/src/components/firmware-tool/FlashingMethodStep.tsx
Normal file
421
gui/src/components/firmware-tool/FlashingMethodStep.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
220
gui/src/components/firmware-tool/FlashingStep.tsx
Normal file
220
gui/src/components/firmware-tool/FlashingStep.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
gui/src/components/firmware-tool/SelectBoardStep.tsx
Normal file
95
gui/src/components/firmware-tool/SelectBoardStep.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
120
gui/src/components/firmware-tool/SelectFirmwareStep.tsx
Normal file
120
gui/src/components/firmware-tool/SelectFirmwareStep.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
402
gui/src/components/firmware-update/FirmwareUpdate.tsx
Normal file
402
gui/src/components/firmware-update/FirmwareUpdate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
@@ -122,6 +126,7 @@ export function TrackerCard({
|
||||
bg = 'bg-background-60',
|
||||
shakeHighlight = true,
|
||||
warning = false,
|
||||
showUpdates = false,
|
||||
}: {
|
||||
tracker: TrackerDataT;
|
||||
device?: DeviceDataT;
|
||||
@@ -132,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +6,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
AssignTrackerRequestT,
|
||||
BoardType,
|
||||
BodyPart,
|
||||
ForgetDeviceRequestT,
|
||||
ImuType,
|
||||
@@ -36,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'],
|
||||
@@ -149,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(
|
||||
@@ -161,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"
|
||||
@@ -188,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">
|
||||
@@ -237,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')}
|
||||
@@ -285,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">
|
||||
|
||||
659
gui/src/firmware-tool-api/firmwareToolComponents.ts
Normal file
659
gui/src/firmware-tool-api/firmwareToolComponents.ts
Normal 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;
|
||||
};
|
||||
99
gui/src/firmware-tool-api/firmwareToolContext.ts
Normal file
99
gui/src/firmware-tool-api/firmwareToolContext.ts
Normal 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);
|
||||
};
|
||||
109
gui/src/firmware-tool-api/firmwareToolFetcher.ts
Normal file
109
gui/src/firmware-tool-api/firmwareToolFetcher.ts
Normal 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;
|
||||
};
|
||||
608
gui/src/firmware-tool-api/firmwareToolSchemas.ts
Normal file
608
gui/src/firmware-tool-api/firmwareToolSchemas.ts
Normal 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;
|
||||
};
|
||||
15
gui/src/firmware-tool-api/firmwareToolUtils.ts
Normal file
15
gui/src/firmware-tool-api/firmwareToolUtils.ts
Normal 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]
|
||||
>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
68
gui/src/hooks/cache.ts
Normal 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);
|
||||
}
|
||||
252
gui/src/hooks/firmware-tool.ts
Normal file
252
gui/src/hooks/firmware-tool.ts
Normal 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;
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -90,7 +90,7 @@ body {
|
||||
}
|
||||
|
||||
:root {
|
||||
overflow: hidden;
|
||||
// overflow: hidden; -- NEVER EVER BRING THIS BACK <3
|
||||
background: theme('colors.background.20');
|
||||
|
||||
--navbar-w: 101px;
|
||||
@@ -388,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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
3614
pnpm-lock.yaml
generated
3614
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -57,12 +57,8 @@ tasks.withType<Javadoc> {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven(url = "https://jitpack.io")
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -2,8 +2,11 @@ plugins {
|
||||
id("com.diffplug.spotless")
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://jitpack.io")
|
||||
}
|
||||
}
|
||||
|
||||
configure<com.diffplug.gradle.spotless.SpotlessExtension> {
|
||||
@@ -35,7 +38,7 @@ configure<com.diffplug.gradle.spotless.SpotlessExtension> {
|
||||
"ktlint_standard_property-naming" to "disabled",
|
||||
"ij_kotlin_packages_to_use_import_on_demand" to
|
||||
"java.util.*,kotlin.math.*,dev.slimevr.autobone.errors.*" +
|
||||
",io.github.axisangles.ktmath.*,kotlinx.atomicfu.*" +
|
||||
",io.github.axisangles.ktmath.*,kotlinx.atomicfu.*,kotlinx.coroutines.*" +
|
||||
",dev.slimevr.tracking.trackers.*,dev.slimevr.desktop.platform.ProtobufMessages.*" +
|
||||
",solarxr_protocol.rpc.*,kotlinx.coroutines.*,com.illposed.osc.*,android.app.*",
|
||||
"ij_kotlin_allow_trailing_comma" to true,
|
||||
|
||||
@@ -77,6 +77,12 @@ dependencies {
|
||||
implementation("com.melloware:jintellitype:1.+")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||
implementation("com.mayakapps.kache:kache:2.1.0")
|
||||
|
||||
api("com.github.loucass003:EspflashKotlin:v0.10.0")
|
||||
|
||||
// Allow the use of reflection
|
||||
implementation(kotlin("reflect"))
|
||||
|
||||
// Jitpack
|
||||
implementation("com.github.SlimeVR:oscquery-kt:566a0cba58")
|
||||
@@ -87,6 +93,7 @@ dependencies {
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import dev.slimevr.autobone.AutoBoneHandler
|
||||
import dev.slimevr.bridge.Bridge
|
||||
import dev.slimevr.bridge.ISteamVRBridge
|
||||
import dev.slimevr.config.ConfigManager
|
||||
import dev.slimevr.firmware.FirmwareUpdateHandler
|
||||
import dev.slimevr.firmware.SerialFlashingHandler
|
||||
import dev.slimevr.osc.OSCHandler
|
||||
import dev.slimevr.osc.OSCRouter
|
||||
import dev.slimevr.osc.VMCHandler
|
||||
@@ -47,9 +49,12 @@ class VRServer @JvmOverloads constructor(
|
||||
driverBridgeProvider: SteamBridgeProvider = { _, _ -> null },
|
||||
feederBridgeProvider: (VRServer) -> ISteamVRBridge? = { _ -> null },
|
||||
serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() },
|
||||
flashingHandlerProvider: (VRServer) -> SerialFlashingHandler? = { _ -> null },
|
||||
acquireMulticastLock: () -> Any? = { null },
|
||||
// configPath is used by VRWorkout, do not remove!
|
||||
configPath: String,
|
||||
) : Thread("VRServer") {
|
||||
|
||||
@JvmField
|
||||
val configManager: ConfigManager
|
||||
|
||||
@@ -60,6 +65,7 @@ class VRServer @JvmOverloads constructor(
|
||||
private val bridges: MutableList<Bridge> = FastList()
|
||||
private val tasks: Queue<Runnable> = LinkedBlockingQueue()
|
||||
private val newTrackersConsumers: MutableList<Consumer<Tracker>> = FastList()
|
||||
private val trackerStatusListeners: MutableList<TrackerStatusListener> = FastList()
|
||||
private val onTick: MutableList<Runnable> = FastList()
|
||||
private val lock = acquireMulticastLock()
|
||||
val oSCRouter: OSCRouter
|
||||
@@ -77,6 +83,10 @@ class VRServer @JvmOverloads constructor(
|
||||
@JvmField
|
||||
val serialHandler: SerialHandler
|
||||
|
||||
var serialFlashingHandler: SerialFlashingHandler?
|
||||
|
||||
val firmwareUpdateHandler: FirmwareUpdateHandler
|
||||
|
||||
@JvmField
|
||||
val autoBoneHandler: AutoBoneHandler
|
||||
|
||||
@@ -106,12 +116,14 @@ class VRServer @JvmOverloads constructor(
|
||||
configManager.loadConfig()
|
||||
deviceManager = DeviceManager(this)
|
||||
serialHandler = serialHandlerProvider(this)
|
||||
serialFlashingHandler = flashingHandlerProvider(this)
|
||||
provisioningHandler = ProvisioningHandler(this)
|
||||
resetHandler = ResetHandler()
|
||||
tapSetupHandler = TapSetupHandler()
|
||||
humanPoseManager = HumanPoseManager(this)
|
||||
// AutoBone requires HumanPoseManager first
|
||||
autoBoneHandler = AutoBoneHandler(this)
|
||||
firmwareUpdateHandler = FirmwareUpdateHandler(this)
|
||||
protocolAPI = ProtocolAPI(this)
|
||||
val computedTrackers = humanPoseManager.computedTrackers
|
||||
|
||||
@@ -409,6 +421,18 @@ class VRServer @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun trackerStatusChanged(tracker: Tracker, oldStatus: TrackerStatus, newStatus: TrackerStatus) {
|
||||
trackerStatusListeners.forEach { it.onTrackerStatusChanged(tracker, oldStatus, newStatus) }
|
||||
}
|
||||
|
||||
fun addTrackerStatusListener(listener: TrackerStatusListener) {
|
||||
trackerStatusListeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeTrackerStatusListener(listener: TrackerStatusListener) {
|
||||
trackerStatusListeners.removeIf { listener == it }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val nextLocalTrackerId = AtomicInteger()
|
||||
lateinit var instance: VRServer
|
||||
|
||||
@@ -6,6 +6,7 @@ import dev.slimevr.autobone.errors.*
|
||||
import dev.slimevr.config.AutoBoneConfig
|
||||
import dev.slimevr.poseframeformat.PoseFrameIO
|
||||
import dev.slimevr.poseframeformat.PoseFrames
|
||||
import dev.slimevr.tracking.processor.BoneType
|
||||
import dev.slimevr.tracking.processor.HumanPoseManager
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigManager
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
|
||||
@@ -94,38 +95,73 @@ class AutoBone(server: VRServer) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getBoneDirection(
|
||||
/**
|
||||
* Computes the local tail position of the bone after rotation.
|
||||
*/
|
||||
fun getBoneLocalTail(
|
||||
skeleton: HumanPoseManager,
|
||||
configOffset: SkeletonConfigOffsets,
|
||||
rightSide: Boolean,
|
||||
boneType: BoneType,
|
||||
): Vector3 {
|
||||
// IMPORTANT: This assumption for acquiring BoneType only works if
|
||||
// SkeletonConfigOffsets is set up to only affect one BoneType, make sure no
|
||||
// changes to SkeletonConfigOffsets goes against this assumption, please!
|
||||
val boneType = when (configOffset) {
|
||||
SkeletonConfigOffsets.HIPS_WIDTH, SkeletonConfigOffsets.SHOULDERS_WIDTH,
|
||||
SkeletonConfigOffsets.SHOULDERS_DISTANCE, SkeletonConfigOffsets.UPPER_ARM,
|
||||
SkeletonConfigOffsets.LOWER_ARM, SkeletonConfigOffsets.UPPER_LEG,
|
||||
SkeletonConfigOffsets.LOWER_LEG, SkeletonConfigOffsets.FOOT_LENGTH,
|
||||
->
|
||||
if (rightSide) configOffset.affectedOffsets[1] else configOffset.affectedOffsets[0]
|
||||
|
||||
else -> configOffset.affectedOffsets[0]
|
||||
}
|
||||
return skeleton.getBone(boneType).getGlobalRotation().toRotationVector()
|
||||
val bone = skeleton.getBone(boneType)
|
||||
return bone.getTailPosition() - bone.getPosition()
|
||||
}
|
||||
|
||||
fun getDotProductDiff(
|
||||
/**
|
||||
* Computes the direction of the bone tail's movement between skeletons 1 and 2.
|
||||
*/
|
||||
fun getBoneLocalTailDir(
|
||||
skeleton1: HumanPoseManager,
|
||||
skeleton2: HumanPoseManager,
|
||||
configOffset: SkeletonConfigOffsets,
|
||||
rightSide: Boolean,
|
||||
offset: Vector3,
|
||||
boneType: BoneType,
|
||||
): Vector3? {
|
||||
val boneOff = getBoneLocalTail(skeleton2, boneType) - getBoneLocalTail(skeleton1, boneType)
|
||||
val boneOffLen = boneOff.len()
|
||||
return if (boneOffLen > MIN_SLIDE_DIST) boneOff / boneOffLen else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts how much the provided config should be affecting the slide offsets
|
||||
* of the left and right ankles.
|
||||
*/
|
||||
fun getSlideDot(
|
||||
skeleton1: HumanPoseManager,
|
||||
skeleton2: HumanPoseManager,
|
||||
config: SkeletonConfigOffsets,
|
||||
slideL: Vector3?,
|
||||
slideR: Vector3?,
|
||||
): Float {
|
||||
val normalizedOffset = offset.unit()
|
||||
val dot1 = normalizedOffset.dot(getBoneDirection(skeleton1, configOffset, rightSide))
|
||||
val dot2 = normalizedOffset.dot(getBoneDirection(skeleton2, configOffset, rightSide))
|
||||
return dot2 - dot1
|
||||
var slideDot = 0f
|
||||
// Used for right offset if not a symmetric bone
|
||||
var boneOffL: Vector3? = null
|
||||
|
||||
if (slideL != null) {
|
||||
boneOffL = getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[0])
|
||||
|
||||
if (boneOffL != null) {
|
||||
slideDot += slideL.dot(boneOffL)
|
||||
}
|
||||
}
|
||||
|
||||
if (slideR != null) {
|
||||
// IMPORTANT: This assumption for acquiring BoneType only works if
|
||||
// SkeletonConfigOffsets is set up to only affect one BoneType, make sure no
|
||||
// changes to SkeletonConfigOffsets goes against this assumption, please!
|
||||
val boneOffR = if (SYMM_CONFIGS.contains(config)) {
|
||||
getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[1])
|
||||
} else if (slideL != null) {
|
||||
// Use cached offset if slideL was used
|
||||
boneOffL
|
||||
} else {
|
||||
// Compute offset if missing because of slideL
|
||||
getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[0])
|
||||
}
|
||||
|
||||
if (boneOffR != null) {
|
||||
slideDot += slideR.dot(boneOffR)
|
||||
}
|
||||
}
|
||||
|
||||
return slideDot / 2f
|
||||
}
|
||||
|
||||
fun applyConfig(
|
||||
@@ -488,13 +524,15 @@ class AutoBone(server: VRServer) {
|
||||
return
|
||||
}
|
||||
|
||||
val slideLeft = skeleton2
|
||||
.getComputedTracker(TrackerRole.LEFT_FOOT).position -
|
||||
val slideL = skeleton2.getComputedTracker(TrackerRole.LEFT_FOOT).position -
|
||||
skeleton1.getComputedTracker(TrackerRole.LEFT_FOOT).position
|
||||
val slideLLen = slideL.len()
|
||||
val slideLUnit: Vector3? = if (slideLLen > MIN_SLIDE_DIST) slideL / slideLLen else null
|
||||
|
||||
val slideRight = skeleton2
|
||||
.getComputedTracker(TrackerRole.RIGHT_FOOT).position -
|
||||
val slideR = skeleton2.getComputedTracker(TrackerRole.RIGHT_FOOT).position -
|
||||
skeleton1.getComputedTracker(TrackerRole.RIGHT_FOOT).position
|
||||
val slideRLen = slideR.len()
|
||||
val slideRUnit: Vector3? = if (slideRLen > MIN_SLIDE_DIST) slideR / slideRLen else null
|
||||
|
||||
val intermediateOffsets = EnumMap(offsets)
|
||||
for (entry in intermediateOffsets.entries) {
|
||||
@@ -505,28 +543,23 @@ class AutoBone(server: VRServer) {
|
||||
}
|
||||
val originalLength = entry.value
|
||||
|
||||
val leftDotProduct = getDotProductDiff(
|
||||
skeleton1,
|
||||
skeleton2,
|
||||
entry.key,
|
||||
false,
|
||||
slideLeft,
|
||||
)
|
||||
val rightDotProduct = getDotProductDiff(
|
||||
skeleton1,
|
||||
skeleton2,
|
||||
entry.key,
|
||||
true,
|
||||
slideRight,
|
||||
)
|
||||
|
||||
// Calculate the total effect of the bone based on change in rotation
|
||||
val dotLength = originalLength * ((leftDotProduct + rightDotProduct) / 2f)
|
||||
val slideDot = getSlideDot(
|
||||
skeleton1,
|
||||
skeleton2,
|
||||
entry.key,
|
||||
slideLUnit,
|
||||
slideRUnit,
|
||||
)
|
||||
val dotLength = originalLength * slideDot
|
||||
|
||||
// Scale by the total effect of the bone
|
||||
val curAdjustVal = adjustVal * -dotLength
|
||||
val newLength = originalLength + curAdjustVal
|
||||
if (curAdjustVal == 0f) {
|
||||
continue
|
||||
}
|
||||
|
||||
val newLength = originalLength + curAdjustVal
|
||||
// No small or negative numbers!!! Bad algorithm!
|
||||
if (newLength < 0.01f) {
|
||||
continue
|
||||
@@ -754,6 +787,7 @@ class AutoBone(server: VRServer) {
|
||||
|
||||
companion object {
|
||||
const val MIN_HEIGHT = 0.4f
|
||||
const val MIN_SLIDE_DIST = 0.002f
|
||||
const val AUTOBONE_FOLDER = "AutoBone Recordings"
|
||||
const val LOADAUTOBONE_FOLDER = "Load AutoBone Recordings"
|
||||
|
||||
@@ -773,5 +807,16 @@ class AutoBone(server: VRServer) {
|
||||
private fun errorFunc(errorDeriv: Float): Float = 0.5f * (errorDeriv * errorDeriv)
|
||||
|
||||
private fun decayFunc(initialAdjustRate: Float, adjustRateDecay: Float, epoch: Int): Float = if (epoch >= 0) initialAdjustRate / (1 + (adjustRateDecay * epoch)) else 0.0f
|
||||
|
||||
private val SYMM_CONFIGS = arrayOf(
|
||||
SkeletonConfigOffsets.HIPS_WIDTH,
|
||||
SkeletonConfigOffsets.SHOULDERS_WIDTH,
|
||||
SkeletonConfigOffsets.SHOULDERS_DISTANCE,
|
||||
SkeletonConfigOffsets.UPPER_ARM,
|
||||
SkeletonConfigOffsets.LOWER_ARM,
|
||||
SkeletonConfigOffsets.UPPER_LEG,
|
||||
SkeletonConfigOffsets.LOWER_LEG,
|
||||
SkeletonConfigOffsets.FOOT_LENGTH,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,14 @@ class PositionError : IAutoBoneError {
|
||||
val position = trackerFrame.tryGetPosition() ?: continue
|
||||
val trackerRole = trackerFrame.tryGetTrackerPosition()?.trackerRole ?: continue
|
||||
|
||||
val computedTracker = skeleton.getComputedTracker(trackerRole) ?: continue
|
||||
try {
|
||||
val computedTracker = skeleton.getComputedTracker(trackerRole)
|
||||
|
||||
offset += (position - computedTracker.position).len()
|
||||
offsetCount++
|
||||
offset += (position - computedTracker.position).len()
|
||||
offsetCount++
|
||||
} catch (_: Exception) {
|
||||
// Ignore unsupported positions
|
||||
}
|
||||
}
|
||||
return if (offsetCount > 0) offset / offsetCount else 0f
|
||||
}
|
||||
|
||||
@@ -37,13 +37,17 @@ class PositionOffsetError : IAutoBoneError {
|
||||
val position2 = trackerFrame2.tryGetPosition() ?: continue
|
||||
val trackerRole2 = trackerFrame2.tryGetTrackerPosition()?.trackerRole ?: continue
|
||||
|
||||
val computedTracker1 = skeleton1.getComputedTracker(trackerRole1) ?: continue
|
||||
val computedTracker2 = skeleton2.getComputedTracker(trackerRole2) ?: continue
|
||||
try {
|
||||
val computedTracker1 = skeleton1.getComputedTracker(trackerRole1)
|
||||
val computedTracker2 = skeleton2.getComputedTracker(trackerRole2)
|
||||
|
||||
val dist1 = (position1 - computedTracker1.position).len()
|
||||
val dist2 = (position2 - computedTracker2.position).len()
|
||||
offset += abs(dist2 - dist1)
|
||||
offsetCount++
|
||||
val dist1 = (position1 - computedTracker1.position).len()
|
||||
val dist2 = (position2 - computedTracker2.position).len()
|
||||
offset += abs(dist2 - dist1)
|
||||
offsetCount++
|
||||
} catch (_: Exception) {
|
||||
// Ignore unsupported positions
|
||||
}
|
||||
}
|
||||
return if (offsetCount > 0) offset / offsetCount else 0f
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ class AutoBoneConfig {
|
||||
var cursorIncrement = 2
|
||||
var minDataDistance = 1
|
||||
var maxDataDistance = 1
|
||||
var numEpochs = 100
|
||||
var numEpochs = 50
|
||||
var printEveryNumEpochs = 25
|
||||
var initialAdjustRate = 10.0f
|
||||
var adjustRateDecay = 1.0f
|
||||
var slideErrorFactor = 0.0f
|
||||
var offsetSlideErrorFactor = 1.0f
|
||||
var slideErrorFactor = 1.0f
|
||||
var offsetSlideErrorFactor = 0.0f
|
||||
var footHeightOffsetErrorFactor = 0.0f
|
||||
var bodyProportionErrorFactor = 0.25f
|
||||
var bodyProportionErrorFactor = 0.05f
|
||||
var heightErrorFactor = 0.0f
|
||||
var positionErrorFactor = 0.0f
|
||||
var positionOffsetErrorFactor = 0.0f
|
||||
|
||||
@@ -304,6 +304,32 @@ public class CurrentVRConfigConverter implements VersionedModelConverter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 14) {
|
||||
// Update AutoBone defaults
|
||||
ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone");
|
||||
if (autoBoneNode != null) {
|
||||
JsonNode offsetSlideNode = autoBoneNode.get("offsetSlideErrorFactor");
|
||||
JsonNode slideNode = autoBoneNode.get("slideErrorFactor");
|
||||
if (
|
||||
offsetSlideNode != null
|
||||
&& slideNode != null
|
||||
&& offsetSlideNode.floatValue() == 1.0f
|
||||
&& slideNode.floatValue() == 0.0f
|
||||
) {
|
||||
autoBoneNode.set("offsetSlideErrorFactor", new FloatNode(0.0f));
|
||||
autoBoneNode.set("slideErrorFactor", new FloatNode(1.0f));
|
||||
}
|
||||
JsonNode bodyProportionsNode = autoBoneNode.get("bodyProportionErrorFactor");
|
||||
if (bodyProportionsNode != null && bodyProportionsNode.floatValue() == 0.25f) {
|
||||
autoBoneNode.set("bodyProportionErrorFactor", new FloatNode(0.05f));
|
||||
}
|
||||
JsonNode numEpochsNode = autoBoneNode.get("numEpochs");
|
||||
if (numEpochsNode != null && numEpochsNode.intValue() == 100) {
|
||||
autoBoneNode.set("numEpochs", new IntNode(50));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LogManager.severe("Error during config migration: " + e);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import dev.slimevr.tracking.trackers.Tracker
|
||||
import dev.slimevr.tracking.trackers.TrackerRole
|
||||
|
||||
@JsonVersionedModel(
|
||||
currentVersion = "13",
|
||||
defaultDeserializeToVersion = "13",
|
||||
currentVersion = "14",
|
||||
defaultDeserializeToVersion = "14",
|
||||
toCurrentConverterClass = CurrentVRConfigConverter::class,
|
||||
)
|
||||
class VRConfig {
|
||||
|
||||
@@ -23,7 +23,7 @@ class QuaternionMovingAverage(
|
||||
) {
|
||||
private var smoothFactor = 0f
|
||||
private var predictFactor = 0f
|
||||
private lateinit var rotBuffer: CircularArrayList<Quaternion>
|
||||
private var rotBuffer: CircularArrayList<Quaternion>? = null
|
||||
private var latestQuaternion = IDENTITY
|
||||
private var smoothingQuaternion = IDENTITY
|
||||
private val fpsTimer = if (VRServer.instanceInitialized) VRServer.instance.fpsTimer else NanoTimer()
|
||||
@@ -57,11 +57,11 @@ class QuaternionMovingAverage(
|
||||
@Synchronized
|
||||
fun update() {
|
||||
if (type == TrackerFilters.PREDICTION) {
|
||||
if (rotBuffer.size > 0) {
|
||||
if (rotBuffer!!.size > 0) {
|
||||
var quatBuf = latestQuaternion
|
||||
|
||||
// Applies the past rotations to the current rotation
|
||||
rotBuffer.forEach { quatBuf *= it }
|
||||
rotBuffer?.forEach { quatBuf *= it }
|
||||
|
||||
// Calculate how much to slerp
|
||||
val amt = predictFactor * fpsTimer.timePerFrame
|
||||
@@ -98,12 +98,12 @@ class QuaternionMovingAverage(
|
||||
@Synchronized
|
||||
fun addQuaternion(q: Quaternion) {
|
||||
if (type == TrackerFilters.PREDICTION) {
|
||||
if (rotBuffer.size == rotBuffer.capacity()) {
|
||||
rotBuffer.removeLast()
|
||||
if (rotBuffer!!.size == rotBuffer!!.capacity()) {
|
||||
rotBuffer?.removeLast()
|
||||
}
|
||||
|
||||
// Gets and stores the rotation between the last 2 quaternions
|
||||
rotBuffer.add(latestQuaternion.inv().times(q))
|
||||
rotBuffer?.add(latestQuaternion.inv().times(q))
|
||||
} else if (type == TrackerFilters.SMOOTHING) {
|
||||
frameCounter = 0
|
||||
lastAmt = 0f
|
||||
@@ -116,8 +116,11 @@ class QuaternionMovingAverage(
|
||||
}
|
||||
|
||||
fun resetQuats(q: Quaternion) {
|
||||
if (type == TrackerFilters.PREDICTION) {
|
||||
rotBuffer?.clear()
|
||||
latestQuaternion = q
|
||||
}
|
||||
filteredQuaternion = q
|
||||
latestQuaternion = q
|
||||
smoothingQuaternion = q
|
||||
addQuaternion(q)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import com.mayakapps.kache.KacheStrategy
|
||||
import dev.llelievr.espflashkotlin.Flasher
|
||||
import dev.llelievr.espflashkotlin.FlashingProgressListener
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.serial.ProvisioningListener
|
||||
import dev.slimevr.serial.ProvisioningStatus
|
||||
import dev.slimevr.serial.SerialPort
|
||||
import dev.slimevr.tracking.trackers.Tracker
|
||||
import dev.slimevr.tracking.trackers.TrackerStatus
|
||||
import dev.slimevr.tracking.trackers.TrackerStatusListener
|
||||
import dev.slimevr.tracking.trackers.udp.UDPDevice
|
||||
import io.eiren.util.logging.LogManager
|
||||
import kotlinx.coroutines.*
|
||||
import solarxr_protocol.rpc.FirmwarePartT
|
||||
import solarxr_protocol.rpc.FirmwareUpdateRequestT
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.concurrent.scheduleAtFixedRate
|
||||
|
||||
data class DownloadedFirmwarePart(
|
||||
val firmware: ByteArray,
|
||||
val offset: Long?,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as DownloadedFirmwarePart
|
||||
|
||||
if (!firmware.contentEquals(other.firmware)) return false
|
||||
if (offset != other.offset) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = firmware.contentHashCode()
|
||||
result = 31 * result + (offset?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class FirmwareUpdateHandler(private val server: VRServer) :
|
||||
TrackerStatusListener,
|
||||
ProvisioningListener,
|
||||
SerialRebootListener {
|
||||
|
||||
private val updateTickTimer = Timer("StatusUpdateTimer")
|
||||
private val runningJobs: MutableList<Job> = CopyOnWriteArrayList()
|
||||
private val watchRestartQueue: MutableList<Pair<UpdateDeviceId<*>, () -> Unit>> =
|
||||
CopyOnWriteArrayList()
|
||||
private val updatingDevicesStatus: MutableMap<UpdateDeviceId<*>, UpdateStatusEvent<*>> =
|
||||
ConcurrentHashMap()
|
||||
private val listeners: MutableList<FirmwareUpdateListener> = CopyOnWriteArrayList()
|
||||
private val firmwareCache =
|
||||
InMemoryKache<String, Array<DownloadedFirmwarePart>>(maxSize = 5 * 1024 * 1024) {
|
||||
strategy = KacheStrategy.LRU
|
||||
sizeCalculator = { _, parts -> parts.sumOf { it.firmware.size }.toLong() }
|
||||
}
|
||||
private val mainScope: CoroutineScope = CoroutineScope(SupervisorJob())
|
||||
private var clearJob: Deferred<Unit>? = null
|
||||
|
||||
private var serialRebootHandler: SerialRebootHandler = SerialRebootHandler(watchRestartQueue, server, this)
|
||||
|
||||
fun addListener(channel: FirmwareUpdateListener) {
|
||||
listeners.add(channel)
|
||||
}
|
||||
|
||||
fun removeListener(channel: FirmwareUpdateListener) {
|
||||
listeners.removeIf { channel == it }
|
||||
}
|
||||
|
||||
init {
|
||||
server.addTrackerStatusListener(this)
|
||||
server.provisioningHandler.addListener(this)
|
||||
server.serialHandler.addListener(serialRebootHandler)
|
||||
|
||||
this.updateTickTimer.scheduleAtFixedRate(0, 1000) {
|
||||
checkUpdateTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startOtaUpdate(
|
||||
part: DownloadedFirmwarePart,
|
||||
deviceId: UpdateDeviceId<Int>,
|
||||
) {
|
||||
val udpDevice: UDPDevice? =
|
||||
(this.server.deviceManager.devices.find { device -> device is UDPDevice && device.id == deviceId.id }) as UDPDevice?
|
||||
|
||||
if (udpDevice == null) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
OTAUpdateTask(
|
||||
part.firmware,
|
||||
deviceId,
|
||||
udpDevice.ipAddress,
|
||||
this::onStatusChange,
|
||||
).run()
|
||||
}
|
||||
|
||||
private fun startSerialUpdate(
|
||||
firmwares: Array<DownloadedFirmwarePart>,
|
||||
deviceId: UpdateDeviceId<String>,
|
||||
needManualReboot: Boolean,
|
||||
ssid: String,
|
||||
password: String,
|
||||
) {
|
||||
val serialPort = this.server.serialHandler.knownPorts.toList()
|
||||
.find { port -> deviceId.id == port.portLocation }
|
||||
|
||||
if (serialPort == null) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val flashingHandler = this.server.serialFlashingHandler
|
||||
|
||||
if (flashingHandler == null) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_UNSUPPORTED_METHOD,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val flasher = Flasher(flashingHandler)
|
||||
|
||||
for (part in firmwares) {
|
||||
if (part.offset == null) {
|
||||
error("Offset is empty")
|
||||
}
|
||||
flasher.addBin(part.firmware, part.offset.toInt())
|
||||
}
|
||||
|
||||
flasher.addProgressListener(object : FlashingProgressListener {
|
||||
override fun progress(progress: Float) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.UPLOADING,
|
||||
(progress * 100).toInt(),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.SYNCING_WITH_MCU,
|
||||
),
|
||||
)
|
||||
flasher.flash(serialPort)
|
||||
if (needManualReboot) {
|
||||
if (watchRestartQueue.find { it.first == deviceId } != null) {
|
||||
LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping")
|
||||
}
|
||||
|
||||
onStatusChange(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.NEED_MANUAL_REBOOT))
|
||||
server.serialHandler.openSerial(deviceId.id, false)
|
||||
watchRestartQueue.add(
|
||||
Pair(deviceId) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.REBOOTING,
|
||||
),
|
||||
)
|
||||
server.provisioningHandler.start(
|
||||
ssid,
|
||||
password,
|
||||
serialPort.portLocation,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
onStatusChange(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.REBOOTING))
|
||||
server.provisioningHandler.start(ssid, password, serialPort.portLocation)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.severe("[FirmwareUpdateHandler] Upload failed", e)
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_UPLOAD_FAILED,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun queueFirmwareUpdate(
|
||||
request: FirmwareUpdateRequestT,
|
||||
deviceId: UpdateDeviceId<*>,
|
||||
) = mainScope.launch {
|
||||
val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method")
|
||||
|
||||
clearJob?.await()
|
||||
if (method == FirmwareUpdateMethod.OTA) {
|
||||
if (watchRestartQueue.find { it.first == deviceId } != null) {
|
||||
LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping")
|
||||
}
|
||||
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.NEED_MANUAL_REBOOT,
|
||||
),
|
||||
)
|
||||
watchRestartQueue.add(
|
||||
Pair(deviceId) {
|
||||
mainScope.launch {
|
||||
startFirmwareUpdateJob(
|
||||
request,
|
||||
deviceId,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
if (updatingDevicesStatus[deviceId] != null) {
|
||||
LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping")
|
||||
return@launch
|
||||
}
|
||||
|
||||
startFirmwareUpdateJob(
|
||||
request,
|
||||
deviceId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelUpdates() {
|
||||
val oldClearJob = clearJob
|
||||
clearJob = mainScope.async {
|
||||
oldClearJob?.await()
|
||||
watchRestartQueue.clear()
|
||||
runningJobs.forEach { it.cancelAndJoin() }
|
||||
runningJobs.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFirmwareParts(request: FirmwareUpdateRequestT): ArrayList<FirmwarePartT> {
|
||||
val parts = ArrayList<FirmwarePartT>()
|
||||
val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method")
|
||||
when (method) {
|
||||
FirmwareUpdateMethod.OTA -> {
|
||||
val updateReq = request.method.asOTAFirmwareUpdate()
|
||||
parts.add(updateReq.firmwarePart)
|
||||
}
|
||||
|
||||
FirmwareUpdateMethod.SERIAL -> {
|
||||
val updateReq = request.method.asSerialFirmwareUpdate()
|
||||
parts.addAll(updateReq.firmwarePart)
|
||||
}
|
||||
|
||||
FirmwareUpdateMethod.NONE -> error("Method should not be NONE")
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
private suspend fun startFirmwareUpdateJob(
|
||||
request: FirmwareUpdateRequestT,
|
||||
deviceId: UpdateDeviceId<*>,
|
||||
) = coroutineScope {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.DOWNLOADING,
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
// We add the firmware to an LRU cache
|
||||
val toDownloadParts = getFirmwareParts(request)
|
||||
val firmwareParts =
|
||||
firmwareCache.getOrPut(toDownloadParts.joinToString("|") { "${it.url}#${it.offset}" }) {
|
||||
withTimeoutOrNull(30_000) {
|
||||
toDownloadParts.map {
|
||||
val firmware = downloadFirmware(it.url)
|
||||
?: error("unable to download firmware part")
|
||||
DownloadedFirmwarePart(
|
||||
firmware,
|
||||
it.offset,
|
||||
)
|
||||
}.toTypedArray()
|
||||
}
|
||||
}
|
||||
|
||||
val job = launch {
|
||||
withTimeout(2 * 60 * 1000) {
|
||||
if (firmwareParts.isNullOrEmpty()) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED,
|
||||
),
|
||||
)
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method")
|
||||
when (method) {
|
||||
FirmwareUpdateMethod.NONE -> error("unsupported method")
|
||||
|
||||
FirmwareUpdateMethod.OTA -> {
|
||||
if (deviceId.id !is Int) {
|
||||
error("invalid state, the device id is not an int")
|
||||
}
|
||||
if (firmwareParts.size > 1) {
|
||||
error("invalid state, ota only use one firmware file")
|
||||
}
|
||||
startOtaUpdate(
|
||||
firmwareParts.first(),
|
||||
UpdateDeviceId(
|
||||
FirmwareUpdateMethod.OTA,
|
||||
deviceId.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
FirmwareUpdateMethod.SERIAL -> {
|
||||
val req = request.method.asSerialFirmwareUpdate()
|
||||
if (deviceId.id !is String) {
|
||||
error("invalid state, the device id is not a string")
|
||||
}
|
||||
startSerialUpdate(
|
||||
firmwareParts,
|
||||
UpdateDeviceId(
|
||||
FirmwareUpdateMethod.SERIAL,
|
||||
deviceId.id,
|
||||
),
|
||||
req.needManualReboot,
|
||||
req.ssid,
|
||||
req.password,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
runningJobs.add(job)
|
||||
} catch (e: Exception) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
if (e is TimeoutCancellationException) FirmwareUpdateStatus.ERROR_TIMEOUT else FirmwareUpdateStatus.ERROR_UNKNOWN,
|
||||
),
|
||||
)
|
||||
if (e !is TimeoutCancellationException) {
|
||||
LogManager.severe("[FirmwareUpdateHandler] Update process timed out", e)
|
||||
e.printStackTrace()
|
||||
}
|
||||
return@coroutineScope
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> onStatusChange(event: UpdateStatusEvent<T>) {
|
||||
this.updatingDevicesStatus[event.deviceId] = event
|
||||
|
||||
if (event.status == FirmwareUpdateStatus.DONE || event.status.isError()) {
|
||||
this.updatingDevicesStatus.remove(event.deviceId)
|
||||
|
||||
// we remove the device from the restart queue
|
||||
val queuedDevice = watchRestartQueue.find { it.first.id == event.deviceId }
|
||||
if (queuedDevice != null) {
|
||||
watchRestartQueue.remove(queuedDevice)
|
||||
if (event.deviceId.type == FirmwareUpdateMethod.SERIAL && server.serialHandler.isConnected) {
|
||||
server.serialHandler.closeSerial()
|
||||
}
|
||||
}
|
||||
|
||||
// We make sure to stop the provisioning routine if the tracker is done
|
||||
// flashing
|
||||
if (event.deviceId.type == FirmwareUpdateMethod.SERIAL) {
|
||||
this.server.provisioningHandler.stop()
|
||||
}
|
||||
}
|
||||
listeners.forEach { l -> l.onUpdateStatusChange(event) }
|
||||
}
|
||||
|
||||
private fun checkUpdateTimeout() {
|
||||
updatingDevicesStatus.forEach { (id, device) ->
|
||||
// if more than 30s between two events, consider the update as stuck
|
||||
// We do not timeout on the Downloading step as it has it own timeout
|
||||
// We do not timeout on the Done step as it is the end of the update process
|
||||
if (!device.status.isError() &&
|
||||
!intArrayOf(FirmwareUpdateStatus.DONE.id, FirmwareUpdateStatus.DOWNLOADING.id).contains(device.status.id) &&
|
||||
System.currentTimeMillis() - device.time > 30 * 1000
|
||||
) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
id,
|
||||
FirmwareUpdateStatus.ERROR_TIMEOUT,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this only works for OTA trackers as the device id
|
||||
// only exists when the usb connection is created
|
||||
override fun onTrackerStatusChanged(
|
||||
tracker: Tracker,
|
||||
oldStatus: TrackerStatus,
|
||||
newStatus: TrackerStatus,
|
||||
) {
|
||||
val device = tracker.device
|
||||
if (device !is UDPDevice) return
|
||||
|
||||
if (oldStatus == TrackerStatus.DISCONNECTED && newStatus == TrackerStatus.OK) {
|
||||
val queuedDevice = watchRestartQueue.find { it.first.id == device.id }
|
||||
|
||||
if (queuedDevice != null) {
|
||||
queuedDevice.second() // we start the queued update task
|
||||
watchRestartQueue.remove(queuedDevice) // then we remove it from the queue
|
||||
return
|
||||
}
|
||||
|
||||
// We can only filter OTA method here as the device id is only provided when using Wi-Fi
|
||||
val deviceStatusKey =
|
||||
updatingDevicesStatus.keys.find { it.type == FirmwareUpdateMethod.OTA && it.id == device.id }
|
||||
?: return
|
||||
val updateStatus = updatingDevicesStatus[deviceStatusKey] ?: return
|
||||
// We check for the reconnection of the tracker, once the tracker reconnected we notify the user that the update is completed
|
||||
if (updateStatus.status == FirmwareUpdateStatus.REBOOTING) {
|
||||
onStatusChange(
|
||||
UpdateStatusEvent(
|
||||
updateStatus.deviceId,
|
||||
FirmwareUpdateStatus.DONE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProvisioningStatusChange(
|
||||
status: ProvisioningStatus,
|
||||
port: SerialPort?,
|
||||
) {
|
||||
fun update(s: FirmwareUpdateStatus) {
|
||||
val deviceStatusKey =
|
||||
updatingDevicesStatus.keys.find { it.type == FirmwareUpdateMethod.SERIAL && it.id == port?.portLocation }
|
||||
?: return
|
||||
val updateStatus = updatingDevicesStatus[deviceStatusKey] ?: return
|
||||
onStatusChange(UpdateStatusEvent(updateStatus.deviceId, s))
|
||||
}
|
||||
|
||||
when (status) {
|
||||
ProvisioningStatus.PROVISIONING -> update(FirmwareUpdateStatus.PROVISIONING)
|
||||
ProvisioningStatus.DONE -> update(FirmwareUpdateStatus.DONE)
|
||||
ProvisioningStatus.CONNECTION_ERROR, ProvisioningStatus.COULD_NOT_FIND_SERVER -> update(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSerialDeviceReconnect(deviceHandle: Pair<UpdateDeviceId<*>, () -> Unit>) {
|
||||
deviceHandle.second()
|
||||
watchRestartQueue.remove(deviceHandle)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadFirmware(url: String): ByteArray? {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
try {
|
||||
val chunk = ByteArray(4096)
|
||||
var bytesRead: Int
|
||||
val stream: InputStream = URL(url).openStream()
|
||||
while (stream.read(chunk).also { bytesRead = it } > 0) {
|
||||
outputStream.write(chunk, 0, bytesRead)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
error("Cant download firmware $url")
|
||||
}
|
||||
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
interface FirmwareUpdateListener {
|
||||
fun onUpdateStatusChange(event: UpdateStatusEvent<*>)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
enum class FirmwareUpdateMethod(val id: Byte) {
|
||||
NONE(solarxr_protocol.rpc.FirmwareUpdateMethod.NONE),
|
||||
OTA(solarxr_protocol.rpc.FirmwareUpdateMethod.OTAFirmwareUpdate),
|
||||
SERIAL(solarxr_protocol.rpc.FirmwareUpdateMethod.SerialFirmwareUpdate),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun getById(id: Byte): FirmwareUpdateMethod? = byId[id]
|
||||
}
|
||||
}
|
||||
|
||||
private val byId = FirmwareUpdateMethod.entries.associateBy { it.id }
|
||||
@@ -0,0 +1,29 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
enum class FirmwareUpdateStatus(val id: Int) {
|
||||
DOWNLOADING(solarxr_protocol.rpc.FirmwareUpdateStatus.DOWNLOADING),
|
||||
AUTHENTICATING(solarxr_protocol.rpc.FirmwareUpdateStatus.AUTHENTICATING),
|
||||
UPLOADING(solarxr_protocol.rpc.FirmwareUpdateStatus.UPLOADING),
|
||||
SYNCING_WITH_MCU(solarxr_protocol.rpc.FirmwareUpdateStatus.SYNCING_WITH_MCU),
|
||||
REBOOTING(solarxr_protocol.rpc.FirmwareUpdateStatus.REBOOTING),
|
||||
NEED_MANUAL_REBOOT(solarxr_protocol.rpc.FirmwareUpdateStatus.NEED_MANUAL_REBOOT),
|
||||
PROVISIONING(solarxr_protocol.rpc.FirmwareUpdateStatus.PROVISIONING),
|
||||
DONE(solarxr_protocol.rpc.FirmwareUpdateStatus.DONE),
|
||||
ERROR_DEVICE_NOT_FOUND(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND),
|
||||
ERROR_TIMEOUT(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_TIMEOUT),
|
||||
ERROR_DOWNLOAD_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED),
|
||||
ERROR_AUTHENTICATION_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED),
|
||||
ERROR_UPLOAD_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UPLOAD_FAILED),
|
||||
ERROR_PROVISIONING_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED),
|
||||
ERROR_UNSUPPORTED_METHOD(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UNSUPPORTED_METHOD),
|
||||
ERROR_UNKNOWN(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UNKNOWN),
|
||||
;
|
||||
|
||||
fun isError(): Boolean = id in ERROR_DEVICE_NOT_FOUND.id..ERROR_UNKNOWN.id
|
||||
|
||||
companion object {
|
||||
fun getById(id: Int): FirmwareUpdateStatus? = byId[id]
|
||||
}
|
||||
}
|
||||
|
||||
private val byId = FirmwareUpdateStatus.entries.associateBy { it.id }
|
||||
181
server/core/src/main/java/dev/slimevr/firmware/OTAUpdateTask.kt
Normal file
181
server/core/src/main/java/dev/slimevr/firmware/OTAUpdateTask.kt
Normal file
@@ -0,0 +1,181 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import io.eiren.util.logging.LogManager
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.*
|
||||
import java.util.function.Consumer
|
||||
import kotlin.math.min
|
||||
|
||||
class OTAUpdateTask(
|
||||
private val firmware: ByteArray,
|
||||
private val deviceId: UpdateDeviceId<Int>,
|
||||
private val deviceIp: InetAddress,
|
||||
private val statusCallback: Consumer<UpdateStatusEvent<Int>>,
|
||||
) {
|
||||
private val receiveBuffer: ByteArray = ByteArray(38)
|
||||
|
||||
@Throws(NoSuchAlgorithmException::class)
|
||||
private fun bytesToMd5(bytes: ByteArray): String {
|
||||
val md5 = MessageDigest.getInstance("MD5")
|
||||
md5.update(bytes)
|
||||
val digest = md5.digest()
|
||||
val md5str = StringBuilder()
|
||||
for (b in digest) {
|
||||
md5str.append(String.format("%02x", b))
|
||||
}
|
||||
return md5str.toString()
|
||||
}
|
||||
|
||||
private fun authenticate(localPort: Int): Boolean {
|
||||
try {
|
||||
DatagramSocket().use { socket ->
|
||||
statusCallback.accept(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.AUTHENTICATING))
|
||||
LogManager.info("[OTAUpdate] Sending OTA invitation to: $deviceIp")
|
||||
|
||||
val fileMd5 = bytesToMd5(firmware)
|
||||
val message = "$FLASH $localPort ${firmware.size} $fileMd5\n"
|
||||
|
||||
socket.send(DatagramPacket(message.toByteArray(), message.length, deviceIp, PORT))
|
||||
socket.soTimeout = 10000
|
||||
|
||||
val authPacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
|
||||
socket.receive(authPacket)
|
||||
|
||||
val data = String(authPacket.data, 0, authPacket.length)
|
||||
|
||||
// if we received OK directly from the MCU, we do not need to authenticate
|
||||
if (data == "OK") return true
|
||||
|
||||
val args = data.split(" ")
|
||||
|
||||
// The expected auth payload should look like "AUTH AUTH_TOKEN"
|
||||
// if we have less than those two args it means that we are in an invalid state
|
||||
if (args.size != 2 || args[0] != "AUTH") return false
|
||||
|
||||
LogManager.info("[OTAUpdate] Authenticating...")
|
||||
|
||||
val authToken = args[1]
|
||||
val signature = bytesToMd5(UUID.randomUUID().toString().toByteArray())
|
||||
val hashedPassword = bytesToMd5(PASSWORD.toByteArray())
|
||||
val resultText = "$hashedPassword:$authToken:$signature"
|
||||
val payload = bytesToMd5(resultText.toByteArray())
|
||||
|
||||
val authMessage = "$AUTH $signature $payload\n"
|
||||
|
||||
socket.soTimeout = 10000
|
||||
socket.send(
|
||||
DatagramPacket(
|
||||
authMessage.toByteArray(),
|
||||
authMessage.length,
|
||||
deviceIp,
|
||||
PORT,
|
||||
),
|
||||
)
|
||||
|
||||
val authResponsePacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
|
||||
socket.receive(authResponsePacket)
|
||||
|
||||
val authResponse = String(authResponsePacket.data, 0, authResponsePacket.length)
|
||||
|
||||
return authResponse == "OK"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.severe("OTA Authentication exception", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun upload(serverSocket: ServerSocket): Boolean {
|
||||
try {
|
||||
LogManager.info("[OTAUpdate] Starting on: ${serverSocket.localPort}")
|
||||
LogManager.info("[OTAUpdate] Waiting for device...")
|
||||
|
||||
val connection = serverSocket.accept()
|
||||
connection.setSoTimeout(1000)
|
||||
|
||||
val dos = DataOutputStream(connection.getOutputStream())
|
||||
val dis = DataInputStream(connection.getInputStream())
|
||||
|
||||
LogManager.info("[OTAUpdate] Upload size: ${firmware.size} bytes")
|
||||
var offset = 0
|
||||
val chunkSize = 2048
|
||||
while (offset != firmware.size) {
|
||||
statusCallback.accept(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.UPLOADING,
|
||||
((offset.toDouble() / firmware.size) * 100).toInt(),
|
||||
),
|
||||
)
|
||||
|
||||
val chunkLen = min(chunkSize, (firmware.size - offset))
|
||||
dos.write(firmware, offset, chunkLen)
|
||||
dos.flush()
|
||||
offset += chunkLen
|
||||
|
||||
// Those skipped bytes are the size written to the MCU. We do not really need that information,
|
||||
// so we simply skip it.
|
||||
// The reason those bytes are skipped here is to not have to skip all of them when checking
|
||||
// for the OK response. Saving time
|
||||
dis.skipNBytes(4)
|
||||
}
|
||||
|
||||
LogManager.info("[OTAUpdate] Waiting for result...")
|
||||
// We set the timeout of the connection bigger as it can take some time for the MCU
|
||||
// to confirm that everything is ok
|
||||
connection.setSoTimeout(10000)
|
||||
val responseBytes = dis.readAllBytes()
|
||||
val response = String(responseBytes)
|
||||
|
||||
return response.contains("OK")
|
||||
} catch (e: Exception) {
|
||||
LogManager.severe("Unable to upload the firmware using ota", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun run() {
|
||||
ServerSocket(0).use { serverSocket ->
|
||||
if (!authenticate(serverSocket.localPort)) {
|
||||
statusCallback.accept(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!upload(serverSocket)) {
|
||||
statusCallback.accept(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.ERROR_UPLOAD_FAILED,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
statusCallback.accept(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
FirmwareUpdateStatus.REBOOTING,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FLASH = 0
|
||||
private const val PORT = 8266
|
||||
private const val PASSWORD = "SlimeVR-OTA"
|
||||
private const val AUTH = 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import dev.llelievr.espflashkotlin.FlasherSerialInterface
|
||||
|
||||
interface SerialFlashingHandler : FlasherSerialInterface
|
||||
@@ -0,0 +1,66 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.serial.SerialListener
|
||||
import dev.slimevr.serial.SerialPort
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
interface SerialRebootListener {
|
||||
fun onSerialDeviceReconnect(deviceHandle: Pair<UpdateDeviceId<*>, () -> Unit>)
|
||||
}
|
||||
|
||||
/**
|
||||
* This class watch for a serial device to disconnect then reconnect.
|
||||
* This is used to watch the user progress through the firmware update process
|
||||
*/
|
||||
class SerialRebootHandler(
|
||||
private val watchRestartQueue: MutableList<Pair<UpdateDeviceId<*>, () -> Unit>>,
|
||||
private val server: VRServer,
|
||||
// Could be moved to a list of listeners later
|
||||
private val serialRebootListener: SerialRebootListener,
|
||||
) : SerialListener {
|
||||
|
||||
private var currentPort: SerialPort? = null
|
||||
private val disconnectedDevices: MutableList<SerialPort> = CopyOnWriteArrayList()
|
||||
|
||||
override fun onSerialConnected(port: SerialPort) {
|
||||
currentPort = port
|
||||
}
|
||||
|
||||
override fun onSerialDisconnected() {
|
||||
currentPort = null
|
||||
}
|
||||
|
||||
override fun onSerialLog(str: String) {
|
||||
if (str.contains("starting up...")) {
|
||||
val foundPort = watchRestartQueue.find { it.first.id == currentPort?.portLocation }
|
||||
if (foundPort != null) {
|
||||
disconnectedDevices.remove(currentPort)
|
||||
serialRebootListener.onSerialDeviceReconnect(foundPort)
|
||||
// once the restart detected we close the connection
|
||||
if (server.serialHandler.isConnected) {
|
||||
server.serialHandler.closeSerial()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewSerialDevice(port: SerialPort) {
|
||||
val foundPort = watchRestartQueue.find { it.first.id == port.portLocation }
|
||||
if (foundPort != null && disconnectedDevices.contains(port)) {
|
||||
disconnectedDevices.remove(port)
|
||||
serialRebootListener.onSerialDeviceReconnect(foundPort)
|
||||
// once the restart detected we close the connection
|
||||
if (server.serialHandler.isConnected) {
|
||||
server.serialHandler.closeSerial()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSerialDeviceDeleted(port: SerialPort) {
|
||||
val foundPort = watchRestartQueue.find { it.first.id == port.portLocation }
|
||||
if (foundPort != null) {
|
||||
disconnectedDevices.add(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
data class UpdateDeviceId<T>(
|
||||
val type: FirmwareUpdateMethod,
|
||||
val id: T,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as UpdateDeviceId<*>
|
||||
|
||||
if (type != other.type) return false
|
||||
if (id != other.id) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = type.hashCode()
|
||||
result = 31 * result + (id?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.slimevr.firmware
|
||||
|
||||
data class UpdateStatusEvent<T>(
|
||||
val deviceId: UpdateDeviceId<T>,
|
||||
val status: FirmwareUpdateStatus,
|
||||
val progress: Int = 0,
|
||||
val time: Long = System.currentTimeMillis(),
|
||||
)
|
||||
@@ -41,6 +41,7 @@ data class TrackerFrames(val name: String = "", val frames: FastList<TrackerFram
|
||||
// Make sure this is false!! Otherwise HumanSkeleton ignores it
|
||||
isInternal = false,
|
||||
isComputed = true,
|
||||
trackRotDirection = false,
|
||||
)
|
||||
|
||||
tracker.status = TrackerStatus.OK
|
||||
|
||||
@@ -65,4 +65,5 @@ public class ProtocolAPI {
|
||||
public void removeAPIServer(ProtocolAPIServer server) {
|
||||
this.servers.remove(server);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ public class DataFeedBuilder {
|
||||
? fbb.createString(device.getManufacturer())
|
||||
: 0;
|
||||
|
||||
int boardTypeOffset = fbb.createString(device.getBoardType().toString());
|
||||
|
||||
int hardwareIdentifierOffset = fbb.createString(device.getHardwareIdentifier());
|
||||
|
||||
HardwareInfo.startHardwareInfo(fbb);
|
||||
@@ -68,7 +66,7 @@ public class DataFeedBuilder {
|
||||
// TODO need support: HardwareInfo.addDisplayName(fbb, de);
|
||||
|
||||
HardwareInfo.addMcuId(fbb, device.getMcuType().getSolarType());
|
||||
HardwareInfo.addBoardType(fbb, boardTypeOffset);
|
||||
HardwareInfo.addOfficialBoardType(fbb, device.getBoardType().getSolarType());
|
||||
return HardwareInfo.endHardwareInfo(fbb);
|
||||
}
|
||||
|
||||
@@ -351,7 +349,7 @@ public class DataFeedBuilder {
|
||||
for (int i = 0; i < devices.size(); i++) {
|
||||
Device device = devices.get(i);
|
||||
devicesDataOffsets[i] = DataFeedBuilder
|
||||
.createDeviceData(fbb, i, deviceDataMaskT, device);
|
||||
.createDeviceData(fbb, device.getId(), deviceDataMaskT, device);
|
||||
}
|
||||
|
||||
return DataFeedUpdate.createDevicesVector(fbb, devicesDataOffsets);
|
||||
|
||||
@@ -8,6 +8,7 @@ import dev.slimevr.protocol.ProtocolAPI
|
||||
import dev.slimevr.protocol.ProtocolHandler
|
||||
import dev.slimevr.protocol.datafeed.DataFeedBuilder
|
||||
import dev.slimevr.protocol.rpc.autobone.RPCAutoBoneHandler
|
||||
import dev.slimevr.protocol.rpc.firmware.RPCFirmwareUpdateHandler
|
||||
import dev.slimevr.protocol.rpc.reset.RPCResetHandler
|
||||
import dev.slimevr.protocol.rpc.serial.RPCProvisioningHandler
|
||||
import dev.slimevr.protocol.rpc.serial.RPCSerialHandler
|
||||
@@ -41,6 +42,7 @@ class RPCHandler(private val api: ProtocolAPI) : ProtocolHandler<RpcMessageHeade
|
||||
RPCAutoBoneHandler(this, api)
|
||||
RPCHandshakeHandler(this, api)
|
||||
RPCTrackingPause(this, api)
|
||||
RPCFirmwareUpdateHandler(this, api)
|
||||
|
||||
registerPacketListener(
|
||||
RpcMessage.ResetRequest,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package dev.slimevr.protocol.rpc.firmware
|
||||
|
||||
import com.google.flatbuffers.FlatBufferBuilder
|
||||
import dev.slimevr.firmware.FirmwareUpdateListener
|
||||
import dev.slimevr.firmware.FirmwareUpdateMethod
|
||||
import dev.slimevr.firmware.UpdateDeviceId
|
||||
import dev.slimevr.firmware.UpdateStatusEvent
|
||||
import dev.slimevr.protocol.GenericConnection
|
||||
import dev.slimevr.protocol.ProtocolAPI
|
||||
import dev.slimevr.protocol.rpc.RPCHandler
|
||||
import solarxr_protocol.datatypes.DeviceIdT
|
||||
import solarxr_protocol.datatypes.DeviceIdTableT
|
||||
import solarxr_protocol.rpc.FirmwareUpdateDeviceId
|
||||
import solarxr_protocol.rpc.FirmwareUpdateDeviceIdUnion
|
||||
import solarxr_protocol.rpc.FirmwareUpdateRequest
|
||||
import solarxr_protocol.rpc.FirmwareUpdateRequestT
|
||||
import solarxr_protocol.rpc.FirmwareUpdateStatusResponse
|
||||
import solarxr_protocol.rpc.RpcMessage
|
||||
import solarxr_protocol.rpc.RpcMessageHeader
|
||||
import solarxr_protocol.rpc.SerialDevicePortT
|
||||
|
||||
class RPCFirmwareUpdateHandler(
|
||||
private val rpcHandler: RPCHandler,
|
||||
var api: ProtocolAPI,
|
||||
) : FirmwareUpdateListener {
|
||||
|
||||
init {
|
||||
api.server.firmwareUpdateHandler.addListener(this)
|
||||
rpcHandler.registerPacketListener(
|
||||
RpcMessage.FirmwareUpdateRequest,
|
||||
this::onFirmwareUpdateRequest,
|
||||
)
|
||||
rpcHandler.registerPacketListener(
|
||||
RpcMessage.FirmwareUpdateStopQueuesRequest,
|
||||
this::onFirmwareUpdateStopQueuesRequest,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onFirmwareUpdateStopQueuesRequest(
|
||||
conn: GenericConnection,
|
||||
messageHeader: RpcMessageHeader,
|
||||
) {
|
||||
api.server.firmwareUpdateHandler.cancelUpdates()
|
||||
}
|
||||
|
||||
private fun onFirmwareUpdateRequest(
|
||||
conn: GenericConnection,
|
||||
messageHeader: RpcMessageHeader,
|
||||
) {
|
||||
val req =
|
||||
(messageHeader.message(FirmwareUpdateRequest()) as FirmwareUpdateRequest).unpack()
|
||||
val updateDeviceId = buildUpdateDeviceId(req) ?: return
|
||||
|
||||
api.server.firmwareUpdateHandler.queueFirmwareUpdate(
|
||||
req,
|
||||
updateDeviceId,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUpdateStatusChange(event: UpdateStatusEvent<*>) {
|
||||
val fbb = FlatBufferBuilder(32)
|
||||
|
||||
val dataUnion = FirmwareUpdateDeviceIdUnion()
|
||||
dataUnion.type = event.deviceId.type.id
|
||||
dataUnion.value = createUpdateDeviceId(event.deviceId)
|
||||
|
||||
val deviceIdOffset = FirmwareUpdateDeviceIdUnion.pack(fbb, dataUnion)
|
||||
|
||||
FirmwareUpdateStatusResponse.startFirmwareUpdateStatusResponse(fbb)
|
||||
FirmwareUpdateStatusResponse.addStatus(fbb, event.status.id)
|
||||
FirmwareUpdateStatusResponse.addDeviceIdType(fbb, dataUnion.type)
|
||||
FirmwareUpdateStatusResponse.addDeviceId(fbb, deviceIdOffset)
|
||||
FirmwareUpdateStatusResponse.addProgress(fbb, event.progress.toByte())
|
||||
|
||||
val update = FirmwareUpdateStatusResponse.endFirmwareUpdateStatusResponse(fbb)
|
||||
val outbound = rpcHandler.createRPCMessage(
|
||||
fbb,
|
||||
RpcMessage.FirmwareUpdateStatusResponse,
|
||||
update,
|
||||
)
|
||||
fbb.finish(outbound)
|
||||
|
||||
api
|
||||
.apiServers.forEach { server ->
|
||||
server.apiConnections.forEach { conn ->
|
||||
conn.send(fbb.dataBuffer())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildUpdateDeviceId(req: FirmwareUpdateRequestT): UpdateDeviceId<Any>? {
|
||||
when (req.method.type) {
|
||||
FirmwareUpdateDeviceId.solarxr_protocol_datatypes_DeviceIdTable -> {
|
||||
return UpdateDeviceId(
|
||||
FirmwareUpdateMethod.OTA,
|
||||
req.method.asOTAFirmwareUpdate().deviceId.id,
|
||||
)
|
||||
}
|
||||
|
||||
FirmwareUpdateDeviceId.SerialDevicePort -> {
|
||||
return UpdateDeviceId(
|
||||
FirmwareUpdateMethod.SERIAL,
|
||||
req.method.asSerialFirmwareUpdate().deviceId.port,
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun createUpdateDeviceId(data: UpdateDeviceId<*>): Any = when (data.type) {
|
||||
FirmwareUpdateMethod.NONE -> error("Unsupported method")
|
||||
|
||||
FirmwareUpdateMethod.OTA -> {
|
||||
if (data.id !is Int) {
|
||||
error("Invalid state, the id type should be Int")
|
||||
}
|
||||
DeviceIdTableT().apply {
|
||||
id = DeviceIdT().apply {
|
||||
id = data.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FirmwareUpdateMethod.SERIAL -> {
|
||||
if (data.id !is String) {
|
||||
error("Invalid state, the id type should be String")
|
||||
}
|
||||
SerialDevicePortT().apply {
|
||||
port = data.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import dev.slimevr.protocol.ProtocolAPI;
|
||||
import dev.slimevr.protocol.rpc.RPCHandler;
|
||||
import dev.slimevr.serial.ProvisioningListener;
|
||||
import dev.slimevr.serial.ProvisioningStatus;
|
||||
import dev.slimevr.serial.SerialPort;
|
||||
import solarxr_protocol.rpc.*;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
@@ -59,7 +60,7 @@ public class RPCProvisioningHandler implements ProvisioningListener {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProvisioningStatusChange(ProvisioningStatus status) {
|
||||
public void onProvisioningStatusChange(ProvisioningStatus status, SerialPort port) {
|
||||
|
||||
FlatBufferBuilder fbb = new FlatBufferBuilder(32);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import dev.slimevr.protocol.rpc.RPCHandler;
|
||||
import dev.slimevr.serial.SerialListener;
|
||||
import dev.slimevr.serial.SerialPort;
|
||||
import io.eiren.util.logging.LogManager;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import solarxr_protocol.rpc.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -274,4 +275,7 @@ public class RPCSerialHandler implements SerialListener {
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSerialDeviceDeleted(@NotNull SerialPort port) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package dev.slimevr.serial;
|
||||
|
||||
import dev.slimevr.VRServer;
|
||||
import io.eiren.util.logging.LogManager;
|
||||
import kotlin.text.Regex;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
@@ -80,6 +81,10 @@ public class ProvisioningHandler implements SerialListener {
|
||||
|
||||
}
|
||||
|
||||
public void tryObtainMacAddress() {
|
||||
this.changeStatus(ProvisioningStatus.OBTAINING_MAC_ADDRESS);
|
||||
vrServer.serialHandler.infoRequest();
|
||||
}
|
||||
|
||||
public void tryProvisioning() {
|
||||
this.changeStatus(ProvisioningStatus.PROVISIONING);
|
||||
@@ -97,12 +102,16 @@ public class ProvisioningHandler implements SerialListener {
|
||||
|
||||
|
||||
if (System.currentTimeMillis() - this.lastStatusChange > 10000) {
|
||||
if (this.provisioningStatus == ProvisioningStatus.NONE)
|
||||
if (
|
||||
this.provisioningStatus == ProvisioningStatus.NONE
|
||||
|| this.provisioningStatus == ProvisioningStatus.SERIAL_INIT
|
||||
)
|
||||
this.initSerial(this.preferredPort);
|
||||
else if (this.provisioningStatus == ProvisioningStatus.SERIAL_INIT)
|
||||
initSerial(this.preferredPort);
|
||||
else if (this.provisioningStatus == ProvisioningStatus.PROVISIONING)
|
||||
this.tryProvisioning();
|
||||
else if (
|
||||
this.provisioningStatus == ProvisioningStatus.OBTAINING_MAC_ADDRESS
|
||||
|| this.provisioningStatus == ProvisioningStatus.PROVISIONING
|
||||
)
|
||||
this.tryObtainMacAddress();
|
||||
else if (this.provisioningStatus == ProvisioningStatus.LOOKING_FOR_SERVER)
|
||||
this.changeStatus(ProvisioningStatus.COULD_NOT_FIND_SERVER);
|
||||
}
|
||||
@@ -113,7 +122,7 @@ public class ProvisioningHandler implements SerialListener {
|
||||
public void onSerialConnected(@NotNull SerialPort port) {
|
||||
if (!isRunning)
|
||||
return;
|
||||
this.tryProvisioning();
|
||||
this.tryObtainMacAddress();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -129,6 +138,23 @@ public class ProvisioningHandler implements SerialListener {
|
||||
if (!isRunning)
|
||||
return;
|
||||
|
||||
if (
|
||||
provisioningStatus == ProvisioningStatus.OBTAINING_MAC_ADDRESS && str.contains("mac:")
|
||||
) {
|
||||
var match = new Regex("mac: (?<mac>([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})), ")
|
||||
.find(str, str.indexOf("mac:"));
|
||||
|
||||
if (match != null) {
|
||||
var b = match.getGroups().get(1);
|
||||
if (b != null) {
|
||||
vrServer.configManager.getVrConfig().addKnownDevice(b.getValue());
|
||||
vrServer.configManager.saveConfig();
|
||||
this.tryProvisioning();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (
|
||||
provisioningStatus == ProvisioningStatus.PROVISIONING
|
||||
&& str.contains("New wifi credentials set")
|
||||
@@ -166,7 +192,11 @@ public class ProvisioningHandler implements SerialListener {
|
||||
public void changeStatus(ProvisioningStatus status) {
|
||||
this.lastStatusChange = System.currentTimeMillis();
|
||||
if (this.provisioningStatus != status) {
|
||||
this.listeners.forEach((l) -> l.onProvisioningStatusChange(status));
|
||||
this.listeners
|
||||
.forEach(
|
||||
(l) -> l
|
||||
.onProvisioningStatusChange(status, vrServer.serialHandler.getCurrentPort())
|
||||
);
|
||||
this.provisioningStatus = status;
|
||||
}
|
||||
}
|
||||
@@ -186,4 +216,7 @@ public class ProvisioningHandler implements SerialListener {
|
||||
listeners.removeIf(listener -> l == listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSerialDeviceDeleted(@NotNull SerialPort port) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ package dev.slimevr.serial;
|
||||
|
||||
public interface ProvisioningListener {
|
||||
|
||||
void onProvisioningStatusChange(ProvisioningStatus status);
|
||||
void onProvisioningStatusChange(ProvisioningStatus status, SerialPort port);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package dev.slimevr.serial;
|
||||
|
||||
import solarxr_protocol.rpc.WifiProvisioningStatus;
|
||||
|
||||
|
||||
public enum ProvisioningStatus {
|
||||
|
||||
NONE(0),
|
||||
SERIAL_INIT(1),
|
||||
PROVISIONING(2),
|
||||
CONNECTING(3),
|
||||
CONNECTION_ERROR(4),
|
||||
LOOKING_FOR_SERVER(5),
|
||||
COULD_NOT_FIND_SERVER(6),
|
||||
DONE(7);
|
||||
NONE(WifiProvisioningStatus.NONE),
|
||||
SERIAL_INIT(WifiProvisioningStatus.SERIAL_INIT),
|
||||
PROVISIONING(WifiProvisioningStatus.PROVISIONING),
|
||||
OBTAINING_MAC_ADDRESS(WifiProvisioningStatus.OBTAINING_MAC_ADDRESS),
|
||||
CONNECTING(WifiProvisioningStatus.CONNECTING),
|
||||
CONNECTION_ERROR(WifiProvisioningStatus.CONNECTION_ERROR),
|
||||
LOOKING_FOR_SERVER(WifiProvisioningStatus.LOOKING_FOR_SERVER),
|
||||
COULD_NOT_FIND_SERVER(WifiProvisioningStatus.COULD_NOT_FIND_SERVER),
|
||||
DONE(WifiProvisioningStatus.DONE);
|
||||
|
||||
public final int id;
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ abstract class SerialHandler {
|
||||
abstract fun infoRequest()
|
||||
abstract fun wifiScanRequest()
|
||||
abstract fun closeSerial()
|
||||
abstract fun write(buff: ByteArray)
|
||||
abstract fun setWifi(ssid: String, passwd: String)
|
||||
abstract fun getCurrentPort(): SerialPort?
|
||||
|
||||
companion object {
|
||||
val supportedSerial: Set<Pair<Int, Int>> = setOf(
|
||||
@@ -65,5 +67,9 @@ class SerialHandlerStub : SerialHandler() {
|
||||
|
||||
override fun closeSerial() {}
|
||||
|
||||
override fun write(buff: ByteArray) {}
|
||||
|
||||
override fun setWifi(ssid: String, passwd: String) {}
|
||||
|
||||
override fun getCurrentPort(): SerialPort? = null
|
||||
}
|
||||
|
||||
@@ -25,4 +25,7 @@ interface SerialListener {
|
||||
fun onSerialDisconnected()
|
||||
fun onSerialLog(str: String)
|
||||
fun onNewSerialDevice(port: SerialPort)
|
||||
|
||||
// This is called when the serial diver does not see the device anymore
|
||||
fun onSerialDeviceDeleted(port: SerialPort)
|
||||
}
|
||||
|
||||
@@ -785,11 +785,6 @@ class HumanSkeleton(
|
||||
var hipRot = it.getRotation()
|
||||
var chestRot = chest.getRotation()
|
||||
|
||||
// Get the rotation relative to where we expect the hip to be
|
||||
if (chestRot.times(FORWARD_QUATERNION).dot(hipRot) < 0.0f) {
|
||||
hipRot = hipRot.unaryMinus()
|
||||
}
|
||||
|
||||
// Interpolate between the chest and the hip
|
||||
chestRot = chestRot.interpQ(hipRot, waistFromChestHipAveraging)
|
||||
|
||||
@@ -802,15 +797,6 @@ class HumanSkeleton(
|
||||
var rightLegRot = rightUpperLegTracker?.getRotation() ?: IDENTITY
|
||||
var chestRot = chest.getRotation()
|
||||
|
||||
// Get the rotation relative to where we expect the upper legs to be
|
||||
val expectedUpperLegsRot = chestRot.times(FORWARD_QUATERNION)
|
||||
if (expectedUpperLegsRot.dot(leftLegRot) < 0.0f) {
|
||||
leftLegRot = leftLegRot.unaryMinus()
|
||||
}
|
||||
if (expectedUpperLegsRot.dot(rightLegRot) < 0.0f) {
|
||||
rightLegRot = rightLegRot.unaryMinus()
|
||||
}
|
||||
|
||||
// Interpolate between the pelvis, averaged from the legs, and the chest
|
||||
chestRot = chestRot.interpQ(leftLegRot.lerpQ(rightLegRot, 0.5f), waistFromChestLegsAveraging).unit()
|
||||
|
||||
@@ -827,15 +813,6 @@ class HumanSkeleton(
|
||||
var rightLegRot = rightUpperLegTracker?.getRotation() ?: IDENTITY
|
||||
var waistRot = it.getRotation()
|
||||
|
||||
// Get the rotation relative to where we expect the upper legs to be
|
||||
val expectedUpperLegsRot = waistRot.times(FORWARD_QUATERNION)
|
||||
if (expectedUpperLegsRot.dot(leftLegRot) < 0.0f) {
|
||||
leftLegRot = leftLegRot.unaryMinus()
|
||||
}
|
||||
if (expectedUpperLegsRot.dot(rightLegRot) < 0.0f) {
|
||||
rightLegRot = rightLegRot.unaryMinus()
|
||||
}
|
||||
|
||||
// Interpolate between the pelvis, averaged from the legs, and the chest
|
||||
waistRot = waistRot.interpQ(leftLegRot.lerpQ(rightLegRot, 0.5f), hipFromWaistLegsAveraging).unit()
|
||||
|
||||
@@ -849,15 +826,6 @@ class HumanSkeleton(
|
||||
var rightLegRot = rightUpperLegTracker?.getRotation() ?: IDENTITY
|
||||
var chestRot = it.getRotation()
|
||||
|
||||
// Get the rotation relative to where we expect the upper legs to be
|
||||
val expectedUpperLegsRot = chestRot.times(FORWARD_QUATERNION)
|
||||
if (expectedUpperLegsRot.dot(leftLegRot) < 0.0f) {
|
||||
leftLegRot = leftLegRot.unaryMinus()
|
||||
}
|
||||
if (expectedUpperLegsRot.dot(rightLegRot) < 0.0f) {
|
||||
rightLegRot = rightLegRot.unaryMinus()
|
||||
}
|
||||
|
||||
// Interpolate between the pelvis, averaged from the legs, and the chest
|
||||
chestRot = chestRot.interpQ(leftLegRot.lerpQ(rightLegRot, 0.5f), hipFromChestLegsAveraging).unit()
|
||||
|
||||
@@ -1110,24 +1078,11 @@ class HumanSkeleton(
|
||||
rightKnee: Quaternion,
|
||||
hip: Quaternion,
|
||||
): Quaternion {
|
||||
// Get the knees' rotation relative to where we expect them to be.
|
||||
// The angle between your knees and hip can be over 180 degrees...
|
||||
var leftKneeRot = leftKnee
|
||||
var rightKneeRot = rightKnee
|
||||
|
||||
val kneeRot = hip.times(FORWARD_QUATERNION)
|
||||
if (kneeRot.dot(leftKneeRot) < 0.0f) {
|
||||
leftKneeRot = leftKneeRot.unaryMinus()
|
||||
}
|
||||
if (kneeRot.dot(rightKneeRot) < 0.0f) {
|
||||
rightKneeRot = rightKneeRot.unaryMinus()
|
||||
}
|
||||
|
||||
// R = InverseHip * (LeftLeft + RightLeg)
|
||||
// C = Quaternion(R.w, -R.x, 0, 0)
|
||||
// Pelvis = Hip * R * C
|
||||
// normalize(Pelvis)
|
||||
val r = hip.inv() * (leftKneeRot + rightKneeRot)
|
||||
val r = hip.inv() * (leftKnee + rightKnee)
|
||||
val c = Quaternion(r.w, -r.x, 0f, 0f)
|
||||
return (hip * r * c).unit()
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ open class Device(val magSupport: Boolean = false) {
|
||||
* Implement toString() to return a string that uniquely identifies the board type
|
||||
* SHOULDN'T RETURN NULL WHEN toString() IS CALLED
|
||||
*/
|
||||
open val boardType: Any = BoardType.UNKNOWN
|
||||
open val boardType: BoardType = BoardType.UNKNOWN
|
||||
open val mcuType: MCUType = MCUType.UNKNOWN
|
||||
|
||||
open val hardwareIdentifier: String = "Unknown"
|
||||
|
||||
@@ -67,6 +67,12 @@ class Tracker @JvmOverloads constructor(
|
||||
val needsReset: Boolean = false,
|
||||
val needsMounting: Boolean = false,
|
||||
val isHmd: Boolean = false,
|
||||
/**
|
||||
* Whether to track the direction of the tracker's rotation
|
||||
* (positive vs negative rotation). This needs to be disabled for AutoBone and
|
||||
* unit tests, where the rotation is absolute and not temporal.
|
||||
*/
|
||||
val trackRotDirection: Boolean = true,
|
||||
magStatus: MagnetometerStatus = MagnetometerStatus.NOT_SUPPORTED,
|
||||
/**
|
||||
* Rotation by default.
|
||||
@@ -117,6 +123,8 @@ class Tracker @JvmOverloads constructor(
|
||||
}
|
||||
checkReportErrorStatus()
|
||||
checkReportRequireReset()
|
||||
|
||||
VRServer.instance.trackerStatusChanged(this, old, new)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +316,9 @@ class Tracker @JvmOverloads constructor(
|
||||
fun dataTick() {
|
||||
timer.update()
|
||||
timeAtLastUpdate = System.currentTimeMillis()
|
||||
filteringHandler.dataTick(_rotation)
|
||||
if (trackRotDirection) {
|
||||
filteringHandler.dataTick(_rotation)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,6 +328,13 @@ class Tracker @JvmOverloads constructor(
|
||||
timeAtLastUpdate = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun getFilteredRotation(): Quaternion = if (trackRotDirection) {
|
||||
filteringHandler.getFilteredRotation()
|
||||
} else {
|
||||
// Get raw rotation
|
||||
_rotation
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the adjusted tracker rotation after all corrections
|
||||
* (filtering, reset, mounting and drift compensation).
|
||||
@@ -326,13 +343,7 @@ class Tracker @JvmOverloads constructor(
|
||||
* it too much should be avoided for performance reasons.
|
||||
*/
|
||||
fun getRotation(): Quaternion {
|
||||
var rot = if (allowFiltering && filteringHandler.filteringEnabled) {
|
||||
// Get filtered rotation
|
||||
filteringHandler.getFilteredRotation()
|
||||
} else {
|
||||
// Get unfiltered rotation
|
||||
filteringHandler.getTrackedRotation()
|
||||
}
|
||||
var rot = getFilteredRotation()
|
||||
|
||||
// Reset if needed and is not computed and internal
|
||||
if (needsReset && !(isComputed && isInternal) && trackerDataType == TrackerDataType.ROTATION) {
|
||||
@@ -358,13 +369,7 @@ class Tracker @JvmOverloads constructor(
|
||||
* This is used for debugging/visualizing tracker data
|
||||
*/
|
||||
fun getIdentityAdjustedRotation(): Quaternion {
|
||||
var rot = if (filteringHandler.filteringEnabled) {
|
||||
// Get filtered rotation
|
||||
filteringHandler.getFilteredRotation()
|
||||
} else {
|
||||
// Get unfiltered rotation
|
||||
filteringHandler.getTrackedRotation()
|
||||
}
|
||||
var rot = getFilteredRotation()
|
||||
|
||||
// Reset if needed or is a computed tracker besides head
|
||||
if (needsReset && !(isComputed && trackerPosition != TrackerPosition.HEAD) && trackerDataType == TrackerDataType.ROTATION) {
|
||||
@@ -422,6 +427,6 @@ class Tracker @JvmOverloads constructor(
|
||||
* Call when doing a full reset to reset the tracking of rotations >180 degrees
|
||||
*/
|
||||
fun resetFilteringQuats() {
|
||||
filteringHandler.resetQuats(_rotation)
|
||||
filteringHandler.resetMovingAverage(_rotation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,8 @@ import io.github.axisangles.ktmath.Quaternion
|
||||
* See QuaternionMovingAverage.kt for the quaternion math.
|
||||
*/
|
||||
class TrackerFilteringHandler {
|
||||
|
||||
private var filteringMovingAverage: QuaternionMovingAverage? = null
|
||||
private var trackingMovingAverage = QuaternionMovingAverage(TrackerFilters.NONE)
|
||||
// Instantiated by default in case config doesn't get read (if tracker doesn't support filtering)
|
||||
private var movingAverage = QuaternionMovingAverage(TrackerFilters.NONE)
|
||||
var filteringEnabled = false
|
||||
|
||||
/**
|
||||
@@ -22,14 +21,14 @@ class TrackerFilteringHandler {
|
||||
fun readFilteringConfig(config: FiltersConfig, currentRawRotation: Quaternion) {
|
||||
val type = TrackerFilters.getByConfigkey(config.type)
|
||||
if (type == TrackerFilters.SMOOTHING || type == TrackerFilters.PREDICTION) {
|
||||
filteringMovingAverage = QuaternionMovingAverage(
|
||||
movingAverage = QuaternionMovingAverage(
|
||||
type,
|
||||
config.amount,
|
||||
currentRawRotation,
|
||||
)
|
||||
filteringEnabled = true
|
||||
} else {
|
||||
filteringMovingAverage = null
|
||||
movingAverage = QuaternionMovingAverage(TrackerFilters.NONE)
|
||||
filteringEnabled = false
|
||||
}
|
||||
}
|
||||
@@ -38,33 +37,25 @@ class TrackerFilteringHandler {
|
||||
* Update the moving average to make it smooth
|
||||
*/
|
||||
fun update() {
|
||||
trackingMovingAverage.update()
|
||||
filteringMovingAverage?.update()
|
||||
movingAverage.update()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the latest rotation
|
||||
*/
|
||||
fun dataTick(currentRawRotation: Quaternion) {
|
||||
trackingMovingAverage.addQuaternion(currentRawRotation)
|
||||
filteringMovingAverage?.addQuaternion(currentRawRotation)
|
||||
movingAverage.addQuaternion(currentRawRotation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call when doing a full reset to reset the tracking of rotations >180 degrees
|
||||
*/
|
||||
fun resetQuats(currentRawRotation: Quaternion) {
|
||||
trackingMovingAverage.resetQuats(currentRawRotation)
|
||||
filteringMovingAverage?.resetQuats(currentRawRotation)
|
||||
fun resetMovingAverage(currentRawRotation: Quaternion) {
|
||||
movingAverage.resetQuats(currentRawRotation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tracked rotation from the moving average (allows >180 degrees)
|
||||
* Get the filtered rotation from the moving average (either prediction/smoothing or just >180 degs)
|
||||
*/
|
||||
fun getTrackedRotation() = trackingMovingAverage.filteredQuaternion
|
||||
|
||||
/**
|
||||
* Get the filtered rotation from the moving average
|
||||
*/
|
||||
fun getFilteredRotation() = filteringMovingAverage?.filteredQuaternion ?: Quaternion.IDENTITY
|
||||
fun getFilteredRotation() = movingAverage.filteredQuaternion
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user