116 Commits

Author SHA1 Message Date
Rune Harlyk 2b1aed91d9 🎨 Simplifies spin skill 2025-09-10 21:07:38 +02:00
Rune Harlyk 923ea17702 🎨 Use std for min and max 2025-09-10 20:21:56 +02:00
Rune Harlyk 3a401abfab Adds skilmanager and spin skill 2025-09-10 15:59:41 +02:00
Rune Harlyk 26c36b8302 🎨 Makes gesture sensor more readable and motion take last gesture 2025-09-10 15:16:00 +02:00
Rune Harlyk bfc259e660 Adds gesture controls 2025-09-10 15:16:00 +02:00
Rune Harlyk 6368bf9213 🎨 Makes use of msg type for sensors 2025-09-10 11:15:44 +02:00
Rune Harlyk cd802f1c22 Makes fsm states by time aware 2025-09-08 22:39:53 +02:00
Rune Harlyk 59bb1d9579 ️ Improves imu speed by making it non blocking and run faster 2025-09-08 22:37:57 +02:00
Rune Harlyk ae98ba76f7 Makes stand imu compensating 2025-09-06 21:02:28 +02:00
Rune Harlyk bd8c8fd988 🐛 Fixes imu handling 2025-09-06 19:55:57 +02:00
Rune Harlyk 7de5a1aa7c 🎨 Lerp gait params to target 2025-09-05 15:22:47 +02:00
Rune Harlyk a3e4fdd8a5 🎨 Moves kinematics config to kinematics file 2025-09-05 14:55:02 +02:00
Rune Harlyk f82fa051f2 🎨 Renames states folder 2025-09-04 23:33:45 +02:00
Rune Harlyk b66ddc3e81 Introduces motion as a state machine 2025-09-04 23:33:45 +02:00
Rune Harlyk c85ac41ebc 🐛 Makes step height dynamic 2025-09-04 21:03:09 +02:00
Rune Harlyk 78d01533f4 Makes body rotation controllable 2025-09-04 19:31:45 +02:00
Rune Harlyk 18d4d66758 Makes robot stand compensate imu 2025-09-04 19:27:48 +02:00
Rune Harlyk 1b9dc9bb9e Makes motion use target position for body state 2025-09-04 19:27:17 +02:00
Rune Harlyk 767d1157df Makes kinematics params be based on config 2025-09-04 19:08:54 +02:00
Rune Harlyk 1799889712 Introduces kinmatics config to sync mapping between variants 2025-09-04 18:02:38 +02:00
Rune Harlyk 0b5d7b1534 Fixes gait into bezier 2025-09-04 17:33:25 +02:00
Rune Harlyk 10b78e6919 🎨 Smoother crawl body shift 2025-09-04 17:33:25 +02:00
Rune Harlyk 3fd72d081e 🎨 Correct behavoir 2025-09-04 17:33:25 +02:00
Rune Harlyk 1f3a465d3e 🎨 Adds speed factor to frontend 2025-09-04 17:33:25 +02:00
Rune Harlyk cddb6023e7 🎨 Better base walking speed 2025-09-04 17:33:25 +02:00
Rune Harlyk 2f46484e0a 🎨 Simplifies gait 2025-09-04 17:33:25 +02:00
Rune Harlyk 4fcaf5d77d 🐛 Try to handle body shifting 2025-09-04 17:33:25 +02:00
Rune Harlyk ea8ddb43ef 🎨 Adds speed factor between gaits 2025-09-04 17:33:25 +02:00
Rune Harlyk 774c546487 🎨 Cleanup crawl 2025-09-04 17:33:25 +02:00
Rune Harlyk 6f46c1f598 🎨 Renames kinematics config 2025-09-04 17:33:25 +02:00
Rune Harlyk bc810ee2dd 🎨 Adds defaults to notification service 2025-09-04 17:33:25 +02:00
Rune Harlyk 54a0419770 🎨 Cleans up gait handling code 2025-09-04 17:33:25 +02:00
Rune Harlyk d7a6bffe0a 🎨 Update the rotation command handling 2025-09-01 22:53:14 +02:00
Rune Harlyk df087decdb 🎨 Renames topics 2025-09-01 18:48:27 +02:00
Rune Harlyk 527764b0b5 🐛 Expands number of endpoints 2025-09-01 18:43:12 +02:00
Rune Harlyk 8c97c68d11 🚩 Add feature flag for spot pico 2025-09-01 18:42:51 +02:00
Rune Harlyk e5bf10cdb0 🎨 Updates and simplifies command handling 2025-09-01 18:41:59 +02:00
Rune Harlyk de3912ff10 Adds kinematics for spot pico 2025-08-22 12:31:22 +02:00
Rune Harlyk 251a791876 Enables better zoom for viz 2025-08-21 23:13:50 +02:00
Rune Harlyk e36365ead6 Adds gif of short walk 2025-08-11 14:40:49 +02:00
Rune Harlyk cb5c095888 🐛 Removes camera endpoint using feature flag 2025-08-03 15:53:49 +02:00
Rune Harlyk 281fa32c89 🐛 Fixes the relative paths 2025-08-02 16:43:45 +02:00
Rune Harlyk d899701195 Simplifies frontend test 2025-07-16 21:58:39 +02:00
Rune Harlyk 7061166fcd 🎨 Matches command mapping in frontend 2025-07-16 21:47:24 +02:00
Rune Harlyk 36b39d41ba 🎨 Replace magic number for stand_frac 2025-07-16 21:44:55 +02:00
Rune Harlyk 7d0a7861ea 🎨 Formats extensions.json 2025-07-16 20:41:28 +02:00
Rune Harlyk bf8c9bce95 📝 Updates readme 2025-07-16 20:40:34 +02:00
Rune Harlyk 9c984d3215 🎨 Inlines cors wildcard 2025-07-16 20:33:12 +02:00
Rune Harlyk 43e76770a8 ️ Removes unnecessary lerp 2025-07-16 20:32:46 +02:00
Rune Harlyk 6e10eabd9f 🔥 Cleans up peripherals service 2025-07-16 20:32:19 +02:00
Rune Harlyk 922a4e3665 🔥 Removes certs 2025-07-16 20:27:33 +02:00
Rune Harlyk 5e162ffb71 ️ Adds build flags for speed and gc 2025-07-16 20:26:21 +02:00
Rune Harlyk f21ce92d43 🐛 Excludes models files for other variants when building 2025-07-12 12:43:07 +02:00
Rune Harlyk 98f3fc674b Makes socket messages event typed 2025-07-11 18:59:07 +02:00
Rune Harlyk c5901c65b3 Adds yertle model visulization 2025-07-11 15:16:47 +02:00
Rune Harlyk 2eab893dd7 🚩 Expands feature flag handling with persistence 2025-07-11 15:16:47 +02:00
Rune Harlyk a3be035f98 🚚 Moves firmware to src and include 2025-07-11 12:16:23 +02:00
Rune Harlyk 743aa073b7 🚀 Makes deploy action run 2025-07-10 23:18:15 +02:00
Rune Harlyk a3de13c619 🔧 Makes default visualization be spot micro 2025-07-10 22:32:27 +02:00
Rune Harlyk 90be771211 🚀 Deploys app 2025-07-10 22:28:05 +02:00
Rune Harlyk 7d79ec39ab Fixes more linter errors 2025-07-10 21:54:38 +02:00
Rune Harlyk 211ff7205b 🔧 Adds env with default variables 2025-07-10 21:54:38 +02:00
Rune Harlyk d0aa3b7b42 💄 Updates colors for metrics chart 2025-07-10 21:54:38 +02:00
Rune Harlyk d529eaa201 Fixes build warning and errors 2025-07-10 21:54:38 +02:00
Rune Harlyk c8ee64d7f4 🐛 Fixes event socket binary serialization buffer length 2025-07-10 20:44:04 +02:00
Rune Harlyk ec4c3fd98e Changes mgspack dependency 2025-07-10 19:04:39 +02:00
Rune Harlyk 0cc372cd36 🐛 Fixes some linting errors 2025-07-10 19:04:39 +02:00
Rune Harlyk 9be405a89d 🐛 Maps frontend gait params same as backend 2025-07-10 19:04:39 +02:00
Rune Harlyk e3cfe89e19 ♻️ Replaces JsonObject with JsonVariant 2025-07-10 19:04:39 +02:00
Rune Harlyk 144b99c180 🔥 Removes debug logging 2025-07-10 19:04:39 +02:00
Rune Harlyk c788e118e3 ️ Adds O3 build flag 2025-07-10 19:04:39 +02:00
Rune Harlyk aae16335b3 ♻️ Centralizes socket serialization 2025-07-10 19:04:39 +02:00
Rune Harlyk a43c250ed1 Adds msgPack and update message protocol 2025-07-10 19:04:39 +02:00
Rune Harlyk 01d46f283b 👔 Update model utils to be able to load both urdf and xacro 2025-07-02 22:55:31 +02:00
Rune Harlyk 7c8c5b40a1 👔 Update visualization to better align with robot 2025-07-02 22:55:00 +02:00
Rune Harlyk 632f603fda 👔 Calculate default feet positions from kinematics 2025-07-02 22:53:58 +02:00
Rune Harlyk 4101ad033c 🐛 Expand allowed _numberEndpoints 2025-06-30 22:00:52 +02:00
Rune Harlyk 3ee096bfab 🚸 Update default feet positions 2025-06-30 22:00:26 +02:00
Rune Harlyk 753e692fe2 🔧 Adds support for Yertle legs
https://github.com/Jerome-Graves/yertle/
2025-06-27 22:50:25 +02:00
Rune Harlyk 40025a55c3 💄 Simplify calibration UX 2025-06-27 22:39:18 +02:00
Rune Harlyk 98262b2efc 🗃️ Improves UI filesystem interface 2025-05-24 19:23:46 +02:00
Rune Harlyk 01e174f337 🧃 Adds IMU orientations indicator 2025-05-17 12:37:06 +02:00
Rune Harlyk a9fea7fd56 🎍 Updates feature flags and adds BNO055 2025-05-17 11:57:00 +02:00
Rune Harlyk e09ec81f1d 🤹 Adds option for direct control of multiple servos 2025-05-15 19:59:06 +02:00
Rune Harlyk ee17f6862c 👆 Fixes on click for system status view 2025-05-05 20:56:34 +02:00
Rune Harlyk 8be7546eba 🎍 Updates reset reason mapping 2025-04-21 13:14:57 +02:00
Rune Harlyk e156b732eb 🏎️ Simplifies kinematics by removing matrix muls 2025-04-20 14:48:43 +02:00
Rune Harlyk 20c5a8ee92 🎮 Adds gamepad api control 2025-04-18 21:17:06 +02:00
Rune Harlyk dac21a499f 🪻 Hides menu overflow-x 2025-04-03 10:08:51 +02:00
Rune Harlyk 9a6c240140 🎋 Updates adafruit pwm lib to own fork until pr merged 2025-03-29 14:13:52 +01:00
Rune Harlyk 8733ecd9b7 ⏱️ Updates the frequency of main control loop from 100 hz to 200 2025-03-29 14:13:52 +01:00
Rune Harlyk fba531d3e8 🫅 Updates spot control task priority 3 -> 5 2025-03-29 14:13:52 +01:00
Rune Harlyk fc04d1b8d6 ✍️ Updates I2C freq to Fast Mode Plus 2025-03-29 14:13:52 +01:00
Rune Harlyk 4c33a75164 ✍️ Adds bulk writing of pwm values to PCA9685 2025-03-29 14:13:52 +01:00
Rune Harlyk 6015e67d05 🧼 Clean up MDNS UI 2025-03-23 20:14:01 +01:00
Rune Harlyk f59f32ce26 🧼 Removes unused imports 2025-03-23 20:14:01 +01:00
Rune Harlyk 3671610860 🖥️ Adds mDNS service 2025-03-23 20:14:01 +01:00
Rune Harlyk c346f7f553 🚇 Enables metrics in ui 2025-03-23 16:52:24 +01:00
Rune Harlyk f864616303 🖨️ Adds printing of feature flags 2025-03-23 16:44:22 +01:00
Rune Harlyk ad2d28c9ba ⚒️ Enables bigger range of motion for servo controller 2025-03-23 16:25:46 +01:00
Rune Harlyk 967923321f 📦 Use std:move for callback 2025-03-23 16:25:12 +01:00
Rune Harlyk 6b7e3281cf 🎋 Updates kinematics with modifiers 2025-03-23 16:24:26 +01:00
Rune Harlyk fdf70f7eb8 ⚒️ Updates build workflow file 2025-03-23 16:18:57 +01:00
Rune Harlyk e4cb035ad9 📦 Moves platform ini to root 2025-03-23 16:18:57 +01:00
Rune Harlyk c02938b567 💫 Update menu styling 2025-03-23 16:06:20 +01:00
TitanDynamics c24740e8ec Add Servo Motor Designations
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)
2025-03-21 09:32:44 +01:00
TitanDynamics e0d3912d83 Fixed Grammatical Errors and updated documentation. 2025-03-21 09:32:44 +01:00
Rune Harlyk b113a30942 🥷 Adds i2c configurator 2025-03-20 15:49:53 +01:00
Rune Harlyk 9534529e50 🎋 Adds i2c configuration type 2025-03-20 15:49:53 +01:00
Rune Harlyk 23a41d26b1 🎋 Makes icon optional for status item 2025-03-20 15:49:53 +01:00
Rune Harlyk 569c19ad1d 🧼 Cleans up setting card 2025-03-20 15:49:53 +01:00
Rune Harlyk 17e30ebfe9 🧼 Simplifies and updates color scheme for confirm 2025-03-20 15:49:53 +01:00
Rune Harlyk 170e180c11 🌌 Adds edit icons 2025-03-20 15:49:53 +01:00
Rune Harlyk 5a24038d68 📂 Fixes file system view 2025-03-08 16:18:42 +01:00
Rune Harlyk 99660b9a23 🧼 Refactors wifi and ap to use StatusItem 2025-03-08 14:48:48 +01:00
Rune Harlyk 72f3bcfd78 🌌 Makes front page simplere 2025-03-08 13:22:41 +01:00
195 changed files with 220053 additions and 7830 deletions
+61
View File
@@ -0,0 +1,61 @@
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
+8 -9
View File
@@ -2,20 +2,19 @@ name: PlatformIO CI
on: on:
push: push:
branches: [ master ] branches: [master]
paths: paths:
- 'esp32/**' - "esp32/**"
- "platformio.ini"
pull_request: pull_request:
branches: [ master ] branches: [master]
paths: paths:
- 'esp32/**' - "esp32/**"
- "platformio.ini"
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults:
run:
working-directory: ./esp32
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -28,8 +27,8 @@ jobs:
- uses: actions/setup-python@v4 - uses: actions/setup-python@v4
with: with:
python-version: '3.x' python-version: "3.x"
- run: pip install -r ./scripts/requirements.txt - run: pip install -r esp32/scripts/requirements.txt
- name: Install PlatformIO Core - name: Install PlatformIO Core
run: pip install --upgrade platformio run: pip install --upgrade platformio
+1 -1
View File
@@ -2,7 +2,7 @@
.vscode/c_cpp_properties.json .vscode/c_cpp_properties.json
.vscode/launch.json .vscode/launch.json
.vscode/ipch .vscode/ipch
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
.pio
+4 -4
View File
@@ -2,10 +2,10 @@
// See http://go.microsoft.com/fwlink/?LinkId=827846 // See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format // for the documentation about the extensions.json format
"recommendations": [ "recommendations": [
"platformio.platformio-ide", "bradlc.vscode-tailwindcss",
"svelte.svelte-vscode", "esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss", "platformio.platformio-ide",
"esbenp.prettier-vscode" "svelte.svelte-vscode"
], ],
"unwantedRecommendations": [ "unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack" "ms-vscode.cpptools-extension-pack"
+3
View File
@@ -0,0 +1,3 @@
PUBLIC_VITE_USE_HOST_NAME=true
PUBLIC_USE_JSON=true
PUBLIC_USE_MSGPACK=true
-1
View File
@@ -3,7 +3,6 @@ node_modules
/build /build
/.svelte-kit /.svelte-kit
/package /package
.env
.env.* .env.*
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
+8 -17
View File
@@ -8,30 +8,21 @@ If you're seeing this, you've probably already done this step. Congrats!
```bash ```bash
# create a new project in the current directory # create a new project in the current directory
npm create svelte@latest npx sv create
# create a new project in my-app
npm create svelte@latest my-app
``` ```
## Developing ## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: Once you've created your project, follow these steps:
```bash 1: Delete package-lock.json
npm run dev 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
# or start the server and open the app in a new browser tab Running `git status` should show:
npm run dev -- --open
```
## Building [![example.png](https://i.postimg.cc/yddM3hH3/example.png)](https://postimg.cc/7CFsp2bq)
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`. You can preview the production build with `npm run preview`.
+1 -1
View File
@@ -45,6 +45,7 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.2",
"@niku/vite-env-caster": "^1.0.2", "@niku/vite-env-caster": "^1.0.2",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
"@tailwindcss/vite": "^4.0.12", "@tailwindcss/vite": "^4.0.12",
@@ -52,7 +53,6 @@
"compare-versions": "^6.1.0", "compare-versions": "^6.1.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"daisyui": "^5.0.0", "daisyui": "^5.0.0",
"jwt-decode": "^4.0.0",
"nipplejs": "^0.10.1", "nipplejs": "^0.10.1",
"svelte-dnd-list": "^0.1.8", "svelte-dnd-list": "^0.1.8",
"svelte-modals": "^2.0.0", "svelte-modals": "^2.0.0",
+58 -41
View File
@@ -8,15 +8,18 @@ importers:
.: .:
dependencies: dependencies:
'@msgpack/msgpack':
specifier: ^3.1.2
version: 3.1.2
'@niku/vite-env-caster': '@niku/vite-env-caster':
specifier: ^1.0.2 specifier: ^1.0.2
version: 1.1.2 version: 1.1.2
'@sveltejs/adapter-auto': '@sveltejs/adapter-auto':
specifier: ^4.0.0 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(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))) 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)))
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.0.12 specifier: ^4.0.12
version: 4.0.12(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) 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))
chart.js: chart.js:
specifier: ^4.4.2 specifier: ^4.4.2
version: 4.4.2 version: 4.4.2
@@ -29,9 +32,6 @@ importers:
daisyui: daisyui:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0 version: 5.0.0
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
nipplejs: nipplejs:
specifier: ^0.10.1 specifier: ^0.10.1
version: 0.10.1 version: 0.10.1
@@ -65,13 +65,13 @@ importers:
version: 1.49.1 version: 1.49.1
'@sveltejs/adapter-static': '@sveltejs/adapter-static':
specifier: ^3.0.1 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(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))) 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)))
'@sveltejs/kit': '@sveltejs/kit':
specifier: ^2.5.27 specifier: ^2.5.27
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)) 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))
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: ^5.0.3 specifier: ^5.0.3
version: 5.0.3(svelte@5.20.4)(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)) 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))
'@types/eslint': '@types/eslint':
specifier: ^8.56.0 specifier: ^8.56.0
version: 8.56.0 version: 8.56.0
@@ -128,10 +128,10 @@ importers:
version: 0.18.5 version: 0.18.5
vite: vite:
specifier: ^6.2.1 specifier: ^6.2.1
version: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) version: 6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
vitest: vitest:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0(jsdom@24.0.0)(lightningcss@1.29.2) version: 1.2.0(@types/node@24.0.12)(jsdom@24.0.0)(lightningcss@1.29.2)
packages: packages:
@@ -509,6 +509,10 @@ packages:
'@kurkle/color@0.3.2': '@kurkle/color@0.3.2':
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} 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': '@niku/vite-env-caster@1.1.2':
resolution: {integrity: sha512-6I/8REFdmfeGnK92H3nYHGc6lExwjm72jLxAsDPlfji97Eej4rOMl6WuYGLgsQI0pl5RrMRMveeRdijdL6hW+Q==} resolution: {integrity: sha512-6I/8REFdmfeGnK92H3nYHGc6lExwjm72jLxAsDPlfji97Eej4rOMl6WuYGLgsQI0pl5RrMRMveeRdijdL6hW+Q==}
@@ -760,6 +764,9 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} 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': '@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
@@ -1423,10 +1430,6 @@ packages:
json-stable-stringify-without-jsonify@1.0.1: json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2019,6 +2022,9 @@ packages:
ufo@1.5.3: ufo@1.5.3:
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
undici-types@7.8.0:
resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==}
universalify@0.2.0: universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}
@@ -2532,6 +2538,8 @@ snapshots:
'@kurkle/color@0.3.2': {} '@kurkle/color@0.3.2': {}
'@msgpack/msgpack@3.1.2': {}
'@niku/vite-env-caster@1.1.2': '@niku/vite-env-caster@1.1.2':
dependencies: dependencies:
chalk: 4.1.2 chalk: 4.1.2
@@ -2613,18 +2621,18 @@ snapshots:
'@sinclair/typebox@0.27.8': {} '@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(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/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)))':
dependencies: dependencies:
'@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))
import-meta-resolve: 4.1.0 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(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/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)))':
dependencies: dependencies:
'@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))': '@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))':
dependencies: dependencies:
'@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)) '@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))
'@types/cookie': 0.6.0 '@types/cookie': 0.6.0
cookie: 0.6.0 cookie: 0.6.0
devalue: 5.1.1 devalue: 5.1.1
@@ -2637,27 +2645,27 @@ snapshots:
set-cookie-parser: 2.6.0 set-cookie-parser: 2.6.0
sirv: 3.0.1 sirv: 3.0.1
svelte: 5.20.4 svelte: 5.20.4
vite: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) 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))': '@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))':
dependencies: dependencies:
'@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)) '@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))
debug: 4.4.0 debug: 4.4.0
svelte: 5.20.4 svelte: 5.20.4
vite: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) vite: 6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@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))': '@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))':
dependencies: dependencies:
'@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)) '@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))
debug: 4.4.0 debug: 4.4.0
deepmerge: 4.3.1 deepmerge: 4.3.1
kleur: 4.1.5 kleur: 4.1.5
magic-string: 0.30.17 magic-string: 0.30.17
svelte: 5.20.4 svelte: 5.20.4
vite: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) 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)) 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))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -2714,13 +2722,13 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.0.12 '@tailwindcss/oxide-win32-arm64-msvc': 4.0.12
'@tailwindcss/oxide-win32-x64-msvc': 4.0.12 '@tailwindcss/oxide-win32-x64-msvc': 4.0.12
'@tailwindcss/vite@4.0.12(vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2))': '@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))':
dependencies: dependencies:
'@tailwindcss/node': 4.0.12 '@tailwindcss/node': 4.0.12
'@tailwindcss/oxide': 4.0.12 '@tailwindcss/oxide': 4.0.12
lightningcss: 1.29.2 lightningcss: 1.29.2
tailwindcss: 4.0.12 tailwindcss: 4.0.12
vite: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) vite: 6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
'@tweenjs/tween.js@23.1.2': {} '@tweenjs/tween.js@23.1.2': {}
@@ -2737,6 +2745,11 @@ snapshots:
'@types/json-schema@7.0.15': {} '@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/semver@7.5.8': {}
'@types/stats.js@0.17.3': {} '@types/stats.js@0.17.3': {}
@@ -3500,8 +3513,6 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify-without-jsonify@1.0.1: {}
jwt-decode@4.0.0: {}
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
@@ -4010,6 +4021,9 @@ snapshots:
ufo@1.5.3: {} ufo@1.5.3: {}
undici-types@7.8.0:
optional: true
universalify@0.2.0: {} universalify@0.2.0: {}
unplugin-icons@0.18.5: unplugin-icons@0.18.5:
@@ -4054,13 +4068,13 @@ snapshots:
uzip@0.20201231.0: {} uzip@0.20201231.0: {}
vite-node@1.2.0(lightningcss@1.29.2): vite-node@1.2.0(@types/node@24.0.12)(lightningcss@1.29.2):
dependencies: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.4.0 debug: 4.4.0
pathe: 1.1.2 pathe: 1.1.2
picocolors: 1.0.1 picocolors: 1.0.1
vite: 5.4.14(lightningcss@1.29.2) vite: 5.4.14(@types/node@24.0.12)(lightningcss@1.29.2)
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node' - '@types/node'
- less - less
@@ -4072,31 +4086,33 @@ snapshots:
- supports-color - supports-color
- terser - terser
vite@5.4.14(lightningcss@1.29.2): vite@5.4.14(@types/node@24.0.12)(lightningcss@1.29.2):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.5.3 postcss: 8.5.3
rollup: 4.34.8 rollup: 4.34.8
optionalDependencies: optionalDependencies:
'@types/node': 24.0.12
fsevents: 2.3.3 fsevents: 2.3.3
lightningcss: 1.29.2 lightningcss: 1.29.2
vite@6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2): vite@6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2):
dependencies: dependencies:
esbuild: 0.25.0 esbuild: 0.25.0
postcss: 8.5.3 postcss: 8.5.3
rollup: 4.34.8 rollup: 4.34.8
optionalDependencies: optionalDependencies:
'@types/node': 24.0.12
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.4.2 jiti: 2.4.2
lightningcss: 1.29.2 lightningcss: 1.29.2
yaml: 2.4.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)): 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)):
optionalDependencies: optionalDependencies:
vite: 6.2.1(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2) vite: 6.2.1(@types/node@24.0.12)(jiti@2.4.2)(lightningcss@1.29.2)(yaml@2.4.2)
vitest@1.2.0(jsdom@24.0.0)(lightningcss@1.29.2): vitest@1.2.0(@types/node@24.0.12)(jsdom@24.0.0)(lightningcss@1.29.2):
dependencies: dependencies:
'@vitest/expect': 1.2.0 '@vitest/expect': 1.2.0
'@vitest/runner': 1.2.0 '@vitest/runner': 1.2.0
@@ -4116,10 +4132,11 @@ snapshots:
strip-literal: 1.3.0 strip-literal: 1.3.0
tinybench: 2.8.0 tinybench: 2.8.0
tinypool: 0.8.4 tinypool: 0.8.4
vite: 5.4.14(lightningcss@1.29.2) vite: 5.4.14(@types/node@24.0.12)(lightningcss@1.29.2)
vite-node: 1.2.0(lightningcss@1.29.2) vite-node: 1.2.0(@types/node@24.0.12)(lightningcss@1.29.2)
why-is-node-running: 2.2.2 why-is-node-running: 2.2.2
optionalDependencies: optionalDependencies:
'@types/node': 24.0.12
jsdom: 24.0.0 jsdom: 24.0.0
transitivePeerDependencies: transitivePeerDependencies:
- less - less
+34 -52
View File
@@ -1,61 +1,43 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap'; import { focusTrap } from 'svelte-focus-trap'
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition'
import { Cancel, Check } from '$lib/components/icons'; import { Cancel, Check } from '$lib/components/icons'
import { modals, exitBeforeEnter } from 'svelte-modals'; import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
// provided by <Modals /> let {
isOpen,
interface Props { title,
isOpen: boolean; message,
title: string; onConfirm,
message: string; labels = {
onConfirm: any; cancel: { label: 'Cancel', icon: Cancel },
labels?: any; confirm: { label: 'OK', icon: Check }
} }
}: ModalProps = $props()
let {
isOpen,
title,
message,
onConfirm,
labels = {
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'OK', icon: Check }
}
}: Props = $props();
</script> </script>
{#if isOpen} {#if isOpen}
{@const SvelteComponent = labels?.confirm.icon} {@const SvelteComponent = labels?.confirm.icon}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
<div <div
role="dialog" 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="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" <h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
transition:fly={{ y: 50 }} <div class="divider my-2"></div>
use:exitBeforeEnter <p class="text-base-content mb-1 text-start">{message}</p>
use:focusTrap <div class="divider my-2"></div>
> <div class="flex justify-end gap-2">
<div <button class="btn btn-error inline-flex items-center" onclick={() => modals.close()}>
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg" <labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span>
> </button>
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2> <button class="btn btn-primary inline-flex items-center" onclick={onConfirm}>
<div class="divider my-2"></div> <SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span>
<p class="text-base-content mb-1 text-start">{message}</p> </button>
<div class="divider my-2"></div> </div>
<div class="flex justify-end gap-2">
<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-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>
</div>
</div> </div>
</div>
{/if} {/if}
+32 -43
View File
@@ -1,51 +1,40 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap'; import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { Check } from './icons'; import { Check } from './icons';
import { exitBeforeEnter } from 'svelte-modals'; import { exitBeforeEnter, type ModalProps } from 'svelte-modals';
// provided by <Modals /> let {
isOpen,
interface Props { title,
isOpen: boolean; message,
title: string; onDismiss,
message: string; labels = {
onDismiss: any; dismiss: { label: 'Dismiss', icon: Check },
dismiss?: any; },
} }: ModalProps = $props();
let {
isOpen,
title,
message,
onDismiss,
dismiss = { label: 'Dismiss', icon: Check }
}: Props = $props();
</script> </script>
{#if isOpen} {#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
<div <div
role="dialog" 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="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" <h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
transition:fly={{ y: 50 }} <div class="divider my-2"></div>
use:exitBeforeEnter <p class="text-base-content mb-1 text-start">{message}</p>
use:focusTrap <div class="divider my-2"></div>
> <div class="flex justify-end gap-2">
<div <button
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="btn btn-warning text-warning-content inline-flex items-center"
> onclick={onDismiss}>
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2> <labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span>
<div class="divider my-2"></div> </button>
<p class="text-base-content mb-1 text-start">{message}</p> </div>
<div class="divider my-2"></div>
<div class="flex justify-end gap-2">
<button
class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onDismiss}
>
<dismiss.icon class="mr-2 h-5 w-5" /><span>{dismiss.label}</span>
</button>
</div>
</div>
</div> </div>
</div>
{/if} {/if}
@@ -0,0 +1,78 @@
<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>
+53 -62
View File
@@ -1,69 +1,60 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing'
import { Down } from './icons'; import { Down } from './icons'
interface Props { interface Props {
open?: boolean; open?: boolean
collapsible?: boolean; collapsible?: boolean
icon?: import('svelte').Snippet; icon?: import('svelte').Snippet
title?: import('svelte').Snippet; title?: import('svelte').Snippet
children?: import('svelte').Snippet; children?: import('svelte').Snippet
} right?: import('svelte').Snippet
}
let { let { open = $bindable(true), collapsible = true, icon, title, children, right }: Props = $props()
open = $bindable(true),
collapsible = true,
icon,
title,
children
}: Props = $props();
</script> </script>
{#if collapsible} {#if collapsible}
<div <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
<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?.()}
<span class="inline-flex items-baseline"> {@render title?.()}
{@render icon?.()} </span>
{@render title?.()} <button
</span> class="btn btn-circle btn-ghost btn-sm"
<button onclick={() => {
class="btn btn-circle btn-ghost btn-sm" open = !open
onclick={() => { }}>
open = !open; <Down
}} class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open ?
> 'rotate-180'
<Down : ''}" />
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open </button>
? 'rotate-180' </div>
: ''}" {#if open}
/> <div
</button> class="flex flex-col gap-2 p-4 pt-0"
</div> transition:slide|local={{ duration: 300, easing: cubicOut }}>
{#if open} {@render children?.()}
<div </div>
class="flex flex-col gap-2 p-4 pt-0" {/if}
transition:slide|local={{ duration: 300, easing: cubicOut }} </div>
>
{@render children?.()}
</div>
{/if}
</div>
{:else} {:else}
<div <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
<div class="min-h-16 w-full 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"> <span class="inline-flex items-baseline">
{@render icon?.()} {@render icon?.()}
{@render title?.()} {@render title?.()}
</span> </span>
</div> {@render right?.()}
<div class="flex flex-col gap-2 p-4 pt-0"> </div>
{@render children?.()} <div class="flex flex-col gap-2 p-4 pt-0">
</div> {@render children?.()}
</div> </div>
</div>
{/if} {/if}
+45
View File
@@ -0,0 +1,45 @@
<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>
+306 -319
View File
@@ -1,345 +1,332 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte'
import { import {
BufferGeometry, BufferGeometry,
Line, Line,
LineBasicMaterial, LineBasicMaterial,
Mesh, Mesh,
MeshBasicMaterial, MeshBasicMaterial,
Object3D, type Object3D,
SphereGeometry, SphereGeometry,
Vector3, Vector3,
type NormalBufferAttributes, type NormalBufferAttributes,
type Object3DEventMap type Object3DEventMap
} from 'three'; } from 'three'
import { import {
ModesEnum, ModesEnum,
kinematicData, kinematicData,
mode, mode,
model, model,
outControllerData, outControllerData,
servoAnglesOut, servoAnglesOut,
servoAngles, servoAngles,
mpu, mpu,
jointNames jointNames,
} from '$lib/stores'; currentKinematic,
import { footColor, populateModelCache, throttler, toeWorldPositions } from '$lib/utilities'; walkGait,
import SceneBuilder from '$lib/sceneBuilder'; walkGaits,
import { lerp, degToRad } from 'three/src/math/MathUtils'; walkGaitToMode
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; } from '$lib/stores'
import Kinematic, { type body_state_t } from '$lib/kinematic'; import {
import { extractFootColor,
BezierState, populateModelCache,
CalibrationState, throttler,
EightPhaseWalkState, getToeWorldPositions
FourPhaseWalkState, } from '$lib/utilities'
IdleState, import SceneBuilder from '$lib/sceneBuilder'
RestState, import { lerp, degToRad } from 'three/src/math/MathUtils'
StandState import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
} from '$lib/gait'; import { type body_state_t } from '$lib/kinematic'
import { radToDeg } from 'three/src/math/MathUtils.js'; import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait'
import type { URDFRobot } from 'urdf-loader'; import { radToDeg } from 'three/src/math/MathUtils.js'
import { get } from 'svelte/store'; import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store'
interface Props { interface Props {
sky?: boolean; sky?: boolean
orbit?: boolean; orbit?: boolean
panel?: boolean; panel?: boolean
debug?: boolean; debug?: boolean
ground?: boolean; ground?: boolean
}
let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props()
let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement
let currentModelAngles: number[] = new Array(12).fill(0)
let modelTargetAngles: number[] = new Array(12).fill(0)
let gui_panel: GUI
let Throttler = new throttler()
let feet_trace = new Array(4).fill([])
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
let target: Object3D<Object3DEventMap>
let target_position = { x: 0, z: 0, yaw: 0 }
let kinematic = get(currentKinematic)
let planners = {
[ModesEnum.Deactivated]: new IdleState(),
[ModesEnum.Idle]: new IdleState(),
[ModesEnum.Calibration]: new CalibrationState(),
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Walk]: new BezierState()
}
let lastTick = performance.now()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
let body_state = {
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.5,
zm: 0,
feet: kinematic.getDefaultFeetPos()
}
let settings = {
'Internal kinematic': true,
'Robot transform controls': false,
'Auto orient robot': true,
'Trace feet': debug,
'Target position': false,
'Trace points': 30,
'Fix camera on robot': true,
'Smooth motion': true,
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.7,
zm: 0,
Background: 'black'
}
onMount(async () => {
await populateModelCache()
await createScene()
servoAngles.subscribe(updateAnglesFromStore)
walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait)))
if (panel) createPanel()
})
onDestroy(() => {
canvas.remove()
gui_panel?.destroy()
})
const updateAnglesFromStore = (angles: number[]) => {
if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return
modelTargetAngles = angles
}
const createPanel = () => {
gui_panel = new GUI({ width: 310 })
gui_panel.close()
gui_panel.domElement.id = 'three-gui-panel'
const general = gui_panel.addFolder('General')
general.add(settings, 'Internal kinematic')
general.add(settings, 'Robot transform controls')
general.add(settings, 'Auto orient robot')
const kinematic = gui_panel.addFolder('Kinematics')
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
const visibility = gui_panel.addFolder('Visualization')
visibility.add(settings, 'Trace feet')
visibility.add(settings, 'Trace points', 1, 1000, 1)
visibility.add(settings, 'Target position')
visibility.add(settings, 'Smooth motion')
visibility.addColor(settings, 'Background')
}
const updateKinematicPosition = () => {
kinematicData.set([
settings.omega,
settings.phi,
settings.psi,
settings.xm,
settings.ym,
settings.zm
])
}
const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
Throttler.throttle(() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))), 100)
}
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(2, 20, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop()
if (ground) sceneManager.addGroundPlane()
const geometry = new SphereGeometry(0.1, 32, 16)
const material = new MeshBasicMaterial({ color: 0xffff00 })
target = new Mesh(geometry, material)
sceneManager.scene.add(target)
if (debug) {
sceneManager.addDragControl(updateAngles)
}
if (sky) sceneManager.addSky()
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry()
const material = new LineBasicMaterial({ color: extractFootColor() })
const line = new Line(geometry, material)
trace_lines.push(geometry)
sceneManager.scene.add(line)
}
}
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
feet_trace = new Array(4).fill([])
return
} }
let { trace_lines.forEach((line, i) => {
sky = true, feet_trace[i].push(foot_positions[i])
orbit = false, feet_trace[i] = feet_trace[i].slice(-settings['Trace points'])
panel = true, line.setFromPoints(feet_trace[i])
debug = false, })
ground = true }
}: Props = $props();
let sceneManager = $state(new SceneBuilder()); const calculate_kinematics = () => {
let canvas: HTMLCanvasElement = $state(); if (sceneManager.isDragging || !settings['Internal kinematic']) return
const position: body_state_t = {
omega: settings.omega,
phi: settings.phi,
psi: settings.psi,
xm: settings.xm,
ym: settings.ym,
zm: settings.zm,
feet: body_state.feet
}
let currentModelAngles: number[] = new Array(12).fill(0); let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
let modelTargetAngles: number[] = new Array(12).fill(0); modelTargetAngles = new_angles
let gui_panel: GUI; }
let Throttler = new throttler();
let feet_trace = new Array(4).fill([]); const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []; if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
let target: Object3D<Object3DEventMap>; robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
let target_position = { x: 0, z: 0, yaw: 0 }; robot.position.z = smooth(robot.position.z, -settings.xm, 0.1)
robot.position.x = smooth(robot.position.x, -settings.zm, 0.1)
let kinematic = new Kinematic(); robot.rotation.z = smooth(robot.rotation.z, degToRad(-settings.phi + $mpu.heading + 90), 0.1)
robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1)
robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1)
}
let planners = { const update_camera = (robot: URDFRobot) => {
[ModesEnum.Deactivated]: new IdleState(), if (!settings['Fix camera on robot']) return
[ModesEnum.Idle]: new IdleState(), sceneManager.orbit.target = robot.position.clone()
[ModesEnum.Calibration]: new CalibrationState(), }
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Crawl]: new EightPhaseWalkState(),
[ModesEnum.Walk]: new BezierState()
};
let lastTick = performance.now();
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]; const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end
}
let body_state = { const update_gait = () => {
omega: 0, if (sceneManager.isDragging || !settings['Internal kinematic']) return
phi: 0, const controlData = get(outControllerData)
psi: 0, const data = {
xm: 0, lx: controlData[0],
ym: 0.5, ly: controlData[1],
zm: 0, rx: controlData[2],
feet: planners[ModesEnum.Idle].default_feet_pos ry: controlData[3],
}; h: controlData[4],
s: controlData[5],
s1: controlData[6]
}
body_state.ym = data.h
let settings = { let planner = planners[get(mode)]
'Internal kinematic': true, const delta = performance.now() - lastTick
'Robot transform controls': false, lastTick = performance.now()
'Auto orient robot': true,
'Trace feet': debug,
'Target position': false,
'Trace points': 30,
'Fix camera on robot': true,
'Smooth motion': true,
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.7,
zm: 0,
Background: 'black'
};
onMount(async () => { body_state = planner.step(body_state, data, delta)
await populateModelCache();
await createScene();
servoAngles.subscribe(updateAnglesFromStore);
if (panel) createPanel();
});
onDestroy(() => { settings.omega = body_state.omega
canvas.remove(); settings.phi = body_state.phi
gui_panel?.destroy(); settings.psi = body_state.psi
}); settings.xm = body_state.xm
settings.ym = body_state.ym
settings.zm = body_state.zm
}
const updateAnglesFromStore = (angles: number[]) => { const update_robot_position = (robot: URDFRobot) => {
if (sceneManager.isDragging) return; if (!settings['Robot transform controls']) return
if (settings['Internal kinematic']) return; settings.omega = radToDeg(robot.rotation.y)
modelTargetAngles = angles; settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90
}; settings.psi = radToDeg(robot.rotation.x) + 90
settings.xm = robot.position.z * 100
settings.zm = -robot.position.x * 100
}
const createPanel = () => { const updateTargetPosition = () => {
gui_panel = new GUI({ width: 310 }); target.visible = settings['Target position']
gui_panel.close(); target.position.x = smooth(target.position.x, target_position.x, 0.5)
gui_panel.domElement.id = 'three-gui-panel'; target.position.z = smooth(target.position.z, target_position.z, 0.5)
}
const general = gui_panel.addFolder('General'); const render = () => {
general.add(settings, 'Internal kinematic'); const robot = sceneManager.model
general.add(settings, 'Robot transform controls'); if (!robot) return
general.add(settings, 'Auto orient robot');
const kinematic = gui_panel.addFolder('Kinematics'); const toes = getToeWorldPositions(robot)
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen();
const visibility = gui_panel.addFolder('Visualization'); renderTraceLines(toes)
visibility.add(settings, 'Trace feet'); update_camera(robot)
visibility.add(settings, 'Trace points', 1, 1000, 1); update_gait()
visibility.add(settings, 'Target position'); calculate_kinematics()
visibility.add(settings, 'Smooth motion'); update_robot_position(robot)
visibility.addColor(settings, 'Background');
};
const updateKinematicPosition = () => { sceneManager.transformControl.showX = settings['Robot transform controls']
kinematicData.set([ sceneManager.transformControl.showY = settings['Robot transform controls']
settings.omega, sceneManager.transformControl.showZ = settings['Robot transform controls']
settings.phi,
settings.psi,
settings.xm,
settings.ym,
settings.zm
]);
};
const updateAngles = (name: string, angle: number) => { for (let i = 0; i < $jointNames.length; i++) {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI); currentModelAngles[i] = smooth(
Throttler.throttle( (robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))), modelTargetAngles[i],
100 0.1
); )
}; robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
}
const createScene = async () => { orient_robot(robot, toes)
sceneManager updateTargetPosition()
.addRenderer({ antialias: true, canvas, alpha: true }) }
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(8, 30, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop();
if (ground) sceneManager.addGroundPlane();
const geometry = new SphereGeometry(0.1, 32, 16);
const material = new MeshBasicMaterial({ color: 0xffff00 });
target = new Mesh(geometry, material);
sceneManager.scene.add(target);
if (debug) {
sceneManager.addDragControl(updateAngles);
}
if (sky) sceneManager.addSky();
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry();
const material = new LineBasicMaterial({ color: footColor() });
const line = new Line(geometry, material);
trace_lines.push(geometry);
sceneManager.scene.add(line);
}
};
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return;
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)));
feet_trace = new Array(4).fill([]);
return;
}
trace_lines.forEach((line, i) => {
feet_trace[i].push(foot_positions[i]);
feet_trace[i] = feet_trace[i].slice(-settings['Trace points']);
line.setFromPoints(feet_trace[i]);
});
};
const calculate_kinematics = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return;
const position: body_state_t = {
omega: settings.omega,
phi: settings.phi,
psi: settings.psi,
xm: settings.xm,
ym: settings.ym,
zm: settings.zm,
feet: body_state.feet
};
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]));
modelTargetAngles = new_angles;
};
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return;
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y));
robot.position.z = smooth(robot.position.z, -settings.xm, 0.1);
robot.position.x = smooth(robot.position.x, -settings.zm, 0.1);
robot.rotation.z = smooth(
robot.rotation.z,
degToRad(-settings.phi + $mpu.heading + 90),
0.1
);
robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1);
robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1);
};
const update_camera = (robot: URDFRobot) => {
if (!settings['Fix camera on robot']) return;
sceneManager.orbit.target = robot.position.clone();
};
const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end;
};
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return;
const controlData = get(outControllerData);
const data = {
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 + 127) * 0.75) / 100;
let planner = planners[get(mode)];
const delta = performance.now() - lastTick;
lastTick = performance.now();
body_state = planner.step(body_state, data, delta);
settings.omega = body_state.omega;
settings.phi = body_state.phi;
settings.psi = body_state.psi;
settings.xm = body_state.xm;
settings.ym = body_state.ym;
settings.zm = body_state.zm;
};
const update_robot_position = (robot: URDFRobot) => {
if (!settings['Robot transform controls']) return;
settings.omega = radToDeg(robot.rotation.y);
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90;
settings.psi = radToDeg(robot.rotation.x) + 90;
settings.xm = robot.position.z * 100;
settings.zm = -robot.position.x * 100;
};
const updateTargetPosition = () => {
target.visible = settings['Target position'];
target.position.x = smooth(target.position.x, target_position.x, 0.5);
target.position.z = smooth(target.position.z, target_position.z, 0.5);
};
const render = () => {
const robot = sceneManager.model;
if (!robot) return;
const toes = toeWorldPositions(robot);
renderTraceLines(toes);
update_camera(robot);
update_gait();
calculate_kinematics();
update_robot_position(robot);
sceneManager.transformControl.showX = settings['Robot transform controls'];
sceneManager.transformControl.showY = settings['Robot transform controls'];
sceneManager.transformControl.showZ = settings['Robot transform controls'];
for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i],
0.1
);
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]));
}
orient_robot(robot, toes);
updateTargetPosition();
};
</script> </script>
<svelte:window onresize={sceneManager.fillParent} /> <svelte:window onresize={sceneManager.fillParent} />
+6 -2
View File
@@ -35,6 +35,9 @@ export { default as Hamburger } from '~icons/mdi/hamburger-menu'
export { default as FileIcon } from '~icons/mdi/file' export { default as FileIcon } from '~icons/mdi/file'
export { default as FolderIcon } from '~icons/mdi/folder-outline' export { default as FolderIcon } from '~icons/mdi/folder-outline'
export { default as FolderOpenOutline } from '~icons/mdi/folder-open-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 Down } from '~icons/tabler/chevron-down'
export { default as Cancel } from '~icons/tabler/x' export { default as Cancel } from '~icons/tabler/x'
@@ -50,13 +53,14 @@ export { default as Power } from '~icons/tabler/power'
export { default as MAC } from '~icons/tabler/dna-2' export { default as MAC } from '~icons/tabler/dna-2'
export { default as Home } from '~icons/tabler/home' export { default as Home } from '~icons/tabler/home'
export { default as SSID } from '~icons/tabler/router' export { default as SSID } from '~icons/tabler/router'
export { default as DNS } from '~icons/tabler/address-book' export { default as DNS } from '~icons/mdi/dns'
export { default as Gateway } from '~icons/tabler/torii' export { default as Gateway } from '~icons/tabler/torii'
export { default as Subnet } from '~icons/tabler/grid-dots' export { default as Subnet } from '~icons/tabler/grid-dots'
export { default as Channel } from '~icons/tabler/antenna' export { default as Channel } from '~icons/tabler/antenna'
export { default as Scan } from '~icons/tabler/radar-2' export { default as Scan } from '~icons/tabler/radar-2'
export { default as Add } from '~icons/tabler/circle-plus' export { default as Add } from '~icons/tabler/circle-plus'
export { default as Edit } from '~icons/tabler/pencil' export { default as Edit } from '~icons/mdi/edit'
export { default as EditOff } from '~icons/mdi/edit-off'
export { default as Delete } from '~icons/tabler/trash' export { default as Delete } from '~icons/tabler/trash'
export { default as Network } from '~icons/tabler/router' export { default as Network } from '~icons/tabler/router'
@@ -1,37 +1,40 @@
<script lang="ts"> <script lang="ts">
import WidgetContainer from './WidgetContainer.svelte'; import WidgetContainer from './WidgetContainer.svelte';
import { WidgetComponents, type WidgetContainerConfig, isWidgetConfig } from '$lib/stores/application'; import {
import Widget from './Widget.svelte'; WidgetComponents,
type WidgetContainerConfig,
isWidgetConfig,
} from '$lib/stores/application';
import Widget from './Widget.svelte';
interface Props { interface Props {
container: WidgetContainerConfig; container: WidgetContainerConfig;
} }
let { container }: Props = $props(); let { container }: Props = $props();
</script> </script>
<div class="w-full h-full flex flex-col overflow-hidden"> <div class="w-full h-full flex flex-col overflow-hidden">
<div <div
class="flex w-full h-full" class="flex w-full h-full"
class:flex-row={container.layout === 'column'} class:flex-row={container.layout === 'column'}
class:flex-col={container.layout === 'row'} class:flex-col={container.layout === 'row'}
class:flex-wrap={container.layout === 'wrap'} class:flex-wrap={container.layout === 'wrap'}>
> {#each container.widgets as widget, index (widget.id + '-' + index)}
{#each container.widgets as widget, index (widget.id + '-' + index)} <Widget>
<Widget> {#if isWidgetConfig(widget)}
{#if isWidgetConfig(widget)} {@const SvelteComponent = WidgetComponents[widget.component]}
{@const SvelteComponent = WidgetComponents[widget.component]} <SvelteComponent {...widget.props} />
<SvelteComponent {...widget.props} /> {:else if widget.widgets}
{:else if widget.widgets} <WidgetContainer container={widget} />
<WidgetContainer container={widget} /> {/if}
{/if} </Widget>
</Widget> {#if index !== container.widgets.length - 1}
{#if index !== container.widgets.length - 1} <div
<div class="divider bg-base-300 m-0"
class="divider bg-base-300 m-0" class:divider-horizontal={container.layout === 'column'}>
class:divider-horizontal={container.layout === 'column'} </div>
></div> {/if}
{/if} {/each}
{/each} </div>
</div>
</div> </div>
+33 -17
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
import { base } from '$app/paths'
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags'
import GithubButton from '../menu/GithubButton.svelte' import GithubButton from '../menu/GithubButton.svelte'
import LogoButton from '../menu/LogoButton.svelte' import LogoButton from '../menu/LogoButton.svelte'
@@ -19,9 +20,10 @@
Router, Router,
AP, AP,
Copyright, Copyright,
Metrics Metrics,
DNS
} from '$lib/components/icons' } from '$lib/components/icons'
import appEnv from 'app-env' import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
const features = useFeatureFlags() const features = useFeatureFlags()
@@ -40,6 +42,10 @@
submenu?: menuItem[] submenu?: menuItem[]
} }
function withBase(path: string) {
return `${base}${path.startsWith('/') ? path : '/' + path}`
}
let menuItems = $state<menuItem[]>([]) let menuItems = $state<menuItem[]>([])
$effect(() => { $effect(() => {
@@ -47,13 +53,13 @@
{ {
title: 'Connection', title: 'Connection',
icon: WiFi, icon: WiFi,
href: '/connection', href: withBase('/connection'),
feature: !appEnv.VITE_USE_HOST_NAME feature: !PUBLIC_VITE_USE_HOST_NAME
}, },
{ {
title: 'Controller', title: 'Controller',
icon: MdiController, icon: MdiController,
href: '/controller', href: withBase('/controller'),
feature: true feature: true
}, },
{ {
@@ -64,25 +70,25 @@
{ {
title: 'I2C', title: 'I2C',
icon: Connection, icon: Connection,
href: '/peripherals/i2c', href: withBase('/peripherals/i2c'),
feature: true feature: true
}, },
{ {
title: 'Camera', title: 'Camera',
icon: Camera, icon: Camera,
href: '/peripherals/camera', href: withBase('/peripherals/camera'),
feature: $features.camera feature: $features.camera
}, },
{ {
title: 'Servo', title: 'Servo',
icon: MotorOutline, icon: MotorOutline,
href: '/peripherals/servo', href: withBase('/peripherals/servo'),
feature: true feature: true
}, },
{ {
title: 'IMU', title: 'IMU',
icon: Rotate3d, icon: Rotate3d,
href: '/peripherals/imu', href: withBase('/peripherals/imu'),
feature: $features.imu || $features.mag || $features.bmp feature: $features.imu || $features.mag || $features.bmp
} }
] ]
@@ -95,13 +101,19 @@
{ {
title: 'WiFi Station', title: 'WiFi Station',
icon: Router, icon: Router,
href: '/wifi/sta', href: withBase('/wifi/sta'),
feature: true feature: true
}, },
{ {
title: 'Access Point', title: 'Access Point',
icon: AP, icon: AP,
href: '/wifi/ap', href: withBase('/wifi/ap'),
feature: true
},
{
title: 'mDNS',
icon: DNS,
href: withBase('/wifi/mdns'),
feature: true feature: true
} }
] ]
@@ -114,25 +126,25 @@
{ {
title: 'System Status', title: 'System Status',
icon: Health, icon: Health,
href: '/system/status', href: withBase('/system/status'),
feature: true feature: true
}, },
{ {
title: 'File System', title: 'File System',
icon: Folder, icon: Folder,
href: '/system/filesystem', href: withBase('/system/filesystem'),
feature: true feature: true
}, },
{ {
title: 'System Metrics', title: 'System Metrics',
icon: Metrics, icon: Metrics,
href: '/system/metrics', href: withBase('/system/metrics'),
feature: $features.analytics feature: true
}, },
{ {
title: 'Firmware Update', title: 'Firmware Update',
icon: Update, icon: Update,
href: '/system/update', href: withBase('/system/update'),
feature: $features.ota || $features.upload_firmware || $features.download_firmware feature: $features.ota || $features.upload_firmware || $features.download_firmware
} }
] ]
@@ -165,7 +177,11 @@
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content"> <div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
<LogoButton {appName} /> <LogoButton {appName} />
<MenuList {menuItems} select={updateMenu} class="grow flex-nowrap overflow-y-auto" level="0" /> <MenuList
{menuItems}
select={updateMenu}
class="grow flex-nowrap overflow-y-auto overflow-x-hidden"
level="0" />
<div class="divider my-0"></div> <div class="divider my-0"></div>
+2 -2
View File
@@ -16,13 +16,13 @@
} }
</script> </script>
<ul class={klass + ' menu'}> <ul class={klass + ' menu w-full'}>
{#each menuItems as MenuItem[] as menuItem, i (menuItem.title)} {#each menuItems as MenuItem[] as menuItem, i (menuItem.title)}
{#if menuItem.feature} {#if menuItem.feature}
<li> <li>
{#if menuItem.submenu} {#if menuItem.submenu}
<details open={menuItem.submenu.some(subItem => subItem.active)}> <details open={menuItem.submenu.some(subItem => subItem.active)}>
<summary class="text-lg font-bold"> <summary class="font-bold">
<menuItem.icon class="h-6 w-6" /> <menuItem.icon class="h-6 w-6" />
{menuItem.title} {menuItem.title}
</summary> </summary>
@@ -1,111 +1,109 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state'
import { modals } from 'svelte-modals'; import { modals } from 'svelte-modals'
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'; import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'
import { compareVersions } from 'compare-versions'; import { compareVersions } from 'compare-versions'
import { onMount } from 'svelte'; import { onMount } from 'svelte'
import { api } from '$lib/api'; import { api } from '$lib/api'
import type { GithubRelease } from '$lib/types/models'; import type { GithubRelease } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags'; import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Cancel, CloudDown, Firmware } from '../icons'; import { Cancel, CloudDown, Firmware } from '../icons'
const features = useFeatureFlags(); const features = useFeatureFlags()
interface Props { interface Props {
update?: boolean; update?: boolean
}
let { update = $bindable(false) }: Props = $props()
let firmwareVersion: string = $state('')
let firmwareDownloadLink: string = $state('')
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}
const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${page.data.github}/releases/latest`,
{ headers }
)
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository')
return
}
if (result.isErr()) {
console.error('Error:', result.inner)
return
} }
let { update = $bindable(false) }: Props = $props(); const results = result.inner
update = false
firmwareVersion = ''
let firmwareVersion: string = $state(''); if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
let firmwareDownloadLink: string = $state(''); // iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) {
async function getGithubAPI() { // check if the asset is of type *.bin
const headers = { if (
accept: 'application/vnd.github+json', results.assets[i].name.includes('.bin') &&
'X-GitHub-Api-Version': '2022-11-28' results.assets[i].name.includes($features.firmware_built_target as string)
}; ) {
const result = await api.get<GithubRelease>( update = true
`https://api.github.com/repos/${page.data.github}/releases/latest`, firmwareVersion = results.tag_name
{ headers } firmwareDownloadLink = results.assets[i].browser_download_url
); notifications.info('Firmware update available.', 5000)
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository');
return;
}
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
const results = result.inner;
update = false;
firmwareVersion = '';
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)
) {
update = true;
firmwareVersion = results.tag_name;
firmwareDownloadLink = results.assets[i].browser_download_url;
notifications.info('Firmware update available.', 5000);
}
}
} }
}
} }
}
async function postGithubDownload(url: string) { 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()) { if (result.isErr()) {
console.error('Error:', result.inner); console.error('Error:', result.inner)
return; return
}
} }
}
onMount(async () => { onMount(async () => {
if ($features.download_firmware) { if ($features.download_firmware) {
await getGithubAPI(); await getGithubAPI()
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000); // once per hour setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour
}
});
function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url);
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
});
}
});
} }
})
function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url)
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
})
}
})
}
</script> </script>
{#if update} {#if update}
<div class="indicator flex-none"> <div class="indicator flex-none">
<button <button
class="btn btn-square btn-ghost h-9 w-9" class="btn btn-square btn-ghost h-9 w-9"
onclick={() => confirmGithubUpdate(firmwareDownloadLink)} onclick={() => confirmGithubUpdate(firmwareDownloadLink)}>
> <span
<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>
{firmwareVersion} <Firmware class="h-7 w-7" />
</span> </button>
<Firmware class="h-7 w-7" /> </div>
</button>
</div>
{/if} {/if}
+30 -30
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 = { type State = {
id: string; id: string
type: StateType; type: StateType
message: string; message: string
}; }
function createNotificationStore() { function createNotificationStore() {
const state: State[] = []; const state: State[] = []
const notifications = writable(state); const notifications = writable(state)
const { subscribe } = notifications; const { subscribe } = notifications
function send(message: string, type: StateType = 'info', timeout: number) { function send(message: string, type: StateType = 'info', timeout: number) {
const id = generateId(); const id = generateId()
setTimeout(() => { setTimeout(() => {
notifications.update((state) => { notifications.update(state => {
return state.filter((n) => n.id !== id); return state.filter(n => n.id !== id)
}); })
}, timeout); }, timeout)
notifications.update((state) => { notifications.update(state => {
return [...state, { id, type, message }]; return [...state, { id, type, message }]
}); })
} }
return { return {
subscribe, subscribe,
send, send,
error: (msg: string, timeout: number) => send(msg, 'error', timeout), error: (msg: string, timeout: number = 4000) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number) => send(msg, 'warning', timeout), warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number) => send(msg, 'info', timeout), info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout),
success: (msg: string, timeout: number) => send(msg, 'success', timeout) success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout)
}; }
} }
function generateId() { function generateId() {
return '_' + Math.random().toString(36).substr(2, 9); return '_' + Math.random().toString(36).substr(2, 9)
} }
export const notifications = createNotificationStore(); export const notifications = createNotificationStore()
@@ -1,103 +1,101 @@
<script lang="ts"> <script lang="ts">
import { daisyColor } from "$lib/utilities"; import { daisyColor } from '$lib/utilities';
import { Chart, registerables } from "chart.js"; import { Chart, registerables } from 'chart.js';
import { onMount } from "svelte"; import { onMount } from 'svelte';
import { cubicOut } from "svelte/easing"; import { cubicOut } from 'svelte/easing';
import { slide } from "svelte/transition"; import { slide } from 'svelte/transition';
let chartElement: HTMLCanvasElement = $state(); let chartElement: HTMLCanvasElement;
let chart: Chart; let chart: Chart;
interface Props { interface Props {
label: any; label: any;
data: number[]; data: number[];
title: any; title: any;
} }
let { label, data, title }: Props = $props(); let { label, data, title }: Props = $props();
Chart.register(...registerables); Chart.register(...registerables);
onMount(() => { onMount(() => {
chart = new Chart(chartElement, { chart = new Chart(chartElement, {
type: 'line', type: 'line',
data: { data: {
labels: data, labels: data,
datasets: [ datasets: [
{ {
label, label,
borderColor: daisyColor('--p'), borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--p', 50),
borderWidth: 2, borderWidth: 2,
data, data,
yAxisID: 'y' yAxisID: 'y',
}, },
] ],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false,
} },
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0,
} },
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--bc', 10),
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: title, text: title,
color: daisyColor('--bc'), color: daisyColor('--bc'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold',
} },
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: 100, max: 100,
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--bc', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--bc', 10) },
} },
} },
} },
}); });
setInterval(() => { setInterval(() => {
chart.data.labels = data chart.data.labels = data;
chart.data.datasets[0].data = data chart.data.datasets[0].data = data;
}, 500); }, 500);
}) });
</script> </script>
<div class="w-full h-full overflow-x-auto"> <div class="w-full h-full overflow-x-auto">
<div <div
class="flex w-full flex-col space-y-1 h-60" class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}>
> <canvas bind:this={chartElement}></canvas>
<canvas bind:this={chartElement}></canvas> </div>
</div>
</div> </div>
+13 -14
View File
@@ -1,20 +1,19 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
options?: string[]; options?: string[];
selectedOption?: string; selectedOption?: string;
change: () => void; change?: () => void;
[key: string]: any; [key: string]: any;
} }
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props(); let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props();
</script> </script>
<select <select
bind:value={selectedOption} bind:value={selectedOption}
{...rest} {...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}" class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}">
> {#each options as option}
{#each options as option} <option value={option}>{option}</option>
<option value={option}>{option}</option> {/each}
{/each}
</select> </select>
+360 -387
View File
@@ -1,452 +1,425 @@
import type { body_state_t } from './kinematic'; import { get } from 'svelte/store'
import { fromInt8 } from './utilities'; import type { body_state_t } from './kinematic'
import { currentKinematic } from './stores/featureFlags'
const { sin } = Math;
export interface gait_state_t { export interface gait_state_t {
step_height: number; step_height: number
step_x: number; step_x: number
step_z: number; step_z: number
step_angle: number; step_angle: number
step_velocity: number; step_velocity: number
step_depth: number; step_depth: number
} }
export interface ControllerCommand { export interface ControllerCommand {
stop: number; lx: number
lx: number; ly: number
ly: number; rx: number
rx: number; ry: number
ry: number; h: number
h: number; s: number
s: number; s1: number
s1: number;
} }
export abstract class GaitState { export abstract class GaitState {
protected abstract name: string; protected abstract name: string
protected dt = 0.02; protected dt = 0.02
protected body_state!: body_state_t; protected body_state!: body_state_t
protected gait_state: gait_state_t = { protected gait_state: gait_state_t = {
step_height: 0.4, step_height: 0.4,
step_x: 0, step_x: 0,
step_z: 0, step_z: 0,
step_angle: 0, step_angle: 0,
step_velocity: 1, step_velocity: 1,
step_depth: 0.002 step_depth: 0.002
}; }
public get default_feet_pos() { public get default_feet_pos() {
return [ return get(currentKinematic).getDefaultFeetPos()
[1, -1, 1, 1], }
[1, -1, -1, 1],
[-1, -1, 1, 1], protected get default_height() {
[-1, -1, -1, 1] return 0.5
]; }
begin() {
console.log('Starting', this.name)
}
end() {
console.log('Ending', this.name)
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
this.map_command(command)
this.body_state = body_state
this.dt = dt / 1000
return body_state
}
map_command(command: ControllerCommand) {
const newCommand = {
step_height: 0.4 + (command.s1 + 1) / 2,
step_x: command.ly,
step_z: -command.lx,
step_velocity: command.s,
step_angle: command.rx,
step_depth: 0.002
} }
protected get default_height() { this.gait_state = newCommand
return 0.5; }
}
begin() {
console.log('Starting', this.name);
}
end() {
console.log('Ending', this.name);
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
this.map_command(command);
this.body_state = body_state;
this.dt = dt / 1000;
return body_state;
}
map_command(command: ControllerCommand) {
const newCommand = {
step_height: 0.4 + (command.s1 / 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;
}
} }
export class IdleState extends GaitState { export class IdleState extends GaitState {
protected name = 'Idle'; protected name = 'Idle'
} }
export class CalibrationState extends GaitState { export class CalibrationState extends GaitState {
protected name = 'Calibration'; protected name = 'Calibration'
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { // eslint-disable-next-line @typescript-eslint/no-unused-vars
body_state.omega = 0; step(body_state: body_state_t, _command: ControllerCommand) {
body_state.phi = 0; body_state.omega = 0
body_state.psi = 0; body_state.phi = 0
body_state.xm = 0; body_state.psi = 0
body_state.ym = this.default_height * 10; body_state.xm = 0
body_state.zm = 0; body_state.ym = this.default_height * 10
body_state.feet = this.default_feet_pos; body_state.zm = 0
return body_state; body_state.feet = this.default_feet_pos
} return body_state
}
} }
export class RestState extends GaitState { export class RestState extends GaitState {
protected name = 'Rest'; protected name = 'Rest'
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { // eslint-disable-next-line @typescript-eslint/no-unused-vars
body_state.omega = 0; step(body_state: body_state_t, _command: ControllerCommand) {
body_state.phi = 0; body_state.omega = 0
body_state.psi = 0; body_state.phi = 0
body_state.xm = 0; body_state.psi = 0
body_state.ym = this.default_height / 2; body_state.xm = 0
body_state.zm = 0; body_state.ym = this.default_height / 2
body_state.feet = this.default_feet_pos; body_state.zm = 0
return body_state; body_state.feet = this.default_feet_pos
} return body_state
}
} }
export class StandState extends GaitState { export class StandState extends GaitState {
protected name = 'Stand'; protected name = 'Stand'
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerCommand) {
body_state.omega = 0; body_state.omega = 0
body_state.phi = command.rx / 8; body_state.phi = command.rx * 10 * (Math.PI / 2)
body_state.psi = command.ry / 8; body_state.psi = command.ry * 10 * (Math.PI / 2)
body_state.xm = command.ly / 2 / 100; body_state.xm = command.ly / 4
body_state.zm = command.lx / 2 / 100; body_state.zm = command.lx / 4
body_state.feet = this.default_feet_pos; body_state.feet = this.default_feet_pos
return body_state; return body_state
} }
}
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 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.update_phase();
this.update_body_position();
this.update_feet_positions();
return this.body_state;
}
update_phase() {
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() {
if (this.num_phases === 4) return;
const shift = this.shifts[Math.floor(this.phase / 2)];
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);
}
}
update_foot_position(index: number): number[] {
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 { export class BezierState extends GaitState {
protected name = 'Bezier'; protected name = 'Bezier'
protected phase = 0; protected phase = 0
protected phase_num = 0; protected phase_num = 0
protected step_length: number = 0; protected step_length = 0
offset = [0, 0.5, 0.5, 0]; protected stand_offset = 0.85
protected mode: 'crawl' | 'trot' = 'trot'
protected speed_factor = 1
offset = [0, 0.5, 0.75, 0.25]
begin() { protected shift_start_pos = { x: 0, z: 0 }
super.begin(); 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() { end() {
super.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_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
}
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { update_body_position() {
super.step(body_state, command, dt); const m = this.gait_state
this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2); const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (this.gait_state.step_x < 0) { if (!moving) return
this.step_length = -this.step_length;
if (this.mode !== 'crawl') return
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
} }
this.update_phase(); } else {
this.update_feet_positions(); swing.push(i)
return this.body_state; }
} }
update_phase() { return { stance, swing, next_swing, time_to_lift: min_time_to_swing }
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() { protected smoothstep01(t: number): number {
for (let i = 0; i < 4; i++) { const x = Math.max(0, Math.min(1, t))
this.body_state.feet[i] = this.update_foot_position(i); return x * x * (3 - 2 * x)
} }
}
update_foot_position(index: number): number[] { update_feet_positions() {
let phase = this.phase + this.offset[index]; for (let i = 0; i < 4; i++) this.body_state.feet[i] = this.update_foot_position(i)
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) { update_foot_position(index: number): number[] {
let depth = this.gait_state.step_depth; let phase = this.phase + this.offset[index]
return this.controller(index, phase, stance_curve, depth); 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))
}
swing_controller(index: number, phase: number) { stand_controller(index: number, phase: number) {
let height = this.gait_state.step_height; const depth = this.gait_state.step_depth
return this.controller(index, phase, bezier_curve, height); return this.controller(index, phase, stance_curve, depth)
} }
controller( swing_controller(index: number, phase: number) {
index: number, const height = this.gait_state.step_height
phase: number, return this.controller(index, phase, bezier_curve, height)
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);
length = this.gait_state.step_angle * 2; controller(
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index]); index: number,
phase: number,
controller: (length: number, angle: number, ...args: number[]) => number[],
...args: number[]
) {
let length = this.step_length / 2
let angle = Math.atan2(this.gait_state.step_z, this.step_length) * 2
const delta_pos = controller(length, angle, ...args, phase)
const delta_rot = controller(length, angle, ...args, phase); length = this.gait_state.step_angle * 2
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2; const delta_rot = controller(length, angle, ...args, phase)
this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2;
if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle)
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2;
return this.body_state.feet[index]; this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2
} this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2
if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle)
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2
return this.body_state.feet[index]
}
} }
const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => { const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => {
const X_POLAR = Math.cos(angle); const X_POLAR = Math.cos(angle)
const Y_POLAR = Math.sin(angle); const Y_POLAR = Math.sin(angle)
const step = length * (1 - 2 * phase); const step = length * (1 - 2 * phase)
const X = step * X_POLAR; const X = step * X_POLAR
const Z = step * Y_POLAR; const Z = step * Y_POLAR
let Y = 0; let Y = 0
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
if (length !== 0) { return [X, Y, Z]
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 yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => {
const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2); const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2)
const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0]); const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0])
const offsets = [ const offsets = [
current_foot_pos[0] - default_foot_pos[0], current_foot_pos[0] - default_foot_pos[0],
current_foot_pos[2] - default_foot_pos[2], current_foot_pos[2] - default_foot_pos[2],
current_foot_pos[1] - default_foot_pos[1] current_foot_pos[1] - default_foot_pos[1]
]; ]
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2); const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2)
const offset_mod = Math.atan2(offset_mag, foot_mag); const offset_mod = Math.atan2(offset_mag, foot_mag)
return Math.PI / 2.0 + foot_dir + offset_mod; return Math.PI / 2.0 + foot_dir + offset_mod
}; }
const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => { const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => {
const control_points = get_control_points(length, angle, height); const control_points = get_control_points(length, angle, height)
const n = control_points.length - 1; const n = control_points.length - 1
const point = [0, 0, 0]
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]
}
return point
}
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];
}
return point;
};
const get_control_points = (length: number, angle: number, height: number): number[][] => { const get_control_points = (length: number, angle: number, height: number): number[][] => {
const X_POLAR = Math.cos(angle); const X_POLAR = Math.cos(angle)
const Z_POLAR = Math.sin(angle); const Z_POLAR = Math.sin(angle)
const STEP = [ const STEP = [
-length, -length,
-length * 1.4, -length * 1.4,
-length * 1.5, -length * 1.5,
-length * 1.5, -length * 1.5,
-length * 1.5, -length * 1.5,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
length * 1.5, length * 1.5,
length * 1.5, length * 1.5,
length * 1.4, length * 1.4,
length length
]; ]
const Y = [ const Y = [
0.0, 0.0,
0.0, 0.0,
height * 0.9, height * 0.9,
height * 0.9, height * 0.9,
height * 0.9, height * 0.9,
height * 0.9, height * 0.9,
height * 0.9, height * 0.9,
height * 1.1, height * 1.1,
height * 1.1, height * 1.1,
height * 1.1, height * 1.1,
0.0, 0.0,
0.0 0.0
]; ]
const control_points: number[][] = []; const control_points: number[][] = []
for (let i = 0; i < STEP.length; i++) { for (let i = 0; i < STEP.length; i++) {
const X = STEP[i] * X_POLAR; const X = STEP[i] * X_POLAR
const Z = STEP[i] * Z_POLAR; const Z = STEP[i] * Z_POLAR
control_points.push([X, Y[i], Z]); control_points.push([X, Y[i], Z])
} }
return control_points; return control_points
}; }
const comb = (n: number, k: number): number => { const comb = (n: number, k: number): number => {
if (k < 0 || k > n) return 0; if (k < 0 || k > n) return 0
if (k === 0 || k === n) return 1; if (k === 0 || k === n) return 1
k = Math.min(k, n - k); k = Math.min(k, n - k)
let c = 1; let c = 1
for (let i = 0; i < k; i++) { for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1)
c = (c * (n - i)) / (i + 1); return c
} }
return c;
};
+127 -307
View File
@@ -1,320 +1,140 @@
export interface body_state_t { export interface body_state_t {
omega: number; omega: number
phi: number; phi: number
psi: number; psi: number
xm: number; xm: number
ym: number; ym: number
zm: number; zm: number
feet: number[][]; feet: number[][]
} }
export interface position { export interface position {
x: number; x: number
y: number; y: number
z: number; z: number
} }
export interface target_position { export interface target_position {
x: number; x: number
z: number; z: number
yaw: number; yaw: number
} }
const { cos, sin, atan2, sqrt } = Math; export interface KinematicParams {
coxa: number
coxa_offset: number
femur: number
tibia: number
L: number
W: number
}
const DEG2RAD = 0.017453292519943; const { cos, sin, atan2, acos, sqrt, max, min } = Math
const DEG2RAD = 0.017453292519943
export default class Kinematic { export default class Kinematic {
l1: number; coxa: number
l2: number; coxa_offset: number
l3: number; femur: number
l4: number; tibia: number
L: number; L: number
W: number; W: number
DEG2RAD = DEG2RAD; DEG2RAD = DEG2RAD
sHp = sin(Math.PI / 2); mountOffsets: number[][]
cHp = cos(Math.PI / 2);
invMountRot = [
Tlf: number[][] = []; [0, 0, -1],
Trf: number[][] = []; [0, 1, 0],
Tlb: number[][] = []; [1, 0, 0]
Trb: number[][] = []; ]
point_lf: number[][]; constructor(params: KinematicParams) {
point_rf: number[][]; this.coxa = params.coxa
point_lb: number[][]; this.coxa_offset = params.coxa_offset
point_rb: number[][]; this.femur = params.femur
Ix: number[][]; this.tibia = params.tibia
this.L = params.L
constructor() { this.W = params.W
this.l1 = 60.5 / 100;
this.l2 = 10 / 100; this.mountOffsets = [
this.l3 = 100.7 / 100; [this.L / 2, 0, this.W / 2],
this.l4 = 118.5 / 100; [this.L / 2, 0, -this.W / 2],
[-this.L / 2, 0, this.W / 2],
this.L = 207.5 / 100; [-this.L / 2, 0, -this.W / 2]
this.W = 78 / 100; ]
}
this.point_lf = [
[this.cHp, 0, this.sHp, this.L / 2], getDefaultFeetPos(): number[][] {
[0, 1, 0, 0], return this.mountOffsets.map((offset, i) => {
[-this.sHp, 0, this.cHp, this.W / 2], return [offset[0], -1, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)]
[0, 0, 0, 1] })
]; }
this.point_rf = [ calcIK(p: body_state_t): number[] {
[this.cHp, 0, this.sHp, this.L / 2], const roll = p.omega * this.DEG2RAD
[0, 1, 0, 0], const pitch = p.phi * this.DEG2RAD
[-this.sHp, 0, this.cHp, -this.W / 2], const yaw = p.psi * this.DEG2RAD
[0, 0, 0, 1] const rot = this.euler2R(roll, pitch, yaw)
]; const inv_rot = [
[rot[0][0], rot[1][0], rot[2][0]],
this.point_lb = [ [rot[0][1], rot[1][1], rot[2][1]],
[this.cHp, 0, this.sHp, -this.L / 2], [rot[0][2], rot[1][2], rot[2][2]]
[0, 1, 0, 0], ]
[-this.sHp, 0, this.cHp, this.W / 2], const inv_trans = [
[0, 0, 0, 1] -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
this.point_rb = [ ]
[this.cHp, 0, this.sHp, -this.L / 2], return p.feet.flatMap((foot, i) => {
[0, 1, 0, 0], const [wx, wy, wz] = foot
[-this.sHp, 0, this.cHp, -this.W / 2], const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
[0, 0, 0, 1] 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]
this.Ix = [
[-1, 0, 0, 0], const [mx, my, mz] = this.mountOffsets[i]
[0, 1, 0, 0], const px = bx - mx,
[0, 0, 1, 0], py = by - my,
[0, 0, 0, 1] pz = bz - mz
];
} const lx =
this.invMountRot[0][0] * px + this.invMountRot[0][1] * py + this.invMountRot[0][2] * pz
public calcIK(body_state: body_state_t): number[] { const ly =
this.bodyIK(body_state); this.invMountRot[1][0] * px + this.invMountRot[1][1] * py + this.invMountRot[1][2] * pz
const lz =
return [ this.invMountRot[2][0] * px + this.invMountRot[2][1] * py + this.invMountRot[2][2] * pz
...this.legIK(this.multiplyVector(this.inverse(this.Tlf), body_state.feet[0])),
...this.legIK( const xLocal = i % 2 === 1 ? -lx : lx
this.multiplyVector( return this.legIK(xLocal, ly, lz)
this.Ix, })
this.multiplyVector(this.inverse(this.Trf), body_state.feet[1]) }
)
), private legIK(x: number, y: number, z: number): [number, number, number] {
...this.legIK(this.multiplyVector(this.inverse(this.Tlb), body_state.feet[2])), const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa))
...this.legIK( const G = F - this.coxa_offset
this.multiplyVector( const H = sqrt(G * G + z * z)
this.Ix, const t1 = -atan2(y, x) - atan2(F, -this.coxa)
this.multiplyVector(this.inverse(this.Trb), body_state.feet[3]) 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]
}
bodyIK(p: body_state_t) {
const cos_omega = cos(p.omega * this.DEG2RAD); private euler2R(roll: number, pitch: number, yaw: number): number[][] {
const sin_omega = sin(p.omega * this.DEG2RAD); const cr = cos(roll),
const cos_phi = cos(p.phi * this.DEG2RAD); sr = sin(roll)
const sin_phi = sin(p.phi * this.DEG2RAD); const cp = cos(pitch),
const cos_psi = cos(p.psi * this.DEG2RAD); sp = sin(pitch)
const sin_psi = sin(p.psi * this.DEG2RAD); const cy = cos(yaw),
sy = sin(yaw)
const Tm: number[][] = [ return [
[cos_phi * cos_psi, -sin_psi * cos_phi, sin_phi, p.xm], [cp * cy, -cp * sy, sp],
[ [sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp],
sin_omega * sin_phi * cos_psi + sin_psi * cos_omega, [sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp]
-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;
}
} }
+349 -348
View File
@@ -1,379 +1,380 @@
import { import {
Mesh, Mesh,
PerspectiveCamera, PerspectiveCamera,
PlaneGeometry, PlaneGeometry,
Scene, Scene,
WebGLRenderer, WebGLRenderer,
AmbientLight, AmbientLight,
DirectionalLight, DirectionalLight,
PCFSoftShadowMap, PCFSoftShadowMap,
type GridHelper, type GridHelper,
ArrowHelper, ArrowHelper,
Vector3, Vector3,
FogExp2, FogExp2,
CanvasTexture, CanvasTexture,
type ColorRepresentation, type ColorRepresentation,
type WebGLRendererParameters, type WebGLRendererParameters,
MeshPhongMaterial, MeshPhongMaterial,
EquirectangularReflectionMapping, EquirectangularReflectionMapping,
ACESFilmicToneMapping, ACESFilmicToneMapping,
MathUtils, MathUtils,
Group, Group,
MeshBasicMaterial, MeshBasicMaterial,
RepeatWrapping RepeatWrapping
} from 'three'; } from 'three'
import { Sky } from 'three/addons/objects/Sky.js'; import { Sky } from 'three/addons/objects/Sky.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'; import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import { Reflector } from 'three/examples/jsm/objects/Reflector.js'; import { Reflector } from 'three/examples/jsm/objects/Reflector.js'
import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader'; import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader'
import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls'; import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls'
import { sunCalculator } from './utilities/position-utilities'; import { sunCalculator } from './utilities/position-utilities'
export const addScene = () => new Scene(); export const addScene = () => new Scene()
interface position { interface position {
x?: number; x?: number
y?: number; y?: number
z?: number; z?: number
} }
interface light { interface light {
color?: ColorRepresentation; color?: ColorRepresentation
intensity?: number; intensity?: number
} }
interface arrowOptions { interface arrowOptions {
origin: position; origin: position
direction: position; direction: position
length?: number; length?: number
color?: ColorRepresentation; color?: ColorRepresentation
} }
type directionalLight = position & light; type directionalLight = position & light
export default class SceneBuilder { export default class SceneBuilder {
public scene: Scene; public scene: Scene
public camera!: PerspectiveCamera; public camera!: PerspectiveCamera
public ground!: Mesh; public ground!: Mesh
public renderer!: WebGLRenderer; public renderer!: WebGLRenderer
public orbit: OrbitControls; public orbit: OrbitControls
public callback: Function | undefined; public callback: Function | undefined
public gridHelper!: GridHelper; public gridHelper!: GridHelper
public model!: URDFRobot; public model!: URDFRobot
public liveStreamTexture!: CanvasTexture; public liveStreamTexture!: CanvasTexture
private fog!: FogExp2; private fog!: FogExp2
private isLoaded: boolean = false; private isLoaded: boolean = false
public isDragging: boolean = false; public isDragging: boolean = false
highlightMaterial: any; highlightMaterial: any
sky!: Sky; sky!: Sky
transformControl: TransformControls; transformControl: TransformControls
public modelGroup!: Group; public modelGroup!: Group
constructor() { constructor() {
this.scene = new Scene(); this.scene = new Scene()
if (this.scene.environment?.mapping) { if (this.scene.environment?.mapping) {
this.scene.environment.mapping = EquirectangularReflectionMapping; this.scene.environment.mapping = EquirectangularReflectionMapping
} }
return this; return this
}
public addRenderer = (parameters?: WebGLRendererParameters) => {
this.renderer = new WebGLRenderer(parameters)
this.renderer.outputColorSpace = 'srgb'
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = PCFSoftShadowMap
this.renderer.toneMapping = ACESFilmicToneMapping
this.renderer.toneMappingExposure = 0.85
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement)
return this
}
public addSky = () => {
this.sky = new Sky()
this.sky.scale.setScalar(450000)
this.scene.add(this.sky)
const effectController = {
turbidity: 10,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: sunCalculator.calculateSunElevation(),
azimuth: 200,
exposure: this.renderer.toneMappingExposure
}
const uniforms = this.sky.material.uniforms
uniforms['turbidity'].value = effectController.turbidity
uniforms['rayleigh'].value = effectController.rayleigh
uniforms['mieCoefficient'].value = effectController.mieCoefficient
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG
this.renderer.toneMappingExposure = 0.5
const phi = MathUtils.degToRad(90 - effectController.elevation)
const theta = MathUtils.degToRad(effectController.azimuth)
const sun = new Vector3()
sun.setFromSphericalCoords(1, phi, theta)
uniforms['sunPosition'].value.copy(sun)
return this
}
public addPerspectiveCamera = (options: position) => {
this.camera = new PerspectiveCamera()
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0)
this.scene.add(this.camera)
return this
}
public addGroundPlane = (options?: position) => {
const checkerboardTexture = this.createCheckerboardTexture(1024, 2)
checkerboardTexture.wrapS = RepeatWrapping
checkerboardTexture.wrapT = RepeatWrapping
checkerboardTexture.repeat.set(100, 100)
const checkerboardMat = new MeshBasicMaterial({
map: checkerboardTexture,
opacity: 0.1,
transparent: true
})
const plane = new PlaneGeometry(400, 400)
this.ground = new Mesh(plane, checkerboardMat)
this.ground.rotation.x = -Math.PI / 2
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0)
this.ground.receiveShadow = true
this.scene.add(this.ground)
const mirror = new Reflector(plane, {
clipBias: 0.003,
textureWidth: window.innerWidth * window.devicePixelRatio,
textureHeight: window.innerHeight * window.devicePixelRatio,
color: 0x00bfff
})
mirror.rotateX(-Math.PI / 2)
this.scene.add(mirror)
return this
}
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement)
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2
this.orbit.maxDistance = maxDistance
this.orbit.autoRotate = autoRotate
this.orbit.update()
this.orbit.minDistance = minDistance
return this
}
public addAmbientLight = (options: light) => {
const ambientLight = new AmbientLight(options.color, options.intensity)
this.scene.add(ambientLight)
return this
}
public addDirectionalLight = (options: directionalLight) => {
const directionalLight = new DirectionalLight(options.color, options.intensity)
directionalLight.castShadow = true
directionalLight.shadow.camera.top = 10
directionalLight.shadow.camera.bottom = -10
directionalLight.shadow.camera.right = 10
directionalLight.shadow.camera.left = -10
directionalLight.shadow.mapSize.set(4096, 4096)
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0)
this.scene.add(directionalLight)
return this
}
private createCheckerboardTexture = (size: number, squares: number) => {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
const squareSize = size / squares
for (let y = 0; y < squares; y++) {
for (let x = 0; x < squares; x++) {
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000'
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize)
}
} }
public addRenderer = (parameters?: WebGLRendererParameters) => { const texture = new CanvasTexture(canvas)
this.renderer = new WebGLRenderer(parameters); texture.wrapS = texture.wrapT = RepeatWrapping
this.renderer.outputColorSpace = 'srgb'; texture.anisotropy = 16
this.renderer.shadowMap.enabled = true; return texture
this.renderer.shadowMap.type = PCFSoftShadowMap; }
this.renderer.toneMapping = ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 0.85;
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement);
return this;
};
public addSky = () => { public addFogExp2 = (color: ColorRepresentation, density?: number) => {
this.sky = new Sky(); this.scene.fog = new FogExp2(color, density)
this.sky.scale.setScalar(450000); return this
this.scene.add(this.sky); }
const effectController = {
turbidity: 10,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: sunCalculator.calculateSunElevation(),
azimuth: 200,
exposure: this.renderer.toneMappingExposure
};
const uniforms = this.sky.material.uniforms;
uniforms['turbidity'].value = effectController.turbidity;
uniforms['rayleigh'].value = effectController.rayleigh;
uniforms['mieCoefficient'].value = effectController.mieCoefficient;
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG;
this.renderer.toneMappingExposure = 0.5;
const phi = MathUtils.degToRad(90 - effectController.elevation);
const theta = MathUtils.degToRad(effectController.azimuth);
const sun = new Vector3();
sun.setFromSphericalCoords(1, phi, theta); public fillParent = () => {
uniforms['sunPosition'].value.copy(sun); const parentElement = this.renderer.domElement.parentElement
return this; if (parentElement) {
}; const width = parentElement.clientWidth
const height = parentElement.clientHeight
public addPerspectiveCamera = (options: position) => { this.handleResize(width, height)
this.camera = new PerspectiveCamera();
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0);
this.scene.add(this.camera);
return this;
};
public addGroundPlane = (options?: position) => {
const checkerboardTexture = this.createCheckerboardTexture(1024, 2);
checkerboardTexture.wrapS = RepeatWrapping;
checkerboardTexture.wrapT = RepeatWrapping;
checkerboardTexture.repeat.set(100, 100);
const checkerboardMat = new MeshBasicMaterial({
map: checkerboardTexture,
opacity: 0.1,
transparent: true
});
const plane = new PlaneGeometry(400, 400);
this.ground = new Mesh(plane, checkerboardMat);
this.ground.rotation.x = -Math.PI / 2;
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0);
this.ground.receiveShadow = true;
this.scene.add(this.ground);
const mirror = new Reflector(plane, {
clipBias: 0.003,
textureWidth: window.innerWidth * window.devicePixelRatio,
textureHeight: window.innerHeight * window.devicePixelRatio,
color: 0x00bfff
});
mirror.rotateX(-Math.PI / 2);
this.scene.add(mirror);
return this;
};
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement);
this.orbit.minDistance = minDistance;
this.orbit.maxDistance = maxDistance;
this.orbit.autoRotate = autoRotate;
this.orbit.update();
return this;
};
public addAmbientLight = (options: light) => {
const ambientLight = new AmbientLight(options.color, options.intensity);
this.scene.add(ambientLight);
return this;
};
public addDirectionalLight = (options: directionalLight) => {
const directionalLight = new DirectionalLight(options.color, options.intensity);
directionalLight.castShadow = true;
directionalLight.shadow.camera.top = 10;
directionalLight.shadow.camera.bottom = -10;
directionalLight.shadow.camera.right = 10;
directionalLight.shadow.camera.left = -10;
directionalLight.shadow.mapSize.set(4096, 4096);
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
this.scene.add(directionalLight);
return this;
};
private createCheckerboardTexture = (size: number, squares: number) => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
const squareSize = size / squares;
for (let y = 0; y < squares; y++) {
for (let x = 0; x < squares; x++) {
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000';
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize);
}
}
const texture = new CanvasTexture(canvas);
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.anisotropy = 16;
return texture;
};
public addFogExp2 = (color: ColorRepresentation, density?: number) => {
this.scene.fog = new FogExp2(color, density);
return this;
};
public fillParent = () => {
const parentElement = this.renderer.domElement.parentElement;
if (parentElement) {
const width = parentElement.clientWidth;
const height = parentElement.clientHeight;
this.handleResize(width, height);
}
return this;
};
public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
return this;
};
public addRenderCb = (callback: Function) => {
this.callback = callback;
return this;
};
public startRenderLoop = () => {
this.renderer.setAnimationLoop(() => {
this.renderer.render(this.scene, this.camera);
this.orbit.update();
this.handleRobotShadow();
if (this.callback) this.callback();
if (!this.liveStreamTexture) return;
});
return this;
};
public addArrowHelper = (options?: arrowOptions) => {
const dir = new Vector3(
options?.direction.x ?? 0,
options?.direction.y ?? 0,
options?.direction.z ?? 0
);
const origin = new Vector3(
options?.origin.x ?? 0,
options?.origin.y ?? 0,
options?.origin.z ?? 0
);
const arrowHelper = new ArrowHelper(
dir,
origin,
options?.length ?? 1.5,
options?.color ?? 0xff0000
);
this.scene.add(arrowHelper);
return this;
};
private setJointValue(jointName: string, angle: number) {
if (!this.model) return;
if (!this.model.joints[jointName]) return;
this.model.joints[jointName].setJointValue(angle);
} }
return this
}
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'; public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
this.renderer.setSize(width, height)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
return this
}
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => { public addRenderCb = (callback: Function) => {
const traverse = (c: any) => { this.callback = callback
if (c.type === 'Mesh') { return this
if (revert) { }
c.material = c.__origMaterial;
delete c.__origMaterial;
} else {
c.__origMaterial = c.material;
c.material = material;
}
}
if (c === m || !this.isJoint(c)) { public startRenderLoop = () => {
for (let i = 0; i < c.children.length; i++) { this.renderer.setAnimationLoop(() => {
const child = c.children[i]; this.renderer.render(this.scene, this.camera)
if (!child.isURDFCollider) { this.orbit.update()
traverse(c.children[i]); this.handleRobotShadow()
} if (this.callback) this.callback()
} if (!this.liveStreamTexture) return
} })
}; return this
traverse(m); }
};
public addTransformControls = (model: any) => { public addArrowHelper = (options?: arrowOptions) => {
this.transformControl = new TransformControls(this.camera, this.renderer.domElement); const dir = new Vector3(
this.transformControl.addEventListener('dragging-changed', (event: any) => { options?.direction.x ?? 0,
this.orbit.enabled = !event.value; options?.direction.y ?? 0,
this.isDragging = !event.value; options?.direction.z ?? 0
}); )
this.transformControl.attach(model); const origin = new Vector3(
this.scene.add(this.transformControl); options?.origin.x ?? 0,
this.transformControl.setMode('rotate'); options?.origin.y ?? 0,
return this; options?.origin.z ?? 0
}; )
const arrowHelper = new ArrowHelper(
dir,
origin,
options?.length ?? 1.5,
options?.color ?? 0xff0000
)
this.scene.add(arrowHelper)
return this
}
public addModel = (model: any) => { private setJointValue(jointName: string, angle: number) {
this.modelGroup = new Group(); if (!this.model) return
this.modelGroup.add(model); if (!this.model.joints[jointName]) return
this.model = model; this.model.joints[jointName].setJointValue(angle)
this.scene.add(this.modelGroup); }
return this;
};
public addDragControl = (updateAngle: any) => { isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'
const highlightColor = '#FFFFFF';
const highlightMaterial = new MeshPhongMaterial({
shininess: 10,
color: highlightColor,
emissive: highlightColor,
emissiveIntensity: 0.9
});
const dragControls = new PointerURDFDragControls( highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
this.scene, const traverse = (c: any) => {
this.camera, if (c.type === 'Mesh') {
this.renderer.domElement if (revert) {
); c.material = c.__origMaterial
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => { delete c.__origMaterial
this.setJointValue(joint.name, angle); } else {
updateAngle(joint.name, angle); c.__origMaterial = c.material
}; c.material = material
dragControls.onDragStart = () => { }
this.orbit.enabled = false; }
this.isDragging = true;
};
dragControls.onDragEnd = () => {
this.orbit.enabled = true;
this.isDragging = false;
};
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial);
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial);
this.renderer.domElement.addEventListener( if (c === m || !this.isJoint(c)) {
'touchstart', for (let i = 0; i < c.children.length; i++) {
data => dragControls._mouseDown(data.touches[0]), const child = c.children[i]
{ passive: true } if (!child.isURDFCollider) {
); traverse(c.children[i])
this.renderer.domElement.addEventListener( }
'touchmove', }
data => dragControls._mouseMove(data.touches[0]), }
{ passive: true } }
); traverse(m)
this.renderer.domElement.addEventListener( }
'touchend',
data => dragControls._mouseUp(data.touches[0]),
{ passive: true }
);
return this;
};
public toggleFog = () => { public addTransformControls = (model: any) => {
this.scene.fog = this.scene.fog ? null : this.fog; this.transformControl = new TransformControls(this.camera, this.renderer.domElement)
}; this.transformControl.addEventListener('dragging-changed', (event: any) => {
this.orbit.enabled = !event.value
this.isDragging = !event.value
})
this.transformControl.attach(model)
this.scene.add(this.transformControl)
this.transformControl.setMode('rotate')
return this
}
private handleRobotShadow = () => { public addModel = (model: any) => {
if (this.isLoaded) return; this.modelGroup = new Group()
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10); this.modelGroup.add(model)
setTimeout(() => clearInterval(intervalId), 1000); this.model = model
this.isLoaded = true; this.scene.add(this.modelGroup)
}; return this
}
public addDragControl = (updateAngle: any) => {
const highlightColor = '#FFFFFF'
const highlightMaterial = new MeshPhongMaterial({
shininess: 10,
color: highlightColor,
emissive: highlightColor,
emissiveIntensity: 0.9
})
const dragControls = new PointerURDFDragControls(
this.scene,
this.camera,
this.renderer.domElement
)
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
this.setJointValue(joint.name, angle)
updateAngle(joint.name, angle)
}
dragControls.onDragStart = () => {
this.orbit.enabled = false
this.isDragging = true
}
dragControls.onDragEnd = () => {
this.orbit.enabled = true
this.isDragging = false
}
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial)
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial)
this.renderer.domElement.addEventListener(
'touchstart',
data => dragControls._mouseDown(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchmove',
data => dragControls._mouseMove(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchend',
data => dragControls._mouseUp(data.touches[0]),
{ passive: true }
)
return this
}
public toggleFog = () => {
this.scene.fog = this.scene.fog ? null : this.fog
}
private handleRobotShadow = () => {
if (this.isLoaded) return
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10)
setTimeout(() => clearInterval(intervalId), 1000)
this.isLoaded = true
}
} }
+56 -14
View File
@@ -1,20 +1,62 @@
import { api } from '$lib/api'; import { api } from '$lib/api'
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications'
import { writable, type Writable } from 'svelte/store'; import Kinematic from '$lib/kinematic'
import { persistentStore } from '$lib/utilities'
import { derived, type Writable } from 'svelte/store'
import { base } from '$app/paths'
let featureFlagsStore: Writable<Record<string, boolean>>; let featureFlagsStore: Writable<Record<string, boolean | string>>
export function useFeatureFlags() { export function useFeatureFlags() {
if (!featureFlagsStore) { if (!featureFlagsStore) {
featureFlagsStore = writable<Record<string, boolean>>({}); featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
api.get<Record<string, boolean>>('/api/features').then((result) => { api.get<Record<string, boolean>>('/api/features').then(result => {
if (result.isOk()) featureFlagsStore.set(result.inner); if (result.isOk()) featureFlagsStore.set(result.inner)
else { 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
@@ -0,0 +1,47 @@
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 { persistentStore } from '$lib/utilities';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import appEnv from 'app-env'; import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public';
export const location = appEnv.VITE_USE_HOST_NAME ? writable('') : persistentStore('location', ''); export const location = PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '');
+41 -32
View File
@@ -1,45 +1,54 @@
import type { ControllerInput } from '$lib/types/models'; import type { ControllerInput } from '$lib/types/models'
import { persistentStore } from '$lib/utilities/svelte-utilities'; import { persistentStore } from '$lib/utilities/svelte-utilities'
import { writable, type Writable } from 'svelte/store'; import { writable, type Writable } from 'svelte/store'
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 = [ export const modes = ['deactivated', 'idle', 'calibration', 'rest', 'stand', 'walk'] as const
'deactivated',
'idle',
'calibration',
'rest',
'stand',
'crawl',
'walk'
] as const;
export type Modes = (typeof modes)[number]; export type Modes = (typeof modes)[number]
export enum ModesEnum { export enum ModesEnum {
Deactivated, Deactivated = 0,
Idle, Idle = 1,
Calibration, Calibration = 2,
Rest, Rest = 3,
Stand, Stand = 4,
Crawl, Walk = 5
Walk
} }
export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated); export enum WalkGaits {
Trot = 0,
Crawl = 1
}
export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0]); export const walkGaits = ['trot', 'crawl'] as const
export const kinematicData = writable([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 input: Writable<ControllerInput> = writable({ export const input: Writable<ControllerInput> = writable({
left: { x: 0, y: 0 }, left: { x: 0, y: 0 },
right: { x: 0, y: 0 }, right: { x: 0, y: 0 },
height: 50, height: 0.5,
speed: 50, speed: 0.5,
s1: 50 s1: 0.05
}); })
-27
View File
@@ -1,27 +0,0 @@
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);
};
});
+138 -100
View File
@@ -1,122 +1,160 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { encode, decode } from '@msgpack/msgpack';
const socketEvents = ['open', 'close', 'error', 'message', 'unresponsive'] as const; const socketEvents = ['open', 'close', 'error', 'message', 'unresponsive'] as const;
type SocketEvent = (typeof socketEvents)[number]; 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() { function createWebSocket() {
let listeners = new Map<string, Set<(data?: unknown) => void>>(); const listeners = new Map<string, Set<(data?: unknown) => void>>();
const { subscribe, set } = writable(false); const { subscribe, set } = writable(false);
const reconnectTimeoutTime = 5000; const reconnectTimeoutTime = 5000;
let unresponsiveTimeoutId: number; let unresponsiveTimeoutId: ReturnType<typeof setTimeout>;
let reconnectTimeoutId: number; let reconnectTimeoutId: ReturnType<typeof setTimeout>;
let ws: WebSocket; let ws: WebSocket;
let socketUrl: string | URL; let socketUrl: string | URL;
function init(url: string | URL) { function init(url: string | URL) {
socketUrl = url; socketUrl = url;
connect(); connect();
} }
function disconnect(reason: SocketEvent, event?: Event) { function disconnect(reason: SocketEvent, event?: Event) {
ws.close(); ws.close();
set(false); set(false);
clearTimeout(unresponsiveTimeoutId); clearTimeout(unresponsiveTimeoutId);
clearTimeout(reconnectTimeoutId); clearTimeout(reconnectTimeoutId);
listeners.get(reason)?.forEach((listener) => listener(event)); listeners.get(reason)?.forEach(listener => listener(event));
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime); reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime);
} }
function connect() { function connect() {
ws = new WebSocket(socketUrl); ws = new WebSocket(socketUrl);
ws.onopen = (ev) => { ws.binaryType = 'arraybuffer';
set(true); ws.onopen = ev => {
clearTimeout(reconnectTimeoutId); ping();
listeners.get('open')?.forEach((listener) => listener(ev)); useBinary = true;
for (const event of listeners.keys()) { ping();
if (socketEvents.includes(event as SocketEvent)) continue; set(true);
subscribeToEvent(event); clearTimeout(reconnectTimeoutId);
} listeners.get('open')?.forEach(listener => listener(ev));
}; for (const event of listeners.keys()) {
ws.onmessage = (message) => { if (socketEvents.includes(event as SocketEvent)) continue;
resetUnresponsiveCheck(); subscribeToEvent(event);
let data = message.data; }
if (data instanceof ArrayBuffer) { };
listeners.get('binary')?.forEach((listener) => listener(data)); ws.onmessage = frame => {
return; resetUnresponsiveCheck();
} const message = decodeMessage(frame.data);
data = data.substring(1); if (!message) return;
const [, event, payload = undefined] = message;
if (event) listeners.get(event)?.forEach(listener => listener(payload));
};
ws.onerror = ev => disconnect('error', ev);
ws.onclose = ev => disconnect('close', ev);
}
if (!data) return; function unsubscribe(event: string, listener?: (data: unknown) => void) {
const eventListeners = listeners.get(event);
if (!eventListeners) return;
let event = data.substring(data.indexOf('/') + 1, data.indexOf('[')); if (!eventListeners.size) {
let payload = data.substring(data.indexOf('[') + 1, data.lastIndexOf(']')); unsubscribeToEvent(event);
}
if (listener) {
eventListeners?.delete(listener);
} else {
listeners.delete(event);
}
}
try { function resetUnresponsiveCheck() {
payload = JSON.parse(payload); clearTimeout(unresponsiveTimeoutId);
} catch (error) {} unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime);
if (event) listeners.get(event)?.forEach((listener) => listener(payload)); }
};
ws.onerror = (ev) => disconnect('error', ev);
ws.onclose = (ev) => disconnect('close', ev);
}
function unsubscribe(event: string, listener?: (data: any) => void) { function sendEvent(event: string, data: unknown) {
let eventListeners = listeners.get(event); if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (!eventListeners) return; send([2, event, data]);
}
if (!eventListeners.size) { function unsubscribeToEvent(event: string) {
unsubscribeToEvent(event); if (!ws || ws.readyState !== WebSocket.OPEN) return;
} send([1, event]);
if (listener) { }
eventListeners?.delete(listener);
} else {
listeners.delete(event);
}
}
function resetUnresponsiveCheck() { function subscribeToEvent(event: string) {
clearTimeout(unresponsiveTimeoutId); if (!ws || ws.readyState !== WebSocket.OPEN) return;
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime); send([0, event]);
} }
function sendEvent(event: string, data: unknown) { function send(data: unknown) {
if (!ws || ws.readyState !== WebSocket.OPEN) return; if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(`2/${event}[${JSON.stringify(data)}]`); const serialized = encodeMessage(data);
} if (!serialized) {
console.error('Could not serialize data:', data);
return;
}
ws.send(serialized);
}
function unsubscribeToEvent(event: string) { function ping() {
if (!ws || ws.readyState !== WebSocket.OPEN) return; const serialized = encodeMessage([4]);
ws.send('1/' + event); if (!serialized) {
} console.error('Could not serialize message');
return;
}
ws.send(serialized);
}
function subscribeToEvent(event: string) { return {
if (!ws || ws.readyState !== WebSocket.OPEN) return; subscribe,
ws.send('0/' + event); sendEvent,
} init,
on: <T>(event: string, listener: (data: T) => void): (() => void) => {
let eventListeners = listeners.get(event);
if (!eventListeners) {
if (!socketEvents.includes(event as SocketEvent)) {
subscribeToEvent(event);
}
eventListeners = new Set();
listeners.set(event, eventListeners);
}
eventListeners.add(listener as (data: unknown) => void);
return { return () => {
subscribe, unsubscribe(event, listener as (data: unknown) => void);
sendEvent, };
init, },
on: <T>(event: string, listener: (data: T) => void): (() => void) => { off: <T>(event: string, listener?: (data: T) => void) => {
let eventListeners = listeners.get(event); unsubscribe(event, listener as (data: unknown) => void);
if (!eventListeners) { },
if (!socketEvents.includes(event as SocketEvent)) { };
subscribeToEvent(event);
}
eventListeners = new Set();
listeners.set(event, eventListeners);
}
eventListeners.add(listener as (data: any) => void);
return () => {
unsubscribe(event, listener);
};
},
off: (event: string, listener?: (data: any) => void) => {
unsubscribe(event, listener);
}
};
} }
export const socket = createWebSocket(); export const socket = createWebSocket();
+195 -134
View File
@@ -1,178 +1,239 @@
export type vector = { x: number; y: number }; 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 interface ControllerInput { export interface ControllerInput {
left: vector; left: vector
right: vector; right: vector
height: number; height: number
speed: number; speed: number
s1: number; s1: number
} }
export type GithubRelease = { export type GithubRelease = {
message: string; message: string
tag_name: string; tag_name: string
assets: Array<{ assets: Array<{
name: string; name: string
browser_download_url: string; browser_download_url: string
}>; }>
}; }
export type angles = number[] | Int16Array; export type angles = number[] | Int16Array
export type WifiStatus = { export type WifiStatus = {
status: number; status: number
local_ip: string; local_ip: string
mac_address: string; mac_address: string
rssi: number; rssi: number
ssid: string; ssid: string
bssid: string; bssid: string
channel: number; channel: number
subnet_mask: string; subnet_mask: string
gateway_ip: string; gateway_ip: string
dns_ip_1: string; dns_ip_1: string
dns_ip_2?: string; dns_ip_2?: string
}; }
export type WifiSettings = { export type WifiSettings = {
hostname: string; hostname: string
priority_RSSI: boolean; priority_RSSI: boolean
wifi_networks: KnownNetworkItem[]; wifi_networks: KnownNetworkItem[]
}; }
export type NetworkList = { export type NetworkList = {
networks: NetworkItem[]; networks: NetworkItem[]
}; }
export type KnownNetworkItem = { export type KnownNetworkItem = {
ssid: string; ssid: string
password: string; password: string
static_ip_config: boolean; static_ip_config: boolean
local_ip?: string; local_ip?: string
subnet_mask?: string; subnet_mask?: string
gateway_ip?: string; gateway_ip?: string
dns_ip_1?: string; dns_ip_1?: string
dns_ip_2?: string; dns_ip_2?: string
}; }
export type NetworkItem = { export type NetworkItem = {
rssi: number; rssi: number
ssid: string; ssid: string
bssid: string; bssid: string
channel: number; channel: number
encryption_type: number; encryption_type: number
}; }
export type ApStatus = { export type ApStatus = {
status: number; status: number
ip_address: string; ip_address: string
mac_address: string; mac_address: string
station_num: number; station_num: number
}; }
export type ApSettings = { export type ApSettings = {
provision_mode: number; provision_mode: number
ssid: string; ssid: string
password: string; password: string
channel: number; channel: number
ssid_hidden: boolean; ssid_hidden: boolean
max_clients: number; max_clients: number
local_ip: string; local_ip: string
gateway_ip: string; gateway_ip: string
subnet_mask: string; subnet_mask: string
}; }
export type DownloadOTA = { export type DownloadOTA = {
status: string; status: string
progress: number; progress: number
error: string; error: string
}; }
export type Analytics = { export type Analytics = {
max_alloc_heap: number; max_alloc_heap: number
psram_size: number; psram_size: number
free_psram: number; free_psram: number
free_heap: number; free_heap: number
total_heap: number; total_heap: number
min_free_heap: number; min_free_heap: number
core_temp: number; core_temp: number
fs_total: number; fs_total: number
fs_used: number; fs_used: number
uptime: number; uptime: number
cpu0_usage: number; cpu0_usage: number
cpu1_usage: number; cpu1_usage: number
cpu_usage: number; cpu_usage: number
}; }
export type Rssi = { export type Rssi = {
rssi: number; rssi: number
ssid: string; ssid: string
}; }
export type StaticSystemInformation = { export type StaticSystemInformation = {
esp_platform: string; esp_platform: string
firmware_version: string; firmware_version: string
cpu_freq_mhz: number; cpu_freq_mhz: number
cpu_type: string; cpu_type: string
cpu_rev: number; cpu_rev: number
cpu_cores: number; cpu_cores: number
sketch_size: number; sketch_size: number
free_sketch_space: number; free_sketch_space: number
sdk_version: string; sdk_version: string
arduino_version: string; arduino_version: string
flash_chip_size: number; flash_chip_size: number
flash_chip_speed: number; flash_chip_speed: number
cpu_reset_reason: string; cpu_reset_reason: string
}; }
export type SystemInformation = Analytics & StaticSystemInformation; export type SystemInformation = Analytics & StaticSystemInformation
export type IMU = { export type IMU = {
x: number; x: number
y: number; y: number
z: number; z: number
heading: number; heading: number
altitude: number; altitude: number
bmp_temp: number; bmp_temp: number
pressure: number; pressure: number
}; }
export interface I2CDevice { export interface I2CDevice {
address: number; address: number
part_number: string; part_number: string
name: string; name: string
}
export type PinConfig = {
pin: number
mode: string
type: string
role: string
}
export type PeripheralsConfiguration = {
sda: number
scl: number
frequency: number
pins: PinConfig[]
} }
export type CameraSettings = { export type CameraSettings = {
framesize: number; framesize: number
quality: number; quality: number
brightness: number; brightness: number
contrast: number; contrast: number
saturation: number; saturation: number
sharpness: number; sharpness: number
denoise: number; denoise: number
special_effect: number; special_effect: number
wb_mode: number; wb_mode: number
vflip: boolean; vflip: boolean
hmirror: boolean; hmirror: boolean
}; }
export type File = number; export type File = number
export interface Directory { export interface Directory {
[key: string]: File | Directory; [key: string]: File | Directory
} }
export type Servo = { export type Servo = {
name: string; name: string
channel: number; channel: number
inverted: boolean; inverted: boolean
angle: number; angle: number
center_angle: number; center_angle: number
}; }
export type ServoConfiguration = { export type ServoConfiguration = {
is_active: boolean; is_active: boolean
servo_pwm_frequency: number; servo_pwm_frequency: number
servo_oscillator_frequency: number; servo_oscillator_frequency: number
servos: Servo[]; servos: Servo[]
}; }
export interface MDNSServiceQuery {
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[]
}
+4 -2
View File
@@ -1,4 +1,6 @@
export const daisyColor = (name: string, opacity: number = 100) => { export const daisyColor = (name: string, opacity: number = 100) => {
const color = getComputedStyle(document.documentElement).getPropertyValue(name); const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return `oklch(${color} / ${opacity}%)`; if (opacity >= 100) return color;
const alpha = Math.min(Math.max(opacity, 0), 100) / 100;
return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`;
}; };
+80 -76
View File
@@ -1,89 +1,93 @@
import { Color, LoaderUtils, Vector3 } from 'three'; import { Color, LoaderUtils, Vector3 } from 'three'
import URDFLoader, { type URDFRobot } from 'urdf-loader'; import URDFLoader, { type URDFRobot } from 'urdf-loader'
import { XacroLoader } from 'xacro-parser'; import { XacroLoader } from 'xacro-parser'
import { Result } from '$lib/utilities'; import { Result } from '$lib/utilities'
import { jointNames, model } from '$lib/stores'; import { currentVariant, jointNames, model } from '$lib/stores'
import uzip from 'uzip'; import uzip from 'uzip'
import { fileService } from '$lib/services'; import { fileService } from '$lib/services'
import { get } from 'svelte/store'
let model_xml: XMLDocument; let model_xml: XMLDocument
export const populateModelCache = async () => { export const populateModelCache = async () => {
await cacheModelFiles(); await cacheModelFiles()
const modelRes = await loadModelAsync('/spot_micro.urdf.xacro'); const modelRes = await loadModel(get(currentVariant).model)
if (modelRes.isOk()) { if (modelRes.isOk()) {
const [urdf, JOINT_NAME] = modelRes.inner; const [urdf, JOINT_NAME] = modelRes.inner
jointNames.set(JOINT_NAME); jointNames.set(JOINT_NAME)
model.set(urdf); model.set(urdf)
} else { } else {
console.error(modelRes.inner, { exception: modelRes.exception }); console.error(modelRes.inner, { exception: modelRes.exception })
} }
}; }
export const cacheModelFiles = async () => { export const cacheModelFiles = async () => {
let data = await fetch('/stl.zip'); const data = await fetch(get(currentVariant).stl)
var files = uzip.parse(await data.arrayBuffer()); const files = uzip.parse(await data.arrayBuffer())
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) { for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const url = new URL(path, window.location.href); const url = new URL(path, window.location.href)
fileService.saveFile(url.toString(), data); fileService?.saveFile(url.toString(), data)
} }
}; }
export const loadModelAsync = async ( export const loadModel = async (url: string): Promise<Result<[URDFRobot, string[]], string>> => {
url: string const urdfLoader = new URDFLoader()
): Promise<Result<[URDFRobot, string[]], string>> => { urdfLoader.workingPath = LoaderUtils.extractUrlBase(url)
return new Promise((resolve, reject) => {
const xacroLoader = new XacroLoader();
const urdfLoader = new URDFLoader();
urdfLoader.workingPath = LoaderUtils.extractUrlBase(url);
xacroLoader.load( let xml = url.endsWith('.xacro') ? await loadXacro(url) : await fetch(url).then(res => res.text())
url,
async (xml) => {
model_xml = xml;
try {
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]);
resolve(Result.ok([model, joints])); if (typeof xml === 'string') {
} catch (error) { xml = new window.DOMParser().parseFromString(xml, 'text/xml')
resolve(Result.err('Failed to load model', error)); }
}
},
(error) => resolve(Result.err('Failed to load model', error))
);
});
};
export const toeWorldPositions = (robot: URDFRobot) => { return new Promise(resolve => {
const toe_positions: Vector3[] = []; model_xml = xml
robot.traverse((child) => { try {
if (child.name.includes('toe') && !child.name.includes('_link')) { const model = urdfLoader.parse(xml)
const worldPosition = new Vector3(); setupRobot(model)
child.getWorldPosition(worldPosition); const joints = Object.entries(model.joints)
toe_positions.push(worldPosition); .filter(joint => joint[1].jointType !== 'fixed')
} .map(joint => joint[0])
});
return toe_positions;
};
export const footColor = () => { resolve(Result.ok([model, joints]))
const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element; } catch (error) {
const colorAttrStr = colorElem.getAttribute('rgba') as string; resolve(Result.err('Failed to load model', error))
const colorStr = colorAttrStr }
.split(' ') })
.slice(0, 3) }
.map((val) => Math.floor(+val * 255))
.join(', ');
return new Color(`rgb(${colorStr})`); const loadXacro = async (url: string): Promise<XMLDocument> =>
}; new Promise((resolve, reject) => {
new XacroLoader().load(url, resolve, reject)
})
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
const colorStr = colorAttrStr
.split(' ')
.slice(0, 3)
.map(val => Math.floor(+val * 255))
.join(', ')
return new Color(`rgb(${colorStr})`)
}
+39 -28
View File
@@ -1,36 +1,47 @@
export const humanFileSize = (size: number): string => { export const humanFileSize = (size: number): string => {
const units = ['B', 'kB', 'MB', 'GB', 'TB']; const units = ['B', 'kB', 'MB', 'GB', 'TB']
var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024))
return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i]; return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i]
}; }
export const capitalize = (str: string): string => { export const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}; }
export const convertSeconds = (seconds: number) => { export const convertSeconds = (seconds: number) => {
// Calculate the number of seconds, minutes, hours, and days // Calculate the number of seconds, minutes, hours, and days
let minutes = Math.floor(seconds / 60); let minutes = Math.floor(seconds / 60)
let hours = Math.floor(minutes / 60); let hours = Math.floor(minutes / 60)
let days = Math.floor(hours / 24); const days = Math.floor(hours / 24)
// Calculate the remaining hours, minutes, and seconds // Calculate the remaining hours, minutes, and seconds
hours = hours % 24; hours = hours % 24
minutes = minutes % 60; minutes = minutes % 60
seconds = seconds % 60; seconds = seconds % 60
// Create the formatted string // Create the formatted string
let result = ''; let result = ''
if (days > 0) { if (days > 0) {
result += days + ' day' + (days > 1 ? 's' : '') + ' '; result += days + ' day' + (days > 1 ? 's' : '') + ' '
} }
if (hours > 0) { if (hours > 0) {
result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '; result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '
} }
if (minutes > 0) { if (minutes > 0) {
result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' '; result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' '
} }
result += seconds + ' second' + (seconds > 1 ? 's' : ''); result += seconds + ' second' + (seconds > 1 ? 's' : '')
return result; return result
}; }
export const compareIp = (ip1: string, ip2: string) => {
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
}
+3 -10
View File
@@ -1,15 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { page } from '$app/state'
import { onMount } from 'svelte';
onMount(() => {
setTimeout(() => {
goto('/');
}, 3000);
});
</script> </script>
<div class="flex justify-center items-center w-full h-full"> <div class="flex justify-center items-center w-full h-full">
<h1 class="text-4xl">404 - Page not found</h1> <h1>{page.status} {page.error?.message}</h1>
<p>You will be redirected to the home page in 3 seconds</p> <span>Go to <a class="btn btn-primary" href="/">Home page</a></span>
</div> </div>
+100 -96
View File
@@ -1,125 +1,129 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte'
import { page } from '$app/state'; import { page } from '$app/state'
import { Modals, modals } from 'svelte-modals'; import { Modals, modals } from 'svelte-modals'
import Toast from '$lib/components/toasts/Toast.svelte'; import Toast from '$lib/components/toasts/Toast.svelte'
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications'
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition'
import '../app.css'; import '../app.css'
import Menu from '../lib/components/menu/Menu.svelte'; import Menu from '../lib/components/menu/Menu.svelte'
import Statusbar from '../lib/components/statusbar/statusbar.svelte'; import Statusbar from '../lib/components/statusbar/statusbar.svelte'
import { import {
telemetry, telemetry,
analytics, analytics,
ModesEnum, ModesEnum,
kinematicData, kinematicData,
mode, mode,
outControllerData, outControllerData,
servoAngles, servoAngles,
servoAnglesOut, servoAnglesOut,
socket, socket,
location, location,
useFeatureFlags useFeatureFlags,
} from '$lib/stores'; walkGait
import type { Analytics, DownloadOTA } from '$lib/types/models'; } from '$lib/stores'
interface Props { import { type Analytics, type DownloadOTA } from '$lib/types/models'
children?: import('svelte').Snippet; import { MessageTopic } from '$lib/types/models'
}
let { children }: Props = $props(); interface Props {
children?: import('svelte').Snippet
}
const features = useFeatureFlags(); let { children }: Props = $props()
onMount(async () => { const features = useFeatureFlags()
const ws = $location ? $location : window.location.host;
socket.init(`ws://${ws}/api/ws/events`);
addEventListeners(); onMount(async () => {
const ws = $location ? $location : window.location.host
socket.init(`ws://${ws}/api/ws/events`)
outControllerData.subscribe(data => socket.sendEvent('input', { data })); addEventListeners()
mode.subscribe(data => socket.sendEvent('mode', { data }));
servoAnglesOut.subscribe(data => socket.sendEvent('angles', { data }));
kinematicData.subscribe(data => socket.sendEvent('position', { data }));
});
onDestroy(() => { outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data))
removeEventListeners(); 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))
})
const addEventListeners = () => { onDestroy(() => {
socket.on('open', handleOpen); removeEventListeners()
socket.on('close', handleClose); })
socket.on('error', handleError);
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('otastatus', handleOAT);
if (data?.sonar) socket.on('sonar', data => console.log(data));
});
};
const removeEventListeners = () => { const addEventListeners = () => {
socket.off('analytics', handleAnalytics); socket.on('open', handleOpen)
socket.off('open', handleOpen); socket.on('close', handleClose)
socket.off('close', handleClose); socket.on('error', handleError)
socket.off('rssi', handleNetworkStatus); socket.on(MessageTopic.rssi, handleNetworkStatus)
socket.off('otastatus', handleOAT); socket.on(MessageTopic.mode, (data: ModesEnum) => mode.set(data))
}; socket.on(MessageTopic.analytics, handleAnalytics)
socket.on(MessageTopic.angles, (angles: number[]) => {
if (angles.length) servoAngles.set(angles)
})
features.subscribe(data => {
if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT)
if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data))
})
}
const handleOpen = () => { const removeEventListeners = () => {
notifications.success('Connection to device established', 5000); socket.off(MessageTopic.analytics, handleAnalytics)
}; socket.off('open', handleOpen)
socket.off('close', handleClose)
socket.off(MessageTopic.rssi, handleNetworkStatus)
socket.off(MessageTopic.otastatus, handleOAT)
}
const handleClose = () => { const handleOpen = () => {
notifications.error('Connection to device lost', 5000); notifications.success('Connection to device established', 5000)
telemetry.setRSSI(0); }
};
const handleError = (data: any) => console.error(data); const handleClose = () => {
notifications.error('Connection to device lost', 5000)
telemetry.setRSSI(0)
}
const handleAnalytics = (data: Analytics) => analytics.addData(data); const handleError = (data: any) => console.error(data)
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data); const handleAnalytics = (data: Analytics) => analytics.addData(data)
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data); const handleNetworkStatus = (data: number) => telemetry.setRSSI(data)
let menuOpen = $state(false); const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data)
let menuOpen = $state(false)
</script> </script>
<svelte:head> <svelte:head>
<title>{page.data.title}</title> <title>{page.data.title}</title>
</svelte:head> </svelte:head>
<div class="drawer"> <div class="drawer">
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} /> <input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
<div class="drawer-content flex flex-col"> <div class="drawer-content flex flex-col">
<!-- Status bar content here --> <!-- Status bar content here -->
<Statusbar /> <Statusbar />
<!-- Main page content here --> <!-- Main page content here -->
{@render children?.()} {@render children?.()}
</div> </div>
<!-- Side Navigation --> <!-- Side Navigation -->
<div class="drawer-side z-30 shadow-lg"> <div class="drawer-side z-30 shadow-lg">
<label for="main-menu" class="drawer-overlay"></label> <label for="main-menu" class="drawer-overlay"></label>
<Menu menuClicked={() => (menuOpen = false)} /> <Menu menuClicked={() => (menuOpen = false)} />
</div> </div>
</div> </div>
<Modals> <Modals>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
{#snippet backdrop()} {#snippet backdrop()}
<div <div
class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm" class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm"
transition:fade transition:fade
onclick={modals.closeAll} onclick={modals.closeAll}>
></div> </div>
{/snippet} {/snippet}
</Modals> </Modals>
<Toast /> <Toast />
+18 -18
View File
@@ -1,22 +1,22 @@
export const prerender = false; export const prerender = true
export const ssr = false; export const ssr = false
const registerFetchIntercept = async () => { const registerFetchIntercept = async () => {
const { fetch: originalFetch } = window; const { fetch: originalFetch } = window
const fileService = (await import('$lib/services/file-service')).default; const fileService = (await import('$lib/services/file-service')).default
window.fetch = async (resource, config) => { window.fetch = async (resource, config) => {
let url = resource instanceof Request ? resource.url : resource.toString(); const url = resource instanceof Request ? resource.url : resource.toString()
let file = await fileService.getFile(url); const file = await fileService?.getFile(url)
return file.isOk() ? new Response(file.inner) : originalFetch(resource, config); return file?.isOk() ? new Response(file.inner) : originalFetch(resource, config)
}; }
}; }
export const load = async () => { export const load = async () => {
await registerFetchIntercept(); await registerFetchIntercept()
return { return {
title: 'Spot micro controller', title: 'Spot micro controller',
github: 'runeharlyk/SpotMicroESP32-Leika', github: 'runeharlyk/SpotMicroESP32-Leika',
app_name: 'Spot Micro Controller', app_name: 'Spot Micro Controller',
copyright: '2024 Rune Harlyk' copyright: '2025 Rune Harlyk'
}; }
}; }
+21 -22
View File
@@ -1,28 +1,27 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import { goto } from '$app/navigation'
import { notifications } from '$lib/components/toasts/notifications'; import Visualization from '$lib/components/Visualization.svelte'
import Visualization from '$lib/components/Visualization.svelte'; import { socket } from '$lib/stores'
import { onMount } from 'svelte'
interface Props { onMount(() => {
data: PageData; socket.subscribe(isConnected => {
} if (isConnected) {
goto('/controller')
let { data }: Props = $props(); }
})
})
</script> </script>
<div class="hero bg-base-100 h-screen"> <div class="hero bg-base-100 h-screen">
<div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center"> <div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center">
<div class="w-64 h-64"> <div class="w-64 h-64">
<Visualization sky={false} orbit panel={false} ground={false}/> <Visualization sky={false} orbit panel={false} ground={false} />
</div> </div>
<div class="card-body w-80"> <div class="card-body w-80">
<h2 class="card-title text-center text-2xl">Welcome to {data.app_name}</h2> <h2 class="card-title text-center text-2xl">Begin you journey</h2>
<p class="py-6 text-center"></p> <p class="py-6 text-center"></p>
<a <a class="btn btn-primary" href={$socket ? '/controller' : '/connection'}> Add Robot Dog </a>
class="btn btn-primary" </div>
href="/controller" </div>
onclick={() => notifications.success('You did it!', 1000)}>Begin</a
>
</div>
</div>
</div> </div>
+18 -20
View File
@@ -1,28 +1,26 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import { WiFi } from '$lib/components/icons'; import { WiFi } from '$lib/components/icons';
import { location, socket, useFeatureFlags } from '$lib/stores'; import { location, socket } from '$lib/stores';
const features = useFeatureFlags(); const update = () => {
const ws = $location ? $location : window.location.host;
const update = () => { socket.init(`ws://${ws}/api/ws/events`);
const ws = $location ? $location : window.location.host; };
socket.init(`ws://${ws}/api/ws/events`);
};
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<WiFi class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <WiFi class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span >Connection</span> <span>Connection</span>
{/snippet} {/snippet}
<div class="flex"> <div class="flex">
<label class="label w-32" for="server">Address:</label> <label class="label w-32" for="server">Address:</label>
<input class="input" bind:value={$location} /> <input class="input" bind:value={$location} />
</div> </div>
<button class="btn btn-primary" onclick={update}>Update</button> <button class="btn btn-primary" onclick={update}>Update</button>
</SettingsCard> </SettingsCard>
+23 -23
View File
@@ -1,31 +1,31 @@
<script lang="ts"> <script lang="ts">
import Controls from './Controls.svelte'; import Controls from './Controls.svelte'
import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte'; import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte'
import { selectedView, views } from '$lib/stores/application'; import { selectedView, views } from '$lib/stores/application'
import { onMount } from 'svelte'; import { onMount } from 'svelte'
import { mpu, socket } from '$lib/stores'; import { mpu, socket } from '$lib/stores'
import { imu } from '$lib/stores/imu'; import { imu } from '$lib/stores/imu'
import type { IMU } from '$lib/types/models'; import { MessageTopic, type IMU } from '$lib/types/models'
let layout = $derived($views.find(v => v.name === $selectedView)!); let layout = $derived($views.find(v => v.name === $selectedView)!)
onMount(() => { onMount(() => {
socket.on('imu', (data: IMU) => { socket.on(MessageTopic.imu, (data: IMU) => {
imu.addData(data); imu.addData(data)
if (data.heading) if (data.heading)
mpu.update(mpuData => { mpu.update(mpuData => {
mpuData.heading = data.heading; mpuData.heading = data.heading
console.log(data.heading); console.log(data.heading)
return mpuData; return mpuData
}); })
}); })
}); })
</script> </script>
<div class="absolute top-0 select-none w-screen h-screen"> <div class="absolute top-0 select-none w-screen h-screen">
<Controls /> <Controls />
<div class="absolute w-full h-screen top-0 overflow-hidden lg:pt-16 pt-12"> <div class="absolute w-full h-screen top-0 overflow-hidden lg:pt-16 pt-12">
<WidgetContainer container={layout.content} /> <WidgetContainer container={layout.content} />
</div> </div>
</div> </div>
+68 -16
View File
@@ -1,17 +1,47 @@
<script lang="ts"> <script lang="ts">
import nipplejs from 'nipplejs' import nipplejs from 'nipplejs'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { capitalize, throttler, toInt8 } from '$lib/utilities' import { capitalize, throttler } from '$lib/utilities'
import { input, outControllerData, mode, modes, type Modes, ModesEnum } from '$lib/stores' import {
input,
outControllerData,
mode,
modes,
type Modes,
ModesEnum,
walkGaits,
WalkGaits,
walkGait,
walkGaitLabels
} from '$lib/stores'
import type { vector } from '$lib/types/models' import type { vector } from '$lib/types/models'
import { VerticalSlider } from '$lib/components/input' import { VerticalSlider } from '$lib/components/input'
import { gamepadAxes, hasGamepad } from '$lib/stores/gamepad'
import { notifications } from '$lib/components/toasts/notifications'
let throttle = new throttler() let throttle = new throttler()
let left: nipplejs.JoystickManager let left: nipplejs.JoystickManager
let right: nipplejs.JoystickManager let right: nipplejs.JoystickManager
let throttle_timing = 40 let throttle_timing = 40
let data = new Array(8) 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
//
// })
onMount(() => { onMount(() => {
left = nipplejs.create({ left = nipplejs.create({
@@ -45,14 +75,13 @@
} }
const updateData = () => { const updateData = () => {
data[0] = 0 data[0] = $input.left.x
data[1] = toInt8($input.left.x, -1, 1) data[1] = $input.left.y
data[2] = toInt8($input.left.y, -1, 1) data[2] = $input.right.x
data[3] = toInt8($input.right.x, -1, 1) data[3] = $input.right.y
data[4] = toInt8($input.right.y, -1, 1) data[4] = $input.height
data[5] = toInt8($input.height, 0, 100) data[5] = $input.speed
data[6] = toInt8($input.speed, 0, 100) data[6] = $input.s1
data[7] = toInt8($input.s1, 0, 100)
outControllerData.set(data) outControllerData.set(data)
} }
@@ -70,7 +99,7 @@
} }
const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => { const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => {
const value: number = event.target?.value const value: number = Number((event.target as HTMLInputElement).value)
input.update(inputData => { input.update(inputData => {
inputData[key] = value inputData[key] = value
@@ -82,6 +111,10 @@
const changeMode = (modeValue: Modes) => { const changeMode = (modeValue: Modes) => {
mode.set(modes.indexOf(modeValue)) mode.set(modes.indexOf(modeValue))
} }
const changeWalkGait = (walkGaitValue: WalkGaits) => {
walkGait.set(walkGaitValue)
}
</script> </script>
<div class="absolute top-0 left-0 w-screen h-screen"> <div class="absolute top-0 left-0 w-screen h-screen">
@@ -103,7 +136,11 @@
</div> </div>
<div class="absolute bottom-0 z-10 flex items-end"> <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"> <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={100} oninput={(e: Event) => handleRange(e, 'height')} /> <VerticalSlider
min={0}
max={1}
step={0.01}
oninput={(e: Event) => handleRange(e, 'height')} />
<label for="height">Ht</label> <label for="height">Ht</label>
</div> </div>
<div <div
@@ -119,7 +156,20 @@
{/each} {/each}
</div> </div>
{#if $mode === ModesEnum.Walk || $mode === ModesEnum.Crawl} {#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>
<div class="flex gap-4"> <div class="flex gap-4">
<div> <div>
<label for="s1">S1</label> <label for="s1">S1</label>
@@ -127,7 +177,8 @@
type="range" type="range"
name="s1" name="s1"
min="0" min="0"
max="100" step="0.01"
max="1"
oninput={e => handleRange(e, 's1')} oninput={e => handleRange(e, 's1')}
class="range range-sm range-primary" /> class="range range-sm range-primary" />
</div> </div>
@@ -137,7 +188,8 @@
type="range" type="range"
name="speed" name="speed"
min="0" min="0"
max="100" step="0.01"
max="1"
oninput={e => handleRange(e, 'speed')} oninput={e => handleRange(e, 'speed')}
class="range range-sm range-primary" /> class="range range-sm range-primary" />
</div> </div>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import I2C from './i2c.svelte'; import I2C from './i2c.svelte'
</script> </script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8"> <div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
<I2C /> <I2C />
</div> </div>
+5 -5
View File
@@ -1,7 +1,7 @@
import type { PageLoad } from './$types'; import type { PageLoad } from './$types'
export const load = (async () => { export const load = (async () => {
return { return {
title: 'I2C' title: 'I2C'
}; }
}) satisfies PageLoad; }) satisfies PageLoad
+69 -47
View File
@@ -1,57 +1,79 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte'
import { onMount } from 'svelte'; import { onMount } from 'svelte'
import { socket } from '$lib/stores'; import { socket } from '$lib/stores'
import type { I2CDevice } from '$lib/types/models'; import { MessageTopic, type I2CDevice } from '$lib/types/models'
import { Connection } from '$lib/components/icons'; import { Connection } from '$lib/components/icons'
import I2CSetting from './i2cSetting.svelte'
const i2cDevices = [ const i2cDevices = [
{ address: 30, part_number: 'HMC5883', name: '3-Axis Digital Compass/Magnetometer IC' }, { address: 30, part_number: 'HMC5883', name: '3-Axis Digital Compass/Magnetometer IC' },
{ address: 64, part_number: 'PCA9685', name: '16-channel PWM driver default address' }, { address: 41, part_number: 'BNO055', name: '9-Axis Absolute Orientation Sensor' },
{ address: 72, part_number: 'ADS1115', name: '4-channel 16-bit ADC' }, { address: 64, part_number: 'PCA9685', name: '16-channel PWM driver default address' },
{ { address: 72, part_number: 'ADS1115', name: '4-channel 16-bit ADC' },
address: 104, {
part_number: 'MPU6050', address: 104,
name: 'Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices' part_number: 'MPU6050',
}, name: 'Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices'
{ address: 119, part_number: 'BMP085', name: 'Temp/Barometric' } },
]; { address: 115, part_number: 'PAJ7620U2', name: 'Gesture sensor' },
{ address: 119, part_number: 'BMP085', name: 'Temp/Barometric' }
]
let active_devices: I2CDevice[] = $state([]); let active_devices: I2CDevice[] = $state([])
onMount(() => { let isLoading = $state(false)
socket.on('i2cScan', handleScan);
socket.sendEvent('i2cScan', '');
return () => socket.off('i2cScan', handleScan);
});
const handleScan = (data: any) => { onMount(() => {
active_devices = data.addresses.map( socket.on(MessageTopic.i2cScan, handleScan)
(address: number) => triggerScan()
i2cDevices.find(device => device.address === address) || { return () => socket.off(MessageTopic.i2cScan, handleScan)
address, })
part_number: 'Unknown',
name: 'Unknown' const handleScan = (data: any) => {
} active_devices = data.addresses.map(
); (address: number) =>
}; i2cDevices.find(device => device.address === address) || {
address,
part_number: 'Unknown',
name: 'Unknown'
}
)
isLoading = false
}
const triggerScan = () => {
isLoading = true
socket.sendEvent(MessageTopic.i2cScan, '')
}
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<Connection class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <Connection class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span >I<sup>2</sup>C</span> <span>I<sup>2</sup>C</span>
{/snippet} {/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="grid"> <I2CSetting />
{#if active_devices.length === 0}
<div>No I2C devices found</div> <div class="grid">
{:else} {#if active_devices.length === 0}
{#each active_devices as device} <div>No I2C devices found</div>
<div>[{device.address.toString(16)}] {device.part_number} - {device.name}</div> {:else}
{/each} {#each active_devices as device}
{/if} <div>[{device.address.toString(16)}] {device.part_number} - {device.name}</div>
</div> {/each}
{/if}
</div>
</SettingsCard> </SettingsCard>
@@ -0,0 +1,99 @@
<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}
+231 -288
View File
@@ -1,310 +1,253 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from "$lib/components/SettingsCard.svelte"; import SettingsCard from '$lib/components/SettingsCard.svelte'
import { imu } from '$lib/stores/imu'; import { imu } from '$lib/stores/imu'
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js'
import { cubicOut } from "svelte/easing"; import { cubicOut } from 'svelte/easing'
import { slide } from "svelte/transition"; import { slide } from 'svelte/transition'
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from 'svelte'
import { daisyColor } from "$lib/utilities"; import { socket } from '$lib/stores'
import { socket } from "$lib/stores"; import { MessageTopic, type IMU } from '$lib/types/models'
import type { IMU } from "$lib/types/models"; import { useFeatureFlags } from '$lib/stores/featureFlags'
import { useFeatureFlags } from "$lib/stores/featureFlags"; import { Rotate3d } from '$lib/components/icons'
import { Rotate3d } from "$lib/components/icons";
const features = useFeatureFlags(); Chart.register(...registerables)
Chart.register(...registerables); const features = useFeatureFlags()
let intervalId: ReturnType<typeof setInterval> | number
let angleChartElement: HTMLCanvasElement = $state(); let angleChartElement: HTMLCanvasElement
let angleChart: Chart; let tempChartElement: HTMLCanvasElement
let altitudeChartElement: HTMLCanvasElement
let tempChartElement: HTMLCanvasElement = $state(); let angleChart: Chart
let tempChart: Chart; let tempChart: Chart
let altitudeChart: Chart
let altitudeChartElement: HTMLCanvasElement = $state(); const getChartColors = () => {
let altitudeChart: Chart; const style = getComputedStyle(document.body)
return {
const handleImu = (data: IMU) => { primary: style.getPropertyValue('--color-primary'),
console.log(data); secondary: style.getPropertyValue('--color-secondary'),
accent: style.getPropertyValue('--color-accent'),
imu.addData(data); background: style.getPropertyValue('--color-background')
} }
}
onMount(() => { const createBaseChartConfig = (bgColor: string) => ({
socket.on('imu', handleImu); maintainAspectRatio: false,
angleChart = new Chart(angleChartElement, { responsive: true,
type: 'line', plugins: {
data: { legend: { display: true },
datasets: [ tooltip: { mode: 'index' as const, intersect: false }
{ },
label: 'x', elements: { point: { radius: 1 } },
borderColor: daisyColor('--p'), scales: {
backgroundColor: daisyColor('--p', 50), x: {
borderWidth: 2, grid: { color: bgColor },
data: $imu.x, ticks: { color: bgColor },
yAxisID: 'y' display: false
}, },
{ y: {
label: 'y', type: 'linear' as const,
borderColor: daisyColor('--s'), position: 'left' as const,
backgroundColor: daisyColor('--s', 50), min: 0,
borderWidth: 2, max: 10,
data: $imu.y, grid: { color: bgColor },
yAxisID: 'y' ticks: { color: bgColor },
}, border: { color: bgColor }
{ }
label: 'z', }
borderColor: daisyColor('--a'), })
backgroundColor: daisyColor('--a', 50),
borderWidth: 2, const initializeCharts = () => {
data: $imu.z, const colors = getChartColors()
yAxisID: 'y' const baseConfig = createBaseChartConfig(colors.background)
}
] angleChart = new Chart(angleChartElement, {
}, type: 'line',
options: { data: {
maintainAspectRatio: false, datasets: [
responsive: true, {
plugins: { label: 'x',
legend: { borderColor: colors.primary,
display: true backgroundColor: colors.primary,
}, borderWidth: 2,
tooltip: { data: $imu.x,
mode: 'index', yAxisID: 'y'
intersect: false },
} {
}, label: 'y',
elements: { borderColor: colors.secondary,
point: { backgroundColor: colors.secondary,
radius: 1 borderWidth: 2,
} data: $imu.y,
}, yAxisID: 'y'
scales: { },
x: { {
grid: { label: 'z',
color: daisyColor('--bc', 10) borderColor: colors.accent,
}, backgroundColor: colors.accent,
ticks: { borderWidth: 2,
color: daisyColor('--bc') data: $imu.z,
}, yAxisID: 'y'
display: false }
}, ]
y: { },
type: 'linear', options: {
title: { ...baseConfig,
display: true, scales: {
text: 'Angle [°]', ...baseConfig.scales,
color: daisyColor('--bc'), y: {
font: { ...baseConfig.scales.y,
size: 16, title: {
weight: 'bold' display: true,
} text: 'Angle [°]',
}, color: colors.background,
position: 'left', font: { size: 16, weight: 'bold' }
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: daisyColor('--s'),
backgroundColor: daisyColor('--s', 50),
borderWidth: 2,
data: $imu.bmp_temp,
yAxisID: 'y'
}
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
},
elements: {
point: {
radius: 1
}
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: 'Temperature [C°]',
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: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $imu.altitude,
yAxisID: 'y'
}
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
},
elements: {
point: {
radius: 1
}
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: 'Altitude [M]',
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) }
}
}
}
});
setInterval(() => {
updateData(), 200;
});
}) })
onDestroy(() => { tempChart = new Chart(tempChartElement, {
socket.off('imu', handleImu); type: 'line',
data: {
datasets: [
{
label: 'Barometer temperature',
borderColor: colors.secondary,
backgroundColor: colors.secondary,
borderWidth: 2,
data: $imu.bmp_temp,
yAxisID: 'y'
}
]
},
options: {
...baseConfig,
scales: {
...baseConfig.scales,
y: {
...baseConfig.scales.y,
title: {
display: true,
text: 'Temperature [C°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
}) })
const updateData = () => { altitudeChart = new Chart(altitudeChartElement, {
if ($features.imu) { type: 'line',
angleChart.data.labels = $imu.x; data: {
angleChart.data.datasets[0].data = $imu.x; datasets: [
angleChart.data.datasets[1].data = $imu.y; {
angleChart.data.datasets[2].data = $imu.z; label: 'Altitude',
angleChart.options.scales!.y!.min = Math.min(Math.min(...$imu.x), Math.min(...$imu.y), Math.min(...$imu.z)) - 1; borderColor: colors.primary,
angleChart.options.scales!.y!.max = Math.max(Math.max(...$imu.x), Math.max(...$imu.y), Math.max(...$imu.z)) + 1; backgroundColor: colors.primary,
angleChart.update('none'); borderWidth: 2,
data: $imu.altitude,
yAxisID: 'y'
}
]
},
options: {
...baseConfig,
scales: {
...baseConfig.scales,
y: {
...baseConfig.scales.y,
title: {
display: true,
text: 'Altitude [M]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
} }
}
})
}
if ($features.bmp) { const updateChartData = (chart: Chart, data: number[], label: string) => {
tempChart.data.labels = $imu.bmp_temp; chart.data.labels = data
tempChart.data.datasets[0].data = $imu.bmp_temp; chart.data.datasets[0].data = data
tempChart.options.scales!.y!.min = Math.min(...$imu.bmp_temp) - 1; chart.options.scales!.y!.min = Math.min(...data) - 1
tempChart.options.scales!.y!.max = Math.max(...$imu.bmp_temp) + 1; chart.options.scales!.y!.max = Math.max(...data) + 1
tempChart.update('none'); chart.update('none')
}
altitudeChart.data.labels = $imu.altitude; const updateData = () => {
altitudeChart.data.datasets[0].data = $imu.altitude; if ($features.imu) {
altitudeChart.options.scales!.y!.min = Math.min(Math.min(...$imu.altitude)) - 1; angleChart.data.labels = $imu.x
altitudeChart.options.scales!.y!.max = Math.max(Math.max(...$imu.altitude)) + 1; angleChart.data.datasets[0].data = $imu.x
altitudeChart.update('none'); 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)
})
onDestroy(() => {
socket.off(MessageTopic.imu)
clearInterval(intervalId)
})
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<Rotate3d class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <Rotate3d class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span >IMU</span> <span>IMU</span>
{/snippet} {/snippet}
{#if $features.imu}
<div class="w-full overflow-x-auto"> {#if $features.imu}
<div <div class="w-full overflow-x-auto">
class="flex w-full flex-col space-y-1 h-60" <div
transition:slide|local={{ duration: 300, easing: cubicOut }} class="flex w-full flex-col space-y-1 h-60"
> transition:slide|local={{ duration: 300, easing: cubicOut }}>
<canvas bind:this={angleChartElement}></canvas> <canvas bind:this={angleChartElement}></canvas>
</div> </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 }}
>
<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 }}
>
<canvas bind:this={altitudeChartElement}></canvas>
</div>
</div> </div>
{/if} {/if}
<!-- <IMUSetting /> -->
{#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 }}>
<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 }}>
<canvas bind:this={altitudeChartElement}></canvas>
</div>
</div>
{/if}
</SettingsCard> </SettingsCard>
@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import Servos from './servos.svelte'; import Servos from './servos.svelte'
import ServoTable from './ServoTable.svelte'; import ServoTable from './ServoTable.svelte'
let servoId = $state(0)
let pwm = $state(306)
</script> </script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8"> <div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
<Servos /> <Servos bind:servoId bind:pwm />
<ServoTable /> <ServoTable {servoId} {pwm} />
</div> </div>
@@ -1,73 +1,113 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api } from '$lib/api'
import { onMount } from 'svelte'; import { onMount } from 'svelte'
interface Props { import { RotateCw, RotateCcw } from '$lib/components/icons'
data?: any; interface Props {
data?: any
servoId?: number
pwm?: number
}
let {
data = $bindable({
servos: []
}),
pwm = $bindable(306),
servoId = $bindable(0)
}: Props = $props()
const updateValue = (event: Event, index: number, key: string) => {
data.servos[index][key] = Number((event.target as HTMLInputElement).value)
}
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()
}
onMount(async () => {
const result = await api.get('/api/servo/config')
if (result.isOk()) {
data = result.inner
} }
})
let { data = $bindable({ const setCenterPWM = async () => {
servos: [] console.log('setCenterPWM', servoId, pwm)
}) }: Props = $props(); data.servos[servoId]['center_pwm'] = pwm
await syncConfig()
const updateValue = (event, index, key) => { }
data.servos[index][key] = event.target.innerText;
};
const syncConfig = async () => {
await api.post('/api/servo/config', data);
};
onMount(async () => {
const result = await api.get('/api/servo/config');
if (result.isOk()) {
data = result.inner;
}
});
</script> </script>
<div class="overflow-x-auto"> <div>
<table class="table table-xs"> <button class="btn btn-sm btn-primary" onclick={() => setCenterPWM()}>Set center pwm</button>
<thead> </div>
<tr>
<th>Center PWM</th> <div class="overflow-x-auto">
<th>Center Angle</th> <table class="table table-xs">
<th>Direction</th> <thead>
<th>Conversion</th> <tr>
</tr> <th>Servo</th>
</thead> <th>Center PWM</th>
<tbody> <th>Center Angle</th>
{#each data.servos as servo, index} <th>Direction</th>
<tr> <th>Conversion</th>
<td </tr>
contenteditable="true" </thead>
onblur={syncConfig} <tbody>
oninput={event => updateValue(event, index, 'center_pwm')} {#each data.servos as servo, index}
> <tr class="hover:bg-base-200">
{servo.center_pwm} <td class="font-medium">Servo {index}</td>
</td> <td>
<td <input
contenteditable="true" type="number"
onblur={syncConfig} class="input input-sm input-bordered w-20"
oninput={event => updateValue(event, index, 'center_angle')} value={servo.center_pwm}
> onblur={syncConfig}
{servo.center_angle} oninput={event => updateValue(event, index, 'center_pwm')}
</td> min="80"
<td max="600" />
contenteditable="true" </td>
onblur={syncConfig} <td>
oninput={event => updateValue(event, index, 'direction')} <input
> type="number"
{servo.direction} step="0.1"
</td> class="input input-sm input-bordered w-20"
<td value={servo.center_angle}
contenteditable="true" onblur={syncConfig}
onblur={syncConfig} oninput={event => updateValue(event, index, 'center_angle')}
oninput={event => updateValue(event, index, 'conversion')} min="-90"
> max="90" />
{servo.conversion} </td>
</td> <td>
</tr> <button
{/each} class="btn btn-sm btn-ghost"
</tbody> title="Toggle direction {servo.direction}"
</table> 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>
<td>
<input
type="number"
step="0.01"
class="input input-sm input-bordered w-20"
value={servo.conversion}
onblur={syncConfig}
oninput={event => updateValue(event, index, 'conversion')}
min="0"
max="10" />
</td>
</tr>
{/each}
</tbody>
</table>
</div> </div>
+52 -63
View File
@@ -1,75 +1,64 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte'; import { socket } from '$lib/stores'
import type { ServoConfiguration, Servo } from '$lib/types/models'; import { MessageTopic } from '$lib/types/models'
import Spinner from '$lib/components/Spinner.svelte'; import { throttler as Throttler } from '$lib/utilities'
import { socket } from '$lib/stores'; let { servoId = $bindable(0), pwm = $bindable(306) } = $props()
import { onDestroy, onMount } from 'svelte';
import { throttler as Throttler } from '$lib/utilities';
import { MotorOutline } from '$lib/components/icons';
let isLoading = false; let active = $state(false)
let active = $state(false); let allServos = $state(false)
let servoId = $state(0); const throttler = new Throttler()
const throttler = new Throttler(); const activateServo = () => {
socket.sendEvent(MessageTopic.servoState, { active: 1 })
}
const sweep = (event: any) => { const deactivateServo = () => {
let channel = event.detail.channel; socket.sendEvent(MessageTopic.servoState, { active: 0 })
socket.sendEvent('servoConfiguration', { servos: [{ channel, sweep: true }] }); }
};
const activateServo = (event: any) => { const updatePWM = () => {
socket.sendEvent('servoState', { active: 1 }); throttler.throttle(() => {
}; socket.sendEvent(MessageTopic.servoPWM, { servo_id: servoId, pwm })
}, 10)
}
const deactivateServo = (event: any) => { const toggleMode = () => {
socket.sendEvent('servoState', { active: 0 }); servoId = allServos ? -1 : 0
}; }
let pwm = $state(306);
const updatePWM = () => {
throttler.throttle(() => {
socket.sendEvent('servoPWM', { servo_id: servoId, pwm });
}, 10);
};
</script> </script>
<SettingsCard collapsible={false}> <div class="flex flex-col">
{#snippet icon()} <h2 class="text-lg">General servo configuration</h2>
<MotorOutline class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <span>Servo</span>
{/snippet} <span>{pwm}</span>
{#snippet title()} </div>
<span >Servo</span> <input
{/snippet} type="range"
{pwm} min="80"
<input max="600"
type="range" bind:value={pwm}
min="80" oninput={updatePWM}
max="600" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" />
bind:value={pwm}
oninput={updatePWM}
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
{#if isLoading} <div class="flex flex-col">
<Spinner /> <h2 class="text-lg">General servo configuration</h2>
{:else} <span>
<div class="flex flex-col"> <label for="mode">All servoes</label>
<h2 class="text-lg">General servo configuration</h2> <input type="checkbox" class="toggle" bind:checked={allServos} onchange={toggleMode} />
<span class="flex items-center gap-2"> </span>
<label for="servoId">Servo active {servoId}</label> <span>
<input type="range" min="0" max="11" step="1" bind:value={servoId} /> <label for="active">Active</label>
<input <input
type="checkbox" type="checkbox"
class="toggle" class="toggle"
bind:checked={active} bind:checked={active}
onchange={active ? activateServo : deactivateServo} onchange={active ? activateServo : deactivateServo} />
/> </span>
</span> <span class="flex items-center gap-2">
</div> <label for="servoId">Servo active {servoId}</label>
{/if} <input type="range" min="0" max="11" step="1" bind:value={servoId} />
</SettingsCard> </span>
</div>
+21 -8
View File
@@ -1,11 +1,24 @@
<script> <script lang="ts">
import { FileIcon } from '$lib/components/icons' import { FileIcon, TrashIcon } from '$lib/components/icons'
let { name, selected } = $props() interface Props {
name: string
selected: (name: string) => void
onDelete: (name: string) => void
}
let { name, selected, onDelete }: Props = $props()
</script> </script>
<!-- svelte-ignore a11y_interactive_supports_focus --> <div class="flex items-center pl-4 group hover:bg-gray-700 rounded py-1">
<!-- svelte-ignore a11y_click_events_have_key_events --> <button class="flex items-center gap-2 flex-grow" onclick={() => selected(name)}>
<span role="button" class="flex pl-4 gap-2 items-center" onclick={selected}> <FileIcon class="w-4 h-4" />
<FileIcon />{name} <span class="text-sm">{name}</span>
</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>
@@ -1,62 +1,172 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from "$lib/components/SettingsCard.svelte"; import SettingsCard from '$lib/components/SettingsCard.svelte'
import Spinner from "$lib/components/Spinner.svelte"; import Spinner from '$lib/components/Spinner.svelte'
import Folder from "./Folder.svelte"; import Folder from './Folder.svelte'
import { api } from "$lib/api"; import { api } from '$lib/api'
import type { Directory } from "$lib/types/models"; import type { Directory } from '$lib/types/models'
import { FolderIcon } from "$lib/components/icons"; import { FolderIcon, Add, FileIcon } from '$lib/components/icons'
import { modals } from 'svelte-modals'
import NewFolderDialog from './NewFolderDialog.svelte'
import NewFileDialog from './NewFileDialog.svelte'
let filename = $state(''); let filename = $state('')
let content = $state('')
let isEditing = $state(false)
const getFiles = async () => { const getFiles = async () => {
const result = await api.get<Directory>('/api/files') const result = await api.get<Directory>('/api/files')
if (result.isOk()) { if (result.isOk()) {
return result.inner; return result.inner
}
return { root: {} }
};
const getContent = async (name: string) => {
if (!name) return '';
const result = await api.get(`/api/config/${name}`)
if (result.isOk()) {
return JSON.stringify(result.inner, null, 4);
}
return ''
} }
return { root: {} }
}
const deleteFile = async (name: string) => { const getContent = async (name: string) => {
const result = await api.post(`/api/files/delete`, { file: "/config/"+ name }) if (!name) return ''
if (result.isOk()) { const result = await api.get(`/api/config/${name}`)
return result.inner; if (result.isOk()) {
} content = JSON.stringify(result.inner, null, 4)
return '' return content
} }
return ''
}
const updateSelected = async (event:any) => { const saveContent = async () => {
filename = event.detail.name; 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 })
if (result.isOk()) {
filename = ''
content = ''
}
}
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
})
}
</script> </script>
<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 on:selected={updateSelected}/>
{/await}
{#await getContent(filename)} <!-- <SettingsCard collapsible={false}> -->
<div> <!-- {#snippet icon()} -->
<Spinner /> <FolderIcon class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
</div> <!-- {/snippet}
{:then content} {#snippet title()} -->
<pre>{content}</pre> <div class="flex justify-between items-center w-full gap-2">
{/await} <span>File System</span>
</div> <div class="flex gap-2">
</SettingsCard> <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">
{#await getFiles()}
<Spinner />
{:then files}
<Folder
name="/"
files={files.root}
expanded
selected={updateSelected}
onDelete={deleteFile} />
{/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)}
<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> -->
+34 -37
View File
@@ -1,47 +1,44 @@
<script lang="ts"> <script lang="ts">
import Folder from './Folder.svelte'; import Folder from './Folder.svelte'
import File from './File.svelte'; import File from './File.svelte'
import { createEventDispatcher } from 'svelte'; import { FolderIcon, FolderOpenOutline } from '$lib/components/icons'
import { FolderIcon, FolderOpenOutline } from '$lib/components/icons';
interface Props { interface Props {
expanded?: boolean; expanded?: boolean
name: any; name: string
files: any; files: any
} selected: (name: string) => void
onDelete: (name: string) => void
}
let { expanded = $bindable(false), name, files }: Props = $props(); let { expanded = $bindable(false), name, files, selected, onDelete }: Props = $props()
function toggle() { function toggle() {
expanded = !expanded; expanded = !expanded
} }
const dispatch = createEventDispatcher();
const updateSelected = async (event:any) => {
dispatch('selected', { name:event.detail.name });
}
</script> </script>
<button class="flex pl-2" onclick={toggle}> <div class="folder-item">
<button class="flex items-center pl-2 hover:bg-gray-700 w-full rounded py-1" onclick={toggle}>
{#if expanded} {#if expanded}
<FolderOpenOutline class="w-6 h-6" /> <FolderOpenOutline class="w-5 h-5 mr-1" />
{:else} {:else}
<FolderIcon class="w-6 h-6" /> <FolderIcon class="w-5 h-5 mr-1" />
{/if} {/if}
{name} <span class="text-sm">{name}</span>
</button> </button>
{#if expanded} {#if expanded}
<ul class="ml-5 border-l border-slate-600"> <ul class="ml-4 border-l border-gray-600 mt-1">
{#each Object.entries(files) as [name, content]} {#each Object.entries(files) as [itemName, content]}
<li class="p-1"> <li class="py-1">
{#if typeof content == 'object'} {#if typeof content === 'object'}
<Folder {name} files={content} on:selected={updateSelected} /> <Folder name={itemName} files={content} {selected} {onDelete} />
{:else} {:else}
<File {name} on:selected={updateSelected}/> <File name={itemName} {selected} {onDelete} />
{/if} {/if}
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
</div>
@@ -0,0 +1,44 @@
<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}
@@ -0,0 +1,44 @@
<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}
+2 -10
View File
@@ -1,15 +1,7 @@
<script lang="ts"> <script lang="ts">
import SystemMetrics from './SystemMetrics.svelte'; import SystemMetrics from './SystemMetrics.svelte';
import { goto } from '$app/navigation';
import { useFeatureFlags } from '$lib/stores/featureFlags';
const features = useFeatureFlags();
if (!$features.analytics) {
goto('/');
}
</script> </script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8"> <div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
<SystemMetrics /> <SystemMetrics />
</div> </div>
+351 -355
View File
@@ -1,373 +1,369 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
import { daisyColor } from '$lib/utilities'; import { daisyColor } from '$lib/utilities';
import { analytics } from '$lib/stores/analytics'; import { analytics } from '$lib/stores/analytics';
import { Metrics } from '$lib/components/icons'; import { Metrics } from '$lib/components/icons';
Chart.register(...registerables); Chart.register(...registerables);
let cpuChartElement: HTMLCanvasElement = $state(); let cpuChartElement: HTMLCanvasElement;
let cpuChart: Chart; let cpuChart: Chart;
let heapChartElement: HTMLCanvasElement = $state(); let heapChartElement: HTMLCanvasElement;
let heapChart: Chart; let heapChart: Chart;
let filesystemChartElement: HTMLCanvasElement = $state(); let filesystemChartElement: HTMLCanvasElement;
let filesystemChart: Chart; let filesystemChart: Chart;
let temperatureChartElement: HTMLCanvasElement = $state(); let temperatureChartElement: HTMLCanvasElement;
let temperatureChart: Chart; let temperatureChart: Chart;
onMount(() => { onMount(() => {
cpuChart = new Chart(cpuChartElement, { cpuChart = new Chart(cpuChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.cpu_usage, labels: $analytics.cpu_usage,
datasets: [ datasets: [
{ {
label: 'Cpu usage core 0', label: 'Cpu usage core 0',
borderColor: daisyColor('--p'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.cpu0_usage, data: $analytics.cpu0_usage,
yAxisID: 'y' yAxisID: 'y',
}, },
{ {
label: 'Cpu usage core 1', label: 'Cpu usage core 1',
borderColor: daisyColor('--p'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.cpu1_usage, data: $analytics.cpu1_usage,
yAxisID: 'y' yAxisID: 'y',
}, },
{ {
label: 'Cpu usage total', label: 'Cpu usage total',
borderColor: daisyColor('--s'), borderColor: daisyColor('--color-secondary'),
backgroundColor: daisyColor('--s', 50), backgroundColor: daisyColor('--color-secondary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.cpu_usage, data: $analytics.cpu_usage,
yAxisID: 'y' yAxisID: 'y',
}, },
] ],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false,
} },
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0,
} },
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--color-base-content', 10),
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--color-base-content'),
}, },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: 'Cpu usage [%]', text: 'Cpu usage [%]',
color: daisyColor('--bc'), color: daisyColor('--color-base-content'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold',
} },
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: 100, max: 100,
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--color-base-content', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--color-base-content'),
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--color-base-content', 10) },
} },
} },
} },
}); });
heapChart = new Chart(heapChartElement, { heapChart = new Chart(heapChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.uptime, labels: $analytics.uptime,
datasets: [ datasets: [
{ {
label: 'Used Heap', label: 'Used Heap',
borderColor: daisyColor('--p'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.used_heap, data: $analytics.used_heap,
fill:true, fill: true,
yAxisID: 'y' yAxisID: 'y',
} },
] ],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false,
} },
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0,
} },
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--color-base-content', 10),
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--color-base-content'),
}, },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: 'Heap [kb]', text: 'Heap [kb]',
color: daisyColor('--bc'), color: daisyColor('--color-base-content'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold',
} },
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: Math.round($analytics.total_heap[0]), max: Math.round($analytics.total_heap[0]),
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--color-base-content', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--color-base-content'),
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--color-base-content', 10) },
} },
} },
} },
}); });
filesystemChart = new Chart(filesystemChartElement, { filesystemChart = new Chart(filesystemChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.uptime, labels: $analytics.uptime,
datasets: [ datasets: [
{ {
label: 'File System Used', label: 'File System Used',
borderColor: daisyColor('--p'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.fs_used, data: $analytics.fs_used,
fill:true, fill: true,
yAxisID: 'y' yAxisID: 'y',
} },
] ],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false,
} },
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0,
} },
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--color-base-content', 10),
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--color-base-content'),
}, },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: 'File System [kb]', text: 'File System [kb]',
color: daisyColor('--bc'), color: daisyColor('--color-base-content'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold',
} },
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: Math.round($analytics.fs_total[0]), max: Math.round($analytics.fs_total[0]),
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--color-base-content', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--color-base-content'),
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--color-base-content', 10) },
} },
} },
} },
}); });
temperatureChart = new Chart(temperatureChartElement, { temperatureChart = new Chart(temperatureChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.uptime, labels: $analytics.uptime,
datasets: [ datasets: [
{ {
label: 'Core Temperature', label: 'Core Temperature',
borderColor: daisyColor('--p'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.core_temp, data: $analytics.core_temp,
yAxisID: 'y' yAxisID: 'y',
} },
] ],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false,
} },
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0,
} },
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--color-base-content', 10),
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--color-base-content'),
}, },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: 'Core Temperature [°C]', text: 'Core Temperature [°C]',
color: daisyColor('--bc'), color: daisyColor('--color-base-content'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold',
} },
}, },
position: 'left', position: 'left',
suggestedMin: 20, suggestedMin: 20,
suggestedMax: 100, suggestedMax: 100,
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--color-base-content', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--color-base-content'),
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--color-base-content', 10) },
} },
} },
} },
}); });
setInterval(updateData, 500); setInterval(updateData, 500);
}); });
function updateData() { function updateData() {
cpuChart.data.labels = $analytics.cpu_usage; cpuChart.data.labels = $analytics.cpu_usage;
cpuChart.data.datasets[0].data = $analytics.cpu0_usage; cpuChart.data.datasets[0].data = $analytics.cpu0_usage;
cpuChart.data.datasets[1].data = $analytics.cpu1_usage; cpuChart.data.datasets[1].data = $analytics.cpu1_usage;
cpuChart.data.datasets[2].data = $analytics.cpu_usage; cpuChart.data.datasets[2].data = $analytics.cpu_usage;
cpuChart.update('none'); cpuChart.update('none');
heapChart.data.labels = $analytics.uptime; heapChart.data.labels = $analytics.uptime;
heapChart.data.datasets[0].data = $analytics.used_heap; heapChart.data.datasets[0].data = $analytics.used_heap;
heapChart.options.scales!.y!.max = Math.ceil($analytics.total_heap[0]); heapChart.options.scales!.y!.max = Math.ceil($analytics.total_heap[0]);
heapChart.update('none'); heapChart.update('none');
filesystemChart.data.labels = $analytics.uptime; filesystemChart.data.labels = $analytics.uptime;
filesystemChart.data.datasets[0].data = $analytics.fs_used; filesystemChart.data.datasets[0].data = $analytics.fs_used;
heapChart.options.scales!.y!.max = Math.ceil($analytics.fs_total[0]); heapChart.options.scales!.y!.max = Math.ceil($analytics.fs_total[0]);
filesystemChart.update('none'); filesystemChart.update('none');
temperatureChart.data.labels = $analytics.uptime; temperatureChart.data.labels = $analytics.uptime;
temperatureChart.data.datasets[0].data = $analytics.core_temp; temperatureChart.data.datasets[0].data = $analytics.core_temp;
temperatureChart.update('none'); temperatureChart.update('none');
} }
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<Metrics class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <Metrics class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span >System Metrics</span> <span>System Metrics</span>
{/snippet} {/snippet}
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
<div <div
class="flex w-full flex-col space-y-1 h-60" class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}>
> <canvas bind:this={cpuChartElement}></canvas>
<canvas bind:this={cpuChartElement}></canvas> </div>
</div> </div>
</div>
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
<div <div
class="flex w-full flex-col space-y-1 h-60" class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}>
> <canvas bind:this={heapChartElement}></canvas>
<canvas bind:this={heapChartElement}></canvas> </div>
</div> </div>
</div> <div class="w-full overflow-x-auto">
<div class="w-full overflow-x-auto"> <div
<div class="flex w-full flex-col space-y-1 h-52"
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>
<canvas bind:this={filesystemChartElement}></canvas> </div>
</div> <div class="w-full overflow-x-auto">
</div> <div
<div class="w-full overflow-x-auto"> class="flex w-full flex-col space-y-1 h-52"
<div transition:slide|local={{ duration: 300, easing: cubicOut }}>
class="flex w-full flex-col space-y-1 h-52" <canvas bind:this={temperatureChartElement}></canvas>
transition:slide|local={{ duration: 300, easing: cubicOut }} </div>
> </div>
<canvas bind:this={temperatureChartElement}></canvas>
</div>
</div>
</SettingsCard> </SettingsCard>
@@ -1,15 +0,0 @@
<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,12 +6,10 @@
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
import { type SystemInformation, type Analytics, MessageTopic } from '$lib/types/models'
import type { SystemInformation, Analytics } from '$lib/types/models'
import { socket } from '$lib/stores/socket' import { socket } from '$lib/stores/socket'
import { api } from '$lib/api' import { api } from '$lib/api'
import { convertSeconds } from '$lib/utilities' import { convertSeconds } from '$lib/utilities'
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags'
import { import {
Cancel, Cancel,
@@ -31,12 +29,12 @@
Temperature, Temperature,
Stopwatch Stopwatch
} from '$lib/components/icons' } from '$lib/components/icons'
import StatusItem from './StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte'
import ActionButton from './ActionButton.svelte' import ActionButton from './ActionButton.svelte'
const features = useFeatureFlags() const features = useFeatureFlags()
let systemInformation: SystemInformation = $state() let systemInformation: SystemInformation | null = $state(null)
async function getSystemStatus() { async function getSystemStatus() {
const result = await api.get<SystemInformation>('/api/system/status') const result = await api.get<SystemInformation>('/api/system/status')
@@ -52,12 +50,17 @@
const postSleep = async () => await api.post('api/sleep') const postSleep = async () => await api.post('api/sleep')
onMount(() => socket.on('analytics', handleSystemData)) onMount(() => socket.on(MessageTopic.analytics, handleSystemData))
onDestroy(() => socket.off('analytics', handleSystemData)) onDestroy(() => socket.off(MessageTopic.analytics, handleSystemData))
const handleSystemData = (data: Analytics) => {
const handleSystemData = (data: Analytics) => if (systemInformation) {
(systemInformation = { ...systemInformation, ...data }) systemInformation = {
...systemInformation,
...(data as unknown as SystemInformation)
}
}
}
const postRestart = async () => await api.post('/api/system/restart') const postRestart = async () => await api.post('/api/system/restart')
@@ -146,89 +149,92 @@
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
{#await getSystemStatus()} {#await getSystemStatus()}
<Spinner /> <Spinner />
{:then nothing} {:then}
<div {#if systemInformation}
class="flex w-full flex-col space-y-1" <div
transition:slide|local={{ duration: 300, easing: cubicOut }}> class="flex w-full flex-col space-y-1"
<StatusItem transition:slide|local={{ duration: 300, easing: cubicOut }}>
icon={CPU} <StatusItem
title="Chip" icon={CPU}
description={`${systemInformation.cpu_type} Rev ${systemInformation.cpu_rev}`} /> title="Chip"
description={`${systemInformation.cpu_type} Rev ${systemInformation.cpu_rev}`} />
<StatusItem <StatusItem
icon={SDK} icon={SDK}
title="SDK Version" title="SDK Version"
description={`ESP-IDF ${systemInformation.sdk_version} / Arduino ${systemInformation.arduino_version}`} /> description={`ESP-IDF ${systemInformation.sdk_version} / Arduino ${systemInformation.arduino_version}`} />
<StatusItem <StatusItem
icon={CPP} icon={CPP}
title="Firmware Version" title="Firmware Version"
description={systemInformation.firmware_version} /> description={systemInformation.firmware_version} />
<StatusItem <StatusItem
icon={Speed} icon={Speed}
title="CPU Frequency" title="CPU Frequency"
description={`${systemInformation.cpu_freq_mhz} MHz ${ description={`${systemInformation.cpu_freq_mhz} MHz ${
systemInformation.cpu_cores == 2 ? 'Dual Core' : 'Single Core' systemInformation.cpu_cores == 2 ? 'Dual Core' : 'Single Core'
}`} /> }`} />
<StatusItem <StatusItem
icon={Heap} icon={Heap}
title="Heap (Free / Max Alloc)" title="Heap (Free / Max Alloc)"
description={`${systemInformation.free_heap} / ${systemInformation.max_alloc_heap} bytes`} /> description={`${systemInformation.free_heap} / ${systemInformation.max_alloc_heap} bytes`} />
<StatusItem <StatusItem
icon={Pyramid} icon={Pyramid}
title="PSRAM (Size / Free)" title="PSRAM (Size / Free)"
description={`${systemInformation.psram_size} / ${systemInformation.psram_size} bytes`} /> description={`${systemInformation.psram_size} / ${systemInformation.psram_size} bytes`} />
<StatusItem <StatusItem
icon={Sketch} icon={Sketch}
title="Sketch (Used / Free)" title="Sketch (Used / Free)"
description={`${( description={`${(
(systemInformation.sketch_size / systemInformation.free_sketch_space) * (systemInformation.sketch_size / systemInformation.free_sketch_space) *
100 100
).toFixed(1)} % of ).toFixed(1)} % of
${systemInformation.free_sketch_space / 1000000} MB used (${ ${systemInformation.free_sketch_space / 1000000} MB used (${
(systemInformation.free_sketch_space - systemInformation.sketch_size) / 1000000 (systemInformation.free_sketch_space - systemInformation.sketch_size) / 1000000
} MB free)`} /> } MB free)`} />
<StatusItem <StatusItem
icon={Flash} icon={Flash}
title="Flash Chip (Size / Speed)" title="Flash Chip (Size / Speed)"
description={`${systemInformation.flash_chip_size / 1000000} MB / ${ description={`${systemInformation.flash_chip_size / 1000000} MB / ${
systemInformation.flash_chip_speed / 1000000 systemInformation.flash_chip_speed / 1000000
} MHz`} /> } MHz`} />
<StatusItem <StatusItem
icon={Folder} icon={Folder}
title="File System (Used / Total)" title="File System (Used / Total)"
description={`${((systemInformation.fs_used / systemInformation.fs_total) * 100).toFixed( description={`${(
1 (systemInformation.fs_used / systemInformation.fs_total) *
)} % of ${systemInformation.fs_total / 1000000} MB used (${ 100
(systemInformation.fs_total - systemInformation.fs_used) / 1000000 ).toFixed(1)} % of ${systemInformation.fs_total / 1000000} MB used (${
} (systemInformation.fs_total - systemInformation.fs_used) / 1000000
}
MB free)`} /> MB free)`} />
<StatusItem <StatusItem
icon={Temperature} icon={Temperature}
title="Core Temperature" title="Core Temperature"
description={`${ description={`${
systemInformation.core_temp == 53.33 ? systemInformation.core_temp == 53.33 ?
'NaN' 'NaN'
: systemInformation.core_temp.toFixed(2) + ' °C' : systemInformation.core_temp.toFixed(2) + ' °C'
}`} /> }`} />
<StatusItem <StatusItem
icon={Stopwatch} icon={Stopwatch}
title="Uptime" title="Uptime"
description={convertSeconds(systemInformation.uptime)} /> description={convertSeconds(systemInformation.uptime)} />
<StatusItem <StatusItem
icon={Power} icon={Power}
title="Reset Reason" title="Reset Reason"
description={systemInformation.cpu_reset_reason} /> description={systemInformation.cpu_reset_reason} />
</div> </div>
{/if}
{/await} {/await}
</div> </div>
@@ -236,7 +242,7 @@
{#each actionButtons as button} {#each actionButtons as button}
{#if button.condition === undefined || button.condition()} {#if button.condition === undefined || button.condition()}
<ActionButton <ActionButton
on:click={button.onClick} onclick={button.onClick}
icon={button.icon} icon={button.icon}
label={button.label} label={button.label}
type={button.type || 'primary'} /> type={button.type || 'primary'} />
@@ -1,165 +1,154 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { modals } from 'svelte-modals'; import { modals } from 'svelte-modals';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Spinner from '$lib/components/Spinner.svelte'; import Spinner from '$lib/components/Spinner.svelte';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import { compareVersions } from 'compare-versions'; import { compareVersions } from 'compare-versions';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'; import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import InfoDialog from '$lib/components/InfoDialog.svelte'; import InfoDialog from '$lib/components/InfoDialog.svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { useFeatureFlags } from '$lib/stores'; import { useFeatureFlags } from '$lib/stores';
import { Error, Cancel, Check, CloudDown, Github, Prerelease } from '$lib/components/icons'; import { Error, Cancel, Check, CloudDown, Github, Prerelease } from '$lib/components/icons';
const features = useFeatureFlags(); const features = useFeatureFlags();
async function getGithubAPI() { async function getGithubAPI() {
const headers = { const headers = {
accept: 'application/vnd.github+json', 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`, { const result = await api.get(`https://api.github.com/repos/${page.data.github}/releases`, {
headers headers,
});
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
return result.inner as any;
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/firmware/download', { download_url: url });
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
}
function confirmGithubUpdate(assets: any) {
let url = '';
// iterate over assets and find the correct one
for (let i = 0; i < assets.length; i++) {
// check if the asset is of type *.bin
if (
assets[i].name.includes('.bin') &&
assets[i].name.includes($features.firmware_built_target)
) {
url = assets[i].browser_download_url;
}
}
if (url === '') {
// if no asset was found, use the first one
modals.open(InfoDialog, {
title: 'No matching firmware found',
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(),
});
return;
}
modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown },
},
onConfirm: () => {
postGithubDownload(url);
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll(),
}); });
if (result.isErr()) { },
console.error('Error:', result.inner); });
return; }
}
return result.inner as any;
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/firmware/download', { download_url: url });
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
}
function confirmGithubUpdate(assets: any) {
let url = '';
// iterate over assets and find the correct one
for (let i = 0; i < assets.length; i++) {
// check if the asset is of type *.bin
if (
assets[i].name.includes('.bin') &&
assets[i].name.includes($features.firmware_built_target)
) {
url = assets[i].browser_download_url;
}
}
if (url === '') {
// if no asset was found, use the first one
modals.open(InfoDialog, {
title: 'No matching firmware found',
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()
});
return;
}
modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url);
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
});
}
});
}
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<Github class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" /> <Github class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span>Github Firmware Manager</span> <span>Github Firmware Manager</span>
{/snippet} {/snippet}
{#await getGithubAPI()} {#await getGithubAPI()}
<Spinner /> <Spinner />
{:then githubReleases} {:then githubReleases}
<div class="relative w-full overflow-visible"> <div class="relative w-full overflow-visible">
<div <div class="overflow-x-auto" transition:slide|local={{ duration: 300, easing: cubicOut }}>
class="overflow-x-auto" <table class="table w-full table-auto">
transition:slide|local={{ duration: 300, easing: cubicOut }} <thead>
> <tr class="font-bold">
<table class="table w-full table-auto"> <th align="left">Release</th>
<thead> <th align="center" class="hidden sm:block">Release Date</th>
<tr class="font-bold"> <th align="center">Experimental</th>
<th align="left">Release</th> <th align="center">Install</th>
<th align="center" class="hidden sm:block">Release Date</th> </tr>
<th align="center">Experimental</th> </thead>
<th align="center">Install</th> <tbody>
</tr> {#each githubReleases as release}
</thead> <tr
<tbody> class={(
{#each githubReleases as release} compareVersions($features.firmware_version as string, release.tag_name) === 0
<tr ) ?
class={( 'bg-primary text-primary-content'
compareVersions( : 'bg-base-100 h-14'}>
$features.firmware_version, <td align="left" class="text-base font-semibold">
release.tag_name <a
) === 0 href={release.html_url}
) ? class="link link-hover"
'bg-primary text-primary-content' target="_blank"
: 'bg-base-100 h-14'} rel="noopener noreferrer">{release.name}</a
> ></td>
<td align="left" class="text-base font-semibold"> <td align="center" class="hidden min-h-full align-middle sm:block">
<a <div class="my-2">
href={release.html_url} {new Intl.DateTimeFormat('en-GB', {
class="link link-hover" dateStyle: 'medium',
target="_blank" }).format(new Date(release.published_at))}
rel="noopener noreferrer">{release.name}</a </div>
></td </td>
> <td align="center">
<td align="center" class="hidden min-h-full align-middle sm:block"> {#if release.prerelease}
<div class="my-2"> <Prerelease class="text-accent h-5 w-5" />
{new Intl.DateTimeFormat('en-GB', { {/if}
dateStyle: 'medium' </td>
}).format(new Date(release.published_at))} <td align="center">
</div> {#if compareVersions($features.firmware_version as string, release.tag_name) != 0}
</td> <button
<td align="center"> class="btn btn-ghost btn-circle btn-sm"
{#if release.prerelease} onclick={() => {
<Prerelease class="text-accent h-5 w-5" /> confirmGithubUpdate(release.assets);
{/if} }}>
</td> <CloudDown class="text-secondary h-6 w-6" />
<td align="center"> </button>
{#if compareVersions($features.firmware_version, release.tag_name) != 0} {/if}
<button </td>
class="btn btn-ghost btn-circle btn-sm" </tr>
onclick={() => { {/each}
confirmGithubUpdate(release.assets); </tbody>
}} </table>
> </div>
<CloudDown class="text-secondary h-6 w-6" /> </div>
</button> {:catch error}
{/if} <div class="alert alert-error shadow-lg">
</td> <Error class="h-6 w-6 shrink-0" />
</tr> <span>Please connect to a network with internet access to perform a firmware update.</span>
{/each} </div>
</tbody> {/await}
</table>
</div>
</div>
{: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
>
</div>
{/await}
</SettingsCard> </SettingsCard>
@@ -1,57 +1,56 @@
<script lang="ts"> <script lang="ts">
import { modals } from 'svelte-modals'; import { modals } from 'svelte-modals';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { Cancel, OTA, Warning } from '$lib/components/icons'; import { Cancel, OTA, Warning } from '$lib/components/icons';
let files: FileList = $state(); let files: FileList | undefined = $state();
async function uploadBIN() { async function uploadBIN() {
const formData = new FormData(); const formData = new FormData();
formData.append('file', files[0]); formData.append('file', files![0]);
const result = await api.post('/api/firmware', formData); const result = await api.post('/api/firmware', formData);
if (result.isErr()) console.error('Error:', result.inner); if (result.isErr()) console.error('Error:', result.inner);
} }
function confirmBinUpload() { function confirmBinUpload() {
modals.open(ConfirmDialog, { modals.open(ConfirmDialog, {
title: 'Confirm Flashing the Device', title: 'Confirm Flashing the Device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?', message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: { labels: {
cancel: { label: 'Abort', icon: Cancel }, cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Upload', icon: OTA } confirm: { label: 'Upload', icon: OTA },
}, },
onConfirm: () => { onConfirm: () => {
modals.close(); modals.close();
uploadBIN(); uploadBIN();
} },
}); });
} }
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<OTA class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" /> <OTA class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span>Upload Firmware</span> <span>Upload Firmware</span>
{/snippet} {/snippet}
<div class="alert alert-warning shadow-lg"> <div class="alert alert-warning shadow-lg">
<Warning class="h-6 w-6 shrink-0" /> <Warning class="h-6 w-6 shrink-0" />
<span <span
>Uploading a new firmware (.bin) file will replace the existing firmware. You may upload >Uploading a new firmware (.bin) file will replace the existing firmware. You may upload a
a (.md5) file first to verify the uploaded firmware. (.md5) file first to verify the uploaded firmware.
</span> </span>
</div> </div>
<input <input
type="file" type="file"
id="binFile" id="binFile"
class="file-input file-input-bordered file-input-secondary mt-4 w-full" class="file-input file-input-bordered file-input-secondary mt-4 w-full"
bind:files bind:files
accept=".bin,.md5" accept=".bin,.md5"
onchange={confirmBinUpload} onchange={confirmBinUpload} />
/>
</SettingsCard> </SettingsCard>
+343 -414
View File
@@ -1,436 +1,365 @@
<script lang="ts"> <script lang="ts">
import { preventDefault } from 'svelte/legacy'; import { preventDefault } from 'svelte/legacy';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { PasswordInput } from '$lib/components/input'; import { PasswordInput } from '$lib/components/input';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications';
import Spinner from '$lib/components/Spinner.svelte'; import Spinner from '$lib/components/Spinner.svelte';
import type { ApSettings, ApStatus } from '$lib/types/models'; import type { ApSettings, ApStatus } from '$lib/types/models';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { useFeatureFlags } from '$lib/stores'; import { AP, Devices, Home, MAC } from '$lib/components/icons';
import { AP, Devices, Home, MAC } from '$lib/components/icons'; import StatusItem from '$lib/components/StatusItem.svelte';
const features = useFeatureFlags(); let apSettings: ApSettings | null = $state(null);
let apStatus: ApStatus | null = $state(null);
let apSettings: ApSettings = $state(); let formField: any = $state();
let apStatus: ApStatus = $state();
let formField: any = $state(); async function getAPStatus() {
const result = await api.get<ApStatus>('/api/wifi/ap/status');
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
apStatus = result.inner;
return apStatus;
}
async function getAPStatus() { async function getAPSettings() {
const result = await api.get<ApStatus>('/api/wifi/ap/status'); const result = await api.get<ApSettings>('/api/wifi/ap/settings');
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner); console.error('Error:', result.inner);
return; return;
} }
apStatus = result.inner; apSettings = result.inner;
return apStatus; return apSettings;
}
const interval = setInterval(async () => {
getAPStatus();
}, 5000);
onDestroy(() => clearInterval(interval));
onMount(getAPSettings);
let provisionMode = [
{
id: 0,
text: `Always`,
},
{
id: 1,
text: `When WiFi Disconnected`,
},
{
id: 2,
text: `Never`,
},
];
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning';
let apStatusVariant: Variant[] = ['success', 'error', 'warning'];
let apStatusDescription = ['Active', 'Inactive', 'Lingering'];
let formErrors = $state({
ssid: false,
channel: false,
max_clients: false,
local_ip: false,
gateway_ip: false,
subnet_mask: false,
});
async function postAPSettings(data: ApSettings) {
const result = await api.post<ApSettings>('/api/wifi/ap/settings', data);
if (result.isErr()) {
notifications.error('User not authorized.', 3000);
console.error('Error:', result.inner);
return;
}
notifications.success('Access Point settings updated.', 3000);
apSettings = result.inner;
}
function handleSubmitAP() {
if (!apSettings) return;
let valid = true;
// Validate SSID
if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) {
valid = false;
formErrors.ssid = true;
} else {
formErrors.ssid = false;
} }
async function getAPSettings() { // Validate Channel
const result = await api.get<ApSettings>('/api/wifi/ap/settings'); let channel = Number(apSettings.channel);
if (result.isErr()) { if (1 > channel || channel > 13) {
console.error('Error:', result.inner); valid = false;
return; formErrors.channel = true;
} } else {
apSettings = result.inner; formErrors.channel = false;
return apSettings;
} }
const interval = setInterval(async () => { // Validate max_clients
getAPStatus(); let maxClients = Number(apSettings.max_clients);
}, 5000); if (1 > maxClients || maxClients > 8) {
valid = false;
onDestroy(() => clearInterval(interval)); formErrors.max_clients = true;
} else {
onMount(getAPSettings); formErrors.max_clients = false;
let provisionMode = [
{
id: 0,
text: `Always`
},
{
id: 1,
text: `When WiFi Disconnected`
},
{
id: 2,
text: `Never`
}
];
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,
channel: false,
max_clients: false,
local_ip: false,
gateway_ip: false,
subnet_mask: false
});
async function postAPSettings(data: ApSettings) {
const result = await api.post<ApSettings>('/api/wifi/ap/settings', data);
if (result.isErr()) {
notifications.error('User not authorized.', 3000);
console.error('Error:', result.inner);
return;
}
notifications.success('Access Point settings updated.', 3000);
apSettings = result.inner;
} }
function handleSubmitAP() { // RegEx for IPv4
let valid = true; const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/;
// Validate SSID // Validate gateway IP
if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) { if (!regexExp.test(apSettings.gateway_ip)) {
valid = false; valid = false;
formErrors.ssid = true; formErrors.gateway_ip = true;
} else { } else {
formErrors.ssid = false; formErrors.gateway_ip = false;
}
// Validate Channel
let channel = Number(apSettings.channel);
if (1 > channel || channel > 13) {
valid = false;
formErrors.channel = true;
} else {
formErrors.channel = false;
}
// Validate max_clients
let maxClients = Number(apSettings.max_clients);
if (1 > maxClients || maxClients > 8) {
valid = false;
formErrors.max_clients = true;
} else {
formErrors.max_clients = false;
}
// RegEx for IPv4
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/;
// Validate gateway IP
if (!regexExp.test(apSettings.gateway_ip)) {
valid = false;
formErrors.gateway_ip = true;
} else {
formErrors.gateway_ip = false;
}
// Validate Subnet Mask
if (!regexExp.test(apSettings.subnet_mask)) {
valid = false;
formErrors.subnet_mask = true;
} else {
formErrors.subnet_mask = false;
}
// Validate local IP
if (!regexExp.test(apSettings.local_ip)) {
valid = false;
formErrors.local_ip = true;
} else {
formErrors.local_ip = false;
}
// Submit JSON to REST API
if (valid) {
postAPSettings(apSettings);
}
} }
// Validate Subnet Mask
if (!regexExp.test(apSettings.subnet_mask)) {
valid = false;
formErrors.subnet_mask = true;
} else {
formErrors.subnet_mask = false;
}
// Validate local IP
if (!regexExp.test(apSettings.local_ip)) {
valid = false;
formErrors.local_ip = true;
} else {
formErrors.local_ip = false;
}
// Submit JSON to REST API
if (valid) {
postAPSettings(apSettings);
}
}
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<AP class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <AP class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span >Access Point</span> <span>Access Point</span>
{/snippet} {/snippet}
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
{#await getAPStatus()} {#await getAPStatus()}
<Spinner /> <Spinner />
{:then nothing} {:then}
<div {#if apStatus}
class="flex w-full flex-col space-y-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 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>
{/await}
</div>
<div class="bg-base-200 relative grid w-full max-w-2xl self-center overflow-hidden">
<div <div
class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium" class="flex w-full flex-col space-y-1"
> transition:slide|local={{ duration: 300, easing: cubicOut }}>
Change AP Settings <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} />
</div> </div>
{#await getAPSettings()} {/if}
<Spinner /> {/await}
{:then nothing} </div>
<div
class="flex flex-col gap-2 p-0"
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}
>
<div>
<label class="label" for="apmode">
<span class="label-text">Provide Access Point ...</span>
</label>
<select
class="select select-bordered w-full"
id="apmode"
bind:value={apSettings.provision_mode}
>
{#each provisionMode as mode}
<option value={mode.id}>
{mode.text}
</option>
{/each}
</select>
</div>
<div>
<label class="label" for="ssid">
<span class="label-text text-md">SSID</span>
</label>
<input
type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrors.ssid
) ?
'border-error border-2'
: ''}"
bind:value={apSettings.ssid}
id="ssid"
min="2"
max="32"
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
>
</label>
</div>
<div> <div class="bg-base-200 relative grid w-full max-w-2xl self-center overflow-hidden">
<label class="label" for="pwd"> <div
<span class="label-text text-md">Password</span> class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium">
</label> Change AP Settings
<PasswordInput bind:value={apSettings.password} id="pwd" />
</div>
<div>
<label class="label" for="channel">
<span class="label-text text-md">Preferred Channel</span>
</label>
<input
type="number"
min="1"
max="13"
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrors.channel
) ?
'border-error border-2'
: ''}"
bind:value={apSettings.channel}
id="channel"
required
/>
<label class="label" for="channel">
<span
class="label-text-alt text-error {formErrors.channel ? '' : (
'hidden'
)}">Must be channel 1 to 13</span
>
</label>
</div>
<div>
<label class="label" for="clients">
<span class="label-text text-md">Max Clients</span>
</label>
<input
type="number"
min="1"
max="8"
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrors.max_clients
) ?
'border-error border-2'
: ''}"
bind:value={apSettings.max_clients}
id="clients"
required
/>
<label class="label" for="clients">
<span
class="label-text-alt text-error {formErrors.max_clients ? '' : (
'hidden'
)}">Maximum 8 clients allowed</span
>
</label>
</div>
<div>
<label class="label" for="localIP">
<span class="label-text text-md">Local IP</span>
</label>
<input
type="text"
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
/>
<label class="label" for="localIP">
<span
class="label-text-alt text-error {formErrors.local_ip ? '' : (
'hidden'
)}">Must be a valid IPv4 address</span
>
</label>
</div>
<div>
<label class="label" for="gateway">
<span class="label-text text-md">Gateway IP</span>
</label>
<input
type="text"
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
/>
<label class="label" for="gateway">
<span
class="label-text-alt text-error {formErrors.gateway_ip ? '' : (
'hidden'
)}">Must be a valid IPv4 address</span
>
</label>
</div>
<div>
<label class="label" for="subnet">
<span class="label-text text-md">Subnet Mask</span>
</label>
<input
type="text"
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
/>
<label class="label" for="subnet">
<span
class="label-text-alt text-error {formErrors.subnet_mask ? '' : (
'hidden'
)}">Must be a valid IPv4 address</span
>
</label>
</div>
<label class="label my-auto cursor-pointer justify-start gap-4">
<input
type="checkbox"
bind:checked={apSettings.ssid_hidden}
class="checkbox checkbox-primary"
/>
<span class="">Hide SSID</span>
</label>
<div class="place-self-end">
<button class="btn btn-primary" type="submit">Apply Settings</button>
</div>
</form>
</div>
{/await}
</div> </div>
{#await getAPSettings()}
<Spinner />
{:then}
{#if apSettings}
<div
class="flex flex-col gap-2 p-0"
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}>
<div>
<label class="label" for="apmode">
<span class="label-text">Provide Access Point ...</span>
</label>
<select
class="select select-bordered w-full"
id="apmode"
bind:value={apSettings.provision_mode}>
{#each provisionMode as mode}
<option value={mode.id}>
{mode.text}
</option>
{/each}
</select>
</div>
<div>
<label class="label" for="ssid">
<span class="label-text text-md">SSID</span>
</label>
<input
type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrors.ssid
) ?
'border-error border-2'
: ''}"
bind:value={apSettings.ssid}
id="ssid"
min="2"
max="32"
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>
</label>
</div>
<div>
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<PasswordInput bind:value={apSettings.password} id="pwd" />
</div>
<div>
<label class="label" for="channel">
<span class="label-text text-md">Preferred Channel</span>
</label>
<input
type="number"
min="1"
max="13"
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrors.channel
) ?
'border-error border-2'
: ''}"
bind:value={apSettings.channel}
id="channel"
required />
<label class="label" for="channel">
<span class="label-text-alt text-error {formErrors.channel ? '' : 'hidden'}"
>Must be channel 1 to 13</span>
</label>
</div>
<div>
<label class="label" for="clients">
<span class="label-text text-md">Max Clients</span>
</label>
<input
type="number"
min="1"
max="8"
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrors.max_clients
) ?
'border-error border-2'
: ''}"
bind:value={apSettings.max_clients}
id="clients"
required />
<label class="label" for="clients">
<span class="label-text-alt text-error {formErrors.max_clients ? '' : 'hidden'}"
>Maximum 8 clients allowed</span>
</label>
</div>
<div>
<label class="label" for="localIP">
<span class="label-text text-md">Local IP</span>
</label>
<input
type="text"
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 />
<label class="label" for="localIP">
<span class="label-text-alt text-error {formErrors.local_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
</label>
</div>
<div>
<label class="label" for="gateway">
<span class="label-text text-md">Gateway IP</span>
</label>
<input
type="text"
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 />
<label class="label" for="gateway">
<span class="label-text-alt text-error {formErrors.gateway_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
</label>
</div>
<div>
<label class="label" for="subnet">
<span class="label-text text-md">Subnet Mask</span>
</label>
<input
type="text"
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 />
<label class="label" for="subnet">
<span class="label-text-alt text-error {formErrors.subnet_mask ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
</label>
</div>
<label class="label my-auto cursor-pointer justify-start gap-4">
<input
type="checkbox"
bind:checked={apSettings.ssid_hidden}
class="checkbox checkbox-primary" />
<span class="">Hide SSID</span>
</label>
<div class="place-self-end">
<button class="btn btn-primary" type="submit">Apply Settings</button>
</div>
</form>
</div>
{/if}
{/await}
</div>
</SettingsCard> </SettingsCard>
+7
View File
@@ -0,0 +1,7 @@
<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
@@ -0,0 +1,100 @@
<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>
+115 -131
View File
@@ -1,147 +1,131 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap'; import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'; import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte';
import type { NetworkItem, NetworkList } from '$lib/types/models'; import type { NetworkItem, NetworkList } from '$lib/types/models';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { AP, Network, Reload, Cancel } from '$lib/components/icons'; import { AP, Network, Reload, Cancel } from '$lib/components/icons';
import { modals, exitBeforeEnter } from 'svelte-modals'; import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals';
// provided by <Modals /> let { isOpen, storeNetwork }: ModalProps = $props();
interface Props {
isOpen: boolean; const encryptionType = [
storeNetwork: any; 'Open',
'WEP',
'WPA PSK',
'WPA2 PSK',
'WPA WPA2 PSK',
'WPA2 Enterprise',
'WPA3 PSK',
'WPA2 WPA3 PSK',
'WAPI PSK'
]
let listOfNetworks: NetworkItem[] = $state([])
let scanActive = $state(false)
let pollingId: ReturnType<typeof setTimeout> | number
async function scanNetworks() {
scanActive = true
await api.get('/api/wifi/scan')
if ((await pollingResults()) == false) {
pollingId = setInterval(() => pollingResults(), 1000)
} }
return
}
let { isOpen, storeNetwork }: Props = $props(); async function pollingResults() {
const result = await api.get<NetworkList>('/api/wifi/networks')
const encryptionType = [ if (result.isErr()) {
'Open', console.error(`Error occurred while fetching: `, result.inner)
'WEP', return false
'WPA PSK',
'WPA2 PSK',
'WPA WPA2 PSK',
'WPA2 Enterprise',
'WPA3 PSK',
'WPA2 WPA3 PSK',
'WAPI PSK'
];
let listOfNetworks: NetworkItem[] = $state([]);
let scanActive = $state(false);
let pollingId: number;
async function scanNetworks() {
scanActive = true;
await api.get('/api/wifi/scan');
if ((await pollingResults()) == false) {
pollingId = setInterval(() => pollingResults(), 1000);
}
return;
} }
let response = result.inner
async function pollingResults() { listOfNetworks = response.networks
const result = await api.get<NetworkList>('/api/wifi/networks'); scanActive = false
if (result.isErr()) { if (listOfNetworks.length) {
console.error(`Error occurred while fetching: `, result.inner); clearInterval(pollingId)
return false; pollingId = 0
}
let response = result.inner;
listOfNetworks = response.networks;
scanActive = false;
if (listOfNetworks.length) {
clearInterval(pollingId);
pollingId = 0;
}
return listOfNetworks.length;
} }
return listOfNetworks.length
}
onMount(() => { onMount(() => {
scanNetworks(); scanNetworks()
}); })
onDestroy(() => { onDestroy(() => {
if (pollingId) { if (pollingId) {
clearInterval(pollingId); clearInterval(pollingId)
pollingId = 0; pollingId = 0
} }
}); })
</script> </script>
{#if isOpen} {#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
<div <div
role="dialog" 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="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" <h2 class="text-base-content text-start text-2xl font-bold">Scan Networks</h2>
transition:fly={{ y: 50 }} <div class="divider my-2"></div>
use:exitBeforeEnter <div class="overflow-y-auto">
use:focusTrap {#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" />
<div <p class="mt-8 text-2xl">Scanning ...</p>
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" </div>
> {:else}
<h2 class="text-base-content text-start text-2xl font-bold">Scan Networks</h2> <ul class="menu">
<div class="divider my-2"></div> {#each listOfNetworks as network, i}
<div class="overflow-y-auto"> <li>
{#if scanActive}<div <!-- svelte-ignore a11y_click_events_have_key_events -->
class="bg-base-100 flex flex-col items-center justify-center p-6" <div
> class="bg-base-200 rounded-btn my-1 flex items-center space-x-3 hover:scale-[1.02] active:scale-[0.98]"
<AP class="text-secondary h-32 w-32 shrink animate-ping stroke-2" /> onclick={() => {
<p class="mt-8 text-2xl">Scanning ...</p> storeNetwork(network.ssid)
}}
role="button"
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" />
</div>
<div>
<div class="font-bold">{network.ssid}</div>
<div class="text-sm opacity-75">
Security: {encryptionType[network.encryption_type]}, Channel: {network.channel}
</div> </div>
{:else} </div>
<ul class="menu"> <div class="grow"></div>
{#each listOfNetworks as network, i} <RssiIndicator showDBm={true} rssi={network.rssi} />
<li> </div>
<!-- svelte-ignore a11y_click_events_have_key_events --> </li>
<div {/each}
class="bg-base-200 rounded-btn my-1 flex items-center space-x-3 hover:scale-[1.02] active:scale-[0.98]" </ul>
onclick={() => { {/if}
storeNetwork(network.ssid); </div>
}} <div class="divider my-2"></div>
role="button" <div class="flex flex-wrap justify-end gap-2">
tabindex="0" <button
> class="btn btn-primary inline-flex flex-none items-center"
<div class="mask mask-hexagon bg-primary h-auto w-10 shrink-0"> disabled={scanActive}
<Network onclick={scanNetworks}>
class="text-primary-content h-auto w-full scale-75" <Reload class="mr-2 h-5 w-5" /><span>Scan again</span>
/> </button>
</div>
<div>
<div class="font-bold">{network.ssid}</div>
<div class="text-sm opacity-75">
Security: {encryptionType[network.encryption_type]},
Channel: {network.channel}
</div>
</div>
<div class="grow"></div>
<RssiIndicator showDBm={true} rssi={network.rssi} />
</div>
</li>
{/each}
</ul>
{/if}
</div>
<div class="divider my-2"></div>
<div class="flex flex-wrap justify-end gap-2">
<button
class="btn btn-primary inline-flex flex-none items-center"
disabled={scanActive}
onclick={scanNetworks}
>
<Reload class="mr-2 h-5 w-5" /><span>Scan again</span>
</button>
<div class="grow"></div> <div class="grow"></div>
<button <button
class="btn btn-warning text-warning-content inline-flex flex-none items-center" class="btn btn-warning text-warning-content inline-flex flex-none items-center"
onclick={() => modals.close()} onclick={() => modals.close()}>
> <Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span> </button>
</button> </div>
</div>
</div>
</div> </div>
</div>
{/if} {/if}
File diff suppressed because it is too large Load Diff
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
# 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
@@ -0,0 +1,5 @@
# 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
@@ -0,0 +1,14 @@
# 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
@@ -0,0 +1,14 @@
# 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
@@ -0,0 +1,14 @@
# 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
@@ -0,0 +1,14 @@
# 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
@@ -0,0 +1,5 @@
# 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
@@ -0,0 +1,14 @@
# 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
@@ -0,0 +1,402 @@
<?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>
+6 -3
View File
@@ -1,10 +1,10 @@
import adapter from '@sveltejs/adapter-static' import adapter from '@sveltejs/adapter-static'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
const basePath = process.env.BASE_PATH ?? ''
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { kit: {
@@ -14,7 +14,10 @@ const config = {
fallback: 'index.html', fallback: 'index.html',
precompress: false, precompress: false,
strict: true strict: true
}) }),
paths: {
base: basePath
}
} }
} }
+7 -20
View File
@@ -1,24 +1,11 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test'
test('has title', async ({ page }) => { test('has title', async ({ page }) => {
await page.goto('/'); await page.goto('/')
await page.route('**/api/features', (route) => await expect(page).toHaveTitle(/Spot micro controller/)
route.fulfill({ })
status: 200,
body: JSON.stringify({})
})
);
await expect(page).toHaveTitle(/Spot micro controller/);
});
test('index page has expected h1', async ({ page }) => { test('index page has expected h1', async ({ page }) => {
await page.goto('/'); await page.goto('/')
await page.route('**/api/features', (route) => await expect(page.getByRole('heading', { name: 'Spot micro controller' }).first()).toBeVisible()
route.fulfill({ })
status: 200,
body: JSON.stringify({})
})
);
await expect(page.getByRole('heading', { name: 'Spot micro controller' }).first()).toBeVisible();
});
+43 -25
View File
@@ -1,31 +1,49 @@
import type { UserConfig, Plugin } from 'vite'; import type { Plugin } from 'vite';
export default function viteLittleFS(): Plugin[] { export default function viteLittleFS(): Plugin[] {
return [ return [
{ {
name: 'vite-plugin-littlefs', name: 'vite-plugin-littlefs',
enforce: 'post', enforce: 'post',
apply: 'build', apply: 'build',
async config(config, _configEnv) { async config(config) {
const { assetFileNames, chunkFileNames, entryFileNames } = const output = config.build?.rollupOptions?.output;
config.build?.rollupOptions?.output;
// Handle Server-build + Client Assets if (!output || !config.build?.rollupOptions) {
config.build.rollupOptions.output = { return;
...config.build?.rollupOptions?.output, }
assetFileNames: assetFileNames.replace('.[hash]', '')
};
// Handle Client-build const outputOptions = Array.isArray(output) ? output[0] : output;
if (config.build?.rollupOptions?.output.chunkFileNames.includes('hash')) {
config.build.rollupOptions.output = { if (!outputOptions) {
...config.build?.rollupOptions?.output, return;
chunkFileNames: chunkFileNames.replace('.[hash]', ''), }
entryFileNames: entryFileNames.replace('.[hash]', '')
}; const { assetFileNames, chunkFileNames, entryFileNames } = outputOptions;
}
} if (assetFileNames && typeof assetFileNames === 'string') {
} config.build.rollupOptions.output = {
]; ...outputOptions,
assetFileNames: assetFileNames.replace('.[hash]', ''),
};
}
if (
chunkFileNames &&
typeof chunkFileNames === 'string' &&
chunkFileNames.includes('hash')
) {
config.build.rollupOptions.output = {
...config.build.rollupOptions.output,
chunkFileNames: chunkFileNames.replace('.[hash]', ''),
...(entryFileNames &&
typeof entryFileNames === 'string' && {
entryFileNames: entryFileNames.replace('.[hash]', ''),
}),
};
}
},
},
];
} }
+3 -3
View File
@@ -5,7 +5,10 @@ import viteLittleFS from './vite-plugin-littlefs'
import EnvCaster from '@niku/vite-env-caster' import EnvCaster from '@niku/vite-env-caster'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
const basePath = process.env.BASE_PATH ?? ''
export default defineConfig({ export default defineConfig({
base: basePath,
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
sveltekit(), sveltekit(),
@@ -15,9 +18,6 @@ export default defineConfig({
viteLittleFS(), viteLittleFS(),
EnvCaster() EnvCaster()
], ],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
},
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
+7 -7
View File
@@ -1,17 +1,17 @@
# Components # Components
Spot is comprised of a 3D printed body, some hardware and list of electronic components. Spot is comprised of a 3D-printed body, some hardware, and a list of electronic components.
## Hardware ## Hardware
Spot is 3D printed and is a combination of different Spot Micro designs, with some minor modification on top. Spot is 3D-printed and is a combination of different Spot Micro designs, with some minor modifications.
The original design is developed by KDY0523. The original design was developed by KDY0523.
- [robjk reinforced shoulder remix](https://www.thingiverse.com/thing:4937631) - [robjk reinforced shoulder remix](https://www.thingiverse.com/thing:4937631)
- [Kooba SpotMicroESP32 remix](https://www.thingiverse.com/thing:4559827) - [Kooba SpotMicroESP32 remix](https://www.thingiverse.com/thing:4559827)
- [KDY0532 original design](https://www.thingiverse.com/thing:3445283) - [KDY0532 original design](https://www.thingiverse.com/thing:3445283)
The 3D prints is assembled with some additional component: The 3D prints are assembled with some additional non-printable components:
- 84x M2x8 screws + M2 nuts - 84x M2x8 screws + M2 nuts
- 92x M3x8 screws + M3 nuts - 92x M3x8 screws + M3 nuts
@@ -20,7 +20,7 @@ The 3D prints is assembled with some additional component:
## Electronics ## Electronics
These are the electronics i used for mine and can easily be switched up to suit your Spot's needs. These are the electronics I used for mine, and they can easily be swapped to suit your Spot's needs.
| Component | Specification | Required | Recommendation | | Component | Specification | Required | Recommendation |
| ------------------------- | ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | | ------------------------- | ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------- |
@@ -39,6 +39,6 @@ These are the electronics i used for mine and can easily be switched up to suit
| 7.6-8.4V Battery | Battery | No | Im using 4x 18650 in 2s2p configuration, but other people have 2s LiPos. | | 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. | | 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 a ESP32-S3 with a camera, allowing for more computation and imaging capabilities. I recommend getting an ESP32-S3 with a camera, allowing for more computation and imaging capabilities.
It means a more responsive robot as its faster doing sensor fusion, calculating kinematic and gait planning, and networking. It means a more responsive robot as it's faster at doing sensor fusion, calculating kinematics and gait planning, and networking.
+20 -5
View File
@@ -1,6 +1,6 @@
# Assembly and calibration # Assembly and calibration
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: 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:
- [Michael Kubina SpotMicroESP32 assembly](https://github.com/michaelkubina/SpotMicroESP32/tree/master/assembly) - [Michael Kubina SpotMicroESP32 assembly](https://github.com/michaelkubina/SpotMicroESP32/tree/master/assembly)
- [Spot Micro AI assembly](https://spotmicroai.readthedocs.io/en/latest/assembly/) - [Spot Micro AI assembly](https://spotmicroai.readthedocs.io/en/latest/assembly/)
@@ -9,7 +9,7 @@ There exist a number of great resources for the assembly of the spot micro. For
Discussion about [Calibration](https://github.com/runeharlyk/SpotMicroESP32-Leika/discussions/118) Discussion about [Calibration](https://github.com/runeharlyk/SpotMicroESP32-Leika/discussions/118)
Assuming the servos are connected to the PCA9685 and is powered on: Assuming the servos are connected to the PCA9685 and are powered on:
### Calibrate in servo frame ### Calibrate in servo frame
@@ -38,13 +38,28 @@ You now have the values for the servos.
### Calibration in body frame ### Calibration in body frame
They now has to calibrated to the body frame. It assumed they have the center pwm pointing straight down. They now have to be calibrated to the body frame. It is 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. 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. 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 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. 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.
## Circuit diagram ## Circuit diagram
![Electronics diagram](media/circuit.png "Title") ![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) |
+5 -5
View File
@@ -1,6 +1,6 @@
# Software # Software
The robots firmware is built using platform io using the arduino framework over ESP-IDF. The robot's firmware is built using PlatformIO with the Arduino framework over ESP-IDF.
## Prerequisites ## Prerequisites
@@ -8,7 +8,7 @@ To prepare the frontend code for the ESP32, a specific build chain is required.
### Required Software ### Required Software
Install the following software to ensure all functionalities: Install the following software to ensure all functionality:
- [VSCode](https://code.visualstudio.com/) - Preferred IDE for development - [VSCode](https://code.visualstudio.com/) - Preferred IDE for development
- [Node.js](https://nodejs.org) - Needed for app building - [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 ### Factory settings
Update the `esp32/factory_setting.ini` with new wifi settings, app name and other device information. Update the `esp32/factory_setting.ini` with new Wi-Fi settings, app name and other device information.
### Build & Upload Process ### 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 finish, click `Upload and Monitor`. The filesystem image only has to be uploaded the first time and will override config files on the microcontroller. 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 rebuild. When uploading new firmware, the app is evaluated, and if necessary, will be rebuilt.

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