Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbfd7aa354 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ 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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
[submodule "submodules/nanopb"]
|
|
||||||
path = submodules/nanopb
|
|
||||||
url = https://github.com/nanopb/nanopb
|
|
||||||
branch = master
|
|
||||||
Vendored
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.16.0)
|
|
||||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
|
||||||
project(Spot_Micro_Leika)
|
|
||||||
@@ -1 +1,3 @@
|
|||||||
PUBLIC_VITE_USE_HOST_NAME=true
|
PUBLIC_VITE_USE_HOST_NAME=true
|
||||||
|
PUBLIC_USE_JSON=true
|
||||||
|
PUBLIC_USE_MSGPACK=true
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+6
-6
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Generated
+12
-272
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,4 +1,4 @@
|
|||||||
export class Throttler {
|
export class throttler {
|
||||||
private _throttlePause: boolean
|
private _throttlePause: boolean
|
||||||
constructor() {
|
constructor() {
|
||||||
this._throttlePause = false
|
this._throttlePause = false
|
||||||
|
|||||||
@@ -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'
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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'}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ const config = {
|
|||||||
}),
|
}),
|
||||||
paths: {
|
paths: {
|
||||||
base: basePath
|
base: basePath
|
||||||
},
|
|
||||||
output: {
|
|
||||||
bundleStrategy: 'single'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
+9
-14
@@ -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: {
|
export default defineConfig(config)
|
||||||
globals: true,
|
|
||||||
environment: 'jsdom'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default defineConfig(config)
|
|
||||||
@@ -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
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -2,4 +2,7 @@
|
|||||||
build_flags =
|
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
|
||||||
@@ -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
|
||||||
|
|||||||
+12
-13
@@ -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,23 +16,25 @@ 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();
|
||||||
void startAP();
|
void startAP();
|
||||||
void stopAP();
|
void stopAP();
|
||||||
void handleDNS();
|
void handleDNS();
|
||||||
};
|
};
|
||||||
@@ -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_);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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_;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
+20
-32
@@ -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);
|
|
||||||
|
|
||||||
} // namespace FileSystem
|
esp_err_t mkdir(PsychicRequest *request, JsonVariant &json);
|
||||||
|
} // namespace FileSystem
|
||||||
@@ -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
|
|
||||||
+24
-36
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user