mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-05 18:01:56 +02:00
Firmware tool (#880)
Co-authored-by: ImUrX <urielfontan2002@gmail.com> Co-authored-by: Uriel <imurx@proton.me>
This commit is contained in:
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,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,16 @@
|
||||
"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",
|
||||
"@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",
|
||||
@@ -34,7 +37,8 @@
|
||||
"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 +50,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 +72,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",
|
||||
@@ -77,6 +86,8 @@
|
||||
"spdx-satisfies": "^5.0.1",
|
||||
"tailwind-gradient-mask-image": "^1.2.0",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"vite": "^5.4.8"
|
||||
"vite": "^5.4.8",
|
||||
"globals": "^15.10.0",
|
||||
"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
|
||||
@@ -325,6 +338,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
|
||||
@@ -699,6 +713,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
|
||||
@@ -748,6 +763,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
|
||||
@@ -1040,6 +1056,157 @@ 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 a 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 = Press the flash button on the pcb before inserting turning on the tracker.
|
||||
If the tracker was already on, simply turn it off and back on while pressing the button or shorting the flash pads.
|
||||
Here are a few pictures on how to do it according to the different revisions of the SlimeVR tracker
|
||||
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
|
||||
|
||||
## 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 |
@@ -51,10 +51,12 @@ 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';
|
||||
|
||||
export const GH_REPO = 'SlimeVR/SlimeVR-Server';
|
||||
@@ -105,6 +107,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 />} />
|
||||
@@ -272,19 +275,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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
107
gui/src/components/firmware-tool/DeviceCard.tsx
Normal file
107
gui/src/components/firmware-tool/DeviceCard.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { ProgressBar } from '@/components/commons/ProgressBar';
|
||||
import { CHECKBOX_CLASSES } from '@/components/commons/Checkbox';
|
||||
import classNames from 'classnames';
|
||||
import { FirmwareUpdateStatus } from 'solarxr-protocol';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { firmwareUpdateErrorStatus } from '@/hooks/firmware-tool';
|
||||
|
||||
interface DeviceCardProps {
|
||||
deviceNames: string[];
|
||||
status?: FirmwareUpdateStatus;
|
||||
}
|
||||
|
||||
interface DeviceCardControlProps {
|
||||
control?: Control<any>;
|
||||
name?: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
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 && (
|
||||
<Typography color="secondary">
|
||||
{l10n.getString(
|
||||
'firmware-update_status_' + FirmwareUpdateStatus[status]
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceCardControl({
|
||||
control,
|
||||
name,
|
||||
progress,
|
||||
...props
|
||||
}: DeviceCardControlProps & DeviceCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'rounded-md bg-background-60 pt-2 flex flex-col justify-between border-2',
|
||||
props.status && firmwareUpdateErrorStatus.includes(props.status)
|
||||
? 'border-status-critical'
|
||||
: '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"
|
||||
></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>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
271
gui/src/components/firmware-tool/FlashingStep.tsx
Normal file
271
gui/src/components/firmware-tool/FlashingStep.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { Localized, useLocalization } from '@fluent/react';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import {
|
||||
SelectedDevice,
|
||||
firmwareUpdateErrorStatus,
|
||||
useFirmwareTool,
|
||||
} from '@/hooks/firmware-tool';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import {
|
||||
DeviceIdT,
|
||||
DeviceIdTableT,
|
||||
FirmwarePartT,
|
||||
FirmwareUpdateMethod,
|
||||
FirmwareUpdateRequestT,
|
||||
FirmwareUpdateStatus,
|
||||
FirmwareUpdateStatusResponseT,
|
||||
FirmwareUpdateStopQueuesRequestT,
|
||||
OTAFirmwareUpdateT,
|
||||
RpcMessage,
|
||||
SerialDevicePortT,
|
||||
SerialFirmwareUpdateT,
|
||||
} from 'solarxr-protocol';
|
||||
import { firmwareToolS3BaseUrl } from '@/firmware-tool-api/firmwareToolFetcher';
|
||||
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';
|
||||
|
||||
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 = (devices: SelectedDevice[]) => {
|
||||
clear();
|
||||
|
||||
if (!buildStatus.firmwareFiles)
|
||||
throw new Error('invalid state - no firmware files');
|
||||
|
||||
const firmware = buildStatus.firmwareFiles.find(
|
||||
({ isFirmware }) => isFirmware
|
||||
);
|
||||
if (!firmware) throw new Error('invalid state - no firmware to find');
|
||||
|
||||
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 = firmwareToolS3BaseUrl + '/' + firmware.url;
|
||||
|
||||
const method = new OTAFirmwareUpdateT();
|
||||
method.deviceId = dId;
|
||||
method.firmwarePart = part;
|
||||
|
||||
const req = new FirmwareUpdateRequestT();
|
||||
req.method = method;
|
||||
req.methodType = FirmwareUpdateMethod.OTAFirmwareUpdate;
|
||||
sendRPCPacket(RpcMessage.FirmwareUpdateRequest, 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 = buildStatus.firmwareFiles.map(
|
||||
({ offset, url }) => {
|
||||
const part = new FirmwarePartT();
|
||||
part.offset = offset;
|
||||
part.url = firmwareToolS3BaseUrl + '/' + url;
|
||||
return part;
|
||||
}
|
||||
);
|
||||
|
||||
const req = new FirmwareUpdateRequestT();
|
||||
req.method = method;
|
||||
req.methodType = FirmwareUpdateMethod.SerialFirmwareUpdate;
|
||||
sendRPCPacket(RpcMessage.FirmwareUpdateRequest, req);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error('unsupported flashing method');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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.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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
AssignTrackerRequestT,
|
||||
BoardType,
|
||||
BodyPart,
|
||||
ForgetDeviceRequestT,
|
||||
ImuType,
|
||||
@@ -149,6 +150,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(
|
||||
@@ -285,9 +306,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]
|
||||
>;
|
||||
@@ -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
|
||||
|
||||
179
gui/src/hooks/firmware-tool.ts
Normal file
179
gui/src/hooks/firmware-tool.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import {
|
||||
fetchGetFirmwaresDefaultConfigBoard,
|
||||
useGetHealth,
|
||||
useGetIsCompatibleVersion,
|
||||
} from '@/firmware-tool-api/firmwareToolComponents';
|
||||
import {
|
||||
BuildResponseDTO,
|
||||
CreateBoardConfigDTO,
|
||||
CreateBuildFirmwareDTO,
|
||||
DefaultBuildConfigDTO,
|
||||
} from '@/firmware-tool-api/firmwareToolSchemas';
|
||||
import { BoardPinsForm } from '@/components/firmware-tool/BoardPinsStep';
|
||||
import { DeepPartial } from 'react-hook-form';
|
||||
import {
|
||||
BoardType,
|
||||
FirmwareUpdateMethod,
|
||||
FirmwareUpdateStatus,
|
||||
} from 'solarxr-protocol';
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -84,7 +84,7 @@ body {
|
||||
}
|
||||
|
||||
:root {
|
||||
overflow: hidden;
|
||||
// overflow: hidden; -- NEVER EVER BRING THIS BACK <3
|
||||
background: theme('colors.background.20');
|
||||
|
||||
--navbar-w: 101px;
|
||||
|
||||
@@ -162,9 +162,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',
|
||||
|
||||
@@ -21,7 +21,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',
|
||||
});
|
||||
|
||||
2754
pnpm-lock.yaml
generated
2754
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
|
||||
|
||||
@@ -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 }
|
||||
180
server/core/src/main/java/dev/slimevr/firmware/OTAUpdateTask.kt
Normal file
180
server/core/src/main/java/dev/slimevr/firmware/OTAUpdateTask.kt
Normal file
@@ -0,0 +1,180 @@
|
||||
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) {
|
||||
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(),
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -117,6 +117,8 @@ class Tracker @JvmOverloads constructor(
|
||||
}
|
||||
checkReportErrorStatus()
|
||||
checkReportRequireReset()
|
||||
|
||||
VRServer.instance.trackerStatusChanged(this, old, new)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.slimevr.tracking.trackers
|
||||
|
||||
interface TrackerStatusListener {
|
||||
|
||||
fun onTrackerStatusChanged(tracker: Tracker, oldStatus: TrackerStatus, newStatus: TrackerStatus)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ enum class BoardType(val id: UInt) {
|
||||
ESP01(8u),
|
||||
SLIMEVR(9u),
|
||||
LOLIN_C3_MINI(10u),
|
||||
BEETLE32C32(11u),
|
||||
BEETLE32C3(11u),
|
||||
ES32C3DEVKITM1(12u),
|
||||
OWOTRACK(13u),
|
||||
WRANGLER(14u),
|
||||
@@ -53,6 +53,8 @@ enum class BoardType(val id: UInt) {
|
||||
DEV_RESERVED(250u),
|
||||
;
|
||||
|
||||
fun getSolarType(): Int = this.id.toInt()
|
||||
|
||||
override fun toString(): String = when (this) {
|
||||
UNKNOWN -> "Unknown"
|
||||
SLIMEVR_LEGACY -> "SlimeVR Legacy"
|
||||
@@ -65,7 +67,7 @@ enum class BoardType(val id: UInt) {
|
||||
ESP01 -> "ESP-01"
|
||||
SLIMEVR -> "SlimeVR"
|
||||
LOLIN_C3_MINI -> "Lolin C3 Mini"
|
||||
BEETLE32C32 -> "Beetle ESP32-C3"
|
||||
BEETLE32C3 -> "Beetle ESP32-C3"
|
||||
ES32C3DEVKITM1 -> "Espressif ESP32-C3 DevKitM-1"
|
||||
OWOTRACK -> "owoTrack"
|
||||
WRANGLER -> "Wrangler Joycons"
|
||||
|
||||
@@ -6,6 +6,7 @@ import dev.slimevr.Keybinding
|
||||
import dev.slimevr.SLIMEVR_IDENTIFIER
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.bridge.ISteamVRBridge
|
||||
import dev.slimevr.desktop.firmware.DesktopSerialFlashingHandler
|
||||
import dev.slimevr.desktop.platform.SteamVRBridge
|
||||
import dev.slimevr.desktop.platform.linux.UnixSocketBridge
|
||||
import dev.slimevr.desktop.platform.windows.WindowsNamedPipeBridge
|
||||
@@ -121,6 +122,7 @@ fun main(args: Array<String>) {
|
||||
::provideSteamVRBridge,
|
||||
::provideFeederBridge,
|
||||
{ _ -> DesktopSerialHandler() },
|
||||
{ _ -> DesktopSerialFlashingHandler() },
|
||||
configPath = configDir,
|
||||
)
|
||||
vrServer.start()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package dev.slimevr.desktop.firmware
|
||||
|
||||
import com.fazecast.jSerialComm.SerialPort
|
||||
import dev.slimevr.firmware.SerialFlashingHandler
|
||||
import io.eiren.util.logging.LogManager
|
||||
import dev.slimevr.serial.SerialPort as SerialPortWrapper
|
||||
|
||||
class DesktopSerialFlashingHandler : SerialFlashingHandler {
|
||||
private var port: SerialPort? = null
|
||||
|
||||
override fun openSerial(port: Any) {
|
||||
if (port !is SerialPortWrapper) {
|
||||
error("Not a serial port")
|
||||
}
|
||||
val ports = SerialPort.getCommPorts()
|
||||
val comPort = ports.find { it.portLocation == port.portLocation }
|
||||
?: error("Unable to find port ${port.portLocation}")
|
||||
if (comPort.isOpen) {
|
||||
comPort.closePort()
|
||||
}
|
||||
if (!comPort.openPort(1000)) {
|
||||
error("unable to open port")
|
||||
}
|
||||
this.port = comPort
|
||||
}
|
||||
|
||||
override fun closeSerial() {
|
||||
val p = port ?: error("no port to close")
|
||||
try {
|
||||
p.closePort()
|
||||
LogManager.info("Port closed")
|
||||
} catch (e: Exception) {
|
||||
error("unable to close port")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDTR(value: Boolean) {
|
||||
val p = port ?: error("no port to set DTR")
|
||||
if (value) {
|
||||
p.setDTR()
|
||||
} else {
|
||||
p.clearDTR()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setRTS(value: Boolean) {
|
||||
val p = port ?: error("no port to set RTS")
|
||||
if (value) {
|
||||
p.setRTS()
|
||||
} else {
|
||||
p.clearRTS()
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(data: ByteArray) {
|
||||
val p = port ?: error("no port to write")
|
||||
p.writeBytes(data, data.size)
|
||||
}
|
||||
|
||||
override fun read(length: Int): ByteArray {
|
||||
val p = port ?: error("no port to read")
|
||||
val data = ByteArray(length)
|
||||
p.readBytes(data, length)
|
||||
return data
|
||||
}
|
||||
|
||||
override fun changeBaud(baud: Int) {
|
||||
val p = port ?: error("no port to set the baud")
|
||||
if (!p.setBaudRate(baud)) {
|
||||
error("Unable to change baudrate")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setReadTimeout(timeout: Long) {
|
||||
val p = port ?: error("no port to set the timeout")
|
||||
p.setComPortTimeouts(SerialPort.TIMEOUT_READ_BLOCKING, timeout.toInt(), 0)
|
||||
}
|
||||
|
||||
override fun availableBytes(): Int {
|
||||
val p = port ?: error("no port to check available bytes")
|
||||
return p.bytesAvailable()
|
||||
}
|
||||
|
||||
override fun flushIOBuffers() {
|
||||
val p = port ?: error("no port to flush")
|
||||
p.flushIOBuffers()
|
||||
}
|
||||
}
|
||||
@@ -71,10 +71,14 @@ class DesktopSerialHandler :
|
||||
getDevicesTimer.purge()
|
||||
}
|
||||
|
||||
fun onNewDevice(port: SerialPort) {
|
||||
private fun onNewDevice(port: SerialPort) {
|
||||
listeners.forEach { it.onNewSerialDevice(SerialPortWrapper(port)) }
|
||||
}
|
||||
|
||||
private fun onDeviceDel(port: SerialPort) {
|
||||
listeners.forEach { it.onSerialDeviceDeleted(SerialPortWrapper(port)) }
|
||||
}
|
||||
|
||||
override fun addListener(channel: SerialListener) {
|
||||
listeners.add(channel)
|
||||
}
|
||||
@@ -181,6 +185,11 @@ class DesktopSerialHandler :
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(buff: ByteArray) {
|
||||
LogManager.info("[SerialHandler] WRITING $buff")
|
||||
currentPort?.outputStream?.write(buff)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun setWifi(ssid: String, passwd: String) {
|
||||
val os = currentPort?.outputStream ?: return
|
||||
@@ -236,13 +245,20 @@ class DesktopSerialHandler :
|
||||
|
||||
private fun detectNewPorts() {
|
||||
try {
|
||||
val differences = knownPorts.asSequence() - lastKnownPorts
|
||||
val addDifferences = knownPorts.asSequence() - lastKnownPorts
|
||||
val delDifferences = lastKnownPorts - knownPorts.asSequence().toSet()
|
||||
lastKnownPorts = SerialPort.getCommPorts().map { SerialPortWrapper(it) }.toSet()
|
||||
differences.forEach { onNewDevice(it.port) }
|
||||
addDifferences.forEach { onNewDevice(it.port) }
|
||||
delDifferences.forEach { onDeviceDel(it.port) }
|
||||
} catch (e: Throwable) {
|
||||
LogManager
|
||||
.severe("[SerialHandler] Using serial ports is not supported on this platform", e)
|
||||
throw RuntimeException("Serial unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentPort(): dev.slimevr.serial.SerialPort? {
|
||||
val port = this.currentPort ?: return null
|
||||
return SerialPortWrapper(port)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user