🎨 format

This commit is contained in:
Rune Harlyk
2025-10-11 10:42:32 +02:00
parent 4d51b9f556
commit 91a7b170fe
139 changed files with 6645 additions and 6317 deletions
+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 -1
View File
@@ -1,7 +1,7 @@
{ {
"useTabs": false, "useTabs": false,
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "tabWidth": 4,
"trailingComma": "none", "trailingComma": "none",
"arrowParens": "avoid", "arrowParens": "avoid",
"experimentalTernaries": true, "experimentalTernaries": true,
+5 -1
View File
@@ -1,3 +1,7 @@
{ {
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"] "recommendations": [
"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
} }
+63 -63
View File
@@ -1,65 +1,65 @@
{ {
"name": "spot_micro_controller", "name": "spot_micro_controller",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
"build": "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.1.64", "@iconify-json/mdi": "^1.1.64",
"@iconify-json/tabler": "^1.1.109", "@iconify-json/tabler": "^1.1.109",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@sveltejs/adapter-static": "^3.0.1", "@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.27", "@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/eslint": "^8.56.0", "@types/eslint": "^8.56.0",
"@types/three": "^0.162.0", "@types/three": "^0.162.0",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0", "@typescript-eslint/parser": "^7.0.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.45.1", "eslint-plugin-svelte": "^2.45.1",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.6",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"svelte-focus-trap": "^1.2.0", "svelte-focus-trap": "^1.2.0",
"tailwindcss": "^4.0.12", "tailwindcss": "^4.0.12",
"tslib": "^2.6.1", "tslib": "^2.6.1",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"unplugin-icons": "^0.18.5", "unplugin-icons": "^0.18.5",
"vite": "^6.2.1", "vite": "^6.2.1",
"vitest": "^1.2.0" "vitest": "^1.2.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.2", "@msgpack/msgpack": "^3.1.2",
"@niku/vite-env-caster": "^1.0.2", "@niku/vite-env-caster": "^1.0.2",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
"@tailwindcss/vite": "^4.0.12", "@tailwindcss/vite": "^4.0.12",
"chart.js": "^4.4.2", "chart.js": "^4.4.2",
"compare-versions": "^6.1.0", "compare-versions": "^6.1.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"daisyui": "^5.0.0", "daisyui": "^5.0.0",
"nipplejs": "^0.10.1", "nipplejs": "^0.10.1",
"svelte-dnd-list": "^0.1.8", "svelte-dnd-list": "^0.1.8",
"svelte-modals": "^2.0.0", "svelte-modals": "^2.0.0",
"three": "^0.162.0", "three": "^0.162.0",
"urdf-loader": "^0.12.1", "urdf-loader": "^0.12.1",
"uzip": "^0.20201231.0", "uzip": "^0.20201231.0",
"xacro-parser": "^0.3.9" "xacro-parser": "^0.3.9"
}, },
"packageManager": "pnpm@9.3.0" "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
+8
View File
@@ -23,6 +23,14 @@
--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 {}
+14 -11
View File
@@ -1,14 +1,17 @@
<!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 name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" /> <meta
<meta name="apple-mobile-web-app-capable" content="yes" /> name="viewport"
<meta name="mobile-web-app-capable" content="yes" /> content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
%sveltekit.head% />
</head> <meta name="apple-mobile-web-app-capable" content="yes" />
<body data-sveltekit-preload-data="hover"> <meta name="mobile-web-app-capable" content="yes" />
<div style="display: contents">%sveltekit.body%</div> %sveltekit.head%
</body> </head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html> </html>
-7
View File
@@ -1,7 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
+24 -25
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 { location } from './stores'; import { location } from './stores'
export namespace api { export namespace api {
export function 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)
} }
export function 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)
} }
export function 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)
} }
export function 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,43 +38,42 @@ 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 (error) { } 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 = const contentType = response.headers.get('Content-Type') ?? response.headers.get('Content-Type')
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(location)) return url; if (url.startsWith('http') || !get(location)) return url
const protocol = window.location.protocol; const protocol = window.location.protocol
return `${protocol}//${get(location)}${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">
+40 -35
View File
@@ -1,43 +1,48 @@
<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
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"> role="dialog"
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2> class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
<div class="divider my-2"></div> transition:fly={{ y: 50 }}
<p class="text-base-content mb-1 text-start">{message}</p> use:exitBeforeEnter
<div class="divider my-2"></div> use:focusTrap
<div class="flex justify-end gap-2"> >
<button class="btn btn-error inline-flex items-center" onclick={() => modals.close()}> <div
<labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span> class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
</button> >
<button class="btn btn-primary inline-flex items-center" onclick={onConfirm}> <h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span> <div class="divider my-2"></div>
</button> <p class="text-base-content mb-1 text-start">{message}</p>
</div> <div class="divider my-2"></div>
<div class="flex justify-end gap-2">
<button
class="btn btn-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
+35 -32
View File
@@ -1,40 +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 { Check } from './icons'; import { Check } from './icons'
import { exitBeforeEnter, type ModalProps } from 'svelte-modals'; import { exitBeforeEnter, type ModalProps } from 'svelte-modals'
let { let {
isOpen, isOpen,
title, title,
message, message,
onDismiss, onDismiss,
labels = { labels = {
dismiss: { label: 'Dismiss', icon: Check }, dismiss: { label: 'Dismiss', icon: Check }
}, }
}: ModalProps = $props(); }: ModalProps = $props()
</script> </script>
{#if isOpen} {#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
<div <div
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"> role="dialog"
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2> class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
<div class="divider my-2"></div> transition:fly={{ y: 50 }}
<p class="text-base-content mb-1 text-start">{message}</p> use:exitBeforeEnter
<div class="divider my-2"></div> use:focusTrap
<div class="flex justify-end gap-2"> >
<button <div
class="btn btn-warning text-warning-content inline-flex items-center" class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
onclick={onDismiss}> >
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span> <h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
</button> <div class="divider my-2"></div>
</div> <p class="text-base-content mb-1 text-start">{message}</p>
<div class="divider my-2"></div>
<div class="flex justify-end gap-2">
<button
class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onDismiss}
>
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span>
</button>
</div>
</div>
</div> </div>
</div>
{/if} {/if}
@@ -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
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>
+65 -49
View File
@@ -1,60 +1,76 @@
<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 { open = $bindable(true), collapsible = true, icon, title, children, right }: Props = $props() let {
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="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"> class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
<span class="inline-flex items-baseline"> >
{@render icon?.()} <div
{@render title?.()} class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
</span> >
<button <span class="inline-flex items-baseline">
class="btn btn-circle btn-ghost btn-sm" {@render icon?.()}
onclick={() => { {@render title?.()}
open = !open </span>
}}> <button
<Down class="btn btn-circle btn-ghost btn-sm"
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open ? onclick={() => {
'rotate-180' open = !open
: ''}" /> }}
</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="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"> class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
<span class="inline-flex items-baseline"> >
{@render icon?.()} <div
{@render title?.()} class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
</span> >
{@render right?.()} <span class="inline-flex items-baseline">
{@render icon?.()}
{@render title?.()}
</span>
{@render right?.()}
</div>
<div class="flex flex-col gap-2 p-4 pt-0">
{@render children?.()}
</div>
</div> </div>
<div class="flex flex-col gap-2 p-4 pt-0">
{@render children?.()}
</div>
</div>
{/if} {/if}
+3 -4
View File
@@ -1,9 +1,8 @@
<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 -35
View File
@@ -1,45 +1,45 @@
<script lang="ts"> <script lang="ts">
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning' type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
const { const {
icon, icon,
title, title,
description = '', description = '',
variant = 'primary', variant = 'primary',
class: klass = '', class: klass = '',
children = null children = null
} = $props<{ } = $props<{
icon?: any icon?: any
title: string title: string
description?: string | number description?: string | number
variant?: Variant variant?: Variant
class?: string class?: string
children?: () => any children?: () => any
}>() }>()
const Icon = $derived(icon) const Icon = $derived(icon)
const variants: Record<Variant, [string, string]> = { const variants: Record<Variant, [string, string]> = {
success: ['bg-success', 'text-success-content'], success: ['bg-success', 'text-success-content'],
error: ['bg-error', 'text-error-content'], error: ['bg-error', 'text-error-content'],
primary: ['bg-primary', 'text-primary-content'], primary: ['bg-primary', 'text-primary-content'],
info: ['bg-info', 'text-info-content'], info: ['bg-info', 'text-info-content'],
warning: ['bg-warning', 'text-warning-content'] warning: ['bg-warning', 'text-warning-content']
} }
const variantKey: Variant = (variant as Variant) in variants ? (variant as Variant) : 'primary' const variantKey: Variant = (variant as Variant) in variants ? (variant as Variant) : 'primary'
const [bgColor, textColor] = variants[variantKey] 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>
{/if} {@render children?.()}
<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 { location } from '$lib/stores'; import { location } from '$lib/stores'
let source = $state(`${$location}/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">
+31 -29
View File
@@ -1,35 +1,37 @@
<script> <script>
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate'
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition'
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications'
import { error, info, success, warning } from './icons'; import { error, info, success, warning } from './icons'
/** @type {{theme?: any, icon?: any}} */
/** @type {{theme?: any, icon?: any}} */ let {
let { theme = { 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 = { },
error: error, icon = {
success: success, error: error,
warning: warning, success: success,
info: info warning: warning,
} } = $props(); info: info
}
} = $props()
</script> </script>
<div class="toast toast-end mr-4"> <div class="toast toast-end mr-4">
{#each $notifications as notification (notification.id)} {#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]} {@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>
+323 -316
View File
@@ -1,332 +1,339 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { import {
BufferGeometry, BufferGeometry,
Line, Line,
LineBasicMaterial, LineBasicMaterial,
Mesh, Mesh,
MeshBasicMaterial, MeshBasicMaterial,
type Object3D, type Object3D,
SphereGeometry, SphereGeometry,
Vector3, Vector3,
type NormalBufferAttributes, type NormalBufferAttributes,
type Object3DEventMap type Object3DEventMap
} from 'three' } from 'three'
import { import {
ModesEnum, ModesEnum,
kinematicData, kinematicData,
mode, mode,
model, model,
outControllerData, outControllerData,
servoAnglesOut, servoAnglesOut,
servoAngles, servoAngles,
mpu, mpu,
jointNames, jointNames,
currentKinematic, currentKinematic,
walkGait, walkGait,
walkGaits, walkGaits,
walkGaitToMode walkGaitToMode
} from '$lib/stores' } from '$lib/stores'
import { import {
extractFootColor, extractFootColor,
populateModelCache, populateModelCache,
throttler, throttler,
getToeWorldPositions getToeWorldPositions
} from '$lib/utilities' } from '$lib/utilities'
import SceneBuilder from '$lib/sceneBuilder' import SceneBuilder from '$lib/sceneBuilder'
import { lerp, degToRad } from 'three/src/math/MathUtils' import { lerp, degToRad } from 'three/src/math/MathUtils'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js' import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import { type body_state_t } from '$lib/kinematic' import { type body_state_t } from '$lib/kinematic'
import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait' import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait'
import { radToDeg } from 'three/src/math/MathUtils.js' import { radToDeg } from 'three/src/math/MathUtils.js'
import type { URDFRobot } from 'urdf-loader' import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store' import { get } from 'svelte/store'
interface Props { interface Props {
sky?: boolean sky?: boolean
orbit?: boolean orbit?: boolean
panel?: boolean panel?: boolean
debug?: boolean debug?: boolean
ground?: boolean ground?: boolean
}
let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props()
let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement
let currentModelAngles: number[] = new Array(12).fill(0)
let modelTargetAngles: number[] = new Array(12).fill(0)
let gui_panel: GUI
let Throttler = new throttler()
let feet_trace = new Array(4).fill([])
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
let target: Object3D<Object3DEventMap>
let target_position = { x: 0, z: 0, yaw: 0 }
let kinematic = get(currentKinematic)
let planners = {
[ModesEnum.Deactivated]: new IdleState(),
[ModesEnum.Idle]: new IdleState(),
[ModesEnum.Calibration]: new CalibrationState(),
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Walk]: new BezierState()
}
let lastTick = performance.now()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
let body_state = {
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.5,
zm: 0,
feet: kinematic.getDefaultFeetPos()
}
let settings = {
'Internal kinematic': true,
'Robot transform controls': false,
'Auto orient robot': true,
'Trace feet': debug,
'Target position': false,
'Trace points': 30,
'Fix camera on robot': true,
'Smooth motion': true,
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.7,
zm: 0,
Background: 'black'
}
onMount(async () => {
await populateModelCache()
await createScene()
servoAngles.subscribe(updateAnglesFromStore)
walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait)))
if (panel) createPanel()
})
onDestroy(() => {
canvas.remove()
gui_panel?.destroy()
})
const updateAnglesFromStore = (angles: number[]) => {
if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return
modelTargetAngles = angles
}
const createPanel = () => {
gui_panel = new GUI({ width: 310 })
gui_panel.close()
gui_panel.domElement.id = 'three-gui-panel'
const general = gui_panel.addFolder('General')
general.add(settings, 'Internal kinematic')
general.add(settings, 'Robot transform controls')
general.add(settings, 'Auto orient robot')
const kinematic = gui_panel.addFolder('Kinematics')
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
const visibility = gui_panel.addFolder('Visualization')
visibility.add(settings, 'Trace feet')
visibility.add(settings, 'Trace points', 1, 1000, 1)
visibility.add(settings, 'Target position')
visibility.add(settings, 'Smooth motion')
visibility.addColor(settings, 'Background')
}
const updateKinematicPosition = () => {
kinematicData.set([
settings.omega,
settings.phi,
settings.psi,
settings.xm,
settings.ym,
settings.zm
])
}
const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
Throttler.throttle(() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))), 100)
}
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(2, 20, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop()
if (ground) sceneManager.addGroundPlane()
const geometry = new SphereGeometry(0.1, 32, 16)
const material = new MeshBasicMaterial({ color: 0xffff00 })
target = new Mesh(geometry, material)
sceneManager.scene.add(target)
if (debug) {
sceneManager.addDragControl(updateAngles)
}
if (sky) sceneManager.addSky()
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry()
const material = new LineBasicMaterial({ color: extractFootColor() })
const line = new Line(geometry, material)
trace_lines.push(geometry)
sceneManager.scene.add(line)
}
}
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
feet_trace = new Array(4).fill([])
return
} }
trace_lines.forEach((line, i) => { let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props()
feet_trace[i].push(foot_positions[i])
feet_trace[i] = feet_trace[i].slice(-settings['Trace points']) let sceneManager = $state(new SceneBuilder())
line.setFromPoints(feet_trace[i]) let canvas: HTMLCanvasElement
let currentModelAngles: number[] = new Array(12).fill(0)
let modelTargetAngles: number[] = new Array(12).fill(0)
let gui_panel: GUI
let Throttler = new throttler()
let feet_trace = new Array(4).fill([])
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
let target: Object3D<Object3DEventMap>
let target_position = { x: 0, z: 0, yaw: 0 }
let kinematic = get(currentKinematic)
let planners = {
[ModesEnum.Deactivated]: new IdleState(),
[ModesEnum.Idle]: new IdleState(),
[ModesEnum.Calibration]: new CalibrationState(),
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Walk]: new BezierState()
}
let lastTick = performance.now()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
let body_state = {
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.5,
zm: 0,
feet: kinematic.getDefaultFeetPos()
}
let settings = {
'Internal kinematic': true,
'Robot transform controls': false,
'Auto orient robot': true,
'Trace feet': debug,
'Target position': false,
'Trace points': 30,
'Fix camera on robot': true,
'Smooth motion': true,
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.7,
zm: 0,
Background: 'black'
}
onMount(async () => {
await populateModelCache()
await createScene()
servoAngles.subscribe(updateAnglesFromStore)
walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait)))
if (panel) createPanel()
}) })
}
const calculate_kinematics = () => { onDestroy(() => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return canvas.remove()
const position: body_state_t = { gui_panel?.destroy()
omega: settings.omega, })
phi: settings.phi,
psi: settings.psi, const updateAnglesFromStore = (angles: number[]) => {
xm: settings.xm, if (sceneManager.isDragging) return
ym: settings.ym, if (settings['Internal kinematic']) return
zm: settings.zm, modelTargetAngles = angles
feet: body_state.feet
} }
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i])) const createPanel = () => {
modelTargetAngles = new_angles gui_panel = new GUI({ width: 310 })
} gui_panel.close()
gui_panel.domElement.id = 'three-gui-panel'
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => { const general = gui_panel.addFolder('General')
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return general.add(settings, 'Internal kinematic')
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y)) general.add(settings, 'Robot transform controls')
general.add(settings, 'Auto orient robot')
robot.position.z = smooth(robot.position.z, -settings.xm, 0.1) const kinematic = gui_panel.addFolder('Kinematics')
robot.position.x = smooth(robot.position.x, -settings.zm, 0.1) 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()
robot.rotation.z = smooth(robot.rotation.z, degToRad(-settings.phi + $mpu.heading + 90), 0.1) const visibility = gui_panel.addFolder('Visualization')
robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1) visibility.add(settings, 'Trace feet')
robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1) visibility.add(settings, 'Trace points', 1, 1000, 1)
} visibility.add(settings, 'Target position')
visibility.add(settings, 'Smooth motion')
const update_camera = (robot: URDFRobot) => { visibility.addColor(settings, 'Background')
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)
renderTraceLines(toes)
update_camera(robot)
update_gait()
calculate_kinematics()
update_robot_position(robot)
sceneManager.transformControl.showX = settings['Robot transform controls']
sceneManager.transformControl.showY = settings['Robot transform controls']
sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i],
0.1
)
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
} }
orient_robot(robot, toes) const updateKinematicPosition = () => {
updateTargetPosition() kinematicData.set([
} settings.omega,
settings.phi,
settings.psi,
settings.xm,
settings.ym,
settings.zm
])
}
const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
Throttler.throttle(
() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))),
100
)
}
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(2, 20, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop()
if (ground) sceneManager.addGroundPlane()
const geometry = new SphereGeometry(0.1, 32, 16)
const material = new MeshBasicMaterial({ color: 0xffff00 })
target = new Mesh(geometry, material)
sceneManager.scene.add(target)
if (debug) {
sceneManager.addDragControl(updateAngles)
}
if (sky) sceneManager.addSky()
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry()
const material = new LineBasicMaterial({ color: extractFootColor() })
const line = new Line(geometry, material)
trace_lines.push(geometry)
sceneManager.scene.add(line)
}
}
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
feet_trace = new Array(4).fill([])
return
}
trace_lines.forEach((line, i) => {
feet_trace[i].push(foot_positions[i])
feet_trace[i] = feet_trace[i].slice(-settings['Trace points'])
line.setFromPoints(feet_trace[i])
})
}
const calculate_kinematics = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const position: body_state_t = {
omega: settings.omega,
phi: settings.phi,
psi: settings.psi,
xm: settings.xm,
ym: settings.ym,
zm: settings.zm,
feet: body_state.feet
}
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
modelTargetAngles = new_angles
}
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
robot.position.z = smooth(robot.position.z, -settings.xm, 0.1)
robot.position.x = smooth(robot.position.x, -settings.zm, 0.1)
robot.rotation.z = smooth(
robot.rotation.z,
degToRad(-settings.phi + $mpu.heading + 90),
0.1
)
robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1)
robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1)
}
const update_camera = (robot: URDFRobot) => {
if (!settings['Fix camera on robot']) return
sceneManager.orbit.target = robot.position.clone()
}
const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end
}
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(outControllerData)
const data = {
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)
renderTraceLines(toes)
update_camera(robot)
update_gait()
calculate_kinematics()
update_robot_position(robot)
sceneManager.transformControl.showX = settings['Robot transform controls']
sceneManager.transformControl.showY = settings['Robot transform controls']
sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i],
0.1
)
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
}
orient_robot(robot, toes)
updateTargetPosition()
}
</script> </script>
<svelte:window onresize={sceneManager.fillParent} /> <svelte:window onresize={sceneManager.fillParent} />
@@ -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');
const handleInput = (e: any) => value = e.target.value let type = $derived(show ? 'text' : 'password')
const togglePassword = () => show = !show const handleInput = (e: any) => (value = e.target.value)
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">
@@ -23,4 +23,4 @@
<MdiEyeOffOutline class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}" /> <MdiEyeOffOutline class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}" />
<MdiEyeOutline class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}" /> <MdiEyeOutline class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}" />
</div> </div>
</label> </label>
@@ -1,34 +1,35 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
min?: number min?: number
max?: number max?: number
step?: number step?: number
value?: any value?: any
oninput?: any 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,40 +1,41 @@
<script lang="ts"> <script lang="ts">
import WidgetContainer from './WidgetContainer.svelte'; import WidgetContainer from './WidgetContainer.svelte'
import { import {
WidgetComponents, WidgetComponents,
type WidgetContainerConfig, type WidgetContainerConfig,
isWidgetConfig, isWidgetConfig
} from '$lib/stores/application'; } from '$lib/stores/application'
import Widget from './Widget.svelte'; 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)} >
<Widget> {#each container.widgets as widget, index (widget.id + '-' + index)}
{#if isWidgetConfig(widget)} <Widget>
{@const SvelteComponent = WidgetComponents[widget.component]} {#if isWidgetConfig(widget)}
<SvelteComponent {...widget.props} /> {@const SvelteComponent = WidgetComponents[widget.component]}
{:else if widget.widgets} <SvelteComponent {...widget.props} />
<WidgetContainer container={widget} /> {:else if widget.widgets}
{/if} <WidgetContainer container={widget} />
</Widget> {/if}
{#if index !== container.widgets.length - 1} </Widget>
<div {#if index !== container.widgets.length - 1}
class="divider bg-base-300 m-0" <div
class:divider-horizontal={container.layout === 'column'}> class="divider bg-base-300 m-0"
</div> class:divider-horizontal={container.layout === 'column'}
{/if} ></div>
{/each} {/if}
</div> {/each}
</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: any; 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,14 +1,11 @@
<script> <script>
import logo from '$lib/assets/logo512.png'; import logo from '$lib/assets/logo512.png'
/** @type {{appName: any}} */ /** @type {{appName: any}} */
let { appName } = $props(); let { appName } = $props()
</script> </script>
<a <a href="/" class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]">
href="/" <img src={logo} alt="Logo" class="h-12 w-12" />
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]" <h1 class="px-4 text-2xl font-bold">{appName}</h1>
>
<img src={logo} alt="Logo" class="h-12 w-12" />
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
</a> </a>
+178 -174
View File
@@ -1,194 +1,198 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
import { base } from '$app/paths' 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 { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public' 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 }
type menuItem = { type menuItem = {
title: string title: string
icon: ConstructorOfATypedSvelteComponent icon: ConstructorOfATypedSvelteComponent
href?: string href?: string
feature: boolean feature: boolean
active?: boolean active?: boolean
submenu?: menuItem[] submenu?: menuItem[]
} }
function withBase(path: string) { function withBase(path: string) {
return `${base}${path.startsWith('/') ? path : '/' + path}` return `${base}${path.startsWith('/') ? path : '/' + path}`
} }
let menuItems = $state<menuItem[]>([]) let menuItems = $state<menuItem[]>([])
$effect(() => { $effect(() => {
menuItems = [ menuItems = [
{ {
title: 'Connection', title: 'Connection',
icon: WiFi, icon: WiFi,
href: withBase('/connection'), href: withBase('/connection'),
feature: !PUBLIC_VITE_USE_HOST_NAME feature: !PUBLIC_VITE_USE_HOST_NAME
}, },
{ {
title: 'Controller', title: 'Controller',
icon: MdiController, icon: MdiController,
href: withBase('/controller'), href: withBase('/controller'),
feature: true feature: true
}, },
{ {
title: 'Peripherals', title: 'Peripherals',
icon: Devices, icon: Devices,
feature: true, feature: true,
submenu: [ submenu: [
{ {
title: 'I2C', title: 'I2C',
icon: Connection, icon: Connection,
href: withBase('/peripherals/i2c'), href: withBase('/peripherals/i2c'),
feature: true feature: true
}, },
{ {
title: 'Camera', title: 'Camera',
icon: Camera, icon: Camera,
href: withBase('/peripherals/camera'), href: withBase('/peripherals/camera'),
feature: $features.camera feature: $features.camera
}, },
{ {
title: 'Servo', title: 'Servo',
icon: MotorOutline, icon: MotorOutline,
href: withBase('/peripherals/servo'), href: withBase('/peripherals/servo'),
feature: true feature: true
}, },
{ {
title: 'IMU', title: 'IMU',
icon: Rotate3d, icon: Rotate3d,
href: withBase('/peripherals/imu'), href: withBase('/peripherals/imu'),
feature: $features.imu || $features.mag || $features.bmp feature: $features.imu || $features.mag || $features.bmp
} }
] ]
}, },
{ {
title: 'WiFi', title: 'WiFi',
icon: WiFi, icon: WiFi,
feature: true, feature: true,
submenu: [ submenu: [
{ {
title: 'WiFi Station', title: 'WiFi Station',
icon: Router, icon: Router,
href: withBase('/wifi/sta'), href: withBase('/wifi/sta'),
feature: true feature: true
}, },
{ {
title: 'Access Point', title: 'Access Point',
icon: AP, icon: AP,
href: withBase('/wifi/ap'), href: withBase('/wifi/ap'),
feature: true feature: true
}, },
{ {
title: 'mDNS', title: 'mDNS',
icon: DNS, icon: DNS,
href: withBase('/wifi/mdns'), href: withBase('/wifi/mdns'),
feature: true feature: true
} }
] ]
}, },
{ {
title: 'System', title: 'System',
icon: Settings, icon: Settings,
feature: true, feature: true,
submenu: [ submenu: [
{ {
title: 'System Status', title: 'System Status',
icon: Health, icon: Health,
href: withBase('/system/status'), href: withBase('/system/status'),
feature: true feature: true
}, },
{ {
title: 'File System', title: 'File System',
icon: Folder, icon: Folder,
href: withBase('/system/filesystem'), href: withBase('/system/filesystem'),
feature: true feature: true
}, },
{ {
title: 'System Metrics', title: 'System Metrics',
icon: Metrics, icon: Metrics,
href: withBase('/system/metrics'), href: withBase('/system/metrics'),
feature: true feature: true
}, },
{ {
title: 'Firmware Update', title: 'Firmware Update',
icon: Update, icon: Update,
href: withBase('/system/update'), href: withBase('/system/update'),
feature: $features.ota || $features.upload_firmware || $features.download_firmware feature:
} $features.ota ||
] $features.upload_firmware ||
} $features.download_firmware
] as menuItem[] }
}) ]
}
const { menuClicked } = $props() ] as menuItem[]
function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => {
item.active = item.title === targetTitle
item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle
})
}) })
menuItems = menuItems
menuClicked()
}
$effect(() => { const { menuClicked } = $props()
setActiveMenuItem(page.data.title)
})
const updateMenu = (event: any) => { function setActiveMenuItem(targetTitle: string) {
setActiveMenuItem(event.details) menuItems.forEach(item => {
} item.active = item.title === targetTitle
item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle
})
})
menuItems = menuItems
menuClicked()
}
$effect(() => {
setActiveMenuItem(page.data.title)
})
const updateMenu = (event: any) => {
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>
+46 -40
View File
@@ -1,48 +1,54 @@
<script lang="ts"> <script lang="ts">
import MenuList from './MenuList.svelte' import MenuList from './MenuList.svelte'
type MenuItem = { type MenuItem = {
title: string title: string
icon: ConstructorOfATypedSvelteComponent icon: ConstructorOfATypedSvelteComponent
href?: string href?: string
feature: boolean feature: boolean
active?: boolean active?: boolean
submenu?: MenuItem[] submenu?: MenuItem[]
} }
let { level, menuItems, select, class: klass } = $props() let { level, menuItems, select, class: klass } = $props()
const selectMenuItem = (title: string) => { const selectMenuItem = (title: string) => {
select(title) select(title)
} }
</script> </script>
<ul class={klass + ' menu w-full'}> <ul class={klass + ' menu w-full'}>
{#each menuItems as MenuItem[] as menuItem, i (menuItem.title)} {#each menuItems as MenuItem[] as menuItem, i (menuItem.title)}
{#if menuItem.feature} {#if menuItem.feature}
<li> <li>
{#if menuItem.submenu} {#if menuItem.submenu}
<details open={menuItem.submenu.some(subItem => subItem.active)}> <details open={menuItem.submenu.some(subItem => subItem.active)}>
<summary class="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 menuItems={menuItem.submenu} level={level + 1} {select} class={klass} /> <MenuList
</div> menuItems={menuItem.submenu}
</details> level={level + 1}
{:else} {select}
<a class={klass}
href={menuItem.href} />
class="font-bold" </div>
class:bg-base-100={menuItem.active} </details>
class:text-lg={level === 0} {:else}
class:text-md={level === 1} <a
onclick={() => selectMenuItem(menuItem.title)}> href={menuItem.href}
<menuItem.icon class="h-6 w-6" /> class="font-bold"
{menuItem.title} class:bg-base-100={menuItem.active}
</a> class:text-lg={level === 0}
class:text-md={level === 1}
onclick={() => selectMenuItem(menuItem.title)}
>
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</a>
{/if}
</li>
{/if} {/if}
</li> {/each}
{/if}
{/each}
</ul> </ul>
@@ -1,10 +1,10 @@
<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}>
<SvelteComponent class="h-7 w-7" /> <SvelteComponent class="h-7 w-7" />
</button> </button>
@@ -1,33 +1,33 @@
<script lang="ts"> <script lang="ts">
import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from "../icons"; import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from '../icons'
interface Props { interface Props {
showDBm?: boolean; showDBm?: boolean
rssi?: number; rssi?: number
} }
let { showDBm = false, rssi = 0 }: Props = $props(); let { showDBm = false, rssi = 0 }: Props = $props()
const getWiFiIcon = () => { const getWiFiIcon = () => {
if (rssi === 0) return WifiOff; if (rssi === 0) return WifiOff
if (rssi >= -55) return WiFi; if (rssi >= -55) return WiFi
if (rssi >= -75) return WiFi2; if (rssi >= -75) return WiFi2
if (rssi >= -85) return WiFi1; if (rssi >= -85) return WiFi1
return WiFi0; return WiFi0
}; }
const SvelteComponent = $derived(getWiFiIcon()); const SvelteComponent = $derived(getWiFiIcon())
</script> </script>
<div class="indicator"> <div class="indicator">
<div class="tooltip tooltip-left" data-tip={rssi + " dBm"}> <div class="tooltip tooltip-left" data-tip={rssi + ' dBm'}>
{#if showDBm} {#if showDBm}
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs"> <span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
{rssi} dBm {rssi} dBm
</span> </span>
{/if} {/if}
<div class="h-7 w-7"> <div class="h-7 w-7">
<SvelteComponent class="absolute inset-0 h-full w-full" /> <SvelteComponent class="absolute inset-0 h-full w-full" />
</div> </div>
</div> </div>
</div> </div>
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { useFeatureFlags } from '$lib/stores'; import { useFeatureFlags } from '$lib/stores'
import { modals } from 'svelte-modals'; import { modals } from 'svelte-modals'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import { api } from '$lib/api'; import { api } from '$lib/api'
import { Cancel, Power } from '../icons'; import { Cancel, Power } from '../icons'
const features = useFeatureFlags(); const features = useFeatureFlags()
const postSleep = async () => await api.post('/api/system/sleep'); const postSleep = async () => await api.post('/api/system/sleep')
const confirmSleep = () => { const confirmSleep = () => {
modals.open(ConfirmDialog, { modals.open(ConfirmDialog, {
@@ -18,11 +18,11 @@
confirm: { label: 'Switch Off', icon: Power } confirm: { label: 'Switch Off', icon: Power }
}, },
onConfirm: () => { onConfirm: () => {
modals.close(); modals.close()
postSleep(); postSleep()
} }
}); })
}; }
</script> </script>
{#if $features.sleep} {#if $features.sleep}
@@ -1,10 +1,9 @@
<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,9 +1,9 @@
<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">
<input type="checkbox" value="light" class="theme-controller" /> <input type="checkbox" value="light" class="theme-controller" />
<MdiWeatherSunny class="swap-off h-7 w-7" /> <MdiWeatherSunny class="swap-off h-7 w-7" />
<MdiMoonAndStars class="swap-on h-7 w-7" /> <MdiMoonAndStars class="swap-on h-7 w-7" />
</label> </label>
@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import {Hamburger} from '../icons' import { Hamburger } from '../icons'
</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="/"> <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,109 +1,111 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals'
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte' import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'
import { compareVersions } from 'compare-versions' import { compareVersions } from 'compare-versions'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { api } from '$lib/api' import { api } from '$lib/api'
import type { GithubRelease } from '$lib/types/models' import type { GithubRelease } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Cancel, CloudDown, Firmware } from '../icons' import { Cancel, CloudDown, Firmware } from '../icons'
const features = useFeatureFlags() const features = useFeatureFlags()
interface Props { interface Props {
update?: boolean update?: boolean
}
let { update = $bindable(false) }: Props = $props()
let firmwareVersion: string = $state('')
let firmwareDownloadLink: string = $state('')
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}
const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${page.data.github}/releases/latest`,
{ headers }
)
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository')
return
}
if (result.isErr()) {
console.error('Error:', result.inner)
return
} }
const results = result.inner let { update = $bindable(false) }: Props = $props()
update = false
firmwareVersion = ''
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) { let firmwareVersion: string = $state('')
// iterate over assets and find the correct one let firmwareDownloadLink: string = $state('')
for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin async function getGithubAPI() {
if ( const headers = {
results.assets[i].name.includes('.bin') && accept: 'application/vnd.github+json',
results.assets[i].name.includes($features.firmware_built_target as string) 'X-GitHub-Api-Version': '2022-11-28'
) { }
update = true const result = await api.get<GithubRelease>(
firmwareVersion = results.tag_name `https://api.github.com/repos/${page.data.github}/releases/latest`,
firmwareDownloadLink = results.assets[i].browser_download_url { headers }
notifications.info('Firmware update available.', 5000) )
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository')
return
}
if (result.isErr()) {
console.error('Error:', result.inner)
return
} }
}
}
}
async function postGithubDownload(url: string) { const results = result.inner
const result = await api.post('/api/downloadUpdate', { download_url: url }) update = false
if (result.isErr()) { firmwareVersion = ''
console.error('Error:', result.inner)
return
}
}
onMount(async () => { if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
if ($features.download_firmware) { // iterate over assets and find the correct one
await getGithubAPI() for (let i = 0; i < results.assets.length; i++) {
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour // check if the asset is of type *.bin
if (
results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($features.firmware_built_target as string)
) {
update = true
firmwareVersion = results.tag_name
firmwareDownloadLink = results.assets[i].browser_download_url
notifications.info('Firmware update available.', 5000)
}
}
}
} }
})
function confirmGithubUpdate(url: string) { async function postGithubDownload(url: string) {
modals.open(ConfirmDialog, { const result = await api.post('/api/downloadUpdate', { download_url: url })
title: 'Confirm flashing new firmware to the device', if (result.isErr()) {
message: 'Are you sure you want to overwrite the existing firmware with a new one?', console.error('Error:', result.inner)
labels: { return
cancel: { label: 'Abort', icon: Cancel }, }
confirm: { label: 'Update', icon: CloudDown } }
},
onConfirm: () => { onMount(async () => {
postGithubDownload(url) if ($features.download_firmware) {
modals.open(GithubUpdateDialog, { await getGithubAPI()
onConfirm: () => modals.closeAll() setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour
}) }
}
}) })
}
function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url)
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
})
}
})
}
</script> </script>
{#if update} {#if update}
<div class="indicator flex-none"> <div class="indicator flex-none">
<button <button
class="btn btn-square btn-ghost h-9 w-9" class="btn btn-square btn-ghost h-9 w-9"
onclick={() => confirmGithubUpdate(firmwareDownloadLink)}> onclick={() => confirmGithubUpdate(firmwareDownloadLink)}
<span >
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"> <span
{firmwareVersion} class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
</span> >
<Firmware class="h-7 w-7" /> {firmwareVersion}
</button> </span>
</div> <Firmware class="h-7 w-7" />
</button>
</div>
{/if} {/if}
@@ -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>
+31 -29
View File
@@ -1,35 +1,37 @@
<script> <script>
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate'
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition'
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications'
import { error, info, success, warning } from '../icons'; import { error, info, success, warning } from '../icons'
/** @type {{theme?: any, icon?: any}} */
/** @type {{theme?: any, icon?: any}} */ let {
let { theme = { 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 = { },
error: error, icon = {
success: success, error: error,
warning: warning, success: success,
info: info warning: warning,
} } = $props(); info: info
}
} = $props()
</script> </script>
<div class="toast toast-end mr-4 z-20"> <div class="toast toast-end mr-4 z-20">
{#each $notifications as notification (notification.id)} {#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]} {@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>
+26 -26
View File
@@ -3,40 +3,40 @@ 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 = 4000) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout), warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout), info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout),
success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout) success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout)
} }
} }
function generateId() { function generateId() {
return '_' + Math.random().toString(36).substr(2, 9) return '_' + Math.random().toString(36).substr(2, 9)
} }
export const notifications = createNotificationStore() export const notifications = createNotificationStore()
@@ -1,101 +1,102 @@
<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
let chart: Chart; let chart: Chart
interface Props { interface Props {
label: any; label: any
data: number[]; data: number[]
title: any; title: any
} }
let { label, data, title }: Props = $props(); let { label, data, title }: Props = $props()
Chart.register(...registerables); Chart.register(...registerables)
onMount(() => { onMount(() => {
chart = new Chart(chartElement, { chart = new Chart(chartElement, {
type: 'line', type: 'line',
data: { data: {
labels: data, labels: data,
datasets: [ datasets: [
{ {
label, label,
borderColor: daisyColor('--p'), borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--p', 50),
borderWidth: 2, borderWidth: 2,
data, data,
yAxisID: 'y', yAxisID: 'y'
}, }
], ]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true,
},
tooltip: {
mode: 'index',
intersect: false,
},
},
elements: {
point: {
radius: 0,
},
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10),
}, },
ticks: { options: {
color: daisyColor('--bc'), maintainAspectRatio: false,
}, responsive: true,
display: false, plugins: {
}, legend: {
y: { display: true
type: 'linear', },
title: { tooltip: {
display: true, mode: 'index',
text: title, intersect: false
color: daisyColor('--bc'), }
font: { },
size: 16, elements: {
weight: 'bold', point: {
}, radius: 0
}, }
position: 'left', },
min: 0, scales: {
max: 100, x: {
grid: { color: daisyColor('--bc', 10) }, grid: {
ticks: { color: daisyColor('--bc', 10)
color: daisyColor('--bc'), },
}, ticks: {
border: { color: daisyColor('--bc', 10) }, color: daisyColor('--bc')
}, },
}, display: false
}, },
}); y: {
type: 'linear',
title: {
display: true,
text: title,
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
},
position: 'left',
min: 0,
max: 100,
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
})
setInterval(() => { setInterval(() => {
chart.data.labels = data; chart.data.labels = data
chart.data.datasets[0].data = data; chart.data.datasets[0].data = data
}, 500); }, 500)
}); })
</script> </script>
<div class="w-full h-full overflow-x-auto"> <div class="w-full h-full overflow-x-auto">
<div <div
class="flex w-full flex-col space-y-1 h-60" class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }}> transition:slide|local={{ duration: 300, easing: cubicOut }}
<canvas bind:this={chartElement}></canvas> >
</div> <canvas bind:this={chartElement}></canvas>
</div>
</div> </div>
+14 -13
View File
@@ -1,19 +1,20 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
options?: string[]; options?: string[]
selectedOption?: string; selectedOption?: string
change?: () => void; change?: () => void
[key: string]: any; [key: string]: any
} }
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props(); let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props()
</script> </script>
<select <select
bind:value={selectedOption} bind:value={selectedOption}
{...rest} {...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"> class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"
{#each options as option} >
<option value={option}>{option}</option> {#each options as option}
{/each} <option value={option}>{option}</option>
{/each}
</select> </select>
+349 -349
View File
@@ -3,423 +3,423 @@ import type { body_state_t } from './kinematic'
import { currentKinematic } from './stores/featureFlags' import { currentKinematic } from './stores/featureFlags'
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 lx: number
ly: number ly: number
rx: number rx: number
ry: number ry: number
h: number h: number
s: number s: number
s1: number s1: number
} }
export abstract class GaitState { export abstract class GaitState {
protected abstract name: string protected abstract name: string
protected dt = 0.02 protected dt = 0.02
protected body_state!: body_state_t protected body_state!: body_state_t
protected gait_state: gait_state_t = { protected gait_state: gait_state_t = {
step_height: 0.4, step_height: 0.4,
step_x: 0, step_x: 0,
step_z: 0, step_z: 0,
step_angle: 0, step_angle: 0,
step_velocity: 1, step_velocity: 1,
step_depth: 0.002 step_depth: 0.002
}
public get default_feet_pos() {
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
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 public get default_feet_pos() {
} 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
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'
} }
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 // eslint-disable-next-line @typescript-eslint/no-unused-vars
step(body_state: body_state_t, _command: ControllerCommand) { step(body_state: body_state_t, _command: ControllerCommand) {
body_state.omega = 0 body_state.omega = 0
body_state.phi = 0 body_state.phi = 0
body_state.psi = 0 body_state.psi = 0
body_state.xm = 0 body_state.xm = 0
body_state.ym = this.default_height * 10 body_state.ym = this.default_height * 10
body_state.zm = 0 body_state.zm = 0
body_state.feet = this.default_feet_pos body_state.feet = this.default_feet_pos
return body_state return body_state
} }
} }
export class RestState extends GaitState { export class RestState extends GaitState {
protected name = 'Rest' protected name = 'Rest'
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
step(body_state: body_state_t, _command: ControllerCommand) { step(body_state: body_state_t, _command: ControllerCommand) {
body_state.omega = 0 body_state.omega = 0
body_state.phi = 0 body_state.phi = 0
body_state.psi = 0 body_state.psi = 0
body_state.xm = 0 body_state.xm = 0
body_state.ym = this.default_height / 2 body_state.ym = this.default_height / 2
body_state.zm = 0 body_state.zm = 0
body_state.feet = this.default_feet_pos body_state.feet = this.default_feet_pos
return body_state return body_state
} }
} }
export class StandState extends GaitState { export class StandState extends GaitState {
protected name = 'Stand' protected name = 'Stand'
step(body_state: body_state_t, command: ControllerCommand) { step(body_state: body_state_t, command: ControllerCommand) {
body_state.omega = 0 body_state.omega = 0
body_state.phi = command.rx * 10 * (Math.PI / 2) body_state.phi = command.rx * 10 * (Math.PI / 2)
body_state.psi = command.ry * 10 * (Math.PI / 2) body_state.psi = command.ry * 10 * (Math.PI / 2)
body_state.xm = command.ly / 4 body_state.xm = command.ly / 4
body_state.zm = command.lx / 4 body_state.zm = command.lx / 4
body_state.feet = this.default_feet_pos body_state.feet = this.default_feet_pos
return body_state return body_state
} }
} }
export class BezierState extends GaitState { 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 = 0
protected stand_offset = 0.85 protected stand_offset = 0.85
protected mode: 'crawl' | 'trot' = 'trot' protected mode: 'crawl' | 'trot' = 'trot'
protected speed_factor = 1 protected speed_factor = 1
offset = [0, 0.5, 0.75, 0.25] offset = [0, 0.5, 0.75, 0.25]
protected shift_start_pos = { x: 0, z: 0 } protected shift_start_pos = { x: 0, z: 0 }
protected shift_target_pos = { x: 0, z: 0 } protected shift_target_pos = { x: 0, z: 0 }
protected shift_start_time = 0 protected shift_start_time = 0
protected current_shift_leg = -1 protected current_shift_leg = -1
constructor() { constructor() {
super() super()
this.set_mode(this.mode) this.set_mode(this.mode)
}
begin() {
super.begin()
}
set_mode(mode: 'crawl' | 'trot', duty?: number, order?: [number, number, number, number]) {
console.log('BezierState set_mode', mode)
this.mode = mode
if (mode === 'crawl') {
this.speed_factor = 0.5
this.stand_offset = duty ?? 0.85
const o = order ?? [3, 0, 2, 1]
const base = [0, 0.25, 0.5, 0.75]
const offsets = new Array(4).fill(0)
for (let i = 0; i < 4; i++) offsets[o[i]] = base[i]
this.offset = offsets
} else {
this.speed_factor = 2
this.stand_offset = duty ?? 0.6
this.offset = order ? (order.map(v => v % 1) as number[]) : [0, 0.5, 0.5, 0]
} }
}
end() { begin() {
super.end() super.begin()
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super.step(body_state, command, dt)
this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2)
if (this.gait_state.step_x < 0) this.step_length = -this.step_length
this.update_phase()
this.update_body_position()
this.update_feet_positions()
return this.body_state
}
update_phase() {
const m = this.gait_state
if (m.step_x === 0 && m.step_z === 0 && m.step_angle === 0) {
this.phase = 0
return
} }
this.phase += this.dt * m.step_velocity * this.speed_factor
if (this.phase >= 1) {
this.phase_num = (this.phase_num + 1) % 2
this.phase = 0
}
}
update_body_position() { set_mode(mode: 'crawl' | 'trot', duty?: number, order?: [number, number, number, number]) {
const m = this.gait_state console.log('BezierState set_mode', mode)
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (!moving) return
if (this.mode !== 'crawl') return this.mode = mode
if (mode === 'crawl') {
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states() this.speed_factor = 0.5
this.stand_offset = duty ?? 0.85
if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) { const o = order ?? [3, 0, 2, 1]
if (this.current_shift_leg !== next_swing) { const base = [0, 0.25, 0.5, 0.75]
this.current_shift_leg = next_swing const offsets = new Array(4).fill(0)
this.shift_start_pos.x = this.body_state.xm for (let i = 0; i < 4; i++) offsets[o[i]] = base[i]
this.shift_start_pos.z = this.body_state.zm this.offset = offsets
} else {
const remaining_legs = stance.filter(leg => leg !== next_swing) this.speed_factor = 2
const target = this.stance_centroid(remaining_legs) this.stand_offset = duty ?? 0.6
this.shift_target_pos.x = target[0] this.offset = order ? (order.map(v => v % 1) as number[]) : [0, 0.5, 0.5, 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 } end() {
} super.end()
}
protected smoothstep01(t: number): number { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
const x = Math.max(0, Math.min(1, t)) super.step(body_state, command, dt)
return x * x * (3 - 2 * x) this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2)
} if (this.gait_state.step_x < 0) this.step_length = -this.step_length
this.update_phase()
this.update_body_position()
this.update_feet_positions()
return this.body_state
}
update_feet_positions() { update_phase() {
for (let i = 0; i < 4; i++) this.body_state.feet[i] = this.update_foot_position(i) const m = this.gait_state
} if (m.step_x === 0 && m.step_z === 0 && m.step_angle === 0) {
this.phase = 0
return
}
this.phase += this.dt * m.step_velocity * this.speed_factor
if (this.phase >= 1) {
this.phase_num = (this.phase_num + 1) % 2
this.phase = 0
}
}
update_foot_position(index: number): number[] { update_body_position() {
let phase = this.phase + this.offset[index] const m = this.gait_state
if (phase >= 1) phase -= 1 const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
this.body_state.feet[index][0] = this.default_feet_pos[index][0] if (!moving) return
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) { if (this.mode !== 'crawl') return
const depth = this.gait_state.step_depth
return this.controller(index, phase, stance_curve, depth)
}
swing_controller(index: number, phase: number) { const { stance, swing, next_swing, time_to_lift } = this.get_leg_states()
const height = this.gait_state.step_height
return this.controller(index, phase, bezier_curve, height)
}
controller( if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) {
index: number, if (this.current_shift_leg !== next_swing) {
phase: number, this.current_shift_leg = next_swing
controller: (length: number, angle: number, ...args: number[]) => number[], this.shift_start_pos.x = this.body_state.xm
...args: number[] this.shift_start_pos.z = this.body_state.zm
) {
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 const remaining_legs = stance.filter(leg => leg !== next_swing)
angle = yawArc(this.default_feet_pos[index], 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]
const delta_rot = controller(length, angle, ...args, phase) this.shift_start_time = time_to_lift
}
this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2 const total_time = this.shift_start_time
this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2 const progress = total_time > 0 ? 1 - time_to_lift / total_time : 1
if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle) const smooth_progress = this.smoothstep01(Math.max(0, Math.min(1, progress)))
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2
return this.body_state.feet[index] 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]
}
} }
const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => { const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => {
const X_POLAR = Math.cos(angle) const X_POLAR = Math.cos(angle)
const Y_POLAR = Math.sin(angle) const Y_POLAR = Math.sin(angle)
const step = length * (1 - 2 * phase) const step = length * (1 - 2 * phase)
const X = step * X_POLAR const X = step * X_POLAR
const Z = step * Y_POLAR const Z = step * Y_POLAR
let Y = 0 let Y = 0
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length)) if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
return [X, Y, Z] 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++) c = (c * (n - i)) / (i + 1)
return c return c
} }
+117 -110
View File
@@ -1,32 +1,32 @@
export interface body_state_t { export interface body_state_t {
omega: number omega: number
phi: number phi: number
psi: number psi: number
xm: number xm: number
ym: number ym: number
zm: number zm: number
feet: number[][] feet: number[][]
} }
export interface position { export interface position {
x: number x: number
y: number y: number
z: number z: number
} }
export interface target_position { export interface target_position {
x: number x: number
z: number z: number
yaw: number yaw: number
} }
export interface KinematicParams { export interface KinematicParams {
coxa: number coxa: number
coxa_offset: number coxa_offset: number
femur: number femur: number
tibia: number tibia: number
L: number L: number
W: number W: number
} }
const { cos, sin, atan2, acos, sqrt, max, min } = Math const { cos, sin, atan2, acos, sqrt, max, min } = Math
@@ -34,107 +34,114 @@ 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 coxa: number
coxa_offset: number coxa_offset: number
femur: number femur: number
tibia: number tibia: 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(params: KinematicParams) {
this.coxa = params.coxa
this.coxa_offset = params.coxa_offset
this.femur = params.femur
this.tibia = params.tibia
this.L = params.L
this.W = params.W
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]
] ]
}
getDefaultFeetPos(): number[][] { constructor(params: KinematicParams) {
return this.mountOffsets.map((offset, i) => { this.coxa = params.coxa
return [offset[0], -1, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)] this.coxa_offset = params.coxa_offset
}) this.femur = params.femur
} this.tibia = params.tibia
this.L = params.L
this.W = params.W
calcIK(p: body_state_t): number[] { this.mountOffsets = [
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) [-this.L / 2, 0, -this.W / 2]
const inv_rot = [ ]
[rot[0][0], rot[1][0], rot[2][0]], }
[rot[0][1], rot[1][1], rot[2][1]],
[rot[0][2], rot[1][2], rot[2][2]]
]
const inv_trans = [
-inv_rot[0][0] * p.xm - inv_rot[0][1] * p.ym - inv_rot[0][2] * p.zm,
-inv_rot[1][0] * p.xm - inv_rot[1][1] * p.ym - inv_rot[1][2] * p.zm,
-inv_rot[2][0] * p.xm - inv_rot[2][1] * p.ym - inv_rot[2][2] * p.zm
]
return p.feet.flatMap((foot, i) => {
const [wx, wy, wz] = foot
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
const [mx, my, mz] = this.mountOffsets[i] getDefaultFeetPos(): number[][] {
const px = bx - mx, return this.mountOffsets.map((offset, i) => {
py = by - my, return [offset[0], -1, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)]
pz = bz - mz })
}
const lx = calcIK(p: body_state_t): number[] {
this.invMountRot[0][0] * px + this.invMountRot[0][1] * py + this.invMountRot[0][2] * pz const roll = p.omega * this.DEG2RAD
const ly = const pitch = p.phi * this.DEG2RAD
this.invMountRot[1][0] * px + this.invMountRot[1][1] * py + this.invMountRot[1][2] * pz const yaw = p.psi * this.DEG2RAD
const lz = const rot = this.euler2R(roll, pitch, yaw)
this.invMountRot[2][0] * px + this.invMountRot[2][1] * py + this.invMountRot[2][2] * pz const inv_rot = [
[rot[0][0], rot[1][0], rot[2][0]],
[rot[0][1], rot[1][1], rot[2][1]],
[rot[0][2], rot[1][2], rot[2][2]]
]
const inv_trans = [
-inv_rot[0][0] * p.xm - inv_rot[0][1] * p.ym - inv_rot[0][2] * p.zm,
-inv_rot[1][0] * p.xm - inv_rot[1][1] * p.ym - inv_rot[1][2] * p.zm,
-inv_rot[2][0] * p.xm - inv_rot[2][1] * p.ym - inv_rot[2][2] * p.zm
]
return p.feet.flatMap((foot, i) => {
const [wx, wy, wz] = foot
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
const xLocal = i % 2 === 1 ? -lx : lx const [mx, my, mz] = this.mountOffsets[i]
return this.legIK(xLocal, ly, lz) const px = bx - mx,
}) py = by - my,
} pz = bz - mz
private legIK(x: number, y: number, z: number): [number, number, number] { const lx =
const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa)) this.invMountRot[0][0] * px +
const G = F - this.coxa_offset this.invMountRot[0][1] * py +
const H = sqrt(G * G + z * z) this.invMountRot[0][2] * pz
const t1 = -atan2(y, x) - atan2(F, -this.coxa) const ly =
const D = this.invMountRot[1][0] * px +
(H * H - this.femur * this.femur - this.tibia * this.tibia) / (2 * this.femur * this.tibia) 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.tibia * sin(t3), this.femur + this.tibia * cos(t3)) const lz =
return [t1, t2, t3] this.invMountRot[2][0] * px +
} this.invMountRot[2][1] * py +
this.invMountRot[2][2] * pz
private euler2R(roll: number, pitch: number, yaw: number): number[][] { const xLocal = i % 2 === 1 ? -lx : lx
const cr = cos(roll), return this.legIK(xLocal, ly, lz)
sr = sin(roll) })
const cp = cos(pitch), }
sp = sin(pitch)
const cy = cos(yaw), private legIK(x: number, y: number, z: number): [number, number, number] {
sy = sin(yaw) const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa))
return [ const G = F - this.coxa_offset
[cp * cy, -cp * sy, sp], const H = sqrt(G * G + z * z)
[sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp], const t1 = -atan2(y, x) - atan2(F, -this.coxa)
[sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp] const D =
] (H * H - this.femur * this.femur - this.tibia * this.tibia) /
} (2 * this.femur * this.tibia)
const t3 = acos(max(-1, min(1, D)))
const t2 = atan2(z, G) - atan2(this.tibia * sin(t3), this.femur + this.tibia * cos(t3))
return [t1, t2, t3]
}
private euler2R(roll: number, pitch: number, yaw: number): number[][] {
const cr = cos(roll),
sr = sin(roll)
const cp = cos(pitch),
sp = sin(pitch)
const cy = cos(yaw),
sy = sin(yaw)
return [
[cp * cy, -cp * sy, sp],
[sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp],
[sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp]
]
}
} }
+343 -343
View File
@@ -1,26 +1,26 @@
import { import {
Mesh, Mesh,
PerspectiveCamera, PerspectiveCamera,
PlaneGeometry, PlaneGeometry,
Scene, Scene,
WebGLRenderer, WebGLRenderer,
AmbientLight, AmbientLight,
DirectionalLight, DirectionalLight,
PCFSoftShadowMap, PCFSoftShadowMap,
type GridHelper, type GridHelper,
ArrowHelper, ArrowHelper,
Vector3, Vector3,
FogExp2, FogExp2,
CanvasTexture, CanvasTexture,
type ColorRepresentation, type ColorRepresentation,
type WebGLRendererParameters, type WebGLRendererParameters,
MeshPhongMaterial, MeshPhongMaterial,
EquirectangularReflectionMapping, EquirectangularReflectionMapping,
ACESFilmicToneMapping, ACESFilmicToneMapping,
MathUtils, MathUtils,
Group, Group,
MeshBasicMaterial, MeshBasicMaterial,
RepeatWrapping RepeatWrapping
} from 'three' } from 'three'
import { Sky } from 'three/addons/objects/Sky.js' import { Sky } from 'three/addons/objects/Sky.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
@@ -33,348 +33,348 @@ import { sunCalculator } from './utilities/position-utilities'
export const addScene = () => new Scene() export const addScene = () => new Scene()
interface position { interface position {
x?: number x?: number
y?: number y?: number
z?: number z?: number
} }
interface light { interface light {
color?: ColorRepresentation color?: ColorRepresentation
intensity?: number intensity?: number
} }
interface arrowOptions { interface arrowOptions {
origin: position origin: position
direction: position direction: position
length?: number length?: number
color?: ColorRepresentation color?: ColorRepresentation
} }
type directionalLight = position & light type directionalLight = position & light
export default class SceneBuilder { export default class SceneBuilder {
public scene: Scene public scene: Scene
public camera!: PerspectiveCamera public camera!: PerspectiveCamera
public ground!: Mesh public ground!: Mesh
public renderer!: WebGLRenderer public renderer!: WebGLRenderer
public orbit: OrbitControls public orbit: OrbitControls
public callback: Function | undefined public callback: Function | undefined
public gridHelper!: GridHelper public gridHelper!: GridHelper
public model!: URDFRobot public model!: URDFRobot
public liveStreamTexture!: CanvasTexture public liveStreamTexture!: CanvasTexture
private fog!: FogExp2 private fog!: FogExp2
private isLoaded: boolean = false private isLoaded: boolean = false
public isDragging: boolean = false public isDragging: boolean = false
highlightMaterial: any highlightMaterial: any
sky!: Sky sky!: Sky
transformControl: TransformControls transformControl: TransformControls
public modelGroup!: Group public modelGroup!: Group
constructor() { constructor() {
this.scene = new Scene() this.scene = new Scene()
if (this.scene.environment?.mapping) { if (this.scene.environment?.mapping) {
this.scene.environment.mapping = EquirectangularReflectionMapping this.scene.environment.mapping = EquirectangularReflectionMapping
}
return this
}
public addRenderer = (parameters?: WebGLRendererParameters) => {
this.renderer = new WebGLRenderer(parameters)
this.renderer.outputColorSpace = 'srgb'
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = PCFSoftShadowMap
this.renderer.toneMapping = ACESFilmicToneMapping
this.renderer.toneMappingExposure = 0.85
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement)
return this
}
public addSky = () => {
this.sky = new Sky()
this.sky.scale.setScalar(450000)
this.scene.add(this.sky)
const effectController = {
turbidity: 10,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: sunCalculator.calculateSunElevation(),
azimuth: 200,
exposure: this.renderer.toneMappingExposure
}
const uniforms = this.sky.material.uniforms
uniforms['turbidity'].value = effectController.turbidity
uniforms['rayleigh'].value = effectController.rayleigh
uniforms['mieCoefficient'].value = effectController.mieCoefficient
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG
this.renderer.toneMappingExposure = 0.5
const phi = MathUtils.degToRad(90 - effectController.elevation)
const theta = MathUtils.degToRad(effectController.azimuth)
const sun = new Vector3()
sun.setFromSphericalCoords(1, phi, theta)
uniforms['sunPosition'].value.copy(sun)
return this
}
public addPerspectiveCamera = (options: position) => {
this.camera = new PerspectiveCamera()
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0)
this.scene.add(this.camera)
return this
}
public addGroundPlane = (options?: position) => {
const checkerboardTexture = this.createCheckerboardTexture(1024, 2)
checkerboardTexture.wrapS = RepeatWrapping
checkerboardTexture.wrapT = RepeatWrapping
checkerboardTexture.repeat.set(100, 100)
const checkerboardMat = new MeshBasicMaterial({
map: checkerboardTexture,
opacity: 0.1,
transparent: true
})
const plane = new PlaneGeometry(400, 400)
this.ground = new Mesh(plane, checkerboardMat)
this.ground.rotation.x = -Math.PI / 2
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0)
this.ground.receiveShadow = true
this.scene.add(this.ground)
const mirror = new Reflector(plane, {
clipBias: 0.003,
textureWidth: window.innerWidth * window.devicePixelRatio,
textureHeight: window.innerHeight * window.devicePixelRatio,
color: 0x00bfff
})
mirror.rotateX(-Math.PI / 2)
this.scene.add(mirror)
return this
}
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement)
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2
this.orbit.maxDistance = maxDistance
this.orbit.autoRotate = autoRotate
this.orbit.update()
this.orbit.minDistance = minDistance
return this
}
public addAmbientLight = (options: light) => {
const ambientLight = new AmbientLight(options.color, options.intensity)
this.scene.add(ambientLight)
return this
}
public addDirectionalLight = (options: directionalLight) => {
const directionalLight = new DirectionalLight(options.color, options.intensity)
directionalLight.castShadow = true
directionalLight.shadow.camera.top = 10
directionalLight.shadow.camera.bottom = -10
directionalLight.shadow.camera.right = 10
directionalLight.shadow.camera.left = -10
directionalLight.shadow.mapSize.set(4096, 4096)
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0)
this.scene.add(directionalLight)
return this
}
private createCheckerboardTexture = (size: number, squares: number) => {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
const squareSize = size / squares
for (let y = 0; y < squares; y++) {
for (let x = 0; x < squares; x++) {
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000'
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize)
}
}
const texture = new CanvasTexture(canvas)
texture.wrapS = texture.wrapT = RepeatWrapping
texture.anisotropy = 16
return texture
}
public addFogExp2 = (color: ColorRepresentation, density?: number) => {
this.scene.fog = new FogExp2(color, density)
return this
}
public fillParent = () => {
const parentElement = this.renderer.domElement.parentElement
if (parentElement) {
const width = parentElement.clientWidth
const height = parentElement.clientHeight
this.handleResize(width, height)
}
return this
}
public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
this.renderer.setSize(width, height)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
return this
}
public addRenderCb = (callback: Function) => {
this.callback = callback
return this
}
public startRenderLoop = () => {
this.renderer.setAnimationLoop(() => {
this.renderer.render(this.scene, this.camera)
this.orbit.update()
this.handleRobotShadow()
if (this.callback) this.callback()
if (!this.liveStreamTexture) return
})
return this
}
public addArrowHelper = (options?: arrowOptions) => {
const dir = new Vector3(
options?.direction.x ?? 0,
options?.direction.y ?? 0,
options?.direction.z ?? 0
)
const origin = new Vector3(
options?.origin.x ?? 0,
options?.origin.y ?? 0,
options?.origin.z ?? 0
)
const arrowHelper = new ArrowHelper(
dir,
origin,
options?.length ?? 1.5,
options?.color ?? 0xff0000
)
this.scene.add(arrowHelper)
return this
}
private setJointValue(jointName: string, angle: number) {
if (!this.model) return
if (!this.model.joints[jointName]) return
this.model.joints[jointName].setJointValue(angle)
}
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
const traverse = (c: any) => {
if (c.type === 'Mesh') {
if (revert) {
c.material = c.__origMaterial
delete c.__origMaterial
} else {
c.__origMaterial = c.material
c.material = material
} }
} return this
}
if (c === m || !this.isJoint(c)) { public addRenderer = (parameters?: WebGLRendererParameters) => {
for (let i = 0; i < c.children.length; i++) { this.renderer = new WebGLRenderer(parameters)
const child = c.children[i] this.renderer.outputColorSpace = 'srgb'
if (!child.isURDFCollider) { this.renderer.shadowMap.enabled = true
traverse(c.children[i]) this.renderer.shadowMap.type = PCFSoftShadowMap
} this.renderer.toneMapping = ACESFilmicToneMapping
this.renderer.toneMappingExposure = 0.85
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement)
return this
}
public addSky = () => {
this.sky = new Sky()
this.sky.scale.setScalar(450000)
this.scene.add(this.sky)
const effectController = {
turbidity: 10,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: sunCalculator.calculateSunElevation(),
azimuth: 200,
exposure: this.renderer.toneMappingExposure
} }
} const uniforms = this.sky.material.uniforms
uniforms['turbidity'].value = effectController.turbidity
uniforms['rayleigh'].value = effectController.rayleigh
uniforms['mieCoefficient'].value = effectController.mieCoefficient
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG
this.renderer.toneMappingExposure = 0.5
const phi = MathUtils.degToRad(90 - effectController.elevation)
const theta = MathUtils.degToRad(effectController.azimuth)
const sun = new Vector3()
sun.setFromSphericalCoords(1, phi, theta)
uniforms['sunPosition'].value.copy(sun)
return this
} }
traverse(m)
}
public addTransformControls = (model: any) => { public addPerspectiveCamera = (options: position) => {
this.transformControl = new TransformControls(this.camera, this.renderer.domElement) this.camera = new PerspectiveCamera()
this.transformControl.addEventListener('dragging-changed', (event: any) => { this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0)
this.orbit.enabled = !event.value this.scene.add(this.camera)
this.isDragging = !event.value return this
})
this.transformControl.attach(model)
this.scene.add(this.transformControl)
this.transformControl.setMode('rotate')
return this
}
public addModel = (model: any) => {
this.modelGroup = new Group()
this.modelGroup.add(model)
this.model = model
this.scene.add(this.modelGroup)
return this
}
public addDragControl = (updateAngle: any) => {
const highlightColor = '#FFFFFF'
const highlightMaterial = new MeshPhongMaterial({
shininess: 10,
color: highlightColor,
emissive: highlightColor,
emissiveIntensity: 0.9
})
const dragControls = new PointerURDFDragControls(
this.scene,
this.camera,
this.renderer.domElement
)
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
this.setJointValue(joint.name, angle)
updateAngle(joint.name, angle)
} }
dragControls.onDragStart = () => {
this.orbit.enabled = false public addGroundPlane = (options?: position) => {
this.isDragging = true const checkerboardTexture = this.createCheckerboardTexture(1024, 2)
checkerboardTexture.wrapS = RepeatWrapping
checkerboardTexture.wrapT = RepeatWrapping
checkerboardTexture.repeat.set(100, 100)
const checkerboardMat = new MeshBasicMaterial({
map: checkerboardTexture,
opacity: 0.1,
transparent: true
})
const plane = new PlaneGeometry(400, 400)
this.ground = new Mesh(plane, checkerboardMat)
this.ground.rotation.x = -Math.PI / 2
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0)
this.ground.receiveShadow = true
this.scene.add(this.ground)
const mirror = new Reflector(plane, {
clipBias: 0.003,
textureWidth: window.innerWidth * window.devicePixelRatio,
textureHeight: window.innerHeight * window.devicePixelRatio,
color: 0x00bfff
})
mirror.rotateX(-Math.PI / 2)
this.scene.add(mirror)
return this
} }
dragControls.onDragEnd = () => {
this.orbit.enabled = true public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.isDragging = false this.orbit = new OrbitControls(this.camera, this.renderer.domElement)
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2
this.orbit.maxDistance = maxDistance
this.orbit.autoRotate = autoRotate
this.orbit.update()
this.orbit.minDistance = minDistance
return this
} }
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial)
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial)
this.renderer.domElement.addEventListener( public addAmbientLight = (options: light) => {
'touchstart', const ambientLight = new AmbientLight(options.color, options.intensity)
data => dragControls._mouseDown(data.touches[0]), this.scene.add(ambientLight)
{ passive: true } return this
) }
this.renderer.domElement.addEventListener(
'touchmove',
data => dragControls._mouseMove(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchend',
data => dragControls._mouseUp(data.touches[0]),
{ passive: true }
)
return this
}
public toggleFog = () => { public addDirectionalLight = (options: directionalLight) => {
this.scene.fog = this.scene.fog ? null : this.fog const directionalLight = new DirectionalLight(options.color, options.intensity)
} directionalLight.castShadow = true
directionalLight.shadow.camera.top = 10
directionalLight.shadow.camera.bottom = -10
directionalLight.shadow.camera.right = 10
directionalLight.shadow.camera.left = -10
directionalLight.shadow.mapSize.set(4096, 4096)
private handleRobotShadow = () => { directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0)
if (this.isLoaded) return this.scene.add(directionalLight)
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10) return this
setTimeout(() => clearInterval(intervalId), 1000) }
this.isLoaded = true
} private createCheckerboardTexture = (size: number, squares: number) => {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
const squareSize = size / squares
for (let y = 0; y < squares; y++) {
for (let x = 0; x < squares; x++) {
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000'
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize)
}
}
const texture = new CanvasTexture(canvas)
texture.wrapS = texture.wrapT = RepeatWrapping
texture.anisotropy = 16
return texture
}
public addFogExp2 = (color: ColorRepresentation, density?: number) => {
this.scene.fog = new FogExp2(color, density)
return this
}
public fillParent = () => {
const parentElement = this.renderer.domElement.parentElement
if (parentElement) {
const width = parentElement.clientWidth
const height = parentElement.clientHeight
this.handleResize(width, height)
}
return this
}
public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
this.renderer.setSize(width, height)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
return this
}
public addRenderCb = (callback: Function) => {
this.callback = callback
return this
}
public startRenderLoop = () => {
this.renderer.setAnimationLoop(() => {
this.renderer.render(this.scene, this.camera)
this.orbit.update()
this.handleRobotShadow()
if (this.callback) this.callback()
if (!this.liveStreamTexture) return
})
return this
}
public addArrowHelper = (options?: arrowOptions) => {
const dir = new Vector3(
options?.direction.x ?? 0,
options?.direction.y ?? 0,
options?.direction.z ?? 0
)
const origin = new Vector3(
options?.origin.x ?? 0,
options?.origin.y ?? 0,
options?.origin.z ?? 0
)
const arrowHelper = new ArrowHelper(
dir,
origin,
options?.length ?? 1.5,
options?.color ?? 0xff0000
)
this.scene.add(arrowHelper)
return this
}
private setJointValue(jointName: string, angle: number) {
if (!this.model) return
if (!this.model.joints[jointName]) return
this.model.joints[jointName].setJointValue(angle)
}
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
const traverse = (c: any) => {
if (c.type === 'Mesh') {
if (revert) {
c.material = c.__origMaterial
delete c.__origMaterial
} else {
c.__origMaterial = c.material
c.material = material
}
}
if (c === m || !this.isJoint(c)) {
for (let i = 0; i < c.children.length; i++) {
const child = c.children[i]
if (!child.isURDFCollider) {
traverse(c.children[i])
}
}
}
}
traverse(m)
}
public addTransformControls = (model: any) => {
this.transformControl = new TransformControls(this.camera, this.renderer.domElement)
this.transformControl.addEventListener('dragging-changed', (event: any) => {
this.orbit.enabled = !event.value
this.isDragging = !event.value
})
this.transformControl.attach(model)
this.scene.add(this.transformControl)
this.transformControl.setMode('rotate')
return this
}
public addModel = (model: any) => {
this.modelGroup = new Group()
this.modelGroup.add(model)
this.model = model
this.scene.add(this.modelGroup)
return this
}
public addDragControl = (updateAngle: any) => {
const highlightColor = '#FFFFFF'
const highlightMaterial = new MeshPhongMaterial({
shininess: 10,
color: highlightColor,
emissive: highlightColor,
emissiveIntensity: 0.9
})
const dragControls = new PointerURDFDragControls(
this.scene,
this.camera,
this.renderer.domElement
)
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
this.setJointValue(joint.name, angle)
updateAngle(joint.name, angle)
}
dragControls.onDragStart = () => {
this.orbit.enabled = false
this.isDragging = true
}
dragControls.onDragEnd = () => {
this.orbit.enabled = true
this.isDragging = false
}
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial)
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial)
this.renderer.domElement.addEventListener(
'touchstart',
data => dragControls._mouseDown(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchmove',
data => dragControls._mouseMove(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchend',
data => dragControls._mouseUp(data.touches[0]),
{ passive: true }
)
return this
}
public toggleFog = () => {
this.scene.fog = this.scene.fog ? null : this.fog
}
private handleRobotShadow = () => {
if (this.isLoaded) return
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10)
setTimeout(() => clearInterval(intervalId), 1000)
this.isLoaded = true
}
} }
+42 -43
View File
@@ -1,54 +1,53 @@
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 = browser private dbPromise: Promise<Result<IDBDatabase, string>> | null =
? this.openDatabase() browser ? this.openDatabase() : null
: 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()
+65 -51
View File
@@ -1,55 +1,69 @@
import { type Analytics } from '$lib/types/models'; import { type Analytics } from '$lib/types/models'
import { writable } from 'svelte/store'; import { writable } from 'svelte/store'
let 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;
function createAnalytics() {
const { subscribe, update } = writable(analytics_data);
return {
subscribe,
addData: (content: Analytics) => {
update((analytics_data) => ({
...analytics_data,
uptime: [...analytics_data.uptime, content.uptime].slice(-maxAnalyticsData),
free_heap: [...analytics_data.free_heap, content.free_heap / 1000].slice(-maxAnalyticsData),
total_heap: [...analytics_data.total_heap, content.total_heap / 1000].slice(
-maxAnalyticsData
),
used_heap: [
...analytics_data.used_heap,
(content.total_heap - content.free_heap) / 1000
].slice(-maxAnalyticsData),
min_free_heap: [...analytics_data.min_free_heap, content.min_free_heap / 1000].slice(
-maxAnalyticsData
),
max_alloc_heap: [...analytics_data.max_alloc_heap, content.max_alloc_heap / 1000].slice(
-maxAnalyticsData
),
fs_used: [...analytics_data.fs_used, content.fs_used / 1000].slice(-maxAnalyticsData),
fs_total: [...analytics_data.fs_total, content.fs_total / 1000].slice(-maxAnalyticsData),
core_temp: [...analytics_data.core_temp, content.core_temp].slice(-maxAnalyticsData),
cpu0_usage: [...analytics_data.cpu0_usage, content.cpu0_usage].slice(-maxAnalyticsData),
cpu1_usage: [...analytics_data.cpu1_usage, content.cpu1_usage].slice(-maxAnalyticsData),
cpu_usage: [...analytics_data.cpu_usage, content.cpu_usage].slice(-maxAnalyticsData)
}));
}
};
} }
export const analytics = createAnalytics(); const maxAnalyticsData = 100
function createAnalytics() {
const { subscribe, update } = writable(analytics_data)
return {
subscribe,
addData: (content: Analytics) => {
update(analytics_data => ({
...analytics_data,
uptime: [...analytics_data.uptime, content.uptime].slice(-maxAnalyticsData),
free_heap: [...analytics_data.free_heap, content.free_heap / 1000].slice(
-maxAnalyticsData
),
total_heap: [...analytics_data.total_heap, content.total_heap / 1000].slice(
-maxAnalyticsData
),
used_heap: [
...analytics_data.used_heap,
(content.total_heap - content.free_heap) / 1000
].slice(-maxAnalyticsData),
min_free_heap: [
...analytics_data.min_free_heap,
content.min_free_heap / 1000
].slice(-maxAnalyticsData),
max_alloc_heap: [
...analytics_data.max_alloc_heap,
content.max_alloc_heap / 1000
].slice(-maxAnalyticsData),
fs_used: [...analytics_data.fs_used, content.fs_used / 1000].slice(
-maxAnalyticsData
),
fs_total: [...analytics_data.fs_total, content.fs_total / 1000].slice(
-maxAnalyticsData
),
core_temp: [...analytics_data.core_temp, content.core_temp].slice(
-maxAnalyticsData
),
cpu0_usage: [...analytics_data.cpu0_usage, content.cpu0_usage].slice(
-maxAnalyticsData
),
cpu1_usage: [...analytics_data.cpu1_usage, content.cpu1_usage].slice(
-maxAnalyticsData
),
cpu_usage: [...analytics_data.cpu_usage, content.cpu_usage].slice(-maxAnalyticsData)
}))
}
}
}
export const analytics = createAnalytics()
+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, any>; 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: 'Stream', name: 'Stream',
content: { content: {
id: 'root', id: 'root',
layout: 'column', layout: 'column',
widgets: [{ id: 2, component: 'Stream' }] widgets: [{ id: 2, component: 'Stream' }]
} }
}, },
{ {
name: '3D representation', name: '3D representation',
content: { content: {
id: 'root', id: 'root',
layout: 'column', layout: 'column',
widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }] 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)
+39 -39
View File
@@ -8,55 +8,55 @@ import { base } from '$app/paths'
let featureFlagsStore: Writable<Record<string, boolean | string>> let featureFlagsStore: Writable<Record<string, boolean | string>>
export function useFeatureFlags() { export function useFeatureFlags() {
if (!featureFlagsStore) { if (!featureFlagsStore) {
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {}) featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
api.get<Record<string, boolean>>('/api/features').then(result => { api.get<Record<string, boolean>>('/api/features').then(result => {
if (result.isOk()) featureFlagsStore.set(result.inner) if (result.isOk()) featureFlagsStore.set(result.inner)
else { else {
notifications.error('Feature flag could not be fetched', 2500) notifications.error('Feature flag could not be fetched', 2500)
} }
}) })
} }
return featureFlagsStore return featureFlagsStore
} }
export const variants = { export const variants = {
SPOTMICRO_ESP32: { SPOTMICRO_ESP32: {
model: `${base}/spot_micro.urdf.xacro`, model: `${base}/spot_micro.urdf.xacro`,
stl: `${base}/stl.zip`, stl: `${base}/stl.zip`,
kinematics: { kinematics: {
coxa: 60.5 / 100, coxa: 60.5 / 100,
coxa_offset: 10 / 100, coxa_offset: 10 / 100,
femur: 111.7 / 100, femur: 111.7 / 100,
tibia: 118.5 / 100, tibia: 118.5 / 100,
L: 207.5 / 100, L: 207.5 / 100,
W: 78 / 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
}
} }
},
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 => { export const currentVariant = derived(useFeatureFlags(), $flagStore => {
const variantFlag = $flagStore['variant'] as string const variantFlag = $flagStore['variant'] as string
return variantFlag && variants[variantFlag as keyof typeof variants] ? return variantFlag && variants[variantFlag as keyof typeof variants] ?
variants[variantFlag as keyof typeof variants] variants[variantFlag as keyof typeof variants]
: variants.SPOTMICRO_ESP32 : variants.SPOTMICRO_ESP32
}) })
export const currentKinematic = derived( export const currentKinematic = derived(
currentVariant, currentVariant,
$variant => new Kinematic($variant.kinematics) $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)
} }
} }
+27 -27
View File
@@ -1,40 +1,40 @@
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[]
} }
export const gamepads = readable<GamepadState>({ available: false, gamepads: [] }, set => { export const gamepads = readable<GamepadState>({ available: false, gamepads: [] }, set => {
const update = () => { const update = () => {
const hasGamepadAPI = 'getGamepads' in navigator const hasGamepadAPI = 'getGamepads' in navigator
if (!hasGamepadAPI) { if (!hasGamepadAPI) {
set({ available: false, gamepads: [] }) set({ available: false, gamepads: [] })
return return
}
const gps = navigator.getGamepads?.() ?? []
const validGamepads = gps.filter(Boolean) as Gamepad[]
set({
available: true,
gamepads: validGamepads
})
raf = requestAnimationFrame(update)
} }
const gps = navigator.getGamepads?.() ?? [] window.addEventListener('gamepadconnected', update)
const validGamepads = gps.filter(Boolean) as Gamepad[] window.addEventListener('gamepaddisconnected', update)
set({ let raf = requestAnimationFrame(update)
available: true,
gamepads: validGamepads
})
raf = requestAnimationFrame(update)
}
window.addEventListener('gamepadconnected', update) return () => {
window.addEventListener('gamepaddisconnected', update) cancelAnimationFrame(raf)
let raf = requestAnimationFrame(update) window.removeEventListener('gamepadconnected', update)
window.removeEventListener('gamepaddisconnected', update)
return () => { }
cancelAnimationFrame(raf)
window.removeEventListener('gamepadconnected', update)
window.removeEventListener('gamepaddisconnected', update)
}
}) })
export const gamepad = derived(gamepads, $gamepads => export const gamepad = derived(gamepads, $gamepads =>
$gamepads.available && $gamepads.gamepads.length > 0 ? $gamepads.gamepads[0] : null $gamepads.available && $gamepads.gamepads.length > 0 ? $gamepads.gamepads[0] : null
) )
export const gamepadAxes = derived(gamepad, $gamepad => $gamepad?.axes ?? [0, 0, 0, 0]) export const gamepadAxes = derived(gamepad, $gamepad => $gamepad?.axes ?? [0, 0, 0, 0])
@@ -42,6 +42,6 @@ export const gamepadAxes = derived(gamepad, $gamepad => $gamepad?.axes ?? [0, 0,
export const gamepadButtons = derived(gamepad, $gamepad => $gamepad?.buttons ?? []) export const gamepadButtons = derived(gamepad, $gamepad => $gamepad?.buttons ?? [])
export const hasGamepad = derived( export const hasGamepad = derived(
gamepads, gamepads,
$gamepads => $gamepads.available && $gamepads.gamepads.length > 0 $gamepads => $gamepads.available && $gamepads.gamepads.length > 0
) )
+12 -12
View File
@@ -1,7 +1,7 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store'
import type { IMU } 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,16 +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: IMU) => { const addData = (content: IMU) => {
update(data => { update(data => {
(Object.keys(content) as (keyof IMU)[]).forEach(key => { ;(Object.keys(content) as (keyof IMU)[]).forEach(key => {
data[key] = [...data[key], content[key]].slice(-maxIMUData); data[key] = [...data[key], content[key]].slice(-maxIMUData)
}); })
return data; return data
}); })
}; }
return { subscribe, addData }; 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 -4
View File
@@ -1,5 +1,5 @@
import { persistentStore } from '$lib/utilities'; import { persistentStore } from '$lib/utilities'
import { writable } from 'svelte/store'; import { writable } from 'svelte/store'
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'; import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
export const location = PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', ''); export const 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([])
+16 -16
View File
@@ -13,28 +13,28 @@ export const modes = ['deactivated', 'idle', 'calibration', 'rest', 'stand', 'wa
export type Modes = (typeof modes)[number] export type Modes = (typeof modes)[number]
export enum ModesEnum { export enum ModesEnum {
Deactivated = 0, Deactivated = 0,
Idle = 1, Idle = 1,
Calibration = 2, Calibration = 2,
Rest = 3, Rest = 3,
Stand = 4, Stand = 4,
Walk = 5 Walk = 5
} }
export enum WalkGaits { export enum WalkGaits {
Trot = 0, Trot = 0,
Crawl = 1 Crawl = 1
} }
export const walkGaits = ['trot', 'crawl'] as const export const walkGaits = ['trot', 'crawl'] as const
export const walkGaitLabels: Record<WalkGaits, string> = { export const walkGaitLabels: Record<WalkGaits, string> = {
[WalkGaits.Trot]: 'Trot', [WalkGaits.Trot]: 'Trot',
[WalkGaits.Crawl]: 'Crawl' [WalkGaits.Crawl]: 'Crawl'
} }
export const walkGaitToMode = (gait: WalkGaits): 'trot' | 'crawl' => { export const walkGaitToMode = (gait: WalkGaits): 'trot' | 'crawl' => {
return gait === WalkGaits.Trot ? 'trot' : 'crawl' return gait === WalkGaits.Trot ? 'trot' : 'crawl'
} }
export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated) export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated)
@@ -46,9 +46,9 @@ export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0])
export const kinematicData = writable([0, 0, 0, 0, 1, 0]) export const kinematicData = writable([0, 0, 0, 0, 1, 0])
export const input: Writable<ControllerInput> = writable({ 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: 0.5,
speed: 0.5, speed: 0.5,
s1: 0.05 s1: 0.05
}) })
+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
}; }
+151 -151
View File
@@ -1,160 +1,160 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store'
import { encode, decode } from '@msgpack/msgpack'; 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?]; type SocketMessage = [number, string?, unknown?]
let useBinary = false; let useBinary = false
const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => { const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => {
useBinary = data instanceof ArrayBuffer; useBinary = data instanceof ArrayBuffer
try { try {
if (useBinary) { if (useBinary) {
return decode(new Uint8Array(data as ArrayBuffer)) as SocketMessage; return decode(new Uint8Array(data as ArrayBuffer)) as SocketMessage
}
return JSON.parse(data as string);
} catch (error) {
console.error(`Could not decode data: ${new Uint8Array(data as ArrayBuffer)} - ${error}`);
}
return null;
};
const encodeMessage = (data: unknown) => {
try {
return useBinary ? encode(data) : JSON.stringify(data);
} catch (error) {
console.error(`Could not encode data: ${data} - ${error}`);
}
};
function createWebSocket() {
const listeners = new Map<string, Set<(data?: unknown) => void>>();
const { subscribe, set } = writable(false);
const reconnectTimeoutTime = 5000;
let unresponsiveTimeoutId: ReturnType<typeof setTimeout>;
let reconnectTimeoutId: ReturnType<typeof setTimeout>;
let ws: WebSocket;
let socketUrl: string | URL;
function init(url: string | URL) {
socketUrl = url;
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.binaryType = 'arraybuffer';
ws.onopen = ev => {
ping();
useBinary = true;
ping();
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);
}
};
ws.onmessage = frame => {
resetUnresponsiveCheck();
const message = decodeMessage(frame.data);
if (!message) return;
const [, event, payload = undefined] = message;
if (event) listeners.get(event)?.forEach(listener => listener(payload));
};
ws.onerror = ev => disconnect('error', ev);
ws.onclose = ev => disconnect('close', ev);
}
function unsubscribe(event: string, listener?: (data: unknown) => void) {
const eventListeners = listeners.get(event);
if (!eventListeners) return;
if (!eventListeners.size) {
unsubscribeToEvent(event);
}
if (listener) {
eventListeners?.delete(listener);
} else {
listeners.delete(event);
}
}
function resetUnresponsiveCheck() {
clearTimeout(unresponsiveTimeoutId);
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime);
}
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(); return JSON.parse(data as string)
listeners.set(event, eventListeners); } catch (error) {
} console.error(`Could not decode data: ${new Uint8Array(data as ArrayBuffer)} - ${error}`)
eventListeners.add(listener as (data: unknown) => void); }
return null
return () => {
unsubscribe(event, listener as (data: unknown) => void);
};
},
off: <T>(event: string, listener?: (data: T) => void) => {
unsubscribe(event, listener as (data: unknown) => void);
},
};
} }
export const socket = createWebSocket(); const encodeMessage = (data: unknown) => {
try {
return useBinary ? encode(data) : JSON.stringify(data)
} catch (error) {
console.error(`Could not encode data: ${data} - ${error}`)
}
}
function createWebSocket() {
const listeners = new Map<string, Set<(data?: unknown) => void>>()
const { subscribe, set } = writable(false)
const reconnectTimeoutTime = 5000
let unresponsiveTimeoutId: ReturnType<typeof setTimeout>
let reconnectTimeoutId: ReturnType<typeof setTimeout>
let ws: WebSocket
let socketUrl: string | URL
function init(url: string | URL) {
socketUrl = url
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.binaryType = 'arraybuffer'
ws.onopen = ev => {
ping()
useBinary = true
ping()
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)
}
}
ws.onmessage = frame => {
resetUnresponsiveCheck()
const message = decodeMessage(frame.data)
if (!message) return
const [, event, payload = undefined] = message
if (event) listeners.get(event)?.forEach(listener => listener(payload))
}
ws.onerror = ev => disconnect('error', ev)
ws.onclose = ev => disconnect('close', ev)
}
function unsubscribe(event: string, listener?: (data: unknown) => void) {
const eventListeners = listeners.get(event)
if (!eventListeners) return
if (!eventListeners.size) {
unsubscribeToEvent(event)
}
if (listener) {
eventListeners?.delete(listener)
} else {
listeners.delete(event)
}
}
function resetUnresponsiveCheck() {
clearTimeout(unresponsiveTimeoutId)
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime)
}
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)
}
}
}
export const socket = createWebSocket()
+8 -8
View File
@@ -1,5 +1,5 @@
import type { DownloadOTA } from '$lib/types/models'; import type { DownloadOTA } from '$lib/types/models'
import { writable } from 'svelte/store'; import { writable } from 'svelte/store'
let telemetry_data = { let telemetry_data = {
rssi: { rssi: {
@@ -10,10 +10,10 @@ let telemetry_data = {
progress: 0, progress: 0,
error: '' error: ''
} }
}; }
function createTelemetry() { function createTelemetry() {
const { subscribe, set, 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
} }
+151 -151
View File
@@ -1,239 +1,239 @@
export enum MessageTopic { export enum MessageTopic {
imu = 'imu', imu = 'imu',
mode = 'mode', mode = 'mode',
input = 'input', input = 'input',
analytics = 'analytics', analytics = 'analytics',
position = 'position', position = 'position',
angles = 'angles', angles = 'angles',
i2cScan = 'i2cScan', i2cScan = 'i2cScan',
peripheralSettings = 'peripheralSettings', peripheralSettings = 'peripheralSettings',
otastatus = 'otastatus', otastatus = 'otastatus',
gait = 'walk_gait', gait = 'walk_gait',
servoState = 'servoState', servoState = 'servoState',
servoPWM = 'servoPWM', servoPWM = 'servoPWM',
WiFiSettings = 'WiFiSettings', WiFiSettings = 'WiFiSettings',
sonar = 'sonar', sonar = 'sonar',
rssi = 'rssi' 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 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): any; parse(data: Uint8Array | ArrayBuffer): any
compress(data: any): Uint8Array | ArrayBuffer; compress(data: any): Uint8Array | ArrayBuffer
compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer; compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer
decompress(data: Uint8Array | ArrayBuffer): any; decompress(data: Uint8Array | ArrayBuffer): any
decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer; decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer
encode(data: any): Uint8Array | ArrayBuffer; encode(data: any): Uint8Array | ArrayBuffer
decode(data: Uint8Array | ArrayBuffer): any; 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: Function, 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)
}; }
} }
+5 -5
View File
@@ -1,6 +1,6 @@
export const daisyColor = (name: string, opacity: number = 100) => { export const daisyColor = (name: string, opacity: number = 100) => {
const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
if (opacity >= 100) return color; if (opacity >= 100) return color
const alpha = Math.min(Math.max(opacity, 0), 100) / 100; const alpha = Math.min(Math.max(opacity, 0), 100) / 100
return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`; return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`
}; }
+9 -9
View File
@@ -1,9 +1,9 @@
export * from './result'; export * from './result'
export * from './string-utilities'; export * from './string-utilities'
export * from './svelte-utilities'; export * from './svelte-utilities'
export * from './math-utilities'; export * from './math-utilities'
export * from './buffer-utilities'; export * from './buffer-utilities'
export * from './model-utilities'; export * from './model-utilities'
export * from './position-utilities'; export * from './position-utilities'
export * from './string-utilities'; export * from './string-utilities'
export * from './color-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))
let 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))
let 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 -57
View File
@@ -10,84 +10,85 @@ import { get } from 'svelte/store'
let model_xml: XMLDocument let model_xml: XMLDocument
export const populateModelCache = async () => { export const populateModelCache = async () => {
await cacheModelFiles() await cacheModelFiles()
const modelRes = await loadModel(get(currentVariant).model) const modelRes = await loadModel(get(currentVariant).model)
if (modelRes.isOk()) { if (modelRes.isOk()) {
const [urdf, JOINT_NAME] = modelRes.inner const [urdf, JOINT_NAME] = modelRes.inner
jointNames.set(JOINT_NAME) jointNames.set(JOINT_NAME)
model.set(urdf) model.set(urdf)
} else { } else {
console.error(modelRes.inner, { exception: modelRes.exception }) console.error(modelRes.inner, { exception: modelRes.exception })
} }
} }
export const cacheModelFiles = async () => { export const cacheModelFiles = async () => {
const data = await fetch(get(currentVariant).stl) const data = await fetch(get(currentVariant).stl)
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 url = new URL(path, window.location.href) const url = new URL(path, window.location.href)
fileService?.saveFile(url.toString(), data) fileService?.saveFile(url.toString(), data)
} }
} }
export const 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) urdfLoader.workingPath = LoaderUtils.extractUrlBase(url)
let xml = url.endsWith('.xacro') ? await loadXacro(url) : await fetch(url).then(res => res.text()) let xml =
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})`)
} }
+75 -73
View File
@@ -1,84 +1,86 @@
class SunCalculator { class SunCalculator {
calculateSunElevation(lat: number = 55, lon: number = 12) { calculateSunElevation(lat: number = 55, lon: number = 12) {
const now = new Date(); const now = new Date()
const JD = this.getJulianDate(now); const JD = this.getJulianDate(now)
const solarDec = this.getSolarDeclination(JD); const solarDec = this.getSolarDeclination(JD)
const solarTime = this.getSolarTime(now, lon); const solarTime = this.getSolarTime(now, lon)
const hourAngle = (solarTime - 12) * 15; const hourAngle = (solarTime - 12) * 15
const elevation = Math.asin( const elevation = Math.asin(
Math.sin(this.degToRad(lat)) * Math.sin(solarDec) + Math.sin(this.degToRad(lat)) * Math.sin(solarDec) +
Math.cos(this.degToRad(lat)) * Math.cos(solarDec) * Math.cos(this.degToRad(hourAngle)) Math.cos(this.degToRad(lat)) *
); Math.cos(solarDec) *
Math.cos(this.degToRad(hourAngle))
)
return this.radToDeg(elevation); return this.radToDeg(elevation)
} }
getJulianDate(date: Date) { getJulianDate(date: Date) {
const Y = date.getUTCFullYear(); const Y = date.getUTCFullYear()
const M = date.getUTCMonth() + 1; const M = date.getUTCMonth() + 1
const D = const D =
date.getUTCDate() + date.getUTCDate() +
date.getUTCHours() / 24 + date.getUTCHours() / 24 +
date.getUTCMinutes() / 1440 + date.getUTCMinutes() / 1440 +
date.getUTCSeconds() / 86400; date.getUTCSeconds() / 86400
const A = Math.floor((14 - M) / 12); const A = Math.floor((14 - M) / 12)
const Y1 = Y + 4800 - A; const Y1 = Y + 4800 - A
const M1 = M + 12 * A - 3; const M1 = M + 12 * A - 3
return ( return (
D + D +
Math.floor((153 * M1 + 2) / 5) + Math.floor((153 * M1 + 2) / 5) +
365 * Y1 + 365 * Y1 +
Math.floor(Y1 / 4) - Math.floor(Y1 / 4) -
Math.floor(Y1 / 100) + Math.floor(Y1 / 100) +
Math.floor(Y1 / 400) - Math.floor(Y1 / 400) -
32045 32045
); )
} }
getSolarDeclination(JulianDate: number) { getSolarDeclination(JulianDate: number) {
const n = JulianDate - 2451545; const n = JulianDate - 2451545
const L = (280.46 + 0.9856474 * n) % 360; const L = (280.46 + 0.9856474 * n) % 360
const g = this.degToRad((357.528 + 0.9856003 * 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)); 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))); return Math.asin(Math.sin(lambda) * Math.sin(this.degToRad(23.44)))
} }
getSolarTime(date: Date, lon: number) { getSolarTime(date: Date, lon: number) {
const EoT = this.getEquationOfTime(date); const EoT = this.getEquationOfTime(date)
const offset = date.getTimezoneOffset() / 60; const offset = date.getTimezoneOffset() / 60
const standardMeridian = Math.round(lon / 15) * 15; const standardMeridian = Math.round(lon / 15) * 15
const solarTime = const solarTime =
date.getUTCHours() + date.getUTCHours() +
(date.getUTCMinutes() + (4 * (standardMeridian - lon) + EoT)) / 60 - (date.getUTCMinutes() + (4 * (standardMeridian - lon) + EoT)) / 60 -
offset; offset
return (solarTime + 24) % 24; return (solarTime + 24) % 24
} }
getEquationOfTime(date: Date) { getEquationOfTime(date: Date) {
const JD = this.getJulianDate(date); const JD = this.getJulianDate(date)
const n = JD - 2451545; const n = JD - 2451545
const g = this.degToRad((357.528 + 0.9856003 * n) % 360); const g = this.degToRad((357.528 + 0.9856003 * n) % 360)
const q = this.degToRad((280.46 + 0.9856474 * n) % 360); const q = this.degToRad((280.46 + 0.9856474 * n) % 360)
return ( return (
4 * 4 *
this.radToDeg( this.radToDeg(
0.000075 + 0.000075 +
0.001868 * Math.cos(q) - 0.001868 * Math.cos(q) -
0.032077 * Math.sin(g) - 0.032077 * Math.sin(g) -
0.014615 * Math.cos(2 * q) - 0.014615 * Math.cos(2 * q) -
0.040849 * Math.sin(2 * g) 0.040849 * Math.sin(2 * g)
) )
); )
} }
degToRad(deg: number) { degToRad(deg: number) {
return deg * (Math.PI / 180); return deg * (Math.PI / 180)
} }
radToDeg(rad: number) { radToDeg(rad: number) {
return rad * (180 / Math.PI); return rad * (180 / Math.PI)
} }
} }
export const sunCalculator = new SunCalculator(); 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)
} }
} }
+15 -15
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 namespace Result { export namespace Result {
/** /**
* @returns `Ok<T>` * @returns `Ok<T>`
*/ */
export function 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>`
*/ */
export function 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
}; }
+3 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
</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>{page.status} {page.error?.message}</h1>
<span>Go to <a class="btn btn-primary" href="/">Home page</a></span> <span>Go to <a class="btn btn-primary" href="/">Home page</a></span>
</div> </div>
+99 -99
View File
@@ -1,129 +1,129 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { page } from '$app/state' import { page } from '$app/state'
import { Modals, modals } from 'svelte-modals' import { Modals, modals } from 'svelte-modals'
import Toast from '$lib/components/toasts/Toast.svelte' import Toast from '$lib/components/toasts/Toast.svelte'
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications'
import { fade } from 'svelte/transition' import { fade } from 'svelte/transition'
import '../app.css' import '../app.css'
import Menu from '../lib/components/menu/Menu.svelte' import Menu from '../lib/components/menu/Menu.svelte'
import Statusbar from '../lib/components/statusbar/statusbar.svelte' import Statusbar from '../lib/components/statusbar/statusbar.svelte'
import { import {
telemetry, telemetry,
analytics, analytics,
ModesEnum, ModesEnum,
kinematicData, kinematicData,
mode, mode,
outControllerData, outControllerData,
servoAngles, servoAngles,
servoAnglesOut, servoAnglesOut,
socket, socket,
location, location,
useFeatureFlags, useFeatureFlags,
walkGait walkGait
} from '$lib/stores' } from '$lib/stores'
import { type Analytics, type DownloadOTA } from '$lib/types/models' import { type Analytics, type DownloadOTA } from '$lib/types/models'
import { MessageTopic } 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 = $location ? $location : window.location.host const ws = $location ? $location : window.location.host
socket.init(`ws://${ws}/api/ws`) socket.init(`ws://${ws}/api/ws`)
addEventListeners() addEventListeners()
outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data)) outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data))
mode.subscribe(data => socket.sendEvent(MessageTopic.mode, data)) mode.subscribe(data => socket.sendEvent(MessageTopic.mode, data))
walkGait.subscribe(data => socket.sendEvent(MessageTopic.gait, data)) walkGait.subscribe(data => socket.sendEvent(MessageTopic.gait, data))
servoAnglesOut.subscribe(data => socket.sendEvent(MessageTopic.angles, data)) servoAnglesOut.subscribe(data => socket.sendEvent(MessageTopic.angles, data))
kinematicData.subscribe(data => socket.sendEvent(MessageTopic.position, data)) kinematicData.subscribe(data => socket.sendEvent(MessageTopic.position, data))
})
onDestroy(() => {
removeEventListeners()
})
const addEventListeners = () => {
socket.on('open', handleOpen)
socket.on('close', handleClose)
socket.on('error', handleError)
socket.on(MessageTopic.rssi, handleNetworkStatus)
socket.on(MessageTopic.mode, (data: ModesEnum) => mode.set(data))
socket.on(MessageTopic.analytics, handleAnalytics)
socket.on(MessageTopic.angles, (angles: number[]) => {
if (angles.length) servoAngles.set(angles)
}) })
features.subscribe(data => {
if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT) onDestroy(() => {
if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data)) removeEventListeners()
}) })
}
const removeEventListeners = () => { const addEventListeners = () => {
socket.off(MessageTopic.analytics, handleAnalytics) socket.on('open', handleOpen)
socket.off('open', handleOpen) socket.on('close', handleClose)
socket.off('close', handleClose) socket.on('error', handleError)
socket.off(MessageTopic.rssi, handleNetworkStatus) socket.on(MessageTopic.rssi, handleNetworkStatus)
socket.off(MessageTopic.otastatus, handleOAT) socket.on(MessageTopic.mode, (data: ModesEnum) => mode.set(data))
} socket.on(MessageTopic.analytics, handleAnalytics)
socket.on(MessageTopic.angles, (angles: number[]) => {
if (angles.length) servoAngles.set(angles)
})
features.subscribe(data => {
if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT)
if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data))
})
}
const handleOpen = () => { const removeEventListeners = () => {
notifications.success('Connection to device established', 5000) socket.off(MessageTopic.analytics, handleAnalytics)
} socket.off('open', handleOpen)
socket.off('close', handleClose)
socket.off(MessageTopic.rssi, handleNetworkStatus)
socket.off(MessageTopic.otastatus, handleOAT)
}
const handleClose = () => { const handleOpen = () => {
notifications.error('Connection to device lost', 5000) notifications.success('Connection to device established', 5000)
telemetry.setRSSI(0) }
}
const handleError = (data: any) => console.error(data) const handleClose = () => {
notifications.error('Connection to device lost', 5000)
telemetry.setRSSI(0)
}
const handleAnalytics = (data: Analytics) => analytics.addData(data) const handleError = (data: any) => console.error(data)
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data) const handleAnalytics = (data: Analytics) => analytics.addData(data)
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data) const handleNetworkStatus = (data: number) => telemetry.setRSSI(data)
let menuOpen = $state(false) const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data)
let menuOpen = $state(false)
</script> </script>
<svelte:head> <svelte:head>
<title>{page.data.title}</title> <title>{page.data.title}</title>
</svelte:head> </svelte:head>
<div class="drawer"> <div class="drawer">
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} /> <input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
<div class="drawer-content flex flex-col"> <div class="drawer-content flex flex-col">
<!-- Status bar content here --> <!-- Status bar content here -->
<Statusbar /> <Statusbar />
<!-- Main page content here --> <!-- Main page content here -->
{@render children?.()} {@render children?.()}
</div> </div>
<!-- Side Navigation --> <!-- Side Navigation -->
<div class="drawer-side z-30 shadow-lg"> <div class="drawer-side z-30 shadow-lg">
<label for="main-menu" class="drawer-overlay"></label> <label for="main-menu" class="drawer-overlay"></label>
<Menu menuClicked={() => (menuOpen = false)} /> <Menu menuClicked={() => (menuOpen = false)} />
</div> </div>
</div> </div>
<Modals> <Modals>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
{#snippet backdrop()} {#snippet backdrop()}
<div <div
class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm" class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm"
transition:fade transition:fade
onclick={modals.closeAll}> onclick={modals.closeAll}
</div> ></div>
{/snippet} {/snippet}
</Modals> </Modals>
<Toast /> <Toast />
+16 -14
View File
@@ -2,21 +2,23 @@ export const prerender = true
export const ssr = false export const ssr = false
const registerFetchIntercept = async () => { const registerFetchIntercept = async () => {
const { fetch: originalFetch } = window const { fetch: originalFetch } = window
const fileService = (await import('$lib/services/file-service')).default const fileService = (await import('$lib/services/file-service')).default
window.fetch = async (resource, config) => { window.fetch = async (resource, config) => {
const url = resource instanceof Request ? resource.url : resource.toString() const url = resource instanceof Request ? resource.url : resource.toString()
const file = await fileService?.getFile(url) const file = await fileService?.getFile(url)
return file?.isOk() ? new Response(file.inner) : originalFetch(resource, config) return file?.isOk() && file.inner ?
} new Response(new Uint8Array(file.inner))
: originalFetch(resource, config)
}
} }
export const load = async () => { export const load = async () => {
await registerFetchIntercept() await registerFetchIntercept()
return { return {
title: 'Spot micro controller', title: 'Spot micro controller',
github: 'runeharlyk/SpotMicroESP32-Leika', github: 'runeharlyk/SpotMicroESP32-Leika',
app_name: 'Spot Micro Controller', app_name: 'Spot Micro Controller',
copyright: '2025 Rune Harlyk' copyright: '2025 Rune Harlyk'
} }
} }
+21 -19
View File
@@ -1,27 +1,29 @@
<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'
onMount(() => { onMount(() => {
socket.subscribe(isConnected => { socket.subscribe(isConnected => {
if (isConnected) { if (isConnected) {
goto('/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 sky={false} 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={$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
+18 -18
View File
@@ -1,26 +1,26 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte'
import { WiFi } from '$lib/components/icons'; import { WiFi } from '$lib/components/icons'
import { location, socket } from '$lib/stores'; import { location, socket } from '$lib/stores'
const update = () => { const update = () => {
const ws = $location ? $location : 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={$location} /> <input class="input" bind:value={$location} />
</div> </div>
<button class="btn btn-primary" onclick={update}>Update</button> <button class="btn btn-primary" onclick={update}>Update</button>
</SettingsCard> </SettingsCard>
+21 -21
View File
@@ -1,31 +1,31 @@
<script lang="ts"> <script lang="ts">
import Controls from './Controls.svelte' import Controls from './Controls.svelte'
import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte' import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte'
import { selectedView, views } from '$lib/stores/application' import { selectedView, views } from '$lib/stores/application'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { mpu, socket } from '$lib/stores' import { mpu, socket } from '$lib/stores'
import { imu } from '$lib/stores/imu' import { imu } from '$lib/stores/imu'
import { MessageTopic, type IMU } from '$lib/types/models' import { MessageTopic, type IMU } from '$lib/types/models'
let layout = $derived($views.find(v => v.name === $selectedView)!) let layout = $derived($views.find(v => v.name === $selectedView)!)
onMount(() => { onMount(() => {
socket.on(MessageTopic.imu, (data: IMU) => { socket.on(MessageTopic.imu, (data: IMU) => {
imu.addData(data) imu.addData(data)
if (data.heading) if (data.heading)
mpu.update(mpuData => { mpu.update(mpuData => {
mpuData.heading = data.heading mpuData.heading = data.heading
console.log(data.heading) console.log(data.heading)
return mpuData return mpuData
})
}) })
}) })
})
</script> </script>
<div class="absolute top-0 select-none w-screen h-screen"> <div class="absolute top-0 select-none w-screen h-screen">
<Controls /> <Controls />
<div class="absolute w-full h-screen top-0 overflow-hidden lg:pt-16 pt-12"> <div class="absolute w-full h-screen top-0 overflow-hidden lg:pt-16 pt-12">
<WidgetContainer container={layout.content} /> <WidgetContainer container={layout.content} />
</div> </div>
</div> </div>
+2 -2
View File
@@ -1,3 +1,3 @@
export const load = async () => { export const load = async () => {
return { title: 'Controller' }; return { title: 'Controller' }
}; }
+186 -178
View File
@@ -1,202 +1,210 @@
<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 } from '$lib/utilities'
import { import {
input, input,
outControllerData, outControllerData,
mode, mode,
modes, modes,
type Modes, type Modes,
ModesEnum, ModesEnum,
walkGaits, walkGaits,
WalkGaits, WalkGaits,
walkGait, walkGait,
walkGaitLabels walkGaitLabels
} from '$lib/stores' } from '$lib/stores'
import type { vector } from '$lib/types/models' import type { vector } from '$lib/types/models'
import { VerticalSlider } from '$lib/components/input' import { VerticalSlider } from '$lib/components/input'
import { gamepadAxes, hasGamepad } from '$lib/stores/gamepad' import { gamepadAxes, hasGamepad } from '$lib/stores/gamepad'
import { notifications } from '$lib/components/toasts/notifications' 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(7)
$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
})
right = nipplejs.create({
zone: document.getElementById('right') as HTMLElement,
color: '#15191e80',
dynamicPage: true,
mode: 'static',
restOpacity: 1
})
left.on('move', (_, data) => handleJoyMove('left', data.vector))
left.on('end', (_, __) => handleJoyMove('left', { x: 0, y: 0 }))
right.on('move', (_, data) => handleJoyMove('right', data.vector))
right.on('end', (_, __) => handleJoyMove('right', { x: 0, y: 0 }))
})
const handleJoyMove = (key: 'left' | 'right', data: vector) => {
input.update(inputData => {
inputData[key] = data
return inputData
})
throttle.throttle(updateData, throttle_timing)
} }
})
$effect(() => { const updateData = () => {
handleJoyMove('left', { x: $gamepadAxes[0], y: -$gamepadAxes[1] }) data[0] = $input.left.x
handleJoyMove('right', { x: $gamepadAxes[2], y: $gamepadAxes[3] }) data[1] = $input.left.y
}) data[2] = $input.right.x
data[3] = $input.right.y
data[4] = $input.height
data[5] = $input.speed
data[6] = $input.s1
// TODO React to button press outControllerData.set(data)
// $effect(() => { }
// if ($gamepadButtons.length === 0) return
//
// })
onMount(() => { const handleKeyup = (event: KeyboardEvent) => {
left = nipplejs.create({ const down = event.type === 'keydown'
zone: document.getElementById('left') as HTMLElement, input.update(data => {
color: '#15191e80', if (event.key === 'w') data.left.y = down ? 1 : 0
dynamicPage: true, if (event.key === 'a') data.left.x = down ? 1 : 0
mode: 'static', if (event.key === 's') data.left.y = down ? -1 : 0
restOpacity: 1 if (event.key === 'd') data.left.x = down ? -1 : 0
}) return data
})
throttle.throttle(updateData, throttle_timing)
}
right = nipplejs.create({ const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => {
zone: document.getElementById('right') as HTMLElement, const value: number = Number((event.target as HTMLInputElement).value)
color: '#15191e80',
dynamicPage: true,
mode: 'static',
restOpacity: 1
})
left.on('move', (_, data) => handleJoyMove('left', data.vector)) input.update(inputData => {
left.on('end', (_, __) => handleJoyMove('left', { x: 0, y: 0 })) inputData[key] = value
right.on('move', (_, data) => handleJoyMove('right', data.vector)) return inputData
right.on('end', (_, __) => handleJoyMove('right', { x: 0, y: 0 })) })
}) throttle.throttle(updateData, throttle_timing)
}
const handleJoyMove = (key: 'left' | 'right', data: vector) => { const changeMode = (modeValue: Modes) => {
input.update(inputData => { mode.set(modes.indexOf(modeValue))
inputData[key] = data }
return inputData
})
throttle.throttle(updateData, throttle_timing)
}
const updateData = () => { const changeWalkGait = (walkGaitValue: WalkGaits) => {
data[0] = $input.left.x walkGait.set(walkGaitValue)
data[1] = $input.left.y }
data[2] = $input.right.x
data[3] = $input.right.y
data[4] = $input.height
data[5] = $input.speed
data[6] = $input.s1
outControllerData.set(data)
}
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
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="flex justify-center gap-2 w-full"> <div class="absolute bottom-0 right-0 p-4 z-10 gap-2 flex-col hidden lg:flex">
<kbd class="kbd">A</kbd> <div class="flex justify-center w-full">
<kbd class="kbd">S</kbd> <kbd class="kbd">W</kbd>
<kbd class="kbd">D</kbd> </div>
<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="flex justify-center w-full"></div> <div class="absolute bottom-0 z-10 flex items-end">
</div> <div
<div class="absolute bottom-0 z-10 flex items-end"> class="flex items-center flex-col bg-base-300 bg-opacity-50 p-3 pb-2 gap-2 rounded-tr-xl"
<div class="flex items-center flex-col bg-base-300 bg-opacity-50 p-3 pb-2 gap-2 rounded-tr-xl"> >
<VerticalSlider <VerticalSlider
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
oninput={(e: Event) => handleRange(e, 'height')} /> oninput={(e: Event) => handleRange(e, 'height')}
<label for="height">Ht</label> />
</div> <label for="height">Ht</label>
<div </div>
class="flex items-end gap-4 bg-base-300 bg-opacity-50 h-min rounded-tr-xl pl-0 p-3 portrait:hidden"> <div
<div class="join"> class="flex items-end gap-4 bg-base-300 bg-opacity-50 h-min rounded-tr-xl pl-0 p-3 portrait:hidden"
{#each modes as modeValue} >
<button <div class="join">
class="btn join-item" {#each modes as modeValue}
class:btn-primary={$mode === modes.indexOf(modeValue)} <button
onclick={() => changeMode(modeValue)}> class="btn join-item"
{capitalize(modeValue)} class:btn-primary={$mode === modes.indexOf(modeValue)}
</button> onclick={() => changeMode(modeValue)}
{/each} >
</div> {capitalize(modeValue)}
</button>
{/each}
</div>
{#if $mode === ModesEnum.Walk} {#if $mode === ModesEnum.Walk}
<div class="join"> <div class="join">
{#each Object.values(WalkGaits) as gaitValue} {#each Object.values(WalkGaits) as gaitValue}
{#if typeof gaitValue === 'number'} {#if typeof gaitValue === 'number'}
<button <button
class="btn join-item btn-sm" class="btn join-item btn-sm"
class:btn-secondary={$walkGait === gaitValue} class:btn-secondary={$walkGait === gaitValue}
onclick={() => changeWalkGait(gaitValue)}> onclick={() => changeWalkGait(gaitValue)}
{walkGaitLabels[gaitValue]} >
</button> {walkGaitLabels[gaitValue]}
</button>
{/if}
{/each}
</div>
<div class="flex gap-4">
<div>
<label for="s1">S1</label>
<input
type="range"
name="s1"
min="0"
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} {/if}
{/each}
</div> </div>
<div class="flex gap-4">
<div>
<label for="s1">S1</label>
<input
type="range"
name="s1"
min="0"
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>
</div>
</div> </div>
<svelte:window onkeyup={handleKeyup} onkeydown={handleKeyup} /> <svelte:window onkeyup={handleKeyup} onkeydown={handleKeyup} />
+5 -5
View File
@@ -1,7 +1,7 @@
import type { PageLoad } from './$types'; import type { PageLoad } from './$types'
import { goto } from '$app/navigation'; import { goto } from '$app/navigation'
export const load = (async () => { export const load = (async () => {
goto('/'); 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,13 +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()
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
@@ -15,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
@@ -25,27 +25,47 @@
{#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}>Update camera settings</button> <button class="btn btn-primary" type="button" onclick={updateCameraSettings}
>Update camera settings</button
>
<label for="brightness"> <label for="brightness">
Brightness {settings.brightness} Brightness {settings.brightness}
<input type="range" min="-2" max="2" class="range range-xs" bind:value={settings.brightness}/> <input
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 type="range" min="-2" max="2" class="range range-xs" bind:value={settings.contrast}/> <input
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 type="range" min="0" max="10" class="range range-xs" bind:value={settings.framesize}/> <input
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">
Vertical flip Vertical flip
<input type="checkbox" class="toggle" bind:checked={settings.vflip} /> <input type="checkbox" class="toggle" bind:checked={settings.vflip} />
</label> </label>
@@ -56,7 +76,10 @@
<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 class="select select-bordered select-sm w-full max-w-xs" bind:value={settings.special_effect}> <select
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>
@@ -67,4 +90,4 @@
</select> </select>
</label> </label>
</div> </div>
{/await} {/await}
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import I2C from './i2c.svelte' import I2C from './i2c.svelte'
</script> </script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8"> <div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
<I2C /> <I2C />
</div> </div>
+3 -3
View File
@@ -1,7 +1,7 @@
import type { PageLoad } from './$types' import type { PageLoad } from './$types'
export const load = (async () => { export const load = (async () => {
return { return {
title: 'I2C' title: 'I2C'
} }
}) satisfies PageLoad }) satisfies PageLoad
+66 -66
View File
@@ -1,79 +1,79 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { socket } from '$lib/stores' import { socket } from '$lib/stores'
import { MessageTopic, type I2CDevice } from '$lib/types/models' import { MessageTopic, type I2CDevice } from '$lib/types/models'
import { Connection } from '$lib/components/icons' import { Connection } from '$lib/components/icons'
import I2CSetting from './i2cSetting.svelte' import I2CSetting from './i2cSetting.svelte'
const i2cDevices = [ const i2cDevices = [
{ address: 30, part_number: 'HMC5883', name: '3-Axis Digital Compass/Magnetometer IC' }, { address: 30, part_number: 'HMC5883', name: '3-Axis Digital Compass/Magnetometer IC' },
{ address: 41, part_number: 'BNO055', name: '9-Axis Absolute Orientation Sensor' }, { address: 41, part_number: 'BNO055', name: '9-Axis Absolute Orientation Sensor' },
{ address: 64, part_number: 'PCA9685', name: '16-channel PWM driver default address' }, { address: 64, part_number: 'PCA9685', name: '16-channel PWM driver default address' },
{ address: 72, part_number: 'ADS1115', name: '4-channel 16-bit ADC' }, { address: 72, part_number: 'ADS1115', name: '4-channel 16-bit ADC' },
{ {
address: 104, address: 104,
part_number: 'MPU6050', part_number: 'MPU6050',
name: 'Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices' name: 'Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices'
}, },
{ address: 115, part_number: 'PAJ7620U2', name: 'Gesture sensor' }, { address: 115, part_number: 'PAJ7620U2', name: 'Gesture sensor' },
{ address: 119, part_number: 'BMP085', name: 'Temp/Barometric' } { address: 119, part_number: 'BMP085', name: 'Temp/Barometric' }
] ]
let active_devices: I2CDevice[] = $state([]) let active_devices: I2CDevice[] = $state([])
let isLoading = $state(false) let isLoading = $state(false)
onMount(() => { onMount(() => {
socket.on(MessageTopic.i2cScan, handleScan) socket.on(MessageTopic.i2cScan, handleScan)
triggerScan() triggerScan()
return () => socket.off(MessageTopic.i2cScan, handleScan) return () => socket.off(MessageTopic.i2cScan, handleScan)
}) })
const handleScan = (data: any) => { const handleScan = (data: any) => {
active_devices = data.addresses.map( active_devices = data.addresses.map(
(address: number) => (address: number) =>
i2cDevices.find(device => device.address === address) || { i2cDevices.find(device => device.address === address) || {
address, address,
part_number: 'Unknown', part_number: 'Unknown',
name: 'Unknown' name: 'Unknown'
} }
) )
isLoading = false isLoading = false
} }
const triggerScan = () => { const triggerScan = () => {
isLoading = true isLoading = true
socket.sendEvent(MessageTopic.i2cScan, '') socket.sendEvent(MessageTopic.i2cScan, '')
} }
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<Connection class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <Connection class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span>I<sup>2</sup>C</span> <span>I<sup>2</sup>C</span>
{/snippet} {/snippet}
{#snippet right()} {#snippet right()}
<button class="btn btn-primary" onclick={triggerScan} disabled={isLoading}> <button class="btn btn-primary" onclick={triggerScan} disabled={isLoading}>
{#if isLoading} {#if isLoading}
<span class="loading loading-ring loading-xs"></span> <span class="loading loading-ring loading-xs"></span>
{:else} {:else}
Scan Scan
{/if} {/if}
</button> </button>
{/snippet} {/snippet}
<I2CSetting /> <I2CSetting />
<div class="grid"> <div class="grid">
{#if active_devices.length === 0} {#if active_devices.length === 0}
<div>No I2C devices found</div> <div>No I2C devices found</div>
{:else} {:else}
{#each active_devices as device} {#each active_devices as device}
<div>[{device.address.toString(16)}] {device.part_number} - {device.name}</div> <div>[{device.address.toString(16)}] {device.part_number} - {device.name}</div>
{/each} {/each}
{/if} {/if}
</div> </div>
</SettingsCard> </SettingsCard>
@@ -1,99 +1,107 @@
<script lang="ts"> <script lang="ts">
import { Cancel, Edit, EditOff, Power } from '$lib/components/icons' import { Cancel, Edit, EditOff, Power } from '$lib/components/icons'
import { socket } from '$lib/stores' import { socket } from '$lib/stores'
import { MessageTopic, type PeripheralsConfiguration } from '$lib/types/models' import { MessageTopic, type PeripheralsConfiguration } from '$lib/types/models'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
let settings: PeripheralsConfiguration | null = $state(null) let settings: PeripheralsConfiguration | null = $state(null)
let isEditing = $state(false) let isEditing = $state(false)
onMount(() => { onMount(() => {
socket.on(MessageTopic.peripheralSettings, handleSettings) socket.on(MessageTopic.peripheralSettings, handleSettings)
socket.sendEvent(MessageTopic.peripheralSettings, '') socket.sendEvent(MessageTopic.peripheralSettings, '')
return () => socket.off(MessageTopic.peripheralSettings, handleSettings) return () => socket.off(MessageTopic.peripheralSettings, handleSettings)
})
const handleSettings = (data: any) => {
settings = data
}
const handleSave = () => {
modals.open(ConfirmDialog, {
title: 'Confirm configuration',
message:
'Are you sure you want to save this configuration? The operation cannot be undone. Please make sure you have the correct settings.',
labels: {
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'Confirm', icon: Power }
},
onConfirm: () => {
modals.close()
socket.sendEvent(MessageTopic.peripheralSettings, settings)
}
}) })
}
const Icon = $derived(isEditing ? EditOff : Edit) const handleSettings = (data: any) => {
settings = data
}
const handleSave = () => {
modals.open(ConfirmDialog, {
title: 'Confirm configuration',
message:
'Are you sure you want to save this configuration? The operation cannot be undone. Please make sure you have the correct settings.',
labels: {
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'Confirm', icon: Power }
},
onConfirm: () => {
modals.close()
socket.sendEvent(MessageTopic.peripheralSettings, settings)
}
})
}
const Icon = $derived(isEditing ? EditOff : Edit)
</script> </script>
{#if settings} {#if settings}
<div class="collapse bg-base-100 border-base-300 border"> <div class="collapse bg-base-100 border-base-300 border">
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title font-semibold">Configuration</div> <div class="collapse-title font-semibold">Configuration</div>
<div class="collapse-content text-sm"> <div class="collapse-content text-sm">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="sda" class="input validator"> <label for="sda" class="input validator">
SDA SDA
<input <input
id="sda" id="sda"
type="number" type="number"
required required
placeholder="Type a number between 1 to 48" placeholder="Type a number between 1 to 48"
min="0" min="0"
max="48" max="48"
title="SDA pin number (0-48)" title="SDA pin number (0-48)"
disabled={!isEditing} disabled={!isEditing}
bind:value={settings.sda} /> bind:value={settings.sda}
</label> />
<label for="scl" class="input validator"> </label>
SCL <label for="scl" class="input validator">
SCL
<input <input
id="scl" id="scl"
type="number" type="number"
required required
placeholder="Type a number between 1 to 48" placeholder="Type a number between 1 to 48"
min="1" min="1"
max="48" max="48"
title="SCL pin number (0-48)" title="SCL pin number (0-48)"
disabled={!isEditing} disabled={!isEditing}
bind:value={settings.scl} /> bind:value={settings.scl}
</label> />
<label class="input validator" for="frequency"> </label>
Frequency <label class="input validator" for="frequency">
<input Frequency
id="frequency" <input
type="number" id="frequency"
required type="number"
placeholder="Type a number between 100000 to 430000" required
min="100000" placeholder="Type a number between 100000 to 430000"
max="430000" min="100000"
title="I2C frequency in Hz" max="430000"
disabled={!isEditing} title="I2C frequency in Hz"
bind:value={settings.frequency} /> disabled={!isEditing}
</label> bind:value={settings.frequency}
<div> />
<button class="btn btn-outline btn-primary" onclick={() => (isEditing = !isEditing)}> </label>
<Icon class="h-6 w-6" /> <div>
</button> <button
{#if isEditing} class="btn btn-outline btn-primary"
<button class="btn btn-outline btn-primary" onclick={handleSave}>Save</button> onclick={() => (isEditing = !isEditing)}
{/if} >
<Icon class="h-6 w-6" />
</button>
{#if isEditing}
<button class="btn btn-outline btn-primary" onclick={handleSave}
>Save</button
>
{/if}
</div>
</div>
</div> </div>
</div>
</div> </div>
</div>
{/if} {/if}
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import IMU from './imu.svelte'; import IMU from './imu.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">
<IMU /> <IMU />
</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: 'IMU' title: 'IMU'
}; }
}) satisfies PageLoad; }) satisfies PageLoad
+238 -235
View File
@@ -1,253 +1,256 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import { imu } from '$lib/stores/imu' import { imu } from '$lib/stores/imu'
import { Chart, registerables } from 'chart.js' import { Chart, registerables } from 'chart.js'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { socket } from '$lib/stores' import { socket } from '$lib/stores'
import { MessageTopic, type IMU } from '$lib/types/models' import { MessageTopic, type IMU } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Rotate3d } from '$lib/components/icons' import { Rotate3d } from '$lib/components/icons'
Chart.register(...registerables) Chart.register(...registerables)
const features = useFeatureFlags() const features = useFeatureFlags()
let intervalId: ReturnType<typeof setInterval> | number let intervalId: ReturnType<typeof setInterval> | number
let angleChartElement: HTMLCanvasElement let angleChartElement: HTMLCanvasElement
let tempChartElement: HTMLCanvasElement let tempChartElement: HTMLCanvasElement
let altitudeChartElement: HTMLCanvasElement let altitudeChartElement: HTMLCanvasElement
let angleChart: Chart let angleChart: Chart
let tempChart: Chart let tempChart: Chart
let altitudeChart: Chart let altitudeChart: Chart
const getChartColors = () => { const getChartColors = () => {
const style = getComputedStyle(document.body) const style = getComputedStyle(document.body)
return { return {
primary: style.getPropertyValue('--color-primary'), primary: style.getPropertyValue('--color-primary'),
secondary: style.getPropertyValue('--color-secondary'), secondary: style.getPropertyValue('--color-secondary'),
accent: style.getPropertyValue('--color-accent'), accent: style.getPropertyValue('--color-accent'),
background: style.getPropertyValue('--color-background') background: style.getPropertyValue('--color-background')
}
}
const createBaseChartConfig = (bgColor: string) => ({
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: { display: true },
tooltip: { mode: 'index' as const, intersect: false }
},
elements: { point: { radius: 1 } },
scales: {
x: {
grid: { color: bgColor },
ticks: { color: bgColor },
display: false
},
y: {
type: 'linear' as const,
position: 'left' as const,
min: 0,
max: 10,
grid: { color: bgColor },
ticks: { color: bgColor },
border: { color: bgColor }
}
}
})
const initializeCharts = () => {
const colors = getChartColors()
const baseConfig = createBaseChartConfig(colors.background)
angleChart = new Chart(angleChartElement, {
type: 'line',
data: {
datasets: [
{
label: 'x',
borderColor: colors.primary,
backgroundColor: colors.primary,
borderWidth: 2,
data: $imu.x,
yAxisID: 'y'
},
{
label: 'y',
borderColor: colors.secondary,
backgroundColor: colors.secondary,
borderWidth: 2,
data: $imu.y,
yAxisID: 'y'
},
{
label: 'z',
borderColor: colors.accent,
backgroundColor: colors.accent,
borderWidth: 2,
data: $imu.z,
yAxisID: 'y'
}
]
},
options: {
...baseConfig,
scales: {
...baseConfig.scales,
y: {
...baseConfig.scales.y,
title: {
display: true,
text: 'Angle [°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
} }
}
})
tempChart = new Chart(tempChartElement, {
type: 'line',
data: {
datasets: [
{
label: 'Barometer temperature',
borderColor: colors.secondary,
backgroundColor: colors.secondary,
borderWidth: 2,
data: $imu.bmp_temp,
yAxisID: 'y'
}
]
},
options: {
...baseConfig,
scales: {
...baseConfig.scales,
y: {
...baseConfig.scales.y,
title: {
display: true,
text: 'Temperature [C°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
})
altitudeChart = new Chart(altitudeChartElement, {
type: 'line',
data: {
datasets: [
{
label: 'Altitude',
borderColor: colors.primary,
backgroundColor: colors.primary,
borderWidth: 2,
data: $imu.altitude,
yAxisID: 'y'
}
]
},
options: {
...baseConfig,
scales: {
...baseConfig.scales,
y: {
...baseConfig.scales.y,
title: {
display: true,
text: 'Altitude [M]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
})
}
const updateChartData = (chart: Chart, data: number[], label: string) => {
chart.data.labels = data
chart.data.datasets[0].data = data
chart.options.scales!.y!.min = Math.min(...data) - 1
chart.options.scales!.y!.max = Math.max(...data) + 1
chart.update('none')
}
const updateData = () => {
if ($features.imu) {
angleChart.data.labels = $imu.x
angleChart.data.datasets[0].data = $imu.x
angleChart.data.datasets[1].data = $imu.y
angleChart.data.datasets[2].data = $imu.z
const allValues = [...$imu.x, ...$imu.y, ...$imu.z]
angleChart.options.scales!.y!.min = Math.min(...allValues) - 1
angleChart.options.scales!.y!.max = Math.max(...allValues) + 1
angleChart.update('none')
} }
if ($features.bmp) { const createBaseChartConfig = (bgColor: string) => ({
updateChartData(tempChart, $imu.bmp_temp, 'Temperature') maintainAspectRatio: false,
updateChartData(altitudeChart, $imu.altitude, 'Altitude') responsive: true,
} plugins: {
} legend: { display: true },
tooltip: { mode: 'index' as const, intersect: false }
onMount(() => { },
socket.on(MessageTopic.imu, (data: IMU) => { elements: { point: { radius: 1 } },
console.log(data) scales: {
imu.addData(data) x: {
grid: { color: bgColor },
ticks: { color: bgColor },
display: false
},
y: {
type: 'linear' as const,
position: 'left' as const,
min: 0,
max: 10,
grid: { color: bgColor },
ticks: { color: bgColor },
border: { color: bgColor }
}
}
}) })
initializeCharts() const initializeCharts = () => {
intervalId = setInterval(updateData, 200) const colors = getChartColors()
}) const baseConfig = createBaseChartConfig(colors.background)
onDestroy(() => { angleChart = new Chart(angleChartElement, {
socket.off(MessageTopic.imu) type: 'line',
clearInterval(intervalId) data: {
}) datasets: [
{
label: 'x',
borderColor: colors.primary,
backgroundColor: colors.primary,
borderWidth: 2,
data: $imu.x,
yAxisID: 'y'
},
{
label: 'y',
borderColor: colors.secondary,
backgroundColor: colors.secondary,
borderWidth: 2,
data: $imu.y,
yAxisID: 'y'
},
{
label: 'z',
borderColor: colors.accent,
backgroundColor: colors.accent,
borderWidth: 2,
data: $imu.z,
yAxisID: 'y'
}
]
},
options: {
...baseConfig,
scales: {
...baseConfig.scales,
y: {
...baseConfig.scales.y,
title: {
display: true,
text: 'Angle [°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
})
tempChart = new Chart(tempChartElement, {
type: 'line',
data: {
datasets: [
{
label: 'Barometer temperature',
borderColor: colors.secondary,
backgroundColor: colors.secondary,
borderWidth: 2,
data: $imu.bmp_temp,
yAxisID: 'y'
}
]
},
options: {
...baseConfig,
scales: {
...baseConfig.scales,
y: {
...baseConfig.scales.y,
title: {
display: true,
text: 'Temperature [C°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
})
altitudeChart = new Chart(altitudeChartElement, {
type: 'line',
data: {
datasets: [
{
label: 'Altitude',
borderColor: colors.primary,
backgroundColor: colors.primary,
borderWidth: 2,
data: $imu.altitude,
yAxisID: 'y'
}
]
},
options: {
...baseConfig,
scales: {
...baseConfig.scales,
y: {
...baseConfig.scales.y,
title: {
display: true,
text: 'Altitude [M]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
})
}
const updateChartData = (chart: Chart, data: number[], label: string) => {
chart.data.labels = data
chart.data.datasets[0].data = data
chart.options.scales!.y!.min = Math.min(...data) - 1
chart.options.scales!.y!.max = Math.max(...data) + 1
chart.update('none')
}
const updateData = () => {
if ($features.imu) {
angleChart.data.labels = $imu.x
angleChart.data.datasets[0].data = $imu.x
angleChart.data.datasets[1].data = $imu.y
angleChart.data.datasets[2].data = $imu.z
const allValues = [...$imu.x, ...$imu.y, ...$imu.z]
angleChart.options.scales!.y!.min = Math.min(...allValues) - 1
angleChart.options.scales!.y!.max = Math.max(...allValues) + 1
angleChart.update('none')
}
if ($features.bmp) {
updateChartData(tempChart, $imu.bmp_temp, 'Temperature')
updateChartData(altitudeChart, $imu.altitude, 'Altitude')
}
}
onMount(() => {
socket.on(MessageTopic.imu, (data: IMU) => {
console.log(data)
imu.addData(data)
})
initializeCharts()
intervalId = setInterval(updateData, 200)
})
onDestroy(() => {
socket.off(MessageTopic.imu)
clearInterval(intervalId)
})
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<Rotate3d class="flex-shrink-0 mr-2 h-6 w-6 self-end" /> <Rotate3d class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span>IMU</span> <span>IMU</span>
{/snippet} {/snippet}
{#if $features.imu} {#if $features.imu}
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
<div <div
class="flex w-full flex-col space-y-1 h-60" class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }}> transition:slide|local={{ duration: 300, easing: cubicOut }}
<canvas bind:this={angleChartElement}></canvas> >
</div> <canvas bind:this={angleChartElement}></canvas>
</div> </div>
{/if} </div>
{/if}
{#if $features.bmp} {#if $features.bmp}
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
<div <div
class="flex w-full flex-col space-y-1 h-60" class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }}> transition:slide|local={{ duration: 300, easing: cubicOut }}
<canvas bind:this={tempChartElement}></canvas> >
</div> <canvas bind:this={tempChartElement}></canvas>
</div> </div>
<div class="w-full overflow-x-auto"> </div>
<div <div class="w-full overflow-x-auto">
class="flex w-full flex-col space-y-1 h-60" <div
transition:slide|local={{ duration: 300, easing: cubicOut }}> class="flex w-full flex-col space-y-1 h-60"
<canvas bind:this={altitudeChartElement}></canvas> transition:slide|local={{ duration: 300, easing: cubicOut }}
</div> >
</div> <canvas bind:this={altitudeChartElement}></canvas>
{/if} </div>
</div>
{/if}
</SettingsCard> </SettingsCard>

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