1 Commits

Author SHA1 Message Date
Rune Harlyk d0b192a3e6 💅 Updates the frontpage 2025-03-08 13:06:56 +01:00
399 changed files with 14393 additions and 237078 deletions
-66
View File
@@ -1,66 +0,0 @@
name: Deploy GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./app
env:
BASE_PATH: /SpotMicroESP32-Leika
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: "./app/pnpm-lock.yaml"
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
version: "27.x"
- run: pnpm install
- run: pnpm run build
- name: Setup Pages
uses: actions/configure-pages@v4
with:
static_site_generator: "sveltekit"
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: app/build/
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+9 -16
View File
@@ -2,24 +2,23 @@ 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
with:
submodules: "recursive"
- uses: actions/cache@v3 - uses: actions/cache@v3
with: with:
path: | path: |
@@ -29,17 +28,11 @@ 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 esp32/scripts/requirements.txt - run: pip install -r ./scripts/requirements.txt
- name: Install PlatformIO Core - name: Install PlatformIO Core
run: pip install --upgrade platformio run: pip install --upgrade platformio
- name: Install Python dependencies for nanopb
run: pip install protobuf grpcio-tools
- name: Build Protocol Buffers (nanopb)
run: python ./submodules/nanopb/generator/nanopb_generator.py -I "./platform_shared/" -D esp32/src/platform_shared ./platform_shared/message.proto
- name: Build PlatformIO Project - name: Build PlatformIO Project
run: pio run run: pio run
+20 -29
View File
@@ -2,13 +2,13 @@ name: Frontend Tests
on: on:
push: push:
branches: [master] branches: [ master ]
paths: paths:
- "app/**" - 'app/**'
pull_request: pull_request:
branches: [master] branches: [ master ]
paths: paths:
- "app/**" - 'app/**'
permissions: permissions:
contents: read contents: read
@@ -20,31 +20,22 @@ jobs:
run: run:
working-directory: ./app working-directory: ./app
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
version: 9 version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "latest" node-version: 'latest'
cache: "pnpm" cache: 'pnpm'
cache-dependency-path: "./app/pnpm-lock.yaml" cache-dependency-path: './app/pnpm-lock.yaml'
- name: Install Protoc - name: Install dependencies
uses: arduino/setup-protoc@v3 run: pnpm install
with: - name: Install Playwright Browsers
version: "27.x" run: npx playwright install --with-deps
- name: Install dependencies - name: Run tests
run: pnpm install run: pnpm test
- name: Generate Proto
run: pnpm proto
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run tests
run: pnpm test
-59
View File
@@ -1,59 +0,0 @@
name: Proto Build
on:
push:
branches: [master, protobuf-playground]
pull_request:
branches: [master, protobuf-playground]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./
env:
BASE_PATH: /SpotMicroESP32-Leika
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Install Python dependencies
run: pip install protobuf grpcio-tools
- name: Build Protocol Buffers (nanopb)
run: python ./submodules/nanopb/generator/nanopb_generator.py -I "./platform_shared/" -D esp32/src/platform_shared ./platform_shared/message.proto
- name: Setup Protocol Buffers compiler
uses: arduino/setup-protoc@v3
with:
version: "25.x"
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: "./app/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install
working-directory: ./app
- name: Build Protocol Buffers (Typescript)
run: pnpm proto
working-directory: ./app
+1 -10
View File
@@ -2,16 +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
managed_components/
dependencies.lock
sdkconfig
sdkconfig.*
!sdkconfig.defaults
esp32/src/platform_shared/*
!esp32/src/platform_shared/.gitkeep
app/src/lib/platform_shared/*
!app/src/lib/platform_shared/.gitkeep
-4
View File
@@ -1,4 +0,0 @@
[submodule "submodules/nanopb"]
path = submodules/nanopb
url = https://github.com/nanopb/nanopb
branch = master
+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": [
"bradlc.vscode-tailwindcss", "platformio.platformio-ide",
"esbenp.prettier-vscode", "svelte.svelte-vscode",
"platformio.platformio-ide", "bradlc.vscode-tailwindcss",
"svelte.svelte-vscode" "esbenp.prettier-vscode"
], ],
"unwantedRecommendations": [ "unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack" "ms-vscode.cpptools-extension-pack"
+1 -1
View File
@@ -13,7 +13,7 @@
}, },
"editor.tabSize": 4, "editor.tabSize": 4,
"editor.detectIndentation": false, "editor.detectIndentation": false,
"cmake.sourceDirectory": "C:/data/repos/Hardware/Spot_Micro_Leika", "cmake.sourceDirectory": "C:/data/repos/Hardware/Spot Micro - Leika/.pio/libdeps/esp32cam/esp32-camera",
"cSpell.words": [ "cSpell.words": [
"Adafruit", "Adafruit",
"IRAM", "IRAM",
-3
View File
@@ -1,3 +0,0 @@
cmake_minimum_required(VERSION 3.16.0)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(Spot_Micro_Leika)
-1
View File
@@ -1 +0,0 @@
PUBLIC_VITE_USE_HOST_NAME=true
+13
View File
@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
+31
View File
@@ -0,0 +1,31 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};
+1
View File
@@ -3,6 +3,7 @@ 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-*
+2 -1
View File
@@ -1,12 +1,13 @@
{ {
"useTabs": false, "useTabs": false,
"singleQuote": true, "singleQuote": true,
"tabWidth": 4, "tabWidth": 2,
"trailingComma": "none", "trailingComma": "none",
"arrowParens": "avoid", "arrowParens": "avoid",
"experimentalTernaries": true, "experimentalTernaries": true,
"printWidth": 100, "printWidth": 100,
"semi": false, "semi": false,
"svelteBracketNewLine": false,
"plugins": ["prettier-plugin-svelte"], "plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
} }
+1 -5
View File
@@ -1,7 +1,3 @@
{ {
"recommendations": [ "recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"]
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
} }
+17 -8
View File
@@ -8,21 +8,30 @@ 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
npx sv create npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
``` ```
## Developing ## Developing
Once you've created your project, follow these steps: Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
1: Delete package-lock.json ```bash
2: Check `git status`. If you see any changes other than package-lock.json or favicon.ico, run the command `git restore ./` (See below) npm run dev
3: Run `npm install` or `pnpm install` or `yarn` to install the dependencies
4: Run `npm run build` to build the project
Running `git status` should show: # or start the server and open the app in a new browser tab
npm run dev -- --open
```
[![example.png](https://i.postimg.cc/yddM3hH3/example.png)](https://postimg.cc/7CFsp2bq) ## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`. You can preview the production build with `npm run preview`.
-44
View File
@@ -1,44 +0,0 @@
import js from '@eslint/js'
import ts from 'typescript-eslint'
import svelte from 'eslint-plugin-svelte'
import prettier from 'eslint-config-prettier'
import globals from 'globals'
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: [
'.DS_Store',
'node_modules/',
'build/',
'.svelte-kit/',
'package/',
'.env',
'.env.*',
'!.env.example',
'pnpm-lock.yaml',
'package-lock.json',
'yarn.lock'
]
}
)
+63 -71
View File
@@ -1,73 +1,65 @@
{ {
"name": "spot_micro_controller", "name": "spot_micro_controller",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
"build": "pnpm proto && vite build", "build": "vite build",
"build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build", "build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "pnpm run test:integration && pnpm run test:unit", "test": "pnpm run test:integration && pnpm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"test:integration": "playwright test", "test:integration": "playwright test",
"test:unit": "vitest", "test:unit": "vitest"
"proto": "node scripts/compile_protos.js" },
}, "devDependencies": {
"devDependencies": { "@iconify-json/mdi": "^1.1.64",
"@eslint/js": "^9.39.2", "@iconify-json/tabler": "^1.1.109",
"@iconify-json/mdi": "^1.2.3", "@playwright/test": "^1.49.1",
"@iconify-json/tabler": "^1.2.23", "@sveltejs/adapter-static": "^3.0.1",
"@playwright/test": "^1.56.0", "@sveltejs/kit": "^2.5.27",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/kit": "^2.46.4", "@types/eslint": "^8.56.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@types/three": "^0.162.0",
"@types/eslint": "^9.6.1", "@typescript-eslint/eslint-plugin": "^7.0.0",
"@types/three": "^0.180.0", "@typescript-eslint/parser": "^7.0.0",
"@types/ws": "^8.18.1", "autoprefixer": "^10.4.19",
"@typescript-eslint/eslint-plugin": "^8.46.0", "eslint": "^8.56.0",
"@typescript-eslint/parser": "^8.46.0", "eslint-config-prettier": "^9.1.0",
"autoprefixer": "^10.4.21", "eslint-plugin-svelte": "^2.45.1",
"eslint": "^9.37.0", "jsdom": "^24.0.0",
"eslint-config-prettier": "^10.1.8", "prettier": "^3.1.1",
"eslint-plugin-svelte": "^3.12.4", "prettier-plugin-svelte": "^3.2.6",
"globals": "^17.0.0", "svelte": "^5.0.0",
"jsdom": "^27.0.0", "svelte-check": "^4.0.0",
"prettier": "^3.6.2", "svelte-focus-trap": "^1.2.0",
"prettier-plugin-svelte": "^3.4.0", "tailwindcss": "^4.0.12",
"svelte": "^5.39.11", "tslib": "^2.6.1",
"svelte-check": "^4.3.3", "typescript": "^5.5.0",
"svelte-focus-trap": "^1.2.0", "unplugin-icons": "^0.18.5",
"tailwindcss": "^4.1.14", "vite": "^6.2.1",
"ts-proto-descriptors": "^2.1.0", "vitest": "^1.2.0"
"tslib": "^2.8.1", },
"typescript": "^5.9.3", "type": "module",
"typescript-eslint": "^8.51.0", "dependencies": {
"unplugin-icons": "^22.4.2", "@niku/vite-env-caster": "^1.0.2",
"vite": "^7.1.9", "@sveltejs/adapter-auto": "^4.0.0",
"vitest": "^3.2.4", "@tailwindcss/vite": "^4.0.12",
"ws": "^8.18.3" "chart.js": "^4.4.2",
}, "compare-versions": "^6.1.0",
"type": "module", "cross-env": "^7.0.3",
"dependencies": { "daisyui": "^5.0.0",
"@bufbuild/protobuf": "^2.10.2", "jwt-decode": "^4.0.0",
"@niku/vite-env-caster": "^1.1.2", "nipplejs": "^0.10.1",
"@sveltejs/adapter-auto": "^6.1.1", "svelte-dnd-list": "^0.1.8",
"@tailwindcss/vite": "^4.1.14", "svelte-modals": "^2.0.0",
"chart.js": "^4.5.0", "three": "^0.162.0",
"compare-versions": "^6.1.1", "urdf-loader": "^0.12.1",
"cross-env": "^10.1.0", "uzip": "^0.20201231.0",
"daisyui": "^5.2.0", "xacro-parser": "^0.3.9"
"nipplejs": "^0.10.2", },
"svelte-dnd-list": "^0.1.8", "packageManager": "pnpm@9.3.0"
"svelte-modals": "^2.0.1",
"three": "^0.180.0",
"ts-proto": "^2.10.1",
"urdf-loader": "^0.12.6",
"uzip": "^0.20201231.0",
"xacro-parser": "^0.3.10"
},
"packageManager": "pnpm@9.3.0"
} }
+9 -9
View File
@@ -1,12 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test' import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = { const config: PlaywrightTestConfig = {
webServer: { webServer: {
command: 'pnpm run build && pnpm run preview', command: 'pnpm run build && pnpm run preview',
port: 4173 port: 4173
}, },
testDir: 'tests/integration', testDir: 'tests/integration',
testMatch: /(.+\.)?(test|spec)\.[jt]s/ testMatch: /(.+\.)?(test|spec)\.[jt]s/
} };
export default config export default config;
+2174 -2165
View File
File diff suppressed because it is too large Load Diff
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env node
import { execSync } from 'child_process'
import path from 'path'
import os from 'os'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const isWindows = os.platform() === 'win32'
const projectRoot = path.resolve(__dirname, '..')
const platformSharedDir = path.resolve(projectRoot, '..', 'platform_shared')
const outputDir = path.resolve(projectRoot, 'src', 'lib', 'platform_shared')
const pluginPath =
isWindows ?
path.join(projectRoot, 'node_modules', '.bin', 'protoc-gen-ts_proto.cmd')
: path.join(projectRoot, 'node_modules', '.bin', 'protoc-gen-ts_proto')
const protoFiles = ['filesystem.proto', 'message.proto', "api.proto"]
const tsProtoOpts = ['useExactTypes=false', 'outputExtensions=true', 'outputSchema=true'].join(',')
const cmd = [
'protoc',
`--plugin=protoc-gen-ts_proto=${pluginPath}`,
`--ts_proto_out=${outputDir}`,
`--ts_proto_opt=${tsProtoOpts}`,
`-I${platformSharedDir}`,
...protoFiles.map(f => path.join(platformSharedDir, f))
].join(' ')
console.log('Compiling protos...')
console.log(` Platform: ${os.platform()}`)
console.log(` Output: ${outputDir}`)
try {
execSync(cmd, { stdio: 'inherit', cwd: projectRoot })
console.log('Proto compilation complete!')
} catch (error) {
console.error('Proto compilation failed!', error)
process.exit(1)
}
+19 -27
View File
@@ -2,47 +2,39 @@
@plugin "daisyui"; @plugin "daisyui";
@plugin "daisyui" { @plugin "daisyui" {
themes: themes:
light --default, light --default,
dark --prefersdark; dark --prefersdark;
} }
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: 'light'; name: 'light';
default: true; default: true;
--color-primary: #00bfff; --color-primary: #00bfff;
--color-secondary: #3c00ff; --color-secondary: #3c00ff;
--base-content: white; --base-content: white;
} }
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: 'dark'; name: 'dark';
prefersdark: true; prefersdark: true;
--color-primary: #00bfff; --color-primary: #00bfff;
--color-secondary: #3c00ff; --color-secondary: #3c00ff;
--base-content: oklch(0.3 0.012 256); --base-content: oklch(0.3 0.012 256);
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
} }
#nipple_0_0, #nipple_0_0,
#nipple_1_1 { #nipple_1_1 {
z-index: 10 !important; z-index: 10 !important;
} }
#three-gui-panel { #three-gui-panel {
top: 64px; top: 64px;
right: 0px; right: 0px;
} }
@media (max-width: 1023px) { @media (max-width: 1023px) {
#three-gui-panel { #three-gui-panel {
top: 48px; top: 48px;
} }
} }
+8 -8
View File
@@ -1,13 +1,13 @@
// See https://kit.svelte.dev/docs/types#app // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
} }
export {} export {};
+11 -14
View File
@@ -1,17 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/logo512.png" /> <link rel="icon" href="%sveltekit.assets%/logo512.png" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" />
name="viewport" <meta name="apple-mobile-web-app-capable" content="yes" />
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" <meta name="mobile-web-app-capable" content="yes" />
/> %sveltekit.head%
<meta name="apple-mobile-web-app-capable" content="yes" /> </head>
<meta name="mobile-web-app-capable" content="yes" /> <body data-sveltekit-preload-data="hover">
%sveltekit.head% <div style="display: contents">%sveltekit.body%</div>
</head> </body>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html> </html>
+7
View File
@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
+35 -48
View File
@@ -1,29 +1,22 @@
import { get } from 'svelte/store' import { get } from 'svelte/store';
import { Err, Ok, type Result } from './utilities' import { Err, Ok, type Result } from './utilities';
import { apiLocation } from './stores/location-store' import { location } from './stores';
import type { MessageFns } from './platform_shared/filesystem'
import { Request, Response as ProtoResponse } from './platform_shared/api'
import { BinaryWriter } from '@bufbuild/protobuf/wire'
export const api = { export namespace api {
get<TResponse>(endpoint: string, params?: RequestInit) { export function get<TResponse>(endpoint: string, params?: RequestInit) {
return sendRequest<TResponse>(endpoint, 'GET', null, params) return sendRequest<TResponse>(endpoint, 'GET', null, params);
}, }
post<TResponse>(endpoint: string, data?: unknown) { export function post<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'POST', data) return sendRequest<TResponse>(endpoint, 'POST', data);
}, }
post_proto<TResponse>(endpoint: string, data: Request) { export function put<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'POST', Request.encode(data)) return sendRequest<TResponse>(endpoint, 'PUT', data);
}, }
put<TResponse>(endpoint: string, data?: unknown) { export function remove<TResponse>(endpoint: string) {
return sendRequest<TResponse>(endpoint, 'PUT', data) return sendRequest<TResponse>(endpoint, 'DELETE');
},
remove<TResponse>(endpoint: string) {
return sendRequest<TResponse>(endpoint, 'DELETE')
} }
} }
@@ -33,12 +26,8 @@ async function sendRequest<TResponse>(
data?: unknown, data?: unknown,
params?: RequestInit params?: RequestInit
): Promise<Result<TResponse, Error>> { ): Promise<Result<TResponse, Error>> {
endpoint = resolveUrl(endpoint) endpoint = resolveUrl(endpoint);
const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined;
const isProtobuf = data instanceof BinaryWriter
const body = data !== null && typeof data !== 'undefined'
? (isProtobuf ? data.finish() : JSON.stringify(data))
: undefined
const request = { const request = {
...params, ...params,
@@ -47,47 +36,45 @@ async function sendRequest<TResponse>(
headers: { headers: {
...params?.headers, ...params?.headers,
Authorization: 'Basic', Authorization: 'Basic',
'Content-Type': isProtobuf ? 'application/x-protobuf' : 'application/json' 'Content-Type': 'application/json'
} }
} };
let response let response;
try { try {
response = await fetch(endpoint, request) response = await fetch(endpoint, request);
} catch { } catch (error) {
return Err.new(new Error(), 'An error has occurred') return Err.new(new Error(), 'An error has occurred');
} }
const isResponseOk = response.status >= 200 && response.status < 400 const isResponseOk = response.status >= 200 && response.status < 400;
if (!isResponseOk) { if (!isResponseOk) {
if (response.status === 401) { if (response.status === 401) {
return Err.new(new ApiError(response), 'User was not authorized') return Err.new(new ApiError(response), 'User was not authorized');
} }
return Err.new(new ApiError(response), 'An error has occurred') return Err.new(new ApiError(response), 'An error has occurred');
} }
const contentType = response.headers.get('Content-Type') ?? response.headers.get('Content-Type') const contentType =
response.headers.get('Content-Type') ?? response.headers.get('Content-Type');
if (contentType && contentType.includes('application/json')) { if (contentType && contentType.includes('application/json')) {
const data = await response.json() const data = await response.json();
return Ok.new(data as TResponse) return Ok.new(data as TResponse);
} else if (contentType && contentType.includes('application/x-protobuf')) {
let data: ProtoResponse = ProtoResponse.decode(await response.bytes());
return Ok.new(data as TResponse)
} else { } else {
// Handle empty object as response // Handle empty object as response
return Ok.new(null as TResponse) return Ok.new(null as TResponse);
} }
} }
function resolveUrl(url: string): string { function resolveUrl(url: string): string {
if (url.startsWith('http') || !get(apiLocation)) return url if (url.startsWith('http') || !get(location)) return url;
const protocol = window.location.protocol const protocol = window.location.protocol;
return `${protocol}//${get(apiLocation)}${url.startsWith('/') ? '' : '/'}${url}` return `${protocol}//${get(location)}${url.startsWith('/') ? '' : '/'}${url}`;
} }
export class ApiError extends Error { export class ApiError extends Error {
constructor(public readonly response: Response) { constructor(public readonly response: Response) {
super(`${response.status}`) super(`${response.status}`);
} }
} }
+7 -7
View File
@@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing';
import { Down } from './icons' import { Down } from './icons';
function openCollapsible() { function openCollapsible() {
open = !open open = !open;
if (open) { if (open) {
opened() opened();
} else { } else {
closed() closed();
} }
} }
let { icon, title, children, open, opened, closed, class: klass } = $props() let { icon, title, children, open, opened, closed, class: klass } = $props();
</script> </script>
<div class="{klass} relative grid w-full max-w-2xl self-center overflow-hidden"> <div class="{klass} relative grid w-full max-w-2xl self-center overflow-hidden">
-81
View File
@@ -1,81 +0,0 @@
<script lang="ts">
interface Props {
heading?: number
size?: string
}
let { heading = 0, size = 'w-48 h-48' }: Props = $props()
const getCardinalDirection = (h: number) => {
if (h >= 337.5 || h < 22.5) return 'N'
if (h >= 22.5 && h < 67.5) return 'NE'
if (h >= 67.5 && h < 112.5) return 'E'
if (h >= 112.5 && h < 157.5) return 'SE'
if (h >= 157.5 && h < 202.5) return 'S'
if (h >= 202.5 && h < 247.5) return 'SW'
if (h >= 247.5 && h < 292.5) return 'W'
return 'NW'
}
const ticks = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
</script>
<div class="flex flex-col items-center">
<div class="relative {size}">
<svg viewBox="0 0 200 200" class="w-full h-full">
<circle
cx="100"
cy="100"
r="90"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opacity-30"
/>
<circle
cx="100"
cy="100"
r="70"
fill="none"
stroke="currentColor"
stroke-width="1"
class="opacity-20"
/>
<circle
cx="100"
cy="100"
r="50"
fill="none"
stroke="currentColor"
stroke-width="1"
class="opacity-20"
/>
<text x="100" y="20" text-anchor="middle" class="fill-current text-sm font-bold">N</text>
<text x="180" y="105" text-anchor="middle" class="fill-current text-sm font-bold">E</text>
<text x="100" y="190" text-anchor="middle" class="fill-current text-sm font-bold">S</text>
<text x="20" y="105" text-anchor="middle" class="fill-current text-sm font-bold">W</text>
{#each ticks as tick}
<line
x1={100 + 85 * Math.sin((tick * Math.PI) / 180)}
y1={100 - 85 * Math.cos((tick * Math.PI) / 180)}
x2={100 + 78 * Math.sin((tick * Math.PI) / 180)}
y2={100 - 78 * Math.cos((tick * Math.PI) / 180)}
stroke="currentColor"
stroke-width={tick % 90 === 0 ? 2 : 1}
class="opacity-50"
/>
{/each}
<g transform="rotate({heading}, 100, 100)">
<polygon points="100,25 93,100 100,90 107,100" class="fill-error" />
<polygon points="100,175 93,100 100,110 107,100" class="fill-base-300" />
</g>
<circle cx="100" cy="100" r="8" class="fill-base-content" />
</svg>
</div>
<div class="text-2xl font-mono font-bold mt-2">{heading.toFixed(1)}°</div>
<div class="text-sm opacity-70">{getCardinalDirection(heading)}</div>
</div>
+20 -7
View File
@@ -1,8 +1,18 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap' import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { Cancel, Check } from '$lib/components/icons' import { Cancel, Check } from '$lib/components/icons';
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals' import { modals, exitBeforeEnter } from 'svelte-modals';
// provided by <Modals />
interface Props {
isOpen: boolean;
title: string;
message: string;
onConfirm: any;
labels?: any;
}
let { let {
isOpen, isOpen,
@@ -13,7 +23,7 @@
cancel: { label: 'Cancel', icon: Cancel }, cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'OK', icon: Check } confirm: { label: 'OK', icon: Check }
} }
}: ModalProps = $props() }: Props = $props();
</script> </script>
{#if isOpen} {#if isOpen}
@@ -34,12 +44,15 @@
<div class="divider my-2"></div> <div class="divider my-2"></div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button
class="btn btn-error inline-flex items-center" class="btn btn-primary inline-flex items-center"
onclick={() => modals.close()} onclick={() => modals.close()}
> >
<labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span> <labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span>
</button> </button>
<button class="btn btn-primary inline-flex items-center" onclick={onConfirm}> <button
class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onConfirm}
>
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span> <SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span>
</button> </button>
</div> </div>
@@ -1,61 +1,61 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap' import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { telemetry } from '$lib/stores/telemetry' import { telemetry } from '$lib/stores/telemetry';
import { Cancel } from './icons' import { Cancel } from './icons';
import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals' import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals';
// provided by <Modals /> // provided by <Modals />
interface Props { interface Props {
isOpen: boolean isOpen: boolean;
} }
let { isOpen }: Props = $props() let { isOpen }: Props = $props();
let updating = $state(true) let updating = $state(true);
let progress = $state(0) let progress = $state(0);
$effect(() => { $effect(() => {
if ($telemetry.download_ota.status == 'progress') { if ($telemetry.download_ota.status == 'progress') {
progress = $telemetry.download_ota.progress progress = $telemetry.download_ota.progress;
} }
}) });
$effect(() => { $effect(() => {
if ($telemetry.download_ota.status == 'error') { if ($telemetry.download_ota.status == 'error') {
updating = false updating = false;
} }
}) });
let message = $state('Preparing ...') let message = $state('Preparing ...');
$effect(() => { $effect(() => {
if ($telemetry.download_ota.status == 'progress') { if ($telemetry.download_ota.status == 'progress') {
message = 'Downloading ...' message = 'Downloading ...';
} else if ($telemetry.download_ota.status == 'error') { } else if ($telemetry.download_ota.status == 'error') {
message = $telemetry.download_ota.error message = $telemetry.download_ota.error;
} else if ($telemetry.download_ota.status == 'finished') { } else if ($telemetry.download_ota.status == 'finished') {
message = 'Restarting ...' message = 'Restarting ...';
progress = 0 progress = 0;
// Reload page after 5 sec // Reload page after 5 sec
setTimeout(() => { setTimeout(() => {
modals.closeAll() modals.closeAll();
location.reload() location.reload();
}, 5000) }, 5000);
} }
}) });
onBeforeClose(() => { onBeforeClose(() => {
if (updating) { if (updating) {
// prevents modal from closing // prevents modal from closing
return false return false;
} else { } else {
$telemetry.download_ota.status = 'idle' $telemetry.download_ota.status = 'idle';
$telemetry.download_ota.error = '' $telemetry.download_ota.error = '';
$telemetry.download_ota.progress = 0 $telemetry.download_ota.progress = 0;
return true return true;
} }
}) });
</script> </script>
{#if isOpen} {#if isOpen}
@@ -89,8 +89,8 @@
class="btn btn-warning text-warning-content inline-flex flex-none items-center" class="btn btn-warning text-warning-content inline-flex flex-none items-center"
disabled={updating} disabled={updating}
onclick={() => { onclick={() => {
modals.closeAll() modals.closeAll();
location.reload() location.reload();
}} }}
> >
<Cancel class="mr-2 h-5 w-5" /><span>Close</span></button <Cancel class="mr-2 h-5 w-5" /><span>Close</span></button
+17 -9
View File
@@ -1,18 +1,26 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap' import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { Check } from './icons' import { Check } from './icons';
import { exitBeforeEnter, type ModalProps } from 'svelte-modals' import { exitBeforeEnter } from 'svelte-modals';
// provided by <Modals />
interface Props {
isOpen: boolean;
title: string;
message: string;
onDismiss: any;
dismiss?: any;
}
let { let {
isOpen, isOpen,
title, title,
message, message,
onDismiss, onDismiss,
labels = { dismiss = { label: 'Dismiss', icon: Check }
dismiss: { label: 'Dismiss', icon: Check } }: Props = $props();
}
}: ModalProps = $props()
</script> </script>
{#if isOpen} {#if isOpen}
@@ -35,7 +43,7 @@
class="btn btn-warning text-warning-content inline-flex items-center" class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onDismiss} onclick={onDismiss}
> >
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span> <dismiss.icon class="mr-2 h-5 w-5" /><span>{dismiss.label}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1,78 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import * as THREE from 'three'
import { imu } from '$lib/stores/imu'
import SceneBuilder from '$lib/sceneBuilder'
let canvas: HTMLCanvasElement
let sceneBuilder: SceneBuilder
let cube: THREE.Mesh
let targetRotation = new THREE.Euler()
let lastUpdateTime = 0
const LERP_SPEED = 5 // rotations per second
const initThreeJS = () => {
sceneBuilder = new SceneBuilder()
.addRenderer({ canvas: canvas, antialias: true, alpha: true })
.addPerspectiveCamera({ x: 2, y: 0, z: 2 })
.addOrbitControls(1, 10, false)
.addAmbientLight({ color: 0x404040, intensity: 0.5 })
.addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 })
.fillParent()
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshPhongMaterial({
color: 0x00ff00,
transparent: true,
opacity: 0.8
})
cube = new THREE.Mesh(geometry, material)
sceneBuilder.scene.add(cube)
sceneBuilder.addRenderCb(() => {
if (!cube) return
const currentTime = performance.now()
const deltaTime = (currentTime - lastUpdateTime) / 1000 // convert to seconds
lastUpdateTime = currentTime
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime)
cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor)
cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor)
cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor)
})
sceneBuilder.startRenderLoop()
}
const updateOrientation = () => {
if (!cube) return
const y = -$imu.x[$imu.x.length - 1] || 0
const x = $imu.y[$imu.y.length - 1] || 0
const z = -$imu.z[$imu.z.length - 1] || 0
targetRotation.set(
THREE.MathUtils.degToRad(x),
THREE.MathUtils.degToRad(y),
THREE.MathUtils.degToRad(z)
)
}
onMount(() => {
initThreeJS()
})
onDestroy(() => {
sceneBuilder?.renderer?.dispose()
})
$effect(() => {
if ($imu) {
updateOrientation()
}
})
</script>
<div class="h-60 w-60 border-2 border-base-300 rounded-md">
<canvas class="w-full h-full" bind:this={canvas}></canvas>
</div>
+62 -69
View File
@@ -1,76 +1,69 @@
<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), open = $bindable(true),
collapsible = true, collapsible = true,
icon, icon,
title, title,
children, children
right }: Props = $props();
}: 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"> <span class="inline-flex items-baseline">
{@render icon?.()} {@render icon?.()}
{@render title?.()} {@render title?.()}
</span> </span>
<button <button
class="btn btn-circle btn-ghost btn-sm" class="btn btn-circle btn-ghost btn-sm"
onclick={() => { onclick={() => {
open = !open open = !open;
}} }}
> >
<Down <Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {( class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open
open ? 'rotate-180'
) ? : ''}"
'rotate-180' />
: ''}" </button>
/> </div>
</button> {#if open}
</div> <div
{#if open} class="flex flex-col gap-2 p-4 pt-0"
<div transition:slide|local={{ duration: 300, easing: cubicOut }}
class="flex flex-col gap-2 p-4 pt-0" >
transition:slide|local={{ duration: 300, easing: cubicOut }} {@render children?.()}
> </div>
{@render children?.()} {/if}
</div> </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">
> {@render icon?.()}
<span class="inline-flex items-baseline"> {@render title?.()}
{@render icon?.()} </span>
{@render title?.()} </div>
</span> <div class="flex flex-col gap-2 p-4 pt-0">
{@render right?.()} {@render children?.()}
</div> </div>
<div class="flex flex-col gap-2 p-4 pt-0"> </div>
{@render children?.()}
</div>
</div>
{/if} {/if}
+4 -3
View File
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Loader } from './icons' import { Loader } from "./icons";
</script> </script>
<div class="flex h-full w-full flex-col items-center justify-center p-6"> <div class="flex h-full w-full flex-col items-center justify-center p-6">
<Loader class="text-primary h-14 w-auto animate-spin stroke-2" /> <Loader class="text-primary h-14 w-auto animate-spin stroke-2" />
<p class="text-xl">Loading...</p> <p class="text-xl">Loading...</p>
</div> </div>
-47
View File
@@ -1,47 +0,0 @@
<script lang="ts">
import type { Component } from 'svelte'
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
const {
icon,
title,
description = '',
variant = 'primary',
class: klass = '',
children = null
} = $props<{
icon?: Component
title: string
description?: string | number
variant?: Variant
class?: string
children?: () => Component
}>()
const 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>
+4 -4
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte' import { onDestroy } from 'svelte';
import { apiLocation } from '$lib/stores' import { location } from '$lib/stores';
let source = $state(`${$apiLocation}/api/camera/stream`) let source = $state(`${$location}/api/camera/stream`);
onDestroy(() => (source = '#')) onDestroy(() => (source = '#'));
</script> </script>
<div class="w-full h-full"> <div class="w-full h-full">
+29 -31
View File
@@ -1,37 +1,35 @@
<script> <script>
import { flip } from 'svelte/animate' import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import { error, info, success, warning } from './icons' import { error, info, success, warning } from './icons';
/** @type {{theme?: any, icon?: any}} */
let { /** @type {{theme?: any, icon?: any}} */
theme = { let { theme = {
error: 'alert-error', error: 'alert-error',
success: 'alert-success', success: 'alert-success',
warning: 'alert-warning', warning: 'alert-warning',
info: 'alert-info' info: 'alert-info'
}, }, icon = {
icon = { error: error,
error: error, success: success,
success: success, warning: warning,
warning: warning, info: info
info: info } } = $props();
}
} = $props()
</script> </script>
<div class="toast toast-end mr-4"> <div class="toast toast-end mr-4">
{#each $notifications as notification (notification.id)} {#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]} {@const SvelteComponent = icon[notification.type]}
<div <div
animate:flip={{ duration: 400 }} animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}" class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }} in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }} out:fly={{ x: 100, duration: 400 }}
> >
<SvelteComponent class="h-6 w-6 shrink-0" /> <SvelteComponent class="h-6 w-6 shrink-0" />
<span>{notification.message}</span> <span>{notification.message}</span>
</div> </div>
{/each} {/each}
</div> </div>
+327 -359
View File
@@ -1,372 +1,340 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { import {
Mesh, BufferGeometry,
MeshBasicMaterial, Line,
type Object3D, LineBasicMaterial,
SphereGeometry, Mesh,
Vector3, MeshBasicMaterial,
type Object3DEventMap, Object3D,
Color SphereGeometry,
} from 'three' Vector3,
import { type NormalBufferAttributes,
mode, type Object3DEventMap
model, } from 'three'
input, import {
servoAnglesOut, ModesEnum,
servoAngles, kinematicData,
mpu, mode,
jointNames, model,
currentKinematic, outControllerData,
walkGait, servoAnglesOut,
kinematicData servoAngles,
} from '$lib/stores' mpu,
import { populateModelCache, getToeWorldPositions } from '$lib/utilities' jointNames
import SceneBuilder from '$lib/sceneBuilder' } from '$lib/stores'
import { lerp, degToRad } from 'three/src/math/MathUtils' import { footColor, populateModelCache, throttler, toeWorldPositions } from '$lib/utilities'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js' import SceneBuilder from '$lib/sceneBuilder'
import { type body_state_t } from '$lib/kinematic' import { lerp, degToRad } from 'three/src/math/MathUtils'
import { import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
BezierState, import Kinematic, { type body_state_t } from '$lib/kinematic'
CalibrationState, import {
GaitState, BezierState,
IdleState, CalibrationState,
RestState, EightPhaseWalkState,
StandState FourPhaseWalkState,
} from '$lib/gait' IdleState,
import { radToDeg } from 'three/src/math/MathUtils.js' RestState,
import type { URDFRobot } from 'urdf-loader' StandState
import { get } from 'svelte/store' } from '$lib/gait'
import { AnglesData, KinematicData, ModesEnum } from '$lib/platform_shared/message' import { radToDeg } from 'three/src/math/MathUtils.js'
import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store'
interface Props { interface Props {
defaultColor?: string | null sky?: boolean
orbit?: boolean orbit?: boolean
panel?: boolean panel?: boolean
debug?: boolean debug?: boolean
ground?: boolean ground?: boolean
zoom?: number
}
let {
sky = true,
orbit = false,
panel = true,
debug = false,
ground = true,
zoom = 8
}: Props = $props()
let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement = $state()
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 = new Kinematic()
let planners = {
[ModesEnum.Deactivated]: new IdleState(),
[ModesEnum.Idle]: new IdleState(),
[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]
let body_state = {
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.5,
zm: 0,
feet: planners[ModesEnum.Idle].default_feet_pos
}
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)
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(Math.min(zoom, 8), 30, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.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
} }
let { trace_lines.forEach((line, i) => {
defaultColor = '#0091ff', 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())
let canvas: HTMLCanvasElement
const NUM_ANGLES = 12 // TODO: This number should come from the robot
let currentModelAngles: AnglesData = AnglesData.create({
angles: new Array(NUM_ANGLES).fill(0)
}) })
let modelTargetAngles: AnglesData = AnglesData.create({ angles: new Array(NUM_ANGLES).fill(0) }) }
let gui_panel: GUI
const SMOOTH_AMOUNT = 0.2
let target: Object3D<Object3DEventMap> const calculate_kinematics = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
let target_position = { x: 0, z: 0, yaw: 0 } const position: body_state_t = {
omega: settings.omega,
let kinematic = get(currentKinematic) phi: settings.phi,
psi: settings.psi,
const planners: Record<ModesEnum, GaitState> = { xm: settings.xm,
[ModesEnum.DEACTIVATED]: new IdleState(), ym: settings.ym,
[ModesEnum.IDLE]: new IdleState(), zm: settings.zm,
[ModesEnum.CALIBRATION]: new CalibrationState(), feet: body_state.feet
[ModesEnum.REST]: new RestState(),
[ModesEnum.STAND]: new StandState(),
[ModesEnum.WALK]: new BezierState(),
[ModesEnum.UNRECOGNIZED]: new IdleState()
}
let lastTick = performance.now()
let lastRobotPosition = new Vector3()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
const THREEJS_SCALE = 10
let body_state = {
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.15,
zm: 0,
feet: kinematic.getDefaultFeetPos(),
cumulative_x: 0,
cumulative_y: 0,
cumulative_z: 0,
cumulative_roll: 0,
cumulative_pitch: 0,
cumulative_yaw: 0
} }
let settings = { let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
'Internal kinematic': true, modelTargetAngles = new_angles
'Robot transform controls': false, }
'Auto orient robot': true,
'Trace feet': debug, const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
'Target position': false, if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
'Trace points': 30, robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
'Smooth motion': true,
omega: 0, robot.position.z = smooth(robot.position.z, -settings.xm, 0.1)
phi: 0, robot.position.x = smooth(robot.position.x, -settings.zm, 0.1)
psi: 0,
xm: 0, robot.rotation.z = smooth(robot.rotation.z, degToRad(-settings.phi + $mpu.heading + 90), 0.1)
ym: 0.15, robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1)
zm: 0, robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1)
Background: defaultColor }
const update_camera = (robot: URDFRobot) => {
if (!settings['Fix camera on robot']) return
sceneManager.orbit.target = robot.position.clone()
}
const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end
}
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(outControllerData)
const data = {
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]))
} }
onMount(async () => { orient_robot(robot, toes)
await populateModelCache() updateTargetPosition()
await createScene() }
servoAngles.subscribe(updateAnglesFromStore)
walkGait.subscribe(gait => {
const walkPlanner = planners[ModesEnum.WALK]
if (!(walkPlanner instanceof BezierState)) {
throw new Error(
`Expected BezierState for WALK mode, got ${walkPlanner.constructor.name}`
)
}
walkPlanner.set_mode(gait.gait)
})
if (panel) createPanel()
})
onDestroy(() => {
gui_panel?.destroy()
})
const updateAnglesFromStore = (angles: AnglesData) => {
if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return
modelTargetAngles = angles
}
const createPanel = () => {
gui_panel = new GUI({ width: 310 })
gui_panel.close()
gui_panel.domElement.id = 'three-gui-panel'
const general = gui_panel.addFolder('General')
general.add(settings, 'Internal kinematic')
general.add(settings, 'Robot transform controls')
general.add(settings, 'Auto orient robot')
const kinematic = gui_panel.addFolder('Kinematics')
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
const visibility = gui_panel.addFolder('Visualization')
visibility.add(settings, 'Trace feet')
visibility.add(settings, 'Trace points', 1, 1000, 1)
visibility.add(settings, 'Target position')
visibility.add(settings, 'Smooth motion')
visibility.addColor(settings, 'Background').onChange(setSceneBackground).listen()
}
const updateKinematicPosition = () => {
kinematicData.set(
KinematicData.create({
omega: settings.omega,
phi: settings.phi,
psi: settings.psi,
xm: settings.xm,
ym: settings.ym,
zm: settings.zm
})
)
}
const setSceneBackground = (c: string | null) => (sceneManager.scene.background = new Color(c!))
const updateAngles = (name: string, angle: number) => {
modelTargetAngles.angles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
servoAnglesOut.set(
AnglesData.create({
angles: modelTargetAngles.angles.map(num => Math.round(num))
})
)
}
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(2, 20, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model as URDFRobot)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop()
if (ground) sceneManager.addGroundPlane()
const geometry = new SphereGeometry(0.1, 32, 16)
const material = new MeshBasicMaterial({ color: 0xffff00 })
target = new Mesh(geometry, material)
sceneManager.scene.add(target)
if (debug) {
sceneManager.addDragControl(angles => {
Object.entries(angles).forEach(([name, angle]) => {
updateAngles(name, angle)
})
})
}
if (defaultColor) setSceneBackground(settings['Background'] || defaultColor)
}
const calculate_kinematics = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const position: body_state_t = {
omega: settings.omega,
phi: settings.phi,
psi: settings.psi,
xm: settings.xm,
ym: settings.ym,
zm: settings.zm,
feet: body_state.feet,
cumulative_x: body_state.cumulative_x,
cumulative_y: body_state.cumulative_y,
cumulative_z: body_state.cumulative_z,
cumulative_roll: body_state.cumulative_roll,
cumulative_pitch: body_state.cumulative_pitch,
cumulative_yaw: body_state.cumulative_yaw
}
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
modelTargetAngles.angles = new_angles
}
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
const cumulativeYaw = body_state.cumulative_yaw
const headingYaw = degToRad(-settings.phi + $mpu.heading)
const totalYaw = headingYaw + cumulativeYaw
const cosTotal = Math.cos(totalYaw)
const sinTotal = Math.sin(totalYaw)
const rotatedXm = settings.xm * cosTotal - settings.zm * sinTotal
const rotatedZm = settings.xm * sinTotal + settings.zm * cosTotal
const mpuHeadingRad = degToRad($mpu.heading)
const cosHead = Math.cos(mpuHeadingRad)
const sinHead = Math.sin(mpuHeadingRad)
const rotatedCumX = body_state.cumulative_x * cosHead - body_state.cumulative_z * sinHead
const rotatedCumZ = body_state.cumulative_x * sinHead + body_state.cumulative_z * cosHead
robot.position.x = smooth(
robot.position.x,
(-rotatedZm - rotatedCumZ) * THREEJS_SCALE,
SMOOTH_AMOUNT
)
robot.position.z = smooth(
robot.position.z,
(-rotatedXm - rotatedCumX) * THREEJS_SCALE,
SMOOTH_AMOUNT
)
const cosYaw = Math.cos(totalYaw)
const sinYaw = Math.sin(totalYaw)
const cmdPitch = degToRad(settings.psi)
const cmdRoll = degToRad(settings.omega)
const pitch =
degToRad(-90) + cmdPitch * cosYaw - cmdRoll * sinYaw + body_state.cumulative_pitch
const roll = cmdPitch * sinYaw + cmdRoll * cosYaw + body_state.cumulative_roll
robot.rotation.z = smooth(
robot.rotation.z,
degToRad(-settings.phi + $mpu.heading + 90) + cumulativeYaw,
SMOOTH_AMOUNT
)
robot.rotation.y = smooth(robot.rotation.y, roll, SMOOTH_AMOUNT)
robot.rotation.x = smooth(robot.rotation.x, pitch, SMOOTH_AMOUNT)
}
const update_camera = (robot: URDFRobot) => {
const delta = robot.position.clone().sub(lastRobotPosition)
sceneManager.orbit.target.add(delta)
sceneManager.camera.position.add(delta)
lastRobotPosition.copy(robot.position)
}
const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end
}
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(input)
let planner = planners[get(mode).mode]
const delta = performance.now() - lastTick
lastTick = performance.now()
body_state = planner.step(body_state, controlData, delta)
settings.omega = body_state.omega
settings.phi = body_state.phi
settings.psi = body_state.psi
settings.xm = body_state.xm
settings.ym = body_state.ym
settings.zm = body_state.zm
}
const update_robot_position = (robot: URDFRobot) => {
if (!settings['Robot transform controls']) return
settings.omega = radToDeg(robot.rotation.y)
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90
settings.psi = radToDeg(robot.rotation.x) + 90
settings.xm = robot.position.z / THREEJS_SCALE
settings.zm = -robot.position.x / THREEJS_SCALE
}
const updateTargetPosition = () => {
target.visible = settings['Target position']
target.position.x = smooth(target.position.x, target_position.x, SMOOTH_AMOUNT)
target.position.z = smooth(target.position.z, target_position.z, SMOOTH_AMOUNT)
}
const render = () => {
const robot = sceneManager.model
if (!robot) return
const toes = getToeWorldPositions(robot)
update_camera(robot)
update_gait()
calculate_kinematics()
update_robot_position(robot)
sceneManager.transformControl.showX = settings['Robot transform controls']
sceneManager.transformControl.showY = settings['Robot transform controls']
sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles.angles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles.angles[i],
SMOOTH_AMOUNT
)
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles.angles[i]))
}
orient_robot(robot, toes)
updateTargetPosition()
}
</script> </script>
<svelte:window onresize={sceneManager.fillParent} /> <svelte:window onresize={sceneManager.fillParent} />
@@ -1,228 +0,0 @@
<script lang="ts">
import { fileSystemClient } from '$lib/filesystem/chunkedTransfer'
import type { TransferProgress } from '$lib/types/models'
import { onMount } from 'svelte'
let currentPath = '/'
let files: Array<{ name: string; size: number }> = []
let directories: Array<{ name: string }> = []
let loading = false
let error = ''
let uploadProgress: TransferProgress | null = null
let downloadProgress: TransferProgress | null = null
const joinPath = (name: string) => (currentPath === '/' ? '/' + name : currentPath + '/' + name)
const getError = (e: unknown, fallback: string) =>
e instanceof Error ? e.message : (e as { error?: string })?.error || fallback
async function loadDirectory() {
loading = true
error = ''
try {
const result = await fileSystemClient.listDirectory(currentPath)
if (result.success) {
files = result.files
directories = result.directories
} else {
error = result.error || 'Failed to load directory'
}
} catch (e) {
error = getError(e, 'Unknown error')
} finally {
loading = false
}
}
async function navigateTo(path: string) {
currentPath = path
await loadDirectory()
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploadProgress = null
error = ''
try {
const result = await fileSystemClient.uploadFileFromBrowser(
joinPath(file.name),
file,
p => (uploadProgress = p)
)
if (result.success) await loadDirectory()
else error = result.error || 'Upload failed'
} catch (e) {
error = getError(e, 'Upload error')
} finally {
uploadProgress = null
input.value = ''
}
}
async function handleDownload(filename: string) {
downloadProgress = null
error = ''
try {
const result = await fileSystemClient.downloadFileAndSave(
joinPath(filename),
filename,
p => (downloadProgress = p)
)
if (!result.success) error = result.error || 'Download failed'
} catch (e) {
error = getError(e, 'Download error')
} finally {
downloadProgress = null
}
}
async function handleDelete(name: string, isDirectory: boolean) {
if (!confirm(`Delete ${isDirectory ? 'directory' : 'file'} "${name}"?`)) return
error = ''
try {
const result = await fileSystemClient.deleteFile(joinPath(name))
if (result.success) await loadDirectory()
else error = result.error || 'Delete failed'
} catch (e) {
error = getError(e, 'Delete error')
}
}
async function handleCreateDirectory() {
const name = prompt('Enter directory name:')
if (!name) return
error = ''
try {
const result = await fileSystemClient.createDirectory(joinPath(name))
if (result.success) await loadDirectory()
else error = result.error || 'Failed to create directory'
} catch (e) {
error = getError(e, 'Error creating directory')
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
onMount(loadDirectory)
</script>
<div class="max-w-3xl mx-auto my-8 p-4 border border-gray-300 rounded-lg bg-white">
<div class="mb-4">
<h2 class="m-0 mb-2">File Manager</h2>
<div class="font-mono bg-gray-100 p-2 rounded mb-2">Current: {currentPath}</div>
<div class="flex gap-2">
<button
class="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer hover:bg-blue-700"
on:click={handleCreateDirectory}>New Folder</button
>
<label
class="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer hover:bg-blue-700"
>
Upload File
<input type="file" on:change={handleFileUpload} class="hidden" />
</label>
<button
class="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer hover:bg-blue-700"
on:click={loadDirectory}>Refresh</button
>
</div>
</div>
{#if error}
<div class="bg-red-100 text-red-800 p-3 rounded mb-4">{error}</div>
{/if}
{#if uploadProgress}
<div class="mb-4">
<div class="mb-2 text-sm">
Uploading: {uploadProgress.percentage.toFixed(1)}% ({formatBytes(
uploadProgress.bytesTransferred
)} / {formatBytes(uploadProgress.totalBytes)})
</div>
<div class="h-5 bg-gray-200 rounded overflow-hidden">
<div
class="h-full bg-green-600 transition-all duration-300"
style="width: {uploadProgress.percentage}%"
></div>
</div>
</div>
{/if}
{#if downloadProgress}
<div class="mb-4">
<div class="mb-2 text-sm">
Downloading: {downloadProgress.percentage.toFixed(1)}% ({formatBytes(
downloadProgress.bytesTransferred
)} / {formatBytes(downloadProgress.totalBytes)})
</div>
<div class="h-5 bg-gray-200 rounded overflow-hidden">
<div
class="h-full bg-green-600 transition-all duration-300"
style="width: {downloadProgress.percentage}%"
></div>
</div>
</div>
{/if}
<div class="border border-gray-300 rounded min-h-[200px]">
{#if loading}
<div class="text-center p-8 text-gray-500">Loading...</div>
{:else}
{#if currentPath !== '/'}
<div
class="flex items-center p-3 border-b border-gray-100 gap-2 bg-gray-50 cursor-pointer"
on:click={() => navigateTo('/')}
>
<span class="text-2xl">📁</span>
<span class="flex-1 hover:underline">..</span>
</div>
{/if}
{#each directories as dir}
<div class="flex items-center p-3 border-b border-gray-100 gap-2 bg-gray-50">
<span class="text-2xl">📁</span>
<span
class="flex-1 cursor-pointer hover:underline"
on:click={() => navigateTo(currentPath + '/' + dir.name)}>{dir.name}</span
>
<button
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
on:click={() => handleDelete(dir.name, true)}>Delete</button
>
</div>
{/each}
{#each files as file}
<div class="flex items-center p-3 border-b border-gray-100 gap-2 last:border-b-0">
<span class="text-2xl">📄</span>
<span class="flex-1">{file.name}</span>
<span class="text-gray-500 text-sm">{formatBytes(file.size)}</span>
<button
class="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
on:click={() => handleDownload(file.name)}>Download</button
>
<button
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
on:click={() => handleDelete(file.name, false)}>Delete</button
>
</div>
{/each}
{#if files.length === 0 && directories.length === 0}
<div class="text-center p-8 text-gray-500">Directory is empty</div>
{/if}
{/if}
</div>
</div>
+2 -8
View File
@@ -35,11 +35,6 @@ 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 UploadIcon } from '~icons/mdi/upload'
export { default as DownloadIcon } from '~icons/mdi/download'
export { default as Down } from '~icons/tabler/chevron-down' export { default as Down } from '~icons/tabler/chevron-down'
export { default as Cancel } from '~icons/tabler/x' export { default as Cancel } from '~icons/tabler/x'
@@ -55,14 +50,13 @@ 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/mdi/dns' export { default as DNS } from '~icons/tabler/address-book'
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/mdi/edit' export { default as Edit } from '~icons/tabler/pencil'
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,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { MdiEyeOffOutline, MdiEyeOutline } from '../icons' import { MdiEyeOffOutline, MdiEyeOutline } from "../icons";
interface Props { interface Props {
show?: boolean show?: boolean;
value?: string value?: string;
id?: string id?: string;
} }
let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props() let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props();
let type = $derived(show ? 'text' : 'password') let type = $derived(show ? 'text' : 'password');
const handleInput = (e: Event) => (value = (e.target as HTMLInputElement).value) const handleInput = (e: any) => value = e.target.value
const togglePassword = () => (show = !show) const togglePassword = () => show = !show
</script> </script>
<label class="input input-bordered flex items-center gap-2"> <label class="input input-bordered flex items-center gap-2">
@@ -1,35 +1,34 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
min?: number min?: number
max?: number max?: number
step?: number step?: number
value?: number value?: any
oninput?: (value: Event) => void oninput?: any
} }
let { let {
min = 0, min = 0,
max = 100, max = 100,
step = 1, step = 1,
value = $bindable((max - min) / 2), value = $bindable((max - min) / 2),
...rest ...rest
}: Props = $props() }: Props = $props()
</script> </script>
<input <input
type="range" type="range"
style="writing-mode: vertical-lr; direction: rtl" style="writing-mode: vertical-lr; direction: rtl"
class="cursor-pointer" class="cursor-pointer"
{min} {min}
{max} {max}
{step} {step}
bind:value bind:value
{...rest} {...rest} />
/>
<style> <style>
input[type='range']::-webkit-slider-runnable-track { input[type='range']::-webkit-slider-runnable-track {
background: oklch(var(--p) / 1); background: oklch(var(--p) / 1);
border-radius: var(--rounded-box, 1rem); border-radius: var(--rounded-box, 1rem);
} }
</style> </style>
+2 -2
View File
@@ -1,2 +1,2 @@
export { default as PasswordInput } from './InputPassword.svelte' export { default as PasswordInput } from './InputPassword.svelte';
export { default as VerticalSlider } from './VerticalSlider.svelte' export { default as VerticalSlider } from './VerticalSlider.svelte';
+2 -2
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
children?: import('svelte').Snippet children?: import('svelte').Snippet;
} }
let { children }: Props = $props() let { children }: Props = $props();
</script> </script>
<div class="box-border overflow-hidden flex-1"> <div class="box-border overflow-hidden flex-1">
@@ -1,41 +1,37 @@
<script lang="ts"> <script lang="ts">
import WidgetContainer from './WidgetContainer.svelte' import WidgetContainer from './WidgetContainer.svelte';
import { import { WidgetComponents, type WidgetContainerConfig, isWidgetConfig } from '$lib/stores/application';
WidgetComponents, import Widget from './Widget.svelte';
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>
@@ -1,16 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Github } from '../icons' import { Github } from "../icons";
interface Props { interface Props {
github: { href: string; active?: boolean } github: any;
} }
let { github }: Props = $props() let { github }: Props = $props();
</script> </script>
{#if github.active} {#if github.active}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- external URL --> <a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
<Github class="h-5 w-5" /> <Github class="h-5 w-5" />
</a> </a>
{/if} {/if}
@@ -1,15 +1,14 @@
<script> <script>
import logo from '$lib/assets/logo512.png' import logo from '$lib/assets/logo512.png';
import { resolve } from '$app/paths'
/** @type {{appName: any}} */ /** @type {{appName: any}} */
let { appName } = $props() let { appName } = $props();
</script> </script>
<a <a
href={resolve('/')} href="/"
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]" class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
> >
<img src={logo} alt="Logo" class="h-12 w-12" /> <img src={logo} alt="Logo" class="h-12 w-12" />
<h1 class="px-4 text-2xl font-bold">{appName}</h1> <h1 class="px-4 text-2xl font-bold">{appName}</h1>
</a> </a>
+158 -166
View File
@@ -1,186 +1,178 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
import { resolve } from '$app/paths' import { useFeatureFlags } from '$lib/stores/featureFlags'
import { useFeatureFlags } from '$lib/stores/featureFlags' import GithubButton from '../menu/GithubButton.svelte'
import GithubButton from '../menu/GithubButton.svelte' import LogoButton from '../menu/LogoButton.svelte'
import LogoButton from '../menu/LogoButton.svelte' import MenuList from '../menu/MenuList.svelte'
import MenuList from '../menu/MenuList.svelte' import {
import { Connection,
Connection, Settings,
Settings, MdiController,
MdiController, Devices,
Devices, Camera,
Camera, Rotate3d,
Rotate3d, MotorOutline,
MotorOutline, Health,
Health, Folder,
Folder, Update,
Update, WiFi,
WiFi, Router,
Router, AP,
AP, Copyright,
Copyright, Metrics
Metrics, } from '$lib/components/icons'
DNS import appEnv from 'app-env'
} from '$lib/components/icons'
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
const features = useFeatureFlags() const features = useFeatureFlags()
const appName = page.data.app_name const appName = page.data.app_name
const copyright = page.data.copyright const copyright = page.data.copyright
const github = { href: 'https://github.com/' + page.data.github, active: true } const github = { href: 'https://github.com/' + page.data.github, active: true }
import type { Component } from 'svelte' type menuItem = {
title: string
icon: ConstructorOfATypedSvelteComponent
href?: string
feature: boolean
active?: boolean
submenu?: menuItem[]
}
type menuItem = { let menuItems = $state<menuItem[]>([])
title: string
icon: Component
href?: string
feature: boolean
active?: boolean
submenu?: menuItem[]
}
function withBase(path: string) { $effect(() => {
return `${resolve('/')}${path.startsWith('/') ? path.slice(1) : path}` menuItems = [
} {
title: 'Connection',
icon: WiFi,
href: '/connection',
feature: !appEnv.VITE_USE_HOST_NAME
},
{
title: 'Controller',
icon: MdiController,
href: '/controller',
feature: true
},
{
title: 'Peripherals',
icon: Devices,
feature: true,
submenu: [
{
title: 'I2C',
icon: Connection,
href: '/peripherals/i2c',
feature: true
},
{
title: 'Camera',
icon: Camera,
href: '/peripherals/camera',
feature: $features.camera
},
{
title: 'Servo',
icon: MotorOutline,
href: '/peripherals/servo',
feature: true
},
{
title: 'IMU',
icon: Rotate3d,
href: '/peripherals/imu',
feature: $features.imu || $features.mag || $features.bmp
}
]
},
{
title: 'WiFi',
icon: WiFi,
feature: true,
submenu: [
{
title: 'WiFi Station',
icon: Router,
href: '/wifi/sta',
feature: true
},
{
title: 'Access Point',
icon: AP,
href: '/wifi/ap',
feature: true
}
]
},
{
title: 'System',
icon: Settings,
feature: true,
submenu: [
{
title: 'System Status',
icon: Health,
href: '/system/status',
feature: true
},
{
title: 'File System',
icon: Folder,
href: '/system/filesystem',
feature: true
},
{
title: 'System Metrics',
icon: Metrics,
href: '/system/metrics',
feature: $features.analytics
},
{
title: 'Firmware Update',
icon: Update,
href: '/system/update',
feature: $features.ota || $features.upload_firmware || $features.download_firmware
}
]
}
] as menuItem[]
})
const { menuClicked } = $props() const { menuClicked } = $props()
const activeTitle = $derived(page.data.title) function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => {
item.active = item.title === targetTitle
item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle
})
})
menuItems = menuItems
menuClicked()
}
const menuItems = $derived<menuItem[]>( $effect(() => {
[ setActiveMenuItem(page.data.title)
{ })
title: 'Connection',
icon: WiFi,
href: withBase('/connection'),
feature: !PUBLIC_VITE_USE_HOST_NAME
},
{
title: 'Controller',
icon: MdiController,
href: withBase('/controller'),
feature: true
},
{
title: 'Peripherals',
icon: Devices,
feature: true,
submenu: [
{
title: 'I2C',
icon: Connection,
href: withBase('/peripherals/i2c'),
feature: true
},
{
title: 'Camera',
icon: Camera,
href: withBase('/peripherals/camera'),
feature: true
},
{
title: 'Servo',
icon: MotorOutline,
href: withBase('/peripherals/servo'),
feature: true
},
{
title: 'IMU',
icon: Rotate3d,
href: withBase('/peripherals/imu'),
feature: true
}
].map(sub => ({ ...sub, active: sub.title === activeTitle }))
},
{
title: 'WiFi',
icon: WiFi,
feature: true,
submenu: [
{
title: 'WiFi Station',
icon: Router,
href: withBase('/wifi/sta'),
feature: true
},
{
title: 'Access Point',
icon: AP,
href: withBase('/wifi/ap'),
feature: true
},
{
title: 'mDNS',
icon: DNS,
href: withBase('/wifi/mdns'),
feature: true
}
].map(sub => ({ ...sub, active: sub.title === activeTitle }))
},
{
title: 'System',
icon: Settings,
feature: true,
submenu: [
{
title: 'System Status',
icon: Health,
href: withBase('/system/status'),
feature: true
},
{
title: 'File System',
icon: Folder,
href: withBase('/system/filesystem'),
feature: true
},
{
title: 'System Metrics',
icon: Metrics,
href: withBase('/system/metrics'),
feature: true
},
{
title: 'Firmware Update',
icon: Update,
href: withBase('/system/update'),
feature: !!(
$features.ota ||
$features.upload_firmware ||
$features.download_firmware
)
}
].map(sub => ({ ...sub, active: sub.title === activeTitle }))
}
].map(item => ({ ...item, active: item.title === activeTitle }))
)
const updateMenu = () => { const updateMenu = (event: any) => {
menuClicked() setActiveMenuItem(event.details)
} }
</script> </script>
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content"> <div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
<LogoButton {appName} /> <LogoButton {appName} />
<MenuList <MenuList {menuItems} select={updateMenu} class="grow flex-nowrap overflow-y-auto" level="0" />
{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>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<GithubButton {github} /> <GithubButton {github} />
<div class="flex items-center justify-end text-sm gap-2"> <div class="flex items-center justify-end text-sm gap-2">
<Copyright class="h-4 w-4" />{copyright} <Copyright class="h-4 w-4" />{copyright}
</div>
</div> </div>
</div>
</div> </div>
+41 -49
View File
@@ -1,56 +1,48 @@
<script lang="ts"> <script lang="ts">
import MenuList from './MenuList.svelte' import MenuList from './MenuList.svelte'
import type { Component } from 'svelte' type MenuItem = {
title: string
icon: ConstructorOfATypedSvelteComponent
href?: string
feature: boolean
active?: boolean
submenu?: MenuItem[]
}
type MenuItem = { let { level, menuItems, select, class: klass } = $props()
title: string
icon: Component
href?: string
feature: boolean
active?: boolean
submenu?: MenuItem[]
}
let { level, menuItems, select, class: klass } = $props() const selectMenuItem = (title: string) => {
select(title)
const selectMenuItem = (title: string) => { }
select(title)
}
</script> </script>
<ul class={klass + ' menu w-full'}> <ul class={klass + ' menu'}>
{#each menuItems as MenuItem[] as menuItem (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="font-bold"> <summary class="text-lg font-bold">
<menuItem.icon class="h-6 w-6" /> <menuItem.icon class="h-6 w-6" />
{menuItem.title} {menuItem.title}
</summary> </summary>
<div class="pl-4"> <div class="pl-4">
<MenuList <MenuList menuItems={menuItem.submenu} level={level + 1} {select} class={klass} />
menuItems={menuItem.submenu} </div>
level={level + 1} </details>
{select} {:else}
class={klass} <a
/> href={menuItem.href}
</div> class="font-bold"
</details> class:bg-base-100={menuItem.active}
{:else} class:text-lg={level === 0}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve --><a class:text-md={level === 1}
href={menuItem.href} onclick={() => selectMenuItem(menuItem.title)}>
class="font-bold" <menuItem.icon class="h-6 w-6" />
class:bg-base-100={menuItem.active} {menuItem.title}
class:text-lg={level === 0} </a>
class:text-md={level === 1}
onclick={() => selectMenuItem(menuItem.title)}
>
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</a>
{/if}
</li>
{/if} {/if}
{/each} </li>
{/if}
{/each}
</ul> </ul>
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { isFullscreen, toggleFullscreen } from '$lib/stores' import { isFullscreen, toggleFullscreen } from '$lib/stores';
import { MdiFullscreenExit, MdiFullscreen } from '../icons' import { MdiFullscreenExit, MdiFullscreen } from '../icons';
const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen) const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen);
</script> </script>
<button onclick={toggleFullscreen}> <button onclick={toggleFullscreen}>
@@ -1,33 +1,33 @@
<script lang="ts"> <script lang="ts">
import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from '../icons' import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from "../icons";
interface Props { interface Props {
showDBm?: boolean showDBm?: boolean;
rssi?: number rssi?: number;
} }
let { showDBm = false, rssi = 0 }: Props = $props() let { showDBm = false, rssi = 0 }: Props = $props();
const getWiFiIcon = () => { const getWiFiIcon = () => {
if (rssi === 0) return WifiOff if (rssi === 0) return WifiOff;
if (rssi >= -55) return WiFi if (rssi >= -55) return WiFi;
if (rssi >= -75) return WiFi2 if (rssi >= -75) return WiFi2;
if (rssi >= -85) return WiFi1 if (rssi >= -85) return WiFi1;
return WiFi0 return WiFi0;
} };
const SvelteComponent = $derived(getWiFiIcon()) const SvelteComponent = $derived(getWiFiIcon());
</script> </script>
<div class="indicator"> <div class="indicator">
<div class="tooltip tooltip-left" data-tip={rssi + ' dBm'}> <div class="tooltip tooltip-left" data-tip={rssi + " dBm"}>
{#if showDBm} {#if showDBm}
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs"> <span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
{rssi} dBm {rssi} dBm
</span> </span>
{/if} {/if}
<div class="h-7 w-7"> <div class="h-7 w-7">
<SvelteComponent class="absolute inset-0 h-full w-full" /> <SvelteComponent class="absolute inset-0 h-full w-full" />
</div> </div>
</div> </div>
</div> </div>
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { useFeatureFlags } from '$lib/stores' import { useFeatureFlags } from '$lib/stores';
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { api } from '$lib/api' import { api } from '$lib/api';
import { Cancel, Power } from '../icons' import { Cancel, Power } from '../icons';
const features = useFeatureFlags() const features = useFeatureFlags();
const postSleep = async () => await api.post('/api/system/sleep') const postSleep = async () => await api.post('/api/system/sleep');
const confirmSleep = () => { const confirmSleep = () => {
modals.open(ConfirmDialog, { modals.open(ConfirmDialog, {
@@ -18,11 +18,11 @@
confirm: { label: 'Switch Off', icon: Power } confirm: { label: 'Switch Off', icon: Power }
}, },
onConfirm: () => { onConfirm: () => {
modals.close() modals.close();
postSleep() postSleep();
} }
}) });
} };
</script> </script>
{#if $features.sleep} {#if $features.sleep}
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { ModeData, ModesEnum } from '$lib/platform_shared/message' import { mode, modes } from "$lib/stores";
import { mode } from '$lib/stores'
const deactivate = async () => { const deactivate = async () => {
mode.set(ModeData.create({ mode: ModesEnum.DEACTIVATED })) mode.set(modes.indexOf('deactivated'));
} };
</script> </script>
<button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button> <button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { MdiWeatherSunny, MdiMoonAndStars } from '../icons' import { MdiWeatherSunny, MdiMoonAndStars } from "../icons";
</script> </script>
<label class="swap swap-rotate"> <label class="swap swap-rotate">
@@ -1,18 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Hamburger } from '../icons' import {Hamburger} from '../icons'
import { resolve } from '$app/paths'
</script> </script>
<div class="topbar absolute left-0 top-0 w-full z-20 flex justify-between bg-zinc-800"> <div class="topbar absolute left-0 top-0 w-full z-20 flex justify-between bg-zinc-800">
<div class="flex gap-2 p-2"> <div class="flex gap-2 p-2">
<a href={resolve('/')}> <a href="/">
<Hamburger class="h-8 w-8" /> <Hamburger class="h-8 w-8"/>
</a> </a>
</div> </div>
</div> </div>
<style> <style>
.topbar { .topbar {
height: 50px; height: 50px;
} }
</style> </style>
@@ -1,80 +1,80 @@
<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 { update = $bindable(false) }: Props = $props();
let firmwareVersion: string = $state('') let firmwareVersion: string = $state('');
let firmwareDownloadLink: string = $state('') let firmwareDownloadLink: string = $state('');
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<GithubRelease>( const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${page.data.github}/releases/latest`, `https://api.github.com/repos/${page.data.github}/releases/latest`,
{ headers } { headers }
) );
if (result.inner.message === '404' || result.inner.message == 'Not Found') { if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository') console.warn('Error: Could not find releases in the repository');
return return;
} }
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner);
return return;
} }
const results = result.inner const results = result.inner;
update = false update = false;
firmwareVersion = '' firmwareVersion = '';
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) { if (compareVersions(results.tag_name, $features.firmware_version) === 1) {
// iterate over assets and find the correct one // iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) { for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin // check if the asset is of type *.bin
if ( if (
results.assets[i].name.includes('.bin') && results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($features.firmware_built_target as string) results.assets[i].name.includes($features.firmware_built_target)
) { ) {
update = true update = true;
firmwareVersion = results.tag_name firmwareVersion = results.tag_name;
firmwareDownloadLink = results.assets[i].browser_download_url firmwareDownloadLink = results.assets[i].browser_download_url;
notifications.info('Firmware update available.', 5000) 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) { function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, { modals.open(ConfirmDialog, {
@@ -85,12 +85,12 @@
confirm: { label: 'Update', icon: CloudDown } confirm: { label: 'Update', icon: CloudDown }
}, },
onConfirm: () => { onConfirm: () => {
postGithubDownload(url) postGithubDownload(url);
modals.open(GithubUpdateDialog, { modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll() onConfirm: () => modals.closeAll()
}) });
} }
}) });
} }
</script> </script>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { selectedView, views } from '$lib/stores/application' import { selectedView, views } from "$lib/stores/application";
import Selector from '../widget/Selector.svelte' import Selector from "../widget/Selector.svelte";
</script> </script>
<Selector bind:selectedOption={$selectedView} options={$views.map(v => v.name)} /> <Selector bind:selectedOption={$selectedView} options={$views.map((v) => v.name)} />
@@ -1,38 +1,38 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
import { telemetry } from '$lib/stores/telemetry' import { telemetry } from '$lib/stores/telemetry'
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte' import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte' import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte'
import SleepButton from './SleepButton.svelte' import SleepButton from './SleepButton.svelte'
import ThemeButton from './ThemeButton.svelte' import ThemeButton from './ThemeButton.svelte'
import FullscreenButton from './FullscreenButton.svelte' import FullscreenButton from './FullscreenButton.svelte'
import StopButton from './StopButton.svelte' import StopButton from './StopButton.svelte'
import ViewSelector from './ViewSelector.svelte' import ViewSelector from './ViewSelector.svelte'
import { Hamburger } from '../icons' import { Hamburger } from '../icons'
</script> </script>
<div class="navbar bg-base-300 sticky top-0 z-10 h-12 min-h-fit drop-shadow-lg lg:h-16 gap-2 pr-0"> <div class="navbar bg-base-300 sticky top-0 z-10 h-12 min-h-fit drop-shadow-lg lg:h-16 gap-2 pr-0">
<div class="flex flex-1 gap-2"> <div class="flex flex-1 gap-2">
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button"> <label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button">
<Hamburger class="h-6 w-auto" /> <Hamburger class="h-6 w-auto" />
</label> </label>
{#if page.data.title === 'Controller'} {#if page.data.title === 'Controller'}
<ViewSelector /> <ViewSelector />
{:else} {:else}
<h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1> <h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1>
{/if} {/if}
</div> </div>
<UpdateIndicator /> <UpdateIndicator />
<FullscreenButton /> <FullscreenButton />
<ThemeButton /> <ThemeButton />
<RssiIndicator rssi={$telemetry.rssi.rssi} /> <RssiIndicator rssi={$telemetry.rssi.rssi} />
<SleepButton /> <SleepButton />
<StopButton /> <StopButton />
</div> </div>
+29 -31
View File
@@ -1,37 +1,35 @@
<script> <script>
import { flip } from 'svelte/animate' import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import { error, info, success, warning } from '../icons' import { error, info, success, warning } from '../icons';
/** @type {{theme?: any, icon?: any}} */
let { /** @type {{theme?: any, icon?: any}} */
theme = { let { theme = {
error: 'alert-error', error: 'alert-error',
success: 'alert-success', success: 'alert-success',
warning: 'alert-warning', warning: 'alert-warning',
info: 'alert-info' info: 'alert-info'
}, }, icon = {
icon = { error: error,
error: error, success: success,
success: success, warning: warning,
warning: warning, info: info
info: info } } = $props();
}
} = $props()
</script> </script>
<div class="toast toast-end mr-4 z-20"> <div class="toast toast-end mr-4 z-20">
{#each $notifications as notification (notification.id)} {#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]} {@const SvelteComponent = icon[notification.type]}
<div <div
animate:flip={{ duration: 400 }} animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}" class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }} in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }} out:fly={{ x: 100, duration: 400 }}
> >
<SvelteComponent class="h-6 w-6 shrink-0" /> <SvelteComponent class="h-6 w-6 shrink-0" />
<span>{notification.message}</span> <span>{notification.message}</span>
</div> </div>
{/each} {/each}
</div> </div>
+30 -30
View File
@@ -1,42 +1,42 @@
import { 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 = 4000) => send(msg, 'error', timeout), error: (msg: string, timeout: number) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout), warning: (msg: string, timeout: number) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout), info: (msg: string, timeout: number) => send(msg, 'info', timeout),
success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout) success: (msg: string, timeout: number) => 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,97 +1,98 @@
<script lang="ts"> <script lang="ts">
import { daisyColor } from '$lib/utilities' import { daisyColor } from "$lib/utilities";
import { Chart, registerables } from 'chart.js' import { Chart, registerables } from "chart.js";
import { onMount } from 'svelte' import { onMount } from "svelte";
import { cubicOut } from 'svelte/easing' import { cubicOut } from "svelte/easing";
import { slide } from 'svelte/transition' import { slide } from "svelte/transition";
let chartElement: HTMLCanvasElement let chartElement: HTMLCanvasElement = $state();
let chart: Chart<'line', number[], number> let chart: Chart;
interface Props { interface Props {
label: string label: any;
data: number[] data: number[];
title: string 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"
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
options?: string[] options?: string[];
selectedOption?: string selectedOption?: string;
change?: () => void change: () => void;
[key: string]: unknown [key: string]: any;
} }
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props() let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props();
</script> </script>
<select <select
@@ -14,7 +14,7 @@
{...rest} {...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}" class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"
> >
{#each options as option (option)} {#each options as option}
<option value={option}>{option}</option> <option value={option}>{option}</option>
{/each} {/each}
</select> </select>
-470
View File
@@ -1,470 +0,0 @@
import { socket } from '$lib/stores/socket'
import * as FSMessages from '$lib/platform_shared/filesystem'
import type {
FSDeleteRequest,
FSMkdirRequest,
FSListRequest,
FSDownloadRequest,
FSDownloadMetadata,
FSDownloadData,
FSDownloadComplete,
FSUploadStart,
FSUploadData,
FSUploadComplete,
FSCancelTransfer
} from '$lib/platform_shared/filesystem'
import type { Result, DataResult, ListResult, ProgressCallback } from '$lib/types/models'
const MAX_CHUNK_SIZE = 2 ** 14
type TimeoutId = ReturnType<typeof setTimeout>
type CleanupFn = (() => void) | null
interface TransferBase<T extends Result> {
resolve: (result: T) => void
reject: (error: Error) => void
onProgress?: ProgressCallback
timeoutId: TimeoutId
}
interface ActiveDownload extends TransferBase<DataResult> {
path: string
buffer: Uint8Array
fileSize: number
totalChunks: number
chunksReceived: number
bytesReceived: number
}
interface ActiveUpload extends TransferBase<Result> {
path: string
transferId: number
totalChunks: number
chunksSent: number
}
export class FileSystemClient {
private activeDownloads = new Map<number, ActiveDownload>()
private activeUploads = new Map<number, ActiveUpload>()
private pendingDownloads = new Map<string, TransferBase<DataResult>>()
private metadataListenerCleanup: CleanupFn = null
private downloadListenerCleanup: CleanupFn = null
private completeListenerCleanup: CleanupFn = null
private uploadCompleteListenerCleanup: CleanupFn = null
private transferTimeout = 60000
constructor() {
this.setupListeners()
}
private setupListeners() {
// Listen for download metadata (sent first with file size)
this.metadataListenerCleanup = socket.on(
FSMessages.FSDownloadMetadata,
(metadata: FSDownloadMetadata) => {
this.handleDownloadMetadata(metadata)
}
)
// Listen for download data chunks
this.downloadListenerCleanup = socket.on(
FSMessages.FSDownloadData,
(data: FSDownloadData) => {
this.handleDownloadData(data)
}
)
// Listen for download completion
this.completeListenerCleanup = socket.on(
FSMessages.FSDownloadComplete,
(complete: FSDownloadComplete) => {
this.handleDownloadComplete(complete)
}
)
// Listen for upload completion
this.uploadCompleteListenerCleanup = socket.on(
FSMessages.FSUploadComplete,
(complete: FSUploadComplete) => {
this.handleUploadComplete(complete)
}
)
}
private handleDownloadMetadata(metadata: FSDownloadMetadata) {
// Find the pending download by path (we don't have transferId yet)
// The metadata arrives in response to a download request
const pending = this.pendingDownloads.values().next().value
if (!pending) {
console.warn(`Received download metadata but no pending download`)
return
}
// Clear initial timeout
clearTimeout(pending.timeoutId)
// Get the path from the pending downloads (first one)
const [path] = this.pendingDownloads.keys()
this.pendingDownloads.delete(path)
if (!metadata.success) {
pending.resolve({ success: false, error: metadata.error || 'Download failed' })
return
}
const transferId = metadata.transferId
// Now we know the exact file size - allocate properly sized buffer
const buffer = new Uint8Array(metadata.fileSize)
const download: ActiveDownload = {
path,
buffer,
fileSize: metadata.fileSize,
totalChunks: metadata.totalChunks,
chunksReceived: 0,
bytesReceived: 0,
resolve: pending.resolve,
reject: pending.reject,
onProgress: pending.onProgress,
timeoutId: setTimeout(() => {
this.activeDownloads.delete(transferId)
pending.reject(new Error('Download timeout'))
}, this.transferTimeout)
}
this.activeDownloads.set(transferId, download)
}
private handleDownloadData(data: FSDownloadData) {
const download = this.activeDownloads.get(data.transferId)
if (!download) {
console.warn(`Received download data for unknown transfer: ${data.transferId}`)
return
}
// Reset timeout
clearTimeout(download.timeoutId)
download.timeoutId = setTimeout(() => {
this.activeDownloads.delete(data.transferId)
download.reject(new Error('Download timeout'))
}, this.transferTimeout)
// Copy chunk data to buffer
if (data.data && data.data.length > 0) {
const offset = data.chunkIndex * MAX_CHUNK_SIZE
download.buffer.set(data.data, offset)
download.bytesReceived += data.data.length
download.chunksReceived++
}
// Report progress
if (download.onProgress) {
download.onProgress({
transferId: data.transferId,
bytesTransferred: download.bytesReceived,
totalBytes: download.fileSize,
chunksCompleted: download.chunksReceived,
totalChunks: download.totalChunks,
percentage: (download.chunksReceived / download.totalChunks) * 100
})
}
}
private handleDownloadComplete(complete: FSDownloadComplete) {
const download = this.activeDownloads.get(complete.transferId)
if (!download) {
// This is normal for error cases where transferId wasn't set
if (complete.error) {
console.warn(`Download failed: ${complete.error}`)
}
return
}
clearTimeout(download.timeoutId)
this.activeDownloads.delete(complete.transferId)
if (complete.success) {
// Trim buffer to actual file size
const finalData = download.buffer.slice(0, complete.fileSize)
download.resolve({ success: true, data: finalData })
} else {
download.resolve({ success: false, error: complete.error || 'Download failed' })
}
}
private handleUploadComplete(complete: FSUploadComplete) {
const upload = this.activeUploads.get(complete.transferId)
if (!upload) {
console.warn(`Received upload complete for unknown transfer: ${complete.transferId}`)
return
}
clearTimeout(upload.timeoutId)
this.activeUploads.delete(complete.transferId)
if (complete.success) {
upload.resolve({ success: true })
} else {
upload.resolve({ success: false, error: complete.error || 'Upload failed' })
}
}
/** Delete a file or directory on the ESP32 */
async deleteFile(path: string): Promise<Result> {
const request: FSDeleteRequest = { path }
const response = await socket.request({
fsDeleteRequest: request
})
if (response.fsDeleteResponse) {
return {
success: response.fsDeleteResponse.success,
error: response.fsDeleteResponse.error || undefined
}
}
return { success: false, error: 'No response received' }
}
/** Create a directory on the ESP32 */
async createDirectory(path: string): Promise<Result> {
const request: FSMkdirRequest = { path }
const response = await socket.request({
fsMkdirRequest: request
})
if (response.fsMkdirResponse) {
return {
success: response.fsMkdirResponse.success,
error: response.fsMkdirResponse.error || undefined
}
}
return { success: false, error: 'No response received' }
}
/** List files and directories at the given path */
async listDirectory(path = '/'): Promise<ListResult> {
const request: FSListRequest = { path }
const response = await socket.request({
fsListRequest: request
})
if (response.fsListResponse) {
const resp = response.fsListResponse
return {
success: resp.success,
error: resp.error || undefined,
files: (resp.files || []).map((f) => ({ name: f.name, size: f.size })),
directories: (resp.directories || []).map((d) => ({ name: d.name }))
}
}
return { success: false, error: 'No response received', files: [], directories: [] }
}
/** Download a file from the ESP32 using streaming transfer */
async downloadFile(path: string, onProgress?: ProgressCallback): Promise<DataResult> {
return new Promise((resolve, reject) => {
// Send download request - server will send metadata first, then stream chunks
const request: FSDownloadRequest = { path }
// Set up timeout for initial metadata response
const initialTimeout = setTimeout(() => {
this.pendingDownloads.delete(path)
reject(new Error('Download request timeout - no metadata received'))
}, this.transferTimeout)
// Track this pending download - will be converted to active when metadata arrives
this.pendingDownloads.set(path, {
resolve,
reject,
onProgress,
timeoutId: initialTimeout
})
// Send the download request (server will respond with metadata, then stream data)
socket.request({ fsDownloadRequest: request }).catch((err) => {
clearTimeout(initialTimeout)
this.pendingDownloads.delete(path)
reject(err)
})
})
}
/** Upload a file to the ESP32 using streaming transfer */
async uploadFile(path: string, data: Uint8Array, onProgress?: ProgressCallback): Promise<Result> {
const fileSize = data.length
const chunkSize = MAX_CHUNK_SIZE
const totalChunks = Math.ceil(fileSize / chunkSize) || 1
// Start upload - get transfer ID
const startRequest: FSUploadStart = {
path,
fileSize,
totalChunks
}
const startResponse = await socket.request({
fsUploadStart: startRequest
})
if (!startResponse.fsUploadStartResponse) {
return { success: false, error: 'Failed to start upload' }
}
const startResp = startResponse.fsUploadStartResponse
if (!startResp.success) {
return { success: false, error: startResp.error || 'Failed to start upload' }
}
const transferId = startResp.transferId
return new Promise((resolve, reject) => {
// Set up upload tracking
const upload: ActiveUpload = {
path,
transferId,
totalChunks,
chunksSent: 0,
resolve,
reject,
onProgress,
timeoutId: setTimeout(() => {
this.activeUploads.delete(transferId)
reject(new Error('Upload timeout - no completion received'))
}, this.transferTimeout)
}
this.activeUploads.set(transferId, upload)
// Stream all chunks without waiting for ACKs
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const offset = chunkIndex * chunkSize
const end = Math.min(offset + chunkSize, fileSize)
const chunkData = data.slice(offset, end)
const uploadData: FSUploadData = {
transferId,
chunkIndex,
data: chunkData
}
// Send chunk as fire-and-forget message
socket.emit(FSMessages.FSUploadData, uploadData)
upload.chunksSent++
// Report progress
if (onProgress) {
onProgress({
transferId,
bytesTransferred: end,
totalBytes: fileSize,
chunksCompleted: chunkIndex + 1,
totalChunks,
percentage: ((chunkIndex + 1) / totalChunks) * 100
})
}
}
// All chunks sent - now wait for completion message from server
// The timeout will handle if server doesn't respond
})
}
/** Cancel an ongoing transfer */
async cancelTransfer(transferId: number): Promise<Pick<Result, 'success'>> {
const request: FSCancelTransfer = { transferId }
// Clean up local state
const download = this.activeDownloads.get(transferId)
if (download) {
clearTimeout(download.timeoutId)
this.activeDownloads.delete(transferId)
download.resolve({ success: false, error: 'Transfer cancelled' })
}
const upload = this.activeUploads.get(transferId)
if (upload) {
clearTimeout(upload.timeoutId)
this.activeUploads.delete(transferId)
upload.resolve({ success: false, error: 'Transfer cancelled' })
}
const response = await socket.request({
fsCancelTransfer: request
})
if (response.fsCancelTransferResponse) {
return { success: response.fsCancelTransferResponse.success }
}
return { success: false }
}
/** Upload a File object from browser */
async uploadFileFromBrowser(
destinationPath: string,
file: File,
onProgress?: ProgressCallback
): Promise<Result> {
const arrayBuffer = await file.arrayBuffer()
const data = new Uint8Array(arrayBuffer)
return this.uploadFile(destinationPath, data, onProgress)
}
/** Download a file and save it to browser */
async downloadFileAndSave(
path: string,
filename: string,
onProgress?: ProgressCallback
): Promise<Result> {
const result = await this.downloadFile(path, onProgress)
if (!result.success || !result.data) {
return { success: false, error: result.error }
}
// Create blob and trigger download
const blob = new Blob([result.data.buffer as ArrayBuffer])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
return { success: true }
}
/** Cleanup listeners when no longer needed */
destroy() {
this.metadataListenerCleanup?.()
this.downloadListenerCleanup?.()
this.completeListenerCleanup?.()
this.uploadCompleteListenerCleanup?.()
// Cancel all active transfers
for (const [, download] of this.activeDownloads) {
clearTimeout(download.timeoutId)
download.reject(new Error('FileSystemClient destroyed'))
}
this.activeDownloads.clear()
for (const [, upload] of this.activeUploads) {
clearTimeout(upload.timeoutId)
upload.reject(new Error('FileSystemClient destroyed'))
}
this.activeUploads.clear()
}
}
export const fileSystemClient = new FileSystemClient()
+318 -362
View File
@@ -1,334 +1,332 @@
import { get } from 'svelte/store' import type { body_state_t } from './kinematic';
import type { body_state_t } from './kinematic' import { fromInt8 } from './utilities';
import { currentKinematic } from './stores/featureFlags'
import { ControllerData, WalkGaits } from './platform_shared/message' 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 {
stop: number;
lx: number;
ly: number;
rx: number;
ry: number;
h: number;
s: number;
s1: number;
} }
export abstract class GaitState { export abstract class GaitState {
protected abstract name: string protected abstract name: string;
protected dt = 0.02
protected body_state!: body_state_t
protected get kinematic() {
return get(currentKinematic)
}
protected dt = 0.02;
protected body_state!: body_state_t;
protected gait_state: gait_state_t = { protected gait_state: gait_state_t = {
step_height: 0, 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 step_depth: 0.002
} };
public get default_feet_pos() { public get default_feet_pos() {
return this.kinematic.getDefaultFeetPos() return [
[1, -1, 1, 1],
[1, -1, -1, 1],
[-1, -1, 1, 1],
[-1, -1, -1, 1]
];
} }
protected get default_height() { protected get default_height() {
return this.kinematic.default_body_height return 0.5;
}
protected get default_step_depth() {
return this.kinematic.default_step_depth
}
protected get default_step_height() {
return this.kinematic.default_step_height
} }
begin() { begin() {
console.log('Starting', this.name) console.log('Starting', this.name);
} }
end() { end() {
console.log('Ending', this.name) console.log('Ending', this.name);
} }
step(body_state: body_state_t, command: ControllerData, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
this.map_command(command) this.map_command(command);
this.body_state = body_state this.body_state = body_state;
this.dt = dt / 1000 this.dt = dt / 1000;
return body_state;
if (body_state.cumulative_x === undefined) {
body_state.cumulative_x = 0
body_state.cumulative_y = 0
body_state.cumulative_z = 0
body_state.cumulative_roll = 0
body_state.cumulative_pitch = 0
body_state.cumulative_yaw = 0
}
return body_state
} }
map_command(command: ControllerData) { map_command(command: ControllerCommand) {
const kin = this.kinematic const newCommand = {
this.gait_state = { step_height: 0.4 + (command.s1 / 128 + 1) / 2,
step_height: command.s1 * kin.max_step_height, step_x: Math.floor(fromInt8(command.ly, -1, 1) * 10) / 10,
step_x: command.left!.y * kin.max_step_length, step_z: -(Math.floor(fromInt8(command.lx, -1, 1) * 10) / 10),
step_z: -command.left!.x * kin.max_step_length, step_velocity: command.s / 128 + 1,
step_velocity: command.speed, step_angle: command.rx / 128,
step_angle: command.right!.x, step_depth: 0.002
step_depth: kin.default_step_depth };
}
this.gait_state = newCommand;
} }
} }
export class IdleState extends GaitState { export class IdleState extends GaitState {
protected name = 'Idle' protected name = 'Idle';
step(body_state: body_state_t, command: ControllerData) {
super.step(body_state, command)
return body_state
}
} }
export class CalibrationState extends GaitState { export class CalibrationState extends GaitState {
protected name = 'Calibration' protected name = 'Calibration';
step(body_state: body_state_t, _command: ControllerData) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super.step(body_state, _command) body_state.omega = 0;
body_state.omega = 0 body_state.phi = 0;
body_state.phi = 0 body_state.psi = 0;
body_state.psi = 0 body_state.xm = 0;
body_state.xm = 0 body_state.ym = this.default_height * 10;
body_state.ym = this.kinematic.max_body_height body_state.zm = 0;
body_state.zm = 0 body_state.feet = this.default_feet_pos;
body_state.feet = this.default_feet_pos return body_state;
return body_state
} }
} }
export class RestState extends GaitState { export class RestState extends GaitState {
protected name = 'Rest' protected name = 'Rest';
step(body_state: body_state_t, _command: ControllerData) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super.step(body_state, _command) body_state.omega = 0;
body_state.omega = 0 body_state.phi = 0;
body_state.phi = 0 body_state.psi = 0;
body_state.psi = 0 body_state.xm = 0;
body_state.xm = 0 body_state.ym = this.default_height / 2;
body_state.ym = this.kinematic.min_body_height body_state.zm = 0;
body_state.zm = 0 body_state.feet = this.default_feet_pos;
body_state.feet = this.default_feet_pos return body_state;
return body_state
} }
} }
export class StandState extends GaitState { export class StandState extends GaitState {
protected name = 'Stand' protected name = 'Stand';
step(body_state: body_state_t, command: ControllerData) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super.step(body_state, command) body_state.omega = 0;
const kin = this.kinematic body_state.phi = command.rx / 8;
body_state.omega = 0 body_state.psi = command.ry / 8;
body_state.ym = kin.min_body_height + command.height * kin.body_height_range body_state.xm = command.ly / 2 / 100;
body_state.psi = command.right!.y * kin.max_pitch body_state.zm = command.lx / 2 / 100;
body_state.phi = command.right!.x * kin.max_roll body_state.feet = this.default_feet_pos;
body_state.xm = command.left!.y * kin.max_body_shift_x return body_state;
body_state.zm = command.left!.x * kin.max_body_shift_z
body_state.feet = this.default_feet_pos
return body_state
} }
} }
export class BezierState extends GaitState { abstract class PhaseGaitState extends GaitState {
protected name = 'Bezier' protected tick = 0;
protected phase = 0 protected phase = 0;
protected phase_num = 0 protected phase_time = 0;
protected step_length = 0 protected abstract num_phases: number;
protected stand_offset = 0.75 protected abstract phase_speed_factor: number;
protected mode: WalkGaits = WalkGaits.TROT protected abstract swing_stand_ratio: number;
protected speed_factor = 1
offset = [0, 0.5, 0.75, 0.25]
protected shift_start_pos = { x: 0, z: 0 } protected contact_phases!: number[][];
protected shift_target_pos = { x: 0, z: 0 } protected shifts!: number[][];
protected shift_start_time = 0
protected current_shift_leg = -1
protected last_body_state: body_state_t | null = null step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
protected cumulative_position = { x: 0, y: 0, z: 0 } super.step(body_state, command, dt);
protected cumulative_orientation = { roll: 0, pitch: 0, yaw: 0 } this.update_phase();
this.update_body_position();
constructor() { this.update_feet_positions();
super() return this.body_state;
this.set_mode(this.mode)
}
begin() {
super.begin()
}
set_mode(mode: WalkGaits, duty?: number, order?: [number, number, number, number]) {
this.mode = mode
if (mode === WalkGaits.CRAWL) {
this.speed_factor = 0.5
this.stand_offset = duty ?? 0.85
const o = order ?? [3, 0, 2, 1]
const base = [0, 0.25, 0.5, 0.75]
const offsets = new Array(4).fill(0)
for (let i = 0; i < 4; i++) offsets[o[i]] = base[i]
this.offset = offsets
} else {
this.speed_factor = 2
this.stand_offset = duty ?? 0.6
this.offset = order ? (order.map(v => v % 1) as number[]) : [0, 0.5, 0.5, 0]
}
}
end() {
super.end()
}
step(body_state: body_state_t, command: ControllerData, dt: number = 0.02) {
super.step(body_state, command, dt)
const kin = this.kinematic
this.body_state.ym = kin.min_body_height + command.height * kin.body_height_range
this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2)
if (this.gait_state.step_x < 0) this.step_length = -this.step_length
this.update_phase()
this.update_body_position()
this.update_feet_positions()
this.update_cumulative_position()
return this.body_state
} }
update_phase() { update_phase() {
const m = this.gait_state this.phase_time += this.dt * this.phase_speed_factor * this.gait_state.step_velocity;
if (m.step_x === 0 && m.step_z === 0 && m.step_angle === 0) {
this.phase = 0 if (this.phase_time >= 1) {
return this.phase += 1;
} if (this.phase == this.num_phases) this.phase = 0;
this.phase += this.dt * m.step_velocity * this.speed_factor this.phase_time = 0;
if (this.phase >= 1) {
this.phase_num = (this.phase_num + 1) % 2
this.phase = 0
} }
} }
update_body_position() { update_body_position() {
const m = this.gait_state if (this.num_phases === 4) return;
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (!moving) return
if (this.mode !== WalkGaits.CRAWL) return const shift = this.shifts[Math.floor(this.phase / 2)];
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states() 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;
if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) {
if (this.current_shift_leg !== next_swing) {
this.current_shift_leg = next_swing
this.shift_start_pos.x = this.body_state.xm
this.shift_start_pos.z = this.body_state.zm
const remaining_legs = stance.filter(leg => leg !== next_swing)
const target = this.stance_centroid(remaining_legs)
this.shift_target_pos.x = target[0]
this.shift_target_pos.z = target[2]
this.shift_start_time = time_to_lift
}
const total_time = this.shift_start_time
const progress = total_time > 0 ? 1 - time_to_lift / total_time : 1
const smooth_progress = this.smoothstep01(Math.max(0, Math.min(1, progress)))
this.body_state.xm = this.lerp(
this.shift_start_pos.x,
this.shift_target_pos.x,
smooth_progress
)
this.body_state.zm = this.lerp(
this.shift_start_pos.z,
this.shift_target_pos.z,
smooth_progress
)
}
}
protected lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
protected stance_centroid(legs: number[]): number[] {
if (legs.length === 0) return [this.body_state.xm, 0, this.body_state.zm]
let sx = 0,
sz = 0
for (const i of legs) {
sx += this.body_state.feet[i][0]
sz += this.body_state.feet[i][2]
}
return [sx / legs.length, 0, sz / legs.length]
}
protected get_leg_states(): {
stance: number[]
swing: number[]
next_swing: number
time_to_lift: number
} {
const stance: number[] = []
const swing: number[] = []
let next_swing = -1
let min_time_to_swing = Infinity
for (let i = 0; i < 4; i++) {
let phase = this.phase + this.offset[i]
if (phase >= 1) phase -= 1
if (phase <= this.stand_offset) {
stance.push(i)
const time_to_swing = this.stand_offset - phase
if (time_to_swing < min_time_to_swing) {
min_time_to_swing = time_to_swing
next_swing = i
}
} else {
swing.push(i)
}
}
return { stance, swing, next_swing, time_to_lift: min_time_to_swing }
}
protected smoothstep01(t: number): number {
const x = Math.max(0, Math.min(1, t))
return x * x * (3 - 2 * x)
} }
update_feet_positions() { update_feet_positions() {
for (let i = 0; i < 4; i++) this.body_state.feet[i] = this.update_foot_position(i) for (let i = 0; i < 4; i++) {
this.body_state.feet[i] = this.update_foot_position(i);
}
} }
update_foot_position(index: number): number[] { update_foot_position(index: number): number[] {
let phase = this.phase + this.offset[index] const contact = this.contact_phases[index][this.phase];
if (phase >= 1) phase -= 1 return contact ? this.stand(index) : this.swing(index);
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] stand(index: number): number[] {
return phase <= this.stand_offset ? const delta_pos = [
this.stand_controller(index, phase / this.stand_offset) -this.gait_state.step_x * this.dt * this.swing_stand_ratio,
: this.swing_controller(index, (phase - this.stand_offset) / (1 - this.stand_offset)) 0,
-this.gait_state.step_z * this.dt * this.swing_stand_ratio
];
this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0];
this.body_state.feet[index][1] = this.default_feet_pos[index][1];
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2];
return this.body_state.feet[index];
}
swing(index: number): number[] {
const delta_pos = [this.gait_state.step_x * this.dt, 0, this.gait_state.step_z * this.dt];
if (this.gait_state.step_x == 0) {
delta_pos[0] =
(this.default_feet_pos[index][0] - this.body_state.feet[index][0]) * this.dt * 8;
}
if (this.gait_state.step_z == 0) {
delta_pos[2] =
(this.default_feet_pos[index][2] - this.body_state.feet[index][2]) * this.dt * 8;
}
this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0];
this.body_state.feet[index][1] =
this.default_feet_pos[index][1] +
sin(this.phase_time * Math.PI) * this.gait_state.step_height;
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2];
return this.body_state.feet[index];
}
}
export class FourPhaseWalkState extends PhaseGaitState {
protected name = 'Four phase walk';
protected num_phases = 4;
protected phase_speed_factor = 6;
protected contact_phases = [
[1, 0, 1, 1],
[1, 1, 1, 0],
[1, 1, 1, 0],
[1, 0, 1, 1]
];
protected swing_stand_ratio = 1 / (this.num_phases - 1);
begin() {
super.begin();
}
end() {
super.end();
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt);
}
}
export class EightPhaseWalkState extends PhaseGaitState {
protected name = 'Eight phase walk';
protected num_phases = 8;
protected phase_speed_factor = 4;
protected contact_phases = [
[1, 0, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 0, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 0, 1, 1, 1, 1]
];
protected shifts = [
[-0.05, 0, -0.2],
[0.3, 0, 0.2],
[-0.05, 0, 0.2],
[0.3, 0, -0.2]
];
protected swing_stand_ratio = 1 / (this.num_phases - 1);
begin() {
super.begin();
}
end() {
super.end();
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt);
}
}
export class BezierState extends GaitState {
protected name = 'Bezier';
protected phase = 0;
protected phase_num = 0;
protected step_length: number = 0;
offset = [0, 0.5, 0.5, 0];
begin() {
super.begin();
}
end() {
super.end();
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super.step(body_state, command, dt);
this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2);
if (this.gait_state.step_x < 0) {
this.step_length = -this.step_length;
}
this.update_phase();
this.update_feet_positions();
return this.body_state;
}
update_phase() {
this.phase += this.dt * this.gait_state.step_velocity * 2;
if (this.phase >= 1) {
this.phase_num += 1;
this.phase_num %= 2;
this.phase = 0;
}
}
update_feet_positions() {
for (let i = 0; i < 4; i++) {
this.body_state.feet[i] = this.update_foot_position(i);
}
}
update_foot_position(index: number): number[] {
let phase = this.phase + this.offset[index];
if (phase >= 1) {
phase -= 1;
}
this.body_state.feet[index][0] = this.default_feet_pos[index][0];
this.body_state.feet[index][1] = this.default_feet_pos[index][1];
this.body_state.feet[index][2] = this.default_feet_pos[index][2];
return phase <= 0.75 ?
this.stand_controller(index, phase / 0.75)
: this.swing_controller(index, (phase - 0.75) / (1 - 0.75));
} }
stand_controller(index: number, phase: number) { stand_controller(index: number, phase: number) {
const depth = this.gait_state.step_depth let depth = this.gait_state.step_depth;
return this.controller(index, phase, stance_curve, depth) return this.controller(index, phase, stance_curve, depth);
} }
swing_controller(index: number, phase: number) { swing_controller(index: number, phase: number) {
const height = this.gait_state.step_height let height = this.gait_state.step_height;
return this.controller(index, phase, bezier_curve, height) return this.controller(index, phase, bezier_curve, height);
} }
controller( controller(
@@ -337,113 +335,69 @@ export class BezierState extends GaitState {
controller: (length: number, angle: number, ...args: number[]) => number[], controller: (length: number, angle: number, ...args: number[]) => number[],
...args: number[] ...args: number[]
) { ) {
let length = this.step_length / 2 let length = this.step_length / 2;
let angle = Math.atan2(this.gait_state.step_z, 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_pos = controller(length, angle, ...args, phase);
const kin = this.kinematic length = this.gait_state.step_angle * 2;
length = this.gait_state.step_angle * kin.max_step_length angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index]);
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
const delta_rot = controller(length, angle, ...args, phase) const delta_rot = controller(length, angle, ...args, phase);
this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2 this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2;
this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2 this.body_state.feet[index][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) if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle)
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2 this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2;
return this.body_state.feet[index] return this.body_state.feet[index];
}
update_cumulative_position() {
if (this.last_body_state === null) {
this.last_body_state = { ...this.body_state }
this.body_state.cumulative_x = 0
this.body_state.cumulative_y = 0
this.body_state.cumulative_z = 0
this.body_state.cumulative_roll = 0
this.body_state.cumulative_pitch = 0
this.body_state.cumulative_yaw = 0
return
}
const m = this.gait_state
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (moving) {
const step_displacement_x_local =
m.step_x * m.step_velocity * this.dt * this.speed_factor
const step_displacement_z_local =
m.step_z * m.step_velocity * this.dt * this.speed_factor
const step_displacement_yaw =
m.step_angle * m.step_velocity * this.dt * this.speed_factor
const cos_yaw = Math.cos(this.cumulative_orientation.yaw)
const sin_yaw = Math.sin(this.cumulative_orientation.yaw)
const step_displacement_x =
step_displacement_x_local * cos_yaw - step_displacement_z_local * sin_yaw
const step_displacement_z =
step_displacement_x_local * sin_yaw + step_displacement_z_local * cos_yaw
this.cumulative_position.x += step_displacement_x
this.cumulative_position.z += step_displacement_z
this.cumulative_orientation.yaw += step_displacement_yaw
}
this.body_state.cumulative_x = this.cumulative_position.x
this.body_state.cumulative_y = this.cumulative_position.y
this.body_state.cumulative_z = this.cumulative_position.z
this.body_state.cumulative_roll = this.cumulative_orientation.roll
this.body_state.cumulative_pitch = this.cumulative_orientation.pitch
this.body_state.cumulative_yaw = this.cumulative_orientation.yaw
this.last_body_state = { ...this.body_state }
} }
} }
const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => { const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => {
const X_POLAR = Math.cos(angle) const X_POLAR = Math.cos(angle);
const Y_POLAR = Math.sin(angle) const Y_POLAR = Math.sin(angle);
const step = length * (1 - 2 * phase) const step = length * (1 - 2 * phase);
const X = step * X_POLAR const X = step * X_POLAR;
const Z = step * Y_POLAR const Z = step * Y_POLAR;
let Y = 0 let Y = 0;
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
return [X, Y, Z] if (length !== 0) {
} Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length));
}
return [X, Y, Z];
};
const yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => { const yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => {
const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2) const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2);
const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0]) const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0]);
const offsets = [ const offsets = [
current_foot_pos[0] - default_foot_pos[0], current_foot_pos[0] - default_foot_pos[0],
current_foot_pos[2] - default_foot_pos[2], current_foot_pos[2] - default_foot_pos[2],
current_foot_pos[1] - default_foot_pos[1] current_foot_pos[1] - default_foot_pos[1]
] ];
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2) const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2);
const offset_mod = Math.atan2(offset_mag, foot_mag) const offset_mod = Math.atan2(offset_mag, foot_mag);
return Math.PI / 2.0 + foot_dir + offset_mod return Math.PI / 2.0 + foot_dir + offset_mod;
} };
const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => { const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => {
const control_points = get_control_points(length, angle, height) const control_points = get_control_points(length, angle, height);
const n = control_points.length - 1 const n = control_points.length - 1;
const point = [0, 0, 0] const point = [0, 0, 0];
for (let i = 0; i <= n; i++) { for (let i = 0; i <= n; i++) {
const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i) const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i);
point[0] += bernstein_poly * control_points[i][0] point[0] += bernstein_poly * control_points[i][0];
point[1] += bernstein_poly * control_points[i][1] point[1] += bernstein_poly * control_points[i][1];
point[2] += bernstein_poly * control_points[i][2] point[2] += bernstein_poly * control_points[i][2];
} }
return point return point;
} };
const get_control_points = (length: number, angle: number, height: number): number[][] => { const get_control_points = (length: number, angle: number, height: number): number[][] => {
const X_POLAR = Math.cos(angle) const X_POLAR = Math.cos(angle);
const Z_POLAR = Math.sin(angle) const Z_POLAR = Math.sin(angle);
const STEP = [ const STEP = [
-length, -length,
@@ -458,7 +412,7 @@ const get_control_points = (length: number, angle: number, height: number): numb
length * 1.5, length * 1.5,
length * 1.4, length * 1.4,
length length
] ];
const Y = [ const Y = [
0.0, 0.0,
@@ -473,24 +427,26 @@ const get_control_points = (length: number, angle: number, height: number): numb
height * 1.1, height * 1.1,
0.0, 0.0,
0.0 0.0
] ];
const control_points: number[][] = [] const control_points: number[][] = [];
for (let i = 0; i < STEP.length; i++) { for (let i = 0; i < STEP.length; i++) {
const X = STEP[i] * X_POLAR const X = STEP[i] * X_POLAR;
const Z = STEP[i] * Z_POLAR const Z = STEP[i] * Z_POLAR;
control_points.push([X, Y[i], Z]) control_points.push([X, Y[i], Z]);
} }
return control_points return control_points;
} };
const comb = (n: number, k: number): number => { const comb = (n: number, k: number): number => {
if (k < 0 || k > n) return 0 if (k < 0 || k > n) return 0;
if (k === 0 || k === n) return 1 if (k === 0 || k === n) return 1;
k = Math.min(k, n - k) k = Math.min(k, n - k);
let c = 1 let c = 1;
for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1) for (let i = 0; i < k; i++) {
return c c = (c * (n - i)) / (i + 1);
} }
return c;
};
+291 -155
View File
@@ -1,184 +1,320 @@
export interface body_state_t { export interface body_state_t {
omega: number omega: number;
phi: number phi: number;
psi: number psi: number;
xm: number xm: number;
ym: number ym: number;
zm: number zm: number;
feet: number[][] feet: number[][];
cumulative_x: number
cumulative_y: number
cumulative_z: number
cumulative_roll: number
cumulative_pitch: number
cumulative_yaw: number
} }
export interface position { export interface position {
x: number x: number;
y: number y: number;
z: number z: number;
} }
export interface target_position { export interface target_position {
x: number x: number;
z: number z: number;
yaw: number yaw: number;
} }
export interface KinematicParams { const { cos, sin, atan2, sqrt } = Math;
coxa: number
coxa_offset: number
femur: number
tibia: number
L: number
W: number
}
const { cos, sin, atan2, acos, sqrt, max, min } = Math const DEG2RAD = 0.017453292519943;
const DEG2RAD = 0.017453292519943
export default class Kinematic { export default class Kinematic {
coxa: number l1: number;
coxa_offset: number l2: number;
femur: number l3: number;
tibia: number l4: number;
L: number L: number;
W: number W: number;
DEG2RAD = DEG2RAD DEG2RAD = DEG2RAD;
max_roll: number sHp = sin(Math.PI / 2);
max_pitch: number cHp = cos(Math.PI / 2);
max_body_shift_x: number
max_body_shift_z: number
max_leg_reach: number
min_body_height: number
max_body_height: number
body_height_range: number
max_step_length: number
max_step_height: number
default_step_depth: number
default_body_height: number
default_step_height: number
mountOffsets: number[][] Tlf: number[][] = [];
default_feet_positions: number[][] Trf: number[][] = [];
Tlb: number[][] = [];
Trb: number[][] = [];
invMountRot = [ point_lf: number[][];
[0, 0, -1], point_rf: number[][];
[0, 1, 0], point_lb: number[][];
[1, 0, 0] point_rb: number[][];
] Ix: number[][];
constructor(params: KinematicParams) { constructor() {
this.coxa = params.coxa this.l1 = 60.5 / 100;
this.coxa_offset = params.coxa_offset this.l2 = 10 / 100;
this.femur = params.femur this.l3 = 100.7 / 100;
this.tibia = params.tibia this.l4 = 118.5 / 100;
this.L = params.L
this.W = params.W
this.max_roll = 15 * (Math.PI / 2) this.L = 207.5 / 100;
this.max_pitch = 15 * (Math.PI / 2) this.W = 78 / 100;
this.max_body_shift_x = this.W / 3
this.max_body_shift_z = this.W / 3
this.max_leg_reach = this.femur + this.tibia - this.coxa_offset
this.min_body_height = this.max_leg_reach * 0.45
this.max_body_height = this.max_leg_reach * 1
this.body_height_range = this.max_body_height - this.min_body_height
this.max_step_length = this.max_leg_reach * 0.8
this.max_step_height = this.max_leg_reach / 2
this.default_step_depth = 0.002
this.default_body_height = this.min_body_height + this.body_height_range / 2
this.default_step_height = this.default_body_height / 2
this.mountOffsets = [ this.point_lf = [
[this.L / 2, 0, this.W / 2], [this.cHp, 0, this.sHp, this.L / 2],
[this.L / 2, 0, -this.W / 2], [0, 1, 0, 0],
[-this.L / 2, 0, this.W / 2], [-this.sHp, 0, this.cHp, this.W / 2],
[-this.L / 2, 0, -this.W / 2] [0, 0, 0, 1]
] ];
this.default_feet_positions = this.mountOffsets.map((offset, i) => { this.point_rf = [
return [offset[0], 0, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)] [this.cHp, 0, this.sHp, this.L / 2],
}) [0, 1, 0, 0],
} [-this.sHp, 0, this.cHp, -this.W / 2],
[0, 0, 0, 1]
];
getDefaultFeetPos(): number[][] { this.point_lb = [
return this.default_feet_positions.map(pos => [...pos]) [this.cHp, 0, this.sHp, -this.L / 2],
} [0, 1, 0, 0],
[-this.sHp, 0, this.cHp, this.W / 2],
[0, 0, 0, 1]
];
calcIK(p: body_state_t): number[] { this.point_rb = [
const roll = p.omega * this.DEG2RAD [this.cHp, 0, this.sHp, -this.L / 2],
const pitch = p.phi * this.DEG2RAD [0, 1, 0, 0],
const yaw = p.psi * this.DEG2RAD [-this.sHp, 0, this.cHp, -this.W / 2],
const rot = this.euler2R(roll, pitch, yaw) [0, 0, 0, 1]
const inv_rot = [ ];
[rot[0][0], rot[1][0], rot[2][0]], this.Ix = [
[rot[0][1], rot[1][1], rot[2][1]], [-1, 0, 0, 0],
[rot[0][2], rot[1][2], rot[2][2]] [0, 1, 0, 0],
] [0, 0, 1, 0],
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
]
return p.feet.flatMap((foot, i) => {
const [wx, wy, wz] = foot
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
const [mx, my, mz] = this.mountOffsets[i] public calcIK(body_state: body_state_t): number[] {
const px = bx - mx, this.bodyIK(body_state);
py = by - my,
pz = bz - mz
const lx = return [
this.invMountRot[0][0] * px + ...this.legIK(this.multiplyVector(this.inverse(this.Tlf), body_state.feet[0])),
this.invMountRot[0][1] * py + ...this.legIK(
this.invMountRot[0][2] * pz this.multiplyVector(
const ly = this.Ix,
this.invMountRot[1][0] * px + this.multiplyVector(this.inverse(this.Trf), body_state.feet[1])
this.invMountRot[1][1] * py + )
this.invMountRot[1][2] * pz ),
const lz = ...this.legIK(this.multiplyVector(this.inverse(this.Tlb), body_state.feet[2])),
this.invMountRot[2][0] * px + ...this.legIK(
this.invMountRot[2][1] * py + this.multiplyVector(
this.invMountRot[2][2] * pz this.Ix,
this.multiplyVector(this.inverse(this.Trb), body_state.feet[3])
)
)
];
}
const xLocal = i % 2 === 1 ? -lx : lx bodyIK(p: body_state_t) {
return this.legIK(xLocal, ly, lz) const cos_omega = cos(p.omega * this.DEG2RAD);
}) const sin_omega = sin(p.omega * this.DEG2RAD);
} const cos_phi = cos(p.phi * this.DEG2RAD);
const sin_phi = sin(p.phi * this.DEG2RAD);
const cos_psi = cos(p.psi * this.DEG2RAD);
const sin_psi = sin(p.psi * this.DEG2RAD);
private legIK(x: number, y: number, z: number): [number, number, number] { const Tm: number[][] = [
const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa)) [cos_phi * cos_psi, -sin_psi * cos_phi, sin_phi, p.xm],
const G = F - this.coxa_offset [
const H = sqrt(G * G + z * z) sin_omega * sin_phi * cos_psi + sin_psi * cos_omega,
const t1 = -atan2(y, x) - atan2(F, -this.coxa) -sin_omega * sin_phi * sin_psi + cos_omega * cos_psi,
const D = -sin_omega * cos_phi,
(H * H - this.femur * this.femur - this.tibia * this.tibia) / p.ym
(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)) sin_omega * sin_psi - sin_phi * cos_omega * cos_psi,
return [t1, t2, t3] sin_omega * cos_psi + sin_phi * sin_psi * cos_omega,
} cos_omega * cos_phi,
p.zm
],
[0, 0, 0, 1]
];
private euler2R(roll: number, pitch: number, yaw: number): number[][] { this.Tlf = this.matrixMultiply(Tm, this.point_lf);
const cr = cos(roll), this.Trf = this.matrixMultiply(Tm, this.point_rf);
sr = sin(roll) this.Tlb = this.matrixMultiply(Tm, this.point_lb);
const cp = cos(pitch), this.Trb = this.matrixMultiply(Tm, this.point_rb);
sp = sin(pitch) }
const cy = cos(yaw),
sy = sin(yaw) public legIK(point: number[]): number[] {
return [ const [x, y, z] = point;
[cp * cy, -cp * sy, sp],
[sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp], let F = sqrt(x ** 2 + y ** 2 - this.l1 ** 2);
[sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp] 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;
}
} }
+344 -313
View File
@@ -1,348 +1,379 @@
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,
Group, MathUtils,
MeshBasicMaterial, Group,
RepeatWrapping, MeshBasicMaterial,
type Object3D RepeatWrapping
} from 'three' } from 'three'
import { Sky } from 'three/addons/objects/Sky.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls' import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import { Reflector } from 'three/examples/jsm/objects/Reflector.js' import { Reflector } from 'three/examples/jsm/objects/Reflector.js'
import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader' import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader'
import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls' import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls'
import { sunCalculator } from './utilities/position-utilities'
export const addScene = () => new Scene() export const addScene = () => new Scene()
interface position { interface position {
x?: number x?: number
y?: number y?: number
z?: number z?: number
} }
interface light { interface light {
color?: ColorRepresentation color?: ColorRepresentation
intensity?: number intensity?: number
} }
interface arrowOptions { interface arrowOptions {
origin: position origin: position
direction: position direction: position
length?: number length?: number
color?: ColorRepresentation color?: ColorRepresentation
} }
type directionalLight = position & light type directionalLight = position & light
export default class SceneBuilder { export default class SceneBuilder {
public scene: Scene public scene: Scene
public camera!: PerspectiveCamera public camera!: PerspectiveCamera
public ground!: Mesh public ground!: Mesh
public renderer!: WebGLRenderer public renderer!: WebGLRenderer
public orbit: OrbitControls public orbit: OrbitControls
public callback: (() => void) | 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
transformControl: TransformControls highlightMaterial: any
public modelGroup!: Group sky!: Sky
transformControl: TransformControls
public modelGroup!: Group
constructor() { constructor() {
this.scene = new Scene() this.scene = new Scene()
if (this.scene.environment?.mapping) { if (this.scene.environment?.mapping) {
this.scene.environment.mapping = EquirectangularReflectionMapping this.scene.environment.mapping = EquirectangularReflectionMapping
}
return this
}
public addRenderer = (parameters?: WebGLRendererParameters) => {
this.renderer = new WebGLRenderer(parameters)
this.renderer.outputColorSpace = 'srgb'
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = PCFSoftShadowMap
this.renderer.toneMapping = ACESFilmicToneMapping
this.renderer.toneMappingExposure = 0.85
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement)
return this
}
public addSky = () => {
this.sky = new Sky()
this.sky.scale.setScalar(450000)
this.scene.add(this.sky)
const effectController = {
turbidity: 10,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: sunCalculator.calculateSunElevation(),
azimuth: 200,
exposure: this.renderer.toneMappingExposure
}
const uniforms = this.sky.material.uniforms
uniforms['turbidity'].value = effectController.turbidity
uniforms['rayleigh'].value = effectController.rayleigh
uniforms['mieCoefficient'].value = effectController.mieCoefficient
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG
this.renderer.toneMappingExposure = 0.5
const phi = MathUtils.degToRad(90 - effectController.elevation)
const theta = MathUtils.degToRad(effectController.azimuth)
const sun = new Vector3()
sun.setFromSphericalCoords(1, phi, theta)
uniforms['sunPosition'].value.copy(sun)
return this
}
public addPerspectiveCamera = (options: position) => {
this.camera = new PerspectiveCamera()
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0)
this.scene.add(this.camera)
return this
}
public addGroundPlane = (options?: position) => {
const checkerboardTexture = this.createCheckerboardTexture(1024, 2)
checkerboardTexture.wrapS = RepeatWrapping
checkerboardTexture.wrapT = RepeatWrapping
checkerboardTexture.repeat.set(100, 100)
const checkerboardMat = new MeshBasicMaterial({
map: checkerboardTexture,
opacity: 0.1,
transparent: true
})
const plane = new PlaneGeometry(400, 400)
this.ground = new Mesh(plane, checkerboardMat)
this.ground.rotation.x = -Math.PI / 2
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0)
this.ground.receiveShadow = true
this.scene.add(this.ground)
const mirror = new Reflector(plane, {
clipBias: 0.003,
textureWidth: window.innerWidth * window.devicePixelRatio,
textureHeight: window.innerHeight * window.devicePixelRatio,
color: 0x00bfff
})
mirror.rotateX(-Math.PI / 2)
this.scene.add(mirror)
return this
}
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement)
this.orbit.minDistance = 5
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)
}
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
const traverse = (c: any) => {
if (c.type === 'Mesh') {
if (revert) {
c.material = c.__origMaterial
delete c.__origMaterial
} else {
c.__origMaterial = c.material
c.material = material
} }
return this }
}
public addRenderer = (parameters?: WebGLRendererParameters) => { if (c === m || !this.isJoint(c)) {
this.renderer = new WebGLRenderer(parameters) for (let i = 0; i < c.children.length; i++) {
this.renderer.outputColorSpace = 'srgb' const child = c.children[i]
this.renderer.shadowMap.enabled = true if (!child.isURDFCollider) {
this.renderer.shadowMap.type = PCFSoftShadowMap traverse(c.children[i])
this.renderer.toneMapping = ACESFilmicToneMapping }
this.renderer.toneMappingExposure = 0.85
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement)
return this
}
public addPerspectiveCamera = (options: position) => {
this.camera = new PerspectiveCamera()
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0)
this.scene.add(this.camera)
return this
}
public addGroundPlane = (options?: position) => {
const checkerboardTexture = this.createCheckerboardTexture(1024, 2)
checkerboardTexture.wrapS = RepeatWrapping
checkerboardTexture.wrapT = RepeatWrapping
checkerboardTexture.repeat.set(100, 100)
const checkerboardMat = new MeshBasicMaterial({
map: checkerboardTexture,
opacity: 0.1,
transparent: true
})
const plane = new PlaneGeometry(400, 400)
this.ground = new Mesh(plane, checkerboardMat)
this.ground.rotation.x = -Math.PI / 2
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0)
this.ground.receiveShadow = true
this.scene.add(this.ground)
const mirror = new Reflector(plane, {
clipBias: 0.003,
textureWidth: window.innerWidth * window.devicePixelRatio,
textureHeight: window.innerHeight * window.devicePixelRatio,
color: 0x00bfff
})
mirror.rotateX(-Math.PI / 2)
this.scene.add(mirror)
return this
}
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement)
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2
this.orbit.maxDistance = maxDistance
this.orbit.autoRotate = autoRotate
this.orbit.update()
this.orbit.minDistance = minDistance
return this
}
public addAmbientLight = (options: light) => {
const ambientLight = new AmbientLight(options.color, options.intensity)
this.scene.add(ambientLight)
return this
}
public addDirectionalLight = (options: directionalLight) => {
const directionalLight = new DirectionalLight(options.color, options.intensity)
directionalLight.castShadow = true
directionalLight.shadow.camera.top = 10
directionalLight.shadow.camera.bottom = -10
directionalLight.shadow.camera.right = 10
directionalLight.shadow.camera.left = -10
directionalLight.shadow.mapSize.set(4096, 4096)
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0)
this.scene.add(directionalLight)
return this
}
private createCheckerboardTexture = (size: number, squares: number) => {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
const squareSize = size / squares
for (let y = 0; y < squares; y++) {
for (let x = 0; x < squares; x++) {
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000'
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize)
}
} }
}
const texture = new CanvasTexture(canvas)
texture.wrapS = texture.wrapT = RepeatWrapping
texture.anisotropy = 16
return texture
} }
traverse(m)
}
public addFogExp2 = (color: ColorRepresentation, density?: number) => { public addTransformControls = (model: any) => {
this.scene.fog = new FogExp2(color, density) this.transformControl = new TransformControls(this.camera, this.renderer.domElement)
return this 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
}
public addModel = (model: any) => {
this.modelGroup = new Group()
this.modelGroup.add(model)
this.model = model
this.scene.add(this.modelGroup)
return this
}
public addDragControl = (updateAngle: any) => {
const highlightColor = '#FFFFFF'
const highlightMaterial = new MeshPhongMaterial({
shininess: 10,
color: highlightColor,
emissive: highlightColor,
emissiveIntensity: 0.9
})
const dragControls = new PointerURDFDragControls(
this.scene,
this.camera,
this.renderer.domElement
)
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
this.setJointValue(joint.name, angle)
updateAngle(joint.name, angle)
} }
dragControls.onDragStart = () => {
public fillParent = () => { this.orbit.enabled = false
const parentElement = this.renderer.domElement.parentElement this.isDragging = true
if (parentElement) {
const width = parentElement.clientWidth
const height = parentElement.clientHeight
this.handleResize(width, height)
}
return this
} }
dragControls.onDragEnd = () => {
public handleResize = (width = window.innerWidth, height = window.innerHeight) => { this.orbit.enabled = true
this.renderer.setSize(width, height) this.isDragging = false
this.renderer.setPixelRatio(window.devicePixelRatio)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
return this
} }
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial)
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial)
public addRenderCb = (callback: () => void) => { this.renderer.domElement.addEventListener(
this.callback = callback 'touchstart',
return this 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 startRenderLoop = () => { public toggleFog = () => {
this.renderer.setAnimationLoop(() => { this.scene.fog = this.scene.fog ? null : this.fog
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) => { private handleRobotShadow = () => {
const dir = new Vector3( if (this.isLoaded) return
options?.direction.x ?? 0, const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10)
options?.direction.y ?? 0, setTimeout(() => clearInterval(intervalId), 1000)
options?.direction.z ?? 0 this.isLoaded = true
) }
const origin = new Vector3(
options?.origin.x ?? 0,
options?.origin.y ?? 0,
options?.origin.z ?? 0
)
const arrowHelper = new ArrowHelper(
dir,
origin,
options?.length ?? 1.5,
options?.color ?? 0xff0000
)
this.scene.add(arrowHelper)
return this
}
private setJointValue(jointName: string, angle: number) {
if (!this.model) return
if (!this.model.joints[jointName]) return
this.model.joints[jointName].setJointValue(angle)
}
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
const traverse = (c: Object3D) => {
if (c.type === 'Mesh') {
if (revert) {
c.material = c.__origMaterial
delete c.__origMaterial
} else {
c.__origMaterial = c.material
c.material = material
}
}
if (c === m || !this.isJoint(c)) {
for (let i = 0; i < c.children.length; i++) {
const child = c.children[i]
if (!child.isURDFCollider) {
traverse(c.children[i])
}
}
}
}
traverse(m)
}
public addTransformControls = (model: Object3D) => {
this.transformControl = new TransformControls(this.camera, this.renderer.domElement)
this.transformControl.addEventListener('dragging-changed', (event: { value: boolean }) => {
this.orbit.enabled = !event.value
this.isDragging = !event.value
})
this.transformControl.attach(model)
this.scene.add(this.transformControl)
this.transformControl.setMode('rotate')
return this
}
public addModel = (model: URDFRobot) => {
this.modelGroup = new Group()
this.modelGroup.add(model)
this.model = model
this.scene.add(this.modelGroup)
return this
}
public addDragControl = (updateAngle: (angles: Record<string, number>) => void) => {
const highlightColor = '#FFFFFF'
const highlightMaterial = new MeshPhongMaterial({
shininess: 10,
color: highlightColor,
emissive: highlightColor,
emissiveIntensity: 0.9
})
const dragControls = new PointerURDFDragControls(
this.scene,
this.camera,
this.renderer.domElement
)
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
this.setJointValue(joint.name, angle)
updateAngle({ [joint.name]: angle })
}
dragControls.onDragStart = () => {
this.orbit.enabled = false
this.isDragging = true
}
dragControls.onDragEnd = () => {
this.orbit.enabled = true
this.isDragging = false
}
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial)
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial)
this.renderer.domElement.addEventListener(
'touchstart',
data => dragControls._mouseDown(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchmove',
data => dragControls._mouseMove(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchend',
data => dragControls._mouseUp(data.touches[0]),
{ passive: true }
)
return this
}
public toggleFog = () => {
this.scene.fog = this.scene.fog ? null : this.fog
}
private handleRobotShadow = () => {
if (this.isLoaded) return
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10)
setTimeout(() => clearInterval(intervalId), 1000)
this.isLoaded = true
}
} }
+43 -42
View File
@@ -1,53 +1,54 @@
import { Result } from '$lib/utilities/result' import { Result } from '$lib/utilities/result';
import { browser } from '$app/environment' import { browser } from '$app/environment';
class FileService { class FileService {
private dbPromise: Promise<Result<IDBDatabase, string>> | null = private dbPromise: Promise<Result<IDBDatabase, string>> | null = browser
browser ? this.openDatabase() : null ? this.openDatabase()
: null;
private async openDatabase(): Promise<Result<IDBDatabase, string>> { private async openDatabase(): Promise<Result<IDBDatabase, string>> {
return new Promise(resolve => { return new Promise((resolve) => {
const request = indexedDB.open('fileStorageDB', 1) const request = indexedDB.open('fileStorageDB', 1);
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
request.result.createObjectStore('files') request.result.createObjectStore('files');
} };
request.onsuccess = () => resolve(Result.ok(request.result)) request.onsuccess = () => resolve(Result.ok(request.result));
request.onerror = () => resolve(Result.err('Error opening database')) request.onerror = () => resolve(Result.err('Error opening database'));
}) });
} }
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> { private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
if (!browser || !this.dbPromise) if (!browser || !this.dbPromise)
return Result.err('Not running in browser or DB not initialized') return Result.err('Not running in browser or DB not initialized');
const dbResult = await this.dbPromise const dbResult = await this.dbPromise;
if (dbResult.isErr()) return Result.err('Database not initialized') if (dbResult.isErr()) return Result.err('Database not initialized');
const store = dbResult.inner.transaction('files', mode).objectStore('files') const store = dbResult.inner.transaction('files', mode).objectStore('files');
return Result.ok(store) return Result.ok(store);
} }
public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> { public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> {
const storeResult = await this.getStore('readwrite') const storeResult = await this.getStore('readwrite');
if (storeResult.isErr()) return Result.err('Failed to access store') if (storeResult.isErr()) return Result.err('Failed to access store');
return new Promise(resolve => { return new Promise((resolve) => {
const request = storeResult.inner.put(file, key) const request = storeResult.inner.put(file, key);
request.onsuccess = () => resolve(Result.ok(request.result)) request.onsuccess = () => resolve(Result.ok(request.result));
request.onerror = () => resolve(Result.err('Failed to save file')) request.onerror = () => resolve(Result.err('Failed to save file'));
}) });
} }
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> { public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> {
const storeResult = await this.getStore('readonly') const storeResult = await this.getStore('readonly');
if (storeResult.isErr()) return Result.err('Failed to access store') if (storeResult.isErr()) return Result.err('Failed to access store');
return new Promise(resolve => { return new Promise((resolve) => {
const request = storeResult.inner.get(key) const request = storeResult.inner.get(key);
request.onsuccess = () => request.onsuccess = () =>
resolve(request.result ? Result.ok(request.result) : Result.err('File not found')) resolve(request.result ? Result.ok(request.result) : Result.err('File not found'));
request.onerror = () => resolve(Result.err('Failed to retrieve file')) request.onerror = () => resolve(Result.err('Failed to retrieve file'));
}) });
} }
} }
export default browser ? new FileService() : null export default browser ? new FileService() : null;
+2 -2
View File
@@ -1,2 +1,2 @@
export { default as fileService } from './file-service' export { default as fileService } from './file-service';
export { default as resultService } from './result-service' export { default as resultService } from './result-service';
+14 -14
View File
@@ -1,19 +1,19 @@
import { errorLogs, latestErrorLog } from '$lib/stores' import { errorLogs, latestErrorLog } from '$lib/stores';
import type { Result } from '$lib/utilities' import type { Result } from '$lib/utilities';
class ResultService { class ResultService {
public handleResult(result: Result<unknown, string>, tag?: string) { public handleResult(result: Result<unknown, string>, tag?: string) {
if (result.isErr()) { if (result.isErr()) {
const errorLogEntry = { tag, message: result.inner, exception: result.exception } const errorLogEntry = { tag, message: result.inner, exception: result.exception };
latestErrorLog.set(errorLogEntry) latestErrorLog.set(errorLogEntry);
errorLogs.update(entries => { errorLogs.update((entries) => {
entries.push(errorLogEntry) entries.push(errorLogEntry);
return entries return entries;
}) });
} }
return result return result;
} }
} }
export default new ResultService() export default new ResultService();
+49 -30
View File
@@ -1,36 +1,55 @@
import { AnalyticsData } from '$lib/platform_shared/message' import { type Analytics } from '$lib/types/models';
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
import { socket } from './socket'
const maxAnalyticsData = 100 let analytics_data = {
uptime: <number[]>[],
free_heap: <number[]>[],
total_heap: <number[]>[],
used_heap: <number[]>[],
min_free_heap: <number[]>[],
max_alloc_heap: <number[]>[],
fs_used: <number[]>[],
fs_total: <number[]>[],
core_temp: <number[]>[],
cpu0_usage: <number[]>[],
cpu1_usage: <number[]>[],
cpu_usage: <number[]>[]
};
const maxAnalyticsData = 100;
function createAnalytics() { function createAnalytics() {
const { subscribe, update } = writable<AnalyticsData[]>([]) const { subscribe, update } = writable(analytics_data);
let unsubscribe: (() => void) | null = null return {
let listenerCount = 0 subscribe,
addData: (content: Analytics) => {
const addData = (content: AnalyticsData) => { update((analytics_data) => ({
update(data => [...data, content].slice(-maxAnalyticsData)) ...analytics_data,
} uptime: [...analytics_data.uptime, content.uptime].slice(-maxAnalyticsData),
free_heap: [...analytics_data.free_heap, content.free_heap / 1000].slice(-maxAnalyticsData),
return { total_heap: [...analytics_data.total_heap, content.total_heap / 1000].slice(
subscribe, -maxAnalyticsData
addData, ),
listen: () => { used_heap: [
listenerCount++ ...analytics_data.used_heap,
if (!unsubscribe) { (content.total_heap - content.free_heap) / 1000
unsubscribe = socket.on(AnalyticsData, addData) ].slice(-maxAnalyticsData),
} min_free_heap: [...analytics_data.min_free_heap, content.min_free_heap / 1000].slice(
}, -maxAnalyticsData
stop: () => { ),
listenerCount = Math.max(0, listenerCount - 1) max_alloc_heap: [...analytics_data.max_alloc_heap, content.max_alloc_heap / 1000].slice(
if (listenerCount === 0 && unsubscribe) { -maxAnalyticsData
unsubscribe() ),
unsubscribe = null fs_used: [...analytics_data.fs_used, content.fs_used / 1000].slice(-maxAnalyticsData),
} fs_total: [...analytics_data.fs_total, content.fs_total / 1000].slice(-maxAnalyticsData),
} core_temp: [...analytics_data.core_temp, content.core_temp].slice(-maxAnalyticsData),
} cpu0_usage: [...analytics_data.cpu0_usage, content.cpu0_usage].slice(-maxAnalyticsData),
cpu1_usage: [...analytics_data.cpu1_usage, content.cpu1_usage].slice(-maxAnalyticsData),
cpu_usage: [...analytics_data.cpu_usage, content.cpu_usage].slice(-maxAnalyticsData)
}));
}
};
} }
export const analytics = createAnalytics() export const analytics = createAnalytics();
+49 -49
View File
@@ -1,67 +1,67 @@
import { persistentStore } from '$lib/utilities' import { persistentStore } from '$lib/utilities';
import { get, type Writable } from 'svelte/store' import { get, type Writable } from 'svelte/store';
import Visualization from '$lib/components/Visualization.svelte' import Visualization from '$lib/components/Visualization.svelte';
import Stream from '$lib/components/Stream.svelte' import Stream from '$lib/components/Stream.svelte';
import ChartWidget from '$lib/components/widget/ChartWidget.svelte' import ChartWidget from '$lib/components/widget/ChartWidget.svelte';
export interface WidgetConfig { export interface WidgetConfig {
id: string | number id: string | number;
component: keyof typeof WidgetComponents component: keyof typeof WidgetComponents;
props?: Record<string, unknown> props?: Record<string, any>;
} }
export interface WidgetContainerConfig { export interface WidgetContainerConfig {
id: string | number id: string | number;
layout?: 'row' | 'column' | 'wrap' layout?: 'row' | 'column' | 'wrap';
header?: string header?: string;
widgets: Array<WidgetConfig | WidgetContainerConfig> widgets: Array<WidgetConfig | WidgetContainerConfig>;
} }
export const isWidgetConfig = ( export const isWidgetConfig = (
widget: WidgetConfig | WidgetContainerConfig widget: WidgetConfig | WidgetContainerConfig
): widget is WidgetConfig => 'component' in widget ): widget is WidgetConfig => 'component' in widget;
export const WidgetComponents = { export const WidgetComponents = {
Visualization, Visualization,
Stream, Stream,
ChartWidget ChartWidget
} };
interface View { interface View {
name: string name: string;
content: WidgetContainerConfig content: WidgetContainerConfig;
} }
const defaultViews: View[] = [ const defaultViews: View[] = [
{ {
name: '3D representation', name: 'Stream',
content: { content: {
id: 'root', id: 'root',
layout: 'column', layout: 'column',
widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }] widgets: [{ id: 2, component: 'Stream' }]
} }
}, },
{ {
name: 'Stream', name: '3D representation',
content: { content: {
id: 'root', id: 'root',
layout: 'column', layout: 'column',
widgets: [{ id: 2, component: 'Stream' }] widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }]
} }
}, },
{ {
name: 'Split screen', name: 'Split screen',
content: { content: {
id: 'root', id: 'root',
widgets: [ widgets: [
{ id: 2, component: 'Stream' }, { id: 2, component: 'Stream' },
{ id: 2, component: 'Visualization', props: { debug: true } } { id: 2, component: 'Visualization', props: { debug: true } }
] ]
} }
} }
] ];
export const views: Writable<View[]> = persistentStore('views', defaultViews) export const views: Writable<View[]> = persistentStore('views', defaultViews);
export const selectedView = persistentStore('selected_view', get(views)[0].name) export const selectedView = persistentStore('selected_view', get(views)[0].name);
+14 -66
View File
@@ -1,72 +1,20 @@
import { notifications } from '$lib/components/toasts/notifications' import { api } from '$lib/api';
import Kinematic from '$lib/kinematic' import { notifications } from '$lib/components/toasts/notifications';
import { persistentStore } from '$lib/utilities' import { writable, type Writable } from 'svelte/store';
import { derived, type Writable } from 'svelte/store'
import { resolve } from '$app/paths'
import { socket } from '$lib/stores'
let featureFlagsStore: Writable<Record<string, boolean | string>> let featureFlagsStore: Writable<Record<string, boolean>>;
export function useFeatureFlags() { export function useFeatureFlags() {
if (!featureFlagsStore) { if (!featureFlagsStore) {
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {}) featureFlagsStore = writable<Record<string, boolean>>({});
socket api.get<Record<string, boolean>>('/api/features').then((result) => {
.request({ featuresDataRequest: {} }) if (result.isOk()) featureFlagsStore.set(result.inner);
.then(response => { else {
if (response.featuresDataResponse) { notifications.error('Feature flag could not be fetched', 2500);
featureFlagsStore.set( }
response.featuresDataResponse as unknown as Record<string, boolean | string> });
) }
} else {
notifications.error('Feature flags could not be fetched', 2500)
}
})
.catch(() => {
notifications.error('Feature flags could not be fetched', 2500)
})
}
return featureFlagsStore return featureFlagsStore;
} }
const base = resolve('/')
export const variants = {
SPOTMICRO_ESP32: {
model: `${base}spot_micro.urdf.xacro`,
stl: `${base}stl.zip`,
kinematics: {
coxa: 0.0605,
coxa_offset: 0.01,
femur: 0.1112,
tibia: 0.1185,
L: 0.2075,
W: 0.078
}
},
SPOTMICRO_YERTLE: {
model: `${base}yertle.URDF`,
stl: `${base}URDF.zip`,
kinematics: {
coxa: 0.035,
coxa_offset: 0.0,
femur: 0.13,
tibia: 0.13,
L: 0.24,
W: 0.078
}
}
}
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)
)
+14 -15
View File
@@ -1,25 +1,24 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
export const isFullscreen = writable(false) export const isFullscreen = writable(false);
export function toggleFullscreen() { export function toggleFullscreen() {
isFullscreen.update(state => { isFullscreen.update((state) => {
if (!state) document.documentElement.requestFullscreen() !state ? document.documentElement.requestFullscreen() : document.exitFullscreen();
else document.exitFullscreen() return !state;
return !state });
})
} }
export function enterFullscreen() { export function enterFullscreen() {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
document.documentElement.requestFullscreen() document.documentElement.requestFullscreen();
isFullscreen.set(true) isFullscreen.set(true);
} }
} }
export function exitFullscreen() { export function exitFullscreen() {
if (document.fullscreenElement) { if (document.fullscreenElement) {
document.exitFullscreen() document.exitFullscreen();
isFullscreen.set(false) isFullscreen.set(false);
} }
} }
-87
View File
@@ -1,87 +0,0 @@
import { readable, derived } from 'svelte/store'
export type GamepadState = {
available: boolean
gamepads: Gamepad[]
}
const DEADZONE = 0.15
const dz = (x: number) => {
const a = Math.abs(x)
if (a < DEADZONE) return 0
return ((a - DEADZONE) / (1 - DEADZONE)) * Math.sign(x)
}
let raf = 0
let running = false
export const gamepads = readable<GamepadState>({ available: false, gamepads: [] }, set => {
const update = () => {
const pads = navigator.getGamepads?.() ?? []
const list = Array.from(pads)
.map(p => p || null)
.filter(Boolean) as Gamepad[]
set({ available: 'getGamepads' in navigator, gamepads: list })
raf = requestAnimationFrame(update)
}
const onConnect = () => update()
const onDisconnect = () => update()
const onVis = () => {
if (document.hidden) {
running = false
cancelAnimationFrame(raf)
} else if (!running) {
running = true
raf = requestAnimationFrame(update)
}
}
window.addEventListener('gamepadconnected', onConnect)
window.addEventListener('gamepaddisconnected', onDisconnect)
document.addEventListener('visibilitychange', onVis)
running = true
raf = requestAnimationFrame(update)
return () => {
running = false
cancelAnimationFrame(raf)
window.removeEventListener('gamepadconnected', onConnect)
window.removeEventListener('gamepaddisconnected', onDisconnect)
document.removeEventListener('visibilitychange', onVis)
}
})
export const gamepad = derived(gamepads, s =>
s.available && s.gamepads.length ? s.gamepads[0] : null
)
export const hasGamepad = derived(gamepads, s => s.available && s.gamepads.length > 0)
export const gamepadAxes = derived(gamepad, g => (g ? g.axes.map(dz) : [0, 0, 0, 0]))
type ButtonEdge = { pressed: boolean; value: number; justPressed: boolean; justReleased: boolean }
const prev = new Map<number, { pressed: boolean; value: number }[]>()
export const gamepadButtons = derived(gamepad, g => g?.buttons ?? [])
export const gamepadButtonsEdges = derived(gamepad, g => {
if (!g) return [] as ButtonEdge[]
const p = prev.get(g.index) || []
const out = g.buttons.map((b, i): ButtonEdge => {
const pr = p[i] || { pressed: false, value: 0 }
const pressed = !!b.pressed || b.value > 0.5
return {
pressed,
value: b.value,
justPressed: pressed && !pr.pressed,
justReleased: !pressed && pr.pressed
}
})
prev.set(
g.index,
out.map(x => ({ pressed: x.pressed, value: x.value }))
)
return out
})
+22 -29
View File
@@ -1,34 +1,27 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
import { IMUData } from '$lib/platform_shared/message' import type { IMU } from '$lib/types/models';
import { socket } from './socket'
const maxIMUData = 100 const maxIMUData = 100;
export const imu = (() => { export const imu = (() => {
const { subscribe, update } = writable<IMUData[]>([]) const { subscribe, update } = writable({
x: [] as number[],
y: [] as number[],
z: [] as number[],
heading: [] as number[],
altitude: [] as number[],
pressure: [] as number[],
bmp_temp: [] as number[]
});
let unsubscribe: (() => void) | null = null const addData = (content: IMU) => {
let listenerCount = 0 update(data => {
(Object.keys(content) as (keyof IMU)[]).forEach(key => {
data[key] = [...data[key], content[key]].slice(-maxIMUData);
});
return data;
});
};
const addData = (content: IMUData) => { return { subscribe, addData };
update(data => [...data, content].slice(-maxIMUData)) })();
}
return {
subscribe,
addData,
listen: () => {
listenerCount++
if (!unsubscribe) {
unsubscribe = socket.on(IMUData, addData)
}
},
stop: () => {
listenerCount = Math.max(0, listenerCount - 1)
if (listenerCount === 0 && unsubscribe) {
unsubscribe()
unsubscribe = null
}
}
}
})()
+9 -9
View File
@@ -1,9 +1,9 @@
export * from './socket-store' export * from './socket-store';
export * from './logging-store' export * from './logging-store';
export * from './model-store' export * from './model-store';
export * from './socket' export * from './socket';
export * from './fullscreen' export * from './fullscreen';
export * from './telemetry' export * from './telemetry';
export * from './analytics' export * from './analytics';
export * from './featureFlags' export * from './featureFlags';
export * from './location-store' export * from './location-store';
+4 -5
View File
@@ -1,6 +1,5 @@
import { persistentStore } from '$lib/utilities' import { persistentStore } from '$lib/utilities';
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public' import appEnv from 'app-env';
export const apiLocation = export const location = appEnv.VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '');
PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '')
+6 -6
View File
@@ -1,11 +1,11 @@
import { writable, type Writable } from 'svelte/store' import { writable, type Writable } from 'svelte/store';
export interface errorLog { export interface errorLog {
message: unknown message: unknown;
tag?: string tag?: string;
exception?: unknown exception?: unknown;
} }
export const latestErrorLog: Writable<errorLog> = writable() export const latestErrorLog: Writable<errorLog> = writable();
export const errorLogs: Writable<errorLog[]> = writable([]) export const errorLogs: Writable<errorLog[]> = writable([]);
+36 -47
View File
@@ -1,56 +1,45 @@
import Kinematic from '$lib/kinematic' import type { ControllerInput } from '$lib/types/models';
import { import { persistentStore } from '$lib/utilities/svelte-utilities';
ControllerData, import { writable, type Writable } from 'svelte/store';
KinematicData,
ModeData,
ModesEnum,
WalkGaitData,
WalkGaits
} from '$lib/platform_shared/message'
import { persistentStore } from '$lib/utilities/svelte-utilities'
import { writable, type Writable } from 'svelte/store'
export const emulateModel = writable(true) export const emulateModel = writable(true);
export const jointNames = persistentStore('joint_names', <string[]>[]) export const jointNames = persistentStore('joint_names', <string[]>[]);
export const model = writable() export const model = writable();
export const mode: Writable<ModeData> = writable(ModeData.create({ mode: ModesEnum.DEACTIVATED })) export const modes = [
'deactivated',
'idle',
'calibration',
'rest',
'stand',
'crawl',
'walk'
] as const;
export const walkGait: Writable<WalkGaitData> = writable( export type Modes = (typeof modes)[number];
WalkGaitData.create({ gait: WalkGaits.TROT })
)
export const kinematicData = writable(KinematicData.create()) export enum ModesEnum {
Deactivated,
export const input: Writable<ControllerData> = writable( Idle,
ControllerData.create({ Calibration,
left: { x: 0, y: 0 }, Rest,
right: { x: 0, y: 0 }, Stand,
height: 0.7, Crawl,
s1: 0.5, Walk
speed: 0.5
})
)
function enumToValuesAndLabels<T extends number>(enumObj: Record<string, T | string>) {
const entries = Object.entries(enumObj).filter(
([key, v]) => typeof v === 'number' && key !== 'UNRECOGNIZED'
) as [string, T][]
return {
values: entries.map(([, v]) => v),
labels: Object.fromEntries(
entries.map(([k, v]) => [v, k.charAt(0) + k.slice(1).toLowerCase()])
) as Record<T, string>
}
} }
const modesData = enumToValuesAndLabels<ModesEnum>(ModesEnum) export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated);
export const modes = modesData.values
export const modeLabels = modesData.labels
const walkGaitsData = enumToValuesAndLabels<WalkGaits>(WalkGaits) export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0]);
export const walkGaits = walkGaitsData.values
export const walkGaitLabels = walkGaitsData.labels export const kinematicData = writable([0, 0, 0, 0, 1, 0]);
export const input: Writable<ControllerInput> = writable({
left: { x: 0, y: 0 },
right: { x: 0, y: 0 },
height: 50,
speed: 50,
s1: 50
});
+27
View File
@@ -0,0 +1,27 @@
import { readable } from 'svelte/store';
export const heading = readable(0, (set) => {
const updateHeading = (e: any) => {
let alpha;
if (e.webkitCompassHeading) alpha = e.webkitCompassHeading;
else if (e.alpha) alpha = e.alpha;
else {
let q = e.target.quaternion;
alpha =
Math.atan2(2 * q[0] * q[1] + 2 * q[2] * q[3], 1 - 2 * q[1] * q[1] - 2 * q[2] * q[2]) *
(180 / Math.PI);
if (alpha < 0) alpha += 360;
}
set(alpha);
};
if ('AbsoluteOrientationSensor' in window) {
var sensor = new window.AbsoluteOrientationSensor({ frequency: 60 }) as any;
sensor.addEventListener('reading', updateHeading);
sensor.start();
} else if (window.DeviceMotionEvent) window.addEventListener('deviceorientation', updateHeading);
return () => {
if ('AbsoluteOrientationSensor' in window) sensor.removeEventListener('reading', updateHeading);
window.addEventListener('deviceorientation', updateHeading);
};
});
+25 -10
View File
@@ -1,12 +1,27 @@
import { AnglesData } from '$lib/platform_shared/message' import { writable, type Writable } from 'svelte/store';
import { writable, type Writable } from 'svelte/store' import { type angles } from '$lib/types/models';
export const servoAnglesOut: Writable<AnglesData> = writable( export const servoAnglesOut: Writable<number[]> = writable([
AnglesData.create({ angles: [0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90] }) 0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
) ]);
export const servoAngles: Writable<AnglesData> = writable( export const servoAngles: Writable<number[]> = writable([
AnglesData.create({ angles: [0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90] }) 0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
) ]);
export const logs = writable([] as string[]);
export const mpu = writable({ heading: 0 });
export const sonar = writable([0, 0]);
export const distances = writable({});
export const mpu = writable({ heading: 0 }) export interface socketDataCollection {
export const sonar = writable([0, 0]) angles: Writable<angles>;
logs: Writable<string[]>;
mpu: Writable<unknown>;
distances: Writable<unknown>;
}
export const socketData = {
angles: servoAngles,
logs,
mpu,
distances
};
+103 -292
View File
@@ -1,311 +1,122 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
import {
Message,
CorrelationRequest,
CorrelationResponse,
protoMetadata,
type MessageFns
} from '$lib/platform_shared/message'
import * as Messages from '$lib/platform_shared/message'
import { protoMetadata as filesystemProtoMetadata } from '$lib/platform_shared/filesystem'
export const MESSAGE_TYPE_TO_KEY = new Map<MessageFns<unknown>, string>() const socketEvents = ['open', 'close', 'error', 'message', 'unresponsive'] as const;
export const MESSAGE_TYPE_TO_TAG = new Map<MessageFns<unknown>, number>() type SocketEvent = (typeof socketEvents)[number];
export const MESSAGE_KEY_TO_TAG = new Map<string, number>()
export const MESSAGE_TAG_TO_KEY = new Map<number, string>()
type CorrelationRequestData = Omit<CorrelationRequest, 'correlationId'>
type PendingRequest = {
resolve: (response: CorrelationResponse) => void
reject: (error: Error) => void
timeoutId: ReturnType<typeof setTimeout>
}
// Combine references from both message.proto and filesystem.proto
const combinedReferences: Record<string, MessageFns<unknown>> = {
...protoMetadata.references,
...filesystemProtoMetadata.references
}
const MessageType = protoMetadata.fileDescriptor.messageType?.find(
(msg: { name: string }) => msg.name === 'Message'
)
if (MessageType?.field) {
for (const field of MessageType.field) {
if (field.typeName) {
const messageFns = combinedReferences[field.typeName]
if (messageFns && field.jsonName && field.number) {
MESSAGE_TYPE_TO_KEY.set(messageFns, field.jsonName)
MESSAGE_TYPE_TO_TAG.set(messageFns, field.number)
MESSAGE_KEY_TO_TAG.set(field.jsonName, field.number)
MESSAGE_TAG_TO_KEY.set(field.number, field.jsonName)
}
}
}
}
function getNameFromMessageType<T>(event_type: MessageFns<T>): string {
const event = MESSAGE_TYPE_TO_KEY.get(event_type as MessageFns<unknown>)
if (!event) {
throw new Error(
"Event type not found in 'Message'. The MessageFns you passed doesn't correspond to any Message field."
)
}
return event
}
function getTagFromMessageType<T>(event_type: MessageFns<T>): number {
const fieldNumber = MESSAGE_TYPE_TO_TAG.get(event_type as MessageFns<unknown>)
if (fieldNumber === undefined) {
throw new Error(
"Tag not found in 'Message'. The MessageFns you passed doesn't correspond to any Message field."
)
}
return fieldNumber
}
type SocketEvent = 'open' | 'close' | 'error' | 'message' | 'unresponsive'
type TaggedMessage = { tag: number; msg: Message }
export const decodeMessage = (data: ArrayBuffer): TaggedMessage => {
const decoded = Message.decode(new Uint8Array(data))
const values = Object.entries(decoded).filter(([, value]) => value !== undefined)
if (values.length != 1) {
throw new Error('Message included either 0 or more than 1 data point')
}
const fieldName = values[0][0]
const tag = MESSAGE_KEY_TO_TAG.get(fieldName)
if (tag === undefined) {
throw new Error(`Tag not found for field: ${fieldName}`)
}
return { tag: tag, msg: decoded }
}
export const encodeMessage = (data: Message): Uint8Array<ArrayBuffer> => {
const encoded = Message.encode(data).finish()
return encoded
}
function createWebSocket() { function createWebSocket() {
const message_listeners = new Map<number, Set<(data?: unknown) => void>>() let listeners = new Map<string, Set<(data?: unknown) => void>>();
const event_listeners = new Map<string, Set<(data?: unknown) => void>>() const { subscribe, set } = writable(false);
const pending_requests = new Map<number, PendingRequest>() const reconnectTimeoutTime = 5000;
const queued_requests = new Map< let unresponsiveTimeoutId: number;
string, let reconnectTimeoutId: number;
{ let ws: WebSocket;
data: CorrelationRequestData let socketUrl: string | URL;
resolve: (r: CorrelationResponse) => void
reject: (e: Error) => void
}
>()
const { subscribe, set } = writable(false)
const reconnectTimeoutTime = 500000
const requestTimeoutTime = 30000
let correlationIdCounter = 0
let unresponsiveTimeoutId: ReturnType<typeof setTimeout>
let reconnectTimeoutId: ReturnType<typeof setTimeout>
let ws: WebSocket
let socketUrl: string | URL
function getRequestKey(data: CorrelationRequestData): string { function init(url: string | URL) {
return ( socketUrl = url;
Object.keys(data).find(k => data[k as keyof CorrelationRequestData] !== undefined) ?? connect();
'unknown' }
)
}
function init(url: string | URL) { function disconnect(reason: SocketEvent, event?: Event) {
socketUrl = url ws.close();
connect() set(false);
} clearTimeout(unresponsiveTimeoutId);
clearTimeout(reconnectTimeoutId);
listeners.get(reason)?.forEach((listener) => listener(event));
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime);
}
function disconnect(reason: SocketEvent, event?: Event) { function connect() {
ws.close() ws = new WebSocket(socketUrl);
set(false) ws.onopen = (ev) => {
clearTimeout(unresponsiveTimeoutId) set(true);
clearTimeout(reconnectTimeoutId) clearTimeout(reconnectTimeoutId);
event_listeners.get(reason)?.forEach(listener => listener(event)) listeners.get('open')?.forEach((listener) => listener(ev));
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime) for (const event of listeners.keys()) {
} if (socketEvents.includes(event as SocketEvent)) continue;
subscribeToEvent(event);
}
};
ws.onmessage = (message) => {
resetUnresponsiveCheck();
let data = message.data;
if (data instanceof ArrayBuffer) {
listeners.get('binary')?.forEach((listener) => listener(data));
return;
}
data = data.substring(1);
function connect() { if (!data) return;
ws = new WebSocket(socketUrl)
ws.binaryType = 'arraybuffer'
ws.onopen = ev => {
ping()
set(true)
clearTimeout(reconnectTimeoutId)
resubscribeAll()
flushQueuedRequests()
event_listeners.get('open')?.forEach(listener => listener(ev))
}
ws.onmessage = frame => {
resetUnresponsiveCheck()
for (const [correlationId, pending] of pending_requests) { let event = data.substring(data.indexOf('/') + 1, data.indexOf('['));
clearTimeout(pending.timeoutId) let payload = data.substring(data.indexOf('[') + 1, data.lastIndexOf(']'));
pending.timeoutId = setTimeout(() => {
pending_requests.delete(correlationId)
pending.reject(new Error(`Request timeout (id: ${correlationId})`))
}, requestTimeoutTime)
}
const { tag, msg } = decodeMessage(frame.data) try {
if (msg.correlationResponse) { payload = JSON.parse(payload);
const pending = pending_requests.get(msg.correlationResponse.correlationId) } catch (error) {}
if (pending) { if (event) listeners.get(event)?.forEach((listener) => listener(payload));
clearTimeout(pending.timeoutId) };
pending_requests.delete(msg.correlationResponse.correlationId) ws.onerror = (ev) => disconnect('error', ev);
pending.resolve(msg.correlationResponse) ws.onclose = (ev) => disconnect('close', ev);
} }
return
}
if (tag) {
const key = MESSAGE_TAG_TO_KEY.get(tag)!
message_listeners
.get(tag)
?.forEach(listener => listener(msg[key as keyof typeof msg]))
}
}
ws.onerror = ev => disconnect('error', ev)
ws.onclose = ev => disconnect('close', ev)
}
function unsubscribe<MT>(event_type: MessageFns<MT>, listener: (data: MT) => void) { function unsubscribe(event: string, listener?: (data: any) => void) {
const tag = getTagFromMessageType(event_type) let eventListeners = listeners.get(event);
const message_listeners_totag = message_listeners.get(tag) if (!eventListeners) return;
if (!message_listeners_totag) return
message_listeners_totag?.delete(listener as (data?: unknown) => void) if (!eventListeners.size) {
if (message_listeners_totag.size == 0) { unsubscribeToEvent(event);
unsubscribeToMessageFromServer(event_type) }
} if (listener) {
} eventListeners?.delete(listener);
} else {
listeners.delete(event);
}
}
function unsubscribeEvent(event_type: SocketEvent, listener: (data: unknown) => void) { function resetUnresponsiveCheck() {
const message_listeners_totag = event_listeners.get(event_type) clearTimeout(unresponsiveTimeoutId);
if (!message_listeners_totag) return unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime);
}
message_listeners_totag?.delete(listener) function sendEvent(event: string, data: unknown) {
} if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(`2/${event}[${JSON.stringify(data)}]`);
}
function resetUnresponsiveCheck() { function unsubscribeToEvent(event: string) {
clearTimeout(unresponsiveTimeoutId) if (!ws || ws.readyState !== WebSocket.OPEN) return;
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime) ws.send('1/' + event);
} }
function emit<T>(event: MessageFns<T>, data: T) { function subscribeToEvent(event: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!ws || ws.readyState !== WebSocket.OPEN) return;
const type = getNameFromMessageType(event) ws.send('0/' + event);
const wsm = Message.create() as Record<string, unknown> }
wsm[type] = data
send(wsm as Message)
}
function unsubscribeToMessageFromServer<T>(event_type: MessageFns<T>) { return {
if (!ws || ws.readyState !== WebSocket.OPEN) return subscribe,
const unsub_msg = Messages.UnsubscribeNotification.create({ sendEvent,
tag: getTagFromMessageType(event_type) init,
}) on: <T>(event: string, listener: (data: T) => void): (() => void) => {
send(Message.create({ unsubNotif: unsub_msg })) 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: any) => void);
function subscribeToEvent<T>(event_type: MessageFns<T>) { return () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return unsubscribe(event, listener);
const sub_msg = Messages.SubscribeNotification.create({ };
tag: getTagFromMessageType(event_type) },
}) off: (event: string, listener?: (data: any) => void) => {
send(Message.create({ subNotif: sub_msg })) unsubscribe(event, listener);
} }
};
function resubscribeAll() {
for (const tag of message_listeners.keys()) {
const sub_msg = Messages.SubscribeNotification.create({ tag })
send(Message.create({ subNotif: sub_msg }))
}
}
function send(data: Message) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const encoded = encodeMessage(data)
ws.send(encoded)
}
function ping() {
send(Message.create({ pingmsg: {} }))
}
function request(
data: CorrelationRequestData,
resolve: (r: CorrelationResponse) => void,
reject: (e: Error) => void
) {
const correlationId = ++correlationIdCounter
const timeoutId = setTimeout(() => {
pending_requests.delete(correlationId)
reject(new Error(`Request timeout (id: ${correlationId})`))
}, requestTimeoutTime)
pending_requests.set(correlationId, { resolve, reject, timeoutId })
const request = CorrelationRequest.create({ correlationId, ...data })
send(Message.create({ correlationRequest: request }))
}
function flushQueuedRequests() {
for (const [, { data, resolve, reject }] of queued_requests) {
request(data, resolve, reject)
}
queued_requests.clear()
}
return {
subscribe,
emit,
init,
on: <MT>(event_type: MessageFns<MT>, listener: (data: MT) => void): (() => void) => {
const tag = getTagFromMessageType(event_type)
let message_listeners_totag = message_listeners.get(tag)
if (!message_listeners_totag) {
message_listeners_totag = new Set()
message_listeners.set(tag, message_listeners_totag)
subscribeToEvent(event_type)
}
message_listeners_totag.add(listener as (data: unknown) => void)
return () => {
unsubscribe(event_type, listener)
}
},
onEvent: (event_type: SocketEvent, listener: (data: unknown) => void): (() => void) => {
let listeners = event_listeners.get(event_type)
if (!listeners) {
listeners = new Set()
event_listeners.set(event_type, listeners)
}
listeners.add(listener)
return () => {
unsubscribeEvent(event_type, listener)
}
},
request: (data: CorrelationRequestData): Promise<CorrelationResponse> => {
return new Promise((resolve, reject) => {
if (ws && ws.readyState === WebSocket.OPEN) {
request(data, resolve, reject)
} else {
const key = getRequestKey(data)
const existing = queued_requests.get(key)
if (existing) {
existing.reject(new Error('Request superseded by newer request'))
}
queued_requests.set(key, { data, resolve, reject })
}
})
}
}
} }
export const socket = createWebSocket() export const socket = createWebSocket();
+25 -23
View File
@@ -1,33 +1,35 @@
import { DownloadOTAData, RSSIData } from '$lib/platform_shared/message' import type { DownloadOTA } from '$lib/types/models';
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
type telemetry_data_type = { let telemetry_data = {
rssi: RSSIData rssi: {
download_ota: DownloadOTAData rssi: 0
} },
const telemetry_data: telemetry_data_type = { download_ota: {
rssi: RSSIData.create(), status: 'none',
download_ota: DownloadOTAData.create() progress: 0,
} // Note: perhaps init these as null instead of an undefined create() error: ''
}
};
function createTelemetry() { function createTelemetry() {
const { subscribe, update } = writable(telemetry_data) const { subscribe, set, update } = writable(telemetry_data);
return { return {
subscribe, subscribe,
setRSSI: (data: RSSIData) => { setRSSI: (data: number) => {
update(telemetry_data => { update(telemetry_data => ({
telemetry_data.rssi = data ...telemetry_data,
return telemetry_data rssi: { rssi: data }
}) }));
}, },
setDownloadOTA: (data: DownloadOTAData) => { setDownloadOTA: (data: DownloadOTA) => {
update(telemetry_data => { update(telemetry_data => ({
telemetry_data.download_ota = data ...telemetry_data,
return telemetry_data download_ota: { status: data.status, progress: data.progress, error: data.error }
}) }));
} }
} };
} }
export const telemetry = createTelemetry() export const telemetry = createTelemetry();
+15 -15
View File
@@ -1,17 +1,17 @@
declare module 'three/src/math/MathUtils' { declare module 'three/src/math/MathUtils' {
export function generateUUID(): string export function generateUUID(): string;
export function clamp(value: number, min: number, max: number): number export function clamp(value: number, min: number, max: number): number;
export function euclideanModulo(n: number, m: number): number export function euclideanModulo(n: number, m: number): number;
export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number;
export function lerp(x: number, y: number, t: number): number export function lerp(x: number, y: number, t: number): number;
export function smoothstep(x: number, min: number, max: number): number export function smoothstep(x: number, min: number, max: number): number;
export function smootherstep(x: number, min: number, max: number): number export function smootherstep(x: number, min: number, max: number): number;
export function randInt(low: number, high: number): number export function randInt(low: number, high: number): number;
export function randFloat(low: number, high: number): number export function randFloat(low: number, high: number): number;
export function randFloatSpread(range: number): number export function randFloatSpread(range: number): number;
export function degToRad(degrees: number): number export function degToRad(degrees: number): number;
export function radToDeg(radians: number): number export function radToDeg(radians: number): number;
export function isPowerOfTwo(value: number): boolean export function isPowerOfTwo(value: number): boolean;
export function ceilPowerOfTwo(value: number): number export function ceilPowerOfTwo(value: number): number;
export function floorPowerOfTwo(value: number): number export function floorPowerOfTwo(value: number): number;
} }
+167 -72
View File
@@ -1,83 +1,178 @@
export enum MessageTopic { export type vector = { x: number; y: number };
imu = 'imu',
imuCalibrate = 'imuCalibrate',
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 {
left: vector;
right: vector;
height: number;
speed: number;
s1: number;
}
export type GithubRelease = { export type GithubRelease = {
message: string message: string;
tag_name: string tag_name: string;
assets: Array<{ assets: Array<{
name: string name: string;
browser_download_url: string browser_download_url: string;
}> }>;
} };
export type angles = number[] | Int16Array;
export type WifiStatus = {
status: number;
local_ip: string;
mac_address: string;
rssi: number;
ssid: string;
bssid: string;
channel: number;
subnet_mask: string;
gateway_ip: string;
dns_ip_1: string;
dns_ip_2?: string;
};
export type WifiSettings = {
hostname: string;
priority_RSSI: boolean;
wifi_networks: KnownNetworkItem[];
};
export type NetworkList = {
networks: NetworkItem[];
};
export type KnownNetworkItem = {
ssid: string;
password: string;
static_ip_config: boolean;
local_ip?: string;
subnet_mask?: string;
gateway_ip?: string;
dns_ip_1?: string;
dns_ip_2?: string;
};
export type NetworkItem = {
rssi: number;
ssid: string;
bssid: string;
channel: number;
encryption_type: number;
};
export type ApStatus = {
status: number;
ip_address: string;
mac_address: string;
station_num: number;
};
export type ApSettings = {
provision_mode: number;
ssid: string;
password: string;
channel: number;
ssid_hidden: boolean;
max_clients: number;
local_ip: string;
gateway_ip: string;
subnet_mask: string;
};
export type DownloadOTA = {
status: string;
progress: number;
error: string;
};
export type Analytics = {
max_alloc_heap: number;
psram_size: number;
free_psram: number;
free_heap: number;
total_heap: number;
min_free_heap: number;
core_temp: number;
fs_total: number;
fs_used: number;
uptime: number;
cpu0_usage: number;
cpu1_usage: number;
cpu_usage: number;
};
export type Rssi = { export type Rssi = {
rssi: number rssi: number;
ssid: string ssid: string;
};
export type StaticSystemInformation = {
esp_platform: string;
firmware_version: string;
cpu_freq_mhz: number;
cpu_type: string;
cpu_rev: number;
cpu_cores: number;
sketch_size: number;
free_sketch_space: number;
sdk_version: string;
arduino_version: string;
flash_chip_size: number;
flash_chip_speed: number;
cpu_reset_reason: string;
};
export type SystemInformation = Analytics & StaticSystemInformation;
export type IMU = {
x: number;
y: number;
z: number;
heading: number;
altitude: number;
bmp_temp: number;
pressure: number;
};
export interface I2CDevice {
address: number;
part_number: string;
name: string;
}
export type CameraSettings = {
framesize: number;
quality: number;
brightness: number;
contrast: number;
saturation: number;
sharpness: number;
denoise: number;
special_effect: number;
wb_mode: number;
vflip: boolean;
hmirror: boolean;
};
export type File = number;
export interface Directory {
[key: string]: File | Directory;
} }
export type Servo = { export type Servo = {
name: string name: string;
channel: number channel: number;
inverted: boolean inverted: boolean;
angle: number angle: number;
center_angle: number center_angle: number;
} };
export type ServoConfiguration = { export type ServoConfiguration = {
is_active: boolean is_active: boolean;
servo_pwm_frequency: number servo_pwm_frequency: number;
servo_oscillator_frequency: number servo_oscillator_frequency: number;
servos: Servo[] servos: Servo[];
} };
export interface Result {
success: boolean
error?: string
}
export interface DataResult extends Result {
data?: Uint8Array
}
export interface FileInfo {
name: string
size: number
}
export interface DirectoryInfo {
name: string
}
export interface ListResult extends Result {
files: FileInfo[]
directories: DirectoryInfo[]
}
export interface TransferProgress {
transferId: number
bytesTransferred: number
totalBytes: number
chunksCompleted: number
totalChunks: number
percentage: number
}
export type ProgressCallback = (progress: TransferProgress) => void
+11 -11
View File
@@ -1,14 +1,14 @@
declare module 'uzip' { declare module 'uzip' {
interface UZIP { interface UZIP {
parse(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array> parse(data: Uint8Array | ArrayBuffer): any;
compress(data: Record<string, Uint8Array>): Uint8Array | ArrayBuffer compress(data: any): Uint8Array | ArrayBuffer;
compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer;
decompress(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array> decompress(data: Uint8Array | ArrayBuffer): any;
decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer;
encode(data: Record<string, Uint8Array>): Uint8Array | ArrayBuffer encode(data: any): Uint8Array | ArrayBuffer;
decode(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array> decode(data: Uint8Array | ArrayBuffer): any;
} }
const uzip: UZIP const uzip: UZIP;
export default uzip export default uzip;
} }
+13 -13
View File
@@ -1,15 +1,15 @@
export class Throttler { export class throttler {
private _throttlePause: boolean private _throttlePause: boolean;
constructor() { constructor() {
this._throttlePause = false this._throttlePause = false;
} }
throttle = (callback: () => void, time: number) => { throttle = (callback: Function, time: number) => {
if (this._throttlePause) return if (this._throttlePause) return;
this._throttlePause = true this._throttlePause = true;
setTimeout(() => { setTimeout(() => {
callback() callback();
this._throttlePause = false this._throttlePause = false;
}, time) }, time);
} };
} }
+3 -5
View File
@@ -1,6 +1,4 @@
export const daisyColor = (name: string, opacity: number = 100) => { export const daisyColor = (name: string, opacity: number = 100) => {
const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim() const color = getComputedStyle(document.documentElement).getPropertyValue(name);
if (opacity >= 100) return color return `oklch(${color} / ${opacity}%)`;
const alpha = Math.min(Math.max(opacity, 0), 100) / 100 };
return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`
}
+9 -9
View File
@@ -1,9 +1,9 @@
export * from './result' export * from './result';
export * from './string-utilities' export * from './string-utilities';
export * from './svelte-utilities' export * from './svelte-utilities';
export * from './math-utilities' export * from './math-utilities';
export * from './buffer-utilities' export * from './buffer-utilities';
export * from './model-utilities' export * from './model-utilities';
export * from './string-utilities' export * from './position-utilities';
export * from './color-utilities' export * from './string-utilities';
export * from './ip-utilities' export * from './color-utilities';
-23
View File
@@ -1,23 +0,0 @@
export function ipToUint32(ip: string): number {
const parts = ip.split('.')
if (parts.length !== 4) return 0
return (
(parseInt(parts[0], 10) |
(parseInt(parts[1], 10) << 8) |
(parseInt(parts[2], 10) << 16) |
(parseInt(parts[3], 10) << 24)) >>>
0
)
}
export function uint32ToIp(ip: number): string {
return [ip & 0xff, (ip >>> 8) & 0xff, (ip >>> 16) & 0xff, (ip >>> 24) & 0xff].join('.')
}
export function isValidIpString(ip: string | undefined): boolean {
if (!ip) return false
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
return regexExp.test(ip)
}
+13 -13
View File
@@ -1,18 +1,18 @@
export const toUint8 = (number: number, min: number, max: number) => { export const toUint8 = (number: number, min: number, max: number) => {
number = Math.max(min, Math.min(max, number)) number = Math.max(min, Math.min(max, number));
const scaled = ((number - min) / (max - min)) * 255 let scaled = ((number - min) / (max - min)) * 255;
return Math.round(scaled) & 0xff return Math.round(scaled) & 0xff;
} };
export const toInt8 = (number: number, min: number, max: number) => { export const toInt8 = (number: number, min: number, max: number) => {
number = Math.max(min, Math.min(max, number)) number = Math.max(min, Math.min(max, number));
const scaled = ((number - min) / (max - min)) * 255 - 128 let scaled = ((number - min) / (max - min)) * 255 - 128;
return Math.max(-128, Math.min(127, Math.round(scaled))) | 0 return Math.max(-128, Math.min(127, Math.round(scaled))) | 0;
} };
export const fromInt8 = (int8: number, min: number, max: number) => { export const fromInt8 = (int8: number, min: number, max: number) => {
int8 = Math.max(-128, Math.min(127, int8)) int8 = Math.max(-128, Math.min(127, int8));
const scaled = (int8 + 128) / 255 const scaled = (int8 + 128) / 255;
const number = scaled * (max - min) + min const number = scaled * (max - min) + min;
return number return number;
} };
+76 -83
View File
@@ -1,96 +1,89 @@
import { Color, 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 { currentVariant, jointNames, model } from '$lib/stores' import { jointNames, model } from '$lib/stores';
import uzip from 'uzip' import uzip from 'uzip';
import { fileService } from '$lib/services' import { fileService } from '$lib/services';
import { get } from 'svelte/store'
import { resolve } from '$app/paths'
let model_xml: XMLDocument let model_xml: XMLDocument;
export const populateModelCache = async () => { export const populateModelCache = async () => {
await cacheModelFiles() await cacheModelFiles();
const modelRes = await loadModel(get(currentVariant).model) const modelRes = await loadModelAsync('/spot_micro.urdf.xacro');
if (modelRes.isOk()) { if (modelRes.isOk()) {
const [urdf, JOINT_NAME] = modelRes.inner const [urdf, JOINT_NAME] = modelRes.inner;
jointNames.set(JOINT_NAME) jointNames.set(JOINT_NAME);
model.set(urdf) model.set(urdf);
} else { } else {
console.error(modelRes.inner, { exception: modelRes.exception }) console.error(modelRes.inner, { exception: modelRes.exception });
} }
} };
export const cacheModelFiles = async () => { export const cacheModelFiles = async () => {
const data = await fetch(get(currentVariant).stl) let data = await fetch('/stl.zip');
const files = uzip.parse(await data.arrayBuffer()) var files = uzip.parse(await data.arrayBuffer());
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) { for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const normalizedPath = path.startsWith('/') ? path : '/' + path const url = new URL(path, window.location.href);
const resolvedUrl = `${resolve('/')}${normalizedPath}` fileService.saveFile(url.toString(), data);
fileService?.saveFile(resolvedUrl, data) }
fileService?.saveFile(normalizedPath, data) };
}
}
export const loadModel = async (url: string): Promise<Result<[URDFRobot, string[]], string>> => { export const loadModelAsync = async (
const urdfLoader = new URDFLoader() url: string
): Promise<Result<[URDFRobot, string[]], string>> => {
return new Promise((resolve, reject) => {
const xacroLoader = new XacroLoader();
const urdfLoader = new URDFLoader();
urdfLoader.workingPath = LoaderUtils.extractUrlBase(url);
let xml = xacroLoader.load(
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]);
if (typeof xml === 'string') { resolve(Result.ok([model, joints]));
xml = new window.DOMParser().parseFromString(xml, 'text/xml') } catch (error) {
} resolve(Result.err('Failed to load model', error));
}
},
(error) => resolve(Result.err('Failed to load model', error))
);
});
};
return new Promise(resolve => { export const toeWorldPositions = (robot: URDFRobot) => {
model_xml = xml const toe_positions: Vector3[] = [];
try { robot.traverse((child) => {
const model = urdfLoader.parse(xml) if (child.name.includes('toe') && !child.name.includes('_link')) {
setupRobot(model) const worldPosition = new Vector3();
const joints = Object.entries(model.joints) child.getWorldPosition(worldPosition);
.filter(joint => joint[1].jointType !== 'fixed') toe_positions.push(worldPosition);
.map(joint => joint[0]) }
});
return toe_positions;
};
resolve(Result.ok([model, joints])) export const footColor = () => {
} catch (error) { const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element;
resolve(Result.err('Failed to load model', error)) const colorAttrStr = colorElem.getAttribute('rgba') as string;
} const colorStr = colorAttrStr
}) .split(' ')
} .slice(0, 3)
.map((val) => Math.floor(+val * 255))
.join(', ');
const loadXacro = async (url: string): Promise<XMLDocument> => return new Color(`rgb(${colorStr})`);
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})`)
}
@@ -0,0 +1,84 @@
class SunCalculator {
calculateSunElevation(lat: number = 55, lon: number = 12) {
const now = new Date();
const JD = this.getJulianDate(now);
const solarDec = this.getSolarDeclination(JD);
const solarTime = this.getSolarTime(now, lon);
const hourAngle = (solarTime - 12) * 15;
const elevation = Math.asin(
Math.sin(this.degToRad(lat)) * Math.sin(solarDec) +
Math.cos(this.degToRad(lat)) * Math.cos(solarDec) * Math.cos(this.degToRad(hourAngle))
);
return this.radToDeg(elevation);
}
getJulianDate(date: Date) {
const Y = date.getUTCFullYear();
const M = date.getUTCMonth() + 1;
const D =
date.getUTCDate() +
date.getUTCHours() / 24 +
date.getUTCMinutes() / 1440 +
date.getUTCSeconds() / 86400;
const A = Math.floor((14 - M) / 12);
const Y1 = Y + 4800 - A;
const M1 = M + 12 * A - 3;
return (
D +
Math.floor((153 * M1 + 2) / 5) +
365 * Y1 +
Math.floor(Y1 / 4) -
Math.floor(Y1 / 100) +
Math.floor(Y1 / 400) -
32045
);
}
getSolarDeclination(JulianDate: number) {
const n = JulianDate - 2451545;
const L = (280.46 + 0.9856474 * n) % 360;
const g = this.degToRad((357.528 + 0.9856003 * n) % 360);
const lambda = this.degToRad(L + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g));
return Math.asin(Math.sin(lambda) * Math.sin(this.degToRad(23.44)));
}
getSolarTime(date: Date, lon: number) {
const EoT = this.getEquationOfTime(date);
const offset = date.getTimezoneOffset() / 60;
const standardMeridian = Math.round(lon / 15) * 15;
const solarTime =
date.getUTCHours() +
(date.getUTCMinutes() + (4 * (standardMeridian - lon) + EoT)) / 60 -
offset;
return (solarTime + 24) % 24;
}
getEquationOfTime(date: Date) {
const JD = this.getJulianDate(date);
const n = JD - 2451545;
const g = this.degToRad((357.528 + 0.9856003 * n) % 360);
const q = this.degToRad((280.46 + 0.9856474 * n) % 360);
return (
4 *
this.radToDeg(
0.000075 +
0.001868 * Math.cos(q) -
0.032077 * Math.sin(g) -
0.014615 * Math.cos(2 * q) -
0.040849 * Math.sin(2 * g)
)
);
}
degToRad(deg: number) {
return deg * (Math.PI / 180);
}
radToDeg(rad: number) {
return rad * (180 / Math.PI);
}
}
export const sunCalculator = new SunCalculator();
+34 -34
View File
@@ -1,42 +1,42 @@
export class Err<T, U> { export class Err<T, U> {
#inner: T #inner: T;
#exception?: U #exception?: U;
constructor(inner: T, exception?: U) { constructor(inner: T, exception?: U) {
this.#inner = inner this.#inner = inner;
this.#exception = exception this.#exception = exception;
} }
get inner(): T { get inner(): T {
return this.#inner return this.#inner;
} }
get exception(): U | undefined { get exception(): U | undefined {
return this.#exception return this.#exception;
} }
/** /**
* Type guard for `Ok` * Type guard for `Ok`
* @returns `true` if `Ok`; `false` if `Err` * @returns `true` if `Ok`; `false` if `Err`
*/ */
isOk(): false { isOk(): false {
return false return false;
} }
/** /**
* Type guard for `Err` * Type guard for `Err`
* @returns `true` if `Err`; `false` if `Ok` * @returns `true` if `Err`; `false` if `Ok`
*/ */
isErr(): this is Err<T, U> { isErr(): this is Err<T, U> {
return true return true;
} }
/** /**
* Create an `Err` * Create an `Err`
* @param inner * @param inner
* @returns `Err(inner)` * @returns `Err(inner)`
*/ */
static new<E, F>(inner: E, exception: F): Err<E, F> { static new<E, F>(inner: E, exception: F): Err<E, F> {
return new Err<E, F>(inner, exception) return new Err<E, F>(inner, exception);
} }
} }
+3 -3
View File
@@ -1,3 +1,3 @@
export * from './err' export * from './err';
export * from './ok' export * from './ok';
export * from './result' export * from './result';
+36 -36
View File
@@ -1,44 +1,44 @@
export class Ok<T> { export class Ok<T> {
#inner: T #inner: T;
constructor(inner: T) { constructor(inner: T) {
this.#inner = inner this.#inner = inner;
} }
get inner(): T { get inner(): T {
return this.#inner return this.#inner;
} }
/** /**
* Type guard for `Ok` * Type guard for `Ok`
* @returns `true` if `Ok`; `false` if `Err` * @returns `true` if `Ok`; `false` if `Err`
*/ */
isOk(): this is Ok<T> { isOk(): this is Ok<T> {
return true return true;
} }
/** /**
* Type guard for `Err` * Type guard for `Err`
* @returns `true` if `Err`; `false` if `Ok` * @returns `true` if `Err`; `false` if `Ok`
*/ */
isErr(): false { isErr(): false {
return false return false;
} }
/** /**
* Create an `Ok` * Create an `Ok`
* @param inner * @param inner
* @returns `Ok(inner)` * @returns `Ok(inner)`
*/ */
static new<T>(inner: T): Ok<T> { static new<T>(inner: T): Ok<T> {
return new Ok<T>(inner) return new Ok<T>(inner);
} }
/** /**
* Create an empty `Ok` * Create an empty `Ok`
* @returns `Ok(void)` * @returns `Ok(void)`
*/ */
static void(): Ok<void> { static void(): Ok<void> {
return new Ok(undefined) return new Ok(undefined);
} }
} }
+16 -16
View File
@@ -1,20 +1,20 @@
import { Err } from './err' import { Err } from './err';
import { Ok } from './ok' import { Ok } from './ok';
export type Result<T = unknown, E = unknown, F = unknown> = Ok<T> | Err<E, F> export type Result<T = unknown, E = unknown, F = unknown> = Ok<T> | Err<E, F>;
export const Result = { export namespace Result {
/** /**
* @returns `Ok<T>` * @returns `Ok<T>`
*/ */
ok<T = unknown>(value: T) { export function ok<T = unknown>(value: T) {
return Ok.new(value) return Ok.new(value);
}, }
/** /**
* @returns `Err<E, F>` * @returns `Err<E, F>`
*/ */
err<E = unknown, F = unknown>(error: E, exception?: F) { export function err<E = unknown, F = unknown>(error: E, exception?: F) {
return Err.new(error, exception) return Err.new(error, exception);
} }
} }
+28 -39
View File
@@ -1,47 +1,36 @@
export const humanFileSize = (size: number): string => { export const humanFileSize = (size: number): string => {
const units = ['B', 'kB', 'MB', 'GB', 'TB'] const units = ['B', 'kB', 'MB', 'GB', 'TB'];
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)) var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i] return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i];
} };
export const capitalize = (str: string): string => { export const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
} };
export const convertSeconds = (seconds: number) => { export const convertSeconds = (seconds: number) => {
// Calculate the number of seconds, minutes, hours, and days // Calculate the number of seconds, minutes, hours, and days
let minutes = Math.floor(seconds / 60) let minutes = Math.floor(seconds / 60);
let hours = Math.floor(minutes / 60) let hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24) let 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
}
+11 -11
View File
@@ -1,16 +1,16 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
import { browser } from '$app/environment' import { browser } from '$app/environment';
export const persistentStore = <T>(key: string, initialValue: T) => { export const persistentStore = <T>(key: string, initialValue: T) => {
const savedValue = browser ? localStorage.getItem(key) : null const savedValue = browser ? localStorage.getItem(key) : null;
const data: T = savedValue !== null ? JSON.parse(savedValue) : initialValue const data: T = savedValue !== null ? JSON.parse(savedValue) : initialValue;
const store = writable<T>() const store = writable<T>();
store.subscribe(value => { store.subscribe(value => {
if (browser) localStorage.setItem(key, JSON.stringify(value)) if (browser) localStorage.setItem(key, JSON.stringify(value));
}) });
store.set(data) store.set(data);
return store return store;
} };

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