Merge branch 'main' into onboarding-usage-step

This commit is contained in:
Uriel
2025-01-22 18:05:32 +01:00
107 changed files with 9428 additions and 988 deletions

View File

@@ -285,7 +285,7 @@ jobs:
./bundle_dmg.sh --volname SlimeVR --icon slimevr 180 170 --app-drop-link 480 170 \
--window-size 660 400 --hide-extension ../macos/SlimeVR.app \
--volicon ../macos/SlimeVR.app/Contents/Resources/icon.icns --skip-jenkins \
--eula ../../../../LICENSE-MIT slimevr.dmg ../macos/SlimeVR.app
--eula ../../../../../LICENSE-MIT slimevr.dmg ../macos/SlimeVR.app
- uses: actions/upload-artifact@v4
with:

1
.npmrc Normal file
View File

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

12
Cargo.lock generated
View File

@@ -2018,6 +2018,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "0.4.8"
@@ -3188,7 +3197,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3827,6 +3836,7 @@ dependencies = [
"discord-sdk",
"flexi_logger",
"glob",
"itertools",
"libloading 0.8.5",
"log",
"log-panics",

View File

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

View File

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

8
gui/.env Normal file
View File

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

View File

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

1
gui/.gitignore vendored
View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -75,6 +75,19 @@ body_part-RIGHT_LITTLE_PROXIMAL = Right little proximal
body_part-RIGHT_LITTLE_INTERMEDIATE = Right little intermediate
body_part-RIGHT_LITTLE_DISTAL = Right little distal
## BoardType
board_type-UNKNOWN = Unknown
board_type-NODEMCU = NodeMCU
board_type-CUSTOM = Custom Board
board_type-WROOM32 = WROOM32
board_type-WEMOSD1MINI = Wemos D1 Mini
board_type-TTGO_TBASE = TTGO T-Base
board_type-ESP01 = ESP-01
board_type-SLIMEVR = SlimeVR
board_type-LOLIN_C3_MINI = Lolin C3 Mini
board_type-BEETLE32C3 = Beetle ESP32-C3
board_type-ES32C3DEVKITM1 = Espressif ESP32-C3 DevKitM-1
## Proportions
skeleton_bone-NONE = None
skeleton_bone-HEAD = Head Shift
@@ -253,6 +266,11 @@ tracker-settings-name_section-label = Tracker name
tracker-settings-forget = Forget tracker
tracker-settings-forget-description = Removes the tracker from the SlimeVR Server and prevent it from connecting to it until the server is restarted. The configuration of the tracker won't be lost.
tracker-settings-forget-label = Forget tracker
tracker-settings-update-unavailable = Cannot be updated (DIY)
tracker-settings-update-up_to_date = Up to date
tracker-settings-update-available = { $versionName } is now available
tracker-settings-update = Update now
tracker-settings-update-title = Firmware version
## Tracker part card info
tracker-part_card-no_name = No name
@@ -325,6 +343,7 @@ settings-sidebar-utils = Utilities
settings-sidebar-serial = Serial console
settings-sidebar-appearance = Appearance
settings-sidebar-notifications = Notifications
settings-sidebar-firmware-tool = DIY Firmware Tool
settings-sidebar-advanced = Advanced
## SteamVR settings
@@ -701,6 +720,7 @@ onboarding-wifi_creds-submit = Submit!
onboarding-wifi_creds-ssid =
.label = Wi-Fi name
.placeholder = Enter Wi-Fi name
onboarding-wifi_creds-ssid-required = Wi-Fi name is required
onboarding-wifi_creds-password =
.label = Password
.placeholder = Enter password
@@ -750,6 +770,7 @@ onboarding-connect_tracker-issue-serial = I'm having trouble connecting!
onboarding-connect_tracker-usb = USB Tracker
onboarding-connect_tracker-connection_status-none = Looking for trackers
onboarding-connect_tracker-connection_status-serial_init = Connecting to serial device
onboarding-connect_tracker-connection_status-obtaining_mac_address = Obtaining the tracker mac address
onboarding-connect_tracker-connection_status-provisioning = Sending Wi-Fi credentials
onboarding-connect_tracker-connection_status-connecting = Trying to connect to Wi-Fi
onboarding-connect_tracker-connection_status-looking_for_server = Looking for server
@@ -1115,6 +1136,165 @@ status_system-StatusSteamVRDisconnected = { $type ->
status_system-StatusTrackerError = The { $trackerName } tracker has an error.
status_system-StatusUnassignedHMD = The VR headset should be assigned as a head tracker.
## Firmware tool globals
firmware_tool-next_step = Next Step
firmware_tool-previous_step = Previous Step
firmware_tool-ok = Looks good
firmware_tool-retry = Retry
firmware_tool-loading = Loading...
## Firmware tool Steps
firmware_tool = DIY Firmware tool
firmware_tool-description =
Allows you to configure and flash your DIY trackers
firmware_tool-not_available = Oops, the firmware tool is not available at the moment. Come back later!
firmware_tool-not_compatible = The firmware tool is not compatible with this version of the server. Please update your server!
firmware_tool-board_step = Select your Board
firmware_tool-board_step-description = Select one of the boards listed below.
firmware_tool-board_pins_step = Check the pins
firmware_tool-board_pins_step-description =
Please verify that the selected pins are correct.
If you followed the SlimeVR documentation the defaults values should be correct
firmware_tool-board_pins_step-enable_led = Enable LED
firmware_tool-board_pins_step-led_pin =
.label = LED Pin
.placeholder = Enter the pin address of the LED
firmware_tool-board_pins_step-battery_type = Select the battery type
firmware_tool-board_pins_step-battery_type-BAT_EXTERNAL = External battery
firmware_tool-board_pins_step-battery_type-BAT_INTERNAL = Internal battery
firmware_tool-board_pins_step-battery_type-BAT_INTERNAL_MCP3021 = Internal MCP3021
firmware_tool-board_pins_step-battery_type-BAT_MCP3021 = MCP3021
firmware_tool-board_pins_step-battery_sensor_pin =
.label = Battery sensor Pin
.placeholder = Enter the pin address of battery sensor
firmware_tool-board_pins_step-battery_resistor =
.label = Battery Resistor (Ohms)
.placeholder = Enter the value of battery resistor
firmware_tool-board_pins_step-battery_shield_resistor-0 =
.label = Battery Shield R1 (Ohms)
.placeholder = Enter the value of Battery Shield R1
firmware_tool-board_pins_step-battery_shield_resistor-1 =
.label = Battery Shield R2 (Ohms)
.placeholder = Enter the value of Battery Shield R2
firmware_tool-add_imus_step = Declare your IMUs
firmware_tool-add_imus_step-description =
Please add the IMUs that your tracker has
If you followed the SlimeVR documentation the defaults values should be correct
firmware_tool-add_imus_step-imu_type-label = IMU type
firmware_tool-add_imus_step-imu_type-placeholder = Select the type of IMU
firmware_tool-add_imus_step-imu_rotation =
.label = IMU Rotation (deg)
.placeholder = Rotation angle of the IMU
firmware_tool-add_imus_step-scl_pin =
.label = SCL Pin
.placeholder = Pin address of SCL
firmware_tool-add_imus_step-sda_pin =
.label = SDA Pin
.placeholder = Pin address of SDA
firmware_tool-add_imus_step-int_pin =
.label = INT Pin
.placeholder = Pin address of INT
firmware_tool-add_imus_step-optional_tracker =
.label = Optional tracker
firmware_tool-add_imus_step-show_less = Show Less
firmware_tool-add_imus_step-show_more = Show More
firmware_tool-add_imus_step-add_more = Add more IMUs
firmware_tool-select_firmware_step = Select the firmware version
firmware_tool-select_firmware_step-description =
Please choose what version of the firmware you want to use
firmware_tool-select_firmware_step-show-third-party =
.label = Show third party firmwares
firmware_tool-flash_method_step = Flashing Method
firmware_tool-flash_method_step-description =
Please select the flashing method you want to use
firmware_tool-flash_method_step-ota =
.label = OTA
.description = Use the over the air method. Your tracker will use the Wi-Fi to update it's firmware. Works only on already setup trackers.
firmware_tool-flash_method_step-serial =
.label = Serial
.description = Use a USB cable to update your tracker.
firmware_tool-flashbtn_step = Press the boot btn
firmware_tool-flashbtn_step-description = Before going into the next step there is a few things you need to do
firmware_tool-flashbtn_step-board_SLIMEVR = Turn off the tracker, remove the case (if any), connect a USB cable to this computer, then do one of the following steps according to your SlimeVR board revision:
firmware_tool-flashbtn_step-board_SLIMEVR-r11 = Turn on the tracker while shorting the second rectangular FLASH pad from the edge on the top side of the board, and the metal shield of the microcontroller
firmware_tool-flashbtn_step-board_SLIMEVR-r12 = Turn on the tracker while shorting the circular FLASH pad on the top side of the board, and the metal shield of the microcontroller
firmware_tool-flashbtn_step-board_SLIMEVR-r14 = Turn on the tracker while pushing in the FLASH button on the top side of the board
firmware_tool-flashbtn_step-board_OTHER = Before flashing you will probably need to put the tracker into bootloader mode.
Most of the time it means pressing the boot button on the board before the flashing process starts.
If the flashing process timeout at the begining of the flashing it probably means that the tracker was not in bootloader mode
Please refer to the flashing instructions of your board to know how to turn on the boatloader mode
firmware_tool-flash_method_ota-devices = Detected OTA Devices:
firmware_tool-flash_method_ota-no_devices = There are no boards that can be updated using OTA, make sure you selected the correct board type
firmware_tool-flash_method_serial-wifi = Wi-Fi Credentials:
firmware_tool-flash_method_serial-devices-label = Detected Serial Devices:
firmware_tool-flash_method_serial-devices-placeholder = Select a serial device
firmware_tool-flash_method_serial-no_devices = There are no compatible serial devices detected, make sure the tracker is plugged in
firmware_tool-build_step = Building
firmware_tool-build_step-description =
The firmware is building, please wait
firmware_tool-flashing_step = Flashing
firmware_tool-flashing_step-description =
Your trackers are flashing, please follow the instructions on the screen
firmware_tool-flashing_step-warning = Do not unplug or restart the tracker during the upload process unless told to, it may make your board unusable
firmware_tool-flashing_step-flash_more = Flash more trackers
firmware_tool-flashing_step-exit = Exit
## firmware tool build status
firmware_tool-build-CREATING_BUILD_FOLDER = Creating the build folder
firmware_tool-build-DOWNLOADING_FIRMWARE = Downloading the firmware
firmware_tool-build-EXTRACTING_FIRMWARE = Extracting the firmware
firmware_tool-build-SETTING_UP_DEFINES = Configuring the defines
firmware_tool-build-BUILDING = Building the firmware
firmware_tool-build-SAVING = Saving the build
firmware_tool-build-DONE = Build Complete
firmware_tool-build-ERROR = Unable to build the firmware
## Firmware update status
firmware_update-status-DOWNLOADING = Downloading the firmware
firmware_update-status-NEED_MANUAL_REBOOT = Waiting for the user to reboot the tracker
firmware_update-status-AUTHENTICATING = Authenticating with the mcu
firmware_update-status-UPLOADING = Uploading the firmware
firmware_update-status-SYNCING_WITH_MCU = Syncing with the mcu
firmware_update-status-REBOOTING = Rebooting the tracker
firmware_update-status-PROVISIONING = Setting Wi-Fi credentials
firmware_update-status-DONE = Update complete!
firmware_update-status-ERROR_DEVICE_NOT_FOUND = Could not find the device
firmware_update-status-ERROR_TIMEOUT = The update process timed out
firmware_update-status-ERROR_DOWNLOAD_FAILED = Could not download the firmware
firmware_update-status-ERROR_AUTHENTICATION_FAILED = Could not authenticate with the mcu
firmware_update-status-ERROR_UPLOAD_FAILED = Could not upload the firmware
firmware_update-status-ERROR_PROVISIONING_FAILED = Could not set the Wi-Fi credentials
firmware_update-status-ERROR_UNSUPPORTED_METHOD = The update method is not supported
firmware_update-status-ERROR_UNKNOWN = Unknown error
## Dedicated Firmware Update Page
firmware_update-title = Firmware update
firmware_update-devices = Available Devices
firmware_update-devices-description = Please select the trackers you want to update to the latest version of SlimeVR firmware
firmware_update-no_devices = Plase make sure that the trackers you want to update are ON and connected to the Wi-Fi!
firmware_update-changelog-title = Updating to {$version}
firmware_update-looking_for_devices = Looking for devices to update...
firmware_update-retry = Retry
firmware_update-update = Update Selected Trackers
## Tray Menu
tray_menu-show = Show
tray_menu-hide = Hide

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

View File

@@ -53,6 +53,7 @@ rfd = { version = "0.15", features = ["gtk3"], default-features = false }
dirs-next = "2.0.0"
discord-sdk = "0.3.6"
tokio = { version = "1.37.0", features = ["time"] }
itertools = "0.13.0"
[target.'cfg(windows)'.dependencies]
win32job = "1"

View File

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

View File

@@ -51,11 +51,14 @@ import { useBreakpoint, useIsTauri } from './hooks/breakpoint';
import { VRModePage } from './components/vr-mode/VRModePage';
import { InterfaceSettings } from './components/settings/pages/InterfaceSettings';
import { error, log } from './utils/logging';
import { FirmwareToolSettings } from './components/firmware-tool/FirmwareTool';
import { AppLayout } from './AppLayout';
import { Preload } from './components/Preload';
import { UnknownDeviceModal } from './components/UnknownDeviceModal';
import { useDiscordPresence } from './hooks/discord-presence';
import { EmptyLayout } from './components/EmptyLayout';
import { AdvancedSettings } from './components/settings/pages/AdvancedSettings';
import { FirmwareUpdate } from './components/firmware-update/FirmwareUpdate';
import { UsageChoose } from './components/onboarding/pages/usage-reason/UsageChoose';
import { VRUsageChoose } from './components/onboarding/pages/usage-reason/VRUsageChoose';
import { StandaloneUsageSetup } from './components/onboarding/pages/usage-reason/StandaloneUsageSetup';
@@ -89,6 +92,14 @@ function Layout() {
</MainLayout>
}
/>
<Route
path="/firmware-update"
element={
<MainLayout isMobile={isMobile} widgets={false}>
<FirmwareUpdate />
</MainLayout>
}
/>
<Route
path="/vr-mode"
element={
@@ -113,6 +124,7 @@ function Layout() {
</SettingsLayout>
}
>
<Route path="firmware-tool" element={<FirmwareToolSettings />} />
<Route path="trackers" element={<GeneralSettings />} />
<Route path="serial" element={<Serial />} />
<Route path="osc/router" element={<OSCRouterSettings />} />
@@ -295,19 +307,16 @@ export default function App() {
<VersionContext.Provider value={updateFound}>
<div className="h-full w-full text-standard bg-background-80 text-background-10">
<Preload />
<div className="flex-col h-full">
{!websocketAPI.isConnected && (
<>
<TopBar></TopBar>
<div className="flex w-full h-full justify-center items-center p-2">
{websocketAPI.isFirstConnection
? l10n.getString('websocket-connecting')
: l10n.getString('websocket-connection_lost')}
</div>
</>
)}
{websocketAPI.isConnected && <Layout></Layout>}
</div>
{!websocketAPI.isConnected && (
<EmptyLayout>
<div className="flex w-full h-full justify-center items-center p-2">
{websocketAPI.isFirstConnection
? l10n.getString('websocket-connecting')
: l10n.getString('websocket-connection_lost')}
</div>
</EmptyLayout>
)}
{websocketAPI.isConnected && <Layout></Layout>}
</div>
</VersionContext.Provider>
</StatusProvider>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -90,7 +90,7 @@ body {
}
:root {
overflow: hidden;
// overflow: hidden; -- NEVER EVER BRING THIS BACK <3
background: theme('colors.background.20');
--navbar-w: 101px;
@@ -388,6 +388,14 @@ body {
background: theme('colors.background.60');
}
.bg-background-60::-webkit-scrollbar-thumb:hover {
background: theme('colors.background.40');
}
.bg-background-60 {
scrollbar-color: theme('colors.background.50') transparent;
}
.dropdown-scroll {
scrollbar-color: theme('colors.background.40') theme('colors.background.50');
}

View File

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

View File

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

3614
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import dev.slimevr.autobone.errors.*
import dev.slimevr.config.AutoBoneConfig
import dev.slimevr.poseframeformat.PoseFrameIO
import dev.slimevr.poseframeformat.PoseFrames
import dev.slimevr.tracking.processor.BoneType
import dev.slimevr.tracking.processor.HumanPoseManager
import dev.slimevr.tracking.processor.config.SkeletonConfigManager
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
@@ -94,38 +95,73 @@ class AutoBone(server: VRServer) {
}
}
fun getBoneDirection(
/**
* Computes the local tail position of the bone after rotation.
*/
fun getBoneLocalTail(
skeleton: HumanPoseManager,
configOffset: SkeletonConfigOffsets,
rightSide: Boolean,
boneType: BoneType,
): Vector3 {
// IMPORTANT: This assumption for acquiring BoneType only works if
// SkeletonConfigOffsets is set up to only affect one BoneType, make sure no
// changes to SkeletonConfigOffsets goes against this assumption, please!
val boneType = when (configOffset) {
SkeletonConfigOffsets.HIPS_WIDTH, SkeletonConfigOffsets.SHOULDERS_WIDTH,
SkeletonConfigOffsets.SHOULDERS_DISTANCE, SkeletonConfigOffsets.UPPER_ARM,
SkeletonConfigOffsets.LOWER_ARM, SkeletonConfigOffsets.UPPER_LEG,
SkeletonConfigOffsets.LOWER_LEG, SkeletonConfigOffsets.FOOT_LENGTH,
->
if (rightSide) configOffset.affectedOffsets[1] else configOffset.affectedOffsets[0]
else -> configOffset.affectedOffsets[0]
}
return skeleton.getBone(boneType).getGlobalRotation().toRotationVector()
val bone = skeleton.getBone(boneType)
return bone.getTailPosition() - bone.getPosition()
}
fun getDotProductDiff(
/**
* Computes the direction of the bone tail's movement between skeletons 1 and 2.
*/
fun getBoneLocalTailDir(
skeleton1: HumanPoseManager,
skeleton2: HumanPoseManager,
configOffset: SkeletonConfigOffsets,
rightSide: Boolean,
offset: Vector3,
boneType: BoneType,
): Vector3? {
val boneOff = getBoneLocalTail(skeleton2, boneType) - getBoneLocalTail(skeleton1, boneType)
val boneOffLen = boneOff.len()
return if (boneOffLen > MIN_SLIDE_DIST) boneOff / boneOffLen else null
}
/**
* Predicts how much the provided config should be affecting the slide offsets
* of the left and right ankles.
*/
fun getSlideDot(
skeleton1: HumanPoseManager,
skeleton2: HumanPoseManager,
config: SkeletonConfigOffsets,
slideL: Vector3?,
slideR: Vector3?,
): Float {
val normalizedOffset = offset.unit()
val dot1 = normalizedOffset.dot(getBoneDirection(skeleton1, configOffset, rightSide))
val dot2 = normalizedOffset.dot(getBoneDirection(skeleton2, configOffset, rightSide))
return dot2 - dot1
var slideDot = 0f
// Used for right offset if not a symmetric bone
var boneOffL: Vector3? = null
if (slideL != null) {
boneOffL = getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[0])
if (boneOffL != null) {
slideDot += slideL.dot(boneOffL)
}
}
if (slideR != null) {
// IMPORTANT: This assumption for acquiring BoneType only works if
// SkeletonConfigOffsets is set up to only affect one BoneType, make sure no
// changes to SkeletonConfigOffsets goes against this assumption, please!
val boneOffR = if (SYMM_CONFIGS.contains(config)) {
getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[1])
} else if (slideL != null) {
// Use cached offset if slideL was used
boneOffL
} else {
// Compute offset if missing because of slideL
getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[0])
}
if (boneOffR != null) {
slideDot += slideR.dot(boneOffR)
}
}
return slideDot / 2f
}
fun applyConfig(
@@ -488,13 +524,15 @@ class AutoBone(server: VRServer) {
return
}
val slideLeft = skeleton2
.getComputedTracker(TrackerRole.LEFT_FOOT).position -
val slideL = skeleton2.getComputedTracker(TrackerRole.LEFT_FOOT).position -
skeleton1.getComputedTracker(TrackerRole.LEFT_FOOT).position
val slideLLen = slideL.len()
val slideLUnit: Vector3? = if (slideLLen > MIN_SLIDE_DIST) slideL / slideLLen else null
val slideRight = skeleton2
.getComputedTracker(TrackerRole.RIGHT_FOOT).position -
val slideR = skeleton2.getComputedTracker(TrackerRole.RIGHT_FOOT).position -
skeleton1.getComputedTracker(TrackerRole.RIGHT_FOOT).position
val slideRLen = slideR.len()
val slideRUnit: Vector3? = if (slideRLen > MIN_SLIDE_DIST) slideR / slideRLen else null
val intermediateOffsets = EnumMap(offsets)
for (entry in intermediateOffsets.entries) {
@@ -505,28 +543,23 @@ class AutoBone(server: VRServer) {
}
val originalLength = entry.value
val leftDotProduct = getDotProductDiff(
skeleton1,
skeleton2,
entry.key,
false,
slideLeft,
)
val rightDotProduct = getDotProductDiff(
skeleton1,
skeleton2,
entry.key,
true,
slideRight,
)
// Calculate the total effect of the bone based on change in rotation
val dotLength = originalLength * ((leftDotProduct + rightDotProduct) / 2f)
val slideDot = getSlideDot(
skeleton1,
skeleton2,
entry.key,
slideLUnit,
slideRUnit,
)
val dotLength = originalLength * slideDot
// Scale by the total effect of the bone
val curAdjustVal = adjustVal * -dotLength
val newLength = originalLength + curAdjustVal
if (curAdjustVal == 0f) {
continue
}
val newLength = originalLength + curAdjustVal
// No small or negative numbers!!! Bad algorithm!
if (newLength < 0.01f) {
continue
@@ -754,6 +787,7 @@ class AutoBone(server: VRServer) {
companion object {
const val MIN_HEIGHT = 0.4f
const val MIN_SLIDE_DIST = 0.002f
const val AUTOBONE_FOLDER = "AutoBone Recordings"
const val LOADAUTOBONE_FOLDER = "Load AutoBone Recordings"
@@ -773,5 +807,16 @@ class AutoBone(server: VRServer) {
private fun errorFunc(errorDeriv: Float): Float = 0.5f * (errorDeriv * errorDeriv)
private fun decayFunc(initialAdjustRate: Float, adjustRateDecay: Float, epoch: Int): Float = if (epoch >= 0) initialAdjustRate / (1 + (adjustRateDecay * epoch)) else 0.0f
private val SYMM_CONFIGS = arrayOf(
SkeletonConfigOffsets.HIPS_WIDTH,
SkeletonConfigOffsets.SHOULDERS_WIDTH,
SkeletonConfigOffsets.SHOULDERS_DISTANCE,
SkeletonConfigOffsets.UPPER_ARM,
SkeletonConfigOffsets.LOWER_ARM,
SkeletonConfigOffsets.UPPER_LEG,
SkeletonConfigOffsets.LOWER_LEG,
SkeletonConfigOffsets.FOOT_LENGTH,
)
}
}

View File

@@ -39,10 +39,14 @@ class PositionError : IAutoBoneError {
val position = trackerFrame.tryGetPosition() ?: continue
val trackerRole = trackerFrame.tryGetTrackerPosition()?.trackerRole ?: continue
val computedTracker = skeleton.getComputedTracker(trackerRole) ?: continue
try {
val computedTracker = skeleton.getComputedTracker(trackerRole)
offset += (position - computedTracker.position).len()
offsetCount++
offset += (position - computedTracker.position).len()
offsetCount++
} catch (_: Exception) {
// Ignore unsupported positions
}
}
return if (offsetCount > 0) offset / offsetCount else 0f
}

View File

@@ -37,13 +37,17 @@ class PositionOffsetError : IAutoBoneError {
val position2 = trackerFrame2.tryGetPosition() ?: continue
val trackerRole2 = trackerFrame2.tryGetTrackerPosition()?.trackerRole ?: continue
val computedTracker1 = skeleton1.getComputedTracker(trackerRole1) ?: continue
val computedTracker2 = skeleton2.getComputedTracker(trackerRole2) ?: continue
try {
val computedTracker1 = skeleton1.getComputedTracker(trackerRole1)
val computedTracker2 = skeleton2.getComputedTracker(trackerRole2)
val dist1 = (position1 - computedTracker1.position).len()
val dist2 = (position2 - computedTracker2.position).len()
offset += abs(dist2 - dist1)
offsetCount++
val dist1 = (position1 - computedTracker1.position).len()
val dist2 = (position2 - computedTracker2.position).len()
offset += abs(dist2 - dist1)
offsetCount++
} catch (_: Exception) {
// Ignore unsupported positions
}
}
return if (offsetCount > 0) offset / offsetCount else 0f
}

View File

@@ -4,14 +4,14 @@ class AutoBoneConfig {
var cursorIncrement = 2
var minDataDistance = 1
var maxDataDistance = 1
var numEpochs = 100
var numEpochs = 50
var printEveryNumEpochs = 25
var initialAdjustRate = 10.0f
var adjustRateDecay = 1.0f
var slideErrorFactor = 0.0f
var offsetSlideErrorFactor = 1.0f
var slideErrorFactor = 1.0f
var offsetSlideErrorFactor = 0.0f
var footHeightOffsetErrorFactor = 0.0f
var bodyProportionErrorFactor = 0.25f
var bodyProportionErrorFactor = 0.05f
var heightErrorFactor = 0.0f
var positionErrorFactor = 0.0f
var positionOffsetErrorFactor = 0.0f

View File

@@ -304,6 +304,32 @@ public class CurrentVRConfigConverter implements VersionedModelConverter {
}
}
}
if (version < 14) {
// Update AutoBone defaults
ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone");
if (autoBoneNode != null) {
JsonNode offsetSlideNode = autoBoneNode.get("offsetSlideErrorFactor");
JsonNode slideNode = autoBoneNode.get("slideErrorFactor");
if (
offsetSlideNode != null
&& slideNode != null
&& offsetSlideNode.floatValue() == 1.0f
&& slideNode.floatValue() == 0.0f
) {
autoBoneNode.set("offsetSlideErrorFactor", new FloatNode(0.0f));
autoBoneNode.set("slideErrorFactor", new FloatNode(1.0f));
}
JsonNode bodyProportionsNode = autoBoneNode.get("bodyProportionErrorFactor");
if (bodyProportionsNode != null && bodyProportionsNode.floatValue() == 0.25f) {
autoBoneNode.set("bodyProportionErrorFactor", new FloatNode(0.05f));
}
JsonNode numEpochsNode = autoBoneNode.get("numEpochs");
if (numEpochsNode != null && numEpochsNode.intValue() == 100) {
autoBoneNode.set("numEpochs", new IntNode(50));
}
}
}
} catch (Exception e) {
LogManager.severe("Error during config migration: " + e);
}

View File

@@ -10,8 +10,8 @@ import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.tracking.trackers.TrackerRole
@JsonVersionedModel(
currentVersion = "13",
defaultDeserializeToVersion = "13",
currentVersion = "14",
defaultDeserializeToVersion = "14",
toCurrentConverterClass = CurrentVRConfigConverter::class,
)
class VRConfig {

View File

@@ -23,7 +23,7 @@ class QuaternionMovingAverage(
) {
private var smoothFactor = 0f
private var predictFactor = 0f
private lateinit var rotBuffer: CircularArrayList<Quaternion>
private var rotBuffer: CircularArrayList<Quaternion>? = null
private var latestQuaternion = IDENTITY
private var smoothingQuaternion = IDENTITY
private val fpsTimer = if (VRServer.instanceInitialized) VRServer.instance.fpsTimer else NanoTimer()
@@ -57,11 +57,11 @@ class QuaternionMovingAverage(
@Synchronized
fun update() {
if (type == TrackerFilters.PREDICTION) {
if (rotBuffer.size > 0) {
if (rotBuffer!!.size > 0) {
var quatBuf = latestQuaternion
// Applies the past rotations to the current rotation
rotBuffer.forEach { quatBuf *= it }
rotBuffer?.forEach { quatBuf *= it }
// Calculate how much to slerp
val amt = predictFactor * fpsTimer.timePerFrame
@@ -98,12 +98,12 @@ class QuaternionMovingAverage(
@Synchronized
fun addQuaternion(q: Quaternion) {
if (type == TrackerFilters.PREDICTION) {
if (rotBuffer.size == rotBuffer.capacity()) {
rotBuffer.removeLast()
if (rotBuffer!!.size == rotBuffer!!.capacity()) {
rotBuffer?.removeLast()
}
// Gets and stores the rotation between the last 2 quaternions
rotBuffer.add(latestQuaternion.inv().times(q))
rotBuffer?.add(latestQuaternion.inv().times(q))
} else if (type == TrackerFilters.SMOOTHING) {
frameCounter = 0
lastAmt = 0f
@@ -116,8 +116,11 @@ class QuaternionMovingAverage(
}
fun resetQuats(q: Quaternion) {
if (type == TrackerFilters.PREDICTION) {
rotBuffer?.clear()
latestQuaternion = q
}
filteredQuaternion = q
latestQuaternion = q
smoothingQuaternion = q
addQuaternion(q)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
package dev.slimevr.firmware
enum class FirmwareUpdateMethod(val id: Byte) {
NONE(solarxr_protocol.rpc.FirmwareUpdateMethod.NONE),
OTA(solarxr_protocol.rpc.FirmwareUpdateMethod.OTAFirmwareUpdate),
SERIAL(solarxr_protocol.rpc.FirmwareUpdateMethod.SerialFirmwareUpdate),
;
companion object {
fun getById(id: Byte): FirmwareUpdateMethod? = byId[id]
}
}
private val byId = FirmwareUpdateMethod.entries.associateBy { it.id }

View File

@@ -0,0 +1,29 @@
package dev.slimevr.firmware
enum class FirmwareUpdateStatus(val id: Int) {
DOWNLOADING(solarxr_protocol.rpc.FirmwareUpdateStatus.DOWNLOADING),
AUTHENTICATING(solarxr_protocol.rpc.FirmwareUpdateStatus.AUTHENTICATING),
UPLOADING(solarxr_protocol.rpc.FirmwareUpdateStatus.UPLOADING),
SYNCING_WITH_MCU(solarxr_protocol.rpc.FirmwareUpdateStatus.SYNCING_WITH_MCU),
REBOOTING(solarxr_protocol.rpc.FirmwareUpdateStatus.REBOOTING),
NEED_MANUAL_REBOOT(solarxr_protocol.rpc.FirmwareUpdateStatus.NEED_MANUAL_REBOOT),
PROVISIONING(solarxr_protocol.rpc.FirmwareUpdateStatus.PROVISIONING),
DONE(solarxr_protocol.rpc.FirmwareUpdateStatus.DONE),
ERROR_DEVICE_NOT_FOUND(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND),
ERROR_TIMEOUT(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_TIMEOUT),
ERROR_DOWNLOAD_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED),
ERROR_AUTHENTICATION_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED),
ERROR_UPLOAD_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UPLOAD_FAILED),
ERROR_PROVISIONING_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED),
ERROR_UNSUPPORTED_METHOD(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UNSUPPORTED_METHOD),
ERROR_UNKNOWN(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UNKNOWN),
;
fun isError(): Boolean = id in ERROR_DEVICE_NOT_FOUND.id..ERROR_UNKNOWN.id
companion object {
fun getById(id: Int): FirmwareUpdateStatus? = byId[id]
}
}
private val byId = FirmwareUpdateStatus.entries.associateBy { it.id }

View File

@@ -0,0 +1,181 @@
package dev.slimevr.firmware
import io.eiren.util.logging.LogManager
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.ServerSocket
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.util.*
import java.util.function.Consumer
import kotlin.math.min
class OTAUpdateTask(
private val firmware: ByteArray,
private val deviceId: UpdateDeviceId<Int>,
private val deviceIp: InetAddress,
private val statusCallback: Consumer<UpdateStatusEvent<Int>>,
) {
private val receiveBuffer: ByteArray = ByteArray(38)
@Throws(NoSuchAlgorithmException::class)
private fun bytesToMd5(bytes: ByteArray): String {
val md5 = MessageDigest.getInstance("MD5")
md5.update(bytes)
val digest = md5.digest()
val md5str = StringBuilder()
for (b in digest) {
md5str.append(String.format("%02x", b))
}
return md5str.toString()
}
private fun authenticate(localPort: Int): Boolean {
try {
DatagramSocket().use { socket ->
statusCallback.accept(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.AUTHENTICATING))
LogManager.info("[OTAUpdate] Sending OTA invitation to: $deviceIp")
val fileMd5 = bytesToMd5(firmware)
val message = "$FLASH $localPort ${firmware.size} $fileMd5\n"
socket.send(DatagramPacket(message.toByteArray(), message.length, deviceIp, PORT))
socket.soTimeout = 10000
val authPacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
socket.receive(authPacket)
val data = String(authPacket.data, 0, authPacket.length)
// if we received OK directly from the MCU, we do not need to authenticate
if (data == "OK") return true
val args = data.split(" ")
// The expected auth payload should look like "AUTH AUTH_TOKEN"
// if we have less than those two args it means that we are in an invalid state
if (args.size != 2 || args[0] != "AUTH") return false
LogManager.info("[OTAUpdate] Authenticating...")
val authToken = args[1]
val signature = bytesToMd5(UUID.randomUUID().toString().toByteArray())
val hashedPassword = bytesToMd5(PASSWORD.toByteArray())
val resultText = "$hashedPassword:$authToken:$signature"
val payload = bytesToMd5(resultText.toByteArray())
val authMessage = "$AUTH $signature $payload\n"
socket.soTimeout = 10000
socket.send(
DatagramPacket(
authMessage.toByteArray(),
authMessage.length,
deviceIp,
PORT,
),
)
val authResponsePacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
socket.receive(authResponsePacket)
val authResponse = String(authResponsePacket.data, 0, authResponsePacket.length)
return authResponse == "OK"
}
} catch (e: Exception) {
LogManager.severe("OTA Authentication exception", e)
return false
}
}
private fun upload(serverSocket: ServerSocket): Boolean {
try {
LogManager.info("[OTAUpdate] Starting on: ${serverSocket.localPort}")
LogManager.info("[OTAUpdate] Waiting for device...")
val connection = serverSocket.accept()
connection.setSoTimeout(1000)
val dos = DataOutputStream(connection.getOutputStream())
val dis = DataInputStream(connection.getInputStream())
LogManager.info("[OTAUpdate] Upload size: ${firmware.size} bytes")
var offset = 0
val chunkSize = 2048
while (offset != firmware.size) {
statusCallback.accept(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.UPLOADING,
((offset.toDouble() / firmware.size) * 100).toInt(),
),
)
val chunkLen = min(chunkSize, (firmware.size - offset))
dos.write(firmware, offset, chunkLen)
dos.flush()
offset += chunkLen
// Those skipped bytes are the size written to the MCU. We do not really need that information,
// so we simply skip it.
// The reason those bytes are skipped here is to not have to skip all of them when checking
// for the OK response. Saving time
dis.skipNBytes(4)
}
LogManager.info("[OTAUpdate] Waiting for result...")
// We set the timeout of the connection bigger as it can take some time for the MCU
// to confirm that everything is ok
connection.setSoTimeout(10000)
val responseBytes = dis.readAllBytes()
val response = String(responseBytes)
return response.contains("OK")
} catch (e: Exception) {
LogManager.severe("Unable to upload the firmware using ota", e)
return false
}
}
fun run() {
ServerSocket(0).use { serverSocket ->
if (!authenticate(serverSocket.localPort)) {
statusCallback.accept(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED,
),
)
return
}
if (!upload(serverSocket)) {
statusCallback.accept(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.ERROR_UPLOAD_FAILED,
),
)
return
}
statusCallback.accept(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.REBOOTING,
),
)
}
}
companion object {
private const val FLASH = 0
private const val PORT = 8266
private const val PASSWORD = "SlimeVR-OTA"
private const val AUTH = 200
}
}

View File

@@ -0,0 +1,5 @@
package dev.slimevr.firmware
import dev.llelievr.espflashkotlin.FlasherSerialInterface
interface SerialFlashingHandler : FlasherSerialInterface

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,7 @@ data class TrackerFrames(val name: String = "", val frames: FastList<TrackerFram
// Make sure this is false!! Otherwise HumanSkeleton ignores it
isInternal = false,
isComputed = true,
trackRotDirection = false,
)
tracker.status = TrackerStatus.OK

View File

@@ -65,4 +65,5 @@ public class ProtocolAPI {
public void removeAPIServer(ProtocolAPIServer server) {
this.servers.remove(server);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,5 +2,5 @@ package dev.slimevr.serial;
public interface ProvisioningListener {
void onProvisioningStatusChange(ProvisioningStatus status);
void onProvisioningStatusChange(ProvisioningStatus status, SerialPort port);
}

View File

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

View File

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

View File

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

View File

@@ -785,11 +785,6 @@ class HumanSkeleton(
var hipRot = it.getRotation()
var chestRot = chest.getRotation()
// Get the rotation relative to where we expect the hip to be
if (chestRot.times(FORWARD_QUATERNION).dot(hipRot) < 0.0f) {
hipRot = hipRot.unaryMinus()
}
// Interpolate between the chest and the hip
chestRot = chestRot.interpQ(hipRot, waistFromChestHipAveraging)
@@ -802,15 +797,6 @@ class HumanSkeleton(
var rightLegRot = rightUpperLegTracker?.getRotation() ?: IDENTITY
var chestRot = chest.getRotation()
// Get the rotation relative to where we expect the upper legs to be
val expectedUpperLegsRot = chestRot.times(FORWARD_QUATERNION)
if (expectedUpperLegsRot.dot(leftLegRot) < 0.0f) {
leftLegRot = leftLegRot.unaryMinus()
}
if (expectedUpperLegsRot.dot(rightLegRot) < 0.0f) {
rightLegRot = rightLegRot.unaryMinus()
}
// Interpolate between the pelvis, averaged from the legs, and the chest
chestRot = chestRot.interpQ(leftLegRot.lerpQ(rightLegRot, 0.5f), waistFromChestLegsAveraging).unit()
@@ -827,15 +813,6 @@ class HumanSkeleton(
var rightLegRot = rightUpperLegTracker?.getRotation() ?: IDENTITY
var waistRot = it.getRotation()
// Get the rotation relative to where we expect the upper legs to be
val expectedUpperLegsRot = waistRot.times(FORWARD_QUATERNION)
if (expectedUpperLegsRot.dot(leftLegRot) < 0.0f) {
leftLegRot = leftLegRot.unaryMinus()
}
if (expectedUpperLegsRot.dot(rightLegRot) < 0.0f) {
rightLegRot = rightLegRot.unaryMinus()
}
// Interpolate between the pelvis, averaged from the legs, and the chest
waistRot = waistRot.interpQ(leftLegRot.lerpQ(rightLegRot, 0.5f), hipFromWaistLegsAveraging).unit()
@@ -849,15 +826,6 @@ class HumanSkeleton(
var rightLegRot = rightUpperLegTracker?.getRotation() ?: IDENTITY
var chestRot = it.getRotation()
// Get the rotation relative to where we expect the upper legs to be
val expectedUpperLegsRot = chestRot.times(FORWARD_QUATERNION)
if (expectedUpperLegsRot.dot(leftLegRot) < 0.0f) {
leftLegRot = leftLegRot.unaryMinus()
}
if (expectedUpperLegsRot.dot(rightLegRot) < 0.0f) {
rightLegRot = rightLegRot.unaryMinus()
}
// Interpolate between the pelvis, averaged from the legs, and the chest
chestRot = chestRot.interpQ(leftLegRot.lerpQ(rightLegRot, 0.5f), hipFromChestLegsAveraging).unit()
@@ -1110,24 +1078,11 @@ class HumanSkeleton(
rightKnee: Quaternion,
hip: Quaternion,
): Quaternion {
// Get the knees' rotation relative to where we expect them to be.
// The angle between your knees and hip can be over 180 degrees...
var leftKneeRot = leftKnee
var rightKneeRot = rightKnee
val kneeRot = hip.times(FORWARD_QUATERNION)
if (kneeRot.dot(leftKneeRot) < 0.0f) {
leftKneeRot = leftKneeRot.unaryMinus()
}
if (kneeRot.dot(rightKneeRot) < 0.0f) {
rightKneeRot = rightKneeRot.unaryMinus()
}
// R = InverseHip * (LeftLeft + RightLeg)
// C = Quaternion(R.w, -R.x, 0, 0)
// Pelvis = Hip * R * C
// normalize(Pelvis)
val r = hip.inv() * (leftKneeRot + rightKneeRot)
val r = hip.inv() * (leftKnee + rightKnee)
val c = Quaternion(r.w, -r.x, 0f, 0f)
return (hip * r * c).unit()
}

View File

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

View File

@@ -67,6 +67,12 @@ class Tracker @JvmOverloads constructor(
val needsReset: Boolean = false,
val needsMounting: Boolean = false,
val isHmd: Boolean = false,
/**
* Whether to track the direction of the tracker's rotation
* (positive vs negative rotation). This needs to be disabled for AutoBone and
* unit tests, where the rotation is absolute and not temporal.
*/
val trackRotDirection: Boolean = true,
magStatus: MagnetometerStatus = MagnetometerStatus.NOT_SUPPORTED,
/**
* Rotation by default.
@@ -117,6 +123,8 @@ class Tracker @JvmOverloads constructor(
}
checkReportErrorStatus()
checkReportRequireReset()
VRServer.instance.trackerStatusChanged(this, old, new)
}
}
@@ -308,7 +316,9 @@ class Tracker @JvmOverloads constructor(
fun dataTick() {
timer.update()
timeAtLastUpdate = System.currentTimeMillis()
filteringHandler.dataTick(_rotation)
if (trackRotDirection) {
filteringHandler.dataTick(_rotation)
}
}
/**
@@ -318,6 +328,13 @@ class Tracker @JvmOverloads constructor(
timeAtLastUpdate = System.currentTimeMillis()
}
private fun getFilteredRotation(): Quaternion = if (trackRotDirection) {
filteringHandler.getFilteredRotation()
} else {
// Get raw rotation
_rotation
}
/**
* Gets the adjusted tracker rotation after all corrections
* (filtering, reset, mounting and drift compensation).
@@ -326,13 +343,7 @@ class Tracker @JvmOverloads constructor(
* it too much should be avoided for performance reasons.
*/
fun getRotation(): Quaternion {
var rot = if (allowFiltering && filteringHandler.filteringEnabled) {
// Get filtered rotation
filteringHandler.getFilteredRotation()
} else {
// Get unfiltered rotation
filteringHandler.getTrackedRotation()
}
var rot = getFilteredRotation()
// Reset if needed and is not computed and internal
if (needsReset && !(isComputed && isInternal) && trackerDataType == TrackerDataType.ROTATION) {
@@ -358,13 +369,7 @@ class Tracker @JvmOverloads constructor(
* This is used for debugging/visualizing tracker data
*/
fun getIdentityAdjustedRotation(): Quaternion {
var rot = if (filteringHandler.filteringEnabled) {
// Get filtered rotation
filteringHandler.getFilteredRotation()
} else {
// Get unfiltered rotation
filteringHandler.getTrackedRotation()
}
var rot = getFilteredRotation()
// Reset if needed or is a computed tracker besides head
if (needsReset && !(isComputed && trackerPosition != TrackerPosition.HEAD) && trackerDataType == TrackerDataType.ROTATION) {
@@ -422,6 +427,6 @@ class Tracker @JvmOverloads constructor(
* Call when doing a full reset to reset the tracking of rotations >180 degrees
*/
fun resetFilteringQuats() {
filteringHandler.resetQuats(_rotation)
filteringHandler.resetMovingAverage(_rotation)
}
}

View File

@@ -11,9 +11,8 @@ import io.github.axisangles.ktmath.Quaternion
* See QuaternionMovingAverage.kt for the quaternion math.
*/
class TrackerFilteringHandler {
private var filteringMovingAverage: QuaternionMovingAverage? = null
private var trackingMovingAverage = QuaternionMovingAverage(TrackerFilters.NONE)
// Instantiated by default in case config doesn't get read (if tracker doesn't support filtering)
private var movingAverage = QuaternionMovingAverage(TrackerFilters.NONE)
var filteringEnabled = false
/**
@@ -22,14 +21,14 @@ class TrackerFilteringHandler {
fun readFilteringConfig(config: FiltersConfig, currentRawRotation: Quaternion) {
val type = TrackerFilters.getByConfigkey(config.type)
if (type == TrackerFilters.SMOOTHING || type == TrackerFilters.PREDICTION) {
filteringMovingAverage = QuaternionMovingAverage(
movingAverage = QuaternionMovingAverage(
type,
config.amount,
currentRawRotation,
)
filteringEnabled = true
} else {
filteringMovingAverage = null
movingAverage = QuaternionMovingAverage(TrackerFilters.NONE)
filteringEnabled = false
}
}
@@ -38,33 +37,25 @@ class TrackerFilteringHandler {
* Update the moving average to make it smooth
*/
fun update() {
trackingMovingAverage.update()
filteringMovingAverage?.update()
movingAverage.update()
}
/**
* Updates the latest rotation
*/
fun dataTick(currentRawRotation: Quaternion) {
trackingMovingAverage.addQuaternion(currentRawRotation)
filteringMovingAverage?.addQuaternion(currentRawRotation)
movingAverage.addQuaternion(currentRawRotation)
}
/**
* Call when doing a full reset to reset the tracking of rotations >180 degrees
*/
fun resetQuats(currentRawRotation: Quaternion) {
trackingMovingAverage.resetQuats(currentRawRotation)
filteringMovingAverage?.resetQuats(currentRawRotation)
fun resetMovingAverage(currentRawRotation: Quaternion) {
movingAverage.resetQuats(currentRawRotation)
}
/**
* Gets the tracked rotation from the moving average (allows >180 degrees)
* Get the filtered rotation from the moving average (either prediction/smoothing or just >180 degs)
*/
fun getTrackedRotation() = trackingMovingAverage.filteredQuaternion
/**
* Get the filtered rotation from the moving average
*/
fun getFilteredRotation() = filteringMovingAverage?.filteredQuaternion ?: Quaternion.IDENTITY
fun getFilteredRotation() = movingAverage.filteredQuaternion
}

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