Merge branch 'main' into sentry-gui

This commit is contained in:
Uriel
2024-12-21 00:20:23 +01:00
91 changed files with 9262 additions and 873 deletions

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

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

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,15 +2,19 @@
"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",
"@sentry/react": "^8.44.0",
"@sentry/vite-plugin": "^2.22.7",
"@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",
@@ -28,15 +32,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",
@@ -48,10 +55,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",
@@ -66,6 +77,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",
@@ -73,12 +85,14 @@
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^4.6.2",
"globals": "^15.10.0",
"prettier": "^3.3.3",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.79.4",
"spdx-satisfies": "^5.0.1",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwindcss": "^3.4.13",
"vite": "^5.4.8"
"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
@@ -326,6 +344,7 @@ settings-sidebar-serial = Serial console
settings-sidebar-appearance = Appearance
settings-sidebar-notifications = Notifications
settings-sidebar-behavior = Behavior
settings-sidebar-firmware-tool = DIY Firmware Tool
settings-sidebar-advanced = Advanced
## SteamVR settings
@@ -709,6 +728,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
@@ -758,6 +778,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
@@ -1050,6 +1071,167 @@ 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 = Press the flash button on the pcb before inserting turning on the tracker.
If the tracker was already on, simply turn it off and back on while pressing the button or shorting the flash pads.
Here are a few pictures on how to do it according to the different revisions of the SlimeVR tracker
firmware_tool-flashbtn_step-board_SLIMEVR-r11 = Turn on the tracker while shorting the second rectangular FLASH pad from the edge on the top side of the board, and the metal shield of the microcontroller
firmware_tool-flashbtn_step-board_SLIMEVR-r12 = Turn on the tracker while shorting the circular FLASH pad on the top side of the board, and the metal shield of the microcontroller
firmware_tool-flashbtn_step-board_SLIMEVR-r14 = Turn on the tracker while pushing in the FLASH button on the top side of the board
firmware_tool-flashbtn_step-board_OTHER = Before flashing you will probably need to put the tracker into bootloader mode.
Most of the time it means pressing the boot button on the board before the flashing process starts.
If the flashing process timeout at the begining of the flashing it probably means that the tracker was not in bootloader mode
Please refer to the flashing instructions of your board to know how to turn on the boatloader mode
firmware_tool-flash_method_ota-devices = Detected OTA Devices:
firmware_tool-flash_method_ota-no_devices = There are no boards that can be updated using OTA, make sure you selected the correct board type
firmware_tool-flash_method_serial-wifi = Wi-Fi Credentials:
firmware_tool-flash_method_serial-devices-label = Detected Serial Devices:
firmware_tool-flash_method_serial-devices-placeholder = Select a serial device
firmware_tool-flash_method_serial-no_devices = There are no compatible serial devices detected, make sure the tracker is plugged in
firmware_tool-build_step = Building
firmware_tool-build_step-description =
The firmware is building, please wait
firmware_tool-flashing_step = Flashing
firmware_tool-flashing_step-description =
Your trackers are flashing, please follow the instructions on the screen
firmware_tool-flashing_step-warning = Do not unplug or restart the tracker during the upload process unless told to, it may make your board unusable
firmware_tool-flashing_step-flash_more = Flash more trackers
firmware_tool-flashing_step-exit = Exit
## firmware tool build status
firmware_tool-build-CREATING_BUILD_FOLDER = Creating the build folder
firmware_tool-build-DOWNLOADING_FIRMWARE = Downloading the firmware
firmware_tool-build-EXTRACTING_FIRMWARE = Extracting the firmware
firmware_tool-build-SETTING_UP_DEFINES = Configuring the defines
firmware_tool-build-BUILDING = Building the firmware
firmware_tool-build-SAVING = Saving the build
firmware_tool-build-DONE = Build Complete
firmware_tool-build-ERROR = Unable to build the firmware
## Firmware update status
firmware_update-status-DOWNLOADING = Downloading the firmware
firmware_update-status-NEED_MANUAL_REBOOT = Waiting for the user to reboot the tracker
firmware_update-status-AUTHENTICATING = Authenticating with the mcu
firmware_update-status-UPLOADING = Uploading the firmware
firmware_update-status-SYNCING_WITH_MCU = Syncing with the mcu
firmware_update-status-REBOOTING = Rebooting the tracker
firmware_update-status-PROVISIONING = Setting Wi-Fi credentials
firmware_update-status-DONE = Update complete!
firmware_update-status-ERROR_DEVICE_NOT_FOUND = Could not find the device
firmware_update-status-ERROR_TIMEOUT = The update process timed out
firmware_update-status-ERROR_DOWNLOAD_FAILED = Could not download the firmware
firmware_update-status-ERROR_AUTHENTICATION_FAILED = Could not authenticate with the mcu
firmware_update-status-ERROR_UPLOAD_FAILED = Could not upload the firmware
firmware_update-status-ERROR_PROVISIONING_FAILED = Could not set the Wi-Fi credentials
firmware_update-status-ERROR_UNSUPPORTED_METHOD = The update method is not supported
firmware_update-status-ERROR_UNKNOWN = Unknown error
## 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,12 +51,15 @@ 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 { withSentryReactRouterV6Routing } from '@sentry/react';
import { EmptyLayout } from './components/EmptyLayout';
import { AdvancedSettings } from './components/settings/pages/AdvancedSettings';
import { FirmwareUpdate } from './components/firmware-update/FirmwareUpdate';
export const GH_REPO = 'SlimeVR/SlimeVR-Server';
export const VersionContext = createContext('');
@@ -84,6 +87,14 @@ function Layout() {
</MainLayout>
}
/>
<Route
path="/firmware-update"
element={
<MainLayout isMobile={isMobile} widgets={false}>
<FirmwareUpdate />
</MainLayout>
}
/>
<Route
path="/vr-mode"
element={
@@ -108,6 +119,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 />} />
@@ -275,19 +287,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

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

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

