1 Commits

Author SHA1 Message Date
Rune Harlyk cbfd7aa354 Adds skill system 2025-12-25 14:03:39 +01:00
176 changed files with 4306 additions and 12337 deletions
-5
View File
@@ -36,11 +36,6 @@ jobs:
cache: "pnpm" cache: "pnpm"
cache-dependency-path: "./app/pnpm-lock.yaml" cache-dependency-path: "./app/pnpm-lock.yaml"
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
version: "27.x"
- run: pnpm install - run: pnpm install
- run: pnpm run build - run: pnpm run build
-8
View File
@@ -18,8 +18,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with:
submodules: "recursive"
- uses: actions/cache@v3 - uses: actions/cache@v3
with: with:
path: | path: |
@@ -34,12 +32,6 @@ jobs:
- name: Install PlatformIO Core - name: Install PlatformIO Core
run: pip install --upgrade platformio run: pip install --upgrade platformio
- name: Install Python dependencies for nanopb
run: pip install protobuf grpcio-tools
- name: Build Protocol Buffers (nanopb)
run: python ./submodules/nanopb/generator/nanopb_generator.py -I "./platform_shared/" -D esp32/src/platform_shared ./platform_shared/message.proto
- name: Build PlatformIO Project - name: Build PlatformIO Project
run: pio run run: pio run
+20 -29
View File
@@ -2,13 +2,13 @@ name: Frontend Tests
on: on:
push: push:
branches: [master] branches: [ master ]
paths: paths:
- "app/**" - 'app/**'
pull_request: pull_request:
branches: [master] branches: [ master ]
paths: paths:
- "app/**" - 'app/**'
permissions: permissions:
contents: read contents: read
@@ -20,31 +20,22 @@ jobs:
run: run:
working-directory: ./app working-directory: ./app
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
version: 9 version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "latest" node-version: 'latest'
cache: "pnpm" cache: 'pnpm'
cache-dependency-path: "./app/pnpm-lock.yaml" cache-dependency-path: './app/pnpm-lock.yaml'
- name: Install Protoc - name: Install dependencies
uses: arduino/setup-protoc@v3 run: pnpm install
with: - name: Install Playwright Browsers
version: "27.x" run: npx playwright install --with-deps
- name: Install dependencies - name: Run tests
run: pnpm install run: pnpm test
- name: Generate Proto
run: pnpm proto
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run tests
run: pnpm test
-59
View File
@@ -1,59 +0,0 @@
name: Proto Build
on:
push:
branches: [master, protobuf-playground]
pull_request:
branches: [master, protobuf-playground]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./
env:
BASE_PATH: /SpotMicroESP32-Leika
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Install Python dependencies
run: pip install protobuf grpcio-tools
- name: Build Protocol Buffers (nanopb)
run: python ./submodules/nanopb/generator/nanopb_generator.py -I "./platform_shared/" -D esp32/src/platform_shared ./platform_shared/message.proto
- name: Setup Protocol Buffers compiler
uses: arduino/setup-protoc@v3
with:
version: "25.x"
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: "./app/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install
working-directory: ./app
- name: Build Protocol Buffers (Typescript)
run: pnpm proto
working-directory: ./app
-9
View File
@@ -6,12 +6,3 @@ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
.pio .pio
managed_components/
dependencies.lock
sdkconfig
sdkconfig.*
!sdkconfig.defaults
esp32/src/platform_shared/*
!esp32/src/platform_shared/.gitkeep
app/src/lib/platform_shared/*
!app/src/lib/platform_shared/.gitkeep
-4
View File
@@ -1,4 +0,0 @@
[submodule "submodules/nanopb"]
path = submodules/nanopb
url = https://github.com/nanopb/nanopb
branch = master
+1 -1
View File
@@ -13,7 +13,7 @@
}, },
"editor.tabSize": 4, "editor.tabSize": 4,
"editor.detectIndentation": false, "editor.detectIndentation": false,
"cmake.sourceDirectory": "C:/data/repos/Hardware/Spot_Micro_Leika", "cmake.sourceDirectory": "C:/data/repos/Hardware/Spot Micro - Leika/.pio/libdeps/esp32cam/esp32-camera",
"cSpell.words": [ "cSpell.words": [
"Adafruit", "Adafruit",
"IRAM", "IRAM",
-3
View File
@@ -1,3 +0,0 @@
cmake_minimum_required(VERSION 3.16.0)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(Spot_Micro_Leika)
+2
View File
@@ -1 +1,3 @@
PUBLIC_VITE_USE_HOST_NAME=true PUBLIC_VITE_USE_HOST_NAME=true
PUBLIC_USE_JSON=true
PUBLIC_USE_MSGPACK=true
+13
View File
@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
+31
View File
@@ -0,0 +1,31 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
}
+6 -6
View File
@@ -1,8 +1,8 @@
declare module "app-env" { declare module 'app-env' {
interface ENV { interface ENV {
VITE_USE_HOST_NAME: boolean; VITE_USE_HOST_NAME: boolean
} }
const appEnv: ENV; const appEnv: ENV
export default appEnv; export default appEnv
} }
-44
View File
@@ -1,44 +0,0 @@
import js from '@eslint/js'
import ts from 'typescript-eslint'
import svelte from 'eslint-plugin-svelte'
import prettier from 'eslint-config-prettier'
import globals from 'globals'
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: [
'.DS_Store',
'node_modules/',
'build/',
'.svelte-kit/',
'package/',
'.env',
'.env.*',
'!.env.example',
'pnpm-lock.yaml',
'package-lock.json',
'yarn.lock'
]
}
)
+4 -12
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
"build": "pnpm proto && vite build", "build": "vite build",
"build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build", "build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "pnpm run test:integration && pnpm run test:unit", "test": "pnpm run test:integration && pnpm run test:unit",
@@ -13,11 +13,9 @@
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"test:integration": "playwright test", "test:integration": "playwright test",
"test:unit": "vitest", "test:unit": "vitest"
"proto": "node scripts/compile_protos.js"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2",
"@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi": "^1.2.3",
"@iconify-json/tabler": "^1.2.23", "@iconify-json/tabler": "^1.2.23",
"@playwright/test": "^1.56.0", "@playwright/test": "^1.56.0",
@@ -26,14 +24,12 @@
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/three": "^0.180.0", "@types/three": "^0.180.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.46.0", "@typescript-eslint/eslint-plugin": "^8.46.0",
"@typescript-eslint/parser": "^8.46.0", "@typescript-eslint/parser": "^8.46.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.37.0", "eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4", "eslint-plugin-svelte": "^3.12.4",
"globals": "^17.0.0",
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
@@ -41,18 +37,15 @@
"svelte-check": "^4.3.3", "svelte-check": "^4.3.3",
"svelte-focus-trap": "^1.2.0", "svelte-focus-trap": "^1.2.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"ts-proto-descriptors": "^2.1.0",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.51.0",
"unplugin-icons": "^22.4.2", "unplugin-icons": "^22.4.2",
"vite": "^7.1.9", "vite": "^7.1.9",
"vitest": "^3.2.4", "vitest": "^3.2.4"
"ws": "^8.18.3"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^2.10.2", "@msgpack/msgpack": "^3.1.2",
"@niku/vite-env-caster": "^1.1.2", "@niku/vite-env-caster": "^1.1.2",
"@sveltejs/adapter-auto": "^6.1.1", "@sveltejs/adapter-auto": "^6.1.1",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
@@ -64,7 +57,6 @@
"svelte-dnd-list": "^0.1.8", "svelte-dnd-list": "^0.1.8",
"svelte-modals": "^2.0.1", "svelte-modals": "^2.0.1",
"three": "^0.180.0", "three": "^0.180.0",
"ts-proto": "^2.10.1",
"urdf-loader": "^0.12.6", "urdf-loader": "^0.12.6",
"uzip": "^0.20201231.0", "uzip": "^0.20201231.0",
"xacro-parser": "^0.3.10" "xacro-parser": "^0.3.10"
+12 -272
View File
@@ -8,9 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@bufbuild/protobuf': '@msgpack/msgpack':
specifier: ^2.10.2 specifier: ^3.1.2
version: 2.10.2 version: 3.1.2
'@niku/vite-env-caster': '@niku/vite-env-caster':
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2 version: 1.1.2
@@ -44,9 +44,6 @@ importers:
three: three:
specifier: ^0.180.0 specifier: ^0.180.0
version: 0.180.0 version: 0.180.0
ts-proto:
specifier: ^2.10.1
version: 2.10.1
urdf-loader: urdf-loader:
specifier: ^0.12.6 specifier: ^0.12.6
version: 0.12.6(three@0.180.0) version: 0.12.6(three@0.180.0)
@@ -57,9 +54,6 @@ importers:
specifier: ^0.3.10 specifier: ^0.3.10
version: 0.3.10 version: 0.3.10
devDependencies: devDependencies:
'@eslint/js':
specifier: ^9.39.2
version: 9.39.2
'@iconify-json/mdi': '@iconify-json/mdi':
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.2.3 version: 1.2.3
@@ -84,9 +78,6 @@ importers:
'@types/three': '@types/three':
specifier: ^0.180.0 specifier: ^0.180.0
version: 0.180.0 version: 0.180.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^8.46.0 specifier: ^8.46.0
version: 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) version: 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
@@ -105,9 +96,6 @@ importers:
eslint-plugin-svelte: eslint-plugin-svelte:
specifier: ^3.12.4 specifier: ^3.12.4
version: 3.12.4(eslint@9.37.0(jiti@2.6.1))(svelte@5.39.11) version: 3.12.4(eslint@9.37.0(jiti@2.6.1))(svelte@5.39.11)
globals:
specifier: ^17.0.0
version: 17.0.0
jsdom: jsdom:
specifier: ^27.0.0 specifier: ^27.0.0
version: 27.0.0(postcss@8.5.6) version: 27.0.0(postcss@8.5.6)
@@ -129,18 +117,12 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4.1.14 specifier: ^4.1.14
version: 4.1.14 version: 4.1.14
ts-proto-descriptors:
specifier: ^2.1.0
version: 2.1.0
tslib: tslib:
specifier: ^2.8.1 specifier: ^2.8.1
version: 2.8.1 version: 2.8.1
typescript: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
typescript-eslint:
specifier: ^8.51.0
version: 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
unplugin-icons: unplugin-icons:
specifier: ^22.4.2 specifier: ^22.4.2
version: 22.4.2(svelte@5.39.11) version: 22.4.2(svelte@5.39.11)
@@ -150,9 +132,6 @@ importers:
vitest: vitest:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(@types/node@24.7.1)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(yaml@2.8.1) version: 3.2.4(@types/node@24.7.1)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(yaml@2.8.1)
ws:
specifier: ^8.18.3
version: 8.18.3
packages: packages:
@@ -171,9 +150,6 @@ packages:
'@asamuzakjp/nwsapi@2.3.9': '@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@bufbuild/protobuf@2.10.2':
resolution: {integrity: sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==}
'@csstools/color-helpers@5.1.0': '@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -400,10 +376,6 @@ packages:
resolution: {integrity: sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==} resolution: {integrity: sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.39.2':
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.6': '@eslint/object-schema@2.1.6':
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -463,6 +435,10 @@ packages:
'@kurkle/color@0.3.4': '@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@msgpack/msgpack@3.1.2':
resolution: {integrity: sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==}
engines: {node: '>= 18'}
'@niku/vite-env-caster@1.1.2': '@niku/vite-env-caster@1.1.2':
resolution: {integrity: sha512-6I/8REFdmfeGnK92H3nYHGc6lExwjm72jLxAsDPlfji97Eej4rOMl6WuYGLgsQI0pl5RrMRMveeRdijdL6hW+Q==} resolution: {integrity: sha512-6I/8REFdmfeGnK92H3nYHGc6lExwjm72jLxAsDPlfji97Eej4rOMl6WuYGLgsQI0pl5RrMRMveeRdijdL6hW+Q==}
@@ -765,9 +741,6 @@ packages:
'@types/webxr@0.5.24': '@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.46.0': '@typescript-eslint/eslint-plugin@8.46.0':
resolution: {integrity: sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==} resolution: {integrity: sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -776,14 +749,6 @@ packages:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/eslint-plugin@8.51.0':
resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.51.0
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.46.0': '@typescript-eslint/parser@8.46.0':
resolution: {integrity: sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==} resolution: {integrity: sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -791,45 +756,22 @@ packages:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.51.0':
resolution: {integrity: sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.46.0': '@typescript-eslint/project-service@8.46.0':
resolution: {integrity: sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==} resolution: {integrity: sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.51.0':
resolution: {integrity: sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.46.0': '@typescript-eslint/scope-manager@8.46.0':
resolution: {integrity: sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==} resolution: {integrity: sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/scope-manager@8.51.0':
resolution: {integrity: sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.46.0': '@typescript-eslint/tsconfig-utils@8.46.0':
resolution: {integrity: sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==} resolution: {integrity: sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/tsconfig-utils@8.51.0':
resolution: {integrity: sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.46.0': '@typescript-eslint/type-utils@8.46.0':
resolution: {integrity: sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==} resolution: {integrity: sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -837,33 +779,16 @@ packages:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.51.0':
resolution: {integrity: sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.46.0': '@typescript-eslint/types@8.46.0':
resolution: {integrity: sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==} resolution: {integrity: sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.51.0':
resolution: {integrity: sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.46.0': '@typescript-eslint/typescript-estree@8.46.0':
resolution: {integrity: sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==} resolution: {integrity: sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/typescript-estree@8.51.0':
resolution: {integrity: sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.46.0': '@typescript-eslint/utils@8.46.0':
resolution: {integrity: sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==} resolution: {integrity: sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -871,21 +796,10 @@ packages:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.51.0':
resolution: {integrity: sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.46.0': '@typescript-eslint/visitor-keys@8.46.0':
resolution: {integrity: sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==} resolution: {integrity: sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/visitor-keys@8.51.0':
resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/expect@3.2.4': '@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
@@ -1001,10 +915,6 @@ packages:
caniuse-lite@1.0.30001749: caniuse-lite@1.0.30001749:
resolution: {integrity: sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==} resolution: {integrity: sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==}
case-anything@2.1.13:
resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==}
engines: {node: '>=12.13'}
chai@5.3.3: chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1112,11 +1022,6 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
hasBin: true
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1124,9 +1029,6 @@ packages:
devalue@5.3.2: devalue@5.3.2:
resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==}
dprint-node@1.0.8:
resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==}
electron-to-chromium@1.5.234: electron-to-chromium@1.5.234:
resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==} resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==}
@@ -1318,10 +1220,6 @@ packages:
resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
engines: {node: '>=18'} engines: {node: '>=18'}
globals@17.0.0:
resolution: {integrity: sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==}
engines: {node: '>=18'}
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -1982,22 +1880,6 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
ts-poet@6.12.0:
resolution: {integrity: sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==}
ts-proto-descriptors@2.1.0:
resolution: {integrity: sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==}
ts-proto@2.10.1:
resolution: {integrity: sha512-4sOE1hCs0uobJgdRCtcEwdbc8MAyKP+LJqUIKxZIiKac0rPBlVKsRGEGo2oQ1MnKA2Wwk0KuGP2POkiCwPtebw==}
hasBin: true
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -2005,13 +1887,6 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
typescript-eslint@8.51.0:
resolution: {integrity: sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
typescript@5.9.3: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -2273,8 +2148,6 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {} '@asamuzakjp/nwsapi@2.3.9': {}
'@bufbuild/protobuf@2.10.2': {}
'@csstools/color-helpers@5.1.0': {} '@csstools/color-helpers@5.1.0': {}
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
@@ -2420,8 +2293,6 @@ snapshots:
'@eslint/js@9.37.0': {} '@eslint/js@9.37.0': {}
'@eslint/js@9.39.2': {}
'@eslint/object-schema@2.1.6': {} '@eslint/object-schema@2.1.6': {}
'@eslint/plugin-kit@0.4.0': '@eslint/plugin-kit@0.4.0':
@@ -2488,6 +2359,8 @@ snapshots:
'@kurkle/color@0.3.4': {} '@kurkle/color@0.3.4': {}
'@msgpack/msgpack@3.1.2': {}
'@niku/vite-env-caster@1.1.2': '@niku/vite-env-caster@1.1.2':
dependencies: dependencies:
chalk: 4.1.2 chalk: 4.1.2
@@ -2723,6 +2596,7 @@ snapshots:
'@types/node@24.7.1': '@types/node@24.7.1':
dependencies: dependencies:
undici-types: 7.14.0 undici-types: 7.14.0
optional: true
'@types/stats.js@0.17.4': {} '@types/stats.js@0.17.4': {}
@@ -2738,10 +2612,6 @@ snapshots:
'@types/webxr@0.5.24': {} '@types/webxr@0.5.24': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.7.1
'@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
@@ -2759,22 +2629,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.51.0
'@typescript-eslint/type-utils': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.51.0
eslint: 9.37.0(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.46.0 '@typescript-eslint/scope-manager': 8.46.0
@@ -2787,18 +2641,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.51.0
'@typescript-eslint/types': 8.51.0
'@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.51.0
debug: 4.4.3
eslint: 9.37.0(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.46.0(typescript@5.9.3)': '@typescript-eslint/project-service@8.46.0(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3)
@@ -2808,33 +2650,15 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/project-service@8.51.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3)
'@typescript-eslint/types': 8.51.0
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.46.0': '@typescript-eslint/scope-manager@8.46.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.46.0 '@typescript-eslint/types': 8.46.0
'@typescript-eslint/visitor-keys': 8.46.0 '@typescript-eslint/visitor-keys': 8.46.0
'@typescript-eslint/scope-manager@8.51.0':
dependencies:
'@typescript-eslint/types': 8.51.0
'@typescript-eslint/visitor-keys': 8.51.0
'@typescript-eslint/tsconfig-utils@8.46.0(typescript@5.9.3)': '@typescript-eslint/tsconfig-utils@8.46.0(typescript@5.9.3)':
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
'@typescript-eslint/tsconfig-utils@8.51.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/type-utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.46.0 '@typescript-eslint/types': 8.46.0
@@ -2847,22 +2671,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/type-utils@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.51.0
'@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.37.0(jiti@2.6.1)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.46.0': {} '@typescript-eslint/types@8.46.0': {}
'@typescript-eslint/types@8.51.0': {}
'@typescript-eslint/typescript-estree@8.46.0(typescript@5.9.3)': '@typescript-eslint/typescript-estree@8.46.0(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.46.0(typescript@5.9.3) '@typescript-eslint/project-service': 8.46.0(typescript@5.9.3)
@@ -2879,21 +2689,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/typescript-estree@8.51.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.51.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3)
'@typescript-eslint/types': 8.51.0
'@typescript-eslint/visitor-keys': 8.51.0
debug: 4.4.3
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1))
@@ -2905,27 +2700,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.51.0
'@typescript-eslint/types': 8.51.0
'@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
eslint: 9.37.0(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.46.0': '@typescript-eslint/visitor-keys@8.46.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.46.0 '@typescript-eslint/types': 8.46.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@typescript-eslint/visitor-keys@8.51.0':
dependencies:
'@typescript-eslint/types': 8.51.0
eslint-visitor-keys: 4.2.1
'@vitest/expect@3.2.4': '@vitest/expect@3.2.4':
dependencies: dependencies:
'@types/chai': 5.2.2 '@types/chai': 5.2.2
@@ -3044,8 +2823,6 @@ snapshots:
caniuse-lite@1.0.30001749: {} caniuse-lite@1.0.30001749: {}
case-anything@2.1.13: {}
chai@5.3.3: chai@5.3.3:
dependencies: dependencies:
assertion-error: 2.0.1 assertion-error: 2.0.1
@@ -3140,16 +2917,10 @@ snapshots:
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
detect-libc@1.0.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
devalue@5.3.2: {} devalue@5.3.2: {}
dprint-node@1.0.8:
dependencies:
detect-libc: 1.0.3
electron-to-chromium@1.5.234: {} electron-to-chromium@1.5.234: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@@ -3371,8 +3142,6 @@ snapshots:
globals@16.4.0: {} globals@16.4.0: {}
globals@17.0.0: {}
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
graphemer@1.4.0: {} graphemer@1.4.0: {}
@@ -3965,47 +3734,18 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
ts-poet@6.12.0:
dependencies:
dprint-node: 1.0.8
ts-proto-descriptors@2.1.0:
dependencies:
'@bufbuild/protobuf': 2.10.2
ts-proto@2.10.1:
dependencies:
'@bufbuild/protobuf': 2.10.2
case-anything: 2.1.13
ts-poet: 6.12.0
ts-proto-descriptors: 2.1.0
tslib@2.8.1: {} tslib@2.8.1: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
typescript-eslint@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.37.0(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {} typescript@5.9.3: {}
ufo@1.6.1: {} ufo@1.6.1: {}
undici-types@7.14.0: {} undici-types@7.14.0:
optional: true
unplugin-icons@22.4.2(svelte@5.39.11): unplugin-icons@22.4.2(svelte@5.39.11):
dependencies: dependencies:
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env node
import { execSync } from 'child_process'
import path from 'path'
import os from 'os'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const isWindows = os.platform() === 'win32'
const projectRoot = path.resolve(__dirname, '..')
const platformSharedDir = path.resolve(projectRoot, '..', 'platform_shared')
const outputDir = path.resolve(projectRoot, 'src', 'lib', 'platform_shared')
const pluginPath =
isWindows ?
path.join(projectRoot, 'node_modules', '.bin', 'protoc-gen-ts_proto.cmd')
: path.join(projectRoot, 'node_modules', '.bin', 'protoc-gen-ts_proto')
const protoFiles = ['filesystem.proto', 'message.proto', "api.proto"]
const tsProtoOpts = ['useExactTypes=false', 'outputExtensions=true', 'outputSchema=true'].join(',')
const cmd = [
'protoc',
`--plugin=protoc-gen-ts_proto=${pluginPath}`,
`--ts_proto_out=${outputDir}`,
`--ts_proto_opt=${tsProtoOpts}`,
`-I${platformSharedDir}`,
...protoFiles.map(f => path.join(platformSharedDir, f))
].join(' ')
console.log('Compiling protos...')
console.log(` Platform: ${os.platform()}`)
console.log(` Output: ${outputDir}`)
try {
execSync(cmd, { stdio: 'inherit', cwd: projectRoot })
console.log('Proto compilation complete!')
} catch (error) {
console.error('Proto compilation failed!', error)
process.exit(1)
}
+3 -17
View File
@@ -1,9 +1,6 @@
import { get } from 'svelte/store' import { get } from 'svelte/store'
import { Err, Ok, type Result } from './utilities' import { Err, Ok, type Result } from './utilities'
import { apiLocation } from './stores/location-store' import { apiLocation } from './stores'
import type { MessageFns } from './platform_shared/filesystem'
import { Request, Response as ProtoResponse } from './platform_shared/api'
import { BinaryWriter } from '@bufbuild/protobuf/wire'
export const api = { export const api = {
get<TResponse>(endpoint: string, params?: RequestInit) { get<TResponse>(endpoint: string, params?: RequestInit) {
@@ -14,10 +11,6 @@ export const api = {
return sendRequest<TResponse>(endpoint, 'POST', data) return sendRequest<TResponse>(endpoint, 'POST', data)
}, },
post_proto<TResponse>(endpoint: string, data: Request) {
return sendRequest<TResponse>(endpoint, 'POST', Request.encode(data))
},
put<TResponse>(endpoint: string, data?: unknown) { put<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'PUT', data) return sendRequest<TResponse>(endpoint, 'PUT', data)
}, },
@@ -34,11 +27,7 @@ async function sendRequest<TResponse>(
params?: RequestInit params?: RequestInit
): Promise<Result<TResponse, Error>> { ): Promise<Result<TResponse, Error>> {
endpoint = resolveUrl(endpoint) endpoint = resolveUrl(endpoint)
const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined
const isProtobuf = data instanceof BinaryWriter
const body = data !== null && typeof data !== 'undefined'
? (isProtobuf ? data.finish() : JSON.stringify(data))
: undefined
const request = { const request = {
...params, ...params,
@@ -47,7 +36,7 @@ async function sendRequest<TResponse>(
headers: { headers: {
...params?.headers, ...params?.headers,
Authorization: 'Basic', Authorization: 'Basic',
'Content-Type': isProtobuf ? 'application/x-protobuf' : 'application/json' 'Content-Type': 'application/json'
} }
} }
@@ -71,9 +60,6 @@ async function sendRequest<TResponse>(
if (contentType && contentType.includes('application/json')) { if (contentType && contentType.includes('application/json')) {
const data = await response.json() const data = await response.json()
return Ok.new(data as TResponse) return Ok.new(data as TResponse)
} else if (contentType && contentType.includes('application/x-protobuf')) {
let data: ProtoResponse = ProtoResponse.decode(await response.bytes());
return Ok.new(data as TResponse)
} else { } else {
// Handle empty object as response // Handle empty object as response
return Ok.new(null as TResponse) return Ok.new(null as TResponse)
-81
View File
@@ -1,81 +0,0 @@
<script lang="ts">
interface Props {
heading?: number
size?: string
}
let { heading = 0, size = 'w-48 h-48' }: Props = $props()
const getCardinalDirection = (h: number) => {
if (h >= 337.5 || h < 22.5) return 'N'
if (h >= 22.5 && h < 67.5) return 'NE'
if (h >= 67.5 && h < 112.5) return 'E'
if (h >= 112.5 && h < 157.5) return 'SE'
if (h >= 157.5 && h < 202.5) return 'S'
if (h >= 202.5 && h < 247.5) return 'SW'
if (h >= 247.5 && h < 292.5) return 'W'
return 'NW'
}
const ticks = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
</script>
<div class="flex flex-col items-center">
<div class="relative {size}">
<svg viewBox="0 0 200 200" class="w-full h-full">
<circle
cx="100"
cy="100"
r="90"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opacity-30"
/>
<circle
cx="100"
cy="100"
r="70"
fill="none"
stroke="currentColor"
stroke-width="1"
class="opacity-20"
/>
<circle
cx="100"
cy="100"
r="50"
fill="none"
stroke="currentColor"
stroke-width="1"
class="opacity-20"
/>
<text x="100" y="20" text-anchor="middle" class="fill-current text-sm font-bold">N</text>
<text x="180" y="105" text-anchor="middle" class="fill-current text-sm font-bold">E</text>
<text x="100" y="190" text-anchor="middle" class="fill-current text-sm font-bold">S</text>
<text x="20" y="105" text-anchor="middle" class="fill-current text-sm font-bold">W</text>
{#each ticks as tick}
<line
x1={100 + 85 * Math.sin((tick * Math.PI) / 180)}
y1={100 - 85 * Math.cos((tick * Math.PI) / 180)}
x2={100 + 78 * Math.sin((tick * Math.PI) / 180)}
y2={100 - 78 * Math.cos((tick * Math.PI) / 180)}
stroke="currentColor"
stroke-width={tick % 90 === 0 ? 2 : 1}
class="opacity-50"
/>
{/each}
<g transform="rotate({heading}, 100, 100)">
<polygon points="100,25 93,100 100,90 107,100" class="fill-error" />
<polygon points="100,175 93,100 100,110 107,100" class="fill-base-300" />
</g>
<circle cx="100" cy="100" r="8" class="fill-base-content" />
</svg>
</div>
<div class="text-2xl font-mono font-bold mt-2">{heading.toFixed(1)}°</div>
<div class="text-sm opacity-70">{getCardinalDirection(heading)}</div>
</div>
+156
View File
@@ -0,0 +1,156 @@
<script lang="ts">
import { skill } from '$lib/stores'
import { onMount, onDestroy } from 'svelte'
let targetX = $state(0.5)
let targetZ = $state(0)
let targetYaw = $state(0)
let speed = $state(0.5)
const status = skill.status
const isActive = skill.isActive
const progress = skill.progress
const presets = [
{ name: 'Forward 0.5m', x: 0.5, z: 0, yaw: 0 },
{ name: 'Forward 1m', x: 1, z: 0, yaw: 0 },
{ name: 'Back 0.5m', x: -0.5, z: 0, yaw: 0 },
{ name: 'Left 0.5m', x: 0, z: 0.5, yaw: 0 },
{ name: 'Right 0.5m', x: 0, z: -0.5, yaw: 0 },
{ name: 'Turn Left 90°', x: 0, z: 0, yaw: 1.57 },
{ name: 'Turn Right 90°', x: 0, z: 0, yaw: -1.57 },
{ name: 'Turn 180°', x: 0, z: 0, yaw: 3.14 }
]
onMount(() => skill.init())
onDestroy(() => skill.destroy())
function executeSkill() {
skill.walk(targetX, targetZ, targetYaw, speed)
}
function runPreset(preset: (typeof presets)[0]) {
skill.walk(preset.x, preset.z, preset.yaw, speed)
}
function formatMeters(val: number): string {
return val.toFixed(3) + 'm'
}
function formatDegrees(rad: number): string {
return ((rad * 180) / Math.PI).toFixed(1) + '°'
}
</script>
<div class="card bg-base-200 shadow-xl">
<div class="card-body p-4">
<h2 class="card-title text-sm flex justify-between">
Skill Control
<span class="badge" class:badge-success={$isActive} class:badge-ghost={!$isActive}>
{$isActive ? 'Active' : 'Idle'}
</span>
</h2>
<div class="grid grid-cols-2 gap-2 text-xs mb-2">
<div class="stat bg-base-300 rounded-lg p-2">
<div class="stat-title text-xs">Position</div>
<div class="stat-value text-sm">
{formatMeters($status.x)}, {formatMeters($status.z)}
</div>
<div class="stat-desc">Yaw: {formatDegrees($status.yaw)}</div>
</div>
<div class="stat bg-base-300 rounded-lg p-2">
<div class="stat-title text-xs">Distance</div>
<div class="stat-value text-sm">{formatMeters($status.distance)}</div>
<div class="stat-desc">Total traveled</div>
</div>
</div>
{#if $isActive}
<div class="mb-2">
<div class="flex justify-between text-xs mb-1">
<span>Progress</span>
<span>{($progress * 100).toFixed(0)}%</span>
</div>
<progress class="progress progress-primary w-full" value={$progress} max="1"></progress>
<div class="text-xs text-base-content/60 mt-1">
Target: ({$status.skill_target_x.toFixed(2)}, {$status.skill_target_z.toFixed(2)}, {formatDegrees(
$status.skill_target_yaw
)})
</div>
</div>
{/if}
<div class="divider my-1 text-xs">Presets</div>
<div class="grid grid-cols-4 gap-1">
{#each presets as preset}
<button class="btn btn-xs btn-outline" onclick={() => runPreset(preset)}>
{preset.name}
</button>
{/each}
</div>
<div class="divider my-1 text-xs">Custom</div>
<div class="grid grid-cols-3 gap-2">
<div class="form-control">
<label class="label py-0" for="skill-x">
<span class="label-text text-xs">X (m)</span>
</label>
<input
id="skill-x"
type="number"
step="0.1"
bind:value={targetX}
class="input input-bordered input-xs w-full"
/>
</div>
<div class="form-control">
<label class="label py-0" for="skill-z">
<span class="label-text text-xs">Z (m)</span>
</label>
<input
id="skill-z"
type="number"
step="0.1"
bind:value={targetZ}
class="input input-bordered input-xs w-full"
/>
</div>
<div class="form-control">
<label class="label py-0" for="skill-yaw">
<span class="label-text text-xs">Yaw (rad)</span>
</label>
<input
id="skill-yaw"
type="number"
step="0.1"
bind:value={targetYaw}
class="input input-bordered input-xs w-full"
/>
</div>
</div>
<div class="form-control mt-2">
<label class="label py-0" for="skill-speed">
<span class="label-text text-xs">Speed: {speed.toFixed(2)}</span>
</label>
<input id="skill-speed" type="range" min="0.1" max="1" step="0.05" bind:value={speed} class="range range-xs range-primary" />
</div>
<div class="card-actions justify-between mt-2">
<div class="flex gap-1">
<button class="btn btn-xs btn-ghost" onclick={() => skill.resetPosition()}>Reset Pos</button>
</div>
<div class="flex gap-1">
<button class="btn btn-xs btn-error" onclick={() => skill.stop()} disabled={!$isActive}>
Stop
</button>
<button class="btn btn-xs btn-primary" onclick={executeSkill} disabled={$isActive}>
Execute
</button>
</div>
</div>
</div>
</div>
+3 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Component } from 'svelte' import type { ComponentType } from 'svelte'
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning' type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
@@ -11,12 +11,12 @@
class: klass = '', class: klass = '',
children = null children = null
} = $props<{ } = $props<{
icon?: Component icon?: ComponentType
title: string title: string
description?: string | number description?: string | number
variant?: Variant variant?: Variant
class?: string class?: string
children?: () => Component children?: () => ComponentType
}>() }>()
const Icon = $derived(icon) const Icon = $derived(icon)
+66 -92
View File
@@ -10,34 +10,28 @@
Color Color
} from 'three' } from 'three'
import { import {
ModesEnum,
kinematicData,
mode, mode,
model, model,
input, outControllerData,
servoAnglesOut, servoAnglesOut,
servoAngles, servoAngles,
mpu, mpu,
jointNames, jointNames,
currentKinematic, currentKinematic,
walkGait, walkGait,
kinematicData walkGaitToMode
} from '$lib/stores' } from '$lib/stores'
import { populateModelCache, getToeWorldPositions } from '$lib/utilities' import { populateModelCache, throttler, getToeWorldPositions } from '$lib/utilities'
import SceneBuilder from '$lib/sceneBuilder' import SceneBuilder from '$lib/sceneBuilder'
import { lerp, degToRad } from 'three/src/math/MathUtils' import { lerp, degToRad } from 'three/src/math/MathUtils'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js' import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import { type body_state_t } from '$lib/kinematic' import { type body_state_t } from '$lib/kinematic'
import { import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait'
BezierState,
CalibrationState,
GaitState,
IdleState,
RestState,
StandState
} from '$lib/gait'
import { radToDeg } from 'three/src/math/MathUtils.js' import { radToDeg } from 'three/src/math/MathUtils.js'
import type { URDFRobot } from 'urdf-loader' import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store' import { get } from 'svelte/store'
import { AnglesData, KinematicData, ModesEnum } from '$lib/platform_shared/message'
interface Props { interface Props {
defaultColor?: string | null defaultColor?: string | null
@@ -57,14 +51,11 @@
let sceneManager = $state(new SceneBuilder()) let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement let canvas: HTMLCanvasElement
const NUM_ANGLES = 12 // TODO: This number should come from the robot
let currentModelAngles: AnglesData = AnglesData.create({ let currentModelAngles: number[] = new Array(12).fill(0)
angles: new Array(NUM_ANGLES).fill(0) let modelTargetAngles: number[] = new Array(12).fill(0)
})
let modelTargetAngles: AnglesData = AnglesData.create({ angles: new Array(NUM_ANGLES).fill(0) })
let gui_panel: GUI let gui_panel: GUI
const SMOOTH_AMOUNT = 0.2 let Throttler = new throttler()
let target: Object3D<Object3DEventMap> let target: Object3D<Object3DEventMap>
@@ -72,17 +63,15 @@
let kinematic = get(currentKinematic) let kinematic = get(currentKinematic)
const planners: Record<ModesEnum, GaitState> = { let planners = {
[ModesEnum.DEACTIVATED]: new IdleState(), [ModesEnum.Deactivated]: new IdleState(),
[ModesEnum.IDLE]: new IdleState(), [ModesEnum.Idle]: new IdleState(),
[ModesEnum.CALIBRATION]: new CalibrationState(), [ModesEnum.Calibration]: new CalibrationState(),
[ModesEnum.REST]: new RestState(), [ModesEnum.Rest]: new RestState(),
[ModesEnum.STAND]: new StandState(), [ModesEnum.Stand]: new StandState(),
[ModesEnum.WALK]: new BezierState(), [ModesEnum.Walk]: new BezierState()
[ModesEnum.UNRECOGNIZED]: new IdleState()
} }
let lastTick = performance.now() let lastTick = performance.now()
let lastRobotPosition = new Vector3()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1] const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
const THREEJS_SCALE = 10 const THREEJS_SCALE = 10
@@ -110,6 +99,7 @@
'Trace feet': debug, 'Trace feet': debug,
'Target position': false, 'Target position': false,
'Trace points': 30, 'Trace points': 30,
'Fix camera on robot': true,
'Smooth motion': true, 'Smooth motion': true,
omega: 0, omega: 0,
phi: 0, phi: 0,
@@ -124,23 +114,16 @@
await populateModelCache() await populateModelCache()
await createScene() await createScene()
servoAngles.subscribe(updateAnglesFromStore) servoAngles.subscribe(updateAnglesFromStore)
walkGait.subscribe(gait => { walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait)))
const walkPlanner = planners[ModesEnum.WALK]
if (!(walkPlanner instanceof BezierState)) {
throw new Error(
`Expected BezierState for WALK mode, got ${walkPlanner.constructor.name}`
)
}
walkPlanner.set_mode(gait.gait)
})
if (panel) createPanel() if (panel) createPanel()
}) })
onDestroy(() => { onDestroy(() => {
canvas.remove()
gui_panel?.destroy() gui_panel?.destroy()
}) })
const updateAnglesFromStore = (angles: AnglesData) => { const updateAnglesFromStore = (angles: number[]) => {
if (sceneManager.isDragging) return if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return if (settings['Internal kinematic']) return
modelTargetAngles = angles modelTargetAngles = angles
@@ -173,26 +156,23 @@
} }
const updateKinematicPosition = () => { const updateKinematicPosition = () => {
kinematicData.set( kinematicData.set([
KinematicData.create({ settings.omega,
omega: settings.omega, settings.phi,
phi: settings.phi, settings.psi,
psi: settings.psi, settings.xm,
xm: settings.xm, settings.ym,
ym: settings.ym, settings.zm
zm: settings.zm ])
})
)
} }
const setSceneBackground = (c: string | null) => (sceneManager.scene.background = new Color(c!)) const setSceneBackground = (c: string | null) => (sceneManager.scene.background = new Color(c!))
const updateAngles = (name: string, angle: number) => { const updateAngles = (name: string, angle: number) => {
modelTargetAngles.angles[$jointNames.indexOf(name)] = angle * (180 / Math.PI) modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
servoAnglesOut.set( Throttler.throttle(
AnglesData.create({ () => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))),
angles: modelTargetAngles.angles.map(num => Math.round(num)) 100
})
) )
} }
@@ -246,7 +226,7 @@
} }
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i])) let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
modelTargetAngles.angles = new_angles modelTargetAngles = new_angles
} }
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => { const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
@@ -254,53 +234,38 @@
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y)) robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
const cumulativeYaw = body_state.cumulative_yaw const cumulativeYaw = body_state.cumulative_yaw
const headingYaw = degToRad(-settings.phi + $mpu.heading)
const totalYaw = headingYaw + cumulativeYaw
const cosTotal = Math.cos(totalYaw) const cosYaw = Math.cos(cumulativeYaw)
const sinTotal = Math.sin(totalYaw) const sinYaw = Math.sin(cumulativeYaw)
const rotatedXm = settings.xm * cosTotal - settings.zm * sinTotal const rotatedXm = settings.xm * cosYaw - settings.zm * sinYaw
const rotatedZm = settings.xm * sinTotal + settings.zm * cosTotal const rotatedZm = settings.xm * sinYaw + settings.zm * cosYaw
const mpuHeadingRad = degToRad($mpu.heading)
const cosHead = Math.cos(mpuHeadingRad)
const sinHead = Math.sin(mpuHeadingRad)
const rotatedCumX = body_state.cumulative_x * cosHead - body_state.cumulative_z * sinHead
const rotatedCumZ = body_state.cumulative_x * sinHead + body_state.cumulative_z * cosHead
robot.position.x = smooth( robot.position.x = smooth(
robot.position.x, robot.position.x,
(-rotatedZm - rotatedCumZ) * THREEJS_SCALE, (-rotatedZm - body_state.cumulative_z) * THREEJS_SCALE,
SMOOTH_AMOUNT 0.1
) )
robot.position.z = smooth( robot.position.z = smooth(
robot.position.z, robot.position.z,
(-rotatedXm - rotatedCumX) * THREEJS_SCALE, (-rotatedXm - body_state.cumulative_x) * THREEJS_SCALE,
SMOOTH_AMOUNT 0.1
) )
const cosYaw = Math.cos(totalYaw) const pitch = degToRad(settings.psi - 90) + body_state.cumulative_pitch
const sinYaw = Math.sin(totalYaw) const roll = degToRad(settings.omega) + body_state.cumulative_roll
const cmdPitch = degToRad(settings.psi)
const cmdRoll = degToRad(settings.omega)
const pitch =
degToRad(-90) + cmdPitch * cosYaw - cmdRoll * sinYaw + body_state.cumulative_pitch
const roll = cmdPitch * sinYaw + cmdRoll * cosYaw + body_state.cumulative_roll
robot.rotation.z = smooth( robot.rotation.z = smooth(
robot.rotation.z, robot.rotation.z,
degToRad(-settings.phi + $mpu.heading + 90) + cumulativeYaw, degToRad(-settings.phi + $mpu.heading + 90) + cumulativeYaw,
SMOOTH_AMOUNT 0.1
) )
robot.rotation.y = smooth(robot.rotation.y, roll, SMOOTH_AMOUNT) robot.rotation.y = smooth(robot.rotation.y, roll, 0.1)
robot.rotation.x = smooth(robot.rotation.x, pitch, SMOOTH_AMOUNT) robot.rotation.x = smooth(robot.rotation.x, pitch, 0.1)
} }
const update_camera = (robot: URDFRobot) => { const update_camera = (robot: URDFRobot) => {
const delta = robot.position.clone().sub(lastRobotPosition) if (!settings['Fix camera on robot']) return
sceneManager.orbit.target.add(delta) sceneManager.orbit.target = robot.position.clone()
sceneManager.camera.position.add(delta)
lastRobotPosition.copy(robot.position)
} }
const smooth = (start: number, end: number, amount: number) => { const smooth = (start: number, end: number, amount: number) => {
@@ -309,13 +274,22 @@
const update_gait = () => { const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(input) const controlData = get(outControllerData)
const data = {
lx: controlData[0],
ly: controlData[1],
rx: controlData[2],
ry: controlData[3],
h: controlData[4],
s: controlData[5],
s1: controlData[6]
}
let planner = planners[get(mode).mode] let planner = planners[get(mode)]
const delta = performance.now() - lastTick const delta = performance.now() - lastTick
lastTick = performance.now() lastTick = performance.now()
body_state = planner.step(body_state, controlData, delta) body_state = planner.step(body_state, data, delta)
settings.omega = body_state.omega settings.omega = body_state.omega
settings.phi = body_state.phi settings.phi = body_state.phi
@@ -336,8 +310,8 @@
const updateTargetPosition = () => { const updateTargetPosition = () => {
target.visible = settings['Target position'] target.visible = settings['Target position']
target.position.x = smooth(target.position.x, target_position.x, SMOOTH_AMOUNT) target.position.x = smooth(target.position.x, target_position.x, 0.5)
target.position.z = smooth(target.position.z, target_position.z, SMOOTH_AMOUNT) target.position.z = smooth(target.position.z, target_position.z, 0.5)
} }
const render = () => { const render = () => {
@@ -356,12 +330,12 @@
sceneManager.transformControl.showZ = settings['Robot transform controls'] sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) { for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles.angles[i] = smooth( currentModelAngles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI), (robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles.angles[i], modelTargetAngles[i],
SMOOTH_AMOUNT 0.1
) )
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles.angles[i])) robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
} }
orient_robot(robot, toes) orient_robot(robot, toes)
@@ -1,228 +0,0 @@
<script lang="ts">
import { fileSystemClient } from '$lib/filesystem/chunkedTransfer'
import type { TransferProgress } from '$lib/types/models'
import { onMount } from 'svelte'
let currentPath = '/'
let files: Array<{ name: string; size: number }> = []
let directories: Array<{ name: string }> = []
let loading = false
let error = ''
let uploadProgress: TransferProgress | null = null
let downloadProgress: TransferProgress | null = null
const joinPath = (name: string) => (currentPath === '/' ? '/' + name : currentPath + '/' + name)
const getError = (e: unknown, fallback: string) =>
e instanceof Error ? e.message : (e as { error?: string })?.error || fallback
async function loadDirectory() {
loading = true
error = ''
try {
const result = await fileSystemClient.listDirectory(currentPath)
if (result.success) {
files = result.files
directories = result.directories
} else {
error = result.error || 'Failed to load directory'
}
} catch (e) {
error = getError(e, 'Unknown error')
} finally {
loading = false
}
}
async function navigateTo(path: string) {
currentPath = path
await loadDirectory()
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploadProgress = null
error = ''
try {
const result = await fileSystemClient.uploadFileFromBrowser(
joinPath(file.name),
file,
p => (uploadProgress = p)
)
if (result.success) await loadDirectory()
else error = result.error || 'Upload failed'
} catch (e) {
error = getError(e, 'Upload error')
} finally {
uploadProgress = null
input.value = ''
}
}
async function handleDownload(filename: string) {
downloadProgress = null
error = ''
try {
const result = await fileSystemClient.downloadFileAndSave(
joinPath(filename),
filename,
p => (downloadProgress = p)
)
if (!result.success) error = result.error || 'Download failed'
} catch (e) {
error = getError(e, 'Download error')
} finally {
downloadProgress = null
}
}
async function handleDelete(name: string, isDirectory: boolean) {
if (!confirm(`Delete ${isDirectory ? 'directory' : 'file'} "${name}"?`)) return
error = ''
try {
const result = await fileSystemClient.deleteFile(joinPath(name))
if (result.success) await loadDirectory()
else error = result.error || 'Delete failed'
} catch (e) {
error = getError(e, 'Delete error')
}
}
async function handleCreateDirectory() {
const name = prompt('Enter directory name:')
if (!name) return
error = ''
try {
const result = await fileSystemClient.createDirectory(joinPath(name))
if (result.success) await loadDirectory()
else error = result.error || 'Failed to create directory'
} catch (e) {
error = getError(e, 'Error creating directory')
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
onMount(loadDirectory)
</script>
<div class="max-w-3xl mx-auto my-8 p-4 border border-gray-300 rounded-lg bg-white">
<div class="mb-4">
<h2 class="m-0 mb-2">File Manager</h2>
<div class="font-mono bg-gray-100 p-2 rounded mb-2">Current: {currentPath}</div>
<div class="flex gap-2">
<button
class="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer hover:bg-blue-700"
on:click={handleCreateDirectory}>New Folder</button
>
<label
class="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer hover:bg-blue-700"
>
Upload File
<input type="file" on:change={handleFileUpload} class="hidden" />
</label>
<button
class="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer hover:bg-blue-700"
on:click={loadDirectory}>Refresh</button
>
</div>
</div>
{#if error}
<div class="bg-red-100 text-red-800 p-3 rounded mb-4">{error}</div>
{/if}
{#if uploadProgress}
<div class="mb-4">
<div class="mb-2 text-sm">
Uploading: {uploadProgress.percentage.toFixed(1)}% ({formatBytes(
uploadProgress.bytesTransferred
)} / {formatBytes(uploadProgress.totalBytes)})
</div>
<div class="h-5 bg-gray-200 rounded overflow-hidden">
<div
class="h-full bg-green-600 transition-all duration-300"
style="width: {uploadProgress.percentage}%"
></div>
</div>
</div>
{/if}
{#if downloadProgress}
<div class="mb-4">
<div class="mb-2 text-sm">
Downloading: {downloadProgress.percentage.toFixed(1)}% ({formatBytes(
downloadProgress.bytesTransferred
)} / {formatBytes(downloadProgress.totalBytes)})
</div>
<div class="h-5 bg-gray-200 rounded overflow-hidden">
<div
class="h-full bg-green-600 transition-all duration-300"
style="width: {downloadProgress.percentage}%"
></div>
</div>
</div>
{/if}
<div class="border border-gray-300 rounded min-h-[200px]">
{#if loading}
<div class="text-center p-8 text-gray-500">Loading...</div>
{:else}
{#if currentPath !== '/'}
<div
class="flex items-center p-3 border-b border-gray-100 gap-2 bg-gray-50 cursor-pointer"
on:click={() => navigateTo('/')}
>
<span class="text-2xl">📁</span>
<span class="flex-1 hover:underline">..</span>
</div>
{/if}
{#each directories as dir}
<div class="flex items-center p-3 border-b border-gray-100 gap-2 bg-gray-50">
<span class="text-2xl">📁</span>
<span
class="flex-1 cursor-pointer hover:underline"
on:click={() => navigateTo(currentPath + '/' + dir.name)}>{dir.name}</span
>
<button
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
on:click={() => handleDelete(dir.name, true)}>Delete</button
>
</div>
{/each}
{#each files as file}
<div class="flex items-center p-3 border-b border-gray-100 gap-2 last:border-b-0">
<span class="text-2xl">📄</span>
<span class="flex-1">{file.name}</span>
<span class="text-gray-500 text-sm">{formatBytes(file.size)}</span>
<button
class="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
on:click={() => handleDownload(file.name)}>Download</button
>
<button
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
on:click={() => handleDelete(file.name, false)}>Delete</button
>
</div>
{/each}
{#if files.length === 0 && directories.length === 0}
<div class="text-center p-8 text-gray-500">Directory is empty</div>
{/if}
{/if}
</div>
</div>
-2
View File
@@ -38,8 +38,6 @@ export { default as FolderOpenOutline } from '~icons/mdi/folder-open-outline'
export { default as TrashIcon } from '~icons/mdi/trash' export { default as TrashIcon } from '~icons/mdi/trash'
export { default as RotateCcw } from '~icons/mdi/rotate-left' export { default as RotateCcw } from '~icons/mdi/rotate-left'
export { default as RotateCw } from '~icons/mdi/rotate-right' export { default as RotateCw } from '~icons/mdi/rotate-right'
export { default as UploadIcon } from '~icons/mdi/upload'
export { default as DownloadIcon } from '~icons/mdi/download'
export { default as Down } from '~icons/tabler/chevron-down' export { default as Down } from '~icons/tabler/chevron-down'
export { default as Cancel } from '~icons/tabler/x' export { default as Cancel } from '~icons/tabler/x'
@@ -4,7 +4,7 @@
max?: number max?: number
step?: number step?: number
value?: number value?: number
oninput?: (value: Event) => void oninput?: (value: number) => void
} }
let { let {
@@ -2,14 +2,13 @@
import { Github } from '../icons' import { Github } from '../icons'
interface Props { interface Props {
github: { href: string; active?: boolean } github: { url: string; version: string; active?: boolean; href?: string }
} }
let { github }: Props = $props() let { github }: Props = $props()
</script> </script>
{#if github.active} {#if github.active}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- external URL -->
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer"> <a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
<Github class="h-5 w-5" /> <Github class="h-5 w-5" />
</a> </a>
+33 -19
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
import { resolve } from '$app/paths' import { base } from '$app/paths'
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags'
import GithubButton from '../menu/GithubButton.svelte' import GithubButton from '../menu/GithubButton.svelte'
import LogoButton from '../menu/LogoButton.svelte' import LogoButton from '../menu/LogoButton.svelte'
@@ -33,11 +33,11 @@
const github = { href: 'https://github.com/' + page.data.github, active: true } const github = { href: 'https://github.com/' + page.data.github, active: true }
import type { Component } from 'svelte' import type { ComponentType } from 'svelte'
type menuItem = { type menuItem = {
title: string title: string
icon: Component icon: ComponentType
href?: string href?: string
feature: boolean feature: boolean
active?: boolean active?: boolean
@@ -45,15 +45,13 @@
} }
function withBase(path: string) { function withBase(path: string) {
return `${resolve('/')}${path.startsWith('/') ? path.slice(1) : path}` return `${base}${path.startsWith('/') ? path : '/' + path}`
} }
const { menuClicked } = $props() let menuItems = $state<menuItem[]>([])
const activeTitle = $derived(page.data.title) $effect(() => {
menuItems = [
const menuItems = $derived<menuItem[]>(
[
{ {
title: 'Connection', title: 'Connection',
icon: WiFi, icon: WiFi,
@@ -81,7 +79,7 @@
title: 'Camera', title: 'Camera',
icon: Camera, icon: Camera,
href: withBase('/peripherals/camera'), href: withBase('/peripherals/camera'),
feature: true feature: $features.camera
}, },
{ {
title: 'Servo', title: 'Servo',
@@ -93,9 +91,9 @@
title: 'IMU', title: 'IMU',
icon: Rotate3d, icon: Rotate3d,
href: withBase('/peripherals/imu'), href: withBase('/peripherals/imu'),
feature: true feature: $features.imu || $features.mag || $features.bmp
} }
].map(sub => ({ ...sub, active: sub.title === activeTitle })) ]
}, },
{ {
title: 'WiFi', title: 'WiFi',
@@ -120,7 +118,7 @@
href: withBase('/wifi/mdns'), href: withBase('/wifi/mdns'),
feature: true feature: true
} }
].map(sub => ({ ...sub, active: sub.title === activeTitle })) ]
}, },
{ {
title: 'System', title: 'System',
@@ -149,20 +147,36 @@
title: 'Firmware Update', title: 'Firmware Update',
icon: Update, icon: Update,
href: withBase('/system/update'), href: withBase('/system/update'),
feature: !!( feature:
$features.ota || $features.ota ||
$features.upload_firmware || $features.upload_firmware ||
$features.download_firmware $features.download_firmware
)
} }
].map(sub => ({ ...sub, active: sub.title === activeTitle })) ]
} }
].map(item => ({ ...item, active: item.title === activeTitle })) ] as menuItem[]
) })
const updateMenu = () => { const { menuClicked } = $props()
function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => {
item.active = item.title === targetTitle
item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle
})
})
menuItems = menuItems
menuClicked() menuClicked()
} }
$effect(() => {
setActiveMenuItem(page.data.title)
})
const updateMenu = (event: CustomEvent) => {
setActiveMenuItem(event.details)
}
</script> </script>
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content"> <div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
+3 -3
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import MenuList from './MenuList.svelte' import MenuList from './MenuList.svelte'
import type { Component } from 'svelte' import type { ComponentType } from 'svelte'
type MenuItem = { type MenuItem = {
title: string title: string
icon: Component icon: ComponentType
href?: string href?: string
feature: boolean feature: boolean
active?: boolean active?: boolean
@@ -38,7 +38,7 @@
</div> </div>
</details> </details>
{:else} {:else}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve --><a <a
href={menuItem.href} href={menuItem.href}
class="font-bold" class="font-bold"
class:bg-base-100={menuItem.active} class:bg-base-100={menuItem.active}
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { ModeData, ModesEnum } from '$lib/platform_shared/message' import { mode, modes } from '$lib/stores'
import { mode } from '$lib/stores'
const deactivate = async () => { const deactivate = async () => {
mode.set(ModeData.create({ mode: ModesEnum.DEACTIVATED })) mode.set(modes.indexOf('deactivated'))
} }
</script> </script>
@@ -14,7 +14,7 @@
{...rest} {...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}" class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"
> >
{#each options as option (option)} {#each options as option}
<option value={option}>{option}</option> <option value={option}>{option}</option>
{/each} {/each}
</select> </select>
-470
View File
@@ -1,470 +0,0 @@
import { socket } from '$lib/stores/socket'
import * as FSMessages from '$lib/platform_shared/filesystem'
import type {
FSDeleteRequest,
FSMkdirRequest,
FSListRequest,
FSDownloadRequest,
FSDownloadMetadata,
FSDownloadData,
FSDownloadComplete,
FSUploadStart,
FSUploadData,
FSUploadComplete,
FSCancelTransfer
} from '$lib/platform_shared/filesystem'
import type { Result, DataResult, ListResult, ProgressCallback } from '$lib/types/models'
const MAX_CHUNK_SIZE = 2 ** 14
type TimeoutId = ReturnType<typeof setTimeout>
type CleanupFn = (() => void) | null
interface TransferBase<T extends Result> {
resolve: (result: T) => void
reject: (error: Error) => void
onProgress?: ProgressCallback
timeoutId: TimeoutId
}
interface ActiveDownload extends TransferBase<DataResult> {
path: string
buffer: Uint8Array
fileSize: number
totalChunks: number
chunksReceived: number
bytesReceived: number
}
interface ActiveUpload extends TransferBase<Result> {
path: string
transferId: number
totalChunks: number
chunksSent: number
}
export class FileSystemClient {
private activeDownloads = new Map<number, ActiveDownload>()
private activeUploads = new Map<number, ActiveUpload>()
private pendingDownloads = new Map<string, TransferBase<DataResult>>()
private metadataListenerCleanup: CleanupFn = null
private downloadListenerCleanup: CleanupFn = null
private completeListenerCleanup: CleanupFn = null
private uploadCompleteListenerCleanup: CleanupFn = null
private transferTimeout = 60000
constructor() {
this.setupListeners()
}
private setupListeners() {
// Listen for download metadata (sent first with file size)
this.metadataListenerCleanup = socket.on(
FSMessages.FSDownloadMetadata,
(metadata: FSDownloadMetadata) => {
this.handleDownloadMetadata(metadata)
}
)
// Listen for download data chunks
this.downloadListenerCleanup = socket.on(
FSMessages.FSDownloadData,
(data: FSDownloadData) => {
this.handleDownloadData(data)
}
)
// Listen for download completion
this.completeListenerCleanup = socket.on(
FSMessages.FSDownloadComplete,
(complete: FSDownloadComplete) => {
this.handleDownloadComplete(complete)
}
)
// Listen for upload completion
this.uploadCompleteListenerCleanup = socket.on(
FSMessages.FSUploadComplete,
(complete: FSUploadComplete) => {
this.handleUploadComplete(complete)
}
)
}
private handleDownloadMetadata(metadata: FSDownloadMetadata) {
// Find the pending download by path (we don't have transferId yet)
// The metadata arrives in response to a download request
const pending = this.pendingDownloads.values().next().value
if (!pending) {
console.warn(`Received download metadata but no pending download`)
return
}
// Clear initial timeout
clearTimeout(pending.timeoutId)
// Get the path from the pending downloads (first one)
const [path] = this.pendingDownloads.keys()
this.pendingDownloads.delete(path)
if (!metadata.success) {
pending.resolve({ success: false, error: metadata.error || 'Download failed' })
return
}
const transferId = metadata.transferId
// Now we know the exact file size - allocate properly sized buffer
const buffer = new Uint8Array(metadata.fileSize)
const download: ActiveDownload = {
path,
buffer,
fileSize: metadata.fileSize,
totalChunks: metadata.totalChunks,
chunksReceived: 0,
bytesReceived: 0,
resolve: pending.resolve,
reject: pending.reject,
onProgress: pending.onProgress,
timeoutId: setTimeout(() => {
this.activeDownloads.delete(transferId)
pending.reject(new Error('Download timeout'))
}, this.transferTimeout)
}
this.activeDownloads.set(transferId, download)
}
private handleDownloadData(data: FSDownloadData) {
const download = this.activeDownloads.get(data.transferId)
if (!download) {
console.warn(`Received download data for unknown transfer: ${data.transferId}`)
return
}
// Reset timeout
clearTimeout(download.timeoutId)
download.timeoutId = setTimeout(() => {
this.activeDownloads.delete(data.transferId)
download.reject(new Error('Download timeout'))
}, this.transferTimeout)
// Copy chunk data to buffer
if (data.data && data.data.length > 0) {
const offset = data.chunkIndex * MAX_CHUNK_SIZE
download.buffer.set(data.data, offset)
download.bytesReceived += data.data.length
download.chunksReceived++
}
// Report progress
if (download.onProgress) {
download.onProgress({
transferId: data.transferId,
bytesTransferred: download.bytesReceived,
totalBytes: download.fileSize,
chunksCompleted: download.chunksReceived,
totalChunks: download.totalChunks,
percentage: (download.chunksReceived / download.totalChunks) * 100
})
}
}
private handleDownloadComplete(complete: FSDownloadComplete) {
const download = this.activeDownloads.get(complete.transferId)
if (!download) {
// This is normal for error cases where transferId wasn't set
if (complete.error) {
console.warn(`Download failed: ${complete.error}`)
}
return
}
clearTimeout(download.timeoutId)
this.activeDownloads.delete(complete.transferId)
if (complete.success) {
// Trim buffer to actual file size
const finalData = download.buffer.slice(0, complete.fileSize)
download.resolve({ success: true, data: finalData })
} else {
download.resolve({ success: false, error: complete.error || 'Download failed' })
}
}
private handleUploadComplete(complete: FSUploadComplete) {
const upload = this.activeUploads.get(complete.transferId)
if (!upload) {
console.warn(`Received upload complete for unknown transfer: ${complete.transferId}`)
return
}
clearTimeout(upload.timeoutId)
this.activeUploads.delete(complete.transferId)
if (complete.success) {
upload.resolve({ success: true })
} else {
upload.resolve({ success: false, error: complete.error || 'Upload failed' })
}
}
/** Delete a file or directory on the ESP32 */
async deleteFile(path: string): Promise<Result> {
const request: FSDeleteRequest = { path }
const response = await socket.request({
fsDeleteRequest: request
})
if (response.fsDeleteResponse) {
return {
success: response.fsDeleteResponse.success,
error: response.fsDeleteResponse.error || undefined
}
}
return { success: false, error: 'No response received' }
}
/** Create a directory on the ESP32 */
async createDirectory(path: string): Promise<Result> {
const request: FSMkdirRequest = { path }
const response = await socket.request({
fsMkdirRequest: request
})
if (response.fsMkdirResponse) {
return {
success: response.fsMkdirResponse.success,
error: response.fsMkdirResponse.error || undefined
}
}
return { success: false, error: 'No response received' }
}
/** List files and directories at the given path */
async listDirectory(path = '/'): Promise<ListResult> {
const request: FSListRequest = { path }
const response = await socket.request({
fsListRequest: request
})
if (response.fsListResponse) {
const resp = response.fsListResponse
return {
success: resp.success,
error: resp.error || undefined,
files: (resp.files || []).map((f) => ({ name: f.name, size: f.size })),
directories: (resp.directories || []).map((d) => ({ name: d.name }))
}
}
return { success: false, error: 'No response received', files: [], directories: [] }
}
/** Download a file from the ESP32 using streaming transfer */
async downloadFile(path: string, onProgress?: ProgressCallback): Promise<DataResult> {
return new Promise((resolve, reject) => {
// Send download request - server will send metadata first, then stream chunks
const request: FSDownloadRequest = { path }
// Set up timeout for initial metadata response
const initialTimeout = setTimeout(() => {
this.pendingDownloads.delete(path)
reject(new Error('Download request timeout - no metadata received'))
}, this.transferTimeout)
// Track this pending download - will be converted to active when metadata arrives
this.pendingDownloads.set(path, {
resolve,
reject,
onProgress,
timeoutId: initialTimeout
})
// Send the download request (server will respond with metadata, then stream data)
socket.request({ fsDownloadRequest: request }).catch((err) => {
clearTimeout(initialTimeout)
this.pendingDownloads.delete(path)
reject(err)
})
})
}
/** Upload a file to the ESP32 using streaming transfer */
async uploadFile(path: string, data: Uint8Array, onProgress?: ProgressCallback): Promise<Result> {
const fileSize = data.length
const chunkSize = MAX_CHUNK_SIZE
const totalChunks = Math.ceil(fileSize / chunkSize) || 1
// Start upload - get transfer ID
const startRequest: FSUploadStart = {
path,
fileSize,
totalChunks
}
const startResponse = await socket.request({
fsUploadStart: startRequest
})
if (!startResponse.fsUploadStartResponse) {
return { success: false, error: 'Failed to start upload' }
}
const startResp = startResponse.fsUploadStartResponse
if (!startResp.success) {
return { success: false, error: startResp.error || 'Failed to start upload' }
}
const transferId = startResp.transferId
return new Promise((resolve, reject) => {
// Set up upload tracking
const upload: ActiveUpload = {
path,
transferId,
totalChunks,
chunksSent: 0,
resolve,
reject,
onProgress,
timeoutId: setTimeout(() => {
this.activeUploads.delete(transferId)
reject(new Error('Upload timeout - no completion received'))
}, this.transferTimeout)
}
this.activeUploads.set(transferId, upload)
// Stream all chunks without waiting for ACKs
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const offset = chunkIndex * chunkSize
const end = Math.min(offset + chunkSize, fileSize)
const chunkData = data.slice(offset, end)
const uploadData: FSUploadData = {
transferId,
chunkIndex,
data: chunkData
}
// Send chunk as fire-and-forget message
socket.emit(FSMessages.FSUploadData, uploadData)
upload.chunksSent++
// Report progress
if (onProgress) {
onProgress({
transferId,
bytesTransferred: end,
totalBytes: fileSize,
chunksCompleted: chunkIndex + 1,
totalChunks,
percentage: ((chunkIndex + 1) / totalChunks) * 100
})
}
}
// All chunks sent - now wait for completion message from server
// The timeout will handle if server doesn't respond
})
}
/** Cancel an ongoing transfer */
async cancelTransfer(transferId: number): Promise<Pick<Result, 'success'>> {
const request: FSCancelTransfer = { transferId }
// Clean up local state
const download = this.activeDownloads.get(transferId)
if (download) {
clearTimeout(download.timeoutId)
this.activeDownloads.delete(transferId)
download.resolve({ success: false, error: 'Transfer cancelled' })
}
const upload = this.activeUploads.get(transferId)
if (upload) {
clearTimeout(upload.timeoutId)
this.activeUploads.delete(transferId)
upload.resolve({ success: false, error: 'Transfer cancelled' })
}
const response = await socket.request({
fsCancelTransfer: request
})
if (response.fsCancelTransferResponse) {
return { success: response.fsCancelTransferResponse.success }
}
return { success: false }
}
/** Upload a File object from browser */
async uploadFileFromBrowser(
destinationPath: string,
file: File,
onProgress?: ProgressCallback
): Promise<Result> {
const arrayBuffer = await file.arrayBuffer()
const data = new Uint8Array(arrayBuffer)
return this.uploadFile(destinationPath, data, onProgress)
}
/** Download a file and save it to browser */
async downloadFileAndSave(
path: string,
filename: string,
onProgress?: ProgressCallback
): Promise<Result> {
const result = await this.downloadFile(path, onProgress)
if (!result.success || !result.data) {
return { success: false, error: result.error }
}
// Create blob and trigger download
const blob = new Blob([result.data.buffer as ArrayBuffer])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
return { success: true }
}
/** Cleanup listeners when no longer needed */
destroy() {
this.metadataListenerCleanup?.()
this.downloadListenerCleanup?.()
this.completeListenerCleanup?.()
this.uploadCompleteListenerCleanup?.()
// Cancel all active transfers
for (const [, download] of this.activeDownloads) {
clearTimeout(download.timeoutId)
download.reject(new Error('FileSystemClient destroyed'))
}
this.activeDownloads.clear()
for (const [, upload] of this.activeUploads) {
clearTimeout(upload.timeoutId)
upload.reject(new Error('FileSystemClient destroyed'))
}
this.activeUploads.clear()
}
}
export const fileSystemClient = new FileSystemClient()
+34 -22
View File
@@ -1,7 +1,6 @@
import { get } from 'svelte/store' import { get } from 'svelte/store'
import type { body_state_t } from './kinematic' import type { body_state_t } from './kinematic'
import { currentKinematic } from './stores/featureFlags' import { currentKinematic } from './stores/featureFlags'
import { ControllerData, WalkGaits } from './platform_shared/message'
export interface gait_state_t { export interface gait_state_t {
step_height: number step_height: number
@@ -12,6 +11,16 @@ export interface gait_state_t {
step_depth: number step_depth: number
} }
export interface ControllerCommand {
lx: number
ly: number
rx: number
ry: number
h: number
s: number
s1: number
}
export abstract class GaitState { export abstract class GaitState {
protected abstract name: string protected abstract name: string
@@ -53,7 +62,7 @@ export abstract class GaitState {
end() { end() {
console.log('Ending', this.name) console.log('Ending', this.name)
} }
step(body_state: body_state_t, command: ControllerData, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
this.map_command(command) this.map_command(command)
this.body_state = body_state this.body_state = body_state
this.dt = dt / 1000 this.dt = dt / 1000
@@ -70,14 +79,14 @@ export abstract class GaitState {
return body_state return body_state
} }
map_command(command: ControllerData) { map_command(command: ControllerCommand) {
const kin = this.kinematic const kin = this.kinematic
this.gait_state = { this.gait_state = {
step_height: command.s1 * kin.max_step_height, step_height: command.s1 * kin.max_step_height,
step_x: command.left!.y * kin.max_step_length, step_x: command.ly * kin.max_step_length,
step_z: -command.left!.x * kin.max_step_length, step_z: -command.lx * kin.max_step_length,
step_velocity: command.speed, step_velocity: command.s,
step_angle: command.right!.x, step_angle: command.rx,
step_depth: kin.default_step_depth step_depth: kin.default_step_depth
} }
} }
@@ -85,7 +94,8 @@ export abstract class GaitState {
export class IdleState extends GaitState { export class IdleState extends GaitState {
protected name = 'Idle' protected name = 'Idle'
step(body_state: body_state_t, command: ControllerData) {
step(body_state: body_state_t, command: ControllerCommand) {
super.step(body_state, command) super.step(body_state, command)
return body_state return body_state
} }
@@ -94,7 +104,7 @@ export class IdleState extends GaitState {
export class CalibrationState extends GaitState { export class CalibrationState extends GaitState {
protected name = 'Calibration' protected name = 'Calibration'
step(body_state: body_state_t, _command: ControllerData) { step(body_state: body_state_t, _command: ControllerCommand) {
super.step(body_state, _command) super.step(body_state, _command)
body_state.omega = 0 body_state.omega = 0
body_state.phi = 0 body_state.phi = 0
@@ -110,7 +120,7 @@ export class CalibrationState extends GaitState {
export class RestState extends GaitState { export class RestState extends GaitState {
protected name = 'Rest' protected name = 'Rest'
step(body_state: body_state_t, _command: ControllerData) { step(body_state: body_state_t, _command: ControllerCommand) {
super.step(body_state, _command) super.step(body_state, _command)
body_state.omega = 0 body_state.omega = 0
body_state.phi = 0 body_state.phi = 0
@@ -126,15 +136,15 @@ export class RestState extends GaitState {
export class StandState extends GaitState { export class StandState extends GaitState {
protected name = 'Stand' protected name = 'Stand'
step(body_state: body_state_t, command: ControllerData) { step(body_state: body_state_t, command: ControllerCommand) {
super.step(body_state, command) super.step(body_state, command)
const kin = this.kinematic const kin = this.kinematic
body_state.omega = 0 body_state.omega = 0
body_state.ym = kin.min_body_height + command.height * kin.body_height_range body_state.ym = kin.min_body_height + command.h * kin.body_height_range
body_state.psi = command.right!.y * kin.max_pitch body_state.psi = command.ry * kin.max_pitch
body_state.phi = command.right!.x * kin.max_roll body_state.phi = command.rx * kin.max_roll
body_state.xm = command.left!.y * kin.max_body_shift_x body_state.xm = command.ly * kin.max_body_shift_x
body_state.zm = command.left!.x * kin.max_body_shift_z body_state.zm = command.lx * kin.max_body_shift_z
body_state.feet = this.default_feet_pos body_state.feet = this.default_feet_pos
return body_state return body_state
} }
@@ -146,7 +156,7 @@ export class BezierState extends GaitState {
protected phase_num = 0 protected phase_num = 0
protected step_length = 0 protected step_length = 0
protected stand_offset = 0.75 protected stand_offset = 0.75
protected mode: WalkGaits = WalkGaits.TROT protected mode: 'crawl' | 'trot' = 'trot'
protected speed_factor = 1 protected speed_factor = 1
offset = [0, 0.5, 0.75, 0.25] offset = [0, 0.5, 0.75, 0.25]
@@ -168,9 +178,11 @@ export class BezierState extends GaitState {
super.begin() super.begin()
} }
set_mode(mode: WalkGaits, duty?: number, order?: [number, number, number, number]) { set_mode(mode: 'crawl' | 'trot', duty?: number, order?: [number, number, number, number]) {
console.log('BezierState set_mode', mode)
this.mode = mode this.mode = mode
if (mode === WalkGaits.CRAWL) { if (mode === 'crawl') {
this.speed_factor = 0.5 this.speed_factor = 0.5
this.stand_offset = duty ?? 0.85 this.stand_offset = duty ?? 0.85
const o = order ?? [3, 0, 2, 1] const o = order ?? [3, 0, 2, 1]
@@ -189,10 +201,10 @@ export class BezierState extends GaitState {
super.end() super.end()
} }
step(body_state: body_state_t, command: ControllerData, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super.step(body_state, command, dt) super.step(body_state, command, dt)
const kin = this.kinematic const kin = this.kinematic
this.body_state.ym = kin.min_body_height + command.height * kin.body_height_range this.body_state.ym = kin.min_body_height + command.h * kin.body_height_range
this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2) this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2)
if (this.gait_state.step_x < 0) this.step_length = -this.step_length if (this.gait_state.step_x < 0) this.step_length = -this.step_length
this.update_phase() this.update_phase()
@@ -220,7 +232,7 @@ export class BezierState extends GaitState {
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0 const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (!moving) return if (!moving) return
if (this.mode !== WalkGaits.CRAWL) return if (this.mode !== 'crawl') return
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states() const { stance, swing, next_swing, time_to_lift } = this.get_leg_states()
+56 -23
View File
@@ -1,34 +1,67 @@
import { AnalyticsData } from '$lib/platform_shared/message' import { type Analytics } from '$lib/types/models'
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import { socket } from './socket'
const analytics_data = {
uptime: <number[]>[],
free_heap: <number[]>[],
total_heap: <number[]>[],
used_heap: <number[]>[],
min_free_heap: <number[]>[],
max_alloc_heap: <number[]>[],
fs_used: <number[]>[],
fs_total: <number[]>[],
core_temp: <number[]>[],
cpu0_usage: <number[]>[],
cpu1_usage: <number[]>[],
cpu_usage: <number[]>[]
}
const maxAnalyticsData = 100 const maxAnalyticsData = 100
function createAnalytics() { function createAnalytics() {
const { subscribe, update } = writable<AnalyticsData[]>([]) const { subscribe, update } = writable(analytics_data)
let unsubscribe: (() => void) | null = null
let listenerCount = 0
const addData = (content: AnalyticsData) => {
update(data => [...data, content].slice(-maxAnalyticsData))
}
return { return {
subscribe, subscribe,
addData, addData: (content: Analytics) => {
listen: () => { update(analytics_data => ({
listenerCount++ ...analytics_data,
if (!unsubscribe) { uptime: [...analytics_data.uptime, content.uptime].slice(-maxAnalyticsData),
unsubscribe = socket.on(AnalyticsData, addData) free_heap: [...analytics_data.free_heap, content.free_heap / 1000].slice(
} -maxAnalyticsData
}, ),
stop: () => { total_heap: [...analytics_data.total_heap, content.total_heap / 1000].slice(
listenerCount = Math.max(0, listenerCount - 1) -maxAnalyticsData
if (listenerCount === 0 && unsubscribe) { ),
unsubscribe() used_heap: [
unsubscribe = null ...analytics_data.used_heap,
} (content.total_heap - content.free_heap) / 1000
].slice(-maxAnalyticsData),
min_free_heap: [
...analytics_data.min_free_heap,
content.min_free_heap / 1000
].slice(-maxAnalyticsData),
max_alloc_heap: [
...analytics_data.max_alloc_heap,
content.max_alloc_heap / 1000
].slice(-maxAnalyticsData),
fs_used: [...analytics_data.fs_used, content.fs_used / 1000].slice(
-maxAnalyticsData
),
fs_total: [...analytics_data.fs_total, content.fs_total / 1000].slice(
-maxAnalyticsData
),
core_temp: [...analytics_data.core_temp, content.core_temp].slice(
-maxAnalyticsData
),
cpu0_usage: [...analytics_data.cpu0_usage, content.cpu0_usage].slice(
-maxAnalyticsData
),
cpu1_usage: [...analytics_data.cpu1_usage, content.cpu1_usage].slice(
-maxAnalyticsData
),
cpu_usage: [...analytics_data.cpu_usage, content.cpu_usage].slice(-maxAnalyticsData)
}))
} }
} }
} }
+13 -1
View File
@@ -4,6 +4,7 @@ import { get, type Writable } from 'svelte/store'
import Visualization from '$lib/components/Visualization.svelte' import Visualization from '$lib/components/Visualization.svelte'
import Stream from '$lib/components/Stream.svelte' import Stream from '$lib/components/Stream.svelte'
import ChartWidget from '$lib/components/widget/ChartWidget.svelte' import ChartWidget from '$lib/components/widget/ChartWidget.svelte'
import SkillPanel from '$lib/components/SkillPanel.svelte'
export interface WidgetConfig { export interface WidgetConfig {
id: string | number id: string | number
@@ -25,7 +26,8 @@ export const isWidgetConfig = (
export const WidgetComponents = { export const WidgetComponents = {
Visualization, Visualization,
Stream, Stream,
ChartWidget ChartWidget,
SkillPanel
} }
interface View { interface View {
@@ -59,6 +61,16 @@ const defaultViews: View[] = [
{ id: 2, component: 'Visualization', props: { debug: true } } { id: 2, component: 'Visualization', props: { debug: true } }
] ]
} }
},
{
name: 'Skills',
content: {
id: 'root',
widgets: [
{ id: 1, component: 'Visualization', props: { debug: true } },
{ id: 2, component: 'SkillPanel' }
]
}
} }
] ]
+7 -15
View File
@@ -1,9 +1,9 @@
import { api } from '$lib/api'
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications'
import Kinematic from '$lib/kinematic' import Kinematic from '$lib/kinematic'
import { persistentStore } from '$lib/utilities' import { persistentStore } from '$lib/utilities'
import { derived, type Writable } from 'svelte/store' import { derived, type Writable } from 'svelte/store'
import { resolve } from '$app/paths' import { resolve } from '$app/paths'
import { socket } from '$lib/stores'
let featureFlagsStore: Writable<Record<string, boolean | string>> let featureFlagsStore: Writable<Record<string, boolean | string>>
@@ -11,20 +11,12 @@ export function useFeatureFlags() {
if (!featureFlagsStore) { if (!featureFlagsStore) {
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {}) featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
socket api.get<Record<string, boolean>>('/api/features').then(result => {
.request({ featuresDataRequest: {} }) if (result.isOk()) featureFlagsStore.set(result.inner)
.then(response => { else {
if (response.featuresDataResponse) { notifications.error('Feature flag could not be fetched', 2500)
featureFlagsStore.set( }
response.featuresDataResponse as unknown as Record<string, boolean | string> })
)
} else {
notifications.error('Feature flags could not be fetched', 2500)
}
})
.catch(() => {
notifications.error('Feature flags could not be fetched', 2500)
})
} }
return featureFlagsStore return featureFlagsStore
+1 -2
View File
@@ -4,8 +4,7 @@ export const isFullscreen = writable(false)
export function toggleFullscreen() { export function toggleFullscreen() {
isFullscreen.update(state => { isFullscreen.update(state => {
if (!state) document.documentElement.requestFullscreen() !state ? document.documentElement.requestFullscreen() : document.exitFullscreen()
else document.exitFullscreen()
return !state return !state
}) })
} }
+30 -24
View File
@@ -1,34 +1,40 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import { IMUData } from '$lib/platform_shared/message' import type { IMUMsg } from '$lib/types/models'
import { socket } from './socket'
const maxIMUData = 100 const maxIMUData = 100
export const imu = (() => { export const imu = (() => {
const { subscribe, update } = writable<IMUData[]>([]) const { subscribe, update } = writable({
x: [] as number[],
y: [] as number[],
z: [] as number[],
heading: [] as number[],
altitude: [] as number[],
pressure: [] as number[],
bmp_temp: [] as number[]
})
let unsubscribe: (() => void) | null = null const addData = (content: IMUMsg) => {
let listenerCount = 0 update(data => {
if (content.imu && content.imu[4]) {
data.x = [...data.x, content.imu[0]].slice(-maxIMUData)
data.y = [...data.y, content.imu[1]].slice(-maxIMUData)
data.z = [...data.z, content.imu[2]].slice(-maxIMUData)
}
const addData = (content: IMUData) => { if (content.mag && content.mag[4]) {
update(data => [...data, content].slice(-maxIMUData)) data.heading = [...data.heading, content.mag[3]].slice(-maxIMUData)
}
if (content.bmp && content.bmp[3]) {
data.pressure = [...data.pressure, content.bmp[0]].slice(-maxIMUData)
data.altitude = [...data.altitude, content.bmp[1]].slice(-maxIMUData)
data.bmp_temp = [...data.bmp_temp, content.bmp[2]].slice(-maxIMUData)
}
return data
})
} }
return { return { subscribe, addData }
subscribe,
addData,
listen: () => {
listenerCount++
if (!unsubscribe) {
unsubscribe = socket.on(IMUData, addData)
}
},
stop: () => {
listenerCount = Math.max(0, listenerCount - 1)
if (listenerCount === 0 && unsubscribe) {
unsubscribe()
unsubscribe = null
}
}
}
})() })()
+1
View File
@@ -7,3 +7,4 @@ export * from './telemetry'
export * from './analytics' export * from './analytics'
export * from './featureFlags' export * from './featureFlags'
export * from './location-store' export * from './location-store'
export * from './skill'
+40 -42
View File
@@ -1,12 +1,4 @@
import Kinematic from '$lib/kinematic' import type { ControllerInput } from '$lib/types/models'
import {
ControllerData,
KinematicData,
ModeData,
ModesEnum,
WalkGaitData,
WalkGaits
} from '$lib/platform_shared/message'
import { persistentStore } from '$lib/utilities/svelte-utilities' import { persistentStore } from '$lib/utilities/svelte-utilities'
import { writable, type Writable } from 'svelte/store' import { writable, type Writable } from 'svelte/store'
@@ -16,41 +8,47 @@ export const jointNames = persistentStore('joint_names', <string[]>[])
export const model = writable() export const model = writable()
export const mode: Writable<ModeData> = writable(ModeData.create({ mode: ModesEnum.DEACTIVATED })) export const modes = ['deactivated', 'idle', 'calibration', 'rest', 'stand', 'walk'] as const
export const walkGait: Writable<WalkGaitData> = writable( export type Modes = (typeof modes)[number]
WalkGaitData.create({ gait: WalkGaits.TROT })
)
export const kinematicData = writable(KinematicData.create()) export enum ModesEnum {
Deactivated = 0,
export const input: Writable<ControllerData> = writable( Idle = 1,
ControllerData.create({ Calibration = 2,
left: { x: 0, y: 0 }, Rest = 3,
right: { x: 0, y: 0 }, Stand = 4,
height: 0.7, Walk = 5
s1: 0.5,
speed: 0.5
})
)
function enumToValuesAndLabels<T extends number>(enumObj: Record<string, T | string>) {
const entries = Object.entries(enumObj).filter(
([key, v]) => typeof v === 'number' && key !== 'UNRECOGNIZED'
) as [string, T][]
return {
values: entries.map(([, v]) => v),
labels: Object.fromEntries(
entries.map(([k, v]) => [v, k.charAt(0) + k.slice(1).toLowerCase()])
) as Record<T, string>
}
} }
const modesData = enumToValuesAndLabels<ModesEnum>(ModesEnum) export enum WalkGaits {
export const modes = modesData.values Trot = 0,
export const modeLabels = modesData.labels Crawl = 1
}
const walkGaitsData = enumToValuesAndLabels<WalkGaits>(WalkGaits) export const walkGaits = ['trot', 'crawl'] as const
export const walkGaits = walkGaitsData.values
export const walkGaitLabels = walkGaitsData.labels export const walkGaitLabels: Record<WalkGaits, string> = {
[WalkGaits.Trot]: 'Trot',
[WalkGaits.Crawl]: 'Crawl'
}
export const walkGaitToMode = (gait: WalkGaits): 'trot' | 'crawl' => {
return gait === WalkGaits.Trot ? 'trot' : 'crawl'
}
export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated)
export const walkGait: Writable<WalkGaits> = writable(WalkGaits.Trot)
export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0])
export const kinematicData = writable([0, 0, 0, 0, 1, 0])
export const input: Writable<ControllerInput> = writable({
left: { x: 0, y: 0 },
right: { x: 0, y: 0 },
height: 0.5,
speed: 0.5,
s1: 0.5
})
+85
View File
@@ -0,0 +1,85 @@
import { writable, derived } from 'svelte/store'
import { socket } from './socket'
import { MessageTopic, type SkillStatus, type SkillCommand } from '$lib/types/models'
const defaultStatus: SkillStatus = {
x: 0,
y: 0,
z: 0,
yaw: 0,
distance: 0,
skill_active: false,
skill_target_x: 0,
skill_target_z: 0,
skill_target_yaw: 0,
skill_traveled_x: 0,
skill_traveled_z: 0,
skill_rotated: 0,
skill_progress: 0,
skill_complete: false
}
function createSkillStore() {
const status = writable<SkillStatus>(defaultStatus)
const history = writable<SkillCommand[]>([])
let unsubscribe: (() => void) | null = null
function init() {
if (unsubscribe) return
unsubscribe = socket.on<SkillStatus>(MessageTopic.skillStatus, data => {
status.set(data)
if (data.event === 'complete') {
history.update(h => [...h.slice(-9), getCurrentTarget(data)])
}
})
}
function getCurrentTarget(s: SkillStatus): SkillCommand {
return { x: s.skill_target_x, z: s.skill_target_z, yaw: s.skill_target_yaw }
}
function execute(cmd: SkillCommand) {
socket.sendEvent(MessageTopic.skill, cmd)
}
function walk(x: number, z: number = 0, yaw: number = 0, speed: number = 0.5) {
execute({ x, z, yaw, speed })
}
function turn(yaw: number, speed: number = 0.5) {
execute({ x: 0, z: 0, yaw, speed })
}
function stop() {
socket.sendEvent(MessageTopic.displacement, { action: 'clear' })
}
function resetPosition() {
socket.sendEvent(MessageTopic.displacement, { action: 'reset' })
}
function destroy() {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
}
return {
status,
history,
init,
destroy,
execute,
walk,
turn,
stop,
resetPosition,
isActive: derived(status, $s => $s.skill_active),
progress: derived(status, $s => $s.skill_progress),
position: derived(status, $s => ({ x: $s.x, y: $s.y, z: $s.z, yaw: $s.yaw }))
}
}
export const skill = createSkillStore()
+23 -8
View File
@@ -1,12 +1,27 @@
import { AnglesData } from '$lib/platform_shared/message'
import { writable, type Writable } from 'svelte/store' import { writable, type Writable } from 'svelte/store'
import { type angles } from '$lib/types/models'
export const servoAnglesOut: Writable<AnglesData> = writable( export const servoAnglesOut: Writable<number[]> = writable([
AnglesData.create({ angles: [0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90] }) 0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
) ])
export const servoAngles: Writable<AnglesData> = writable( export const servoAngles: Writable<number[]> = writable([
AnglesData.create({ angles: [0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90] }) 0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
) ])
export const logs = writable([] as string[])
export const mpu = writable({ heading: 0 }) export const mpu = writable({ heading: 0 })
export const sonar = writable([0, 0]) export const sonar = writable([0, 0])
export const distances = writable({})
export interface socketDataCollection {
angles: Writable<angles>
logs: Writable<string[]>
mpu: Writable<unknown>
distances: Writable<unknown>
}
export const socketData = {
angles: servoAngles,
logs,
mpu,
distances
}
+78 -229
View File
@@ -1,121 +1,44 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import { import { encode, decode } from '@msgpack/msgpack'
Message,
CorrelationRequest,
CorrelationResponse,
protoMetadata,
type MessageFns
} from '$lib/platform_shared/message'
import * as Messages from '$lib/platform_shared/message'
import { protoMetadata as filesystemProtoMetadata } from '$lib/platform_shared/filesystem'
export const MESSAGE_TYPE_TO_KEY = new Map<MessageFns<unknown>, string>() const socketEvents = ['open', 'close', 'error', 'message', 'unresponsive'] as const
export const MESSAGE_TYPE_TO_TAG = new Map<MessageFns<unknown>, number>() type SocketEvent = (typeof socketEvents)[number]
export const MESSAGE_KEY_TO_TAG = new Map<string, number>()
export const MESSAGE_TAG_TO_KEY = new Map<number, string>()
type CorrelationRequestData = Omit<CorrelationRequest, 'correlationId'> type SocketMessage = [number, string?, unknown?]
type PendingRequest = {
resolve: (response: CorrelationResponse) => void
reject: (error: Error) => void
timeoutId: ReturnType<typeof setTimeout>
}
// Combine references from both message.proto and filesystem.proto let useBinary = false
const combinedReferences: Record<string, MessageFns<unknown>> = {
...protoMetadata.references,
...filesystemProtoMetadata.references
}
const MessageType = protoMetadata.fileDescriptor.messageType?.find( const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => {
(msg: { name: string }) => msg.name === 'Message' useBinary = data instanceof ArrayBuffer
)
if (MessageType?.field) { try {
for (const field of MessageType.field) { if (useBinary) {
if (field.typeName) { return decode(new Uint8Array(data as ArrayBuffer)) as SocketMessage
const messageFns = combinedReferences[field.typeName]
if (messageFns && field.jsonName && field.number) {
MESSAGE_TYPE_TO_KEY.set(messageFns, field.jsonName)
MESSAGE_TYPE_TO_TAG.set(messageFns, field.number)
MESSAGE_KEY_TO_TAG.set(field.jsonName, field.number)
MESSAGE_TAG_TO_KEY.set(field.number, field.jsonName)
}
} }
return JSON.parse(data as string)
} catch (error) {
console.error(`Could not decode data: ${new Uint8Array(data as ArrayBuffer)} - ${error}`)
} }
return null
} }
function getNameFromMessageType<T>(event_type: MessageFns<T>): string { const encodeMessage = (data: unknown) => {
const event = MESSAGE_TYPE_TO_KEY.get(event_type as MessageFns<unknown>) try {
if (!event) { return useBinary ? encode(data) : JSON.stringify(data)
throw new Error( } catch (error) {
"Event type not found in 'Message'. The MessageFns you passed doesn't correspond to any Message field." console.error(`Could not encode data: ${data} - ${error}`)
)
} }
return event
}
function getTagFromMessageType<T>(event_type: MessageFns<T>): number {
const fieldNumber = MESSAGE_TYPE_TO_TAG.get(event_type as MessageFns<unknown>)
if (fieldNumber === undefined) {
throw new Error(
"Tag not found in 'Message'. The MessageFns you passed doesn't correspond to any Message field."
)
}
return fieldNumber
}
type SocketEvent = 'open' | 'close' | 'error' | 'message' | 'unresponsive'
type TaggedMessage = { tag: number; msg: Message }
export const decodeMessage = (data: ArrayBuffer): TaggedMessage => {
const decoded = Message.decode(new Uint8Array(data))
const values = Object.entries(decoded).filter(([, value]) => value !== undefined)
if (values.length != 1) {
throw new Error('Message included either 0 or more than 1 data point')
}
const fieldName = values[0][0]
const tag = MESSAGE_KEY_TO_TAG.get(fieldName)
if (tag === undefined) {
throw new Error(`Tag not found for field: ${fieldName}`)
}
return { tag: tag, msg: decoded }
}
export const encodeMessage = (data: Message): Uint8Array<ArrayBuffer> => {
const encoded = Message.encode(data).finish()
return encoded
} }
function createWebSocket() { function createWebSocket() {
const message_listeners = new Map<number, Set<(data?: unknown) => void>>() const listeners = new Map<string, Set<(data?: unknown) => void>>()
const event_listeners = new Map<string, Set<(data?: unknown) => void>>()
const pending_requests = new Map<number, PendingRequest>()
const queued_requests = new Map<
string,
{
data: CorrelationRequestData
resolve: (r: CorrelationResponse) => void
reject: (e: Error) => void
}
>()
const { subscribe, set } = writable(false) const { subscribe, set } = writable(false)
const reconnectTimeoutTime = 500000 const reconnectTimeoutTime = 5000
const requestTimeoutTime = 30000
let correlationIdCounter = 0
let unresponsiveTimeoutId: ReturnType<typeof setTimeout> let unresponsiveTimeoutId: ReturnType<typeof setTimeout>
let reconnectTimeoutId: ReturnType<typeof setTimeout> let reconnectTimeoutId: ReturnType<typeof setTimeout>
let ws: WebSocket let ws: WebSocket
let socketUrl: string | URL let socketUrl: string | URL
function getRequestKey(data: CorrelationRequestData): string {
return (
Object.keys(data).find(k => data[k as keyof CorrelationRequestData] !== undefined) ??
'unknown'
)
}
function init(url: string | URL) { function init(url: string | URL) {
socketUrl = url socketUrl = url
connect() connect()
@@ -126,7 +49,7 @@ function createWebSocket() {
set(false) set(false)
clearTimeout(unresponsiveTimeoutId) clearTimeout(unresponsiveTimeoutId)
clearTimeout(reconnectTimeoutId) clearTimeout(reconnectTimeoutId)
event_listeners.get(reason)?.forEach(listener => listener(event)) listeners.get(reason)?.forEach(listener => listener(event))
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime) reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime)
} }
@@ -134,61 +57,40 @@ function createWebSocket() {
ws = new WebSocket(socketUrl) ws = new WebSocket(socketUrl)
ws.binaryType = 'arraybuffer' ws.binaryType = 'arraybuffer'
ws.onopen = ev => { ws.onopen = ev => {
ping()
useBinary = true
ping() ping()
set(true) set(true)
clearTimeout(reconnectTimeoutId) clearTimeout(reconnectTimeoutId)
resubscribeAll() listeners.get('open')?.forEach(listener => listener(ev))
flushQueuedRequests() for (const event of listeners.keys()) {
event_listeners.get('open')?.forEach(listener => listener(ev)) if (socketEvents.includes(event as SocketEvent)) continue
subscribeToEvent(event)
}
} }
ws.onmessage = frame => { ws.onmessage = frame => {
resetUnresponsiveCheck() resetUnresponsiveCheck()
const message = decodeMessage(frame.data)
for (const [correlationId, pending] of pending_requests) { if (!message) return
clearTimeout(pending.timeoutId) const [, event, payload = undefined] = message
pending.timeoutId = setTimeout(() => { if (event) listeners.get(event)?.forEach(listener => listener(payload))
pending_requests.delete(correlationId)
pending.reject(new Error(`Request timeout (id: ${correlationId})`))
}, requestTimeoutTime)
}
const { tag, msg } = decodeMessage(frame.data)
if (msg.correlationResponse) {
const pending = pending_requests.get(msg.correlationResponse.correlationId)
if (pending) {
clearTimeout(pending.timeoutId)
pending_requests.delete(msg.correlationResponse.correlationId)
pending.resolve(msg.correlationResponse)
}
return
}
if (tag) {
const key = MESSAGE_TAG_TO_KEY.get(tag)!
message_listeners
.get(tag)
?.forEach(listener => listener(msg[key as keyof typeof msg]))
}
} }
ws.onerror = ev => disconnect('error', ev) ws.onerror = ev => disconnect('error', ev)
ws.onclose = ev => disconnect('close', ev) ws.onclose = ev => disconnect('close', ev)
} }
function unsubscribe<MT>(event_type: MessageFns<MT>, listener: (data: MT) => void) { function unsubscribe(event: string, listener?: (data: unknown) => void) {
const tag = getTagFromMessageType(event_type) const eventListeners = listeners.get(event)
const message_listeners_totag = message_listeners.get(tag) if (!eventListeners) return
if (!message_listeners_totag) return
message_listeners_totag?.delete(listener as (data?: unknown) => void) if (!eventListeners.size) {
if (message_listeners_totag.size == 0) { unsubscribeToEvent(event)
unsubscribeToMessageFromServer(event_type) }
if (listener) {
eventListeners?.delete(listener)
} else {
listeners.delete(event)
} }
}
function unsubscribeEvent(event_type: SocketEvent, listener: (data: unknown) => void) {
const message_listeners_totag = event_listeners.get(event_type)
if (!message_listeners_totag) return
message_listeners_totag?.delete(listener)
} }
function resetUnresponsiveCheck() { function resetUnresponsiveCheck() {
@@ -196,114 +98,61 @@ function createWebSocket() {
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime) unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime)
} }
function emit<T>(event: MessageFns<T>, data: T) { function sendEvent(event: string, data: unknown) {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!ws || ws.readyState !== WebSocket.OPEN) return
const type = getNameFromMessageType(event) send([2, event, data])
const wsm = Message.create() as Record<string, unknown>
wsm[type] = data
send(wsm as Message)
} }
function unsubscribeToMessageFromServer<T>(event_type: MessageFns<T>) { function unsubscribeToEvent(event: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!ws || ws.readyState !== WebSocket.OPEN) return
const unsub_msg = Messages.UnsubscribeNotification.create({ send([1, event])
tag: getTagFromMessageType(event_type)
})
send(Message.create({ unsubNotif: unsub_msg }))
} }
function subscribeToEvent<T>(event_type: MessageFns<T>) { function subscribeToEvent(event: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!ws || ws.readyState !== WebSocket.OPEN) return
const sub_msg = Messages.SubscribeNotification.create({ send([0, event])
tag: getTagFromMessageType(event_type)
})
send(Message.create({ subNotif: sub_msg }))
} }
function resubscribeAll() { function send(data: unknown) {
for (const tag of message_listeners.keys()) { if (!ws || ws.readyState !== WebSocket.OPEN) return
const sub_msg = Messages.SubscribeNotification.create({ tag }) const serialized = encodeMessage(data)
send(Message.create({ subNotif: sub_msg })) if (!serialized) {
console.error('Could not serialize data:', data)
return
} }
} ws.send(serialized)
function send(data: Message) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const encoded = encodeMessage(data)
ws.send(encoded)
} }
function ping() { function ping() {
send(Message.create({ pingmsg: {} })) const serialized = encodeMessage([4])
} if (!serialized) {
console.error('Could not serialize message')
function request( return
data: CorrelationRequestData,
resolve: (r: CorrelationResponse) => void,
reject: (e: Error) => void
) {
const correlationId = ++correlationIdCounter
const timeoutId = setTimeout(() => {
pending_requests.delete(correlationId)
reject(new Error(`Request timeout (id: ${correlationId})`))
}, requestTimeoutTime)
pending_requests.set(correlationId, { resolve, reject, timeoutId })
const request = CorrelationRequest.create({ correlationId, ...data })
send(Message.create({ correlationRequest: request }))
}
function flushQueuedRequests() {
for (const [, { data, resolve, reject }] of queued_requests) {
request(data, resolve, reject)
} }
queued_requests.clear() ws.send(serialized)
} }
return { return {
subscribe, subscribe,
emit, sendEvent,
init, init,
on: <MT>(event_type: MessageFns<MT>, listener: (data: MT) => void): (() => void) => { on: <T>(event: string, listener: (data: T) => void): (() => void) => {
const tag = getTagFromMessageType(event_type) let eventListeners = listeners.get(event)
if (!eventListeners) {
let message_listeners_totag = message_listeners.get(tag) if (!socketEvents.includes(event as SocketEvent)) {
if (!message_listeners_totag) { subscribeToEvent(event)
message_listeners_totag = new Set()
message_listeners.set(tag, message_listeners_totag)
subscribeToEvent(event_type)
}
message_listeners_totag.add(listener as (data: unknown) => void)
return () => {
unsubscribe(event_type, listener)
}
},
onEvent: (event_type: SocketEvent, listener: (data: unknown) => void): (() => void) => {
let listeners = event_listeners.get(event_type)
if (!listeners) {
listeners = new Set()
event_listeners.set(event_type, listeners)
}
listeners.add(listener)
return () => {
unsubscribeEvent(event_type, listener)
}
},
request: (data: CorrelationRequestData): Promise<CorrelationResponse> => {
return new Promise((resolve, reject) => {
if (ws && ws.readyState === WebSocket.OPEN) {
request(data, resolve, reject)
} else {
const key = getRequestKey(data)
const existing = queued_requests.get(key)
if (existing) {
existing.reject(new Error('Request superseded by newer request'))
}
queued_requests.set(key, { data, resolve, reject })
} }
}) eventListeners = new Set()
listeners.set(event, eventListeners)
}
eventListeners.add(listener as (data: unknown) => void)
return () => {
unsubscribe(event, listener as (data: unknown) => void)
}
},
off: <T>(event: string, listener?: (data: T) => void) => {
unsubscribe(event, listener as (data: unknown) => void)
} }
} }
} }
+20 -18
View File
@@ -1,31 +1,33 @@
import { DownloadOTAData, RSSIData } from '$lib/platform_shared/message' import type { DownloadOTA } from '$lib/types/models'
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
type telemetry_data_type = { const telemetry_data = {
rssi: RSSIData rssi: {
download_ota: DownloadOTAData rssi: 0
},
download_ota: {
status: 'none',
progress: 0,
error: ''
}
} }
const telemetry_data: telemetry_data_type = {
rssi: RSSIData.create(),
download_ota: DownloadOTAData.create()
} // Note: perhaps init these as null instead of an undefined create()
function createTelemetry() { function createTelemetry() {
const { subscribe, update } = writable(telemetry_data) const { subscribe, update } = writable(telemetry_data)
return { return {
subscribe, subscribe,
setRSSI: (data: RSSIData) => { setRSSI: (data: number) => {
update(telemetry_data => { update(telemetry_data => ({
telemetry_data.rssi = data ...telemetry_data,
return telemetry_data rssi: { rssi: data }
}) }))
}, },
setDownloadOTA: (data: DownloadOTAData) => { setDownloadOTA: (data: DownloadOTA) => {
update(telemetry_data => { update(telemetry_data => ({
telemetry_data.download_ota = data ...telemetry_data,
return telemetry_data download_ota: { status: data.status, progress: data.progress, error: data.error }
}) }))
} }
} }
} }
+220 -25
View File
@@ -14,11 +14,22 @@ export enum MessageTopic {
servoPWM = 'servoPWM', servoPWM = 'servoPWM',
WiFiSettings = 'WiFiSettings', WiFiSettings = 'WiFiSettings',
sonar = 'sonar', sonar = 'sonar',
rssi = 'rssi' rssi = 'rssi',
skill = 'skill',
skillStatus = 'skill_status',
displacement = 'displacement'
} }
export type vector = { x: number; y: number } export type vector = { x: number; y: number }
export interface ControllerInput {
left: vector
right: vector
height: number
speed: number
s1: number
}
export type GithubRelease = { export type GithubRelease = {
message: string message: string
tag_name: string tag_name: string
@@ -28,11 +39,175 @@ export type GithubRelease = {
}> }>
} }
export type angles = number[] | Int16Array
export type WifiStatus = {
status: number
local_ip: string
mac_address: string
rssi: number
ssid: string
bssid: string
channel: number
subnet_mask: string
gateway_ip: string
dns_ip_1: string
dns_ip_2?: string
}
export type WifiSettings = {
hostname: string
priority_RSSI: boolean
wifi_networks: KnownNetworkItem[]
}
export type NetworkList = {
networks: NetworkItem[]
}
export type KnownNetworkItem = {
ssid: string
password: string
static_ip_config: boolean
local_ip?: string
subnet_mask?: string
gateway_ip?: string
dns_ip_1?: string
dns_ip_2?: string
}
export type NetworkItem = {
rssi: number
ssid: string
bssid: string
channel: number
encryption_type: number
}
export type ApStatus = {
status: number
ip_address: string
mac_address: string
station_num: number
}
export type ApSettings = {
provision_mode: number
ssid: string
password: string
channel: number
ssid_hidden: boolean
max_clients: number
local_ip: string
gateway_ip: string
subnet_mask: string
}
export type DownloadOTA = {
status: string
progress: number
error: string
}
export type Analytics = {
max_alloc_heap: number
psram_size: number
free_psram: number
free_heap: number
total_heap: number
min_free_heap: number
core_temp: number
fs_total: number
fs_used: number
uptime: number
cpu0_usage: number
cpu1_usage: number
cpu_usage: number
}
export type Rssi = { export type Rssi = {
rssi: number rssi: number
ssid: string ssid: string
} }
export type StaticSystemInformation = {
esp_platform: string
firmware_version: string
cpu_freq_mhz: number
cpu_type: string
cpu_rev: number
cpu_cores: number
sketch_size: number
free_sketch_space: number
sdk_version: string
arduino_version: string
flash_chip_size: number
flash_chip_speed: number
cpu_reset_reason: string
}
export type SystemInformation = Analytics & StaticSystemInformation
export type IMU = {
x: number
y: number
z: number
heading: number
altitude: number
bmp_temp: number
pressure: number
}
export type IMUMsg = {
imu: [number, number, number, number, boolean]
mag: [number, number, number, number, boolean]
bmp: [number, number, number, boolean]
}
export type IMUCalibrationResult = {
success: boolean
}
export interface I2CDevice {
address: number
part_number: string
name: string
}
export type PinConfig = {
pin: number
mode: string
type: string
role: string
}
export type PeripheralsConfiguration = {
sda: number
scl: number
frequency: number
pins: PinConfig[]
}
export type CameraSettings = {
framesize: number
quality: number
brightness: number
contrast: number
saturation: number
sharpness: number
denoise: number
special_effect: number
wb_mode: number
vflip: boolean
hmirror: boolean
}
export type File = number
export interface Directory {
[key: string]: File | Directory
}
export type Servo = { export type Servo = {
name: string name: string
channel: number channel: number
@@ -48,36 +223,56 @@ export type ServoConfiguration = {
servos: Servo[] servos: Servo[]
} }
export interface Result { export interface MDNSServiceQuery {
success: boolean services: MDNSServiceItem[]
error?: string
} }
export interface DataResult extends Result { export interface MDNSServiceItem {
data?: Uint8Array ip: string
} port: number
export interface FileInfo {
name: string
size: number
}
export interface DirectoryInfo {
name: string name: string
} }
export interface ListResult extends Result { export interface MDNSService {
files: FileInfo[] service: string
directories: DirectoryInfo[] protocol: string
port: number
} }
export interface TransferProgress { export interface MDNSTxtRecord {
transferId: number key: string
bytesTransferred: number value: string
totalBytes: number
chunksCompleted: number
totalChunks: number
percentage: number
} }
export type ProgressCallback = (progress: TransferProgress) => void export interface MDNSStatus {
started: boolean
hostname: string
instance: string
services: MDNSService[]
global_txt_records: MDNSTxtRecord[]
}
export interface SkillCommand {
x: number
z: number
yaw: number
speed?: number
}
export interface SkillStatus {
x: number
y: number
z: number
yaw: number
distance: number
skill_active: boolean
skill_target_x: number
skill_target_z: number
skill_target_yaw: number
skill_traveled_x: number
skill_traveled_z: number
skill_rotated: number
skill_progress: number
skill_complete: boolean
event?: string
}
+1 -1
View File
@@ -1,4 +1,4 @@
export class Throttler { export class throttler {
private _throttlePause: boolean private _throttlePause: boolean
constructor() { constructor() {
this._throttlePause = false this._throttlePause = false
-1
View File
@@ -6,4 +6,3 @@ export * from './buffer-utilities'
export * from './model-utilities' export * from './model-utilities'
export * from './string-utilities' export * from './string-utilities'
export * from './color-utilities' export * from './color-utilities'
export * from './ip-utilities'
-23
View File
@@ -1,23 +0,0 @@
export function ipToUint32(ip: string): number {
const parts = ip.split('.')
if (parts.length !== 4) return 0
return (
(parseInt(parts[0], 10) |
(parseInt(parts[1], 10) << 8) |
(parseInt(parts[2], 10) << 16) |
(parseInt(parts[3], 10) << 24)) >>>
0
)
}
export function uint32ToIp(ip: number): string {
return [ip & 0xff, (ip >>> 8) & 0xff, (ip >>> 16) & 0xff, (ip >>> 24) & 0xff].join('.')
}
export function isValidIpString(ip: string | undefined): boolean {
if (!ip) return false
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
return regexExp.test(ip)
}
+1 -1
View File
@@ -29,7 +29,7 @@ export const cacheModelFiles = async () => {
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) { for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const normalizedPath = path.startsWith('/') ? path : '/' + path const normalizedPath = path.startsWith('/') ? path : '/' + path
const resolvedUrl = `${resolve('/')}${normalizedPath}` const resolvedUrl = resolve(normalizedPath as any)
fileService?.saveFile(resolvedUrl, data) fileService?.saveFile(resolvedUrl, data)
fileService?.saveFile(normalizedPath, data) fileService?.saveFile(normalizedPath, data)
} }
+36 -39
View File
@@ -10,9 +10,11 @@
import Statusbar from '../lib/components/statusbar/statusbar.svelte' import Statusbar from '../lib/components/statusbar/statusbar.svelte'
import { import {
telemetry, telemetry,
analytics,
ModesEnum,
kinematicData, kinematicData,
mode, mode,
input, outControllerData,
servoAngles, servoAngles,
servoAnglesOut, servoAnglesOut,
socket, socket,
@@ -20,17 +22,8 @@
useFeatureFlags, useFeatureFlags,
walkGait walkGait
} from '$lib/stores' } from '$lib/stores'
import { import { type Analytics, type DownloadOTA } from '$lib/types/models'
AnglesData, import { MessageTopic } from '$lib/types/models'
DownloadOTAData,
ControllerData,
KinematicData,
ModeData,
RSSIData,
SonarData,
WalkGaitData
} from '$lib/platform_shared/message'
import { Throttler } from '$lib/utilities'
interface Props { interface Props {
children?: import('svelte').Snippet children?: import('svelte').Snippet
@@ -39,7 +32,6 @@
let { children }: Props = $props() let { children }: Props = $props()
const features = useFeatureFlags() const features = useFeatureFlags()
const throttler = new Throttler()
onMount(async () => { onMount(async () => {
const ws = $apiLocation ? $apiLocation : window.location.host const ws = $apiLocation ? $apiLocation : window.location.host
@@ -47,53 +39,58 @@
addEventListeners() addEventListeners()
input.subscribe(data => throttler.throttle(() => socket.emit(ControllerData, data), 100)) outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data))
mode.subscribe(data => socket.emit(ModeData, data)) mode.subscribe(data => socket.sendEvent(MessageTopic.mode, data))
walkGait.subscribe(data => socket.emit(WalkGaitData, data)) walkGait.subscribe(data => socket.sendEvent(MessageTopic.gait, data))
servoAnglesOut.subscribe(data => servoAnglesOut.subscribe(data => socket.sendEvent(MessageTopic.angles, data))
throttler.throttle(() => socket.emit(AnglesData, data), 100) kinematicData.subscribe(data => socket.sendEvent(MessageTopic.position, data))
)
kinematicData.subscribe(data => socket.emit(KinematicData, data))
}) })
onDestroy(() => { onDestroy(() => {
removeEventListeners() removeEventListeners()
}) })
const eventListeners: (() => void)[] = []
const addEventListeners = () => { const addEventListeners = () => {
eventListeners.push( socket.on('open', handleOpen)
socket.onEvent('open', handleOpen), socket.on('close', handleClose)
socket.onEvent('close', handleClose), socket.on('error', handleError)
socket.onEvent('error', handleError), socket.on(MessageTopic.rssi, handleNetworkStatus)
socket.on(RSSIData, data => telemetry.setRSSI(data)), socket.on(MessageTopic.mode, (data: ModesEnum) => mode.set(data))
socket.on(ModeData, data => mode.set(data)), socket.on(MessageTopic.analytics, handleAnalytics)
socket.on(AnglesData, data => { socket.on(MessageTopic.angles, (angles: number[]) => {
servoAngles.set(data) if (angles.length) servoAngles.set(angles)
}) })
)
features.subscribe(data => { features.subscribe(data => {
if (data?.download_firmware) if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT)
eventListeners.push( if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data))
socket.on(DownloadOTAData, data => telemetry.setDownloadOTA(data))
)
if (data?.sonar) eventListeners.push(socket.on(SonarData, data => console.log(data)))
}) })
} }
const removeEventListeners = () => { const removeEventListeners = () => {
eventListeners.forEach(offFunction => offFunction()) socket.off(MessageTopic.analytics, handleAnalytics)
socket.off('open', handleOpen)
socket.off('close', handleClose)
socket.off(MessageTopic.rssi, handleNetworkStatus)
socket.off(MessageTopic.otastatus, handleOAT)
} }
const handleOpen = () => notifications.success('Connection to device established', 5000) const handleOpen = () => {
notifications.success('Connection to device established', 5000)
}
const handleClose = () => { const handleClose = () => {
notifications.error('Connection to device lost', 5000) notifications.error('Connection to device lost', 5000)
telemetry.setRSSI(RSSIData.create({ rssi: 0 })) telemetry.setRSSI(0)
} }
const handleError = (data: unknown) => console.error(data) const handleError = (data: unknown) => console.error(data)
const handleAnalytics = (data: Analytics) => analytics.addData(data)
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data)
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data)
let menuOpen = $state(false) let menuOpen = $state(false)
</script> </script>
+1 -3
View File
@@ -16,9 +16,7 @@ const registerFetchIntercept = async () => {
const pathOnly = urlObj.pathname const pathOnly = urlObj.pathname
file = await fileService?.getFile(pathOnly) file = await fileService?.getFile(pathOnly)
if (file?.isOk() && file.inner) return new Response(new Uint8Array(file.inner)) if (file?.isOk() && file.inner) return new Response(new Uint8Array(file.inner))
} catch { } catch {}
console.error('Failed to get file for ', url)
}
} }
return originalFetch(resource, config) return originalFetch(resource, config)
+2 -2
View File
@@ -5,12 +5,12 @@
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { mpu, socket } from '$lib/stores' import { mpu, socket } from '$lib/stores'
import { imu } from '$lib/stores/imu' import { imu } from '$lib/stores/imu'
import { IMUData } from '$lib/platform_shared/message' import { MessageTopic, type IMU } from '$lib/types/models'
let layout = $derived($views.find(v => v.name === $selectedView)!) let layout = $derived($views.find(v => v.name === $selectedView)!)
onMount(() => { onMount(() => {
socket.on(IMUData, (data: IMUData) => { socket.on(MessageTopic.imu, (data: IMU) => {
imu.addData(data) imu.addData(data)
if (data.heading) if (data.heading)
mpu.update(mpuData => { mpu.update(mpuData => {
+82 -60
View File
@@ -1,24 +1,30 @@
<script lang="ts"> <script lang="ts">
import nipplejs from 'nipplejs' import nipplejs from 'nipplejs'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { capitalize, throttler } from '$lib/utilities'
import { import {
input, input,
outControllerData,
mode, mode,
walkGait,
modes, modes,
modeLabels, type Modes,
walkGaits, ModesEnum,
WalkGaits,
walkGait,
walkGaitLabels walkGaitLabels
} from '$lib/stores' } from '$lib/stores'
import type { vector } from '$lib/types/models' import type { vector } from '$lib/types/models'
import { VerticalSlider } from '$lib/components/input' import { VerticalSlider } from '$lib/components/input'
import { gamepadAxes, gamepadButtonsEdges, hasGamepad } from '$lib/stores/gamepad' import { gamepadAxes, gamepadButtonsEdges, hasGamepad } from '$lib/stores/gamepad'
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications'
import { ModeData, ModesEnum, WalkGaitData, WalkGaits } from '$lib/platform_shared/message'
let throttle = new throttler()
let left: nipplejs.JoystickManager let left: nipplejs.JoystickManager
let right: nipplejs.JoystickManager let right: nipplejs.JoystickManager
let throttle_timing = 40
let data = new Array(7)
$effect(() => { $effect(() => {
if ($hasGamepad) { if ($hasGamepad) {
notifications.success('🎮 Gamepad connected', 3000) notifications.success('🎮 Gamepad connected', 3000)
@@ -34,18 +40,18 @@
if (!$hasGamepad) return if (!$hasGamepad) return
const b = $gamepadButtonsEdges const b = $gamepadButtonsEdges
if (!b.length) return if (!b.length) return
if (b[0]?.justPressed) mode.set(ModeData.create({ mode: ModesEnum.WALK })) if (b[0]?.justPressed) mode.set(5)
if (b[1]?.justPressed) mode.set(ModeData.create({ mode: ModesEnum.STAND })) if (b[1]?.justPressed) mode.set(4)
if (b[2]?.justPressed) mode.set(ModeData.create({ mode: ModesEnum.REST })) if (b[2]?.justPressed) mode.set(3)
if (b[3]?.justPressed) mode.set(ModeData.create({ mode: ModesEnum.DEACTIVATED })) if (b[3]?.justPressed) mode.set(0)
if (b[12]?.justPressed) if (b[12]?.justPressed)
input.update(inputData => { input.update(inputData => {
inputData.height = Math.min(inputData.height + 0.1, 1) inputData['height'] = Math.min(inputData.height + 0.1, 1)
return inputData return inputData
}) })
if (b[13].justPressed) if (b[13]?.justPressed)
input.update(inputData => { input.update(inputData => {
inputData.height = Math.min(inputData.height - 0.1, 1) inputData['height'] = Math.min(inputData.height - 0.1, 1)
return inputData return inputData
}) })
}) })
@@ -78,120 +84,136 @@
inputData[key] = data inputData[key] = data
return inputData return inputData
}) })
throttle.throttle(updateData, throttle_timing)
}
const updateData = () => {
data[0] = $input.left.x
data[1] = $input.left.y
data[2] = $input.right.x
data[3] = $input.right.y
data[4] = $input.height
data[5] = $input.speed
data[6] = $input.s1
outControllerData.set(data)
} }
const handleKeyup = (event: KeyboardEvent) => { const handleKeyup = (event: KeyboardEvent) => {
const down = event.type === 'keydown' const down = event.type === 'keydown'
input.update(data => { input.update(data => {
if (event.key === 'w') data.left!.y = down ? 1 : 0 if (event.key === 'w') data.left.y = down ? 1 : 0
if (event.key === 'a') data.left!.x = down ? -1 : 0 if (event.key === 'a') data.left.x = down ? -1 : 0
if (event.key === 's') data.left!.y = down ? -1 : 0 if (event.key === 's') data.left.y = down ? -1 : 0
if (event.key === 'd') data.left!.x = down ? 1 : 0 if (event.key === 'd') data.left.x = down ? 1 : 0
if (event.key === 'ArrowLeft') data.right!.x = down ? 1 : 0 if (event.key === 'ArrowLeft') data.right.x = down ? 1 : 0
if (event.key === 'ArrowRight') data.right!.x = down ? -1 : 0 if (event.key === 'ArrowRight') data.right.x = down ? -1 : 0
return data return data
}) })
throttle.throttle(updateData, throttle_timing)
} }
const handleRange = (value: number, key: 'speed' | 'height' | 's1') => { const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => {
const value: number = Number((event.target as HTMLInputElement).value)
input.update(inputData => { input.update(inputData => {
inputData[key] = value inputData[key] = value
return inputData return inputData
}) })
throttle.throttle(updateData, throttle_timing)
} }
const changeMode = (modeValue: ModesEnum) => { const changeMode = (modeValue: Modes) => {
mode.set(ModeData.create({ mode: modeValue })) mode.set(modes.indexOf(modeValue))
} }
const changeWalkGait = (walkGaitValue: WalkGaits) => { const changeWalkGait = (walkGaitValue: WalkGaits) => {
walkGait.set(WalkGaitData.create({ gait: walkGaitValue })) walkGait.set(walkGaitValue)
} }
</script> </script>
<div class="absolute top-0 left-0 w-screen h-screen"> <div class="absolute top-0 left-0 w-screen h-screen">
<div class="absolute top-0 left-0 h-full w-full flex max-[599px]:hidden"> <div class="absolute top-0 left-0 h-full w-full flex portrait:hidden">
<div id="left" class="flex w-60 items-center justify-end"></div> <div id="left" class="flex w-60 items-center justify-end"></div>
<div class="flex-1"></div> <div class="flex-1"></div>
<div id="right" class="flex w-60 items-center"></div> <div id="right" class="flex w-60 items-center"></div>
</div> </div>
<div <div class="absolute bottom-0 right-0 p-4 z-10 gap-2 flex-col hidden lg:flex">
class="absolute bottom-0 right-0 p-4 z-10 gap-1.5 flex-col hidden lg:flex opacity-40 hover:opacity-80 transition-opacity duration-300"
>
<div class="flex justify-center w-full"> <div class="flex justify-center w-full">
<kbd class="kbd kbd-sm bg-base-100/80 border-base-content/20 shadow-md">W</kbd> <kbd class="kbd">W</kbd>
</div> </div>
<div class="flex justify-center gap-1.5 w-full"> <div class="flex justify-center gap-2 w-full">
<kbd class="kbd kbd-sm bg-base-100/80 border-base-content/20 shadow-md">A</kbd> <kbd class="kbd">A</kbd>
<kbd class="kbd kbd-sm bg-base-100/80 border-base-content/20 shadow-md">S</kbd> <kbd class="kbd">S</kbd>
<kbd class="kbd kbd-sm bg-base-100/80 border-base-content/20 shadow-md">D</kbd> <kbd class="kbd">D</kbd>
</div> </div>
<div class="flex justify-center w-full"></div>
</div> </div>
<div class="absolute bottom-0 z-10 flex items-end pointer-events-none"> <div class="absolute bottom-0 z-10 flex items-end">
<div <div
class="flex items-center flex-col backdrop-blur-sm bg-base-300/60 p-3 pb-2 gap-2 rounded-tr-2xl border-t border-base-content/5 pointer-events-auto" class="flex items-center flex-col bg-base-300 bg-opacity-50 p-3 pb-2 gap-2 rounded-tr-xl"
> >
<VerticalSlider <VerticalSlider
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
oninput={e => handleRange(Number((e.target as HTMLInputElement).value), 'height')} oninput={(e: Event) => handleRange(e, 'height')}
/> />
<label for="height" class="text-xs font-medium opacity-70">Ht</label> <label for="height">Ht</label>
</div> </div>
<div <div
class="flex items-end gap-4 backdrop-blur-sm bg-base-300/60 h-min rounded-tr-2xl pl-0 p-3 border-t border-r border-base-content/5 pointer-events-auto" class="flex items-end gap-4 bg-base-300 bg-opacity-50 h-min rounded-tr-xl pl-0 p-3 portrait:hidden"
> >
<div class="join shadow-lg"> <div class="join">
{#each modes as modeValue (modeValue)} {#each modes as modeValue}
<button <button
class="btn join-item btn-sm transition-all duration-200" class="btn join-item"
class:btn-primary={$mode.mode === modeValue} class:btn-primary={$mode === modes.indexOf(modeValue)}
onclick={() => changeMode(modeValue)} onclick={() => changeMode(modeValue)}
> >
{modeLabels[modeValue]} {capitalize(modeValue)}
</button> </button>
{/each} {/each}
</div> </div>
{#if $mode.mode === ModesEnum.WALK} {#if $mode === ModesEnum.Walk}
<div class="join shadow-md"> <div class="join">
{#each walkGaits as gaitValue (gaitValue)} {#each Object.values(WalkGaits) as gaitValue}
<button {#if typeof gaitValue === 'number'}
class="btn join-item btn-xs transition-all duration-200" <button
class:btn-secondary={$walkGait.gait === gaitValue} class="btn join-item btn-sm"
onclick={() => changeWalkGait(gaitValue)} class:btn-secondary={$walkGait === gaitValue}
> onclick={() => changeWalkGait(gaitValue)}
{walkGaitLabels[gaitValue]} >
</button> {walkGaitLabels[gaitValue]}
</button>
{/if}
{/each} {/each}
</div> </div>
<div class="flex gap-4"> <div class="flex gap-4">
<div class="flex flex-col gap-1"> <div>
<label for="s1" class="text-xs font-medium opacity-70">Step height</label> <label for="s1">S1</label>
<input <input
type="range" type="range"
name="s1" name="s1"
min="0" min="0"
step="0.01" step="0.01"
max="1" max="1"
oninput={e => oninput={e => handleRange(e, 's1')}
handleRange(Number((e.target as HTMLInputElement).value), 's1')} class="range range-sm range-primary"
class="range range-xs range-primary"
/> />
</div> </div>
<div class="flex flex-col gap-1"> <div>
<label for="speed" class="text-xs font-medium opacity-70">Speed</label> <label for="speed">Speed</label>
<input <input
type="range" type="range"
name="speed" name="speed"
min="0" min="0"
step="0.01" step="0.01"
max="1" max="1"
oninput={e => oninput={e => handleRange(e, 'speed')}
handleRange(Number((e.target as HTMLInputElement).value), 'speed')} class="range range-sm range-primary"
class="range range-xs range-primary"
/> />
</div> </div>
</div> </div>
@@ -1,40 +1,38 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api' import { api } from '$lib/api'
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import { CameraSettings, Request, type Response as ProtoResponse } from '$lib/platform_shared/api' import type { CameraSettings } from '$lib/types/models'
let settings: CameraSettings = $state({
let settings = $state<CameraSettings>(CameraSettings.create({})) brightness: 0,
contrast: 0,
framesize: 0,
vflip: false,
hmirror: false,
special_effect: 0,
quality: 0,
saturation: 0,
sharpness: 0,
denoise: 0,
wb_mode: 0
})
const getCameraSettings = async () => { const getCameraSettings = async () => {
const result = await api.get<ProtoResponse>('/api/camera/settings') const result = await api.get<CameraSettings>('/api/camera/settings')
if (result.isErr()) { if (result.isErr()) {
console.error('An error occurred', result.inner) console.error('An error occurred', result.inner)
return return
} }
if (result.inner.cameraSettings) { settings = result.inner
settings = result.inner.cameraSettings
}
} }
const updateCameraSettings = async () => { const updateCameraSettings = async () => {
const request = Request.create({ const result = await api.post<CameraSettings>('/api/camera/settings', settings)
cameraSettings: settings
})
const result = await api.post_proto<ProtoResponse>('/api/camera/settings', request)
if (result.isErr()) { if (result.isErr()) {
console.error('An error occurred', result.inner) console.error('An error occurred', result.inner)
return return
} }
if (result.inner.cameraSettings) { settings = result.inner
settings = result.inner.cameraSettings
}
} }
// Helper to convert number (0/1) to boolean for checkbox binding
const getVflip = () => settings.vflip !== 0
const setVflip = (value: boolean) => (settings.vflip = value ? 1 : 0)
const getHmirror = () => settings.hmirror !== 0
const setHmirror = (value: boolean) => (settings.hmirror = value ? 1 : 0)
</script> </script>
{#await getCameraSettings()} {#await getCameraSettings()}
@@ -80,29 +78,19 @@
<label class="cursor-pointer flex items-center justify-between"> <label class="cursor-pointer flex items-center justify-between">
Vertical flip Vertical flip
<input <input type="checkbox" class="toggle" bind:checked={settings.vflip} />
type="checkbox"
class="toggle"
checked={getVflip()}
onchange={(e) => setVflip(e.currentTarget.checked)}
/>
</label> </label>
<label class="cursor-pointer flex items-center justify-between"> <label class="cursor-pointer flex items-center justify-between">
Horizontal flip Horizontal flip
<input <input type="checkbox" class="toggle" bind:checked={settings.hmirror} />
type="checkbox"
class="toggle"
checked={getHmirror()}
onchange={(e) => setHmirror(e.currentTarget.checked)}
/>
</label> </label>
<label for="special_effect" class="flex items-center"> <label for="special_effect" class="flex items-center">
<span class="basis-1/2">Special Effect</span> <span class="basis-1/2">Special Effect</span>
<select <select
class="select select-bordered select-sm w-full max-w-xs" class="select select-bordered select-sm w-full max-w-xs"
bind:value={settings.specialEffect} bind:value={settings.special_effect}
> >
<option value={0}>No effect</option> <option value={0}>No effect</option>
<option value={1}>Negative</option> <option value={1}>Negative</option>
+34 -10
View File
@@ -2,25 +2,49 @@
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { socket } from '$lib/stores' import { socket } from '$lib/stores'
import { MessageTopic, type I2CDevice } from '$lib/types/models'
import { Connection } from '$lib/components/icons' import { Connection } from '$lib/components/icons'
import I2CSetting from './i2cSetting.svelte' import I2CSetting from './i2cSetting.svelte'
import type { I2CDevice } from '$lib/platform_shared/message'
const i2cDevices = [
{ address: 30, part_number: 'HMC5883', name: '3-Axis Digital Compass/Magnetometer IC' },
{ address: 41, part_number: 'BNO055', name: '9-Axis Absolute Orientation Sensor' },
{ address: 64, part_number: 'PCA9685', name: '16-channel PWM driver default address' },
{ address: 72, part_number: 'ADS1115', name: '4-channel 16-bit ADC' },
{
address: 104,
part_number: 'MPU6050',
name: 'Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices'
},
{ address: 115, part_number: 'PAJ7620U2', name: 'Gesture sensor' },
{ address: 119, part_number: 'BMP085', name: 'Temp/Barometric' }
]
let active_devices: I2CDevice[] = $state([]) let active_devices: I2CDevice[] = $state([])
let isLoading = $state(false) let isLoading = $state(false)
onMount(() => { onMount(() => {
socket.on(MessageTopic.i2cScan, handleScan)
triggerScan() triggerScan()
return () => socket.off(MessageTopic.i2cScan, handleScan)
}) })
const triggerScan = async () => { const handleScan = (data: { addresses: number[] }) => {
active_devices = data.addresses.map(
(address: number) =>
i2cDevices.find(device => device.address === address) || {
address,
part_number: 'Unknown',
name: 'Unknown'
}
)
isLoading = false
}
const triggerScan = () => {
isLoading = true isLoading = true
try { socket.sendEvent(MessageTopic.i2cScan, '')
const response = await socket.request({ i2cScanDataRequest: {} })
active_devices = response.i2cScanData?.devices ?? []
} finally {
isLoading = false
}
} }
</script> </script>
@@ -47,8 +71,8 @@
{#if active_devices.length === 0} {#if active_devices.length === 0}
<div>No I2C devices found</div> <div>No I2C devices found</div>
{:else} {:else}
{#each active_devices as device (device.address)} {#each active_devices as device}
<div>[{device.address.toString(16)}] {device.partNumber} - {device.name}</div> <div>[{device.address.toString(16)}] {device.part_number} - {device.name}</div>
{/each} {/each}
{/if} {/if}
</div> </div>
@@ -1,31 +1,22 @@
<script lang="ts"> <script lang="ts">
import { Cancel, Edit, EditOff, Power } from '$lib/components/icons' import { Cancel, Edit, EditOff, Power } from '$lib/components/icons'
import { api } from '$lib/api' import { socket } from '$lib/stores'
import { MessageTopic, type PeripheralsConfiguration } from '$lib/types/models'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import {
type PeripheralSettings,
Request,
type Response as ProtoResponse
} from '$lib/platform_shared/api'
let settings = $state<PeripheralSettings | null>(null) let settings: PeripheralsConfiguration | null = $state(null)
let isEditing = $state(false) let isEditing = $state(false)
onMount(() => { onMount(() => {
getPeripheralSettings() socket.on(MessageTopic.peripheralSettings, handleSettings)
socket.sendEvent(MessageTopic.peripheralSettings, '')
return () => socket.off(MessageTopic.peripheralSettings, handleSettings)
}) })
const getPeripheralSettings = async () => { const handleSettings = (data: Record<string, unknown>) => {
const result = await api.get<ProtoResponse>('/api/peripherals/settings') settings = data as PeripheralsConfiguration
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
if (result.inner.peripheralSettings) {
settings = result.inner.peripheralSettings
}
} }
const handleSave = () => { const handleSave = () => {
@@ -37,21 +28,9 @@
cancel: { label: 'Cancel', icon: Cancel }, cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'Confirm', icon: Power } confirm: { label: 'Confirm', icon: Power }
}, },
onConfirm: async () => { onConfirm: () => {
modals.close() modals.close()
if (!settings) return socket.sendEvent(MessageTopic.peripheralSettings, settings)
const request = Request.create({
peripheralSettings: settings
})
const result = await api.post_proto<ProtoResponse>('/api/peripherals/settings', request)
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
if (result.inner.peripheralSettings) {
settings = result.inner.peripheralSettings
}
isEditing = false
} }
}) })
} }
+128 -208
View File
@@ -1,33 +1,29 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import Compass from '$lib/components/Compass.svelte'
import { imu } from '$lib/stores/imu' import { imu } from '$lib/stores/imu'
import { Chart, registerables } from 'chart.js' import { Chart, registerables } from 'chart.js'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { socket, mpu } from '$lib/stores' import { socket } from '$lib/stores'
import { MessageTopic, type IMUMsg, type IMUCalibrationResult } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Rotate3d } from '$lib/components/icons' import { Rotate3d } from '$lib/components/icons'
import { type IMUCalibrateData } from '$lib/platform_shared/message'
Chart.register(...registerables) Chart.register(...registerables)
const features = useFeatureFlags() const features = useFeatureFlags()
let intervalId: ReturnType<typeof setInterval> | number let intervalId: ReturnType<typeof setInterval> | number
let isCalibrating = $state(false) let isCalibrating = $state(false)
let calibrationResult = $state<IMUCalibrateData | null>(null) let calibrationResult = $state<IMUCalibrationResult | null>(null)
let angleChartElement: HTMLCanvasElement = $state()! let angleChartElement: HTMLCanvasElement
let tempChartElement: HTMLCanvasElement = $state()! let tempChartElement: HTMLCanvasElement
let altitudeChartElement: HTMLCanvasElement = $state()! let altitudeChartElement: HTMLCanvasElement
let headingChartElement: HTMLCanvasElement = $state()!
let angleChart: Chart let angleChart: Chart
let tempChart: Chart let tempChart: Chart
let altitudeChart: Chart let altitudeChart: Chart
let headingChart: Chart
const getChartColors = () => { const getChartColors = () => {
const style = getComputedStyle(document.body) const style = getComputedStyle(document.body)
@@ -69,155 +65,114 @@
const colors = getChartColors() const colors = getChartColors()
const baseConfig = createBaseChartConfig(colors.background) const baseConfig = createBaseChartConfig(colors.background)
if (angleChartElement) { angleChart = new Chart(angleChartElement, {
angleChart = new Chart(angleChartElement, { type: 'line',
type: 'line', data: {
data: { datasets: [
datasets: [ {
{ label: 'x',
label: 'x', borderColor: colors.primary,
borderColor: colors.primary, backgroundColor: colors.primary,
backgroundColor: colors.primary, borderWidth: 2,
borderWidth: 2, data: $imu.x,
data: $imu.map(datapoint => datapoint.x), yAxisID: 'y'
yAxisID: 'y' },
}, {
{ label: 'y',
label: 'y', borderColor: colors.secondary,
borderColor: colors.secondary, backgroundColor: colors.secondary,
backgroundColor: colors.secondary, borderWidth: 2,
borderWidth: 2, data: $imu.y,
data: $imu.map(datapoint => datapoint.y), yAxisID: 'y'
yAxisID: 'y' },
}, {
{ label: 'z',
label: 'z', borderColor: colors.accent,
borderColor: colors.accent, backgroundColor: colors.accent,
backgroundColor: colors.accent, borderWidth: 2,
borderWidth: 2, data: $imu.z,
data: $imu.map(datapoint => datapoint.z), yAxisID: 'y'
yAxisID: 'y' }
} ]
] },
}, options: {
options: { ...baseConfig,
...baseConfig, scales: {
scales: { ...baseConfig.scales,
...baseConfig.scales, y: {
y: { ...baseConfig.scales.y,
...baseConfig.scales.y, title: {
title: { display: true,
display: true, text: 'Angle [°]',
text: 'Angle [°]', color: colors.background,
color: colors.background, font: { size: 16, weight: 'bold' }
font: { size: 16, weight: 'bold' }
}
} }
} }
} }
}) }
} })
if (tempChartElement) { tempChart = new Chart(tempChartElement, {
tempChart = new Chart(tempChartElement, { type: 'line',
type: 'line', data: {
data: { datasets: [
datasets: [ {
{ label: 'Barometer temperature',
label: 'Barometer temperature', borderColor: colors.secondary,
borderColor: colors.secondary, backgroundColor: colors.secondary,
backgroundColor: colors.secondary, borderWidth: 2,
borderWidth: 2, data: $imu.bmp_temp,
data: $imu.map(datapoint => datapoint.bmpTemp), yAxisID: 'y'
yAxisID: 'y' }
} ]
] },
}, options: {
options: { ...baseConfig,
...baseConfig, scales: {
scales: { ...baseConfig.scales,
...baseConfig.scales, y: {
y: { ...baseConfig.scales.y,
...baseConfig.scales.y, title: {
title: { display: true,
display: true, text: 'Temperature [C°]',
text: 'Temperature [C°]', color: colors.background,
color: colors.background, font: { size: 16, weight: 'bold' }
font: { size: 16, weight: 'bold' }
}
} }
} }
} }
}) }
} })
if (altitudeChartElement) { altitudeChart = new Chart(altitudeChartElement, {
altitudeChart = new Chart(altitudeChartElement, { type: 'line',
type: 'line', data: {
data: { datasets: [
datasets: [ {
{ label: 'Altitude',
label: 'Altitude', borderColor: colors.primary,
borderColor: colors.primary, backgroundColor: colors.primary,
backgroundColor: colors.primary, borderWidth: 2,
borderWidth: 2, data: $imu.altitude,
data: $imu.map(datapoint => datapoint.altitude), yAxisID: 'y'
yAxisID: 'y' }
} ]
] },
}, options: {
options: { ...baseConfig,
...baseConfig, scales: {
scales: { ...baseConfig.scales,
...baseConfig.scales, y: {
y: { ...baseConfig.scales.y,
...baseConfig.scales.y, title: {
title: { display: true,
display: true, text: 'Altitude [M]',
text: 'Altitude [M]', color: colors.background,
color: colors.background, font: { size: 16, weight: 'bold' }
font: { size: 16, weight: 'bold' }
}
} }
} }
} }
}) }
} })
if (headingChartElement) {
headingChart = new Chart(headingChartElement, {
type: 'line',
data: {
datasets: [
{
label: 'Heading',
borderColor: colors.accent,
backgroundColor: colors.accent,
borderWidth: 2,
data: $imu.map(datapoint => datapoint.heading),
yAxisID: 'y'
}
]
},
options: {
...baseConfig,
scales: {
...baseConfig.scales,
y: {
...baseConfig.scales.y,
min: 0,
max: 360,
title: {
display: true,
text: 'Heading [°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
})
}
} }
const updateChartData = (chart: Chart, data: number[]) => { const updateChartData = (chart: Chart, data: number[]) => {
@@ -229,64 +184,49 @@
} }
const updateData = () => { const updateData = () => {
if ($features.imu && angleChart) { if ($features.imu) {
const x = $imu.map(datapoint => datapoint.x) angleChart.data.labels = $imu.x
const y = $imu.map(datapoint => datapoint.y) angleChart.data.datasets[0].data = $imu.x
const z = $imu.map(datapoint => datapoint.z) angleChart.data.datasets[1].data = $imu.y
angleChart.data.datasets[2].data = $imu.z
angleChart.data.labels = Array.from({ length: $imu.length }, (_, i) => i + 1) const allValues = [...$imu.x, ...$imu.y, ...$imu.z]
angleChart.data.datasets[0].data = x
angleChart.data.datasets[1].data = y
angleChart.data.datasets[2].data = z
const allValues = [...x, ...y, ...z]
angleChart.options.scales!.y!.min = Math.min(...allValues) - 1 angleChart.options.scales!.y!.min = Math.min(...allValues) - 1
angleChart.options.scales!.y!.max = Math.max(...allValues) + 1 angleChart.options.scales!.y!.max = Math.max(...allValues) + 1
angleChart.update('none') angleChart.update('none')
} }
if ($features.bmp && tempChart && altitudeChart) { if ($features.bmp) {
updateChartData( updateChartData(tempChart, $imu.bmp_temp)
tempChart, updateChartData(altitudeChart, $imu.altitude)
$imu.map(datapoint => datapoint.bmpTemp)
)
updateChartData(
altitudeChart,
$imu.map(datapoint => datapoint.altitude)
)
}
if ($features.mag && headingChart) {
const headingData = $imu.map(datapoint => datapoint.heading)
headingChart.data.labels = headingData
headingChart.data.datasets[0].data = headingData
headingChart.update('none')
if ($imu.length > 0) {
mpu.set({ heading: $imu[$imu.length - 1].heading })
}
} }
} }
onMount(() => { onMount(() => {
imu.listen() socket.on(MessageTopic.imu, (data: IMUMsg) => {
console.log(data)
imu.addData(data)
})
socket.on(MessageTopic.imuCalibrate, (data: IMUCalibrationResult) => {
isCalibrating = false
calibrationResult = data
})
initializeCharts() initializeCharts()
intervalId = setInterval(updateData, 200) intervalId = setInterval(updateData, 200)
}) })
onDestroy(() => { onDestroy(() => {
imu.stop() socket.off(MessageTopic.imu)
socket.off(MessageTopic.imuCalibrate)
clearInterval(intervalId) clearInterval(intervalId)
}) })
async function startCalibration() { function startCalibration() {
isCalibrating = true isCalibrating = true
calibrationResult = null calibrationResult = null
try { socket.sendEvent(MessageTopic.imuCalibrate, {})
const response = await socket.request({ imuCalibrateExecute: {} })
calibrationResult = response.imuCalibrateData ?? null
} finally {
isCalibrating = false
}
} }
</script> </script>
@@ -312,11 +252,7 @@
{/if} {/if}
</button> </button>
{#if calibrationResult} {#if calibrationResult}
<span <span class="badge" class:badge-success={calibrationResult.success} class:badge-error={!calibrationResult.success}>
class="badge"
class:badge-success={calibrationResult.success}
class:badge-error={!calibrationResult.success}
>
{calibrationResult.success ? 'Calibrated' : 'Failed'} {calibrationResult.success ? 'Calibrated' : 'Failed'}
</span> </span>
{/if} {/if}
@@ -333,23 +269,7 @@
</div> </div>
{/if} {/if}
{#if $features.mag}
<div class="divider">Magnetometer</div>
<div class="flex flex-col lg:flex-row gap-4 items-center">
<Compass heading={$mpu.heading} />
<div class="flex-1 w-full overflow-x-auto">
<div
class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<canvas bind:this={headingChartElement}></canvas>
</div>
</div>
</div>
{/if}
{#if $features.bmp} {#if $features.bmp}
<div class="divider">Barometer</div>
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
<div <div
class="flex w-full flex-col space-y-1 h-60" class="flex w-full flex-col space-y-1 h-60"
@@ -2,48 +2,43 @@
import { api } from '$lib/api' import { api } from '$lib/api'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { RotateCw, RotateCcw } from '$lib/components/icons' import { RotateCw, RotateCcw } from '$lib/components/icons'
import { Request, Response, type ServoSettings } from '$lib/platform_shared/api'
import { notifications } from '$lib/components/toasts/notifications'
interface Props { interface Props {
servoSettings?: ServoSettings | null data?: Record<string, unknown>
servoId?: number servoId?: number
pwm?: number pwm?: number
} }
let { let {
servoSettings = $bindable(null), data = $bindable({
servos: []
}),
pwm = $bindable(306), pwm = $bindable(306),
servoId = $bindable(0) servoId = $bindable(0)
}: Props = $props() }: Props = $props()
const updateValue = (event: Event, index: number, key: string) => {
data.servos[index][key] = Number((event.target as HTMLInputElement).value)
}
const syncConfig = async () => { const syncConfig = async () => {
if (!servoSettings) return await api.post('/api/servo/config', data)
notifications.info("Uploading servo config...", 3000)
await api.post_proto<Response>('/api/servo/config', Request.create({ servoSettings }))
notifications.success('Servo config uploaded successfully', 3000)
} }
const toggleDirection = async (index: number) => { const toggleDirection = async (index: number) => {
if (!servoSettings) return data.servos[index].direction = data.servos[index].direction === 1 ? -1 : 1
servoSettings.servos[index].direction = servoSettings.servos[index].direction === 1 ? -1 : 1
await syncConfig() await syncConfig()
} }
onMount(async () => { onMount(async () => {
const result = await api.get<Response>('/api/servo/config') const result = await api.get('/api/servo/config')
if (result.isOk() && result.inner.servoSettings) { if (result.isOk()) {
servoSettings = result.inner.servoSettings data = result.inner
} else {
console.log("Failed to fetch servo config!")
console.log(result)
} }
}) })
const setCenterPWM = async () => { const setCenterPWM = async () => {
if (!servoSettings) return
console.log('setCenterPWM', servoId, pwm) console.log('setCenterPWM', servoId, pwm)
servoSettings.servos[servoId].centerPwm = pwm data.servos[servoId]['center_pwm'] = pwm
await syncConfig() await syncConfig()
} }
</script> </script>
@@ -52,7 +47,6 @@
<button class="btn btn-sm btn-primary" onclick={() => setCenterPWM()}>Set center pwm</button> <button class="btn btn-sm btn-primary" onclick={() => setCenterPWM()}>Set center pwm</button>
</div> </div>
{#if servoSettings}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-xs"> <table class="table table-xs">
<thead> <thead>
@@ -65,16 +59,16 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each servoSettings.servos as servo, index (index)} {#each data.servos as servo, index}
<tr class="hover:bg-base-200"> <tr class="hover:bg-base-200">
<td class="font-medium">Servo {index}</td> <td class="font-medium">Servo {index}</td>
<td> <td>
<input <input
type="number" type="number"
class="input input-sm input-bordered w-20" class="input input-sm input-bordered w-20"
value={servo.centerPwm} value={servo.center_pwm}
onblur={syncConfig} onblur={syncConfig}
oninput={event => servo.centerPwm = Number((event.target as HTMLInputElement).value)} oninput={event => updateValue(event, index, 'center_pwm')}
min="80" min="80"
max="600" max="600"
/> />
@@ -84,9 +78,9 @@
type="number" type="number"
step="0.1" step="0.1"
class="input input-sm input-bordered w-20" class="input input-sm input-bordered w-20"
value={servo.centerAngle} value={servo.center_angle}
onblur={syncConfig} onblur={syncConfig}
oninput={event => servo.centerAngle = Number((event.target as HTMLInputElement).value)} oninput={event => updateValue(event, index, 'center_angle')}
min="-90" min="-90"
max="90" max="90"
/> />
@@ -111,7 +105,7 @@
class="input input-sm input-bordered w-20" class="input input-sm input-bordered w-20"
value={servo.conversion} value={servo.conversion}
onblur={syncConfig} onblur={syncConfig}
oninput={event => servo.conversion = Number((event.target as HTMLInputElement).value)} oninput={event => updateValue(event, index, 'conversion')}
min="0" min="0"
max="10" max="10"
/> />
@@ -121,4 +115,3 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{/if}
+38 -57
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ServoPWMData, ServoStateData } from '$lib/platform_shared/message'
import { socket } from '$lib/stores' import { socket } from '$lib/stores'
import { Throttler } from '$lib/utilities' import { MessageTopic } from '$lib/types/models'
import { throttler as Throttler } from '$lib/utilities'
let { servoId = $bindable(0), pwm = $bindable(306) } = $props() let { servoId = $bindable(0), pwm = $bindable(306) } = $props()
@@ -12,16 +12,16 @@
const throttler = new Throttler() const throttler = new Throttler()
const activateServo = () => { const activateServo = () => {
socket.emit(ServoStateData, ServoStateData.create({ active: true })) socket.sendEvent(MessageTopic.servoState, { active: 1 })
} }
const deactivateServo = () => { const deactivateServo = () => {
socket.emit(ServoStateData, ServoStateData.create({ active: false })) socket.sendEvent(MessageTopic.servoState, { active: 0 })
} }
const updatePWM = () => { const updatePWM = () => {
throttler.throttle(() => { throttler.throttle(() => {
socket.emit(ServoPWMData, ServoPWMData.create({ servoId: servoId, servoPwm: pwm })) socket.sendEvent(MessageTopic.servoPWM, { servo_id: servoId, pwm })
}, 10) }, 10)
} }
@@ -30,56 +30,37 @@
} }
</script> </script>
<div class="flex flex-col gap-6 p-4 bg-base-200 rounded-xl"> <div class="flex flex-col">
<div class="flex flex-col gap-2"> <h2 class="text-lg">General servo configuration</h2>
<h2 class="text-lg font-semibold">PWM Control</h2> <span>Servo</span>
<div class="flex items-center justify-between"> <span>{pwm}</span>
<span class="text-sm opacity-70">PWM Value</span> </div>
<span class="text-2xl font-mono font-bold text-primary">{pwm}</span> <input
</div> type="range"
<input min="80"
type="range" max="600"
min="80" bind:value={pwm}
max="600" oninput={updatePWM}
bind:value={pwm} class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
oninput={updatePWM} />
class="range range-primary"
/> <div class="flex flex-col">
</div> <h2 class="text-lg">General servo configuration</h2>
<span>
<div class="divider my-0"></div> <label for="mode">All servoes</label>
<input type="checkbox" class="toggle" bind:checked={allServos} onchange={toggleMode} />
<div class="flex flex-col gap-3"> </span>
<h2 class="text-lg font-semibold">Servo Selection</h2> <span>
<label class="flex items-center justify-between cursor-pointer"> <label for="active">Active</label>
<span>All servos</span> <input
<input type="checkbox"
type="checkbox" class="toggle"
class="toggle toggle-primary" bind:checked={active}
bind:checked={allServos} onchange={active ? activateServo : deactivateServo}
onchange={toggleMode} />
/> </span>
</label> <span class="flex items-center gap-2">
<label class="flex items-center justify-between cursor-pointer"> <label for="servoId">Servo active {servoId}</label>
<span>Active</span> <input type="range" min="0" max="11" step="1" bind:value={servoId} />
<input </span>
type="checkbox"
class="toggle toggle-success"
bind:checked={active}
onchange={active ? activateServo : deactivateServo}
/>
</label>
<label class="flex items-center justify-between">
<span>Servo {servoId}</span>
<input
type="range"
min="0"
max="11"
step="1"
bind:value={servoId}
class="range range-sm w-32"
disabled={allServos}
/>
</label>
</div>
</div> </div>
+162 -406
View File
@@ -1,426 +1,182 @@
<script lang="ts"> <script lang="ts">
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import { fileSystemClient } from '$lib/filesystem/chunkedTransfer' import Folder from './Folder.svelte'
import type { TransferProgress } from '$lib/types/models' import { api } from '$lib/api'
import { FolderIcon, Add, FileIcon, UploadIcon, DownloadIcon, TrashIcon } from '$lib/components/icons' import type { Directory } from '$lib/types/models'
import { modals } from 'svelte-modals' import { FolderIcon, Add, FileIcon } from '$lib/components/icons'
import NewFolderDialog from './NewFolderDialog.svelte' import { modals } from 'svelte-modals'
import NewFileDialog from './NewFileDialog.svelte' import NewFolderDialog from './NewFolderDialog.svelte'
import { api } from '$lib/api' import NewFileDialog from './NewFileDialog.svelte'
import type { Response } from '$lib/platform_shared/api'
let currentPath = $state('/') let filename = $state('')
let files = $state<Array<{ name: string; size: number }>>([]) let content = $state('')
let directories = $state<Array<{ name: string }>>([]) let isEditing = $state(false)
let loading = $state(false)
let error = $state('')
let selectedFile = $state('') const getFiles = async () => {
let fileContent = $state('') const result = await api.get<Directory>('/api/files')
let isEditing = $state(false) if (result.isOk()) {
let fileLoading = $state(false) return result.inner
}
return { root: {} }
}
let uploadProgress = $state<TransferProgress | null>(null) const getContent = async (name: string) => {
let downloadProgress = $state<TransferProgress | null>(null) if (!name) return ''
let uploadInputRef: HTMLInputElement const result = await api.get(`/api/config/${name}`)
if (result.isOk()) {
content = JSON.stringify(result.inner, null, 4)
return content
}
return ''
}
async function loadDirectory(path: string = currentPath) { const saveContent = async () => {
loading = true if (!filename) return
error = '' const result = await api.post('/api/files/edit', {
try { file: '/config/' + filename,
const result = await fileSystemClient.listDirectory(path) content
if (result.success) { })
files = result.files if (result.isOk()) {
directories = result.directories isEditing = false
currentPath = path }
} else { }
error = result.error || 'Failed to load directory'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading = false
}
}
async function navigateTo(dirName: string) { const deleteFile = async (name: string) => {
const newPath = currentPath === '/' ? `/${dirName}` : `${currentPath}/${dirName}` if (!confirm(`Are you sure you want to delete ${name}?`)) return
await loadDirectory(newPath) const result = await api.post('/api/files/delete', { file: '/config/' + name })
selectedFile = '' if (result.isOk()) {
fileContent = '' filename = ''
} content = ''
}
}
async function navigateUp() { const createFolder = async (folderName: string) => {
if (currentPath === '/') return if (!folderName) return
const parts = currentPath.split('/').filter(Boolean) const result = await api.post('/api/files/mkdir', {
parts.pop() path: '/config/' + folderName
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/') })
await loadDirectory(newPath) if (result.isOk()) {
selectedFile = '' // Refresh the file list
fileContent = '' await getFiles()
} }
}
async function loadFileContent(filename: string) { const updateSelected = async (name: string) => {
fileLoading = true filename = name
error = '' isEditing = false
try { await getContent(name)
const filePath = currentPath === '/' ? `/${filename}` : `${currentPath}/${filename}` }
const result = await fileSystemClient.downloadFile(filePath)
if (result.success && result.data) { const openNewFolderDialog = () => {
// Convert bytes to string (assuming UTF-8 text file) modals.open(NewFolderDialog, {
const decoder = new TextDecoder('utf-8') onConfirm: createFolder
fileContent = decoder.decode(result.data) })
selectedFile = filename }
isEditing = false
} else {
error = result.error || 'Failed to load file'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load file'
} finally {
fileLoading = false
}
}
async function saveFileContent() { const createFile = async (fileName: string) => {
if (!selectedFile) return if (!fileName) return
const result = await api.post('/api/files/edit', {
file: '/config/' + fileName,
content: '{}' // Default empty JSON object
})
if (result.isOk()) {
// Refresh the file list and select the new file
await getFiles()
await updateSelected(fileName)
}
}
error = '' const openNewFileDialog = () => {
try { modals.open(NewFileDialog, {
const filePath = currentPath === '/' ? `/${selectedFile}` : `${currentPath}/${selectedFile}` onConfirm: createFile
const data = new TextEncoder().encode(fileContent) })
}
const result = await fileSystemClient.uploadFile(filePath, data)
if (result.success) {
isEditing = false
await loadDirectory() // Refresh to update file sizes
} else {
error = result.error || 'Failed to save file'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save file'
}
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploadProgress = null
error = ''
const destinationPath = currentPath === '/'
? `/${file.name}`
: `${currentPath}/${file.name}`
try {
const result = await fileSystemClient.uploadFileFromBrowser(
destinationPath,
file,
(progress) => {
uploadProgress = progress
}
)
if (result.success) {
await loadDirectory()
} else {
error = result.error || 'Upload failed'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Upload error'
} finally {
uploadProgress = null
input.value = ''
}
}
async function handleDownload(filename: string) {
downloadProgress = null
error = ''
const filePath = currentPath === '/'
? `/${filename}`
: `${currentPath}/${filename}`
try {
const result = await fileSystemClient.downloadFileAndSave(filePath, filename, (progress) => {
downloadProgress = progress
})
if (!result.success) {
error = result.error || 'Download failed'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Download error'
} finally {
downloadProgress = null
}
}
async function handleDelete(name: string, isDirectory: boolean) {
if (!confirm(`Delete ${isDirectory ? 'directory' : 'file'} "${name}"?`)) return
error = ''
const path = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`
try {
const result = await fileSystemClient.deleteFile(path)
if (result.success) {
if (selectedFile === name) {
selectedFile = ''
fileContent = ''
}
await loadDirectory()
} else {
error = result.error || 'Delete failed'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Delete error'
}
}
async function createFolder(folderName: string) {
if (!folderName) return
error = ''
const path = currentPath === '/' ? `/${folderName}` : `${currentPath}/${folderName}`
try {
const result = await api.post_proto<Response>('/api/files/mkdir', {
fileMkdirRequest: { path }
})
if (result.isOk() && result.inner.statusCode === 200) {
await loadDirectory()
} else if (result.isErr()) {
error = 'Failed to create directory'
} else {
error = result.inner.errorMessage || 'Failed to create directory'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error creating directory'
}
}
async function createFile(fileName: string) {
if (!fileName) return
error = ''
const path = currentPath === '/' ? `/${fileName}` : `${currentPath}/${fileName}`
try {
const result = await fileSystemClient.uploadFile(path, new Uint8Array(0))
if (result.success) {
await loadDirectory()
await loadFileContent(fileName)
} else {
error = result.error || 'Failed to create file'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error creating file'
}
}
function openNewFolderDialog() {
modals.open(NewFolderDialog, {
onConfirm: createFolder
})
}
function openNewFileDialog() {
modals.open(NewFileDialog, {
onConfirm: createFile
})
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// Load initial directory
$effect(() => {
loadDirectory('/')
})
</script> </script>
<!-- <SettingsCard collapsible={false}> -->
<!-- {#snippet icon()} -->
<FolderIcon class="flex-shrink-0 mr-2 h-6 w-6 self-end" /> <FolderIcon class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
<!-- {/snippet}
<div class="flex justify-between items-center w-full gap-2 mb-4"> {#snippet title()} -->
<span class="text-xl font-bold">File System</span> <div class="flex justify-between items-center w-full gap-2">
<div class="flex gap-2"> <span>File System</span>
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={() => uploadInputRef.click()}> <div class="flex gap-2">
<UploadIcon class="w-4 h-4" /> <button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFileDialog}>
Upload File <FileIcon class="w-4 h-4" />
</button> New File
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFileDialog}> </button>
<FileIcon class="w-4 h-4" /> <button
New File class="btn btn-sm btn-primary flex items-center gap-2"
</button> onclick={openNewFolderDialog}
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFolderDialog}> >
<Add class="w-4 h-4" /> <Add class="w-4 h-4" />
New Folder New Folder
</button> </button>
</div> </div>
</div> </div>
<!-- {/snippet} -->
<input
type="file"
bind:this={uploadInputRef}
onchange={handleFileUpload}
style="display: none;"
/>
{#if error}
<div class="alert alert-error mb-4">
<span>{error}</span>
</div>
{/if}
{#if uploadProgress}
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span>Uploading...</span>
<span>{uploadProgress.percentage.toFixed(1)}% ({formatBytes(uploadProgress.bytesTransferred)} / {formatBytes(uploadProgress.totalBytes)})</span>
</div>
<progress class="progress progress-primary w-full" value={uploadProgress.percentage} max="100"></progress>
</div>
{/if}
{#if downloadProgress}
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span>Downloading...</span>
<span>{downloadProgress.percentage.toFixed(1)}% ({formatBytes(downloadProgress.bytesTransferred)} / {formatBytes(downloadProgress.totalBytes)})</span>
</div>
<progress class="progress progress-primary w-full" value={downloadProgress.percentage} max="100"></progress>
</div>
{/if}
<div class="flex flex-col md:flex-row gap-4 w-full"> <div class="flex flex-col md:flex-row gap-4 w-full">
<!-- File Tree --> <!-- File Tree -->
<div class="w-full md:w-[300px] md:min-w-[300px] md:max-w-[300px] border-b md:border-b-0 md:border-r pb-4 md:pb-0 md:pr-4"> <div
<!-- Current Path --> class="w-full md:w-[300px] md:min-w-[300px] md:max-w-[300px] border-b md:border-b-0 md:border-r pb-4 md:pb-0 md:pr-4"
<div class="mb-4 p-2 bg-base-200 rounded font-mono text-sm flex items-center justify-between"> >
<span class="truncate">{currentPath}</span> {#await getFiles()}
{#if currentPath !== '/'} <Spinner />
<button class="btn btn-xs btn-ghost" onclick={navigateUp}> {:then files}
↑ Up <Folder
</button> name="/"
{/if} files={files.root}
</div> expanded
selected={updateSelected}
onDelete={deleteFile}
/>
{/await}
</div>
{#if loading} <!-- File Content -->
<Spinner /> <div class="flex-1 min-w-0">
{:else} {#if filename}
<!-- Directories --> <div
{#each directories as dir (dir.name)} class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-2"
<div class="flex items-center py-1 px-2 hover:bg-base-200 rounded group"> >
<button class="flex items-center gap-2 flex-1" onclick={() => navigateTo(dir.name)}> <h3 class="text-lg font-semibold truncate">{filename}</h3>
<FolderIcon class="w-5 h-5 text-yellow-500" /> <div class="flex gap-2">
<span class="text-sm">{dir.name}</span> {#if isEditing}
</button> <button class="btn btn-sm btn-primary" onclick={saveContent}>Save</button>
<button <button
class="opacity-0 group-hover:opacity-100 btn btn-xs btn-ghost btn-square" class="btn btn-sm btn-secondary"
onclick={() => handleDelete(dir.name, true)} onclick={() => (isEditing = false)}
> >
<TrashIcon class="w-4 h-4 text-error" /> Cancel
</button> </button>
</div> {:else}
{/each} <button class="btn btn-sm btn-primary" onclick={() => (isEditing = true)}>
Edit
</button>
<button class="btn btn-sm btn-danger" onclick={() => deleteFile(filename)}>
Delete
</button>
{/if}
</div>
</div>
<!-- Files --> {#await getContent(filename)}
{#each files as file (file.name)} <Spinner />
<div class="flex items-center py-1 px-2 hover:bg-base-200 rounded group"> {:then}
<button {#if isEditing}
class="flex items-center gap-2 flex-1 min-w-0" <textarea
onclick={() => loadFileContent(file.name)} class="w-full h-[300px] sm:h-[500px] font-mono p-2 bg-gray-800 text-white"
class:font-bold={selectedFile === file.name} bind:value={content}
> ></textarea>
<FileIcon class="w-4 h-4 flex-shrink-0" /> {:else}
<span class="text-sm truncate">{file.name}</span> <pre
<span class="text-xs opacity-60 ml-auto flex-shrink-0">{formatBytes(file.size)}</span> class="bg-gray-800 p-4 rounded overflow-auto max-h-[300px] sm:max-h-[500px]">{content}</pre>
</button> {/if}
<div class="flex gap-1 opacity-0 group-hover:opacity-100 flex-shrink-0"> {/await}
<button {:else}
class="btn btn-xs btn-ghost btn-square" <div class="text-center text-gray-500">Select a file to view its contents</div>
onclick={() => handleDownload(file.name)} {/if}
title="Download" </div>
>
<DownloadIcon class="w-4 h-4 text-info" />
</button>
<button
class="btn btn-xs btn-ghost btn-square"
onclick={() => handleDelete(file.name, false)}
title="Delete"
>
<TrashIcon class="w-4 h-4 text-error" />
</button>
</div>
</div>
{/each}
{#if files.length === 0 && directories.length === 0}
<div class="text-center text-base-content/50 py-8">
Directory is empty
</div>
{/if}
{/if}
</div>
<!-- File Content -->
<div class="flex-1 min-w-0">
{#if selectedFile}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-2">
<h3 class="text-lg font-semibold truncate">{selectedFile}</h3>
<div class="flex gap-2">
{#if isEditing}
<button class="btn btn-sm btn-primary" onclick={saveFileContent}>
Save
</button>
<button class="btn btn-sm btn-ghost" onclick={() => {
isEditing = false
loadFileContent(selectedFile)
}}>
Cancel
</button>
{:else}
<button class="btn btn-sm btn-primary" onclick={() => (isEditing = true)}>
Edit
</button>
<button class="btn btn-sm btn-ghost" onclick={() => handleDownload(selectedFile)}>
<DownloadIcon class="w-4 h-4 mr-1" />
Download
</button>
<button class="btn btn-sm btn-error" onclick={() => handleDelete(selectedFile, false)}>
Delete
</button>
{/if}
</div>
</div>
{#if fileLoading}
<Spinner />
{:else if isEditing}
<textarea
class="textarea textarea-bordered w-full h-[300px] sm:h-[500px] font-mono text-sm"
bind:value={fileContent}
></textarea>
{:else}
<pre class="bg-base-200 p-4 rounded overflow-auto max-h-[300px] sm:max-h-[500px] text-sm">{fileContent}</pre>
{/if}
{:else}
<div class="text-center text-base-content/50 py-16">
Select a file to view its contents
</div>
{/if}
</div>
</div> </div>
<!-- </SettingsCard> -->
@@ -30,7 +30,7 @@
{#if expanded} {#if expanded}
<ul class="ml-4 border-l border-gray-600 mt-1"> <ul class="ml-4 border-l border-gray-600 mt-1">
{#each Object.entries(files) as [itemName, content] (itemName)} {#each Object.entries(files) as [itemName, content]}
<li class="py-1"> <li class="py-1">
{#if typeof content === 'object'} {#if typeof content === 'object'}
<Folder name={itemName} files={content} {selected} {onDelete} /> <Folder name={itemName} files={content} {selected} {onDelete} />
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte' import { onMount } from 'svelte'
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
@@ -21,18 +21,17 @@
let temperatureChart: Chart let temperatureChart: Chart
onMount(() => { onMount(() => {
analytics.listen()
heapChart = new Chart(heapChartElement, { heapChart = new Chart(heapChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.map(datapoint => datapoint.uptime), labels: $analytics.uptime,
datasets: [ datasets: [
{ {
label: 'Used Heap', label: 'Used Heap',
borderColor: daisyColor('--color-primary'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.map(datapoint => datapoint.totalHeap - datapoint.freeHeap), data: $analytics.used_heap,
fill: true, fill: true,
yAxisID: 'y' yAxisID: 'y'
} }
@@ -78,7 +77,7 @@
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: Math.round($analytics[0]?.totalHeap ?? 0), max: Math.round($analytics.total_heap[0]),
grid: { color: daisyColor('--color-base-content', 10) }, grid: { color: daisyColor('--color-base-content', 10) },
ticks: { ticks: {
color: daisyColor('--color-base-content') color: daisyColor('--color-base-content')
@@ -91,14 +90,14 @@
filesystemChart = new Chart(filesystemChartElement, { filesystemChart = new Chart(filesystemChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.map(datapoint => datapoint.uptime), labels: $analytics.uptime,
datasets: [ datasets: [
{ {
label: 'File System Used', label: 'File System Used',
borderColor: daisyColor('--color-primary'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.map(datapoint => datapoint.fsUsed), data: $analytics.fs_used,
fill: true, fill: true,
yAxisID: 'y' yAxisID: 'y'
} }
@@ -144,7 +143,7 @@
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: Math.round($analytics[0]?.fsTotal ?? 0), max: Math.round($analytics.fs_total[0]),
grid: { color: daisyColor('--color-base-content', 10) }, grid: { color: daisyColor('--color-base-content', 10) },
ticks: { ticks: {
color: daisyColor('--color-base-content') color: daisyColor('--color-base-content')
@@ -157,14 +156,14 @@
temperatureChart = new Chart(temperatureChartElement, { temperatureChart = new Chart(temperatureChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.map(datapoint => datapoint.uptime), labels: $analytics.uptime,
datasets: [ datasets: [
{ {
label: 'Core Temperature', label: 'Core Temperature',
borderColor: daisyColor('--color-primary'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.map(datapoint => datapoint.coreTemp), data: $analytics.core_temp,
yAxisID: 'y' yAxisID: 'y'
} }
] ]
@@ -222,23 +221,19 @@
setInterval(updateData, 500) setInterval(updateData, 500)
}) })
onDestroy(() => analytics.stop())
function updateData() { function updateData() {
heapChart.data.labels = $analytics.map(datapoint => datapoint.uptime) heapChart.data.labels = $analytics.uptime
heapChart.data.datasets[0].data = $analytics.map( heapChart.data.datasets[0].data = $analytics.used_heap
datapoint => datapoint.totalHeap - datapoint.freeHeap heapChart.options.scales!.y!.max = Math.ceil($analytics.total_heap[0])
)
heapChart.options.scales!.y!.max = Math.ceil($analytics[0]?.totalHeap ?? 0)
heapChart.update('none') heapChart.update('none')
filesystemChart.data.labels = $analytics.map(datapoint => datapoint.uptime) filesystemChart.data.labels = $analytics.uptime
filesystemChart.data.datasets[0].data = $analytics.map(datapoint => datapoint.fsUsed) filesystemChart.data.datasets[0].data = $analytics.fs_used
filesystemChart.options.scales!.y!.max = Math.ceil($analytics[0]?.fsTotal ?? 0) heapChart.options.scales!.y!.max = Math.ceil($analytics.fs_total[0])
filesystemChart.update('none') filesystemChart.update('none')
temperatureChart.data.labels = $analytics.map(datapoint => datapoint.uptime) temperatureChart.data.labels = $analytics.uptime
temperatureChart.data.datasets[0].data = $analytics.map(datapoint => datapoint.coreTemp) temperatureChart.data.datasets[0].data = $analytics.core_temp
temperatureChart.update('none') temperatureChart.update('none')
} }
</script> </script>
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import type { Component } from 'svelte' import type { ComponentType } from 'svelte'
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
import { type SystemInformation, type Analytics, MessageTopic } from '$lib/types/models'
import { socket } from '$lib/stores/socket' import { socket } from '$lib/stores/socket'
import { api } from '$lib/api' import { api } from '$lib/api'
import { convertSeconds } from '$lib/utilities' import { convertSeconds } from '$lib/utilities'
@@ -31,37 +32,29 @@
} from '$lib/components/icons' } from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte'
import ActionButton from './ActionButton.svelte' import ActionButton from './ActionButton.svelte'
import { AnalyticsData, type SystemInformation } from '$lib/platform_shared/message'
import Error from '../../+error.svelte'
import { notifications } from '$lib/components/toasts/notifications'
const features = useFeatureFlags() const features = useFeatureFlags()
let systemInformation: SystemInformation | null = $state(null) let systemInformation: SystemInformation | null = $state(null)
async function getSystemStatus() { async function getSystemStatus() {
socket const result = await api.get<SystemInformation>('/api/system/status')
.request({ systemInformationRequest: {} }) if (result.isErr()) {
.then(response => { console.error('Error:', result.inner)
if (response.systemInformationResponse) { return
systemInformation = response.systemInformationResponse }
return systemInformation; systemInformation = result.inner
} else { throw new TypeError("System Information not found in reponse") } return systemInformation
})
return
} }
const postFactoryReset = async () => await api.post('/api/system/reset') const postFactoryReset = async () => await api.post('/api/system/reset')
const postSleep = async () => await api.post('api/sleep') const postSleep = async () => await api.post('api/sleep')
let unsub: (() => void) | undefined = undefined onMount(() => socket.on(MessageTopic.analytics, handleSystemData))
onMount(() => (unsub = socket.on(AnalyticsData, handleSystemData)))
onDestroy(() => {
if (unsub) unsub()
})
const handleSystemData = (data: AnalyticsData) => { onDestroy(() => socket.off(MessageTopic.analytics, handleSystemData))
const handleSystemData = (data: Analytics) => {
if (systemInformation) { if (systemInformation) {
systemInformation = { systemInformation = {
...systemInformation, ...systemInformation,
@@ -118,7 +111,7 @@
} }
interface ActionButtonDef { interface ActionButtonDef {
icon: Component icon: ComponentType
label: string label: string
onClick: () => void onClick: () => void
type?: string type?: string
@@ -166,63 +159,58 @@
<StatusItem <StatusItem
icon={CPU} icon={CPU}
title="Chip" title="Chip"
description={`${systemInformation.staticSystemInformation?.cpuType} Rev ${systemInformation.staticSystemInformation?.cpuRev}`} description={`${systemInformation.cpu_type} Rev ${systemInformation.cpu_rev}`}
/> />
<StatusItem <StatusItem
icon={SDK} icon={SDK}
title="SDK Version" title="SDK Version"
description={`ESP-IDF ${systemInformation.staticSystemInformation?.sdkVersion} / Arduino ${systemInformation.staticSystemInformation?.arduinoVersion}`} description={`ESP-IDF ${systemInformation.sdk_version} / Arduino ${systemInformation.arduino_version}`}
/> />
<StatusItem <StatusItem
icon={CPP} icon={CPP}
title="Firmware Version" title="Firmware Version"
description={systemInformation.staticSystemInformation?.firmwareVersion} description={systemInformation.firmware_version}
/> />
<StatusItem <StatusItem
icon={Speed} icon={Speed}
title="CPU Frequency" title="CPU Frequency"
description={`${systemInformation.staticSystemInformation?.cpuFreqMhz} MHz ${ description={`${systemInformation.cpu_freq_mhz} MHz ${
systemInformation.staticSystemInformation?.cpuCores == 2 ? systemInformation.cpu_cores == 2 ? 'Dual Core' : 'Single Core'
'Dual Core'
: 'Single Core'
}`} }`}
/> />
<StatusItem <StatusItem
icon={Heap} icon={Heap}
title="Heap (Free / Max Alloc)" title="Heap (Free / Max Alloc)"
description={`${systemInformation.analyticsData?.freeHeap} / ${systemInformation.analyticsData?.maxAllocHeap} bytes`} description={`${systemInformation.free_heap} / ${systemInformation.max_alloc_heap} bytes`}
/> />
<StatusItem <StatusItem
icon={Pyramid} icon={Pyramid}
title="PSRAM (Size / Free)" title="PSRAM (Size / Free)"
description={`${systemInformation.analyticsData!.psramSize - systemInformation.analyticsData!.freePsram} / ${systemInformation.analyticsData?.psramSize} bytes`} description={`${systemInformation.psram_size} / ${systemInformation.psram_size} bytes`}
/> />
<StatusItem <StatusItem
icon={Sketch} icon={Sketch}
title="Sketch (Used / Free)" title="Sketch (Used / Free)"
description={`${( description={`${(
(systemInformation.staticSystemInformation!.sketchSize / (systemInformation.sketch_size / systemInformation.free_sketch_space) *
systemInformation.staticSystemInformation!.freeSketchSpace) *
100 100
).toFixed(1)} % of ).toFixed(1)} % of
${systemInformation.staticSystemInformation!.freeSketchSpace / 1000000} MB used (${ ${systemInformation.free_sketch_space / 1000000} MB used (${
(systemInformation.staticSystemInformation!.freeSketchSpace - (systemInformation.free_sketch_space - systemInformation.sketch_size) / 1000000
systemInformation.staticSystemInformation!.sketchSize) /
1000000
} MB free)`} } MB free)`}
/> />
<StatusItem <StatusItem
icon={Flash} icon={Flash}
title="Flash Chip (Size / Speed)" title="Flash Chip (Size / Speed)"
description={`${systemInformation.staticSystemInformation!.flashChipSize / 1000000} MB / ${ description={`${systemInformation.flash_chip_size / 1000000} MB / ${
systemInformation.staticSystemInformation!.flashChipSpeed / 1000000 systemInformation.flash_chip_speed / 1000000
} MHz`} } MHz`}
/> />
@@ -230,15 +218,10 @@
icon={Folder} icon={Folder}
title="File System (Used / Total)" title="File System (Used / Total)"
description={`${( description={`${(
(systemInformation.analyticsData!.fsUsed / (systemInformation.fs_used / systemInformation.fs_total) *
systemInformation.analyticsData!.fsTotal) *
100 100
).toFixed( ).toFixed(1)} % of ${systemInformation.fs_total / 1000000} MB used (${
1 (systemInformation.fs_total - systemInformation.fs_used) / 1000000
)} % of ${systemInformation.analyticsData!.fsTotal / 1000000} MB used (${
(systemInformation.analyticsData!.fsTotal -
systemInformation.analyticsData!.fsUsed) /
1000000
} }
MB free)`} MB free)`}
/> />
@@ -247,22 +230,22 @@
icon={Temperature} icon={Temperature}
title="Core Temperature" title="Core Temperature"
description={`${ description={`${
systemInformation.analyticsData!.coreTemp == 53.33 ? systemInformation.core_temp == 53.33 ?
'NaN' 'NaN'
: systemInformation.analyticsData!.coreTemp.toFixed(2) + ' °C' : systemInformation.core_temp.toFixed(2) + ' °C'
}`} }`}
/> />
<StatusItem <StatusItem
icon={Stopwatch} icon={Stopwatch}
title="Uptime" title="Uptime"
description={convertSeconds(systemInformation.analyticsData!.uptime)} description={convertSeconds(systemInformation.uptime)}
/> />
<StatusItem <StatusItem
icon={Power} icon={Power}
title="Reset Reason" title="Reset Reason"
description={systemInformation.staticSystemInformation?.cpuResetReason} description={systemInformation.cpu_reset_reason}
/> />
</div> </div>
{/if} {/if}
@@ -270,7 +253,7 @@
</div> </div>
<div class="mt-4 flex flex-wrap justify-end gap-2"> <div class="mt-4 flex flex-wrap justify-end gap-2">
{#each actionButtons as button (button.label)} {#each actionButtons as button}
{#if button.condition === undefined || button.condition()} {#if button.condition === undefined || button.condition()}
<ActionButton <ActionButton
onclick={button.onClick} onclick={button.onClick}
@@ -108,7 +108,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each githubReleases as release (release.tag_name)} {#each githubReleases as release}
<tr <tr
class={( class={(
compareVersions( compareVersions(
@@ -119,8 +119,8 @@
'bg-primary text-primary-content' 'bg-primary text-primary-content'
: 'bg-base-100 h-14'} : 'bg-base-100 h-14'}
> >
<td align="left" class="text-base font-semibold" <td align="left" class="text-base font-semibold">
><!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- external URL --><a <a
href={release.html_url} href={release.html_url}
class="link link-hover" class="link link-hover"
target="_blank" target="_blank"
+41 -55
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { preventDefault } from 'svelte/legacy'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
@@ -6,46 +8,33 @@
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications'
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import type { ApSettings, ApStatus } from '$lib/types/models'
import { api } from '$lib/api' import { api } from '$lib/api'
import { ipToUint32, uint32ToIp, isValidIpString } from '$lib/utilities'
import { AP, Devices, Home, MAC } from '$lib/components/icons' import { AP, Devices, Home, MAC } from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte'
import { APSettings, APStatus, Request, Response } from '$lib/platform_shared/api'
import { input } from '$lib/stores'
let apSettings: APSettings | null = $state(null) let apSettings: ApSettings | null = $state(null)
let apStatus: APStatus | null = $state(null) let apStatus: ApStatus | null = $state(null)
let ipDisplay = $state({ let formField: Record<string, unknown> = $state()
local_ip: '',
gateway_ip: '',
subnet_mask: ''
})
let formField: Record<string, unknown> = $state({})
async function getAPStatus() { async function getAPStatus() {
const result = await api.get<Response>('/api/ap/status') const result = await api.get<ApStatus>('/api/wifi/ap/status')
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner)
return return
} }
apStatus = result.inner
apStatus = result.inner.apStatus! return apStatus
} }
async function getAPSettings() { async function getAPSettings() {
const result = await api.get<Response>('/api/ap/settings') const result = await api.get<ApSettings>('/api/wifi/ap/settings')
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner)
return return
} }
apSettings = result.inner.apSettings! apSettings = result.inner
ipDisplay = {
local_ip: uint32ToIp(apSettings.localIp),
gateway_ip: uint32ToIp(apSettings.gatewayIp),
subnet_mask: uint32ToIp(apSettings.subnetMask)
}
return apSettings return apSettings
} }
@@ -87,28 +76,22 @@
subnet_mask: false subnet_mask: false
}) })
async function postAPSettings(data: APSettings) { async function postAPSettings(data: ApSettings) {
const result = await api.post_proto<Response>('/api/ap/settings', Request.create({ apSettings: data })) const result = await api.post<ApSettings>('/api/wifi/ap/settings', data)
if (result.isErr()) { if (result.isErr()) {
notifications.error('User not authorized.', 3000) notifications.error('User not authorized.', 3000)
console.error('Error:', result.inner) console.error('Error:', result.inner)
return return
} }
if (result.inner.statusCode !== 200) {
notifications.error(result.inner.errorMessage || 'Failed to update settings', 3000)
return
}
if (result.inner.apSettings) {
apSettings = result.inner.apSettings
}
notifications.success('Access Point settings updated.', 3000) notifications.success('Access Point settings updated.', 3000)
apSettings = result.inner
} }
function handleSubmitAP(e: Event) { function handleSubmitAP() {
e.preventDefault()
if (!apSettings) return if (!apSettings) return
let valid = true let valid = true
// Validate SSID
if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) { if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) {
valid = false valid = false
formErrors.ssid = true formErrors.ssid = true
@@ -116,6 +99,7 @@
formErrors.ssid = false formErrors.ssid = false
} }
// Validate Channel
let channel = Number(apSettings.channel) let channel = Number(apSettings.channel)
if (1 > channel || channel > 13) { if (1 > channel || channel > 13) {
valid = false valid = false
@@ -124,7 +108,8 @@
formErrors.channel = false formErrors.channel = false
} }
let maxClients = Number(apSettings.maxClients) // Validate max_clients
let maxClients = Number(apSettings.max_clients)
if (1 > maxClients || maxClients > 8) { if (1 > maxClients || maxClients > 8) {
valid = false valid = false
formErrors.max_clients = true formErrors.max_clients = true
@@ -132,31 +117,36 @@
formErrors.max_clients = false formErrors.max_clients = false
} }
if (!isValidIpString(ipDisplay.gateway_ip)) { // RegEx for IPv4
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
// Validate gateway IP
if (!regexExp.test(apSettings.gateway_ip)) {
valid = false valid = false
formErrors.gateway_ip = true formErrors.gateway_ip = true
} else { } else {
formErrors.gateway_ip = false formErrors.gateway_ip = false
} }
if (!isValidIpString(ipDisplay.subnet_mask)) { // Validate Subnet Mask
if (!regexExp.test(apSettings.subnet_mask)) {
valid = false valid = false
formErrors.subnet_mask = true formErrors.subnet_mask = true
} else { } else {
formErrors.subnet_mask = false formErrors.subnet_mask = false
} }
if (!isValidIpString(ipDisplay.local_ip)) { // Validate local IP
if (!regexExp.test(apSettings.local_ip)) {
valid = false valid = false
formErrors.local_ip = true formErrors.local_ip = true
} else { } else {
formErrors.local_ip = false formErrors.local_ip = false
} }
// Submit JSON to REST API
if (valid) { if (valid) {
apSettings.localIp = ipToUint32(ipDisplay.local_ip)
apSettings.gatewayIp = ipToUint32(ipDisplay.gateway_ip)
apSettings.subnetMask = ipToUint32(ipDisplay.subnet_mask)
postAPSettings(apSettings) postAPSettings(apSettings)
} }
} }
@@ -185,18 +175,14 @@
description={apStatusDescription[apStatus.status]} description={apStatusDescription[apStatus.status]}
/> />
<StatusItem <StatusItem icon={Home} title="IP Address" description={apStatus.ip_address} />
icon={Home}
title="IP Address"
description={uint32ToIp(apStatus.ipAddress)}
/>
<StatusItem icon={MAC} title="MAC Address" description={apStatus.macAddress} /> <StatusItem icon={MAC} title="MAC Address" description={apStatus.mac_address} />
<StatusItem <StatusItem
icon={Devices} icon={Devices}
title="AP Clients" title="AP Clients"
description={apStatus.stationNum} description={apStatus.station_num}
/> />
</div> </div>
{/if} {/if}
@@ -219,7 +205,7 @@
> >
<form <form
class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2" class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2"
onsubmit={handleSubmitAP} onsubmit={preventDefault(handleSubmitAP)}
novalidate novalidate
bind:this={formField} bind:this={formField}
> >
@@ -230,9 +216,9 @@
<select <select
class="select select-bordered w-full" class="select select-bordered w-full"
id="apmode" id="apmode"
bind:value={apSettings.provisionMode} bind:value={apSettings.provision_mode}
> >
{#each provisionMode as mode (mode.id)} {#each provisionMode as mode}
<option value={mode.id}> <option value={mode.id}>
{mode.text} {mode.text}
</option> </option>
@@ -310,7 +296,7 @@
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
bind:value={apSettings.maxClients} bind:value={apSettings.max_clients}
id="clients" id="clients"
required required
/> />
@@ -334,7 +320,7 @@
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={ipDisplay.local_ip} bind:value={apSettings.local_ip}
id="localIP" id="localIP"
required required
/> />
@@ -359,7 +345,7 @@
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={ipDisplay.gateway_ip} bind:value={apSettings.gateway_ip}
id="gateway" id="gateway"
required required
/> />
@@ -383,7 +369,7 @@
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={ipDisplay.subnet_mask} bind:value={apSettings.subnet_mask}
id="subnet" id="subnet"
required required
/> />
@@ -398,7 +384,7 @@
<label class="label my-auto cursor-pointer justify-start gap-4"> <label class="label my-auto cursor-pointer justify-start gap-4">
<input <input
type="checkbox" type="checkbox"
bind:checked={apSettings.ssidHidden} bind:checked={apSettings.ssid_hidden}
class="checkbox checkbox-primary" class="checkbox checkbox-primary"
/> />
<span class="">Hide SSID</span> <span class="">Hide SSID</span>
+10 -23
View File
@@ -6,46 +6,33 @@
import StatusItem from '$lib/components/StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { import type { MDNSStatus, MDNSServiceItem, MDNSServiceQuery } from '$lib/types/models'
type MDNSStatus,
type MDNSQueryResult,
Request,
type Response as ProtoResponse
} from '$lib/platform_shared/api'
import { compareIp } from '$lib/utilities' import { compareIp } from '$lib/utilities'
let mdnsStatus = $state<MDNSStatus | undefined>() let mdnsStatus: MDNSStatus | undefined = $state()
let services = $state<MDNSQueryResult[]>([]) let services: MDNSServiceItem[] = $state([])
let isLoading = $state(false) let isLoading = $state(false)
const getMDNSStatus = async () => { const getMDNSStatus = async () => {
const result = await api.get<ProtoResponse>('/api/mdns/status') const result = await api.get<MDNSStatus>('/api/mdns/status')
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner)
return return
} }
if (result.inner.mdnsStatus) { mdnsStatus = result.inner
mdnsStatus = result.inner.mdnsStatus
}
} }
const queryMDNSServices = async () => { const queryMDNSServices = async () => {
isLoading = true isLoading = true
const request = Request.create({ const result = await api.post<MDNSServiceQuery>('/api/mdns/query', {
mdnsQueryRequest: { service: 'http',
service: 'http', protocol: 'tcp'
protocol: 'tcp'
}
}) })
const result = await api.post_proto<ProtoResponse>('/api/mdns/query', request)
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner)
isLoading = false
return return
} }
if (result.inner.mdnsQueryResponse) { services = result.inner.services.sort((a, b) => compareIp(a.ip, b.ip))
services = result.inner.mdnsQueryResponse.services.sort((a, b) => compareIp(a.ip, b.ip))
}
isLoading = false isLoading = false
} }
@@ -101,7 +88,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each services as service (service.ip)} {#each services as service}
<tr> <tr>
<td><Devices class="h-6 w-6" /></td> <td><Devices class="h-6 w-6" /></td>
<td>{service.name}</td> <td>{service.name}</td>
+12 -14
View File
@@ -3,14 +3,14 @@
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte' import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
import { type WifiNetworkScan, type Response as ProtoResponse } from '$lib/platform_shared/api' import type { NetworkItem, NetworkList } from '$lib/types/models'
import { api } from '$lib/api' import { api } from '$lib/api'
import { AP, Network, Reload, Cancel } from '$lib/components/icons' import { AP, Network, Reload, Cancel } from '$lib/components/icons'
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals' import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
let { isOpen, storeNetwork }: ModalProps = $props() let { isOpen, storeNetwork }: ModalProps = $props()
const encryptionTypes = [ const encryptionType = [
'Open', 'Open',
'WEP', 'WEP',
'WPA PSK', 'WPA PSK',
@@ -22,7 +22,7 @@
'WAPI PSK' 'WAPI PSK'
] ]
let listOfNetworks = $state<WifiNetworkScan[]>([]) let listOfNetworks: NetworkItem[] = $state([])
let scanActive = $state(false) let scanActive = $state(false)
@@ -38,21 +38,19 @@
} }
async function pollingResults() { async function pollingResults() {
const result = await api.get<ProtoResponse>('/api/wifi/networks') const result = await api.get<NetworkList>('/api/wifi/networks')
if (result.isErr() || !result.inner) { if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner)
return false return false
} }
// Check if scan is complete (status 200 means we have results) let response = result.inner
if (result.inner.statusCode === 200 && result.inner.wifiNetworkList) { listOfNetworks = response.networks
listOfNetworks = result.inner.wifiNetworkList.networks ?? [] scanActive = false
scanActive = false if (listOfNetworks.length) {
clearInterval(pollingId) clearInterval(pollingId)
pollingId = 0 pollingId = 0
return listOfNetworks.length
} }
// Still scanning (status 202) return listOfNetworks.length
return 0
} }
onMount(() => { onMount(() => {
@@ -89,7 +87,7 @@
</div> </div>
{:else} {:else}
<ul class="menu"> <ul class="menu">
{#each listOfNetworks as network (network.ssid)} {#each listOfNetworks as network}
<li> <li>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
@@ -108,7 +106,7 @@
<div> <div>
<div class="font-bold">{network.ssid}</div> <div class="font-bold">{network.ssid}</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
Security: {encryptionTypes[network.encryptionType]}, Security: {encryptionType[network.encryption_type]},
Channel: {network.channel} Channel: {network.channel}
</div> </div>
</div> </div>
+101 -126
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
@@ -11,14 +12,13 @@
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import InfoDialog from '$lib/components/InfoDialog.svelte' import InfoDialog from '$lib/components/InfoDialog.svelte'
import { import {
type WifiStatus, MessageTopic,
type KnownNetworkItem,
type WifiSettings, type WifiSettings,
type WifiNetwork, type WifiStatus
type Response as ProtoResponse, } from '$lib/types/models'
Request import { socket } from '$lib/stores'
} from '$lib/platform_shared/api'
import { api } from '$lib/api' import { api } from '$lib/api'
import { ipToUint32, uint32ToIp, isValidIpString } from '$lib/utilities'
import { import {
Cancel, Cancel,
Delete, Delete,
@@ -40,26 +40,18 @@
} from '$lib/components/icons' } from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte'
let networkEditable: WifiNetwork = $state({ let networkEditable: KnownNetworkItem = $state({
ssid: '', ssid: '',
password: '', password: '',
staticIpConfig: false, static_ip_config: false,
localIp: 0, local_ip: undefined,
subnetMask: 0, subnet_mask: undefined,
gatewayIp: 0, gateway_ip: undefined,
dnsIp1: 0, dns_ip_1: undefined,
dnsIp2: 0 dns_ip_2: undefined
}) })
let ipDisplay = $state({ let static_ip_config = $state(false)
localIp: '',
subnetMask: '',
gatewayIp: '',
dnsIp1: '',
dnsIp2: ''
})
let staticIpConfig = $state(false)
let newNetwork: boolean = $state(true) let newNetwork: boolean = $state(true)
let showNetworkEditor: boolean = $state(false) let showNetworkEditor: boolean = $state(false)
@@ -67,60 +59,61 @@
let wifiStatus: WifiStatus | null = $state(null) let wifiStatus: WifiStatus | null = $state(null)
let wifiSettings: WifiSettings | null = $state(null) let wifiSettings: WifiSettings | null = $state(null)
let dndNetworkList: WifiNetwork[] = $state([]) let dndNetworkList: KnownNetworkItem[] = $state([])
let showWifiDetails = $state(false) let showWifiDetails = $state(false)
let formField: Record<string, unknown> = $state({}) let formField: Record<string, unknown> = $state()
let formErrors = $state({ let formErrors = $state({
ssid: false, ssid: false,
localIp: false, local_ip: false,
gatewayIp: false, gateway_ip: false,
subnetMask: false, subnet_mask: false,
dnsIp1: false, dns_1: false,
dnsIp2: false dns_2: false
}) })
let formErrorhostname = $state(false) let formErrorhostname = $state(false)
async function getWifiStatus() { async function getWifiStatus() {
const result = await api.get<ProtoResponse>('/api/wifi/sta/status') const result = await api.get<WifiStatus>('/api/wifi/sta/status')
if (result.isErr()) { if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner)
return return
} }
if (result.inner.wifiStatus) { wifiStatus = result.inner
wifiStatus = result.inner.wifiStatus
}
return wifiStatus return wifiStatus
} }
async function getWifiSettings() { async function getWifiSettings() {
const result = await api.get<ProtoResponse>('/api/wifi/sta/settings') const result = await api.get<WifiSettings>('/api/wifi/sta/settings')
if (result.isErr()) { if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner)
return return
} }
wifiSettings = result.inner.wifiSettings! wifiSettings = result.inner
dndNetworkList = wifiSettings.wifiNetworks dndNetworkList = wifiSettings.wifi_networks
return wifiSettings return wifiSettings
} }
onDestroy(() => socket.off(MessageTopic.WiFiSettings))
onMount(() => {
socket.on<WifiSettings>(MessageTopic.WiFiSettings, data => {
wifiSettings = data
dndNetworkList = wifiSettings.wifi_networks
})
})
async function postWiFiSettings(data: WifiSettings) { async function postWiFiSettings(data: WifiSettings) {
const result = await api.post_proto<ProtoResponse>('/api/wifi/sta/settings', Request.create({ wifiSettings: data })) const result = await api.post<WifiSettings>('/api/wifi/sta/settings', data)
if (result.isErr()) { if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner)
notifications.error('User not authorized.', 3000) notifications.error('User not authorized.', 3000)
return return
} }
if (result.inner.statusCode !== 200) { wifiSettings = result.inner
notifications.error(result.inner.errorMessage || 'Failed to update settings', 3000)
return
}
if (result.inner.wifiSettings) {
wifiSettings = result.inner.wifiSettings
}
notifications.success('Wi-Fi settings updated.', 3000) notifications.success('Wi-Fi settings updated.', 3000)
} }
@@ -131,7 +124,7 @@
} else { } else {
formErrorhostname = false formErrorhostname = false
// Update global wifiSettings object // Update global wifiSettings object
wifiSettings.wifiNetworks = dndNetworkList wifiSettings.wifi_networks = dndNetworkList
// Post to REST API // Post to REST API
postWiFiSettings(wifiSettings) postWiFiSettings(wifiSettings)
console.log(wifiSettings) console.log(wifiSettings)
@@ -142,6 +135,7 @@
event.preventDefault() event.preventDefault()
let valid = true let valid = true
// Validate SSID
if (networkEditable.ssid.length < 3 || networkEditable.ssid.length > 32) { if (networkEditable.ssid.length < 3 || networkEditable.ssid.length > 32) {
valid = false valid = false
formErrors.ssid = true formErrors.ssid = true
@@ -149,57 +143,60 @@
formErrors.ssid = false formErrors.ssid = false
} }
networkEditable.staticIpConfig = staticIpConfig networkEditable.static_ip_config = static_ip_config
if (networkEditable.staticIpConfig) { if (networkEditable.static_ip_config) {
if (!isValidIpString(ipDisplay.gatewayIp)) { // RegEx for IPv4
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
// Validate gateway IP
if (!regexExp.test(networkEditable.gateway_ip!)) {
valid = false valid = false
formErrors.gatewayIp = true formErrors.gateway_ip = true
} else { } else {
formErrors.gatewayIp = false formErrors.gateway_ip = false
} }
if (!isValidIpString(ipDisplay.subnetMask)) { // Validate Subnet Mask
if (!regexExp.test(networkEditable.subnet_mask!)) {
valid = false valid = false
formErrors.subnetMask = true formErrors.subnet_mask = true
} else { } else {
formErrors.subnetMask = false formErrors.subnet_mask = false
} }
if (!isValidIpString(ipDisplay.localIp)) { // Validate local IP
if (!regexExp.test(networkEditable.local_ip!)) {
valid = false valid = false
formErrors.localIp = true formErrors.local_ip = true
} else { } else {
formErrors.localIp = false formErrors.local_ip = false
} }
if (!isValidIpString(ipDisplay.dnsIp1)) { // Validate DNS 1
if (!regexExp.test(networkEditable.dns_ip_1!)) {
valid = false valid = false
formErrors.dnsIp1 = true formErrors.dns_1 = true
} else { } else {
formErrors.dnsIp1 = false formErrors.dns_1 = false
} }
if (!isValidIpString(ipDisplay.dnsIp2)) { // Validate DNS 2
if (!regexExp.test(networkEditable.dns_ip_2!)) {
valid = false valid = false
formErrors.dnsIp2 = true formErrors.dns_2 = true
} else { } else {
formErrors.dnsIp2 = false formErrors.dns_2 = false
} }
networkEditable.localIp = ipToUint32(ipDisplay.localIp)
networkEditable.subnetMask = ipToUint32(ipDisplay.subnetMask)
networkEditable.gatewayIp = ipToUint32(ipDisplay.gatewayIp)
networkEditable.dnsIp1 = ipToUint32(ipDisplay.dnsIp1)
networkEditable.dnsIp2 = ipToUint32(ipDisplay.dnsIp2)
} else { } else {
formErrors.localIp = false formErrors.local_ip = false
formErrors.subnetMask = false formErrors.subnet_mask = false
formErrors.gatewayIp = false formErrors.gateway_ip = false
formErrors.dnsIp1 = false formErrors.dns_1 = false
formErrors.dnsIp2 = false formErrors.dns_2 = false
} }
// Submit JSON to REST API
if (valid) { if (valid) {
if (newNetwork) { if (newNetwork) {
dndNetworkList.push(networkEditable) dndNetworkList.push(networkEditable)
@@ -207,12 +204,8 @@
dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable) dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable)
} }
addNetwork() addNetwork()
dndNetworkList = [...dndNetworkList] dndNetworkList = [...dndNetworkList] //Trigger reactivity
showNetworkEditor = false showNetworkEditor = false
if (wifiSettings) {
wifiSettings.wifiNetworks = dndNetworkList
postWiFiSettings(wifiSettings)
}
} }
} }
@@ -232,19 +225,12 @@
networkEditable = { networkEditable = {
ssid: '', ssid: '',
password: '', password: '',
staticIpConfig: false, static_ip_config: false,
localIp: 0, local_ip: undefined,
subnetMask: 0, subnet_mask: undefined,
gatewayIp: 0, gateway_ip: undefined,
dnsIp1: 0, dns_ip_1: undefined,
dnsIp2: 0 dns_ip_2: undefined
}
ipDisplay = {
localIp: '',
subnetMask: '',
gatewayIp: '',
dnsIp1: '',
dnsIp2: ''
} }
} }
@@ -252,13 +238,6 @@
newNetwork = false newNetwork = false
showNetworkEditor = true showNetworkEditor = true
networkEditable = dndNetworkList[index] networkEditable = dndNetworkList[index]
ipDisplay = {
localIp: networkEditable.localIp ? uint32ToIp(networkEditable.localIp) : '',
subnetMask: networkEditable.subnetMask ? uint32ToIp(networkEditable.subnetMask) : '',
gatewayIp: networkEditable.gatewayIp ? uint32ToIp(networkEditable.gatewayIp) : '',
dnsIp1: networkEditable.dnsIp1 ? uint32ToIp(networkEditable.dnsIp1) : '',
dnsIp2: networkEditable.dnsIp2 ? uint32ToIp(networkEditable.dnsIp2) : ''
}
} }
function confirmDelete(index: number) { function confirmDelete(index: number) {
@@ -337,7 +316,7 @@
<StatusItem <StatusItem
icon={Home} icon={Home}
title="IP Address" title="IP Address"
description={uint32ToIp(wifiStatus.localIp)} description={wifiStatus.local_ip}
/> />
<StatusItem icon={WiFi} title="RSSI" description={`${wifiStatus.rssi} dBm`}> <StatusItem icon={WiFi} title="RSSI" description={`${wifiStatus.rssi} dBm`}>
@@ -368,7 +347,7 @@
<StatusItem <StatusItem
icon={MAC} icon={MAC}
title="MAC Address" title="MAC Address"
description={wifiStatus.macAddress} description={wifiStatus.mac_address}
/> />
<StatusItem <StatusItem
@@ -380,20 +359,16 @@
<StatusItem <StatusItem
icon={Gateway} icon={Gateway}
title="Gateway IP" title="Gateway IP"
description={uint32ToIp(wifiStatus.gatewayIp)} description={wifiStatus.gateway_ip}
/> />
<StatusItem <StatusItem
icon={Subnet} icon={Subnet}
title="Subnet Mask" title="Subnet Mask"
description={uint32ToIp(wifiStatus.subnetMask)} description={wifiStatus.subnet_mask}
/> />
<StatusItem <StatusItem icon={DNS} title="DNS" description={wifiStatus.dns_ip_1} />
icon={DNS}
title="DNS"
description={uint32ToIp(wifiStatus.dnsIp1)}
/>
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -510,7 +485,7 @@
> >
<input <input
type="checkbox" type="checkbox"
bind:checked={wifiSettings.priorityRssi} bind:checked={wifiSettings.priority_RSSI}
class="checkbox checkbox-primary sm:-mb-5" class="checkbox checkbox-primary sm:-mb-5"
/> />
<span class="sm:-mb-5">Connect to strongest WiFi</span> <span class="sm:-mb-5">Connect to strongest WiFi</span>
@@ -559,13 +534,13 @@
> >
<input <input
type="checkbox" type="checkbox"
bind:checked={staticIpConfig} bind:checked={static_ip_config}
class="checkbox checkbox-primary sm:-mb-5" class="checkbox checkbox-primary sm:-mb-5"
/> />
<span class="sm:-mb-5">Static IP Config?</span> <span class="sm:-mb-5">Static IP Config?</span>
</label> </label>
</div> </div>
{#if staticIpConfig} {#if static_ip_config}
<div <div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2" class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}
@@ -577,21 +552,21 @@
<input <input
type="text" type="text"
class="input input-bordered w-full {( class="input input-bordered w-full {(
formErrors.localIp formErrors.local_ip
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={ipDisplay.localIp} bind:value={networkEditable.local_ip}
id="localIP" id="localIP"
required required
/> />
<label class="label" for="localIP"> <label class="label" for="localIP">
<span <span
class="label-text-alt text-error {( class="label-text-alt text-error {(
formErrors.localIp formErrors.local_ip
) ? ) ?
'' ''
: 'hidden'}">Must be a valid IPv4 address</span : 'hidden'}">Must be a valid IPv4 address</span
@@ -606,20 +581,20 @@
<input <input
type="text" type="text"
class="input input-bordered w-full {( class="input input-bordered w-full {(
formErrors.gatewayIp formErrors.gateway_ip
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={ipDisplay.gatewayIp} bind:value={networkEditable.gateway_ip}
required required
/> />
<label class="label" for="gateway"> <label class="label" for="gateway">
<span <span
class="label-text-alt text-error {( class="label-text-alt text-error {(
formErrors.gatewayIp formErrors.gateway_ip
) ? ) ?
'' ''
: 'hidden'}">Must be a valid IPv4 address</span : 'hidden'}">Must be a valid IPv4 address</span
@@ -633,20 +608,20 @@
<input <input
type="text" type="text"
class="input input-bordered w-full {( class="input input-bordered w-full {(
formErrors.subnetMask formErrors.subnet_mask
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={ipDisplay.subnetMask} bind:value={networkEditable.subnet_mask}
required required
/> />
<label class="label" for="subnet"> <label class="label" for="subnet">
<span <span
class="label-text-alt text-error {( class="label-text-alt text-error {(
formErrors.subnetMask formErrors.subnet_mask
) ? ) ?
'' ''
: 'hidden'}" : 'hidden'}"
@@ -661,18 +636,18 @@
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full {formErrors.dnsIp1 ? class="input input-bordered w-full {formErrors.dns_1 ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={ipDisplay.dnsIp1} bind:value={networkEditable.dns_ip_1}
required required
/> />
<label class="label" for="gateway"> <label class="label" for="gateway">
<span <span
class="label-text-alt text-error {formErrors.dnsIp1 ? class="label-text-alt text-error {formErrors.dns_1 ?
'' ''
: 'hidden'}" : 'hidden'}"
> >
@@ -686,18 +661,18 @@
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full {formErrors.dnsIp2 ? class="input input-bordered w-full {formErrors.dns_2 ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={ipDisplay.dnsIp2} bind:value={networkEditable.dns_ip_2}
required required
/> />
<label class="label" for="subnet"> <label class="label" for="subnet">
<span <span
class="label-text-alt text-error {formErrors.dnsIp2 ? class="label-text-alt text-error {formErrors.dns_2 ?
'' ''
: 'hidden'}" : 'hidden'}"
> >
-3
View File
@@ -17,9 +17,6 @@ const config = {
}), }),
paths: { paths: {
base: basePath base: basePath
},
output: {
bundleStrategy: 'single'
} }
} }
} }
+4 -4
View File
@@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
test('has title', async ({ page }) => { test('has title', async ({ page }) => {
await page.goto('/') await page.goto('/')
await expect(page).toHaveTitle(/Spot micro controller/) await expect(page).toHaveTitle(/Spot micro controller/)
}) })
test('index page has expected h1', async ({ page }) => { test('index page has expected h1', async ({ page }) => {
await page.goto('/') await page.goto('/')
await expect(page.getByRole('heading', { name: 'Spot micro controller' }).first()).toBeVisible() await expect(page.getByRole('heading', { name: 'Spot micro controller' }).first()).toBeVisible()
}) })
+21 -21
View File
@@ -1,28 +1,28 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { humanFileSize } from '../../src/lib/utilities/string-utilities' import { humanFileSize } from '../../src/lib/utilities/string-utilities';
describe('humanFileSize', () => { describe('humanFileSize', () => {
it('returns "0B" for 0 bytes', () => { it('returns "0B" for 0 bytes', () => {
expect(humanFileSize(0)).toBe('0B') expect(humanFileSize(0)).toBe('0B');
}) });
it('returns the size in bytes correctly', () => { it('returns the size in bytes correctly', () => {
expect(humanFileSize(500)).toBe('500B') expect(humanFileSize(500)).toBe('500B');
}) });
it('returns the size in kB correctly', () => { it('returns the size in kB correctly', () => {
expect(humanFileSize(1024)).toBe('1kB') expect(humanFileSize(1024)).toBe('1kB');
}) });
it('returns the size in MB correctly', () => { it('returns the size in MB correctly', () => {
expect(humanFileSize(1048576)).toBe('1MB') // 1024 * 1024 expect(humanFileSize(1048576)).toBe('1MB'); // 1024 * 1024
}) });
it('returns the size in GB correctly', () => { it('returns the size in GB correctly', () => {
expect(humanFileSize(1073741824)).toBe('1GB') // 1024 * 1024 * 1024 expect(humanFileSize(1073741824)).toBe('1GB'); // 1024 * 1024 * 1024
}) });
it('rounds to 2 decimal places correctly', () => { it('rounds to 2 decimal places correctly', () => {
expect(humanFileSize(1536)).toBe('1.5kB') // 1024 + 512 expect(humanFileSize(1536)).toBe('1.5kB'); // 1024 + 512
}) });
}) });
+34 -34
View File
@@ -1,44 +1,44 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { toUint8, toInt8 } from '../../src/lib/utilities/math-utilities' import { toUint8, toInt8 } from '../../src/lib/utilities/math-utilities';
describe('toUint8', () => { describe('toUint8', () => {
it('min interval value should get 0', () => { it('min interval value should get 0', () => {
expect(toUint8(-1, -1, 1)).toBe(0) expect(toUint8(-1, -1, 1)).toBe(0);
}) });
it('middle interval value should get 128', () => { it('middle interval value should get 128', () => {
expect(toUint8(0, -1, 1)).toBe(128) expect(toUint8(0, -1, 1)).toBe(128);
}) });
it('max interval value should get 255', () => { it('max interval value should get 255', () => {
expect(toUint8(1, -1, 1)).toBe(255) expect(toUint8(1, -1, 1)).toBe(255);
}) });
it('min value should be clamped', () => { it('min value should be clamped', () => {
expect(toUint8(-2, -1, 1)).toBe(0) expect(toUint8(-2, -1, 1)).toBe(0);
}) });
it('max value should be clamped', () => { it('max value should be clamped', () => {
expect(toUint8(2, -1, 1)).toBe(255) expect(toUint8(2, -1, 1)).toBe(255);
}) });
}) });
describe('toInt8', () => { describe('toInt8', () => {
it('min interval value should get -128', () => { it('min interval value should get -128', () => {
expect(toInt8(-1, -1, 1)).toBe(-128) expect(toInt8(-1, -1, 1)).toBe(-128);
}) });
it('middle interval value should get 0', () => { it('middle interval value should get 0', () => {
expect(toInt8(0, -1, 1)).toBe(0) expect(toInt8(0, -1, 1)).toBe(0);
}) });
it('max interval value should get 127', () => { it('max interval value should get 127', () => {
expect(toInt8(1, -1, 1)).toBe(127) expect(toInt8(1, -1, 1)).toBe(127);
}) });
it('min value should be clamped', () => { it('min value should be clamped', () => {
expect(toInt8(-2, -1, 1)).toBe(-128) expect(toInt8(-2, -1, 1)).toBe(-128);
}) });
it('max value should be clamped', () => { it('max value should be clamped', () => {
expect(toInt8(2, -1, 1)).toBe(127) expect(toInt8(2, -1, 1)).toBe(127);
}) });
}) });
+31 -31
View File
@@ -1,39 +1,39 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { Result } from '../../src/lib/utilities/result' import { Result } from '../../src/lib/utilities/result';
describe('Result', () => { describe('Result', () => {
it('should create a success result correctly', () => { it('should create a success result correctly', () => {
const successValue = 'Success value' const successValue = 'Success value';
const result = Result.ok(successValue) const result = Result.ok(successValue);
expect(result.isOk()).toBe(true) expect(result.isOk()).toBe(true);
expect(result.isErr()).toBe(false) expect(result.isErr()).toBe(false);
expect(result.inner).toBe(successValue) expect(result.inner).toBe(successValue);
}) });
it('should create an error result correctly', () => { it('should create an error result correctly', () => {
const errorMessage = 'Error message' const errorMessage = 'Error message';
const result = Result.err(errorMessage) const result = Result.err(errorMessage);
expect(result.isOk()).toBe(false) expect(result.isOk()).toBe(false);
expect(result.isErr()).toBe(true) expect(result.isErr()).toBe(true);
expect(result.inner).toBe(errorMessage) expect(result.inner).toBe(errorMessage);
}) });
it('should type guard success and error results correctly', () => { it('should type guard success and error results correctly', () => {
const successResult = Result.ok(123) const successResult = Result.ok(123);
const errorResult = Result.err('Error') const errorResult = Result.err('Error');
if (successResult.isOk()) { if (successResult.isOk()) {
expect(typeof successResult.inner).toBe('number') expect(typeof successResult.inner).toBe('number');
} else { } else {
throw new Error('Expected successResult to be ok') throw new Error('Expected successResult to be ok');
} }
if (errorResult.isErr()) { if (errorResult.isErr()) {
expect(typeof errorResult.inner).toBe('string') expect(typeof errorResult.inner).toBe('string');
} else { } else {
throw new Error('Expected errorResult to be fail') throw new Error('Expected errorResult to be fail');
} }
}) });
}) });
-265
View File
@@ -1,265 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { WebSocketServer } from 'ws'
import { decodeMessage, MESSAGE_KEY_TO_TAG, socket } from '../../src/lib/stores/socket'
import { IMUData, PingMsg, PongMsg, Message } from '../../src/lib/platform_shared/message'
// Helper function to create encoded WebSocket messages
function createEncodedMessage(messageType: 'imu' | 'rssi' | 'mode', data: unknown): Uint8Array {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message: any = {}
message[messageType] = data
const wsMessage = Message.create(message)
return Message.encode(wsMessage).finish()
}
describe.sequential('WebSocket Integration Tests', () => {
let wss: WebSocketServer
let TEST_PORT = 8765
beforeEach(async () => {
// Use a different port for each test to avoid conflicts
TEST_PORT++
// Create real WebSocket server
wss = new WebSocketServer({ port: TEST_PORT })
// Wait for server to start
await new Promise<void>(resolve => {
wss.on('listening', () => resolve())
})
})
afterEach(async () => {
// Close all connections and server
wss.clients.forEach(client => client.close())
await new Promise<void>(resolve => {
wss.close(() => resolve())
})
// Wait a bit for cleanup
await new Promise(resolve => setTimeout(resolve, 100))
})
it('should connect to WebSocket server', async () => {
socket.init(`ws://localhost:${TEST_PORT}`)
// Wait for connection
await new Promise(resolve => setTimeout(resolve, 100))
let isConnected = false
socket.subscribe(value => {
isConnected = value
})()
expect(isConnected).toBe(true)
})
it('should receive and decode IMU data from server', async () => {
let receivedIMUData: IMUData = null
// Subscribe to IMU messages before connecting
const unsubscribe = socket.on(IMUData, data => {
receivedIMUData = data
})
// Connect socket
socket.init(`ws://localhost:${TEST_PORT}`)
// Wait for client to connect
await new Promise<void>(resolve => {
wss.on('connection', ws => {
// Server sends IMU data to client
const imuPayload = IMUData.create({
x: 3.25,
y: 2.5,
z: 1.75,
heading: 10,
altitude: 11,
bmpTemp: 22,
pressure: 23
})
const encodedMessage = createEncodedMessage('imu', imuPayload)
ws.send(encodedMessage)
setTimeout(resolve, 50)
})
})
expect(receivedIMUData).toBeDefined()
expect(receivedIMUData.x).toBe(3.25)
expect(receivedIMUData.y).toBe(2.5)
expect(receivedIMUData.z).toBe(1.75)
expect(receivedIMUData.heading).toBe(10)
expect(receivedIMUData.altitude).toBe(11)
expect(receivedIMUData.bmpTemp).toBe(22)
expect(receivedIMUData.pressure).toBe(23)
unsubscribe()
})
it('should send IMU data from client to server using emit', async () => {
let serverReceivedData: any = null
// Connect socket
socket.init(`ws://localhost:${TEST_PORT}`)
// Wait for client to connect and send data
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Test timeout - server did not receive message'))
}, 3000)
wss.on('connection', ws => {
// console.log('Server: Client connected')
// Server listens for messages from client
ws.on('message', (data: Buffer) => {
// console.log('Server: Received message, length:', data.length)
// Skip empty messages (from ping, etc.)
if (data.length === 0) {
console.log('Server: Skipping empty message (Probably a ping')
return
}
try {
// Decode the protobuf message
const decoded = Message.decode(new Uint8Array(data))
// console.log('Server: Decoded message:', JSON.stringify(decoded, null, 2))
// Only resolve if we got actual IMU data
if (decoded.imu) {
serverReceivedData = decoded
clearTimeout(timeout)
resolve()
} else {
// console.log('Server: Message decoded but no IMU data, waiting...')
}
} catch (error) {
console.error('Server: Failed to decode:', error)
clearTimeout(timeout)
reject(error)
}
})
})
// Wait for WebSocket to be fully connected
setTimeout(() => {
console.log('Client: Sending IMU data...')
// Client sends IMU data to server
const imuData = IMUData.create({
x: 3.25,
y: 2.5,
z: 1.75,
heading: 10,
altitude: 11,
bmpTemp: 22,
pressure: 23
})
socket.emit(IMUData, imuData)
console.log('Client: emit called')
}, 150)
})
// Verify server received the data
expect(serverReceivedData).toBeDefined()
expect(serverReceivedData?.imu).toBeDefined()
expect(serverReceivedData?.imu.x).toBe(3.25)
expect(serverReceivedData?.imu.y).toBe(2.5)
expect(serverReceivedData?.imu.z).toBe(1.75)
expect(serverReceivedData?.imu.heading).toBe(10)
expect(serverReceivedData?.imu.altitude).toBe(11)
expect(serverReceivedData?.imu.bmpTemp).toBe(22)
expect(serverReceivedData?.imu.pressure).toBe(23)
})
it('should fail to serialize data on emit', async () => {
// Connect socket
socket.init(`ws://localhost:${TEST_PORT}`)
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Test timeout'))
}, 1000)
// Wait for WebSocket to be fully connected
setTimeout(() => {
console.log('Client: Sending invalid message type...')
// Send any invalid message type
const wsm = Message.create()
try {
socket.emit(Message as any, wsm)
clearTimeout(timeout)
reject(new Error('Expected emit to throw, but it did not'))
} catch (e) {
console.log('Client: emit correctly threw error:', e)
clearTimeout(timeout)
resolve()
}
}, 150)
})
})
})
describe('Message Protobuf Encoding/Decoding', () => {
it('should encode and decode IMU data correctly', () => {
const imuData = IMUData.create({
x: 3.25,
y: 2.5,
z: 1.75,
heading: 10,
altitude: 11,
bmpTemp: 22,
pressure: 23
})
const encoded = IMUData.encode(imuData).finish()
const decoded = IMUData.decode(encoded)
expect(decoded.x).toBe(3.25)
expect(decoded.y).toBe(2.5)
expect(decoded.z).toBe(1.75)
expect(decoded.heading).toBe(10)
expect(decoded.altitude).toBe(11)
expect(decoded.bmpTemp).toBe(22)
expect(decoded.pressure).toBe(23)
})
it('should encode and decode two empty types correctly', () => {
const encoded_ping = Message.encode(Message.create({ pingmsg: PingMsg.create() })).finish()
const decoded_ping = decodeMessage(encoded_ping.buffer)
expect(decoded_ping.tag).toBe(MESSAGE_KEY_TO_TAG.get('pingmsg'))
const encoded_pong = Message.encode(Message.create({ pongmsg: PongMsg.create() })).finish()
const decoded_pong = decodeMessage(encoded_pong.buffer)
expect(decoded_pong.tag).toBe(MESSAGE_KEY_TO_TAG.get('pongmsg'))
})
it('should encode and decode complete Message', () => {
const original = Message.create({
imu: IMUData.create({
x: 3.25,
y: 2.5,
z: 1.75,
heading: 10,
altitude: 11,
bmpTemp: 22,
pressure: 23
})
})
const encoded = Message.encode(original).finish()
const decoded = Message.decode(encoded)
expect(decoded.imu).toBeDefined()
expect(decoded.imu?.x).toBe(3.25)
expect(decoded.imu?.y).toBe(2.5)
expect(decoded.imu?.z).toBe(1.75)
expect(decoded.imu?.heading).toBe(10)
expect(decoded.imu?.altitude).toBe(11)
expect(decoded.imu?.bmpTemp).toBe(22)
expect(decoded.imu?.pressure).toBe(23)
})
})
+35 -35
View File
@@ -1,46 +1,46 @@
import { describe, it, expect, beforeEach, afterEach, vitest } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vitest } from 'vitest';
import { Throttler } from '../../src/lib/utilities/buffer-utilities' import { throttler } from '../../src/lib/utilities/buffer-utilities';
describe('throttler', () => { describe('throttler', () => {
let throttleInstance: Throttler let throttleInstance: throttler;
let callback: () => void let callback: Function;
beforeEach(() => { beforeEach(() => {
vitest.useFakeTimers() vitest.useFakeTimers();
throttleInstance = new Throttler() throttleInstance = new throttler();
callback = vitest.fn() callback = vitest.fn();
}) });
afterEach(() => { afterEach(() => {
vitest.useRealTimers() vitest.useRealTimers();
}) });
it('should call the callback function after the specified time', () => { it('should call the callback function after the specified time', () => {
throttleInstance.throttle(callback, 1000) throttleInstance.throttle(callback, 1000);
expect(callback).not.toHaveBeenCalled() expect(callback).not.toHaveBeenCalled();
vitest.advanceTimersByTime(1000) vitest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledTimes(1);
}) });
it('should not call the callback function if throttle is called again within the timeout period', () => { it('should not call the callback function if throttle is called again within the timeout period', () => {
throttleInstance.throttle(callback, 1000) throttleInstance.throttle(callback, 1000);
throttleInstance.throttle(callback, 1000) throttleInstance.throttle(callback, 1000);
vitest.advanceTimersByTime(500) vitest.advanceTimersByTime(500);
expect(callback).not.toHaveBeenCalled() expect(callback).not.toHaveBeenCalled();
vitest.advanceTimersByTime(500) vitest.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledTimes(1);
}) });
it('should allow the callback to be called again after the timeout period', () => { it('should allow the callback to be called again after the timeout period', () => {
throttleInstance.throttle(callback, 1000) throttleInstance.throttle(callback, 1000);
vitest.advanceTimersByTime(1000) vitest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledTimes(1);
throttleInstance.throttle(callback, 1000) throttleInstance.throttle(callback, 1000);
vitest.advanceTimersByTime(1000) vitest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(2) expect(callback).toHaveBeenCalledTimes(2);
}) });
}) });
+8 -13
View File
@@ -1,17 +1,12 @@
import { defineConfig, UserConfigExport } from 'vitest/config' import { defineConfig, UserConfigExport } from 'vitest/config'
import { svelte } from '@sveltejs/vite-plugin-svelte' import { svelte } from '@sveltejs/vite-plugin-svelte';
import path from 'path'
const config: UserConfigExport = { const config: UserConfigExport = {
plugins: [svelte()], plugins: [svelte()],
resolve: { test: {
alias: { globals: true,
$lib: path.resolve(__dirname, './src/lib') environment: 'jsdom'
} }
}, };
test: {
globals: true,
environment: 'jsdom'
}
}
export default defineConfig(config) export default defineConfig(config)
-33
View File
@@ -1,33 +0,0 @@
{
"build": {
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM"
],
"f_cpu": "360000000L",
"f_flash": "80000000L",
"f_psram": "200000000L",
"flash_mode": "qio",
"mcu": "esp32p4",
"variant": "esp32p4"
},
"connectivity": [
"wifi"
],
"debug": {
"openocd_target": "esp32p4.cfg"
},
"frameworks": [
"espidf"
],
"name": "ESP32-P4 Dev Board (32MB PSRAM + 32MB Flash, C6 coprocessor)",
"upload": {
"flash_size": "32MB",
"maximum_ram_size": 786432,
"maximum_size": 33554432,
"require_upload_port": true,
"speed": 1500000
},
"url": "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32p4/",
"vendor": "Espressif"
}
+1 -14
View File
@@ -2,20 +2,7 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.2.0] ## [Unreleased]
### Added
- Implemented cumulative robot displacement in the visualization [#161](https://github.com/runeharlyk/SpotMicroESP32-Leika/pull/161)
- Adds gesture control [#157](https://github.com/runeharlyk/SpotMicroESP32-Leika/pull/157)
- Stand mode imu compensation [#155](https://github.com/runeharlyk/SpotMicroESP32-Leika/pull/155)
### Changed
- Protobuf replacement for JSON and MsgPack communication between Svelte and ESP32 [#164](https://github.com/runeharlyk/SpotMicroESP32-Leika/pull/164)
- Removed the used of Arduino strings [#160](https://github.com/runeharlyk/SpotMicroESP32-Leika/pull/160)
## [0.1.0]
### Added ### Added
+39 -75
View File
@@ -1,80 +1,44 @@
# API # API
<!-- https://dev.bostondynamics.com/docs/concepts/choreography/choreography_in_tablet.html -->
The back end exposes a number of API endpoints which are referenced in the table below. The back end exposes a number of API endpoints which are referenced in the table below.
## System | Method | Endpoint | Authentication | POST JSON Body | Info |
| ------ | -------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| GET | /rest/features | `NONE_REQUIRED` | none | Tells the client which features of the UI should be use |
| GET | /rest/apStatus | `IS_AUTHENTICATED` | none | Current AP status and client information |
| GET | /rest/apSettings | `IS_ADMIN` | none | Current AP settings |
| POST | /rest/apSettings | `IS_ADMIN` | `{"provision_mode": 1,"ssid": "ESP32-SvelteKit-e89f6d20372c","password": "esp-sveltekit","channel": 1,"ssid_hidden": false,"max_clients": 4,"local_ip": "192.168.4.1","gateway_ip": "192.168.4.1","subnet_mask": "255.255.255.0"}` | Update AP settings |
| GET | /rest/wifiStatus | `IS_AUTHENTICATED` | none | Current status of the wifi client connection |
| GET | /rest/scanNetworks | `IS_ADMIN` | none | Async Scan for Networks in Range |
| GET | /rest/listNetworks | `IS_ADMIN` | none | List networks in range after successful scanning. Otherwise triggers scanning. |
| GET | /rest/wifiSettings | `IS_ADMIN` | none | Current WiFi settings |
| POST | /rest/wifiSettings | `IS_ADMIN` | `{"hostname":"esp32-f412fa4495f8","priority_RSSI":true,"wifi_networks":[{"ssid":"YourSSID","password":"YourPassword","static_ip_config":false}]}` | Update WiFi settings and credentials |
| GET | /rest/systemStatus | `IS_AUTHENTICATED` | none | Get system information about the ESP. |
| POST | /rest/restart | `IS_ADMIN` | none | Restart the ESP32 |
| POST | /rest/factoryReset | `IS_ADMIN` | none | Reset the ESP32 and all settings to their default values |
| POST | /rest/uploadFirmware | `IS_ADMIN` | none | File upload of firmware.bin |
| POST | /rest/sleep | `IS_AUTHENTICATED` | none | Puts the device in deep sleep mode |
| POST | /rest/downloadUpdate | `IS_ADMIN` | `{"download_url": "https://github.com/theelims/ESP32-sveltekit/releases/download/v0.1.0/firmware_esp32s3.bin"}` | Download link for OTA. This requires a valid SSL certificate and will follow redirects. |
| Method | Endpoint | Description | <!-- | HTTP Method | Endpoint | Description | Parameters |
| ------ | ------------------- | -------------------------------------------- | |-------------|----------------|----------------------------|---------------------------|
| GET | /api/features | Get enabled features for the UI | | GET | /api/sensor/mpu | Retrieve the mpu state | |
| GET | /api/system/status | Get system information about the ESP | | GET | /api/sensor/magnetometer | Retrieve the magnetometer state | |
| POST | /api/system/reset | Reset the ESP32 and all settings to defaults | | GET | /api/sensor/distances | Retrieve the distances state | |
| POST | /api/system/restart | Restart the ESP32 | | GET | /api/sensor/distance/{position} | Retrieve the distance state | `position`: The position of the distance sensor **LEFT** and **RIGHT** |
| POST | /api/system/sleep | Put the device in deep sleep mode | | GET | /api/sensor/stream | Retrieve the camera stream | |
| GET | /api/actuator | Retrieve the actuator states | |
## WiFi | GET | /api/actuator/{id} | Retrieve the actuator state for `id` | `id`: The ID of the actuator |
| POST | /api/actuator/{id} | Set the actuator state | `id`: The ID of the actuator|
| Method | Endpoint | Description | | GET | /api/kinematics/feet | Retrieve the current feet positions as (x, y, z) coordinates| |
| ------ | ---------------------- | ------------------------------------- | | GET | /api/kinematics/body | Retrieve the current body position as a (x, y, z) coordinates| |
| GET | /api/wifi/sta/settings | Get current WiFi settings | | GET | /api/kinematics/bodystate | Retrieve the current body and feet positions | |
| POST | /api/wifi/sta/settings | Update WiFi settings and credentials | | GET | /api/system/log | Retrieve the system log | |
| GET | /api/wifi/scan | Trigger async scan for networks | | GET | /api/system/info | Retrieve the system information | |
| GET | /api/wifi/networks | List networks in range after scanning | | GET | /api/system/settings | Retrieve the system settings | |
| GET | /api/wifi/sta/status | Get WiFi client connection status | | POST | /api/system/settings | Set the system settings | |
| POST | /api/system/reset | Reset system | |
## Access Point | POST | /api/system/power/off | Power of the system | |
| POST | /api/system/stop | Stop power to actuators | `id`: The stop level **CUT**, **SETTLE_THEN_CUT**, **NONE** | -->
| Method | Endpoint | Description |
| ------ | ---------------- | --------------------- |
| GET | /api/ap/status | Get current AP status |
| GET | /api/ap/settings | Get AP settings |
| POST | /api/ap/settings | Update AP settings |
## Camera (if enabled)
| Method | Endpoint | Description |
| ------ | -------------------- | ---------------------- |
| GET | /api/camera/still | Capture a still image |
| GET | /api/camera/stream | Get camera stream |
| GET | /api/camera/settings | Get camera settings |
| POST | /api/camera/settings | Update camera settings |
## Servo
| Method | Endpoint | Description |
| ------ | ----------------- | ----------------------- |
| GET | /api/servo/config | Get servo configuration |
| POST | /api/servo/config | Update servo config |
## Peripherals
| Method | Endpoint | Description |
| ------ | ---------------- | -------------------------- |
| GET | /api/peripherals | Get peripheral settings |
| POST | /api/peripherals | Update peripheral settings |
## mDNS (if enabled)
| Method | Endpoint | Description |
| ------ | ---------------- | -------------------- |
| GET | /api/mdns | Get mDNS settings |
| POST | /api/mdns | Update mDNS settings |
| GET | /api/mdns/status | Get mDNS status |
| POST | /api/mdns/query | Query mDNS services |
## Filesystem
| Method | Endpoint | Description |
| ------ | ----------------- | ---------------- |
| GET | /api/config/\* | Get config file |
| GET | /api/files | List files |
| POST | /api/files | Upload file |
| POST | /api/files/delete | Delete file |
| POST | /api/files/edit | Edit file |
| POST | /api/files/mkdir | Create directory |
## WebSocket
Real-time communication is handled via WebSocket at `/api/ws` using Protocol Buffers.
See [websocket.md](websocket.md) for the full WebSocket API documentation.
+2
View File
@@ -4,6 +4,8 @@ The software make use of a range of different libraries to enhance the functiona
Up to date list can be seen in platformio.ini file. Up to date list can be seen in platformio.ini file.
The libraries includes: The libraries includes:
- Esp32SvelteKit
- PsychicHttp
- ArduinoJson - ArduinoJson
- Adafruit SSD1306 - Adafruit SSD1306
- Adafruit GFX Library - Adafruit GFX Library
-47
View File
@@ -1,47 +0,0 @@
# WebSocket API
The ESP32 exposes a WebSocket endpoint at `/api/ws` for real-time bidirectional communication using Protocol Buffers (protobuf).
## Connection
Connect to the WebSocket at:
```
ws://<device-ip>/api/ws
```
All messages are binary-encoded protobuf `Message` wrappers defined in `platform_shared/message.proto`.
## Message Flow
The WebSocket supports three communication patterns:
1. **Client to Server**: Commands like controller input, mode changes, servo control
2. **Server to Client**: Periodic data broadcasts like IMU, system metrics, RSSI, servo angles
3. **Request-Response**: Use `socket.request()` for operations requiring a response
## Example: Sending Controller Input
```typescript
import { Message, ControllerData } from "./proto/message";
const input: ControllerData = {
left: { x: 0.5, y: 0.0 },
right: { x: 0.0, y: 0.0 },
height: 0.1,
speed: 1.0,
s1: 0.0,
};
const message = Message.encode({ ControllerData: input }).finish();
socket.send(message);
```
## Example: Request-Response
```typescript
const response = await socket.request({ imuCalibrateExecute: {} });
const result = response.imuCalibrateData;
```
See `platform_shared/message.proto` for all available message types and their definitions.
+3
View File
@@ -3,3 +3,6 @@ build_flags =
-D BUILD_TARGET=\"$PIOENV\" -D BUILD_TARGET=\"$PIOENV\"
-D APPLICATION_CORE=0 -D APPLICATION_CORE=0
-D EMBED_WEBAPP=1 -D EMBED_WEBAPP=1
-D USE_MSGPACK=1 ; Use either msgpack or json
-D USE_JSON=0 ; Use either msgpack or json
+14 -1
View File
@@ -1,3 +1,9 @@
; The indicated settings support placeholder substitution as follows:
;
; #{platform} - The microcontroller platform, e.g. "esp32" or "esp8266"
; #{unique_id} - A unique identifier derived from the MAC address, e.g. "0b0a859d6816"
; #{random} - A random number encoded as a hex string, e.g. "55722f94"
[factory_settings] [factory_settings]
build_flags = build_flags =
-D APP_NAME=\"Spot-Micro\" ; [a-zA-Z0-9-_] -D APP_NAME=\"Spot-Micro\" ; [a-zA-Z0-9-_]
@@ -10,7 +16,7 @@ build_flags =
; Access point settings ; Access point settings
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED -D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
-D FACTORY_AP_SSID=\"Spot-Micro\" ; 1-64 characters -D FACTORY_AP_SSID=\"Spot-Micro-#{unique_id}\" ; 1-64 characters, supports placeholders
-D FACTORY_AP_PASSWORD=\"spot-leika\" ; 8-64 characters -D FACTORY_AP_PASSWORD=\"spot-leika\" ; 8-64 characters
-D FACTORY_AP_CHANNEL=1 -D FACTORY_AP_CHANNEL=1
-D FACTORY_AP_SSID_HIDDEN=false -D FACTORY_AP_SSID_HIDDEN=false
@@ -19,9 +25,16 @@ build_flags =
-D FACTORY_AP_GATEWAY_IP=\"192.168.4.1\" -D FACTORY_AP_GATEWAY_IP=\"192.168.4.1\"
-D FACTORY_AP_SUBNET_MASK=\"255.255.255.0\" -D FACTORY_AP_SUBNET_MASK=\"255.255.255.0\"
; OTA settings
-D FACTORY_OTA_PORT=8266
-D FACTORY_OTA_PASSWORD=\"spot-leika\"
-D FACTORY_OTA_ENABLED=true
; Servo settings ; Servo settings
-D FACTORY_SERVO_NUM=12
-D FACTORY_SERVO_OSCILLATOR_FREQUENCY=27000000 -D FACTORY_SERVO_OSCILLATOR_FREQUENCY=27000000
-D FACTORY_SERVO_PWM_FREQUENCY=50 -D FACTORY_SERVO_PWM_FREQUENCY=50
-D FACTORY_SERVO_CENTER_ANGLE=90
; Deep Sleep Configuration ; Deep Sleep Configuration
-D WAKEUP_PIN_NUMBER=38 ; pin number to wake up the ESP -D WAKEUP_PIN_NUMBER=38 ; pin number to wake up the ESP
+11 -12
View File
@@ -1,13 +1,10 @@
#pragma once
#include <template/stateful_service.h> #include <template/stateful_service.h>
#include <template/stateful_proto_endpoint.h> #include <template/stateful_endpoint.h>
#include <template/stateful_persistence.h> #include <template/stateful_persistence.h>
#include <settings/ap_settings.h> #include <settings/ap_settings.h>
#include <utils/timing.h> #include <utils/timing.h>
#include <wifi/wifi_idf.h> #include <WiFi.h>
#include <wifi/dns_server.h> #include "esp_timer.h"
#include <esp_timer.h>
#include <string> #include <string>
class APService : public StatefulService<APSettings> { class APService : public StatefulService<APSettings> {
@@ -19,19 +16,21 @@ class APService : public StatefulService<APSettings> {
void loop(); void loop();
void recoveryMode(); void recoveryMode();
esp_err_t getStatusProto(httpd_req_t *request); esp_err_t getStatus(PsychicRequest *request);
void statusProto(api_APStatus &proto); void status(JsonObject &root);
APNetworkStatus getAPNetworkStatus(); APNetworkStatus getAPNetworkStatus();
StatefulProtoEndpoint<APSettings, api_APSettings> protoEndpoint; StatefulHttpEndpoint<APSettings> endpoint;
private: private:
FSPersistencePB<APSettings> _persistence; PsychicHttpServer *_server;
FSPersistence<APSettings> _persistence;
DNSServer *_dnsServer; DNSServer *_dnsServer;
volatile unsigned long _lastManaged; volatile unsigned long _lastManaged;
volatile bool _reconfigureAp; volatile boolean _reconfigureAp;
volatile bool _recoveryMode = false; volatile boolean _recoveryMode = false;
void reconfigureAP(); void reconfigureAP();
void manageAP(); void manageAP();
+101 -93
View File
@@ -1,127 +1,135 @@
#pragma once #pragma once
#include <esp_log.h> #include <ArduinoJson.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <functional> #include <functional>
#include <list>
#include <map> enum message_type_t { CONNECT = 0, DISCONNECT = 1, EVENT = 2, PING = 3, PONG = 4, BINARY_EVENT = 5 };
#include <type_traits>
#include <communication/proto_helpers.h> typedef std::function<void(JsonVariant &root, int originId)> EventCallback;
typedef std::function<void(const std::string &originId, bool sync)> SubscribeCallback;
class CommAdapterBase { class CommAdapterBase {
public: public:
CommAdapterBase() { CommAdapterBase() { mutex_ = xSemaphoreCreateMutex(); }
mutex_ = xSemaphoreCreateMutex();
decoder_.onSubscribe([this](int32_t tag, int cid) { subscribe(tag, cid); });
decoder_.onUnsubscribe([this](int32_t tag, int cid) { unsubscribe(tag, cid); });
decoder_.onPing([this](int cid) { sendPong(cid); });
}
~CommAdapterBase() { vSemaphoreDelete(mutex_); } ~CommAdapterBase() { vSemaphoreDelete(mutex_); }
virtual void begin() {} virtual void begin() {}
bool hasSubscribers(int32_t tag) { bool hasSubscribers(const char *event) { return !client_subscriptions[event].empty(); }
void onEvent(std::string event, EventCallback callback) { event_callbacks[event].push_back(std::move(callback)); }
void onSubscribe(std::string event, SubscribeCallback callback) {
subscribe_callbacks[event].push_back(std::move(callback));
}
void emit(const char *event, JsonVariant &payload, const char *originId = "", bool onlyToSameOrigin = false) {
int originSubscriptionId = originId[0] ? atoi(originId) : -1;
xSemaphoreTake(mutex_, portMAX_DELAY); xSemaphoreTake(mutex_, portMAX_DELAY);
bool result = !client_subscriptions_[tag].empty(); auto &subscriptions = client_subscriptions[event];
xSemaphoreGive(mutex_); if (subscriptions.empty()) {
return result; xSemaphoreGive(mutex_);
}
ProtoDecoder& decoder() { return decoder_; }
template <typename T>
void on(std::function<void(const T&, int)> handler) {
decoder_.on<T>(handler);
}
template <typename T>
void emit(const T& data, int clientId = -1) {
constexpr pb_size_t tag = MessageTraits<T>::tag;
if (clientId < 0 && !hasSubscribers(tag)) return;
msg_.which_message = tag;
MessageTraits<T>::assign(msg_, data);
size_t out_size;
pb_get_encoded_size(&out_size, socket_message_Message_fields, &msg_);
uint8_t* buffer = pb_heap_enc_buf;
if (out_size > sizeof(pb_heap_enc_buf)) { // If the encoded size exceeds our buffer size, we needs to malloc a
// buffer of a proper size
buffer = (uint8_t*)malloc(out_size);
}
pb_ostream_t stream = pb_ostream_from_buffer(buffer, out_size);
if (!pb_encode(&stream, socket_message_Message_fields, &msg_)) {
ESP_LOGE("ProtoComm", "Failed to encode message (tag %d), buffer too small?", (int)tag);
return; return;
} }
if (clientId >= 0) { JsonDocument doc;
send(buffer, stream.bytes_written, clientId); JsonArray array = doc.to<JsonArray>();
} else { array.add(static_cast<uint8_t>(message_type_t::EVENT));
sendToSubscribers(tag, buffer, stream.bytes_written); array.add(event);
} array.add(payload);
if (pb_heap_enc_buf != buffer) { #if USE_MSGPACK
free(buffer); std::string bin;
} serializeMsgPack(doc, bin);
xSemaphoreGive(mutex_);
send(reinterpret_cast<const uint8_t *>(bin.data()), bin.size(), -1);
#else
String out;
serializeJson(doc, out);
xSemaphoreGive(mutex_);
send(out.c_str(), -1);
#endif
} }
protected: protected:
virtual void send(const uint8_t* data, size_t len, int cid = -1) = 0; void send(const char *data, int cid = -1) { send(reinterpret_cast<const uint8_t *>(data), strlen(data), cid); }
virtual void send(const uint8_t *data, size_t len, int cid = -1) = 0;
void subscribe(int32_t tag, int cid = 0) { void subscribe(const char *event, int cid = 0) {
xSemaphoreTake(mutex_, portMAX_DELAY); xSemaphoreTake(mutex_, portMAX_DELAY);
client_subscriptions_[tag].push_back(cid); client_subscriptions[event].push_back(cid);
xSemaphoreGive(mutex_); xSemaphoreGive(mutex_);
ESP_LOGI("ProtoComm", "Client %d subscribed to tag %d", cid, (int)tag);
} }
void unsubscribe(const char *event, int cid = 0) {
void unsubscribe(int32_t tag, int cid = 0) {
xSemaphoreTake(mutex_, portMAX_DELAY); xSemaphoreTake(mutex_, portMAX_DELAY);
client_subscriptions_[tag].remove(cid); client_subscriptions[event].remove(cid);
xSemaphoreGive(mutex_);
ESP_LOGI("ProtoComm", "Client %d unsubscribed from tag %d", cid, (int)tag);
}
void removeClient(int cid) {
xSemaphoreTake(mutex_, portMAX_DELAY);
for (auto& [tag, clients] : client_subscriptions_) {
clients.remove(cid);
}
xSemaphoreGive(mutex_); xSemaphoreGive(mutex_);
} }
void handleIncoming(const uint8_t* data, size_t len, int cid) { void handleEventCallbacks(std::string event, JsonVariant &jsonObject, int originId) {
if (!decoder_.decode(data, len, cid)) { for (auto &callback : event_callbacks[event]) {
ESP_LOGE("ProtoComm", "Failed to decode incoming message from client %d", cid); callback(jsonObject, originId);
} }
} }
void sendPong(int cid) { virtual void handleIncoming(const uint8_t *data, size_t len, int cid = 0) {
uint8_t pongBuffer[16]; JsonDocument doc;
msg_.which_message = socket_message_Message_pongmsg_tag; #if USE_MSGPACK
msg_.message.pongmsg = socket_message_PongMsg_init_zero; DeserializationError error = deserializeMsgPack(doc, data, len);
pb_ostream_t stream = pb_ostream_from_buffer(pongBuffer, sizeof(pongBuffer)); #else
if (pb_encode(&stream, socket_message_Message_fields, &msg_)) { DeserializationError error = deserializeJson(doc, data, len);
send(pongBuffer, stream.bytes_written, cid); #endif
if (error) {
ESP_LOGE("Comm Base", "Failed to deserialize incoming: (%s)", error.c_str());
return;
} }
JsonArray obj = doc.as<JsonArray>(); // TODO: Make const
message_type_t type = static_cast<message_type_t>(obj[0].as<uint8_t>());
switch (type) {
case message_type_t::CONNECT: {
const char *event = obj[1].as<const char *>();
ESP_LOGI("Comm Base", "CONNECT topic: %s (cid=%d)", event, cid);
subscribe(event, cid);
break;
}
case message_type_t::DISCONNECT: {
const char *event = obj[1].as<const char *>();
ESP_LOGI("Comm Base", "DISCONNECT topic: %s (cid=%d)", event, cid);
unsubscribe(event, cid);
break;
}
case message_type_t::EVENT: {
const char *event = obj[1].as<const char *>();
JsonVariant payload = obj[2].as<JsonVariant>();
handleEventCallbacks(event, payload, cid);
break;
}
case message_type_t::PING: {
ESP_LOGI("Comm Base", "PING (cid=%d)", cid);
ping(cid);
break;
}
case message_type_t::PONG: ESP_LOGI("Comm Base", "PONG (cid=%d)", cid); break;
default: ESP_LOGW("Comm Base", "Unknown message type: %d", static_cast<int>(type)); break;
}
}
void ping(int cid) {
#if USE_MSGPACK
static const uint8_t pong[] = {0x91, 0x04};
send(pong, sizeof(pong), cid);
#else
send("[4]", cid);
#endif
} }
SemaphoreHandle_t mutex_; SemaphoreHandle_t mutex_;
std::map<int32_t, std::list<int>> client_subscriptions_; std::map<std::string, std::list<int>> client_subscriptions;
ProtoDecoder decoder_; std::map<std::string, std::list<EventCallback>> event_callbacks;
socket_message_Message msg_ = socket_message_Message_init_zero; std::map<std::string, std::list<SubscribeCallback>> subscribe_callbacks;
uint8_t pb_heap_enc_buf[PROTO_BUFFER_SIZE];
private:
void sendToSubscribers(int32_t tag, const uint8_t* data, size_t len) {
xSemaphoreTake(mutex_, portMAX_DELAY);
for (int cid : client_subscriptions_[tag]) {
send(data, len, cid);
}
xSemaphoreGive(mutex_);
}
}; };
-108
View File
@@ -1,108 +0,0 @@
#pragma once
#include <pb_encode.h>
#include <pb_decode.h>
#include <platform_shared/message.pb.h>
#include <functional>
#include <map>
#define PROTO_BUFFER_SIZE 2048
template <typename T>
struct MessageTraits;
#define DEFINE_MESSAGE_TRAITS(DataType, field) \
template <> \
struct MessageTraits<socket_message_##DataType> { \
static constexpr pb_size_t tag = socket_message_Message_##field##_tag; \
static void assign(socket_message_Message& msg, const socket_message_##DataType& data) { \
msg.message.field = data; \
} \
static const socket_message_##DataType& access(const socket_message_Message& msg) { \
return msg.message.field; \
} \
};
DEFINE_MESSAGE_TRAITS(IMUData, imu)
DEFINE_MESSAGE_TRAITS(ModeData, mode)
DEFINE_MESSAGE_TRAITS(AnalyticsData, analytics)
DEFINE_MESSAGE_TRAITS(AnglesData, angles)
DEFINE_MESSAGE_TRAITS(RSSIData, rssi)
DEFINE_MESSAGE_TRAITS(KinematicData, kinematic_data)
DEFINE_MESSAGE_TRAITS(IMUCalibrateData, imu_calibrate)
DEFINE_MESSAGE_TRAITS(I2CScanData, i2c_scan)
DEFINE_MESSAGE_TRAITS(PeripheralSettingsData, peripheral_settings)
DEFINE_MESSAGE_TRAITS(ControllerData, controller_data)
DEFINE_MESSAGE_TRAITS(WalkGaitData, walk_gait)
DEFINE_MESSAGE_TRAITS(IMUCalibrateExecute, imu_calibrate_execute)
DEFINE_MESSAGE_TRAITS(I2CScanDataRequest, i2c_scan_data_request)
DEFINE_MESSAGE_TRAITS(PeripheralSettingsDataRequest, peripheral_settings_data_request)
DEFINE_MESSAGE_TRAITS(ServoPWMData, servo_pwm)
DEFINE_MESSAGE_TRAITS(ServoStateData, servo_state)
DEFINE_MESSAGE_TRAITS(CorrelationRequest, correlation_request)
DEFINE_MESSAGE_TRAITS(CorrelationResponse, correlation_response)
// Streaming file transfer messages
DEFINE_MESSAGE_TRAITS(FSDownloadMetadata, fs_download_metadata)
DEFINE_MESSAGE_TRAITS(FSDownloadData, fs_download_data)
DEFINE_MESSAGE_TRAITS(FSDownloadComplete, fs_download_complete)
DEFINE_MESSAGE_TRAITS(FSUploadData, fs_upload_data)
DEFINE_MESSAGE_TRAITS(FSUploadComplete, fs_upload_complete)
#undef DEFINE_MESSAGE_TRAITS
class ProtoDecoder {
public:
using SubscribeHandler = std::function<void(int32_t tag, int clientId)>;
using UnsubscribeHandler = std::function<void(int32_t tag, int clientId)>;
using PingHandler = std::function<void(int clientId)>;
void onSubscribe(SubscribeHandler handler) { subscribeHandler_ = handler; }
void onUnsubscribe(UnsubscribeHandler handler) { unsubscribeHandler_ = handler; }
void onPing(PingHandler handler) { pingHandler_ = handler; }
template <typename T>
void on(std::function<void(const T&, int)> handler) {
handlers_[MessageTraits<T>::tag] = [handler, this](int clientId) {
handler(MessageTraits<T>::access(msg_), clientId);
};
}
bool decode(const uint8_t* data, size_t len, int clientId) {
pb_istream_t stream = pb_istream_from_buffer(data, len);
if (!pb_decode(&stream, socket_message_Message_fields, &msg_)) {
return false;
}
switch (msg_.which_message) {
case socket_message_Message_sub_notif_tag:
if (subscribeHandler_) subscribeHandler_(msg_.message.sub_notif.tag, clientId);
return true;
case socket_message_Message_unsub_notif_tag:
if (unsubscribeHandler_) unsubscribeHandler_(msg_.message.unsub_notif.tag, clientId);
return true;
case socket_message_Message_pingmsg_tag:
if (pingHandler_) pingHandler_(clientId);
return true;
default: {
auto it = handlers_.find(msg_.which_message);
if (it != handlers_.end()) {
it->second(clientId);
return true;
}
return false;
}
}
}
private:
socket_message_Message msg_ = socket_message_Message_init_zero;
SubscribeHandler subscribeHandler_;
UnsubscribeHandler unsubscribeHandler_;
PingHandler pingHandler_;
std::map<pb_size_t, std::function<void(int)>> handlers_;
};
-145
View File
@@ -1,145 +0,0 @@
#pragma once
#ifndef CONFIG_HTTPD_WS_SUPPORT
#define CONFIG_HTTPD_WS_SUPPORT 1
#endif
#include <esp_http_server.h>
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <functional>
#include <vector>
#include <string>
#include <map>
#include <pb_encode.h>
#include <pb_decode.h>
#include <platform_shared/api.pb.h>
#include <freertos/semphr.h>
using HttpGetHandler = std::function<esp_err_t(httpd_req_t*)>;
using HttpPostHandler = std::function<esp_err_t(httpd_req_t*, api_Request*)>;
using WsFrameHandler = std::function<esp_err_t(httpd_req_t*, httpd_ws_frame_t*)>;
using WsOpenHandler = std::function<void(httpd_req_t*)>;
using WsCloseHandler = std::function<void(int)>;
// Macro to register a proto endpoint that extracts a specific payload type
// Usage: STAITC_PROTO_POST_ENDPOINT(server, "/api/files/delete", file_delete_request, FileSystem::handleDelete)
// Handler signature: esp_err_t handleDelete(httpd_req_t* req, const api_FileDeleteRequest& payload)
#define STAITC_PROTO_POST_ENDPOINT(server_ref, uri, payload_type, handler) \
(server_ref).on(uri, HTTP_POST, [&](httpd_req_t *request, api_Request *protoReq) { \
if (protoReq->which_payload != api_Request_##payload_type##_tag) { \
return WebServer::sendError(request, 400, "Invalid request payload"); \
} \
return handler(request, protoReq->payload.payload_type); \
})
struct HttpRoute {
std::string uri;
httpd_method_t method;
HttpGetHandler getHandler;
HttpPostHandler postHandler;
bool isWebsocket;
};
class WebServer {
public:
WebServer();
~WebServer();
void config(size_t maxUriHandlers, size_t stackSize);
esp_err_t listen(uint16_t port);
void stop();
void on(const char* uri, httpd_method_t method, HttpGetHandler handler);
void on(const char* uri, httpd_method_t method, HttpPostHandler handler);
void onWsFrame(WsFrameHandler handler);
void onWsOpen(WsOpenHandler handler);
void onWsClose(WsCloseHandler handler);
void registerWebsocket(const char* uri);
esp_err_t wsSend(int sockfd, const uint8_t* data, size_t len);
esp_err_t wsSendAll(const uint8_t* data, size_t len);
void addWsClient(int sockfd);
void removeWsClient(int sockfd);
std::vector<int> getWsClients();
void addDefaultHeader(const char* key, const char* value);
httpd_handle_t getHandle() { return server_; }
static esp_err_t sendError(httpd_req_t* req, int status, const char* message);
static esp_err_t sendOk(httpd_req_t* req);
static esp_err_t send(httpd_req_t* req, int status, const uint8_t* data, size_t len);
template <typename T>
static esp_err_t send(httpd_req_t* req, int status, const T& msg, const pb_msgdesc_t* fields) {
size_t size = 0;
if (!pb_get_encoded_size(&size, fields, &msg)) {
return sendError(req, 500, "Failed to calculate proto size");
}
uint8_t* buffer = (uint8_t*)malloc(size);
if (!buffer) {
return sendError(req, 500, "Failed to allocate memory for proto");
}
pb_ostream_t stream = pb_ostream_from_buffer(buffer, size);
if (!pb_encode(&stream, fields, &msg)) {
free(buffer);
return sendError(req, 500, "Failed to encode proto");
}
esp_err_t result = send(req, status, buffer, stream.bytes_written);
free(buffer);
return result;
}
template <typename T>
static bool receiveProto(httpd_req_t* req, T& msg, const pb_msgdesc_t* fields) {
size_t contentLen = req->content_len;
if (contentLen == 0 || contentLen > 4096) {
return false;
}
uint8_t* buffer = (uint8_t*)malloc(contentLen);
if (!buffer) {
return false;
}
int received = 0;
int remaining = contentLen;
while (remaining > 0) {
int ret = httpd_req_recv(req, (char*)buffer + received, remaining);
if (ret <= 0) {
free(buffer);
return false;
}
received += ret;
remaining -= ret;
}
pb_istream_t stream = pb_istream_from_buffer(buffer, contentLen);
bool success = pb_decode(&stream, fields, &msg);
free(buffer);
return success;
}
private:
httpd_handle_t server_ = nullptr;
httpd_config_t config_;
std::vector<HttpRoute> routes_;
std::map<std::string, std::string> defaultHeaders_;
std::vector<int> wsClients_;
SemaphoreHandle_t wsMutex_;
WsFrameHandler wsFrameHandler_;
WsOpenHandler wsOpenHandler_;
WsCloseHandler wsCloseHandler_;
static esp_err_t httpHandler(httpd_req_t* req);
static esp_err_t wsHandler(httpd_req_t* req);
void applyDefaultHeaders(httpd_req_t* req);
esp_err_t registerRoute(const HttpRoute& route);
};
extern WebServer server;
-22
View File
@@ -1,22 +0,0 @@
#pragma once
#include <cstdint>
#include <communication/webserver.h>
#include <communication/comm_base.hpp>
class Websocket : public CommAdapterBase {
public:
Websocket(WebServer& server, const char* route = "/api/ws");
void begin() override;
private:
WebServer& server_;
const char* route_;
void onWsOpen(httpd_req_t* req);
void onWsClose(int sockfd);
esp_err_t onFrame(httpd_req_t* req, httpd_ws_frame_t* frame);
void send(const uint8_t* data, size_t len, int cid = -1) override;
};
@@ -0,0 +1,36 @@
#ifndef Socket_h
#define Socket_h
#include <PsychicHttp.h>
#include <template/stateful_service.h>
#include <list>
#include <map>
#include <vector>
#include <string>
#include <communication/comm_base.hpp>
class Websocket : public CommAdapterBase {
public:
Websocket(PsychicHttpServer &server, const char *route = "/api/ws");
void begin() override;
void onEvent(std::string event, EventCallback callback);
void emit(const char *event, JsonVariant &payload, const char *originId = "", bool onlyToSameOrigin = false);
private:
PsychicWebSocketHandler _socket;
PsychicHttpServer &_server;
const char *_route;
void onWSOpen(PsychicWebSocketClient *client);
void onWSClose(PsychicWebSocketClient *client);
esp_err_t onFrame(PsychicWebSocketRequest *request, httpd_ws_frame *frame);
void send(const uint8_t *data, size_t len, int cid = -1) override;
};
#endif
-21
View File
@@ -1,21 +0,0 @@
#pragma once
#ifndef PROGMEM
#define PROGMEM
#endif
#ifndef PGM_P
#define PGM_P const char *
#endif
#ifndef pgm_read_byte
#define pgm_read_byte(addr) (*(const unsigned char *)(addr))
#endif
#ifndef pgm_read_word
#define pgm_read_word(addr) (*(const unsigned short *)(addr))
#endif
#ifndef pgm_read_dword
#define pgm_read_dword(addr) (*(const unsigned long *)(addr))
#endif
+31 -6
View File
@@ -1,48 +1,69 @@
#pragma once #ifndef Features_h
#define Features_h
#include <sdkconfig.h> #include <WiFi.h>
#include <wifi/wifi_idf.h> #include <ArduinoJson.h>
#include <esp_http_server.h> #include <PsychicHttp.h>
#include "platform_shared/message.pb.h"
#define FT_ENABLED(feature) feature #define FT_ENABLED(feature) feature
// ESP32 camera off by default
#ifndef USE_CAMERA #ifndef USE_CAMERA
#define USE_CAMERA 0 #define USE_CAMERA 0
#endif #endif
// ESP32 IMU on by default
#ifndef USE_MPU6050 #ifndef USE_MPU6050
#define USE_MPU6050 0 #define USE_MPU6050 0
#endif #endif
// ESP32 IMU on by default
#ifndef USE_BNO055 #ifndef USE_BNO055
#define USE_BNO055 1 #define USE_BNO055 1
#endif #endif
// ESP32 magnetometer on by default
#ifndef USE_HMC5883 #ifndef USE_HMC5883
#define USE_HMC5883 0 #define USE_HMC5883 0
#endif #endif
// ESP32 barometer off by default
#ifndef USE_BMP180 #ifndef USE_BMP180
#define USE_BMP180 0 #define USE_BMP180 0
#endif #endif
// ESP32 SONAR off by default
#ifndef USE_USS #ifndef USE_USS
#define USE_USS 0 #define USE_USS 0
#endif #endif
// PCA9685 Servo controller on by default
#ifndef USE_PCA9685 #ifndef USE_PCA9685
#define USE_PCA9685 1 #define USE_PCA9685 1
#endif #endif
// WS2812 LED strip off by default
#ifndef USE_WS2812 #ifndef USE_WS2812
#define USE_WS2812 0 #define USE_WS2812 0
#endif #endif
// ESP32 MDNS on by default
#ifndef USE_MDNS #ifndef USE_MDNS
#define USE_MDNS 1 #define USE_MDNS 1
#endif #endif
// ESP32 MSGPACK on by default
#ifndef USE_MSGPACK
#define USE_MSGPACK 1
#endif
// ESP32 JSON off by default
#ifndef USE_JSON
#define USE_JSON 0
#endif
static_assert(!(USE_JSON == 1 && USE_MSGPACK == 1), "Cannot set both USE_JSON and USE_MSGPACK to 1 simultaneously");
#if defined(SPOTMICRO_ESP32) && defined(SPOTMICRO_ESP32_MINI) && defined(SPOTMICRO_YERTLE) #if defined(SPOTMICRO_ESP32) && defined(SPOTMICRO_ESP32_MINI) && defined(SPOTMICRO_YERTLE)
#error "Only one kinematics variant must be defined" #error "Only one kinematics variant must be defined"
#endif #endif
@@ -65,6 +86,10 @@ namespace feature_service {
void printFeatureConfiguration(); void printFeatureConfiguration();
void features_request(const socket_message_FeaturesDataRequest& fd_req, socket_message_FeaturesDataResponse& fd_res); void features(JsonObject &root);
esp_err_t getFeatures(PsychicRequest *request);
} // namespace feature_service } // namespace feature_service
#endif
+19 -31
View File
@@ -1,45 +1,33 @@
#pragma once #pragma once
#include <esp_http_server.h> #include <PsychicHttp.h>
#include <esp_littlefs.h>
#include <esp_vfs.h> #include <LittleFS.h>
#include <dirent.h>
#include <sys/stat.h>
#include <string> #include <string>
#include <cstdio>
#include <platform_shared/api.pb.h>
#define MOUNT_POINT "/littlefs" #define ESP_FS LittleFS
#define FS_CONFIG_DIRECTORY MOUNT_POINT "/config" #define AP_SETTINGS_FILE "/config/apSettings.json"
#define DEVICE_CONFIG_FILE MOUNT_POINT "/config/peripheral.pb" #define CAMERA_SETTINGS_FILE "/config/cameraSettings.json"
#define CAMERA_SETTINGS_FILE MOUNT_POINT "/config/cameraSettings.pb" #define FS_CONFIG_DIRECTORY "/config"
#define AP_SETTINGS_FILE MOUNT_POINT "/config/apSettings.pb" #define DEVICE_CONFIG_FILE "/config/peripheral.json"
#define MDNS_SETTINGS_FILE MOUNT_POINT "/config/mdnsSettings.pb" #define WIFI_SETTINGS_FILE "/config/wifiSettings.json"
#define WIFI_SETTINGS_FILE MOUNT_POINT "/config/wifiSettings.pb" #define SERVO_SETTINGS_FILE "/config/servoSettings.json"
#define PERIPHERAL_SETTINGS_FILE MOUNT_POINT "/config/peripheralSettings.pb" #define MDNS_SETTINGS_FILE "/config/mdnsSettings.json"
#define SERVO_SETTINGS_FILE MOUNT_POINT "/config/servoSettings.pb"
namespace FileSystem { namespace FileSystem {
extern PsychicUploadHandler *uploadHandler;
bool init();
void listFilesProto(const std::string &directory, api_FileEntry *entry);
std::string listFiles(const std::string &directory, bool isRoot = true); std::string listFiles(const std::string &directory, bool isRoot = true);
bool deleteFile(const char *filename); bool deleteFile(const char *filename);
bool editFile(const char *filename, const uint8_t *content, size_t size);
bool editFile(const char *filename, const char *content); bool editFile(const char *filename, const char *content);
bool fileExists(const char *filename); esp_err_t uploadFile(PsychicRequest *request, const std::string &filename, uint64_t index, uint8_t *data, size_t len,
std::string readFile(const char *filename); bool last);
bool writeFile(const char *filename, const char *content);
bool writeFile(const char *filename, const uint8_t *content, size_t size);
bool mkdirRecursive(const char *path);
esp_err_t getFilesProto(httpd_req_t *request); esp_err_t getFiles(PsychicRequest *request);
esp_err_t getFiles(httpd_req_t *request); esp_err_t getConfigFile(PsychicRequest *request);
esp_err_t getConfigFile(httpd_req_t *request); esp_err_t handleDelete(PsychicRequest *request, JsonVariant &json);
esp_err_t handleDelete(httpd_req_t *request, const api_FileDeleteRequest &req); esp_err_t handleEdit(PsychicRequest *request, JsonVariant &json);
esp_err_t handleEdit(httpd_req_t *request, const api_FileEditRequest &req);
esp_err_t mkdir(httpd_req_t *request, const api_FileMkdirRequest &req);
esp_err_t mkdir(PsychicRequest *request, JsonVariant &json);
} // namespace FileSystem } // namespace FileSystem
-81
View File
@@ -1,81 +0,0 @@
#pragma once
#include <platform_shared/message.pb.h>
#include <filesystem.h>
#include <map>
#include <string>
#include <functional>
#include <cstdio>
#define FS_MAX_CHUNK_SIZE 16384
#define FS_TRANSFER_TIMEOUT_MS 30000
namespace FileSystemWS {
struct DownloadState {
std::string path;
FILE* file;
uint32_t fileSize;
uint32_t chunkSize;
uint32_t totalChunks;
uint32_t chunksSent;
uint32_t lastActivityTime;
int clientId;
};
struct UploadState {
std::string path;
FILE* file;
uint32_t fileSize;
uint32_t totalChunks;
uint32_t chunksReceived;
uint32_t bytesReceived;
uint32_t lastActivityTime;
int clientId;
bool hasError;
std::string errorMessage;
};
using SendMetadataCallback = std::function<void(const socket_message_FSDownloadMetadata&, int clientId)>;
using SendCallback = std::function<void(const socket_message_FSDownloadData&, int clientId)>;
using SendCompleteCallback = std::function<void(const socket_message_FSDownloadComplete&, int clientId)>;
using SendUploadCompleteCallback = std::function<void(const socket_message_FSUploadComplete&, int clientId)>;
class FileSystemHandler {
public:
FileSystemHandler();
void setSendCallbacks(SendMetadataCallback sendMetadata, SendCallback sendData, SendCompleteCallback sendComplete,
SendUploadCompleteCallback sendUploadComplete);
socket_message_FSDeleteResponse handleDelete(const socket_message_FSDeleteRequest& req);
socket_message_FSMkdirResponse handleMkdir(const socket_message_FSMkdirRequest& req);
socket_message_FSListResponse handleList(const socket_message_FSListRequest& req);
void handleDownloadRequest(const socket_message_FSDownloadRequest& req, int clientId);
socket_message_FSUploadStartResponse handleUploadStart(const socket_message_FSUploadStart& req, int clientId);
void handleUploadData(const socket_message_FSUploadData& req);
socket_message_FSCancelTransferResponse handleCancelTransfer(const socket_message_FSCancelTransfer& req);
void cleanupExpiredTransfers();
void processPendingDownloads();
private:
std::map<uint32_t, DownloadState> downloads_;
std::map<uint32_t, UploadState> uploads_;
uint32_t transferIdCounter_;
inline uint32_t generateTransferId() { return ++transferIdCounter_; }
SendMetadataCallback sendMetadataCallback_;
SendCallback sendDataCallback_;
SendCompleteCallback sendCompleteCallback_;
SendUploadCompleteCallback sendUploadCompleteCallback_;
void listDirectory(const std::string& path, socket_message_FSListResponse& response);
bool deleteRecursive(const std::string& path);
bool sendNextDownloadChunk(uint32_t transferId);
void finalizeUpload(uint32_t transferId, bool success, const std::string& error = "");
};
extern FileSystemHandler fsHandler;
} // namespace FileSystemWS
+23 -35
View File
@@ -1,61 +1,49 @@
#pragma once #pragma once
#include <sdkconfig.h> #include <esp32-hal.h>
#include <esp_system.h>
#if CONFIG_IDF_TARGET_ESP32 #if CONFIG_IDF_TARGET_ESP32 // ESP32/PICO-D4
#include "esp32/rom/rtc.h" #include "esp32/rom/rtc.h"
#ifndef ESP_PLATFORM_NAME #ifndef ESP_PLATFORM
#define ESP_PLATFORM_NAME "ESP32" #define ESP_PLATFORM "ESP32"
#endif #endif
#elif CONFIG_IDF_TARGET_ESP32S2 #elif CONFIG_IDF_TARGET_ESP32S2
#include "esp32s2/rom/rtc.h" #include "esp32/rom/rtc.h"
#ifndef ESP_PLATFORM_NAME #ifndef ESP_PLATFORM
#define ESP_PLATFORM_NAME "ESP32-S2" #define ESP_PLATFORM "ESP32-S2"
#endif #endif
#elif CONFIG_IDF_TARGET_ESP32C3 #elif CONFIG_IDF_TARGET_ESP32C3
#include "esp32c3/rom/rtc.h" #include "esp32c3/rom/rtc.h"
#ifndef ESP_PLATFORM_NAME #ifndef ESP_PLATFORM
#define ESP_PLATFORM_NAME "ESP32-C3" #define ESP_PLATFORM "ESP32-C3"
#endif #endif
#elif CONFIG_IDF_TARGET_ESP32S3 #elif CONFIG_IDF_TARGET_ESP32S3
#include "esp32s3/rom/rtc.h" #include "esp32s3/rom/rtc.h"
#ifndef ESP_PLATFORM_NAME #ifndef ESP_PLATFORM
#define ESP_PLATFORM_NAME "ESP32-S3" #define ESP_PLATFORM "ESP32-S3"
#endif #endif
#elif CONFIG_IDF_TARGET_ESP32C6
#include "esp32c6/rom/rtc.h"
#ifndef ESP_PLATFORM_NAME
#define ESP_PLATFORM_NAME "ESP32-C6"
#endif
#elif CONFIG_IDF_TARGET_ESP32P4
#include "esp32p4/rom/rtc.h"
#ifndef ESP_PLATFORM_NAME
#define ESP_PLATFORM_NAME "ESP32-P4"
#endif
#define ESP32P4_USES_C6_COPROCESSOR 1
#else #else
#error Target CONFIG_IDF_TARGET is not supported #error Target CONFIG_IDF_TARGET is not supported
#endif #endif
#ifndef ARDUINO_VERSION
#ifndef STRINGIFY
#define STRINGIFY(s) #s
#endif
#define ARDUINO_VERSION_STR(major, minor, patch) "v" STRINGIFY(major) "." STRINGIFY(minor) "." STRINGIFY(patch)
#define ARDUINO_VERSION \
ARDUINO_VERSION_STR(ESP_ARDUINO_VERSION_MAJOR, ESP_ARDUINO_VERSION_MINOR, ESP_ARDUINO_VERSION_PATCH)
#endif
/* /*
* I2C software connection * I2C software connection
*/ */
#if CONFIG_IDF_TARGET_ESP32P4
#ifndef SDA_PIN #ifndef SDA_PIN
#define SDA_PIN 7 #define SDA_PIN SDA
#endif #endif
#ifndef SCL_PIN #ifndef SCL_PIN
#define SCL_PIN 8 #define SCL_PIN SCL
#endif
#else
#ifndef SDA_PIN
#define SDA_PIN 21
#endif
#ifndef SCL_PIN
#define SCL_PIN 22
#endif
#endif #endif
#ifndef I2C_FREQUENCY #ifndef I2C_FREQUENCY
#define I2C_FREQUENCY 1000000UL #define I2C_FREQUENCY 100000UL
#endif #endif
+78 -6
View File
@@ -38,9 +38,8 @@ class KinConfig {
{mountOffsets[3][0], 0, mountOffsets[3][2] - coxa, 1}, {mountOffsets[3][0], 0, mountOffsets[3][2] - coxa, 1},
}; };
// Max constants static constexpr float max_roll = 15 * DEG2RAD_F;
static constexpr float max_roll = 20.0f; static constexpr float max_pitch = 15 * DEG2RAD_F;
static constexpr float max_pitch = 15.0f;
static constexpr float max_body_shift_x = W / 3; static constexpr float max_body_shift_x = W / 3;
static constexpr float max_body_shift_z = W / 3; static constexpr float max_body_shift_z = W / 3;
@@ -60,19 +59,92 @@ class KinConfig {
static constexpr float default_step_height = default_body_height / 2; static constexpr float default_step_height = default_body_height / 2;
}; };
struct displacement_state_t {
float x {0};
float y {0};
float z {0};
float roll {0};
float pitch {0};
float yaw {0};
void reset() { x = y = z = roll = pitch = yaw = 0; }
float distance() const { return std::sqrt(x * x + z * z); }
};
struct skill_target_t {
float target_x {0};
float target_z {0};
float target_yaw {0};
float traveled_x {0};
float traveled_z {0};
float rotated {0};
bool active {false};
void set(float x, float z, float yaw) {
target_x = x;
target_z = z;
target_yaw = yaw;
traveled_x = traveled_z = rotated = 0;
active = true;
}
void reset() {
target_x = target_z = target_yaw = 0;
traveled_x = traveled_z = rotated = 0;
active = false;
}
void accumulate(float dx, float dz, float dyaw) {
traveled_x += dx;
traveled_z += dz;
rotated += dyaw;
}
bool isComplete() const {
if (!active) return false;
bool x_ok = (target_x == 0) || (target_x > 0 ? traveled_x >= target_x : traveled_x <= target_x);
bool z_ok = (target_z == 0) || (target_z > 0 ? traveled_z >= target_z : traveled_z <= target_z);
bool yaw_ok = (target_yaw == 0) || (target_yaw > 0 ? rotated >= target_yaw : rotated <= target_yaw);
return x_ok && z_ok && yaw_ok;
}
float progress() const {
if (!active) return 0;
float total_target = std::fabs(target_x) + std::fabs(target_z) + std::fabs(target_yaw);
if (total_target == 0) return 1;
auto clampProgress = [](float traveled, float target) -> float {
if (target == 0) return 0;
float p = traveled / target;
return std::clamp(p, 0.0f, 1.0f) * std::fabs(target);
};
float total_progress = clampProgress(traveled_x, target_x) + clampProgress(traveled_z, target_z) +
clampProgress(rotated, target_yaw);
return total_progress / total_target;
}
};
struct alignas(16) body_state_t { struct alignas(16) body_state_t {
float omega {0}, phi {0}, psi {0}, xm {0}, ym {KinConfig::default_body_height}, zm {0}; float omega {0}, phi {0}, psi {0}, xm {0}, ym {KinConfig::default_body_height}, zm {0};
float feet[4][4]; float feet[4][4];
displacement_state_t cumulative;
skill_target_t skill;
void updateFeet(const float newFeet[4][4]) { COPY_2D_ARRAY_4x4(feet, newFeet); } void updateFeet(const float newFeet[4][4]) { COPY_2D_ARRAY_4x4(feet, newFeet); }
void resetDisplacement() { cumulative.reset(); }
bool operator==(const body_state_t &other) const { bool operator==(const body_state_t &other) const {
if (!IS_ALMOST_EQUAL(omega, other.omega) || !IS_ALMOST_EQUAL(phi, other.phi) || if (!IS_ALMOST_EQUAL(omega, other.omega) || !IS_ALMOST_EQUAL(phi, other.phi) ||
!IS_ALMOST_EQUAL(psi, other.psi) || !IS_ALMOST_EQUAL(xm, other.xm) || !IS_ALMOST_EQUAL(ym, other.ym) || !IS_ALMOST_EQUAL(psi, other.psi) || !IS_ALMOST_EQUAL(xm, other.xm) || !IS_ALMOST_EQUAL(ym, other.ym) ||
!IS_ALMOST_EQUAL(zm, other.zm)) { !IS_ALMOST_EQUAL(zm, other.zm)) {
return false; return false;
} }
return arrayEqual(feet, other.feet, 0.001f); return arrayEqual(feet, other.feet, 0.1f);
} }
}; };
@@ -184,13 +256,13 @@ class Kinematics {
} }
inline void legIK(float x, float y, float z, float out[3]) { inline void legIK(float x, float y, float z, float out[3]) {
float F = sqrt(fmax(0.0f, x * x + y * y - coxa * coxa)); float F = sqrt(max(0.0f, x * x + y * y - coxa * coxa));
float G = F - coxa_offset; float G = F - coxa_offset;
float H = sqrt(G * G + z * z); float H = sqrt(G * G + z * z);
float theta1 = -atan2f(y, x) - atan2f(F, -coxa); float theta1 = -atan2f(y, x) - atan2f(F, -coxa);
float D = (H * H - femur * femur - tibia * tibia) / (2 * femur * tibia); float D = (H * H - femur * femur - tibia * tibia) / (2 * femur * tibia);
float theta3 = acosf(fmax(-1.0f, fmin(1.0f, D))); float theta3 = acosf(max(-1.0f, min(1.0f, D)));
float theta2 = atan2f(z, G) - atan2f(tibia * sinf(theta3), femur + tibia * cosf(theta3)); float theta2 = atan2f(z, G) - atan2f(tibia * sinf(theta3), femur + tibia * cosf(theta3));
out[0] = RAD_TO_DEG_F(theta1); out[0] = RAD_TO_DEG_F(theta1);
out[1] = RAD_TO_DEG_F(theta2); out[1] = RAD_TO_DEG_F(theta2);
+18 -15
View File
@@ -1,31 +1,34 @@
#pragma once #pragma once
#include <esp_http_server.h> #include <PsychicHttp.h>
#include <mdns.h> #include <ESPmDNS.h>
#include <template/stateful_service.h> #include <template/stateful_service.h>
#include <template/stateful_proto_endpoint.h> #include <template/stateful_endpoint.h>
#include <template/stateful_persistence.h> #include <template/stateful_persistence.h>
#include <settings/mdns_settings.h> #include <settings/mdns_settings.h>
#include <utils/timing.h> #include <utils/timing.h>
#include <string>
class MDNSService : public StatefulService<MDNSSettings> { class MDNSService : public StatefulService<MDNSSettings> {
public:
MDNSService();
~MDNSService();
void begin();
esp_err_t getStatus(httpd_req_t *request);
esp_err_t queryServices(httpd_req_t *request, api_Request *protoReq);
StatefulProtoEndpoint<MDNSSettings, api_MDNSSettings> protoEndpoint;
private: private:
FSPersistencePB<MDNSSettings> _persistence; FSPersistence<MDNSSettings> _persistence;
bool _started {false}; bool _started {false};
void reconfigureMDNS(); void reconfigureMDNS();
void startMDNS(); void startMDNS();
void stopMDNS(); void stopMDNS();
void addServices(); void addServices();
public:
MDNSService();
~MDNSService();
void begin();
esp_err_t getStatus(PsychicRequest *request);
void getStatus(JsonVariant &root);
static esp_err_t queryServices(PsychicRequest *request, JsonVariant &json);
StatefulHttpEndpoint<MDNSSettings> endpoint;
}; };
+20 -9
View File
@@ -1,17 +1,28 @@
#pragma once #pragma once
#include <platform_shared/message.pb.h> #include <ArduinoJson.h>
struct CommandMsg { struct CommandMsg {
float lx, ly, rx, ry, h, s, s1; float lx, ly, rx, ry, h, s, s1;
friend void toJson(JsonVariant v, CommandMsg const &c) {
JsonArray arr = v.to<JsonArray>();
arr.add(c.lx);
arr.add(c.ly);
arr.add(c.rx);
arr.add(c.ry);
arr.add(c.h);
arr.add(c.s);
arr.add(c.s1);
}
void fromProto(const socket_message_ControllerData& data) { void fromJson(JsonVariantConst o) {
lx = data.has_left ? data.left.x : 0; JsonArrayConst arr = o.as<JsonArrayConst>();
ly = data.has_left ? data.left.y : 0; lx = arr[0].as<float>();
rx = data.has_right ? data.right.x : 0; ly = arr[1].as<float>();
ry = data.has_right ? data.right.y : 0; rx = arr[2].as<float>();
h = data.height; ry = arr[3].as<float>();
s = data.speed; h = arr[4].as<float>();
s1 = data.s1; s = arr[5].as<float>();
s1 = arr[6].as<float>();
} }
}; };
+54 -8
View File
@@ -1,7 +1,9 @@
#ifndef MotionService_h #ifndef MotionService_h
#define MotionService_h #define MotionService_h
#include <ArduinoJson.h>
#include "esp_timer.h" #include "esp_timer.h"
#include <functional>
#include <kinematics.h> #include <kinematics.h>
#include <peripherals/gesture.h> #include <peripherals/gesture.h>
@@ -17,30 +19,69 @@
enum class MOTION_STATE { DEACTIVATED, IDLE, CALIBRATION, REST, STAND, WALK }; enum class MOTION_STATE { DEACTIVATED, IDLE, CALIBRATION, REST, STAND, WALK };
using SkillCompleteCallback = std::function<void()>;
class MotionService { class MotionService {
public: public:
void begin(); void begin();
void handleAngles(const socket_message_AnglesData& data); void anglesEvent(JsonVariant &root, int originId);
void handleInput(const socket_message_ControllerData& data); void handleInput(JsonVariant &root, int originId);
void handleWalkGait(const socket_message_WalkGaitData& data); void handleWalkGait(JsonVariant &root, int originId);
void handleMode(const socket_message_ModeData& data); void handleMode(JsonVariant &root, int originId);
void setState(MotionState* newState); void handleDisplacement(JsonVariant &root, int originId);
void handleSkill(JsonVariant &root, int originId);
void setState(MotionState *newState);
void handleGestures(const gesture_t ges); void handleGestures(const gesture_t ges);
bool update(Peripherals* peripherals); bool update(Peripherals *peripherals);
bool update_angles(float new_angles[12], float angles[12]); bool update_angles(float new_angles[12], float angles[12]);
float* getAngles() { return angles; } float *getAngles() { return angles; }
inline bool isActive() { return state != nullptr; } inline bool isActive() { return state != nullptr; }
void resetDisplacement() { body_state.resetDisplacement(); }
void setSkillTarget(float x, float z, float yaw) { body_state.skill.set(x, z, yaw); }
void clearSkill() { body_state.skill.reset(); }
bool isSkillActive() const { return body_state.skill.active; }
bool isSkillComplete() const { return body_state.skill.isComplete(); }
const displacement_state_t &getDisplacement() const { return body_state.cumulative; }
const skill_target_t &getSkill() const { return body_state.skill; }
void getDisplacementResult(JsonVariant &root) const {
root["x"] = body_state.cumulative.x;
root["y"] = body_state.cumulative.y;
root["z"] = body_state.cumulative.z;
root["yaw"] = body_state.cumulative.yaw;
root["distance"] = body_state.cumulative.distance();
root["skill_active"] = body_state.skill.active;
root["skill_target_x"] = body_state.skill.target_x;
root["skill_target_z"] = body_state.skill.target_z;
root["skill_target_yaw"] = body_state.skill.target_yaw;
root["skill_traveled_x"] = body_state.skill.traveled_x;
root["skill_traveled_z"] = body_state.skill.traveled_z;
root["skill_rotated"] = body_state.skill.rotated;
root["skill_progress"] = body_state.skill.progress();
root["skill_complete"] = body_state.skill.isComplete();
}
void setSkillCompleteCallback(SkillCompleteCallback callback) { skillCompleteCallback = callback; }
private: private:
Kinematics kinematics; Kinematics kinematics;
@@ -48,7 +89,7 @@ class MotionService {
friend class MotionState; friend class MotionState;
MotionState* state = nullptr; MotionState *state = nullptr;
RestState restState; RestState restState;
StandState standState; StandState standState;
@@ -62,6 +103,11 @@ class MotionService {
float dir[12] = {1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1}; float dir[12] = {1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1};
int64_t lastUpdate = esp_timer_get_time(); int64_t lastUpdate = esp_timer_get_time();
SkillCompleteCallback skillCompleteCallback = nullptr;
bool skillWasComplete = false;
void checkSkillComplete();
}; };
#endif #endif
+7 -12
View File
@@ -2,8 +2,6 @@
#include <kinematics.h> #include <kinematics.h>
#include <message_types.h> #include <message_types.h>
#include <utils/math_utils.h>
#include <cstring>
class MotionState { class MotionState {
protected: protected:
@@ -19,24 +17,21 @@ class MotionState {
body_state.ym = lerp(body_state.ym, target_body_state.ym, smoothing_factor); body_state.ym = lerp(body_state.ym, target_body_state.ym, smoothing_factor);
body_state.zm = lerp(body_state.zm, target_body_state.zm, smoothing_factor); body_state.zm = lerp(body_state.zm, target_body_state.zm, smoothing_factor);
body_state.phi = lerp(body_state.phi, target_body_state.phi, smoothing_factor); body_state.phi = lerp(body_state.phi, target_body_state.phi, smoothing_factor);
const float target_psi = body_state.psi = lerp(body_state.psi, target_body_state.psi - imuCompensate * psi_offset, smoothing_factor);
clamp(target_body_state.psi - imuCompensate * psi_offset, -KinConfig::max_pitch, KinConfig::max_pitch); body_state.omega =
const float target_omega = lerp(body_state.omega, target_body_state.omega - imuCompensate * omega_offset, smoothing_factor);
clamp(target_body_state.omega - imuCompensate * omega_offset, -KinConfig::max_roll, KinConfig::max_roll);
body_state.psi = lerp(body_state.psi, target_psi, smoothing_factor);
body_state.omega = lerp(body_state.omega, target_omega, smoothing_factor);
} }
void updateFeet(body_state_t& body_state, const float smoothing_factor = default_smoothing_factor) { void updateFeet(body_state_t& body_state, const float smoothing_factor = default_smoothing_factor) {
if (std::memcmp(target_body_state.feet, body_state.feet, sizeof(body_state.feet)) != 0) { if (target_body_state.feet != body_state.feet) {
body_state.updateFeet(target_body_state.feet); body_state.updateFeet(target_body_state.feet);
} }
} }
public: public:
void updateImuOffsets(const float new_omega, const float new_psi) { void updateImuOffsets(const float omega_offset, const float psi_offset) {
omega_offset = RAD_TO_DEG_F(new_omega); this->omega_offset = omega_offset * RAD2DEG_F;
psi_offset = RAD_TO_DEG_F(new_psi); this->psi_offset = psi_offset * RAD2DEG_F;
} }
virtual ~MotionState() {} virtual ~MotionState() {}
+27 -8
View File
@@ -2,7 +2,6 @@
#include <motion_states/state.h> #include <motion_states/state.h>
#include <utils/math_utils.h> #include <utils/math_utils.h>
#include <algorithm>
#include <array> #include <array>
#include <functional> #include <functional>
@@ -99,11 +98,33 @@ class WalkState : public MotionState {
step_length = std::hypot(gait_state.step_x, gait_state.step_z); step_length = std::hypot(gait_state.step_x, gait_state.step_z);
if (gait_state.step_x < 0.0f) step_length = -step_length; if (gait_state.step_x < 0.0f) step_length = -step_length;
const bool moving = !isZero(gait_state.step_x) || !isZero(gait_state.step_z) || !isZero(gait_state.step_angle);
updateDisplacement(body_state, dt, moving);
updatePhase(dt); updatePhase(dt);
updateBodyPosition(body_state, dt); updateBodyPosition(body_state, dt);
updateFeetPositions(body_state); updateFeetPositions(body_state);
} }
void updateDisplacement(body_state_t &body_state, float dt, bool moving) {
if (!moving) return;
float dx_local = gait_state.step_x * gait_state.step_velocity * dt * speed_factor;
float dz_local = gait_state.step_z * gait_state.step_velocity * dt * speed_factor;
float dyaw = gait_state.step_angle * gait_state.step_velocity * dt * speed_factor;
if (body_state.skill.active) {
body_state.skill.accumulate(dx_local, dz_local, dyaw);
}
float cos_yaw = std::cos(body_state.cumulative.yaw);
float sin_yaw = std::sin(body_state.cumulative.yaw);
body_state.cumulative.x += dx_local * cos_yaw - dz_local * sin_yaw;
body_state.cumulative.z += dx_local * sin_yaw + dz_local * cos_yaw;
body_state.cumulative.yaw += dyaw;
}
protected: protected:
void handleCommand(const CommandMsg &cmd) override { void handleCommand(const CommandMsg &cmd) override {
target_body_state.ym = KinConfig::min_body_height + cmd.h * KinConfig::body_height_range; target_body_state.ym = KinConfig::min_body_height + cmd.h * KinConfig::body_height_range;
@@ -116,16 +137,14 @@ class WalkState : public MotionState {
target_gait_state.step_depth = KinConfig::default_step_depth; target_gait_state.step_depth = KinConfig::default_step_depth;
} }
static inline bool isZero(float num) { return std::fabs(num) < 0.001; } static inline bool isZero(float num) { return std::fabs(num) < 0.01; }
void updatePhase(float dt) { void updatePhase(float dt) {
const bool moving = !isZero(gait_state.step_x) || !isZero(gait_state.step_z) || !isZero(gait_state.step_angle); if (isZero(gait_state.step_x) && isZero(gait_state.step_z) && isZero(gait_state.step_angle)) {
if (!moving) {
phase_time = 0; phase_time = 0;
return; return;
} }
const float velocity = std::max(gait_state.step_velocity, 0.5f); phase_time = std::fmod(phase_time + dt * gait_state.step_velocity * speed_factor, 1.0f);
phase_time = std::fmod(phase_time + dt * velocity * speed_factor, 1.0f);
} }
LegStates getLegStates() { LegStates getLegStates() {
@@ -245,7 +264,7 @@ class WalkState : public MotionState {
float angle = std::atan2(gait_state.step_z, step_length) * 2.0f; float angle = std::atan2(gait_state.step_z, step_length) * 2.0f;
curve(length, angle, arg, phase, delta_pos); curve(length, angle, arg, phase, delta_pos);
length = gait_state.step_angle * KinConfig::max_step_length; length = gait_state.step_angle * 2.0f;
angle = yawArc(default_feet_pos[index], body_state.feet[index]); angle = yawArc(default_feet_pos[index], body_state.feet[index]);
curve(length, angle, arg, phase, delta_rot); curve(length, angle, arg, phase, delta_rot);
@@ -278,7 +297,7 @@ class WalkState : public MotionState {
point[1] += b * BEZIER_HEIGHTS[i] * *height; point[1] += b * BEZIER_HEIGHTS[i] * *height;
point[2] += b * BEZIER_STEPS[i] * length * Z_POLAR; point[2] += b * BEZIER_STEPS[i] * length * Z_POLAR;
phase_power *= t; phase_power *= phase;
inv_phase_power /= one_minus_phase; inv_phase_power /= one_minus_phase;
} }
} }
+39 -13
View File
@@ -1,42 +1,68 @@
#pragma once #pragma once
#include <list>
#include <SPI.h>
#include <Wire.h>
#include <ArduinoJson.h>
#include <utils/math_utils.h> #include <utils/math_utils.h>
#include <peripherals/sensor.hpp>
#include <peripherals/drivers/bmp180.h>
struct BarometerMsg { #include <Adafruit_BMP085_U.h>
#include <Adafruit_Sensor.h>
#include <peripherals/sensor.hpp>
struct BarometerMsg : public SensorMessageBase {
float pressure {-1}; float pressure {-1};
float altitude {-1}; float altitude {-1};
float temperature {-1}; float temperature {-1};
bool success {false}; bool success {false};
void toJson(JsonVariant v) const override {
JsonArray arr = v.to<JsonArray>();
arr.add(pressure);
arr.add(altitude);
arr.add(temperature);
arr.add(success);
}
void fromJson(JsonVariantConst v) override {
JsonArrayConst arr = v.as<JsonArrayConst>();
pressure = arr[0] | -1.0f;
altitude = arr[1] | -1.0f;
temperature = arr[2] | -1.0f;
success = arr[3] | false;
}
friend void toJson(JsonVariant v, BarometerMsg const& a) { a.toJson(v); }
}; };
class Barometer : public SensorBase<BarometerMsg> { class Barometer : public SensorBase<BarometerMsg> {
public: public:
bool initialize() override { bool initialize() override {
_msg.success = _bmp.begin(); _msg.success = _bmp.begin();
if (_msg.success) {
ESP_LOGI("BMP", "BMP180 initialized successfully");
} else {
ESP_LOGE("BMP", "BMP180 initialization failed");
}
return _msg.success; return _msg.success;
} }
bool update() override { bool update() override {
if (!_msg.success) return false; if (!_msg.success) return false;
if (!_bmp.update()) return false; _bmp.getTemperature(&_msg.temperature);
_msg.temperature = _bmp.getTemperature(); sensors_event_t event;
_msg.pressure = _bmp.getPressure(); _bmp.getEvent(&event);
_msg.altitude = _bmp.getAltitude(); _msg.pressure = event.pressure;
_msg.altitude = _bmp.pressureToAltitude(seaLevelPressure, _msg.pressure);
return true; return true;
} }
float getPressure() { return _msg.pressure; } float getPressure() { return _msg.pressure; }
float getAltitude() { return _msg.altitude; } float getAltitude() { return _msg.altitude; }
float getTemperature() { return _msg.temperature; } float getTemperature() { return _msg.temperature; }
bool active() { return _msg.success; } bool active() { return _msg.success; }
private: private:
BMP180Driver _bmp; Adafruit_BMP085_Unified _bmp {10085};
const float seaLevelPressure = SENSORS_PRESSURE_SEALEVELHPA;
}; };

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