1 Commits

Author SHA1 Message Date
Rune Harlyk d0b192a3e6 💅 Updates the frontpage 2025-03-08 13:06:56 +01:00
196 changed files with 7111 additions and 219338 deletions
-61
View File
@@ -1,61 +0,0 @@
name: Deploy GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./app
env:
BASE_PATH: /SpotMicroESP32-Leika
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: "./app/pnpm-lock.yaml"
- run: pnpm install
- run: pnpm run build
- name: Setup Pages
uses: actions/configure-pages@v4
with:
static_site_generator: "sveltekit"
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: app/build/
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+9 -8
View File
@@ -2,19 +2,20 @@ name: PlatformIO CI
on:
push:
branches: [master]
branches: [ master ]
paths:
- "esp32/**"
- "platformio.ini"
- 'esp32/**'
pull_request:
branches: [master]
branches: [ master ]
paths:
- "esp32/**"
- "platformio.ini"
- 'esp32/**'
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./esp32
steps:
- uses: actions/checkout@v3
@@ -27,8 +28,8 @@ jobs:
- uses: actions/setup-python@v4
with:
python-version: "3.x"
- run: pip install -r esp32/scripts/requirements.txt
python-version: '3.x'
- run: pip install -r ./scripts/requirements.txt
- name: Install PlatformIO Core
run: pip install --upgrade platformio
+1 -1
View File
@@ -2,7 +2,7 @@
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
__pycache__/
*.py[cod]
*$py.class
.pio
+3 -3
View File
@@ -2,10 +2,10 @@
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode",
"platformio.platformio-ide",
"svelte.svelte-vscode"
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
-3
View File
@@ -1,3 +0,0 @@
PUBLIC_VITE_USE_HOST_NAME=true
PUBLIC_USE_JSON=true
PUBLIC_USE_MSGPACK=true
+1
View File
@@ -3,6 +3,7 @@ node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
+17 -8
View File
@@ -8,21 +8,30 @@ If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created your project, follow these steps:
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
1: Delete package-lock.json
2: Check `git status`. If you see any changes other than package-lock.json or favicon.ico, run the command `git restore ./` (See below)
3: Run `npm install` or `pnpm install` or `yarn` to install the dependencies
4: Run `npm run build` to build the project
```bash
npm run dev
Running `git status` should show:
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
[![example.png](https://i.postimg.cc/yddM3hH3/example.png)](https://postimg.cc/7CFsp2bq)
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
+1 -1
View File
@@ -45,7 +45,6 @@
},
"type": "module",
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
"@niku/vite-env-caster": "^1.0.2",
"@sveltejs/adapter-auto": "^4.0.0",
"@tailwindcss/vite": "^4.0.12",
@@ -53,6 +52,7 @@
"compare-versions": "^6.1.0",
"cross-env": "^7.0.3",
"daisyui": "^5.0.0",
"jwt-decode": "^4.0.0",
"nipplejs": "^0.10.1",
"svelte-dnd-list": "^0.1.8",
"svelte-modals": "^2.0.0",
+41 -58
View File
@@ -8,18 +8,15 @@ importers:
.:
dependencies:
'@msgpack/msgpack':
specifier: ^3.1.2
version: 3.1.2
'@niku/vite-env-caster':
specifier: ^1.0.2
version: 1.1.2
'@sveltejs/adapter-auto':
specifier: ^4.0.0
version: 4.0.0(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))
version: 4.0.0(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))
'@tailwindcss/vite':
specifier: ^4.0.12
version: 4.0.12(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
version: 4.0.12(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
chart.js:
specifier: ^4.4.2
version: 4.4.2
@@ -32,6 +29,9 @@ importers:
daisyui:
specifier: ^5.0.0
version: 5.0.0
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
nipplejs:
specifier: ^0.10.1
version: 0.10.1
@@ -65,13 +65,13 @@ importers:
version: 1.49.1
'@sveltejs/adapter-static':
specifier: ^3.0.1
version: 3.0.1(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))
version: 3.0.1(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))
'@sveltejs/kit':
specifier: ^2.5.27
version: 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
version: 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
'@sveltejs/vite-plugin-svelte':
specifier: ^5.0.3
version: 5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
version: 5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
'@types/eslint':
specifier: ^8.56.0
version: 8.56.0
@@ -128,10 +128,10 @@ importers:
version: 0.18.5
vite:
specifier: ^6.2.1
version: 6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
version: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
vitest:
specifier: ^1.2.0
version: 1.2.0(@types/node@24.0.12)(jsdom@24.0.0)(lightningcss@1.29.2)
version: 1.2.0(jsdom@24.0.0)(lightningcss@1.29.2)
packages:
@@ -509,10 +509,6 @@ packages:
'@kurkle/color@0.3.2':
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
'@msgpack/msgpack@3.1.2':
resolution: {integrity: sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==}
engines: {node: '>= 18'}
'@niku/vite-env-caster@1.1.2':
resolution: {integrity: sha512-6I/8REFdmfeGnK92H3nYHGc6lExwjm72jLxAsDPlfji97Eej4rOMl6WuYGLgsQI0pl5RrMRMveeRdijdL6hW+Q==}
@@ -764,9 +760,6 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@24.0.12':
resolution: {integrity: sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==}
'@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
@@ -1430,6 +1423,10 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2022,9 +2019,6 @@ packages:
ufo@1.5.3:
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
undici-types@7.8.0:
resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==}
universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'}
@@ -2538,8 +2532,6 @@ snapshots:
'@kurkle/color@0.3.2': {}
'@msgpack/msgpack@3.1.2': {}
'@niku/vite-env-caster@1.1.2':
dependencies:
chalk: 4.1.2
@@ -2621,18 +2613,18 @@ snapshots:
'@sinclair/typebox@0.27.8': {}
'@sveltejs/adapter-auto@4.0.0(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))':
'@sveltejs/adapter-auto@4.0.0(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))':
dependencies:
'@sveltejs/kit': 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
'@sveltejs/kit': 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
import-meta-resolve: 4.1.0
'@sveltejs/adapter-static@3.0.1(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))':
'@sveltejs/adapter-static@3.0.1(@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))':
dependencies:
'@sveltejs/kit': 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
'@sveltejs/kit': 2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
'@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))':
'@sveltejs/kit@2.17.3(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
'@types/cookie': 0.6.0
cookie: 0.6.0
devalue: 5.1.1
@@ -2645,27 +2637,27 @@ snapshots:
set-cookie-parser: 2.6.0
sirv: 3.0.1
svelte: 5.20.4
vite: 6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
vite: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
'@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))':
'@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
debug: 4.4.0
svelte: 5.20.4
vite: 6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
vite: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))':
'@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
'@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)))(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
debug: 4.4.0
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.17
svelte: 5.20.4
vite: 6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
vitefu: 1.0.6(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
vite: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
vitefu: 1.0.6(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))
transitivePeerDependencies:
- supports-color
@@ -2722,13 +2714,13 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.0.12
'@tailwindcss/oxide-win32-x64-msvc': 4.0.12
'@tailwindcss/vite@4.0.12(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))':
'@tailwindcss/vite@4.0.12(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))':
dependencies:
'@tailwindcss/node': 4.0.12
'@tailwindcss/oxide': 4.0.12
lightningcss: 1.29.2
tailwindcss: 4.0.12
vite: 6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
vite: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
'@tweenjs/tween.js@23.1.2': {}
@@ -2745,11 +2737,6 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/node@24.0.12':
dependencies:
undici-types: 7.8.0
optional: true
'@types/semver@7.5.8': {}
'@types/stats.js@0.17.3': {}
@@ -3513,6 +3500,8 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
jwt-decode@4.0.0: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -4021,9 +4010,6 @@ snapshots:
ufo@1.5.3: {}
undici-types@7.8.0:
optional: true
universalify@0.2.0: {}
unplugin-icons@0.18.5:
@@ -4068,13 +4054,13 @@ snapshots:
uzip@0.20201231.0: {}
vite-node@1.2.0(@types/node@24.0.12)(lightningcss@1.29.2):
vite-node@1.2.0(lightningcss@1.29.2):
dependencies:
cac: 6.7.14
debug: 4.4.0
pathe: 1.1.2
picocolors: 1.0.1
vite: 5.4.14(@types/node@24.0.12)(lightningcss@1.29.2)
vite: 5.4.14(lightningcss@1.29.2)
transitivePeerDependencies:
- '@types/node'
- less
@@ -4086,33 +4072,31 @@ snapshots:
- supports-color
- terser
vite@5.4.14(@types/node@24.0.12)(lightningcss@1.29.2):
vite@5.4.14(lightningcss@1.29.2):
dependencies:
esbuild: 0.21.5
postcss: 8.5.3
rollup: 4.34.8
optionalDependencies:
'@types/node': 24.0.12
fsevents: 2.3.3
lightningcss: 1.29.2
vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2):
vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2):
dependencies:
esbuild: 0.25.0
postcss: 8.5.3
rollup: 4.34.8
optionalDependencies:
'@types/node': 24.0.12
fsevents: 2.3.3
jiti: 2.4.2
lightningcss: 1.29.2
yaml: 2.4.2
vitefu@1.0.6(vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)):
vitefu@1.0.6(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)):
optionalDependencies:
vite: 6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
vite: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
vitest@1.2.0(@types/node@24.0.12)(jsdom@24.0.0)(lightningcss@1.29.2):
vitest@1.2.0(jsdom@24.0.0)(lightningcss@1.29.2):
dependencies:
'@vitest/expect': 1.2.0
'@vitest/runner': 1.2.0
@@ -4132,11 +4116,10 @@ snapshots:
strip-literal: 1.3.0
tinybench: 2.8.0
tinypool: 0.8.4
vite: 5.4.14(@types/node@24.0.12)(lightningcss@1.29.2)
vite-node: 1.2.0(@types/node@24.0.12)(lightningcss@1.29.2)
vite: 5.4.14(lightningcss@1.29.2)
vite-node: 1.2.0(lightningcss@1.29.2)
why-is-node-running: 2.2.2
optionalDependencies:
'@types/node': 24.0.12
jsdom: 24.0.0
transitivePeerDependencies:
- less
+27 -9
View File
@@ -1,8 +1,18 @@
<script lang="ts">
import { focusTrap } from 'svelte-focus-trap'
import { fly } from 'svelte/transition'
import { Cancel, Check } from '$lib/components/icons'
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition';
import { Cancel, Check } from '$lib/components/icons';
import { modals, exitBeforeEnter } from 'svelte-modals';
// provided by <Modals />
interface Props {
isOpen: boolean;
title: string;
message: string;
onConfirm: any;
labels?: any;
}
let {
isOpen,
@@ -13,7 +23,7 @@
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'OK', icon: Check }
}
}: ModalProps = $props()
}: Props = $props();
</script>
{#if isOpen}
@@ -23,18 +33,26 @@
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
use:focusTrap
>
<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">
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
>
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<div class="divider my-2"></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-error inline-flex items-center" onclick={() => modals.close()}>
<button
class="btn btn-primary 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}>
<button
class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onConfirm}
>
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span>
</button>
</div>
+20 -9
View File
@@ -2,17 +2,25 @@
import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition';
import { Check } from './icons';
import { exitBeforeEnter, type ModalProps } from 'svelte-modals';
import { exitBeforeEnter } from 'svelte-modals';
// provided by <Modals />
interface Props {
isOpen: boolean;
title: string;
message: string;
onDismiss: any;
dismiss?: any;
}
let {
isOpen,
title,
message,
onDismiss,
labels = {
dismiss: { label: 'Dismiss', icon: Check },
},
}: ModalProps = $props();
dismiss = { label: 'Dismiss', icon: Check }
}: Props = $props();
</script>
{#if isOpen}
@@ -21,9 +29,11 @@
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
use:focusTrap
>
<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">
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
>
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<div class="divider my-2"></div>
<p class="text-base-content mb-1 text-start">{message}</p>
@@ -31,8 +41,9 @@
<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>
onclick={onDismiss}
>
<dismiss.icon class="mr-2 h-5 w-5" /><span>{dismiss.label}</span>
</button>
</div>
</div>
@@ -1,78 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import * as THREE from 'three';
import { imu } from '$lib/stores/imu';
import SceneBuilder from '$lib/sceneBuilder';
let canvas: HTMLCanvasElement;
let sceneBuilder: SceneBuilder;
let cube: THREE.Mesh;
let targetRotation = new THREE.Euler();
let lastUpdateTime = 0;
const LERP_SPEED = 5; // rotations per second
const initThreeJS = () => {
sceneBuilder = new SceneBuilder()
.addRenderer({ canvas: canvas, antialias: true, alpha: true })
.addPerspectiveCamera({ x: 2, y: 0, z: 2 })
.addOrbitControls(1, 10, false)
.addAmbientLight({ color: 0x404040, intensity: 0.5 })
.addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 })
.fillParent();
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshPhongMaterial({
color: 0x00ff00,
transparent: true,
opacity: 0.8,
});
cube = new THREE.Mesh(geometry, material);
sceneBuilder.scene.add(cube);
sceneBuilder.addRenderCb(() => {
if (!cube) return;
const currentTime = performance.now();
const deltaTime = (currentTime - lastUpdateTime) / 1000; // convert to seconds
lastUpdateTime = currentTime;
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime);
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.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor);
});
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();
}
});
</script>
<div class="h-60 w-60 border-2 border-base-300 rounded-md">
<canvas class="w-full h-full" bind:this={canvas}></canvas>
</div>
+31 -22
View File
@@ -1,24 +1,31 @@
<script lang="ts">
import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { Down } from './icons'
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { Down } from './icons';
interface Props {
open?: boolean
collapsible?: boolean
icon?: import('svelte').Snippet
title?: import('svelte').Snippet
children?: import('svelte').Snippet
right?: import('svelte').Snippet
open?: boolean;
collapsible?: boolean;
icon?: import('svelte').Snippet;
title?: import('svelte').Snippet;
children?: import('svelte').Snippet;
}
let { open = $bindable(true), collapsible = true, icon, title, children, right }: Props = $props()
let {
open = $bindable(true),
collapsible = true,
icon,
title,
children
}: Props = $props();
</script>
{#if collapsible}
<div
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg">
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
>
<div
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium">
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
>
<span class="inline-flex items-baseline">
{@render icon?.()}
{@render title?.()}
@@ -26,32 +33,34 @@
<button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => {
open = !open
}}>
open = !open;
}}
>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open ?
'rotate-180'
: ''}" />
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 }}>
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
{@render children?.()}
</div>
{/if}
</div>
{:else}
<div
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg">
<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"
>
<div class="min-h-16 w-full p-4 text-xl font-medium">
<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?.()}
-45
View File
@@ -1,45 +0,0 @@
<script lang="ts">
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
const {
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 variants: Record<Variant, [string, string]> = {
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>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2 {klass}">
{#if icon}
<div class="mask mask-hexagon {bgColor} h-auto w-10 flex-none">
<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>
{@render children?.()}
</div>
+38 -30
View File
@@ -6,7 +6,7 @@
LineBasicMaterial,
Mesh,
MeshBasicMaterial,
type Object3D,
Object3D,
SphereGeometry,
Vector3,
type NormalBufferAttributes,
@@ -21,23 +21,22 @@
servoAnglesOut,
servoAngles,
mpu,
jointNames,
currentKinematic,
walkGait,
walkGaits,
walkGaitToMode
jointNames
} from '$lib/stores'
import {
extractFootColor,
populateModelCache,
throttler,
getToeWorldPositions
} from '$lib/utilities'
import { footColor, populateModelCache, throttler, toeWorldPositions } from '$lib/utilities'
import SceneBuilder from '$lib/sceneBuilder'
import { lerp, degToRad } from 'three/src/math/MathUtils'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import { type body_state_t } from '$lib/kinematic'
import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait'
import Kinematic, { type body_state_t } from '$lib/kinematic'
import {
BezierState,
CalibrationState,
EightPhaseWalkState,
FourPhaseWalkState,
IdleState,
RestState,
StandState
} from '$lib/gait'
import { radToDeg } from 'three/src/math/MathUtils.js'
import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store'
@@ -48,12 +47,20 @@
panel?: boolean
debug?: boolean
ground?: boolean
zoom?: number
}
let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props()
let {
sky = true,
orbit = false,
panel = true,
debug = false,
ground = true,
zoom = 8
}: Props = $props()
let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement
let canvas: HTMLCanvasElement = $state()
let currentModelAngles: number[] = new Array(12).fill(0)
let modelTargetAngles: number[] = new Array(12).fill(0)
@@ -66,7 +73,7 @@
let target_position = { x: 0, z: 0, yaw: 0 }
let kinematic = get(currentKinematic)
let kinematic = new Kinematic()
let planners = {
[ModesEnum.Deactivated]: new IdleState(),
@@ -74,6 +81,7 @@
[ModesEnum.Calibration]: new CalibrationState(),
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Crawl]: new EightPhaseWalkState(),
[ModesEnum.Walk]: new BezierState()
}
let lastTick = performance.now()
@@ -87,7 +95,7 @@
xm: 0,
ym: 0.5,
zm: 0,
feet: kinematic.getDefaultFeetPos()
feet: planners[ModesEnum.Idle].default_feet_pos
}
let settings = {
@@ -112,7 +120,6 @@
await populateModelCache()
await createScene()
servoAngles.subscribe(updateAnglesFromStore)
walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait)))
if (panel) createPanel()
})
@@ -173,7 +180,7 @@
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(2, 20, orbit)
.addOrbitControls(Math.min(zoom, 8), 30, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
@@ -197,7 +204,7 @@
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry()
const material = new LineBasicMaterial({ color: extractFootColor() })
const material = new LineBasicMaterial({ color: footColor() })
const line = new Line(geometry, material)
trace_lines.push(geometry)
sceneManager.scene.add(line)
@@ -260,15 +267,16 @@
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]
stop: controlData[0],
lx: controlData[1],
ly: controlData[2],
rx: controlData[3],
ry: controlData[4],
h: controlData[5],
s: controlData[6],
s1: controlData[7]
}
body_state.ym = data.h
body_state.ym = ((data.h + 127) * 0.75) / 100
let planner = planners[get(mode)]
const delta = performance.now() - lastTick
@@ -303,7 +311,7 @@
const robot = sceneManager.model
if (!robot) return
const toes = getToeWorldPositions(robot)
const toes = toeWorldPositions(robot)
renderTraceLines(toes)
update_camera(robot)
+2 -6
View File
@@ -35,9 +35,6 @@ export { default as Hamburger } from '~icons/mdi/hamburger-menu'
export { default as FileIcon } from '~icons/mdi/file'
export { default as FolderIcon } from '~icons/mdi/folder-outline'
export { default as FolderOpenOutline } from '~icons/mdi/folder-open-outline'
export { default as TrashIcon } from '~icons/mdi/trash'
export { default as RotateCcw } from '~icons/mdi/rotate-left'
export { default as RotateCw } from '~icons/mdi/rotate-right'
export { default as Down } from '~icons/tabler/chevron-down'
export { default as Cancel } from '~icons/tabler/x'
@@ -53,14 +50,13 @@ export { default as Power } from '~icons/tabler/power'
export { default as MAC } from '~icons/tabler/dna-2'
export { default as Home } from '~icons/tabler/home'
export { default as SSID } from '~icons/tabler/router'
export { default as DNS } from '~icons/mdi/dns'
export { default as DNS } from '~icons/tabler/address-book'
export { default as Gateway } from '~icons/tabler/torii'
export { default as Subnet } from '~icons/tabler/grid-dots'
export { default as Channel } from '~icons/tabler/antenna'
export { default as Scan } from '~icons/tabler/radar-2'
export { default as Add } from '~icons/tabler/circle-plus'
export { default as Edit } from '~icons/mdi/edit'
export { default as EditOff } from '~icons/mdi/edit-off'
export { default as Edit } from '~icons/tabler/pencil'
export { default as Delete } from '~icons/tabler/trash'
export { default as Network } from '~icons/tabler/router'
@@ -1,10 +1,6 @@
<script lang="ts">
import WidgetContainer from './WidgetContainer.svelte';
import {
WidgetComponents,
type WidgetContainerConfig,
isWidgetConfig,
} from '$lib/stores/application';
import { WidgetComponents, type WidgetContainerConfig, isWidgetConfig } from '$lib/stores/application';
import Widget from './Widget.svelte';
interface Props {
@@ -19,7 +15,8 @@
class="flex w-full h-full"
class:flex-row={container.layout === 'column'}
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>
{#if isWidgetConfig(widget)}
@@ -32,8 +29,8 @@
{#if index !== container.widgets.length - 1}
<div
class="divider bg-base-300 m-0"
class:divider-horizontal={container.layout === 'column'}>
</div>
class:divider-horizontal={container.layout === 'column'}
></div>
{/if}
{/each}
</div>
+17 -33
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { page } from '$app/state'
import { base } from '$app/paths'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import GithubButton from '../menu/GithubButton.svelte'
import LogoButton from '../menu/LogoButton.svelte'
@@ -20,10 +19,9 @@
Router,
AP,
Copyright,
Metrics,
DNS
Metrics
} from '$lib/components/icons'
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
import appEnv from 'app-env'
const features = useFeatureFlags()
@@ -42,10 +40,6 @@
submenu?: menuItem[]
}
function withBase(path: string) {
return `${base}${path.startsWith('/') ? path : '/' + path}`
}
let menuItems = $state<menuItem[]>([])
$effect(() => {
@@ -53,13 +47,13 @@
{
title: 'Connection',
icon: WiFi,
href: withBase('/connection'),
feature: !PUBLIC_VITE_USE_HOST_NAME
href: '/connection',
feature: !appEnv.VITE_USE_HOST_NAME
},
{
title: 'Controller',
icon: MdiController,
href: withBase('/controller'),
href: '/controller',
feature: true
},
{
@@ -70,25 +64,25 @@
{
title: 'I2C',
icon: Connection,
href: withBase('/peripherals/i2c'),
href: '/peripherals/i2c',
feature: true
},
{
title: 'Camera',
icon: Camera,
href: withBase('/peripherals/camera'),
href: '/peripherals/camera',
feature: $features.camera
},
{
title: 'Servo',
icon: MotorOutline,
href: withBase('/peripherals/servo'),
href: '/peripherals/servo',
feature: true
},
{
title: 'IMU',
icon: Rotate3d,
href: withBase('/peripherals/imu'),
href: '/peripherals/imu',
feature: $features.imu || $features.mag || $features.bmp
}
]
@@ -101,19 +95,13 @@
{
title: 'WiFi Station',
icon: Router,
href: withBase('/wifi/sta'),
href: '/wifi/sta',
feature: true
},
{
title: 'Access Point',
icon: AP,
href: withBase('/wifi/ap'),
feature: true
},
{
title: 'mDNS',
icon: DNS,
href: withBase('/wifi/mdns'),
href: '/wifi/ap',
feature: true
}
]
@@ -126,25 +114,25 @@
{
title: 'System Status',
icon: Health,
href: withBase('/system/status'),
href: '/system/status',
feature: true
},
{
title: 'File System',
icon: Folder,
href: withBase('/system/filesystem'),
href: '/system/filesystem',
feature: true
},
{
title: 'System Metrics',
icon: Metrics,
href: withBase('/system/metrics'),
feature: true
href: '/system/metrics',
feature: $features.analytics
},
{
title: 'Firmware Update',
icon: Update,
href: withBase('/system/update'),
href: '/system/update',
feature: $features.ota || $features.upload_firmware || $features.download_firmware
}
]
@@ -177,11 +165,7 @@
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
<LogoButton {appName} />
<MenuList
{menuItems}
select={updateMenu}
class="grow flex-nowrap overflow-y-auto overflow-x-hidden"
level="0" />
<MenuList {menuItems} select={updateMenu} class="grow flex-nowrap overflow-y-auto" level="0" />
<div class="divider my-0"></div>
+2 -2
View File
@@ -16,13 +16,13 @@
}
</script>
<ul class={klass + ' menu w-full'}>
<ul class={klass + ' menu'}>
{#each menuItems as MenuItem[] as menuItem, i (menuItem.title)}
{#if menuItem.feature}
<li>
{#if menuItem.submenu}
<details open={menuItem.submenu.some(subItem => subItem.active)}>
<summary class="font-bold">
<summary class="text-lg font-bold">
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</summary>
@@ -1,80 +1,80 @@
<script lang="ts">
import { page } from '$app/state'
import { modals } from 'svelte-modals'
import { notifications } from '$lib/components/toasts/notifications'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'
import { compareVersions } from 'compare-versions'
import { onMount } from 'svelte'
import { api } from '$lib/api'
import type { GithubRelease } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Cancel, CloudDown, Firmware } from '../icons'
import { page } from '$app/state';
import { modals } from 'svelte-modals';
import { notifications } from '$lib/components/toasts/notifications';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import { compareVersions } from 'compare-versions';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { GithubRelease } from '$lib/types/models';
import { useFeatureFlags } from '$lib/stores/featureFlags';
import { Cancel, CloudDown, Firmware } from '../icons';
const features = useFeatureFlags()
const features = useFeatureFlags();
interface Props {
update?: boolean
update?: boolean;
}
let { update = $bindable(false) }: Props = $props()
let { update = $bindable(false) }: Props = $props();
let firmwareVersion: string = $state('')
let firmwareDownloadLink: string = $state('')
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
console.warn('Error: Could not find releases in the repository');
return;
}
if (result.isErr()) {
console.error('Error:', result.inner)
return
console.error('Error:', result.inner);
return;
}
const results = result.inner
update = false
firmwareVersion = ''
const results = result.inner;
update = false;
firmwareVersion = '';
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
if (compareVersions(results.tag_name, $features.firmware_version) === 1) {
// iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) {
// 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)
results.assets[i].name.includes($features.firmware_built_target)
) {
update = true
firmwareVersion = results.tag_name
firmwareDownloadLink = results.assets[i].browser_download_url
notifications.info('Firmware update available.', 5000)
update = true;
firmwareVersion = results.tag_name;
firmwareDownloadLink = results.assets[i].browser_download_url;
notifications.info('Firmware update available.', 5000);
}
}
}
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url })
const result = await api.post('/api/downloadUpdate', { download_url: url });
if (result.isErr()) {
console.error('Error:', result.inner)
return
console.error('Error:', result.inner);
return;
}
}
onMount(async () => {
if ($features.download_firmware) {
await getGithubAPI()
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour
await getGithubAPI();
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000); // once per hour
}
})
});
function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, {
@@ -85,12 +85,12 @@
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url)
postGithubDownload(url);
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
})
});
}
})
});
}
</script>
@@ -98,9 +98,11 @@
<div class="indicator flex-none">
<button
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">
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
>
{firmwareVersion}
</span>
<Firmware class="h-7 w-7" />
+24 -24
View File
@@ -1,42 +1,42 @@
import { writable, derived, type Writable } from 'svelte/store'
import { writable, derived, type Writable } from 'svelte/store';
type StateType = 'info' | 'success' | 'warning' | 'error'
type StateType = 'info' | 'success' | 'warning' | 'error';
type State = {
id: string
type: StateType
message: string
}
id: string;
type: StateType;
message: string;
};
function createNotificationStore() {
const state: State[] = []
const notifications = writable(state)
const { subscribe } = notifications
const state: State[] = [];
const notifications = writable(state);
const { subscribe } = notifications;
function send(message: string, type: StateType = 'info', timeout: number) {
const id = generateId()
const id = generateId();
setTimeout(() => {
notifications.update(state => {
return state.filter(n => n.id !== id)
})
}, timeout)
notifications.update(state => {
return [...state, { id, type, message }]
})
notifications.update((state) => {
return state.filter((n) => n.id !== id);
});
}, timeout);
notifications.update((state) => {
return [...state, { id, type, message }];
});
}
return {
subscribe,
send,
error: (msg: string, timeout: number = 4000) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout),
success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout)
}
error: (msg: string, timeout: number) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number) => send(msg, 'info', timeout),
success: (msg: string, timeout: number) => send(msg, 'success', timeout)
};
}
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,11 +1,11 @@
<script lang="ts">
import { daisyColor } from '$lib/utilities';
import { Chart, registerables } from 'chart.js';
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import { daisyColor } from "$lib/utilities";
import { Chart, registerables } from "chart.js";
import { onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { slide } from "svelte/transition";
let chartElement: HTMLCanvasElement;
let chartElement: HTMLCanvasElement = $state();
let chart: Chart;
interface Props {
@@ -30,36 +30,36 @@
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data,
yAxisID: 'y',
yAxisID: 'y'
},
],
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true,
display: true
},
tooltip: {
mode: 'index',
intersect: false,
},
intersect: false
}
},
elements: {
point: {
radius: 0,
},
radius: 0
}
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10),
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc'),
color: daisyColor('--bc')
},
display: false,
display: false
},
y: {
type: 'linear',
@@ -69,33 +69,35 @@
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold',
},
weight: 'bold'
}
},
position: 'left',
min: 0,
max: 100,
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc'),
},
border: { color: daisyColor('--bc', 10) },
},
},
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
setInterval(() => {
chart.data.labels = data;
chart.data.datasets[0].data = data;
chart.data.labels = data
chart.data.datasets[0].data = data
}, 500);
});
})
</script>
<div class="w-full h-full overflow-x-auto">
<div
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>
</div>
@@ -2,7 +2,7 @@
interface Props {
options?: string[];
selectedOption?: string;
change?: () => void;
change: () => void;
[key: string]: any;
}
@@ -12,7 +12,8 @@
<select
bind:value={selectedOption}
{...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}
+307 -280
View File
@@ -1,31 +1,33 @@
import { get } from 'svelte/store'
import type { body_state_t } from './kinematic'
import { currentKinematic } from './stores/featureFlags'
import type { body_state_t } from './kinematic';
import { fromInt8 } from './utilities';
const { sin } = Math;
export interface gait_state_t {
step_height: number
step_x: number
step_z: number
step_angle: number
step_velocity: number
step_depth: number
step_height: number;
step_x: number;
step_z: number;
step_angle: number;
step_velocity: number;
step_depth: number;
}
export interface ControllerCommand {
lx: number
ly: number
rx: number
ry: number
h: number
s: number
s1: number
stop: number;
lx: number;
ly: number;
rx: number;
ry: number;
h: number;
s: number;
s1: number;
}
export abstract class GaitState {
protected abstract name: string
protected abstract name: string;
protected dt = 0.02
protected body_state!: body_state_t
protected dt = 0.02;
protected body_state!: body_state_t;
protected gait_state: gait_state_t = {
step_height: 0.4,
step_x: 0,
@@ -33,277 +35,298 @@ export abstract class GaitState {
step_angle: 0,
step_velocity: 1,
step_depth: 0.002
}
};
public get default_feet_pos() {
return get(currentKinematic).getDefaultFeetPos()
return [
[1, -1, 1, 1],
[1, -1, -1, 1],
[-1, -1, 1, 1],
[-1, -1, -1, 1]
];
}
protected get default_height() {
return 0.5
return 0.5;
}
begin() {
console.log('Starting', this.name)
console.log('Starting', this.name);
}
end() {
console.log('Ending', this.name)
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
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_height: 0.4 + (command.s1 / 128 + 1) / 2,
step_x: Math.floor(fromInt8(command.ly, -1, 1) * 10) / 10,
step_z: -(Math.floor(fromInt8(command.lx, -1, 1) * 10) / 10),
step_velocity: command.s / 128 + 1,
step_angle: command.rx / 128,
step_depth: 0.002
}
};
this.gait_state = newCommand
this.gait_state = newCommand;
}
}
export class IdleState extends GaitState {
protected name = 'Idle'
protected name = 'Idle';
}
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: ControllerCommand) {
body_state.omega = 0
body_state.phi = 0
body_state.psi = 0
body_state.xm = 0
body_state.ym = this.default_height * 10
body_state.zm = 0
body_state.feet = this.default_feet_pos
return body_state
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
body_state.omega = 0;
body_state.phi = 0;
body_state.psi = 0;
body_state.xm = 0;
body_state.ym = this.default_height * 10;
body_state.zm = 0;
body_state.feet = this.default_feet_pos;
return body_state;
}
}
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: ControllerCommand) {
body_state.omega = 0
body_state.phi = 0
body_state.psi = 0
body_state.xm = 0
body_state.ym = this.default_height / 2
body_state.zm = 0
body_state.feet = this.default_feet_pos
return body_state
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
body_state.omega = 0;
body_state.phi = 0;
body_state.psi = 0;
body_state.xm = 0;
body_state.ym = this.default_height / 2;
body_state.zm = 0;
body_state.feet = this.default_feet_pos;
return body_state;
}
}
export class StandState extends GaitState {
protected name = 'Stand'
protected name = 'Stand';
step(body_state: body_state_t, command: ControllerCommand) {
body_state.omega = 0
body_state.phi = command.rx * 10 * (Math.PI / 2)
body_state.psi = command.ry * 10 * (Math.PI / 2)
body_state.xm = command.ly / 4
body_state.zm = command.lx / 4
body_state.feet = this.default_feet_pos
return body_state
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
body_state.omega = 0;
body_state.phi = command.rx / 8;
body_state.psi = command.ry / 8;
body_state.xm = command.ly / 2 / 100;
body_state.zm = command.lx / 2 / 100;
body_state.feet = this.default_feet_pos;
return body_state;
}
}
export class BezierState extends GaitState {
protected name = 'Bezier'
protected phase = 0
protected phase_num = 0
protected step_length = 0
protected stand_offset = 0.85
protected mode: 'crawl' | 'trot' = 'trot'
protected speed_factor = 1
offset = [0, 0.5, 0.75, 0.25]
abstract class PhaseGaitState extends GaitState {
protected tick = 0;
protected phase = 0;
protected phase_time = 0;
protected abstract num_phases: number;
protected abstract phase_speed_factor: number;
protected abstract swing_stand_ratio: number;
protected shift_start_pos = { x: 0, z: 0 }
protected shift_target_pos = { x: 0, z: 0 }
protected shift_start_time = 0
protected current_shift_leg = -1
constructor() {
super()
this.set_mode(this.mode)
}
begin() {
super.begin()
}
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() {
super.end()
}
protected contact_phases!: number[][];
protected shifts!: number[][];
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
super.step(body_state, command, dt);
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
this.phase_time += this.dt * this.phase_speed_factor * this.gait_state.step_velocity;
if (this.phase_time >= 1) {
this.phase += 1;
if (this.phase == this.num_phases) this.phase = 0;
this.phase_time = 0;
}
}
update_body_position() {
const m = this.gait_state
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (!moving) return
if (this.num_phases === 4) return;
if (this.mode !== 'crawl') return
const shift = this.shifts[Math.floor(this.phase / 2)];
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states()
if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) {
if (this.current_shift_leg !== next_swing) {
this.current_shift_leg = next_swing
this.shift_start_pos.x = this.body_state.xm
this.shift_start_pos.z = this.body_state.zm
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 }
}
protected smoothstep01(t: number): number {
const x = Math.max(0, Math.min(1, t))
return x * x * (3 - 2 * x)
this.body_state.xm += (shift[0] - this.body_state.xm) * this.dt * 4;
this.body_state.zm += (shift[2] - this.body_state.zm) * this.dt * 4;
}
update_feet_positions() {
for (let i = 0; i < 4; i++) this.body_state.feet[i] = this.update_foot_position(i)
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))
const contact = this.contact_phases[index][this.phase];
return contact ? this.stand(index) : this.swing(index);
}
stand(index: number): number[] {
const delta_pos = [
-this.gait_state.step_x * this.dt * this.swing_stand_ratio,
0,
-this.gait_state.step_z * this.dt * this.swing_stand_ratio
];
this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0];
this.body_state.feet[index][1] = this.default_feet_pos[index][1];
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2];
return this.body_state.feet[index];
}
swing(index: number): number[] {
const delta_pos = [this.gait_state.step_x * this.dt, 0, this.gait_state.step_z * this.dt];
if (this.gait_state.step_x == 0) {
delta_pos[0] =
(this.default_feet_pos[index][0] - this.body_state.feet[index][0]) * this.dt * 8;
}
if (this.gait_state.step_z == 0) {
delta_pos[2] =
(this.default_feet_pos[index][2] - this.body_state.feet[index][2]) * this.dt * 8;
}
this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0];
this.body_state.feet[index][1] =
this.default_feet_pos[index][1] +
sin(this.phase_time * Math.PI) * this.gait_state.step_height;
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2];
return this.body_state.feet[index];
}
}
export class FourPhaseWalkState extends PhaseGaitState {
protected name = 'Four phase walk';
protected num_phases = 4;
protected phase_speed_factor = 6;
protected contact_phases = [
[1, 0, 1, 1],
[1, 1, 1, 0],
[1, 1, 1, 0],
[1, 0, 1, 1]
];
protected swing_stand_ratio = 1 / (this.num_phases - 1);
begin() {
super.begin();
}
end() {
super.end();
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt);
}
}
export class EightPhaseWalkState extends PhaseGaitState {
protected name = 'Eight phase walk';
protected num_phases = 8;
protected phase_speed_factor = 4;
protected contact_phases = [
[1, 0, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 0, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 0, 1, 1, 1, 1]
];
protected shifts = [
[-0.05, 0, -0.2],
[0.3, 0, 0.2],
[-0.05, 0, 0.2],
[0.3, 0, -0.2]
];
protected swing_stand_ratio = 1 / (this.num_phases - 1);
begin() {
super.begin();
}
end() {
super.end();
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt);
}
}
export class BezierState extends GaitState {
protected name = 'Bezier';
protected phase = 0;
protected phase_num = 0;
protected step_length: number = 0;
offset = [0, 0.5, 0.5, 0];
begin() {
super.begin();
}
end() {
super.end();
}
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_feet_positions();
return this.body_state;
}
update_phase() {
this.phase += this.dt * this.gait_state.step_velocity * 2;
if (this.phase >= 1) {
this.phase_num += 1;
this.phase_num %= 2;
this.phase = 0;
}
}
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 <= 0.75 ?
this.stand_controller(index, phase / 0.75)
: this.swing_controller(index, (phase - 0.75) / (1 - 0.75));
}
stand_controller(index: number, phase: number) {
const depth = this.gait_state.step_depth
return this.controller(index, phase, stance_curve, depth)
let 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)
let height = this.gait_state.step_height;
return this.controller(index, phase, bezier_curve, height);
}
controller(
@@ -312,67 +335,69 @@ export class BezierState extends GaitState {
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)
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
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
length = this.gait_state.step_angle * 2;
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index]);
const delta_rot = controller(length, angle, ...args, phase)
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
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
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2;
return this.body_state.feet[index]
return this.body_state.feet[index];
}
}
const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => {
const X_POLAR = Math.cos(angle)
const Y_POLAR = Math.sin(angle)
const X_POLAR = Math.cos(angle);
const Y_POLAR = Math.sin(angle);
const step = length * (1 - 2 * phase)
const X = step * X_POLAR
const Z = step * Y_POLAR
let Y = 0
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
return [X, Y, Z]
}
const step = length * (1 - 2 * phase);
const X = step * X_POLAR;
const Z = step * Y_POLAR;
let Y = 0;
if (length !== 0) {
Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length));
}
return [X, Y, Z];
};
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_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0])
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 offsets = [
current_foot_pos[0] - default_foot_pos[0],
current_foot_pos[2] - default_foot_pos[2],
current_foot_pos[1] - default_foot_pos[1]
]
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2)
const offset_mod = Math.atan2(offset_mag, foot_mag)
];
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2);
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 control_points = get_control_points(length, angle, height)
const n = control_points.length - 1
const control_points = get_control_points(length, angle, height);
const n = control_points.length - 1;
const point = [0, 0, 0]
const point = [0, 0, 0];
for (let i = 0; i <= 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[1] += bernstein_poly * control_points[i][1]
point[2] += bernstein_poly * control_points[i][2]
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[1] += bernstein_poly * control_points[i][1];
point[2] += bernstein_poly * control_points[i][2];
}
return point
}
return point;
};
const get_control_points = (length: number, angle: number, height: number): number[][] => {
const X_POLAR = Math.cos(angle)
const Z_POLAR = Math.sin(angle)
const X_POLAR = Math.cos(angle);
const Z_POLAR = Math.sin(angle);
const STEP = [
-length,
@@ -387,7 +412,7 @@ const get_control_points = (length: number, angle: number, height: number): numb
length * 1.5,
length * 1.4,
length
]
];
const Y = [
0.0,
@@ -402,24 +427,26 @@ const get_control_points = (length: number, angle: number, height: number): numb
height * 1.1,
0.0,
0.0
]
];
const control_points: number[][] = []
const control_points: number[][] = [];
for (let i = 0; i < STEP.length; i++) {
const X = STEP[i] * X_POLAR
const Z = STEP[i] * Z_POLAR
control_points.push([X, Y[i], Z])
const X = STEP[i] * X_POLAR;
const Z = STEP[i] * Z_POLAR;
control_points.push([X, Y[i], Z]);
}
return control_points
}
return control_points;
};
const comb = (n: number, k: number): number => {
if (k < 0 || k > n) return 0
if (k === 0 || k === n) return 1
k = Math.min(k, n - k)
let c = 1
for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1)
return c
}
if (k < 0 || k > n) return 0;
if (k === 0 || k === n) return 1;
k = Math.min(k, n - k);
let c = 1;
for (let i = 0; i < k; i++) {
c = (c * (n - i)) / (i + 1);
}
return c;
};
+296 -116
View File
@@ -1,140 +1,320 @@
export interface body_state_t {
omega: number
phi: number
psi: number
xm: number
ym: number
zm: number
feet: number[][]
omega: number;
phi: number;
psi: number;
xm: number;
ym: number;
zm: number;
feet: number[][];
}
export interface position {
x: number
y: number
z: number
x: number;
y: number;
z: number;
}
export interface target_position {
x: number
z: number
yaw: number
x: number;
z: number;
yaw: number;
}
export interface KinematicParams {
coxa: number
coxa_offset: number
femur: number
tibia: number
L: number
W: number
}
const { cos, sin, atan2, sqrt } = Math;
const { cos, sin, atan2, acos, sqrt, max, min } = Math
const DEG2RAD = 0.017453292519943
const DEG2RAD = 0.017453292519943;
export default class Kinematic {
coxa: number
coxa_offset: number
femur: number
tibia: number
l1: number;
l2: number;
l3: number;
l4: number;
L: number
W: number
L: number;
W: number;
DEG2RAD = DEG2RAD
DEG2RAD = DEG2RAD;
mountOffsets: number[][]
sHp = sin(Math.PI / 2);
cHp = cos(Math.PI / 2);
invMountRot = [
[0, 0, -1],
[0, 1, 0],
[1, 0, 0]
]
Tlf: number[][] = [];
Trf: number[][] = [];
Tlb: number[][] = [];
Trb: number[][] = [];
constructor(params: KinematicParams) {
this.coxa = params.coxa
this.coxa_offset = params.coxa_offset
this.femur = params.femur
this.tibia = params.tibia
this.L = params.L
this.W = params.W
point_lf: number[][];
point_rf: number[][];
point_lb: number[][];
point_rb: number[][];
Ix: number[][];
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]
]
constructor() {
this.l1 = 60.5 / 100;
this.l2 = 10 / 100;
this.l3 = 100.7 / 100;
this.l4 = 118.5 / 100;
this.L = 207.5 / 100;
this.W = 78 / 100;
this.point_lf = [
[this.cHp, 0, this.sHp, this.L / 2],
[0, 1, 0, 0],
[-this.sHp, 0, this.cHp, this.W / 2],
[0, 0, 0, 1]
];
this.point_rf = [
[this.cHp, 0, this.sHp, this.L / 2],
[0, 1, 0, 0],
[-this.sHp, 0, this.cHp, -this.W / 2],
[0, 0, 0, 1]
];
this.point_lb = [
[this.cHp, 0, this.sHp, -this.L / 2],
[0, 1, 0, 0],
[-this.sHp, 0, this.cHp, this.W / 2],
[0, 0, 0, 1]
];
this.point_rb = [
[this.cHp, 0, this.sHp, -this.L / 2],
[0, 1, 0, 0],
[-this.sHp, 0, this.cHp, -this.W / 2],
[0, 0, 0, 1]
];
this.Ix = [
[-1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
];
}
getDefaultFeetPos(): number[][] {
return this.mountOffsets.map((offset, i) => {
return [offset[0], -1, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)]
})
}
public calcIK(body_state: body_state_t): number[] {
this.bodyIK(body_state);
calcIK(p: body_state_t): number[] {
const roll = p.omega * this.DEG2RAD
const pitch = p.phi * this.DEG2RAD
const yaw = p.psi * this.DEG2RAD
const rot = this.euler2R(roll, pitch, yaw)
const inv_rot = [
[rot[0][0], rot[1][0], rot[2][0]],
[rot[0][1], rot[1][1], rot[2][1]],
[rot[0][2], rot[1][2], rot[2][2]]
]
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]
const [mx, my, mz] = this.mountOffsets[i]
const px = bx - mx,
py = by - my,
pz = bz - mz
const lx =
this.invMountRot[0][0] * px + this.invMountRot[0][1] * py + this.invMountRot[0][2] * pz
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
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]
]
...this.legIK(this.multiplyVector(this.inverse(this.Tlf), body_state.feet[0])),
...this.legIK(
this.multiplyVector(
this.Ix,
this.multiplyVector(this.inverse(this.Trf), body_state.feet[1])
)
),
...this.legIK(this.multiplyVector(this.inverse(this.Tlb), body_state.feet[2])),
...this.legIK(
this.multiplyVector(
this.Ix,
this.multiplyVector(this.inverse(this.Trb), body_state.feet[3])
)
)
];
}
bodyIK(p: body_state_t) {
const cos_omega = cos(p.omega * this.DEG2RAD);
const sin_omega = sin(p.omega * this.DEG2RAD);
const cos_phi = cos(p.phi * this.DEG2RAD);
const sin_phi = sin(p.phi * this.DEG2RAD);
const cos_psi = cos(p.psi * this.DEG2RAD);
const sin_psi = sin(p.psi * this.DEG2RAD);
const Tm: number[][] = [
[cos_phi * cos_psi, -sin_psi * cos_phi, sin_phi, p.xm],
[
sin_omega * sin_phi * cos_psi + sin_psi * cos_omega,
-sin_omega * sin_phi * sin_psi + cos_omega * cos_psi,
-sin_omega * cos_phi,
p.ym
],
[
sin_omega * sin_psi - sin_phi * cos_omega * cos_psi,
sin_omega * cos_psi + sin_phi * sin_psi * cos_omega,
cos_omega * cos_phi,
p.zm
],
[0, 0, 0, 1]
];
this.Tlf = this.matrixMultiply(Tm, this.point_lf);
this.Trf = this.matrixMultiply(Tm, this.point_rf);
this.Tlb = this.matrixMultiply(Tm, this.point_lb);
this.Trb = this.matrixMultiply(Tm, this.point_rb);
}
public legIK(point: number[]): number[] {
const [x, y, z] = point;
let F = sqrt(x ** 2 + y ** 2 - this.l1 ** 2);
if (isNaN(F)) F = this.l1;
const G = F - this.l2;
const H = sqrt(G ** 2 + z ** 2);
const theta1 = -atan2(y, x) - atan2(F, -this.l1);
const D = (H ** 2 - this.l3 ** 2 - this.l4 ** 2) / (2 * this.l3 * this.l4);
let theta3 = atan2(sqrt(1 - D ** 2), D);
if (isNaN(theta3)) theta3 = 0;
const theta2 = atan2(z, G) - atan2(this.l4 * sin(theta3), this.l3 + this.l4 * cos(theta3));
return [theta1, theta2, theta3];
}
matrixMultiply(a: number[][], b: number[][]): number[][] {
const result: number[][] = [];
for (let i = 0; i < a.length; i++) {
const row: number[] = [];
for (let j = 0; j < b[0].length; j++) {
let sum = 0;
for (let k = 0; k < a[i].length; k++) {
sum += a[i][k] * b[k][j];
}
row.push(sum);
}
result.push(row);
}
return result;
}
multiplyVector(matrix: number[][], vector: number[]): number[] {
const rows = matrix.length;
const cols = matrix[0].length;
const vectorLength = vector.length;
if (cols !== vectorLength) {
throw new Error('Matrix and vector dimensions do not match for multiplication.');
}
const result = [];
for (let i = 0; i < rows; i++) {
let sum = 0;
for (let j = 0; j < cols; j++) {
sum += matrix[i][j] * vector[j];
}
result.push(sum);
}
return result;
}
private inverse(matrix: number[][]): number[][] {
const det = this.determinant(matrix);
const adjugate = this.adjugate(matrix);
const scalar = 1 / det;
const inverse: number[][] = [];
for (let i = 0; i < matrix.length; i++) {
const row: number[] = [];
for (let j = 0; j < matrix[i].length; j++) {
row.push(adjugate[i][j] * scalar);
}
inverse.push(row);
}
return inverse;
}
private determinant(matrix: number[][]): number {
if (matrix.length !== matrix[0].length) {
throw new Error('The matrix is not square.');
}
if (matrix.length === 2) {
return matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0];
}
let det = 0;
for (let i = 0; i < matrix.length; i++) {
const sign = i % 2 === 0 ? 1 : -1;
const subMatrix: number[][] = [];
for (let j = 1; j < matrix.length; j++) {
const row: number[] = [];
for (let k = 0; k < matrix.length; k++) {
if (k !== i) {
row.push(matrix[j][k]);
}
}
subMatrix.push(row);
}
det += sign * matrix[0][i] * this.determinant(subMatrix);
}
return det;
}
private adjugate(matrix: number[][]): number[][] {
if (matrix.length !== matrix[0].length) {
throw new Error('The matrix is not square.');
}
const adjugate: number[][] = [];
for (let i = 0; i < matrix.length; i++) {
const row: number[] = [];
for (let j = 0; j < matrix[i].length; j++) {
const sign = (i + j) % 2 === 0 ? 1 : -1;
const subMatrix: number[][] = [];
for (let k = 0; k < matrix.length; k++) {
if (k !== i) {
const subRow: number[] = [];
for (let l = 0; l < matrix.length; l++) {
if (l !== j) {
subRow.push(matrix[k][l]);
}
}
subMatrix.push(subRow);
}
}
const cofactor = sign * this.determinant(subMatrix);
row.push(cofactor);
}
adjugate.push(row);
}
return this.transpose(adjugate);
}
private transpose(matrix: number[][]): number[][] {
const transposed: number[][] = [];
for (let i = 0; i < matrix.length; i++) {
const row: number[] = [];
for (let j = 0; j < matrix[i].length; j++) {
row.push(matrix[j][i]);
}
transposed.push(row);
}
return transposed;
}
}
+1 -2
View File
@@ -157,11 +157,10 @@ export default class SceneBuilder {
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.minDistance = 5
this.orbit.maxDistance = maxDistance
this.orbit.autoRotate = autoRotate
this.orbit.update()
this.orbit.minDistance = minDistance
return this
}
+10 -52
View File
@@ -1,62 +1,20 @@
import { api } from '$lib/api'
import { notifications } from '$lib/components/toasts/notifications'
import Kinematic from '$lib/kinematic'
import { persistentStore } from '$lib/utilities'
import { derived, type Writable } from 'svelte/store'
import { base } from '$app/paths'
import { api } from '$lib/api';
import { notifications } from '$lib/components/toasts/notifications';
import { writable, type Writable } from 'svelte/store';
let featureFlagsStore: Writable<Record<string, boolean | string>>
let featureFlagsStore: Writable<Record<string, boolean>>;
export function useFeatureFlags() {
if (!featureFlagsStore) {
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
featureFlagsStore = writable<Record<string, boolean>>({});
api.get<Record<string, boolean>>('/api/features').then(result => {
if (result.isOk()) featureFlagsStore.set(result.inner)
api.get<Record<string, boolean>>('/api/features').then((result) => {
if (result.isOk()) featureFlagsStore.set(result.inner);
else {
notifications.error('Feature flag could not be fetched', 2500)
notifications.error('Feature flag could not be fetched', 2500);
}
})
});
}
return featureFlagsStore
return featureFlagsStore;
}
export const variants = {
SPOTMICRO_ESP32: {
model: `${base}/spot_micro.urdf.xacro`,
stl: `${base}/stl.zip`,
kinematics: {
coxa: 60.5 / 100,
coxa_offset: 10 / 100,
femur: 111.7 / 100,
tibia: 118.5 / 100,
L: 207.5 / 100,
W: 78 / 100
}
},
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 => {
const variantFlag = $flagStore['variant'] as string
return variantFlag && variants[variantFlag as keyof typeof variants] ?
variants[variantFlag as keyof typeof variants]
: variants.SPOTMICRO_ESP32
})
export const currentKinematic = derived(
currentVariant,
$variant => new Kinematic($variant.kinematics)
)
-47
View File
@@ -1,47 +0,0 @@
import { readable, derived } from 'svelte/store'
export type GamepadState = {
available: boolean
gamepads: Gamepad[]
}
export const gamepads = readable<GamepadState>({ available: false, gamepads: [] }, set => {
const update = () => {
const hasGamepadAPI = 'getGamepads' in navigator
if (!hasGamepadAPI) {
set({ available: false, gamepads: [] })
return
}
const gps = navigator.getGamepads?.() ?? []
const validGamepads = gps.filter(Boolean) as Gamepad[]
set({
available: true,
gamepads: validGamepads
})
raf = requestAnimationFrame(update)
}
window.addEventListener('gamepadconnected', update)
window.addEventListener('gamepaddisconnected', update)
let raf = requestAnimationFrame(update)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('gamepadconnected', update)
window.removeEventListener('gamepaddisconnected', update)
}
})
export const gamepad = derived(gamepads, $gamepads =>
$gamepads.available && $gamepads.gamepads.length > 0 ? $gamepads.gamepads[0] : null
)
export const gamepadAxes = derived(gamepad, $gamepad => $gamepad?.axes ?? [0, 0, 0, 0])
export const gamepadButtons = derived(gamepad, $gamepad => $gamepad?.buttons ?? [])
export const hasGamepad = derived(
gamepads,
$gamepads => $gamepads.available && $gamepads.gamepads.length > 0
)
+2 -2
View File
@@ -1,5 +1,5 @@
import { persistentStore } from '$lib/utilities';
import { writable } from 'svelte/store';
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public';
import appEnv from 'app-env';
export const location = PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '');
export const location = appEnv.VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '');
+30 -39
View File
@@ -1,54 +1,45 @@
import type { ControllerInput } from '$lib/types/models'
import { persistentStore } from '$lib/utilities/svelte-utilities'
import { writable, type Writable } from 'svelte/store'
import type { ControllerInput } from '$lib/types/models';
import { persistentStore } from '$lib/utilities/svelte-utilities';
import { writable, type Writable } from 'svelte/store';
export const emulateModel = writable(true)
export const emulateModel = writable(true);
export const jointNames = persistentStore('joint_names', <string[]>[])
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 modes = [
'deactivated',
'idle',
'calibration',
'rest',
'stand',
'crawl',
'walk'
] as const;
export type Modes = (typeof modes)[number]
export type Modes = (typeof modes)[number];
export enum ModesEnum {
Deactivated = 0,
Idle = 1,
Calibration = 2,
Rest = 3,
Stand = 4,
Walk = 5
Deactivated,
Idle,
Calibration,
Rest,
Stand,
Crawl,
Walk
}
export enum WalkGaits {
Trot = 0,
Crawl = 1
}
export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated);
export const walkGaits = ['trot', 'crawl'] as const
export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0]);
export const walkGaitLabels: Record<WalkGaits, string> = {
[WalkGaits.Trot]: 'Trot',
[WalkGaits.Crawl]: 'Crawl'
}
export const walkGaitToMode = (gait: WalkGaits): 'trot' | 'crawl' => {
return gait === WalkGaits.Trot ? 'trot' : 'crawl'
}
export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated)
export const walkGait: Writable<WalkGaits> = writable(WalkGaits.Trot)
export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0])
export const kinematicData = writable([0, 0, 0, 0, 1, 0])
export const 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
})
height: 50,
speed: 50,
s1: 50
});
+27
View File
@@ -0,0 +1,27 @@
import { readable } from 'svelte/store';
export const heading = readable(0, (set) => {
const updateHeading = (e: any) => {
let alpha;
if (e.webkitCompassHeading) alpha = e.webkitCompassHeading;
else if (e.alpha) alpha = e.alpha;
else {
let q = e.target.quaternion;
alpha =
Math.atan2(2 * q[0] * q[1] + 2 * q[2] * q[3], 1 - 2 * q[1] * q[1] - 2 * q[2] * q[2]) *
(180 / Math.PI);
if (alpha < 0) alpha += 360;
}
set(alpha);
};
if ('AbsoluteOrientationSensor' in window) {
var sensor = new window.AbsoluteOrientationSensor({ frequency: 60 }) as any;
sensor.addEventListener('reading', updateHeading);
sensor.start();
} else if (window.DeviceMotionEvent) window.addEventListener('deviceorientation', updateHeading);
return () => {
if ('AbsoluteOrientationSensor' in window) sensor.removeEventListener('reading', updateHeading);
window.addEventListener('deviceorientation', updateHeading);
};
});
+35 -73
View File
@@ -1,41 +1,14 @@
import { writable } from 'svelte/store';
import { encode, decode } from '@msgpack/msgpack';
const socketEvents = ['open', 'close', 'error', 'message', 'unresponsive'] as const;
type SocketEvent = (typeof socketEvents)[number];
type SocketMessage = [number, string?, unknown?];
let useBinary = false;
const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => {
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>>();
let 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 unresponsiveTimeoutId: number;
let reconnectTimeoutId: number;
let ws: WebSocket;
let socketUrl: string | URL;
@@ -49,38 +22,46 @@ function createWebSocket() {
set(false);
clearTimeout(unresponsiveTimeoutId);
clearTimeout(reconnectTimeoutId);
listeners.get(reason)?.forEach(listener => listener(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();
useBinary = true;
ping();
ws.onopen = (ev) => {
set(true);
clearTimeout(reconnectTimeoutId);
listeners.get('open')?.forEach(listener => listener(ev));
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 => {
ws.onmessage = (message) => {
resetUnresponsiveCheck();
const message = decodeMessage(frame.data);
if (!message) return;
const [, event, payload = undefined] = message;
if (event) listeners.get(event)?.forEach(listener => listener(payload));
let data = message.data;
if (data instanceof ArrayBuffer) {
listeners.get('binary')?.forEach((listener) => listener(data));
return;
}
data = data.substring(1);
if (!data) return;
let event = data.substring(data.indexOf('/') + 1, data.indexOf('['));
let payload = data.substring(data.indexOf('[') + 1, data.lastIndexOf(']'));
try {
payload = JSON.parse(payload);
} catch (error) {}
if (event) listeners.get(event)?.forEach((listener) => listener(payload));
};
ws.onerror = ev => disconnect('error', ev);
ws.onclose = ev => disconnect('close', ev);
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);
function unsubscribe(event: string, listener?: (data: any) => void) {
let eventListeners = listeners.get(event);
if (!eventListeners) return;
if (!eventListeners.size) {
@@ -100,36 +81,17 @@ function createWebSocket() {
function sendEvent(event: string, data: unknown) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
send([2, event, data]);
ws.send(`2/${event}[${JSON.stringify(data)}]`);
}
function unsubscribeToEvent(event: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
send([1, event]);
ws.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);
ws.send('0/' + event);
}
return {
@@ -145,15 +107,15 @@ function createWebSocket() {
eventListeners = new Set();
listeners.set(event, eventListeners);
}
eventListeners.add(listener as (data: unknown) => void);
eventListeners.add(listener as (data: any) => void);
return () => {
unsubscribe(event, listener as (data: unknown) => void);
unsubscribe(event, listener);
};
},
off: <T>(event: string, listener?: (data: T) => void) => {
unsubscribe(event, listener as (data: unknown) => void);
},
off: (event: string, listener?: (data: any) => void) => {
unsubscribe(event, listener);
}
};
}
+133 -194
View File
@@ -1,239 +1,178 @@
export enum MessageTopic {
imu = 'imu',
mode = 'mode',
input = 'input',
analytics = 'analytics',
position = 'position',
angles = 'angles',
i2cScan = 'i2cScan',
peripheralSettings = 'peripheralSettings',
otastatus = 'otastatus',
gait = 'walk_gait',
servoState = 'servoState',
servoPWM = 'servoPWM',
WiFiSettings = 'WiFiSettings',
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
left: vector;
right: vector;
height: number;
speed: number;
s1: number;
}
export type GithubRelease = {
message: string
tag_name: string
message: string;
tag_name: string;
assets: Array<{
name: string
browser_download_url: string
}>
}
name: string;
browser_download_url: string;
}>;
};
export type angles = number[] | Int16Array
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
}
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[]
}
hostname: string;
priority_RSSI: boolean;
wifi_networks: KnownNetworkItem[];
};
export type NetworkList = {
networks: NetworkItem[]
}
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
}
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
}
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
}
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
}
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
}
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
}
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 = {
rssi: number
ssid: string
}
rssi: number;
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
}
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 SystemInformation = Analytics & StaticSystemInformation;
export type IMU = {
x: number
y: number
z: number
heading: number
altitude: number
bmp_temp: number
pressure: number
}
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[]
address: number;
part_number: string;
name: string;
}
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
}
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 type File = number;
export interface Directory {
[key: string]: File | Directory
[key: string]: File | Directory;
}
export type Servo = {
name: string
channel: number
inverted: boolean
angle: number
center_angle: number
}
name: string;
channel: number;
inverted: boolean;
angle: number;
center_angle: number;
};
export type ServoConfiguration = {
is_active: boolean
servo_pwm_frequency: number
servo_oscillator_frequency: number
servos: Servo[]
}
export interface MDNSServiceQuery {
services: MDNSServiceItem[]
}
export interface MDNSServiceItem {
ip: string
port: number
name: string
}
export interface MDNSService {
service: string
protocol: string
port: number
}
export interface MDNSTxtRecord {
key: string
value: string
}
export interface MDNSStatus {
started: boolean
hostname: string
instance: string
services: MDNSService[]
global_txt_records: MDNSTxtRecord[]
}
is_active: boolean;
servo_pwm_frequency: number;
servo_oscillator_frequency: number;
servos: Servo[];
};
+2 -4
View File
@@ -1,6 +1,4 @@
export const daisyColor = (name: string, opacity: number = 100) => {
const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
if (opacity >= 100) return color;
const alpha = Math.min(Math.max(opacity, 0), 100) / 100;
return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`;
const color = getComputedStyle(document.documentElement).getPropertyValue(name);
return `oklch(${color} / ${opacity}%)`;
};
+64 -68
View File
@@ -1,93 +1,89 @@
import { Color, LoaderUtils, Vector3 } from 'three'
import URDFLoader, { type URDFRobot } from 'urdf-loader'
import { XacroLoader } from 'xacro-parser'
import { Result } from '$lib/utilities'
import { currentVariant, jointNames, model } from '$lib/stores'
import uzip from 'uzip'
import { fileService } from '$lib/services'
import { get } from 'svelte/store'
import { Color, LoaderUtils, Vector3 } from 'three';
import URDFLoader, { type URDFRobot } from 'urdf-loader';
import { XacroLoader } from 'xacro-parser';
import { Result } from '$lib/utilities';
import { jointNames, model } from '$lib/stores';
import uzip from 'uzip';
import { fileService } from '$lib/services';
let model_xml: XMLDocument
let model_xml: XMLDocument;
export const populateModelCache = async () => {
await cacheModelFiles()
const modelRes = await loadModel(get(currentVariant).model)
await cacheModelFiles();
const modelRes = await loadModelAsync('/spot_micro.urdf.xacro');
if (modelRes.isOk()) {
const [urdf, JOINT_NAME] = modelRes.inner
jointNames.set(JOINT_NAME)
model.set(urdf)
const [urdf, JOINT_NAME] = modelRes.inner;
jointNames.set(JOINT_NAME);
model.set(urdf);
} else {
console.error(modelRes.inner, { exception: modelRes.exception })
console.error(modelRes.inner, { exception: modelRes.exception });
}
}
};
export const cacheModelFiles = async () => {
const data = await fetch(get(currentVariant).stl)
let data = await fetch('/stl.zip');
const files = uzip.parse(await data.arrayBuffer())
var files = uzip.parse(await data.arrayBuffer());
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const url = new URL(path, window.location.href)
fileService?.saveFile(url.toString(), data)
const url = new URL(path, window.location.href);
fileService.saveFile(url.toString(), data);
}
}
};
export const loadModel = async (url: string): Promise<Result<[URDFRobot, string[]], string>> => {
const urdfLoader = new URDFLoader()
urdfLoader.workingPath = LoaderUtils.extractUrlBase(url)
export const loadModelAsync = async (
url: string
): Promise<Result<[URDFRobot, string[]], string>> => {
return new Promise((resolve, reject) => {
const xacroLoader = new XacroLoader();
const urdfLoader = new URDFLoader();
urdfLoader.workingPath = LoaderUtils.extractUrlBase(url);
let xml = url.endsWith('.xacro') ? await loadXacro(url) : await fetch(url).then(res => res.text())
if (typeof xml === 'string') {
xml = new window.DOMParser().parseFromString(xml, 'text/xml')
}
return new Promise(resolve => {
model_xml = xml
xacroLoader.load(
url,
async (xml) => {
model_xml = xml;
try {
const model = urdfLoader.parse(xml)
setupRobot(model)
const model = urdfLoader.parse(xml);
model.rotation.x = -Math.PI / 2;
model.rotation.z = Math.PI / 2;
model.traverse((c) => (c.castShadow = true));
model.updateMatrixWorld(true);
model.scale.setScalar(10);
const joints = Object.entries(model.joints)
.filter(joint => joint[1].jointType !== 'fixed')
.map(joint => joint[0])
.filter((joint) => joint[1].jointType !== 'fixed')
.map((joint) => joint[0]);
resolve(Result.ok([model, joints]))
resolve(Result.ok([model, joints]));
} catch (error) {
resolve(Result.err('Failed to load model', error))
resolve(Result.err('Failed to load model', error));
}
})
}
},
(error) => resolve(Result.err('Failed to load model', error))
);
});
};
const loadXacro = async (url: string): Promise<XMLDocument> =>
new Promise((resolve, reject) => {
new XacroLoader().load(url, resolve, reject)
})
export const toeWorldPositions = (robot: URDFRobot) => {
const toe_positions: Vector3[] = [];
robot.traverse((child) => {
if (child.name.includes('toe') && !child.name.includes('_link')) {
const worldPosition = new Vector3();
child.getWorldPosition(worldPosition);
toe_positions.push(worldPosition);
}
});
return toe_positions;
};
function setupRobot(robot: URDFRobot) {
robot.rotation.x = -Math.PI / 2
robot.rotation.z = Math.PI / 2
robot.scale.setScalar(10)
robot.traverse(c => (c.castShadow = true))
robot.updateMatrixWorld(true)
}
export function getToeWorldPositions(robot: URDFRobot): Vector3[] {
const toes: Vector3[] = []
robot.traverse(c => {
if (c.name.includes('toe') && !c.name.includes('_link'))
toes.push(c.getWorldPosition(new Vector3()))
})
return toes
}
export const extractFootColor = () => {
const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element
const colorAttrStr = colorElem.getAttribute('rgba') as string
export const footColor = () => {
const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element;
const colorAttrStr = colorElem.getAttribute('rgba') as string;
const colorStr = colorAttrStr
.split(' ')
.slice(0, 3)
.map(val => Math.floor(+val * 255))
.join(', ')
.map((val) => Math.floor(+val * 255))
.join(', ');
return new Color(`rgb(${colorStr})`)
}
return new Color(`rgb(${colorStr})`);
};
+19 -30
View File
@@ -1,47 +1,36 @@
export const humanFileSize = (size: number): string => {
const units = ['B', 'kB', 'MB', 'GB', 'TB']
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]
}
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i];
};
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) => {
// Calculate the number of seconds, minutes, hours, and days
let minutes = Math.floor(seconds / 60)
let hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
let minutes = Math.floor(seconds / 60);
let hours = Math.floor(minutes / 60);
let days = Math.floor(hours / 24);
// Calculate the remaining hours, minutes, and seconds
hours = hours % 24
minutes = minutes % 60
seconds = seconds % 60
hours = hours % 24;
minutes = minutes % 60;
seconds = seconds % 60;
// Create the formatted string
let result = ''
let result = '';
if (days > 0) {
result += days + ' day' + (days > 1 ? 's' : '') + ' '
result += days + ' day' + (days > 1 ? 's' : '') + ' ';
}
if (hours > 0) {
result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '
result += hours + ' hour' + (hours > 1 ? 's' : '') + ' ';
}
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
}
export const compareIp = (ip1: string, ip2: string) => {
const ip1Parts = ip1.split('.').map(Number)
const ip2Parts = ip2.split('.').map(Number)
for (let i = 0; i < 4; i++) {
if (ip1Parts[i] !== ip2Parts[i]) {
return ip1Parts[i] > ip2Parts[i] ? 1 : -1
}
}
return 0
}
return result;
};
+10 -3
View File
@@ -1,8 +1,15 @@
<script lang="ts">
import { page } from '$app/state'
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
setTimeout(() => {
goto('/');
}, 3000);
});
</script>
<div class="flex justify-center items-center w-full h-full">
<h1>{page.status} {page.error?.message}</h1>
<span>Go to <a class="btn btn-primary" href="/">Home page</a></span>
<h1 class="text-4xl">404 - Page not found</h1>
<p>You will be redirected to the home page in 3 seconds</p>
</div>
+16 -20
View File
@@ -19,12 +19,9 @@
servoAnglesOut,
socket,
location,
useFeatureFlags,
walkGait
useFeatureFlags
} from '$lib/stores'
import { type Analytics, type DownloadOTA } from '$lib/types/models'
import { MessageTopic } from '$lib/types/models'
import type { Analytics, DownloadOTA } from '$lib/types/models'
interface Props {
children?: import('svelte').Snippet
}
@@ -39,11 +36,10 @@
addEventListeners()
outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data))
mode.subscribe(data => socket.sendEvent(MessageTopic.mode, data))
walkGait.subscribe(data => socket.sendEvent(MessageTopic.gait, data))
servoAnglesOut.subscribe(data => socket.sendEvent(MessageTopic.angles, data))
kinematicData.subscribe(data => socket.sendEvent(MessageTopic.position, data))
outControllerData.subscribe(data => socket.sendEvent('input', { data }))
mode.subscribe(data => socket.sendEvent('mode', { data }))
servoAnglesOut.subscribe(data => socket.sendEvent('angles', { data }))
kinematicData.subscribe(data => socket.sendEvent('position', { data }))
})
onDestroy(() => {
@@ -54,24 +50,24 @@
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[]) => {
socket.on('rssi', handleNetworkStatus)
socket.on('mode', (data: ModesEnum) => mode.set(data))
socket.on('analytics', handleAnalytics)
socket.on('angles', (angles: number[]) => {
if (angles.length) servoAngles.set(angles)
})
features.subscribe(data => {
if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT)
if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data))
if (data?.download_firmware) socket.on('otastatus', handleOAT)
if (data?.sonar) socket.on('sonar', data => console.log(data))
})
}
const removeEventListeners = () => {
socket.off(MessageTopic.analytics, handleAnalytics)
socket.off('analytics', handleAnalytics)
socket.off('open', handleOpen)
socket.off('close', handleClose)
socket.off(MessageTopic.rssi, handleNetworkStatus)
socket.off(MessageTopic.otastatus, handleOAT)
socket.off('rssi', handleNetworkStatus)
socket.off('otastatus', handleOAT)
}
const handleOpen = () => {
@@ -98,7 +94,7 @@
<title>{page.data.title}</title>
</svelte:head>
<div class="drawer">
<div class="drawer h-screen">
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
<div class="drawer-content flex flex-col">
<!-- Status bar content here -->
+13 -13
View File
@@ -1,22 +1,22 @@
export const prerender = true
export const ssr = false
export const prerender = false;
export const ssr = false;
const registerFetchIntercept = async () => {
const { fetch: originalFetch } = window
const fileService = (await import('$lib/services/file-service')).default
const { fetch: originalFetch } = window;
const fileService = (await import('$lib/services/file-service')).default;
window.fetch = async (resource, config) => {
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 url = resource instanceof Request ? resource.url : resource.toString();
let file = await fileService.getFile(url);
return file.isOk() ? new Response(file.inner) : originalFetch(resource, config);
};
};
export const load = async () => {
await registerFetchIntercept()
await registerFetchIntercept();
return {
title: 'Spot micro controller',
github: 'runeharlyk/SpotMicroESP32-Leika',
app_name: 'Spot Micro Controller',
copyright: '2025 Rune Harlyk'
}
}
copyright: '2024 Rune Harlyk'
};
};
+10 -8
View File
@@ -13,15 +13,17 @@
})
</script>
<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="w-64 h-64">
<Visualization sky={false} orbit panel={false} ground={false} />
<div class="w-full h-full flex justify-center items-center">
<div class="h-full flex flex-col">
<div class="grow-3 w-80 relative">
<Visualization sky={false} orbit panel={false} ground={false} zoom={8} />
<div class="absolute bottom-0 w-full h-40 bg-gradient-to-t from-base-100 to-transparent">
</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 class="grow-3 flex justify-center">
<a class="btn btn-primary rounded-full" href={$socket ? '/controller' : '/connection'}>
Add Robot Dog
</a>
</div>
</div>
</div>
+4 -2
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte';
import { WiFi } from '$lib/components/icons';
import { location, socket } from '$lib/stores';
import { location, socket, useFeatureFlags } from '$lib/stores';
const features = useFeatureFlags();
const update = () => {
const ws = $location ? $location : window.location.host;
@@ -14,7 +16,7 @@
<WiFi class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet}
{#snippet title()}
<span>Connection</span>
<span >Connection</span>
{/snippet}
<div class="flex">
+16 -16
View File
@@ -1,26 +1,26 @@
<script lang="ts">
import Controls from './Controls.svelte'
import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte'
import { selectedView, views } from '$lib/stores/application'
import { onMount } from 'svelte'
import { mpu, socket } from '$lib/stores'
import { imu } from '$lib/stores/imu'
import { MessageTopic, type IMU } from '$lib/types/models'
import Controls from './Controls.svelte';
import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte';
import { selectedView, views } from '$lib/stores/application';
import { onMount } from 'svelte';
import { mpu, socket } from '$lib/stores';
import { imu } from '$lib/stores/imu';
import type { IMU } from '$lib/types/models';
let layout = $derived($views.find(v => v.name === $selectedView)!)
let layout = $derived($views.find(v => v.name === $selectedView)!);
onMount(() => {
socket.on(MessageTopic.imu, (data: IMU) => {
imu.addData(data)
socket.on('imu', (data: IMU) => {
imu.addData(data);
if (data.heading)
mpu.update(mpuData => {
mpuData.heading = data.heading
console.log(data.heading)
mpuData.heading = data.heading;
console.log(data.heading);
return mpuData
})
})
})
return mpuData;
});
});
});
</script>
<div class="absolute top-0 select-none w-screen h-screen">
+16 -68
View File
@@ -1,47 +1,17 @@
<script lang="ts">
import nipplejs from 'nipplejs'
import { onMount } from 'svelte'
import { capitalize, throttler } from '$lib/utilities'
import {
input,
outControllerData,
mode,
modes,
type Modes,
ModesEnum,
walkGaits,
WalkGaits,
walkGait,
walkGaitLabels
} from '$lib/stores'
import { capitalize, throttler, toInt8 } from '$lib/utilities'
import { input, outControllerData, mode, modes, type Modes, ModesEnum } from '$lib/stores'
import type { vector } from '$lib/types/models'
import { VerticalSlider } from '$lib/components/input'
import { gamepadAxes, hasGamepad } from '$lib/stores/gamepad'
import { notifications } from '$lib/components/toasts/notifications'
let throttle = new throttler()
let left: nipplejs.JoystickManager
let right: nipplejs.JoystickManager
let throttle_timing = 40
let data = new Array(7)
$effect(() => {
if ($hasGamepad) {
notifications.success('🎮 Gamepad connected', 3000)
}
})
$effect(() => {
handleJoyMove('left', { x: $gamepadAxes[0], y: -$gamepadAxes[1] })
handleJoyMove('right', { x: $gamepadAxes[2], y: $gamepadAxes[3] })
})
// TODO React to button press
// $effect(() => {
// if ($gamepadButtons.length === 0) return
//
// })
let data = new Array(8)
onMount(() => {
left = nipplejs.create({
@@ -75,13 +45,14 @@
}
const updateData = () => {
data[0] = $input.left.x
data[1] = $input.left.y
data[2] = $input.right.x
data[3] = $input.right.y
data[4] = $input.height
data[5] = $input.speed
data[6] = $input.s1
data[0] = 0
data[1] = toInt8($input.left.x, -1, 1)
data[2] = toInt8($input.left.y, -1, 1)
data[3] = toInt8($input.right.x, -1, 1)
data[4] = toInt8($input.right.y, -1, 1)
data[5] = toInt8($input.height, 0, 100)
data[6] = toInt8($input.speed, 0, 100)
data[7] = toInt8($input.s1, 0, 100)
outControllerData.set(data)
}
@@ -99,7 +70,7 @@
}
const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => {
const value: number = Number((event.target as HTMLInputElement).value)
const value: number = event.target?.value
input.update(inputData => {
inputData[key] = value
@@ -111,10 +82,6 @@
const changeMode = (modeValue: Modes) => {
mode.set(modes.indexOf(modeValue))
}
const changeWalkGait = (walkGaitValue: WalkGaits) => {
walkGait.set(walkGaitValue)
}
</script>
<div class="absolute top-0 left-0 w-screen h-screen">
@@ -136,11 +103,7 @@
</div>
<div class="absolute bottom-0 z-10 flex items-end">
<div class="flex items-center flex-col bg-base-300 bg-opacity-50 p-3 pb-2 gap-2 rounded-tr-xl">
<VerticalSlider
min={0}
max={1}
step={0.01}
oninput={(e: Event) => handleRange(e, 'height')} />
<VerticalSlider min={0} max={100} oninput={(e: Event) => handleRange(e, 'height')} />
<label for="height">Ht</label>
</div>
<div
@@ -156,20 +119,7 @@
{/each}
</div>
{#if $mode === ModesEnum.Walk}
<div class="join">
{#each Object.values(WalkGaits) as gaitValue}
{#if typeof gaitValue === 'number'}
<button
class="btn join-item btn-sm"
class:btn-secondary={$walkGait === gaitValue}
onclick={() => changeWalkGait(gaitValue)}>
{walkGaitLabels[gaitValue]}
</button>
{/if}
{/each}
</div>
{#if $mode === ModesEnum.Walk || $mode === ModesEnum.Crawl}
<div class="flex gap-4">
<div>
<label for="s1">S1</label>
@@ -177,8 +127,7 @@
type="range"
name="s1"
min="0"
step="0.01"
max="1"
max="100"
oninput={e => handleRange(e, 's1')}
class="range range-sm range-primary" />
</div>
@@ -188,8 +137,7 @@
type="range"
name="speed"
min="0"
step="0.01"
max="1"
max="100"
oninput={e => handleRange(e, 'speed')}
class="range range-sm range-primary" />
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import I2C from './i2c.svelte'
import I2C from './i2c.svelte';
</script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
+3 -3
View File
@@ -1,7 +1,7 @@
import type { PageLoad } from './$types'
import type { PageLoad } from './$types';
export const load = (async () => {
return {
title: 'I2C'
}
}) satisfies PageLoad
};
}) satisfies PageLoad;
+14 -36
View File
@@ -1,14 +1,12 @@
<script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte'
import { onMount } from 'svelte'
import { socket } from '$lib/stores'
import { MessageTopic, type I2CDevice } from '$lib/types/models'
import { Connection } from '$lib/components/icons'
import I2CSetting from './i2cSetting.svelte'
import SettingsCard from '$lib/components/SettingsCard.svelte';
import { onMount } from 'svelte';
import { socket } from '$lib/stores';
import type { I2CDevice } from '$lib/types/models';
import { Connection } from '$lib/components/icons';
const i2cDevices = [
{ address: 30, part_number: 'HMC5883', name: '3-Axis Digital Compass/Magnetometer IC' },
{ address: 41, part_number: 'BNO055', name: '9-Axis Absolute Orientation Sensor' },
{ address: 64, part_number: 'PCA9685', name: '16-channel PWM driver default address' },
{ address: 72, part_number: 'ADS1115', name: '4-channel 16-bit ADC' },
{
@@ -16,19 +14,16 @@
part_number: 'MPU6050',
name: 'Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices'
},
{ address: 115, part_number: 'PAJ7620U2', name: 'Gesture sensor' },
{ address: 119, part_number: 'BMP085', name: 'Temp/Barometric' }
]
];
let active_devices: I2CDevice[] = $state([])
let isLoading = $state(false)
let active_devices: I2CDevice[] = $state([]);
onMount(() => {
socket.on(MessageTopic.i2cScan, handleScan)
triggerScan()
return () => socket.off(MessageTopic.i2cScan, handleScan)
})
socket.on('i2cScan', handleScan);
socket.sendEvent('i2cScan', '');
return () => socket.off('i2cScan', handleScan);
});
const handleScan = (data: any) => {
active_devices = data.addresses.map(
@@ -38,14 +33,8 @@
part_number: 'Unknown',
name: 'Unknown'
}
)
isLoading = false
}
const triggerScan = () => {
isLoading = true
socket.sendEvent(MessageTopic.i2cScan, '')
}
);
};
</script>
<SettingsCard collapsible={false}>
@@ -53,19 +42,8 @@
<Connection class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet}
{#snippet title()}
<span>I<sup>2</sup>C</span>
<span >I<sup>2</sup>C</span>
{/snippet}
{#snippet right()}
<button class="btn btn-primary" onclick={triggerScan} disabled={isLoading}>
{#if isLoading}
<span class="loading loading-ring loading-xs"></span>
{:else}
Scan
{/if}
</button>
{/snippet}
<I2CSetting />
<div class="grid">
{#if active_devices.length === 0}
@@ -1,99 +0,0 @@
<script lang="ts">
import { Cancel, Edit, EditOff, Power } from '$lib/components/icons'
import { socket } from '$lib/stores'
import { MessageTopic, type PeripheralsConfiguration } from '$lib/types/models'
import { onMount } from 'svelte'
import { modals } from 'svelte-modals'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
let settings: PeripheralsConfiguration | null = $state(null)
let isEditing = $state(false)
onMount(() => {
socket.on(MessageTopic.peripheralSettings, handleSettings)
socket.sendEvent(MessageTopic.peripheralSettings, '')
return () => socket.off(MessageTopic.peripheralSettings, handleSettings)
})
const handleSettings = (data: any) => {
settings = data
}
const handleSave = () => {
modals.open(ConfirmDialog, {
title: 'Confirm configuration',
message:
'Are you sure you want to save this configuration? The operation cannot be undone. Please make sure you have the correct settings.',
labels: {
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'Confirm', icon: Power }
},
onConfirm: () => {
modals.close()
socket.sendEvent(MessageTopic.peripheralSettings, settings)
}
})
}
const Icon = $derived(isEditing ? EditOff : Edit)
</script>
{#if settings}
<div class="collapse bg-base-100 border-base-300 border">
<input type="checkbox" />
<div class="collapse-title font-semibold">Configuration</div>
<div class="collapse-content text-sm">
<div class="flex flex-col gap-2">
<label for="sda" class="input validator">
SDA
<input
id="sda"
type="number"
required
placeholder="Type a number between 1 to 48"
min="0"
max="48"
title="SDA pin number (0-48)"
disabled={!isEditing}
bind:value={settings.sda} />
</label>
<label for="scl" class="input validator">
SCL
<input
id="scl"
type="number"
required
placeholder="Type a number between 1 to 48"
min="1"
max="48"
title="SCL pin number (0-48)"
disabled={!isEditing}
bind:value={settings.scl} />
</label>
<label class="input validator" for="frequency">
Frequency
<input
id="frequency"
type="number"
required
placeholder="Type a number between 100000 to 430000"
min="100000"
max="430000"
title="I2C frequency in Hz"
disabled={!isEditing}
bind:value={settings.frequency} />
</label>
<div>
<button class="btn btn-outline btn-primary" onclick={() => (isEditing = !isEditing)}>
<Icon class="h-6 w-6" />
</button>
{#if isEditing}
<button class="btn btn-outline btn-primary" onclick={handleSave}>Save</button>
{/if}
</div>
</div>
</div>
</div>
{/if}
+193 -136
View File
@@ -1,92 +1,61 @@
<script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte'
import { imu } from '$lib/stores/imu'
import { Chart, registerables } from 'chart.js'
import { cubicOut } from 'svelte/easing'
import { slide } from 'svelte/transition'
import { onDestroy, onMount } from 'svelte'
import { socket } from '$lib/stores'
import { MessageTopic, type IMU } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Rotate3d } from '$lib/components/icons'
import SettingsCard from "$lib/components/SettingsCard.svelte";
import { imu } from '$lib/stores/imu';
import { Chart, registerables } from 'chart.js';
import { cubicOut } from "svelte/easing";
import { slide } from "svelte/transition";
import { onDestroy, onMount } from "svelte";
import { daisyColor } from "$lib/utilities";
import { socket } from "$lib/stores";
import type { IMU } from "$lib/types/models";
import { useFeatureFlags } from "$lib/stores/featureFlags";
import { Rotate3d } from "$lib/components/icons";
Chart.register(...registerables)
const features = useFeatureFlags();
const features = useFeatureFlags()
let intervalId: ReturnType<typeof setInterval> | number
Chart.register(...registerables);
let angleChartElement: HTMLCanvasElement
let tempChartElement: HTMLCanvasElement
let altitudeChartElement: HTMLCanvasElement
let angleChartElement: HTMLCanvasElement = $state();
let angleChart: Chart;
let angleChart: Chart
let tempChart: Chart
let altitudeChart: Chart
let tempChartElement: HTMLCanvasElement = $state();
let tempChart: Chart;
const getChartColors = () => {
const style = getComputedStyle(document.body)
return {
primary: style.getPropertyValue('--color-primary'),
secondary: style.getPropertyValue('--color-secondary'),
accent: style.getPropertyValue('--color-accent'),
background: style.getPropertyValue('--color-background')
}
let altitudeChartElement: HTMLCanvasElement = $state();
let altitudeChart: Chart;
const handleImu = (data: IMU) => {
console.log(data);
imu.addData(data);
}
const createBaseChartConfig = (bgColor: string) => ({
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: { display: true },
tooltip: { mode: 'index' as const, intersect: false }
},
elements: { point: { radius: 1 } },
scales: {
x: {
grid: { color: bgColor },
ticks: { color: bgColor },
display: false
},
y: {
type: 'linear' as const,
position: 'left' as const,
min: 0,
max: 10,
grid: { color: bgColor },
ticks: { color: bgColor },
border: { color: bgColor }
}
}
})
const initializeCharts = () => {
const colors = getChartColors()
const baseConfig = createBaseChartConfig(colors.background)
onMount(() => {
socket.on('imu', handleImu);
angleChart = new Chart(angleChartElement, {
type: 'line',
data: {
datasets: [
{
label: 'x',
borderColor: colors.primary,
backgroundColor: colors.primary,
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $imu.x,
yAxisID: 'y'
},
{
label: 'y',
borderColor: colors.secondary,
backgroundColor: colors.secondary,
borderColor: daisyColor('--s'),
backgroundColor: daisyColor('--s', 50),
borderWidth: 2,
data: $imu.y,
yAxisID: 'y'
},
{
label: 'z',
borderColor: colors.accent,
backgroundColor: colors.accent,
borderColor: daisyColor('--a'),
backgroundColor: daisyColor('--a', 50),
borderWidth: 2,
data: $imu.z,
yAxisID: 'y'
@@ -94,30 +63,61 @@
]
},
options: {
...baseConfig,
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
},
elements: {
point: {
radius: 1
}
},
scales: {
...baseConfig.scales,
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
...baseConfig.scales.y,
type: 'linear',
title: {
display: true,
text: 'Angle [°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
},
position: 'left',
min: 0,
max: 10,
grid: { color: daisyColor('--bc', 10) },
ticks: { color: daisyColor('--bc') },
border: { color: daisyColor('--bc', 10) }
}
}
}
}
})
});
tempChart = new Chart(tempChartElement, {
type: 'line',
data: {
datasets: [
{
label: 'Barometer temperature',
borderColor: colors.secondary,
backgroundColor: colors.secondary,
borderColor: daisyColor('--s'),
backgroundColor: daisyColor('--s', 50),
borderWidth: 2,
data: $imu.bmp_temp,
yAxisID: 'y'
@@ -125,30 +125,61 @@
]
},
options: {
...baseConfig,
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
},
elements: {
point: {
radius: 1
}
},
scales: {
...baseConfig.scales,
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
...baseConfig.scales.y,
type: 'linear',
title: {
display: true,
text: 'Temperature [C°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
},
position: 'left',
min: 0,
max: 10,
grid: { color: daisyColor('--bc', 10) },
ticks: { color: daisyColor('--bc') },
border: { color: daisyColor('--bc', 10) }
}
}
}
}
})
});
altitudeChart = new Chart(altitudeChartElement, {
type: 'line',
data: {
datasets: [
{
label: 'Altitude',
borderColor: colors.primary,
backgroundColor: colors.primary,
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $imu.altitude,
yAxisID: 'y'
@@ -156,98 +187,124 @@
]
},
options: {
...baseConfig,
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
},
elements: {
point: {
radius: 1
}
},
scales: {
...baseConfig.scales,
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
...baseConfig.scales.y,
type: 'linear',
title: {
display: true,
text: 'Altitude [M]',
color: colors.background,
font: { size: 16, weight: 'bold' }
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
},
position: 'left',
min: 0,
max: 10,
grid: { color: daisyColor('--bc', 10) },
ticks: { color: daisyColor('--bc') },
border: { color: daisyColor('--bc', 10) }
}
}
}
}
})
}
const updateChartData = (chart: Chart, data: number[], label: string) => {
chart.data.labels = data
chart.data.datasets[0].data = data
chart.options.scales!.y!.min = Math.min(...data) - 1
chart.options.scales!.y!.max = Math.max(...data) + 1
chart.update('none')
}
const updateData = () => {
if ($features.imu) {
angleChart.data.labels = $imu.x
angleChart.data.datasets[0].data = $imu.x
angleChart.data.datasets[1].data = $imu.y
angleChart.data.datasets[2].data = $imu.z
const allValues = [...$imu.x, ...$imu.y, ...$imu.z]
angleChart.options.scales!.y!.min = Math.min(...allValues) - 1
angleChart.options.scales!.y!.max = Math.max(...allValues) + 1
angleChart.update('none')
}
if ($features.bmp) {
updateChartData(tempChart, $imu.bmp_temp, 'Temperature')
updateChartData(altitudeChart, $imu.altitude, 'Altitude')
}
}
onMount(() => {
socket.on(MessageTopic.imu, (data: IMU) => {
console.log(data)
imu.addData(data)
})
initializeCharts()
intervalId = setInterval(updateData, 200)
});
setInterval(() => {
updateData(), 200;
});
})
onDestroy(() => {
socket.off(MessageTopic.imu)
clearInterval(intervalId)
socket.off('imu', handleImu);
})
const updateData = () => {
if ($features.imu) {
angleChart.data.labels = $imu.x;
angleChart.data.datasets[0].data = $imu.x;
angleChart.data.datasets[1].data = $imu.y;
angleChart.data.datasets[2].data = $imu.z;
angleChart.options.scales!.y!.min = Math.min(Math.min(...$imu.x), Math.min(...$imu.y), Math.min(...$imu.z)) - 1;
angleChart.options.scales!.y!.max = Math.max(Math.max(...$imu.x), Math.max(...$imu.y), Math.max(...$imu.z)) + 1;
angleChart.update('none');
}
if ($features.bmp) {
tempChart.data.labels = $imu.bmp_temp;
tempChart.data.datasets[0].data = $imu.bmp_temp;
tempChart.options.scales!.y!.min = Math.min(...$imu.bmp_temp) - 1;
tempChart.options.scales!.y!.max = Math.max(...$imu.bmp_temp) + 1;
tempChart.update('none');
altitudeChart.data.labels = $imu.altitude;
altitudeChart.data.datasets[0].data = $imu.altitude;
altitudeChart.options.scales!.y!.min = Math.min(Math.min(...$imu.altitude)) - 1;
altitudeChart.options.scales!.y!.max = Math.max(Math.max(...$imu.altitude)) + 1;
altitudeChart.update('none');
}
}
</script>
<SettingsCard collapsible={false}>
{#snippet icon()}
<Rotate3d class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
<Rotate3d class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet}
{#snippet title()}
<span>IMU</span>
<span >IMU</span>
{/snippet}
{#if $features.imu}
<div class="w-full overflow-x-auto">
<div
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={angleChartElement}></canvas>
</div>
</div>
{/if}
{#if $features.bmp}
<div class="w-full overflow-x-auto">
<div
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={tempChartElement}></canvas>
</div>
</div>
<div class="w-full overflow-x-auto">
<div
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={altitudeChartElement}></canvas>
</div>
</div>
{/if}
<!-- <IMUSetting /> -->
</SettingsCard>
@@ -1,12 +1,9 @@
<script lang="ts">
import Servos from './servos.svelte'
import ServoTable from './ServoTable.svelte'
let servoId = $state(0)
let pwm = $state(306)
import Servos from './servos.svelte';
import ServoTable from './ServoTable.svelte';
</script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
<Servos bind:servoId bind:pwm />
<ServoTable {servoId} {pwm} />
<Servos />
<ServoTable />
</div>
@@ -1,57 +1,34 @@
<script lang="ts">
import { api } from '$lib/api'
import { onMount } from 'svelte'
import { RotateCw, RotateCcw } from '$lib/components/icons'
import { api } from '$lib/api';
import { onMount } from 'svelte';
interface Props {
data?: any
servoId?: number
pwm?: number
data?: any;
}
let {
data = $bindable({
let { data = $bindable({
servos: []
}),
pwm = $bindable(306),
servoId = $bindable(0)
}: Props = $props()
}) }: Props = $props();
const updateValue = (event: Event, index: number, key: string) => {
data.servos[index][key] = Number((event.target as HTMLInputElement).value)
}
const updateValue = (event, index, key) => {
data.servos[index][key] = event.target.innerText;
};
const syncConfig = async () => {
await api.post('/api/servo/config', data)
}
const toggleDirection = async (index: number) => {
data.servos[index].direction = data.servos[index].direction === 1 ? -1 : 1
await syncConfig()
}
await api.post('/api/servo/config', data);
};
onMount(async () => {
const result = await api.get('/api/servo/config')
const result = await api.get('/api/servo/config');
if (result.isOk()) {
data = result.inner
}
})
const setCenterPWM = async () => {
console.log('setCenterPWM', servoId, pwm)
data.servos[servoId]['center_pwm'] = pwm
await syncConfig()
data = result.inner;
}
});
</script>
<div>
<button class="btn btn-sm btn-primary" onclick={() => setCenterPWM()}>Set center pwm</button>
</div>
<div class="overflow-x-auto">
<table class="table table-xs">
<thead>
<tr>
<th>Servo</th>
<th>Center PWM</th>
<th>Center Angle</th>
<th>Direction</th>
@@ -60,51 +37,34 @@
</thead>
<tbody>
{#each data.servos as servo, index}
<tr class="hover:bg-base-200">
<td class="font-medium">Servo {index}</td>
<td>
<input
type="number"
class="input input-sm input-bordered w-20"
value={servo.center_pwm}
<tr>
<td
contenteditable="true"
onblur={syncConfig}
oninput={event => updateValue(event, index, 'center_pwm')}
min="80"
max="600" />
>
{servo.center_pwm}
</td>
<td>
<input
type="number"
step="0.1"
class="input input-sm input-bordered w-20"
value={servo.center_angle}
<td
contenteditable="true"
onblur={syncConfig}
oninput={event => updateValue(event, index, 'center_angle')}
min="-90"
max="90" />
>
{servo.center_angle}
</td>
<td>
<button
class="btn btn-sm btn-ghost"
title="Toggle direction {servo.direction}"
onclick={() => toggleDirection(index)}>
{#if servo.direction === 1}
<RotateCw class="w-4 h-4 text-green-500" />
{:else}
<RotateCcw class="w-4 h-4" />
{/if}
</button>
<td
contenteditable="true"
onblur={syncConfig}
oninput={event => updateValue(event, index, 'direction')}
>
{servo.direction}
</td>
<td>
<input
type="number"
step="0.01"
class="input input-sm input-bordered w-20"
value={servo.conversion}
<td
contenteditable="true"
onblur={syncConfig}
oninput={event => updateValue(event, index, 'conversion')}
min="0"
max="10" />
>
{servo.conversion}
</td>
</tr>
{/each}
+51 -40
View File
@@ -1,64 +1,75 @@
<script lang="ts">
import { socket } from '$lib/stores'
import { MessageTopic } from '$lib/types/models'
import { throttler as Throttler } from '$lib/utilities'
import SettingsCard from '$lib/components/SettingsCard.svelte';
import type { ServoConfiguration, Servo } from '$lib/types/models';
import Spinner from '$lib/components/Spinner.svelte';
let { servoId = $bindable(0), pwm = $bindable(306) } = $props()
import { socket } from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import { throttler as Throttler } from '$lib/utilities';
import { MotorOutline } from '$lib/components/icons';
let active = $state(false)
let isLoading = false;
let allServos = $state(false)
let active = $state(false);
const throttler = new Throttler()
let servoId = $state(0);
const activateServo = () => {
socket.sendEvent(MessageTopic.servoState, { active: 1 })
}
const throttler = new Throttler();
const deactivateServo = () => {
socket.sendEvent(MessageTopic.servoState, { active: 0 })
}
const sweep = (event: any) => {
let channel = event.detail.channel;
socket.sendEvent('servoConfiguration', { servos: [{ channel, sweep: true }] });
};
const activateServo = (event: any) => {
socket.sendEvent('servoState', { active: 1 });
};
const deactivateServo = (event: any) => {
socket.sendEvent('servoState', { active: 0 });
};
let pwm = $state(306);
const updatePWM = () => {
throttler.throttle(() => {
socket.sendEvent(MessageTopic.servoPWM, { servo_id: servoId, pwm })
}, 10)
}
const toggleMode = () => {
servoId = allServos ? -1 : 0
}
socket.sendEvent('servoPWM', { servo_id: servoId, pwm });
}, 10);
};
</script>
<div class="flex flex-col">
<h2 class="text-lg">General servo configuration</h2>
<span>Servo</span>
<span>{pwm}</span>
</div>
<input
<SettingsCard collapsible={false}>
{#snippet icon()}
<MotorOutline class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet}
{#snippet title()}
<span >Servo</span>
{/snippet}
{pwm}
<input
type="range"
min="80"
max="600"
bind:value={pwm}
oninput={updatePWM}
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" />
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<div class="flex flex-col">
{#if isLoading}
<Spinner />
{:else}
<div class="flex flex-col">
<h2 class="text-lg">General servo configuration</h2>
<span>
<label for="mode">All servoes</label>
<input type="checkbox" class="toggle" bind:checked={allServos} onchange={toggleMode} />
</span>
<span>
<label for="active">Active</label>
<span class="flex items-center gap-2">
<label for="servoId">Servo active {servoId}</label>
<input type="range" min="0" max="11" step="1" bind:value={servoId} />
<input
type="checkbox"
class="toggle"
bind:checked={active}
onchange={active ? activateServo : deactivateServo} />
onchange={active ? activateServo : deactivateServo}
/>
</span>
<span class="flex items-center gap-2">
<label for="servoId">Servo active {servoId}</label>
<input type="range" min="0" max="11" step="1" bind:value={servoId} />
</span>
</div>
</div>
{/if}
</SettingsCard>
+8 -21
View File
@@ -1,24 +1,11 @@
<script lang="ts">
import { FileIcon, TrashIcon } from '$lib/components/icons'
<script>
import { FileIcon } from '$lib/components/icons'
interface Props {
name: string
selected: (name: string) => void
onDelete: (name: string) => void
}
let { name, selected, onDelete }: Props = $props()
let { name, selected } = $props()
</script>
<div class="flex items-center pl-4 group hover:bg-gray-700 rounded py-1">
<button class="flex items-center gap-2 flex-grow" onclick={() => selected(name)}>
<FileIcon class="w-4 h-4" />
<span class="text-sm">{name}</span>
</button>
<button
class="opacity-0 group-hover:opacity-100 p-1 hover:text-red-500"
onclick={() => onDelete(name)}>
<TrashIcon class="w-4 h-4" />
</button>
</div>
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<span role="button" class="flex pl-4 gap-2 items-center" onclick={selected}>
<FileIcon />{name}
</span>
@@ -1,172 +1,62 @@
<script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte'
import Spinner from '$lib/components/Spinner.svelte'
import Folder from './Folder.svelte'
import { api } from '$lib/api'
import type { Directory } from '$lib/types/models'
import { FolderIcon, Add, FileIcon } from '$lib/components/icons'
import { modals } from 'svelte-modals'
import NewFolderDialog from './NewFolderDialog.svelte'
import NewFileDialog from './NewFileDialog.svelte'
import SettingsCard from "$lib/components/SettingsCard.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import Folder from "./Folder.svelte";
import { api } from "$lib/api";
import type { Directory } from "$lib/types/models";
import { FolderIcon } from "$lib/components/icons";
let filename = $state('')
let content = $state('')
let isEditing = $state(false)
let filename = $state('');
const getFiles = async () => {
const result = await api.get<Directory>('/api/files')
if (result.isOk()) {
return result.inner
return result.inner;
}
return { root: {} }
}
};
const getContent = async (name: string) => {
if (!name) return ''
if (!name) return '';
const result = await api.get(`/api/config/${name}`)
if (result.isOk()) {
content = JSON.stringify(result.inner, null, 4)
return content
return JSON.stringify(result.inner, null, 4);
}
return ''
}
const saveContent = async () => {
if (!filename) return
const result = await api.post('/api/files/edit', {
file: '/config/' + filename,
content
})
if (result.isOk()) {
isEditing = false
}
}
const deleteFile = async (name: string) => {
if (!confirm(`Are you sure you want to delete ${name}?`)) return
const result = await api.post('/api/files/delete', { file: '/config/' + name })
const result = await api.post(`/api/files/delete`, { file: "/config/"+ name })
if (result.isOk()) {
filename = ''
content = ''
return result.inner;
}
return ''
}
const createFolder = async (folderName: string) => {
if (!folderName) return
const result = await api.post('/api/files/mkdir', {
path: '/config/' + folderName
})
if (result.isOk()) {
// Refresh the file list
await getFiles()
}
}
const updateSelected = async (name: string) => {
filename = name
isEditing = false
await getContent(name)
}
const openNewFolderDialog = () => {
modals.open(NewFolderDialog, {
onConfirm: createFolder
})
}
const createFile = async (fileName: string) => {
if (!fileName) return
const result = await api.post('/api/files/edit', {
file: '/config/' + fileName,
content: '{}' // Default empty JSON object
})
if (result.isOk()) {
// Refresh the file list and select the new file
await getFiles()
await updateSelected(fileName)
}
}
const openNewFileDialog = () => {
modals.open(NewFileDialog, {
onConfirm: createFile
})
const updateSelected = async (event:any) => {
filename = event.detail.name;
}
</script>
<!-- <SettingsCard collapsible={false}> -->
<!-- {#snippet icon()} -->
<FolderIcon class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
<!-- {/snippet}
{#snippet title()} -->
<div class="flex justify-between items-center w-full gap-2">
<span>File System</span>
<div class="flex gap-2">
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFileDialog}>
<FileIcon class="w-4 h-4" />
New File
</button>
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFolderDialog}>
<Add class="w-4 h-4" />
New Folder
</button>
</div>
</div>
<!-- {/snippet} -->
<div class="flex flex-col md:flex-row gap-4 w-full">
<!-- File Tree -->
<div
class="w-full md:w-[300px] md:min-w-[300px] md:max-w-[300px] border-b md:border-b-0 md:border-r pb-4 md:pb-0 md:pr-4">
<SettingsCard collapsible={false}>
{#snippet icon()}
<FolderIcon class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet}
{#snippet title()}
<span >File System</span>
{/snippet}
<div class="w-full overflow-x-auto">
{#await getFiles()}
<Spinner />
{:then files}
<Folder
name="/"
files={files.root}
expanded
selected={updateSelected}
onDelete={deleteFile} />
<Folder name="/" files={files.root} expanded on:selected={updateSelected}/>
{/await}
</div>
<!-- File Content -->
<div class="flex-1 min-w-0">
{#if filename}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-2">
<h3 class="text-lg font-semibold truncate">{filename}</h3>
<div class="flex gap-2">
{#if isEditing}
<button class="btn btn-sm btn-primary" onclick={saveContent}>Save</button>
<button class="btn btn-sm btn-secondary" onclick={() => (isEditing = false)}>
Cancel
</button>
{:else}
<button class="btn btn-sm btn-primary" onclick={() => (isEditing = true)}>
Edit
</button>
<button class="btn btn-sm btn-danger" onclick={() => deleteFile(filename)}>
Delete
</button>
{/if}
</div>
</div>
{#await getContent(filename)}
<div>
<Spinner />
{:then _}
{#if isEditing}
<textarea
class="w-full h-[300px] sm:h-[500px] font-mono p-2 bg-gray-800 text-white"
bind:value={content}></textarea>
{:else}
<pre
class="bg-gray-800 p-4 rounded overflow-auto max-h-[300px] sm:max-h-[500px]">{content}</pre>
{/if}
{/await}
{:else}
<div class="text-center text-gray-500">Select a file to view its contents</div>
{/if}
</div>
</div>
<!-- </SettingsCard> -->
{:then content}
<pre>{content}</pre>
{/await}
</div>
</SettingsCard>
+28 -25
View File
@@ -1,44 +1,47 @@
<script lang="ts">
import Folder from './Folder.svelte'
import File from './File.svelte'
import { FolderIcon, FolderOpenOutline } from '$lib/components/icons'
import Folder from './Folder.svelte';
import File from './File.svelte';
import { createEventDispatcher } from 'svelte';
import { FolderIcon, FolderOpenOutline } from '$lib/components/icons';
interface Props {
expanded?: boolean
name: string
files: any
selected: (name: string) => void
onDelete: (name: string) => void
expanded?: boolean;
name: any;
files: any;
}
let { expanded = $bindable(false), name, files, selected, onDelete }: Props = $props()
let { expanded = $bindable(false), name, files }: Props = $props();
function toggle() {
expanded = !expanded
expanded = !expanded;
}
const dispatch = createEventDispatcher();
const updateSelected = async (event:any) => {
dispatch('selected', { name:event.detail.name });
}
</script>
<div class="folder-item">
<button class="flex items-center pl-2 hover:bg-gray-700 w-full rounded py-1" onclick={toggle}>
<button class="flex pl-2" onclick={toggle}>
{#if expanded}
<FolderOpenOutline class="w-5 h-5 mr-1" />
<FolderOpenOutline class="w-6 h-6" />
{:else}
<FolderIcon class="w-5 h-5 mr-1" />
<FolderIcon class="w-6 h-6" />
{/if}
<span class="text-sm">{name}</span>
</button>
{name}
</button>
{#if expanded}
<ul class="ml-4 border-l border-gray-600 mt-1">
{#each Object.entries(files) as [itemName, content]}
<li class="py-1">
{#if typeof content === 'object'}
<Folder name={itemName} files={content} {selected} {onDelete} />
{#if expanded}
<ul class="ml-5 border-l border-slate-600">
{#each Object.entries(files) as [name, content]}
<li class="p-1">
{#if typeof content == 'object'}
<Folder {name} files={content} on:selected={updateSelected} />
{:else}
<File name={itemName} {selected} {onDelete} />
<File {name} on:selected={updateSelected}/>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
@@ -1,44 +0,0 @@
<script lang="ts">
import { focusTrap } from 'svelte-focus-trap'
import { fly } from 'svelte/transition'
import { exitBeforeEnter, modals, type ModalProps } from 'svelte-modals'
import { Cancel, Check } from '$lib/components/icons'
let { isOpen, onConfirm }: ModalProps = $props()
let fileName = $state('')
const handleCreate = () => {
if (!fileName) return
onConfirm(fileName)
modals.close()
}
</script>
{#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
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg">
<h2 class="text-base-content text-start text-2xl font-bold">Create New File</h2>
<div class="divider my-2"></div>
<input
type="text"
class="input input-bordered w-full"
placeholder="File name"
bind:value={fileName} />
<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()}>
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
</button>
<button class="btn btn-primary inline-flex items-center" onclick={handleCreate}>
<Check class="mr-2 h-5 w-5" /><span>Create</span>
</button>
</div>
</div>
</div>
{/if}
@@ -1,44 +0,0 @@
<script lang="ts">
import { focusTrap } from 'svelte-focus-trap'
import { fly } from 'svelte/transition'
import { exitBeforeEnter, modals, type ModalProps } from 'svelte-modals'
import { Cancel, Check } from '$lib/components/icons'
let { isOpen, onConfirm }: ModalProps = $props()
let folderName = $state('')
const handleCreate = () => {
if (!folderName) return
onConfirm(folderName)
modals.close()
}
</script>
{#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
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg">
<h2 class="text-base-content text-start text-2xl font-bold">Create New Folder</h2>
<div class="divider my-2"></div>
<input
type="text"
class="input input-bordered w-full"
placeholder="Folder name"
bind:value={folderName} />
<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()}>
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
</button>
<button class="btn btn-primary inline-flex items-center" onclick={handleCreate}>
<Check class="mr-2 h-5 w-5" /><span>Create</span>
</button>
</div>
</div>
</div>
{/if}
@@ -1,5 +1,13 @@
<script lang="ts">
import SystemMetrics from './SystemMetrics.svelte';
import { goto } from '$app/navigation';
import { useFeatureFlags } from '$lib/stores/featureFlags';
const features = useFeatureFlags();
if (!$features.analytics) {
goto('/');
}
</script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
+108 -104
View File
@@ -12,16 +12,16 @@
Chart.register(...registerables);
let cpuChartElement: HTMLCanvasElement;
let cpuChartElement: HTMLCanvasElement = $state();
let cpuChart: Chart;
let heapChartElement: HTMLCanvasElement;
let heapChartElement: HTMLCanvasElement = $state();
let heapChart: Chart;
let filesystemChartElement: HTMLCanvasElement;
let filesystemChartElement: HTMLCanvasElement = $state();
let filesystemChart: Chart;
let temperatureChartElement: HTMLCanvasElement;
let temperatureChartElement: HTMLCanvasElement = $state();
let temperatureChart: Chart;
onMount(() => {
@@ -32,79 +32,79 @@
datasets: [
{
label: 'Cpu usage core 0',
borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50),
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $analytics.cpu0_usage,
yAxisID: 'y',
yAxisID: 'y'
},
{
label: 'Cpu usage core 1',
borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50),
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $analytics.cpu1_usage,
yAxisID: 'y',
yAxisID: 'y'
},
{
label: 'Cpu usage total',
borderColor: daisyColor('--color-secondary'),
backgroundColor: daisyColor('--color-secondary', 50),
borderColor: daisyColor('--s'),
backgroundColor: daisyColor('--s', 50),
borderWidth: 2,
data: $analytics.cpu_usage,
yAxisID: 'y',
yAxisID: 'y'
},
],
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true,
display: true
},
tooltip: {
mode: 'index',
intersect: false,
},
intersect: false
}
},
elements: {
point: {
radius: 0,
},
radius: 0
}
},
scales: {
x: {
grid: {
color: daisyColor('--color-base-content', 10),
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--color-base-content'),
color: daisyColor('--bc')
},
display: false,
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: 'Cpu usage [%]',
color: daisyColor('--color-base-content'),
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold',
},
weight: 'bold'
}
},
position: 'left',
min: 0,
max: 100,
grid: { color: daisyColor('--color-base-content', 10) },
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--color-base-content'),
},
border: { color: daisyColor('--color-base-content', 10) },
},
},
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
heapChart = new Chart(heapChartElement, {
type: 'line',
@@ -113,64 +113,64 @@
datasets: [
{
label: 'Used Heap',
borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50),
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $analytics.used_heap,
fill: true,
yAxisID: 'y',
},
],
fill:true,
yAxisID: 'y'
}
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true,
display: true
},
tooltip: {
mode: 'index',
intersect: false,
},
intersect: false
}
},
elements: {
point: {
radius: 0,
},
radius: 0
}
},
scales: {
x: {
grid: {
color: daisyColor('--color-base-content', 10),
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--color-base-content'),
color: daisyColor('--bc')
},
display: false,
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: 'Heap [kb]',
color: daisyColor('--color-base-content'),
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold',
},
weight: 'bold'
}
},
position: 'left',
min: 0,
max: Math.round($analytics.total_heap[0]),
grid: { color: daisyColor('--color-base-content', 10) },
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--color-base-content'),
},
border: { color: daisyColor('--color-base-content', 10) },
},
},
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
filesystemChart = new Chart(filesystemChartElement, {
type: 'line',
@@ -179,64 +179,64 @@
datasets: [
{
label: 'File System Used',
borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50),
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $analytics.fs_used,
fill: true,
yAxisID: 'y',
},
],
fill:true,
yAxisID: 'y'
}
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true,
display: true
},
tooltip: {
mode: 'index',
intersect: false,
},
intersect: false
}
},
elements: {
point: {
radius: 0,
},
radius: 0
}
},
scales: {
x: {
grid: {
color: daisyColor('--color-base-content', 10),
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--color-base-content'),
color: daisyColor('--bc')
},
display: false,
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: 'File System [kb]',
color: daisyColor('--color-base-content'),
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold',
},
weight: 'bold'
}
},
position: 'left',
min: 0,
max: Math.round($analytics.fs_total[0]),
grid: { color: daisyColor('--color-base-content', 10) },
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--color-base-content'),
},
border: { color: daisyColor('--color-base-content', 10) },
},
},
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
temperatureChart = new Chart(temperatureChartElement, {
type: 'line',
@@ -245,63 +245,63 @@
datasets: [
{
label: 'Core Temperature',
borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50),
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $analytics.core_temp,
yAxisID: 'y',
},
],
yAxisID: 'y'
}
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true,
display: true
},
tooltip: {
mode: 'index',
intersect: false,
},
intersect: false
}
},
elements: {
point: {
radius: 0,
},
radius: 0
}
},
scales: {
x: {
grid: {
color: daisyColor('--color-base-content', 10),
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--color-base-content'),
color: daisyColor('--bc')
},
display: false,
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: 'Core Temperature [°C]',
color: daisyColor('--color-base-content'),
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold',
},
weight: 'bold'
}
},
position: 'left',
suggestedMin: 20,
suggestedMax: 100,
grid: { color: daisyColor('--color-base-content', 10) },
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--color-base-content'),
},
border: { color: daisyColor('--color-base-content', 10) },
},
},
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
setInterval(updateData, 500);
});
@@ -334,13 +334,14 @@
<Metrics class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet}
{#snippet title()}
<span>System Metrics</span>
<span >System Metrics</span>
{/snippet}
<div class="w-full overflow-x-auto">
<div
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={cpuChartElement}></canvas>
</div>
</div>
@@ -348,21 +349,24 @@
<div class="w-full overflow-x-auto">
<div
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={heapChartElement}></canvas>
</div>
</div>
<div class="w-full overflow-x-auto">
<div
class="flex w-full flex-col space-y-1 h-52"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<canvas bind:this={filesystemChartElement}></canvas>
</div>
</div>
<div class="w-full overflow-x-auto">
<div
class="flex w-full flex-col space-y-1 h-52"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<canvas bind:this={temperatureChartElement}></canvas>
</div>
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
const { icon, title, description } = $props()
const Icon = $derived(icon)
</script>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Icon class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">{title}</div>
<div class="text-sm opacity-75">{description}</div>
</div>
</div>
@@ -6,10 +6,12 @@
import Spinner from '$lib/components/Spinner.svelte'
import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { type SystemInformation, type Analytics, MessageTopic } from '$lib/types/models'
import type { SystemInformation, Analytics } from '$lib/types/models'
import { socket } from '$lib/stores/socket'
import { api } from '$lib/api'
import { convertSeconds } from '$lib/utilities'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import {
Cancel,
@@ -29,12 +31,12 @@
Temperature,
Stopwatch
} from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte'
import StatusItem from './StatusItem.svelte'
import ActionButton from './ActionButton.svelte'
const features = useFeatureFlags()
let systemInformation: SystemInformation | null = $state(null)
let systemInformation: SystemInformation = $state()
async function getSystemStatus() {
const result = await api.get<SystemInformation>('/api/system/status')
@@ -50,17 +52,12 @@
const postSleep = async () => await api.post('api/sleep')
onMount(() => socket.on(MessageTopic.analytics, handleSystemData))
onMount(() => socket.on('analytics', handleSystemData))
onDestroy(() => socket.off(MessageTopic.analytics, handleSystemData))
const handleSystemData = (data: Analytics) => {
if (systemInformation) {
systemInformation = {
...systemInformation,
...(data as unknown as SystemInformation)
}
}
}
onDestroy(() => socket.off('analytics', handleSystemData))
const handleSystemData = (data: Analytics) =>
(systemInformation = { ...systemInformation, ...data })
const postRestart = async () => await api.post('/api/system/restart')
@@ -149,8 +146,7 @@
<div class="w-full overflow-x-auto">
{#await getSystemStatus()}
<Spinner />
{:then}
{#if systemInformation}
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
@@ -207,10 +203,9 @@
<StatusItem
icon={Folder}
title="File System (Used / Total)"
description={`${(
(systemInformation.fs_used / systemInformation.fs_total) *
100
).toFixed(1)} % of ${systemInformation.fs_total / 1000000} MB used (${
description={`${((systemInformation.fs_used / systemInformation.fs_total) * 100).toFixed(
1
)} % of ${systemInformation.fs_total / 1000000} MB used (${
(systemInformation.fs_total - systemInformation.fs_used) / 1000000
}
MB free)`} />
@@ -234,7 +229,6 @@
title="Reset Reason"
description={systemInformation.cpu_reset_reason} />
</div>
{/if}
{/await}
</div>
@@ -242,7 +236,7 @@
{#each actionButtons as button}
{#if button.condition === undefined || button.condition()}
<ActionButton
onclick={button.onClick}
on:click={button.onClick}
icon={button.icon}
label={button.label}
type={button.type || 'primary'} />
@@ -19,10 +19,10 @@
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'X-GitHub-Api-Version': '2022-11-28'
};
const result = await api.get(`https://api.github.com/repos/${page.data.github}/releases`, {
headers,
headers
});
if (result.isErr()) {
console.error('Error:', result.inner);
@@ -58,7 +58,7 @@
message:
'No matching firmware was found for the current device. Upload the firmware manually or build from sources.',
dismiss: { label: 'OK', icon: Check },
onDismiss: () => modals.close(),
onDismiss: () => modals.close()
});
return;
}
@@ -68,14 +68,14 @@
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 },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url);
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll(),
onConfirm: () => modals.closeAll()
});
},
}
});
}
</script>
@@ -91,7 +91,10 @@
<Spinner />
{:then githubReleases}
<div class="relative w-full overflow-visible">
<div class="overflow-x-auto" transition:slide|local={{ duration: 300, easing: cubicOut }}>
<div
class="overflow-x-auto"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<table class="table w-full table-auto">
<thead>
<tr class="font-bold">
@@ -105,21 +108,26 @@
{#each githubReleases as release}
<tr
class={(
compareVersions($features.firmware_version as string, release.tag_name) === 0
compareVersions(
$features.firmware_version,
release.tag_name
) === 0
) ?
'bg-primary text-primary-content'
: 'bg-base-100 h-14'}>
: 'bg-base-100 h-14'}
>
<td align="left" class="text-base font-semibold">
<a
href={release.html_url}
class="link link-hover"
target="_blank"
rel="noopener noreferrer">{release.name}</a
></td>
></td
>
<td align="center" class="hidden min-h-full align-middle sm:block">
<div class="my-2">
{new Intl.DateTimeFormat('en-GB', {
dateStyle: 'medium',
dateStyle: 'medium'
}).format(new Date(release.published_at))}
</div>
</td>
@@ -129,12 +137,13 @@
{/if}
</td>
<td align="center">
{#if compareVersions($features.firmware_version as string, release.tag_name) != 0}
{#if compareVersions($features.firmware_version, release.tag_name) != 0}
<button
class="btn btn-ghost btn-circle btn-sm"
onclick={() => {
confirmGithubUpdate(release.assets);
}}>
}}
>
<CloudDown class="text-secondary h-6 w-6" />
</button>
{/if}
@@ -148,7 +157,9 @@
{:catch error}
<div class="alert alert-error shadow-lg">
<Error class="h-6 w-6 shrink-0" />
<span>Please connect to a network with internet access to perform a firmware update.</span>
<span
>Please connect to a network with internet access to perform a firmware update.</span
>
</div>
{/await}
</SettingsCard>
@@ -6,11 +6,11 @@
import { api } from '$lib/api';
import { Cancel, OTA, Warning } from '$lib/components/icons';
let files: FileList | undefined = $state();
let files: FileList = $state();
async function uploadBIN() {
const formData = new FormData();
formData.append('file', files![0]);
formData.append('file', files[0]);
const result = await api.post('/api/firmware', formData);
if (result.isErr()) console.error('Error:', result.inner);
}
@@ -21,12 +21,12 @@
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Upload', icon: OTA },
confirm: { label: 'Upload', icon: OTA }
},
onConfirm: () => {
modals.close();
uploadBIN();
},
}
});
}
</script>
@@ -41,8 +41,8 @@
<div class="alert alert-warning shadow-lg">
<Warning class="h-6 w-6 shrink-0" />
<span
>Uploading a new firmware (.bin) file will replace the existing firmware. You may upload a
(.md5) file first to verify the uploaded firmware.
>Uploading a new firmware (.bin) file will replace the existing firmware. You may upload
a (.md5) file first to verify the uploaded firmware.
</span>
</div>
@@ -52,5 +52,6 @@
class="file-input file-input-bordered file-input-secondary mt-4 w-full"
bind:files
accept=".bin,.md5"
onchange={confirmBinUpload} />
onchange={confirmBinUpload}
/>
</SettingsCard>
+130 -59
View File
@@ -10,11 +10,13 @@
import Spinner from '$lib/components/Spinner.svelte';
import type { ApSettings, ApStatus } from '$lib/types/models';
import { api } from '$lib/api';
import { useFeatureFlags } from '$lib/stores';
import { AP, Devices, Home, MAC } from '$lib/components/icons';
import StatusItem from '$lib/components/StatusItem.svelte';
let apSettings: ApSettings | null = $state(null);
let apStatus: ApStatus | null = $state(null);
const features = useFeatureFlags();
let apSettings: ApSettings = $state();
let apStatus: ApStatus = $state();
let formField: any = $state();
@@ -49,23 +51,23 @@
let provisionMode = [
{
id: 0,
text: `Always`,
text: `Always`
},
{
id: 1,
text: `When WiFi Disconnected`,
text: `When WiFi Disconnected`
},
{
id: 2,
text: `Never`,
},
text: `Never`
}
];
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning';
let apStatusVariant: Variant[] = ['success', 'error', 'warning'];
let apStatusDescription = ['Active', 'Inactive', 'Lingering'];
let apStatusDescription = [
{ bg_color: 'bg-success', text_color: 'text-success-content', description: 'Active' },
{ bg_color: 'bg-error', text_color: 'text-error-content', description: 'Inactive' },
{ bg_color: 'bg-warning', text_color: 'text-warning-content', description: 'Lingering' }
];
let formErrors = $state({
ssid: false,
@@ -73,7 +75,7 @@
max_clients: false,
local_ip: false,
gateway_ip: false,
subnet_mask: false,
subnet_mask: false
});
async function postAPSettings(data: ApSettings) {
@@ -88,7 +90,6 @@
}
function handleSubmitAP() {
if (!apSettings) return;
let valid = true;
// Validate SSID
@@ -157,49 +158,92 @@
<AP class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet}
{#snippet title()}
<span>Access Point</span>
<span >Access Point</span>
{/snippet}
<div class="w-full overflow-x-auto">
{#await getAPStatus()}
<Spinner />
{:then}
{#if apStatus}
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<StatusItem
icon={AP}
title="Status"
variant={apStatusVariant[apStatus.status]}
description={apStatusDescription[apStatus.status]} />
<StatusItem icon={Home} title="IP Address" description={apStatus.ip_address} />
<StatusItem icon={MAC} title="MAC Address" description={apStatus.mac_address} />
<StatusItem icon={Devices} title="AP Clients" description={apStatus.station_num} />
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div
class="mask mask-hexagon h-auto w-10 {apStatusDescription[apStatus.status]
.bg_color}"
>
<AP
class="h-auto w-full scale-75 {apStatusDescription[apStatus.status]
.text_color}"
/>
</div>
<div>
<div class="font-bold">Status</div>
<div class="text-sm opacity-75">
{apStatusDescription[apStatus.status].description}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Home class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">IP Address</div>
<div class="text-sm opacity-75">
{apStatus.ip_address}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<MAC class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">MAC Address</div>
<div class="text-sm opacity-75">
{apStatus.mac_address}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Devices class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">AP Clients</div>
<div class="text-sm opacity-75">
{apStatus.station_num}
</div>
</div>
</div>
</div>
{/if}
{/await}
</div>
<div class="bg-base-200 relative grid w-full max-w-2xl self-center overflow-hidden">
<div
class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium">
class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium"
>
Change AP Settings
</div>
{#await getAPSettings()}
<Spinner />
{:then}
{#if apSettings}
{:then nothing}
<div
class="flex flex-col gap-2 p-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<form
class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2"
onsubmit={preventDefault(handleSubmitAP)}
novalidate
bind:this={formField}>
bind:this={formField}
>
<div>
<label class="label" for="apmode">
<span class="label-text">Provide Access Point ...</span>
@@ -207,7 +251,8 @@
<select
class="select select-bordered w-full"
id="apmode"
bind:value={apSettings.provision_mode}>
bind:value={apSettings.provision_mode}
>
{#each provisionMode as mode}
<option value={mode.id}>
{mode.text}
@@ -230,10 +275,13 @@
id="ssid"
min="2"
max="32"
required />
required
/>
<label class="label" for="ssid">
<span class="label-text-alt text-error {formErrors.ssid ? '' : 'hidden'}"
>SSID must be between 2 and 32 characters long</span>
<span
class="label-text-alt text-error {formErrors.ssid ? '' : 'hidden'}"
>SSID must be between 2 and 32 characters long</span
>
</label>
</div>
@@ -258,10 +306,14 @@
: ''}"
bind:value={apSettings.channel}
id="channel"
required />
required
/>
<label class="label" for="channel">
<span class="label-text-alt text-error {formErrors.channel ? '' : 'hidden'}"
>Must be channel 1 to 13</span>
<span
class="label-text-alt text-error {formErrors.channel ? '' : (
'hidden'
)}">Must be channel 1 to 13</span
>
</label>
</div>
@@ -280,10 +332,14 @@
: ''}"
bind:value={apSettings.max_clients}
id="clients"
required />
required
/>
<label class="label" for="clients">
<span class="label-text-alt text-error {formErrors.max_clients ? '' : 'hidden'}"
>Maximum 8 clients allowed</span>
<span
class="label-text-alt text-error {formErrors.max_clients ? '' : (
'hidden'
)}">Maximum 8 clients allowed</span
>
</label>
</div>
@@ -293,17 +349,22 @@
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.local_ip ? 'border-error border-2'
class="input input-bordered w-full {formErrors.local_ip ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={apSettings.local_ip}
id="localIP"
required />
required
/>
<label class="label" for="localIP">
<span class="label-text-alt text-error {formErrors.local_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
<span
class="label-text-alt text-error {formErrors.local_ip ? '' : (
'hidden'
)}">Must be a valid IPv4 address</span
>
</label>
</div>
@@ -313,17 +374,22 @@
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.gateway_ip ? 'border-error border-2'
class="input input-bordered w-full {formErrors.gateway_ip ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={apSettings.gateway_ip}
id="gateway"
required />
required
/>
<label class="label" for="gateway">
<span class="label-text-alt text-error {formErrors.gateway_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
<span
class="label-text-alt text-error {formErrors.gateway_ip ? '' : (
'hidden'
)}">Must be a valid IPv4 address</span
>
</label>
</div>
<div>
@@ -332,17 +398,22 @@
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.subnet_mask ? 'border-error border-2'
class="input input-bordered w-full {formErrors.subnet_mask ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={apSettings.subnet_mask}
id="subnet"
required />
required
/>
<label class="label" for="subnet">
<span class="label-text-alt text-error {formErrors.subnet_mask ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
<span
class="label-text-alt text-error {formErrors.subnet_mask ? '' : (
'hidden'
)}">Must be a valid IPv4 address</span
>
</label>
</div>
@@ -350,7 +421,8 @@
<input
type="checkbox"
bind:checked={apSettings.ssid_hidden}
class="checkbox checkbox-primary" />
class="checkbox checkbox-primary"
/>
<span class="">Hide SSID</span>
</label>
@@ -359,7 +431,6 @@
</div>
</form>
</div>
{/if}
{/await}
</div>
</SettingsCard>
-7
View File
@@ -1,7 +0,0 @@
<script lang="ts">
import MDNS from './MDNS.svelte'
</script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
<MDNS />
</div>
-100
View File
@@ -1,100 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api } from '$lib/api'
import SettingsCard from '$lib/components/SettingsCard.svelte'
import { AP, Home, MAC, Devices } from '$lib/components/icons'
import Spinner from '$lib/components/Spinner.svelte'
import StatusItem from '$lib/components/StatusItem.svelte'
import { cubicOut } from 'svelte/easing'
import { slide } from 'svelte/transition'
import type { MDNSStatus, MDNSServiceItem, MDNSServiceQuery } from '$lib/types/models'
import { compareIp } from '$lib/utilities'
let mdnsStatus: MDNSStatus | undefined = $state()
let services: MDNSServiceItem[] = $state([])
let isLoading = $state(false)
const getMDNSStatus = async () => {
const result = await api.get<MDNSStatus>('/api/mdns/status')
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
mdnsStatus = result.inner
}
const queryMDNSServices = async () => {
isLoading = true
const result = await api.post<MDNSServiceQuery>('/api/mdns/query', {
service: 'http',
protocol: 'tcp'
})
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
services = result.inner.services.sort((a, b) => compareIp(a.ip, b.ip))
isLoading = false
}
onMount(async () => {
await getMDNSStatus()
await queryMDNSServices()
})
const triggerScan = async () => {
await queryMDNSServices()
}
</script>
<SettingsCard collapsible={false}>
{#snippet icon()}
<AP class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet}
{#snippet title()}
<span>MDNS</span>
{/snippet}
{#snippet right()}
<button class="btn btn-primary" onclick={triggerScan} disabled={isLoading}>
{#if isLoading}
<span class="loading loading-ring loading-xs"></span>
{:else}
Scan
{/if}
</button>
{/snippet}
<div class="w-full overflow-x-auto">
{#if mdnsStatus}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<StatusItem icon={Home} title="IP Address" description={mdnsStatus.hostname} />
<StatusItem icon={MAC} title="Instance" description={mdnsStatus.instance} />
<StatusItem icon={Devices} title="Services" description={mdnsStatus.services.length} />
<table class="table">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Ip address</th>
<th>Port</th>
</tr>
</thead>
<tbody>
{#each services as service}
<tr>
<td><Devices class="h-6 w-6" /></td>
<td>{service.name}</td>
<td>{service.ip}</td>
<td>{service.port}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</SettingsCard>
+49 -33
View File
@@ -6,9 +6,15 @@
import type { NetworkItem, NetworkList } from '$lib/types/models';
import { api } from '$lib/api';
import { AP, Network, Reload, Cancel } from '$lib/components/icons';
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals';
import { modals, exitBeforeEnter } from 'svelte-modals';
let { isOpen, storeNetwork }: ModalProps = $props();
// provided by <Modals />
interface Props {
isOpen: boolean;
storeNetwork: any;
}
let { isOpen, storeNetwork }: Props = $props();
const encryptionType = [
'Open',
@@ -20,49 +26,49 @@
'WPA3 PSK',
'WPA2 WPA3 PSK',
'WAPI PSK'
]
];
let listOfNetworks: NetworkItem[] = $state([])
let listOfNetworks: NetworkItem[] = $state([]);
let scanActive = $state(false)
let scanActive = $state(false);
let pollingId: ReturnType<typeof setTimeout> | number
let pollingId: number;
async function scanNetworks() {
scanActive = true
await api.get('/api/wifi/scan')
scanActive = true;
await api.get('/api/wifi/scan');
if ((await pollingResults()) == false) {
pollingId = setInterval(() => pollingResults(), 1000)
pollingId = setInterval(() => pollingResults(), 1000);
}
return
return;
}
async function pollingResults() {
const result = await api.get<NetworkList>('/api/wifi/networks')
const result = await api.get<NetworkList>('/api/wifi/networks');
if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner)
return false
console.error(`Error occurred while fetching: `, result.inner);
return false;
}
let response = result.inner
listOfNetworks = response.networks
scanActive = false
let response = result.inner;
listOfNetworks = response.networks;
scanActive = false;
if (listOfNetworks.length) {
clearInterval(pollingId)
pollingId = 0
clearInterval(pollingId);
pollingId = 0;
}
return listOfNetworks.length
return listOfNetworks.length;
}
onMount(() => {
scanNetworks()
})
scanNetworks();
});
onDestroy(() => {
if (pollingId) {
clearInterval(pollingId)
pollingId = 0
clearInterval(pollingId);
pollingId = 0;
}
})
});
</script>
{#if isOpen}
@@ -71,13 +77,17 @@
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
use:focusTrap
>
<div
class="bg-base-100 rounded-box pointer-events-auto flex max-h-full min-w-fit max-w-md flex-col justify-between p-4 shadow-lg">
class="bg-base-100 rounded-box pointer-events-auto flex max-h-full min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
>
<h2 class="text-base-content text-start text-2xl font-bold">Scan Networks</h2>
<div class="divider my-2"></div>
<div class="overflow-y-auto">
{#if scanActive}<div class="bg-base-100 flex flex-col items-center justify-center p-6">
{#if scanActive}<div
class="bg-base-100 flex flex-col items-center justify-center p-6"
>
<AP class="text-secondary h-32 w-32 shrink animate-ping stroke-2" />
<p class="mt-8 text-2xl">Scanning ...</p>
</div>
@@ -89,17 +99,21 @@
<div
class="bg-base-200 rounded-btn my-1 flex items-center space-x-3 hover:scale-[1.02] active:scale-[0.98]"
onclick={() => {
storeNetwork(network.ssid)
storeNetwork(network.ssid);
}}
role="button"
tabindex="0">
tabindex="0"
>
<div class="mask mask-hexagon bg-primary h-auto w-10 shrink-0">
<Network class="text-primary-content h-auto w-full scale-75" />
<Network
class="text-primary-content h-auto w-full scale-75"
/>
</div>
<div>
<div class="font-bold">{network.ssid}</div>
<div class="text-sm opacity-75">
Security: {encryptionType[network.encryption_type]}, Channel: {network.channel}
Security: {encryptionType[network.encryption_type]},
Channel: {network.channel}
</div>
</div>
<div class="grow"></div>
@@ -115,14 +129,16 @@
<button
class="btn btn-primary inline-flex flex-none items-center"
disabled={scanActive}
onclick={scanNetworks}>
onclick={scanNetworks}
>
<Reload class="mr-2 h-5 w-5" /><span>Scan again</span>
</button>
<div class="grow"></div>
<button
class="btn btn-warning text-warning-content inline-flex flex-none items-center"
onclick={() => modals.close()}>
onclick={() => modals.close()}
>
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
</button>
</div>
+341 -192
View File
@@ -1,24 +1,19 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { modals } from 'svelte-modals'
import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { notifications } from '$lib/components/toasts/notifications'
import DragDropList, { VerticalDropZone, reorder, type DropEvent } from 'svelte-dnd-list'
import SettingsCard from '$lib/components/SettingsCard.svelte'
import { PasswordInput } from '$lib/components/input'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import ScanNetworks from './Scan.svelte'
import Spinner from '$lib/components/Spinner.svelte'
import InfoDialog from '$lib/components/InfoDialog.svelte'
import {
MessageTopic,
type KnownNetworkItem,
type WifiSettings,
type WifiStatus
} from '$lib/types/models'
import { socket } from '$lib/stores'
import { api } from '$lib/api'
import { onMount, onDestroy } from 'svelte';
import { modals } from 'svelte-modals';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { notifications } from '$lib/components/toasts/notifications';
import DragDropList, { VerticalDropZone, reorder, type DropEvent } from 'svelte-dnd-list';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import { PasswordInput } from '$lib/components/input';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ScanNetworks from './Scan.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import InfoDialog from '$lib/components/InfoDialog.svelte';
import type { KnownNetworkItem, WifiSettings, WifiStatus } from '$lib/types/models';
import { socket, useFeatureFlags } from '$lib/stores';
import { api } from '$lib/api';
import {
Cancel,
Delete,
@@ -37,8 +32,9 @@
Add,
Scan,
Edit
} from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte'
} from '$lib/components/icons';
const features = useFeatureFlags();
let networkEditable: KnownNetworkItem = $state({
ssid: '',
@@ -49,21 +45,21 @@
gateway_ip: undefined,
dns_ip_1: undefined,
dns_ip_2: undefined
})
});
let static_ip_config = $state(false)
let static_ip_config = $state(false);
let newNetwork: boolean = $state(true)
let showNetworkEditor: boolean = $state(false)
let newNetwork: boolean = $state(true);
let showNetworkEditor: boolean = $state(false);
let wifiStatus: WifiStatus | null = $state(null)
let wifiSettings: WifiSettings | null = $state(null)
let wifiStatus: WifiStatus = $state();
let wifiSettings: WifiSettings = $state();
let dndNetworkList: KnownNetworkItem[] = $state([])
let dndNetworkList: KnownNetworkItem[] = $state([]);
let showWifiDetails = $state(false)
let showWifiDetails = $state(false);
let formField: any = $state()
let formField: any = $state();
let formErrors = $state({
ssid: false,
@@ -72,156 +68,155 @@
subnet_mask: false,
dns_1: false,
dns_2: false
})
});
let formErrorhostname = $state(false)
let formErrorhostname = $state(false);
async function getWifiStatus() {
const result = await api.get<WifiStatus>('/api/wifi/sta/status')
const result = await api.get<WifiStatus>('/api/wifi/sta/status');
if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner)
return
console.error(`Error occurred while fetching: `, result.inner);
return;
}
wifiStatus = result.inner
return wifiStatus
wifiStatus = result.inner;
return wifiStatus;
}
async function getWifiSettings() {
const result = await api.get<WifiSettings>('/api/wifi/sta/settings')
const result = await api.get<WifiSettings>('/api/wifi/sta/settings');
if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner)
return
console.error(`Error occurred while fetching: `, result.inner);
return;
}
wifiSettings = result.inner
dndNetworkList = wifiSettings.wifi_networks
return wifiSettings
wifiSettings = result.inner;
dndNetworkList = wifiSettings.wifi_networks;
return wifiSettings;
}
onDestroy(() => socket.off(MessageTopic.WiFiSettings))
onDestroy(() => socket.off('WiFiSettings'));
onMount(() => {
socket.on<WifiSettings>(MessageTopic.WiFiSettings, data => {
wifiSettings = data
dndNetworkList = wifiSettings.wifi_networks
})
})
socket.on<WifiSettings>('WiFiSettings', data => {
wifiSettings = data;
dndNetworkList = wifiSettings.wifi_networks;
});
});
async function postWiFiSettings(data: WifiSettings) {
const result = await api.post<WifiSettings>('/api/wifi/sta/settings', data)
const result = await api.post<WifiSettings>('/api/wifi/sta/settings', data);
if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner)
notifications.error('User not authorized.', 3000)
return
console.error(`Error occurred while fetching: `, result.inner);
notifications.error('User not authorized.', 3000);
return;
}
wifiSettings = result.inner
notifications.success('Wi-Fi settings updated.', 3000)
wifiSettings = result.inner;
notifications.success('Wi-Fi settings updated.', 3000);
}
function validateHostName() {
if (!wifiSettings) return false
if (wifiSettings.hostname.length < 3 || wifiSettings.hostname.length > 32) {
formErrorhostname = true
formErrorhostname = true;
} else {
formErrorhostname = false
formErrorhostname = false;
// Update global wifiSettings object
wifiSettings.wifi_networks = dndNetworkList
wifiSettings.wifi_networks = dndNetworkList;
// Post to REST API
postWiFiSettings(wifiSettings)
console.log(wifiSettings)
postWiFiSettings(wifiSettings);
console.log(wifiSettings);
}
}
function validateWiFiForm(event: SubmitEvent) {
event.preventDefault()
let valid = true
event.preventDefault();
let valid = true;
// Validate SSID
if (networkEditable.ssid.length < 3 || networkEditable.ssid.length > 32) {
valid = false
formErrors.ssid = true
valid = false;
formErrors.ssid = true;
} else {
formErrors.ssid = false
formErrors.ssid = false;
}
networkEditable.static_ip_config = static_ip_config
networkEditable.static_ip_config = static_ip_config;
if (networkEditable.static_ip_config) {
// RegEx for IPv4
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/;
// Validate gateway IP
if (!regexExp.test(networkEditable.gateway_ip!)) {
valid = false
formErrors.gateway_ip = true
valid = false;
formErrors.gateway_ip = true;
} else {
formErrors.gateway_ip = false
formErrors.gateway_ip = false;
}
// Validate Subnet Mask
if (!regexExp.test(networkEditable.subnet_mask!)) {
valid = false
formErrors.subnet_mask = true
valid = false;
formErrors.subnet_mask = true;
} else {
formErrors.subnet_mask = false
formErrors.subnet_mask = false;
}
// Validate local IP
if (!regexExp.test(networkEditable.local_ip!)) {
valid = false
formErrors.local_ip = true
valid = false;
formErrors.local_ip = true;
} else {
formErrors.local_ip = false
formErrors.local_ip = false;
}
// Validate DNS 1
if (!regexExp.test(networkEditable.dns_ip_1!)) {
valid = false
formErrors.dns_1 = true
valid = false;
formErrors.dns_1 = true;
} else {
formErrors.dns_1 = false
formErrors.dns_1 = false;
}
// Validate DNS 2
if (!regexExp.test(networkEditable.dns_ip_2!)) {
valid = false
formErrors.dns_2 = true
valid = false;
formErrors.dns_2 = true;
} else {
formErrors.dns_2 = false
formErrors.dns_2 = false;
}
} else {
formErrors.local_ip = false
formErrors.subnet_mask = false
formErrors.gateway_ip = false
formErrors.dns_1 = false
formErrors.dns_2 = false
formErrors.local_ip = false;
formErrors.subnet_mask = false;
formErrors.gateway_ip = false;
formErrors.dns_1 = false;
formErrors.dns_2 = false;
}
// Submit JSON to REST API
if (valid) {
if (newNetwork) {
dndNetworkList.push(networkEditable)
dndNetworkList.push(networkEditable);
} else {
dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable)
dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable);
}
addNetwork()
dndNetworkList = [...dndNetworkList] //Trigger reactivity
showNetworkEditor = false
addNetwork();
dndNetworkList = [...dndNetworkList]; //Trigger reactivity
showNetworkEditor = false;
}
}
function scanForNetworks() {
modals.open(ScanNetworks, {
storeNetwork: (network: string) => {
addNetwork()
networkEditable.ssid = network
showNetworkEditor = true
modals.close()
addNetwork();
networkEditable.ssid = network;
showNetworkEditor = true;
modals.close();
}
})
});
}
function addNetwork() {
newNetwork = true
newNetwork = true;
networkEditable = {
ssid: '',
password: '',
@@ -231,13 +226,13 @@
gateway_ip: undefined,
dns_ip_1: undefined,
dns_ip_2: undefined
}
};
}
function handleEdit(index: number) {
newNetwork = false
showNetworkEditor = true
networkEditable = dndNetworkList[index]
newNetwork = false;
showNetworkEditor = true;
networkEditable = dndNetworkList[index];
}
function confirmDelete(index: number) {
@@ -251,15 +246,15 @@
onConfirm: () => {
// Check if network is currently been edited and delete as well
if (dndNetworkList[index].ssid === networkEditable.ssid) {
addNetwork()
addNetwork();
}
// Remove network from array
dndNetworkList.splice(index, 1)
dndNetworkList = [...dndNetworkList] //Trigger reactivity
showNetworkEditor = false
modals.close()
dndNetworkList.splice(index, 1);
dndNetworkList = [...dndNetworkList]; //Trigger reactivity
showNetworkEditor = false;
modals.close();
}
})
});
}
function checkNetworkList() {
@@ -270,20 +265,20 @@
'You have reached the maximum number of networks. Please delete one to add another.',
dismiss: { label: 'OK', icon: Check },
onDismiss: () => modals.close()
})
return false
});
return false;
} else {
return true
return true;
}
}
function onDrop({ detail: { from, to } }: CustomEvent<DropEvent>) {
if (!to || from === to) {
return
return;
}
dndNetworkList = reorder(dndNetworkList, from.index, to.index)
console.log(dndNetworkList)
dndNetworkList = reorder(dndNetworkList, from.index, to.index);
console.log(dndNetworkList);
}
</script>
@@ -297,36 +292,81 @@
<div class="w-full overflow-x-auto">
{#await getWifiStatus()}
<Spinner />
{:then}
{#if wifiStatus}
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<StatusItem
icon={AP}
title="Status"
variant={wifiStatus.status === 3 ? 'success' : 'error'}
description={wifiStatus.status === 3 ? 'Connected' : 'Inactive'} />
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div
class="mask mask-hexagon h-auto w-10 {wifiStatus.status === 3 ?
'bg-success'
: 'bg-error'}"
>
<AP
class="h-auto w-full scale-75 {wifiStatus.status === 3 ?
'text-success-content'
: 'text-error-content'}"
/>
</div>
<div>
<div class="font-bold">Status</div>
<div class="text-sm opacity-75">
{wifiStatus.status === 3 ? 'Connected' : 'Inactive'}
</div>
</div>
</div>
{#if wifiStatus.status === 3}
<StatusItem icon={SSID} title="SSID" description={wifiStatus.ssid} />
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<SSID class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">SSID</div>
<div class="text-sm opacity-75">
{wifiStatus.ssid}
</div>
</div>
</div>
<StatusItem icon={Home} title="IP Address" description={wifiStatus.local_ip} />
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Home class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">IP Address</div>
<div class="text-sm opacity-75">
{wifiStatus.local_ip}
</div>
</div>
</div>
<StatusItem icon={WiFi} title="RSSI" description={`${wifiStatus.rssi} dBm`}>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<WiFi class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">RSSI</div>
<div class="text-sm opacity-75">
{wifiStatus.rssi} dBm
</div>
</div>
<div class="grow"></div>
<button
class="btn btn-circle btn-ghost btn-sm modal-button"
onclick={() => {
showWifiDetails = !showWifiDetails
}}>
showWifiDetails = !showWifiDetails;
}}
>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {(
showWifiDetails
) ?
'rotate-180'
: ''}" />
: ''}"
/>
</button>
</StatusItem>
</div>
{/if}
</div>
@@ -334,78 +374,147 @@
{#if showWifiDetails}
<div
class="flex w-full flex-col space-y-1 pt-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<StatusItem icon={MAC} title="MAC Address" description={wifiStatus.mac_address} />
<StatusItem icon={Channel} title="Channel" description={wifiStatus.channel} />
<StatusItem icon={Gateway} title="Gateway IP" description={wifiStatus.gateway_ip} />
<StatusItem icon={Subnet} title="Subnet Mask" description={wifiStatus.subnet_mask} />
<StatusItem icon={DNS} title="DNS" description={wifiStatus.dns_ip_1} />
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<MAC class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">MAC Address</div>
<div class="text-sm opacity-75">
{wifiStatus.mac_address}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Channel class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Channel</div>
<div class="text-sm opacity-75">
{wifiStatus.channel}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Gateway class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Gateway IP</div>
<div class="text-sm opacity-75">
{wifiStatus.gateway_ip}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Subnet class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Subnet Mask</div>
<div class="text-sm opacity-75">
{wifiStatus.subnet_mask}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<DNS class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">DNS</div>
<div class="text-sm opacity-75">
{wifiStatus.dns_ip_1}
</div>
</div>
</div>
</div>
{/if}
{/if}
{/await}
</div>
<div class="bg-base-200 relative grid w-full max-w-2xl self-center overflow-hidden">
<div
class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium">
class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium"
>
Saved Networks
</div>
{#await getWifiSettings()}
<Spinner />
{:then}
{#if wifiSettings}
{:then nothing}
<div class="relative w-full overflow-visible">
<button
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-16"
onclick={() => {
if (checkNetworkList()) {
addNetwork()
showNetworkEditor = true
addNetwork();
showNetworkEditor = true;
}
}}>
<Add class="h-6 w-6" /></button>
}}
>
<Add class="h-6 w-6" /></button
>
<button
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-0"
onclick={() => {
if (checkNetworkList()) {
scanForNetworks()
showNetworkEditor = true
scanForNetworks();
showNetworkEditor = true;
}
}}>
<Scan class="h-6 w-6" /></button>
}}
>
<Scan class="h-6 w-6" /></button
>
<div
class="overflow-x-auto space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<DragDropList
id="networks"
type={VerticalDropZone}
itemSize={60}
itemCount={dndNetworkList.length}
on:drop={onDrop}>
{#snippet children({ index }: { index: number })}
<StatusItem icon={Router} title={dndNetworkList[index].ssid}>
on:drop={onDrop}
>
{#snippet children({ index })}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"
>
<div class="mask mask-hexagon bg-primary h-auto w-10 shrink-0">
<Router class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">{dndNetworkList[index].ssid}</div>
</div>
<div class="grow"></div>
<div class="space-x-0 px-0 mx-0">
<button
class="btn btn-ghost btn-sm"
onclick={() => {
handleEdit(index)
}}>
<Edit class="h-6 w-6" /></button>
handleEdit(index);
}}
>
<Edit class="h-6 w-6" /></button
>
<button
class="btn btn-ghost btn-sm"
onclick={() => {
confirmDelete(index)
}}>
confirmDelete(index);
}}
>
<Delete class="text-error h-6 w-6" />
</button>
</div>
</StatusItem>
</div>
{/snippet}
</DragDropList>
</div>
@@ -414,7 +523,8 @@
<div class="divider mb-0"></div>
<div
class="flex flex-col gap-2 p-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<form class="" onsubmit={validateWiFiForm} novalidate bind:this={formField}>
<div class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2">
<div>
@@ -432,17 +542,24 @@
: ''}"
bind:value={wifiSettings.hostname}
id="channel"
required />
required
/>
<label class="label" for="channel">
<span class="label-text-alt text-error {formErrorhostname ? '' : 'hidden'}"
>Host name must be between 2 and 32 characters long</span>
<span
class="label-text-alt text-error {formErrorhostname ? '' : (
'hidden'
)}">Host name must be between 2 and 32 characters long</span
>
</label>
</div>
<label class="label inline-flex cursor-pointer content-end justify-start gap-4">
<label
class="label inline-flex cursor-pointer content-end justify-start gap-4"
>
<input
type="checkbox"
bind:checked={wifiSettings.priority_RSSI}
class="checkbox checkbox-primary sm:-mb-5" />
class="checkbox checkbox-primary sm:-mb-5"
/>
<span class="sm:-mb-5">Connect to strongest WiFi</span>
</label>
</div>
@@ -451,7 +568,8 @@
<div class="divider my-0"></div>
<div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div>
<label class="label" for="ssid">
<span class="label-text text-md">SSID</span>
@@ -467,10 +585,14 @@
id="ssid"
min="2"
max="32"
required />
required
/>
<label class="label" for="ssid">
<span class="label-text-alt text-error {formErrors.ssid ? '' : 'hidden'}"
>SSID must be between 3 and 32 characters long</span>
<span
class="label-text-alt text-error {formErrors.ssid ? '' : (
'hidden'
)}">SSID must be between 3 and 32 characters long</span
>
</label>
</div>
<div>
@@ -480,18 +602,21 @@
<PasswordInput bind:value={networkEditable.password} id="pwd" />
</div>
<label
class="label inline-flex cursor-pointer content-end justify-start gap-4 mt-2 sm:mb-4">
class="label inline-flex cursor-pointer content-end justify-start gap-4 mt-2 sm:mb-4"
>
<input
type="checkbox"
bind:checked={static_ip_config}
class="checkbox checkbox-primary sm:-mb-5" />
class="checkbox checkbox-primary sm:-mb-5"
/>
<span class="sm:-mb-5">Static IP Config?</span>
</label>
</div>
{#if static_ip_config}
<div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div>
<label class="label" for="localIP">
<span class="label-text text-md">Local IP</span>
@@ -506,10 +631,14 @@
size="15"
bind:value={networkEditable.local_ip}
id="localIP"
required />
required
/>
<label class="label" for="localIP">
<span class="label-text-alt text-error {formErrors.local_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
<span
class="label-text-alt text-error {formErrors.local_ip ?
''
: 'hidden'}">Must be a valid IPv4 address</span
>
</label>
</div>
@@ -526,11 +655,16 @@
maxlength="15"
size="15"
bind:value={networkEditable.gateway_ip}
required />
required
/>
<label class="label" for="gateway">
<span
class="label-text-alt text-error {formErrors.gateway_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
class="label-text-alt text-error {(
formErrors.gateway_ip
) ?
''
: 'hidden'}">Must be a valid IPv4 address</span
>
</label>
</div>
<div>
@@ -546,10 +680,16 @@
maxlength="15"
size="15"
bind:value={networkEditable.subnet_mask}
required />
required
/>
<label class="label" for="subnet">
<span
class="label-text-alt text-error {formErrors.subnet_mask ? '' : 'hidden'}">
class="label-text-alt text-error {(
formErrors.subnet_mask
) ?
''
: 'hidden'}"
>
Must be a valid IPv4 address
</span>
</label>
@@ -560,15 +700,20 @@
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_1 ? 'border-error border-2'
class="input input-bordered w-full {formErrors.dns_1 ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_1}
required />
required
/>
<label class="label" for="gateway">
<span class="label-text-alt text-error {formErrors.dns_1 ? '' : 'hidden'}">
<span
class="label-text-alt text-error {formErrors.dns_1 ? ''
: 'hidden'}"
>
Must be a valid IPv4 address
</span>
</label>
@@ -579,15 +724,20 @@
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_2 ? 'border-error border-2'
class="input input-bordered w-full {formErrors.dns_2 ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_2}
required />
required
/>
<label class="label" for="subnet">
<span class="label-text-alt text-error {formErrors.dns_2 ? '' : 'hidden'}">
<span
class="label-text-alt text-error {formErrors.dns_2 ? ''
: 'hidden'}"
>
Must be a valid IPv4 address
</span>
</label>
@@ -607,7 +757,6 @@
</div>
</form>
</div>
{/if}
{/await}
</div>
</SettingsCard>
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
# WaveFront *.mtl file (generated by Autodesk ATF)
newmtl Plastic_-_Matte_(Black)
Kd 0.098039 0.098039 0.098039
File diff suppressed because it is too large Load Diff
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
# WaveFront *.mtl file (generated by Autodesk ATF)
newmtl Steel_-_Satin
Kd 0.627451 0.627451 0.627451
File diff suppressed because it is too large Load Diff
Binary file not shown.
-14
View File
@@ -1,14 +0,0 @@
# WaveFront *.mtl file (generated by Autodesk ATF)
newmtl Opaque(28,28,28)
Kd 0.109804 0.109804 0.109804
newmtl Steel_-_Satin
Kd 0.627451 0.627451 0.627451
newmtl Opaque(0,0,255)
Kd 0.000000 0.000000 1.000000
newmtl Opaque(202,209,238)
Kd 0.792157 0.819608 0.933333
File diff suppressed because it is too large Load Diff
Binary file not shown.
-14
View File
@@ -1,14 +0,0 @@
# WaveFront *.mtl file (generated by Autodesk ATF)
newmtl Opaque(28,28,28)
Kd 0.109804 0.109804 0.109804
newmtl Opaque(0,0,255)
Kd 0.000000 0.000000 1.000000
newmtl Opaque(202,209,238)
Kd 0.792157 0.819608 0.933333
newmtl Steel_-_Satin
Kd 0.627451 0.627451 0.627451
File diff suppressed because it is too large Load Diff
Binary file not shown.
-14
View File
@@ -1,14 +0,0 @@
# WaveFront *.mtl file (generated by Autodesk ATF)
newmtl Opaque(28,28,28)
Kd 0.109804 0.109804 0.109804
newmtl Opaque(0,0,255)
Kd 0.000000 0.000000 1.000000
newmtl Opaque(202,209,238)
Kd 0.792157 0.819608 0.933333
newmtl Steel_-_Satin
Kd 0.627451 0.627451 0.627451
File diff suppressed because it is too large Load Diff
Binary file not shown.
-14
View File
@@ -1,14 +0,0 @@
# WaveFront *.mtl file (generated by Autodesk ATF)
newmtl Opaque(28,28,28)
Kd 0.109804 0.109804 0.109804
newmtl Opaque(0,0,255)
Kd 0.000000 0.000000 1.000000
newmtl Opaque(202,209,238)
Kd 0.792157 0.819608 0.933333
newmtl Steel_-_Satin
Kd 0.627451 0.627451 0.627451
File diff suppressed because it is too large Load Diff
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
# WaveFront *.mtl file (generated by Autodesk ATF)
newmtl Steel_-_Satin
Kd 0.627451 0.627451 0.627451
File diff suppressed because it is too large Load Diff
Binary file not shown.
-14
View File
@@ -1,14 +0,0 @@
# WaveFront *.mtl file (generated by Autodesk ATF)
newmtl Stainless_Steel_-_Satin
Kd 0.796078 0.796078 0.796078
newmtl Steel_-_Satin
Kd 0.627451 0.627451 0.627451
newmtl Plastic_-_Matte_(Black)
Kd 0.098039 0.098039 0.098039
newmtl Rubber_-_Soft
Kd 0.152941 0.152941 0.152941
File diff suppressed because it is too large Load Diff
Binary file not shown.
-402
View File
@@ -1,402 +0,0 @@
<?xml version="1.0"?>
<robot name="Dog">
<material name="white">
<color rgba="1 1 1 1" />
</material>
<material name="black">
<color rgba="0 0 0 1" />
</material>
<material name="foot_color">
<color rgba="0 0.75 1 1" />
</material>
<link name="base_link">
<visual>
<geometry>
<mesh filename="package://URDF/frame.stl" />
</geometry>
<origin rpy="0 0 3.141" xyz="0 0 0.0" />
<material name="black" />
</visual>
<inertial>
<mass value="1.5" />
<inertia ixx="0" ixy="0" ixz="0" iyy="0" iyz="0" izz="0" />
</inertial>
</link>
<!-- shell -->
<link name="frame">
<visual>
<geometry>
<mesh filename="package://URDF/shell.stl" />
</geometry>
<origin rpy="0 0 3.141" xyz="0 0 0" />
<material name="white" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
<collision>
<geometry>
<mesh filename="package://URDF/shell.stl" />
</geometry>
<origin rpy="0 0 3.141" xyz="0 0 0.0" />
</collision>
</link>
<joint name="base_to_frame" type="fixed">
<parent link="base_link" />
<child link="frame" />
<origin xyz="0 0 0" />
</joint>
<!-- lf shoulder -->
<link name="lf_shoulder">
<visual>
<geometry>
<mesh filename="package://URDF/lf shoulder.stl" />
</geometry>
<origin rpy="3.141 3.141 0" xyz="0 0 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
</link>
<joint name="lf_shoulder" type="continuous">
<parent link="base_link" />
<child link="lf_shoulder" />
<origin xyz="0.090 0.037 0" />
<axis xyz="1 0 0" />
</joint>
<link name="lf_thigh">
<visual>
<geometry>
<mesh filename="package://URDF/femur.stl" />
</geometry>
<origin rpy="1.5708 0 1.5708" xyz="0 0 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
</link>
<joint name="lf_thigh" type="continuous">
<parent link="lf_shoulder" />
<child link="lf_thigh" />
<origin xyz="0.025 0.027 0 " />
<axis xyz="0 1 0" />
</joint>
<link name="lf_shin">
<visual>
<geometry>
<mesh filename="package://URDF/tibia.stl" />
</geometry>
<origin rpy="0.610865 0 -1.5708" xyz="0 0.008 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
<collision>
<geometry>
<mesh filename="package://URDF/tibia.stl" />
</geometry>
<origin rpy="0.610865 0 -1.5708" xyz="0 0.008 0" />
</collision>
</link>
<joint name="lf_shin" type="continuous">
<parent link="lf_thigh" />
<child link="lf_shin" />
<origin xyz="0 0 -0.130 " />
<axis xyz="0 1 0" />
</joint>
<link name="lf_toe">
<visual>
<geometry>
<sphere radius="0.005" />
</geometry>
<origin rpy="0 0 0" xyz="0 0 0" />
<material name="foot_color" />
</visual>
<inertial>
<mass value="0.05" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
<collision>
<geometry>
<sphere radius="0.020" />
</geometry>
<origin rpy="0 0 0" xyz="0 0 0" />
</collision>
</link>
<joint name="lf_toe" type="fixed">
<parent link="lf_shin" />
<child link="lf_toe" />
<origin xyz="0 0 -0.130" />
</joint>
<!-- rf shoulder -->
<link name="rf_shoulder">
<visual>
<geometry>
<mesh filename="package://URDF/rf shoulder.stl" />
</geometry>
<origin rpy="0 0 3.141" xyz="0 0 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
</link>
<joint name="rf_shoulder" type="continuous">
<parent link="base_link" />
<child link="rf_shoulder" />
<origin xyz="0.090 -0.040 0" />
<axis xyz="1 0 0" />
</joint>
<link name="rf_thigh">
<visual>
<geometry>
<mesh filename="package://URDF/femur.stl" />
</geometry>
<origin rpy="4.71239 3.141 1.5708" xyz="0 0 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
</link>
<joint name="rf_thigh" type="continuous">
<parent link="rf_shoulder" />
<child link="rf_thigh" />
<origin xyz="0.025 -0.027 0 " />
<axis xyz="0 1 0" />
</joint>
<link name="rf_shin">
<visual>
<geometry>
<mesh filename="package://URDF/tibia.stl" />
</geometry>
<origin rpy="0.610865 0 -1.5708" xyz="0 -0.0045 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
<collision>
<geometry>
<mesh filename="package://URDF/tibia.stl" />
</geometry>
<origin rpy="0.610865 0 -1.5708" xyz="0 -0.007 0" />
</collision>
</link>
<joint name="rf_shin" type="continuous">
<parent link="rf_thigh" />
<child link="rf_shin" />
<origin xyz="0 0 -0.130 " />
<axis xyz="0 1 0" />
</joint>
<link name="rf_toe">
<visual>
<geometry>
<sphere radius="0.005" />
</geometry>
<origin rpy="0 0 0" xyz="0 0 0" />
<material name="foot_color" />
</visual>
<inertial>
<mass value="0.05" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
<collision>
<geometry>
<sphere radius="0.020" />
</geometry>
<origin rpy="0 0 0" xyz="0 0 0" />
</collision>
</link>
<joint name="rf_toe" type="fixed">
<parent link="rf_shin" />
<child link="rf_toe" />
<origin xyz="0 0 -0.130" />
</joint>
<!-- lb shoulder -->
<link name="lb_shoulder">
<visual>
<geometry>
<mesh filename="package://URDF/lb shoulder.stl" />
</geometry>
<origin rpy="3.141 3.141 0" xyz="0 0 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
</link>
<joint name="lb_shoulder" type="continuous">
<parent link="base_link" />
<child link="lb_shoulder" />
<origin xyz="-0.081 0.038 0" />
<axis xyz="1 0 0" />
</joint>
<link name="lb_thigh">
<visual>
<geometry>
<mesh filename="package://URDF/femur.stl" />
</geometry>
<origin rpy="1.5708 0 1.5708" xyz="0 0 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
</link>
<joint name="lb_thigh" type="continuous">
<parent link="lb_shoulder" />
<child link="lb_thigh" />
<origin xyz="-0.043 0.027 0 " />
<axis xyz="0 1 0" />
</joint>
<link name="lb_shin">
<visual>
<geometry>
<mesh filename="package://URDF/tibia.stl" />
</geometry>
<origin rpy="0.610865 0 -1.5708" xyz="0 0.008 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
<collision>
<geometry>
<mesh filename="package://URDF/tibia.stl" />
</geometry>
<origin rpy="0.610865 0 -1.5708" xyz="0 0.008 0" />
</collision>
</link>
<joint name="lb_shin" type="continuous">
<parent link="lb_thigh" />
<child link="lb_shin" />
<origin xyz="0 0 -0.130 " />
<axis xyz="0 1 0" />
</joint>
<link name="lb_toe">
<visual>
<geometry>
<sphere radius="0.0005" />
</geometry>
<origin rpy="0 0 0" xyz="0 0 0" />
<material name="foot_color" />
</visual>
<inertial>
<mass value="0.05" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
<collision>
<geometry>
<sphere radius="0.020" />
</geometry>
<origin rpy="0 0 0" xyz="0 0 0" />
</collision>
</link>
<joint name="lb_toe" type="fixed">
<parent link="lb_shin" />
<child link="lb_toe" />
<origin xyz="0 0 -0.130" />
</joint>
<!-- rb arm -->
<link name="rb_shoulder">
<visual>
<geometry>
<mesh filename="package://URDF/rb shoulder.stl" />
</geometry>
<origin rpy="3.141 3.141 0" xyz="0 0 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
</link>
<joint name="rb_shoulder" type="continuous">
<parent link="base_link" />
<child link="rb_shoulder" />
<origin xyz="-0.081 -0.040 0" />
<axis xyz="1 0 0" />
</joint>
<link name="rb_thigh">
<visual>
<geometry>
<mesh filename="package://URDF/femur.stl" />
</geometry>
<origin rpy="4.71239 3.141 1.5708" xyz="0 0 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
</link>
<joint name="rb_thigh" type="continuous">
<parent link="rb_shoulder" />
<child link="rb_thigh" />
<origin xyz="-0.043 -0.027 0 " />
<axis xyz="0 1 0" />
</joint>
<link name="rb_shin">
<visual>
<geometry>
<mesh filename="package://URDF/tibia.stl" />
</geometry>
<origin rpy="0.610865 0 -1.5708" xyz="0 -0.0045 0" />
<material name="black" />
</visual>
<inertial>
<mass value="0.025" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
<collision>
<geometry>
<mesh filename="package://URDF/tibia.stl" />
</geometry>
<origin rpy="0.610865 0 -1.5708" xyz="0 -0.007 0" />
</collision>
</link>
<joint name="rb_shin" type="continuous">
<parent link="rb_thigh" />
<child link="rb_shin" />
<origin xyz="0 0 -0.130 " />
<axis xyz="0 1 0" />
</joint>
<link name="rb_toe">
<visual>
<geometry>
<sphere radius="0.0005" />
</geometry>
<origin rpy="0 0 0" xyz="0 0 0" />
<material name="foot_color" />
</visual>
<inertial>
<mass value="0.05" />
<inertia ixx="0.4" ixy="0.4" ixz="0.4" iyy="0.4" iyz="0.4" izz="0.4" />
</inertial>
<collision>
<geometry>
<sphere radius="0.020" />
</geometry>
<origin rpy="0 0 0" xyz="0 0 0" />
</collision>
</link>
<joint name="rb_toe" type="fixed">
<parent link="rb_shin" />
<child link="rb_toe" />
<origin xyz="0 0 -0.130" />
</joint>
</robot>
+3 -6
View File
@@ -1,10 +1,10 @@
import adapter from '@sveltejs/adapter-static'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
const basePath = process.env.BASE_PATH ?? ''
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
@@ -14,10 +14,7 @@ const config = {
fallback: 'index.html',
precompress: false,
strict: true
}),
paths: {
base: basePath
}
})
}
}
+20 -7
View File
@@ -1,11 +1,24 @@
import { expect, test } from '@playwright/test'
import { expect, test } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/Spot micro controller/)
})
await page.goto('/');
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
body: JSON.stringify({})
})
);
await expect(page).toHaveTitle(/Spot micro controller/);
});
test('index page has expected h1', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: 'Spot micro controller' }).first()).toBeVisible()
})
await page.goto('/');
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
body: JSON.stringify({})
})
);
await expect(page.getByRole('heading', { name: 'Spot micro controller' }).first()).toBeVisible();
});
+13 -31
View File
@@ -1,4 +1,4 @@
import type { Plugin } from 'vite';
import type { UserConfig, Plugin } from 'vite';
export default function viteLittleFS(): Plugin[] {
return [
@@ -7,43 +7,25 @@ export default function viteLittleFS(): Plugin[] {
enforce: 'post',
apply: 'build',
async config(config) {
const output = config.build?.rollupOptions?.output;
async config(config, _configEnv) {
const { assetFileNames, chunkFileNames, entryFileNames } =
config.build?.rollupOptions?.output;
if (!output || !config.build?.rollupOptions) {
return;
}
const outputOptions = Array.isArray(output) ? output[0] : output;
if (!outputOptions) {
return;
}
const { assetFileNames, chunkFileNames, entryFileNames } = outputOptions;
if (assetFileNames && typeof assetFileNames === 'string') {
// Handle Server-build + Client Assets
config.build.rollupOptions.output = {
...outputOptions,
assetFileNames: assetFileNames.replace('.[hash]', ''),
...config.build?.rollupOptions?.output,
assetFileNames: assetFileNames.replace('.[hash]', '')
};
}
if (
chunkFileNames &&
typeof chunkFileNames === 'string' &&
chunkFileNames.includes('hash')
) {
// Handle Client-build
if (config.build?.rollupOptions?.output.chunkFileNames.includes('hash')) {
config.build.rollupOptions.output = {
...config.build.rollupOptions.output,
...config.build?.rollupOptions?.output,
chunkFileNames: chunkFileNames.replace('.[hash]', ''),
...(entryFileNames &&
typeof entryFileNames === 'string' && {
entryFileNames: entryFileNames.replace('.[hash]', ''),
}),
entryFileNames: entryFileNames.replace('.[hash]', '')
};
}
},
},
}
}
];
}
+3 -3
View File
@@ -5,10 +5,7 @@ import viteLittleFS from './vite-plugin-littlefs'
import EnvCaster from '@niku/vite-env-caster'
import tailwindcss from '@tailwindcss/vite'
const basePath = process.env.BASE_PATH ?? ''
export default defineConfig({
base: basePath,
plugins: [
tailwindcss(),
sveltekit(),
@@ -18,6 +15,9 @@ export default defineConfig({
viteLittleFS(),
EnvCaster()
],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
},
server: {
proxy: {
'/api': {
+7 -7
View File
@@ -1,17 +1,17 @@
# Components
Spot is comprised of a 3D-printed body, some hardware, and a list of electronic components.
Spot is comprised of a 3D printed body, some hardware and list of electronic components.
## Hardware
Spot is 3D-printed and is a combination of different Spot Micro designs, with some minor modifications.
The original design was developed by KDY0523.
Spot is 3D printed and is a combination of different Spot Micro designs, with some minor modification on top.
The original design is developed by KDY0523.
- [robjk reinforced shoulder remix](https://www.thingiverse.com/thing:4937631)
- [Kooba SpotMicroESP32 remix](https://www.thingiverse.com/thing:4559827)
- [KDY0532 original design](https://www.thingiverse.com/thing:3445283)
The 3D prints are assembled with some additional non-printable components:
The 3D prints is assembled with some additional component:
- 84x M2x8 screws + M2 nuts
- 92x M3x8 screws + M3 nuts
@@ -20,7 +20,7 @@ The 3D prints are assembled with some additional non-printable components:
## Electronics
These are the electronics I used for mine, and they can easily be swapped to suit your Spot's needs.
These are the electronics i used for mine and can easily be switched up to suit your Spot's needs.
| Component | Specification | Required | Recommendation |
| ------------------------- | ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------- |
@@ -39,6 +39,6 @@ These are the electronics I used for mine, and they can easily be swapped to sui
| 7.6-8.4V Battery | Battery | No | Im using 4x 18650 in 2s2p configuration, but other people have 2s LiPos. |
| 4x Servo extension cables | Servo extension cables | Yes | You can either buy them or make them with a couple or headers and some cable. |
I recommend getting an ESP32-S3 with a camera, allowing for more computation and imaging capabilities.
I recommend getting a ESP32-S3 with a camera, allowing for more computation and imaging capabilities.
It means a more responsive robot as it's faster at doing sensor fusion, calculating kinematics and gait planning, and networking.
It means a more responsive robot as its faster doing sensor fusion, calculating kinematic and gait planning, and networking.
+5 -20
View File
@@ -1,6 +1,6 @@
# Assembly and calibration
There are a number of great resources for the assembly of the Spot Micro. For this reason, I refer to these, as the steps are the same for this version:
There exist a number of great resources for the assembly of the spot micro. For this reason I refer to these, as the steps are the same for this version:
- [Michael Kubina SpotMicroESP32 assembly](https://github.com/michaelkubina/SpotMicroESP32/tree/master/assembly)
- [Spot Micro AI assembly](https://spotmicroai.readthedocs.io/en/latest/assembly/)
@@ -9,7 +9,7 @@ There are a number of great resources for the assembly of the Spot Micro. For th
Discussion about [Calibration](https://github.com/runeharlyk/SpotMicroESP32-Leika/discussions/118)
Assuming the servos are connected to the PCA9685 and are powered on:
Assuming the servos are connected to the PCA9685 and is powered on:
### Calibrate in servo frame
@@ -38,28 +38,13 @@ You now have the values for the servos.
### Calibration in body frame
They now have to be calibrated to the body frame. It is assumed they have the center PWM pointing straight down.
They now has to calibrated to the body frame. It assumed they have the center pwm pointing straight down.
1. Navigate to `/controller` and click on "Calibrate". This will set the servo to the center pwm value.
2. Navigate to `peripherals/servo` - Here you can set the servo angle offset.
All the legs should be pointing down. If they are not, you have two options. 1. Physically move the servos to the correct position by unscrewing the servo horns. 2. Update the servo offset in the servo table.
All the legs should be pointing down. If they are not you have to options. 1; Physically move the servos to the correct position by un screwing the servo horns. 2; Update the servo offset in the servo table.
## Circuit diagram
![Electronics diagram](media/circuitschematic.png "Title")
PCA9685 Servo PWM numbers to joint:
| PWM_0 | Front Left Shoulder |
|--------|------------------------------|
| PWM_1 | Front Left Upper-Limb |
| PWM_2 | Front Left Leg (Lower-Limb) |
| PWM_3 | Front Right Shoulder |
| PWM_4 | Front Right Upper-Limb |
| PWM_5 | Front Right Leg (Lower-Limb) |
| PWM_6 | Rear Left Shoulder |
| PWM_7 | Rear Left Upper-Limb |
| PWM_8 | Rear Left Leg (Lower-Limb) |
| PWM_9 | Rear Right Shoulder |
| PWM_10 | Rear Right Upper-Limb |
| PWM_11 | Rear Right Leg (Lower-limb) |
![Electronics diagram](media/circuit.png "Title")
+5 -5
View File
@@ -1,6 +1,6 @@
# Software
The robot's firmware is built using PlatformIO with the Arduino framework over ESP-IDF.
The robots firmware is built using platform io using the arduino framework over ESP-IDF.
## Prerequisites
@@ -8,7 +8,7 @@ To prepare the frontend code for the ESP32, a specific build chain is required.
### Required Software
Install the following software to ensure all functionality:
Install the following software to ensure all functionalities:
- [VSCode](https://code.visualstudio.com/) - Preferred IDE for development
- [Node.js](https://nodejs.org) - Needed for app building
@@ -45,9 +45,9 @@ For additional boards, refer to the [official board list](https://docs.platformi
### Factory settings
Update the `esp32/factory_setting.ini` with new Wi-Fi settings, app name and other device information.
Update the `esp32/factory_setting.ini` with new wifi settings, app name and other device information.
### Build & Upload Process
Update the `platformio.ini` file for your board, then navigate to the PlatformIO tab, select your environment, click `Upload Filesystem Image` and after uploading finishes, click `Upload and Monitor`. The filesystem image only needs to be uploaded the first time. It will override config files on the microcontroller.
When uploading new firmware, the app is evaluated, and if necessary, will be rebuilt.
Update the `platformio.ini` file for your board, then navigate to the PlatformIO tab, select your environment, click `Upload Filesystem Image` and after uploading finish, click `Upload and Monitor`. The filesystem image only has to be uploaded the first time and will override config files on the microcontroller.
When uploading new firmware the app is evaluated and if necessary will be rebuild.

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