@@ -103,6 +103,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,
@@ -120,6 +124,7 @@ export function TrackerCard({
bg = 'bg-background-60',
shakeHighlight = true,
warning = false,
showUpdates = false,
}: {
tracker: TrackerDataT;
device?: DeviceDataT;
@@ -130,33 +135,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

@@ -84,7 +84,7 @@ body {
}
:root {
overflow: hidden;
// overflow: hidden; -- NEVER EVER BRING THIS BACK <3
background: theme('colors.background.20');
--navbar-w: 101px;
@@ -382,6 +382,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

@@ -22,7 +22,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',
});

3679
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

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

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

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

@@ -117,6 +117,8 @@ class Tracker @JvmOverloads constructor(
}
checkReportErrorStatus()
checkReportRequireReset()
VRServer.instance.trackerStatusChanged(this, old, new)
}
}

View File

@@ -0,0 +1,6 @@
package dev.slimevr.tracking.trackers
interface TrackerStatusListener {
fun onTrackerStatusChanged(tracker: Tracker, oldStatus: TrackerStatus, newStatus: TrackerStatus)
}

View File

@@ -42,7 +42,7 @@ enum class BoardType(val id: UInt) {
ESP01(8u),
SLIMEVR(9u),
LOLIN_C3_MINI(10u),
BEETLE32C32(11u),
BEETLE32C3(11u),
ES32C3DEVKITM1(12u),
OWOTRACK(13u),
WRANGLER(14u),
@@ -53,6 +53,8 @@ enum class BoardType(val id: UInt) {
DEV_RESERVED(250u),
;
fun getSolarType(): Int = this.id.toInt()
override fun toString(): String = when (this) {
UNKNOWN -> "Unknown"
SLIMEVR_LEGACY -> "SlimeVR Legacy"
@@ -65,7 +67,7 @@ enum class BoardType(val id: UInt) {
ESP01 -> "ESP-01"
SLIMEVR -> "SlimeVR"
LOLIN_C3_MINI -> "Lolin C3 Mini"
BEETLE32C32 -> "Beetle ESP32-C3"
BEETLE32C3 -> "Beetle ESP32-C3"
ES32C3DEVKITM1 -> "Espressif ESP32-C3 DevKitM-1"
OWOTRACK -> "owoTrack"
WRANGLER -> "Wrangler Joycons"

View File

@@ -50,6 +50,7 @@ allprojects {
// You can declare any Maven/Ivy/file repository here.
mavenCentral()
maven(url = "https://jitpack.io")
maven(url = "https://oss.sonatype.org/content/repositories/snapshots")
}
}
@@ -62,7 +63,7 @@ dependencies {
implementation("com.google.protobuf:protobuf-java:3.21.12")
implementation("net.java.dev.jna:jna:5.+")
implementation("net.java.dev.jna:jna-platform:5.+")
implementation("com.fazecast:jSerialComm:2.11.0")
implementation("com.fazecast:jSerialComm:2.11.1-SNAPSHOT")
implementation("org.hid4java:hid4java:0.8.0")
}

View File

@@ -6,6 +6,7 @@ import dev.slimevr.Keybinding
import dev.slimevr.SLIMEVR_IDENTIFIER
import dev.slimevr.VRServer
import dev.slimevr.bridge.ISteamVRBridge
import dev.slimevr.desktop.firmware.DesktopSerialFlashingHandler
import dev.slimevr.desktop.platform.SteamVRBridge
import dev.slimevr.desktop.platform.linux.UnixSocketBridge
import dev.slimevr.desktop.platform.windows.WindowsNamedPipeBridge
@@ -121,6 +122,7 @@ fun main(args: Array<String>) {
::provideSteamVRBridge,
::provideFeederBridge,
{ _ -> DesktopSerialHandler() },
{ _ -> DesktopSerialFlashingHandler() },
configPath = configDir,
)
vrServer.start()

View File

@@ -0,0 +1,88 @@
package dev.slimevr.desktop.firmware
import com.fazecast.jSerialComm.SerialPort
import dev.slimevr.firmware.SerialFlashingHandler
import io.eiren.util.logging.LogManager
import dev.slimevr.serial.SerialPort as SerialPortWrapper
class DesktopSerialFlashingHandler : SerialFlashingHandler {
private var port: SerialPort? = null
override fun openSerial(port: Any) {
if (port !is SerialPortWrapper) {
error("Not a serial port")
}
val ports = SerialPort.getCommPorts()
val comPort = ports.find { it.portLocation == port.portLocation }
?: error("Unable to find port ${port.portLocation}")
if (comPort.isOpen) {
comPort.closePort()
}
if (!comPort.openPort(1000)) {
error("unable to open port")
}
this.port = comPort
}
override fun closeSerial() {
val p = port ?: error("no port to close")
try {
p.closePort()
LogManager.info("Port closed")
} catch (e: Exception) {
error("unable to close port")
}
}
override fun setDTR(value: Boolean) {
val p = port ?: error("no port to set DTR")
if (value) {
p.setDTR()
} else {
p.clearDTR()
}
}
override fun setRTS(value: Boolean) {
val p = port ?: error("no port to set RTS")
if (value) {
p.setRTS()
} else {
p.clearRTS()
}
}
override fun write(data: ByteArray) {
val p = port ?: error("no port to write")
p.writeBytes(data, data.size)
}
override fun read(length: Int): ByteArray {
val p = port ?: error("no port to read")
val data = ByteArray(length)
p.readBytes(data, length)
return data
}
override fun changeBaud(baud: Int) {
val p = port ?: error("no port to set the baud")
if (!p.setBaudRate(baud)) {
error("Unable to change baudrate")
}
}
override fun setReadTimeout(timeout: Long) {
val p = port ?: error("no port to set the timeout")
p.setComPortTimeouts(SerialPort.TIMEOUT_READ_BLOCKING, timeout.toInt(), 0)
}
override fun availableBytes(): Int {
val p = port ?: error("no port to check available bytes")
return p.bytesAvailable()
}
override fun flushIOBuffers() {
val p = port ?: error("no port to flush")
p.flushIOBuffers()
}
}

View File

@@ -71,10 +71,14 @@ class DesktopSerialHandler :
getDevicesTimer.purge()
}
fun onNewDevice(port: SerialPort) {
private fun onNewDevice(port: SerialPort) {
listeners.forEach { it.onNewSerialDevice(SerialPortWrapper(port)) }
}
private fun onDeviceDel(port: SerialPort) {
listeners.forEach { it.onSerialDeviceDeleted(SerialPortWrapper(port)) }
}
override fun addListener(channel: SerialListener) {
listeners.add(channel)
}
@@ -181,6 +185,11 @@ class DesktopSerialHandler :
}
}
override fun write(buff: ByteArray) {
LogManager.info("[SerialHandler] WRITING $buff")
currentPort?.outputStream?.write(buff)
}
@Synchronized
override fun setWifi(ssid: String, passwd: String) {
val os = currentPort?.outputStream ?: return
@@ -236,13 +245,20 @@ class DesktopSerialHandler :
private fun detectNewPorts() {
try {
val differences = knownPorts.asSequence() - lastKnownPorts
val addDifferences = knownPorts.asSequence() - lastKnownPorts
val delDifferences = lastKnownPorts - knownPorts.asSequence().toSet()
lastKnownPorts = SerialPort.getCommPorts().map { SerialPortWrapper(it) }.toSet()
differences.forEach { onNewDevice(it.port) }
addDifferences.forEach { onNewDevice(it.port) }
delDifferences.forEach { onDeviceDel(it.port) }
} catch (e: Throwable) {
LogManager
.severe("[SerialHandler] Using serial ports is not supported on this platform", e)
throw RuntimeException("Serial unsupported")
}
}
override fun getCurrentPort(): dev.slimevr.serial.SerialPort? {
val port = this.currentPort ?: return null
return SerialPortWrapper(port)
}
}