10 Commits

Author SHA1 Message Date
Rune Harlyk 3c12ef332e ️Improve eventbus static alloc 2025-07-10 11:17:50 +02:00
Rune Harlyk 481dfaf8e5 🤹 Update adapters with better handling 2025-07-08 22:28:46 +02:00
Rune Harlyk 6769ffeb20 🎨 Moving project to use event bus 2025-07-08 21:59:59 +02:00
Rune Harlyk 0586775849 Adds more messages 2025-07-08 18:17:33 +02:00
Rune Harlyk 4766f47e7e ⚰️ Removes old topic 2025-07-08 18:16:24 +02:00
Rune Harlyk d2d7d8e323 🚩 Updates build flags 2025-07-08 18:15:39 +02:00
Rune Harlyk c5155fe641 Adds servo to topics 2025-07-08 15:22:36 +02:00
Rune Harlyk f1312fb5c6 Adds hasSubscribers function to event bus 2025-07-08 15:20:26 +02:00
Rune Harlyk a592848f34 Adds new messages 2025-07-08 15:20:01 +02:00
Rune Harlyk 06b05b2dc1 🚌 Adds eventbus with bluetooth adapter 2025-07-08 01:25:35 +02:00
313 changed files with 12816 additions and 225698 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
+1
View File
@@ -2,6 +2,7 @@
.vscode/c_cpp_properties.json .vscode/c_cpp_properties.json
.vscode/launch.json .vscode/launch.json
.vscode/ipch .vscode/ipch
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
+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-*
+2 -1
View File
@@ -1,12 +1,13 @@
{ {
"useTabs": false, "useTabs": false,
"singleQuote": true, "singleQuote": true,
"tabWidth": 4, "tabWidth": 2,
"trailingComma": "none", "trailingComma": "none",
"arrowParens": "avoid", "arrowParens": "avoid",
"experimentalTernaries": true, "experimentalTernaries": true,
"printWidth": 100, "printWidth": 100,
"semi": false, "semi": false,
"svelteBracketNewLine": false,
"plugins": ["prettier-plugin-svelte"], "plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
} }
+1 -5
View File
@@ -1,7 +1,3 @@
{ {
"recommendations": [ "recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"]
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
} }
+6 -6
View File
@@ -1,8 +1,8 @@
declare module 'app-env' { declare module "app-env" {
interface ENV { interface ENV {
VITE_USE_HOST_NAME: boolean VITE_USE_HOST_NAME: boolean;
} }
const appEnv: ENV const appEnv: ENV;
export default appEnv export default appEnv;
} }
+65 -63
View File
@@ -1,65 +1,67 @@
{ {
"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", "build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "pnpm run test:integration && pnpm run test:unit", "test": "pnpm run test:integration && pnpm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"test:integration": "playwright test", "test:integration": "playwright test",
"test:unit": "vitest" "test:unit": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi": "^1.1.64",
"@iconify-json/tabler": "^1.2.23", "@iconify-json/tabler": "^1.1.109",
"@playwright/test": "^1.56.0", "@playwright/test": "^1.49.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.46.4", "@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@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.45.1",
"jsdom": "^27.0.0", "jsdom": "^24.0.0",
"prettier": "^3.6.2", "prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.2.6",
"svelte": "^5.39.11", "svelte": "^5.0.0",
"svelte-check": "^4.3.3", "svelte-check": "^4.0.0",
"svelte-focus-trap": "^1.2.0", "svelte-focus-trap": "^1.2.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.0.12",
"tslib": "^2.8.1", "tslib": "^2.6.1",
"typescript": "^5.9.3", "typescript": "^5.5.0",
"unplugin-icons": "^22.4.2", "unplugin-icons": "^0.18.5",
"vite": "^7.1.9", "vite": "^6.2.1",
"vitest": "^3.2.4" "vitest": "^1.2.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.2", "@niku/vite-env-caster": "^1.0.2",
"@niku/vite-env-caster": "^1.1.2", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-auto": "^6.1.1", "@tailwindcss/vite": "^4.0.12",
"@tailwindcss/vite": "^4.1.14", "chart.js": "^4.4.2",
"chart.js": "^4.5.0", "compare-versions": "^6.1.0",
"compare-versions": "^6.1.1", "cross-env": "^7.0.3",
"cross-env": "^10.1.0", "daisyui": "^5.0.0",
"daisyui": "^5.2.0", "jwt-decode": "^4.0.0",
"nipplejs": "^0.10.2", "nipplejs": "^0.10.1",
"svelte-dnd-list": "^0.1.8", "svelte-dnd-list": "^0.1.8",
"svelte-modals": "^2.0.1", "svelte-modals": "^2.0.0",
"three": "^0.180.0", "three": "^0.162.0",
"urdf-loader": "^0.12.6", "urdf-loader": "^0.12.1",
"uzip": "^0.20201231.0", "uzip": "^0.20201231.0",
"xacro-parser": "^0.3.10" "xacro-parser": "^0.3.9",
}, "@types/msgpack-lite": "^0.1.11",
"packageManager": "pnpm@9.3.0" "msgpack-lite": "^0.1.26"
},
"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;
+2232 -1904
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -23,14 +23,6 @@
--base-content: oklch(0.3 0.012 256); --base-content: oklch(0.3 0.012 256);
} }
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
#nipple_0_0, #nipple_0_0,
#nipple_1_1 { #nipple_1_1 {
z-index: 10 !important; z-index: 10 !important;
+8 -8
View File
@@ -1,13 +1,13 @@
// See https://kit.svelte.dev/docs/types#app // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
} }
export {} export {};
+11 -14
View File
@@ -1,17 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/logo512.png" /> <link rel="icon" href="%sveltekit.assets%/logo512.png" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" />
name="viewport" <meta name="apple-mobile-web-app-capable" content="yes" />
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" <meta name="mobile-web-app-capable" content="yes" />
/> %sveltekit.head%
<meta name="apple-mobile-web-app-capable" content="yes" /> </head>
<meta name="mobile-web-app-capable" content="yes" /> <body data-sveltekit-preload-data="hover">
%sveltekit.head% <div style="display: contents">%sveltekit.body%</div>
</head> </body>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html> </html>
+7
View File
@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
+34 -33
View File
@@ -1,22 +1,22 @@
import { get } from 'svelte/store' import { get } from 'svelte/store';
import { Err, Ok, type Result } from './utilities' import { Err, Ok, type Result } from './utilities';
import { apiLocation } from './stores' import { location } from './stores';
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');
} }
} }
@@ -26,8 +26,8 @@ async function sendRequest<TResponse>(
data?: unknown, data?: unknown,
params?: RequestInit params?: RequestInit
): Promise<Result<TResponse, Error>> { ): Promise<Result<TResponse, Error>> {
endpoint = resolveUrl(endpoint) endpoint = resolveUrl(endpoint);
const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined;
const request = { const request = {
...params, ...params,
@@ -38,42 +38,43 @@ async function sendRequest<TResponse>(
Authorization: 'Basic', Authorization: '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 { function resolveUrl(url: string): string {
if (url.startsWith('http') || !get(apiLocation)) return url if (url.startsWith('http') || !get(location)) return url;
const protocol = window.location.protocol const protocol = window.location.protocol;
return `${protocol}//${get(apiLocation)}${url.startsWith('/') ? '' : '/'}${url}` return `${protocol}//${get(location)}${url.startsWith('/') ? '' : '/'}${url}`;
} }
export class ApiError extends Error { export class ApiError extends Error {
constructor(public readonly response: Response) { constructor(public readonly response: Response) {
super(`${response.status}`) super(`${response.status}`);
} }
} }
+7 -7
View File
@@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing';
import { Down } from './icons' import { Down } from './icons';
function openCollapsible() { function openCollapsible() {
open = !open open = !open;
if (open) { if (open) {
opened() opened();
} else { } else {
closed() closed();
} }
} }
let { icon, title, children, open, opened, closed, class: klass } = $props() let { icon, title, children, open, opened, closed, class: klass } = $props();
</script> </script>
<div class="{klass} relative grid w-full max-w-2xl self-center overflow-hidden"> <div class="{klass} relative grid w-full max-w-2xl self-center overflow-hidden">
+35 -40
View File
@@ -1,48 +1,43 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap' import { focusTrap } from 'svelte-focus-trap'
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition'
import { Cancel, Check } from '$lib/components/icons' import { Cancel, Check } from '$lib/components/icons'
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals' import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
let { let {
isOpen, isOpen,
title, title,
message, message,
onConfirm, onConfirm,
labels = { labels = {
cancel: { label: 'Cancel', icon: Cancel }, cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'OK', icon: Check } confirm: { label: 'OK', icon: Check }
} }
}: ModalProps = $props() }: ModalProps = $props()
</script> </script>
{#if isOpen} {#if isOpen}
{@const SvelteComponent = labels?.confirm.icon} {@const SvelteComponent = labels?.confirm.icon}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
<div <div
role="dialog" class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg">
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" <h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
transition:fly={{ y: 50 }} <div class="divider my-2"></div>
use:exitBeforeEnter <p class="text-base-content mb-1 text-start">{message}</p>
use:focusTrap <div class="divider my-2"></div>
> <div class="flex justify-end gap-2">
<div <button class="btn btn-error inline-flex items-center" onclick={() => modals.close()}>
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg" <labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span>
> </button>
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2> <button class="btn btn-primary inline-flex items-center" onclick={onConfirm}>
<div class="divider my-2"></div> <SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span>
<p class="text-base-content mb-1 text-start">{message}</p> </button>
<div class="divider my-2"></div> </div>
<div class="flex justify-end gap-2">
<button
class="btn btn-error inline-flex items-center"
onclick={() => modals.close()}
>
<labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span>
</button>
<button class="btn btn-primary inline-flex items-center" onclick={onConfirm}>
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span>
</button>
</div>
</div>
</div> </div>
</div>
{/if} {/if}
@@ -1,61 +1,61 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap' import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { telemetry } from '$lib/stores/telemetry' import { telemetry } from '$lib/stores/telemetry';
import { Cancel } from './icons' import { Cancel } from './icons';
import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals' import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals';
// provided by <Modals /> // provided by <Modals />
interface Props { interface Props {
isOpen: boolean isOpen: boolean;
} }
let { isOpen }: Props = $props() let { isOpen }: Props = $props();
let updating = $state(true) let updating = $state(true);
let progress = $state(0) let progress = $state(0);
$effect(() => { $effect(() => {
if ($telemetry.download_ota.status == 'progress') { if ($telemetry.download_ota.status == 'progress') {
progress = $telemetry.download_ota.progress progress = $telemetry.download_ota.progress;
} }
}) });
$effect(() => { $effect(() => {
if ($telemetry.download_ota.status == 'error') { if ($telemetry.download_ota.status == 'error') {
updating = false updating = false;
} }
}) });
let message = $state('Preparing ...') let message = $state('Preparing ...');
$effect(() => { $effect(() => {
if ($telemetry.download_ota.status == 'progress') { if ($telemetry.download_ota.status == 'progress') {
message = 'Downloading ...' message = 'Downloading ...';
} else if ($telemetry.download_ota.status == 'error') { } else if ($telemetry.download_ota.status == 'error') {
message = $telemetry.download_ota.error message = $telemetry.download_ota.error;
} else if ($telemetry.download_ota.status == 'finished') { } else if ($telemetry.download_ota.status == 'finished') {
message = 'Restarting ...' message = 'Restarting ...';
progress = 0 progress = 0;
// Reload page after 5 sec // Reload page after 5 sec
setTimeout(() => { setTimeout(() => {
modals.closeAll() modals.closeAll();
location.reload() location.reload();
}, 5000) }, 5000);
} }
}) });
onBeforeClose(() => { onBeforeClose(() => {
if (updating) { if (updating) {
// prevents modal from closing // prevents modal from closing
return false return false;
} else { } else {
$telemetry.download_ota.status = 'idle' $telemetry.download_ota.status = 'idle';
$telemetry.download_ota.error = '' $telemetry.download_ota.error = '';
$telemetry.download_ota.progress = 0 $telemetry.download_ota.progress = 0;
return true return true;
} }
}) });
</script> </script>
{#if isOpen} {#if isOpen}
@@ -89,8 +89,8 @@
class="btn btn-warning text-warning-content inline-flex flex-none items-center" class="btn btn-warning text-warning-content inline-flex flex-none items-center"
disabled={updating} disabled={updating}
onclick={() => { onclick={() => {
modals.closeAll() modals.closeAll();
location.reload() location.reload();
}} }}
> >
<Cancel class="mr-2 h-5 w-5" /><span>Close</span></button <Cancel class="mr-2 h-5 w-5" /><span>Close</span></button
+17 -9
View File
@@ -1,18 +1,26 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap' import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { Check } from './icons' import { Check } from './icons';
import { exitBeforeEnter, type ModalProps } from 'svelte-modals' import { exitBeforeEnter } from 'svelte-modals';
// provided by <Modals />
interface Props {
isOpen: boolean;
title: string;
message: string;
onDismiss: any;
dismiss?: any;
}
let { let {
isOpen, isOpen,
title, title,
message, message,
onDismiss, onDismiss,
labels = { dismiss = { label: 'Dismiss', icon: Check }
dismiss: { label: 'Dismiss', icon: Check } }: Props = $props();
}
}: ModalProps = $props()
</script> </script>
{#if isOpen} {#if isOpen}
@@ -35,7 +43,7 @@
class="btn btn-warning text-warning-content inline-flex items-center" class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onDismiss} onclick={onDismiss}
> >
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span> <dismiss.icon class="mr-2 h-5 w-5" /><span>{dismiss.label}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1,78 +1,78 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import * as THREE from 'three' import * as THREE from 'three'
import { imu } from '$lib/stores/imu' import { imu } from '$lib/stores/imu'
import SceneBuilder from '$lib/sceneBuilder' import SceneBuilder from '$lib/sceneBuilder'
let canvas: HTMLCanvasElement let canvas: HTMLCanvasElement = $state()
let sceneBuilder: SceneBuilder let sceneBuilder: SceneBuilder
let cube: THREE.Mesh let cube: THREE.Mesh
let targetRotation = new THREE.Euler() let targetRotation = new THREE.Euler()
let lastUpdateTime = 0 let lastUpdateTime = 0
const LERP_SPEED = 5 // rotations per second const LERP_SPEED = 5 // rotations per second
const initThreeJS = () => { const initThreeJS = () => {
sceneBuilder = new SceneBuilder() sceneBuilder = new SceneBuilder()
.addRenderer({ canvas: canvas, antialias: true, alpha: true }) .addRenderer({ canvas: canvas, antialias: true, alpha: true })
.addPerspectiveCamera({ x: 2, y: 0, z: 2 }) .addPerspectiveCamera({ x: 2, y: 0, z: 2 })
.addOrbitControls(1, 10, false) .addOrbitControls(1, 10, false)
.addAmbientLight({ color: 0x404040, intensity: 0.5 }) .addAmbientLight({ color: 0x404040, intensity: 0.5 })
.addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 }) .addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 })
.fillParent() .fillParent()
const geometry = new THREE.BoxGeometry(1, 1, 1) const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshPhongMaterial({ const material = new THREE.MeshPhongMaterial({
color: 0x00ff00, color: 0x00ff00,
transparent: true, transparent: true,
opacity: 0.8 opacity: 0.8
}) })
cube = new THREE.Mesh(geometry, material) cube = new THREE.Mesh(geometry, material)
sceneBuilder.scene.add(cube) sceneBuilder.scene.add(cube)
sceneBuilder.addRenderCb(() => { sceneBuilder.addRenderCb(() => {
if (!cube) return if (!cube) return
const currentTime = performance.now() const currentTime = performance.now()
const deltaTime = (currentTime - lastUpdateTime) / 1000 // convert to seconds const deltaTime = (currentTime - lastUpdateTime) / 1000 // convert to seconds
lastUpdateTime = currentTime lastUpdateTime = currentTime
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime) const lerpFactor = Math.min(1, LERP_SPEED * deltaTime)
cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor) cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor)
cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor) cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor)
cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor) cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor)
}) })
sceneBuilder.startRenderLoop() sceneBuilder.startRenderLoop()
}
const updateOrientation = () => {
if (!cube) return
const y = -$imu.x[$imu.x.length - 1] || 0
const x = $imu.y[$imu.y.length - 1] || 0
const z = -$imu.z[$imu.z.length - 1] || 0
targetRotation.set(
THREE.MathUtils.degToRad(x),
THREE.MathUtils.degToRad(y),
THREE.MathUtils.degToRad(z)
)
}
onMount(() => {
initThreeJS()
})
onDestroy(() => {
sceneBuilder?.renderer?.dispose()
})
$effect(() => {
if ($imu) {
updateOrientation()
} }
})
const updateOrientation = () => {
if (!cube) return
const y = -$imu.x[$imu.x.length - 1] || 0
const x = $imu.y[$imu.y.length - 1] || 0
const z = -$imu.z[$imu.z.length - 1] || 0
targetRotation.set(
THREE.MathUtils.degToRad(x),
THREE.MathUtils.degToRad(y),
THREE.MathUtils.degToRad(z)
)
}
onMount(() => {
initThreeJS()
})
onDestroy(() => {
sceneBuilder?.renderer?.dispose()
})
$effect(() => {
if ($imu) {
updateOrientation()
}
})
</script> </script>
<div class="h-60 w-60 border-2 border-base-300 rounded-md"> <div class="h-60 w-60 border-2 border-base-300 rounded-md">
<canvas class="w-full h-full" bind:this={canvas}></canvas> <canvas class="w-full h-full" bind:this={canvas}></canvas>
</div> </div>
+49 -65
View File
@@ -1,76 +1,60 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
import { Down } from './icons' import { Down } from './icons'
interface Props { interface Props {
open?: boolean open?: boolean
collapsible?: boolean collapsible?: boolean
icon?: import('svelte').Snippet icon?: import('svelte').Snippet
title?: import('svelte').Snippet title?: import('svelte').Snippet
children?: import('svelte').Snippet children?: import('svelte').Snippet
right?: import('svelte').Snippet right?: import('svelte').Snippet
} }
let { let { open = $bindable(true), collapsible = true, icon, title, children, right }: Props = $props()
open = $bindable(true),
collapsible = true,
icon,
title,
children,
right
}: Props = $props()
</script> </script>
{#if collapsible} {#if collapsible}
<div
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg">
<div <div
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg" 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">
<div {@render icon?.()}
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium" {@render title?.()}
> </span>
<span class="inline-flex items-baseline"> <button
{@render icon?.()} class="btn btn-circle btn-ghost btn-sm"
{@render title?.()} onclick={() => {
</span> open = !open
<button }}>
class="btn btn-circle btn-ghost btn-sm" <Down
onclick={() => { class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open ?
open = !open 'rotate-180'
}} : ''}" />
> </button>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {(
open
) ?
'rotate-180'
: ''}"
/>
</button>
</div>
{#if open}
<div
class="flex flex-col gap-2 p-4 pt-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
{@render children?.()}
</div>
{/if}
</div> </div>
{#if open}
<div
class="flex flex-col gap-2 p-4 pt-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
{@render children?.()}
</div>
{/if}
</div>
{:else} {:else}
<div
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg">
<div <div
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg" 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">
<div {@render icon?.()}
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium" {@render title?.()}
> </span>
<span class="inline-flex items-baseline"> {@render right?.()}
{@render icon?.()}
{@render title?.()}
</span>
{@render right?.()}
</div>
<div class="flex flex-col gap-2 p-4 pt-0">
{@render children?.()}
</div>
</div> </div>
<div class="flex flex-col gap-2 p-4 pt-0">
{@render children?.()}
</div>
</div>
{/if} {/if}
+4 -3
View File
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Loader } from './icons' import { Loader } from "./icons";
</script> </script>
<div class="flex h-full w-full flex-col items-center justify-center p-6"> <div class="flex h-full w-full flex-col items-center justify-center p-6">
<Loader class="text-primary h-14 w-auto animate-spin stroke-2" /> <Loader class="text-primary h-14 w-auto animate-spin stroke-2" />
<p class="text-xl">Loading...</p> <p class="text-xl">Loading...</p>
</div> </div>
+35 -37
View File
@@ -1,47 +1,45 @@
<script lang="ts"> <script lang="ts">
import type { ComponentType } from 'svelte' type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning' const {
icon,
title,
description = '',
variant = 'primary',
class: klass = '',
children = null
} = $props<{
icon?: any
title: string
description?: string | number
variant?: Variant
class?: string
children?: () => any
}>()
const { const Icon = $derived(icon)
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 variants: Record<Variant, [string, string]> = { const variantKey: Variant = (variant as Variant) in variants ? (variant as Variant) : 'primary'
success: ['bg-success', 'text-success-content'], const [bgColor, textColor] = variants[variantKey]
error: ['bg-error', 'text-error-content'],
primary: ['bg-primary', 'text-primary-content'],
info: ['bg-info', 'text-info-content'],
warning: ['bg-warning', 'text-warning-content']
}
const variantKey: Variant = (variant as Variant) in variants ? (variant as Variant) : 'primary'
const [bgColor, textColor] = variants[variantKey]
</script> </script>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2 {klass}"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2 {klass}">
{#if icon} {#if icon}
<div class="mask mask-hexagon {bgColor} h-auto w-10 flex-none"> <div class="mask mask-hexagon {bgColor} h-auto w-10 flex-none">
<Icon class="{textColor} h-auto w-full scale-75" /> <Icon class="{textColor} h-auto w-full scale-75" />
</div>
{/if}
<div class="grow">
<div class="font-bold">{title}</div>
<div class="text-sm opacity-75 grow">{description}</div>
</div> </div>
{@render children?.()} {/if}
<div class="grow">
<div class="font-bold">{title}</div>
<div class="text-sm opacity-75 grow">{description}</div>
</div>
{@render children?.()}
</div> </div>
+4 -4
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte' import { onDestroy } from 'svelte';
import { apiLocation } from '$lib/stores' import { location } from '$lib/stores';
let source = $state(`${$apiLocation}/api/camera/stream`) let source = $state(`${$location}/api/camera/stream`);
onDestroy(() => (source = '#')) onDestroy(() => (source = '#'));
</script> </script>
<div class="w-full h-full"> <div class="w-full h-full">
+29 -31
View File
@@ -1,37 +1,35 @@
<script> <script>
import { flip } from 'svelte/animate' import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import { error, info, success, warning } from './icons' import { error, info, success, warning } from './icons';
/** @type {{theme?: any, icon?: any}} */
let { /** @type {{theme?: any, icon?: any}} */
theme = { let { theme = {
error: 'alert-error', error: 'alert-error',
success: 'alert-success', success: 'alert-success',
warning: 'alert-warning', warning: 'alert-warning',
info: 'alert-info' info: 'alert-info'
}, }, icon = {
icon = { error: error,
error: error, success: success,
success: success, warning: warning,
warning: warning, info: info
info: info } } = $props();
}
} = $props()
</script> </script>
<div class="toast toast-end mr-4"> <div class="toast toast-end mr-4">
{#each $notifications as notification (notification.id)} {#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]} {@const SvelteComponent = icon[notification.type]}
<div <div
animate:flip={{ duration: 400 }} animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}" class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }} in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }} out:fly={{ x: 100, duration: 400 }}
> >
<SvelteComponent class="h-6 w-6 shrink-0" /> <SvelteComponent class="h-6 w-6 shrink-0" />
<span>{notification.message}</span> <span>{notification.message}</span>
</div> </div>
{/each} {/each}
</div> </div>
+321 -322
View File
@@ -1,338 +1,337 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { import {
Mesh, BufferGeometry,
MeshBasicMaterial, Line,
type Object3D, LineBasicMaterial,
SphereGeometry, Mesh,
Vector3, MeshBasicMaterial,
type Object3DEventMap, Object3D,
Color SphereGeometry,
} from 'three' Vector3,
import { type NormalBufferAttributes,
ModesEnum, type Object3DEventMap
kinematicData, } from 'three'
mode, import {
model, ModesEnum,
outControllerData, kinematicData,
servoAnglesOut, mode,
servoAngles, model,
mpu, outControllerData,
jointNames, servoAnglesOut,
currentKinematic, servoAngles,
walkGait, mpu,
walkGaitToMode jointNames
} from '$lib/stores' } from '$lib/stores'
import { populateModelCache, throttler, getToeWorldPositions } from '$lib/utilities' import {
import SceneBuilder from '$lib/sceneBuilder' extractFootColor,
import { lerp, degToRad } from 'three/src/math/MathUtils' populateModelCache,
import { GUI } from 'three/addons/libs/lil-gui.module.min.js' throttler,
import { type body_state_t } from '$lib/kinematic' getToeWorldPositions
import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait' } from '$lib/utilities'
import { radToDeg } from 'three/src/math/MathUtils.js' import SceneBuilder from '$lib/sceneBuilder'
import type { URDFRobot } from 'urdf-loader' import { lerp, degToRad } from 'three/src/math/MathUtils'
import { get } from 'svelte/store' import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import Kinematic, { type body_state_t } from '$lib/kinematic'
import {
BezierState,
CalibrationState,
EightPhaseWalkState,
FourPhaseWalkState,
IdleState,
RestState,
StandState
} from '$lib/gait'
import { radToDeg } from 'three/src/math/MathUtils.js'
import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store'
interface Props { interface Props {
defaultColor?: string | null sky?: boolean
orbit?: boolean orbit?: boolean
panel?: boolean panel?: boolean
debug?: boolean debug?: boolean
ground?: boolean ground?: boolean
}
let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props()
let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement = $state()
let currentModelAngles: number[] = new Array(12).fill(0)
let modelTargetAngles: number[] = new Array(12).fill(0)
let gui_panel: GUI
let Throttler = new throttler()
let feet_trace = new Array(4).fill([])
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
let target: Object3D<Object3DEventMap>
let target_position = { x: 0, z: 0, yaw: 0 }
let kinematic = new Kinematic()
let planners = {
[ModesEnum.Deactivated]: new IdleState(),
[ModesEnum.Idle]: new IdleState(),
[ModesEnum.Calibration]: new CalibrationState(),
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Crawl]: new EightPhaseWalkState(),
[ModesEnum.Walk]: new BezierState()
}
let lastTick = performance.now()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
let body_state = {
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.5,
zm: 0,
feet: kinematic.getDefaultFeetPos()
}
let settings = {
'Internal kinematic': true,
'Robot transform controls': false,
'Auto orient robot': true,
'Trace feet': debug,
'Target position': false,
'Trace points': 30,
'Fix camera on robot': true,
'Smooth motion': true,
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.7,
zm: 0,
Background: 'black'
}
onMount(async () => {
await populateModelCache()
await createScene()
servoAngles.subscribe(updateAnglesFromStore)
if (panel) createPanel()
})
onDestroy(() => {
canvas.remove()
gui_panel?.destroy()
})
const updateAnglesFromStore = (angles: number[]) => {
if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return
modelTargetAngles = angles
}
const createPanel = () => {
gui_panel = new GUI({ width: 310 })
gui_panel.close()
gui_panel.domElement.id = 'three-gui-panel'
const general = gui_panel.addFolder('General')
general.add(settings, 'Internal kinematic')
general.add(settings, 'Robot transform controls')
general.add(settings, 'Auto orient robot')
const kinematic = gui_panel.addFolder('Kinematics')
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
const visibility = gui_panel.addFolder('Visualization')
visibility.add(settings, 'Trace feet')
visibility.add(settings, 'Trace points', 1, 1000, 1)
visibility.add(settings, 'Target position')
visibility.add(settings, 'Smooth motion')
visibility.addColor(settings, 'Background')
}
const updateKinematicPosition = () => {
kinematicData.set([
settings.omega,
settings.phi,
settings.psi,
settings.xm,
settings.ym,
settings.zm
])
}
const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
Throttler.throttle(() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))), 100)
}
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(8, 30, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop()
if (ground) sceneManager.addGroundPlane()
const geometry = new SphereGeometry(0.1, 32, 16)
const material = new MeshBasicMaterial({ color: 0xffff00 })
target = new Mesh(geometry, material)
sceneManager.scene.add(target)
if (debug) {
sceneManager.addDragControl(updateAngles)
}
if (sky) sceneManager.addSky()
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry()
const material = new LineBasicMaterial({ color: extractFootColor() })
const line = new Line(geometry, material)
trace_lines.push(geometry)
sceneManager.scene.add(line)
}
}
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
feet_trace = new Array(4).fill([])
return
} }
let { trace_lines.forEach((line, i) => {
defaultColor = '#0091ff', feet_trace[i].push(foot_positions[i])
orbit = false, feet_trace[i] = feet_trace[i].slice(-settings['Trace points'])
panel = true, line.setFromPoints(feet_trace[i])
debug = false,
ground = true
}: Props = $props()
let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement
let currentModelAngles: number[] = new Array(12).fill(0)
let modelTargetAngles: number[] = new Array(12).fill(0)
let gui_panel: GUI
let Throttler = new throttler()
let target: Object3D<Object3DEventMap>
let target_position = { x: 0, z: 0, yaw: 0 }
let kinematic = get(currentKinematic)
let planners = {
[ModesEnum.Deactivated]: new IdleState(),
[ModesEnum.Idle]: new IdleState(),
[ModesEnum.Calibration]: new CalibrationState(),
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Walk]: new BezierState()
}
let lastTick = performance.now()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
let body_state = {
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.5,
zm: 0,
feet: kinematic.getDefaultFeetPos(),
cumulative_x: 0,
cumulative_y: 0,
cumulative_z: 0,
cumulative_roll: 0,
cumulative_pitch: 0,
cumulative_yaw: 0
}
let settings = {
'Internal kinematic': true,
'Robot transform controls': false,
'Auto orient robot': true,
'Trace feet': debug,
'Target position': false,
'Trace points': 30,
'Fix camera on robot': true,
'Smooth motion': true,
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.7,
zm: 0,
Background: defaultColor
}
onMount(async () => {
await populateModelCache()
await createScene()
servoAngles.subscribe(updateAnglesFromStore)
walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait)))
if (panel) createPanel()
}) })
}
onDestroy(() => { const calculate_kinematics = () => {
canvas.remove() if (sceneManager.isDragging || !settings['Internal kinematic']) return
gui_panel?.destroy() const position: body_state_t = {
}) omega: settings.omega,
phi: settings.phi,
const updateAnglesFromStore = (angles: number[]) => { psi: settings.psi,
if (sceneManager.isDragging) return xm: settings.xm,
if (settings['Internal kinematic']) return ym: settings.ym,
modelTargetAngles = angles zm: settings.zm,
feet: body_state.feet
} }
const createPanel = () => { let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
gui_panel = new GUI({ width: 310 }) modelTargetAngles = new_angles
gui_panel.close() }
gui_panel.domElement.id = 'three-gui-panel'
const general = gui_panel.addFolder('General') const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
general.add(settings, 'Internal kinematic') if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
general.add(settings, 'Robot transform controls') robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
general.add(settings, 'Auto orient robot')
const kinematic = gui_panel.addFolder('Kinematics') robot.position.z = smooth(robot.position.z, -settings.xm, 0.1)
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen() robot.position.x = smooth(robot.position.x, -settings.zm, 0.1)
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
const visibility = gui_panel.addFolder('Visualization') robot.rotation.z = smooth(robot.rotation.z, degToRad(-settings.phi + $mpu.heading + 90), 0.1)
visibility.add(settings, 'Trace feet') robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1)
visibility.add(settings, 'Trace points', 1, 1000, 1) robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1)
visibility.add(settings, 'Target position') }
visibility.add(settings, 'Smooth motion')
visibility.addColor(settings, 'Background').onChange(setSceneBackground).listen() const update_camera = (robot: URDFRobot) => {
if (!settings['Fix camera on robot']) return
sceneManager.orbit.target = robot.position.clone()
}
const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end
}
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(outControllerData)
const data = {
stop: controlData[0],
lx: controlData[1],
ly: controlData[2],
rx: controlData[3],
ry: controlData[4],
h: controlData[5],
s: controlData[6],
s1: controlData[7]
}
body_state.ym = ((data.h + 127) * 0.35) / 100
let planner = planners[get(mode)]
const delta = performance.now() - lastTick
lastTick = performance.now()
body_state = planner.step(body_state, data, delta)
settings.omega = body_state.omega
settings.phi = body_state.phi
settings.psi = body_state.psi
settings.xm = body_state.xm
settings.ym = body_state.ym
settings.zm = body_state.zm
}
const update_robot_position = (robot: URDFRobot) => {
if (!settings['Robot transform controls']) return
settings.omega = radToDeg(robot.rotation.y)
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90
settings.psi = radToDeg(robot.rotation.x) + 90
settings.xm = robot.position.z * 100
settings.zm = -robot.position.x * 100
}
const updateTargetPosition = () => {
target.visible = settings['Target position']
target.position.x = smooth(target.position.x, target_position.x, 0.5)
target.position.z = smooth(target.position.z, target_position.z, 0.5)
}
const render = () => {
const robot = sceneManager.model
if (!robot) return
const toes = getToeWorldPositions(robot)
renderTraceLines(toes)
update_camera(robot)
update_gait()
calculate_kinematics()
update_robot_position(robot)
sceneManager.transformControl.showX = settings['Robot transform controls']
sceneManager.transformControl.showY = settings['Robot transform controls']
sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i],
0.1
)
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
} }
const updateKinematicPosition = () => { orient_robot(robot, toes)
kinematicData.set([ updateTargetPosition()
settings.omega, }
settings.phi,
settings.psi,
settings.xm,
settings.ym,
settings.zm
])
}
const setSceneBackground = (c: string | null) => (sceneManager.scene.background = new Color(c!))
const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
Throttler.throttle(
() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))),
100
)
}
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(2, 20, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model as URDFRobot)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop()
if (ground) sceneManager.addGroundPlane()
const geometry = new SphereGeometry(0.1, 32, 16)
const material = new MeshBasicMaterial({ color: 0xffff00 })
target = new Mesh(geometry, material)
sceneManager.scene.add(target)
if (debug) {
sceneManager.addDragControl(angles => {
Object.entries(angles).forEach(([name, angle]) => {
updateAngles(name, angle)
})
})
}
if (defaultColor) setSceneBackground(settings['Background'] || defaultColor)
}
const calculate_kinematics = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const position: body_state_t = {
omega: settings.omega,
phi: settings.phi,
psi: settings.psi,
xm: settings.xm,
ym: settings.ym,
zm: settings.zm,
feet: body_state.feet,
cumulative_x: body_state.cumulative_x,
cumulative_y: body_state.cumulative_y,
cumulative_z: body_state.cumulative_z,
cumulative_roll: body_state.cumulative_roll,
cumulative_pitch: body_state.cumulative_pitch,
cumulative_yaw: body_state.cumulative_yaw
}
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
modelTargetAngles = new_angles
}
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
const cumulativeYaw = body_state.cumulative_yaw
const cosYaw = Math.cos(cumulativeYaw)
const sinYaw = Math.sin(cumulativeYaw)
const rotatedXm = settings.xm * cosYaw - settings.zm * sinYaw
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) => {
if (!settings['Fix camera on robot']) return
sceneManager.orbit.target = robot.position.clone()
}
const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end
}
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(outControllerData)
const data = {
lx: controlData[0],
ly: controlData[1],
rx: controlData[2],
ry: controlData[3],
h: controlData[4],
s: controlData[5],
s1: controlData[6]
}
body_state.ym = data.h
let planner = planners[get(mode)]
const delta = performance.now() - lastTick
lastTick = performance.now()
body_state = planner.step(body_state, data, delta)
settings.omega = body_state.omega
settings.phi = body_state.phi
settings.psi = body_state.psi
settings.xm = body_state.xm
settings.ym = body_state.ym
settings.zm = body_state.zm
}
const update_robot_position = (robot: URDFRobot) => {
if (!settings['Robot transform controls']) return
settings.omega = radToDeg(robot.rotation.y)
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90
settings.psi = radToDeg(robot.rotation.x) + 90
settings.xm = robot.position.z * 100
settings.zm = -robot.position.x * 100
}
const updateTargetPosition = () => {
target.visible = settings['Target position']
target.position.x = smooth(target.position.x, target_position.x, 0.5)
target.position.z = smooth(target.position.z, target_position.z, 0.5)
}
const render = () => {
const robot = sceneManager.model
if (!robot) return
const toes = getToeWorldPositions(robot)
update_camera(robot)
update_gait()
calculate_kinematics()
update_robot_position(robot)
sceneManager.transformControl.showX = settings['Robot transform controls']
sceneManager.transformControl.showY = settings['Robot transform controls']
sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i],
0.1
)
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
}
orient_robot(robot, toes)
updateTargetPosition()
}
</script> </script>
<svelte:window onresize={sceneManager.fillParent} /> <svelte:window onresize={sceneManager.fillParent} />
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { MdiEyeOffOutline, MdiEyeOutline } from '../icons' import { MdiEyeOffOutline, MdiEyeOutline } from "../icons";
interface Props { interface Props {
show?: boolean show?: boolean;
value?: string value?: string;
id?: string id?: string;
} }
let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props() let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props();
let type = $derived(show ? 'text' : 'password') let type = $derived(show ? 'text' : 'password');
const handleInput = (e: Event) => (value = (e.target as HTMLInputElement).value) const handleInput = (e: any) => value = e.target.value
const togglePassword = () => (show = !show) const togglePassword = () => show = !show
</script> </script>
<label class="input input-bordered flex items-center gap-2"> <label class="input input-bordered flex items-center gap-2">
@@ -1,35 +1,34 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
min?: number min?: number
max?: number max?: number
step?: number step?: number
value?: number value?: any
oninput?: (value: number) => void oninput?: any
} }
let { let {
min = 0, min = 0,
max = 100, max = 100,
step = 1, step = 1,
value = $bindable((max - min) / 2), value = $bindable((max - min) / 2),
...rest ...rest
}: Props = $props() }: Props = $props()
</script> </script>
<input <input
type="range" type="range"
style="writing-mode: vertical-lr; direction: rtl" style="writing-mode: vertical-lr; direction: rtl"
class="cursor-pointer" class="cursor-pointer"
{min} {min}
{max} {max}
{step} {step}
bind:value bind:value
{...rest} {...rest} />
/>
<style> <style>
input[type='range']::-webkit-slider-runnable-track { input[type='range']::-webkit-slider-runnable-track {
background: oklch(var(--p) / 1); background: oklch(var(--p) / 1);
border-radius: var(--rounded-box, 1rem); border-radius: var(--rounded-box, 1rem);
} }
</style> </style>
+2 -2
View File
@@ -1,2 +1,2 @@
export { default as PasswordInput } from './InputPassword.svelte' export { default as PasswordInput } from './InputPassword.svelte';
export { default as VerticalSlider } from './VerticalSlider.svelte' export { default as VerticalSlider } from './VerticalSlider.svelte';
+2 -2
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
children?: import('svelte').Snippet children?: import('svelte').Snippet;
} }
let { children }: Props = $props() let { children }: Props = $props();
</script> </script>
<div class="box-border overflow-hidden flex-1"> <div class="box-border overflow-hidden flex-1">
@@ -1,41 +1,37 @@
<script lang="ts"> <script lang="ts">
import WidgetContainer from './WidgetContainer.svelte' import WidgetContainer from './WidgetContainer.svelte';
import { import { WidgetComponents, type WidgetContainerConfig, isWidgetConfig } from '$lib/stores/application';
WidgetComponents, import Widget from './Widget.svelte';
type WidgetContainerConfig,
isWidgetConfig
} from '$lib/stores/application'
import Widget from './Widget.svelte'
interface Props { interface Props {
container: WidgetContainerConfig container: WidgetContainerConfig;
} }
let { container }: Props = $props() let { container }: Props = $props();
</script> </script>
<div class="w-full h-full flex flex-col overflow-hidden"> <div class="w-full h-full flex flex-col overflow-hidden">
<div <div
class="flex w-full h-full" class="flex w-full h-full"
class:flex-row={container.layout === 'column'} class:flex-row={container.layout === 'column'}
class:flex-col={container.layout === 'row'} class:flex-col={container.layout === 'row'}
class:flex-wrap={container.layout === 'wrap'} class:flex-wrap={container.layout === 'wrap'}
> >
{#each container.widgets as widget, index (widget.id + '-' + index)} {#each container.widgets as widget, index (widget.id + '-' + index)}
<Widget> <Widget>
{#if isWidgetConfig(widget)} {#if isWidgetConfig(widget)}
{@const SvelteComponent = WidgetComponents[widget.component]} {@const SvelteComponent = WidgetComponents[widget.component]}
<SvelteComponent {...widget.props} /> <SvelteComponent {...widget.props} />
{:else if widget.widgets} {:else if widget.widgets}
<WidgetContainer container={widget} /> <WidgetContainer container={widget} />
{/if} {/if}
</Widget> </Widget>
{#if index !== container.widgets.length - 1} {#if index !== container.widgets.length - 1}
<div <div
class="divider bg-base-300 m-0" class="divider bg-base-300 m-0"
class:divider-horizontal={container.layout === 'column'} class:divider-horizontal={container.layout === 'column'}
></div> ></div>
{/if} {/if}
{/each} {/each}
</div> </div>
</div> </div>
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Github } from '../icons' import { Github } from "../icons";
interface Props { interface Props {
github: { url: string; version: string; active?: boolean; href?: string } github: any;
} }
let { github }: Props = $props() let { github }: Props = $props();
</script> </script>
{#if github.active} {#if github.active}
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer"> <a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
<Github class="h-5 w-5" /> <Github class="h-5 w-5" />
</a> </a>
{/if} {/if}
@@ -1,15 +1,14 @@
<script> <script>
import logo from '$lib/assets/logo512.png' import logo from '$lib/assets/logo512.png';
import { resolve } from '$app/paths'
/** @type {{appName: any}} */ /** @type {{appName: any}} */
let { appName } = $props() let { appName } = $props();
</script> </script>
<a <a
href={resolve('/')} href="/"
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]" class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
> >
<img src={logo} alt="Logo" class="h-12 w-12" /> <img src={logo} alt="Logo" class="h-12 w-12" />
<h1 class="px-4 text-2xl font-bold">{appName}</h1> <h1 class="px-4 text-2xl font-bold">{appName}</h1>
</a> </a>
+168 -179
View File
@@ -1,200 +1,189 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
import { base } from '$app/paths' import { useFeatureFlags } from '$lib/stores/featureFlags'
import { useFeatureFlags } from '$lib/stores/featureFlags' import GithubButton from '../menu/GithubButton.svelte'
import GithubButton from '../menu/GithubButton.svelte' import LogoButton from '../menu/LogoButton.svelte'
import LogoButton from '../menu/LogoButton.svelte' import MenuList from '../menu/MenuList.svelte'
import MenuList from '../menu/MenuList.svelte' import {
import { Connection,
Connection, Settings,
Settings, MdiController,
MdiController, Devices,
Devices, Camera,
Camera, Rotate3d,
Rotate3d, MotorOutline,
MotorOutline, Health,
Health, Folder,
Folder, Update,
Update, WiFi,
WiFi, Router,
Router, AP,
AP, Copyright,
Copyright, Metrics,
Metrics, DNS
DNS } from '$lib/components/icons'
} from '$lib/components/icons' import appEnv from 'app-env'
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
const features = useFeatureFlags() const features = useFeatureFlags()
const appName = page.data.app_name const appName = page.data.app_name
const copyright = page.data.copyright const copyright = page.data.copyright
const github = { href: 'https://github.com/' + page.data.github, active: true } const github = { href: 'https://github.com/' + page.data.github, active: true }
import type { ComponentType } from 'svelte' type menuItem = {
title: string
icon: ConstructorOfATypedSvelteComponent
href?: string
feature: boolean
active?: boolean
submenu?: menuItem[]
}
type menuItem = { let menuItems = $state<menuItem[]>([])
title: string
icon: ComponentType
href?: string
feature: boolean
active?: boolean
submenu?: menuItem[]
}
function withBase(path: string) { $effect(() => {
return `${base}${path.startsWith('/') ? path : '/' + path}` menuItems = [
} {
title: 'Connection',
icon: WiFi,
href: '/connection',
feature: !appEnv.VITE_USE_HOST_NAME
},
{
title: 'Controller',
icon: MdiController,
href: '/controller',
feature: true
},
{
title: 'Peripherals',
icon: Devices,
feature: true,
submenu: [
{
title: 'I2C',
icon: Connection,
href: '/peripherals/i2c',
feature: true
},
{
title: 'Camera',
icon: Camera,
href: '/peripherals/camera',
feature: $features.camera
},
{
title: 'Servo',
icon: MotorOutline,
href: '/peripherals/servo',
feature: true
},
{
title: 'IMU',
icon: Rotate3d,
href: '/peripherals/imu',
feature: $features.imu || $features.mag || $features.bmp
}
]
},
{
title: 'WiFi',
icon: WiFi,
feature: true,
submenu: [
{
title: 'WiFi Station',
icon: Router,
href: '/wifi/sta',
feature: true
},
{
title: 'Access Point',
icon: AP,
href: '/wifi/ap',
feature: true
},
{
title: 'mDNS',
icon: DNS,
href: '/wifi/mdns',
feature: true
}
]
},
{
title: 'System',
icon: Settings,
feature: true,
submenu: [
{
title: 'System Status',
icon: Health,
href: '/system/status',
feature: true
},
{
title: 'File System',
icon: Folder,
href: '/system/filesystem',
feature: true
},
{
title: 'System Metrics',
icon: Metrics,
href: '/system/metrics',
feature: true
},
{
title: 'Firmware Update',
icon: Update,
href: '/system/update',
feature: $features.ota || $features.upload_firmware || $features.download_firmware
}
]
}
] as menuItem[]
})
let menuItems = $state<menuItem[]>([]) const { menuClicked } = $props()
$effect(() => { function setActiveMenuItem(targetTitle: string) {
menuItems = [ menuItems.forEach(item => {
{ item.active = item.title === targetTitle
title: 'Connection', item.submenu?.forEach(subItem => {
icon: WiFi, subItem.active = subItem.title === targetTitle
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[]
}) })
menuItems = menuItems
menuClicked()
}
const { menuClicked } = $props() $effect(() => {
setActiveMenuItem(page.data.title)
})
function setActiveMenuItem(targetTitle: string) { const updateMenu = (event: any) => {
menuItems.forEach(item => { setActiveMenuItem(event.details)
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> </script>
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content"> <div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
<LogoButton {appName} /> <LogoButton {appName} />
<MenuList <MenuList
{menuItems} {menuItems}
select={updateMenu} select={updateMenu}
class="grow flex-nowrap overflow-y-auto overflow-x-hidden" class="grow flex-nowrap overflow-y-auto overflow-x-hidden"
level="0" level="0" />
/>
<div class="divider my-0"></div> <div class="divider my-0"></div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<GithubButton {github} /> <GithubButton {github} />
<div class="flex items-center justify-end text-sm gap-2"> <div class="flex items-center justify-end text-sm gap-2">
<Copyright class="h-4 w-4" />{copyright} <Copyright class="h-4 w-4" />{copyright}
</div>
</div> </div>
</div>
</div> </div>
+40 -48
View File
@@ -1,56 +1,48 @@
<script lang="ts"> <script lang="ts">
import MenuList from './MenuList.svelte' import MenuList from './MenuList.svelte'
import type { ComponentType } from 'svelte' type MenuItem = {
title: string
icon: ConstructorOfATypedSvelteComponent
href?: string
feature: boolean
active?: boolean
submenu?: MenuItem[]
}
type MenuItem = { let { level, menuItems, select, class: klass } = $props()
title: string
icon: ComponentType
href?: string
feature: boolean
active?: boolean
submenu?: MenuItem[]
}
let { level, menuItems, select, class: klass } = $props() const selectMenuItem = (title: string) => {
select(title)
const selectMenuItem = (title: string) => { }
select(title)
}
</script> </script>
<ul class={klass + ' menu w-full'}> <ul class={klass + ' menu w-full'}>
{#each menuItems as MenuItem[] as menuItem (menuItem.title)} {#each menuItems as MenuItem[] as menuItem, i (menuItem.title)}
{#if menuItem.feature} {#if menuItem.feature}
<li> <li>
{#if menuItem.submenu} {#if menuItem.submenu}
<details open={menuItem.submenu.some(subItem => subItem.active)}> <details open={menuItem.submenu.some(subItem => subItem.active)}>
<summary class="font-bold"> <summary class="font-bold">
<menuItem.icon class="h-6 w-6" /> <menuItem.icon class="h-6 w-6" />
{menuItem.title} {menuItem.title}
</summary> </summary>
<div class="pl-4"> <div class="pl-4">
<MenuList <MenuList menuItems={menuItem.submenu} level={level + 1} {select} class={klass} />
menuItems={menuItem.submenu} </div>
level={level + 1} </details>
{select} {:else}
class={klass} <a
/> href={menuItem.href}
</div> class="font-bold"
</details> class:bg-base-100={menuItem.active}
{:else} class:text-lg={level === 0}
<a class:text-md={level === 1}
href={menuItem.href} onclick={() => selectMenuItem(menuItem.title)}>
class="font-bold" <menuItem.icon class="h-6 w-6" />
class:bg-base-100={menuItem.active} {menuItem.title}
class:text-lg={level === 0} </a>
class:text-md={level === 1}
onclick={() => selectMenuItem(menuItem.title)}
>
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</a>
{/if}
</li>
{/if} {/if}
{/each} </li>
{/if}
{/each}
</ul> </ul>
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { isFullscreen, toggleFullscreen } from '$lib/stores' import { isFullscreen, toggleFullscreen } from '$lib/stores';
import { MdiFullscreenExit, MdiFullscreen } from '../icons' import { MdiFullscreenExit, MdiFullscreen } from '../icons';
const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen) const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen);
</script> </script>
<button onclick={toggleFullscreen}> <button onclick={toggleFullscreen}>
@@ -1,33 +1,33 @@
<script lang="ts"> <script lang="ts">
import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from '../icons' import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from "../icons";
interface Props { interface Props {
showDBm?: boolean showDBm?: boolean;
rssi?: number rssi?: number;
} }
let { showDBm = false, rssi = 0 }: Props = $props() let { showDBm = false, rssi = 0 }: Props = $props();
const getWiFiIcon = () => { const getWiFiIcon = () => {
if (rssi === 0) return WifiOff if (rssi === 0) return WifiOff;
if (rssi >= -55) return WiFi if (rssi >= -55) return WiFi;
if (rssi >= -75) return WiFi2 if (rssi >= -75) return WiFi2;
if (rssi >= -85) return WiFi1 if (rssi >= -85) return WiFi1;
return WiFi0 return WiFi0;
} };
const SvelteComponent = $derived(getWiFiIcon()) const SvelteComponent = $derived(getWiFiIcon());
</script> </script>
<div class="indicator"> <div class="indicator">
<div class="tooltip tooltip-left" data-tip={rssi + ' dBm'}> <div class="tooltip tooltip-left" data-tip={rssi + " dBm"}>
{#if showDBm} {#if showDBm}
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs"> <span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
{rssi} dBm {rssi} dBm
</span> </span>
{/if} {/if}
<div class="h-7 w-7"> <div class="h-7 w-7">
<SvelteComponent class="absolute inset-0 h-full w-full" /> <SvelteComponent class="absolute inset-0 h-full w-full" />
</div> </div>
</div> </div>
</div> </div>
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { useFeatureFlags } from '$lib/stores' import { useFeatureFlags } from '$lib/stores';
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { api } from '$lib/api' import { api } from '$lib/api';
import { Cancel, Power } from '../icons' import { Cancel, Power } from '../icons';
const features = useFeatureFlags() const features = useFeatureFlags();
const postSleep = async () => await api.post('/api/system/sleep') const postSleep = async () => await api.post('/api/system/sleep');
const confirmSleep = () => { const confirmSleep = () => {
modals.open(ConfirmDialog, { modals.open(ConfirmDialog, {
@@ -18,11 +18,11 @@
confirm: { label: 'Switch Off', icon: Power } confirm: { label: 'Switch Off', icon: Power }
}, },
onConfirm: () => { onConfirm: () => {
modals.close() modals.close();
postSleep() postSleep();
} }
}) });
} };
</script> </script>
{#if $features.sleep} {#if $features.sleep}
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { mode, modes } from '$lib/stores' import { mode, modes } from "$lib/stores";
const deactivate = async () => { const deactivate = async () => {
mode.set(modes.indexOf('deactivated')) mode.set(modes.indexOf('deactivated'));
} };
</script> </script>
<button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button> <button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { MdiWeatherSunny, MdiMoonAndStars } from '../icons' import { MdiWeatherSunny, MdiMoonAndStars } from "../icons";
</script> </script>
<label class="swap swap-rotate"> <label class="swap swap-rotate">
@@ -1,18 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Hamburger } from '../icons' import {Hamburger} from '../icons'
import { resolve } from '$app/paths'
</script> </script>
<div class="topbar absolute left-0 top-0 w-full z-20 flex justify-between bg-zinc-800"> <div class="topbar absolute left-0 top-0 w-full z-20 flex justify-between bg-zinc-800">
<div class="flex gap-2 p-2"> <div class="flex gap-2 p-2">
<a href={resolve('/')}> <a href="/">
<Hamburger class="h-8 w-8" /> <Hamburger class="h-8 w-8"/>
</a> </a>
</div> </div>
</div> </div>
<style> <style>
.topbar { .topbar {
height: 50px; height: 50px;
} }
</style> </style>
@@ -1,80 +1,80 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state';
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte' import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import { compareVersions } from 'compare-versions' import { compareVersions } from 'compare-versions';
import { onMount } from 'svelte' import { onMount } from 'svelte';
import { api } from '$lib/api' import { api } from '$lib/api';
import type { GithubRelease } from '$lib/types/models' import type { GithubRelease } from '$lib/types/models';
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags';
import { Cancel, CloudDown, Firmware } from '../icons' import { Cancel, CloudDown, Firmware } from '../icons';
const features = useFeatureFlags() const features = useFeatureFlags();
interface Props { interface Props {
update?: boolean update?: boolean;
} }
let { update = $bindable(false) }: Props = $props() let { update = $bindable(false) }: Props = $props();
let firmwareVersion: string = $state('') let firmwareVersion: string = $state('');
let firmwareDownloadLink: string = $state('') let firmwareDownloadLink: string = $state('');
async function getGithubAPI() { async function getGithubAPI() {
const headers = { const headers = {
accept: 'application/vnd.github+json', accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28' 'X-GitHub-Api-Version': '2022-11-28'
} };
const result = await api.get<GithubRelease>( const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${page.data.github}/releases/latest`, `https://api.github.com/repos/${page.data.github}/releases/latest`,
{ headers } { headers }
) );
if (result.inner.message === '404' || result.inner.message == 'Not Found') { if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository') console.warn('Error: Could not find releases in the repository');
return return;
} }
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner);
return return;
} }
const results = result.inner const results = result.inner;
update = false update = false;
firmwareVersion = '' firmwareVersion = '';
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) { if (compareVersions(results.tag_name, $features.firmware_version) === 1) {
// iterate over assets and find the correct one // iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) { for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin // check if the asset is of type *.bin
if ( if (
results.assets[i].name.includes('.bin') && results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($features.firmware_built_target as string) results.assets[i].name.includes($features.firmware_built_target)
) { ) {
update = true update = true;
firmwareVersion = results.tag_name firmwareVersion = results.tag_name;
firmwareDownloadLink = results.assets[i].browser_download_url firmwareDownloadLink = results.assets[i].browser_download_url;
notifications.info('Firmware update available.', 5000) notifications.info('Firmware update available.', 5000);
} }
} }
} }
} }
async function postGithubDownload(url: string) { async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url }) const result = await api.post('/api/downloadUpdate', { download_url: url });
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner);
return return;
} }
} }
onMount(async () => { onMount(async () => {
if ($features.download_firmware) { if ($features.download_firmware) {
await getGithubAPI() await getGithubAPI();
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour setInterval(async () => await getGithubAPI(), 60 * 60 * 1000); // once per hour
} }
}) });
function confirmGithubUpdate(url: string) { function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, { modals.open(ConfirmDialog, {
@@ -85,12 +85,12 @@
confirm: { label: 'Update', icon: CloudDown } confirm: { label: 'Update', icon: CloudDown }
}, },
onConfirm: () => { onConfirm: () => {
postGithubDownload(url) postGithubDownload(url);
modals.open(GithubUpdateDialog, { modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll() onConfirm: () => modals.closeAll()
}) });
} }
}) });
} }
</script> </script>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { selectedView, views } from '$lib/stores/application' import { selectedView, views } from "$lib/stores/application";
import Selector from '../widget/Selector.svelte' import Selector from "../widget/Selector.svelte";
</script> </script>
<Selector bind:selectedOption={$selectedView} options={$views.map(v => v.name)} /> <Selector bind:selectedOption={$selectedView} options={$views.map((v) => v.name)} />
@@ -1,38 +1,38 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
import { telemetry } from '$lib/stores/telemetry' import { telemetry } from '$lib/stores/telemetry'
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte' import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte' import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte'
import SleepButton from './SleepButton.svelte' import SleepButton from './SleepButton.svelte'
import ThemeButton from './ThemeButton.svelte' import ThemeButton from './ThemeButton.svelte'
import FullscreenButton from './FullscreenButton.svelte' import FullscreenButton from './FullscreenButton.svelte'
import StopButton from './StopButton.svelte' import StopButton from './StopButton.svelte'
import ViewSelector from './ViewSelector.svelte' import ViewSelector from './ViewSelector.svelte'
import { Hamburger } from '../icons' import { Hamburger } from '../icons'
</script> </script>
<div class="navbar bg-base-300 sticky top-0 z-10 h-12 min-h-fit drop-shadow-lg lg:h-16 gap-2 pr-0"> <div class="navbar bg-base-300 sticky top-0 z-10 h-12 min-h-fit drop-shadow-lg lg:h-16 gap-2 pr-0">
<div class="flex flex-1 gap-2"> <div class="flex flex-1 gap-2">
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button"> <label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button">
<Hamburger class="h-6 w-auto" /> <Hamburger class="h-6 w-auto" />
</label> </label>
{#if page.data.title === 'Controller'} {#if page.data.title === 'Controller'}
<ViewSelector /> <ViewSelector />
{:else} {:else}
<h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1> <h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1>
{/if} {/if}
</div> </div>
<UpdateIndicator /> <UpdateIndicator />
<FullscreenButton /> <FullscreenButton />
<ThemeButton /> <ThemeButton />
<RssiIndicator rssi={$telemetry.rssi.rssi} /> <RssiIndicator rssi={$telemetry.rssi.rssi} />
<SleepButton /> <SleepButton />
<StopButton /> <StopButton />
</div> </div>
+29 -31
View File
@@ -1,37 +1,35 @@
<script> <script>
import { flip } from 'svelte/animate' import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import { error, info, success, warning } from '../icons' import { error, info, success, warning } from '../icons';
/** @type {{theme?: any, icon?: any}} */
let { /** @type {{theme?: any, icon?: any}} */
theme = { let { theme = {
error: 'alert-error', error: 'alert-error',
success: 'alert-success', success: 'alert-success',
warning: 'alert-warning', warning: 'alert-warning',
info: 'alert-info' info: 'alert-info'
}, }, icon = {
icon = { error: error,
error: error, success: success,
success: success, warning: warning,
warning: warning, info: info
info: info } } = $props();
}
} = $props()
</script> </script>
<div class="toast toast-end mr-4 z-20"> <div class="toast toast-end mr-4 z-20">
{#each $notifications as notification (notification.id)} {#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]} {@const SvelteComponent = icon[notification.type]}
<div <div
animate:flip={{ duration: 400 }} animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}" class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }} in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }} out:fly={{ x: 100, duration: 400 }}
> >
<SvelteComponent class="h-6 w-6 shrink-0" /> <SvelteComponent class="h-6 w-6 shrink-0" />
<span>{notification.message}</span> <span>{notification.message}</span>
</div> </div>
{/each} {/each}
</div> </div>
+30 -30
View File
@@ -1,42 +1,42 @@
import { writable } from 'svelte/store' import { writable, derived, type Writable } from 'svelte/store';
type StateType = 'info' | 'success' | 'warning' | 'error' type StateType = 'info' | 'success' | 'warning' | 'error';
type State = { type State = {
id: string id: string;
type: StateType type: StateType;
message: string message: string;
} };
function createNotificationStore() { function createNotificationStore() {
const state: State[] = [] const state: State[] = [];
const notifications = writable(state) const notifications = writable(state);
const { subscribe } = notifications const { subscribe } = notifications;
function send(message: string, type: StateType = 'info', timeout: number) { function send(message: string, type: StateType = 'info', timeout: number) {
const id = generateId() const id = generateId();
setTimeout(() => { setTimeout(() => {
notifications.update(state => { notifications.update((state) => {
return state.filter(n => n.id !== id) return state.filter((n) => n.id !== id);
}) });
}, timeout) }, timeout);
notifications.update(state => { notifications.update((state) => {
return [...state, { id, type, message }] return [...state, { id, type, message }];
}) });
} }
return { return {
subscribe, subscribe,
send, send,
error: (msg: string, timeout: number = 4000) => send(msg, 'error', timeout), error: (msg: string, timeout: number) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout), warning: (msg: string, timeout: number) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout), info: (msg: string, timeout: number) => send(msg, 'info', timeout),
success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout) success: (msg: string, timeout: number) => send(msg, 'success', timeout)
} };
} }
function generateId() { function generateId() {
return '_' + Math.random().toString(36).substr(2, 9) return '_' + Math.random().toString(36).substr(2, 9);
} }
export const notifications = createNotificationStore() export const notifications = createNotificationStore();
@@ -1,97 +1,98 @@
<script lang="ts"> <script lang="ts">
import { daisyColor } from '$lib/utilities' import { daisyColor } from "$lib/utilities";
import { Chart, registerables } from 'chart.js' import { Chart, registerables } from "chart.js";
import { onMount } from 'svelte' import { onMount } from "svelte";
import { cubicOut } from 'svelte/easing' import { cubicOut } from "svelte/easing";
import { slide } from 'svelte/transition' import { slide } from "svelte/transition";
let chartElement: HTMLCanvasElement let chartElement: HTMLCanvasElement = $state();
let chart: Chart<'line', number[], number> let chart: Chart;
interface Props { interface Props {
label: string label: any;
data: number[] data: number[];
title: string title: any;
} }
let { label, data, title }: Props = $props() let { label, data, title }: Props = $props();
Chart.register(...registerables) Chart.register(...registerables);
onMount(() => { onMount(() => {
chart = new Chart(chartElement, { chart = new Chart(chartElement, {
type: 'line', type: 'line',
data: { data: {
labels: data, labels: data,
datasets: [ datasets: [
{ {
label, label,
borderColor: daisyColor('--p'), borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--p', 50),
borderWidth: 2, borderWidth: 2,
data, data,
yAxisID: 'y' yAxisID: 'y'
} },
] ]
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false
} }
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0
} }
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--bc', 10)
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc')
}, },
display: false display: false
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: title, text: title,
color: daisyColor('--bc'), color: daisyColor('--bc'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold'
} }
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: 100, max: 100,
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--bc', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc')
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--bc', 10) }
} }
} }
} }
}) });
setInterval(() => { setInterval(() => {
chart.data.labels = data chart.data.labels = data
chart.data.datasets[0].data = data chart.data.datasets[0].data = data
}, 500) }, 500);
}) })
</script> </script>
<div class="w-full h-full overflow-x-auto"> <div class="w-full h-full overflow-x-auto">
<div <div
class="flex w-full flex-col space-y-1 h-60" class="flex w-full flex-col space-y-1 h-60"
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
options?: string[] options?: string[];
selectedOption?: string selectedOption?: string;
change?: () => void change: () => void;
[key: string]: unknown [key: string]: any;
} }
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props() let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props();
</script> </script>
<select <select
+376 -422
View File
@@ -1,493 +1,447 @@
import { get } from 'svelte/store'
import type { body_state_t } from './kinematic' import type { body_state_t } from './kinematic'
import { currentKinematic } from './stores/featureFlags' import Kinematic from './kinematic'
import { fromInt8 } from './utilities'
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
s1: number
} }
export abstract class GaitState { export abstract class GaitState {
protected abstract name: string protected abstract name: string
protected dt = 0.02 protected dt = 0.02
protected body_state!: body_state_t protected body_state!: body_state_t
protected gait_state: gait_state_t = { protected gait_state: gait_state_t = {
step_height: 0.4, step_height: 0.4,
step_x: 0, step_x: 0,
step_z: 0, step_z: 0,
step_angle: 0, step_angle: 0,
step_velocity: 1, step_velocity: 1,
step_depth: 0.002 step_depth: 0.002
}
public get default_feet_pos() {
return new Kinematic().getDefaultFeetPos()
}
protected get default_height() {
return 0.5
}
begin() {
console.log('Starting', this.name)
}
end() {
console.log('Ending', this.name)
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
this.map_command(command)
this.body_state = body_state
this.dt = dt / 1000
return body_state
}
map_command(command: ControllerCommand) {
const newCommand = {
step_height: 0.4 + (command.s1 / 128 + 1) / 2,
step_x: Math.floor(fromInt8(command.ly, -1, 1) * 10) / 10,
step_z: -(Math.floor(fromInt8(command.lx, -1, 1) * 10) / 10),
step_velocity: command.s / 128 + 1,
step_angle: command.rx / 128,
step_depth: 0.002
} }
public get default_feet_pos() { this.gait_state = newCommand
return get(currentKinematic).getDefaultFeetPos() }
}
protected get default_height() {
return 0.5
}
begin() {
console.log('Starting', this.name)
}
end() {
console.log('Ending', this.name)
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
this.map_command(command)
this.body_state = body_state
this.dt = dt / 1000
if (body_state.cumulative_x === undefined) {
body_state.cumulative_x = 0
body_state.cumulative_y = 0
body_state.cumulative_z = 0
body_state.cumulative_roll = 0
body_state.cumulative_pitch = 0
body_state.cumulative_yaw = 0
}
return body_state
}
map_command(command: 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 { export class CalibrationState extends GaitState {
protected name = 'Calibration' protected name = 'Calibration'
// eslint-disable-next-line @typescript-eslint/no-unused-vars step(body_state: body_state_t, command: ControllerCommand, 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 * 10
body_state.xm = 0 body_state.zm = 0
body_state.ym = this.default_height * 10 body_state.feet = this.default_feet_pos
body_state.zm = 0 return body_state
body_state.feet = this.default_feet_pos }
return body_state
}
} }
export class RestState extends GaitState { export class RestState extends GaitState {
protected name = 'Rest' protected name = 'Rest'
// 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 }
}
abstract class PhaseGaitState extends GaitState {
protected tick = 0
protected phase = 0
protected phase_time = 0
protected abstract num_phases: number
protected abstract phase_speed_factor: number
protected abstract swing_stand_ratio: number
protected contact_phases!: number[][]
protected shifts!: number[][]
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super.step(body_state, command, dt)
this.update_phase()
this.update_body_position()
this.update_feet_positions()
return this.body_state
}
update_phase() {
this.phase_time += this.dt * this.phase_speed_factor * this.gait_state.step_velocity
if (this.phase_time >= 1) {
this.phase += 1
if (this.phase == this.num_phases) this.phase = 0
this.phase_time = 0
} }
}
update_body_position() {
if (this.num_phases === 4) return
const shift = this.shifts[Math.floor(this.phase / 2)]
this.body_state.xm += (shift[0] - this.body_state.xm) * this.dt * 4
this.body_state.zm += (shift[2] - this.body_state.zm) * this.dt * 4
}
update_feet_positions() {
for (let i = 0; i < 4; i++) {
this.body_state.feet[i] = this.update_foot_position(i)
}
}
update_foot_position(index: number): number[] {
const contact = this.contact_phases[index][this.phase]
return contact ? this.stand(index) : this.swing(index)
}
stand(index: number): number[] {
const delta_pos = [
-this.gait_state.step_x * this.dt * this.swing_stand_ratio,
0,
-this.gait_state.step_z * this.dt * this.swing_stand_ratio
]
this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0]
this.body_state.feet[index][1] = this.default_feet_pos[index][1]
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2]
return this.body_state.feet[index]
}
swing(index: number): number[] {
const delta_pos = [this.gait_state.step_x * this.dt, 0, this.gait_state.step_z * this.dt]
if (this.gait_state.step_x == 0) {
delta_pos[0] =
(this.default_feet_pos[index][0] - this.body_state.feet[index][0]) * this.dt * 8
}
if (this.gait_state.step_z == 0) {
delta_pos[2] =
(this.default_feet_pos[index][2] - this.body_state.feet[index][2]) * this.dt * 8
}
this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0]
this.body_state.feet[index][1] =
this.default_feet_pos[index][1] + sin(this.phase_time * Math.PI) * this.gait_state.step_height
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2]
return this.body_state.feet[index]
}
}
export class FourPhaseWalkState extends PhaseGaitState {
protected name = 'Four phase walk'
protected num_phases = 4
protected phase_speed_factor = 6
protected contact_phases = [
[1, 0, 1, 1],
[1, 1, 1, 0],
[1, 1, 1, 0],
[1, 0, 1, 1]
]
protected swing_stand_ratio = 1 / (this.num_phases - 1)
begin() {
super.begin()
}
end() {
super.end()
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt)
}
}
export class EightPhaseWalkState extends PhaseGaitState {
protected name = 'Eight phase walk'
protected num_phases = 8
protected phase_speed_factor = 4
protected contact_phases = [
[1, 0, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 0, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 0, 1, 1, 1, 1]
]
protected shifts = [
[-0.05, 0, -0.2],
[0.3, 0, 0.2],
[-0.05, 0, 0.2],
[0.3, 0, -0.2]
]
protected swing_stand_ratio = 1 / (this.num_phases - 1)
begin() {
super.begin()
}
end() {
super.end()
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt)
}
} }
export class BezierState extends GaitState { export class BezierState extends GaitState {
protected name = 'Bezier' protected name = 'Bezier'
protected phase = 0 protected phase = 0
protected phase_num = 0 protected phase_num = 0
protected step_length = 0 protected step_length: number = 0
protected stand_offset = 0.75 offset = [0, 0.5, 0.5, 0]
protected mode: 'crawl' | 'trot' = 'trot'
protected speed_factor = 1
offset = [0, 0.5, 0.75, 0.25]
protected shift_start_pos = { x: 0, z: 0 } begin() {
protected shift_target_pos = { x: 0, z: 0 } super.begin()
protected shift_start_time = 0 }
protected current_shift_leg = -1
protected last_body_state: body_state_t | null = null end() {
protected cumulative_position = { x: 0, y: 0, z: 0 } super.end()
protected cumulative_orientation = { roll: 0, pitch: 0, yaw: 0 } }
constructor() { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super() super.step(body_state, command, dt)
this.set_mode(this.mode) this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2)
if (this.gait_state.step_x < 0) {
this.step_length = -this.step_length
} }
this.update_phase()
this.update_feet_positions()
return this.body_state
}
begin() { update_phase() {
super.begin() this.phase += this.dt * this.gait_state.step_velocity * 2
if (this.phase >= 1) {
this.phase_num += 1
this.phase_num %= 2
this.phase = 0
} }
}
set_mode(mode: 'crawl' | 'trot', duty?: number, order?: [number, number, number, number]) { update_feet_positions() {
console.log('BezierState set_mode', mode) for (let i = 0; i < 4; i++) {
this.body_state.feet[i] = this.update_foot_position(i)
this.mode = mode
if (mode === 'crawl') {
this.speed_factor = 0.5
this.stand_offset = duty ?? 0.85
const o = order ?? [3, 0, 2, 1]
const base = [0, 0.25, 0.5, 0.75]
const offsets = new Array(4).fill(0)
for (let i = 0; i < 4; i++) offsets[o[i]] = base[i]
this.offset = offsets
} else {
this.speed_factor = 2
this.stand_offset = duty ?? 0.6
this.offset = order ? (order.map(v => v % 1) as number[]) : [0, 0.5, 0.5, 0]
}
} }
}
end() { update_foot_position(index: number): number[] {
super.end() let phase = this.phase + this.offset[index]
if (phase >= 1) {
phase -= 1
} }
this.body_state.feet[index][0] = this.default_feet_pos[index][0]
this.body_state.feet[index][1] = this.default_feet_pos[index][1]
this.body_state.feet[index][2] = this.default_feet_pos[index][2]
return phase <= 0.75 ?
this.stand_controller(index, phase / 0.75)
: this.swing_controller(index, (phase - 0.75) / (1 - 0.75))
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { stand_controller(index: number, phase: number) {
super.step(body_state, command, dt) let depth = this.gait_state.step_depth
this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2) return this.controller(index, phase, stance_curve, depth)
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() { swing_controller(index: number, phase: number) {
const m = this.gait_state let height = this.gait_state.step_height
if (m.step_x === 0 && m.step_z === 0 && m.step_angle === 0) { return this.controller(index, phase, bezier_curve, height)
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() { controller(
const m = this.gait_state index: number,
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0 phase: number,
if (!moving) return 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)
if (this.mode !== 'crawl') return length = this.gait_state.step_angle * 2
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states() const delta_rot = controller(length, angle, ...args, phase)
if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) { this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2
if (this.current_shift_leg !== next_swing) { this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2
this.current_shift_leg = next_swing if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle)
this.shift_start_pos.x = this.body_state.xm this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2
this.shift_start_pos.z = this.body_state.zm
const remaining_legs = stance.filter(leg => leg !== next_swing) return this.body_state.feet[index]
const target = this.stance_centroid(remaining_legs) }
this.shift_target_pos.x = target[0]
this.shift_target_pos.z = target[2]
this.shift_start_time = time_to_lift
}
const total_time = this.shift_start_time
const progress = total_time > 0 ? 1 - time_to_lift / total_time : 1
const smooth_progress = this.smoothstep01(Math.max(0, Math.min(1, progress)))
this.body_state.xm = this.lerp(
this.shift_start_pos.x,
this.shift_target_pos.x,
smooth_progress
)
this.body_state.zm = this.lerp(
this.shift_start_pos.z,
this.shift_target_pos.z,
smooth_progress
)
}
}
protected lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
protected stance_centroid(legs: number[]): number[] {
if (legs.length === 0) return [this.body_state.xm, 0, this.body_state.zm]
let sx = 0,
sz = 0
for (const i of legs) {
sx += this.body_state.feet[i][0]
sz += this.body_state.feet[i][2]
}
return [sx / legs.length, 0, sz / legs.length]
}
protected get_leg_states(): {
stance: number[]
swing: number[]
next_swing: number
time_to_lift: number
} {
const stance: number[] = []
const swing: number[] = []
let next_swing = -1
let min_time_to_swing = Infinity
for (let i = 0; i < 4; i++) {
let phase = this.phase + this.offset[i]
if (phase >= 1) phase -= 1
if (phase <= this.stand_offset) {
stance.push(i)
const time_to_swing = this.stand_offset - phase
if (time_to_swing < min_time_to_swing) {
min_time_to_swing = time_to_swing
next_swing = i
}
} else {
swing.push(i)
}
}
return { stance, swing, next_swing, time_to_lift: min_time_to_swing }
}
protected smoothstep01(t: number): number {
const x = Math.max(0, Math.min(1, t))
return x * x * (3 - 2 * x)
}
update_feet_positions() {
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[] => { const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => {
const X_POLAR = Math.cos(angle) const X_POLAR = Math.cos(angle)
const Y_POLAR = Math.sin(angle) const Y_POLAR = Math.sin(angle)
const step = length * (1 - 2 * phase) const step = length * (1 - 2 * phase)
const X = step * X_POLAR const X = step * X_POLAR
const Z = step * Y_POLAR const Z = step * Y_POLAR
let Y = 0 let Y = 0
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
return [X, Y, Z] if (length !== 0) {
Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
}
return [X, Y, Z]
} }
const yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => { const yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => {
const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2) const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2)
const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0]) const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0])
const offsets = [ const offsets = [
current_foot_pos[0] - default_foot_pos[0], current_foot_pos[0] - default_foot_pos[0],
current_foot_pos[2] - default_foot_pos[2], current_foot_pos[2] - default_foot_pos[2],
current_foot_pos[1] - default_foot_pos[1] current_foot_pos[1] - default_foot_pos[1]
] ]
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2) const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2)
const offset_mod = Math.atan2(offset_mag, foot_mag) const offset_mod = Math.atan2(offset_mag, foot_mag)
return Math.PI / 2.0 + foot_dir + offset_mod return Math.PI / 2.0 + foot_dir + offset_mod
} }
const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => { const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => {
const control_points = get_control_points(length, angle, height) const control_points = get_control_points(length, angle, height)
const n = control_points.length - 1 const n = control_points.length - 1
const point = [0, 0, 0] const point = [0, 0, 0]
for (let i = 0; i <= n; i++) { for (let i = 0; i <= n; i++) {
const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i) const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i)
point[0] += bernstein_poly * control_points[i][0] point[0] += bernstein_poly * control_points[i][0]
point[1] += bernstein_poly * control_points[i][1] point[1] += bernstein_poly * control_points[i][1]
point[2] += bernstein_poly * control_points[i][2] point[2] += bernstein_poly * control_points[i][2]
} }
return point return point
} }
const get_control_points = (length: number, angle: number, height: number): number[][] => { const get_control_points = (length: number, angle: number, height: number): number[][] => {
const X_POLAR = Math.cos(angle) const X_POLAR = Math.cos(angle)
const Z_POLAR = Math.sin(angle) const Z_POLAR = Math.sin(angle)
const STEP = [ const STEP = [
-length, -length,
-length * 1.4, -length * 1.4,
-length * 1.5, -length * 1.5,
-length * 1.5, -length * 1.5,
-length * 1.5, -length * 1.5,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
length * 1.5, length * 1.5,
length * 1.5, length * 1.5,
length * 1.4, length * 1.4,
length length
] ]
const Y = [ const Y = [
0.0, 0.0,
0.0, 0.0,
height * 0.9, height * 0.9,
height * 0.9, height * 0.9,
height * 0.9, height * 0.9,
height * 0.9, height * 0.9,
height * 0.9, height * 0.9,
height * 1.1, height * 1.1,
height * 1.1, height * 1.1,
height * 1.1, height * 1.1,
0.0, 0.0,
0.0 0.0
] ]
const control_points: number[][] = [] const control_points: number[][] = []
for (let i = 0; i < STEP.length; i++) { for (let i = 0; i < STEP.length; i++) {
const X = STEP[i] * X_POLAR const X = STEP[i] * X_POLAR
const Z = STEP[i] * Z_POLAR const Z = STEP[i] * Z_POLAR
control_points.push([X, Y[i], Z]) control_points.push([X, Y[i], Z])
} }
return control_points return control_points
} }
const comb = (n: number, k: number): number => { const comb = (n: number, k: number): number => {
if (k < 0 || k > n) return 0 if (k < 0 || k > n) return 0
if (k === 0 || k === n) return 1 if (k === 0 || k === n) return 1
k = Math.min(k, n - k) k = Math.min(k, n - k)
let c = 1 let c = 1
for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1) for (let i = 0; i < k; i++) {
return c c = (c * (n - i)) / (i + 1)
}
return c
} }
+104 -126
View File
@@ -1,38 +1,23 @@
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 {
coxa: number
coxa_offset: number
femur: number
tibia: number
L: number
W: number
} }
const { cos, sin, atan2, acos, sqrt, max, min } = Math const { cos, sin, atan2, acos, sqrt, max, min } = Math
@@ -40,114 +25,107 @@ 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[][] mountOffsets: number[][]
invMountRot = [ invMountRot = [
[0, 0, -1], [0, 0, -1],
[0, 1, 0], [0, 1, 0],
[1, 0, 0] [1, 0, 0]
]
constructor() {
this.l1 = 60.5 / 100
this.l2 = 10 / 100
this.l3 = 111.7 / 100
this.l4 = 118.5 / 100
this.L = 207.5 / 100
this.W = 78 / 100
this.mountOffsets = [
[this.L / 2, 0, this.W / 2],
[this.L / 2, 0, -this.W / 2],
[-this.L / 2, 0, this.W / 2],
[-this.L / 2, 0, -this.W / 2]
] ]
}
constructor(params: KinematicParams) { getDefaultFeetPos(): number[][] {
this.coxa = params.coxa return this.mountOffsets.map((offset, i) => {
this.coxa_offset = params.coxa_offset return [offset[0], -1, offset[2] + (i % 2 === 1 ? -this.l1 : this.l1)]
this.femur = params.femur })
this.tibia = params.tibia }
this.L = params.L
this.W = params.W
this.mountOffsets = [ calcIK(p: body_state_t): number[] {
[this.L / 2, 0, this.W / 2], const roll = p.omega * this.DEG2RAD
[this.L / 2, 0, -this.W / 2], const pitch = p.phi * this.DEG2RAD
[-this.L / 2, 0, this.W / 2], const yaw = p.psi * this.DEG2RAD
[-this.L / 2, 0, -this.W / 2] const rot = this.euler2R(roll, pitch, yaw)
] const inv_rot = [
} [rot[0][0], rot[1][0], rot[2][0]],
[rot[0][1], rot[1][1], rot[2][1]],
[rot[0][2], rot[1][2], rot[2][2]]
]
const inv_trans = [
-inv_rot[0][0] * p.xm - inv_rot[0][1] * p.ym - inv_rot[0][2] * p.zm,
-inv_rot[1][0] * p.xm - inv_rot[1][1] * p.ym - inv_rot[1][2] * p.zm,
-inv_rot[2][0] * p.xm - inv_rot[2][1] * p.ym - inv_rot[2][2] * p.zm
]
return p.feet.flatMap((foot, i) => {
const [wx, wy, wz] = foot
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
getDefaultFeetPos(): number[][] { const [mx, my, mz] = this.mountOffsets[i]
return this.mountOffsets.map((offset, i) => { const px = bx - mx,
return [offset[0], -1, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)] py = by - my,
}) pz = bz - mz
}
calcIK(p: body_state_t): number[] { const lx =
const roll = p.omega * this.DEG2RAD this.invMountRot[0][0] * px + this.invMountRot[0][1] * py + this.invMountRot[0][2] * pz
const pitch = p.phi * this.DEG2RAD const ly =
const yaw = p.psi * this.DEG2RAD this.invMountRot[1][0] * px + this.invMountRot[1][1] * py + this.invMountRot[1][2] * pz
const rot = this.euler2R(roll, pitch, yaw) const lz =
const inv_rot = [ this.invMountRot[2][0] * px + this.invMountRot[2][1] * py + this.invMountRot[2][2] * pz
[rot[0][0], rot[1][0], rot[2][0]],
[rot[0][1], rot[1][1], rot[2][1]],
[rot[0][2], rot[1][2], rot[2][2]]
]
const inv_trans = [
-inv_rot[0][0] * p.xm - inv_rot[0][1] * p.ym - inv_rot[0][2] * p.zm,
-inv_rot[1][0] * p.xm - inv_rot[1][1] * p.ym - inv_rot[1][2] * p.zm,
-inv_rot[2][0] * p.xm - inv_rot[2][1] * p.ym - inv_rot[2][2] * p.zm
]
return p.feet.flatMap((foot, i) => {
const [wx, wy, wz] = foot
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
const [mx, my, mz] = this.mountOffsets[i] const xLocal = i % 2 === 1 ? -lx : lx
const px = bx - mx, return this.legIK(xLocal, ly, lz)
py = by - my, })
pz = bz - mz }
const lx = private legIK(x: number, y: number, z: number): [number, number, number] {
this.invMountRot[0][0] * px + const F = sqrt(max(0, x * x + y * y - this.l1 * this.l1))
this.invMountRot[0][1] * py + const G = F - this.l2
this.invMountRot[0][2] * pz const H = sqrt(G * G + z * z)
const ly = const t1 = -atan2(y, x) - atan2(F, -this.l1)
this.invMountRot[1][0] * px + const D = (H * H - this.l3 * this.l3 - this.l4 * this.l4) / (2 * this.l3 * this.l4)
this.invMountRot[1][1] * py + const t3 = acos(max(-1, min(1, D)))
this.invMountRot[1][2] * pz const t2 = atan2(z, G) - atan2(this.l4 * sin(t3), this.l3 + this.l4 * cos(t3))
const lz = return [t1, t2, t3]
this.invMountRot[2][0] * px + }
this.invMountRot[2][1] * py +
this.invMountRot[2][2] * pz
const xLocal = i % 2 === 1 ? -lx : lx private euler2R(roll: number, pitch: number, yaw: number): number[][] {
return this.legIK(xLocal, ly, lz) const cr = cos(roll),
}) sr = sin(roll)
} const cp = cos(pitch),
sp = sin(pitch)
private legIK(x: number, y: number, z: number): [number, number, number] { const cy = cos(yaw),
const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa)) sy = sin(yaw)
const G = F - this.coxa_offset return [
const H = sqrt(G * G + z * z) [cp * cy, -cp * sy, sp],
const t1 = -atan2(y, x) - atan2(F, -this.coxa) [sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp],
const D = [sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp]
(H * H - this.femur * this.femur - this.tibia * this.tibia) / ]
(2 * this.femur * this.tibia) }
const t3 = acos(max(-1, min(1, D)))
const t2 = atan2(z, G) - atan2(this.tibia * sin(t3), this.femur + this.tibia * cos(t3))
return [t1, t2, t3]
}
private euler2R(roll: number, pitch: number, yaw: number): number[][] {
const cr = cos(roll),
sr = sin(roll)
const cp = cos(pitch),
sp = sin(pitch)
const cy = cos(yaw),
sy = sin(yaw)
return [
[cp * cy, -cp * sy, sp],
[sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp],
[sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp]
]
}
} }
+224 -193
View File
@@ -17,332 +17,363 @@ import {
MeshPhongMaterial, MeshPhongMaterial,
EquirectangularReflectionMapping, EquirectangularReflectionMapping,
ACESFilmicToneMapping, ACESFilmicToneMapping,
MathUtils,
Group, Group,
MeshBasicMaterial, MeshBasicMaterial,
RepeatWrapping, RepeatWrapping
Object3D } from 'three';
} from 'three' import { Sky } from 'three/addons/objects/Sky.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls' import { TransformControls } from 'three/examples/jsm/controls/TransformControls';
import { Reflector } from 'three/examples/jsm/objects/Reflector.js' import { Reflector } from 'three/examples/jsm/objects/Reflector.js';
import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader' import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader';
import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls' import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls';
import { sunCalculator } from './utilities/position-utilities';
export const addScene = () => new Scene() export const addScene = () => new Scene();
interface position { interface position {
x?: number x?: number;
y?: number y?: number;
z?: number z?: number;
} }
interface light { interface light {
color?: ColorRepresentation color?: ColorRepresentation;
intensity?: number intensity?: number;
} }
interface arrowOptions { interface arrowOptions {
origin: position origin: position;
direction: position direction: position;
length?: number length?: number;
color?: ColorRepresentation color?: ColorRepresentation;
} }
type directionalLight = position & light type directionalLight = position & light;
export default class SceneBuilder { export default class SceneBuilder {
public scene: Scene public scene: Scene;
public camera!: PerspectiveCamera public camera!: PerspectiveCamera;
public ground!: Mesh public ground!: Mesh;
public renderer!: WebGLRenderer public renderer!: WebGLRenderer;
public orbit: OrbitControls public orbit: OrbitControls;
public callback: (() => 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 addSky = () => {
this.sky = new Sky();
this.sky.scale.setScalar(450000);
this.scene.add(this.sky);
const effectController = {
turbidity: 10,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: sunCalculator.calculateSunElevation(),
azimuth: 200,
exposure: this.renderer.toneMappingExposure
};
const uniforms = this.sky.material.uniforms;
uniforms['turbidity'].value = effectController.turbidity;
uniforms['rayleigh'].value = effectController.rayleigh;
uniforms['mieCoefficient'].value = effectController.mieCoefficient;
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG;
this.renderer.toneMappingExposure = 0.5;
const phi = MathUtils.degToRad(90 - effectController.elevation);
const theta = MathUtils.degToRad(effectController.azimuth);
const sun = new Vector3();
sun.setFromSphericalCoords(1, phi, theta);
uniforms['sunPosition'].value.copy(sun);
return this;
};
public addPerspectiveCamera = (options: position) => { public addPerspectiveCamera = (options: position) => {
this.camera = new PerspectiveCamera() this.camera = new PerspectiveCamera();
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0) this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0);
this.scene.add(this.camera) this.scene.add(this.camera);
return this return this;
} };
public addGroundPlane = (options?: position) => { public addGroundPlane = (options?: position) => {
const checkerboardTexture = this.createCheckerboardTexture(1024, 2) const checkerboardTexture = this.createCheckerboardTexture(1024, 2);
checkerboardTexture.wrapS = RepeatWrapping checkerboardTexture.wrapS = RepeatWrapping;
checkerboardTexture.wrapT = RepeatWrapping checkerboardTexture.wrapT = RepeatWrapping;
checkerboardTexture.repeat.set(100, 100) checkerboardTexture.repeat.set(100, 100);
const checkerboardMat = new MeshBasicMaterial({ const checkerboardMat = new MeshBasicMaterial({
map: checkerboardTexture, map: checkerboardTexture,
opacity: 0.1, opacity: 0.1,
transparent: true transparent: true
}) });
const plane = new PlaneGeometry(400, 400) const plane = new PlaneGeometry(400, 400);
this.ground = new Mesh(plane, checkerboardMat) this.ground = new Mesh(plane, checkerboardMat);
this.ground.rotation.x = -Math.PI / 2 this.ground.rotation.x = -Math.PI / 2;
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0) this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0);
this.ground.receiveShadow = true this.ground.receiveShadow = true;
this.scene.add(this.ground) this.scene.add(this.ground);
const mirror = new Reflector(plane, { const mirror = new Reflector(plane, {
clipBias: 0.003, clipBias: 0.003,
textureWidth: window.innerWidth * window.devicePixelRatio, textureWidth: window.innerWidth * window.devicePixelRatio,
textureHeight: window.innerHeight * window.devicePixelRatio, textureHeight: window.innerHeight * window.devicePixelRatio,
color: 0x00bfff color: 0x00bfff
}) });
mirror.rotateX(-Math.PI / 2) mirror.rotateX(-Math.PI / 2);
this.scene.add(mirror) this.scene.add(mirror);
return this return this;
} };
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => { public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement) this.orbit = new OrbitControls(this.camera, this.renderer.domElement);
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2 this.orbit.minDistance = minDistance;
this.orbit.maxDistance = maxDistance this.orbit.maxDistance = maxDistance;
this.orbit.autoRotate = autoRotate this.orbit.autoRotate = autoRotate;
this.orbit.update() this.orbit.update();
this.orbit.minDistance = minDistance return this;
return this };
}
public addAmbientLight = (options: light) => { public addAmbientLight = (options: light) => {
const ambientLight = new AmbientLight(options.color, options.intensity) const ambientLight = new AmbientLight(options.color, options.intensity);
this.scene.add(ambientLight) this.scene.add(ambientLight);
return this return this;
} };
public addDirectionalLight = (options: directionalLight) => { public addDirectionalLight = (options: directionalLight) => {
const directionalLight = new DirectionalLight(options.color, options.intensity) const directionalLight = new DirectionalLight(options.color, options.intensity);
directionalLight.castShadow = true directionalLight.castShadow = true;
directionalLight.shadow.camera.top = 10 directionalLight.shadow.camera.top = 10;
directionalLight.shadow.camera.bottom = -10 directionalLight.shadow.camera.bottom = -10;
directionalLight.shadow.camera.right = 10 directionalLight.shadow.camera.right = 10;
directionalLight.shadow.camera.left = -10 directionalLight.shadow.camera.left = -10;
directionalLight.shadow.mapSize.set(4096, 4096) directionalLight.shadow.mapSize.set(4096, 4096);
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0) directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
this.scene.add(directionalLight) this.scene.add(directionalLight);
return this return this;
} };
private createCheckerboardTexture = (size: number, squares: number) => { private createCheckerboardTexture = (size: number, squares: number) => {
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas');
canvas.width = size canvas.width = size;
canvas.height = size canvas.height = size;
const context = canvas.getContext('2d') const context = canvas.getContext('2d');
const squareSize = size / squares const squareSize = size / squares;
for (let y = 0; y < squares; y++) { for (let y = 0; y < squares; y++) {
for (let x = 0; x < squares; x++) { for (let x = 0; x < squares; x++) {
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000' context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000';
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize) context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize);
} }
} }
const texture = new CanvasTexture(canvas) const texture = new CanvasTexture(canvas);
texture.wrapS = texture.wrapT = RepeatWrapping texture.wrapS = texture.wrapT = RepeatWrapping;
texture.anisotropy = 16 texture.anisotropy = 16;
return texture return texture;
} };
public addFogExp2 = (color: ColorRepresentation, density?: number) => { public addFogExp2 = (color: ColorRepresentation, density?: number) => {
this.scene.fog = new FogExp2(color, density) this.scene.fog = new FogExp2(color, density);
return this return this;
} };
public fillParent = () => { public fillParent = () => {
const parentElement = this.renderer.domElement.parentElement const parentElement = this.renderer.domElement.parentElement;
if (parentElement) { if (parentElement) {
const width = parentElement.clientWidth const width = parentElement.clientWidth;
const height = parentElement.clientHeight const height = parentElement.clientHeight;
this.handleResize(width, height) this.handleResize(width, height);
} }
return this return this;
} };
public handleResize = (width = window.innerWidth, height = window.innerHeight) => { public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
this.renderer.setSize(width, height) this.renderer.setSize(width, height);
this.renderer.setPixelRatio(window.devicePixelRatio) this.renderer.setPixelRatio(window.devicePixelRatio);
this.camera.aspect = width / height this.camera.aspect = width / height;
this.camera.updateProjectionMatrix() this.camera.updateProjectionMatrix();
return this return this;
} };
public addRenderCb = (callback: () => void) => { public addRenderCb = (callback: Function) => {
this.callback = callback this.callback = callback;
return this return this;
} };
public startRenderLoop = () => { public startRenderLoop = () => {
this.renderer.setAnimationLoop(() => { this.renderer.setAnimationLoop(() => {
this.renderer.render(this.scene, this.camera) this.renderer.render(this.scene, this.camera);
this.orbit.update() this.orbit.update();
this.handleRobotShadow() this.handleRobotShadow();
if (this.callback) this.callback() if (this.callback) this.callback();
if (!this.liveStreamTexture) return if (!this.liveStreamTexture) return;
}) });
return this return this;
} };
public addArrowHelper = (options?: arrowOptions) => { public addArrowHelper = (options?: arrowOptions) => {
const dir = new Vector3( const dir = new Vector3(
options?.direction.x ?? 0, options?.direction.x ?? 0,
options?.direction.y ?? 0, options?.direction.y ?? 0,
options?.direction.z ?? 0 options?.direction.z ?? 0
) );
const origin = new Vector3( const origin = new Vector3(
options?.origin.x ?? 0, options?.origin.x ?? 0,
options?.origin.y ?? 0, options?.origin.y ?? 0,
options?.origin.z ?? 0 options?.origin.z ?? 0
) );
const arrowHelper = new ArrowHelper( const arrowHelper = new ArrowHelper(
dir, dir,
origin, origin,
options?.length ?? 1.5, options?.length ?? 1.5,
options?.color ?? 0xff0000 options?.color ?? 0xff0000
) );
this.scene.add(arrowHelper) this.scene.add(arrowHelper);
return this return this;
} };
private setJointValue(jointName: string, angle: number) { private setJointValue(jointName: string, angle: number) {
if (!this.model) return if (!this.model) return;
if (!this.model.joints[jointName]) return if (!this.model.joints[jointName]) return;
this.model.joints[jointName].setJointValue(angle) this.model.joints[jointName].setJointValue(angle);
} }
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed' isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed';
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => { highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
const traverse = (c: Object3D) => { const traverse = (c: any) => {
if (c.type === 'Mesh') { if (c.type === 'Mesh') {
if (revert) { if (revert) {
c.material = c.__origMaterial c.material = c.__origMaterial;
delete c.__origMaterial delete c.__origMaterial;
} else { } else {
c.__origMaterial = c.material c.__origMaterial = c.material;
c.material = material c.material = material;
} }
} }
if (c === m || !this.isJoint(c)) { if (c === m || !this.isJoint(c)) {
for (let i = 0; i < c.children.length; i++) { for (let i = 0; i < c.children.length; i++) {
const child = c.children[i] const child = c.children[i];
if (!child.isURDFCollider) { if (!child.isURDFCollider) {
traverse(c.children[i]) traverse(c.children[i]);
} }
} }
} }
} };
traverse(m) traverse(m);
} };
public addTransformControls = (model: Object3D) => { public addTransformControls = (model: any) => {
this.transformControl = new TransformControls(this.camera, this.renderer.domElement) this.transformControl = new TransformControls(this.camera, this.renderer.domElement);
this.transformControl.addEventListener('dragging-changed', (event: { value: boolean }) => { this.transformControl.addEventListener('dragging-changed', (event: any) => {
this.orbit.enabled = !event.value this.orbit.enabled = !event.value;
this.isDragging = !event.value this.isDragging = !event.value;
}) });
this.transformControl.attach(model) this.transformControl.attach(model);
this.scene.add(this.transformControl) this.scene.add(this.transformControl);
this.transformControl.setMode('rotate') this.transformControl.setMode('rotate');
return this return this;
} };
public addModel = (model: URDFRobot) => { public addModel = (model: any) => {
this.modelGroup = new Group() this.modelGroup = new Group();
this.modelGroup.add(model) this.modelGroup.add(model);
this.model = model this.model = model;
this.scene.add(this.modelGroup) this.scene.add(this.modelGroup);
return this return this;
} };
public addDragControl = (updateAngle: (angles: Record<string, number>) => void) => { public addDragControl = (updateAngle: any) => {
const highlightColor = '#FFFFFF' const highlightColor = '#FFFFFF';
const highlightMaterial = new MeshPhongMaterial({ const highlightMaterial = new MeshPhongMaterial({
shininess: 10, shininess: 10,
color: highlightColor, color: highlightColor,
emissive: highlightColor, emissive: highlightColor,
emissiveIntensity: 0.9 emissiveIntensity: 0.9
}) });
const dragControls = new PointerURDFDragControls( const dragControls = new PointerURDFDragControls(
this.scene, this.scene,
this.camera, this.camera,
this.renderer.domElement this.renderer.domElement
) );
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => { dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
this.setJointValue(joint.name, angle) this.setJointValue(joint.name, angle);
updateAngle({ [joint.name]: angle }) updateAngle(joint.name, angle);
} };
dragControls.onDragStart = () => { dragControls.onDragStart = () => {
this.orbit.enabled = false this.orbit.enabled = false;
this.isDragging = true this.isDragging = true;
} };
dragControls.onDragEnd = () => { dragControls.onDragEnd = () => {
this.orbit.enabled = true this.orbit.enabled = true;
this.isDragging = false this.isDragging = false;
} };
dragControls.onHover = (joint: URDFMimicJoint) => dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial) this.highlightLinkGeometry(joint, false, highlightMaterial);
dragControls.onUnhover = (joint: URDFMimicJoint) => dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial) this.highlightLinkGeometry(joint, true, highlightMaterial);
this.renderer.domElement.addEventListener( this.renderer.domElement.addEventListener(
'touchstart', 'touchstart',
data => dragControls._mouseDown(data.touches[0]), data => dragControls._mouseDown(data.touches[0]),
{ passive: true } { passive: true }
) );
this.renderer.domElement.addEventListener( this.renderer.domElement.addEventListener(
'touchmove', 'touchmove',
data => dragControls._mouseMove(data.touches[0]), data => dragControls._mouseMove(data.touches[0]),
{ passive: true } { passive: true }
) );
this.renderer.domElement.addEventListener( this.renderer.domElement.addEventListener(
'touchend', 'touchend',
data => dragControls._mouseUp(data.touches[0]), data => dragControls._mouseUp(data.touches[0]),
{ passive: true } { passive: true }
) );
return this return this;
} };
public toggleFog = () => { public toggleFog = () => {
this.scene.fog = this.scene.fog ? null : this.fog this.scene.fog = this.scene.fog ? null : this.fog;
} };
private handleRobotShadow = () => { private handleRobotShadow = () => {
if (this.isLoaded) return if (this.isLoaded) return;
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10) const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10);
setTimeout(() => clearInterval(intervalId), 1000) setTimeout(() => clearInterval(intervalId), 1000);
this.isLoaded = true this.isLoaded = true;
} };
} }
+43 -42
View File
@@ -1,53 +1,54 @@
import { Result } from '$lib/utilities/result' import { Result } from '$lib/utilities/result';
import { browser } from '$app/environment' import { browser } from '$app/environment';
class FileService { class FileService {
private dbPromise: Promise<Result<IDBDatabase, string>> | null = private dbPromise: Promise<Result<IDBDatabase, string>> | null = browser
browser ? this.openDatabase() : null ? this.openDatabase()
: null;
private async openDatabase(): Promise<Result<IDBDatabase, string>> { private async openDatabase(): Promise<Result<IDBDatabase, string>> {
return new Promise(resolve => { return new Promise((resolve) => {
const request = indexedDB.open('fileStorageDB', 1) const request = indexedDB.open('fileStorageDB', 1);
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
request.result.createObjectStore('files') request.result.createObjectStore('files');
} };
request.onsuccess = () => resolve(Result.ok(request.result)) request.onsuccess = () => resolve(Result.ok(request.result));
request.onerror = () => resolve(Result.err('Error opening database')) request.onerror = () => resolve(Result.err('Error opening database'));
}) });
} }
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> { private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
if (!browser || !this.dbPromise) if (!browser || !this.dbPromise)
return Result.err('Not running in browser or DB not initialized') return Result.err('Not running in browser or DB not initialized');
const dbResult = await this.dbPromise const dbResult = await this.dbPromise;
if (dbResult.isErr()) return Result.err('Database not initialized') if (dbResult.isErr()) return Result.err('Database not initialized');
const store = dbResult.inner.transaction('files', mode).objectStore('files') const store = dbResult.inner.transaction('files', mode).objectStore('files');
return Result.ok(store) return Result.ok(store);
} }
public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> { public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> {
const storeResult = await this.getStore('readwrite') const storeResult = await this.getStore('readwrite');
if (storeResult.isErr()) return Result.err('Failed to access store') if (storeResult.isErr()) return Result.err('Failed to access store');
return new Promise(resolve => { return new Promise((resolve) => {
const request = storeResult.inner.put(file, key) const request = storeResult.inner.put(file, key);
request.onsuccess = () => resolve(Result.ok(request.result)) request.onsuccess = () => resolve(Result.ok(request.result));
request.onerror = () => resolve(Result.err('Failed to save file')) request.onerror = () => resolve(Result.err('Failed to save file'));
}) });
} }
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> { public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> {
const storeResult = await this.getStore('readonly') const storeResult = await this.getStore('readonly');
if (storeResult.isErr()) return Result.err('Failed to access store') if (storeResult.isErr()) return Result.err('Failed to access store');
return new Promise(resolve => { return new Promise((resolve) => {
const request = storeResult.inner.get(key) const request = storeResult.inner.get(key);
request.onsuccess = () => request.onsuccess = () =>
resolve(request.result ? Result.ok(request.result) : Result.err('File not found')) resolve(request.result ? Result.ok(request.result) : Result.err('File not found'));
request.onerror = () => resolve(Result.err('Failed to retrieve file')) request.onerror = () => resolve(Result.err('Failed to retrieve file'));
}) });
} }
} }
export default browser ? new FileService() : null export default browser ? new FileService() : null;
+2 -2
View File
@@ -1,2 +1,2 @@
export { default as fileService } from './file-service' export { default as fileService } from './file-service';
export { default as resultService } from './result-service' export { default as resultService } from './result-service';
+14 -14
View File
@@ -1,19 +1,19 @@
import { errorLogs, latestErrorLog } from '$lib/stores' import { errorLogs, latestErrorLog } from '$lib/stores';
import type { Result } from '$lib/utilities' import type { Result } from '$lib/utilities';
class ResultService { class ResultService {
public handleResult(result: Result<unknown, string>, tag?: string) { public handleResult(result: Result<unknown, string>, tag?: string) {
if (result.isErr()) { if (result.isErr()) {
const errorLogEntry = { tag, message: result.inner, exception: result.exception } const errorLogEntry = { tag, message: result.inner, exception: result.exception };
latestErrorLog.set(errorLogEntry) latestErrorLog.set(errorLogEntry);
errorLogs.update(entries => { errorLogs.update((entries) => {
entries.push(errorLogEntry) entries.push(errorLogEntry);
return entries return entries;
}) });
} }
return result return result;
} }
} }
export default new ResultService() export default new ResultService();
+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();
+49 -49
View File
@@ -1,67 +1,67 @@
import { persistentStore } from '$lib/utilities' import { persistentStore } from '$lib/utilities';
import { get, type Writable } from 'svelte/store' import { get, type Writable } from 'svelte/store';
import Visualization from '$lib/components/Visualization.svelte' import Visualization from '$lib/components/Visualization.svelte';
import Stream from '$lib/components/Stream.svelte' import Stream from '$lib/components/Stream.svelte';
import ChartWidget from '$lib/components/widget/ChartWidget.svelte' import ChartWidget from '$lib/components/widget/ChartWidget.svelte';
export interface WidgetConfig { export interface WidgetConfig {
id: string | number id: string | number;
component: keyof typeof WidgetComponents component: keyof typeof WidgetComponents;
props?: Record<string, unknown> props?: Record<string, any>;
} }
export interface WidgetContainerConfig { export interface WidgetContainerConfig {
id: string | number id: string | number;
layout?: 'row' | 'column' | 'wrap' layout?: 'row' | 'column' | 'wrap';
header?: string header?: string;
widgets: Array<WidgetConfig | WidgetContainerConfig> widgets: Array<WidgetConfig | WidgetContainerConfig>;
} }
export const isWidgetConfig = ( export const isWidgetConfig = (
widget: WidgetConfig | WidgetContainerConfig widget: WidgetConfig | WidgetContainerConfig
): widget is WidgetConfig => 'component' in widget ): widget is WidgetConfig => 'component' in widget;
export const WidgetComponents = { export const WidgetComponents = {
Visualization, Visualization,
Stream, Stream,
ChartWidget ChartWidget
} };
interface View { interface View {
name: string name: string;
content: WidgetContainerConfig content: WidgetContainerConfig;
} }
const defaultViews: View[] = [ const defaultViews: View[] = [
{ {
name: '3D representation', name: 'Stream',
content: { content: {
id: 'root', id: 'root',
layout: 'column', layout: 'column',
widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }] widgets: [{ id: 2, component: 'Stream' }]
} }
}, },
{ {
name: 'Stream', name: '3D representation',
content: { content: {
id: 'root', id: 'root',
layout: 'column', layout: 'column',
widgets: [{ id: 2, component: 'Stream' }] widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }]
} }
}, },
{ {
name: 'Split screen', name: 'Split screen',
content: { content: {
id: 'root', id: 'root',
widgets: [ widgets: [
{ id: 2, component: 'Stream' }, { id: 2, component: 'Stream' },
{ id: 2, component: 'Visualization', props: { debug: true } } { id: 2, component: 'Visualization', props: { debug: true } }
] ]
} }
} }
] ];
export const views: Writable<View[]> = persistentStore('views', defaultViews) export const views: Writable<View[]> = persistentStore('views', defaultViews);
export const selectedView = persistentStore('selected_view', get(views)[0].name) export const selectedView = persistentStore('selected_view', get(views)[0].name);
+14 -58
View File
@@ -1,64 +1,20 @@
import { api } from '$lib/api' import { api } from '$lib/api';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import Kinematic from '$lib/kinematic' import { writable, type Writable } from 'svelte/store';
import { persistentStore } from '$lib/utilities'
import { derived, type Writable } from 'svelte/store'
import { resolve } from '$app/paths'
let featureFlagsStore: Writable<Record<string, boolean | string>> let featureFlagsStore: Writable<Record<string, boolean>>;
export function useFeatureFlags() { export function useFeatureFlags() {
if (!featureFlagsStore) { if (!featureFlagsStore) {
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {}) featureFlagsStore = writable<Record<string, boolean>>({});
api.get<Record<string, boolean>>('/api/features').then(result => { api.get<Record<string, boolean>>('/api/features').then((result) => {
if (result.isOk()) featureFlagsStore.set(result.inner) if (result.isOk()) featureFlagsStore.set(result.inner);
else { else {
notifications.error('Feature flag could not be fetched', 2500) notifications.error('Feature flag could not be fetched', 2500);
} }
}) });
} }
return featureFlagsStore return featureFlagsStore;
} }
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)
)
+14 -14
View File
@@ -1,24 +1,24 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
export const isFullscreen = writable(false) export const isFullscreen = writable(false);
export function toggleFullscreen() { export function toggleFullscreen() {
isFullscreen.update(state => { isFullscreen.update((state) => {
!state ? document.documentElement.requestFullscreen() : document.exitFullscreen() !state ? document.documentElement.requestFullscreen() : document.exitFullscreen();
return !state return !state;
}) });
} }
export function enterFullscreen() { export function enterFullscreen() {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
document.documentElement.requestFullscreen() document.documentElement.requestFullscreen();
isFullscreen.set(true) isFullscreen.set(true);
} }
} }
export function exitFullscreen() { export function exitFullscreen() {
if (document.fullscreenElement) { if (document.fullscreenElement) {
document.exitFullscreen() document.exitFullscreen();
isFullscreen.set(false) isFullscreen.set(false);
} }
} }
+31 -71
View File
@@ -1,87 +1,47 @@
import { readable, derived } from 'svelte/store' import { readable, derived } from 'svelte/store'
export type GamepadState = { export type GamepadState = {
available: boolean available: boolean
gamepads: Gamepad[] gamepads: Gamepad[]
} }
const DEADZONE = 0.15
const dz = (x: number) => {
const a = Math.abs(x)
if (a < DEADZONE) return 0
return ((a - DEADZONE) / (1 - DEADZONE)) * Math.sign(x)
}
let raf = 0
let running = false
export const gamepads = readable<GamepadState>({ available: false, gamepads: [] }, set => { export const gamepads = readable<GamepadState>({ available: false, gamepads: [] }, set => {
const update = () => { const update = () => {
const pads = navigator.getGamepads?.() ?? [] const hasGamepadAPI = 'getGamepads' in navigator
const list = Array.from(pads) if (!hasGamepadAPI) {
.map(p => p || null) set({ available: false, gamepads: [] })
.filter(Boolean) as Gamepad[] return
set({ available: 'getGamepads' in navigator, gamepads: list })
raf = requestAnimationFrame(update)
} }
const onConnect = () => update() const gps = navigator.getGamepads?.() ?? []
const onDisconnect = () => update() const validGamepads = gps.filter(Boolean) as Gamepad[]
const onVis = () => { set({
if (document.hidden) { available: true,
running = false gamepads: validGamepads
cancelAnimationFrame(raf) })
} else if (!running) {
running = true
raf = requestAnimationFrame(update)
}
}
window.addEventListener('gamepadconnected', onConnect)
window.addEventListener('gamepaddisconnected', onDisconnect)
document.addEventListener('visibilitychange', onVis)
running = true
raf = requestAnimationFrame(update) raf = requestAnimationFrame(update)
}
return () => { window.addEventListener('gamepadconnected', update)
running = false window.addEventListener('gamepaddisconnected', update)
cancelAnimationFrame(raf) let raf = requestAnimationFrame(update)
window.removeEventListener('gamepadconnected', onConnect)
window.removeEventListener('gamepaddisconnected', onDisconnect) return () => {
document.removeEventListener('visibilitychange', onVis) cancelAnimationFrame(raf)
} window.removeEventListener('gamepadconnected', update)
window.removeEventListener('gamepaddisconnected', update)
}
}) })
export const gamepad = derived(gamepads, s => export const gamepad = derived(gamepads, $gamepads =>
s.available && s.gamepads.length ? s.gamepads[0] : null $gamepads.available && $gamepads.gamepads.length > 0 ? $gamepads.gamepads[0] : null
) )
export const hasGamepad = derived(gamepads, s => s.available && s.gamepads.length > 0) export const gamepadAxes = derived(gamepad, $gamepad => $gamepad?.axes ?? [0, 0, 0, 0])
export const gamepadAxes = derived(gamepad, g => (g ? g.axes.map(dz) : [0, 0, 0, 0])) export const gamepadButtons = derived(gamepad, $gamepad => $gamepad?.buttons ?? [])
type ButtonEdge = { pressed: boolean; value: number; justPressed: boolean; justReleased: boolean } export const hasGamepad = derived(
const prev = new Map<number, { pressed: boolean; value: number }[]>() gamepads,
$gamepads => $gamepads.available && $gamepads.gamepads.length > 0
export const gamepadButtons = derived(gamepad, g => g?.buttons ?? []) )
export const gamepadButtonsEdges = derived(gamepad, g => {
if (!g) return [] as ButtonEdge[]
const p = prev.get(g.index) || []
const out = g.buttons.map((b, i): ButtonEdge => {
const pr = p[i] || { pressed: false, value: 0 }
const pressed = !!b.pressed || b.value > 0.5
return {
pressed,
value: b.value,
justPressed: pressed && !pr.pressed,
justReleased: !pressed && pr.pressed
}
})
prev.set(
g.index,
out.map(x => ({ pressed: x.pressed, value: x.value }))
)
return out
})
+13 -26
View File
@@ -1,7 +1,7 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
import type { IMUMsg } from '$lib/types/models' import type { IMU } from '$lib/types/models';
const maxIMUData = 100 const maxIMUData = 100;
export const imu = (() => { export const imu = (() => {
const { subscribe, update } = writable({ const { subscribe, update } = writable({
@@ -12,29 +12,16 @@ export const imu = (() => {
altitude: [] as number[], altitude: [] as number[],
pressure: [] as number[], pressure: [] as number[],
bmp_temp: [] as number[] bmp_temp: [] as number[]
}) });
const addData = (content: IMUMsg) => { const addData = (content: IMU) => {
update(data => { update(data => {
if (content.imu && content.imu[4]) { (Object.keys(content) as (keyof IMU)[]).forEach(key => {
data.x = [...data.x, content.imu[0]].slice(-maxIMUData) data[key] = [...data[key], content[key]].slice(-maxIMUData);
data.y = [...data.y, content.imu[1]].slice(-maxIMUData) });
data.z = [...data.z, content.imu[2]].slice(-maxIMUData) return data;
} });
};
if (content.mag && content.mag[4]) { return { subscribe, addData };
data.heading = [...data.heading, content.mag[3]].slice(-maxIMUData) })();
}
if (content.bmp && content.bmp[3]) {
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 }
})()
+9 -9
View File
@@ -1,9 +1,9 @@
export * from './socket-store' export * from './socket-store';
export * from './logging-store' export * from './logging-store';
export * from './model-store' export * from './model-store';
export * from './socket' export * from './socket';
export * from './fullscreen' export * from './fullscreen';
export * from './telemetry' export * from './telemetry';
export * from './analytics' export * from './analytics';
export * from './featureFlags' export * from './featureFlags';
export * from './location-store' export * from './location-store';
+4 -5
View File
@@ -1,6 +1,5 @@
import { persistentStore } from '$lib/utilities' import { persistentStore } from '$lib/utilities';
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public' import appEnv from 'app-env';
export const apiLocation = export const location = appEnv.VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '');
PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '')
+6 -6
View File
@@ -1,11 +1,11 @@
import { writable, type Writable } from 'svelte/store' import { writable, type Writable } from 'svelte/store';
export interface errorLog { export interface errorLog {
message: unknown message: unknown;
tag?: string tag?: string;
exception?: unknown exception?: unknown;
} }
export const latestErrorLog: Writable<errorLog> = writable() export const latestErrorLog: Writable<errorLog> = writable();
export const errorLogs: Writable<errorLog[]> = writable([]) export const errorLogs: Writable<errorLog[]> = writable([]);
+32 -41
View File
@@ -1,54 +1,45 @@
import type { ControllerInput } from '$lib/types/models' import type { ControllerInput } from '$lib/types/models';
import { persistentStore } from '$lib/utilities/svelte-utilities' import { persistentStore } from '$lib/utilities/svelte-utilities';
import { writable, type Writable } from 'svelte/store' import { writable, type Writable } from 'svelte/store';
export const emulateModel = writable(true) export const emulateModel = writable(true);
export const jointNames = persistentStore('joint_names', <string[]>[]) export const jointNames = persistentStore('joint_names', <string[]>[]);
export const model = writable() export const model = writable();
export const modes = ['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.Deactivated);
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);
};
});
+19 -19
View File
@@ -1,27 +1,27 @@
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/types/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 mpu = writable({ heading: 0 });
export const sonar = writable([0, 0]) export const sonar = writable([0, 0]);
export const distances = writable({}) 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> mpu: Writable<unknown>;
distances: Writable<unknown> distances: Writable<unknown>;
} }
export const socketData = { export const socketData = {
angles: servoAngles, angles: servoAngles,
logs, logs,
mpu, mpu,
distances distances
} };
+114 -142
View File
@@ -1,160 +1,132 @@
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?] export enum Topics {
imu = 0,
let useBinary = false mode = 1,
command = 2,
const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => { servo = 3,
useBinary = data instanceof ArrayBuffer input = 4,
angles = 5,
try { position = 6
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 | Topics, 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) {
ws.close()
set(false)
clearTimeout(unresponsiveTimeoutId)
clearTimeout(reconnectTimeoutId)
listeners.get(reason)?.forEach(listener => listener(event))
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime)
}
function connect() {
ws = new WebSocket(socketUrl)
ws.onopen = ev => {
set(true)
clearTimeout(reconnectTimeoutId)
listeners.get('open')?.forEach(listener => listener(ev))
for (const event of listeners.keys()) {
if (socketEvents.includes(event as SocketEvent)) continue
subscribeToEvent(event as unknown as Topics)
}
} }
ws.onmessage = message => {
resetUnresponsiveCheck()
let data = message.data
if (data instanceof ArrayBuffer) {
listeners.get('binary')?.forEach(listener => listener(data))
return
}
data = data.substring(1)
function disconnect(reason: SocketEvent, event?: Event) { if (!data) return
ws.close()
set(false) let event = data.substring(data.indexOf('/') + 1, data.indexOf('['))
clearTimeout(unresponsiveTimeoutId) let payload = data.substring(data.indexOf('[') + 1, data.lastIndexOf(']'))
clearTimeout(reconnectTimeoutId)
listeners.get(reason)?.forEach(listener => listener(event)) try {
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime) payload = JSON.parse(payload)
} catch (error) {}
if (event) listeners.get(event)?.forEach(listener => listener(payload))
} }
ws.onerror = ev => disconnect('error', ev)
ws.onclose = ev => disconnect('close', ev)
}
function connect() { function unsubscribe(event: Topics, listener?: (data: any) => void) {
ws = new WebSocket(socketUrl) let eventListeners = listeners.get(event)
ws.binaryType = 'arraybuffer' if (!eventListeners) return
ws.onopen = ev => {
ping() if (!eventListeners.size) {
useBinary = true unsubscribeToEvent(event)
ping() }
set(true) if (listener) {
clearTimeout(reconnectTimeoutId) eventListeners?.delete(listener)
listeners.get('open')?.forEach(listener => listener(ev)) } else {
for (const event of listeners.keys()) { listeners.delete(event)
if (socketEvents.includes(event as SocketEvent)) continue }
subscribeToEvent(event) }
}
} function resetUnresponsiveCheck() {
ws.onmessage = frame => { clearTimeout(unresponsiveTimeoutId)
resetUnresponsiveCheck() unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime)
const message = decodeMessage(frame.data) }
if (!message) return
const [, event, payload = undefined] = message function sendEvent(event: Topics, data: unknown) {
if (event) listeners.get(event)?.forEach(listener => listener(payload)) if (!ws || ws.readyState !== WebSocket.OPEN) return
} ws.send(JSON.stringify([2, event, data]))
ws.onerror = ev => disconnect('error', ev) }
ws.onclose = ev => disconnect('close', ev)
} function unsubscribeToEvent(event: Topics) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
function unsubscribe(event: string, listener?: (data: unknown) => void) { ws.send(`[1,${event}]`)
const eventListeners = listeners.get(event) }
if (!eventListeners) return
function subscribeToEvent(event: Topics) {
if (!eventListeners.size) { if (!ws || ws.readyState !== WebSocket.OPEN) return
unsubscribeToEvent(event) ws.send(`[0,${event}]`)
} }
if (listener) {
eventListeners?.delete(listener) return {
} else { subscribe,
listeners.delete(event) sendEvent,
} init,
} on: <T>(event: Topics | SocketEvent, listener: (data: T) => void): (() => void) => {
let eventListeners = listeners.get(event)
function resetUnresponsiveCheck() { if (!eventListeners) {
clearTimeout(unresponsiveTimeoutId) if (!socketEvents.includes(event)) {
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime) subscribeToEvent(event)
}
function sendEvent(event: string, data: unknown) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
send([2, event, data])
}
function unsubscribeToEvent(event: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
send([1, event])
}
function subscribeToEvent(event: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
send([0, event])
}
function send(data: unknown) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const serialized = encodeMessage(data)
if (!serialized) {
console.error('Could not serialize data:', data)
return
}
ws.send(serialized)
}
function ping() {
const serialized = encodeMessage([4])
if (!serialized) {
console.error('Could not serialize message')
return
}
ws.send(serialized)
}
return {
subscribe,
sendEvent,
init,
on: <T>(event: string, listener: (data: T) => void): (() => void) => {
let eventListeners = listeners.get(event)
if (!eventListeners) {
if (!socketEvents.includes(event as SocketEvent)) {
subscribeToEvent(event)
}
eventListeners = new Set()
listeners.set(event, eventListeners)
}
eventListeners.add(listener as (data: unknown) => void)
return () => {
unsubscribe(event, listener as (data: unknown) => void)
}
},
off: <T>(event: string, listener?: (data: T) => void) => {
unsubscribe(event, listener as (data: unknown) => void)
} }
eventListeners = new Set()
listeners.set(event, eventListeners)
}
eventListeners.add(listener as (data: any) => void)
return () => {
unsubscribe(event, listener)
}
},
off: (event: Topics, listener?: (data: any) => void) => {
unsubscribe(event, listener)
} }
}
} }
export const socket = createWebSocket() export const socket = createWebSocket()
+9 -9
View File
@@ -1,7 +1,7 @@
import type { DownloadOTA } from '$lib/types/models' import type { 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
}, },
@@ -10,10 +10,10 @@ const telemetry_data = {
progress: 0, progress: 0,
error: '' error: ''
} }
} };
function createTelemetry() { function createTelemetry() {
const { subscribe, update } = writable(telemetry_data) const { subscribe, set, update } = writable(telemetry_data);
return { return {
subscribe, subscribe,
@@ -21,15 +21,15 @@ function createTelemetry() {
update(telemetry_data => ({ update(telemetry_data => ({
...telemetry_data, ...telemetry_data,
rssi: { rssi: data } rssi: { rssi: data }
})) }));
}, },
setDownloadOTA: (data: DownloadOTA) => { setDownloadOTA: (data: DownloadOTA) => {
update(telemetry_data => ({ update(telemetry_data => ({
...telemetry_data, ...telemetry_data,
download_ota: { status: data.status, progress: data.progress, error: data.error } download_ota: { status: data.status, progress: data.progress, error: data.error }
})) }));
} }
} };
} }
export const telemetry = createTelemetry() export const telemetry = createTelemetry();
+15 -15
View File
@@ -1,17 +1,17 @@
declare module 'three/src/math/MathUtils' { declare module 'three/src/math/MathUtils' {
export function generateUUID(): string export function generateUUID(): string;
export function clamp(value: number, min: number, max: number): number export function clamp(value: number, min: number, max: number): number;
export function euclideanModulo(n: number, m: number): number export function euclideanModulo(n: number, m: number): number;
export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number;
export function lerp(x: number, y: number, t: number): number export function lerp(x: number, y: number, t: number): number;
export function smoothstep(x: number, min: number, max: number): number export function smoothstep(x: number, min: number, max: number): number;
export function smootherstep(x: number, min: number, max: number): number export function smootherstep(x: number, min: number, max: number): number;
export function randInt(low: number, high: number): number export function randInt(low: number, high: number): number;
export function randFloat(low: number, high: number): number export function randFloat(low: number, high: number): number;
export function randFloatSpread(range: number): number export function randFloatSpread(range: number): number;
export function degToRad(degrees: number): number export function degToRad(degrees: number): number;
export function radToDeg(radians: number): number export function radToDeg(radians: number): number;
export function isPowerOfTwo(value: number): boolean export function isPowerOfTwo(value: number): boolean;
export function ceilPowerOfTwo(value: number): number export function ceilPowerOfTwo(value: number): number;
export function floorPowerOfTwo(value: number): number export function floorPowerOfTwo(value: number): number;
} }
+136 -160
View File
@@ -1,245 +1,221 @@
export enum MessageTopic {
imu = 'imu',
mode = 'mode',
input = 'input',
analytics = 'analytics',
position = 'position',
angles = 'angles',
i2cScan = 'i2cScan',
peripheralSettings = 'peripheralSettings',
otastatus = 'otastatus',
gait = 'walk_gait',
servoState = 'servoState',
servoPWM = 'servoPWM',
WiFiSettings = 'WiFiSettings',
sonar = 'sonar',
rssi = 'rssi'
}
export type vector = { x: number; y: number } export type vector = { x: number; y: number }
export interface ControllerInput { export interface ControllerInput {
left: vector left: vector
right: vector right: vector
height: number height: number
speed: number speed: number
s1: number s1: number
} }
export type GithubRelease = { export type GithubRelease = {
message: string message: string
tag_name: string tag_name: string
assets: Array<{ assets: Array<{
name: string name: string
browser_download_url: string browser_download_url: string
}> }>
} }
export type angles = number[] | Int16Array export type angles = number[] | Int16Array
export type WifiStatus = { export type WifiStatus = {
status: number status: number
local_ip: string local_ip: string
mac_address: string mac_address: string
rssi: number rssi: number
ssid: string ssid: string
bssid: string bssid: string
channel: number channel: number
subnet_mask: string subnet_mask: string
gateway_ip: string gateway_ip: string
dns_ip_1: string dns_ip_1: string
dns_ip_2?: string dns_ip_2?: string
} }
export type WifiSettings = { export type WifiSettings = {
hostname: string hostname: string
priority_RSSI: boolean priority_RSSI: boolean
wifi_networks: KnownNetworkItem[] wifi_networks: KnownNetworkItem[]
} }
export type NetworkList = { export type NetworkList = {
networks: NetworkItem[] networks: NetworkItem[]
} }
export type KnownNetworkItem = { export type KnownNetworkItem = {
ssid: string ssid: string
password: string password: string
static_ip_config: boolean static_ip_config: boolean
local_ip?: string local_ip?: string
subnet_mask?: string subnet_mask?: string
gateway_ip?: string gateway_ip?: string
dns_ip_1?: string dns_ip_1?: string
dns_ip_2?: string dns_ip_2?: string
} }
export type NetworkItem = { export type NetworkItem = {
rssi: number rssi: number
ssid: string ssid: string
bssid: string bssid: string
channel: number channel: number
encryption_type: number encryption_type: number
} }
export type ApStatus = { export type ApStatus = {
status: number status: number
ip_address: string ip_address: string
mac_address: string mac_address: string
station_num: number station_num: number
} }
export type ApSettings = { export type ApSettings = {
provision_mode: number provision_mode: number
ssid: string ssid: string
password: string password: string
channel: number channel: number
ssid_hidden: boolean ssid_hidden: boolean
max_clients: number max_clients: number
local_ip: string local_ip: string
gateway_ip: string gateway_ip: string
subnet_mask: string subnet_mask: string
} }
export type DownloadOTA = { export type DownloadOTA = {
status: string status: string
progress: number progress: number
error: string error: string
} }
export type Analytics = { export type Analytics = {
max_alloc_heap: number max_alloc_heap: number
psram_size: number psram_size: number
free_psram: number free_psram: number
free_heap: number free_heap: number
total_heap: number total_heap: number
min_free_heap: number min_free_heap: number
core_temp: number core_temp: number
fs_total: number fs_total: number
fs_used: number fs_used: number
uptime: number uptime: number
cpu0_usage: number cpu0_usage: number
cpu1_usage: number cpu1_usage: number
cpu_usage: number cpu_usage: number
} }
export type Rssi = { export type Rssi = {
rssi: number rssi: number
ssid: string ssid: string
} }
export type StaticSystemInformation = { export type StaticSystemInformation = {
esp_platform: string esp_platform: string
firmware_version: string firmware_version: string
cpu_freq_mhz: number cpu_freq_mhz: number
cpu_type: string cpu_type: string
cpu_rev: number cpu_rev: number
cpu_cores: number cpu_cores: number
sketch_size: number sketch_size: number
free_sketch_space: number free_sketch_space: number
sdk_version: string sdk_version: string
arduino_version: string arduino_version: string
flash_chip_size: number flash_chip_size: number
flash_chip_speed: number flash_chip_speed: number
cpu_reset_reason: string cpu_reset_reason: string
} }
export type SystemInformation = Analytics & StaticSystemInformation export type SystemInformation = Analytics & StaticSystemInformation
export type IMU = { export type IMU = {
x: number x: number
y: number y: number
z: number z: number
heading: number heading: number
altitude: number altitude: number
bmp_temp: number bmp_temp: number
pressure: number pressure: number
}
export 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 = { export type PinConfig = {
pin: number pin: number
mode: string mode: string
type: string type: string
role: string role: string
} }
export type PeripheralsConfiguration = { export type PeripheralsConfiguration = {
sda: number sda: number
scl: number scl: number
frequency: number frequency: number
pins: PinConfig[] pins: PinConfig[]
} }
export type CameraSettings = { export type CameraSettings = {
framesize: number framesize: number
quality: number quality: number
brightness: number brightness: number
contrast: number contrast: number
saturation: number saturation: number
sharpness: number sharpness: number
denoise: number denoise: number
special_effect: number special_effect: number
wb_mode: number wb_mode: number
vflip: boolean vflip: boolean
hmirror: boolean hmirror: boolean
} }
export type File = number export type File = number
export interface Directory { export interface Directory {
[key: string]: File | Directory [key: string]: File | Directory
} }
export type Servo = { export type Servo = {
name: string name: string
channel: number channel: number
inverted: boolean inverted: boolean
angle: number angle: number
center_angle: number center_angle: number
} }
export type ServoConfiguration = { export type ServoConfiguration = {
is_active: boolean is_active: boolean
servo_pwm_frequency: number servo_pwm_frequency: number
servo_oscillator_frequency: number servo_oscillator_frequency: number
servos: Servo[] servos: Servo[]
} }
export interface MDNSServiceQuery { export interface MDNSServiceQuery {
services: MDNSServiceItem[] services: MDNSServiceItem[]
} }
export interface MDNSServiceItem { export interface MDNSServiceItem {
ip: string ip: string
port: number port: number
name: string name: string
} }
export interface MDNSService { export interface MDNSService {
service: string service: string
protocol: string protocol: string
port: number port: number
} }
export interface MDNSTxtRecord { export interface MDNSTxtRecord {
key: string key: string
value: string value: string
} }
export interface MDNSStatus { export interface MDNSStatus {
started: boolean started: boolean
hostname: string hostname: string
instance: string instance: string
services: MDNSService[] services: MDNSService[]
global_txt_records: MDNSTxtRecord[] global_txt_records: MDNSTxtRecord[]
} }
+11 -11
View File
@@ -1,14 +1,14 @@
declare module 'uzip' { declare module 'uzip' {
interface UZIP { interface UZIP {
parse(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array> parse(data: Uint8Array | ArrayBuffer): any;
compress(data: Record<string, Uint8Array>): Uint8Array | ArrayBuffer compress(data: any): Uint8Array | ArrayBuffer;
compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer;
decompress(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array> decompress(data: Uint8Array | ArrayBuffer): any;
decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer;
encode(data: Record<string, Uint8Array>): Uint8Array | ArrayBuffer encode(data: any): Uint8Array | ArrayBuffer;
decode(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array> decode(data: Uint8Array | ArrayBuffer): any;
} }
const uzip: UZIP const uzip: UZIP;
export default uzip export default uzip;
} }
+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);
} };
} }
+3 -5
View File
@@ -1,6 +1,4 @@
export const daisyColor = (name: string, opacity: number = 100) => { export const daisyColor = (name: string, opacity: number = 100) => {
const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim() const color = getComputedStyle(document.documentElement).getPropertyValue(name);
if (opacity >= 100) return color return `oklch(${color} / ${opacity}%)`;
const alpha = Math.min(Math.max(opacity, 0), 100) / 100 };
return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`
}
+9 -8
View File
@@ -1,8 +1,9 @@
export * from './result' export * from './result';
export * from './string-utilities' export * from './string-utilities';
export * from './svelte-utilities' export * from './svelte-utilities';
export * from './math-utilities' export * from './math-utilities';
export * from './buffer-utilities' export * from './buffer-utilities';
export * from './model-utilities' export * from './model-utilities';
export * from './string-utilities' export * from './position-utilities';
export * from './color-utilities' export * from './string-utilities';
export * from './color-utilities';
+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;
} };
+58 -62
View File
@@ -2,95 +2,91 @@ import { Color, LoaderUtils, Vector3 } from 'three'
import URDFLoader, { type URDFRobot } from 'urdf-loader' import URDFLoader, { type URDFRobot } from 'urdf-loader'
import { XacroLoader } from 'xacro-parser' import { XacroLoader } from 'xacro-parser'
import { Result } from '$lib/utilities' import { Result } from '$lib/utilities'
import { currentVariant, jointNames, model } from '$lib/stores' import { jointNames, model } from '$lib/stores'
import uzip from 'uzip' import uzip from 'uzip'
import { fileService } from '$lib/services' import { fileService } from '$lib/services'
import { get } from 'svelte/store'
import { resolve } from '$app/paths'
let model_xml: XMLDocument let model_xml: XMLDocument
export const populateModelCache = async () => { export const populateModelCache = async () => {
await cacheModelFiles() await cacheModelFiles()
const modelRes = await loadModel(get(currentVariant).model) const modelRes = await loadModel('/yertle.URDF')
if (modelRes.isOk()) { if (modelRes.isOk()) {
const [urdf, JOINT_NAME] = modelRes.inner const [urdf, JOINT_NAME] = modelRes.inner
jointNames.set(JOINT_NAME) jointNames.set(JOINT_NAME)
model.set(urdf) model.set(urdf)
} else { } else {
console.error(modelRes.inner, { exception: modelRes.exception }) console.error(modelRes.inner, { exception: modelRes.exception })
} }
} }
export const cacheModelFiles = async () => { export const cacheModelFiles = async () => {
const data = await fetch(get(currentVariant).stl) const data = await fetch('/URDF.zip')
const files = uzip.parse(await data.arrayBuffer()) const files = uzip.parse(await data.arrayBuffer())
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) { for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const normalizedPath = path.startsWith('/') ? path : '/' + path const url = new URL(path, window.location.href)
const resolvedUrl = resolve(normalizedPath as any) fileService?.saveFile(url.toString(), data)
fileService?.saveFile(resolvedUrl, data) }
fileService?.saveFile(normalizedPath, data)
}
} }
export const loadModel = async (url: string): Promise<Result<[URDFRobot, string[]], string>> => { export const loadModel = async (url: string): Promise<Result<[URDFRobot, string[]], string>> => {
const urdfLoader = new URDFLoader() const urdfLoader = new URDFLoader()
urdfLoader.workingPath = LoaderUtils.extractUrlBase(url)
let xml = let xml = url.endsWith('.xacro') ? await loadXacro(url) : await fetch(url).then(res => res.text())
url.endsWith('.xacro') ? await loadXacro(url) : await fetch(url).then(res => res.text())
if (typeof xml === 'string') { if (typeof xml === 'string') {
xml = new window.DOMParser().parseFromString(xml, 'text/xml') xml = new window.DOMParser().parseFromString(xml, 'text/xml')
}
return new Promise(resolve => {
model_xml = xml
try {
const model = urdfLoader.parse(xml)
setupRobot(model)
const joints = Object.entries(model.joints)
.filter(joint => joint[1].jointType !== 'fixed')
.map(joint => joint[0])
resolve(Result.ok([model, joints]))
} catch (error) {
resolve(Result.err('Failed to load model', error))
} }
})
return new Promise(resolve => {
model_xml = xml
try {
const model = urdfLoader.parse(xml)
setupRobot(model)
const joints = Object.entries(model.joints)
.filter(joint => joint[1].jointType !== 'fixed')
.map(joint => joint[0])
resolve(Result.ok([model, joints]))
} catch (error) {
resolve(Result.err('Failed to load model', error))
}
})
} }
const loadXacro = async (url: string): Promise<XMLDocument> => const loadXacro = async (url: string): Promise<XMLDocument> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
new XacroLoader().load(url, resolve, reject) new XacroLoader().load(url, resolve, reject)
}) })
function setupRobot(robot: URDFRobot) { function setupRobot(robot: URDFRobot) {
robot.rotation.x = -Math.PI / 2 robot.rotation.x = -Math.PI / 2
robot.rotation.z = Math.PI / 2 robot.rotation.z = Math.PI / 2
robot.scale.setScalar(10) robot.scale.setScalar(10)
robot.traverse(c => (c.castShadow = true)) robot.traverse(c => (c.castShadow = true))
robot.updateMatrixWorld(true) robot.updateMatrixWorld(true)
} }
export function getToeWorldPositions(robot: URDFRobot): Vector3[] { export function getToeWorldPositions(robot: URDFRobot): Vector3[] {
const toes: Vector3[] = [] const toes: Vector3[] = []
robot.traverse(c => { robot.traverse(c => {
if (c.name.includes('toe') && !c.name.includes('_link')) if (c.name.includes('toe') && !c.name.includes('_link'))
toes.push(c.getWorldPosition(new Vector3())) toes.push(c.getWorldPosition(new Vector3()))
}) })
return toes return toes
} }
export const extractFootColor = () => { export const extractFootColor = () => {
const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element
const colorAttrStr = colorElem.getAttribute('rgba') as string const colorAttrStr = colorElem.getAttribute('rgba') as string
const colorStr = colorAttrStr const colorStr = colorAttrStr
.split(' ') .split(' ')
.slice(0, 3) .slice(0, 3)
.map(val => Math.floor(+val * 255)) .map(val => Math.floor(+val * 255))
.join(', ') .join(', ')
return new Color(`rgb(${colorStr})`) return new Color(`rgb(${colorStr})`)
} }
@@ -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);
} }
} }
+32 -32
View File
@@ -1,47 +1,47 @@
export const humanFileSize = (size: number): string => { export const humanFileSize = (size: number): string => {
const units = ['B', 'kB', 'MB', 'GB', 'TB'] const units = ['B', 'kB', 'MB', 'GB', 'TB']
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)) const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024))
return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i] return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i]
} }
export const capitalize = (str: string): string => { export const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
} }
export const convertSeconds = (seconds: number) => { export const convertSeconds = (seconds: number) => {
// Calculate the number of seconds, minutes, hours, and days // Calculate the number of seconds, minutes, hours, and days
let minutes = Math.floor(seconds / 60) let minutes = Math.floor(seconds / 60)
let hours = Math.floor(minutes / 60) let hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24)
// Calculate the remaining hours, minutes, and seconds // Calculate the remaining hours, minutes, and seconds
hours = hours % 24 hours = hours % 24
minutes = minutes % 60 minutes = minutes % 60
seconds = seconds % 60 seconds = seconds % 60
// Create the formatted string // Create the formatted string
let result = '' let result = ''
if (days > 0) { if (days > 0) {
result += days + ' day' + (days > 1 ? 's' : '') + ' ' result += days + ' day' + (days > 1 ? 's' : '') + ' '
} }
if (hours > 0) { if (hours > 0) {
result += hours + ' hour' + (hours > 1 ? 's' : '') + ' ' result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '
} }
if (minutes > 0) { if (minutes > 0) {
result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' ' result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' '
} }
result += seconds + ' second' + (seconds > 1 ? 's' : '') result += seconds + ' second' + (seconds > 1 ? 's' : '')
return result return result
} }
export const compareIp = (ip1: string, ip2: string) => { export const compareIp = (ip1: string, ip2: string) => {
const ip1Parts = ip1.split('.').map(Number) const ip1Parts = ip1.split('.').map(Number)
const ip2Parts = ip2.split('.').map(Number) const ip2Parts = ip2.split('.').map(Number)
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
if (ip1Parts[i] !== ip2Parts[i]) { if (ip1Parts[i] !== ip2Parts[i]) {
return ip1Parts[i] > ip2Parts[i] ? 1 : -1 return ip1Parts[i] > ip2Parts[i] ? 1 : -1
}
} }
return 0 }
return 0
} }
+11 -11
View File
@@ -1,16 +1,16 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store';
import { browser } from '$app/environment' import { browser } from '$app/environment';
export const persistentStore = <T>(key: string, initialValue: T) => { export const persistentStore = <T>(key: string, initialValue: T) => {
const savedValue = browser ? localStorage.getItem(key) : null const savedValue = browser ? localStorage.getItem(key) : null;
const data: T = savedValue !== null ? JSON.parse(savedValue) : initialValue const data: T = savedValue !== null ? JSON.parse(savedValue) : initialValue;
const store = writable<T>() const store = writable<T>();
store.subscribe(value => { store.subscribe(value => {
if (browser) localStorage.setItem(key, JSON.stringify(value)) if (browser) localStorage.setItem(key, JSON.stringify(value));
}) });
store.set(data) store.set(data);
return store return store;
} };
+10 -4
View File
@@ -1,9 +1,15 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { goto } from '$app/navigation';
import { resolve } from '$app/paths' import { onMount } from 'svelte';
onMount(() => {
setTimeout(() => {
goto('/');
}, 3000);
});
</script> </script>
<div class="flex justify-center items-center w-full h-full"> <div class="flex justify-center items-center w-full h-full">
<h1>{page.status} {page.error?.message}</h1> <h1 class="text-4xl">404 - Page not found</h1>
<span>Go to <a class="btn btn-primary" href={resolve('/')}>Home page</a></span> <p>You will be redirected to the home page in 3 seconds</p>
</div> </div>
+57 -62
View File
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte';
import { page } from '$app/state' import { page } from '$app/state';
import { Modals, modals } from 'svelte-modals' import { Modals, modals } from 'svelte-modals';
import Toast from '$lib/components/toasts/Toast.svelte' import Toast from '$lib/components/toasts/Toast.svelte';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import { fade } from 'svelte/transition' import { fade } from 'svelte/transition';
import '../app.css' import '../app.css';
import Menu from '../lib/components/menu/Menu.svelte' import Menu from '../lib/components/menu/Menu.svelte';
import Statusbar from '../lib/components/statusbar/statusbar.svelte' import Statusbar from '../lib/components/statusbar/statusbar.svelte';
import { import {
telemetry, telemetry,
analytics, analytics,
@@ -18,80 +18,76 @@
servoAngles, servoAngles,
servoAnglesOut, servoAnglesOut,
socket, socket,
apiLocation, location,
useFeatureFlags, useFeatureFlags
walkGait } from '$lib/stores';
} from '$lib/stores' import type { Analytics, DownloadOTA } from '$lib/types/models';
import { type Analytics, type DownloadOTA } from '$lib/types/models'
import { MessageTopic } from '$lib/types/models'
interface Props { interface Props {
children?: import('svelte').Snippet children?: import('svelte').Snippet;
} }
let { children }: Props = $props() let { children }: Props = $props();
const features = useFeatureFlags() const features = useFeatureFlags();
onMount(async () => { onMount(async () => {
const ws = $apiLocation ? $apiLocation : window.location.host const ws = $location ? $location : window.location.host;
socket.init(`ws://${ws}/api/ws`) socket.init(`ws://${ws}/api/ws/events`);
addEventListeners() addEventListeners();
outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data)) outControllerData.subscribe(data => socket.sendEvent('input', { data }));
mode.subscribe(data => socket.sendEvent(MessageTopic.mode, data)) mode.subscribe(data => socket.sendEvent('mode', { data }));
walkGait.subscribe(data => socket.sendEvent(MessageTopic.gait, data)) servoAnglesOut.subscribe(data => socket.sendEvent('angles', { data }));
servoAnglesOut.subscribe(data => socket.sendEvent(MessageTopic.angles, data)) kinematicData.subscribe(data => socket.sendEvent('position', { data }));
kinematicData.subscribe(data => socket.sendEvent(MessageTopic.position, data)) });
})
onDestroy(() => { onDestroy(() => {
removeEventListeners() removeEventListeners();
}) });
const addEventListeners = () => { const addEventListeners = () => {
socket.on('open', handleOpen) socket.on('open', handleOpen);
socket.on('close', handleClose) socket.on('close', handleClose);
socket.on('error', handleError) socket.on('error', handleError);
socket.on(MessageTopic.rssi, handleNetworkStatus) socket.on('rssi', handleNetworkStatus);
socket.on(MessageTopic.mode, (data: ModesEnum) => mode.set(data)) socket.on('mode', (data: ModesEnum) => mode.set(data));
socket.on(MessageTopic.analytics, handleAnalytics) socket.on('analytics', handleAnalytics);
socket.on(MessageTopic.angles, (angles: number[]) => { socket.on('angles', (angles: number[]) => {
if (angles.length) servoAngles.set(angles) if (angles.length) servoAngles.set(angles);
}) });
features.subscribe(data => { features.subscribe(data => {
if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT) if (data?.download_firmware) socket.on('otastatus', handleOAT);
if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data)) if (data?.sonar) socket.on('sonar', data => console.log(data));
}) });
} };
const removeEventListeners = () => { const removeEventListeners = () => {
socket.off(MessageTopic.analytics, handleAnalytics) socket.off('analytics', handleAnalytics);
socket.off('open', handleOpen) socket.off('open', handleOpen);
socket.off('close', handleClose) socket.off('close', handleClose);
socket.off(MessageTopic.rssi, handleNetworkStatus) socket.off('rssi', handleNetworkStatus);
socket.off(MessageTopic.otastatus, handleOAT) socket.off('otastatus', handleOAT);
} };
const handleOpen = () => { const handleOpen = () => {
notifications.success('Connection to device established', 5000) notifications.success('Connection to device established', 5000);
} };
const handleClose = () => { const handleClose = () => {
notifications.error('Connection to device lost', 5000) notifications.error('Connection to device lost', 5000);
telemetry.setRSSI(0) telemetry.setRSSI(0);
} };
const handleError = (data: unknown) => console.error(data) const handleError = (data: any) => console.error(data);
const handleAnalytics = (data: Analytics) => analytics.addData(data) const handleAnalytics = (data: Analytics) => analytics.addData(data);
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data) const handleNetworkStatus = (data: number) => telemetry.setRSSI(data);
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data) const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data);
let menuOpen = $state(false) let menuOpen = $state(false);
</script> </script>
<svelte:head> <svelte:head>
@@ -115,14 +111,13 @@
</div> </div>
<Modals> <Modals>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#snippet backdrop()} {#snippet backdrop()}
<div <div
class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm" class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm"
transition:fade transition:fade
onclick={modals.closeAll} onclick={modals.closeAll}
onkeydown={e => e.key === 'Escape' && modals.closeAll()}
role="button"
tabindex="0"
></div> ></div>
{/snippet} {/snippet}
</Modals> </Modals>
+18 -30
View File
@@ -1,34 +1,22 @@
export const prerender = true export const prerender = false;
export const ssr = false export const ssr = false;
const registerFetchIntercept = async () => { const registerFetchIntercept = async () => {
const { fetch: originalFetch } = window const { fetch: originalFetch } = window;
const fileService = (await import('$lib/services/file-service')).default const fileService = (await import('$lib/services/file-service')).default;
window.fetch = async (resource, config) => { window.fetch = async (resource, config) => {
const url = resource instanceof Request ? resource.url : resource.toString() let url = resource instanceof Request ? resource.url : resource.toString();
let file = await fileService.getFile(url);
let file = await fileService?.getFile(url) return file.isOk() ? new Response(file.inner) : originalFetch(resource, config);
if (file?.isOk() && file.inner) return new Response(new Uint8Array(file.inner)) };
};
if (url.startsWith('http')) {
try {
const urlObj = new URL(url)
const pathOnly = urlObj.pathname
file = await fileService?.getFile(pathOnly)
if (file?.isOk() && file.inner) return new Response(new Uint8Array(file.inner))
} catch {}
}
return originalFetch(resource, config)
}
}
export const load = async () => { export const load = async () => {
await registerFetchIntercept() await registerFetchIntercept();
return { return {
title: 'Spot micro controller', title: 'Spot micro controller',
github: 'runeharlyk/SpotMicroESP32-Leika', github: 'runeharlyk/SpotMicroESP32-Leika',
app_name: 'Spot Micro Controller', app_name: 'Spot Micro Controller',
copyright: '2025 Rune Harlyk' copyright: '2024 Rune Harlyk'
} };
} };
+19 -22
View File
@@ -1,30 +1,27 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import Visualization from '$lib/components/Visualization.svelte' import Visualization from '$lib/components/Visualization.svelte'
import { socket } from '$lib/stores' import { socket } from '$lib/stores'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { resolve } from '$app/paths'
onMount(() => { onMount(() => {
socket.subscribe(isConnected => { socket.subscribe(isConnected => {
if (isConnected) { if (isConnected) {
goto(resolve('/controller')) goto('/controller')
} }
})
}) })
})
</script> </script>
<div class="hero bg-base-100 h-screen"> <div class="hero bg-base-100 h-screen">
<div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center"> <div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center">
<div class="w-64 h-64"> <div class="w-64 h-64">
<Visualization defaultColor={null} orbit panel={false} ground={false} /> <Visualization sky={false} orbit panel={false} ground={false} />
</div>
<div class="card-body w-80">
<h2 class="card-title text-center text-2xl">Begin you journey</h2>
<p class="py-6 text-center"></p>
<a class="btn btn-primary" href={resolve($socket ? '/controller' : '/connection')}>
Add Robot Dog
</a>
</div>
</div> </div>
<div class="card-body w-80">
<h2 class="card-title text-center text-2xl">Begin you journey</h2>
<p class="py-6 text-center"></p>
<a class="btn btn-primary" href={$socket ? '/controller' : '/connection'}> Add Robot Dog </a>
</div>
</div>
</div> </div>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Connection from './Connection.svelte' import Connection from './Connection.svelte';
</script> </script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8"> <div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
<Connection /> <Connection />
</div> </div>
+5 -5
View File
@@ -1,7 +1,7 @@
import type { PageLoad } from './$types' import type { PageLoad } from './$types';
export const load = (async () => { export const load = (async () => {
return { return {
title: 'Connection' title: 'Connection'
} };
}) satisfies PageLoad }) satisfies PageLoad;
+11 -9
View File
@@ -1,25 +1,27 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte';
import { WiFi } from '$lib/components/icons' import { WiFi } from '$lib/components/icons';
import { apiLocation, socket } from '$lib/stores' import { location, socket, useFeatureFlags } from '$lib/stores';
const features = useFeatureFlags();
const update = () => { const update = () => {
const ws = $apiLocation ? $apiLocation : window.location.host const ws = $location ? $location : window.location.host;
socket.init(`ws://${ws}/api/ws/events`) socket.init(`ws://${ws}/api/ws/events`);
} };
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<WiFi class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <WiFi class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span>Connection</span> <span >Connection</span>
{/snippet} {/snippet}
<div class="flex"> <div class="flex">
<label class="label w-32" for="server">Address:</label> <label class="label w-32" for="server">Address:</label>
<input class="input" bind:value={$apiLocation} /> <input class="input" bind:value={$location} />
</div> </div>
<button class="btn btn-primary" onclick={update}>Update</button> <button class="btn btn-primary" onclick={update}>Update</button>
+16 -16
View File
@@ -1,26 +1,26 @@
<script lang="ts"> <script lang="ts">
import Controls from './Controls.svelte' import Controls from './Controls.svelte';
import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte' import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte';
import { selectedView, views } from '$lib/stores/application' import { selectedView, views } from '$lib/stores/application';
import { onMount } from 'svelte' import { onMount } from 'svelte';
import { mpu, socket } from '$lib/stores' import { mpu, socket } from '$lib/stores';
import { imu } from '$lib/stores/imu' import { imu } from '$lib/stores/imu';
import { MessageTopic, type IMU } from '$lib/types/models' import type { IMU } from '$lib/types/models';
let layout = $derived($views.find(v => v.name === $selectedView)!) let layout = $derived($views.find(v => v.name === $selectedView)!);
onMount(() => { onMount(() => {
socket.on(MessageTopic.imu, (data: IMU) => { socket.on('imu', (data: IMU) => {
imu.addData(data) imu.addData(data);
if (data.heading) if (data.heading)
mpu.update(mpuData => { mpu.update(mpuData => {
mpuData.heading = data.heading mpuData.heading = data.heading;
console.log(data.heading) console.log(data.heading);
return mpuData return mpuData;
}) });
}) });
}) });
</script> </script>
<div class="absolute top-0 select-none w-screen h-screen"> <div class="absolute top-0 select-none w-screen h-screen">
+2 -2
View File
@@ -1,3 +1,3 @@
export const load = async () => { export const load = async () => {
return { title: 'Controller' } return { title: 'Controller' };
} };
+142 -198
View File
@@ -1,225 +1,169 @@
<script lang="ts"> <script lang="ts">
import nipplejs from 'nipplejs' import nipplejs from 'nipplejs'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { capitalize, throttler } from '$lib/utilities' import { capitalize, throttler, toInt8 } from '$lib/utilities'
import { import { input, outControllerData, mode, modes, type Modes, ModesEnum } from '$lib/stores'
input, import type { vector } from '$lib/types/models'
outControllerData, import { VerticalSlider } from '$lib/components/input'
mode, import { gamepadAxes, gamepadButtons, hasGamepad } from '$lib/stores/gamepad'
modes, import { notifications } from '$lib/components/toasts/notifications'
type Modes,
ModesEnum,
WalkGaits,
walkGait,
walkGaitLabels
} from '$lib/stores'
import type { vector } from '$lib/types/models'
import { VerticalSlider } from '$lib/components/input'
import { gamepadAxes, gamepadButtonsEdges, hasGamepad } from '$lib/stores/gamepad'
import { notifications } from '$lib/components/toasts/notifications'
let throttle = new throttler() let throttle = new throttler()
let left: nipplejs.JoystickManager let left: nipplejs.JoystickManager
let right: nipplejs.JoystickManager let right: nipplejs.JoystickManager
let throttle_timing = 40 let throttle_timing = 40
let data = new Array(7) let data = new Array(8)
$effect(() => { $effect(() => {
if ($hasGamepad) { if ($hasGamepad) {
notifications.success('🎮 Gamepad connected', 3000) notifications.success('🎮 Gamepad connected', 3000)
} }
})
$effect(() => {
handleJoyMove('left', { x: $gamepadAxes[0], y: -$gamepadAxes[1] })
handleJoyMove('right', { x: $gamepadAxes[2], y: $gamepadAxes[3] })
})
// TODO React to button press
// $effect(() => {
// if ($gamepadButtons.length === 0) return
//
// })
onMount(() => {
left = nipplejs.create({
zone: document.getElementById('left') as HTMLElement,
color: '#15191e80',
dynamicPage: true,
mode: 'static',
restOpacity: 1
}) })
$effect(() => { right = nipplejs.create({
handleJoyMove('left', { x: $gamepadAxes[0], y: -$gamepadAxes[1] }) zone: document.getElementById('right') as HTMLElement,
handleJoyMove('right', { x: $gamepadAxes[2], y: $gamepadAxes[3] }) color: '#15191e80',
dynamicPage: true,
mode: 'static',
restOpacity: 1
}) })
$effect(() => { left.on('move', (_, data) => handleJoyMove('left', data.vector))
if (!$hasGamepad) return left.on('end', (_, __) => handleJoyMove('left', { x: 0, y: 0 }))
const b = $gamepadButtonsEdges right.on('move', (_, data) => handleJoyMove('right', data.vector))
if (!b.length) return right.on('end', (_, __) => handleJoyMove('right', { x: 0, y: 0 }))
if (b[0]?.justPressed) mode.set(5) })
if (b[1]?.justPressed) mode.set(4)
if (b[2]?.justPressed) mode.set(3) const handleJoyMove = (key: 'left' | 'right', data: vector) => {
if (b[3]?.justPressed) mode.set(0) input.update(inputData => {
if (b[12]?.justPressed) inputData[key] = data
input.update(inputData => { return inputData
inputData['height'] = Math.min(inputData.height + 0.1, 1)
return inputData
})
if (b[13]?.justPressed)
input.update(inputData => {
inputData['height'] = Math.min(inputData.height - 0.1, 1)
return inputData
})
}) })
throttle.throttle(updateData, throttle_timing)
}
onMount(() => { const updateData = () => {
left = nipplejs.create({ data[0] = 0
zone: document.getElementById('left') as HTMLElement, data[1] = toInt8($input.left.x, -1, 1)
color: '#15191e80', data[2] = toInt8($input.left.y, -1, 1)
dynamicPage: true, data[3] = toInt8($input.right.x, -1, 1)
mode: 'static', data[4] = toInt8($input.right.y, -1, 1)
restOpacity: 1 data[5] = toInt8($input.height, 0, 100)
}) data[6] = toInt8($input.speed, 0, 100)
data[7] = toInt8($input.s1, 0, 100)
right = nipplejs.create({ outControllerData.set(data)
zone: document.getElementById('right') as HTMLElement, }
color: '#15191e80',
dynamicPage: true,
mode: 'static',
restOpacity: 1
})
left.on('move', (_, data) => handleJoyMove('left', data.vector)) const handleKeyup = (event: KeyboardEvent) => {
left.on('end', () => handleJoyMove('left', { x: 0, y: 0 })) const down = event.type === 'keydown'
right.on('move', (_, data) => handleJoyMove('right', data.vector)) input.update(data => {
right.on('end', () => handleJoyMove('right', { x: 0, y: 0 })) if (event.key === 'w') data.left.y = down ? 1 : 0
if (event.key === 'a') data.left.x = down ? 1 : 0
if (event.key === 's') data.left.y = down ? -1 : 0
if (event.key === 'd') data.left.x = down ? -1 : 0
return data
}) })
throttle.throttle(updateData, throttle_timing)
}
const handleJoyMove = (key: 'left' | 'right', data: vector) => { const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => {
input.update(inputData => { const value: number = Number((event.target as HTMLInputElement).value)
inputData[key] = data
return inputData
})
throttle.throttle(updateData, throttle_timing)
}
const updateData = () => { input.update(inputData => {
data[0] = $input.left.x inputData[key] = value
data[1] = $input.left.y return inputData
data[2] = $input.right.x })
data[3] = $input.right.y throttle.throttle(updateData, throttle_timing)
data[4] = $input.height }
data[5] = $input.speed
data[6] = $input.s1
outControllerData.set(data) const changeMode = (modeValue: Modes) => {
} mode.set(modes.indexOf(modeValue))
}
const handleKeyup = (event: KeyboardEvent) => {
const down = event.type === 'keydown'
input.update(data => {
if (event.key === 'w') data.left.y = down ? 1 : 0
if (event.key === 'a') data.left.x = down ? -1 : 0
if (event.key === 's') data.left.y = down ? -1 : 0
if (event.key === 'd') data.left.x = down ? 1 : 0
if (event.key === 'ArrowLeft') data.right.x = down ? 1 : 0
if (event.key === 'ArrowRight') data.right.x = down ? -1 : 0
return data
})
throttle.throttle(updateData, throttle_timing)
}
const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => {
const value: number = Number((event.target as HTMLInputElement).value)
input.update(inputData => {
inputData[key] = value
return inputData
})
throttle.throttle(updateData, throttle_timing)
}
const changeMode = (modeValue: Modes) => {
mode.set(modes.indexOf(modeValue))
}
const changeWalkGait = (walkGaitValue: WalkGaits) => {
walkGait.set(walkGaitValue)
}
</script> </script>
<div class="absolute top-0 left-0 w-screen h-screen"> <div class="absolute top-0 left-0 w-screen h-screen">
<div class="absolute top-0 left-0 h-full w-full flex portrait:hidden"> <div class="absolute top-0 left-0 h-full w-full flex portrait:hidden">
<div id="left" class="flex w-60 items-center justify-end"></div> <div id="left" class="flex w-60 items-center justify-end"></div>
<div class="flex-1"></div> <div class="flex-1"></div>
<div id="right" class="flex w-60 items-center"></div> <div id="right" class="flex w-60 items-center"></div>
</div>
<div class="absolute bottom-0 right-0 p-4 z-10 gap-2 flex-col hidden lg:flex">
<div class="flex justify-center w-full">
<kbd class="kbd">W</kbd>
</div> </div>
<div class="absolute bottom-0 right-0 p-4 z-10 gap-2 flex-col hidden lg:flex"> <div class="flex justify-center gap-2 w-full">
<div class="flex justify-center w-full"> <kbd class="kbd">A</kbd>
<kbd class="kbd">W</kbd> <kbd class="kbd">S</kbd>
</div> <kbd class="kbd">D</kbd>
<div class="flex justify-center gap-2 w-full">
<kbd class="kbd">A</kbd>
<kbd class="kbd">S</kbd>
<kbd class="kbd">D</kbd>
</div>
<div class="flex justify-center w-full"></div>
</div> </div>
<div class="absolute bottom-0 z-10 flex items-end"> <div class="flex justify-center w-full"></div>
<div </div>
class="flex items-center flex-col bg-base-300 bg-opacity-50 p-3 pb-2 gap-2 rounded-tr-xl" <div class="absolute bottom-0 z-10 flex items-end">
> <div class="flex items-center flex-col bg-base-300 bg-opacity-50 p-3 pb-2 gap-2 rounded-tr-xl">
<VerticalSlider <VerticalSlider min={0} max={100} oninput={(e: Event) => handleRange(e, 'height')} />
min={0} <label for="height">Ht</label>
max={1} </div>
step={0.01} <div
oninput={(e: Event) => handleRange(e, 'height')} class="flex items-end gap-4 bg-base-300 bg-opacity-50 h-min rounded-tr-xl pl-0 p-3 portrait:hidden">
/> <div class="join">
<label for="height">Ht</label> {#each modes as modeValue}
</div> <button
<div class="btn join-item"
class="flex items-end gap-4 bg-base-300 bg-opacity-50 h-min rounded-tr-xl pl-0 p-3 portrait:hidden" class:btn-primary={$mode === modes.indexOf(modeValue)}
> onclick={() => changeMode(modeValue)}>
<div class="join"> {capitalize(modeValue)}
{#each modes as modeValue} </button>
<button {/each}
class="btn join-item" </div>
class:btn-primary={$mode === modes.indexOf(modeValue)}
onclick={() => changeMode(modeValue)}
>
{capitalize(modeValue)}
</button>
{/each}
</div>
{#if $mode === ModesEnum.Walk} {#if $mode === ModesEnum.Walk || $mode === ModesEnum.Crawl}
<div class="join"> <div class="flex gap-4">
{#each Object.values(WalkGaits) as gaitValue} <div>
{#if typeof gaitValue === 'number'} <label for="s1">S1</label>
<button <input
class="btn join-item btn-sm" type="range"
class:btn-secondary={$walkGait === gaitValue} name="s1"
onclick={() => changeWalkGait(gaitValue)} min="0"
> max="25"
{walkGaitLabels[gaitValue]} oninput={e => handleRange(e, 's1')}
</button> class="range range-sm range-primary" />
{/if} </div>
{/each} <div>
</div> <label for="speed">Speed</label>
<input
<div class="flex gap-4"> type="range"
<div> name="speed"
<label for="s1">S1</label> min="0"
<input max="25"
type="range" oninput={e => handleRange(e, 'speed')}
name="s1" class="range range-sm range-primary" />
min="0" </div>
step="0.01"
max="1"
oninput={e => handleRange(e, 's1')}
class="range range-sm range-primary"
/>
</div>
<div>
<label for="speed">Speed</label>
<input
type="range"
name="speed"
min="0"
step="0.01"
max="1"
oninput={e => handleRange(e, 'speed')}
class="range range-sm range-primary"
/>
</div>
</div>
{/if}
</div> </div>
{/if}
</div> </div>
</div>
</div> </div>
<svelte:window onkeyup={handleKeyup} onkeydown={handleKeyup} /> <svelte:window onkeyup={handleKeyup} onkeydown={handleKeyup} />
+5 -6
View File
@@ -1,8 +1,7 @@
import type { PageLoad } from './$types' import type { PageLoad } from './$types';
import { goto } from '$app/navigation' import { goto } from '$app/navigation';
import { resolve } from '$app/paths'
export const load = (async () => { export const load = (async () => {
goto(resolve('/')) goto('/');
return return;
}) satisfies PageLoad }) satisfies PageLoad;
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Camera from './Camera.svelte' import Camera from './Camera.svelte';
</script> </script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8"> <div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
<Camera /> <Camera />
</div> </div>
+5 -5
View File
@@ -1,7 +1,7 @@
import type { PageLoad } from './$types' import type { PageLoad } from './$types';
export const load = (async () => { export const load = (async () => {
return { return {
title: 'Camera' title: 'Camera'
} };
}) satisfies PageLoad }) satisfies PageLoad;
@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from "$lib/components/SettingsCard.svelte";
import CameraSetting from './CameraSetting.svelte' import CameraSetting from './CameraSetting.svelte';
import Stream from '$lib/components/Stream.svelte' import Stream from '$lib/components/Stream.svelte';
import { Camera } from '$lib/components/icons' import { Camera } from "$lib/components/icons";
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<Camera class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <Camera class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span>Camera</span> <span >Camera</span>
{/snippet} {/snippet}
<Stream /> <Stream />
<CameraSetting /> <CameraSetting />
</SettingsCard> </SettingsCard>
@@ -1,25 +1,13 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api' import { api } from '$lib/api';
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte';
import type { CameraSettings } from '$lib/types/models' import type { CameraSettings } from '$lib/types/models';
let settings: CameraSettings = $state({ let settings:CameraSettings = $state()
brightness: 0,
contrast: 0,
framesize: 0,
vflip: false,
hmirror: false,
special_effect: 0,
quality: 0,
saturation: 0,
sharpness: 0,
denoise: 0,
wb_mode: 0
})
const getCameraSettings = async () => { const getCameraSettings = async () => {
const result = await api.get<CameraSettings>('/api/camera/settings') const result = await api.get<CameraSettings>('/api/camera/settings')
if (result.isErr()) { if (result.isErr()){
console.error('An error occurred', result.inner) console.error("An error occurred", result.inner);
return return
} }
settings = result.inner settings = result.inner
@@ -27,8 +15,8 @@
const updateCameraSettings = async () => { const updateCameraSettings = async () => {
const result = await api.post<CameraSettings>('/api/camera/settings', settings) const result = await api.post<CameraSettings>('/api/camera/settings', settings)
if (result.isErr()) { if (result.isErr()){
console.error('An error occurred', result.inner) console.error("An error occurred", result.inner);
return return
} }
settings = result.inner settings = result.inner
@@ -37,43 +25,23 @@
{#await getCameraSettings()} {#await getCameraSettings()}
<Spinner /> <Spinner />
{:then} {:then _}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<button class="btn btn-primary" type="button" onclick={updateCameraSettings} <button class="btn btn-primary" type="button" onclick={updateCameraSettings}>Update camera settings</button>
>Update camera settings</button
>
<label for="brightness"> <label for="brightness">
Brightness {settings.brightness} Brightness {settings.brightness}
<input <input type="range" min="-2" max="2" class="range range-xs" bind:value={settings.brightness}/>
type="range"
min="-2"
max="2"
class="range range-xs"
bind:value={settings.brightness}
/>
</label> </label>
<label for="contrast"> <label for="contrast">
Contrast {settings.contrast} Contrast {settings.contrast}
<input <input type="range" min="-2" max="2" class="range range-xs" bind:value={settings.contrast}/>
type="range"
min="-2"
max="2"
class="range range-xs"
bind:value={settings.contrast}
/>
</label> </label>
<label for="framesize"> <label for="framesize">
FrameSize {settings.framesize} FrameSize {settings.framesize}
<input <input type="range" min="0" max="10" class="range range-xs" bind:value={settings.framesize}/>
type="range"
min="0"
max="10"
class="range range-xs"
bind:value={settings.framesize}
/>
</label> </label>
<label class="cursor-pointer flex items-center justify-between"> <label class="cursor-pointer flex items-center justify-between">
@@ -88,10 +56,7 @@
<label for="special_effect" class="flex items-center"> <label for="special_effect" class="flex items-center">
<span class="basis-1/2">Special Effect</span> <span class="basis-1/2">Special Effect</span>
<select <select class="select select-bordered select-sm w-full max-w-xs" bind:value={settings.special_effect}>
class="select select-bordered select-sm w-full max-w-xs"
bind:value={settings.special_effect}
>
<option value={0}>No effect</option> <option value={0}>No effect</option>
<option value={1}>Negative</option> <option value={1}>Negative</option>
<option value={2}>Grayscale</option> <option value={2}>Grayscale</option>

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