Compare commits
291 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6108aa9bf6 | |||
| b590f157e1 | |||
| 0f2a0c65ba | |||
| 37474e840d | |||
| e1d37a907d | |||
| eba00f98cd | |||
| 43e7f13888 | |||
| d81b1b0851 | |||
| bf2fd957af | |||
| d6075deb6c | |||
| ff1444b2bc | |||
| e5e9841dd3 | |||
| f4f8035f37 | |||
| 13300aa9e0 | |||
| cdf6c83be5 | |||
| bd984309f1 | |||
| aca8ee6de5 | |||
| 21ed3d51d2 | |||
| f04e97443d | |||
| 07f5ffd5a7 | |||
| 9b5261a022 | |||
| 5df67bffb2 | |||
| bdee1d0e04 | |||
| bce9041f1f | |||
| 2ce29ae0cc | |||
| 0af2f5ebec | |||
| 4575f63921 | |||
| 56d81f75cb | |||
| 72e2522dcd | |||
| e1f44a6f06 | |||
| 1a280f5356 | |||
| 6d62b00c0e | |||
| 0a2d3c0e31 | |||
| 25063c1bd4 | |||
| f9a99ce013 | |||
| bd012046f2 | |||
| 1e333a0ffe | |||
| 1931551fa8 | |||
| 92da5b0dac | |||
| 9666baf858 | |||
| 6e7f7bb657 | |||
| dbca9bd0b7 | |||
| a4e900fb65 | |||
| 476c49f474 | |||
| 7f4a158e24 | |||
| 6e478460f5 | |||
| d5af8d0294 | |||
| ae4a2fe115 | |||
| e1e656478d | |||
| 02aaee0878 | |||
| 3f84434167 | |||
| 098f3b4c8f | |||
| 608eec3894 | |||
| 69e4aefec3 | |||
| 1e9f38fe7b | |||
| dbc74d6f88 | |||
| d9e752777f | |||
| f513de0171 | |||
| 56376e6322 | |||
| a5e62d87fd | |||
| f033e8b0ae | |||
| eb8b83736a | |||
| 57e80655cf | |||
| 92b2d326c7 | |||
| 64199ac1a3 | |||
| 0b8e060063 | |||
| a88c8eb0be | |||
| 38bb16bb6c | |||
| f10406b29c | |||
| 4ac54279a8 | |||
| aff50d6a9c | |||
| 17de0b22af | |||
| 6104c54f39 | |||
| cec9024a26 | |||
| 70043aa139 | |||
| 9b8e92ce32 | |||
| 485ecb7547 | |||
| a799af360f | |||
| f0c4f0f929 | |||
| 50ef91ab22 | |||
| 1b6ffc4641 | |||
| d00e7bc92d | |||
| 6b96d0deba | |||
| 4fa3939209 | |||
| 26c187a480 | |||
| 957b60b132 | |||
| d86c86e028 | |||
| 0435605e18 | |||
| f440fa3973 | |||
| 725d62747d | |||
| d14e598aab | |||
| d611cd043b | |||
| 6be38b2e9e | |||
| fd7b3951ff | |||
| cb74a1e9d4 | |||
| b38bc4e807 | |||
| bfac75c8fb | |||
| 5295ad56c8 | |||
| 7cb5c06524 | |||
| 3a393375fd | |||
| db0d4beb68 | |||
| b96ea51bd8 | |||
| a31e001eb5 | |||
| 39f9e47e59 | |||
| a6e5363533 | |||
| 775ca78a10 | |||
| c4b1ae8335 | |||
| e3ae62e120 | |||
| 2b4468d407 | |||
| 685088c218 | |||
| 0309855d5f | |||
| b0ee7b6b1b | |||
| 0ddfe479d9 | |||
| 2b817e90ef | |||
| c06b349f16 | |||
| dc04204e8e | |||
| 585adaf28f | |||
| 6a117ac5e3 | |||
| 83e5fcd354 | |||
| 9d7caab295 | |||
| f3a3ebe1ea | |||
| 210e0363ab | |||
| 568fa93368 | |||
| 8b12d4008e | |||
| 306e7488e0 | |||
| a2f08540f7 | |||
| 4c6b0c316d | |||
| fa332995f9 | |||
| c0c13754f4 | |||
| 28bb35d104 | |||
| 719e6be8a7 | |||
| a6a8f4988b | |||
| 7461b26c97 | |||
| 62f5758ab0 | |||
| 381ff9463d | |||
| 72f3650c6e | |||
| 86a4cee7ae | |||
| 6c737c10c7 | |||
| a9e38c845a | |||
| 61905f8e95 | |||
| 4e4e8fb190 | |||
| 96075a0110 | |||
| 0ef55bcc7e | |||
| a6b5b0881a | |||
| f3d3cb1b6f | |||
| c2374bd353 | |||
| d07f3139b6 | |||
| ff3a3f3d7d | |||
| 72cde1a90a | |||
| b485579d80 | |||
| 13546d600c | |||
| 58bf8a88a6 | |||
| 9c1ad30771 | |||
| a98faabfba | |||
| 10e56e25b3 | |||
| d6e281d6a5 | |||
| cdaa60d0e1 | |||
| f25aba5f29 | |||
| 1117666f26 | |||
| 19ebceb959 | |||
| a8abaaaf61 | |||
| a53bf806ac | |||
| 49a7431cef | |||
| 4633d2eb09 | |||
| 73aa38951d | |||
| 9cddbf8a9b | |||
| e4ec2dd7b7 | |||
| 2eec367e05 | |||
| f5fc31ca5a | |||
| c90ebe5630 | |||
| eab9aab5c6 | |||
| 466f2b1b37 | |||
| 770a462d78 | |||
| 8098dcec9b | |||
| c4d3c8966c | |||
| 361d8b0975 | |||
| 41d1b8e56d | |||
| 5459d0edd4 | |||
| 8c45f66137 | |||
| c2bbeb2f2b | |||
| 8a5f8a2154 | |||
| 3015c13da8 | |||
| 356ccda4ae | |||
| 1f0b416231 | |||
| 6bdefbbf54 | |||
| 2a25851fb6 | |||
| 7a21580569 | |||
| 8c418fd779 | |||
| 8e66a03c00 | |||
| 9ceb7a9919 | |||
| 04aeeb5f07 | |||
| 3451b93743 | |||
| 4da929a6de | |||
| 21bd4fa837 | |||
| 3c557b69a3 | |||
| 9f3b59f0a7 | |||
| 0d1e27b167 | |||
| e1dad10a87 | |||
| bc27e5000a | |||
| a67d4643b0 | |||
| 4e24d87e4b | |||
| 630bab7678 | |||
| f54c957be8 | |||
| ed88e47944 | |||
| ba36bcc5a5 | |||
| 5e2e29d2a4 | |||
| 3be08a31ed | |||
| e22ac69e9b | |||
| 0e54f0430f | |||
| 0556f86473 | |||
| 097cc0e33e | |||
| fe76f2d7dd | |||
| f78a0f50bd | |||
| d43e98d06b | |||
| ffb2bc8749 | |||
| 6c61227623 | |||
| 7d2f384898 | |||
| 8a80559ea7 | |||
| 7c3dd2d15b | |||
| 135c7b0c94 | |||
| 06d457f4e5 | |||
| 67c5936399 | |||
| f1751f2589 | |||
| 48c0b01f93 | |||
| 64ef3d31eb | |||
| b14f005b22 | |||
| 72a288145d | |||
| af0815b01f | |||
| df3e813470 | |||
| 1b28b8b7fd | |||
| c449cb3390 | |||
| 05a420f345 | |||
| df395657e3 | |||
| 8970457353 | |||
| 0aab42f0e9 | |||
| 76d965ff43 | |||
| 0b9921e592 | |||
| aee29c47e4 | |||
| f2ee454b89 | |||
| a77eb0b1e0 | |||
| 91a7b170fe | |||
| 4d51b9f556 | |||
| 92a98064c3 | |||
| 1fbddd483c | |||
| d47ce02cc6 | |||
| 01c4a80c8f | |||
| 174d77a9fd | |||
| a078f28a82 | |||
| f3f3864b83 | |||
| 46bb5f74b1 | |||
| 89a0316fb4 | |||
| 51ee910fb6 | |||
| a198de05c2 | |||
| d3db2b3650 | |||
| 5a6f195f56 | |||
| 0cae981779 | |||
| c541b3f474 | |||
| ceccb2c901 | |||
| 8c21f3e2e4 | |||
| 55eecdc8d7 | |||
| b98c0e866b | |||
| 3d294f38c2 | |||
| a237dc3995 | |||
| 80c74dc745 | |||
| fb9313913d | |||
| 33e7fac74c | |||
| 2face72aee | |||
| 1f8e7efdb2 | |||
| b184449e7b | |||
| bc31b1b2dd | |||
| 12e1f80830 | |||
| 1cadcf8bdb | |||
| 06d27e0644 | |||
| 98b519dee8 | |||
| 4da2d7fa20 | |||
| 0f992b26e9 | |||
| 2a57d1ecc3 | |||
| fd3180d08b | |||
| 43b5216d9f | |||
| e1e11346b4 | |||
| 3ce8c88a84 | |||
| 0285b522f1 | |||
| 4ea287b162 | |||
| c2d52449b4 | |||
| f9a0880cd9 | |||
| 1bb098e952 | |||
| 9c74c8e87b | |||
| 3f4d956903 | |||
| a5371c36b9 | |||
| 41b863a0eb | |||
| 7fd35f3f48 |
@@ -36,6 +36,11 @@ 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,6 +18,8 @@ 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: |
|
||||||
@@ -32,6 +34,12 @@ 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,22 +20,31 @@ 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 dependencies
|
- name: Install Protoc
|
||||||
run: pnpm install
|
uses: arduino/setup-protoc@v3
|
||||||
- name: Install Playwright Browsers
|
with:
|
||||||
run: npx playwright install --with-deps
|
version: "27.x"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Install dependencies
|
||||||
run: pnpm test
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Generate Proto
|
||||||
|
run: pnpm proto
|
||||||
|
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pnpm test
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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,3 +6,12 @@ __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
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[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/.pio/libdeps/esp32cam/esp32-camera",
|
"cmake.sourceDirectory": "C:/data/repos/Hardware/Spot_Micro_Leika",
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"Adafruit",
|
"Adafruit",
|
||||||
"IRAM",
|
"IRAM",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16.0)
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
project(Spot_Micro_Leika)
|
||||||
@@ -1,3 +1 @@
|
|||||||
PUBLIC_VITE_USE_HOST_NAME=true
|
PUBLIC_VITE_USE_HOST_NAME=true
|
||||||
PUBLIC_USE_JSON=true
|
|
||||||
PUBLIC_USE_MSGPACK=true
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
.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
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/** @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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
+1
-2
@@ -1,13 +1,12 @@
|
|||||||
{
|
{
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 4,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
"experimentalTernaries": true,
|
"experimentalTernaries": true,
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"svelteBracketNewLine": false,
|
|
||||||
"plugins": ["prettier-plugin-svelte"],
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+5
-1
@@ -1,3 +1,7 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"]
|
"recommendations": [
|
||||||
|
"svelte.svelte-vscode",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
+71
-63
@@ -1,65 +1,73 @@
|
|||||||
{
|
{
|
||||||
"name": "spot_micro_controller",
|
"name": "spot_micro_controller",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host",
|
"dev": "vite dev --host",
|
||||||
"build": "vite build",
|
"build": "pnpm proto && 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",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"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": {
|
},
|
||||||
"@iconify-json/mdi": "^1.1.64",
|
"devDependencies": {
|
||||||
"@iconify-json/tabler": "^1.1.109",
|
"@eslint/js": "^9.39.2",
|
||||||
"@playwright/test": "^1.49.1",
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
"@sveltejs/adapter-static": "^3.0.1",
|
"@iconify-json/tabler": "^1.2.23",
|
||||||
"@sveltejs/kit": "^2.5.27",
|
"@playwright/test": "^1.56.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@types/eslint": "^8.56.0",
|
"@sveltejs/kit": "^2.46.4",
|
||||||
"@types/three": "^0.162.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@types/eslint": "^9.6.1",
|
||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@types/three": "^0.180.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "^8.56.0",
|
"@typescript-eslint/eslint-plugin": "^8.46.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"@typescript-eslint/parser": "^8.46.0",
|
||||||
"eslint-plugin-svelte": "^2.45.1",
|
"autoprefixer": "^10.4.21",
|
||||||
"jsdom": "^24.0.0",
|
"eslint": "^9.37.0",
|
||||||
"prettier": "^3.1.1",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"eslint-plugin-svelte": "^3.12.4",
|
||||||
"svelte": "^5.0.0",
|
"globals": "^17.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"svelte-focus-trap": "^1.2.0",
|
"prettier": "^3.6.2",
|
||||||
"tailwindcss": "^4.0.12",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"tslib": "^2.6.1",
|
"svelte": "^5.39.11",
|
||||||
"typescript": "^5.5.0",
|
"svelte-check": "^4.3.3",
|
||||||
"unplugin-icons": "^0.18.5",
|
"svelte-focus-trap": "^1.2.0",
|
||||||
"vite": "^6.2.1",
|
"tailwindcss": "^4.1.14",
|
||||||
"vitest": "^1.2.0"
|
"ts-proto-descriptors": "^2.1.0",
|
||||||
},
|
"tslib": "^2.8.1",
|
||||||
"type": "module",
|
"typescript": "^5.9.3",
|
||||||
"dependencies": {
|
"typescript-eslint": "^8.51.0",
|
||||||
"@msgpack/msgpack": "^3.1.2",
|
"unplugin-icons": "^22.4.2",
|
||||||
"@niku/vite-env-caster": "^1.0.2",
|
"vite": "^7.1.9",
|
||||||
"@sveltejs/adapter-auto": "^4.0.0",
|
"vitest": "^3.2.4",
|
||||||
"@tailwindcss/vite": "^4.0.12",
|
"ws": "^8.18.3"
|
||||||
"chart.js": "^4.4.2",
|
},
|
||||||
"compare-versions": "^6.1.0",
|
"type": "module",
|
||||||
"cross-env": "^7.0.3",
|
"dependencies": {
|
||||||
"daisyui": "^5.0.0",
|
"@bufbuild/protobuf": "^2.10.2",
|
||||||
"nipplejs": "^0.10.1",
|
"@niku/vite-env-caster": "^1.1.2",
|
||||||
"svelte-dnd-list": "^0.1.8",
|
"@sveltejs/adapter-auto": "^6.1.1",
|
||||||
"svelte-modals": "^2.0.0",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"three": "^0.162.0",
|
"chart.js": "^4.5.0",
|
||||||
"urdf-loader": "^0.12.1",
|
"compare-versions": "^6.1.1",
|
||||||
"uzip": "^0.20201231.0",
|
"cross-env": "^10.1.0",
|
||||||
"xacro-parser": "^0.3.9"
|
"daisyui": "^5.2.0",
|
||||||
},
|
"nipplejs": "^0.10.2",
|
||||||
"packageManager": "pnpm@9.3.0"
|
"svelte-dnd-list": "^0.1.8",
|
||||||
|
"svelte-modals": "^2.0.1",
|
||||||
|
"three": "^0.180.0",
|
||||||
|
"ts-proto": "^2.10.1",
|
||||||
|
"urdf-loader": "^0.12.6",
|
||||||
|
"uzip": "^0.20201231.0",
|
||||||
|
"xacro-parser": "^0.3.10"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
import type { PlaywrightTestConfig } from '@playwright/test'
|
||||||
|
|
||||||
const config: PlaywrightTestConfig = {
|
const config: PlaywrightTestConfig = {
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'pnpm run build && pnpm run preview',
|
command: 'pnpm run build && pnpm run preview',
|
||||||
port: 4173
|
port: 4173
|
||||||
},
|
},
|
||||||
testDir: 'tests/integration',
|
testDir: 'tests/integration',
|
||||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/
|
testMatch: /(.+\.)?(test|spec)\.[jt]s/
|
||||||
};
|
}
|
||||||
|
|
||||||
export default config;
|
export default config
|
||||||
|
|||||||
Generated
+2140
-2166
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
#!/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)
|
||||||
|
}
|
||||||
@@ -23,6 +23,14 @@
|
|||||||
--base-content: oklch(0.3 0.012 256);
|
--base-content: oklch(0.3 0.012 256);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
#nipple_0_0,
|
#nipple_0_0,
|
||||||
#nipple_1_1 {
|
#nipple_1_1 {
|
||||||
z-index: 10 !important;
|
z-index: 10 !important;
|
||||||
|
|||||||
Vendored
+8
-8
@@ -1,13 +1,13 @@
|
|||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {}
|
||||||
|
|||||||
+14
-11
@@ -1,14 +1,17 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/logo512.png" />
|
<link rel="icon" href="%sveltekit.assets%/logo512.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" />
|
<meta
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
name="viewport"
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
|
||||||
%sveltekit.head%
|
/>
|
||||||
</head>
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<body data-sveltekit-preload-data="hover">
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
%sveltekit.head%
|
||||||
</body>
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
|
|
||||||
describe('sum test', () => {
|
|
||||||
it('adds 1 + 2 to equal 3', () => {
|
|
||||||
expect(1 + 2).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+48
-35
@@ -1,22 +1,29 @@
|
|||||||
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 { location } from './stores';
|
import { apiLocation } from './stores/location-store'
|
||||||
|
import type { MessageFns } from './platform_shared/filesystem'
|
||||||
|
import { Request, Response as ProtoResponse } from './platform_shared/api'
|
||||||
|
import { BinaryWriter } from '@bufbuild/protobuf/wire'
|
||||||
|
|
||||||
export namespace api {
|
export const api = {
|
||||||
export function get<TResponse>(endpoint: string, params?: RequestInit) {
|
get<TResponse>(endpoint: string, params?: RequestInit) {
|
||||||
return sendRequest<TResponse>(endpoint, 'GET', null, params);
|
return sendRequest<TResponse>(endpoint, 'GET', null, params)
|
||||||
}
|
},
|
||||||
|
|
||||||
export function post<TResponse>(endpoint: string, data?: unknown) {
|
post<TResponse>(endpoint: string, data?: unknown) {
|
||||||
return sendRequest<TResponse>(endpoint, 'POST', data);
|
return sendRequest<TResponse>(endpoint, 'POST', data)
|
||||||
}
|
},
|
||||||
|
|
||||||
export function put<TResponse>(endpoint: string, data?: unknown) {
|
post_proto<TResponse>(endpoint: string, data: Request) {
|
||||||
return sendRequest<TResponse>(endpoint, 'PUT', data);
|
return sendRequest<TResponse>(endpoint, 'POST', Request.encode(data))
|
||||||
}
|
},
|
||||||
|
|
||||||
export function remove<TResponse>(endpoint: string) {
|
put<TResponse>(endpoint: string, data?: unknown) {
|
||||||
return sendRequest<TResponse>(endpoint, 'DELETE');
|
return sendRequest<TResponse>(endpoint, 'PUT', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
remove<TResponse>(endpoint: string) {
|
||||||
|
return sendRequest<TResponse>(endpoint, 'DELETE')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,8 +33,12 @@ async function sendRequest<TResponse>(
|
|||||||
data?: unknown,
|
data?: unknown,
|
||||||
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,
|
||||||
@@ -36,45 +47,47 @@ async function sendRequest<TResponse>(
|
|||||||
headers: {
|
headers: {
|
||||||
...params?.headers,
|
...params?.headers,
|
||||||
Authorization: 'Basic',
|
Authorization: 'Basic',
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': isProtobuf ? 'application/x-protobuf' : 'application/json'
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
let response;
|
let response
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(endpoint, request);
|
response = await fetch(endpoint, request)
|
||||||
} catch (error) {
|
} catch {
|
||||||
return Err.new(new Error(), 'An error has occurred');
|
return Err.new(new Error(), 'An error has occurred')
|
||||||
}
|
}
|
||||||
|
|
||||||
const isResponseOk = response.status >= 200 && response.status < 400;
|
const isResponseOk = response.status >= 200 && response.status < 400
|
||||||
if (!isResponseOk) {
|
if (!isResponseOk) {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
return Err.new(new ApiError(response), 'User was not authorized');
|
return Err.new(new ApiError(response), 'User was not authorized')
|
||||||
}
|
}
|
||||||
return Err.new(new ApiError(response), 'An error has occurred');
|
return Err.new(new ApiError(response), 'An error has occurred')
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType =
|
const contentType = response.headers.get('Content-Type') ?? response.headers.get('Content-Type')
|
||||||
response.headers.get('Content-Type') ?? response.headers.get('Content-Type');
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveUrl(url: string): string {
|
function resolveUrl(url: string): string {
|
||||||
if (url.startsWith('http') || !get(location)) return url;
|
if (url.startsWith('http') || !get(apiLocation)) return url
|
||||||
const protocol = window.location.protocol;
|
const protocol = window.location.protocol
|
||||||
return `${protocol}//${get(location)}${url.startsWith('/') ? '' : '/'}${url}`;
|
return `${protocol}//${get(apiLocation)}${url.startsWith('/') ? '' : '/'}${url}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(public readonly response: Response) {
|
constructor(public readonly response: Response) {
|
||||||
super(`${response.status}`);
|
super(`${response.status}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition'
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing'
|
||||||
import { Down } from './icons';
|
import { Down } from './icons'
|
||||||
|
|
||||||
function openCollapsible() {
|
function openCollapsible() {
|
||||||
open = !open;
|
open = !open
|
||||||
if (open) {
|
if (open) {
|
||||||
opened();
|
opened()
|
||||||
} else {
|
} else {
|
||||||
closed();
|
closed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let { icon, title, children, open, opened, closed, class: klass } = $props();
|
let { icon, title, children, open, opened, closed, class: klass } = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="{klass} relative grid w-full max-w-2xl self-center overflow-hidden">
|
<div class="{klass} relative grid w-full max-w-2xl self-center overflow-hidden">
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<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>
|
||||||
@@ -1,43 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { focusTrap } from 'svelte-focus-trap'
|
import { focusTrap } from 'svelte-focus-trap'
|
||||||
import { fly } from 'svelte/transition'
|
import { fly } from 'svelte/transition'
|
||||||
import { Cancel, Check } from '$lib/components/icons'
|
import { Cancel, Check } from '$lib/components/icons'
|
||||||
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
|
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
isOpen,
|
isOpen,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
labels = {
|
labels = {
|
||||||
cancel: { label: 'Cancel', icon: Cancel },
|
cancel: { label: 'Cancel', icon: Cancel },
|
||||||
confirm: { label: 'OK', icon: Check }
|
confirm: { label: 'OK', icon: Check }
|
||||||
}
|
}
|
||||||
}: ModalProps = $props()
|
}: ModalProps = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
{@const SvelteComponent = labels?.confirm.icon}
|
{@const SvelteComponent = labels?.confirm.icon}
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
|
||||||
transition:fly={{ y: 50 }}
|
|
||||||
use:exitBeforeEnter
|
|
||||||
use:focusTrap>
|
|
||||||
<div
|
<div
|
||||||
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg">
|
role="dialog"
|
||||||
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
|
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||||
<div class="divider my-2"></div>
|
transition:fly={{ y: 50 }}
|
||||||
<p class="text-base-content mb-1 text-start">{message}</p>
|
use:exitBeforeEnter
|
||||||
<div class="divider my-2"></div>
|
use:focusTrap
|
||||||
<div class="flex justify-end gap-2">
|
>
|
||||||
<button class="btn btn-error inline-flex items-center" onclick={() => modals.close()}>
|
<div
|
||||||
<labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span>
|
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
|
||||||
</button>
|
>
|
||||||
<button class="btn btn-primary inline-flex items-center" onclick={onConfirm}>
|
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
|
||||||
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span>
|
<div class="divider my-2"></div>
|
||||||
</button>
|
<p class="text-base-content mb-1 text-start">{message}</p>
|
||||||
</div>
|
<div class="divider my-2"></div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-error inline-flex items-center"
|
||||||
|
onclick={() => modals.close()}
|
||||||
|
>
|
||||||
|
<labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary inline-flex items-center" onclick={onConfirm}>
|
||||||
|
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,61 +1,61 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { focusTrap } from 'svelte-focus-trap';
|
import { focusTrap } from 'svelte-focus-trap'
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition'
|
||||||
import { telemetry } from '$lib/stores/telemetry';
|
import { telemetry } from '$lib/stores/telemetry'
|
||||||
import { Cancel } from './icons';
|
import { Cancel } from './icons'
|
||||||
import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals';
|
import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals'
|
||||||
|
|
||||||
// provided by <Modals />
|
// provided by <Modals />
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let { isOpen }: Props = $props();
|
let { isOpen }: Props = $props()
|
||||||
|
|
||||||
let updating = $state(true);
|
let updating = $state(true)
|
||||||
|
|
||||||
let progress = $state(0);
|
let progress = $state(0)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($telemetry.download_ota.status == 'progress') {
|
if ($telemetry.download_ota.status == 'progress') {
|
||||||
progress = $telemetry.download_ota.progress;
|
progress = $telemetry.download_ota.progress
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($telemetry.download_ota.status == 'error') {
|
if ($telemetry.download_ota.status == 'error') {
|
||||||
updating = false;
|
updating = false
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
let message = $state('Preparing ...');
|
let message = $state('Preparing ...')
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($telemetry.download_ota.status == 'progress') {
|
if ($telemetry.download_ota.status == 'progress') {
|
||||||
message = 'Downloading ...';
|
message = 'Downloading ...'
|
||||||
} else if ($telemetry.download_ota.status == 'error') {
|
} else if ($telemetry.download_ota.status == 'error') {
|
||||||
message = $telemetry.download_ota.error;
|
message = $telemetry.download_ota.error
|
||||||
} else if ($telemetry.download_ota.status == 'finished') {
|
} else if ($telemetry.download_ota.status == 'finished') {
|
||||||
message = 'Restarting ...';
|
message = 'Restarting ...'
|
||||||
progress = 0;
|
progress = 0
|
||||||
// Reload page after 5 sec
|
// Reload page after 5 sec
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
modals.closeAll();
|
modals.closeAll()
|
||||||
location.reload();
|
location.reload()
|
||||||
}, 5000);
|
}, 5000)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
onBeforeClose(() => {
|
onBeforeClose(() => {
|
||||||
if (updating) {
|
if (updating) {
|
||||||
// prevents modal from closing
|
// prevents modal from closing
|
||||||
return false;
|
return false
|
||||||
} else {
|
} else {
|
||||||
$telemetry.download_ota.status = 'idle';
|
$telemetry.download_ota.status = 'idle'
|
||||||
$telemetry.download_ota.error = '';
|
$telemetry.download_ota.error = ''
|
||||||
$telemetry.download_ota.progress = 0;
|
$telemetry.download_ota.progress = 0
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
@@ -89,8 +89,8 @@
|
|||||||
class="btn btn-warning text-warning-content inline-flex flex-none items-center"
|
class="btn btn-warning text-warning-content inline-flex flex-none items-center"
|
||||||
disabled={updating}
|
disabled={updating}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
modals.closeAll();
|
modals.closeAll()
|
||||||
location.reload();
|
location.reload()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Cancel class="mr-2 h-5 w-5" /><span>Close</span></button
|
<Cancel class="mr-2 h-5 w-5" /><span>Close</span></button
|
||||||
|
|||||||
@@ -1,40 +1,43 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { focusTrap } from 'svelte-focus-trap';
|
import { focusTrap } from 'svelte-focus-trap'
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition'
|
||||||
import { Check } from './icons';
|
import { Check } from './icons'
|
||||||
import { exitBeforeEnter, type ModalProps } from 'svelte-modals';
|
import { exitBeforeEnter, type ModalProps } from 'svelte-modals'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
isOpen,
|
isOpen,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
labels = {
|
labels = {
|
||||||
dismiss: { label: 'Dismiss', icon: Check },
|
dismiss: { label: 'Dismiss', icon: Check }
|
||||||
},
|
}
|
||||||
}: ModalProps = $props();
|
}: ModalProps = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
|
||||||
transition:fly={{ y: 50 }}
|
|
||||||
use:exitBeforeEnter
|
|
||||||
use:focusTrap>
|
|
||||||
<div
|
<div
|
||||||
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg">
|
role="dialog"
|
||||||
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
|
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||||
<div class="divider my-2"></div>
|
transition:fly={{ y: 50 }}
|
||||||
<p class="text-base-content mb-1 text-start">{message}</p>
|
use:exitBeforeEnter
|
||||||
<div class="divider my-2"></div>
|
use:focusTrap
|
||||||
<div class="flex justify-end gap-2">
|
>
|
||||||
<button
|
<div
|
||||||
class="btn btn-warning text-warning-content inline-flex items-center"
|
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
|
||||||
onclick={onDismiss}>
|
>
|
||||||
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span>
|
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
|
||||||
</button>
|
<div class="divider my-2"></div>
|
||||||
</div>
|
<p class="text-base-content mb-1 text-start">{message}</p>
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-warning text-warning-content inline-flex items-center"
|
||||||
|
onclick={onDismiss}
|
||||||
|
>
|
||||||
|
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,78 +1,78 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three'
|
||||||
import { imu } from '$lib/stores/imu';
|
import { imu } from '$lib/stores/imu'
|
||||||
import SceneBuilder from '$lib/sceneBuilder';
|
import SceneBuilder from '$lib/sceneBuilder'
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement
|
||||||
let sceneBuilder: SceneBuilder;
|
let sceneBuilder: SceneBuilder
|
||||||
let cube: THREE.Mesh;
|
let cube: THREE.Mesh
|
||||||
let targetRotation = new THREE.Euler();
|
let targetRotation = new THREE.Euler()
|
||||||
let lastUpdateTime = 0;
|
let lastUpdateTime = 0
|
||||||
const LERP_SPEED = 5; // rotations per second
|
const LERP_SPEED = 5 // rotations per second
|
||||||
|
|
||||||
const initThreeJS = () => {
|
const initThreeJS = () => {
|
||||||
sceneBuilder = new SceneBuilder()
|
sceneBuilder = new SceneBuilder()
|
||||||
.addRenderer({ canvas: canvas, antialias: true, alpha: true })
|
.addRenderer({ canvas: canvas, antialias: true, alpha: true })
|
||||||
.addPerspectiveCamera({ x: 2, y: 0, z: 2 })
|
.addPerspectiveCamera({ x: 2, y: 0, z: 2 })
|
||||||
.addOrbitControls(1, 10, false)
|
.addOrbitControls(1, 10, false)
|
||||||
.addAmbientLight({ color: 0x404040, intensity: 0.5 })
|
.addAmbientLight({ color: 0x404040, intensity: 0.5 })
|
||||||
.addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 })
|
.addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 })
|
||||||
.fillParent();
|
.fillParent()
|
||||||
|
|
||||||
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||||
const material = new THREE.MeshPhongMaterial({
|
const material = new THREE.MeshPhongMaterial({
|
||||||
color: 0x00ff00,
|
color: 0x00ff00,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.8,
|
opacity: 0.8
|
||||||
});
|
})
|
||||||
cube = new THREE.Mesh(geometry, material);
|
cube = new THREE.Mesh(geometry, material)
|
||||||
sceneBuilder.scene.add(cube);
|
sceneBuilder.scene.add(cube)
|
||||||
|
|
||||||
sceneBuilder.addRenderCb(() => {
|
sceneBuilder.addRenderCb(() => {
|
||||||
if (!cube) return;
|
if (!cube) return
|
||||||
const currentTime = performance.now();
|
const currentTime = performance.now()
|
||||||
const deltaTime = (currentTime - lastUpdateTime) / 1000; // convert to seconds
|
const deltaTime = (currentTime - lastUpdateTime) / 1000 // convert to seconds
|
||||||
lastUpdateTime = currentTime;
|
lastUpdateTime = currentTime
|
||||||
|
|
||||||
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime);
|
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime)
|
||||||
cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor);
|
cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor)
|
||||||
cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor);
|
cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor)
|
||||||
cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor);
|
cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor)
|
||||||
});
|
})
|
||||||
|
|
||||||
sceneBuilder.startRenderLoop();
|
sceneBuilder.startRenderLoop()
|
||||||
};
|
|
||||||
|
|
||||||
const updateOrientation = () => {
|
|
||||||
if (!cube) return;
|
|
||||||
|
|
||||||
const y = -$imu.x[$imu.x.length - 1] || 0;
|
|
||||||
const x = $imu.y[$imu.y.length - 1] || 0;
|
|
||||||
const z = -$imu.z[$imu.z.length - 1] || 0;
|
|
||||||
|
|
||||||
targetRotation.set(
|
|
||||||
THREE.MathUtils.degToRad(x),
|
|
||||||
THREE.MathUtils.degToRad(y),
|
|
||||||
THREE.MathUtils.degToRad(z)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
initThreeJS();
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
sceneBuilder?.renderer?.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($imu) {
|
|
||||||
updateOrientation();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
const updateOrientation = () => {
|
||||||
|
if (!cube) return
|
||||||
|
|
||||||
|
const y = -$imu.x[$imu.x.length - 1] || 0
|
||||||
|
const x = $imu.y[$imu.y.length - 1] || 0
|
||||||
|
const z = -$imu.z[$imu.z.length - 1] || 0
|
||||||
|
|
||||||
|
targetRotation.set(
|
||||||
|
THREE.MathUtils.degToRad(x),
|
||||||
|
THREE.MathUtils.degToRad(y),
|
||||||
|
THREE.MathUtils.degToRad(z)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initThreeJS()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
sceneBuilder?.renderer?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($imu) {
|
||||||
|
updateOrientation()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-60 w-60 border-2 border-base-300 rounded-md">
|
<div class="h-60 w-60 border-2 border-base-300 rounded-md">
|
||||||
<canvas class="w-full h-full" bind:this={canvas}></canvas>
|
<canvas class="w-full h-full" bind:this={canvas}></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,60 +1,76 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { slide } from 'svelte/transition'
|
import { slide } from 'svelte/transition'
|
||||||
import { cubicOut } from 'svelte/easing'
|
import { cubicOut } from 'svelte/easing'
|
||||||
import { Down } from './icons'
|
import { Down } from './icons'
|
||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean
|
open?: boolean
|
||||||
collapsible?: boolean
|
collapsible?: boolean
|
||||||
icon?: import('svelte').Snippet
|
icon?: import('svelte').Snippet
|
||||||
title?: import('svelte').Snippet
|
title?: import('svelte').Snippet
|
||||||
children?: import('svelte').Snippet
|
children?: import('svelte').Snippet
|
||||||
right?: import('svelte').Snippet
|
right?: import('svelte').Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open = $bindable(true), collapsible = true, icon, title, children, right }: Props = $props()
|
let {
|
||||||
|
open = $bindable(true),
|
||||||
|
collapsible = true,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
right
|
||||||
|
}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if collapsible}
|
{#if collapsible}
|
||||||
<div
|
|
||||||
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg">
|
|
||||||
<div
|
<div
|
||||||
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium">
|
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
|
||||||
<span class="inline-flex items-baseline">
|
>
|
||||||
{@render icon?.()}
|
<div
|
||||||
{@render title?.()}
|
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
|
||||||
</span>
|
>
|
||||||
<button
|
<span class="inline-flex items-baseline">
|
||||||
class="btn btn-circle btn-ghost btn-sm"
|
{@render icon?.()}
|
||||||
onclick={() => {
|
{@render title?.()}
|
||||||
open = !open
|
</span>
|
||||||
}}>
|
<button
|
||||||
<Down
|
class="btn btn-circle btn-ghost btn-sm"
|
||||||
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open ?
|
onclick={() => {
|
||||||
'rotate-180'
|
open = !open
|
||||||
: ''}" />
|
}}
|
||||||
</button>
|
>
|
||||||
|
<Down
|
||||||
|
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {(
|
||||||
|
open
|
||||||
|
) ?
|
||||||
|
'rotate-180'
|
||||||
|
: ''}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-4 pt-0"
|
||||||
|
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if open}
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 p-4 pt-0"
|
|
||||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
|
||||||
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg">
|
|
||||||
<div
|
<div
|
||||||
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium">
|
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
|
||||||
<span class="inline-flex items-baseline">
|
>
|
||||||
{@render icon?.()}
|
<div
|
||||||
{@render title?.()}
|
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
|
||||||
</span>
|
>
|
||||||
{@render right?.()}
|
<span class="inline-flex items-baseline">
|
||||||
|
{@render icon?.()}
|
||||||
|
{@render title?.()}
|
||||||
|
</span>
|
||||||
|
{@render right?.()}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 p-4 pt-0">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 p-4 pt-0">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Loader } from "./icons";
|
import { Loader } from './icons'
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full w-full flex-col items-center justify-center p-6">
|
<div class="flex h-full w-full flex-col items-center justify-center p-6">
|
||||||
<Loader class="text-primary h-14 w-auto animate-spin stroke-2" />
|
<Loader class="text-primary h-14 w-auto animate-spin stroke-2" />
|
||||||
<p class="text-xl">Loading...</p>
|
<p class="text-xl">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,45 +1,47 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
|
import type { Component } from 'svelte'
|
||||||
|
|
||||||
const {
|
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description = '',
|
|
||||||
variant = 'primary',
|
|
||||||
class: klass = '',
|
|
||||||
children = null
|
|
||||||
} = $props<{
|
|
||||||
icon?: any
|
|
||||||
title: string
|
|
||||||
description?: string | number
|
|
||||||
variant?: Variant
|
|
||||||
class?: string
|
|
||||||
children?: () => any
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const Icon = $derived(icon)
|
const {
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description = '',
|
||||||
|
variant = 'primary',
|
||||||
|
class: klass = '',
|
||||||
|
children = null
|
||||||
|
} = $props<{
|
||||||
|
icon?: Component
|
||||||
|
title: string
|
||||||
|
description?: string | number
|
||||||
|
variant?: Variant
|
||||||
|
class?: string
|
||||||
|
children?: () => Component
|
||||||
|
}>()
|
||||||
|
|
||||||
const variants: Record<Variant, [string, string]> = {
|
const Icon = $derived(icon)
|
||||||
success: ['bg-success', 'text-success-content'],
|
|
||||||
error: ['bg-error', 'text-error-content'],
|
|
||||||
primary: ['bg-primary', 'text-primary-content'],
|
|
||||||
info: ['bg-info', 'text-info-content'],
|
|
||||||
warning: ['bg-warning', 'text-warning-content']
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantKey: Variant = (variant as Variant) in variants ? (variant as Variant) : 'primary'
|
const variants: Record<Variant, [string, string]> = {
|
||||||
const [bgColor, textColor] = variants[variantKey]
|
success: ['bg-success', 'text-success-content'],
|
||||||
|
error: ['bg-error', 'text-error-content'],
|
||||||
|
primary: ['bg-primary', 'text-primary-content'],
|
||||||
|
info: ['bg-info', 'text-info-content'],
|
||||||
|
warning: ['bg-warning', 'text-warning-content']
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantKey: Variant = (variant as Variant) in variants ? (variant as Variant) : 'primary'
|
||||||
|
const [bgColor, textColor] = variants[variantKey]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2 {klass}">
|
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2 {klass}">
|
||||||
{#if icon}
|
{#if icon}
|
||||||
<div class="mask mask-hexagon {bgColor} h-auto w-10 flex-none">
|
<div class="mask mask-hexagon {bgColor} h-auto w-10 flex-none">
|
||||||
<Icon class="{textColor} h-auto w-full scale-75" />
|
<Icon class="{textColor} h-auto w-full scale-75" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="grow">
|
||||||
|
<div class="font-bold">{title}</div>
|
||||||
|
<div class="text-sm opacity-75 grow">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{@render children?.()}
|
||||||
<div class="grow">
|
|
||||||
<div class="font-bold">{title}</div>
|
|
||||||
<div class="text-sm opacity-75 grow">{description}</div>
|
|
||||||
</div>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte'
|
||||||
import { location } from '$lib/stores';
|
import { apiLocation } from '$lib/stores'
|
||||||
|
|
||||||
let source = $state(`${$location}/api/camera/stream`);
|
let source = $state(`${$apiLocation}/api/camera/stream`)
|
||||||
|
|
||||||
onDestroy(() => (source = '#'));
|
onDestroy(() => (source = '#'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
|
|||||||
@@ -1,35 +1,37 @@
|
|||||||
<script>
|
<script>
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate'
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition'
|
||||||
import { notifications } from '$lib/components/toasts/notifications';
|
import { notifications } from '$lib/components/toasts/notifications'
|
||||||
import { error, info, success, warning } from './icons';
|
import { error, info, success, warning } from './icons'
|
||||||
|
|
||||||
|
/** @type {{theme?: any, icon?: any}} */
|
||||||
/** @type {{theme?: any, icon?: any}} */
|
let {
|
||||||
let { theme = {
|
theme = {
|
||||||
error: 'alert-error',
|
error: 'alert-error',
|
||||||
success: 'alert-success',
|
success: 'alert-success',
|
||||||
warning: 'alert-warning',
|
warning: 'alert-warning',
|
||||||
info: 'alert-info'
|
info: 'alert-info'
|
||||||
}, icon = {
|
},
|
||||||
error: error,
|
icon = {
|
||||||
success: success,
|
error: error,
|
||||||
warning: warning,
|
success: success,
|
||||||
info: info
|
warning: warning,
|
||||||
} } = $props();
|
info: info
|
||||||
|
}
|
||||||
|
} = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="toast toast-end mr-4">
|
<div class="toast toast-end mr-4">
|
||||||
{#each $notifications as notification (notification.id)}
|
{#each $notifications as notification (notification.id)}
|
||||||
{@const SvelteComponent = icon[notification.type]}
|
{@const SvelteComponent = icon[notification.type]}
|
||||||
<div
|
<div
|
||||||
animate:flip={{ duration: 400 }}
|
animate:flip={{ duration: 400 }}
|
||||||
class="alert animate-none {theme[notification.type]}"
|
class="alert animate-none {theme[notification.type]}"
|
||||||
in:fly={{ y: 100, duration: 400 }}
|
in:fly={{ y: 100, duration: 400 }}
|
||||||
out:fly={{ x: 100, duration: 400 }}
|
out:fly={{ x: 100, duration: 400 }}
|
||||||
>
|
>
|
||||||
<SvelteComponent class="h-6 w-6 shrink-0" />
|
<SvelteComponent class="h-6 w-6 shrink-0" />
|
||||||
<span>{notification.message}</span>
|
<span>{notification.message}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,332 +1,372 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import {
|
import {
|
||||||
BufferGeometry,
|
Mesh,
|
||||||
Line,
|
MeshBasicMaterial,
|
||||||
LineBasicMaterial,
|
type Object3D,
|
||||||
Mesh,
|
SphereGeometry,
|
||||||
MeshBasicMaterial,
|
Vector3,
|
||||||
type Object3D,
|
type Object3DEventMap,
|
||||||
SphereGeometry,
|
Color
|
||||||
Vector3,
|
} from 'three'
|
||||||
type NormalBufferAttributes,
|
import {
|
||||||
type Object3DEventMap
|
mode,
|
||||||
} from 'three'
|
model,
|
||||||
import {
|
input,
|
||||||
ModesEnum,
|
servoAnglesOut,
|
||||||
kinematicData,
|
servoAngles,
|
||||||
mode,
|
mpu,
|
||||||
model,
|
jointNames,
|
||||||
outControllerData,
|
currentKinematic,
|
||||||
servoAnglesOut,
|
walkGait,
|
||||||
servoAngles,
|
kinematicData
|
||||||
mpu,
|
} from '$lib/stores'
|
||||||
jointNames,
|
import { populateModelCache, getToeWorldPositions } from '$lib/utilities'
|
||||||
currentKinematic,
|
import SceneBuilder from '$lib/sceneBuilder'
|
||||||
walkGait,
|
import { lerp, degToRad } from 'three/src/math/MathUtils'
|
||||||
walkGaits,
|
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
|
||||||
walkGaitToMode
|
import { type body_state_t } from '$lib/kinematic'
|
||||||
} from '$lib/stores'
|
import {
|
||||||
import {
|
BezierState,
|
||||||
extractFootColor,
|
CalibrationState,
|
||||||
populateModelCache,
|
GaitState,
|
||||||
throttler,
|
IdleState,
|
||||||
getToeWorldPositions
|
RestState,
|
||||||
} from '$lib/utilities'
|
StandState
|
||||||
import SceneBuilder from '$lib/sceneBuilder'
|
} from '$lib/gait'
|
||||||
import { lerp, degToRad } from 'three/src/math/MathUtils'
|
import { radToDeg } from 'three/src/math/MathUtils.js'
|
||||||
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
|
import type { URDFRobot } from 'urdf-loader'
|
||||||
import { type body_state_t } from '$lib/kinematic'
|
import { get } from 'svelte/store'
|
||||||
import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait'
|
import { AnglesData, KinematicData, ModesEnum } from '$lib/platform_shared/message'
|
||||||
import { radToDeg } from 'three/src/math/MathUtils.js'
|
|
||||||
import type { URDFRobot } from 'urdf-loader'
|
|
||||||
import { get } from 'svelte/store'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sky?: boolean
|
defaultColor?: string | null
|
||||||
orbit?: boolean
|
orbit?: boolean
|
||||||
panel?: boolean
|
panel?: boolean
|
||||||
debug?: boolean
|
debug?: boolean
|
||||||
ground?: boolean
|
ground?: boolean
|
||||||
}
|
|
||||||
|
|
||||||
let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props()
|
|
||||||
|
|
||||||
let sceneManager = $state(new SceneBuilder())
|
|
||||||
let canvas: HTMLCanvasElement
|
|
||||||
|
|
||||||
let currentModelAngles: number[] = new Array(12).fill(0)
|
|
||||||
let modelTargetAngles: number[] = new Array(12).fill(0)
|
|
||||||
let gui_panel: GUI
|
|
||||||
let Throttler = new throttler()
|
|
||||||
|
|
||||||
let feet_trace = new Array(4).fill([])
|
|
||||||
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
|
|
||||||
let target: Object3D<Object3DEventMap>
|
|
||||||
|
|
||||||
let target_position = { x: 0, z: 0, yaw: 0 }
|
|
||||||
|
|
||||||
let kinematic = get(currentKinematic)
|
|
||||||
|
|
||||||
let planners = {
|
|
||||||
[ModesEnum.Deactivated]: new IdleState(),
|
|
||||||
[ModesEnum.Idle]: new IdleState(),
|
|
||||||
[ModesEnum.Calibration]: new CalibrationState(),
|
|
||||||
[ModesEnum.Rest]: new RestState(),
|
|
||||||
[ModesEnum.Stand]: new StandState(),
|
|
||||||
[ModesEnum.Walk]: new BezierState()
|
|
||||||
}
|
|
||||||
let lastTick = performance.now()
|
|
||||||
|
|
||||||
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
|
|
||||||
|
|
||||||
let body_state = {
|
|
||||||
omega: 0,
|
|
||||||
phi: 0,
|
|
||||||
psi: 0,
|
|
||||||
xm: 0,
|
|
||||||
ym: 0.5,
|
|
||||||
zm: 0,
|
|
||||||
feet: kinematic.getDefaultFeetPos()
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = {
|
|
||||||
'Internal kinematic': true,
|
|
||||||
'Robot transform controls': false,
|
|
||||||
'Auto orient robot': true,
|
|
||||||
'Trace feet': debug,
|
|
||||||
'Target position': false,
|
|
||||||
'Trace points': 30,
|
|
||||||
'Fix camera on robot': true,
|
|
||||||
'Smooth motion': true,
|
|
||||||
omega: 0,
|
|
||||||
phi: 0,
|
|
||||||
psi: 0,
|
|
||||||
xm: 0,
|
|
||||||
ym: 0.7,
|
|
||||||
zm: 0,
|
|
||||||
Background: 'black'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await populateModelCache()
|
|
||||||
await createScene()
|
|
||||||
servoAngles.subscribe(updateAnglesFromStore)
|
|
||||||
walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait)))
|
|
||||||
if (panel) createPanel()
|
|
||||||
})
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
canvas.remove()
|
|
||||||
gui_panel?.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateAnglesFromStore = (angles: number[]) => {
|
|
||||||
if (sceneManager.isDragging) return
|
|
||||||
if (settings['Internal kinematic']) return
|
|
||||||
modelTargetAngles = angles
|
|
||||||
}
|
|
||||||
|
|
||||||
const createPanel = () => {
|
|
||||||
gui_panel = new GUI({ width: 310 })
|
|
||||||
gui_panel.close()
|
|
||||||
gui_panel.domElement.id = 'three-gui-panel'
|
|
||||||
|
|
||||||
const general = gui_panel.addFolder('General')
|
|
||||||
general.add(settings, 'Internal kinematic')
|
|
||||||
general.add(settings, 'Robot transform controls')
|
|
||||||
general.add(settings, 'Auto orient robot')
|
|
||||||
|
|
||||||
const kinematic = gui_panel.addFolder('Kinematics')
|
|
||||||
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen()
|
|
||||||
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
|
|
||||||
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
|
|
||||||
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
|
|
||||||
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
|
|
||||||
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
|
|
||||||
|
|
||||||
const visibility = gui_panel.addFolder('Visualization')
|
|
||||||
visibility.add(settings, 'Trace feet')
|
|
||||||
visibility.add(settings, 'Trace points', 1, 1000, 1)
|
|
||||||
visibility.add(settings, 'Target position')
|
|
||||||
visibility.add(settings, 'Smooth motion')
|
|
||||||
visibility.addColor(settings, 'Background')
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateKinematicPosition = () => {
|
|
||||||
kinematicData.set([
|
|
||||||
settings.omega,
|
|
||||||
settings.phi,
|
|
||||||
settings.psi,
|
|
||||||
settings.xm,
|
|
||||||
settings.ym,
|
|
||||||
settings.zm
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateAngles = (name: string, angle: number) => {
|
|
||||||
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
|
|
||||||
Throttler.throttle(() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))), 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
const createScene = async () => {
|
|
||||||
sceneManager
|
|
||||||
.addRenderer({ antialias: true, canvas, alpha: true })
|
|
||||||
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
|
|
||||||
.addOrbitControls(2, 20, orbit)
|
|
||||||
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
|
|
||||||
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
|
|
||||||
.addFogExp2(0xcccccc, 0.015)
|
|
||||||
.addModel($model)
|
|
||||||
.addTransformControls(sceneManager.model)
|
|
||||||
.fillParent()
|
|
||||||
.addRenderCb(render)
|
|
||||||
.startRenderLoop()
|
|
||||||
|
|
||||||
if (ground) sceneManager.addGroundPlane()
|
|
||||||
|
|
||||||
const geometry = new SphereGeometry(0.1, 32, 16)
|
|
||||||
const material = new MeshBasicMaterial({ color: 0xffff00 })
|
|
||||||
target = new Mesh(geometry, material)
|
|
||||||
sceneManager.scene.add(target)
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
sceneManager.addDragControl(updateAngles)
|
|
||||||
}
|
|
||||||
if (sky) sceneManager.addSky()
|
|
||||||
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
const geometry = new BufferGeometry()
|
|
||||||
const material = new LineBasicMaterial({ color: extractFootColor() })
|
|
||||||
const line = new Line(geometry, material)
|
|
||||||
trace_lines.push(geometry)
|
|
||||||
sceneManager.scene.add(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderTraceLines = (foot_positions: Vector3[]) => {
|
|
||||||
if (!settings['Trace feet']) {
|
|
||||||
if (!feet_trace.length) return
|
|
||||||
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
|
|
||||||
feet_trace = new Array(4).fill([])
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trace_lines.forEach((line, i) => {
|
let {
|
||||||
feet_trace[i].push(foot_positions[i])
|
defaultColor = '#0091ff',
|
||||||
feet_trace[i] = feet_trace[i].slice(-settings['Trace points'])
|
orbit = false,
|
||||||
line.setFromPoints(feet_trace[i])
|
panel = true,
|
||||||
|
debug = false,
|
||||||
|
ground = true
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
let sceneManager = $state(new SceneBuilder())
|
||||||
|
let canvas: HTMLCanvasElement
|
||||||
|
const NUM_ANGLES = 12 // TODO: This number should come from the robot
|
||||||
|
|
||||||
|
let currentModelAngles: AnglesData = AnglesData.create({
|
||||||
|
angles: new Array(NUM_ANGLES).fill(0)
|
||||||
})
|
})
|
||||||
}
|
let modelTargetAngles: AnglesData = AnglesData.create({ angles: new Array(NUM_ANGLES).fill(0) })
|
||||||
|
let gui_panel: GUI
|
||||||
|
const SMOOTH_AMOUNT = 0.2
|
||||||
|
|
||||||
const calculate_kinematics = () => {
|
let target: Object3D<Object3DEventMap>
|
||||||
if (sceneManager.isDragging || !settings['Internal kinematic']) return
|
|
||||||
const position: body_state_t = {
|
let target_position = { x: 0, z: 0, yaw: 0 }
|
||||||
omega: settings.omega,
|
|
||||||
phi: settings.phi,
|
let kinematic = get(currentKinematic)
|
||||||
psi: settings.psi,
|
|
||||||
xm: settings.xm,
|
const planners: Record<ModesEnum, GaitState> = {
|
||||||
ym: settings.ym,
|
[ModesEnum.DEACTIVATED]: new IdleState(),
|
||||||
zm: settings.zm,
|
[ModesEnum.IDLE]: new IdleState(),
|
||||||
feet: body_state.feet
|
[ModesEnum.CALIBRATION]: new CalibrationState(),
|
||||||
|
[ModesEnum.REST]: new RestState(),
|
||||||
|
[ModesEnum.STAND]: new StandState(),
|
||||||
|
[ModesEnum.WALK]: new BezierState(),
|
||||||
|
[ModesEnum.UNRECOGNIZED]: new IdleState()
|
||||||
|
}
|
||||||
|
let lastTick = performance.now()
|
||||||
|
let lastRobotPosition = new Vector3()
|
||||||
|
|
||||||
|
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
|
||||||
|
const THREEJS_SCALE = 10
|
||||||
|
|
||||||
|
let body_state = {
|
||||||
|
omega: 0,
|
||||||
|
phi: 0,
|
||||||
|
psi: 0,
|
||||||
|
xm: 0,
|
||||||
|
ym: 0.15,
|
||||||
|
zm: 0,
|
||||||
|
feet: kinematic.getDefaultFeetPos(),
|
||||||
|
cumulative_x: 0,
|
||||||
|
cumulative_y: 0,
|
||||||
|
cumulative_z: 0,
|
||||||
|
cumulative_roll: 0,
|
||||||
|
cumulative_pitch: 0,
|
||||||
|
cumulative_yaw: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
|
let settings = {
|
||||||
modelTargetAngles = new_angles
|
'Internal kinematic': true,
|
||||||
}
|
'Robot transform controls': false,
|
||||||
|
'Auto orient robot': true,
|
||||||
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
|
'Trace feet': debug,
|
||||||
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
|
'Target position': false,
|
||||||
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
|
'Trace points': 30,
|
||||||
|
'Smooth motion': true,
|
||||||
robot.position.z = smooth(robot.position.z, -settings.xm, 0.1)
|
omega: 0,
|
||||||
robot.position.x = smooth(robot.position.x, -settings.zm, 0.1)
|
phi: 0,
|
||||||
|
psi: 0,
|
||||||
robot.rotation.z = smooth(robot.rotation.z, degToRad(-settings.phi + $mpu.heading + 90), 0.1)
|
xm: 0,
|
||||||
robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1)
|
ym: 0.15,
|
||||||
robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1)
|
zm: 0,
|
||||||
}
|
Background: defaultColor
|
||||||
|
|
||||||
const update_camera = (robot: URDFRobot) => {
|
|
||||||
if (!settings['Fix camera on robot']) return
|
|
||||||
sceneManager.orbit.target = robot.position.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
const smooth = (start: number, end: number, amount: number) => {
|
|
||||||
return settings['Smooth motion'] ? lerp(start, end, amount) : end
|
|
||||||
}
|
|
||||||
|
|
||||||
const update_gait = () => {
|
|
||||||
if (sceneManager.isDragging || !settings['Internal kinematic']) return
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
body_state.ym = data.h
|
|
||||||
|
|
||||||
let planner = planners[get(mode)]
|
|
||||||
const delta = performance.now() - lastTick
|
|
||||||
lastTick = performance.now()
|
|
||||||
|
|
||||||
body_state = planner.step(body_state, data, delta)
|
|
||||||
|
|
||||||
settings.omega = body_state.omega
|
|
||||||
settings.phi = body_state.phi
|
|
||||||
settings.psi = body_state.psi
|
|
||||||
settings.xm = body_state.xm
|
|
||||||
settings.ym = body_state.ym
|
|
||||||
settings.zm = body_state.zm
|
|
||||||
}
|
|
||||||
|
|
||||||
const update_robot_position = (robot: URDFRobot) => {
|
|
||||||
if (!settings['Robot transform controls']) return
|
|
||||||
settings.omega = radToDeg(robot.rotation.y)
|
|
||||||
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90
|
|
||||||
settings.psi = radToDeg(robot.rotation.x) + 90
|
|
||||||
settings.xm = robot.position.z * 100
|
|
||||||
settings.zm = -robot.position.x * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateTargetPosition = () => {
|
|
||||||
target.visible = settings['Target position']
|
|
||||||
target.position.x = smooth(target.position.x, target_position.x, 0.5)
|
|
||||||
target.position.z = smooth(target.position.z, target_position.z, 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
const render = () => {
|
|
||||||
const robot = sceneManager.model
|
|
||||||
if (!robot) return
|
|
||||||
|
|
||||||
const toes = getToeWorldPositions(robot)
|
|
||||||
|
|
||||||
renderTraceLines(toes)
|
|
||||||
update_camera(robot)
|
|
||||||
update_gait()
|
|
||||||
calculate_kinematics()
|
|
||||||
update_robot_position(robot)
|
|
||||||
|
|
||||||
sceneManager.transformControl.showX = settings['Robot transform controls']
|
|
||||||
sceneManager.transformControl.showY = settings['Robot transform controls']
|
|
||||||
sceneManager.transformControl.showZ = settings['Robot transform controls']
|
|
||||||
|
|
||||||
for (let i = 0; i < $jointNames.length; i++) {
|
|
||||||
currentModelAngles[i] = smooth(
|
|
||||||
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
|
|
||||||
modelTargetAngles[i],
|
|
||||||
0.1
|
|
||||||
)
|
|
||||||
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
orient_robot(robot, toes)
|
onMount(async () => {
|
||||||
updateTargetPosition()
|
await populateModelCache()
|
||||||
}
|
await createScene()
|
||||||
|
servoAngles.subscribe(updateAnglesFromStore)
|
||||||
|
walkGait.subscribe(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()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
gui_panel?.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateAnglesFromStore = (angles: AnglesData) => {
|
||||||
|
if (sceneManager.isDragging) return
|
||||||
|
if (settings['Internal kinematic']) return
|
||||||
|
modelTargetAngles = angles
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPanel = () => {
|
||||||
|
gui_panel = new GUI({ width: 310 })
|
||||||
|
gui_panel.close()
|
||||||
|
gui_panel.domElement.id = 'three-gui-panel'
|
||||||
|
|
||||||
|
const general = gui_panel.addFolder('General')
|
||||||
|
general.add(settings, 'Internal kinematic')
|
||||||
|
general.add(settings, 'Robot transform controls')
|
||||||
|
general.add(settings, 'Auto orient robot')
|
||||||
|
|
||||||
|
const kinematic = gui_panel.addFolder('Kinematics')
|
||||||
|
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen()
|
||||||
|
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
|
||||||
|
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
|
||||||
|
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
|
||||||
|
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
|
||||||
|
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
|
||||||
|
|
||||||
|
const visibility = gui_panel.addFolder('Visualization')
|
||||||
|
visibility.add(settings, 'Trace feet')
|
||||||
|
visibility.add(settings, 'Trace points', 1, 1000, 1)
|
||||||
|
visibility.add(settings, 'Target position')
|
||||||
|
visibility.add(settings, 'Smooth motion')
|
||||||
|
visibility.addColor(settings, 'Background').onChange(setSceneBackground).listen()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateKinematicPosition = () => {
|
||||||
|
kinematicData.set(
|
||||||
|
KinematicData.create({
|
||||||
|
omega: settings.omega,
|
||||||
|
phi: settings.phi,
|
||||||
|
psi: settings.psi,
|
||||||
|
xm: settings.xm,
|
||||||
|
ym: settings.ym,
|
||||||
|
zm: settings.zm
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSceneBackground = (c: string | null) => (sceneManager.scene.background = new Color(c!))
|
||||||
|
|
||||||
|
const updateAngles = (name: string, angle: number) => {
|
||||||
|
modelTargetAngles.angles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
|
||||||
|
servoAnglesOut.set(
|
||||||
|
AnglesData.create({
|
||||||
|
angles: modelTargetAngles.angles.map(num => Math.round(num))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createScene = async () => {
|
||||||
|
sceneManager
|
||||||
|
.addRenderer({ antialias: true, canvas, alpha: true })
|
||||||
|
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
|
||||||
|
.addOrbitControls(2, 20, orbit)
|
||||||
|
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
|
||||||
|
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
|
||||||
|
.addFogExp2(0xcccccc, 0.015)
|
||||||
|
.addModel($model as URDFRobot)
|
||||||
|
.addTransformControls(sceneManager.model)
|
||||||
|
.fillParent()
|
||||||
|
.addRenderCb(render)
|
||||||
|
.startRenderLoop()
|
||||||
|
|
||||||
|
if (ground) sceneManager.addGroundPlane()
|
||||||
|
|
||||||
|
const geometry = new SphereGeometry(0.1, 32, 16)
|
||||||
|
const material = new MeshBasicMaterial({ color: 0xffff00 })
|
||||||
|
target = new Mesh(geometry, material)
|
||||||
|
sceneManager.scene.add(target)
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
sceneManager.addDragControl(angles => {
|
||||||
|
Object.entries(angles).forEach(([name, angle]) => {
|
||||||
|
updateAngles(name, angle)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (defaultColor) setSceneBackground(settings['Background'] || defaultColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculate_kinematics = () => {
|
||||||
|
if (sceneManager.isDragging || !settings['Internal kinematic']) return
|
||||||
|
const position: body_state_t = {
|
||||||
|
omega: settings.omega,
|
||||||
|
phi: settings.phi,
|
||||||
|
psi: settings.psi,
|
||||||
|
xm: settings.xm,
|
||||||
|
ym: settings.ym,
|
||||||
|
zm: settings.zm,
|
||||||
|
feet: body_state.feet,
|
||||||
|
cumulative_x: body_state.cumulative_x,
|
||||||
|
cumulative_y: body_state.cumulative_y,
|
||||||
|
cumulative_z: body_state.cumulative_z,
|
||||||
|
cumulative_roll: body_state.cumulative_roll,
|
||||||
|
cumulative_pitch: body_state.cumulative_pitch,
|
||||||
|
cumulative_yaw: body_state.cumulative_yaw
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
|
||||||
|
modelTargetAngles.angles = new_angles
|
||||||
|
}
|
||||||
|
|
||||||
|
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
|
||||||
|
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
|
||||||
|
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
|
||||||
|
|
||||||
|
const cumulativeYaw = body_state.cumulative_yaw
|
||||||
|
const headingYaw = degToRad(-settings.phi + $mpu.heading)
|
||||||
|
const totalYaw = headingYaw + cumulativeYaw
|
||||||
|
|
||||||
|
const cosTotal = Math.cos(totalYaw)
|
||||||
|
const sinTotal = Math.sin(totalYaw)
|
||||||
|
const rotatedXm = settings.xm * cosTotal - settings.zm * sinTotal
|
||||||
|
const rotatedZm = settings.xm * sinTotal + settings.zm * cosTotal
|
||||||
|
|
||||||
|
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,
|
||||||
|
(-rotatedZm - rotatedCumZ) * THREEJS_SCALE,
|
||||||
|
SMOOTH_AMOUNT
|
||||||
|
)
|
||||||
|
robot.position.z = smooth(
|
||||||
|
robot.position.z,
|
||||||
|
(-rotatedXm - rotatedCumX) * THREEJS_SCALE,
|
||||||
|
SMOOTH_AMOUNT
|
||||||
|
)
|
||||||
|
|
||||||
|
const cosYaw = Math.cos(totalYaw)
|
||||||
|
const sinYaw = Math.sin(totalYaw)
|
||||||
|
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,
|
||||||
|
degToRad(-settings.phi + $mpu.heading + 90) + cumulativeYaw,
|
||||||
|
SMOOTH_AMOUNT
|
||||||
|
)
|
||||||
|
robot.rotation.y = smooth(robot.rotation.y, roll, SMOOTH_AMOUNT)
|
||||||
|
robot.rotation.x = smooth(robot.rotation.x, pitch, SMOOTH_AMOUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
const update_camera = (robot: URDFRobot) => {
|
||||||
|
const delta = robot.position.clone().sub(lastRobotPosition)
|
||||||
|
sceneManager.orbit.target.add(delta)
|
||||||
|
sceneManager.camera.position.add(delta)
|
||||||
|
lastRobotPosition.copy(robot.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
const smooth = (start: number, end: number, amount: number) => {
|
||||||
|
return settings['Smooth motion'] ? lerp(start, end, amount) : end
|
||||||
|
}
|
||||||
|
|
||||||
|
const update_gait = () => {
|
||||||
|
if (sceneManager.isDragging || !settings['Internal kinematic']) return
|
||||||
|
const controlData = get(input)
|
||||||
|
|
||||||
|
let planner = planners[get(mode).mode]
|
||||||
|
const delta = performance.now() - lastTick
|
||||||
|
lastTick = performance.now()
|
||||||
|
|
||||||
|
body_state = planner.step(body_state, controlData, delta)
|
||||||
|
|
||||||
|
settings.omega = body_state.omega
|
||||||
|
settings.phi = body_state.phi
|
||||||
|
settings.psi = body_state.psi
|
||||||
|
settings.xm = body_state.xm
|
||||||
|
settings.ym = body_state.ym
|
||||||
|
settings.zm = body_state.zm
|
||||||
|
}
|
||||||
|
|
||||||
|
const update_robot_position = (robot: URDFRobot) => {
|
||||||
|
if (!settings['Robot transform controls']) return
|
||||||
|
settings.omega = radToDeg(robot.rotation.y)
|
||||||
|
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90
|
||||||
|
settings.psi = radToDeg(robot.rotation.x) + 90
|
||||||
|
settings.xm = robot.position.z / THREEJS_SCALE
|
||||||
|
settings.zm = -robot.position.x / THREEJS_SCALE
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTargetPosition = () => {
|
||||||
|
target.visible = settings['Target position']
|
||||||
|
target.position.x = smooth(target.position.x, target_position.x, SMOOTH_AMOUNT)
|
||||||
|
target.position.z = smooth(target.position.z, target_position.z, SMOOTH_AMOUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const robot = sceneManager.model
|
||||||
|
if (!robot) return
|
||||||
|
|
||||||
|
const toes = getToeWorldPositions(robot)
|
||||||
|
|
||||||
|
update_camera(robot)
|
||||||
|
update_gait()
|
||||||
|
calculate_kinematics()
|
||||||
|
update_robot_position(robot)
|
||||||
|
|
||||||
|
sceneManager.transformControl.showX = settings['Robot transform controls']
|
||||||
|
sceneManager.transformControl.showY = settings['Robot transform controls']
|
||||||
|
sceneManager.transformControl.showZ = settings['Robot transform controls']
|
||||||
|
|
||||||
|
for (let i = 0; i < $jointNames.length; i++) {
|
||||||
|
currentModelAngles.angles[i] = smooth(
|
||||||
|
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
|
||||||
|
modelTargetAngles.angles[i],
|
||||||
|
SMOOTH_AMOUNT
|
||||||
|
)
|
||||||
|
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles.angles[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
orient_robot(robot, toes)
|
||||||
|
updateTargetPosition()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onresize={sceneManager.fillParent} />
|
<svelte:window onresize={sceneManager.fillParent} />
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<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,6 +38,8 @@ 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'
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { MdiEyeOffOutline, MdiEyeOutline } from "../icons";
|
import { MdiEyeOffOutline, MdiEyeOutline } from '../icons'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show?: boolean;
|
show?: boolean
|
||||||
value?: string;
|
value?: string
|
||||||
id?: string;
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props();
|
let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props()
|
||||||
|
|
||||||
let type = $derived(show ? 'text' : 'password');
|
|
||||||
|
|
||||||
const handleInput = (e: any) => value = e.target.value
|
let type = $derived(show ? 'text' : 'password')
|
||||||
|
|
||||||
const togglePassword = () => show = !show
|
const handleInput = (e: Event) => (value = (e.target as HTMLInputElement).value)
|
||||||
|
|
||||||
|
const togglePassword = () => (show = !show)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
@@ -23,4 +23,4 @@
|
|||||||
<MdiEyeOffOutline class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}" />
|
<MdiEyeOffOutline class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}" />
|
||||||
<MdiEyeOutline class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}" />
|
<MdiEyeOutline class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}" />
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
step?: number
|
step?: number
|
||||||
value?: any
|
value?: number
|
||||||
oninput?: any
|
oninput?: (value: Event) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
min = 0,
|
min = 0,
|
||||||
max = 100,
|
max = 100,
|
||||||
step = 1,
|
step = 1,
|
||||||
value = $bindable((max - min) / 2),
|
value = $bindable((max - min) / 2),
|
||||||
...rest
|
...rest
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
style="writing-mode: vertical-lr; direction: rtl"
|
style="writing-mode: vertical-lr; direction: rtl"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
{min}
|
{min}
|
||||||
{max}
|
{max}
|
||||||
{step}
|
{step}
|
||||||
bind:value
|
bind:value
|
||||||
{...rest} />
|
{...rest}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
input[type='range']::-webkit-slider-runnable-track {
|
input[type='range']::-webkit-slider-runnable-track {
|
||||||
background: oklch(var(--p) / 1);
|
background: oklch(var(--p) / 1);
|
||||||
border-radius: var(--rounded-box, 1rem);
|
border-radius: var(--rounded-box, 1rem);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as PasswordInput } from './InputPassword.svelte';
|
export { default as PasswordInput } from './InputPassword.svelte'
|
||||||
export { default as VerticalSlider } from './VerticalSlider.svelte';
|
export { default as VerticalSlider } from './VerticalSlider.svelte'
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: import('svelte').Snippet;
|
children?: import('svelte').Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="box-border overflow-hidden flex-1">
|
<div class="box-border overflow-hidden flex-1">
|
||||||
|
|||||||
@@ -1,40 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import WidgetContainer from './WidgetContainer.svelte';
|
import WidgetContainer from './WidgetContainer.svelte'
|
||||||
import {
|
import {
|
||||||
WidgetComponents,
|
WidgetComponents,
|
||||||
type WidgetContainerConfig,
|
type WidgetContainerConfig,
|
||||||
isWidgetConfig,
|
isWidgetConfig
|
||||||
} from '$lib/stores/application';
|
} from '$lib/stores/application'
|
||||||
import Widget from './Widget.svelte';
|
import Widget from './Widget.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
container: WidgetContainerConfig;
|
container: WidgetContainerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
let { container }: Props = $props();
|
let { container }: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full h-full flex flex-col overflow-hidden">
|
<div class="w-full h-full flex flex-col overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="flex w-full h-full"
|
class="flex w-full h-full"
|
||||||
class:flex-row={container.layout === 'column'}
|
class:flex-row={container.layout === 'column'}
|
||||||
class:flex-col={container.layout === 'row'}
|
class:flex-col={container.layout === 'row'}
|
||||||
class:flex-wrap={container.layout === 'wrap'}>
|
class:flex-wrap={container.layout === 'wrap'}
|
||||||
{#each container.widgets as widget, index (widget.id + '-' + index)}
|
>
|
||||||
<Widget>
|
{#each container.widgets as widget, index (widget.id + '-' + index)}
|
||||||
{#if isWidgetConfig(widget)}
|
<Widget>
|
||||||
{@const SvelteComponent = WidgetComponents[widget.component]}
|
{#if isWidgetConfig(widget)}
|
||||||
<SvelteComponent {...widget.props} />
|
{@const SvelteComponent = WidgetComponents[widget.component]}
|
||||||
{:else if widget.widgets}
|
<SvelteComponent {...widget.props} />
|
||||||
<WidgetContainer container={widget} />
|
{:else if widget.widgets}
|
||||||
{/if}
|
<WidgetContainer container={widget} />
|
||||||
</Widget>
|
{/if}
|
||||||
{#if index !== container.widgets.length - 1}
|
</Widget>
|
||||||
<div
|
{#if index !== container.widgets.length - 1}
|
||||||
class="divider bg-base-300 m-0"
|
<div
|
||||||
class:divider-horizontal={container.layout === 'column'}>
|
class="divider bg-base-300 m-0"
|
||||||
</div>
|
class:divider-horizontal={container.layout === 'column'}
|
||||||
{/if}
|
></div>
|
||||||
{/each}
|
{/if}
|
||||||
</div>
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Github } from "../icons";
|
import { Github } from '../icons'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
github: any;
|
github: { href: string; active?: boolean }
|
||||||
}
|
}
|
||||||
|
|
||||||
let { github }: Props = $props();
|
let { github }: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if github.active}
|
{#if github.active}
|
||||||
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- external URL -->
|
||||||
|
<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>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<script>
|
<script>
|
||||||
import logo from '$lib/assets/logo512.png';
|
import logo from '$lib/assets/logo512.png'
|
||||||
|
import { resolve } from '$app/paths'
|
||||||
|
|
||||||
/** @type {{appName: any}} */
|
/** @type {{appName: any}} */
|
||||||
let { appName } = $props();
|
let { appName } = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/"
|
href={resolve('/')}
|
||||||
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
|
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<img src={logo} alt="Logo" class="h-12 w-12" />
|
<img src={logo} alt="Logo" class="h-12 w-12" />
|
||||||
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
|
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,194 +1,186 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { base } from '$app/paths'
|
import { resolve } 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'
|
||||||
import MenuList from '../menu/MenuList.svelte'
|
import MenuList from '../menu/MenuList.svelte'
|
||||||
import {
|
import {
|
||||||
Connection,
|
Connection,
|
||||||
Settings,
|
Settings,
|
||||||
MdiController,
|
MdiController,
|
||||||
Devices,
|
Devices,
|
||||||
Camera,
|
Camera,
|
||||||
Rotate3d,
|
Rotate3d,
|
||||||
MotorOutline,
|
MotorOutline,
|
||||||
Health,
|
Health,
|
||||||
Folder,
|
Folder,
|
||||||
Update,
|
Update,
|
||||||
WiFi,
|
WiFi,
|
||||||
Router,
|
Router,
|
||||||
AP,
|
AP,
|
||||||
Copyright,
|
Copyright,
|
||||||
Metrics,
|
Metrics,
|
||||||
DNS
|
DNS
|
||||||
} from '$lib/components/icons'
|
} from '$lib/components/icons'
|
||||||
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
|
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
|
||||||
|
|
||||||
const features = useFeatureFlags()
|
const features = useFeatureFlags()
|
||||||
|
|
||||||
const appName = page.data.app_name
|
const appName = page.data.app_name
|
||||||
|
|
||||||
const copyright = page.data.copyright
|
const copyright = page.data.copyright
|
||||||
|
|
||||||
const github = { href: 'https://github.com/' + page.data.github, active: true }
|
const github = { href: 'https://github.com/' + page.data.github, active: true }
|
||||||
|
|
||||||
type menuItem = {
|
import type { Component } from 'svelte'
|
||||||
title: string
|
|
||||||
icon: ConstructorOfATypedSvelteComponent
|
|
||||||
href?: string
|
|
||||||
feature: boolean
|
|
||||||
active?: boolean
|
|
||||||
submenu?: menuItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function withBase(path: string) {
|
type menuItem = {
|
||||||
return `${base}${path.startsWith('/') ? path : '/' + path}`
|
title: string
|
||||||
}
|
icon: Component
|
||||||
|
href?: string
|
||||||
|
feature: boolean
|
||||||
|
active?: boolean
|
||||||
|
submenu?: menuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
let menuItems = $state<menuItem[]>([])
|
function withBase(path: string) {
|
||||||
|
return `${resolve('/')}${path.startsWith('/') ? path.slice(1) : path}`
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
const { menuClicked } = $props()
|
||||||
menuItems = [
|
|
||||||
{
|
|
||||||
title: 'Connection',
|
|
||||||
icon: WiFi,
|
|
||||||
href: withBase('/connection'),
|
|
||||||
feature: !PUBLIC_VITE_USE_HOST_NAME
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Controller',
|
|
||||||
icon: MdiController,
|
|
||||||
href: withBase('/controller'),
|
|
||||||
feature: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Peripherals',
|
|
||||||
icon: Devices,
|
|
||||||
feature: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
title: 'I2C',
|
|
||||||
icon: Connection,
|
|
||||||
href: withBase('/peripherals/i2c'),
|
|
||||||
feature: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Camera',
|
|
||||||
icon: Camera,
|
|
||||||
href: withBase('/peripherals/camera'),
|
|
||||||
feature: $features.camera
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Servo',
|
|
||||||
icon: MotorOutline,
|
|
||||||
href: withBase('/peripherals/servo'),
|
|
||||||
feature: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'IMU',
|
|
||||||
icon: Rotate3d,
|
|
||||||
href: withBase('/peripherals/imu'),
|
|
||||||
feature: $features.imu || $features.mag || $features.bmp
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'WiFi',
|
|
||||||
icon: WiFi,
|
|
||||||
feature: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
title: 'WiFi Station',
|
|
||||||
icon: Router,
|
|
||||||
href: withBase('/wifi/sta'),
|
|
||||||
feature: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Access Point',
|
|
||||||
icon: AP,
|
|
||||||
href: withBase('/wifi/ap'),
|
|
||||||
feature: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'mDNS',
|
|
||||||
icon: DNS,
|
|
||||||
href: withBase('/wifi/mdns'),
|
|
||||||
feature: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'System',
|
|
||||||
icon: Settings,
|
|
||||||
feature: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
title: 'System Status',
|
|
||||||
icon: Health,
|
|
||||||
href: withBase('/system/status'),
|
|
||||||
feature: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'File System',
|
|
||||||
icon: Folder,
|
|
||||||
href: withBase('/system/filesystem'),
|
|
||||||
feature: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'System Metrics',
|
|
||||||
icon: Metrics,
|
|
||||||
href: withBase('/system/metrics'),
|
|
||||||
feature: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Firmware Update',
|
|
||||||
icon: Update,
|
|
||||||
href: withBase('/system/update'),
|
|
||||||
feature: $features.ota || $features.upload_firmware || $features.download_firmware
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
] as menuItem[]
|
|
||||||
})
|
|
||||||
|
|
||||||
const { menuClicked } = $props()
|
const activeTitle = $derived(page.data.title)
|
||||||
|
|
||||||
function setActiveMenuItem(targetTitle: string) {
|
const menuItems = $derived<menuItem[]>(
|
||||||
menuItems.forEach(item => {
|
[
|
||||||
item.active = item.title === targetTitle
|
{
|
||||||
item.submenu?.forEach(subItem => {
|
title: 'Connection',
|
||||||
subItem.active = subItem.title === targetTitle
|
icon: WiFi,
|
||||||
})
|
href: withBase('/connection'),
|
||||||
})
|
feature: !PUBLIC_VITE_USE_HOST_NAME
|
||||||
menuItems = menuItems
|
},
|
||||||
menuClicked()
|
{
|
||||||
}
|
title: 'Controller',
|
||||||
|
icon: MdiController,
|
||||||
|
href: withBase('/controller'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Peripherals',
|
||||||
|
icon: Devices,
|
||||||
|
feature: true,
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
title: 'I2C',
|
||||||
|
icon: Connection,
|
||||||
|
href: withBase('/peripherals/i2c'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Camera',
|
||||||
|
icon: Camera,
|
||||||
|
href: withBase('/peripherals/camera'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Servo',
|
||||||
|
icon: MotorOutline,
|
||||||
|
href: withBase('/peripherals/servo'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IMU',
|
||||||
|
icon: Rotate3d,
|
||||||
|
href: withBase('/peripherals/imu'),
|
||||||
|
feature: true
|
||||||
|
}
|
||||||
|
].map(sub => ({ ...sub, active: sub.title === activeTitle }))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'WiFi',
|
||||||
|
icon: WiFi,
|
||||||
|
feature: true,
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
title: 'WiFi Station',
|
||||||
|
icon: Router,
|
||||||
|
href: withBase('/wifi/sta'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Access Point',
|
||||||
|
icon: AP,
|
||||||
|
href: withBase('/wifi/ap'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'mDNS',
|
||||||
|
icon: DNS,
|
||||||
|
href: withBase('/wifi/mdns'),
|
||||||
|
feature: true
|
||||||
|
}
|
||||||
|
].map(sub => ({ ...sub, active: sub.title === activeTitle }))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System',
|
||||||
|
icon: Settings,
|
||||||
|
feature: true,
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
title: 'System Status',
|
||||||
|
icon: Health,
|
||||||
|
href: withBase('/system/status'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'File System',
|
||||||
|
icon: Folder,
|
||||||
|
href: withBase('/system/filesystem'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System Metrics',
|
||||||
|
icon: Metrics,
|
||||||
|
href: withBase('/system/metrics'),
|
||||||
|
feature: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Firmware Update',
|
||||||
|
icon: Update,
|
||||||
|
href: withBase('/system/update'),
|
||||||
|
feature: !!(
|
||||||
|
$features.ota ||
|
||||||
|
$features.upload_firmware ||
|
||||||
|
$features.download_firmware
|
||||||
|
)
|
||||||
|
}
|
||||||
|
].map(sub => ({ ...sub, active: sub.title === activeTitle }))
|
||||||
|
}
|
||||||
|
].map(item => ({ ...item, active: item.title === activeTitle }))
|
||||||
|
)
|
||||||
|
|
||||||
$effect(() => {
|
const updateMenu = () => {
|
||||||
setActiveMenuItem(page.data.title)
|
menuClicked()
|
||||||
})
|
}
|
||||||
|
|
||||||
const updateMenu = (event: any) => {
|
|
||||||
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">
|
||||||
<LogoButton {appName} />
|
<LogoButton {appName} />
|
||||||
|
|
||||||
<MenuList
|
<MenuList
|
||||||
{menuItems}
|
{menuItems}
|
||||||
select={updateMenu}
|
select={updateMenu}
|
||||||
class="grow flex-nowrap overflow-y-auto overflow-x-hidden"
|
class="grow flex-nowrap overflow-y-auto overflow-x-hidden"
|
||||||
level="0" />
|
level="0"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="divider my-0"></div>
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<GithubButton {github} />
|
<GithubButton {github} />
|
||||||
<div class="flex items-center justify-end text-sm gap-2">
|
<div class="flex items-center justify-end text-sm gap-2">
|
||||||
<Copyright class="h-4 w-4" />{copyright}
|
<Copyright class="h-4 w-4" />{copyright}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,48 +1,56 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MenuList from './MenuList.svelte'
|
import MenuList from './MenuList.svelte'
|
||||||
type MenuItem = {
|
import type { Component } from 'svelte'
|
||||||
title: string
|
|
||||||
icon: ConstructorOfATypedSvelteComponent
|
|
||||||
href?: string
|
|
||||||
feature: boolean
|
|
||||||
active?: boolean
|
|
||||||
submenu?: MenuItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
let { level, menuItems, select, class: klass } = $props()
|
type MenuItem = {
|
||||||
|
title: string
|
||||||
|
icon: Component
|
||||||
|
href?: string
|
||||||
|
feature: boolean
|
||||||
|
active?: boolean
|
||||||
|
submenu?: MenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
const selectMenuItem = (title: string) => {
|
let { level, menuItems, select, class: klass } = $props()
|
||||||
select(title)
|
|
||||||
}
|
const selectMenuItem = (title: string) => {
|
||||||
|
select(title)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ul class={klass + ' menu w-full'}>
|
<ul class={klass + ' menu w-full'}>
|
||||||
{#each menuItems as MenuItem[] as menuItem, i (menuItem.title)}
|
{#each menuItems as MenuItem[] as menuItem (menuItem.title)}
|
||||||
{#if menuItem.feature}
|
{#if menuItem.feature}
|
||||||
<li>
|
<li>
|
||||||
{#if menuItem.submenu}
|
{#if menuItem.submenu}
|
||||||
<details open={menuItem.submenu.some(subItem => subItem.active)}>
|
<details open={menuItem.submenu.some(subItem => subItem.active)}>
|
||||||
<summary class="font-bold">
|
<summary class="font-bold">
|
||||||
<menuItem.icon class="h-6 w-6" />
|
<menuItem.icon class="h-6 w-6" />
|
||||||
{menuItem.title}
|
{menuItem.title}
|
||||||
</summary>
|
</summary>
|
||||||
<div class="pl-4">
|
<div class="pl-4">
|
||||||
<MenuList menuItems={menuItem.submenu} level={level + 1} {select} class={klass} />
|
<MenuList
|
||||||
</div>
|
menuItems={menuItem.submenu}
|
||||||
</details>
|
level={level + 1}
|
||||||
{:else}
|
{select}
|
||||||
<a
|
class={klass}
|
||||||
href={menuItem.href}
|
/>
|
||||||
class="font-bold"
|
</div>
|
||||||
class:bg-base-100={menuItem.active}
|
</details>
|
||||||
class:text-lg={level === 0}
|
{:else}
|
||||||
class:text-md={level === 1}
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve --><a
|
||||||
onclick={() => selectMenuItem(menuItem.title)}>
|
href={menuItem.href}
|
||||||
<menuItem.icon class="h-6 w-6" />
|
class="font-bold"
|
||||||
{menuItem.title}
|
class:bg-base-100={menuItem.active}
|
||||||
</a>
|
class:text-lg={level === 0}
|
||||||
|
class:text-md={level === 1}
|
||||||
|
onclick={() => selectMenuItem(menuItem.title)}
|
||||||
|
>
|
||||||
|
<menuItem.icon class="h-6 w-6" />
|
||||||
|
{menuItem.title}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
{/each}
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isFullscreen, toggleFullscreen } from '$lib/stores';
|
import { isFullscreen, toggleFullscreen } from '$lib/stores'
|
||||||
import { MdiFullscreenExit, MdiFullscreen } from '../icons';
|
import { MdiFullscreenExit, MdiFullscreen } from '../icons'
|
||||||
|
|
||||||
const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen);
|
const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button onclick={toggleFullscreen}>
|
<button onclick={toggleFullscreen}>
|
||||||
<SvelteComponent class="h-7 w-7" />
|
<SvelteComponent class="h-7 w-7" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from "../icons";
|
import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from '../icons'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
showDBm?: boolean;
|
showDBm?: boolean
|
||||||
rssi?: number;
|
rssi?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
let { showDBm = false, rssi = 0 }: Props = $props();
|
let { showDBm = false, rssi = 0 }: Props = $props()
|
||||||
|
|
||||||
const getWiFiIcon = () => {
|
const getWiFiIcon = () => {
|
||||||
if (rssi === 0) return WifiOff;
|
if (rssi === 0) return WifiOff
|
||||||
if (rssi >= -55) return WiFi;
|
if (rssi >= -55) return WiFi
|
||||||
if (rssi >= -75) return WiFi2;
|
if (rssi >= -75) return WiFi2
|
||||||
if (rssi >= -85) return WiFi1;
|
if (rssi >= -85) return WiFi1
|
||||||
return WiFi0;
|
return WiFi0
|
||||||
};
|
}
|
||||||
|
|
||||||
const SvelteComponent = $derived(getWiFiIcon());
|
const SvelteComponent = $derived(getWiFiIcon())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="indicator">
|
<div class="indicator">
|
||||||
<div class="tooltip tooltip-left" data-tip={rssi + " dBm"}>
|
<div class="tooltip tooltip-left" data-tip={rssi + ' dBm'}>
|
||||||
{#if showDBm}
|
{#if showDBm}
|
||||||
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
|
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
|
||||||
{rssi} dBm
|
{rssi} dBm
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="h-7 w-7">
|
<div class="h-7 w-7">
|
||||||
<SvelteComponent class="absolute inset-0 h-full w-full" />
|
<SvelteComponent class="absolute inset-0 h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useFeatureFlags } from '$lib/stores';
|
import { useFeatureFlags } from '$lib/stores'
|
||||||
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 { api } from '$lib/api';
|
import { api } from '$lib/api'
|
||||||
import { Cancel, Power } from '../icons';
|
import { Cancel, Power } from '../icons'
|
||||||
|
|
||||||
const features = useFeatureFlags();
|
const features = useFeatureFlags()
|
||||||
|
|
||||||
const postSleep = async () => await api.post('/api/system/sleep');
|
const postSleep = async () => await api.post('/api/system/sleep')
|
||||||
|
|
||||||
const confirmSleep = () => {
|
const confirmSleep = () => {
|
||||||
modals.open(ConfirmDialog, {
|
modals.open(ConfirmDialog, {
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
confirm: { label: 'Switch Off', icon: Power }
|
confirm: { label: 'Switch Off', icon: Power }
|
||||||
},
|
},
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
modals.close();
|
modals.close()
|
||||||
postSleep();
|
postSleep()
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $features.sleep}
|
{#if $features.sleep}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { mode, modes } from "$lib/stores";
|
import { ModeData, ModesEnum } from '$lib/platform_shared/message'
|
||||||
|
import { mode } from '$lib/stores'
|
||||||
|
|
||||||
const deactivate = async () => {
|
const deactivate = async () => {
|
||||||
mode.set(modes.indexOf('deactivated'));
|
mode.set(ModeData.create({ mode: ModesEnum.DEACTIVATED }))
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button>
|
||||||
<button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button>
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { MdiWeatherSunny, MdiMoonAndStars } from "../icons";
|
import { MdiWeatherSunny, MdiMoonAndStars } from '../icons'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label class="swap swap-rotate">
|
<label class="swap swap-rotate">
|
||||||
<input type="checkbox" value="light" class="theme-controller" />
|
<input type="checkbox" value="light" class="theme-controller" />
|
||||||
<MdiWeatherSunny class="swap-off h-7 w-7" />
|
<MdiWeatherSunny class="swap-off h-7 w-7" />
|
||||||
<MdiMoonAndStars class="swap-on h-7 w-7" />
|
<MdiMoonAndStars class="swap-on h-7 w-7" />
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Hamburger} from '../icons'
|
import { Hamburger } from '../icons'
|
||||||
|
import { resolve } from '$app/paths'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="topbar absolute left-0 top-0 w-full z-20 flex justify-between bg-zinc-800">
|
<div class="topbar absolute left-0 top-0 w-full z-20 flex justify-between bg-zinc-800">
|
||||||
<div class="flex gap-2 p-2">
|
<div class="flex gap-2 p-2">
|
||||||
<a href="/">
|
<a href={resolve('/')}>
|
||||||
<Hamburger class="h-8 w-8"/>
|
<Hamburger class="h-8 w-8" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.topbar {
|
.topbar {
|
||||||
height: 50px;
|
height: 50px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,109 +1,111 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { modals } from 'svelte-modals'
|
import { modals } from 'svelte-modals'
|
||||||
import { notifications } from '$lib/components/toasts/notifications'
|
import { notifications } from '$lib/components/toasts/notifications'
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||||
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'
|
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'
|
||||||
import { compareVersions } from 'compare-versions'
|
import { compareVersions } from 'compare-versions'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api } from '$lib/api'
|
import { api } from '$lib/api'
|
||||||
import type { GithubRelease } from '$lib/types/models'
|
import type { GithubRelease } from '$lib/types/models'
|
||||||
import { useFeatureFlags } from '$lib/stores/featureFlags'
|
import { useFeatureFlags } from '$lib/stores/featureFlags'
|
||||||
import { Cancel, CloudDown, Firmware } from '../icons'
|
import { Cancel, CloudDown, Firmware } from '../icons'
|
||||||
|
|
||||||
const features = useFeatureFlags()
|
const features = useFeatureFlags()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
update?: boolean
|
update?: boolean
|
||||||
}
|
|
||||||
|
|
||||||
let { update = $bindable(false) }: Props = $props()
|
|
||||||
|
|
||||||
let firmwareVersion: string = $state('')
|
|
||||||
let firmwareDownloadLink: string = $state('')
|
|
||||||
|
|
||||||
async function getGithubAPI() {
|
|
||||||
const headers = {
|
|
||||||
accept: 'application/vnd.github+json',
|
|
||||||
'X-GitHub-Api-Version': '2022-11-28'
|
|
||||||
}
|
|
||||||
const result = await api.get<GithubRelease>(
|
|
||||||
`https://api.github.com/repos/${page.data.github}/releases/latest`,
|
|
||||||
{ headers }
|
|
||||||
)
|
|
||||||
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
|
|
||||||
console.warn('Error: Could not find releases in the repository')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.isErr()) {
|
|
||||||
console.error('Error:', result.inner)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = result.inner
|
let { update = $bindable(false) }: Props = $props()
|
||||||
update = false
|
|
||||||
firmwareVersion = ''
|
|
||||||
|
|
||||||
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
|
let firmwareVersion: string = $state('')
|
||||||
// iterate over assets and find the correct one
|
let firmwareDownloadLink: string = $state('')
|
||||||
for (let i = 0; i < results.assets.length; i++) {
|
|
||||||
// check if the asset is of type *.bin
|
async function getGithubAPI() {
|
||||||
if (
|
const headers = {
|
||||||
results.assets[i].name.includes('.bin') &&
|
accept: 'application/vnd.github+json',
|
||||||
results.assets[i].name.includes($features.firmware_built_target as string)
|
'X-GitHub-Api-Version': '2022-11-28'
|
||||||
) {
|
}
|
||||||
update = true
|
const result = await api.get<GithubRelease>(
|
||||||
firmwareVersion = results.tag_name
|
`https://api.github.com/repos/${page.data.github}/releases/latest`,
|
||||||
firmwareDownloadLink = results.assets[i].browser_download_url
|
{ headers }
|
||||||
notifications.info('Firmware update available.', 5000)
|
)
|
||||||
|
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
|
||||||
|
console.warn('Error: Could not find releases in the repository')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.isErr()) {
|
||||||
|
console.error('Error:', result.inner)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postGithubDownload(url: string) {
|
const results = result.inner
|
||||||
const result = await api.post('/api/downloadUpdate', { download_url: url })
|
update = false
|
||||||
if (result.isErr()) {
|
firmwareVersion = ''
|
||||||
console.error('Error:', result.inner)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
|
||||||
if ($features.download_firmware) {
|
// iterate over assets and find the correct one
|
||||||
await getGithubAPI()
|
for (let i = 0; i < results.assets.length; i++) {
|
||||||
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour
|
// check if the asset is of type *.bin
|
||||||
|
if (
|
||||||
|
results.assets[i].name.includes('.bin') &&
|
||||||
|
results.assets[i].name.includes($features.firmware_built_target as string)
|
||||||
|
) {
|
||||||
|
update = true
|
||||||
|
firmwareVersion = results.tag_name
|
||||||
|
firmwareDownloadLink = results.assets[i].browser_download_url
|
||||||
|
notifications.info('Firmware update available.', 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
function confirmGithubUpdate(url: string) {
|
async function postGithubDownload(url: string) {
|
||||||
modals.open(ConfirmDialog, {
|
const result = await api.post('/api/downloadUpdate', { download_url: url })
|
||||||
title: 'Confirm flashing new firmware to the device',
|
if (result.isErr()) {
|
||||||
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
|
console.error('Error:', result.inner)
|
||||||
labels: {
|
return
|
||||||
cancel: { label: 'Abort', icon: Cancel },
|
}
|
||||||
confirm: { label: 'Update', icon: CloudDown }
|
}
|
||||||
},
|
|
||||||
onConfirm: () => {
|
onMount(async () => {
|
||||||
postGithubDownload(url)
|
if ($features.download_firmware) {
|
||||||
modals.open(GithubUpdateDialog, {
|
await getGithubAPI()
|
||||||
onConfirm: () => modals.closeAll()
|
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour
|
||||||
})
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
function confirmGithubUpdate(url: string) {
|
||||||
|
modals.open(ConfirmDialog, {
|
||||||
|
title: 'Confirm flashing new firmware to the device',
|
||||||
|
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
|
||||||
|
labels: {
|
||||||
|
cancel: { label: 'Abort', icon: Cancel },
|
||||||
|
confirm: { label: 'Update', icon: CloudDown }
|
||||||
|
},
|
||||||
|
onConfirm: () => {
|
||||||
|
postGithubDownload(url)
|
||||||
|
modals.open(GithubUpdateDialog, {
|
||||||
|
onConfirm: () => modals.closeAll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if update}
|
{#if update}
|
||||||
<div class="indicator flex-none">
|
<div class="indicator flex-none">
|
||||||
<button
|
<button
|
||||||
class="btn btn-square btn-ghost h-9 w-9"
|
class="btn btn-square btn-ghost h-9 w-9"
|
||||||
onclick={() => confirmGithubUpdate(firmwareDownloadLink)}>
|
onclick={() => confirmGithubUpdate(firmwareDownloadLink)}
|
||||||
<span
|
>
|
||||||
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1">
|
<span
|
||||||
{firmwareVersion}
|
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
|
||||||
</span>
|
>
|
||||||
<Firmware class="h-7 w-7" />
|
{firmwareVersion}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
<Firmware class="h-7 w-7" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { selectedView, views } from "$lib/stores/application";
|
import { selectedView, views } from '$lib/stores/application'
|
||||||
import Selector from "../widget/Selector.svelte";
|
import Selector from '../widget/Selector.svelte'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Selector bind:selectedOption={$selectedView} options={$views.map((v) => v.name)} />
|
<Selector bind:selectedOption={$selectedView} options={$views.map(v => v.name)} />
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { telemetry } from '$lib/stores/telemetry'
|
import { telemetry } from '$lib/stores/telemetry'
|
||||||
|
|
||||||
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
|
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
|
||||||
import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte'
|
import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte'
|
||||||
import SleepButton from './SleepButton.svelte'
|
import SleepButton from './SleepButton.svelte'
|
||||||
import ThemeButton from './ThemeButton.svelte'
|
import ThemeButton from './ThemeButton.svelte'
|
||||||
import FullscreenButton from './FullscreenButton.svelte'
|
import FullscreenButton from './FullscreenButton.svelte'
|
||||||
import StopButton from './StopButton.svelte'
|
import StopButton from './StopButton.svelte'
|
||||||
import ViewSelector from './ViewSelector.svelte'
|
import ViewSelector from './ViewSelector.svelte'
|
||||||
import { Hamburger } from '../icons'
|
import { Hamburger } from '../icons'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="navbar bg-base-300 sticky top-0 z-10 h-12 min-h-fit drop-shadow-lg lg:h-16 gap-2 pr-0">
|
<div class="navbar bg-base-300 sticky top-0 z-10 h-12 min-h-fit drop-shadow-lg lg:h-16 gap-2 pr-0">
|
||||||
<div class="flex flex-1 gap-2">
|
<div class="flex flex-1 gap-2">
|
||||||
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button">
|
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button">
|
||||||
<Hamburger class="h-6 w-auto" />
|
<Hamburger class="h-6 w-auto" />
|
||||||
</label>
|
</label>
|
||||||
{#if page.data.title === 'Controller'}
|
{#if page.data.title === 'Controller'}
|
||||||
<ViewSelector />
|
<ViewSelector />
|
||||||
{:else}
|
{:else}
|
||||||
<h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1>
|
<h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UpdateIndicator />
|
<UpdateIndicator />
|
||||||
|
|
||||||
<FullscreenButton />
|
<FullscreenButton />
|
||||||
|
|
||||||
<ThemeButton />
|
<ThemeButton />
|
||||||
|
|
||||||
<RssiIndicator rssi={$telemetry.rssi.rssi} />
|
<RssiIndicator rssi={$telemetry.rssi.rssi} />
|
||||||
|
|
||||||
<SleepButton />
|
<SleepButton />
|
||||||
|
|
||||||
<StopButton />
|
<StopButton />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,35 +1,37 @@
|
|||||||
<script>
|
<script>
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate'
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition'
|
||||||
import { notifications } from '$lib/components/toasts/notifications';
|
import { notifications } from '$lib/components/toasts/notifications'
|
||||||
import { error, info, success, warning } from '../icons';
|
import { error, info, success, warning } from '../icons'
|
||||||
|
|
||||||
|
/** @type {{theme?: any, icon?: any}} */
|
||||||
/** @type {{theme?: any, icon?: any}} */
|
let {
|
||||||
let { theme = {
|
theme = {
|
||||||
error: 'alert-error',
|
error: 'alert-error',
|
||||||
success: 'alert-success',
|
success: 'alert-success',
|
||||||
warning: 'alert-warning',
|
warning: 'alert-warning',
|
||||||
info: 'alert-info'
|
info: 'alert-info'
|
||||||
}, icon = {
|
},
|
||||||
error: error,
|
icon = {
|
||||||
success: success,
|
error: error,
|
||||||
warning: warning,
|
success: success,
|
||||||
info: info
|
warning: warning,
|
||||||
} } = $props();
|
info: info
|
||||||
|
}
|
||||||
|
} = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="toast toast-end mr-4 z-20">
|
<div class="toast toast-end mr-4 z-20">
|
||||||
{#each $notifications as notification (notification.id)}
|
{#each $notifications as notification (notification.id)}
|
||||||
{@const SvelteComponent = icon[notification.type]}
|
{@const SvelteComponent = icon[notification.type]}
|
||||||
<div
|
<div
|
||||||
animate:flip={{ duration: 400 }}
|
animate:flip={{ duration: 400 }}
|
||||||
class="alert animate-none {theme[notification.type]}"
|
class="alert animate-none {theme[notification.type]}"
|
||||||
in:fly={{ y: 100, duration: 400 }}
|
in:fly={{ y: 100, duration: 400 }}
|
||||||
out:fly={{ x: 100, duration: 400 }}
|
out:fly={{ x: 100, duration: 400 }}
|
||||||
>
|
>
|
||||||
<SvelteComponent class="h-6 w-6 shrink-0" />
|
<SvelteComponent class="h-6 w-6 shrink-0" />
|
||||||
<span>{notification.message}</span>
|
<span>{notification.message}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
import { writable, derived, type Writable } from 'svelte/store'
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
type StateType = 'info' | 'success' | 'warning' | 'error'
|
type StateType = 'info' | 'success' | 'warning' | 'error'
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
id: string
|
id: string
|
||||||
type: StateType
|
type: StateType
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNotificationStore() {
|
function createNotificationStore() {
|
||||||
const state: State[] = []
|
const state: State[] = []
|
||||||
const notifications = writable(state)
|
const notifications = writable(state)
|
||||||
const { subscribe } = notifications
|
const { subscribe } = notifications
|
||||||
|
|
||||||
function send(message: string, type: StateType = 'info', timeout: number) {
|
function send(message: string, type: StateType = 'info', timeout: number) {
|
||||||
const id = generateId()
|
const id = generateId()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
notifications.update(state => {
|
notifications.update(state => {
|
||||||
return state.filter(n => n.id !== id)
|
return state.filter(n => n.id !== id)
|
||||||
})
|
})
|
||||||
}, timeout)
|
}, timeout)
|
||||||
notifications.update(state => {
|
notifications.update(state => {
|
||||||
return [...state, { id, type, message }]
|
return [...state, { id, type, message }]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
send,
|
send,
|
||||||
error: (msg: string, timeout: number = 4000) => send(msg, 'error', timeout),
|
error: (msg: string, timeout: number = 4000) => send(msg, 'error', timeout),
|
||||||
warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout),
|
warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout),
|
||||||
info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout),
|
info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout),
|
||||||
success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout)
|
success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateId() {
|
function generateId() {
|
||||||
return '_' + Math.random().toString(36).substr(2, 9)
|
return '_' + Math.random().toString(36).substr(2, 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const notifications = createNotificationStore()
|
export const notifications = createNotificationStore()
|
||||||
|
|||||||
@@ -1,101 +1,102 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { daisyColor } from '$lib/utilities';
|
import { daisyColor } from '$lib/utilities'
|
||||||
import { Chart, registerables } from 'chart.js';
|
import { Chart, registerables } from 'chart.js'
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte'
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing'
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition'
|
||||||
|
|
||||||
let chartElement: HTMLCanvasElement;
|
let chartElement: HTMLCanvasElement
|
||||||
let chart: Chart;
|
let chart: Chart<'line', number[], number>
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: any;
|
label: string
|
||||||
data: number[];
|
data: number[]
|
||||||
title: any;
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let { label, data, title }: Props = $props();
|
let { label, data, title }: Props = $props()
|
||||||
|
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
chart = new Chart(chartElement, {
|
chart = new Chart(chartElement, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: data,
|
labels: data,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label,
|
label,
|
||||||
borderColor: daisyColor('--p'),
|
borderColor: daisyColor('--p'),
|
||||||
backgroundColor: daisyColor('--p', 50),
|
backgroundColor: daisyColor('--p', 50),
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
data,
|
data,
|
||||||
yAxisID: 'y',
|
yAxisID: 'y'
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
},
|
|
||||||
options: {
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
responsive: true,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: true,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
point: {
|
|
||||||
radius: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
grid: {
|
|
||||||
color: daisyColor('--bc', 10),
|
|
||||||
},
|
},
|
||||||
ticks: {
|
options: {
|
||||||
color: daisyColor('--bc'),
|
maintainAspectRatio: false,
|
||||||
},
|
responsive: true,
|
||||||
display: false,
|
plugins: {
|
||||||
},
|
legend: {
|
||||||
y: {
|
display: true
|
||||||
type: 'linear',
|
},
|
||||||
title: {
|
tooltip: {
|
||||||
display: true,
|
mode: 'index',
|
||||||
text: title,
|
intersect: false
|
||||||
color: daisyColor('--bc'),
|
}
|
||||||
font: {
|
},
|
||||||
size: 16,
|
elements: {
|
||||||
weight: 'bold',
|
point: {
|
||||||
},
|
radius: 0
|
||||||
},
|
}
|
||||||
position: 'left',
|
},
|
||||||
min: 0,
|
scales: {
|
||||||
max: 100,
|
x: {
|
||||||
grid: { color: daisyColor('--bc', 10) },
|
grid: {
|
||||||
ticks: {
|
color: daisyColor('--bc', 10)
|
||||||
color: daisyColor('--bc'),
|
},
|
||||||
},
|
ticks: {
|
||||||
border: { color: daisyColor('--bc', 10) },
|
color: daisyColor('--bc')
|
||||||
},
|
},
|
||||||
},
|
display: false
|
||||||
},
|
},
|
||||||
});
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: title,
|
||||||
|
color: daisyColor('--bc'),
|
||||||
|
font: {
|
||||||
|
size: 16,
|
||||||
|
weight: 'bold'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
position: 'left',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
grid: { color: daisyColor('--bc', 10) },
|
||||||
|
ticks: {
|
||||||
|
color: daisyColor('--bc')
|
||||||
|
},
|
||||||
|
border: { color: daisyColor('--bc', 10) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
chart.data.labels = data;
|
chart.data.labels = data
|
||||||
chart.data.datasets[0].data = data;
|
chart.data.datasets[0].data = data
|
||||||
}, 500);
|
}, 500)
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full h-full overflow-x-auto">
|
<div class="w-full h-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"
|
||||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||||
<canvas bind:this={chartElement}></canvas>
|
>
|
||||||
</div>
|
<canvas bind:this={chartElement}></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
options?: string[];
|
options?: string[]
|
||||||
selectedOption?: string;
|
selectedOption?: string
|
||||||
change?: () => void;
|
change?: () => void
|
||||||
[key: string]: any;
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props();
|
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
bind:value={selectedOption}
|
bind:value={selectedOption}
|
||||||
{...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 value={option}>{option}</option>
|
{#each options as option (option)}
|
||||||
{/each}
|
<option value={option}>{option}</option>
|
||||||
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
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()
|
||||||
+422
-351
@@ -1,425 +1,496 @@
|
|||||||
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
|
||||||
step_x: number
|
step_x: number
|
||||||
step_z: number
|
step_z: number
|
||||||
step_angle: number
|
step_angle: number
|
||||||
step_velocity: number
|
step_velocity: number
|
||||||
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
|
||||||
|
|
||||||
protected dt = 0.02
|
protected dt = 0.02
|
||||||
protected body_state!: body_state_t
|
protected body_state!: body_state_t
|
||||||
protected gait_state: gait_state_t = {
|
|
||||||
step_height: 0.4,
|
|
||||||
step_x: 0,
|
|
||||||
step_z: 0,
|
|
||||||
step_angle: 0,
|
|
||||||
step_velocity: 1,
|
|
||||||
step_depth: 0.002
|
|
||||||
}
|
|
||||||
|
|
||||||
public get default_feet_pos() {
|
protected get kinematic() {
|
||||||
return get(currentKinematic).getDefaultFeetPos()
|
return get(currentKinematic)
|
||||||
}
|
|
||||||
|
|
||||||
protected get default_height() {
|
|
||||||
return 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
begin() {
|
|
||||||
console.log('Starting', this.name)
|
|
||||||
}
|
|
||||||
end() {
|
|
||||||
console.log('Ending', this.name)
|
|
||||||
}
|
|
||||||
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
|
|
||||||
this.map_command(command)
|
|
||||||
this.body_state = body_state
|
|
||||||
this.dt = dt / 1000
|
|
||||||
return body_state
|
|
||||||
}
|
|
||||||
|
|
||||||
map_command(command: ControllerCommand) {
|
|
||||||
const newCommand = {
|
|
||||||
step_height: 0.4 + (command.s1 + 1) / 2,
|
|
||||||
step_x: command.ly,
|
|
||||||
step_z: -command.lx,
|
|
||||||
step_velocity: command.s,
|
|
||||||
step_angle: command.rx,
|
|
||||||
step_depth: 0.002
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.gait_state = newCommand
|
protected gait_state: gait_state_t = {
|
||||||
}
|
step_height: 0,
|
||||||
|
step_x: 0,
|
||||||
|
step_z: 0,
|
||||||
|
step_angle: 0,
|
||||||
|
step_velocity: 1,
|
||||||
|
step_depth: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public get default_feet_pos() {
|
||||||
|
return this.kinematic.getDefaultFeetPos()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get default_height() {
|
||||||
|
return this.kinematic.default_body_height
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get default_step_depth() {
|
||||||
|
return this.kinematic.default_step_depth
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get default_step_height() {
|
||||||
|
return this.kinematic.default_step_height
|
||||||
|
}
|
||||||
|
|
||||||
|
begin() {
|
||||||
|
console.log('Starting', this.name)
|
||||||
|
}
|
||||||
|
end() {
|
||||||
|
console.log('Ending', this.name)
|
||||||
|
}
|
||||||
|
step(body_state: body_state_t, command: ControllerData, dt: number = 0.02) {
|
||||||
|
this.map_command(command)
|
||||||
|
this.body_state = body_state
|
||||||
|
this.dt = dt / 1000
|
||||||
|
|
||||||
|
if (body_state.cumulative_x === undefined) {
|
||||||
|
body_state.cumulative_x = 0
|
||||||
|
body_state.cumulative_y = 0
|
||||||
|
body_state.cumulative_z = 0
|
||||||
|
body_state.cumulative_roll = 0
|
||||||
|
body_state.cumulative_pitch = 0
|
||||||
|
body_state.cumulative_yaw = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return body_state
|
||||||
|
}
|
||||||
|
|
||||||
|
map_command(command: ControllerData) {
|
||||||
|
const kin = this.kinematic
|
||||||
|
this.gait_state = {
|
||||||
|
step_height: command.s1 * kin.max_step_height,
|
||||||
|
step_x: command.left!.y * kin.max_step_length,
|
||||||
|
step_z: -command.left!.x * kin.max_step_length,
|
||||||
|
step_velocity: command.speed,
|
||||||
|
step_angle: command.right!.x,
|
||||||
|
step_depth: kin.default_step_depth
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IdleState extends GaitState {
|
export class IdleState extends GaitState {
|
||||||
protected name = 'Idle'
|
protected name = 'Idle'
|
||||||
|
step(body_state: body_state_t, command: ControllerData) {
|
||||||
|
super.step(body_state, command)
|
||||||
|
return body_state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CalibrationState extends GaitState {
|
export class CalibrationState extends GaitState {
|
||||||
protected name = 'Calibration'
|
protected name = 'Calibration'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
step(body_state: body_state_t, _command: ControllerData) {
|
||||||
step(body_state: body_state_t, _command: ControllerCommand) {
|
super.step(body_state, _command)
|
||||||
body_state.omega = 0
|
body_state.omega = 0
|
||||||
body_state.phi = 0
|
body_state.phi = 0
|
||||||
body_state.psi = 0
|
body_state.psi = 0
|
||||||
body_state.xm = 0
|
body_state.xm = 0
|
||||||
body_state.ym = this.default_height * 10
|
body_state.ym = this.kinematic.max_body_height
|
||||||
body_state.zm = 0
|
body_state.zm = 0
|
||||||
body_state.feet = this.default_feet_pos
|
body_state.feet = this.default_feet_pos
|
||||||
return body_state
|
return body_state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RestState extends GaitState {
|
export class RestState extends GaitState {
|
||||||
protected name = 'Rest'
|
protected name = 'Rest'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
step(body_state: body_state_t, _command: ControllerData) {
|
||||||
step(body_state: body_state_t, _command: ControllerCommand) {
|
super.step(body_state, _command)
|
||||||
body_state.omega = 0
|
body_state.omega = 0
|
||||||
body_state.phi = 0
|
body_state.phi = 0
|
||||||
body_state.psi = 0
|
body_state.psi = 0
|
||||||
body_state.xm = 0
|
body_state.xm = 0
|
||||||
body_state.ym = this.default_height / 2
|
body_state.ym = this.kinematic.min_body_height
|
||||||
body_state.zm = 0
|
body_state.zm = 0
|
||||||
body_state.feet = this.default_feet_pos
|
body_state.feet = this.default_feet_pos
|
||||||
return body_state
|
return body_state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StandState extends GaitState {
|
export class StandState extends GaitState {
|
||||||
protected name = 'Stand'
|
protected name = 'Stand'
|
||||||
|
|
||||||
step(body_state: body_state_t, command: ControllerCommand) {
|
step(body_state: body_state_t, command: ControllerData) {
|
||||||
body_state.omega = 0
|
super.step(body_state, command)
|
||||||
body_state.phi = command.rx * 10 * (Math.PI / 2)
|
const kin = this.kinematic
|
||||||
body_state.psi = command.ry * 10 * (Math.PI / 2)
|
body_state.omega = 0
|
||||||
body_state.xm = command.ly / 4
|
body_state.ym = kin.min_body_height + command.height * kin.body_height_range
|
||||||
body_state.zm = command.lx / 4
|
body_state.psi = command.right!.y * kin.max_pitch
|
||||||
body_state.feet = this.default_feet_pos
|
body_state.phi = command.right!.x * kin.max_roll
|
||||||
return body_state
|
body_state.xm = command.left!.y * kin.max_body_shift_x
|
||||||
}
|
body_state.zm = command.left!.x * kin.max_body_shift_z
|
||||||
|
body_state.feet = this.default_feet_pos
|
||||||
|
return body_state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BezierState extends GaitState {
|
export class BezierState extends GaitState {
|
||||||
protected name = 'Bezier'
|
protected name = 'Bezier'
|
||||||
protected phase = 0
|
protected phase = 0
|
||||||
protected phase_num = 0
|
protected phase_num = 0
|
||||||
protected step_length = 0
|
protected step_length = 0
|
||||||
protected stand_offset = 0.85
|
protected stand_offset = 0.75
|
||||||
protected mode: 'crawl' | 'trot' = 'trot'
|
protected mode: WalkGaits = WalkGaits.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]
|
||||||
|
|
||||||
protected shift_start_pos = { x: 0, z: 0 }
|
protected shift_start_pos = { x: 0, z: 0 }
|
||||||
protected shift_target_pos = { x: 0, z: 0 }
|
protected shift_target_pos = { x: 0, z: 0 }
|
||||||
protected shift_start_time = 0
|
protected shift_start_time = 0
|
||||||
protected current_shift_leg = -1
|
protected current_shift_leg = -1
|
||||||
|
|
||||||
constructor() {
|
protected last_body_state: body_state_t | null = null
|
||||||
super()
|
protected cumulative_position = { x: 0, y: 0, z: 0 }
|
||||||
this.set_mode(this.mode)
|
protected cumulative_orientation = { roll: 0, pitch: 0, yaw: 0 }
|
||||||
}
|
|
||||||
|
|
||||||
begin() {
|
constructor() {
|
||||||
super.begin()
|
super()
|
||||||
}
|
this.set_mode(this.mode)
|
||||||
|
|
||||||
set_mode(mode: 'crawl' | 'trot', duty?: number, order?: [number, number, number, number]) {
|
|
||||||
console.log('BezierState set_mode', mode)
|
|
||||||
|
|
||||||
this.mode = mode
|
|
||||||
if (mode === 'crawl') {
|
|
||||||
this.speed_factor = 0.5
|
|
||||||
this.stand_offset = duty ?? 0.85
|
|
||||||
const o = order ?? [3, 0, 2, 1]
|
|
||||||
const base = [0, 0.25, 0.5, 0.75]
|
|
||||||
const offsets = new Array(4).fill(0)
|
|
||||||
for (let i = 0; i < 4; i++) offsets[o[i]] = base[i]
|
|
||||||
this.offset = offsets
|
|
||||||
} else {
|
|
||||||
this.speed_factor = 2
|
|
||||||
this.stand_offset = duty ?? 0.6
|
|
||||||
this.offset = order ? (order.map(v => v % 1) as number[]) : [0, 0.5, 0.5, 0]
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
end() {
|
begin() {
|
||||||
super.end()
|
super.begin()
|
||||||
}
|
|
||||||
|
|
||||||
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
|
|
||||||
super.step(body_state, command, dt)
|
|
||||||
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
|
|
||||||
this.update_phase()
|
|
||||||
this.update_body_position()
|
|
||||||
this.update_feet_positions()
|
|
||||||
return this.body_state
|
|
||||||
}
|
|
||||||
|
|
||||||
update_phase() {
|
|
||||||
const m = this.gait_state
|
|
||||||
if (m.step_x === 0 && m.step_z === 0 && m.step_angle === 0) {
|
|
||||||
this.phase = 0
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
this.phase += this.dt * m.step_velocity * this.speed_factor
|
|
||||||
if (this.phase >= 1) {
|
|
||||||
this.phase_num = (this.phase_num + 1) % 2
|
|
||||||
this.phase = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update_body_position() {
|
set_mode(mode: WalkGaits, duty?: number, order?: [number, number, number, number]) {
|
||||||
const m = this.gait_state
|
this.mode = mode
|
||||||
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
|
if (mode === WalkGaits.CRAWL) {
|
||||||
if (!moving) return
|
this.speed_factor = 0.5
|
||||||
|
this.stand_offset = duty ?? 0.85
|
||||||
if (this.mode !== 'crawl') return
|
const o = order ?? [3, 0, 2, 1]
|
||||||
|
const base = [0, 0.25, 0.5, 0.75]
|
||||||
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states()
|
const offsets = new Array(4).fill(0)
|
||||||
|
for (let i = 0; i < 4; i++) offsets[o[i]] = base[i]
|
||||||
if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) {
|
this.offset = offsets
|
||||||
if (this.current_shift_leg !== next_swing) {
|
} else {
|
||||||
this.current_shift_leg = next_swing
|
this.speed_factor = 2
|
||||||
this.shift_start_pos.x = this.body_state.xm
|
this.stand_offset = duty ?? 0.6
|
||||||
this.shift_start_pos.z = this.body_state.zm
|
this.offset = order ? (order.map(v => v % 1) as number[]) : [0, 0.5, 0.5, 0]
|
||||||
|
|
||||||
const remaining_legs = stance.filter(leg => leg !== next_swing)
|
|
||||||
const target = this.stance_centroid(remaining_legs)
|
|
||||||
this.shift_target_pos.x = target[0]
|
|
||||||
this.shift_target_pos.z = target[2]
|
|
||||||
|
|
||||||
this.shift_start_time = time_to_lift
|
|
||||||
}
|
|
||||||
|
|
||||||
const total_time = this.shift_start_time
|
|
||||||
const progress = total_time > 0 ? 1 - time_to_lift / total_time : 1
|
|
||||||
const smooth_progress = this.smoothstep01(Math.max(0, Math.min(1, progress)))
|
|
||||||
|
|
||||||
this.body_state.xm = this.lerp(
|
|
||||||
this.shift_start_pos.x,
|
|
||||||
this.shift_target_pos.x,
|
|
||||||
smooth_progress
|
|
||||||
)
|
|
||||||
this.body_state.zm = this.lerp(
|
|
||||||
this.shift_start_pos.z,
|
|
||||||
this.shift_target_pos.z,
|
|
||||||
smooth_progress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected lerp(a: number, b: number, t: number): number {
|
|
||||||
return a + (b - a) * t
|
|
||||||
}
|
|
||||||
|
|
||||||
protected stance_centroid(legs: number[]): number[] {
|
|
||||||
if (legs.length === 0) return [this.body_state.xm, 0, this.body_state.zm]
|
|
||||||
|
|
||||||
let sx = 0,
|
|
||||||
sz = 0
|
|
||||||
for (const i of legs) {
|
|
||||||
sx += this.body_state.feet[i][0]
|
|
||||||
sz += this.body_state.feet[i][2]
|
|
||||||
}
|
|
||||||
return [sx / legs.length, 0, sz / legs.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get_leg_states(): {
|
|
||||||
stance: number[]
|
|
||||||
swing: number[]
|
|
||||||
next_swing: number
|
|
||||||
time_to_lift: number
|
|
||||||
} {
|
|
||||||
const stance: number[] = []
|
|
||||||
const swing: number[] = []
|
|
||||||
let next_swing = -1
|
|
||||||
let min_time_to_swing = Infinity
|
|
||||||
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
let phase = this.phase + this.offset[i]
|
|
||||||
if (phase >= 1) phase -= 1
|
|
||||||
|
|
||||||
if (phase <= this.stand_offset) {
|
|
||||||
stance.push(i)
|
|
||||||
const time_to_swing = this.stand_offset - phase
|
|
||||||
if (time_to_swing < min_time_to_swing) {
|
|
||||||
min_time_to_swing = time_to_swing
|
|
||||||
next_swing = i
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
swing.push(i)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { stance, swing, next_swing, time_to_lift: min_time_to_swing }
|
end() {
|
||||||
}
|
super.end()
|
||||||
|
}
|
||||||
|
|
||||||
protected smoothstep01(t: number): number {
|
step(body_state: body_state_t, command: ControllerData, dt: number = 0.02) {
|
||||||
const x = Math.max(0, Math.min(1, t))
|
super.step(body_state, command, dt)
|
||||||
return x * x * (3 - 2 * x)
|
const kin = this.kinematic
|
||||||
}
|
this.body_state.ym = kin.min_body_height + command.height * kin.body_height_range
|
||||||
|
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
|
||||||
|
this.update_phase()
|
||||||
|
this.update_body_position()
|
||||||
|
this.update_feet_positions()
|
||||||
|
this.update_cumulative_position()
|
||||||
|
return this.body_state
|
||||||
|
}
|
||||||
|
|
||||||
update_feet_positions() {
|
update_phase() {
|
||||||
for (let i = 0; i < 4; i++) this.body_state.feet[i] = this.update_foot_position(i)
|
const m = this.gait_state
|
||||||
}
|
if (m.step_x === 0 && m.step_z === 0 && m.step_angle === 0) {
|
||||||
|
this.phase = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.phase += this.dt * m.step_velocity * this.speed_factor
|
||||||
|
if (this.phase >= 1) {
|
||||||
|
this.phase_num = (this.phase_num + 1) % 2
|
||||||
|
this.phase = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
update_foot_position(index: number): number[] {
|
update_body_position() {
|
||||||
let phase = this.phase + this.offset[index]
|
const m = this.gait_state
|
||||||
if (phase >= 1) phase -= 1
|
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
|
||||||
this.body_state.feet[index][0] = this.default_feet_pos[index][0]
|
if (!moving) return
|
||||||
this.body_state.feet[index][1] = this.default_feet_pos[index][1]
|
|
||||||
this.body_state.feet[index][2] = this.default_feet_pos[index][2]
|
|
||||||
return phase <= this.stand_offset ?
|
|
||||||
this.stand_controller(index, phase / this.stand_offset)
|
|
||||||
: this.swing_controller(index, (phase - this.stand_offset) / (1 - this.stand_offset))
|
|
||||||
}
|
|
||||||
|
|
||||||
stand_controller(index: number, phase: number) {
|
if (this.mode !== WalkGaits.CRAWL) return
|
||||||
const depth = this.gait_state.step_depth
|
|
||||||
return this.controller(index, phase, stance_curve, depth)
|
|
||||||
}
|
|
||||||
|
|
||||||
swing_controller(index: number, phase: number) {
|
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states()
|
||||||
const height = this.gait_state.step_height
|
|
||||||
return this.controller(index, phase, bezier_curve, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
controller(
|
if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) {
|
||||||
index: number,
|
if (this.current_shift_leg !== next_swing) {
|
||||||
phase: number,
|
this.current_shift_leg = next_swing
|
||||||
controller: (length: number, angle: number, ...args: number[]) => number[],
|
this.shift_start_pos.x = this.body_state.xm
|
||||||
...args: number[]
|
this.shift_start_pos.z = this.body_state.zm
|
||||||
) {
|
|
||||||
let length = this.step_length / 2
|
|
||||||
let angle = Math.atan2(this.gait_state.step_z, this.step_length) * 2
|
|
||||||
const delta_pos = controller(length, angle, ...args, phase)
|
|
||||||
|
|
||||||
length = this.gait_state.step_angle * 2
|
const remaining_legs = stance.filter(leg => leg !== next_swing)
|
||||||
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
|
const target = this.stance_centroid(remaining_legs)
|
||||||
|
this.shift_target_pos.x = target[0]
|
||||||
|
this.shift_target_pos.z = target[2]
|
||||||
|
|
||||||
const delta_rot = controller(length, angle, ...args, phase)
|
this.shift_start_time = time_to_lift
|
||||||
|
}
|
||||||
|
|
||||||
this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2
|
const total_time = this.shift_start_time
|
||||||
this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2
|
const progress = total_time > 0 ? 1 - time_to_lift / total_time : 1
|
||||||
if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle)
|
const smooth_progress = this.smoothstep01(Math.max(0, Math.min(1, progress)))
|
||||||
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2
|
|
||||||
|
|
||||||
return this.body_state.feet[index]
|
this.body_state.xm = this.lerp(
|
||||||
}
|
this.shift_start_pos.x,
|
||||||
|
this.shift_target_pos.x,
|
||||||
|
smooth_progress
|
||||||
|
)
|
||||||
|
this.body_state.zm = this.lerp(
|
||||||
|
this.shift_start_pos.z,
|
||||||
|
this.shift_target_pos.z,
|
||||||
|
smooth_progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
protected stance_centroid(legs: number[]): number[] {
|
||||||
|
if (legs.length === 0) return [this.body_state.xm, 0, this.body_state.zm]
|
||||||
|
|
||||||
|
let sx = 0,
|
||||||
|
sz = 0
|
||||||
|
for (const i of legs) {
|
||||||
|
sx += this.body_state.feet[i][0]
|
||||||
|
sz += this.body_state.feet[i][2]
|
||||||
|
}
|
||||||
|
return [sx / legs.length, 0, sz / legs.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get_leg_states(): {
|
||||||
|
stance: number[]
|
||||||
|
swing: number[]
|
||||||
|
next_swing: number
|
||||||
|
time_to_lift: number
|
||||||
|
} {
|
||||||
|
const stance: number[] = []
|
||||||
|
const swing: number[] = []
|
||||||
|
let next_swing = -1
|
||||||
|
let min_time_to_swing = Infinity
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let phase = this.phase + this.offset[i]
|
||||||
|
if (phase >= 1) phase -= 1
|
||||||
|
|
||||||
|
if (phase <= this.stand_offset) {
|
||||||
|
stance.push(i)
|
||||||
|
const time_to_swing = this.stand_offset - phase
|
||||||
|
if (time_to_swing < min_time_to_swing) {
|
||||||
|
min_time_to_swing = time_to_swing
|
||||||
|
next_swing = i
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
swing.push(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stance, swing, next_swing, time_to_lift: min_time_to_swing }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected smoothstep01(t: number): number {
|
||||||
|
const x = Math.max(0, Math.min(1, t))
|
||||||
|
return x * x * (3 - 2 * x)
|
||||||
|
}
|
||||||
|
|
||||||
|
update_feet_positions() {
|
||||||
|
for (let i = 0; i < 4; i++) this.body_state.feet[i] = this.update_foot_position(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
update_foot_position(index: number): number[] {
|
||||||
|
let phase = this.phase + this.offset[index]
|
||||||
|
if (phase >= 1) phase -= 1
|
||||||
|
this.body_state.feet[index][0] = this.default_feet_pos[index][0]
|
||||||
|
this.body_state.feet[index][1] = this.default_feet_pos[index][1]
|
||||||
|
this.body_state.feet[index][2] = this.default_feet_pos[index][2]
|
||||||
|
return phase <= this.stand_offset ?
|
||||||
|
this.stand_controller(index, phase / this.stand_offset)
|
||||||
|
: this.swing_controller(index, (phase - this.stand_offset) / (1 - this.stand_offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
stand_controller(index: number, phase: number) {
|
||||||
|
const depth = this.gait_state.step_depth
|
||||||
|
return this.controller(index, phase, stance_curve, depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
swing_controller(index: number, phase: number) {
|
||||||
|
const height = this.gait_state.step_height
|
||||||
|
return this.controller(index, phase, bezier_curve, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
controller(
|
||||||
|
index: number,
|
||||||
|
phase: number,
|
||||||
|
controller: (length: number, angle: number, ...args: number[]) => number[],
|
||||||
|
...args: number[]
|
||||||
|
) {
|
||||||
|
let length = this.step_length / 2
|
||||||
|
let angle = Math.atan2(this.gait_state.step_z, this.step_length) * 2
|
||||||
|
const delta_pos = controller(length, angle, ...args, phase)
|
||||||
|
|
||||||
|
const kin = this.kinematic
|
||||||
|
length = this.gait_state.step_angle * kin.max_step_length
|
||||||
|
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
|
||||||
|
|
||||||
|
const delta_rot = controller(length, angle, ...args, phase)
|
||||||
|
|
||||||
|
this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2
|
||||||
|
this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2
|
||||||
|
if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle)
|
||||||
|
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2
|
||||||
|
|
||||||
|
return this.body_state.feet[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
update_cumulative_position() {
|
||||||
|
if (this.last_body_state === null) {
|
||||||
|
this.last_body_state = { ...this.body_state }
|
||||||
|
this.body_state.cumulative_x = 0
|
||||||
|
this.body_state.cumulative_y = 0
|
||||||
|
this.body_state.cumulative_z = 0
|
||||||
|
this.body_state.cumulative_roll = 0
|
||||||
|
this.body_state.cumulative_pitch = 0
|
||||||
|
this.body_state.cumulative_yaw = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = this.gait_state
|
||||||
|
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
|
||||||
|
|
||||||
|
if (moving) {
|
||||||
|
const step_displacement_x_local =
|
||||||
|
m.step_x * m.step_velocity * this.dt * this.speed_factor
|
||||||
|
const step_displacement_z_local =
|
||||||
|
m.step_z * m.step_velocity * this.dt * this.speed_factor
|
||||||
|
const step_displacement_yaw =
|
||||||
|
m.step_angle * m.step_velocity * this.dt * this.speed_factor
|
||||||
|
|
||||||
|
const cos_yaw = Math.cos(this.cumulative_orientation.yaw)
|
||||||
|
const sin_yaw = Math.sin(this.cumulative_orientation.yaw)
|
||||||
|
const step_displacement_x =
|
||||||
|
step_displacement_x_local * cos_yaw - step_displacement_z_local * sin_yaw
|
||||||
|
const step_displacement_z =
|
||||||
|
step_displacement_x_local * sin_yaw + step_displacement_z_local * cos_yaw
|
||||||
|
|
||||||
|
this.cumulative_position.x += step_displacement_x
|
||||||
|
this.cumulative_position.z += step_displacement_z
|
||||||
|
this.cumulative_orientation.yaw += step_displacement_yaw
|
||||||
|
}
|
||||||
|
|
||||||
|
this.body_state.cumulative_x = this.cumulative_position.x
|
||||||
|
this.body_state.cumulative_y = this.cumulative_position.y
|
||||||
|
this.body_state.cumulative_z = this.cumulative_position.z
|
||||||
|
this.body_state.cumulative_roll = this.cumulative_orientation.roll
|
||||||
|
this.body_state.cumulative_pitch = this.cumulative_orientation.pitch
|
||||||
|
this.body_state.cumulative_yaw = this.cumulative_orientation.yaw
|
||||||
|
|
||||||
|
this.last_body_state = { ...this.body_state }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => {
|
const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => {
|
||||||
const X_POLAR = Math.cos(angle)
|
const X_POLAR = Math.cos(angle)
|
||||||
const Y_POLAR = Math.sin(angle)
|
const Y_POLAR = Math.sin(angle)
|
||||||
|
|
||||||
const step = length * (1 - 2 * phase)
|
const step = length * (1 - 2 * phase)
|
||||||
const X = step * X_POLAR
|
const X = step * X_POLAR
|
||||||
const Z = step * Y_POLAR
|
const Z = step * Y_POLAR
|
||||||
let Y = 0
|
let Y = 0
|
||||||
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
|
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
|
||||||
return [X, Y, Z]
|
return [X, Y, Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
const yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => {
|
const yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => {
|
||||||
const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2)
|
const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2)
|
||||||
const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0])
|
const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0])
|
||||||
const offsets = [
|
const offsets = [
|
||||||
current_foot_pos[0] - default_foot_pos[0],
|
current_foot_pos[0] - default_foot_pos[0],
|
||||||
current_foot_pos[2] - default_foot_pos[2],
|
current_foot_pos[2] - default_foot_pos[2],
|
||||||
current_foot_pos[1] - default_foot_pos[1]
|
current_foot_pos[1] - default_foot_pos[1]
|
||||||
]
|
]
|
||||||
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2)
|
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2)
|
||||||
const offset_mod = Math.atan2(offset_mag, foot_mag)
|
const offset_mod = Math.atan2(offset_mag, foot_mag)
|
||||||
|
|
||||||
return Math.PI / 2.0 + foot_dir + offset_mod
|
return Math.PI / 2.0 + foot_dir + offset_mod
|
||||||
}
|
}
|
||||||
|
|
||||||
const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => {
|
const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => {
|
||||||
const control_points = get_control_points(length, angle, height)
|
const control_points = get_control_points(length, angle, height)
|
||||||
const n = control_points.length - 1
|
const n = control_points.length - 1
|
||||||
|
|
||||||
const point = [0, 0, 0]
|
const point = [0, 0, 0]
|
||||||
for (let i = 0; i <= n; i++) {
|
for (let i = 0; i <= n; i++) {
|
||||||
const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i)
|
const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i)
|
||||||
point[0] += bernstein_poly * control_points[i][0]
|
point[0] += bernstein_poly * control_points[i][0]
|
||||||
point[1] += bernstein_poly * control_points[i][1]
|
point[1] += bernstein_poly * control_points[i][1]
|
||||||
point[2] += bernstein_poly * control_points[i][2]
|
point[2] += bernstein_poly * control_points[i][2]
|
||||||
}
|
}
|
||||||
return point
|
return point
|
||||||
}
|
}
|
||||||
|
|
||||||
const get_control_points = (length: number, angle: number, height: number): number[][] => {
|
const get_control_points = (length: number, angle: number, height: number): number[][] => {
|
||||||
const X_POLAR = Math.cos(angle)
|
const X_POLAR = Math.cos(angle)
|
||||||
const Z_POLAR = Math.sin(angle)
|
const Z_POLAR = Math.sin(angle)
|
||||||
|
|
||||||
const STEP = [
|
const STEP = [
|
||||||
-length,
|
-length,
|
||||||
-length * 1.4,
|
-length * 1.4,
|
||||||
-length * 1.5,
|
-length * 1.5,
|
||||||
-length * 1.5,
|
-length * 1.5,
|
||||||
-length * 1.5,
|
-length * 1.5,
|
||||||
0.0,
|
0.0,
|
||||||
0.0,
|
0.0,
|
||||||
0.0,
|
0.0,
|
||||||
length * 1.5,
|
length * 1.5,
|
||||||
length * 1.5,
|
length * 1.5,
|
||||||
length * 1.4,
|
length * 1.4,
|
||||||
length
|
length
|
||||||
]
|
]
|
||||||
|
|
||||||
const Y = [
|
const Y = [
|
||||||
0.0,
|
0.0,
|
||||||
0.0,
|
0.0,
|
||||||
height * 0.9,
|
height * 0.9,
|
||||||
height * 0.9,
|
height * 0.9,
|
||||||
height * 0.9,
|
height * 0.9,
|
||||||
height * 0.9,
|
height * 0.9,
|
||||||
height * 0.9,
|
height * 0.9,
|
||||||
height * 1.1,
|
height * 1.1,
|
||||||
height * 1.1,
|
height * 1.1,
|
||||||
height * 1.1,
|
height * 1.1,
|
||||||
0.0,
|
0.0,
|
||||||
0.0
|
0.0
|
||||||
]
|
]
|
||||||
|
|
||||||
const control_points: number[][] = []
|
const control_points: number[][] = []
|
||||||
|
|
||||||
for (let i = 0; i < STEP.length; i++) {
|
for (let i = 0; i < STEP.length; i++) {
|
||||||
const X = STEP[i] * X_POLAR
|
const X = STEP[i] * X_POLAR
|
||||||
const Z = STEP[i] * Z_POLAR
|
const Z = STEP[i] * Z_POLAR
|
||||||
control_points.push([X, Y[i], Z])
|
control_points.push([X, Y[i], Z])
|
||||||
}
|
}
|
||||||
|
|
||||||
return control_points
|
return control_points
|
||||||
}
|
}
|
||||||
|
|
||||||
const comb = (n: number, k: number): number => {
|
const comb = (n: number, k: number): number => {
|
||||||
if (k < 0 || k > n) return 0
|
if (k < 0 || k > n) return 0
|
||||||
if (k === 0 || k === n) return 1
|
if (k === 0 || k === n) return 1
|
||||||
k = Math.min(k, n - k)
|
k = Math.min(k, n - k)
|
||||||
let c = 1
|
let c = 1
|
||||||
for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1)
|
for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-109
@@ -1,32 +1,38 @@
|
|||||||
export interface body_state_t {
|
export interface body_state_t {
|
||||||
omega: number
|
omega: number
|
||||||
phi: number
|
phi: number
|
||||||
psi: number
|
psi: number
|
||||||
xm: number
|
xm: number
|
||||||
ym: number
|
ym: number
|
||||||
zm: number
|
zm: number
|
||||||
feet: number[][]
|
feet: number[][]
|
||||||
|
cumulative_x: number
|
||||||
|
cumulative_y: number
|
||||||
|
cumulative_z: number
|
||||||
|
cumulative_roll: number
|
||||||
|
cumulative_pitch: number
|
||||||
|
cumulative_yaw: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface position {
|
export interface position {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
z: number
|
z: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface target_position {
|
export interface target_position {
|
||||||
x: number
|
x: number
|
||||||
z: number
|
z: number
|
||||||
yaw: number
|
yaw: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KinematicParams {
|
export interface KinematicParams {
|
||||||
coxa: number
|
coxa: number
|
||||||
coxa_offset: number
|
coxa_offset: number
|
||||||
femur: number
|
femur: number
|
||||||
tibia: number
|
tibia: number
|
||||||
L: number
|
L: number
|
||||||
W: number
|
W: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cos, sin, atan2, acos, sqrt, max, min } = Math
|
const { cos, sin, atan2, acos, sqrt, max, min } = Math
|
||||||
@@ -34,107 +40,145 @@ const { cos, sin, atan2, acos, sqrt, max, min } = Math
|
|||||||
const DEG2RAD = 0.017453292519943
|
const DEG2RAD = 0.017453292519943
|
||||||
|
|
||||||
export default class Kinematic {
|
export default class Kinematic {
|
||||||
coxa: number
|
coxa: number
|
||||||
coxa_offset: number
|
coxa_offset: number
|
||||||
femur: number
|
femur: number
|
||||||
tibia: number
|
tibia: number
|
||||||
|
|
||||||
L: number
|
L: number
|
||||||
W: number
|
W: number
|
||||||
|
|
||||||
DEG2RAD = DEG2RAD
|
DEG2RAD = DEG2RAD
|
||||||
|
|
||||||
mountOffsets: number[][]
|
max_roll: number
|
||||||
|
max_pitch: number
|
||||||
|
max_body_shift_x: number
|
||||||
|
max_body_shift_z: number
|
||||||
|
max_leg_reach: number
|
||||||
|
min_body_height: number
|
||||||
|
max_body_height: number
|
||||||
|
body_height_range: number
|
||||||
|
max_step_length: number
|
||||||
|
max_step_height: number
|
||||||
|
default_step_depth: number
|
||||||
|
default_body_height: number
|
||||||
|
default_step_height: number
|
||||||
|
|
||||||
invMountRot = [
|
mountOffsets: number[][]
|
||||||
[0, 0, -1],
|
default_feet_positions: number[][]
|
||||||
[0, 1, 0],
|
|
||||||
[1, 0, 0]
|
|
||||||
]
|
|
||||||
|
|
||||||
constructor(params: KinematicParams) {
|
invMountRot = [
|
||||||
this.coxa = params.coxa
|
[0, 0, -1],
|
||||||
this.coxa_offset = params.coxa_offset
|
[0, 1, 0],
|
||||||
this.femur = params.femur
|
[1, 0, 0]
|
||||||
this.tibia = params.tibia
|
|
||||||
this.L = params.L
|
|
||||||
this.W = params.W
|
|
||||||
|
|
||||||
this.mountOffsets = [
|
|
||||||
[this.L / 2, 0, this.W / 2],
|
|
||||||
[this.L / 2, 0, -this.W / 2],
|
|
||||||
[-this.L / 2, 0, this.W / 2],
|
|
||||||
[-this.L / 2, 0, -this.W / 2]
|
|
||||||
]
|
]
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultFeetPos(): number[][] {
|
constructor(params: KinematicParams) {
|
||||||
return this.mountOffsets.map((offset, i) => {
|
this.coxa = params.coxa
|
||||||
return [offset[0], -1, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)]
|
this.coxa_offset = params.coxa_offset
|
||||||
})
|
this.femur = params.femur
|
||||||
}
|
this.tibia = params.tibia
|
||||||
|
this.L = params.L
|
||||||
|
this.W = params.W
|
||||||
|
|
||||||
calcIK(p: body_state_t): number[] {
|
this.max_roll = 15 * (Math.PI / 2)
|
||||||
const roll = p.omega * this.DEG2RAD
|
this.max_pitch = 15 * (Math.PI / 2)
|
||||||
const pitch = p.phi * this.DEG2RAD
|
this.max_body_shift_x = this.W / 3
|
||||||
const yaw = p.psi * this.DEG2RAD
|
this.max_body_shift_z = this.W / 3
|
||||||
const rot = this.euler2R(roll, pitch, yaw)
|
this.max_leg_reach = this.femur + this.tibia - this.coxa_offset
|
||||||
const inv_rot = [
|
this.min_body_height = this.max_leg_reach * 0.45
|
||||||
[rot[0][0], rot[1][0], rot[2][0]],
|
this.max_body_height = this.max_leg_reach * 1
|
||||||
[rot[0][1], rot[1][1], rot[2][1]],
|
this.body_height_range = this.max_body_height - this.min_body_height
|
||||||
[rot[0][2], rot[1][2], rot[2][2]]
|
this.max_step_length = this.max_leg_reach * 0.8
|
||||||
]
|
this.max_step_height = this.max_leg_reach / 2
|
||||||
const inv_trans = [
|
this.default_step_depth = 0.002
|
||||||
-inv_rot[0][0] * p.xm - inv_rot[0][1] * p.ym - inv_rot[0][2] * p.zm,
|
this.default_body_height = this.min_body_height + this.body_height_range / 2
|
||||||
-inv_rot[1][0] * p.xm - inv_rot[1][1] * p.ym - inv_rot[1][2] * p.zm,
|
this.default_step_height = this.default_body_height / 2
|
||||||
-inv_rot[2][0] * p.xm - inv_rot[2][1] * p.ym - inv_rot[2][2] * p.zm
|
|
||||||
]
|
|
||||||
return p.feet.flatMap((foot, i) => {
|
|
||||||
const [wx, wy, wz] = foot
|
|
||||||
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
|
|
||||||
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
|
|
||||||
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
|
|
||||||
|
|
||||||
const [mx, my, mz] = this.mountOffsets[i]
|
this.mountOffsets = [
|
||||||
const px = bx - mx,
|
[this.L / 2, 0, this.W / 2],
|
||||||
py = by - my,
|
[this.L / 2, 0, -this.W / 2],
|
||||||
pz = bz - mz
|
[-this.L / 2, 0, this.W / 2],
|
||||||
|
[-this.L / 2, 0, -this.W / 2]
|
||||||
|
]
|
||||||
|
|
||||||
const lx =
|
this.default_feet_positions = this.mountOffsets.map((offset, i) => {
|
||||||
this.invMountRot[0][0] * px + this.invMountRot[0][1] * py + this.invMountRot[0][2] * pz
|
return [offset[0], 0, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)]
|
||||||
const ly =
|
})
|
||||||
this.invMountRot[1][0] * px + this.invMountRot[1][1] * py + this.invMountRot[1][2] * pz
|
}
|
||||||
const lz =
|
|
||||||
this.invMountRot[2][0] * px + this.invMountRot[2][1] * py + this.invMountRot[2][2] * pz
|
|
||||||
|
|
||||||
const xLocal = i % 2 === 1 ? -lx : lx
|
getDefaultFeetPos(): number[][] {
|
||||||
return this.legIK(xLocal, ly, lz)
|
return this.default_feet_positions.map(pos => [...pos])
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private legIK(x: number, y: number, z: number): [number, number, number] {
|
calcIK(p: body_state_t): number[] {
|
||||||
const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa))
|
const roll = p.omega * this.DEG2RAD
|
||||||
const G = F - this.coxa_offset
|
const pitch = p.phi * this.DEG2RAD
|
||||||
const H = sqrt(G * G + z * z)
|
const yaw = p.psi * this.DEG2RAD
|
||||||
const t1 = -atan2(y, x) - atan2(F, -this.coxa)
|
const rot = this.euler2R(roll, pitch, yaw)
|
||||||
const D =
|
const inv_rot = [
|
||||||
(H * H - this.femur * this.femur - this.tibia * this.tibia) / (2 * this.femur * this.tibia)
|
[rot[0][0], rot[1][0], rot[2][0]],
|
||||||
const t3 = acos(max(-1, min(1, D)))
|
[rot[0][1], rot[1][1], rot[2][1]],
|
||||||
const t2 = atan2(z, G) - atan2(this.tibia * sin(t3), this.femur + this.tibia * cos(t3))
|
[rot[0][2], rot[1][2], rot[2][2]]
|
||||||
return [t1, t2, t3]
|
]
|
||||||
}
|
const inv_trans = [
|
||||||
|
-inv_rot[0][0] * p.xm - inv_rot[0][1] * p.ym - inv_rot[0][2] * p.zm,
|
||||||
|
-inv_rot[1][0] * p.xm - inv_rot[1][1] * p.ym - inv_rot[1][2] * p.zm,
|
||||||
|
-inv_rot[2][0] * p.xm - inv_rot[2][1] * p.ym - inv_rot[2][2] * p.zm
|
||||||
|
]
|
||||||
|
return p.feet.flatMap((foot, i) => {
|
||||||
|
const [wx, wy, wz] = foot
|
||||||
|
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
|
||||||
|
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
|
||||||
|
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
|
||||||
|
|
||||||
private euler2R(roll: number, pitch: number, yaw: number): number[][] {
|
const [mx, my, mz] = this.mountOffsets[i]
|
||||||
const cr = cos(roll),
|
const px = bx - mx,
|
||||||
sr = sin(roll)
|
py = by - my,
|
||||||
const cp = cos(pitch),
|
pz = bz - mz
|
||||||
sp = sin(pitch)
|
|
||||||
const cy = cos(yaw),
|
const lx =
|
||||||
sy = sin(yaw)
|
this.invMountRot[0][0] * px +
|
||||||
return [
|
this.invMountRot[0][1] * py +
|
||||||
[cp * cy, -cp * sy, sp],
|
this.invMountRot[0][2] * pz
|
||||||
[sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp],
|
const ly =
|
||||||
[sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp]
|
this.invMountRot[1][0] * px +
|
||||||
]
|
this.invMountRot[1][1] * py +
|
||||||
}
|
this.invMountRot[1][2] * pz
|
||||||
|
const lz =
|
||||||
|
this.invMountRot[2][0] * px +
|
||||||
|
this.invMountRot[2][1] * py +
|
||||||
|
this.invMountRot[2][2] * pz
|
||||||
|
|
||||||
|
const xLocal = i % 2 === 1 ? -lx : lx
|
||||||
|
return this.legIK(xLocal, ly, lz)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private legIK(x: number, y: number, z: number): [number, number, number] {
|
||||||
|
const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa))
|
||||||
|
const G = F - this.coxa_offset
|
||||||
|
const H = sqrt(G * G + z * z)
|
||||||
|
const t1 = -atan2(y, x) - atan2(F, -this.coxa)
|
||||||
|
const D =
|
||||||
|
(H * H - this.femur * this.femur - this.tibia * this.tibia) /
|
||||||
|
(2 * this.femur * this.tibia)
|
||||||
|
const t3 = acos(max(-1, min(1, D)))
|
||||||
|
const t2 = atan2(z, G) - atan2(this.tibia * sin(t3), this.femur + this.tibia * cos(t3))
|
||||||
|
return [t1, t2, t3]
|
||||||
|
}
|
||||||
|
|
||||||
|
private euler2R(roll: number, pitch: number, yaw: number): number[][] {
|
||||||
|
const cr = cos(roll),
|
||||||
|
sr = sin(roll)
|
||||||
|
const cp = cos(pitch),
|
||||||
|
sp = sin(pitch)
|
||||||
|
const cy = cos(yaw),
|
||||||
|
sy = sin(yaw)
|
||||||
|
return [
|
||||||
|
[cp * cy, -cp * sy, sp],
|
||||||
|
[sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp],
|
||||||
|
[sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp]
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+313
-345
@@ -1,380 +1,348 @@
|
|||||||
import {
|
import {
|
||||||
Mesh,
|
Mesh,
|
||||||
PerspectiveCamera,
|
PerspectiveCamera,
|
||||||
PlaneGeometry,
|
PlaneGeometry,
|
||||||
Scene,
|
Scene,
|
||||||
WebGLRenderer,
|
WebGLRenderer,
|
||||||
AmbientLight,
|
AmbientLight,
|
||||||
DirectionalLight,
|
DirectionalLight,
|
||||||
PCFSoftShadowMap,
|
PCFSoftShadowMap,
|
||||||
type GridHelper,
|
type GridHelper,
|
||||||
ArrowHelper,
|
ArrowHelper,
|
||||||
Vector3,
|
Vector3,
|
||||||
FogExp2,
|
FogExp2,
|
||||||
CanvasTexture,
|
CanvasTexture,
|
||||||
type ColorRepresentation,
|
type ColorRepresentation,
|
||||||
type WebGLRendererParameters,
|
type WebGLRendererParameters,
|
||||||
MeshPhongMaterial,
|
MeshPhongMaterial,
|
||||||
EquirectangularReflectionMapping,
|
EquirectangularReflectionMapping,
|
||||||
ACESFilmicToneMapping,
|
ACESFilmicToneMapping,
|
||||||
MathUtils,
|
Group,
|
||||||
Group,
|
MeshBasicMaterial,
|
||||||
MeshBasicMaterial,
|
RepeatWrapping,
|
||||||
RepeatWrapping
|
type Object3D
|
||||||
} from 'three'
|
} from 'three'
|
||||||
import { Sky } from 'three/addons/objects/Sky.js'
|
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||||
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
||||||
import { Reflector } from 'three/examples/jsm/objects/Reflector.js'
|
import { Reflector } from 'three/examples/jsm/objects/Reflector.js'
|
||||||
import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader'
|
import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader'
|
||||||
import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls'
|
import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls'
|
||||||
import { sunCalculator } from './utilities/position-utilities'
|
|
||||||
|
|
||||||
export const addScene = () => new Scene()
|
export const addScene = () => new Scene()
|
||||||
|
|
||||||
interface position {
|
interface position {
|
||||||
x?: number
|
x?: number
|
||||||
y?: number
|
y?: number
|
||||||
z?: number
|
z?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface light {
|
interface light {
|
||||||
color?: ColorRepresentation
|
color?: ColorRepresentation
|
||||||
intensity?: number
|
intensity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface arrowOptions {
|
interface arrowOptions {
|
||||||
origin: position
|
origin: position
|
||||||
direction: position
|
direction: position
|
||||||
length?: number
|
length?: number
|
||||||
color?: ColorRepresentation
|
color?: ColorRepresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
type directionalLight = position & light
|
type directionalLight = position & light
|
||||||
|
|
||||||
export default class SceneBuilder {
|
export default class SceneBuilder {
|
||||||
public scene: Scene
|
public scene: Scene
|
||||||
public camera!: PerspectiveCamera
|
public camera!: PerspectiveCamera
|
||||||
public ground!: Mesh
|
public ground!: Mesh
|
||||||
public renderer!: WebGLRenderer
|
public renderer!: WebGLRenderer
|
||||||
public orbit: OrbitControls
|
public orbit: OrbitControls
|
||||||
public callback: Function | undefined
|
public callback: (() => void) | undefined
|
||||||
public gridHelper!: GridHelper
|
public gridHelper!: GridHelper
|
||||||
public model!: URDFRobot
|
public model!: URDFRobot
|
||||||
public liveStreamTexture!: CanvasTexture
|
public liveStreamTexture!: CanvasTexture
|
||||||
private fog!: FogExp2
|
private fog!: FogExp2
|
||||||
private isLoaded: boolean = false
|
private isLoaded: boolean = false
|
||||||
public isDragging: boolean = false
|
public isDragging: boolean = false
|
||||||
highlightMaterial: any
|
transformControl: TransformControls
|
||||||
sky!: Sky
|
public modelGroup!: Group
|
||||||
transformControl: TransformControls
|
|
||||||
public modelGroup!: Group
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.scene = new Scene()
|
this.scene = new Scene()
|
||||||
if (this.scene.environment?.mapping) {
|
if (this.scene.environment?.mapping) {
|
||||||
this.scene.environment.mapping = EquirectangularReflectionMapping
|
this.scene.environment.mapping = EquirectangularReflectionMapping
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addRenderer = (parameters?: WebGLRendererParameters) => {
|
|
||||||
this.renderer = new WebGLRenderer(parameters)
|
|
||||||
this.renderer.outputColorSpace = 'srgb'
|
|
||||||
this.renderer.shadowMap.enabled = true
|
|
||||||
this.renderer.shadowMap.type = PCFSoftShadowMap
|
|
||||||
this.renderer.toneMapping = ACESFilmicToneMapping
|
|
||||||
this.renderer.toneMappingExposure = 0.85
|
|
||||||
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addSky = () => {
|
|
||||||
this.sky = new Sky()
|
|
||||||
this.sky.scale.setScalar(450000)
|
|
||||||
this.scene.add(this.sky)
|
|
||||||
const effectController = {
|
|
||||||
turbidity: 10,
|
|
||||||
rayleigh: 3,
|
|
||||||
mieCoefficient: 0.005,
|
|
||||||
mieDirectionalG: 0.7,
|
|
||||||
elevation: sunCalculator.calculateSunElevation(),
|
|
||||||
azimuth: 200,
|
|
||||||
exposure: this.renderer.toneMappingExposure
|
|
||||||
}
|
|
||||||
const uniforms = this.sky.material.uniforms
|
|
||||||
uniforms['turbidity'].value = effectController.turbidity
|
|
||||||
uniforms['rayleigh'].value = effectController.rayleigh
|
|
||||||
uniforms['mieCoefficient'].value = effectController.mieCoefficient
|
|
||||||
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG
|
|
||||||
this.renderer.toneMappingExposure = 0.5
|
|
||||||
const phi = MathUtils.degToRad(90 - effectController.elevation)
|
|
||||||
const theta = MathUtils.degToRad(effectController.azimuth)
|
|
||||||
const sun = new Vector3()
|
|
||||||
|
|
||||||
sun.setFromSphericalCoords(1, phi, theta)
|
|
||||||
uniforms['sunPosition'].value.copy(sun)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addPerspectiveCamera = (options: position) => {
|
|
||||||
this.camera = new PerspectiveCamera()
|
|
||||||
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0)
|
|
||||||
this.scene.add(this.camera)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addGroundPlane = (options?: position) => {
|
|
||||||
const checkerboardTexture = this.createCheckerboardTexture(1024, 2)
|
|
||||||
checkerboardTexture.wrapS = RepeatWrapping
|
|
||||||
checkerboardTexture.wrapT = RepeatWrapping
|
|
||||||
checkerboardTexture.repeat.set(100, 100)
|
|
||||||
const checkerboardMat = new MeshBasicMaterial({
|
|
||||||
map: checkerboardTexture,
|
|
||||||
opacity: 0.1,
|
|
||||||
transparent: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const plane = new PlaneGeometry(400, 400)
|
|
||||||
|
|
||||||
this.ground = new Mesh(plane, checkerboardMat)
|
|
||||||
this.ground.rotation.x = -Math.PI / 2
|
|
||||||
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0)
|
|
||||||
this.ground.receiveShadow = true
|
|
||||||
this.scene.add(this.ground)
|
|
||||||
|
|
||||||
const mirror = new Reflector(plane, {
|
|
||||||
clipBias: 0.003,
|
|
||||||
textureWidth: window.innerWidth * window.devicePixelRatio,
|
|
||||||
textureHeight: window.innerHeight * window.devicePixelRatio,
|
|
||||||
color: 0x00bfff
|
|
||||||
})
|
|
||||||
mirror.rotateX(-Math.PI / 2)
|
|
||||||
this.scene.add(mirror)
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
|
|
||||||
this.orbit = new OrbitControls(this.camera, this.renderer.domElement)
|
|
||||||
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2
|
|
||||||
this.orbit.maxDistance = maxDistance
|
|
||||||
this.orbit.autoRotate = autoRotate
|
|
||||||
this.orbit.update()
|
|
||||||
this.orbit.minDistance = minDistance
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addAmbientLight = (options: light) => {
|
|
||||||
const ambientLight = new AmbientLight(options.color, options.intensity)
|
|
||||||
this.scene.add(ambientLight)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addDirectionalLight = (options: directionalLight) => {
|
|
||||||
const directionalLight = new DirectionalLight(options.color, options.intensity)
|
|
||||||
directionalLight.castShadow = true
|
|
||||||
directionalLight.shadow.camera.top = 10
|
|
||||||
directionalLight.shadow.camera.bottom = -10
|
|
||||||
directionalLight.shadow.camera.right = 10
|
|
||||||
directionalLight.shadow.camera.left = -10
|
|
||||||
directionalLight.shadow.mapSize.set(4096, 4096)
|
|
||||||
|
|
||||||
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0)
|
|
||||||
this.scene.add(directionalLight)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
private createCheckerboardTexture = (size: number, squares: number) => {
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
canvas.width = size
|
|
||||||
canvas.height = size
|
|
||||||
const context = canvas.getContext('2d')
|
|
||||||
|
|
||||||
const squareSize = size / squares
|
|
||||||
|
|
||||||
for (let y = 0; y < squares; y++) {
|
|
||||||
for (let x = 0; x < squares; x++) {
|
|
||||||
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000'
|
|
||||||
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const texture = new CanvasTexture(canvas)
|
|
||||||
texture.wrapS = texture.wrapT = RepeatWrapping
|
|
||||||
texture.anisotropy = 16
|
|
||||||
return texture
|
|
||||||
}
|
|
||||||
|
|
||||||
public addFogExp2 = (color: ColorRepresentation, density?: number) => {
|
|
||||||
this.scene.fog = new FogExp2(color, density)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public fillParent = () => {
|
|
||||||
const parentElement = this.renderer.domElement.parentElement
|
|
||||||
if (parentElement) {
|
|
||||||
const width = parentElement.clientWidth
|
|
||||||
const height = parentElement.clientHeight
|
|
||||||
this.handleResize(width, height)
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
|
|
||||||
this.renderer.setSize(width, height)
|
|
||||||
this.renderer.setPixelRatio(window.devicePixelRatio)
|
|
||||||
this.camera.aspect = width / height
|
|
||||||
this.camera.updateProjectionMatrix()
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addRenderCb = (callback: Function) => {
|
|
||||||
this.callback = callback
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public startRenderLoop = () => {
|
|
||||||
this.renderer.setAnimationLoop(() => {
|
|
||||||
this.renderer.render(this.scene, this.camera)
|
|
||||||
this.orbit.update()
|
|
||||||
this.handleRobotShadow()
|
|
||||||
if (this.callback) this.callback()
|
|
||||||
if (!this.liveStreamTexture) return
|
|
||||||
})
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addArrowHelper = (options?: arrowOptions) => {
|
|
||||||
const dir = new Vector3(
|
|
||||||
options?.direction.x ?? 0,
|
|
||||||
options?.direction.y ?? 0,
|
|
||||||
options?.direction.z ?? 0
|
|
||||||
)
|
|
||||||
const origin = new Vector3(
|
|
||||||
options?.origin.x ?? 0,
|
|
||||||
options?.origin.y ?? 0,
|
|
||||||
options?.origin.z ?? 0
|
|
||||||
)
|
|
||||||
const arrowHelper = new ArrowHelper(
|
|
||||||
dir,
|
|
||||||
origin,
|
|
||||||
options?.length ?? 1.5,
|
|
||||||
options?.color ?? 0xff0000
|
|
||||||
)
|
|
||||||
this.scene.add(arrowHelper)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
private setJointValue(jointName: string, angle: number) {
|
|
||||||
if (!this.model) return
|
|
||||||
if (!this.model.joints[jointName]) return
|
|
||||||
this.model.joints[jointName].setJointValue(angle)
|
|
||||||
}
|
|
||||||
|
|
||||||
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'
|
|
||||||
|
|
||||||
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
|
|
||||||
const traverse = (c: any) => {
|
|
||||||
if (c.type === 'Mesh') {
|
|
||||||
if (revert) {
|
|
||||||
c.material = c.__origMaterial
|
|
||||||
delete c.__origMaterial
|
|
||||||
} else {
|
|
||||||
c.__origMaterial = c.material
|
|
||||||
c.material = material
|
|
||||||
}
|
}
|
||||||
}
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
if (c === m || !this.isJoint(c)) {
|
public addRenderer = (parameters?: WebGLRendererParameters) => {
|
||||||
for (let i = 0; i < c.children.length; i++) {
|
this.renderer = new WebGLRenderer(parameters)
|
||||||
const child = c.children[i]
|
this.renderer.outputColorSpace = 'srgb'
|
||||||
if (!child.isURDFCollider) {
|
this.renderer.shadowMap.enabled = true
|
||||||
traverse(c.children[i])
|
this.renderer.shadowMap.type = PCFSoftShadowMap
|
||||||
}
|
this.renderer.toneMapping = ACESFilmicToneMapping
|
||||||
|
this.renderer.toneMappingExposure = 0.85
|
||||||
|
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public addPerspectiveCamera = (options: position) => {
|
||||||
|
this.camera = new PerspectiveCamera()
|
||||||
|
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0)
|
||||||
|
this.scene.add(this.camera)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public addGroundPlane = (options?: position) => {
|
||||||
|
const checkerboardTexture = this.createCheckerboardTexture(1024, 2)
|
||||||
|
checkerboardTexture.wrapS = RepeatWrapping
|
||||||
|
checkerboardTexture.wrapT = RepeatWrapping
|
||||||
|
checkerboardTexture.repeat.set(100, 100)
|
||||||
|
const checkerboardMat = new MeshBasicMaterial({
|
||||||
|
map: checkerboardTexture,
|
||||||
|
opacity: 0.1,
|
||||||
|
transparent: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const plane = new PlaneGeometry(400, 400)
|
||||||
|
|
||||||
|
this.ground = new Mesh(plane, checkerboardMat)
|
||||||
|
this.ground.rotation.x = -Math.PI / 2
|
||||||
|
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0)
|
||||||
|
this.ground.receiveShadow = true
|
||||||
|
this.scene.add(this.ground)
|
||||||
|
|
||||||
|
const mirror = new Reflector(plane, {
|
||||||
|
clipBias: 0.003,
|
||||||
|
textureWidth: window.innerWidth * window.devicePixelRatio,
|
||||||
|
textureHeight: window.innerHeight * window.devicePixelRatio,
|
||||||
|
color: 0x00bfff
|
||||||
|
})
|
||||||
|
mirror.rotateX(-Math.PI / 2)
|
||||||
|
this.scene.add(mirror)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
|
||||||
|
this.orbit = new OrbitControls(this.camera, this.renderer.domElement)
|
||||||
|
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2
|
||||||
|
this.orbit.maxDistance = maxDistance
|
||||||
|
this.orbit.autoRotate = autoRotate
|
||||||
|
this.orbit.update()
|
||||||
|
this.orbit.minDistance = minDistance
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public addAmbientLight = (options: light) => {
|
||||||
|
const ambientLight = new AmbientLight(options.color, options.intensity)
|
||||||
|
this.scene.add(ambientLight)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public addDirectionalLight = (options: directionalLight) => {
|
||||||
|
const directionalLight = new DirectionalLight(options.color, options.intensity)
|
||||||
|
directionalLight.castShadow = true
|
||||||
|
directionalLight.shadow.camera.top = 10
|
||||||
|
directionalLight.shadow.camera.bottom = -10
|
||||||
|
directionalLight.shadow.camera.right = 10
|
||||||
|
directionalLight.shadow.camera.left = -10
|
||||||
|
directionalLight.shadow.mapSize.set(4096, 4096)
|
||||||
|
|
||||||
|
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0)
|
||||||
|
this.scene.add(directionalLight)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCheckerboardTexture = (size: number, squares: number) => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = size
|
||||||
|
canvas.height = size
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const squareSize = size / squares
|
||||||
|
|
||||||
|
for (let y = 0; y < squares; y++) {
|
||||||
|
for (let x = 0; x < squares; x++) {
|
||||||
|
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000'
|
||||||
|
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const texture = new CanvasTexture(canvas)
|
||||||
|
texture.wrapS = texture.wrapT = RepeatWrapping
|
||||||
|
texture.anisotropy = 16
|
||||||
|
return texture
|
||||||
}
|
}
|
||||||
traverse(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
public addTransformControls = (model: any) => {
|
public addFogExp2 = (color: ColorRepresentation, density?: number) => {
|
||||||
this.transformControl = new TransformControls(this.camera, this.renderer.domElement)
|
this.scene.fog = new FogExp2(color, density)
|
||||||
this.transformControl.addEventListener('dragging-changed', (event: any) => {
|
return this
|
||||||
this.orbit.enabled = !event.value
|
|
||||||
this.isDragging = !event.value
|
|
||||||
})
|
|
||||||
this.transformControl.attach(model)
|
|
||||||
this.scene.add(this.transformControl)
|
|
||||||
this.transformControl.setMode('rotate')
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addModel = (model: any) => {
|
|
||||||
this.modelGroup = new Group()
|
|
||||||
this.modelGroup.add(model)
|
|
||||||
this.model = model
|
|
||||||
this.scene.add(this.modelGroup)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public addDragControl = (updateAngle: any) => {
|
|
||||||
const highlightColor = '#FFFFFF'
|
|
||||||
const highlightMaterial = new MeshPhongMaterial({
|
|
||||||
shininess: 10,
|
|
||||||
color: highlightColor,
|
|
||||||
emissive: highlightColor,
|
|
||||||
emissiveIntensity: 0.9
|
|
||||||
})
|
|
||||||
|
|
||||||
const dragControls = new PointerURDFDragControls(
|
|
||||||
this.scene,
|
|
||||||
this.camera,
|
|
||||||
this.renderer.domElement
|
|
||||||
)
|
|
||||||
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
|
|
||||||
this.setJointValue(joint.name, angle)
|
|
||||||
updateAngle(joint.name, angle)
|
|
||||||
}
|
}
|
||||||
dragControls.onDragStart = () => {
|
|
||||||
this.orbit.enabled = false
|
public fillParent = () => {
|
||||||
this.isDragging = true
|
const parentElement = this.renderer.domElement.parentElement
|
||||||
|
if (parentElement) {
|
||||||
|
const width = parentElement.clientWidth
|
||||||
|
const height = parentElement.clientHeight
|
||||||
|
this.handleResize(width, height)
|
||||||
|
}
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
dragControls.onDragEnd = () => {
|
|
||||||
this.orbit.enabled = true
|
public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
|
||||||
this.isDragging = false
|
this.renderer.setSize(width, height)
|
||||||
|
this.renderer.setPixelRatio(window.devicePixelRatio)
|
||||||
|
this.camera.aspect = width / height
|
||||||
|
this.camera.updateProjectionMatrix()
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
dragControls.onHover = (joint: URDFMimicJoint) =>
|
|
||||||
this.highlightLinkGeometry(joint, false, highlightMaterial)
|
|
||||||
dragControls.onUnhover = (joint: URDFMimicJoint) =>
|
|
||||||
this.highlightLinkGeometry(joint, true, highlightMaterial)
|
|
||||||
|
|
||||||
this.renderer.domElement.addEventListener(
|
public addRenderCb = (callback: () => void) => {
|
||||||
'touchstart',
|
this.callback = callback
|
||||||
data => dragControls._mouseDown(data.touches[0]),
|
return this
|
||||||
{ passive: true }
|
}
|
||||||
)
|
|
||||||
this.renderer.domElement.addEventListener(
|
|
||||||
'touchmove',
|
|
||||||
data => dragControls._mouseMove(data.touches[0]),
|
|
||||||
{ passive: true }
|
|
||||||
)
|
|
||||||
this.renderer.domElement.addEventListener(
|
|
||||||
'touchend',
|
|
||||||
data => dragControls._mouseUp(data.touches[0]),
|
|
||||||
{ passive: true }
|
|
||||||
)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
public toggleFog = () => {
|
public startRenderLoop = () => {
|
||||||
this.scene.fog = this.scene.fog ? null : this.fog
|
this.renderer.setAnimationLoop(() => {
|
||||||
}
|
this.renderer.render(this.scene, this.camera)
|
||||||
|
this.orbit.update()
|
||||||
|
this.handleRobotShadow()
|
||||||
|
if (this.callback) this.callback()
|
||||||
|
if (!this.liveStreamTexture) return
|
||||||
|
})
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
private handleRobotShadow = () => {
|
public addArrowHelper = (options?: arrowOptions) => {
|
||||||
if (this.isLoaded) return
|
const dir = new Vector3(
|
||||||
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10)
|
options?.direction.x ?? 0,
|
||||||
setTimeout(() => clearInterval(intervalId), 1000)
|
options?.direction.y ?? 0,
|
||||||
this.isLoaded = true
|
options?.direction.z ?? 0
|
||||||
}
|
)
|
||||||
|
const origin = new Vector3(
|
||||||
|
options?.origin.x ?? 0,
|
||||||
|
options?.origin.y ?? 0,
|
||||||
|
options?.origin.z ?? 0
|
||||||
|
)
|
||||||
|
const arrowHelper = new ArrowHelper(
|
||||||
|
dir,
|
||||||
|
origin,
|
||||||
|
options?.length ?? 1.5,
|
||||||
|
options?.color ?? 0xff0000
|
||||||
|
)
|
||||||
|
this.scene.add(arrowHelper)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
private setJointValue(jointName: string, angle: number) {
|
||||||
|
if (!this.model) return
|
||||||
|
if (!this.model.joints[jointName]) return
|
||||||
|
this.model.joints[jointName].setJointValue(angle)
|
||||||
|
}
|
||||||
|
|
||||||
|
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'
|
||||||
|
|
||||||
|
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
|
||||||
|
const traverse = (c: Object3D) => {
|
||||||
|
if (c.type === 'Mesh') {
|
||||||
|
if (revert) {
|
||||||
|
c.material = c.__origMaterial
|
||||||
|
delete c.__origMaterial
|
||||||
|
} else {
|
||||||
|
c.__origMaterial = c.material
|
||||||
|
c.material = material
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === m || !this.isJoint(c)) {
|
||||||
|
for (let i = 0; i < c.children.length; i++) {
|
||||||
|
const child = c.children[i]
|
||||||
|
if (!child.isURDFCollider) {
|
||||||
|
traverse(c.children[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traverse(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
public addTransformControls = (model: Object3D) => {
|
||||||
|
this.transformControl = new TransformControls(this.camera, this.renderer.domElement)
|
||||||
|
this.transformControl.addEventListener('dragging-changed', (event: { value: boolean }) => {
|
||||||
|
this.orbit.enabled = !event.value
|
||||||
|
this.isDragging = !event.value
|
||||||
|
})
|
||||||
|
this.transformControl.attach(model)
|
||||||
|
this.scene.add(this.transformControl)
|
||||||
|
this.transformControl.setMode('rotate')
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public addModel = (model: URDFRobot) => {
|
||||||
|
this.modelGroup = new Group()
|
||||||
|
this.modelGroup.add(model)
|
||||||
|
this.model = model
|
||||||
|
this.scene.add(this.modelGroup)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public addDragControl = (updateAngle: (angles: Record<string, number>) => void) => {
|
||||||
|
const highlightColor = '#FFFFFF'
|
||||||
|
const highlightMaterial = new MeshPhongMaterial({
|
||||||
|
shininess: 10,
|
||||||
|
color: highlightColor,
|
||||||
|
emissive: highlightColor,
|
||||||
|
emissiveIntensity: 0.9
|
||||||
|
})
|
||||||
|
|
||||||
|
const dragControls = new PointerURDFDragControls(
|
||||||
|
this.scene,
|
||||||
|
this.camera,
|
||||||
|
this.renderer.domElement
|
||||||
|
)
|
||||||
|
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
|
||||||
|
this.setJointValue(joint.name, angle)
|
||||||
|
updateAngle({ [joint.name]: angle })
|
||||||
|
}
|
||||||
|
dragControls.onDragStart = () => {
|
||||||
|
this.orbit.enabled = false
|
||||||
|
this.isDragging = true
|
||||||
|
}
|
||||||
|
dragControls.onDragEnd = () => {
|
||||||
|
this.orbit.enabled = true
|
||||||
|
this.isDragging = false
|
||||||
|
}
|
||||||
|
dragControls.onHover = (joint: URDFMimicJoint) =>
|
||||||
|
this.highlightLinkGeometry(joint, false, highlightMaterial)
|
||||||
|
dragControls.onUnhover = (joint: URDFMimicJoint) =>
|
||||||
|
this.highlightLinkGeometry(joint, true, highlightMaterial)
|
||||||
|
|
||||||
|
this.renderer.domElement.addEventListener(
|
||||||
|
'touchstart',
|
||||||
|
data => dragControls._mouseDown(data.touches[0]),
|
||||||
|
{ passive: true }
|
||||||
|
)
|
||||||
|
this.renderer.domElement.addEventListener(
|
||||||
|
'touchmove',
|
||||||
|
data => dragControls._mouseMove(data.touches[0]),
|
||||||
|
{ passive: true }
|
||||||
|
)
|
||||||
|
this.renderer.domElement.addEventListener(
|
||||||
|
'touchend',
|
||||||
|
data => dragControls._mouseUp(data.touches[0]),
|
||||||
|
{ passive: true }
|
||||||
|
)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleFog = () => {
|
||||||
|
this.scene.fog = this.scene.fog ? null : this.fog
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRobotShadow = () => {
|
||||||
|
if (this.isLoaded) return
|
||||||
|
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10)
|
||||||
|
setTimeout(() => clearInterval(intervalId), 1000)
|
||||||
|
this.isLoaded = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,53 @@
|
|||||||
import { Result } from '$lib/utilities/result';
|
import { Result } from '$lib/utilities/result'
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment'
|
||||||
|
|
||||||
class FileService {
|
class FileService {
|
||||||
private dbPromise: Promise<Result<IDBDatabase, string>> | null = browser
|
private dbPromise: Promise<Result<IDBDatabase, string>> | null =
|
||||||
? this.openDatabase()
|
browser ? this.openDatabase() : null
|
||||||
: null;
|
|
||||||
|
|
||||||
private async openDatabase(): Promise<Result<IDBDatabase, string>> {
|
private async openDatabase(): Promise<Result<IDBDatabase, string>> {
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => {
|
||||||
const request = indexedDB.open('fileStorageDB', 1);
|
const request = indexedDB.open('fileStorageDB', 1)
|
||||||
|
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
request.result.createObjectStore('files');
|
request.result.createObjectStore('files')
|
||||||
};
|
}
|
||||||
request.onsuccess = () => resolve(Result.ok(request.result));
|
request.onsuccess = () => resolve(Result.ok(request.result))
|
||||||
request.onerror = () => resolve(Result.err('Error opening database'));
|
request.onerror = () => resolve(Result.err('Error opening database'))
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
|
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
|
||||||
if (!browser || !this.dbPromise)
|
if (!browser || !this.dbPromise)
|
||||||
return Result.err('Not running in browser or DB not initialized');
|
return Result.err('Not running in browser or DB not initialized')
|
||||||
const dbResult = await this.dbPromise;
|
const dbResult = await this.dbPromise
|
||||||
if (dbResult.isErr()) return Result.err('Database not initialized');
|
if (dbResult.isErr()) return Result.err('Database not initialized')
|
||||||
const store = dbResult.inner.transaction('files', mode).objectStore('files');
|
const store = dbResult.inner.transaction('files', mode).objectStore('files')
|
||||||
return Result.ok(store);
|
return Result.ok(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> {
|
public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> {
|
||||||
const storeResult = await this.getStore('readwrite');
|
const storeResult = await this.getStore('readwrite')
|
||||||
if (storeResult.isErr()) return Result.err('Failed to access store');
|
if (storeResult.isErr()) return Result.err('Failed to access store')
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => {
|
||||||
const request = storeResult.inner.put(file, key);
|
const request = storeResult.inner.put(file, key)
|
||||||
request.onsuccess = () => resolve(Result.ok(request.result));
|
request.onsuccess = () => resolve(Result.ok(request.result))
|
||||||
request.onerror = () => resolve(Result.err('Failed to save file'));
|
request.onerror = () => resolve(Result.err('Failed to save file'))
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> {
|
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> {
|
||||||
const storeResult = await this.getStore('readonly');
|
const storeResult = await this.getStore('readonly')
|
||||||
if (storeResult.isErr()) return Result.err('Failed to access store');
|
if (storeResult.isErr()) return Result.err('Failed to access store')
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => {
|
||||||
const request = storeResult.inner.get(key);
|
const request = storeResult.inner.get(key)
|
||||||
request.onsuccess = () =>
|
request.onsuccess = () =>
|
||||||
resolve(request.result ? Result.ok(request.result) : Result.err('File not found'));
|
resolve(request.result ? Result.ok(request.result) : Result.err('File not found'))
|
||||||
request.onerror = () => resolve(Result.err('Failed to retrieve file'));
|
request.onerror = () => resolve(Result.err('Failed to retrieve file'))
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default browser ? new FileService() : null;
|
export default browser ? new FileService() : null
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as fileService } from './file-service';
|
export { default as fileService } from './file-service'
|
||||||
export { default as resultService } from './result-service';
|
export { default as resultService } from './result-service'
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { errorLogs, latestErrorLog } from '$lib/stores';
|
import { errorLogs, latestErrorLog } from '$lib/stores'
|
||||||
import type { Result } from '$lib/utilities';
|
import type { Result } from '$lib/utilities'
|
||||||
|
|
||||||
class ResultService {
|
class ResultService {
|
||||||
public handleResult(result: Result<unknown, string>, tag?: string) {
|
public handleResult(result: Result<unknown, string>, tag?: string) {
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
const errorLogEntry = { tag, message: result.inner, exception: result.exception };
|
const errorLogEntry = { tag, message: result.inner, exception: result.exception }
|
||||||
latestErrorLog.set(errorLogEntry);
|
latestErrorLog.set(errorLogEntry)
|
||||||
errorLogs.update((entries) => {
|
errorLogs.update(entries => {
|
||||||
entries.push(errorLogEntry);
|
entries.push(errorLogEntry)
|
||||||
return entries;
|
return entries
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ResultService();
|
export default new ResultService()
|
||||||
|
|||||||
@@ -1,55 +1,36 @@
|
|||||||
import { type Analytics } from '$lib/types/models';
|
import { AnalyticsData } from '$lib/platform_shared/message'
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store'
|
||||||
|
import { socket } from './socket'
|
||||||
|
|
||||||
let analytics_data = {
|
const maxAnalyticsData = 100
|
||||||
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;
|
|
||||||
|
|
||||||
function createAnalytics() {
|
function createAnalytics() {
|
||||||
const { subscribe, update } = writable(analytics_data);
|
const { subscribe, update } = writable<AnalyticsData[]>([])
|
||||||
|
|
||||||
return {
|
let unsubscribe: (() => void) | null = null
|
||||||
subscribe,
|
let listenerCount = 0
|
||||||
addData: (content: Analytics) => {
|
|
||||||
update((analytics_data) => ({
|
const addData = (content: AnalyticsData) => {
|
||||||
...analytics_data,
|
update(data => [...data, content].slice(-maxAnalyticsData))
|
||||||
uptime: [...analytics_data.uptime, content.uptime].slice(-maxAnalyticsData),
|
}
|
||||||
free_heap: [...analytics_data.free_heap, content.free_heap / 1000].slice(-maxAnalyticsData),
|
|
||||||
total_heap: [...analytics_data.total_heap, content.total_heap / 1000].slice(
|
return {
|
||||||
-maxAnalyticsData
|
subscribe,
|
||||||
),
|
addData,
|
||||||
used_heap: [
|
listen: () => {
|
||||||
...analytics_data.used_heap,
|
listenerCount++
|
||||||
(content.total_heap - content.free_heap) / 1000
|
if (!unsubscribe) {
|
||||||
].slice(-maxAnalyticsData),
|
unsubscribe = socket.on(AnalyticsData, addData)
|
||||||
min_free_heap: [...analytics_data.min_free_heap, content.min_free_heap / 1000].slice(
|
}
|
||||||
-maxAnalyticsData
|
},
|
||||||
),
|
stop: () => {
|
||||||
max_alloc_heap: [...analytics_data.max_alloc_heap, content.max_alloc_heap / 1000].slice(
|
listenerCount = Math.max(0, listenerCount - 1)
|
||||||
-maxAnalyticsData
|
if (listenerCount === 0 && unsubscribe) {
|
||||||
),
|
unsubscribe()
|
||||||
fs_used: [...analytics_data.fs_used, content.fs_used / 1000].slice(-maxAnalyticsData),
|
unsubscribe = null
|
||||||
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)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const analytics = createAnalytics();
|
export const analytics = createAnalytics()
|
||||||
|
|||||||
@@ -1,67 +1,67 @@
|
|||||||
import { persistentStore } from '$lib/utilities';
|
import { persistentStore } from '$lib/utilities'
|
||||||
import { get, type Writable } from 'svelte/store';
|
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'
|
||||||
|
|
||||||
export interface WidgetConfig {
|
export interface WidgetConfig {
|
||||||
id: string | number;
|
id: string | number
|
||||||
component: keyof typeof WidgetComponents;
|
component: keyof typeof WidgetComponents
|
||||||
props?: Record<string, any>;
|
props?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WidgetContainerConfig {
|
export interface WidgetContainerConfig {
|
||||||
id: string | number;
|
id: string | number
|
||||||
layout?: 'row' | 'column' | 'wrap';
|
layout?: 'row' | 'column' | 'wrap'
|
||||||
header?: string;
|
header?: string
|
||||||
widgets: Array<WidgetConfig | WidgetContainerConfig>;
|
widgets: Array<WidgetConfig | WidgetContainerConfig>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isWidgetConfig = (
|
export const isWidgetConfig = (
|
||||||
widget: WidgetConfig | WidgetContainerConfig
|
widget: WidgetConfig | WidgetContainerConfig
|
||||||
): widget is WidgetConfig => 'component' in widget;
|
): widget is WidgetConfig => 'component' in widget
|
||||||
|
|
||||||
export const WidgetComponents = {
|
export const WidgetComponents = {
|
||||||
Visualization,
|
Visualization,
|
||||||
Stream,
|
Stream,
|
||||||
ChartWidget
|
ChartWidget
|
||||||
};
|
}
|
||||||
|
|
||||||
interface View {
|
interface View {
|
||||||
name: string;
|
name: string
|
||||||
content: WidgetContainerConfig;
|
content: WidgetContainerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultViews: View[] = [
|
const defaultViews: View[] = [
|
||||||
{
|
{
|
||||||
name: 'Stream',
|
name: '3D representation',
|
||||||
content: {
|
content: {
|
||||||
id: 'root',
|
id: 'root',
|
||||||
layout: 'column',
|
layout: 'column',
|
||||||
widgets: [{ id: 2, component: 'Stream' }]
|
widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '3D representation',
|
name: 'Stream',
|
||||||
content: {
|
content: {
|
||||||
id: 'root',
|
id: 'root',
|
||||||
layout: 'column',
|
layout: 'column',
|
||||||
widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }]
|
widgets: [{ id: 2, component: 'Stream' }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Split screen',
|
name: 'Split screen',
|
||||||
content: {
|
content: {
|
||||||
id: 'root',
|
id: 'root',
|
||||||
widgets: [
|
widgets: [
|
||||||
{ id: 2, component: 'Stream' },
|
{ id: 2, component: 'Stream' },
|
||||||
{ id: 2, component: 'Visualization', props: { debug: true } }
|
{ id: 2, component: 'Visualization', props: { debug: true } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
]
|
||||||
|
|
||||||
export const views: Writable<View[]> = persistentStore('views', defaultViews);
|
export const views: Writable<View[]> = persistentStore('views', defaultViews)
|
||||||
|
|
||||||
export const selectedView = persistentStore('selected_view', get(views)[0].name);
|
export const selectedView = persistentStore('selected_view', get(views)[0].name)
|
||||||
|
|||||||
@@ -1,62 +1,72 @@
|
|||||||
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 { base } 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>>
|
||||||
|
|
||||||
export function useFeatureFlags() {
|
export function useFeatureFlags() {
|
||||||
if (!featureFlagsStore) {
|
if (!featureFlagsStore) {
|
||||||
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
|
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
|
||||||
|
|
||||||
api.get<Record<string, boolean>>('/api/features').then(result => {
|
socket
|
||||||
if (result.isOk()) featureFlagsStore.set(result.inner)
|
.request({ featuresDataRequest: {} })
|
||||||
else {
|
.then(response => {
|
||||||
notifications.error('Feature flag could not be fetched', 2500)
|
if (response.featuresDataResponse) {
|
||||||
}
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const base = resolve('/')
|
||||||
|
|
||||||
export const variants = {
|
export const variants = {
|
||||||
SPOTMICRO_ESP32: {
|
SPOTMICRO_ESP32: {
|
||||||
model: `${base}/spot_micro.urdf.xacro`,
|
model: `${base}spot_micro.urdf.xacro`,
|
||||||
stl: `${base}/stl.zip`,
|
stl: `${base}stl.zip`,
|
||||||
kinematics: {
|
kinematics: {
|
||||||
coxa: 60.5 / 100,
|
coxa: 0.0605,
|
||||||
coxa_offset: 10 / 100,
|
coxa_offset: 0.01,
|
||||||
femur: 111.7 / 100,
|
femur: 0.1112,
|
||||||
tibia: 118.5 / 100,
|
tibia: 0.1185,
|
||||||
L: 207.5 / 100,
|
L: 0.2075,
|
||||||
W: 78 / 100
|
W: 0.078
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SPOTMICRO_YERTLE: {
|
||||||
|
model: `${base}yertle.URDF`,
|
||||||
|
stl: `${base}URDF.zip`,
|
||||||
|
kinematics: {
|
||||||
|
coxa: 0.035,
|
||||||
|
coxa_offset: 0.0,
|
||||||
|
femur: 0.13,
|
||||||
|
tibia: 0.13,
|
||||||
|
L: 0.24,
|
||||||
|
W: 0.078
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
SPOTMICRO_YERTLE: {
|
|
||||||
model: `${base}/yertle.URDF`,
|
|
||||||
stl: `${base}/URDF.zip`,
|
|
||||||
kinematics: {
|
|
||||||
coxa: 35 / 100,
|
|
||||||
coxa_offset: 0 / 100,
|
|
||||||
femur: 130 / 100,
|
|
||||||
tibia: 130 / 100,
|
|
||||||
L: 240 / 100,
|
|
||||||
W: 78 / 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const currentVariant = derived(useFeatureFlags(), $flagStore => {
|
export const currentVariant = derived(useFeatureFlags(), $flagStore => {
|
||||||
const variantFlag = $flagStore['variant'] as string
|
const variantFlag = $flagStore['variant'] as string
|
||||||
return variantFlag && variants[variantFlag as keyof typeof variants] ?
|
return variantFlag && variants[variantFlag as keyof typeof variants] ?
|
||||||
variants[variantFlag as keyof typeof variants]
|
variants[variantFlag as keyof typeof variants]
|
||||||
: variants.SPOTMICRO_ESP32
|
: variants.SPOTMICRO_ESP32
|
||||||
})
|
})
|
||||||
|
|
||||||
export const currentKinematic = derived(
|
export const currentKinematic = derived(
|
||||||
currentVariant,
|
currentVariant,
|
||||||
$variant => new Kinematic($variant.kinematics)
|
$variant => new Kinematic($variant.kinematics)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
export const isFullscreen = writable(false);
|
export const isFullscreen = writable(false)
|
||||||
|
|
||||||
export function toggleFullscreen() {
|
export function toggleFullscreen() {
|
||||||
isFullscreen.update((state) => {
|
isFullscreen.update(state => {
|
||||||
!state ? document.documentElement.requestFullscreen() : document.exitFullscreen();
|
if (!state) document.documentElement.requestFullscreen()
|
||||||
return !state;
|
else document.exitFullscreen()
|
||||||
});
|
return !state
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enterFullscreen() {
|
export function enterFullscreen() {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.documentElement.requestFullscreen();
|
document.documentElement.requestFullscreen()
|
||||||
isFullscreen.set(true);
|
isFullscreen.set(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exitFullscreen() {
|
export function exitFullscreen() {
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
document.exitFullscreen();
|
document.exitFullscreen()
|
||||||
isFullscreen.set(false);
|
isFullscreen.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,87 @@
|
|||||||
import { readable, derived } from 'svelte/store'
|
import { readable, derived } from 'svelte/store'
|
||||||
|
|
||||||
export type GamepadState = {
|
export type GamepadState = {
|
||||||
available: boolean
|
available: boolean
|
||||||
gamepads: Gamepad[]
|
gamepads: Gamepad[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEADZONE = 0.15
|
||||||
|
const dz = (x: number) => {
|
||||||
|
const a = Math.abs(x)
|
||||||
|
if (a < DEADZONE) return 0
|
||||||
|
return ((a - DEADZONE) / (1 - DEADZONE)) * Math.sign(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
let raf = 0
|
||||||
|
let running = false
|
||||||
|
|
||||||
export const gamepads = readable<GamepadState>({ available: false, gamepads: [] }, set => {
|
export const gamepads = readable<GamepadState>({ available: false, gamepads: [] }, set => {
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const hasGamepadAPI = 'getGamepads' in navigator
|
const pads = navigator.getGamepads?.() ?? []
|
||||||
if (!hasGamepadAPI) {
|
const list = Array.from(pads)
|
||||||
set({ available: false, gamepads: [] })
|
.map(p => p || null)
|
||||||
return
|
.filter(Boolean) as Gamepad[]
|
||||||
|
set({ available: 'getGamepads' in navigator, gamepads: list })
|
||||||
|
raf = requestAnimationFrame(update)
|
||||||
}
|
}
|
||||||
|
|
||||||
const gps = navigator.getGamepads?.() ?? []
|
const onConnect = () => update()
|
||||||
const validGamepads = gps.filter(Boolean) as Gamepad[]
|
const onDisconnect = () => update()
|
||||||
set({
|
const onVis = () => {
|
||||||
available: true,
|
if (document.hidden) {
|
||||||
gamepads: validGamepads
|
running = false
|
||||||
})
|
cancelAnimationFrame(raf)
|
||||||
|
} else if (!running) {
|
||||||
|
running = true
|
||||||
|
raf = requestAnimationFrame(update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('gamepadconnected', onConnect)
|
||||||
|
window.addEventListener('gamepaddisconnected', onDisconnect)
|
||||||
|
document.addEventListener('visibilitychange', onVis)
|
||||||
|
|
||||||
|
running = true
|
||||||
raf = requestAnimationFrame(update)
|
raf = requestAnimationFrame(update)
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('gamepadconnected', update)
|
return () => {
|
||||||
window.addEventListener('gamepaddisconnected', update)
|
running = false
|
||||||
let raf = requestAnimationFrame(update)
|
cancelAnimationFrame(raf)
|
||||||
|
window.removeEventListener('gamepadconnected', onConnect)
|
||||||
return () => {
|
window.removeEventListener('gamepaddisconnected', onDisconnect)
|
||||||
cancelAnimationFrame(raf)
|
document.removeEventListener('visibilitychange', onVis)
|
||||||
window.removeEventListener('gamepadconnected', update)
|
}
|
||||||
window.removeEventListener('gamepaddisconnected', update)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const gamepad = derived(gamepads, $gamepads =>
|
export const gamepad = derived(gamepads, s =>
|
||||||
$gamepads.available && $gamepads.gamepads.length > 0 ? $gamepads.gamepads[0] : null
|
s.available && s.gamepads.length ? s.gamepads[0] : null
|
||||||
)
|
)
|
||||||
|
|
||||||
export const gamepadAxes = derived(gamepad, $gamepad => $gamepad?.axes ?? [0, 0, 0, 0])
|
export const hasGamepad = derived(gamepads, s => s.available && s.gamepads.length > 0)
|
||||||
|
|
||||||
export const gamepadButtons = derived(gamepad, $gamepad => $gamepad?.buttons ?? [])
|
export const gamepadAxes = derived(gamepad, g => (g ? g.axes.map(dz) : [0, 0, 0, 0]))
|
||||||
|
|
||||||
export const hasGamepad = derived(
|
type ButtonEdge = { pressed: boolean; value: number; justPressed: boolean; justReleased: boolean }
|
||||||
gamepads,
|
const prev = new Map<number, { pressed: boolean; value: number }[]>()
|
||||||
$gamepads => $gamepads.available && $gamepads.gamepads.length > 0
|
|
||||||
)
|
export const gamepadButtons = derived(gamepad, g => g?.buttons ?? [])
|
||||||
|
|
||||||
|
export const gamepadButtonsEdges = derived(gamepad, g => {
|
||||||
|
if (!g) return [] as ButtonEdge[]
|
||||||
|
const p = prev.get(g.index) || []
|
||||||
|
const out = g.buttons.map((b, i): ButtonEdge => {
|
||||||
|
const pr = p[i] || { pressed: false, value: 0 }
|
||||||
|
const pressed = !!b.pressed || b.value > 0.5
|
||||||
|
return {
|
||||||
|
pressed,
|
||||||
|
value: b.value,
|
||||||
|
justPressed: pressed && !pr.pressed,
|
||||||
|
justReleased: !pressed && pr.pressed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
prev.set(
|
||||||
|
g.index,
|
||||||
|
out.map(x => ({ pressed: x.pressed, value: x.value }))
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|||||||
+29
-22
@@ -1,27 +1,34 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store'
|
||||||
import type { IMU } from '$lib/types/models';
|
import { IMUData } from '$lib/platform_shared/message'
|
||||||
|
import { socket } from './socket'
|
||||||
|
|
||||||
const maxIMUData = 100;
|
const maxIMUData = 100
|
||||||
|
|
||||||
export const imu = (() => {
|
export const imu = (() => {
|
||||||
const { subscribe, update } = writable({
|
const { subscribe, update } = writable<IMUData[]>([])
|
||||||
x: [] as number[],
|
|
||||||
y: [] as number[],
|
|
||||||
z: [] as number[],
|
|
||||||
heading: [] as number[],
|
|
||||||
altitude: [] as number[],
|
|
||||||
pressure: [] as number[],
|
|
||||||
bmp_temp: [] as number[]
|
|
||||||
});
|
|
||||||
|
|
||||||
const addData = (content: IMU) => {
|
let unsubscribe: (() => void) | null = null
|
||||||
update(data => {
|
let listenerCount = 0
|
||||||
(Object.keys(content) as (keyof IMU)[]).forEach(key => {
|
|
||||||
data[key] = [...data[key], content[key]].slice(-maxIMUData);
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return { subscribe, addData };
|
const addData = (content: IMUData) => {
|
||||||
})();
|
update(data => [...data, content].slice(-maxIMUData))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
addData,
|
||||||
|
listen: () => {
|
||||||
|
listenerCount++
|
||||||
|
if (!unsubscribe) {
|
||||||
|
unsubscribe = socket.on(IMUData, addData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
listenerCount = Math.max(0, listenerCount - 1)
|
||||||
|
if (listenerCount === 0 && unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
unsubscribe = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export * from './socket-store';
|
export * from './socket-store'
|
||||||
export * from './logging-store';
|
export * from './logging-store'
|
||||||
export * from './model-store';
|
export * from './model-store'
|
||||||
export * from './socket';
|
export * from './socket'
|
||||||
export * from './fullscreen';
|
export * from './fullscreen'
|
||||||
export * from './telemetry';
|
export * from './telemetry'
|
||||||
export * from './analytics';
|
export * from './analytics'
|
||||||
export * from './featureFlags';
|
export * from './featureFlags'
|
||||||
export * from './location-store';
|
export * from './location-store'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { persistentStore } from '$lib/utilities';
|
import { persistentStore } from '$lib/utilities'
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store'
|
||||||
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public';
|
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
|
||||||
|
|
||||||
export const location = PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '');
|
export const apiLocation =
|
||||||
|
PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '')
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { writable, type Writable } from 'svelte/store';
|
import { writable, type Writable } from 'svelte/store'
|
||||||
|
|
||||||
export interface errorLog {
|
export interface errorLog {
|
||||||
message: unknown;
|
message: unknown
|
||||||
tag?: string;
|
tag?: string
|
||||||
exception?: unknown;
|
exception?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export const latestErrorLog: Writable<errorLog> = writable();
|
export const latestErrorLog: Writable<errorLog> = writable()
|
||||||
|
|
||||||
export const errorLogs: Writable<errorLog[]> = writable([]);
|
export const errorLogs: Writable<errorLog[]> = writable([])
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { ControllerInput } from '$lib/types/models'
|
import Kinematic from '$lib/kinematic'
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -8,47 +16,41 @@ export const jointNames = persistentStore('joint_names', <string[]>[])
|
|||||||
|
|
||||||
export const model = writable()
|
export const model = writable()
|
||||||
|
|
||||||
export const modes = ['deactivated', 'idle', 'calibration', 'rest', 'stand', 'walk'] as const
|
export const mode: Writable<ModeData> = writable(ModeData.create({ mode: ModesEnum.DEACTIVATED }))
|
||||||
|
|
||||||
export type Modes = (typeof modes)[number]
|
export const walkGait: Writable<WalkGaitData> = writable(
|
||||||
|
WalkGaitData.create({ gait: WalkGaits.TROT })
|
||||||
|
)
|
||||||
|
|
||||||
export enum ModesEnum {
|
export const kinematicData = writable(KinematicData.create())
|
||||||
Deactivated = 0,
|
|
||||||
Idle = 1,
|
export const input: Writable<ControllerData> = writable(
|
||||||
Calibration = 2,
|
ControllerData.create({
|
||||||
Rest = 3,
|
left: { x: 0, y: 0 },
|
||||||
Stand = 4,
|
right: { x: 0, y: 0 },
|
||||||
Walk = 5
|
height: 0.7,
|
||||||
|
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>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WalkGaits {
|
const modesData = enumToValuesAndLabels<ModesEnum>(ModesEnum)
|
||||||
Trot = 0,
|
export const modes = modesData.values
|
||||||
Crawl = 1
|
export const modeLabels = modesData.labels
|
||||||
}
|
|
||||||
|
|
||||||
export const walkGaits = ['trot', 'crawl'] as const
|
const walkGaitsData = enumToValuesAndLabels<WalkGaits>(WalkGaits)
|
||||||
|
export const walkGaits = walkGaitsData.values
|
||||||
export const walkGaitLabels: Record<WalkGaits, string> = {
|
export const walkGaitLabels = walkGaitsData.labels
|
||||||
[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.05
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
import { writable, type Writable } from 'svelte/store';
|
import { AnglesData } from '$lib/platform_shared/message'
|
||||||
import { type angles } from '$lib/types/models';
|
import { writable, type Writable } from 'svelte/store'
|
||||||
|
|
||||||
export const servoAnglesOut: Writable<number[]> = writable([
|
export const servoAnglesOut: Writable<AnglesData> = writable(
|
||||||
0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
|
AnglesData.create({ angles: [0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90] })
|
||||||
]);
|
)
|
||||||
export const servoAngles: Writable<number[]> = writable([
|
export const servoAngles: Writable<AnglesData> = writable(
|
||||||
0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
|
AnglesData.create({ angles: [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 sonar = writable([0, 0]);
|
|
||||||
export const distances = writable({});
|
|
||||||
|
|
||||||
export interface socketDataCollection {
|
export const mpu = writable({ heading: 0 })
|
||||||
angles: Writable<angles>;
|
export const sonar = writable([0, 0])
|
||||||
logs: Writable<string[]>;
|
|
||||||
mpu: Writable<unknown>;
|
|
||||||
distances: Writable<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const socketData = {
|
|
||||||
angles: servoAngles,
|
|
||||||
logs,
|
|
||||||
mpu,
|
|
||||||
distances
|
|
||||||
};
|
|
||||||
|
|||||||
+307
-156
@@ -1,160 +1,311 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store'
|
||||||
import { encode, decode } from '@msgpack/msgpack';
|
import {
|
||||||
|
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'
|
||||||
|
|
||||||
const socketEvents = ['open', 'close', 'error', 'message', 'unresponsive'] as const;
|
export const MESSAGE_TYPE_TO_KEY = new Map<MessageFns<unknown>, string>()
|
||||||
type SocketEvent = (typeof socketEvents)[number];
|
export const MESSAGE_TYPE_TO_TAG = new Map<MessageFns<unknown>, number>()
|
||||||
|
export const MESSAGE_KEY_TO_TAG = new Map<string, number>()
|
||||||
|
export const MESSAGE_TAG_TO_KEY = new Map<number, string>()
|
||||||
|
|
||||||
type SocketMessage = [number, string?, unknown?];
|
type CorrelationRequestData = Omit<CorrelationRequest, 'correlationId'>
|
||||||
|
type PendingRequest = {
|
||||||
let useBinary = false;
|
resolve: (response: CorrelationResponse) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => {
|
timeoutId: ReturnType<typeof setTimeout>
|
||||||
useBinary = data instanceof ArrayBuffer;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (useBinary) {
|
|
||||||
return decode(new Uint8Array(data as ArrayBuffer)) as SocketMessage;
|
|
||||||
}
|
|
||||||
return JSON.parse(data as string);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Could not decode data: ${new Uint8Array(data as ArrayBuffer)} - ${error}`);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const encodeMessage = (data: unknown) => {
|
|
||||||
try {
|
|
||||||
return useBinary ? encode(data) : JSON.stringify(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Could not encode data: ${data} - ${error}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function createWebSocket() {
|
|
||||||
const listeners = new Map<string, Set<(data?: unknown) => void>>();
|
|
||||||
const { subscribe, set } = writable(false);
|
|
||||||
const reconnectTimeoutTime = 5000;
|
|
||||||
let unresponsiveTimeoutId: ReturnType<typeof setTimeout>;
|
|
||||||
let reconnectTimeoutId: ReturnType<typeof setTimeout>;
|
|
||||||
let ws: WebSocket;
|
|
||||||
let socketUrl: string | URL;
|
|
||||||
|
|
||||||
function init(url: string | URL) {
|
|
||||||
socketUrl = url;
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnect(reason: SocketEvent, event?: Event) {
|
|
||||||
ws.close();
|
|
||||||
set(false);
|
|
||||||
clearTimeout(unresponsiveTimeoutId);
|
|
||||||
clearTimeout(reconnectTimeoutId);
|
|
||||||
listeners.get(reason)?.forEach(listener => listener(event));
|
|
||||||
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
ws = new WebSocket(socketUrl);
|
|
||||||
ws.binaryType = 'arraybuffer';
|
|
||||||
ws.onopen = ev => {
|
|
||||||
ping();
|
|
||||||
useBinary = true;
|
|
||||||
ping();
|
|
||||||
set(true);
|
|
||||||
clearTimeout(reconnectTimeoutId);
|
|
||||||
listeners.get('open')?.forEach(listener => listener(ev));
|
|
||||||
for (const event of listeners.keys()) {
|
|
||||||
if (socketEvents.includes(event as SocketEvent)) continue;
|
|
||||||
subscribeToEvent(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onmessage = frame => {
|
|
||||||
resetUnresponsiveCheck();
|
|
||||||
const message = decodeMessage(frame.data);
|
|
||||||
if (!message) return;
|
|
||||||
const [, event, payload = undefined] = message;
|
|
||||||
if (event) listeners.get(event)?.forEach(listener => listener(payload));
|
|
||||||
};
|
|
||||||
ws.onerror = ev => disconnect('error', ev);
|
|
||||||
ws.onclose = ev => disconnect('close', ev);
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsubscribe(event: string, listener?: (data: unknown) => void) {
|
|
||||||
const eventListeners = listeners.get(event);
|
|
||||||
if (!eventListeners) return;
|
|
||||||
|
|
||||||
if (!eventListeners.size) {
|
|
||||||
unsubscribeToEvent(event);
|
|
||||||
}
|
|
||||||
if (listener) {
|
|
||||||
eventListeners?.delete(listener);
|
|
||||||
} else {
|
|
||||||
listeners.delete(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetUnresponsiveCheck() {
|
|
||||||
clearTimeout(unresponsiveTimeoutId);
|
|
||||||
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendEvent(event: string, data: unknown) {
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
send([2, event, data]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsubscribeToEvent(event: string) {
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
send([1, event]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribeToEvent(event: string) {
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
send([0, event]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(data: unknown) {
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
const serialized = encodeMessage(data);
|
|
||||||
if (!serialized) {
|
|
||||||
console.error('Could not serialize data:', data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ws.send(serialized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ping() {
|
|
||||||
const serialized = encodeMessage([4]);
|
|
||||||
if (!serialized) {
|
|
||||||
console.error('Could not serialize message');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ws.send(serialized);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscribe,
|
|
||||||
sendEvent,
|
|
||||||
init,
|
|
||||||
on: <T>(event: string, listener: (data: T) => void): (() => void) => {
|
|
||||||
let eventListeners = listeners.get(event);
|
|
||||||
if (!eventListeners) {
|
|
||||||
if (!socketEvents.includes(event as SocketEvent)) {
|
|
||||||
subscribeToEvent(event);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const socket = createWebSocket();
|
// Combine references from both message.proto and filesystem.proto
|
||||||
|
const combinedReferences: Record<string, MessageFns<unknown>> = {
|
||||||
|
...protoMetadata.references,
|
||||||
|
...filesystemProtoMetadata.references
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageType = protoMetadata.fileDescriptor.messageType?.find(
|
||||||
|
(msg: { name: string }) => msg.name === 'Message'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (MessageType?.field) {
|
||||||
|
for (const field of MessageType.field) {
|
||||||
|
if (field.typeName) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameFromMessageType<T>(event_type: MessageFns<T>): string {
|
||||||
|
const event = MESSAGE_TYPE_TO_KEY.get(event_type as MessageFns<unknown>)
|
||||||
|
if (!event) {
|
||||||
|
throw new Error(
|
||||||
|
"Event type not found in 'Message'. The MessageFns you passed doesn't correspond to any Message field."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
const message_listeners = new Map<number, 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 reconnectTimeoutTime = 500000
|
||||||
|
const requestTimeoutTime = 30000
|
||||||
|
let correlationIdCounter = 0
|
||||||
|
let unresponsiveTimeoutId: ReturnType<typeof setTimeout>
|
||||||
|
let reconnectTimeoutId: ReturnType<typeof setTimeout>
|
||||||
|
let ws: WebSocket
|
||||||
|
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) {
|
||||||
|
socketUrl = url
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect(reason: SocketEvent, event?: Event) {
|
||||||
|
ws.close()
|
||||||
|
set(false)
|
||||||
|
clearTimeout(unresponsiveTimeoutId)
|
||||||
|
clearTimeout(reconnectTimeoutId)
|
||||||
|
event_listeners.get(reason)?.forEach(listener => listener(event))
|
||||||
|
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws = new WebSocket(socketUrl)
|
||||||
|
ws.binaryType = 'arraybuffer'
|
||||||
|
ws.onopen = ev => {
|
||||||
|
ping()
|
||||||
|
set(true)
|
||||||
|
clearTimeout(reconnectTimeoutId)
|
||||||
|
resubscribeAll()
|
||||||
|
flushQueuedRequests()
|
||||||
|
event_listeners.get('open')?.forEach(listener => listener(ev))
|
||||||
|
}
|
||||||
|
ws.onmessage = frame => {
|
||||||
|
resetUnresponsiveCheck()
|
||||||
|
|
||||||
|
for (const [correlationId, pending] of pending_requests) {
|
||||||
|
clearTimeout(pending.timeoutId)
|
||||||
|
pending.timeoutId = setTimeout(() => {
|
||||||
|
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.onclose = ev => disconnect('close', ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribe<MT>(event_type: MessageFns<MT>, listener: (data: MT) => void) {
|
||||||
|
const tag = getTagFromMessageType(event_type)
|
||||||
|
const message_listeners_totag = message_listeners.get(tag)
|
||||||
|
if (!message_listeners_totag) return
|
||||||
|
|
||||||
|
message_listeners_totag?.delete(listener as (data?: unknown) => void)
|
||||||
|
if (message_listeners_totag.size == 0) {
|
||||||
|
unsubscribeToMessageFromServer(event_type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
clearTimeout(unresponsiveTimeoutId)
|
||||||
|
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit<T>(event: MessageFns<T>, data: T) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
const type = getNameFromMessageType(event)
|
||||||
|
const wsm = Message.create() as Record<string, unknown>
|
||||||
|
wsm[type] = data
|
||||||
|
send(wsm as Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribeToMessageFromServer<T>(event_type: MessageFns<T>) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
const unsub_msg = Messages.UnsubscribeNotification.create({
|
||||||
|
tag: getTagFromMessageType(event_type)
|
||||||
|
})
|
||||||
|
send(Message.create({ unsubNotif: unsub_msg }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeToEvent<T>(event_type: MessageFns<T>) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
const sub_msg = Messages.SubscribeNotification.create({
|
||||||
|
tag: getTagFromMessageType(event_type)
|
||||||
|
})
|
||||||
|
send(Message.create({ subNotif: sub_msg }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resubscribeAll() {
|
||||||
|
for (const tag of message_listeners.keys()) {
|
||||||
|
const sub_msg = Messages.SubscribeNotification.create({ tag })
|
||||||
|
send(Message.create({ subNotif: sub_msg }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(data: Message) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
const encoded = encodeMessage(data)
|
||||||
|
ws.send(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ping() {
|
||||||
|
send(Message.create({ pingmsg: {} }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
emit,
|
||||||
|
init,
|
||||||
|
on: <MT>(event_type: MessageFns<MT>, listener: (data: MT) => void): (() => void) => {
|
||||||
|
const tag = getTagFromMessageType(event_type)
|
||||||
|
|
||||||
|
let message_listeners_totag = message_listeners.get(tag)
|
||||||
|
if (!message_listeners_totag) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const socket = createWebSocket()
|
||||||
|
|||||||
@@ -1,35 +1,33 @@
|
|||||||
import type { DownloadOTA } from '$lib/types/models';
|
import { DownloadOTAData, RSSIData } from '$lib/platform_shared/message'
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
let telemetry_data = {
|
type telemetry_data_type = {
|
||||||
rssi: {
|
rssi: RSSIData
|
||||||
rssi: 0
|
download_ota: DownloadOTAData
|
||||||
},
|
}
|
||||||
download_ota: {
|
const telemetry_data: telemetry_data_type = {
|
||||||
status: 'none',
|
rssi: RSSIData.create(),
|
||||||
progress: 0,
|
download_ota: DownloadOTAData.create()
|
||||||
error: ''
|
} // Note: perhaps init these as null instead of an undefined create()
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function createTelemetry() {
|
function createTelemetry() {
|
||||||
const { subscribe, set, update } = writable(telemetry_data);
|
const { subscribe, update } = writable(telemetry_data)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
setRSSI: (data: number) => {
|
setRSSI: (data: RSSIData) => {
|
||||||
update(telemetry_data => ({
|
update(telemetry_data => {
|
||||||
...telemetry_data,
|
telemetry_data.rssi = data
|
||||||
rssi: { rssi: data }
|
return telemetry_data
|
||||||
}));
|
})
|
||||||
},
|
},
|
||||||
setDownloadOTA: (data: DownloadOTA) => {
|
setDownloadOTA: (data: DownloadOTAData) => {
|
||||||
update(telemetry_data => ({
|
update(telemetry_data => {
|
||||||
...telemetry_data,
|
telemetry_data.download_ota = data
|
||||||
download_ota: { status: data.status, progress: data.progress, error: data.error }
|
return telemetry_data
|
||||||
}));
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const telemetry = createTelemetry();
|
export const telemetry = createTelemetry()
|
||||||
|
|||||||
Vendored
+15
-15
@@ -1,17 +1,17 @@
|
|||||||
declare module 'three/src/math/MathUtils' {
|
declare module 'three/src/math/MathUtils' {
|
||||||
export function generateUUID(): string;
|
export function generateUUID(): string
|
||||||
export function clamp(value: number, min: number, max: number): number;
|
export function clamp(value: number, min: number, max: number): number
|
||||||
export function euclideanModulo(n: number, m: number): number;
|
export function euclideanModulo(n: number, m: number): number
|
||||||
export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number;
|
export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number
|
||||||
export function lerp(x: number, y: number, t: number): number;
|
export function lerp(x: number, y: number, t: number): number
|
||||||
export function smoothstep(x: number, min: number, max: number): number;
|
export function smoothstep(x: number, min: number, max: number): number
|
||||||
export function smootherstep(x: number, min: number, max: number): number;
|
export function smootherstep(x: number, min: number, max: number): number
|
||||||
export function randInt(low: number, high: number): number;
|
export function randInt(low: number, high: number): number
|
||||||
export function randFloat(low: number, high: number): number;
|
export function randFloat(low: number, high: number): number
|
||||||
export function randFloatSpread(range: number): number;
|
export function randFloatSpread(range: number): number
|
||||||
export function degToRad(degrees: number): number;
|
export function degToRad(degrees: number): number
|
||||||
export function radToDeg(radians: number): number;
|
export function radToDeg(radians: number): number
|
||||||
export function isPowerOfTwo(value: number): boolean;
|
export function isPowerOfTwo(value: number): boolean
|
||||||
export function ceilPowerOfTwo(value: number): number;
|
export function ceilPowerOfTwo(value: number): number
|
||||||
export function floorPowerOfTwo(value: number): number;
|
export function floorPowerOfTwo(value: number): number
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-213
@@ -1,239 +1,83 @@
|
|||||||
export enum MessageTopic {
|
export enum MessageTopic {
|
||||||
imu = 'imu',
|
imu = 'imu',
|
||||||
mode = 'mode',
|
imuCalibrate = 'imuCalibrate',
|
||||||
input = 'input',
|
mode = 'mode',
|
||||||
analytics = 'analytics',
|
input = 'input',
|
||||||
position = 'position',
|
analytics = 'analytics',
|
||||||
angles = 'angles',
|
position = 'position',
|
||||||
i2cScan = 'i2cScan',
|
angles = 'angles',
|
||||||
peripheralSettings = 'peripheralSettings',
|
i2cScan = 'i2cScan',
|
||||||
otastatus = 'otastatus',
|
peripheralSettings = 'peripheralSettings',
|
||||||
gait = 'walk_gait',
|
otastatus = 'otastatus',
|
||||||
servoState = 'servoState',
|
gait = 'walk_gait',
|
||||||
servoPWM = 'servoPWM',
|
servoState = 'servoState',
|
||||||
WiFiSettings = 'WiFiSettings',
|
servoPWM = 'servoPWM',
|
||||||
sonar = 'sonar',
|
WiFiSettings = 'WiFiSettings',
|
||||||
rssi = 'rssi'
|
sonar = 'sonar',
|
||||||
|
rssi = 'rssi'
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
assets: Array<{
|
assets: Array<{
|
||||||
name: string
|
name: string
|
||||||
browser_download_url: string
|
browser_download_url: string
|
||||||
}>
|
}>
|
||||||
}
|
|
||||||
|
|
||||||
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 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
|
||||||
inverted: boolean
|
inverted: boolean
|
||||||
angle: number
|
angle: number
|
||||||
center_angle: number
|
center_angle: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServoConfiguration = {
|
export type ServoConfiguration = {
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
servo_pwm_frequency: number
|
servo_pwm_frequency: number
|
||||||
servo_oscillator_frequency: number
|
servo_oscillator_frequency: number
|
||||||
servos: Servo[]
|
servos: Servo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MDNSServiceQuery {
|
export interface Result {
|
||||||
services: MDNSServiceItem[]
|
success: boolean
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MDNSServiceItem {
|
export interface DataResult extends Result {
|
||||||
ip: string
|
data?: Uint8Array
|
||||||
port: number
|
|
||||||
name: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MDNSService {
|
export interface FileInfo {
|
||||||
service: string
|
name: string
|
||||||
protocol: string
|
size: number
|
||||||
port: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MDNSTxtRecord {
|
export interface DirectoryInfo {
|
||||||
key: string
|
name: string
|
||||||
value: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MDNSStatus {
|
export interface ListResult extends Result {
|
||||||
started: boolean
|
files: FileInfo[]
|
||||||
hostname: string
|
directories: DirectoryInfo[]
|
||||||
instance: string
|
|
||||||
services: MDNSService[]
|
|
||||||
global_txt_records: MDNSTxtRecord[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransferProgress {
|
||||||
|
transferId: number
|
||||||
|
bytesTransferred: number
|
||||||
|
totalBytes: number
|
||||||
|
chunksCompleted: number
|
||||||
|
totalChunks: number
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressCallback = (progress: TransferProgress) => void
|
||||||
|
|||||||
Vendored
+11
-11
@@ -1,14 +1,14 @@
|
|||||||
declare module 'uzip' {
|
declare module 'uzip' {
|
||||||
interface UZIP {
|
interface UZIP {
|
||||||
parse(data: Uint8Array | ArrayBuffer): any;
|
parse(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array>
|
||||||
compress(data: any): Uint8Array | ArrayBuffer;
|
compress(data: Record<string, Uint8Array>): Uint8Array | ArrayBuffer
|
||||||
compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer;
|
compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer
|
||||||
decompress(data: Uint8Array | ArrayBuffer): any;
|
decompress(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array>
|
||||||
decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer;
|
decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer
|
||||||
encode(data: any): Uint8Array | ArrayBuffer;
|
encode(data: Record<string, Uint8Array>): Uint8Array | ArrayBuffer
|
||||||
decode(data: Uint8Array | ArrayBuffer): any;
|
decode(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array>
|
||||||
}
|
}
|
||||||
|
|
||||||
const uzip: UZIP;
|
const uzip: UZIP
|
||||||
export default uzip;
|
export default uzip
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
export class throttler {
|
export class Throttler {
|
||||||
private _throttlePause: boolean;
|
private _throttlePause: boolean
|
||||||
constructor() {
|
constructor() {
|
||||||
this._throttlePause = false;
|
this._throttlePause = false
|
||||||
}
|
}
|
||||||
throttle = (callback: Function, time: number) => {
|
throttle = (callback: () => void, time: number) => {
|
||||||
if (this._throttlePause) return;
|
if (this._throttlePause) return
|
||||||
|
|
||||||
this._throttlePause = true;
|
this._throttlePause = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
callback();
|
callback()
|
||||||
this._throttlePause = false;
|
this._throttlePause = false
|
||||||
}, time);
|
}, time)
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const daisyColor = (name: string, opacity: number = 100) => {
|
export const daisyColor = (name: string, opacity: number = 100) => {
|
||||||
const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
if (opacity >= 100) return color;
|
if (opacity >= 100) return color
|
||||||
const alpha = Math.min(Math.max(opacity, 0), 100) / 100;
|
const alpha = Math.min(Math.max(opacity, 0), 100) / 100
|
||||||
return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`;
|
return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export * from './result';
|
export * from './result'
|
||||||
export * from './string-utilities';
|
export * from './string-utilities'
|
||||||
export * from './svelte-utilities';
|
export * from './svelte-utilities'
|
||||||
export * from './math-utilities';
|
export * from './math-utilities'
|
||||||
export * from './buffer-utilities';
|
export * from './buffer-utilities'
|
||||||
export * from './model-utilities';
|
export * from './model-utilities'
|
||||||
export * from './position-utilities';
|
export * from './string-utilities'
|
||||||
export * from './string-utilities';
|
export * from './color-utilities'
|
||||||
export * from './color-utilities';
|
export * from './ip-utilities'
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export function ipToUint32(ip: string): number {
|
||||||
|
const parts = ip.split('.')
|
||||||
|
if (parts.length !== 4) return 0
|
||||||
|
return (
|
||||||
|
(parseInt(parts[0], 10) |
|
||||||
|
(parseInt(parts[1], 10) << 8) |
|
||||||
|
(parseInt(parts[2], 10) << 16) |
|
||||||
|
(parseInt(parts[3], 10) << 24)) >>>
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uint32ToIp(ip: number): string {
|
||||||
|
return [ip & 0xff, (ip >>> 8) & 0xff, (ip >>> 16) & 0xff, (ip >>> 24) & 0xff].join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidIpString(ip: string | undefined): boolean {
|
||||||
|
if (!ip) return false
|
||||||
|
const regexExp =
|
||||||
|
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
|
||||||
|
return regexExp.test(ip)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
export const toUint8 = (number: number, min: number, max: number) => {
|
export const toUint8 = (number: number, min: number, max: number) => {
|
||||||
number = Math.max(min, Math.min(max, number));
|
number = Math.max(min, Math.min(max, number))
|
||||||
let scaled = ((number - min) / (max - min)) * 255;
|
const scaled = ((number - min) / (max - min)) * 255
|
||||||
return Math.round(scaled) & 0xff;
|
return Math.round(scaled) & 0xff
|
||||||
};
|
}
|
||||||
|
|
||||||
export const toInt8 = (number: number, min: number, max: number) => {
|
export const toInt8 = (number: number, min: number, max: number) => {
|
||||||
number = Math.max(min, Math.min(max, number));
|
number = Math.max(min, Math.min(max, number))
|
||||||
let scaled = ((number - min) / (max - min)) * 255 - 128;
|
const scaled = ((number - min) / (max - min)) * 255 - 128
|
||||||
return Math.max(-128, Math.min(127, Math.round(scaled))) | 0;
|
return Math.max(-128, Math.min(127, Math.round(scaled))) | 0
|
||||||
};
|
}
|
||||||
|
|
||||||
export const fromInt8 = (int8: number, min: number, max: number) => {
|
export const fromInt8 = (int8: number, min: number, max: number) => {
|
||||||
int8 = Math.max(-128, Math.min(127, int8));
|
int8 = Math.max(-128, Math.min(127, int8))
|
||||||
const scaled = (int8 + 128) / 255;
|
const scaled = (int8 + 128) / 255
|
||||||
const number = scaled * (max - min) + min;
|
const number = scaled * (max - min) + min
|
||||||
return number;
|
return number
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Color, LoaderUtils, Vector3 } from 'three'
|
import { Color, Vector3 } from 'three'
|
||||||
import URDFLoader, { type URDFRobot } from 'urdf-loader'
|
import URDFLoader, { type URDFRobot } from 'urdf-loader'
|
||||||
import { XacroLoader } from 'xacro-parser'
|
import { XacroLoader } from 'xacro-parser'
|
||||||
import { Result } from '$lib/utilities'
|
import { Result } from '$lib/utilities'
|
||||||
@@ -6,88 +6,91 @@ import { currentVariant, jointNames, model } from '$lib/stores'
|
|||||||
import uzip from 'uzip'
|
import uzip from 'uzip'
|
||||||
import { fileService } from '$lib/services'
|
import { fileService } from '$lib/services'
|
||||||
import { get } from 'svelte/store'
|
import { get } from 'svelte/store'
|
||||||
|
import { resolve } from '$app/paths'
|
||||||
|
|
||||||
let model_xml: XMLDocument
|
let model_xml: XMLDocument
|
||||||
|
|
||||||
export const populateModelCache = async () => {
|
export const populateModelCache = async () => {
|
||||||
await cacheModelFiles()
|
await cacheModelFiles()
|
||||||
const modelRes = await loadModel(get(currentVariant).model)
|
const modelRes = await loadModel(get(currentVariant).model)
|
||||||
if (modelRes.isOk()) {
|
if (modelRes.isOk()) {
|
||||||
const [urdf, JOINT_NAME] = modelRes.inner
|
const [urdf, JOINT_NAME] = modelRes.inner
|
||||||
jointNames.set(JOINT_NAME)
|
jointNames.set(JOINT_NAME)
|
||||||
model.set(urdf)
|
model.set(urdf)
|
||||||
} else {
|
} else {
|
||||||
console.error(modelRes.inner, { exception: modelRes.exception })
|
console.error(modelRes.inner, { exception: modelRes.exception })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cacheModelFiles = async () => {
|
export const cacheModelFiles = async () => {
|
||||||
const data = await fetch(get(currentVariant).stl)
|
const data = await fetch(get(currentVariant).stl)
|
||||||
|
|
||||||
const files = uzip.parse(await data.arrayBuffer())
|
const files = uzip.parse(await data.arrayBuffer())
|
||||||
|
|
||||||
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 url = new URL(path, window.location.href)
|
const normalizedPath = path.startsWith('/') ? path : '/' + path
|
||||||
fileService?.saveFile(url.toString(), data)
|
const resolvedUrl = `${resolve('/')}${normalizedPath}`
|
||||||
}
|
fileService?.saveFile(resolvedUrl, data)
|
||||||
|
fileService?.saveFile(normalizedPath, data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadModel = async (url: string): Promise<Result<[URDFRobot, string[]], string>> => {
|
export const loadModel = async (url: string): Promise<Result<[URDFRobot, string[]], string>> => {
|
||||||
const urdfLoader = new URDFLoader()
|
const urdfLoader = new URDFLoader()
|
||||||
urdfLoader.workingPath = LoaderUtils.extractUrlBase(url)
|
|
||||||
|
|
||||||
let xml = url.endsWith('.xacro') ? await loadXacro(url) : await fetch(url).then(res => res.text())
|
let xml =
|
||||||
|
url.endsWith('.xacro') ? await loadXacro(url) : await fetch(url).then(res => res.text())
|
||||||
|
|
||||||
if (typeof xml === 'string') {
|
if (typeof xml === 'string') {
|
||||||
xml = new window.DOMParser().parseFromString(xml, 'text/xml')
|
xml = new window.DOMParser().parseFromString(xml, 'text/xml')
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise(resolve => {
|
|
||||||
model_xml = xml
|
|
||||||
try {
|
|
||||||
const model = urdfLoader.parse(xml)
|
|
||||||
setupRobot(model)
|
|
||||||
const joints = Object.entries(model.joints)
|
|
||||||
.filter(joint => joint[1].jointType !== 'fixed')
|
|
||||||
.map(joint => joint[0])
|
|
||||||
|
|
||||||
resolve(Result.ok([model, joints]))
|
|
||||||
} catch (error) {
|
|
||||||
resolve(Result.err('Failed to load model', error))
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
model_xml = xml
|
||||||
|
try {
|
||||||
|
const model = urdfLoader.parse(xml)
|
||||||
|
setupRobot(model)
|
||||||
|
const joints = Object.entries(model.joints)
|
||||||
|
.filter(joint => joint[1].jointType !== 'fixed')
|
||||||
|
.map(joint => joint[0])
|
||||||
|
|
||||||
|
resolve(Result.ok([model, joints]))
|
||||||
|
} catch (error) {
|
||||||
|
resolve(Result.err('Failed to load model', error))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadXacro = async (url: string): Promise<XMLDocument> =>
|
const loadXacro = async (url: string): Promise<XMLDocument> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
new XacroLoader().load(url, resolve, reject)
|
new XacroLoader().load(url, resolve, reject)
|
||||||
})
|
})
|
||||||
|
|
||||||
function setupRobot(robot: URDFRobot) {
|
function setupRobot(robot: URDFRobot) {
|
||||||
robot.rotation.x = -Math.PI / 2
|
robot.rotation.x = -Math.PI / 2
|
||||||
robot.rotation.z = Math.PI / 2
|
robot.rotation.z = Math.PI / 2
|
||||||
robot.scale.setScalar(10)
|
robot.scale.setScalar(10)
|
||||||
robot.traverse(c => (c.castShadow = true))
|
robot.traverse(c => (c.castShadow = true))
|
||||||
robot.updateMatrixWorld(true)
|
robot.updateMatrixWorld(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getToeWorldPositions(robot: URDFRobot): Vector3[] {
|
export function getToeWorldPositions(robot: URDFRobot): Vector3[] {
|
||||||
const toes: Vector3[] = []
|
const toes: Vector3[] = []
|
||||||
robot.traverse(c => {
|
robot.traverse(c => {
|
||||||
if (c.name.includes('toe') && !c.name.includes('_link'))
|
if (c.name.includes('toe') && !c.name.includes('_link'))
|
||||||
toes.push(c.getWorldPosition(new Vector3()))
|
toes.push(c.getWorldPosition(new Vector3()))
|
||||||
})
|
})
|
||||||
return toes
|
return toes
|
||||||
}
|
}
|
||||||
|
|
||||||
export const extractFootColor = () => {
|
export const extractFootColor = () => {
|
||||||
const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element
|
const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element
|
||||||
const colorAttrStr = colorElem.getAttribute('rgba') as string
|
const colorAttrStr = colorElem.getAttribute('rgba') as string
|
||||||
const colorStr = colorAttrStr
|
const colorStr = colorAttrStr
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map(val => Math.floor(+val * 255))
|
.map(val => Math.floor(+val * 255))
|
||||||
.join(', ')
|
.join(', ')
|
||||||
|
|
||||||
return new Color(`rgb(${colorStr})`)
|
return new Color(`rgb(${colorStr})`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
class SunCalculator {
|
|
||||||
calculateSunElevation(lat: number = 55, lon: number = 12) {
|
|
||||||
const now = new Date();
|
|
||||||
const JD = this.getJulianDate(now);
|
|
||||||
const solarDec = this.getSolarDeclination(JD);
|
|
||||||
const solarTime = this.getSolarTime(now, lon);
|
|
||||||
|
|
||||||
const hourAngle = (solarTime - 12) * 15;
|
|
||||||
const elevation = Math.asin(
|
|
||||||
Math.sin(this.degToRad(lat)) * Math.sin(solarDec) +
|
|
||||||
Math.cos(this.degToRad(lat)) * Math.cos(solarDec) * Math.cos(this.degToRad(hourAngle))
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.radToDeg(elevation);
|
|
||||||
}
|
|
||||||
|
|
||||||
getJulianDate(date: Date) {
|
|
||||||
const Y = date.getUTCFullYear();
|
|
||||||
const M = date.getUTCMonth() + 1;
|
|
||||||
const D =
|
|
||||||
date.getUTCDate() +
|
|
||||||
date.getUTCHours() / 24 +
|
|
||||||
date.getUTCMinutes() / 1440 +
|
|
||||||
date.getUTCSeconds() / 86400;
|
|
||||||
const A = Math.floor((14 - M) / 12);
|
|
||||||
const Y1 = Y + 4800 - A;
|
|
||||||
const M1 = M + 12 * A - 3;
|
|
||||||
return (
|
|
||||||
D +
|
|
||||||
Math.floor((153 * M1 + 2) / 5) +
|
|
||||||
365 * Y1 +
|
|
||||||
Math.floor(Y1 / 4) -
|
|
||||||
Math.floor(Y1 / 100) +
|
|
||||||
Math.floor(Y1 / 400) -
|
|
||||||
32045
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSolarDeclination(JulianDate: number) {
|
|
||||||
const n = JulianDate - 2451545;
|
|
||||||
const L = (280.46 + 0.9856474 * n) % 360;
|
|
||||||
const g = this.degToRad((357.528 + 0.9856003 * n) % 360);
|
|
||||||
const lambda = this.degToRad(L + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g));
|
|
||||||
return Math.asin(Math.sin(lambda) * Math.sin(this.degToRad(23.44)));
|
|
||||||
}
|
|
||||||
|
|
||||||
getSolarTime(date: Date, lon: number) {
|
|
||||||
const EoT = this.getEquationOfTime(date);
|
|
||||||
const offset = date.getTimezoneOffset() / 60;
|
|
||||||
const standardMeridian = Math.round(lon / 15) * 15;
|
|
||||||
const solarTime =
|
|
||||||
date.getUTCHours() +
|
|
||||||
(date.getUTCMinutes() + (4 * (standardMeridian - lon) + EoT)) / 60 -
|
|
||||||
offset;
|
|
||||||
return (solarTime + 24) % 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
getEquationOfTime(date: Date) {
|
|
||||||
const JD = this.getJulianDate(date);
|
|
||||||
const n = JD - 2451545;
|
|
||||||
const g = this.degToRad((357.528 + 0.9856003 * n) % 360);
|
|
||||||
const q = this.degToRad((280.46 + 0.9856474 * n) % 360);
|
|
||||||
return (
|
|
||||||
4 *
|
|
||||||
this.radToDeg(
|
|
||||||
0.000075 +
|
|
||||||
0.001868 * Math.cos(q) -
|
|
||||||
0.032077 * Math.sin(g) -
|
|
||||||
0.014615 * Math.cos(2 * q) -
|
|
||||||
0.040849 * Math.sin(2 * g)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
degToRad(deg: number) {
|
|
||||||
return deg * (Math.PI / 180);
|
|
||||||
}
|
|
||||||
|
|
||||||
radToDeg(rad: number) {
|
|
||||||
return rad * (180 / Math.PI);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sunCalculator = new SunCalculator();
|
|
||||||
@@ -1,42 +1,42 @@
|
|||||||
export class Err<T, U> {
|
export class Err<T, U> {
|
||||||
#inner: T;
|
#inner: T
|
||||||
#exception?: U;
|
#exception?: U
|
||||||
|
|
||||||
constructor(inner: T, exception?: U) {
|
constructor(inner: T, exception?: U) {
|
||||||
this.#inner = inner;
|
this.#inner = inner
|
||||||
this.#exception = exception;
|
this.#exception = exception
|
||||||
}
|
}
|
||||||
|
|
||||||
get inner(): T {
|
get inner(): T {
|
||||||
return this.#inner;
|
return this.#inner
|
||||||
}
|
}
|
||||||
|
|
||||||
get exception(): U | undefined {
|
get exception(): U | undefined {
|
||||||
return this.#exception;
|
return this.#exception
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard for `Ok`
|
* Type guard for `Ok`
|
||||||
* @returns `true` if `Ok`; `false` if `Err`
|
* @returns `true` if `Ok`; `false` if `Err`
|
||||||
*/
|
*/
|
||||||
isOk(): false {
|
isOk(): false {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard for `Err`
|
* Type guard for `Err`
|
||||||
* @returns `true` if `Err`; `false` if `Ok`
|
* @returns `true` if `Err`; `false` if `Ok`
|
||||||
*/
|
*/
|
||||||
isErr(): this is Err<T, U> {
|
isErr(): this is Err<T, U> {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an `Err`
|
* Create an `Err`
|
||||||
* @param inner
|
* @param inner
|
||||||
* @returns `Err(inner)`
|
* @returns `Err(inner)`
|
||||||
*/
|
*/
|
||||||
static new<E, F>(inner: E, exception: F): Err<E, F> {
|
static new<E, F>(inner: E, exception: F): Err<E, F> {
|
||||||
return new Err<E, F>(inner, exception);
|
return new Err<E, F>(inner, exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from './err';
|
export * from './err'
|
||||||
export * from './ok';
|
export * from './ok'
|
||||||
export * from './result';
|
export * from './result'
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
export class Ok<T> {
|
export class Ok<T> {
|
||||||
#inner: T;
|
#inner: T
|
||||||
|
|
||||||
constructor(inner: T) {
|
constructor(inner: T) {
|
||||||
this.#inner = inner;
|
this.#inner = inner
|
||||||
}
|
}
|
||||||
|
|
||||||
get inner(): T {
|
get inner(): T {
|
||||||
return this.#inner;
|
return this.#inner
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard for `Ok`
|
* Type guard for `Ok`
|
||||||
* @returns `true` if `Ok`; `false` if `Err`
|
* @returns `true` if `Ok`; `false` if `Err`
|
||||||
*/
|
*/
|
||||||
isOk(): this is Ok<T> {
|
isOk(): this is Ok<T> {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard for `Err`
|
* Type guard for `Err`
|
||||||
* @returns `true` if `Err`; `false` if `Ok`
|
* @returns `true` if `Err`; `false` if `Ok`
|
||||||
*/
|
*/
|
||||||
isErr(): false {
|
isErr(): false {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an `Ok`
|
* Create an `Ok`
|
||||||
* @param inner
|
* @param inner
|
||||||
* @returns `Ok(inner)`
|
* @returns `Ok(inner)`
|
||||||
*/
|
*/
|
||||||
static new<T>(inner: T): Ok<T> {
|
static new<T>(inner: T): Ok<T> {
|
||||||
return new Ok<T>(inner);
|
return new Ok<T>(inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an empty `Ok`
|
* Create an empty `Ok`
|
||||||
* @returns `Ok(void)`
|
* @returns `Ok(void)`
|
||||||
*/
|
*/
|
||||||
static void(): Ok<void> {
|
static void(): Ok<void> {
|
||||||
return new Ok(undefined);
|
return new Ok(undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { Err } from './err';
|
import { Err } from './err'
|
||||||
import { Ok } from './ok';
|
import { Ok } from './ok'
|
||||||
|
|
||||||
export type Result<T = unknown, E = unknown, F = unknown> = Ok<T> | Err<E, F>;
|
export type Result<T = unknown, E = unknown, F = unknown> = Ok<T> | Err<E, F>
|
||||||
|
|
||||||
export namespace Result {
|
export const Result = {
|
||||||
/**
|
/**
|
||||||
* @returns `Ok<T>`
|
* @returns `Ok<T>`
|
||||||
*/
|
*/
|
||||||
export function ok<T = unknown>(value: T) {
|
ok<T = unknown>(value: T) {
|
||||||
return Ok.new(value);
|
return Ok.new(value)
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns `Err<E, F>`
|
* @returns `Err<E, F>`
|
||||||
*/
|
*/
|
||||||
export function err<E = unknown, F = unknown>(error: E, exception?: F) {
|
err<E = unknown, F = unknown>(error: E, exception?: F) {
|
||||||
return Err.new(error, exception);
|
return Err.new(error, exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,47 @@
|
|||||||
export const humanFileSize = (size: number): string => {
|
export const humanFileSize = (size: number): string => {
|
||||||
const units = ['B', 'kB', 'MB', 'GB', 'TB']
|
const units = ['B', 'kB', 'MB', 'GB', 'TB']
|
||||||
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024))
|
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024))
|
||||||
return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i]
|
return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const capitalize = (str: string): string => {
|
export const capitalize = (str: string): string => {
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
|
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const convertSeconds = (seconds: number) => {
|
export const convertSeconds = (seconds: number) => {
|
||||||
// Calculate the number of seconds, minutes, hours, and days
|
// Calculate the number of seconds, minutes, hours, and days
|
||||||
let minutes = Math.floor(seconds / 60)
|
let minutes = Math.floor(seconds / 60)
|
||||||
let hours = Math.floor(minutes / 60)
|
let hours = Math.floor(minutes / 60)
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
// Calculate the remaining hours, minutes, and seconds
|
// Calculate the remaining hours, minutes, and seconds
|
||||||
hours = hours % 24
|
hours = hours % 24
|
||||||
minutes = minutes % 60
|
minutes = minutes % 60
|
||||||
seconds = seconds % 60
|
seconds = seconds % 60
|
||||||
|
|
||||||
// Create the formatted string
|
// Create the formatted string
|
||||||
let result = ''
|
let result = ''
|
||||||
if (days > 0) {
|
if (days > 0) {
|
||||||
result += days + ' day' + (days > 1 ? 's' : '') + ' '
|
result += days + ' day' + (days > 1 ? 's' : '') + ' '
|
||||||
}
|
}
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '
|
result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '
|
||||||
}
|
}
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' '
|
result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' '
|
||||||
}
|
}
|
||||||
result += seconds + ' second' + (seconds > 1 ? 's' : '')
|
result += seconds + ' second' + (seconds > 1 ? 's' : '')
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export const compareIp = (ip1: string, ip2: string) => {
|
export const compareIp = (ip1: string, ip2: string) => {
|
||||||
const ip1Parts = ip1.split('.').map(Number)
|
const ip1Parts = ip1.split('.').map(Number)
|
||||||
const ip2Parts = ip2.split('.').map(Number)
|
const ip2Parts = ip2.split('.').map(Number)
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
if (ip1Parts[i] !== ip2Parts[i]) {
|
if (ip1Parts[i] !== ip2Parts[i]) {
|
||||||
return ip1Parts[i] > ip2Parts[i] ? 1 : -1
|
return ip1Parts[i] > ip2Parts[i] ? 1 : -1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return 0
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store'
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment'
|
||||||
|
|
||||||
export const persistentStore = <T>(key: string, initialValue: T) => {
|
export const persistentStore = <T>(key: string, initialValue: T) => {
|
||||||
const savedValue = browser ? localStorage.getItem(key) : null;
|
const savedValue = browser ? localStorage.getItem(key) : null
|
||||||
const data: T = savedValue !== null ? JSON.parse(savedValue) : initialValue;
|
const data: T = savedValue !== null ? JSON.parse(savedValue) : initialValue
|
||||||
const store = writable<T>();
|
const store = writable<T>()
|
||||||
|
|
||||||
store.subscribe(value => {
|
store.subscribe(value => {
|
||||||
if (browser) localStorage.setItem(key, JSON.stringify(value));
|
if (browser) localStorage.setItem(key, JSON.stringify(value))
|
||||||
});
|
})
|
||||||
|
|
||||||
store.set(data);
|
store.set(data)
|
||||||
|
|
||||||
return store;
|
return store
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
|
import { resolve } from '$app/paths'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex justify-center items-center w-full h-full">
|
<div class="flex justify-center items-center w-full h-full">
|
||||||
<h1>{page.status} {page.error?.message}</h1>
|
<h1>{page.status} {page.error?.message}</h1>
|
||||||
<span>Go to <a class="btn btn-primary" href="/">Home page</a></span>
|
<span>Go to <a class="btn btn-primary" href={resolve('/')}>Home page</a></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+105
-101
@@ -1,129 +1,133 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { Modals, modals } from 'svelte-modals'
|
import { Modals, modals } from 'svelte-modals'
|
||||||
import Toast from '$lib/components/toasts/Toast.svelte'
|
import Toast from '$lib/components/toasts/Toast.svelte'
|
||||||
import { notifications } from '$lib/components/toasts/notifications'
|
import { notifications } from '$lib/components/toasts/notifications'
|
||||||
import { fade } from 'svelte/transition'
|
import { fade } from 'svelte/transition'
|
||||||
import '../app.css'
|
import '../app.css'
|
||||||
import Menu from '../lib/components/menu/Menu.svelte'
|
import Menu from '../lib/components/menu/Menu.svelte'
|
||||||
import Statusbar from '../lib/components/statusbar/statusbar.svelte'
|
import Statusbar from '../lib/components/statusbar/statusbar.svelte'
|
||||||
import {
|
import {
|
||||||
telemetry,
|
telemetry,
|
||||||
analytics,
|
kinematicData,
|
||||||
ModesEnum,
|
mode,
|
||||||
kinematicData,
|
input,
|
||||||
mode,
|
servoAngles,
|
||||||
outControllerData,
|
servoAnglesOut,
|
||||||
servoAngles,
|
socket,
|
||||||
servoAnglesOut,
|
apiLocation,
|
||||||
socket,
|
useFeatureFlags,
|
||||||
location,
|
walkGait
|
||||||
useFeatureFlags,
|
} from '$lib/stores'
|
||||||
walkGait
|
import {
|
||||||
} from '$lib/stores'
|
AnglesData,
|
||||||
import { type Analytics, type DownloadOTA } from '$lib/types/models'
|
DownloadOTAData,
|
||||||
import { MessageTopic } from '$lib/types/models'
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props()
|
let { children }: Props = $props()
|
||||||
|
|
||||||
const features = useFeatureFlags()
|
const features = useFeatureFlags()
|
||||||
|
const throttler = new Throttler()
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const ws = $location ? $location : window.location.host
|
const ws = $apiLocation ? $apiLocation : window.location.host
|
||||||
socket.init(`ws://${ws}/api/ws/events`)
|
socket.init(`ws://${ws}/api/ws`)
|
||||||
|
|
||||||
addEventListeners()
|
addEventListeners()
|
||||||
|
|
||||||
outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data))
|
input.subscribe(data => throttler.throttle(() => socket.emit(ControllerData, data), 100))
|
||||||
mode.subscribe(data => socket.sendEvent(MessageTopic.mode, data))
|
mode.subscribe(data => socket.emit(ModeData, data))
|
||||||
walkGait.subscribe(data => socket.sendEvent(MessageTopic.gait, data))
|
walkGait.subscribe(data => socket.emit(WalkGaitData, data))
|
||||||
servoAnglesOut.subscribe(data => socket.sendEvent(MessageTopic.angles, data))
|
servoAnglesOut.subscribe(data =>
|
||||||
kinematicData.subscribe(data => socket.sendEvent(MessageTopic.position, data))
|
throttler.throttle(() => socket.emit(AnglesData, data), 100)
|
||||||
})
|
)
|
||||||
|
kinematicData.subscribe(data => socket.emit(KinematicData, data))
|
||||||
onDestroy(() => {
|
|
||||||
removeEventListeners()
|
|
||||||
})
|
|
||||||
|
|
||||||
const addEventListeners = () => {
|
|
||||||
socket.on('open', handleOpen)
|
|
||||||
socket.on('close', handleClose)
|
|
||||||
socket.on('error', handleError)
|
|
||||||
socket.on(MessageTopic.rssi, handleNetworkStatus)
|
|
||||||
socket.on(MessageTopic.mode, (data: ModesEnum) => mode.set(data))
|
|
||||||
socket.on(MessageTopic.analytics, handleAnalytics)
|
|
||||||
socket.on(MessageTopic.angles, (angles: number[]) => {
|
|
||||||
if (angles.length) servoAngles.set(angles)
|
|
||||||
})
|
})
|
||||||
features.subscribe(data => {
|
|
||||||
if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT)
|
onDestroy(() => {
|
||||||
if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data))
|
removeEventListeners()
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const removeEventListeners = () => {
|
const eventListeners: (() => void)[] = []
|
||||||
socket.off(MessageTopic.analytics, handleAnalytics)
|
const addEventListeners = () => {
|
||||||
socket.off('open', handleOpen)
|
eventListeners.push(
|
||||||
socket.off('close', handleClose)
|
socket.onEvent('open', handleOpen),
|
||||||
socket.off(MessageTopic.rssi, handleNetworkStatus)
|
socket.onEvent('close', handleClose),
|
||||||
socket.off(MessageTopic.otastatus, handleOAT)
|
socket.onEvent('error', handleError),
|
||||||
}
|
socket.on(RSSIData, data => telemetry.setRSSI(data)),
|
||||||
|
socket.on(ModeData, data => mode.set(data)),
|
||||||
|
socket.on(AnglesData, data => {
|
||||||
|
servoAngles.set(data)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
features.subscribe(data => {
|
||||||
|
if (data?.download_firmware)
|
||||||
|
eventListeners.push(
|
||||||
|
socket.on(DownloadOTAData, data => telemetry.setDownloadOTA(data))
|
||||||
|
)
|
||||||
|
if (data?.sonar) eventListeners.push(socket.on(SonarData, data => console.log(data)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpen = () => {
|
const removeEventListeners = () => {
|
||||||
notifications.success('Connection to device established', 5000)
|
eventListeners.forEach(offFunction => offFunction())
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleOpen = () => notifications.success('Connection to device established', 5000)
|
||||||
notifications.error('Connection to device lost', 5000)
|
|
||||||
telemetry.setRSSI(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleError = (data: any) => console.error(data)
|
const handleClose = () => {
|
||||||
|
notifications.error('Connection to device lost', 5000)
|
||||||
|
telemetry.setRSSI(RSSIData.create({ rssi: 0 }))
|
||||||
|
}
|
||||||
|
|
||||||
const handleAnalytics = (data: Analytics) => analytics.addData(data)
|
const handleError = (data: unknown) => console.error(data)
|
||||||
|
|
||||||
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data)
|
let menuOpen = $state(false)
|
||||||
|
|
||||||
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data)
|
|
||||||
|
|
||||||
let menuOpen = $state(false)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{page.data.title}</title>
|
<title>{page.data.title}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="drawer">
|
<div class="drawer">
|
||||||
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
|
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
|
||||||
<div class="drawer-content flex flex-col">
|
<div class="drawer-content flex flex-col">
|
||||||
<!-- Status bar content here -->
|
<!-- Status bar content here -->
|
||||||
<Statusbar />
|
<Statusbar />
|
||||||
|
|
||||||
<!-- Main page content here -->
|
<!-- Main page content here -->
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
<!-- Side Navigation -->
|
<!-- Side Navigation -->
|
||||||
<div class="drawer-side z-30 shadow-lg">
|
<div class="drawer-side z-30 shadow-lg">
|
||||||
<label for="main-menu" class="drawer-overlay"></label>
|
<label for="main-menu" class="drawer-overlay"></label>
|
||||||
<Menu menuClicked={() => (menuOpen = false)} />
|
<Menu menuClicked={() => (menuOpen = false)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modals>
|
<Modals>
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
{#snippet backdrop()}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div
|
||||||
{#snippet backdrop()}
|
class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm"
|
||||||
<div
|
transition:fade
|
||||||
class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm"
|
onclick={modals.closeAll}
|
||||||
transition:fade
|
onkeydown={e => e.key === 'Escape' && modals.closeAll()}
|
||||||
onclick={modals.closeAll}>
|
role="button"
|
||||||
</div>
|
tabindex="0"
|
||||||
{/snippet}
|
></div>
|
||||||
|
{/snippet}
|
||||||
</Modals>
|
</Modals>
|
||||||
|
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|||||||
+28
-14
@@ -2,21 +2,35 @@ export const prerender = true
|
|||||||
export const ssr = false
|
export const ssr = false
|
||||||
|
|
||||||
const registerFetchIntercept = async () => {
|
const registerFetchIntercept = async () => {
|
||||||
const { fetch: originalFetch } = window
|
const { fetch: originalFetch } = window
|
||||||
const fileService = (await import('$lib/services/file-service')).default
|
const fileService = (await import('$lib/services/file-service')).default
|
||||||
window.fetch = async (resource, config) => {
|
window.fetch = async (resource, config) => {
|
||||||
const url = resource instanceof Request ? resource.url : resource.toString()
|
const url = resource instanceof Request ? resource.url : resource.toString()
|
||||||
const file = await fileService?.getFile(url)
|
|
||||||
return file?.isOk() ? new Response(file.inner) : originalFetch(resource, config)
|
let file = await fileService?.getFile(url)
|
||||||
}
|
if (file?.isOk() && file.inner) return new Response(new Uint8Array(file.inner))
|
||||||
|
|
||||||
|
if (url.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
const pathOnly = urlObj.pathname
|
||||||
|
file = await fileService?.getFile(pathOnly)
|
||||||
|
if (file?.isOk() && file.inner) return new Response(new Uint8Array(file.inner))
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to get file for ', url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalFetch(resource, config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load = async () => {
|
export const load = async () => {
|
||||||
await registerFetchIntercept()
|
await registerFetchIntercept()
|
||||||
return {
|
return {
|
||||||
title: 'Spot micro controller',
|
title: 'Spot micro controller',
|
||||||
github: 'runeharlyk/SpotMicroESP32-Leika',
|
github: 'runeharlyk/SpotMicroESP32-Leika',
|
||||||
app_name: 'Spot Micro Controller',
|
app_name: 'Spot Micro Controller',
|
||||||
copyright: '2025 Rune Harlyk'
|
copyright: '2025 Rune Harlyk'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-19
@@ -1,27 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import Visualization from '$lib/components/Visualization.svelte'
|
import Visualization from '$lib/components/Visualization.svelte'
|
||||||
import { socket } from '$lib/stores'
|
import { socket } from '$lib/stores'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
import { resolve } from '$app/paths'
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
socket.subscribe(isConnected => {
|
socket.subscribe(isConnected => {
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
goto('/controller')
|
goto(resolve('/controller'))
|
||||||
}
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="hero bg-base-100 h-screen">
|
<div class="hero bg-base-100 h-screen">
|
||||||
<div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center">
|
<div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center">
|
||||||
<div class="w-64 h-64">
|
<div class="w-64 h-64">
|
||||||
<Visualization sky={false} orbit panel={false} ground={false} />
|
<Visualization defaultColor={null} orbit panel={false} ground={false} />
|
||||||
|
</div>
|
||||||
|
<div class="card-body w-80">
|
||||||
|
<h2 class="card-title text-center text-2xl">Begin you journey</h2>
|
||||||
|
<p class="py-6 text-center"></p>
|
||||||
|
<a class="btn btn-primary" href={resolve($socket ? '/controller' : '/connection')}>
|
||||||
|
Add Robot Dog
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body w-80">
|
|
||||||
<h2 class="card-title text-center text-2xl">Begin you journey</h2>
|
|
||||||
<p class="py-6 text-center"></p>
|
|
||||||
<a class="btn btn-primary" href={$socket ? '/controller' : '/connection'}> Add Robot Dog </a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user