2 Commits

Author SHA1 Message Date
Rune Harlyk 04fecf33f8 🪴 Adds webserial lidar support 2024-08-04 13:53:53 +02:00
Rune Harlyk acf4efde4c 🗺️ Adds lidar visualization 2024-08-04 00:02:17 +02:00
634 changed files with 69827 additions and 287623 deletions
-61
View File
@@ -1,61 +0,0 @@
name: Deploy GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./app
env:
BASE_PATH: /SpotMicroESP32-Leika
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: "./app/pnpm-lock.yaml"
- run: pnpm install
- run: pnpm run build
- name: Setup Pages
uses: actions/configure-pages@v4
with:
static_site_generator: "sveltekit"
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: app/build/
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+10 -9
View File
@@ -2,19 +2,20 @@ 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
@@ -27,8 +28,8 @@ jobs:
- uses: actions/setup-python@v4 - uses: actions/setup-python@v4
with: with:
python-version: "3.x" python-version: '3.x'
- run: pip install -r 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
@@ -36,7 +37,7 @@ jobs:
run: pio run run: pio run
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: build-artifacts name: build-artifacts
path: esp32/build/firmware path: esp32/build/firmware
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
version: 9 version: 8
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
-4
View File
@@ -2,7 +2,3 @@
.vscode/c_cpp_properties.json .vscode/c_cpp_properties.json
.vscode/launch.json .vscode/launch.json
.vscode/ipch .vscode/ipch
__pycache__/
*.py[cod]
*$py.class
.pio
+4 -4
View File
@@ -2,10 +2,10 @@
// See http://go.microsoft.com/fwlink/?LinkId=827846 // See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format // for the documentation about the extensions.json format
"recommendations": [ "recommendations": [
"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"
-3
View File
@@ -1,3 +0,0 @@
PUBLIC_VITE_USE_HOST_NAME=true
PUBLIC_USE_JSON=true
PUBLIC_USE_MSGPACK=true
+29 -29
View File
@@ -1,31 +1,31 @@
/** @type { import("eslint").Linter.Config } */ /** @type { import("eslint").Linter.Config } */
module.exports = { module.exports = {
root: true, root: true,
extends: [ extends: [
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended', 'plugin:svelte/recommended',
'prettier' 'prettier'
], ],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'], plugins: ['@typescript-eslint'],
parserOptions: { parserOptions: {
sourceType: 'module', sourceType: 'module',
ecmaVersion: 2020, ecmaVersion: 2020,
extraFileExtensions: ['.svelte'] extraFileExtensions: ['.svelte']
}, },
env: { env: {
browser: true, browser: true,
es2017: true, es2017: true,
node: true node: true
}, },
overrides: [ overrides: [
{ {
files: ['*.svelte'], files: ['*.svelte'],
parser: 'svelte-eslint-parser', parser: 'svelte-eslint-parser',
parserOptions: { parserOptions: {
parser: '@typescript-eslint/parser' 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-*
+6 -10
View File
@@ -1,12 +1,8 @@
{ {
"useTabs": false, "useTabs": true,
"singleQuote": true, "singleQuote": true,
"tabWidth": 4, "trailingComma": "none",
"trailingComma": "none", "printWidth": 100,
"arrowParens": "avoid", "plugins": ["prettier-plugin-svelte"],
"experimentalTernaries": true, "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
"printWidth": 100,
"semi": false,
"plugins": ["prettier-plugin-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`.
-8
View File
@@ -1,8 +0,0 @@
declare module 'app-env' {
interface ENV {
VITE_USE_HOST_NAME: boolean
}
const appEnv: ENV
export default appEnv
}
+59 -63
View File
@@ -1,65 +1,61 @@
{ {
"name": "spot_micro_controller", "name": "spot_micro_controller",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
"build": "vite build", "build": "vite build",
"build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build", "preview": "vite preview",
"preview": "vite preview", "test": "npm run test:integration && npm 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" },
}, "devDependencies": {
"devDependencies": { "@iconify-json/mdi": "^1.1.64",
"@iconify-json/mdi": "^1.2.3", "@iconify-json/tabler": "^1.1.109",
"@iconify-json/tabler": "^1.2.23", "@playwright/test": "^1.28.1",
"@playwright/test": "^1.56.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.46.4", "@sveltejs/kit": "^2.5.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^9.6.1", "@types/eslint": "^8.56.0",
"@types/three": "^0.180.0", "@types/three": "^0.162.0",
"@typescript-eslint/eslint-plugin": "^8.46.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^8.46.0", "@typescript-eslint/parser": "^7.0.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.19",
"eslint": "^9.37.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^3.12.4", "eslint-plugin-svelte": "^2.35.1",
"jsdom": "^27.0.0", "jsdom": "^24.0.0",
"prettier": "^3.6.2", "postcss": "^8.4.38",
"prettier-plugin-svelte": "^3.4.0", "prettier": "^3.1.1",
"svelte": "^5.39.11", "prettier-plugin-svelte": "^3.1.2",
"svelte-check": "^4.3.3", "svelte": "^4.2.7",
"svelte-focus-trap": "^1.2.0", "svelte-check": "^3.6.0",
"tailwindcss": "^4.1.14", "svelte-focus-trap": "^1.2.0",
"tslib": "^2.8.1", "tailwindcss": "^3.4.3",
"typescript": "^5.9.3", "tslib": "^2.6.1",
"unplugin-icons": "^22.4.2", "typescript": "^5.1.6",
"vite": "^7.1.9", "unplugin-icons": "^0.18.5",
"vitest": "^3.2.4" "vite": "^5.0.3",
}, "vitest": "^1.2.0"
"type": "module", },
"dependencies": { "type": "module",
"@msgpack/msgpack": "^3.1.2", "dependencies": {
"@niku/vite-env-caster": "^1.1.2", "chart.js": "^4.4.2",
"@sveltejs/adapter-auto": "^6.1.1", "compare-versions": "^6.1.0",
"@tailwindcss/vite": "^4.1.14", "daisyui": "^4.10.2",
"chart.js": "^4.5.0", "jwt-decode": "^4.0.0",
"compare-versions": "^6.1.1", "nipplejs": "^0.10.1",
"cross-env": "^10.1.0", "svelte-dnd-list": "^0.1.8",
"daisyui": "^5.2.0", "svelte-modals": "^1.3.0",
"nipplejs": "^0.10.2", "three": "^0.162.0",
"svelte-dnd-list": "^0.1.8", "urdf-loader": "^0.12.1",
"svelte-modals": "^2.0.1", "uzip": "^0.20201231.0",
"three": "^0.180.0", "xacro-parser": "^0.3.9"
"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;
+2303 -2213
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
export default {
plugins: [tailwindcss(), autoprefixer()]
};
+5 -35
View File
@@ -1,39 +1,9 @@
@import 'tailwindcss'; @tailwind base;
@plugin "daisyui"; @tailwind components;
@tailwind utilities;
@plugin "daisyui" { #nipple_0_0, #nipple_1_1 {
themes: z-index: 10!important;
light --default,
dark --prefersdark;
}
@plugin "daisyui/theme" {
name: 'light';
default: true;
--color-primary: #00bfff;
--color-secondary: #3c00ff;
--base-content: white;
}
@plugin "daisyui/theme" {
name: 'dark';
prefersdark: true;
--color-primary: #00bfff;
--color-secondary: #3c00ff;
--base-content: oklch(0.3 0.012 256);
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
#nipple_0_0,
#nipple_1_1 {
z-index: 10 !important;
} }
#three-gui-panel { #three-gui-panel {
+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 {};
+9 -14
View File
@@ -1,17 +1,12 @@
<!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" />
name="viewport" %sveltekit.head%
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" </head>
/> <body data-sveltekit-preload-data="hover">
<meta name="apple-mobile-web-app-capable" content="yes" /> <div style="display: contents">%sveltekit.body%</div>
<meta name="mobile-web-app-capable" content="yes" /> </body>
%sveltekit.head%
</head>
<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);
});
});
+4
View File
@@ -0,0 +1,4 @@
export function daisyColor(name: string, opacity: number = 100) {
const color = getComputedStyle(document.documentElement).getPropertyValue(name);
return `oklch(${color} / ${opacity}%)`;
}
+56 -62
View File
@@ -1,79 +1,73 @@
import { get } from 'svelte/store' import { user } from '$lib/stores/user';
import { Err, Ok, type Result } from './utilities' import { get } from 'svelte/store';
import { apiLocation } from './stores' import { Err, Ok, type Result } from './utilities';
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);
}, }
put<TResponse>(endpoint: string, data?: unknown) { export function put<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'PUT', data) return sendRequest<TResponse>(endpoint, 'PUT', data);
}, }
remove<TResponse>(endpoint: string) { export function remove<TResponse>(endpoint: string) {
return sendRequest<TResponse>(endpoint, 'DELETE') return sendRequest<TResponse>(endpoint, 'DELETE');
} }
} }
async function sendRequest<TResponse>( async function sendRequest<TResponse>(
endpoint: string, endpoint: string,
method: string, method: string,
data?: unknown, data?: unknown,
params?: RequestInit params?: RequestInit
): Promise<Result<TResponse, Error>> { ): Promise<Result<TResponse, Error>> {
endpoint = resolveUrl(endpoint) const user_token = get(user).bearer_token;
const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined;
const request = { const request = {
...params, ...params,
method, method,
body, body,
headers: { headers: {
...params?.headers, ...params?.headers,
Authorization: 'Basic', Authorization: user_token ? 'Bearer ' + user_token : 'Basic',
'Content-Type': '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 { } 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 {
if (url.startsWith('http') || !get(apiLocation)) return url
const protocol = window.location.protocol
return `${protocol}//${get(apiLocation)}${url.startsWith('/') ? '' : '/'}${url}`
} }
export class ApiError extends Error { export class ApiError extends Error {
constructor(public readonly response: Response) { constructor(public readonly response: Response) {
super(`${response.status}`) super(`${response.status}`);
} }
} }
@@ -0,0 +1,27 @@
<script lang="ts">
import Battery0 from '~icons/tabler/battery';
import Battery25 from '~icons/tabler/battery-1';
import Battery50 from '~icons/tabler/battery-2';
import Battery75 from '~icons/tabler/battery-3';
import Battery100 from '~icons/tabler/battery-4';
import BatteryCharging from '~icons/tabler/battery-charging-2';
export let current = 0;
export let voltage = 0;
</script>
<div class="tooltip tooltip-left z-10" data-tip="{voltage}V {Math.floor(current*10)/10} mA">
{#if voltage == 0}
<BatteryCharging class="{$$props.class || ''} -rotate-90 animate-pulse" />
{:else if voltage > 8.2}
<Battery100 class="{$$props.class || ''} -rotate-90" />
{:else if voltage > 8}
<Battery75 class="{$$props.class || ''} -rotate-90" />
{:else if voltage > 7.8}
<Battery50 class="{$$props.class || ''} -rotate-90" />
{:else if voltage > 7.6}
<Battery25 class="{$$props.class || ''} -rotate-90" />
{:else}
<Battery0 class="{$$props.class || ''} text-error -rotate-90 animate-pulse" />
{/if}
</div>
+37 -38
View File
@@ -1,44 +1,43 @@
<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/tabler/chevron-down';
import { createEventDispatcher } from 'svelte';
function openCollapsible() { const dispatch = createEventDispatcher();
open = !open
if (open) {
opened()
} else {
closed()
}
}
let { icon, title, children, open, opened, closed, class: klass } = $props() function openCollapsible() {
open = !open;
if (open) {
dispatch('opened');
} else {
dispatch('closed');
}
}
export let open = false;
</script> </script>
<div class="{klass} relative grid w-full max-w-2xl self-center overflow-hidden"> <div class="{$$props.class || ''} relative grid w-full max-w-2xl self-center overflow-hidden">
<div <div class="min-h-16 flex w-full items-center justify-between space-x-3 p-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">
> <slot name="icon" />
<span class="inline-flex items-baseline"> <slot name="title" />
{@render icon?.()} </span>
{@render title?.()} <button class="btn btn-circle btn-ghost btn-sm" on:click={() => openCollapsible()}>
</span> <Down
<button class="btn btn-circle btn-ghost btn-sm" onclick={() => openCollapsible()}> class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open
<Down ? 'rotate-180'
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {( : ''}"
open />
) ? </button>
'rotate-180' </div>
: ''}" {#if open}
/> <div
</button> class="flex flex-col gap-2 p-4 pt-0"
</div> transition:slide|local={{ duration: 300, easing: cubicOut }}
{#if open} >
<div <slot />
class="flex flex-col gap-2 p-4 pt-0" </div>
transition:slide|local={{ duration: 300, easing: cubicOut }} {/if}
>
{@render children?.()}
</div>
{/if}
</div> </div>
+45 -41
View File
@@ -1,48 +1,52 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap' import { closeModal } from 'svelte-modals';
import { fly } from 'svelte/transition' import { focusTrap } from 'svelte-focus-trap';
import { Cancel, Check } from '$lib/components/icons' import { fly } from 'svelte/transition';
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals' import Cancel from '~icons/tabler/x';
import Check from '~icons/tabler/check';
let { // provided by <Modals />
isOpen, export let isOpen: boolean;
title,
message, export let title: string;
onConfirm, export let message: string;
labels = { export let onConfirm: any;
cancel: { label: 'Cancel', icon: Cancel }, export let labels = {
confirm: { label: 'OK', icon: Check } cancel: { label: 'Cancel', icon: Cancel },
} confirm: { label: 'OK', icon: Check }
}: ModalProps = $props() };
</script> </script>
{#if isOpen} {#if isOpen}
{@const SvelteComponent = labels?.confirm.icon} <div
<div role="dialog"
role="dialog" class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" transition:fly={{ y: 50 }}
transition:fly={{ y: 50 }} on:introstart
use:exitBeforeEnter on:outroend
use:focusTrap use:focusTrap
> >
<div <div
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg" class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
> >
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2> <h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<div class="divider my-2"></div> <div class="divider my-2" />
<p class="text-base-content mb-1 text-start">{message}</p> <p class="text-base-content mb-1 text-start">{message}</p>
<div class="divider my-2"></div> <div class="divider my-2" />
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button class="btn btn-primary inline-flex items-center" on:click={closeModal}
class="btn btn-error inline-flex items-center" ><svelte:component this={labels.cancel.icon} class="mr-2 h-5 w-5" /><span
onclick={() => modals.close()} >{labels?.cancel.label}</span
> ></button
<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}> class="btn btn-warning text-warning-content inline-flex items-center"
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span> on:click={onConfirm}
</button> ><svelte:component this={labels?.confirm.icon} class="mr-2 h-5 w-5" /><span
</div> >{labels?.confirm.label}</span
</div> ></button
</div> >
</div>
</div>
</div>
{/if} {/if}
@@ -1,101 +1,92 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap' import { closeAllModals, onBeforeClose } from 'svelte-modals';
import { fly } from 'svelte/transition' import { focusTrap } from 'svelte-focus-trap';
import { telemetry } from '$lib/stores/telemetry' import { fly } from 'svelte/transition';
import { Cancel } from './icons' import { telemetry } from '$lib/stores/telemetry';
import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals' import Cancel from '~icons/tabler/x';
// provided by <Modals /> // provided by <Modals />
interface Props { export let isOpen: boolean;
isOpen: boolean
}
let { isOpen }: Props = $props() let updating = true;
let updating = $state(true) let progress = 0;
$: if ($telemetry.download_ota.status == 'progress') {
progress = $telemetry.download_ota.progress;
}
let progress = $state(0) $: if ($telemetry.download_ota.status == 'error') {
$effect(() => { updating = false;
if ($telemetry.download_ota.status == 'progress') { }
progress = $telemetry.download_ota.progress
}
})
$effect(() => { let message = 'Preparing ...';
if ($telemetry.download_ota.status == 'error') { let timerId: number;
updating = false
}
})
let message = $state('Preparing ...') $: if ($telemetry.download_ota.status == 'progress') {
message = 'Downloading ...';
} else if ($telemetry.download_ota.status == 'error') {
message = $telemetry.download_ota.error;
} else if ($telemetry.download_ota.status == 'finished') {
message = 'Restarting ...';
progress = 0;
// Reload page after 5 sec
timerId = setTimeout(() => {
closeAllModals();
location.reload();
}, 5000);
}
$effect(() => { onBeforeClose(() => {
if ($telemetry.download_ota.status == 'progress') { if (updating) {
message = 'Downloading ...' // prevents modal from closing
} else if ($telemetry.download_ota.status == 'error') { return false;
message = $telemetry.download_ota.error } else {
} else if ($telemetry.download_ota.status == 'finished') { $telemetry.download_ota.status = 'idle';
message = 'Restarting ...' $telemetry.download_ota.error = '';
progress = 0 $telemetry.download_ota.progress = 0;
// Reload page after 5 sec return true;
setTimeout(() => { }
modals.closeAll() });
location.reload()
}, 5000)
}
})
onBeforeClose(() => {
if (updating) {
// prevents modal from closing
return false
} else {
$telemetry.download_ota.status = 'idle'
$telemetry.download_ota.error = ''
$telemetry.download_ota.progress = 0
return true
}
})
</script> </script>
{#if isOpen} {#if isOpen}
<div <div
role="dialog" role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }} transition:fly={{ y: 50 }}
use:exitBeforeEnter on:introstart
use:focusTrap on:outroend
> use:focusTrap
<div >
class="bg-base-100 rounded-box pointer-events-auto flex max-h-full min-w-fit max-w-md flex-col justify-between p-4 shadow-lg" <div
> class="bg-base-100 rounded-box pointer-events-auto flex max-h-full min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
<h2 class="text-base-content text-start text-2xl font-bold">Updating Firmware</h2> >
<div class="divider my-2"></div> <h2 class="text-base-content text-start text-2xl font-bold">Updating Firmware</h2>
<div class="overflow-y-auto"> <div class="divider my-2" />
<div class="bg-base-100 flex flex-col items-center justify-center p-6"> <div class="overflow-y-auto">
{#if $telemetry.download_ota.status == 'progress'} <div class="bg-base-100 flex flex-col items-center justify-center p-6">
<progress class="progress progress-primary w-56" value={progress} max="100" {#if $telemetry.download_ota.status == 'progress'}
></progress> <progress class="progress progress-primary w-56" value={progress} max="100" />
{:else} {:else}
<progress class="progress progress-primary w-56"></progress> <progress class="progress progress-primary w-56" />
{/if} {/if}
<p class="mt-8 text-2xl">{message}</p> <p class="mt-8 text-2xl">{message}</p>
</div> </div>
</div> </div>
<div class="divider my-2"></div> <div class="divider my-2" />
<div class="flex flex-wrap justify-end gap-2"> <div class="flex flex-wrap justify-end gap-2">
<div class="grow"></div> <div class="flex-grow" />
<button <button
class="btn btn-warning text-warning-content inline-flex flex-none items-center" class="btn btn-warning text-warning-content inline-flex flex-none items-center"
disabled={updating} disabled={updating}
onclick={() => { on:click={() => {
modals.closeAll() closeAllModals();
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
> >
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
+36 -37
View File
@@ -1,43 +1,42 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap' import { closeModal } from 'svelte-modals';
import { fly } from 'svelte/transition' import { focusTrap } from 'svelte-focus-trap';
import { Check } from './icons' import { fly } from 'svelte/transition';
import { exitBeforeEnter, type ModalProps } from 'svelte-modals' import Check from '~icons/tabler/check';
let { // provided by <Modals />
isOpen, export let isOpen: boolean;
title,
message, export let title: string;
onDismiss, export let message: string;
labels = { export let onDismiss: any;
dismiss: { label: 'Dismiss', icon: Check } export let dismiss = { label: 'Dismiss', icon: Check };
}
}: ModalProps = $props()
</script> </script>
{#if isOpen} {#if isOpen}
<div <div
role="dialog" role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }} transition:fly={{ y: 50 }}
use:exitBeforeEnter on:introstart
use:focusTrap on:outroend
> use:focusTrap
<div >
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg" <div
> class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2> >
<div class="divider my-2"></div> <h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<p class="text-base-content mb-1 text-start">{message}</p> <div class="divider my-2" />
<div class="divider my-2"></div> <p class="text-base-content mb-1 text-start">{message}</p>
<div class="flex justify-end gap-2"> <div class="divider my-2" />
<button <div class="flex justify-end gap-2">
class="btn btn-warning text-warning-content inline-flex items-center" <button
onclick={onDismiss} class="btn btn-warning text-warning-content inline-flex items-center"
> on:click={onDismiss}
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span> ><svelte:component this={dismiss.icon} class="mr-2 h-5 w-5" /><span>{dismiss.label}</span
</button> ></button
</div> >
</div> </div>
</div> </div>
</div>
{/if} {/if}
@@ -0,0 +1,60 @@
<script lang="ts">
let show = false;
$: type = show ? 'text' : 'password';
export let value = '';
export let id = '';
function handleInput(e: any) {
value = e.target.value;
}
</script>
<div class="relative">
<input {type} class="input input-bordered w-full" {value} on:input={handleInput} {id} />
<div class="absolute inset-y-0 right-0 flex items-center pr-1">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}"
on:click={() => (show = false)}
width="40"
height="40"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
role="button"
tabindex="0"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path
d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"
/>
<path d="M3 3l18 18" />
</svg>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}"
on:click={() => (show = true)}
width="40"
height="40"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
role="button"
tabindex="0"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</svg>
</div>
</div>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { onMount } from "svelte";
import { lidar, type LidarPoint } from '$lib/stores/lidar'
function getIntersection(angle:number, size:number):number {
const sinAngle = Math.sin(angle);
const cosAngle = Math.cos(angle);
let x, y;
if (Math.abs(cosAngle) > Math.abs(sinAngle)) {
x = size * Math.sign(cosAngle);
y = x * sinAngle / cosAngle;
} else {
y = size * Math.sign(sinAngle);
x = y * cosAngle / sinAngle;
}
return Math.sqrt(x**2 + y**2);
}
let canvas:HTMLCanvasElement
let ctx
const DEG2RAD = 0.017453292519943;
onMount(() => {
ctx = canvas.getContext("2d")
resize()
lidar.subscribe(lidar => {
draw(lidar.points)
})
})
const draw = (points:LidarPoint[]) => {
if(!points) return
const centerX = canvas.width / 2
const centerY = canvas.height / 2
const scale = 0.01//Math.max(centerX, centerY) / Math.max(...points.map((point) => point.distance))
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < points.length; i++){
const angle = points[i].angle
const distance = points[i].distance
const quality = points[i].quality
const endX = centerX + (distance * scale) * Math.cos(angle * DEG2RAD);
const endY = centerY - (distance * scale) * Math.sin(angle * DEG2RAD);
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.lineTo(endX, endY);
ctx.strokeStyle = "grey"
ctx.stroke();
ctx.beginPath();
ctx.arc(endX, endY, 3, 0, Math.PI * 2);
ctx.fillStyle = "#1bfc06"
ctx.fill();
}
}
const resize = () => {
const parentElement = canvas.parentElement;
if (parentElement) {
canvas.width = parentElement.clientWidth
canvas.height = parentElement.clientHeight
}
}
</script>
<svelte:window on:resize={resize}></svelte:window>
<canvas bind:this={canvas} class="w-full h-full"></canvas>
@@ -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>
@@ -0,0 +1,40 @@
<script lang="ts">
import WiFi from '~icons/tabler/wifi';
import WiFi0 from '~icons/tabler/wifi-0';
import WiFi1 from '~icons/tabler/wifi-1';
import WiFi2 from '~icons/tabler/wifi-2';
import WifiOff from '~icons/tabler/wifi-off';
export let showDBm = true;
export let rssi_dbm = 0;
</script>
<div class="indicator">
<div class="tooltip tooltip-left" data-tip={rssi_dbm + " dBm"}>
{#if showDBm}
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
{rssi_dbm} dBm
</span>
{/if}
{#if rssi_dbm >= -55}
<WiFi class={$$props.class || ''} />
{:else if rssi_dbm >= -75}
<div class="{$$props.class || ''} relative">
<WiFi class="absolute inset-0 h-full w-full opacity-30" />
<WiFi2 class="absolute inset-0 h-full w-full" />
</div>
{:else if rssi_dbm >= -85}
<div class="{$$props.class || ''} relative">
<WiFi class="absolute inset-0 h-full w-full opacity-30" />
<WiFi1 class="absolute inset-0 h-full w-full" />
</div>
{:else if rssi_dbm === 0}
<WifiOff class={$$props.class || ''} />
{:else}
<div class="{$$props.class || ''} relative">
<WiFi class="absolute inset-0 h-full w-full opacity-30" />
<WiFi0 class="absolute inset-0 h-full w-full" />
</div>
{/if}
</div>
</div>
+50 -70
View File
@@ -1,76 +1,56 @@
<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/tabler/chevron-down';
interface Props { export let open = true;
open?: boolean export let collapsible = true;
collapsible?: boolean
icon?: import('svelte').Snippet
title?: import('svelte').Snippet
children?: import('svelte').Snippet
right?: import('svelte').Snippet
}
let {
open = $bindable(true),
collapsible = true,
icon,
title,
children,
right
}: 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?.()} <slot name="icon" />
{@render title?.()} <slot name="title" />
</span> </span>
<button <button
class="btn btn-circle btn-ghost btn-sm" class="btn btn-circle btn-ghost btn-sm"
onclick={() => { on:click={() => {
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 }} <slot />
> </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">
> <slot name="icon" />
<span class="inline-flex items-baseline"> <slot name="title" />
{@render icon?.()} </span>
{@render title?.()} </div>
</span> <div class="flex flex-col gap-2 p-4 pt-0">
{@render right?.()} <slot />
</div> </div>
<div class="flex flex-col gap-2 p-4 pt-0"> </div>
{@render children?.()}
</div>
</div>
{/if} {/if}
+3 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Loader } from './icons' import Loader from '~icons/tabler/loader-2';
</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 { ComponentType } from 'svelte'
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
const {
icon,
title,
description = '',
variant = 'primary',
class: klass = '',
children = null
} = $props<{
icon?: ComponentType
title: string
description?: string | number
variant?: Variant
class?: string
children?: () => ComponentType
}>()
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>
+14 -10
View File
@@ -1,17 +1,21 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte' import { user } from '$lib/stores/user';
import { apiLocation } from '$lib/stores' import { onDestroy } from 'svelte';
let source = $state(`${$apiLocation}/api/camera/stream`) const ws_token = `?access_token=${$user.bearer_token}`
onDestroy(() => (source = '#')) let source = "/api/camera/stream"+ ws_token;
onDestroy(() => {
source = '#';
});
</script> </script>
<div class="w-full h-full"> <div class="w-full h-full">
<img <img
src={source} src={source}
class="absolute object-cover blur-3xl w-full h-full -z-10" class="absolute object-cover blur-3xl w-full h-full -z-10"
alt="Live stream is down" alt="Live stream is down"
/> />
<img src={source} class="object-contain w-full h-full" alt="Live stream is down" /> <img src={source} class="object-contain w-full h-full" alt="Live stream is down" />
</div> </div>
+31 -31
View File
@@ -1,37 +1,37 @@
<script> <script>
import { flip } from 'svelte/animate' import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import { error, info, success, warning } from './icons' import error from '~icons/tabler/circle-x';
import success from '~icons/tabler/circle-check';
import warning from '~icons/tabler/alert-triangle';
import info from '~icons/tabler/info-circle';
/** @type {{theme?: any, icon?: any}} */ export let theme = {
let { error: 'alert-error',
theme = { success: 'alert-success',
error: 'alert-error', warning: 'alert-warning',
success: 'alert-success', info: 'alert-info'
warning: 'alert-warning', };
info: 'alert-info'
}, export let icon = {
icon = { error: error,
error: error, success: success,
success: success, warning: warning,
warning: warning, info: info
info: info };
}
} = $props()
</script> </script>
<div class="toast toast-end mr-4"> <div class="toast toast-end mr-4">
{#each $notifications as notification (notification.id)} {#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]} <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 }} >
> <svelte:component this={icon[notification.type]} class="h-6 w-6 flex-shrink-0" />
<SvelteComponent class="h-6 w-6 shrink-0" /> <span>{notification.message}</span>
<span>{notification.message}</span> </div>
</div> {/each}
{/each}
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import MdiHamburgerMenu from '~icons/mdi/hamburger-menu';
</script>
<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">
<a href="/">
<svelte:component this={MdiHamburgerMenu} class="h-8 w-8"/>
</a>
</div>
</div>
<style>
.topbar {
height: 50px;
}
</style>
@@ -0,0 +1,106 @@
<script lang="ts">
import { page } from '$app/stores';
import { openModal, closeAllModals } from 'svelte-modals';
import { user } from '$lib/stores/user';
import { notifications } from '$lib/components/toasts/notifications';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Firmware from '~icons/tabler/refresh-alert';
import Cancel from '~icons/tabler/x';
import CloudDown from '~icons/tabler/cloud-download';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import { compareVersions } from 'compare-versions';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { GithubRelease } from '$lib/models';
export let update = false;
let firmwareVersion: string;
let firmwareDownloadLink: string;
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}
const result = await api.get<GithubRelease>(`https://api.github.com/repos/${$page.data.github}/releases/latest`, {headers})
if (result.inner.message === "404" || result.inner.message == "Not Found") {
console.warn('Error: Could not find releases in the repository');
return
}
if (result.isErr()) {
console.error('Error:', result.inner);
return
}
const results = result.inner;
update = false;
firmwareVersion = '';
if (compareVersions(results.tag_name, $page.data.features.firmware_version) === 1) {
// iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin
if (
results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($page.data.features.firmware_built_target)
) {
update = true;
firmwareVersion = results.tag_name;
firmwareDownloadLink = results.assets[i].browser_download_url;
notifications.info('Firmware update available.', 5000);
}
}
}
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url });
if (result.isErr()){
console.error('Error:', result.inner);
return
}
}
onMount(async () => {
if ($page.data.features.download_firmware && (!$page.data.features.security || $user.admin)) {
await getGithubAPI();
const interval = setInterval(
async () => {
await getGithubAPI();
},
60 * 60 * 1000
); // once per hour
}
});
function confirmGithubUpdate(url: string) {
openModal(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url);
openModal(GithubUpdateDialog, {
onConfirm: () => closeAllModals()
});
}
});
}
</script>
{#if update}
<button
class="btn btn-square btn-ghost h-9 w-9"
on:click={() => confirmGithubUpdate(firmwareDownloadLink)}
>
<span
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
>{firmwareVersion}</span
>
<Firmware class="h-7 w-7" />
</button>
{/if}
+155 -187
View File
@@ -1,75 +1,47 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte';
import { import { BufferGeometry, Line, LineBasicMaterial, Mesh, MeshBasicMaterial, Object3D, SphereGeometry, Vector3, type NormalBufferAttributes, type Object3DEventMap } from 'three';
Mesh, import uzip from 'uzip';
MeshBasicMaterial, import { ModesEnum, kinematicData, mode, model, outControllerData, servoAnglesOut, servoAngles, mpu, jointNames } from '$lib/stores';
type Object3D, import { footColor, isEmbeddedApp, throttler, toeWorldPositions } from '$lib/utilities';
SphereGeometry, import { fileService } from '$lib/services';
Vector3, import SceneBuilder from '$lib/sceneBuilder';
type Object3DEventMap, import { lerp, degToRad } from 'three/src/math/MathUtils';
Color import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
} from 'three' import Kinematic, { type body_state_t } from '$lib/kinematic';
import { import {EightPhaseWalkState, FourPhaseWalkState, IdleState, RestState, StandState} from '$lib/gait'
ModesEnum, import { radToDeg } from 'three/src/math/MathUtils.js';
kinematicData, import type { URDFRobot } from 'urdf-loader';
mode, import { get } from 'svelte/store';
model,
outControllerData,
servoAnglesOut,
servoAngles,
mpu,
jointNames,
currentKinematic,
walkGait,
walkGaitToMode
} from '$lib/stores'
import { populateModelCache, throttler, getToeWorldPositions } from '$lib/utilities'
import SceneBuilder from '$lib/sceneBuilder'
import { lerp, degToRad } from 'three/src/math/MathUtils'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import { type body_state_t } from '$lib/kinematic'
import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait'
import { radToDeg } from 'three/src/math/MathUtils.js'
import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store'
interface Props { export let sky = true
defaultColor?: string | null export let orbit = false
orbit?: boolean export let panel = true
panel?: boolean export let debug = false
debug?: boolean export let ground = true
ground?: boolean
}
let { let sceneManager = new SceneBuilder();
defaultColor = '#0091ff', let canvas: HTMLCanvasElement
orbit = false,
panel = true,
debug = false,
ground = true
}: Props = $props()
let sceneManager = $state(new SceneBuilder()) let currentModelAngles: number[] = new Array(12).fill(0);
let canvas: HTMLCanvasElement let modelTargetAngles: number[] = new Array(12).fill(0)
let currentModelAngles: number[] = new Array(12).fill(0)
let modelTargetAngles: number[] = new Array(12).fill(0)
let gui_panel: GUI let gui_panel: GUI
let Throttler = new throttler() let Throttler = new throttler()
let target: Object3D<Object3DEventMap> 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 target_position = {x: 0, z: 0, yaw: 0}
let kinematic = get(currentKinematic) let kinematic = new Kinematic()
let planners = { let planners = {
[ModesEnum.Deactivated]: new IdleState(),
[ModesEnum.Idle]: new IdleState(), [ModesEnum.Idle]: new IdleState(),
[ModesEnum.Calibration]: new CalibrationState(),
[ModesEnum.Rest]: new RestState(), [ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(), [ModesEnum.Stand]: new StandState(),
[ModesEnum.Walk]: new BezierState() [ModesEnum.Crawl]: new EightPhaseWalkState(),
[ModesEnum.Walk]: new FourPhaseWalkState()
} }
let lastTick = performance.now() let lastTick = performance.now()
@@ -82,76 +54,67 @@
xm: 0, xm: 0,
ym: 0.5, ym: 0.5,
zm: 0, zm: 0,
feet: kinematic.getDefaultFeetPos(), feet: planners[ModesEnum.Idle].default_feet_pos
cumulative_x: 0,
cumulative_y: 0,
cumulative_z: 0,
cumulative_roll: 0,
cumulative_pitch: 0,
cumulative_yaw: 0
} }
let settings = { let settings = {
'Internal kinematic': true, 'Internal kinematic':false,
'Robot transform controls': false, 'Robot transform controls':false,
'Auto orient robot': true, 'Auto orient robot':true,
'Trace feet': debug, 'Trace feet':debug,
'Target position': false,
'Trace points': 30, 'Trace points': 30,
'Fix camera on robot': true, 'Fix camera on robot': true,
'Smooth motion': true, 'Smooth motion': true,
omega: 0, 'omega': 0,
phi: 0, 'phi': 0,
psi: 0, 'psi': 0,
xm: 0, 'xm': 0,
ym: 0.7, 'ym': 0.7,
zm: 0, 'zm': 0,
Background: defaultColor 'Background': "black"
} }
onMount(async () => { onMount(async () => {
await populateModelCache() await cacheModelFiles()
await createScene() await createScene();
if (!isEmbeddedApp && panel) createPanel();
servoAngles.subscribe(updateAnglesFromStore) servoAngles.subscribe(updateAnglesFromStore)
walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait))) });
if (panel) createPanel()
})
onDestroy(() => { onDestroy(() => {
canvas.remove() canvas.remove()
gui_panel?.destroy() gui_panel?.destroy()
}) });
const updateAnglesFromStore = (angles: number[]) => { const updateAnglesFromStore = (angles: number[]) => {
if (sceneManager.isDragging) return if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return if (settings['Internal kinematic']) return
modelTargetAngles = angles modelTargetAngles = angles;
} }
const createPanel = () => { const createPanel = () => {
gui_panel = new GUI({ width: 310 }) gui_panel = new GUI({width: 310});
gui_panel.close() gui_panel.close();
gui_panel.domElement.id = 'three-gui-panel' gui_panel.domElement.id = 'three-gui-panel';
const general = gui_panel.addFolder('General') const general = gui_panel.addFolder('General');
general.add(settings, 'Internal kinematic') general.add(settings, 'Internal kinematic')
general.add(settings, 'Robot transform controls') general.add(settings, 'Robot transform controls')
general.add(settings, 'Auto orient robot') general.add(settings, 'Auto orient robot')
const kinematic = gui_panel.addFolder('Kinematics') const kinematic = gui_panel.addFolder('Kinematics');
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
const visibility = gui_panel.addFolder('Visualization') const visibility = gui_panel.addFolder('Visualization');
visibility.add(settings, 'Trace feet') visibility.add(settings, 'Trace feet')
visibility.add(settings, 'Trace points', 1, 1000, 1) visibility.add(settings, 'Trace points', 1, 1000, 1)
visibility.add(settings, 'Target position')
visibility.add(settings, 'Smooth motion') visibility.add(settings, 'Smooth motion')
visibility.addColor(settings, 'Background').onChange(setSceneBackground).listen() visibility.addColor(settings, 'Background')
} }
const updateKinematicPosition = () => { const updateKinematicPosition = () => {
@@ -165,101 +128,109 @@
]) ])
} }
const setSceneBackground = (c: string | null) => (sceneManager.scene.background = new Color(c!)) const cacheModelFiles = async () => {
let data = await fetch('/stl.zip').then((data) => data.arrayBuffer());
const updateAngles = (name: string, angle: number) => { var files = uzip.parse(data);
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
Throttler.throttle(
() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))),
100
)
}
const createScene = async () => { for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
sceneManager const url = new URL(path, window.location.href);
.addRenderer({ antialias: true, canvas, alpha: true }) fileService.saveFile(url.toString(), data);
.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 }) const updateAngles = (name: string, angle: number) => {
.addFogExp2(0xcccccc, 0.015) modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI);
.addModel($model as URDFRobot) 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(8, 30, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 0.9 })
.addAmbientLight({ color: 0xffffff, intensity: 0.6 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addTransformControls(sceneManager.model) .addTransformControls(sceneManager.model)
.fillParent() .fillParent()
.addRenderCb(render) .addRenderCb(render)
.startRenderLoop() .startRenderLoop();
if (ground) sceneManager.addGroundPlane() if (ground) sceneManager
.addGroundPlane()
.addGridHelper({ size: 30, divisions: 25 })
const geometry = new SphereGeometry(0.1, 32, 16)
const material = new MeshBasicMaterial({ color: 0xffff00 }) const geometry = new SphereGeometry(0.1, 32, 16 );
target = new Mesh(geometry, material) const material = new MeshBasicMaterial( { color: 0xffff00 } );
sceneManager.scene.add(target) target = new Mesh(geometry, material);
if (debug) { if (debug) {
sceneManager.addDragControl(angles => { sceneManager.scene.add(target);
Object.entries(angles).forEach(([name, angle]) => { sceneManager.addDragControl(updateAngles)
updateAngles(name, angle)
})
})
} }
if (defaultColor) setSceneBackground(settings['Background'] || defaultColor) if (sky) sceneManager.addSky()
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry();
const material = new LineBasicMaterial({ color: footColor() });
const line = new Line(geometry, material);
trace_lines.push(geometry);
sceneManager.scene.add(line);
}
};
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
feet_trace = new Array(4).fill([])
return
}
trace_lines.forEach((line, i) => {
feet_trace[i].push(foot_positions[i])
feet_trace[i] = feet_trace[i].slice(-settings['Trace points'])
line.setFromPoints(feet_trace[i]);
})
} }
const calculate_kinematics = () => { const calculate_kinematics = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return if (sceneManager.isDragging || !settings['Internal kinematic']) return
const position: body_state_t = { const position:body_state_t = {
omega: settings.omega, omega: settings.omega,
phi: settings.phi, phi: settings.phi,
psi: settings.psi, psi: settings.psi,
xm: settings.xm, xm: settings.xm,
ym: settings.ym, ym: settings.ym,
zm: settings.zm, zm: settings.zm,
feet: body_state.feet, 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])) let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]));
modelTargetAngles = new_angles modelTargetAngles = new_angles;
} }
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => { const orient_robot = (robot: URDFRobot, toes:Vector3[]) => {
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y)) robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y));
const cumulativeYaw = body_state.cumulative_yaw robot.position.z = smooth(robot.position.z, -settings.xm, 0.1);
robot.position.x = smooth(robot.position.x, -settings.zm, 0.1);
const cosYaw = Math.cos(cumulativeYaw) robot.rotation.z = smooth(robot.rotation.z, degToRad(-settings.phi + $mpu.heading + 90), 0.1);
const sinYaw = Math.sin(cumulativeYaw) robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1);
const rotatedXm = settings.xm * cosYaw - settings.zm * sinYaw robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1);
const rotatedZm = settings.xm * sinYaw + settings.zm * cosYaw
robot.position.x = smooth(robot.position.x, -rotatedZm - body_state.cumulative_z * 1.2, 0.1)
robot.position.z = smooth(robot.position.z, -rotatedXm - body_state.cumulative_x * 1.2, 0.1)
const pitch = degToRad(settings.psi - 90) + body_state.cumulative_pitch
const roll = degToRad(settings.omega) + body_state.cumulative_roll
robot.rotation.z = smooth(
robot.rotation.z,
degToRad(-settings.phi + $mpu.heading + 90) + cumulativeYaw,
0.1
)
robot.rotation.y = smooth(robot.rotation.y, roll, 0.1)
robot.rotation.x = smooth(robot.rotation.x, pitch, 0.1)
} }
const update_camera = (robot: URDFRobot) => { const update_camera = (robot:URDFRobot) => {
if (!settings['Fix camera on robot']) return if (!settings['Fix camera on robot']) return
sceneManager.orbit.target = robot.position.clone() sceneManager.orbit.target = robot.position.clone()
} }
const smooth = (start: number, end: number, amount: number) => { const smooth = (start:number, end:number, amount:number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end return settings['Smooth motion'] ? lerp(start, end, amount) : end
} }
@@ -267,21 +238,21 @@
if (sceneManager.isDragging || !settings['Internal kinematic']) return if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(outControllerData) const controlData = get(outControllerData)
const data = { const data = {
lx: controlData[0], stop: controlData[0],
ly: controlData[1], lx: controlData[1],
rx: controlData[2], ly: controlData[2],
ry: controlData[3], rx: controlData[3],
h: controlData[4], ry: controlData[4],
s: controlData[5], h: controlData[5],
s1: controlData[6] s: controlData[6],
} };
body_state.ym = data.h body_state.ym = ((data.h + 127) * 0.75) / 100;
let planner = planners[get(mode)] let planner = planners[get(mode)]
const delta = performance.now() - lastTick const delta = performance.now() - lastTick
lastTick = performance.now() lastTick = performance.now()
body_state = planner.step(body_state, data, delta) body_state = planner.step(body_state, data, delta);
settings.omega = body_state.omega settings.omega = body_state.omega
settings.phi = body_state.phi settings.phi = body_state.phi
@@ -291,27 +262,27 @@
settings.zm = body_state.zm settings.zm = body_state.zm
} }
const update_robot_position = (robot: URDFRobot) => { const update_robot_position = (robot:URDFRobot) => {
if (!settings['Robot transform controls']) return if (!settings['Robot transform controls']) return
settings.omega = radToDeg(robot.rotation.y) settings.omega = radToDeg(robot.rotation.y)
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90 settings.phi = radToDeg(robot.rotation.z) + $mpu.heading -90
settings.psi = radToDeg(robot.rotation.x) + 90 settings.psi = radToDeg(robot.rotation.x) + 90
settings.xm = robot.position.z * 100 settings.xm = robot.position.z * 100
settings.zm = -robot.position.x * 100 settings.zm = -robot.position.x * 100
} }
const updateTargetPosition = () => { const updateTargetPosition = () => {
target.visible = settings['Target position']
target.position.x = smooth(target.position.x, target_position.x, 0.5) target.position.x = smooth(target.position.x, target_position.x, 0.5)
target.position.z = smooth(target.position.z, target_position.z, 0.5) target.position.z = smooth(target.position.z, target_position.z, 0.5)
} }
const render = () => { const render = () => {
const robot = sceneManager.model const robot = sceneManager.model;
if (!robot) return if (!robot) return;
const toes = getToeWorldPositions(robot) const toes = toeWorldPositions(robot)
renderTraceLines(toes)
update_camera(robot) update_camera(robot)
update_gait() update_gait()
calculate_kinematics() calculate_kinematics()
@@ -321,20 +292,17 @@
sceneManager.transformControl.showY = settings['Robot transform controls'] sceneManager.transformControl.showY = settings['Robot transform controls']
sceneManager.transformControl.showZ = settings['Robot transform controls'] sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) { for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth( currentModelAngles[i] = smooth((robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI), modelTargetAngles[i], 0.1);
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI), robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]));
modelTargetAngles[i],
0.1
)
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
} }
orient_robot(robot, toes) orient_robot(robot, toes)
updateTargetPosition() updateTargetPosition();
} };
</script> </script>
<svelte:window onresize={sceneManager.fillParent} /> <svelte:window on:resize={sceneManager.fillParent} />
<canvas bind:this={canvas}></canvas> <canvas bind:this={canvas}></canvas>
-96
View File
@@ -1,96 +0,0 @@
export { default as Connection } from '~icons/mdi/connection'
export { default as Users } from '~icons/mdi/users'
export { default as Settings } from '~icons/mdi/settings'
export { default as MdiController } from '~icons/mdi/controller'
export { default as Devices } from '~icons/mdi/devices'
export { default as Camera } from '~icons/mdi/camera-outline'
export { default as Rotate3d } from '~icons/mdi/rotate-3d'
export { default as MotorOutline } from '~icons/mdi/motor-outline'
export { default as Health } from '~icons/mdi/stethoscope'
export { default as Folder } from '~icons/mdi/folder-outline'
export { default as Update } from '~icons/mdi/reload'
export { default as Router } from '~icons/mdi/router'
export { default as AP } from '~icons/mdi/access-point'
export { default as Remote } from '~icons/mdi/network'
export { default as Copyright } from '~icons/mdi/copyright'
export { default as NTP } from '~icons/mdi/clock-check'
export { default as Metrics } from '~icons/mdi/report-bar'
export { default as MdiEyeOutline } from '~icons/mdi/eye-outline'
export { default as MdiEyeOffOutline } from '~icons/mdi/eye-off-outline'
export { default as Github } from '~icons/mdi/github'
export { default as Avatar } from '~icons/mdi/user-circle'
export { default as Logout } from '~icons/mdi/logout'
export { default as Record } from '~icons/mdi/radio-button-unchecked'
export { default as MdiFullscreen } from '~icons/mdi/fullscreen'
export { default as MdiFullscreenExit } from '~icons/mdi/fullscreen-exit'
export { default as WiFi } from '~icons/tabler/wifi'
export { default as WiFi0 } from '~icons/tabler/wifi-0'
export { default as WiFi1 } from '~icons/tabler/wifi-1'
export { default as WiFi2 } from '~icons/tabler/wifi-2'
export { default as WifiOff } from '~icons/tabler/wifi-off'
export { default as MdiWeatherSunny } from '~icons/mdi/weather-sunny'
export { default as MdiMoonAndStars } from '~icons/mdi/moon-and-stars'
export { default as Hamburger } from '~icons/mdi/hamburger-menu'
export { default as FileIcon } from '~icons/mdi/file'
export { default as FolderIcon } from '~icons/mdi/folder-outline'
export { default as FolderOpenOutline } from '~icons/mdi/folder-open-outline'
export { default as TrashIcon } from '~icons/mdi/trash'
export { default as RotateCcw } from '~icons/mdi/rotate-left'
export { default as RotateCw } from '~icons/mdi/rotate-right'
export { default as Down } from '~icons/tabler/chevron-down'
export { default as Cancel } from '~icons/tabler/x'
export { default as Check } from '~icons/tabler/check'
export { default as Login } from '~icons/tabler/login'
export { default as Loader } from '~icons/tabler/loader-2'
export { default as error } from '~icons/tabler/circle-x'
export { default as success } from '~icons/tabler/circle-check'
export { default as warning } from '~icons/tabler/alert-triangle'
export { default as info } from '~icons/tabler/info-circle'
export { default as Power } from '~icons/tabler/power'
export { default as MAC } from '~icons/tabler/dna-2'
export { default as Home } from '~icons/tabler/home'
export { default as SSID } from '~icons/tabler/router'
export { default as DNS } from '~icons/mdi/dns'
export { default as Gateway } from '~icons/tabler/torii'
export { default as Subnet } from '~icons/tabler/grid-dots'
export { default as Channel } from '~icons/tabler/antenna'
export { default as Scan } from '~icons/tabler/radar-2'
export { default as Add } from '~icons/tabler/circle-plus'
export { default as Edit } from '~icons/mdi/edit'
export { default as EditOff } from '~icons/mdi/edit-off'
export { default as Delete } from '~icons/tabler/trash'
export { default as Network } from '~icons/tabler/router'
export { default as Reload } from '~icons/tabler/reload'
export { default as Firmware } from '~icons/tabler/refresh-alert'
export { default as CloudDown } from '~icons/tabler/cloud-download'
export { default as Server } from '~icons/tabler/server'
export { default as Clock } from '~icons/tabler/clock'
export { default as UTC } from '~icons/tabler/clock-pin'
export { default as Stopwatch } from '~icons/tabler/24-hours'
export { default as CPU } from '~icons/tabler/cpu'
export { default as CPP } from '~icons/tabler/binary'
export { default as Sleep } from '~icons/tabler/zzz'
export { default as FactoryReset } from '~icons/tabler/refresh-dot'
export { default as Speed } from '~icons/tabler/activity'
export { default as Flash } from '~icons/tabler/device-sd-card'
export { default as Pyramid } from '~icons/tabler/pyramid'
export { default as Sketch } from '~icons/tabler/chart-pie'
export { default as Heap } from '~icons/tabler/box-model'
export { default as Temperature } from '~icons/tabler/temperature'
export { default as SDK } from '~icons/tabler/sdk'
export { default as Prerelease } from '~icons/tabler/test-pipe'
export { default as Error } from '~icons/tabler/circle-x'
export { default as OTA } from '~icons/tabler/file-upload'
export { default as Warning } from '~icons/tabler/alert-triangle'
export { default as AddUser } from '~icons/tabler/user-plus'
export { default as Admin } from '~icons/tabler/key'
export { default as Save } from '~icons/tabler/device-floppy'
@@ -1,26 +0,0 @@
<script lang="ts">
import { MdiEyeOffOutline, MdiEyeOutline } from '../icons'
interface Props {
show?: boolean
value?: string
id?: string
}
let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props()
let type = $derived(show ? 'text' : 'password')
const handleInput = (e: Event) => (value = (e.target as HTMLInputElement).value)
const togglePassword = () => (show = !show)
</script>
<label class="input input-bordered flex items-center gap-2">
<input {type} class="grow" {value} oninput={handleInput} {id} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div onclick={togglePassword} role="button" tabindex="0">
<MdiEyeOffOutline class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}" />
<MdiEyeOutline class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}" />
</div>
</label>
@@ -1,35 +0,0 @@
<script lang="ts">
interface Props {
min?: number
max?: number
step?: number
value?: number
oninput?: (value: number) => void
}
let {
min = 0,
max = 100,
step = 1,
value = $bindable((max - min) / 2),
...rest
}: Props = $props()
</script>
<input
type="range"
style="writing-mode: vertical-lr; direction: rtl"
class="cursor-pointer"
{min}
{max}
{step}
bind:value
{...rest}
/>
<style>
input[type='range']::-webkit-slider-runnable-track {
background: oklch(var(--p) / 1);
border-radius: var(--rounded-box, 1rem);
}
</style>
-2
View File
@@ -1,2 +0,0 @@
export { default as PasswordInput } from './InputPassword.svelte'
export { default as VerticalSlider } from './VerticalSlider.svelte'
@@ -1,11 +0,0 @@
<script lang="ts">
interface Props {
children?: import('svelte').Snippet
}
let { children }: Props = $props()
</script>
<div class="box-border overflow-hidden flex-1">
{@render children?.()}
</div>
@@ -1,41 +0,0 @@
<script lang="ts">
import WidgetContainer from './WidgetContainer.svelte'
import {
WidgetComponents,
type WidgetContainerConfig,
isWidgetConfig
} from '$lib/stores/application'
import Widget from './Widget.svelte'
interface Props {
container: WidgetContainerConfig
}
let { container }: Props = $props()
</script>
<div class="w-full h-full flex flex-col overflow-hidden">
<div
class="flex w-full h-full"
class:flex-row={container.layout === 'column'}
class:flex-col={container.layout === 'row'}
class:flex-wrap={container.layout === 'wrap'}
>
{#each container.widgets as widget, index (widget.id + '-' + index)}
<Widget>
{#if isWidgetConfig(widget)}
{@const SvelteComponent = WidgetComponents[widget.component]}
<SvelteComponent {...widget.props} />
{:else if widget.widgets}
<WidgetContainer container={widget} />
{/if}
</Widget>
{#if index !== container.widgets.length - 1}
<div
class="divider bg-base-300 m-0"
class:divider-horizontal={container.layout === 'column'}
></div>
{/if}
{/each}
</div>
</div>
@@ -1,15 +0,0 @@
<script lang="ts">
import { Github } from '../icons'
interface Props {
github: { url: string; version: string; active?: boolean; href?: string }
}
let { github }: Props = $props()
</script>
{#if github.active}
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
<Github class="h-5 w-5" />
</a>
{/if}
@@ -1,15 +0,0 @@
<script>
import logo from '$lib/assets/logo512.png'
import { resolve } from '$app/paths'
/** @type {{appName: any}} */
let { appName } = $props()
</script>
<a
href={resolve('/')}
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" />
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
</a>
-200
View File
@@ -1,200 +0,0 @@
<script lang="ts">
import { page } from '$app/state'
import { base } from '$app/paths'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import GithubButton from '../menu/GithubButton.svelte'
import LogoButton from '../menu/LogoButton.svelte'
import MenuList from '../menu/MenuList.svelte'
import {
Connection,
Settings,
MdiController,
Devices,
Camera,
Rotate3d,
MotorOutline,
Health,
Folder,
Update,
WiFi,
Router,
AP,
Copyright,
Metrics,
DNS
} from '$lib/components/icons'
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
const features = useFeatureFlags()
const appName = page.data.app_name
const copyright = page.data.copyright
const github = { href: 'https://github.com/' + page.data.github, active: true }
import type { ComponentType } from 'svelte'
type menuItem = {
title: string
icon: ComponentType
href?: string
feature: boolean
active?: boolean
submenu?: menuItem[]
}
function withBase(path: string) {
return `${base}${path.startsWith('/') ? path : '/' + path}`
}
let menuItems = $state<menuItem[]>([])
$effect(() => {
menuItems = [
{
title: 'Connection',
icon: WiFi,
href: withBase('/connection'),
feature: !PUBLIC_VITE_USE_HOST_NAME
},
{
title: 'Controller',
icon: MdiController,
href: withBase('/controller'),
feature: true
},
{
title: 'Peripherals',
icon: Devices,
feature: true,
submenu: [
{
title: 'I2C',
icon: Connection,
href: withBase('/peripherals/i2c'),
feature: true
},
{
title: 'Camera',
icon: Camera,
href: withBase('/peripherals/camera'),
feature: $features.camera
},
{
title: 'Servo',
icon: MotorOutline,
href: withBase('/peripherals/servo'),
feature: true
},
{
title: 'IMU',
icon: Rotate3d,
href: withBase('/peripherals/imu'),
feature: $features.imu || $features.mag || $features.bmp
}
]
},
{
title: 'WiFi',
icon: WiFi,
feature: true,
submenu: [
{
title: 'WiFi Station',
icon: Router,
href: withBase('/wifi/sta'),
feature: true
},
{
title: 'Access Point',
icon: AP,
href: withBase('/wifi/ap'),
feature: true
},
{
title: 'mDNS',
icon: DNS,
href: withBase('/wifi/mdns'),
feature: true
}
]
},
{
title: 'System',
icon: Settings,
feature: true,
submenu: [
{
title: 'System Status',
icon: Health,
href: withBase('/system/status'),
feature: true
},
{
title: 'File System',
icon: Folder,
href: withBase('/system/filesystem'),
feature: true
},
{
title: 'System Metrics',
icon: Metrics,
href: withBase('/system/metrics'),
feature: true
},
{
title: 'Firmware Update',
icon: Update,
href: withBase('/system/update'),
feature:
$features.ota ||
$features.upload_firmware ||
$features.download_firmware
}
]
}
] as menuItem[]
})
const { menuClicked } = $props()
function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => {
item.active = item.title === targetTitle
item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle
})
})
menuItems = menuItems
menuClicked()
}
$effect(() => {
setActiveMenuItem(page.data.title)
})
const updateMenu = (event: CustomEvent) => {
setActiveMenuItem(event.details)
}
</script>
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
<LogoButton {appName} />
<MenuList
{menuItems}
select={updateMenu}
class="grow flex-nowrap overflow-y-auto overflow-x-hidden"
level="0"
/>
<div class="divider my-0"></div>
<div class="flex items-center justify-between">
<GithubButton {github} />
<div class="flex items-center justify-end text-sm gap-2">
<Copyright class="h-4 w-4" />{copyright}
</div>
</div>
</div>
@@ -1,56 +0,0 @@
<script lang="ts">
import MenuList from './MenuList.svelte'
import type { ComponentType } from 'svelte'
type MenuItem = {
title: string
icon: ComponentType
href?: string
feature: boolean
active?: boolean
submenu?: MenuItem[]
}
let { level, menuItems, select, class: klass } = $props()
const selectMenuItem = (title: string) => {
select(title)
}
</script>
<ul class={klass + ' menu w-full'}>
{#each menuItems as MenuItem[] as menuItem (menuItem.title)}
{#if menuItem.feature}
<li>
{#if menuItem.submenu}
<details open={menuItem.submenu.some(subItem => subItem.active)}>
<summary class="font-bold">
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</summary>
<div class="pl-4">
<MenuList
menuItems={menuItem.submenu}
level={level + 1}
{select}
class={klass}
/>
</div>
</details>
{:else}
<a
href={menuItem.href}
class="font-bold"
class:bg-base-100={menuItem.active}
class:text-lg={level === 0}
class:text-md={level === 1}
onclick={() => selectMenuItem(menuItem.title)}
>
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</a>
{/if}
</li>
{/if}
{/each}
</ul>
@@ -1,10 +0,0 @@
<script lang="ts">
import { isFullscreen, toggleFullscreen } from '$lib/stores'
import { MdiFullscreenExit, MdiFullscreen } from '../icons'
const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen)
</script>
<button onclick={toggleFullscreen}>
<SvelteComponent class="h-7 w-7" />
</button>
@@ -1,33 +0,0 @@
<script lang="ts">
import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from '../icons'
interface Props {
showDBm?: boolean
rssi?: number
}
let { showDBm = false, rssi = 0 }: Props = $props()
const getWiFiIcon = () => {
if (rssi === 0) return WifiOff
if (rssi >= -55) return WiFi
if (rssi >= -75) return WiFi2
if (rssi >= -85) return WiFi1
return WiFi0
}
const SvelteComponent = $derived(getWiFiIcon())
</script>
<div class="indicator">
<div class="tooltip tooltip-left" data-tip={rssi + ' dBm'}>
{#if showDBm}
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
{rssi} dBm
</span>
{/if}
<div class="h-7 w-7">
<SvelteComponent class="absolute inset-0 h-full w-full" />
</div>
</div>
</div>
@@ -1,34 +0,0 @@
<script lang="ts">
import { useFeatureFlags } from '$lib/stores'
import { modals } from 'svelte-modals'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import { api } from '$lib/api'
import { Cancel, Power } from '../icons'
const features = useFeatureFlags()
const postSleep = async () => await api.post('/api/system/sleep')
const confirmSleep = () => {
modals.open(ConfirmDialog, {
title: 'Confirm Power Down',
message: 'Are you sure you want to switch off the device?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Switch Off', icon: Power }
},
onConfirm: () => {
modals.close()
postSleep()
}
})
}
</script>
{#if $features.sleep}
<div class="flex-none">
<button class="btn btn-square btn-ghost h-9 w-10" onclick={confirmSleep}>
<Power class="text-error h-9 w-9" />
</button>
</div>
{/if}
@@ -1,9 +0,0 @@
<script lang="ts">
import { mode, modes } from '$lib/stores'
const deactivate = async () => {
mode.set(modes.indexOf('deactivated'))
}
</script>
<button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button>
@@ -1,9 +0,0 @@
<script lang="ts">
import { MdiWeatherSunny, MdiMoonAndStars } from '../icons'
</script>
<label class="swap swap-rotate">
<input type="checkbox" value="light" class="theme-controller" />
<MdiWeatherSunny class="swap-off h-7 w-7" />
<MdiMoonAndStars class="swap-on h-7 w-7" />
</label>
@@ -1,18 +0,0 @@
<script lang="ts">
import { Hamburger } from '../icons'
import { resolve } from '$app/paths'
</script>
<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">
<a href={resolve('/')}>
<Hamburger class="h-8 w-8" />
</a>
</div>
</div>
<style>
.topbar {
height: 50px;
}
</style>
@@ -1,111 +0,0 @@
<script lang="ts">
import { page } from '$app/state'
import { modals } from 'svelte-modals'
import { notifications } from '$lib/components/toasts/notifications'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'
import { compareVersions } from 'compare-versions'
import { onMount } from 'svelte'
import { api } from '$lib/api'
import type { GithubRelease } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Cancel, CloudDown, Firmware } from '../icons'
const features = useFeatureFlags()
interface Props {
update?: boolean
}
let { update = $bindable(false) }: Props = $props()
let firmwareVersion: string = $state('')
let firmwareDownloadLink: string = $state('')
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}
const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${page.data.github}/releases/latest`,
{ headers }
)
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository')
return
}
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
const results = result.inner
update = false
firmwareVersion = ''
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
// iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin
if (
results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($features.firmware_built_target as string)
) {
update = true
firmwareVersion = results.tag_name
firmwareDownloadLink = results.assets[i].browser_download_url
notifications.info('Firmware update available.', 5000)
}
}
}
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url })
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
}
onMount(async () => {
if ($features.download_firmware) {
await getGithubAPI()
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour
}
})
function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url)
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
})
}
})
}
</script>
{#if update}
<div class="indicator flex-none">
<button
class="btn btn-square btn-ghost h-9 w-9"
onclick={() => confirmGithubUpdate(firmwareDownloadLink)}
>
<span
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
>
{firmwareVersion}
</span>
<Firmware class="h-7 w-7" />
</button>
</div>
{/if}
@@ -1,6 +0,0 @@
<script lang="ts">
import { selectedView, views } from '$lib/stores/application'
import Selector from '../widget/Selector.svelte'
</script>
<Selector bind:selectedOption={$selectedView} options={$views.map(v => v.name)} />
@@ -1,38 +0,0 @@
<script lang="ts">
import { page } from '$app/state'
import { telemetry } from '$lib/stores/telemetry'
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte'
import SleepButton from './SleepButton.svelte'
import ThemeButton from './ThemeButton.svelte'
import FullscreenButton from './FullscreenButton.svelte'
import StopButton from './StopButton.svelte'
import ViewSelector from './ViewSelector.svelte'
import { Hamburger } from '../icons'
</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="flex flex-1 gap-2">
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button">
<Hamburger class="h-6 w-auto" />
</label>
{#if page.data.title === 'Controller'}
<ViewSelector />
{:else}
<h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1>
{/if}
</div>
<UpdateIndicator />
<FullscreenButton />
<ThemeButton />
<RssiIndicator rssi={$telemetry.rssi.rssi} />
<SleepButton />
<StopButton />
</div>
+31 -31
View File
@@ -1,37 +1,37 @@
<script> <script>
import { flip } from 'svelte/animate' import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import { error, info, success, warning } from '../icons' import error from '~icons/tabler/circle-x';
import success from '~icons/tabler/circle-check';
import warning from '~icons/tabler/alert-triangle';
import info from '~icons/tabler/info-circle';
/** @type {{theme?: any, icon?: any}} */ export let theme = {
let { error: 'alert-error',
theme = { success: 'alert-success',
error: 'alert-error', warning: 'alert-warning',
success: 'alert-success', info: 'alert-info'
warning: 'alert-warning', };
info: 'alert-info'
}, export let icon = {
icon = { error: error,
error: error, success: success,
success: success, warning: warning,
warning: warning, info: info
info: info };
}
} = $props()
</script> </script>
<div class="toast toast-end mr-4 z-20"> <div class="toast toast-end mr-4 z-20">
{#each $notifications as notification (notification.id)} {#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]} <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 }} >
> <svelte:component this={icon[notification.type]} class="h-6 w-6 flex-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,102 +0,0 @@
<script lang="ts">
import { daisyColor } from '$lib/utilities'
import { Chart, registerables } from 'chart.js'
import { onMount } from 'svelte'
import { cubicOut } from 'svelte/easing'
import { slide } from 'svelte/transition'
let chartElement: HTMLCanvasElement
let chart: Chart<'line', number[], number>
interface Props {
label: string
data: number[]
title: string
}
let { label, data, title }: Props = $props()
Chart.register(...registerables)
onMount(() => {
chart = new Chart(chartElement, {
type: 'line',
data: {
labels: data,
datasets: [
{
label,
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data,
yAxisID: 'y'
}
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
},
elements: {
point: {
radius: 0
}
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: title,
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
},
position: 'left',
min: 0,
max: 100,
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
})
setInterval(() => {
chart.data.labels = data
chart.data.datasets[0].data = data
}, 500)
})
</script>
<div class="w-full h-full overflow-x-auto">
<div
class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<canvas bind:this={chartElement}></canvas>
</div>
</div>
@@ -1,20 +0,0 @@
<script lang="ts">
interface Props {
options?: string[]
selectedOption?: string
change?: () => void
[key: string]: unknown
}
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props()
</script>
<select
bind:value={selectedOption}
{...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"
>
{#each options as option}
<option value={option}>{option}</option>
{/each}
</select>
+193 -444
View File
@@ -1,493 +1,242 @@
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'
const { sin } = Math;
export interface gait_state_t { export interface gait_state_t {
step_height: number step_height: number;
step_x: number step_x: number;
step_z: number step_z: number;
step_angle: number step_angle: number;
step_velocity: number step_velocity: number;
step_depth: number step_depth: number;
} }
export interface ControllerCommand { export interface ControllerCommand {
lx: number stop: number;
ly: number lx: number;
rx: number ly: number;
ry: number rx: number;
h: number ry: number;
s: number h: number;
s1: number s: number;
} }
export abstract class GaitState { export abstract class GaitState {
protected abstract name: string protected abstract name: string;
protected dt = 0.02 protected static body_state: body_state_t;
protected body_state!: body_state_t
protected gait_state: gait_state_t = {
step_height: 0.4,
step_x: 0,
step_z: 0,
step_angle: 0,
step_velocity: 1,
step_depth: 0.002
}
public get default_feet_pos() { public get default_feet_pos() {
return get(currentKinematic).getDefaultFeetPos() return [
} [1, -1, 1, 1],
[1, -1, -1, 1],
[-1, -1, 1, 1],
[-1, -1, -1, 1]
];
}
protected get default_height() { protected get default_height() {
return 0.5 return 0.5;
} }
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: ControllerCommand, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
this.map_command(command) return body_state;
this.body_state = body_state }
this.dt = dt / 1000
if (body_state.cumulative_x === undefined) { map_command(command: ControllerCommand): gait_state_t {
body_state.cumulative_x = 0 return {
body_state.cumulative_y = 0 step_height: 0.4 + Math.abs(command.ry / 128),
body_state.cumulative_z = 0 step_x: (Math.floor(fromInt8(command.ly, -1, 1) * 10) / 10) * 3,
body_state.cumulative_roll = 0 step_z: -(Math.floor(fromInt8(command.lx, -1, 1) * 10) / 10) * 3,
body_state.cumulative_pitch = 0 step_velocity: command.s / 128 + 1,
body_state.cumulative_yaw = 0 step_angle: 0,
} step_depth: 0.2
};
return body_state }
}
map_command(command: ControllerCommand) {
const newCommand = {
step_height: 0.4 + (command.s1 + 1) / 2,
step_x: command.ly,
step_z: -command.lx,
step_velocity: command.s,
step_angle: command.rx,
step_depth: 0.002
}
this.gait_state = newCommand
}
} }
export class IdleState extends GaitState { export class IdleState extends GaitState {
protected name = 'Idle' protected name = 'Idle';
step(body_state: body_state_t, command: ControllerCommand) {
super.step(body_state, command)
return body_state
}
}
export class CalibrationState extends GaitState {
protected name = 'Calibration'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
step(body_state: body_state_t, _command: ControllerCommand) {
super.step(body_state, _command)
body_state.omega = 0
body_state.phi = 0
body_state.psi = 0
body_state.xm = 0
body_state.ym = this.default_height * 10
body_state.zm = 0
body_state.feet = this.default_feet_pos
return body_state
}
} }
export class RestState extends GaitState { export class RestState extends GaitState {
protected name = 'Rest' protected name = 'Rest';
// eslint-disable-next-line @typescript-eslint/no-unused-vars step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
step(body_state: body_state_t, _command: ControllerCommand) { body_state.omega = 0;
super.step(body_state, _command) body_state.phi = 0;
body_state.omega = 0 body_state.psi = 0;
body_state.phi = 0 body_state.xm = 0;
body_state.psi = 0 body_state.ym = this.default_height / 2;
body_state.xm = 0 body_state.zm = 0;
body_state.ym = this.default_height / 2 body_state.feet = this.default_feet_pos;
body_state.zm = 0 return body_state;
body_state.feet = this.default_feet_pos }
return body_state
}
} }
export class StandState extends GaitState { export class StandState extends GaitState {
protected name = 'Stand' protected name = 'Stand';
step(body_state: body_state_t, command: ControllerCommand) { 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 = command.rx / 8;
body_state.phi = command.rx * 10 * (Math.PI / 2) body_state.psi = command.ry / 8;
body_state.psi = command.ry * 10 * (Math.PI / 2) body_state.xm = command.ly / 2 / 100;
body_state.xm = command.ly / 4 body_state.zm = command.lx / 2 / 100;
body_state.zm = command.lx / 4 body_state.feet = this.default_feet_pos;
body_state.feet = this.default_feet_pos return body_state;
return body_state }
}
} }
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: 'crawl' | 'trot' = '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 protected body_state!: body_state_t;
protected cumulative_position = { x: 0, y: 0, z: 0 } protected gait_state!: gait_state_t;
protected cumulative_orientation = { roll: 0, pitch: 0, yaw: 0 } protected dt = 0.02;
constructor() { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super() this.body_state = body_state;
this.set_mode(this.mode) this.gait_state = this.map_command(command);
} this.dt = dt / 1000;
this.update_phase();
this.update_body_position();
this.update_feet_positions();
return this.body_state;
}
begin() { update_phase() {
super.begin() this.phase_time += this.dt * this.phase_speed_factor * this.gait_state.step_velocity;
}
set_mode(mode: 'crawl' | 'trot', duty?: number, order?: [number, number, number, number]) { if (this.phase_time >= 1) {
console.log('BezierState set_mode', mode) this.phase += 1;
if (this.phase == this.num_phases) this.phase = 0;
this.phase_time = 0;
}
}
this.mode = mode update_body_position() {
if (mode === 'crawl') { if (this.num_phases === 4) return;
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() { const shift = this.shifts[Math.floor(this.phase / 2)];
super.end()
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { this.body_state.xm += (shift[0] - this.body_state.xm) * this.dt * 4;
super.step(body_state, command, dt) this.body_state.zm += (shift[2] - this.body_state.zm) * this.dt * 4;
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_feet_positions() {
const m = this.gait_state for (let i = 0; i < 4; i++) {
if (m.step_x === 0 && m.step_z === 0 && m.step_angle === 0) { this.body_state.feet[i] = this.update_foot_position(i);
this.phase = 0 }
return }
}
this.phase += this.dt * m.step_velocity * this.speed_factor
if (this.phase >= 1) {
this.phase_num = (this.phase_num + 1) % 2
this.phase = 0
}
}
update_body_position() { update_foot_position(index: number): number[] {
const m = this.gait_state const contact = this.contact_phases[index][this.phase];
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0 return contact ? this.stand(index) : this.swing(index);
if (!moving) return }
if (this.mode !== 'crawl') return stand(index: number): number[] {
const delta_pos = [
-this.gait_state.step_x * this.dt * this.swing_stand_ratio,
0,
-this.gait_state.step_z * this.dt * this.swing_stand_ratio
];
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states() 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];
}
if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) { swing(index: number): number[] {
if (this.current_shift_leg !== next_swing) { const delta_pos = [this.gait_state.step_x * this.dt, 0, this.gait_state.step_z * this.dt];
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) if (this.gait_state.step_x == 0) {
const target = this.stance_centroid(remaining_legs) delta_pos[0] =
this.shift_target_pos.x = target[0] (this.default_feet_pos[index][0] - this.body_state.feet[index][0]) * this.dt * 8;
this.shift_target_pos.z = target[2] }
this.shift_start_time = time_to_lift 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;
}
const total_time = this.shift_start_time this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0];
const progress = total_time > 0 ? 1 - time_to_lift / total_time : 1 this.body_state.feet[index][1] =
const smooth_progress = this.smoothstep01(Math.max(0, Math.min(1, progress))) this.default_feet_pos[index][1] +
sin(this.phase_time * Math.PI) * this.gait_state.step_height;
this.body_state.xm = this.lerp( this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2];
this.shift_start_pos.x, return this.body_state.feet[index];
this.shift_target_pos.x, }
smooth_progress
)
this.body_state.zm = this.lerp(
this.shift_start_pos.z,
this.shift_target_pos.z,
smooth_progress
)
}
}
protected lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
protected stance_centroid(legs: number[]): number[] {
if (legs.length === 0) return [this.body_state.xm, 0, this.body_state.zm]
let sx = 0,
sz = 0
for (const i of legs) {
sx += this.body_state.feet[i][0]
sz += this.body_state.feet[i][2]
}
return [sx / legs.length, 0, sz / legs.length]
}
protected get_leg_states(): {
stance: number[]
swing: number[]
next_swing: number
time_to_lift: number
} {
const stance: number[] = []
const swing: number[] = []
let next_swing = -1
let min_time_to_swing = Infinity
for (let i = 0; i < 4; i++) {
let phase = this.phase + this.offset[i]
if (phase >= 1) phase -= 1
if (phase <= this.stand_offset) {
stance.push(i)
const time_to_swing = this.stand_offset - phase
if (time_to_swing < min_time_to_swing) {
min_time_to_swing = time_to_swing
next_swing = i
}
} else {
swing.push(i)
}
}
return { stance, swing, next_swing, time_to_lift: min_time_to_swing }
}
protected smoothstep01(t: number): number {
const x = Math.max(0, Math.min(1, t))
return x * x * (3 - 2 * x)
}
update_feet_positions() {
for (let i = 0; i < 4; i++) this.body_state.feet[i] = this.update_foot_position(i)
}
update_foot_position(index: number): number[] {
let phase = this.phase + this.offset[index]
if (phase >= 1) phase -= 1
this.body_state.feet[index][0] = this.default_feet_pos[index][0]
this.body_state.feet[index][1] = this.default_feet_pos[index][1]
this.body_state.feet[index][2] = this.default_feet_pos[index][2]
return phase <= this.stand_offset ?
this.stand_controller(index, phase / this.stand_offset)
: this.swing_controller(index, (phase - this.stand_offset) / (1 - this.stand_offset))
}
stand_controller(index: number, phase: number) {
const depth = this.gait_state.step_depth
return this.controller(index, phase, stance_curve, depth)
}
swing_controller(index: number, phase: number) {
const height = this.gait_state.step_height
return this.controller(index, phase, bezier_curve, height)
}
controller(
index: number,
phase: number,
controller: (length: number, angle: number, ...args: number[]) => number[],
...args: number[]
) {
let length = this.step_length / 2
let angle = Math.atan2(this.gait_state.step_z, this.step_length) * 2
const delta_pos = controller(length, angle, ...args, phase)
length = this.gait_state.step_angle * 2
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
const delta_rot = controller(length, angle, ...args, phase)
this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2
this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2
if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle)
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2
return this.body_state.feet[index]
}
update_cumulative_position() {
if (this.last_body_state === null) {
this.last_body_state = { ...this.body_state }
this.body_state.cumulative_x = 0
this.body_state.cumulative_y = 0
this.body_state.cumulative_z = 0
this.body_state.cumulative_roll = 0
this.body_state.cumulative_pitch = 0
this.body_state.cumulative_yaw = 0
return
}
const m = this.gait_state
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (moving) {
const step_displacement_x_local =
m.step_x * m.step_velocity * this.dt * this.speed_factor
const step_displacement_z_local =
m.step_z * m.step_velocity * this.dt * this.speed_factor
const step_displacement_yaw =
m.step_angle * m.step_velocity * this.dt * this.speed_factor
const cos_yaw = Math.cos(this.cumulative_orientation.yaw)
const sin_yaw = Math.sin(this.cumulative_orientation.yaw)
const step_displacement_x =
step_displacement_x_local * cos_yaw - step_displacement_z_local * sin_yaw
const step_displacement_z =
step_displacement_x_local * sin_yaw + step_displacement_z_local * cos_yaw
this.cumulative_position.x += step_displacement_x
this.cumulative_position.z += step_displacement_z
this.cumulative_orientation.yaw += step_displacement_yaw
}
this.body_state.cumulative_x = this.cumulative_position.x
this.body_state.cumulative_y = this.cumulative_position.y
this.body_state.cumulative_z = this.cumulative_position.z
this.body_state.cumulative_roll = this.cumulative_orientation.roll
this.body_state.cumulative_pitch = this.cumulative_orientation.pitch
this.body_state.cumulative_yaw = this.cumulative_orientation.yaw
this.last_body_state = { ...this.body_state }
}
} }
const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => { export class FourPhaseWalkState extends PhaseGaitState {
const X_POLAR = Math.cos(angle) protected name = 'Four phase walk';
const Y_POLAR = Math.sin(angle) protected num_phases = 4;
protected phase_speed_factor = 2.5;
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);
const step = length * (1 - 2 * phase) begin() {
const X = step * X_POLAR super.begin();
const Z = step * Y_POLAR }
let Y = 0
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length)) end() {
return [X, Y, Z] super.end();
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt);
}
} }
const yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => { export class EightPhaseWalkState extends PhaseGaitState {
const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2) protected name = 'Eight phase walk';
const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0]) protected num_phases = 8;
const offsets = [ protected phase_speed_factor = 1.5;
current_foot_pos[0] - default_foot_pos[0], protected contact_phases = [
current_foot_pos[2] - default_foot_pos[2], [1, 0, 1, 1, 1, 1, 1, 1],
current_foot_pos[1] - default_foot_pos[1] [1, 1, 1, 0, 1, 1, 1, 1],
] [1, 1, 1, 1, 1, 0, 1, 1],
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2) [1, 1, 1, 1, 1, 1, 1, 0]
const offset_mod = Math.atan2(offset_mag, foot_mag) ];
protected shifts = [
[-0.3, 0, -0.2],
[-0.3, 0, 0.2],
[0.3, 0, -0.2],
[0.3, 0, 0.2]
];
protected swing_stand_ratio = 1 / (this.num_phases - 1);
return Math.PI / 2.0 + foot_dir + offset_mod begin() {
} super.begin();
}
const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => {
const control_points = get_control_points(length, angle, height) end() {
const n = control_points.length - 1 super.end();
}
const point = [0, 0, 0]
for (let i = 0; i <= n; i++) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i) return super.step(body_state, command, dt);
point[0] += bernstein_poly * control_points[i][0] }
point[1] += bernstein_poly * control_points[i][1]
point[2] += bernstein_poly * control_points[i][2]
}
return point
}
const get_control_points = (length: number, angle: number, height: number): number[][] => {
const X_POLAR = Math.cos(angle)
const Z_POLAR = Math.sin(angle)
const STEP = [
-length,
-length * 1.4,
-length * 1.5,
-length * 1.5,
-length * 1.5,
0.0,
0.0,
0.0,
length * 1.5,
length * 1.5,
length * 1.4,
length
]
const Y = [
0.0,
0.0,
height * 0.9,
height * 0.9,
height * 0.9,
height * 0.9,
height * 0.9,
height * 1.1,
height * 1.1,
height * 1.1,
0.0,
0.0
]
const control_points: number[][] = []
for (let i = 0; i < STEP.length; i++) {
const X = STEP[i] * X_POLAR
const Z = STEP[i] * Z_POLAR
control_points.push([X, Y[i], Z])
}
return control_points
}
const comb = (n: number, k: number): number => {
if (k < 0 || k > n) return 0
if (k === 0 || k === n) return 1
k = Math.min(k, n - k)
let c = 1
for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1)
return c
} }
+294 -127
View File
@@ -1,153 +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;
mountOffsets: number[][] sHp = sin(Math.PI / 2);
cHp = cos(Math.PI / 2);
invMountRot = [ Tlf: number[][] = [];
[0, 0, -1], Trf: number[][] = [];
[0, 1, 0], Tlb: number[][] = [];
[1, 0, 0] Trb: number[][] = [];
]
constructor(params: KinematicParams) { point_lf: number[][];
this.coxa = params.coxa point_rf: number[][];
this.coxa_offset = params.coxa_offset point_lb: number[][];
this.femur = params.femur point_rb: number[][];
this.tibia = params.tibia Ix: number[][];
this.L = params.L
this.W = params.W
this.mountOffsets = [ constructor() {
[this.L / 2, 0, this.W / 2], this.l1 = 60.5 / 100;
[this.L / 2, 0, -this.W / 2], this.l2 = 10 / 100;
[-this.L / 2, 0, this.W / 2], this.l3 = 100.7 / 100;
[-this.L / 2, 0, -this.W / 2] this.l4 = 118.5 / 100;
]
}
getDefaultFeetPos(): number[][] { this.L = 207.5 / 100;
return this.mountOffsets.map((offset, i) => { this.W = 78 / 100;
return [offset[0], -1, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)]
})
}
calcIK(p: body_state_t): number[] { this.point_lf = [
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]],
[rot[0][1], rot[1][1], rot[2][1]],
[rot[0][2], rot[1][2], rot[2][2]]
]
const inv_trans = [
-inv_rot[0][0] * p.xm - inv_rot[0][1] * p.ym - inv_rot[0][2] * p.zm,
-inv_rot[1][0] * p.xm - inv_rot[1][1] * p.ym - inv_rot[1][2] * p.zm,
-inv_rot[2][0] * p.xm - inv_rot[2][1] * p.ym - inv_rot[2][2] * p.zm
]
return p.feet.flatMap((foot, i) => {
const [wx, wy, wz] = foot
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
const [mx, my, mz] = this.mountOffsets[i] this.point_rf = [
const px = bx - mx, [this.cHp, 0, this.sHp, this.L / 2],
py = by - my, [0, 1, 0, 0],
pz = bz - mz [-this.sHp, 0, this.cHp, -this.W / 2],
[0, 0, 0, 1]
];
const lx = this.point_lb = [
this.invMountRot[0][0] * px + [this.cHp, 0, this.sHp, -this.L / 2],
this.invMountRot[0][1] * py + [0, 1, 0, 0],
this.invMountRot[0][2] * pz [-this.sHp, 0, this.cHp, this.W / 2],
const ly = [0, 0, 0, 1]
this.invMountRot[1][0] * px + ];
this.invMountRot[1][1] * py +
this.invMountRot[1][2] * pz
const lz =
this.invMountRot[2][0] * px +
this.invMountRot[2][1] * py +
this.invMountRot[2][2] * pz
const xLocal = i % 2 === 1 ? -lx : lx this.point_rb = [
return this.legIK(xLocal, ly, lz) [this.cHp, 0, this.sHp, -this.L / 2],
}) [0, 1, 0, 0],
} [-this.sHp, 0, this.cHp, -this.W / 2],
[0, 0, 0, 1]
];
this.Ix = [
[-1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
];
}
private legIK(x: number, y: number, z: number): [number, number, number] { public calcIK(body_state: body_state_t): number[] {
const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa)) this.bodyIK(body_state);
const G = F - this.coxa_offset
const H = sqrt(G * G + z * z)
const t1 = -atan2(y, x) - atan2(F, -this.coxa)
const D =
(H * H - this.femur * this.femur - this.tibia * this.tibia) /
(2 * this.femur * this.tibia)
const t3 = acos(max(-1, min(1, D)))
const t2 = atan2(z, G) - atan2(this.tibia * sin(t3), this.femur + this.tibia * cos(t3))
return [t1, t2, t3]
}
private euler2R(roll: number, pitch: number, yaw: number): number[][] { return [
const cr = cos(roll), ...this.legIK(this.multiplyVector(this.inverse(this.Tlf), body_state.feet[0])),
sr = sin(roll) ...this.legIK(
const cp = cos(pitch), this.multiplyVector(
sp = sin(pitch) this.Ix,
const cy = cos(yaw), this.multiplyVector(this.inverse(this.Trf), body_state.feet[1])
sy = sin(yaw) )
return [ ),
[cp * cy, -cp * sy, sp], ...this.legIK(this.multiplyVector(this.inverse(this.Tlb), body_state.feet[2])),
[sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp], ...this.legIK(
[sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp] this.multiplyVector(
] this.Ix,
} this.multiplyVector(this.inverse(this.Trb), body_state.feet[3])
)
)
];
}
bodyIK(p: body_state_t) {
const cos_omega = cos(p.omega * this.DEG2RAD);
const sin_omega = sin(p.omega * this.DEG2RAD);
const cos_phi = cos(p.phi * this.DEG2RAD);
const sin_phi = sin(p.phi * this.DEG2RAD);
const cos_psi = cos(p.psi * this.DEG2RAD);
const sin_psi = sin(p.psi * this.DEG2RAD);
const Tm: number[][] = [
[cos_phi * cos_psi, -sin_psi * cos_phi, sin_phi, p.xm],
[
sin_omega * sin_phi * cos_psi + sin_psi * cos_omega,
-sin_omega * sin_phi * sin_psi + cos_omega * cos_psi,
-sin_omega * cos_phi,
p.ym
],
[
sin_omega * sin_psi - sin_phi * cos_omega * cos_psi,
sin_omega * cos_psi + sin_phi * sin_psi * cos_omega,
cos_omega * cos_phi,
p.zm
],
[0, 0, 0, 1]
];
this.Tlf = this.matrixMultiply(Tm, this.point_lf);
this.Trf = this.matrixMultiply(Tm, this.point_rf);
this.Tlb = this.matrixMultiply(Tm, this.point_lb);
this.Trb = this.matrixMultiply(Tm, this.point_rb);
}
public legIK(point: number[]): number[] {
const [x, y, z] = point;
let F = sqrt(x ** 2 + y ** 2 - this.l1 ** 2);
if (isNaN(F)) F = this.l1;
const G = F - this.l2;
const H = sqrt(G ** 2 + z ** 2);
const theta1 = -atan2(y, x) - atan2(F, -this.l1);
const D = (H ** 2 - this.l3 ** 2 - this.l4 ** 2) / (2 * this.l3 * this.l4);
let theta3 = atan2(sqrt(1 - D ** 2), D);
if (isNaN(theta3)) theta3 = 0;
const theta2 = atan2(z, G) - atan2(this.l4 * sin(theta3), this.l3 + this.l4 * cos(theta3));
return [theta1, theta2, theta3];
}
matrixMultiply(a: number[][], b: number[][]): number[][] {
const result: number[][] = [];
for (let i = 0; i < a.length; i++) {
const row: number[] = [];
for (let j = 0; j < b[0].length; j++) {
let sum = 0;
for (let k = 0; k < a[i].length; k++) {
sum += a[i][k] * b[k][j];
}
row.push(sum);
}
result.push(row);
}
return result;
}
multiplyVector(matrix: number[][], vector: number[]): number[] {
const rows = matrix.length;
const cols = matrix[0].length;
const vectorLength = vector.length;
if (cols !== vectorLength) {
throw new Error('Matrix and vector dimensions do not match for multiplication.');
}
const result = [];
for (let i = 0; i < rows; i++) {
let sum = 0;
for (let j = 0; j < cols; j++) {
sum += matrix[i][j] * vector[j];
}
result.push(sum);
}
return result;
}
private inverse(matrix: number[][]): number[][] {
const det = this.determinant(matrix);
const adjugate = this.adjugate(matrix);
const scalar = 1 / det;
const inverse: number[][] = [];
for (let i = 0; i < matrix.length; i++) {
const row: number[] = [];
for (let j = 0; j < matrix[i].length; j++) {
row.push(adjugate[i][j] * scalar);
}
inverse.push(row);
}
return inverse;
}
private determinant(matrix: number[][]): number {
if (matrix.length !== matrix[0].length) {
throw new Error('The matrix is not square.');
}
if (matrix.length === 2) {
return matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0];
}
let det = 0;
for (let i = 0; i < matrix.length; i++) {
const sign = i % 2 === 0 ? 1 : -1;
const subMatrix: number[][] = [];
for (let j = 1; j < matrix.length; j++) {
const row: number[] = [];
for (let k = 0; k < matrix.length; k++) {
if (k !== i) {
row.push(matrix[j][k]);
}
}
subMatrix.push(row);
}
det += sign * matrix[0][i] * this.determinant(subMatrix);
}
return det;
}
private adjugate(matrix: number[][]): number[][] {
if (matrix.length !== matrix[0].length) {
throw new Error('The matrix is not square.');
}
const adjugate: number[][] = [];
for (let i = 0; i < matrix.length; i++) {
const row: number[] = [];
for (let j = 0; j < matrix[i].length; j++) {
const sign = (i + j) % 2 === 0 ? 1 : -1;
const subMatrix: number[][] = [];
for (let k = 0; k < matrix.length; k++) {
if (k !== i) {
const subRow: number[] = [];
for (let l = 0; l < matrix.length; l++) {
if (l !== j) {
subRow.push(matrix[k][l]);
}
}
subMatrix.push(subRow);
}
}
const cofactor = sign * this.determinant(subMatrix);
row.push(cofactor);
}
adjugate.push(row);
}
return this.transpose(adjugate);
}
private transpose(matrix: number[][]): number[][] {
const transposed: number[][] = [];
for (let i = 0; i < matrix.length; i++) {
const row: number[] = [];
for (let j = 0; j < matrix[i].length; j++) {
row.push(matrix[j][i]);
}
transposed.push(row);
}
return transposed;
}
} }
+169
View File
@@ -0,0 +1,169 @@
export type vector = { x: number; y: number };
export interface ControllerInput {
left: vector;
right: vector;
height: number;
speed: number;
s1: number;
}
export type GithubRelease = {
message: string;
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
};
export type JWT = { access_token: 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: NetworkItem[];
};
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 LightState = {
led_on: boolean;
};
export type NTPStatus = {
status: number;
utc_time: string;
local_time: string;
server: string;
uptime: number;
};
export type NTPSettings = {
enabled: boolean;
server: string;
tz_label: string;
tz_format: 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;
};
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 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 = {
name: string;
channel: number;
inverted: boolean;
angle: number;
center_angle: number;
};
export type ServoConfiguration = {
is_active: boolean;
servo_pwm_frequency: number;
servo_oscillator_frequency: number;
servos: Servo[];
};
+308 -305
View File
@@ -1,348 +1,351 @@
import { import {
Mesh, Mesh,
PerspectiveCamera, PerspectiveCamera,
PlaneGeometry, PlaneGeometry,
Scene, Scene,
WebGLRenderer, WebGLRenderer,
AmbientLight, AmbientLight,
DirectionalLight, DirectionalLight,
PCFSoftShadowMap, PCFSoftShadowMap,
type GridHelper, 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, MeshStandardMaterial,
RepeatWrapping, Group
Object3D } from 'three';
} from 'three' import { Sky } from 'three/addons/objects/Sky.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls' import { TransformControls } from 'three/examples/jsm/controls/TransformControls';
import { Reflector } from 'three/examples/jsm/objects/Reflector.js' import { 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 gridOptions {
divisions?: number;
size?: 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;
type gridHelperOptions = gridOptions & position;
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 return this;
} }
public addRenderer = (parameters?: WebGLRendererParameters) => { public addRenderer = (parameters?: WebGLRendererParameters) => {
this.renderer = new WebGLRenderer(parameters) this.renderer = new WebGLRenderer(parameters);
this.renderer.outputColorSpace = 'srgb' this.renderer.outputColorSpace = 'srgb';
this.renderer.shadowMap.enabled = true this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = PCFSoftShadowMap this.renderer.shadowMap.type = PCFSoftShadowMap;
this.renderer.toneMapping = ACESFilmicToneMapping this.renderer.toneMapping = ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 0.85 this.renderer.toneMappingExposure = 0.85;
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement) if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement);
return this return this;
} };
public addPerspectiveCamera = (options: position) => { public addSky = () => {
this.camera = new PerspectiveCamera() this.sky = new Sky();
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0) this.sky.scale.setScalar(450000);
this.scene.add(this.camera) this.scene.add(this.sky);
return this const effectController = {
} turbidity: 10,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: sunCalculator.calculateSunElevation(),
azimuth: 180,
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();
public addGroundPlane = (options?: position) => { sun.setFromSphericalCoords(1, phi, theta);
const checkerboardTexture = this.createCheckerboardTexture(1024, 2) uniforms['sunPosition'].value.copy(sun);
checkerboardTexture.wrapS = RepeatWrapping return this;
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) 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;
};
this.ground = new Mesh(plane, checkerboardMat) public addGroundPlane = (options?: position) => {
this.ground.rotation.x = -Math.PI / 2 var planeMaterial = new MeshStandardMaterial({ color: 0x808080, side: 2, opacity: 0.5 });
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0) this.ground = new Mesh(new PlaneGeometry(), planeMaterial);
this.ground.receiveShadow = true this.ground.rotation.x = -Math.PI / 2;
this.scene.add(this.ground) this.ground.scale.setScalar(30);
this.ground.position.set(options?.x ?? 0, options?.y ?? 0, options?.z ?? 0);
this.ground.receiveShadow = true;
this.scene.add(this.ground);
return this;
};
const mirror = new Reflector(plane, { public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
clipBias: 0.003, this.orbit = new OrbitControls(this.camera, this.renderer.domElement);
textureWidth: window.innerWidth * window.devicePixelRatio, this.orbit.minDistance = minDistance;
textureHeight: window.innerHeight * window.devicePixelRatio, this.orbit.maxDistance = maxDistance;
color: 0x00bfff this.orbit.autoRotate = autoRotate;
}) this.orbit.update();
mirror.rotateX(-Math.PI / 2) return this;
this.scene.add(mirror) };
return this public addAmbientLight = (options: light) => {
} const ambientLight = new AmbientLight(options.color, options.intensity);
this.scene.add(ambientLight);
return this;
};
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => { public addDirectionalLight = (options: directionalLight) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement) const directionalLight = new DirectionalLight(options.color, options.intensity);
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2 directionalLight.castShadow = true;
this.orbit.maxDistance = maxDistance directionalLight.shadow.camera.top = 10;
this.orbit.autoRotate = autoRotate directionalLight.shadow.camera.bottom = -10;
this.orbit.update() directionalLight.shadow.camera.right = 10;
this.orbit.minDistance = minDistance directionalLight.shadow.camera.left = -10;
return this directionalLight.shadow.mapSize.set(4096, 4096);
}
public addAmbientLight = (options: light) => { directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
const ambientLight = new AmbientLight(options.color, options.intensity) this.scene.add(directionalLight);
this.scene.add(ambientLight) return this;
return this };
}
public addDirectionalLight = (options: directionalLight) => { public addGridHelper = (options: gridHelperOptions) => {
const directionalLight = new DirectionalLight(options.color, options.intensity) this.gridHelper = new GridHelper(options.size, options.divisions);
directionalLight.castShadow = true this.gridHelper.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
directionalLight.shadow.camera.top = 10 this.gridHelper.material.opacity = 0.2;
directionalLight.shadow.camera.bottom = -10 this.gridHelper.material.depthWrite = false;
directionalLight.shadow.camera.right = 10 this.gridHelper.material.transparent = true;
directionalLight.shadow.camera.left = -10 this.scene.add(this.gridHelper);
directionalLight.shadow.mapSize.set(4096, 4096) return this;
};
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0) public addFogExp2 = (color: ColorRepresentation, density?: number) => {
this.scene.add(directionalLight) this.scene.fog = new FogExp2(color, density);
return this return this;
} };
private createCheckerboardTexture = (size: number, squares: number) => { public fillParent = () => {
const canvas = document.createElement('canvas') const parentElement = this.renderer.domElement.parentElement;
canvas.width = size if (parentElement) {
canvas.height = size const width = parentElement.clientWidth;
const context = canvas.getContext('2d') const height = parentElement.clientHeight;
this.handleResize(width, height);
}
return this;
};
const squareSize = size / squares 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;
};
for (let y = 0; y < squares; y++) { public addRenderCb = (callback: Function) => {
for (let x = 0; x < squares; x++) { this.callback = callback;
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000' return this;
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize) };
}
}
const texture = new CanvasTexture(canvas) public startRenderLoop = () => {
texture.wrapS = texture.wrapT = RepeatWrapping this.renderer.setAnimationLoop(() => {
texture.anisotropy = 16 this.renderer.render(this.scene, this.camera);
return texture this.orbit.update();
} this.handleRobotShadow();
if (this.callback) this.callback();
if (!this.liveStreamTexture) return;
});
return this;
};
public addFogExp2 = (color: ColorRepresentation, density?: number) => { public addArrowHelper = (options?: arrowOptions) => {
this.scene.fog = new FogExp2(color, density) const dir = new Vector3(
return this 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;
};
public fillParent = () => { private setJointValue(jointName: string, angle: number) {
const parentElement = this.renderer.domElement.parentElement if (!this.model) return;
if (parentElement) { if (!this.model.joints[jointName]) return;
const width = parentElement.clientWidth this.model.joints[jointName].setJointValue(angle);
const height = parentElement.clientHeight }
this.handleResize(width, height)
}
return this
}
public handleResize = (width = window.innerWidth, height = window.innerHeight) => { isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed';
this.renderer.setSize(width, height)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
return this
}
public addRenderCb = (callback: () => void) => { highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
this.callback = callback const traverse = (c: any) => {
return this if (c.type === 'Mesh') {
} if (revert) {
c.material = c.__origMaterial;
delete c.__origMaterial;
} else {
c.__origMaterial = c.material;
c.material = material;
}
}
public startRenderLoop = () => { if (c === m || !this.isJoint(c)) {
this.renderer.setAnimationLoop(() => { for (let i = 0; i < c.children.length; i++) {
this.renderer.render(this.scene, this.camera) const child = c.children[i];
this.orbit.update() if (!child.isURDFCollider) {
this.handleRobotShadow() traverse(c.children[i]);
if (this.callback) this.callback() }
if (!this.liveStreamTexture) return }
}) }
return this };
} traverse(m);
};
public addArrowHelper = (options?: arrowOptions) => { public addTransformControls = (model: any) => {
const dir = new Vector3( this.transformControl = new TransformControls(this.camera, this.renderer.domElement);
options?.direction.x ?? 0, this.transformControl.addEventListener('dragging-changed', (event: any) => {
options?.direction.y ?? 0, this.orbit.enabled = !event.value;
options?.direction.z ?? 0 this.isDragging = !event.value;
) });
const origin = new Vector3( this.transformControl.attach(model);
options?.origin.x ?? 0, this.scene.add(this.transformControl);
options?.origin.y ?? 0, this.transformControl.setMode('rotate');
options?.origin.z ?? 0 return this;
) };
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) { public addModel = (model: any) => {
if (!this.model) return this.modelGroup = new Group();
if (!this.model.joints[jointName]) return this.modelGroup.add(model);
this.model.joints[jointName].setJointValue(angle) this.model = model;
} this.scene.add(this.modelGroup);
return this;
};
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed' public addDragControl = (updateAngle: any) => {
const highlightColor = '#FFFFFF';
const highlightMaterial = new MeshPhongMaterial({
shininess: 10,
color: highlightColor,
emissive: highlightColor,
emissiveIntensity: 0.25
});
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => { const dragControls = new PointerURDFDragControls(
const traverse = (c: Object3D) => { this.scene,
if (c.type === 'Mesh') { this.camera,
if (revert) { this.renderer.domElement
c.material = c.__origMaterial );
delete c.__origMaterial dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
} else { this.setJointValue(joint.name, angle);
c.__origMaterial = c.material updateAngle(joint.name, angle);
c.material = material };
} dragControls.onDragStart = () => {
} this.orbit.enabled = false;
this.isDragging = true;
};
dragControls.onDragEnd = () => {
this.orbit.enabled = true;
this.isDragging = false;
};
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial);
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial);
if (c === m || !this.isJoint(c)) { this.renderer.domElement.addEventListener('touchstart', (data) =>
for (let i = 0; i < c.children.length; i++) { dragControls._mouseDown(data.touches[0])
const child = c.children[i] );
if (!child.isURDFCollider) { this.renderer.domElement.addEventListener('touchmove', (data) =>
traverse(c.children[i]) dragControls._mouseMove(data.touches[0])
} );
} this.renderer.domElement.addEventListener('touchend', (data) =>
} dragControls._mouseUp(data.touches[0])
} );
traverse(m) return this;
} };
public addTransformControls = (model: Object3D) => { public toggleFog = () => {
this.transformControl = new TransformControls(this.camera, this.renderer.domElement) this.scene.fog = this.scene.fog ? null : this.fog;
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) => { private handleRobotShadow = () => {
this.modelGroup = new Group() if (this.isLoaded) return;
this.modelGroup.add(model) const intervalId = setInterval(() => {
this.model = model this.model?.traverse((c) => (c.castShadow = true));
this.scene.add(this.modelGroup) }, 10);
return this setTimeout(() => {
} clearInterval(intervalId);
}, 1000);
public addDragControl = (updateAngle: (angles: Record<string, number>) => void) => { this.isLoaded = true;
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
}
} }
+60 -42
View File
@@ -1,53 +1,71 @@
import { Result } from '$lib/utilities/result' import { Result } from '$lib/utilities/result';
import { browser } from '$app/environment'
class FileService { class FileService {
private dbPromise: Promise<Result<IDBDatabase, string>> | null = private dbName = 'fileStorageDB';
browser ? this.openDatabase() : null private dbVersion = 1;
private storeName = 'files';
private dbPromise: Promise<Result<IDBDatabase, string>>;
private async openDatabase(): Promise<Result<IDBDatabase, string>> { constructor() {
return new Promise(resolve => { this.dbPromise = this.openDatabase();
const request = indexedDB.open('fileStorageDB', 1) }
request.onupgradeneeded = () => { private async openDatabase(): Promise<Result<IDBDatabase, string>> {
request.result.createObjectStore('files') return new Promise((resolve) => {
} const request = indexedDB.open(this.dbName, this.dbVersion);
request.onsuccess = () => resolve(Result.ok(request.result))
request.onerror = () => resolve(Result.err('Error opening database'))
})
}
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> { request.onerror = () => resolve(Result.err('Error opening database'));
if (!browser || !this.dbPromise)
return Result.err('Not running in browser or DB not initialized')
const dbResult = await this.dbPromise
if (dbResult.isErr()) return Result.err('Database not initialized')
const store = dbResult.inner.transaction('files', mode).objectStore('files')
return Result.ok(store)
}
public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> { request.onsuccess = () => resolve(Result.ok(request.result));
const storeResult = await this.getStore('readwrite')
if (storeResult.isErr()) return Result.err('Failed to access store')
return new Promise(resolve => { request.onupgradeneeded = (event) => {
const request = storeResult.inner.put(file, key) const db = request.result;
request.onsuccess = () => resolve(Result.ok(request.result)) if (!db.objectStoreNames.contains(this.storeName)) {
request.onerror = () => resolve(Result.err('Failed to save file')) db.createObjectStore(this.storeName);
}) }
} };
});
}
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> { private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
const storeResult = await this.getStore('readonly') const dbResult = await this.dbPromise;
if (storeResult.isErr()) return Result.err('Failed to access store') if (dbResult.isErr()) {
return Result.err('Database not initialized properly');
}
const db = dbResult.inner;
const transaction = db.transaction(this.storeName, mode);
return Result.ok(transaction.objectStore(this.storeName));
}
return new Promise(resolve => { public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> {
const request = storeResult.inner.get(key) const storeResult = await this.getStore('readwrite');
request.onsuccess = () => if (storeResult.isErr()) {
resolve(request.result ? Result.ok(request.result) : Result.err('File not found')) return Result.err('Failed to access object store for writing');
request.onerror = () => resolve(Result.err('Failed to retrieve file')) }
}) const store = storeResult.inner;
}
return new Promise((resolve) => {
const request = store.put(file, key);
request.onsuccess = () => resolve(Result.ok(request.result));
request.onerror = () => resolve(Result.err('Failed to save file'));
});
}
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> {
const storeResult = await this.getStore('readonly');
if (storeResult.isErr()) {
return Result.err('Failed to access object store for reading');
}
const store = storeResult.inner;
return new Promise((resolve) => {
const request = store.get(key);
request.onsuccess = () =>
resolve(request.result ? Result.ok(request.result) : Result.err('File content not found'));
request.onerror = () => resolve(Result.err('Failed to retrieve file'));
});
}
} }
export default browser ? new FileService() : null export default new FileService();
+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();
+48 -62
View File
@@ -1,69 +1,55 @@
import { type Analytics } from '$lib/types/models' import { type Analytics } from '$lib/types/models';
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
const analytics_data = { let analytics_data = {
uptime: <number[]>[], uptime: <number[]>[],
free_heap: <number[]>[], free_heap: <number[]>[],
total_heap: <number[]>[], total_heap: <number[]>[],
used_heap: <number[]>[], used_heap: <number[]>[],
min_free_heap: <number[]>[], min_free_heap: <number[]>[],
max_alloc_heap: <number[]>[], max_alloc_heap: <number[]>[],
fs_used: <number[]>[], fs_used: <number[]>[],
fs_total: <number[]>[], fs_total: <number[]>[],
core_temp: <number[]>[], core_temp: <number[]>[],
cpu0_usage: <number[]>[], cpu0_usage: <number[]>[],
cpu1_usage: <number[]>[], cpu1_usage: <number[]>[],
cpu_usage: <number[]>[] cpu_usage: <number[]>[]
} };
const maxAnalyticsData = 100 const maxAnalyticsData = 100;
function createAnalytics() { function createAnalytics() {
const { subscribe, update } = writable(analytics_data) const { subscribe, update } = writable(analytics_data);
return { return {
subscribe, subscribe,
addData: (content: Analytics) => { addData: (content: Analytics) => {
update(analytics_data => ({ update((analytics_data) => ({
...analytics_data, ...analytics_data,
uptime: [...analytics_data.uptime, content.uptime].slice(-maxAnalyticsData), uptime: [...analytics_data.uptime, content.uptime].slice(-maxAnalyticsData),
free_heap: [...analytics_data.free_heap, content.free_heap / 1000].slice( free_heap: [...analytics_data.free_heap, content.free_heap / 1000].slice(-maxAnalyticsData),
-maxAnalyticsData total_heap: [...analytics_data.total_heap, content.total_heap / 1000].slice(
), -maxAnalyticsData
total_heap: [...analytics_data.total_heap, content.total_heap / 1000].slice( ),
-maxAnalyticsData used_heap: [
), ...analytics_data.used_heap,
used_heap: [ (content.total_heap - content.free_heap) / 1000
...analytics_data.used_heap, ].slice(-maxAnalyticsData),
(content.total_heap - content.free_heap) / 1000 min_free_heap: [...analytics_data.min_free_heap, content.min_free_heap / 1000].slice(
].slice(-maxAnalyticsData), -maxAnalyticsData
min_free_heap: [ ),
...analytics_data.min_free_heap, max_alloc_heap: [...analytics_data.max_alloc_heap, content.max_alloc_heap / 1000].slice(
content.min_free_heap / 1000 -maxAnalyticsData
].slice(-maxAnalyticsData), ),
max_alloc_heap: [ fs_used: [...analytics_data.fs_used, content.fs_used / 1000].slice(-maxAnalyticsData),
...analytics_data.max_alloc_heap, fs_total: [...analytics_data.fs_total, content.fs_total / 1000].slice(-maxAnalyticsData),
content.max_alloc_heap / 1000 core_temp: [...analytics_data.core_temp, content.core_temp].slice(-maxAnalyticsData),
].slice(-maxAnalyticsData), cpu0_usage: [...analytics_data.cpu0_usage, content.cpu0_usage].slice(-maxAnalyticsData),
fs_used: [...analytics_data.fs_used, content.fs_used / 1000].slice( cpu1_usage: [...analytics_data.cpu1_usage, content.cpu1_usage].slice(-maxAnalyticsData),
-maxAnalyticsData cpu_usage: [...analytics_data.cpu_usage, content.cpu_usage].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();
-67
View File
@@ -1,67 +0,0 @@
import { persistentStore } from '$lib/utilities'
import { get, type Writable } from 'svelte/store'
import Visualization from '$lib/components/Visualization.svelte'
import Stream from '$lib/components/Stream.svelte'
import ChartWidget from '$lib/components/widget/ChartWidget.svelte'
export interface WidgetConfig {
id: string | number
component: keyof typeof WidgetComponents
props?: Record<string, unknown>
}
export interface WidgetContainerConfig {
id: string | number
layout?: 'row' | 'column' | 'wrap'
header?: string
widgets: Array<WidgetConfig | WidgetContainerConfig>
}
export const isWidgetConfig = (
widget: WidgetConfig | WidgetContainerConfig
): widget is WidgetConfig => 'component' in widget
export const WidgetComponents = {
Visualization,
Stream,
ChartWidget
}
interface View {
name: string
content: WidgetContainerConfig
}
const defaultViews: View[] = [
{
name: '3D representation',
content: {
id: 'root',
layout: 'column',
widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }]
}
},
{
name: 'Stream',
content: {
id: 'root',
layout: 'column',
widgets: [{ id: 2, component: 'Stream' }]
}
},
{
name: 'Split screen',
content: {
id: 'root',
widgets: [
{ id: 2, component: 'Stream' },
{ id: 2, component: 'Visualization', props: { debug: true } }
]
}
}
]
export const views: Writable<View[]> = persistentStore('views', defaultViews)
export const selectedView = persistentStore('selected_view', get(views)[0].name)
-64
View File
@@ -1,64 +0,0 @@
import { api } from '$lib/api'
import { notifications } from '$lib/components/toasts/notifications'
import Kinematic from '$lib/kinematic'
import { persistentStore } from '$lib/utilities'
import { derived, type Writable } from 'svelte/store'
import { resolve } from '$app/paths'
let featureFlagsStore: Writable<Record<string, boolean | string>>
export function useFeatureFlags() {
if (!featureFlagsStore) {
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
api.get<Record<string, boolean>>('/api/features').then(result => {
if (result.isOk()) featureFlagsStore.set(result.inner)
else {
notifications.error('Feature flag could not be fetched', 2500)
}
})
}
return featureFlagsStore
}
const base = resolve('/')
export const variants = {
SPOTMICRO_ESP32: {
model: `${base}spot_micro.urdf.xacro`,
stl: `${base}stl.zip`,
kinematics: {
coxa: 60.5 / 100,
coxa_offset: 10 / 100,
femur: 111.7 / 100,
tibia: 118.5 / 100,
L: 207.5 / 100,
W: 78 / 100
}
},
SPOTMICRO_YERTLE: {
model: `${base}yertle.URDF`,
stl: `${base}URDF.zip`,
kinematics: {
coxa: 35 / 100,
coxa_offset: 0 / 100,
femur: 130 / 100,
tibia: 130 / 100,
L: 240 / 100,
W: 78 / 100
}
}
}
export const currentVariant = derived(useFeatureFlags(), $flagStore => {
const variantFlag = $flagStore['variant'] as string
return variantFlag && variants[variantFlag as keyof typeof variants] ?
variants[variantFlag as keyof typeof variants]
: variants.SPOTMICRO_ESP32
})
export const currentKinematic = derived(
currentVariant,
$variant => new Kinematic($variant.kinematics)
)
-24
View File
@@ -1,24 +0,0 @@
import { writable } from 'svelte/store'
export const isFullscreen = writable(false)
export function toggleFullscreen() {
isFullscreen.update(state => {
!state ? document.documentElement.requestFullscreen() : document.exitFullscreen()
return !state
})
}
export function enterFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
isFullscreen.set(true)
}
}
export function exitFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen()
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
})
+31 -35
View File
@@ -1,40 +1,36 @@
import { writable } from 'svelte/store' import { type IMU } from '$lib/types/models';
import type { IMUMsg } from '$lib/types/models' import { writable } from 'svelte/store';
const maxIMUData = 100 let imu_data = {
x: <number[]>[],
y: <number[]>[],
z: <number[]>[],
imu_temp: <number[]>[],
altitude: <number[]>[],
pressure: <number[]>[],
bmp_temp: <number[]>[]
};
export const imu = (() => { const maxIMUData = 100;
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[]
})
const addData = (content: IMUMsg) => { function createIMU() {
update(data => { const { subscribe, update } = writable(imu_data);
if (content.imu && content.imu[4]) {
data.x = [...data.x, content.imu[0]].slice(-maxIMUData)
data.y = [...data.y, content.imu[1]].slice(-maxIMUData)
data.z = [...data.z, content.imu[2]].slice(-maxIMUData)
}
if (content.mag && content.mag[4]) { return {
data.heading = [...data.heading, content.mag[3]].slice(-maxIMUData) subscribe,
} addData: (content: IMU) => {
update((imu_data) => ({
...imu_data,
x: [...imu_data.x, content.x].slice(-maxIMUData),
y: [...imu_data.y, content.y].slice(-maxIMUData),
z: [...imu_data.z, content.z].slice(-maxIMUData),
imu_temp: [...imu_data.imu_temp, content.imu_temp].slice(-maxIMUData),
altitude: [...imu_data.altitude, content.altitude].slice(-maxIMUData),
pressure: [...imu_data.pressure, content.pressure].slice(-maxIMUData),
bmp_temp: [...imu_data.bmp_temp, content.bmp_temp].slice(-maxIMUData)
}));
}
};
}
if (content.bmp && content.bmp[3]) { export const imu = createIMU();
data.pressure = [...data.pressure, content.bmp[0]].slice(-maxIMUData)
data.altitude = [...data.altitude, content.bmp[1]].slice(-maxIMUData)
data.bmp_temp = [...data.bmp_temp, content.bmp[2]].slice(-maxIMUData)
}
return data
})
}
return { subscribe, addData }
})()
+4 -9
View File
@@ -1,9 +1,4 @@
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 './telemetry'
export * from './analytics'
export * from './featureFlags'
export * from './location-store'
+29
View File
@@ -0,0 +1,29 @@
import { writable } from 'svelte/store';
export type LidarPoint = {
distance: number;
angle: number;
quality: number;
};
let lidar_data = {
points: <LidarPoint[]>[]
};
const maxLidarData = 600;
function createLidar() {
const { subscribe, update } = writable(lidar_data);
return {
subscribe,
addData: (lidarPoint: LidarPoint) => {
update((lidar_data) => ({
...lidar_data,
points: [...lidar_data.points, lidarPoint].slice(-maxLidarData)
}));
}
};
}
export const lidar = createLidar();
-6
View File
@@ -1,6 +0,0 @@
import { persistentStore } from '$lib/utilities'
import { writable } from 'svelte/store'
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
export const apiLocation =
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([]);
+24 -41
View File
@@ -1,54 +1,37 @@
import type { ControllerInput } from '$lib/types/models' import type { ControllerInput } from '$lib/models';
import { persistentStore } from '$lib/utilities/svelte-utilities' import { persistentStore } from '$lib/utilities/svelte-utilities';
import { writable, type Writable } from 'svelte/store' import { writable, type Writable } from 'svelte/store';
export const emulateModel = writable(true) export const emulateModel = writable(true);
export const jointNames = persistentStore('joint_names', <string[]>[]) export const jointNames = persistentStore('joint_names', []);
export const model = writable() export const model = writable();
export const modes = ['deactivated', 'idle', 'calibration', 'rest', 'stand', 'walk'] as const export const modes = ['deactivated', 'idle', 'calibration', 'rest', 'stand', 'crawl', 'walk'] as const;
export type Modes = (typeof modes)[number] export type Modes = (typeof modes)[number];
export enum ModesEnum { export enum ModesEnum {
Deactivated = 0, Deactivated,
Idle = 1, Idle,
Calibration = 2, Calibration,
Rest = 3, Rest,
Stand = 4, Stand,
Walk = 5 Crawl,
Walk
} }
export enum WalkGaits { export const mode: Writable<ModesEnum> = writable(ModesEnum.Walk);
Trot = 0,
Crawl = 1
}
export const walkGaits = ['trot', 'crawl'] as const export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0]);
export const walkGaitLabels: Record<WalkGaits, string> = { export const kinematicData = writable([0, 0, 0, 0, 1, 0]);
[WalkGaits.Trot]: 'Trot',
[WalkGaits.Crawl]: 'Crawl'
}
export const walkGaitToMode = (gait: WalkGaits): 'trot' | 'crawl' => {
return gait === WalkGaits.Trot ? 'trot' : 'crawl'
}
export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated)
export const walkGait: Writable<WalkGaits> = writable(WalkGaits.Trot)
export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0])
export const kinematicData = writable([0, 0, 0, 0, 1, 0])
export const input: Writable<ControllerInput> = writable({ export const input: Writable<ControllerInput> = writable({
left: { x: 0, y: 0 }, left: { x: 0, y: 0 },
right: { x: 0, y: 0 }, right: { x: 0, y: 0 },
height: 0.5, height: 50,
speed: 0.5, speed: 50,
s1: 0.05 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);
};
});
+22 -19
View File
@@ -1,27 +1,30 @@
import { writable, type Writable } from 'svelte/store' import { writable, type Writable } from 'svelte/store';
import { type angles } from '$lib/types/models' import { type angles } from '$lib/models';
export const servoAnglesOut: Writable<number[]> = writable([ export const servoAnglesOut: Writable<number[]> = writable([
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<number[]> = writable([ export const servoAngles: Writable<number[]> = writable([
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 logs = writable([] as string[]);
export const mpu = writable({ heading: 0 }) export const battery = writable({});
export const sonar = writable([0, 0]) export const mpu = writable({ heading: 0 });
export const distances = writable({}) export const sonar = writable([0, 0]);
export const distances = writable({});
export interface socketDataCollection { export interface socketDataCollection {
angles: Writable<angles> angles: Writable<angles>;
logs: Writable<string[]> logs: Writable<string[]>;
mpu: Writable<unknown> battery: Writable<unknown>;
distances: Writable<unknown> mpu: Writable<unknown>;
distances: Writable<unknown>;
} }
export const socketData = { export const socketData = {
angles: servoAngles, angles: servoAngles,
logs, logs,
mpu, battery,
distances mpu,
} distances
};
+104 -142
View File
@@ -1,160 +1,122 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
import { encode, decode } from '@msgpack/msgpack'
const socketEvents = ['open', 'close', 'error', 'message', 'unresponsive'] as const const socketEvents = ['open', 'close', 'error', 'message', 'unresponsive'] as const;
type SocketEvent = (typeof socketEvents)[number] type SocketEvent = (typeof socketEvents)[number];
type SocketMessage = [number, string?, unknown?]
let useBinary = false
const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => {
useBinary = data instanceof ArrayBuffer
try {
if (useBinary) {
return decode(new Uint8Array(data as ArrayBuffer)) as SocketMessage
}
return JSON.parse(data as string)
} catch (error) {
console.error(`Could not decode data: ${new Uint8Array(data as ArrayBuffer)} - ${error}`)
}
return null
}
const encodeMessage = (data: unknown) => {
try {
return useBinary ? encode(data) : JSON.stringify(data)
} catch (error) {
console.error(`Could not encode data: ${data} - ${error}`)
}
}
function createWebSocket() { function createWebSocket() {
const listeners = new Map<string, Set<(data?: unknown) => void>>() let listeners = new Map<string, Set<(data?: unknown) => void>>();
const { subscribe, set } = writable(false) const { subscribe, set } = writable(false);
const reconnectTimeoutTime = 5000 const reconnectTimeoutTime = 5000;
let unresponsiveTimeoutId: ReturnType<typeof setTimeout> let unresponsiveTimeoutId: number;
let reconnectTimeoutId: ReturnType<typeof setTimeout> let reconnectTimeoutId: number;
let ws: WebSocket let ws: WebSocket;
let socketUrl: string | URL let socketUrl: string | URL;
function init(url: string | URL) { function init(url: string | URL) {
socketUrl = url socketUrl = url;
connect() connect();
} }
function disconnect(reason: SocketEvent, event?: Event) { function disconnect(reason: SocketEvent, event?: Event) {
ws.close() ws.close();
set(false) set(false);
clearTimeout(unresponsiveTimeoutId) clearTimeout(unresponsiveTimeoutId);
clearTimeout(reconnectTimeoutId) clearTimeout(reconnectTimeoutId);
listeners.get(reason)?.forEach(listener => listener(event)) listeners.get(reason)?.forEach((listener) => listener(event));
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime) reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime);
} }
function connect() { function connect() {
ws = new WebSocket(socketUrl) ws = new WebSocket(socketUrl);
ws.binaryType = 'arraybuffer' ws.onopen = (ev) => {
ws.onopen = ev => { set(true);
ping() clearTimeout(reconnectTimeoutId);
useBinary = true listeners.get('open')?.forEach((listener) => listener(ev));
ping() for (const event of listeners.keys()) {
set(true) if (socketEvents.includes(event as SocketEvent)) continue;
clearTimeout(reconnectTimeoutId) subscribeToEvent(event);
listeners.get('open')?.forEach(listener => listener(ev)) }
for (const event of listeners.keys()) { };
if (socketEvents.includes(event as SocketEvent)) continue ws.onmessage = (message) => {
subscribeToEvent(event) resetUnresponsiveCheck();
} let data = message.data;
} if (data instanceof ArrayBuffer) {
ws.onmessage = frame => { listeners.get('binary')?.forEach((listener) => listener(data));
resetUnresponsiveCheck() return;
const message = decodeMessage(frame.data) }
if (!message) return data = data.substring(1);
const [, event, payload = undefined] = message
if (event) listeners.get(event)?.forEach(listener => listener(payload))
}
ws.onerror = ev => disconnect('error', ev)
ws.onclose = ev => disconnect('close', ev)
}
function unsubscribe(event: string, listener?: (data: unknown) => void) { if (!data) return;
const eventListeners = listeners.get(event)
if (!eventListeners) return
if (!eventListeners.size) { let event = data.substring(data.indexOf('/') + 1, data.indexOf('['));
unsubscribeToEvent(event) let payload = data.substring(data.indexOf('[') + 1, data.lastIndexOf(']'));
}
if (listener) {
eventListeners?.delete(listener)
} else {
listeners.delete(event)
}
}
function resetUnresponsiveCheck() { try {
clearTimeout(unresponsiveTimeoutId) payload = JSON.parse(payload);
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime) } catch (error) {}
} if (event) listeners.get(event)?.forEach((listener) => listener(payload));
};
ws.onerror = (ev) => disconnect('error', ev);
ws.onclose = (ev) => disconnect('close', ev);
}
function sendEvent(event: string, data: unknown) { function unsubscribe(event: string, listener?: (data: any) => void) {
if (!ws || ws.readyState !== WebSocket.OPEN) return let eventListeners = listeners.get(event);
send([2, event, data]) if (!eventListeners) return;
}
function unsubscribeToEvent(event: string) { if (!eventListeners.size) {
if (!ws || ws.readyState !== WebSocket.OPEN) return unsubscribeToEvent(event);
send([1, event]) }
} if (listener) {
eventListeners?.delete(listener);
} else {
listeners.delete(event);
}
}
function subscribeToEvent(event: string) { function resetUnresponsiveCheck() {
if (!ws || ws.readyState !== WebSocket.OPEN) return clearTimeout(unresponsiveTimeoutId);
send([0, event]) unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime);
} }
function send(data: unknown) { function sendEvent(event: string, data: unknown) {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!ws || ws.readyState !== WebSocket.OPEN) return;
const serialized = encodeMessage(data) ws.send(`2/${event}[${JSON.stringify(data)}]`);
if (!serialized) { }
console.error('Could not serialize data:', data)
return
}
ws.send(serialized)
}
function ping() { function unsubscribeToEvent(event: string) {
const serialized = encodeMessage([4]) if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (!serialized) { ws.send('1/' + event);
console.error('Could not serialize message') }
return
}
ws.send(serialized)
}
return { function subscribeToEvent(event: string) {
subscribe, if (!ws || ws.readyState !== WebSocket.OPEN) return;
sendEvent, ws.send('0/' + event);
init, }
on: <T>(event: string, listener: (data: T) => void): (() => void) => {
let eventListeners = listeners.get(event)
if (!eventListeners) {
if (!socketEvents.includes(event as SocketEvent)) {
subscribeToEvent(event)
}
eventListeners = new Set()
listeners.set(event, eventListeners)
}
eventListeners.add(listener as (data: unknown) => void)
return () => { return {
unsubscribe(event, listener as (data: unknown) => void) subscribe,
} sendEvent,
}, init,
off: <T>(event: string, listener?: (data: T) => void) => { on: <T>(event: string, listener: (data: T) => void): (() => void) => {
unsubscribe(event, listener as (data: unknown) => void) let eventListeners = listeners.get(event);
} if (!eventListeners) {
} if (!socketEvents.includes(event as SocketEvent)) {
subscribeToEvent(event);
}
eventListeners = new Set();
listeners.set(event, eventListeners);
}
eventListeners.add(listener as (data: any) => void);
return () => {
unsubscribe(event, listener);
};
},
off: (event: string, listener?: (data: any) => void) => {
unsubscribe(event, listener);
}
};
} }
export const socket = createWebSocket() export const socket = createWebSocket();
+39 -29
View File
@@ -1,35 +1,45 @@
import type { DownloadOTA } from '$lib/types/models' import type { Battery, DownloadOTA } from '$lib/types/models';
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
const telemetry_data = { let telemetry_data = {
rssi: { rssi: {
rssi: 0 rssi: 0
}, },
download_ota: { battery: {
status: 'none', voltage: 100,
progress: 0, current: false
error: '' },
} download_ota: {
} status: 'none',
progress: 0,
error: ''
}
};
function createTelemetry() { function createTelemetry() {
const { subscribe, update } = writable(telemetry_data) const { subscribe, set, update } = writable(telemetry_data);
return { return {
subscribe, subscribe,
setRSSI: (data: number) => { setRSSI: (data: number) => {
update(telemetry_data => ({ update((telemetry_data) => ({
...telemetry_data, ...telemetry_data,
rssi: { rssi: data } rssi: { rssi: data }
})) }));
}, },
setDownloadOTA: (data: DownloadOTA) => { setBattery: (data: Battery) => {
update(telemetry_data => ({ update((telemetry_data) => ({
...telemetry_data, ...telemetry_data,
download_ota: { status: data.status, progress: data.progress, error: data.error } battery: { voltage: data.voltage, current: data.current }
})) }));
} },
} setDownloadOTA: (data: DownloadOTA) => {
update((telemetry_data) => ({
...telemetry_data,
download_ota: { status: data.status, progress: data.progress, error: data.error }
}));
}
};
} }
export const telemetry = createTelemetry() export const telemetry = createTelemetry();
+55
View File
@@ -0,0 +1,55 @@
import { writable } from 'svelte/store';
import { goto } from '$app/navigation';
import { jwtDecode } from 'jwt-decode';
export type userProfile = {
username: string;
admin: boolean;
bearer_token: string;
};
type decodedJWT = {
username: string;
admin: boolean;
};
let empty = {
username: '',
admin: false,
bearer_token: ''
};
function createStore() {
const { subscribe, set } = writable(empty);
// retrieve store from sessionStorage / localStorage if available
const userdata = localStorage.getItem('user');
if (userdata) {
set(JSON.parse(userdata));
}
return {
subscribe,
init: (access_token: string) => {
const decoded: decodedJWT = jwtDecode(access_token);
const userdata = {
bearer_token: access_token,
username: decoded.username,
admin: decoded.admin
};
set(userdata);
// persist store in sessionStorage / localStorage
localStorage.setItem('user', JSON.stringify(userdata));
},
invalidate: () => {
console.log('Log out user');
set(empty);
// remove localStorage "user"
localStorage.removeItem('user');
// redirect to login page
goto('/');
}
};
}
export const user = createStore();
-17
View File
@@ -1,17 +0,0 @@
declare module 'three/src/math/MathUtils' {
export function generateUUID(): string
export function clamp(value: number, min: number, max: 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 lerp(x: number, y: number, t: number): number
export function smoothstep(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 randFloat(low: number, high: number): number
export function randFloatSpread(range: number): number
export function degToRad(degrees: number): number
export function radToDeg(radians: number): number
export function isPowerOfTwo(value: number): boolean
export function ceilPowerOfTwo(value: number): number
export function floorPowerOfTwo(value: number): number
}
+119 -221
View File
@@ -1,245 +1,143 @@
export enum MessageTopic {
imu = 'imu',
mode = 'mode',
input = 'input',
analytics = 'analytics',
position = 'position',
angles = 'angles',
i2cScan = 'i2cScan',
peripheralSettings = 'peripheralSettings',
otastatus = 'otastatus',
gait = 'walk_gait',
servoState = 'servoState',
servoPWM = 'servoPWM',
WiFiSettings = 'WiFiSettings',
sonar = 'sonar',
rssi = 'rssi'
}
export type vector = { x: number; y: number }
export interface ControllerInput {
left: vector
right: vector
height: number
speed: number
s1: number
}
export type GithubRelease = {
message: string
tag_name: string
assets: Array<{
name: string
browser_download_url: string
}>
}
export type angles = number[] | Int16Array
export type WifiStatus = { export type WifiStatus = {
status: number status: number;
local_ip: string local_ip: string;
mac_address: string mac_address: string;
rssi: number rssi: number;
ssid: string ssid: string;
bssid: string bssid: string;
channel: number channel: number;
subnet_mask: string subnet_mask: string;
gateway_ip: string gateway_ip: string;
dns_ip_1: string dns_ip_1: string;
dns_ip_2?: string dns_ip_2?: string;
} };
export type WifiSettings = { export type WifiSettings = {
hostname: string hostname: string;
priority_RSSI: boolean priority_RSSI: boolean;
wifi_networks: KnownNetworkItem[] wifi_networks: KnownNetworkItem[];
} };
export type NetworkList = {
networks: NetworkItem[]
}
export type KnownNetworkItem = { export type KnownNetworkItem = {
ssid: string ssid: string;
password: string password: string;
static_ip_config: boolean static_ip_config: boolean;
local_ip?: string local_ip?: string;
subnet_mask?: string subnet_mask?: string;
gateway_ip?: string gateway_ip?: string;
dns_ip_1?: string dns_ip_1?: string;
dns_ip_2?: string dns_ip_2?: string;
} };
export type NetworkItem = { export type NetworkItem = {
rssi: number rssi: number;
ssid: string ssid: string;
bssid: string bssid: string;
channel: number channel: number;
encryption_type: number encryption_type: number;
} };
export type ApStatus = { export type ApStatus = {
status: number status: number;
ip_address: string ip_address: string;
mac_address: string mac_address: string;
station_num: number station_num: number;
} };
export type ApSettings = { export type ApSettings = {
provision_mode: number provision_mode: number;
ssid: string ssid: string;
password: string password: string;
channel: number channel: number;
ssid_hidden: boolean ssid_hidden: boolean;
max_clients: number max_clients: number;
local_ip: string local_ip: string;
gateway_ip: string gateway_ip: string;
subnet_mask: string subnet_mask: string;
} };
export type NTPStatus = {
status: number;
utc_time: string;
local_time: string;
server: string;
uptime: number;
};
export type RSSI = {
rssi: number;
ssid: string;
};
export type Battery = {
voltage: number;
current: boolean;
};
export type DownloadOTA = { export type DownloadOTA = {
status: string status: string;
progress: number progress: number;
error: string error: string;
} };
export type NTPSettings = {
enabled: boolean;
server: string;
tz_label: string;
tz_format: string;
};
export type Analytics = { export type Analytics = {
max_alloc_heap: number max_alloc_heap: number;
psram_size: number psram_size: number;
free_psram: number free_psram: number;
free_heap: number free_heap: number;
total_heap: number total_heap: number;
min_free_heap: number min_free_heap: number;
core_temp: number core_temp: number;
fs_total: number fs_total: number;
fs_used: number fs_used: number;
uptime: number uptime: number;
cpu0_usage: number cpu0_usage: number;
cpu1_usage: number cpu1_usage: number;
cpu_usage: number cpu_usage: number;
} };
export type Rssi = { export type Rssi = {
rssi: number rssi: number;
ssid: string ssid: string;
} };
export type StaticSystemInformation = { export type StaticSystemInformation = {
esp_platform: string esp_platform: string;
firmware_version: string firmware_version: string;
cpu_freq_mhz: number cpu_freq_mhz: number;
cpu_type: string cpu_type: string;
cpu_rev: number cpu_rev: number;
cpu_cores: number cpu_cores: number;
sketch_size: number sketch_size: number;
free_sketch_space: number free_sketch_space: number;
sdk_version: string sdk_version: string;
arduino_version: string arduino_version: string;
flash_chip_size: number flash_chip_size: number;
flash_chip_speed: number flash_chip_speed: number;
cpu_reset_reason: string cpu_reset_reason: string;
} };
export type SystemInformation = Analytics & StaticSystemInformation export type SystemInformation = Analytics & StaticSystemInformation;
export type IMU = { export type IMU = {
x: number x: number;
y: number y: number;
z: number z: number;
heading: number imu_temp: number;
altitude: number altitude: number;
bmp_temp: number bmp_temp: number;
pressure: number pressure: number;
} };
export type IMUMsg = {
imu: [number, number, number, number, boolean]
mag: [number, number, number, number, boolean]
bmp: [number, number, number, boolean]
}
export interface I2CDevice { export interface I2CDevice {
address: number address: number;
part_number: string part_number: string;
name: string name: string;
} };
export type PinConfig = {
pin: number
mode: string
type: string
role: string
}
export type PeripheralsConfiguration = {
sda: number
scl: number
frequency: number
pins: PinConfig[]
}
export type CameraSettings = {
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 = {
name: string
channel: number
inverted: boolean
angle: number
center_angle: number
}
export type ServoConfiguration = {
is_active: boolean
servo_pwm_frequency: number
servo_oscillator_frequency: number
servos: Servo[]
}
export interface MDNSServiceQuery {
services: MDNSServiceItem[]
}
export interface MDNSServiceItem {
ip: string
port: number
name: string
}
export interface MDNSService {
service: string
protocol: string
port: number
}
export interface MDNSTxtRecord {
key: string
value: string
}
export interface MDNSStatus {
started: boolean
hostname: string
instance: string
services: MDNSService[]
global_txt_records: MDNSTxtRecord[]
}
-14
View File
@@ -1,14 +0,0 @@
declare module 'uzip' {
interface UZIP {
parse(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array>
compress(data: Record<string, Uint8Array>): Uint8Array | ArrayBuffer
compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer
decompress(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array>
decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer
encode(data: Record<string, Uint8Array>): Uint8Array | ArrayBuffer
decode(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array>
}
const uzip: UZIP
export default uzip
}
+12 -12
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);
} };
} }
-6
View File
@@ -1,6 +0,0 @@
export const daisyColor = (name: string, opacity: number = 100) => {
const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
if (opacity >= 100) return color
const alpha = Math.min(Math.max(opacity, 0), 100) / 100
return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`
}
+7 -8
View File
@@ -1,8 +1,7 @@
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 './location-utilities';
export * from './color-utilities'
@@ -0,0 +1,9 @@
export const hostname = 'localhost'; //window.location.hostname;
export const isSecure = true; // window.location.protocol === 'https:';
export const location = 'localhost:5173'; //window.location; //import.meta.env.VITE_API_URL.replace('hostname', hostname);
const socketScheme = isSecure ? 'wss://' : 'ws://';
export const socketLocation = socketScheme + location; // import.meta.env.VITE_SOCKET_URL.replace('hostname', hostname);
+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;
} };
+56 -89
View File
@@ -1,96 +1,63 @@
import { Color, LoaderUtils, Vector3 } from 'three' import { Color, LoaderUtils, Vector3 } from 'three';
import URDFLoader, { type URDFRobot } from 'urdf-loader' import URDFLoader, { type URDFRobot } from 'urdf-loader';
import { XacroLoader } from 'xacro-parser' import { XacroLoader } from 'xacro-parser';
import { Result } from '$lib/utilities' import { Result } from '$lib/utilities';
import { currentVariant, jointNames, model } from '$lib/stores'
import uzip from 'uzip'
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 loadModelAsync = async (
await cacheModelFiles() url: string
const modelRes = await loadModel(get(currentVariant).model) ): Promise<Result<[URDFRobot, string[]], string>> => {
if (modelRes.isOk()) { return new Promise((resolve, reject) => {
const [urdf, JOINT_NAME] = modelRes.inner const xacroLoader = new XacroLoader();
jointNames.set(JOINT_NAME) const urdfLoader = new URDFLoader();
model.set(urdf) urdfLoader.workingPath = LoaderUtils.extractUrlBase(url);
} else {
console.error(modelRes.inner, { exception: modelRes.exception })
}
}
export const cacheModelFiles = async () => { xacroLoader.load(
const data = await fetch(get(currentVariant).stl) 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]);
const files = uzip.parse(await data.arrayBuffer()) resolve(Result.ok([model, joints]));
} catch (error) {
resolve(Result.err('Failed to load model', error));
}
},
(error) => resolve(Result.err('Failed to load model', error))
);
});
};
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) { export const toeWorldPositions = (robot: URDFRobot) => {
const normalizedPath = path.startsWith('/') ? path : '/' + path const toe_positions: Vector3[] = [];
const resolvedUrl = resolve(normalizedPath as any) robot.traverse((child) => {
fileService?.saveFile(resolvedUrl, data) if (child.name.includes('toe') && !child.name.includes('_link')) {
fileService?.saveFile(normalizedPath, data) const worldPosition = new Vector3();
} child.getWorldPosition(worldPosition);
} toe_positions.push(worldPosition);
}
});
return toe_positions;
};
export const loadModel = async (url: string): Promise<Result<[URDFRobot, string[]], string>> => { export const footColor = () => {
const urdfLoader = new URDFLoader() 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(', ');
let xml = return new Color(`rgb(${colorStr})`);
url.endsWith('.xacro') ? await loadXacro(url) : await fetch(url).then(res => res.text()) };
if (typeof xml === 'string') {
xml = new window.DOMParser().parseFromString(xml, 'text/xml')
}
return new Promise(resolve => {
model_xml = xml
try {
const model = urdfLoader.parse(xml)
setupRobot(model)
const joints = Object.entries(model.joints)
.filter(joint => joint[1].jointType !== 'fixed')
.map(joint => joint[0])
resolve(Result.ok([model, joints]))
} catch (error) {
resolve(Result.err('Failed to load model', error))
}
})
}
const loadXacro = async (url: string): Promise<XMLDocument> =>
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
}

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