Firmware tool (#880)

Co-authored-by: ImUrX <urielfontan2002@gmail.com>
Co-authored-by: Uriel <imurx@proton.me>
This commit is contained in:
lucas lelievre
2024-12-19 18:35:54 +01:00
committed by GitHub
parent e8afb49685
commit 73cdc890f2
79 changed files with 7491 additions and 694 deletions

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,13 +2,16 @@
"name": "slimevr-ui",
"version": "0.5.1",
"private": true,
"type": "module",
"dependencies": {
"@fluent/bundle": "^0.18.0",
"@fluent/react": "^0.15.2",
"@fontsource/poppins": "^5.1.0",
"@formatjs/intl-localematcher": "^0.2.32",
"@hookform/resolvers": "^3.6.0",
"@react-three/drei": "^9.114.3",
"@react-three/fiber": "^8.17.10",
"@tanstack/react-query": "^5.48.0",
"@tauri-apps/api": "^2.0.2",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
@@ -34,7 +37,8 @@
"three": "^0.163.0",
"ts-pattern": "^5.4.0",
"typescript": "^5.6.3",
"use-double-tap": "^1.3.6"
"use-double-tap": "^1.3.6",
"yup": "^1.4.0"
},
"scripts": {
"start": "vite --force",
@@ -46,10 +50,14 @@
"lint:fix": "tsc --noEmit && eslint --fix --max-warnings=0 \"src/**/*.{js,jsx,ts,tsx,json}\" && pnpm run format",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,md,json}\"",
"preview-vite": "vite preview",
"javaversion-build": "cd src-tauri/src/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class"
"javaversion-build": "cd src-tauri/src/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class",
"gen:javaversion": "cd src-tauri/src/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class",
"gen:firmware-tool": "openapi-codegen gen firmwareTool"
},
"devDependencies": {
"@dword-design/eslint-plugin-import-alias": "^4.0.9",
"@openapi-codegen/cli": "^2.0.2",
"@openapi-codegen/typescript": "^8.0.2",
"@tailwindcss/forms": "^0.5.9",
"@tauri-apps/cli": "^2.0.2",
"@types/file-saver": "^2.0.7",
@@ -64,6 +72,7 @@
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-import-resolver-typescript": "^3.6.3",
@@ -77,6 +86,8 @@
"spdx-satisfies": "^5.0.1",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwindcss": "^3.4.13",
"vite": "^5.4.8"
"vite": "^5.4.8",
"globals": "^15.10.0",
"typescript-eslint": "^8.8.0"
}
}

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
@@ -325,6 +338,7 @@ settings-sidebar-utils = Utilities
settings-sidebar-serial = Serial console
settings-sidebar-appearance = Appearance
settings-sidebar-notifications = Notifications
settings-sidebar-firmware-tool = DIY Firmware Tool
settings-sidebar-advanced = Advanced
## SteamVR settings
@@ -699,6 +713,7 @@ onboarding-wifi_creds-submit = Submit!
onboarding-wifi_creds-ssid =
.label = Wi-Fi name
.placeholder = Enter Wi-Fi name
onboarding-wifi_creds-ssid-required = Wi-Fi name is required
onboarding-wifi_creds-password =
.label = Password
.placeholder = Enter password
@@ -748,6 +763,7 @@ onboarding-connect_tracker-issue-serial = I'm having trouble connecting!
onboarding-connect_tracker-usb = USB Tracker
onboarding-connect_tracker-connection_status-none = Looking for trackers
onboarding-connect_tracker-connection_status-serial_init = Connecting to serial device
onboarding-connect_tracker-connection_status-obtaining_mac_address = Obtaining the tracker mac address
onboarding-connect_tracker-connection_status-provisioning = Sending Wi-Fi credentials
onboarding-connect_tracker-connection_status-connecting = Trying to connect to Wi-Fi
onboarding-connect_tracker-connection_status-looking_for_server = Looking for server
@@ -1040,6 +1056,157 @@ status_system-StatusSteamVRDisconnected = { $type ->
status_system-StatusTrackerError = The { $trackerName } tracker has an error.
status_system-StatusUnassignedHMD = The VR headset should be assigned as a head tracker.
## Firmware tool globals
firmware-tool_next-step = Next Step
firmware-tool_previous-step = Previous Step
firmware-tool_ok = Looks good
firmware-tool_retry = Retry
firmware-tool_loading = Loading...
## Firmware tool Steps
firmware-tool = DIY Firmware tool
firmware-tool_description =
Allows you to configure and flash your DIY trackers
firmware-tool_not-available = Oops, the firmware tool is not available at the moment. Come back later!
firmware-tool_not-compatible = The firmware tool is not compatible with this version of the server. Please update your server!
firmware-tool_board-step = Select your Board
firmware-tool_board-step_description = Select one of the boards listed below.
firmware-tool_board-pins-step = Check the pins
firmware-tool_board-pins-step_description =
Please verify that the selected pins are correct.
If you followed the SlimeVR documentation the defaults values should be correct
firmware-tool_board-pins-step_enable-led = Enable LED
firmware-tool_board-pins-step_led-pin =
.label = LED Pin
.placeholder = Enter the pin address of the LED
firmware-tool_board-pins-step_battery-type = Select a battery type
firmware-tool_board-pins-step_battery-type_BAT_EXTERNAL = External battery
firmware-tool_board-pins-step_battery-type_BAT_INTERNAL = Internal battery
firmware-tool_board-pins-step_battery-type_BAT_INTERNAL_MCP3021 = Internal MCP3021
firmware-tool_board-pins-step_battery-type_BAT_MCP3021 = MCP3021
firmware-tool_board-pins-step_battery-sensor-pin =
.label = Battery sensor Pin
.placeholder = Enter the pin address of battery sensor
firmware-tool_board-pins-step_battery-resistor =
.label = Battery Resistor (Ohms)
.placeholder = Enter the value of battery resistor
firmware-tool_board-pins-step_battery-shield-resistor_0 =
.label = Battery Shield R1 (Ohms)
.placeholder = Enter the value of Battery Shield R1
firmware-tool_board-pins-step_battery-shield-resistor_1 =
.label = Battery Shield R2 (Ohms)
.placeholder = Enter the value of Battery Shield R2
firmware-tool_add-imus-step = Declare your IMUs
firmware-tool_add-imus-step_description =
Please add the IMUs that your tracker has
If you followed the SlimeVR documentation the defaults values should be correct
firmware-tool_add-imus-step_imu-type_label = IMU type
firmware-tool_add-imus-step_imu-type_placeholder = Select the type of IMU
firmware-tool_add-imus-step_imu-rotation =
.label = IMU Rotation (deg)
.placeholder = Rotation angle of the IMU
firmware-tool_add-imus-step_scl-pin =
.label = SCL Pin
.placeholder = Pin address of SCL
firmware-tool_add-imus-step_sda-pin =
.label = SDA Pin
.placeholder = Pin address of SDA
firmware-tool_add-imus-step_int-pin =
.label = INT Pin
.placeholder = Pin address of INT
firmware-tool_add-imus-step_optional-tracker =
.label = Optional tracker
firmware-tool_add-imus-step_show-less = Show Less
firmware-tool_add-imus-step_show-more = Show More
firmware-tool_add-imus-step_add-more = Add more IMUs
firmware-tool_select-firmware-step = Select the firmware version
firmware-tool_select-firmware-step_description =
Please choose what version of the firmware you want to use
firmware-tool_select-firmware-step_show-third-party =
.label = Show third party firmwares
firmware-tool_flash-method-step = Flashing Method
firmware-tool_flash-method-step_description =
Please select the flashing method you want to use
firmware-tool_flash-method-step_ota =
.label = OTA
.description = Use the over the air method. Your tracker will use the Wi-Fi to update it's firmware. Works only on already setup trackers.
firmware-tool_flash-method-step_serial =
.label = Serial
.description = Use a USB cable to update your tracker.
firmware-tool_flashbtn-step = Press the boot btn
firmware-tool_flashbtn-step_description = Before going into the next step there is a few things you need to do
firmware-tool_flashbtn-step_board_SLIMEVR = Press the flash button on the pcb before inserting turning on the tracker.
If the tracker was already on, simply turn it off and back on while pressing the button or shorting the flash pads.
Here are a few pictures on how to do it according to the different revisions of the SlimeVR tracker
firmware-tool_flashbtn-step_board_SLIMEVR-r11 = Turn on the tracker while shorting the second rectangular FLASH pad from the edge on the top side of the board, and the metal shield of the microcontroller
firmware-tool_flashbtn-step_board_SLIMEVR-r12 = Turn on the tracker while shorting the circular FLASH pad on the top side of the board, and the metal shield of the microcontroller
firmware-tool_flashbtn-step_board_SLIMEVR-r14 = Turn on the tracker while pushing in the FLASH button on the top side of the board
firmware-tool_flashbtn-step_board_OTHER = Before flashing you will probably need to put the tracker into bootloader mode.
Most of the time it means pressing the boot button on the board before the flashing process starts.
If the flashing process timeout at the begining of the flashing it probably means that the tracker was not in bootloader mode
Please refer to the flashing instructions of your board to know how to turn on the boatloader mode
firmware-tool_flash-method-ota_devices = Detected OTA Devices:
firmware-tool_flash-method-ota_no-devices = There are no boards that can be updated using OTA, make sure you selected the correct board type
firmware-tool_flash-method-serial_wifi = Wi-Fi Credentials:
firmware-tool_flash-method-serial_devices-label = Detected Serial Devices:
firmware-tool_flash-method-serial_devices-placeholder = Select a serial device
firmware-tool_flash-method-serial_no-devices = There are no compatible serial devices detected, make sure the tracker is plugged in
firmware-tool_build-step = Building
firmware-tool_build-step_description =
The firmware is building, please wait
firmware-tool_flashing-step = Flashing
firmware-tool_flashing-step_description =
Your trackers are flashing, please follow the instructions on the screen
firmware-tool_flashing-step_warning = Do not unplug or restart the tracker during the upload process unless told to, it may make your board unusable
firmware-tool_flashing-step_flash-more = Flash more trackers
firmware-tool_flashing-step_exit = Exit
## firmware tool build status
firmware-tool_build_CREATING_BUILD_FOLDER = Creating the build folder
firmware-tool_build_DOWNLOADING_FIRMWARE = Downloading the firmware
firmware-tool_build_EXTRACTING_FIRMWARE = Extracting the firmware
firmware-tool_build_SETTING_UP_DEFINES = Configuring the defines
firmware-tool_build_BUILDING = Building the firmware
firmware-tool_build_SAVING = Saving the build
firmware-tool_build_DONE = Build Complete
firmware-tool_build_ERROR = Unable to build the firmware
## Firmware update status
firmware-update_status_DOWNLOADING = Downloading the firmware
firmware-update_status_NEED_MANUAL_REBOOT = Waiting for the user to reboot the tracker
firmware-update_status_AUTHENTICATING = Authenticating with the mcu
firmware-update_status_UPLOADING = Uploading the firmware
firmware-update_status_SYNCING_WITH_MCU = Syncing with the mcu
firmware-update_status_REBOOTING = Rebooting the tracker
firmware-update_status_PROVISIONING = Setting Wi-Fi credentials
firmware-update_status_DONE = Update complete!
firmware-update_status_ERROR_DEVICE_NOT_FOUND = Could not find the device
firmware-update_status_ERROR_TIMEOUT = The update process timed out
firmware-update_status_ERROR_DOWNLOAD_FAILED = Could not download the firmware
firmware-update_status_ERROR_AUTHENTICATION_FAILED = Could not authenticate with the mcu
firmware-update_status_ERROR_UPLOAD_FAILED = Could not upload the firmware
firmware-update_status_ERROR_PROVISIONING_FAILED = Could not set the Wi-Fi credentials
firmware-update_status_ERROR_UNSUPPORTED_METHOD = The update method is not supported
firmware-update_status_ERROR_UNKNOWN = Unknown error
## Tray Menu
tray_menu-show = Show
tray_menu-hide = Hide

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

@@ -51,10 +51,12 @@ import { useBreakpoint, useIsTauri } from './hooks/breakpoint';
import { VRModePage } from './components/vr-mode/VRModePage';
import { InterfaceSettings } from './components/settings/pages/InterfaceSettings';
import { error, log } from './utils/logging';
import { FirmwareToolSettings } from './components/firmware-tool/FirmwareTool';
import { AppLayout } from './AppLayout';
import { Preload } from './components/Preload';
import { UnknownDeviceModal } from './components/UnknownDeviceModal';
import { useDiscordPresence } from './hooks/discord-presence';
import { EmptyLayout } from './components/EmptyLayout';
import { AdvancedSettings } from './components/settings/pages/AdvancedSettings';
export const GH_REPO = 'SlimeVR/SlimeVR-Server';
@@ -105,6 +107,7 @@ function Layout() {
</SettingsLayout>
}
>
<Route path="firmware-tool" element={<FirmwareToolSettings />} />
<Route path="trackers" element={<GeneralSettings />} />
<Route path="serial" element={<Serial />} />
<Route path="osc/router" element={<OSCRouterSettings />} />
@@ -272,19 +275,16 @@ export default function App() {
<VersionContext.Provider value={updateFound}>
<div className="h-full w-full text-standard bg-background-80 text-background-10">
<Preload />
<div className="flex-col h-full">
{!websocketAPI.isConnected && (
<>
<TopBar></TopBar>
<div className="flex w-full h-full justify-center items-center p-2">
{websocketAPI.isFirstConnection
? l10n.getString('websocket-connecting')
: l10n.getString('websocket-connection_lost')}
</div>
</>
)}
{websocketAPI.isConnected && <Layout></Layout>}
</div>
{!websocketAPI.isConnected && (
<EmptyLayout>
<div className="flex w-full h-full justify-center items-center p-2">
{websocketAPI.isFirstConnection
? l10n.getString('websocket-connecting')
: l10n.getString('websocket-connection_lost')}
</div>
</EmptyLayout>
)}
{websocketAPI.isConnected && <Layout></Layout>}
</div>
</VersionContext.Provider>
</StatusProvider>

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

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

View File

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

View File

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

View File

@@ -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,107 @@
import { Control, Controller } from 'react-hook-form';
import { Typography } from '@/components/commons/Typography';
import { ProgressBar } from '@/components/commons/ProgressBar';
import { CHECKBOX_CLASSES } from '@/components/commons/Checkbox';
import classNames from 'classnames';
import { FirmwareUpdateStatus } from 'solarxr-protocol';
import { useLocalization } from '@fluent/react';
import { firmwareUpdateErrorStatus } from '@/hooks/firmware-tool';
interface DeviceCardProps {
deviceNames: string[];
status?: FirmwareUpdateStatus;
}
interface DeviceCardControlProps {
control?: Control<any>;
name?: string;
progress?: number;
}
export function DeviceCardContent({ deviceNames, status }: DeviceCardProps) {
const { l10n } = useLocalization();
return (
<div className="p-2 flex h-full gap-2 justify-between flex-col">
<div className="flex flex-row flex-wrap gap-2 items-center h-full">
{deviceNames.map((name) => (
<span
key={name}
className="p-1 px-3 rounded-l-full rounded-r-full bg-background-40"
>
<Typography>{name}</Typography>
</span>
))}
</div>
{status && (
<Typography color="secondary">
{l10n.getString(
'firmware-update_status_' + FirmwareUpdateStatus[status]
)}
</Typography>
)}
</div>
);
}
export function DeviceCardControl({
control,
name,
progress,
...props
}: DeviceCardControlProps & DeviceCardProps) {
return (
<div
className={classNames(
'rounded-md bg-background-60 pt-2 flex flex-col justify-between border-2',
props.status && firmwareUpdateErrorStatus.includes(props.status)
? 'border-status-critical'
: 'border-transparent'
)}
>
{control && name ? (
<Controller
control={control}
name={name}
render={({ field: { onChange, value, ref } }) => (
<label className="flex flex-row gap-2 px-4 h-full">
<div className="flex justify-center flex-col">
<input
ref={ref}
onChange={onChange}
className={CHECKBOX_CLASSES}
checked={value || false}
type="checkbox"
></input>
</div>
<div className="w-full">
<DeviceCardContent {...props}></DeviceCardContent>
</div>
</label>
)}
></Controller>
) : (
<div className="px-2 h-full">
<DeviceCardContent {...props}></DeviceCardContent>
</div>
)}
<div
className={classNames(
'align-bottom',
props.status != FirmwareUpdateStatus.UPLOADING ||
progress === undefined
? 'opacity-0'
: 'opacity-100'
)}
>
<ProgressBar
progress={progress || 0}
bottom
height={6}
colorClass="bg-accent-background-20"
></ProgressBar>
</div>
</div>
);
}

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,271 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import {
SelectedDevice,
firmwareUpdateErrorStatus,
useFirmwareTool,
} from '@/hooks/firmware-tool';
import { useEffect, useMemo, useState } from 'react';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import {
DeviceIdT,
DeviceIdTableT,
FirmwarePartT,
FirmwareUpdateMethod,
FirmwareUpdateRequestT,
FirmwareUpdateStatus,
FirmwareUpdateStatusResponseT,
FirmwareUpdateStopQueuesRequestT,
OTAFirmwareUpdateT,
RpcMessage,
SerialDevicePortT,
SerialFirmwareUpdateT,
} from 'solarxr-protocol';
import { firmwareToolS3BaseUrl } from '@/firmware-tool-api/firmwareToolFetcher';
import { useOnboarding } from '@/hooks/onboarding';
import { DeviceCardControl } from './DeviceCard';
import { WarningBox } from '@/components/commons/TipBox';
import { Button } from '@/components/commons/Button';
import { useNavigate } from 'react-router-dom';
export function FlashingStep({
goTo,
isActive,
}: {
nextStep: () => void;
prevStep: () => void;
goTo: (id: string) => void;
isActive: boolean;
}) {
const nav = useNavigate();
const { l10n } = useLocalization();
const { selectedDevices, buildStatus, selectDevices, defaultConfig } =
useFirmwareTool();
const { state: onboardingState } = useOnboarding();
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const [status, setStatus] = useState<{
[key: string]: {
status: FirmwareUpdateStatus;
type: FirmwareUpdateMethod;
progress: number;
deviceNames: string[];
};
}>({});
const clear = () => {
setStatus({});
sendRPCPacket(
RpcMessage.FirmwareUpdateStopQueuesRequest,
new FirmwareUpdateStopQueuesRequestT()
);
};
const queueFlashing = (devices: SelectedDevice[]) => {
clear();
if (!buildStatus.firmwareFiles)
throw new Error('invalid state - no firmware files');
const firmware = buildStatus.firmwareFiles.find(
({ isFirmware }) => isFirmware
);
if (!firmware) throw new Error('invalid state - no firmware to find');
for (const device of devices) {
switch (device.type) {
case FirmwareUpdateMethod.OTAFirmwareUpdate: {
const dId = new DeviceIdT();
dId.id = +device.deviceId;
const part = new FirmwarePartT();
part.offset = 0;
part.url = firmwareToolS3BaseUrl + '/' + firmware.url;
const method = new OTAFirmwareUpdateT();
method.deviceId = dId;
method.firmwarePart = part;
const req = new FirmwareUpdateRequestT();
req.method = method;
req.methodType = FirmwareUpdateMethod.OTAFirmwareUpdate;
sendRPCPacket(RpcMessage.FirmwareUpdateRequest, req);
break;
}
case FirmwareUpdateMethod.SerialFirmwareUpdate: {
const id = new SerialDevicePortT();
id.port = device.deviceId.toString();
if (!onboardingState.wifi?.ssid || !onboardingState.wifi?.password)
throw new Error('invalid state, wifi should be set');
const method = new SerialFirmwareUpdateT();
method.deviceId = id;
method.ssid = onboardingState.wifi.ssid;
method.password = onboardingState.wifi.password;
method.needManualReboot = defaultConfig?.needManualReboot ?? false;
method.firmwarePart = buildStatus.firmwareFiles.map(
({ offset, url }) => {
const part = new FirmwarePartT();
part.offset = offset;
part.url = firmwareToolS3BaseUrl + '/' + url;
return part;
}
);
const req = new FirmwareUpdateRequestT();
req.method = method;
req.methodType = FirmwareUpdateMethod.SerialFirmwareUpdate;
sendRPCPacket(RpcMessage.FirmwareUpdateRequest, req);
break;
}
default: {
throw new Error('unsupported flashing method');
}
}
}
};
useEffect(() => {
if (!isActive) return;
if (!selectedDevices)
throw new Error('invalid state - no selected devices');
queueFlashing(selectedDevices);
return () => clear();
}, [isActive]);
useRPCPacket(
RpcMessage.FirmwareUpdateStatusResponse,
(data: FirmwareUpdateStatusResponseT) => {
if (!data.deviceId) throw new Error('no device id');
const id =
data.deviceId instanceof DeviceIdTableT
? data.deviceId.id?.id
: data.deviceId.port;
if (!id) throw new Error('invalid device id');
const selectedDevice = selectedDevices?.find(
({ deviceId }) => deviceId == id.toString()
);
// We skip the status as it can be old trackers still sending status
if (!selectedDevice) return;
setStatus((last) => ({
...last,
[id.toString()]: {
progress: data.progress / 100,
status: data.status,
type: selectedDevice.type,
deviceNames: selectedDevice.deviceNames,
},
}));
}
);
const trackerWithErrors = useMemo(
() =>
Object.keys(status).filter((id) =>
firmwareUpdateErrorStatus.includes(status[id].status)
),
[status, firmwareUpdateErrorStatus]
);
const retryError = () => {
const devices = trackerWithErrors.map((id) => {
const device = status[id];
return {
type: device.type,
deviceId: id,
deviceNames: device.deviceNames,
};
});
selectDevices(devices);
queueFlashing(devices);
};
const hasPendingTrackers = useMemo(
() =>
Object.keys(status).filter((id) =>
[
FirmwareUpdateStatus.DOWNLOADING,
FirmwareUpdateStatus.AUTHENTICATING,
FirmwareUpdateStatus.REBOOTING,
FirmwareUpdateStatus.SYNCING_WITH_MCU,
FirmwareUpdateStatus.UPLOADING,
FirmwareUpdateStatus.PROVISIONING,
].includes(status[id].status)
).length > 0,
[status]
);
const shouldShowRebootWarning = useMemo(
() =>
Object.keys(status).find((id) =>
[
FirmwareUpdateStatus.REBOOTING,
FirmwareUpdateStatus.UPLOADING,
].includes(status[id].status)
),
[status]
);
return (
<>
<div className="flex flex-col w-full">
<div className="flex flex-grow flex-col gap-4">
<Typography color="secondary">
{l10n.getString('firmware-tool_flashing-step_description')}
</Typography>
</div>
<div className="my-4 flex gap-2 flex-col">
{shouldShowRebootWarning && (
<Localized id="firmware-tool_flashing-step_warning">
<WarningBox>Warning</WarningBox>
</Localized>
)}
{Object.keys(status).map((id) => {
const val = status[id];
return (
<DeviceCardControl
status={val.status}
progress={val.progress}
key={id}
deviceNames={val.deviceNames}
></DeviceCardControl>
);
})}
<div className="flex gap-2 self-end">
<Localized id="firmware-tool_retry">
<Button
variant="secondary"
disabled={trackerWithErrors.length === 0}
onClick={retryError}
></Button>
</Localized>
<Localized id="firmware-tool_flashing-step_flash-more">
<Button
variant="secondary"
disabled={hasPendingTrackers}
onClick={() => goTo('FlashingMethod')}
></Button>
</Localized>
<Localized id="firmware-tool_flashing-step_exit">
<Button
variant="primary"
onClick={() => {
clear();
nav('/');
}}
></Button>
</Localized>
</div>
</div>
</div>
</>
);
}

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

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import {
AssignTrackerRequestT,
BoardType,
BodyPart,
ForgetDeviceRequestT,
ImuType,
@@ -149,6 +150,26 @@ export function TrackerSettingsPage() {
}
}, [firstLoad]);
const boardType = useMemo(() => {
if (tracker?.device?.hardwareInfo?.officialBoardType) {
return l10n.getString(
'board_type-' +
BoardType[
tracker?.device?.hardwareInfo?.officialBoardType ??
BoardType.UNKNOWN
]
);
} else if (tracker?.device?.hardwareInfo?.boardType) {
return tracker?.device?.hardwareInfo?.boardType;
} else {
return '--';
}
}, [
tracker?.device?.hardwareInfo?.officialBoardType,
tracker?.device?.hardwareInfo?.boardType,
l10n,
]);
const macAddress = useMemo(() => {
if (
/(?:[a-zA-Z\d]{2}:){5}[a-zA-Z\d]{2}/.test(
@@ -285,9 +306,7 @@ export function TrackerSettingsPage() {
<Typography color="secondary">
{l10n.getString('tracker-infos-board_type')}
</Typography>
<Typography>
{tracker?.device?.hardwareInfo?.boardType || '--'}
</Typography>
<Typography>{boardType}</Typography>
</div>
<div className="flex justify-between">
<Typography color="secondary">

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

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

View File

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

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;

View File

@@ -162,9 +162,11 @@ const config = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
screens: {
'mobile-settings': { raw: 'not (min-width: 900px)' },
nsmol: { raw: 'not (min-width: 525px)' },
smol: '525px',
mobile: { raw: 'not (min-width: 800px)' },
'xs-settings': '900px',
xs: '800px',
nsm: { raw: 'not (min-width: 900px)' },
sm: '900px',

View File

@@ -21,7 +21,7 @@ export function i18nHotReload(): PluginOption {
handleHotUpdate({ file, server }) {
if (file.endsWith('.ftl')) {
console.log('Fluent files updated');
server.ws.send({
server.hot.send({
type: 'custom',
event: 'locales-update',
});

2754
pnpm-lock.yaml generated

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

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

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