2 Commits

Author SHA1 Message Date
Rune Harlyk 04fecf33f8 🪴 Adds webserial lidar support 2024-08-04 13:53:53 +02:00
Rune Harlyk acf4efde4c 🗺️ Adds lidar visualization 2024-08-04 00:02:17 +02:00
495 changed files with 64101 additions and 67695 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
run: pio run run: pio run
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: build-artifacts name: build-artifacts
path: esp32/build/firmware path: esp32/build/firmware
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
version: 9 version: 8
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
-4
View File
@@ -2,7 +2,3 @@
.vscode/c_cpp_properties.json .vscode/c_cpp_properties.json
.vscode/launch.json .vscode/launch.json
.vscode/ipch .vscode/ipch
__pycache__/
*.py[cod]
*$py.class
+6 -11
View File
@@ -1,13 +1,8 @@
{ {
"useTabs": false, "useTabs": true,
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "trailingComma": "none",
"trailingComma": "none", "printWidth": 100,
"arrowParens": "avoid", "plugins": ["prettier-plugin-svelte"],
"experimentalTernaries": true, "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
"printWidth": 100,
"semi": false,
"svelteBracketNewLine": false,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
} }
-8
View File
@@ -1,8 +0,0 @@
declare module "app-env" {
interface ENV {
VITE_USE_HOST_NAME: boolean;
}
const appEnv: ENV;
export default appEnv;
}
+59 -63
View File
@@ -1,65 +1,61 @@
{ {
"name": "spot_micro_controller", "name": "spot_micro_controller",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
"build": "vite build", "build": "vite build",
"build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build", "preview": "vite preview",
"preview": "vite preview", "test": "npm run test:integration && npm run test:unit",
"test": "pnpm run test:integration && pnpm run test:unit", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .",
"lint": "prettier --check . && eslint .", "format": "prettier --write .",
"format": "prettier --write .", "test:integration": "playwright test",
"test:integration": "playwright test", "test:unit": "vitest"
"test:unit": "vitest" },
}, "devDependencies": {
"devDependencies": { "@iconify-json/mdi": "^1.1.64",
"@iconify-json/mdi": "^1.1.64", "@iconify-json/tabler": "^1.1.109",
"@iconify-json/tabler": "^1.1.109", "@playwright/test": "^1.28.1",
"@playwright/test": "^1.49.1", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.1", "@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.27", "@sveltejs/kit": "^2.5.5",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@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.35.1",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"prettier": "^3.1.1", "postcss": "^8.4.38",
"prettier-plugin-svelte": "^3.2.6", "prettier": "^3.1.1",
"svelte": "^5.0.0", "prettier-plugin-svelte": "^3.1.2",
"svelte-check": "^4.0.0", "svelte": "^4.2.7",
"svelte-focus-trap": "^1.2.0", "svelte-check": "^3.6.0",
"tailwindcss": "^4.0.12", "svelte-focus-trap": "^1.2.0",
"tslib": "^2.6.1", "tailwindcss": "^3.4.3",
"typescript": "^5.5.0", "tslib": "^2.6.1",
"unplugin-icons": "^0.18.5", "typescript": "^5.1.6",
"vite": "^6.2.1", "unplugin-icons": "^0.18.5",
"vitest": "^1.2.0" "vite": "^5.0.3",
}, "vitest": "^1.2.0"
"type": "module", },
"dependencies": { "type": "module",
"@niku/vite-env-caster": "^1.0.2", "dependencies": {
"@sveltejs/adapter-auto": "^4.0.0", "chart.js": "^4.4.2",
"@tailwindcss/vite": "^4.0.12", "compare-versions": "^6.1.0",
"chart.js": "^4.4.2", "daisyui": "^4.10.2",
"compare-versions": "^6.1.0", "jwt-decode": "^4.0.0",
"cross-env": "^7.0.3", "nipplejs": "^0.10.1",
"daisyui": "^5.0.0", "svelte-dnd-list": "^0.1.8",
"jwt-decode": "^4.0.0", "svelte-modals": "^1.3.0",
"nipplejs": "^0.10.1", "three": "^0.162.0",
"svelte-dnd-list": "^0.1.8", "urdf-loader": "^0.12.1",
"svelte-modals": "^2.0.0", "uzip": "^0.20201231.0",
"three": "^0.162.0", "xacro-parser": "^0.3.9"
"urdf-loader": "^0.12.1", }
"uzip": "^0.20201231.0",
"xacro-parser": "^0.3.9"
},
"packageManager": "pnpm@9.3.0"
} }
+1122 -1301
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
export default {
plugins: [tailwindcss(), autoprefixer()]
};
+10 -32
View File
@@ -1,40 +1,18 @@
@import 'tailwindcss'; @tailwind base;
@plugin "daisyui"; @tailwind components;
@tailwind utilities;
@plugin "daisyui" { #nipple_0_0, #nipple_1_1 {
themes: z-index: 10!important;
light --default,
dark --prefersdark;
}
@plugin "daisyui/theme" {
name: 'light';
default: true;
--color-primary: #00bfff;
--color-secondary: #3c00ff;
--base-content: white;
}
@plugin "daisyui/theme" {
name: 'dark';
prefersdark: true;
--color-primary: #00bfff;
--color-secondary: #3c00ff;
--base-content: oklch(0.3 0.012 256);
}
#nipple_0_0,
#nipple_1_1 {
z-index: 10 !important;
} }
#three-gui-panel { #three-gui-panel {
top: 64px; top: 64px;
right: 0px; right: 0px;
} }
@media (max-width: 1023px) { @media (max-width: 1023px) {
#three-gui-panel { #three-gui-panel {
top: 48px; top: 48px;
} }
} }
+1 -3
View File
@@ -3,9 +3,7 @@
<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 name="viewport" content="width=device-width, initial-scale=1" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
@@ -1,4 +1,4 @@
export const daisyColor = (name: string, opacity: number = 100) => { export function daisyColor(name: string, opacity: number = 100) {
const color = getComputedStyle(document.documentElement).getPropertyValue(name); const color = getComputedStyle(document.documentElement).getPropertyValue(name);
return `oklch(${color} / ${opacity}%)`; return `oklch(${color} / ${opacity}%)`;
}; }
+53 -60
View File
@@ -1,80 +1,73 @@
import { user } from '$lib/stores/user';
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';
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');
} }
} }
async function sendRequest<TResponse>( async function sendRequest<TResponse>(
endpoint: string, endpoint: string,
method: string, method: string,
data?: unknown, data?: unknown,
params?: RequestInit params?: RequestInit
): Promise<Result<TResponse, Error>> { ): Promise<Result<TResponse, Error>> {
endpoint = resolveUrl(endpoint); const user_token = get(user).bearer_token;
const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined; const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined;
const request = { const request = {
...params, ...params,
method, method,
body, body,
headers: { headers: {
...params?.headers, ...params?.headers,
Authorization: 'Basic', Authorization: user_token ? 'Bearer ' + user_token : 'Basic',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}; };
let response; let response;
try { try {
response = await fetch(endpoint, request); response = await fetch(endpoint, request);
} catch (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 {
if (url.startsWith('http') || !get(location)) return url;
const protocol = window.location.protocol;
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}`);
} }
} }
@@ -0,0 +1,27 @@
<script lang="ts">
import Battery0 from '~icons/tabler/battery';
import Battery25 from '~icons/tabler/battery-1';
import Battery50 from '~icons/tabler/battery-2';
import Battery75 from '~icons/tabler/battery-3';
import Battery100 from '~icons/tabler/battery-4';
import BatteryCharging from '~icons/tabler/battery-charging-2';
export let current = 0;
export let voltage = 0;
</script>
<div class="tooltip tooltip-left z-10" data-tip="{voltage}V {Math.floor(current*10)/10} mA">
{#if voltage == 0}
<BatteryCharging class="{$$props.class || ''} -rotate-90 animate-pulse" />
{:else if voltage > 8.2}
<Battery100 class="{$$props.class || ''} -rotate-90" />
{:else if voltage > 8}
<Battery75 class="{$$props.class || ''} -rotate-90" />
{:else if voltage > 7.8}
<Battery50 class="{$$props.class || ''} -rotate-90" />
{:else if voltage > 7.6}
<Battery25 class="{$$props.class || ''} -rotate-90" />
{:else}
<Battery0 class="{$$props.class || ''} text-error -rotate-90 animate-pulse" />
{/if}
</div>
+37 -38
View File
@@ -1,44 +1,43 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { Down } from './icons'; import Down from '~icons/tabler/chevron-down';
import { createEventDispatcher } from 'svelte';
function openCollapsible() { const dispatch = createEventDispatcher();
open = !open;
if (open) {
opened();
} else {
closed();
}
}
let { icon, title, children, open, opened, closed, class: klass } = $props(); function openCollapsible() {
open = !open;
if (open) {
dispatch('opened');
} else {
dispatch('closed');
}
}
export let open = false;
</script> </script>
<div class="{klass} relative grid w-full max-w-2xl self-center overflow-hidden"> <div class="{$$props.class || ''} relative grid w-full max-w-2xl self-center overflow-hidden">
<div <div class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium">
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium" <span class="inline-flex items-baseline">
> <slot name="icon" />
<span class="inline-flex items-baseline"> <slot name="title" />
{@render icon?.()} </span>
{@render title?.()} <button class="btn btn-circle btn-ghost btn-sm" on:click={() => openCollapsible()}>
</span> <Down
<button class="btn btn-circle btn-ghost btn-sm" onclick={() => openCollapsible()}> class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open
<Down ? 'rotate-180'
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {( : ''}"
open />
) ? </button>
'rotate-180' </div>
: ''}" {#if open}
/> <div
</button> class="flex flex-col gap-2 p-4 pt-0"
</div> transition:slide|local={{ duration: 300, easing: cubicOut }}
{#if open} >
<div <slot />
class="flex flex-col gap-2 p-4 pt-0" </div>
transition:slide|local={{ duration: 300, easing: cubicOut }} {/if}
>
{@render children?.()}
</div>
{/if}
</div> </div>
+44 -53
View File
@@ -1,61 +1,52 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap'; import { closeModal } from 'svelte-modals';
import { fly } from 'svelte/transition'; import { focusTrap } from 'svelte-focus-trap';
import { Cancel, Check } from '$lib/components/icons'; import { fly } from 'svelte/transition';
import { modals, exitBeforeEnter } from 'svelte-modals'; import Cancel from '~icons/tabler/x';
import Check from '~icons/tabler/check';
// provided by <Modals /> // provided by <Modals />
export let isOpen: boolean;
interface Props { export let title: string;
isOpen: boolean; export let message: string;
title: string; export let onConfirm: any;
message: string; export let labels = {
onConfirm: any; cancel: { label: 'Cancel', icon: Cancel },
labels?: any; confirm: { label: 'OK', icon: Check }
} };
let {
isOpen,
title,
message,
onConfirm,
labels = {
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'OK', icon: Check }
}
}: Props = $props();
</script> </script>
{#if isOpen} {#if isOpen}
{@const SvelteComponent = labels?.confirm.icon} <div
<div role="dialog"
role="dialog" class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" transition:fly={{ y: 50 }}
transition:fly={{ y: 50 }} on:introstart
use:exitBeforeEnter on:outroend
use:focusTrap use:focusTrap
> >
<div <div
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg" class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
> >
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2> <h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<div class="divider my-2"></div> <div class="divider my-2" />
<p class="text-base-content mb-1 text-start">{message}</p> <p class="text-base-content mb-1 text-start">{message}</p>
<div class="divider my-2"></div> <div class="divider my-2" />
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button class="btn btn-primary inline-flex items-center" on:click={closeModal}
class="btn btn-primary inline-flex items-center" ><svelte:component this={labels.cancel.icon} class="mr-2 h-5 w-5" /><span
onclick={() => modals.close()} >{labels?.cancel.label}</span
> ></button
<labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span> >
</button> <button
<button class="btn btn-warning text-warning-content inline-flex items-center"
class="btn btn-warning text-warning-content inline-flex items-center" on:click={onConfirm}
onclick={onConfirm} ><svelte:component this={labels?.confirm.icon} class="mr-2 h-5 w-5" /><span
> >{labels?.confirm.label}</span
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span> ></button
</button> >
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
@@ -1,101 +1,92 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap'; import { closeAllModals, onBeforeClose } from 'svelte-modals';
import { fly } from 'svelte/transition'; import { focusTrap } from 'svelte-focus-trap';
import { telemetry } from '$lib/stores/telemetry'; import { fly } from 'svelte/transition';
import { Cancel } from './icons'; import { telemetry } from '$lib/stores/telemetry';
import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals'; import Cancel from '~icons/tabler/x';
// provided by <Modals /> // provided by <Modals />
interface Props { export let isOpen: boolean;
isOpen: boolean;
}
let { isOpen }: Props = $props(); let updating = true;
let updating = $state(true); let progress = 0;
$: if ($telemetry.download_ota.status == 'progress') {
progress = $telemetry.download_ota.progress;
}
let progress = $state(0); $: if ($telemetry.download_ota.status == 'error') {
$effect(() => { updating = false;
if ($telemetry.download_ota.status == 'progress') { }
progress = $telemetry.download_ota.progress;
}
});
$effect(() => { let message = 'Preparing ...';
if ($telemetry.download_ota.status == 'error') { let timerId: number;
updating = false;
}
});
let message = $state('Preparing ...'); $: if ($telemetry.download_ota.status == 'progress') {
message = 'Downloading ...';
} else if ($telemetry.download_ota.status == 'error') {
message = $telemetry.download_ota.error;
} else if ($telemetry.download_ota.status == 'finished') {
message = 'Restarting ...';
progress = 0;
// Reload page after 5 sec
timerId = setTimeout(() => {
closeAllModals();
location.reload();
}, 5000);
}
$effect(() => { onBeforeClose(() => {
if ($telemetry.download_ota.status == 'progress') { if (updating) {
message = 'Downloading ...'; // prevents modal from closing
} else if ($telemetry.download_ota.status == 'error') { return false;
message = $telemetry.download_ota.error; } else {
} else if ($telemetry.download_ota.status == 'finished') { $telemetry.download_ota.status = 'idle';
message = 'Restarting ...'; $telemetry.download_ota.error = '';
progress = 0; $telemetry.download_ota.progress = 0;
// Reload page after 5 sec return true;
setTimeout(() => { }
modals.closeAll(); });
location.reload();
}, 5000);
}
});
onBeforeClose(() => {
if (updating) {
// prevents modal from closing
return false;
} else {
$telemetry.download_ota.status = 'idle';
$telemetry.download_ota.error = '';
$telemetry.download_ota.progress = 0;
return true;
}
});
</script> </script>
{#if isOpen} {#if isOpen}
<div <div
role="dialog" role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }} transition:fly={{ y: 50 }}
use:exitBeforeEnter on:introstart
use:focusTrap on:outroend
> use:focusTrap
<div >
class="bg-base-100 rounded-box pointer-events-auto flex max-h-full min-w-fit max-w-md flex-col justify-between p-4 shadow-lg" <div
> class="bg-base-100 rounded-box pointer-events-auto flex max-h-full min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
<h2 class="text-base-content text-start text-2xl font-bold">Updating Firmware</h2> >
<div class="divider my-2"></div> <h2 class="text-base-content text-start text-2xl font-bold">Updating Firmware</h2>
<div class="overflow-y-auto"> <div class="divider my-2" />
<div class="bg-base-100 flex flex-col items-center justify-center p-6"> <div class="overflow-y-auto">
{#if $telemetry.download_ota.status == 'progress'} <div class="bg-base-100 flex flex-col items-center justify-center p-6">
<progress class="progress progress-primary w-56" value={progress} max="100" {#if $telemetry.download_ota.status == 'progress'}
></progress> <progress class="progress progress-primary w-56" value={progress} max="100" />
{:else} {:else}
<progress class="progress progress-primary w-56"></progress> <progress class="progress progress-primary w-56" />
{/if} {/if}
<p class="mt-8 text-2xl">{message}</p> <p class="mt-8 text-2xl">{message}</p>
</div> </div>
</div> </div>
<div class="divider my-2"></div> <div class="divider my-2" />
<div class="flex flex-wrap justify-end gap-2"> <div class="flex flex-wrap justify-end gap-2">
<div class="grow"></div> <div class="flex-grow" />
<button <button
class="btn btn-warning text-warning-content inline-flex flex-none items-center" class="btn btn-warning text-warning-content inline-flex flex-none items-center"
disabled={updating} disabled={updating}
onclick={() => { on:click={() => {
modals.closeAll(); closeAllModals();
location.reload(); location.reload();
}} }}
> >
<Cancel class="mr-2 h-5 w-5" /><span>Close</span></button <Cancel class="mr-2 h-5 w-5" /><span>Close</span></button
> >
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
+35 -44
View File
@@ -1,51 +1,42 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap'; import { closeModal } from 'svelte-modals';
import { fly } from 'svelte/transition'; import { focusTrap } from 'svelte-focus-trap';
import { Check } from './icons'; import { fly } from 'svelte/transition';
import { exitBeforeEnter } from 'svelte-modals'; import Check from '~icons/tabler/check';
// provided by <Modals /> // provided by <Modals />
export let isOpen: boolean;
interface Props { export let title: string;
isOpen: boolean; export let message: string;
title: string; export let onDismiss: any;
message: string; export let dismiss = { label: 'Dismiss', icon: Check };
onDismiss: any;
dismiss?: any;
}
let {
isOpen,
title,
message,
onDismiss,
dismiss = { label: 'Dismiss', icon: Check }
}: Props = $props();
</script> </script>
{#if isOpen} {#if isOpen}
<div <div
role="dialog" role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }} transition:fly={{ y: 50 }}
use:exitBeforeEnter on:introstart
use:focusTrap on:outroend
> use:focusTrap
<div >
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg" <div
> class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2> >
<div class="divider my-2"></div> <h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<p class="text-base-content mb-1 text-start">{message}</p> <div class="divider my-2" />
<div class="divider my-2"></div> <p class="text-base-content mb-1 text-start">{message}</p>
<div class="flex justify-end gap-2"> <div class="divider my-2" />
<button <div class="flex justify-end gap-2">
class="btn btn-warning text-warning-content inline-flex items-center" <button
onclick={onDismiss} class="btn btn-warning text-warning-content inline-flex items-center"
> on:click={onDismiss}
<dismiss.icon class="mr-2 h-5 w-5" /><span>{dismiss.label}</span> ><svelte:component this={dismiss.icon} class="mr-2 h-5 w-5" /><span>{dismiss.label}</span
</button> ></button
</div> >
</div> </div>
</div> </div>
</div>
{/if} {/if}
@@ -0,0 +1,60 @@
<script lang="ts">
let show = false;
$: type = show ? 'text' : 'password';
export let value = '';
export let id = '';
function handleInput(e: any) {
value = e.target.value;
}
</script>
<div class="relative">
<input {type} class="input input-bordered w-full" {value} on:input={handleInput} {id} />
<div class="absolute inset-y-0 right-0 flex items-center pr-1">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}"
on:click={() => (show = false)}
width="40"
height="40"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
role="button"
tabindex="0"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path
d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"
/>
<path d="M3 3l18 18" />
</svg>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}"
on:click={() => (show = true)}
width="40"
height="40"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
role="button"
tabindex="0"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</svg>
</div>
</div>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { onMount } from "svelte";
import { lidar, type LidarPoint } from '$lib/stores/lidar'
function getIntersection(angle:number, size:number):number {
const sinAngle = Math.sin(angle);
const cosAngle = Math.cos(angle);
let x, y;
if (Math.abs(cosAngle) > Math.abs(sinAngle)) {
x = size * Math.sign(cosAngle);
y = x * sinAngle / cosAngle;
} else {
y = size * Math.sign(sinAngle);
x = y * cosAngle / sinAngle;
}
return Math.sqrt(x**2 + y**2);
}
let canvas:HTMLCanvasElement
let ctx
const DEG2RAD = 0.017453292519943;
onMount(() => {
ctx = canvas.getContext("2d")
resize()
lidar.subscribe(lidar => {
draw(lidar.points)
})
})
const draw = (points:LidarPoint[]) => {
if(!points) return
const centerX = canvas.width / 2
const centerY = canvas.height / 2
const scale = 0.01//Math.max(centerX, centerY) / Math.max(...points.map((point) => point.distance))
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < points.length; i++){
const angle = points[i].angle
const distance = points[i].distance
const quality = points[i].quality
const endX = centerX + (distance * scale) * Math.cos(angle * DEG2RAD);
const endY = centerY - (distance * scale) * Math.sin(angle * DEG2RAD);
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.lineTo(endX, endY);
ctx.strokeStyle = "grey"
ctx.stroke();
ctx.beginPath();
ctx.arc(endX, endY, 3, 0, Math.PI * 2);
ctx.fillStyle = "#1bfc06"
ctx.fill();
}
}
const resize = () => {
const parentElement = canvas.parentElement;
if (parentElement) {
canvas.width = parentElement.clientWidth
canvas.height = parentElement.clientHeight
}
}
</script>
<svelte:window on:resize={resize}></svelte:window>
<canvas bind:this={canvas} class="w-full h-full"></canvas>
@@ -0,0 +1,40 @@
<script lang="ts">
import WiFi from '~icons/tabler/wifi';
import WiFi0 from '~icons/tabler/wifi-0';
import WiFi1 from '~icons/tabler/wifi-1';
import WiFi2 from '~icons/tabler/wifi-2';
import WifiOff from '~icons/tabler/wifi-off';
export let showDBm = true;
export let rssi_dbm = 0;
</script>
<div class="indicator">
<div class="tooltip tooltip-left" data-tip={rssi_dbm + " dBm"}>
{#if showDBm}
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
{rssi_dbm} dBm
</span>
{/if}
{#if rssi_dbm >= -55}
<WiFi class={$$props.class || ''} />
{:else if rssi_dbm >= -75}
<div class="{$$props.class || ''} relative">
<WiFi class="absolute inset-0 h-full w-full opacity-30" />
<WiFi2 class="absolute inset-0 h-full w-full" />
</div>
{:else if rssi_dbm >= -85}
<div class="{$$props.class || ''} relative">
<WiFi class="absolute inset-0 h-full w-full opacity-30" />
<WiFi1 class="absolute inset-0 h-full w-full" />
</div>
{:else if rssi_dbm === 0}
<WifiOff class={$$props.class || ''} />
{:else}
<div class="{$$props.class || ''} relative">
<WiFi class="absolute inset-0 h-full w-full opacity-30" />
<WiFi0 class="absolute inset-0 h-full w-full" />
</div>
{/if}
</div>
</div>
+10 -23
View File
@@ -1,22 +1,9 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { Down } from './icons'; import Down from '~icons/tabler/chevron-down';
interface Props { export let open = true;
open?: boolean; export let collapsible = true;
collapsible?: boolean;
icon?: import('svelte').Snippet;
title?: import('svelte').Snippet;
children?: import('svelte').Snippet;
}
let {
open = $bindable(true),
collapsible = true,
icon,
title,
children
}: Props = $props();
</script> </script>
{#if collapsible} {#if collapsible}
@@ -27,12 +14,12 @@
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium" class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
> >
<span class="inline-flex items-baseline"> <span class="inline-flex items-baseline">
{@render icon?.()} <slot name="icon" />
{@render title?.()} <slot name="title" />
</span> </span>
<button <button
class="btn btn-circle btn-ghost btn-sm" class="btn btn-circle btn-ghost btn-sm"
onclick={() => { on:click={() => {
open = !open; open = !open;
}} }}
> >
@@ -48,7 +35,7 @@
class="flex flex-col gap-2 p-4 pt-0" class="flex flex-col gap-2 p-4 pt-0"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}
> >
{@render children?.()} <slot />
</div> </div>
{/if} {/if}
</div> </div>
@@ -58,12 +45,12 @@
> >
<div class="min-h-16 w-full p-4 text-xl font-medium"> <div class="min-h-16 w-full p-4 text-xl font-medium">
<span class="inline-flex items-baseline"> <span class="inline-flex items-baseline">
{@render icon?.()} <slot name="icon" />
{@render title?.()} <slot name="title" />
</span> </span>
</div> </div>
<div class="flex flex-col gap-2 p-4 pt-0"> <div class="flex flex-col gap-2 p-4 pt-0">
{@render children?.()} <slot />
</div> </div>
</div> </div>
{/if} {/if}
+1 -2
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Loader } from "./icons"; import Loader from '~icons/tabler/loader-2';
</script> </script>
<div class="flex h-full w-full flex-col items-center justify-center p-6"> <div class="flex h-full w-full flex-col items-center justify-center p-6">
+14 -10
View File
@@ -1,17 +1,21 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { user } from '$lib/stores/user';
import { location } from '$lib/stores'; import { onDestroy } from 'svelte';
let source = $state(`${$location}/api/camera/stream`); const ws_token = `?access_token=${$user.bearer_token}`
onDestroy(() => (source = '#')); let source = "/api/camera/stream"+ ws_token;
onDestroy(() => {
source = '#';
});
</script> </script>
<div class="w-full h-full"> <div class="w-full h-full">
<img <img
src={source} src={source}
class="absolute object-cover blur-3xl w-full h-full -z-10" class="absolute object-cover blur-3xl w-full h-full -z-10"
alt="Live stream is down" alt="Live stream is down"
/> />
<img src={source} class="object-contain w-full h-full" alt="Live stream is down" /> <img src={source} class="object-contain w-full h-full" alt="Live stream is down" />
</div> </div>
+10 -8
View File
@@ -2,33 +2,35 @@
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications';
import { error, info, success, warning } from './icons'; import error from '~icons/tabler/circle-x';
import success from '~icons/tabler/circle-check';
import warning from '~icons/tabler/alert-triangle';
import info from '~icons/tabler/info-circle';
export let theme = {
/** @type {{theme?: any, icon?: any}} */
let { theme = {
error: 'alert-error', error: 'alert-error',
success: 'alert-success', success: 'alert-success',
warning: 'alert-warning', warning: 'alert-warning',
info: 'alert-info' info: 'alert-info'
}, icon = { };
export let icon = {
error: error, error: error,
success: success, success: success,
warning: warning, warning: warning,
info: info info: info
} } = $props(); };
</script> </script>
<div class="toast toast-end mr-4"> <div class="toast toast-end mr-4">
{#each $notifications as notification (notification.id)} {#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]}
<div <div
animate:flip={{ duration: 400 }} animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}" class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }} in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }} out:fly={{ x: 100, duration: 400 }}
> >
<SvelteComponent class="h-6 w-6 shrink-0" /> <svelte:component this={icon[notification.type]} class="h-6 w-6 flex-shrink-0" />
<span>{notification.message}</span> <span>{notification.message}</span>
</div> </div>
{/each} {/each}
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import {Hamburger} from '../icons' import MdiHamburgerMenu from '~icons/mdi/hamburger-menu';
</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"/> <svelte:component this={MdiHamburgerMenu} class="h-8 w-8"/>
</a> </a>
</div> </div>
</div> </div>
@@ -0,0 +1,106 @@
<script lang="ts">
import { page } from '$app/stores';
import { openModal, closeAllModals } from 'svelte-modals';
import { user } from '$lib/stores/user';
import { notifications } from '$lib/components/toasts/notifications';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Firmware from '~icons/tabler/refresh-alert';
import Cancel from '~icons/tabler/x';
import CloudDown from '~icons/tabler/cloud-download';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import { compareVersions } from 'compare-versions';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { GithubRelease } from '$lib/models';
export let update = false;
let firmwareVersion: string;
let firmwareDownloadLink: string;
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}
const result = await api.get<GithubRelease>(`https://api.github.com/repos/${$page.data.github}/releases/latest`, {headers})
if (result.inner.message === "404" || result.inner.message == "Not Found") {
console.warn('Error: Could not find releases in the repository');
return
}
if (result.isErr()) {
console.error('Error:', result.inner);
return
}
const results = result.inner;
update = false;
firmwareVersion = '';
if (compareVersions(results.tag_name, $page.data.features.firmware_version) === 1) {
// iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin
if (
results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($page.data.features.firmware_built_target)
) {
update = true;
firmwareVersion = results.tag_name;
firmwareDownloadLink = results.assets[i].browser_download_url;
notifications.info('Firmware update available.', 5000);
}
}
}
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url });
if (result.isErr()){
console.error('Error:', result.inner);
return
}
}
onMount(async () => {
if ($page.data.features.download_firmware && (!$page.data.features.security || $user.admin)) {
await getGithubAPI();
const interval = setInterval(
async () => {
await getGithubAPI();
},
60 * 60 * 1000
); // once per hour
}
});
function confirmGithubUpdate(url: string) {
openModal(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url);
openModal(GithubUpdateDialog, {
onConfirm: () => closeAllModals()
});
}
});
}
</script>
{#if update}
<button
class="btn btn-square btn-ghost h-9 w-9"
on:click={() => confirmGithubUpdate(firmwareDownloadLink)}
>
<span
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
>{firmwareVersion}</span
>
<Firmware class="h-7 w-7" />
</button>
{/if}
+285 -319
View File
@@ -1,342 +1,308 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte';
import { import { BufferGeometry, Line, LineBasicMaterial, Mesh, MeshBasicMaterial, Object3D, SphereGeometry, Vector3, type NormalBufferAttributes, type Object3DEventMap } from 'three';
BufferGeometry, import uzip from 'uzip';
Line, import { ModesEnum, kinematicData, mode, model, outControllerData, servoAnglesOut, servoAngles, mpu, jointNames } from '$lib/stores';
LineBasicMaterial, import { footColor, isEmbeddedApp, throttler, toeWorldPositions } from '$lib/utilities';
Mesh, import { fileService } from '$lib/services';
MeshBasicMaterial, import SceneBuilder from '$lib/sceneBuilder';
Object3D, import { lerp, degToRad } from 'three/src/math/MathUtils';
SphereGeometry, import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
Vector3, import Kinematic, { type body_state_t } from '$lib/kinematic';
type NormalBufferAttributes, import {EightPhaseWalkState, FourPhaseWalkState, IdleState, RestState, StandState} from '$lib/gait'
type Object3DEventMap import { radToDeg } from 'three/src/math/MathUtils.js';
} from 'three' import type { URDFRobot } from 'urdf-loader';
import { import { get } from 'svelte/store';
ModesEnum,
kinematicData,
mode,
model,
outControllerData,
servoAnglesOut,
servoAngles,
mpu,
jointNames
} from '$lib/stores'
import { footColor, populateModelCache, throttler, toeWorldPositions } from '$lib/utilities'
import SceneBuilder from '$lib/sceneBuilder'
import { lerp, degToRad } from 'three/src/math/MathUtils'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import Kinematic, { type body_state_t } from '$lib/kinematic'
import {
BezierState,
CalibrationState,
EightPhaseWalkState,
FourPhaseWalkState,
IdleState,
RestState,
StandState
} from '$lib/gait'
import { radToDeg } from 'three/src/math/MathUtils.js'
import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store'
interface Props { export let sky = true
sky?: boolean export let orbit = false
orbit?: boolean export let panel = true
panel?: boolean export let debug = false
debug?: boolean export let ground = true
ground?: boolean
zoom?: number
}
let { let sceneManager = new SceneBuilder();
sky = true, let canvas: HTMLCanvasElement
orbit = false,
panel = true,
debug = false,
ground = true,
zoom = 8
}: Props = $props()
let sceneManager = $state(new SceneBuilder()) let currentModelAngles: number[] = new Array(12).fill(0);
let canvas: HTMLCanvasElement = $state() let modelTargetAngles: number[] = new Array(12).fill(0)
let gui_panel: GUI
let Throttler = new throttler()
let currentModelAngles: number[] = new Array(12).fill(0) let feet_trace = new Array(4).fill([]);
let modelTargetAngles: number[] = new Array(12).fill(0) let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
let gui_panel: GUI let target: Object3D<Object3DEventMap>;
let Throttler = new throttler()
let feet_trace = new Array(4).fill([]) let target_position = {x: 0, z: 0, yaw: 0}
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
let target: Object3D<Object3DEventMap>
let target_position = { x: 0, z: 0, yaw: 0 } let kinematic = new Kinematic()
let kinematic = new Kinematic() let planners = {
[ModesEnum.Idle]: new IdleState(),
let planners = { [ModesEnum.Rest]: new RestState(),
[ModesEnum.Deactivated]: new IdleState(), [ModesEnum.Stand]: new StandState(),
[ModesEnum.Idle]: new IdleState(), [ModesEnum.Crawl]: new EightPhaseWalkState(),
[ModesEnum.Calibration]: new CalibrationState(), [ModesEnum.Walk]: new FourPhaseWalkState()
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Crawl]: new EightPhaseWalkState(),
[ModesEnum.Walk]: new BezierState()
}
let lastTick = performance.now()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
let body_state = {
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.5,
zm: 0,
feet: planners[ModesEnum.Idle].default_feet_pos
}
let settings = {
'Internal kinematic': true,
'Robot transform controls': false,
'Auto orient robot': true,
'Trace feet': debug,
'Target position': false,
'Trace points': 30,
'Fix camera on robot': true,
'Smooth motion': true,
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.7,
zm: 0,
Background: 'black'
}
onMount(async () => {
await populateModelCache()
await createScene()
servoAngles.subscribe(updateAnglesFromStore)
if (panel) createPanel()
})
onDestroy(() => {
canvas.remove()
gui_panel?.destroy()
})
const updateAnglesFromStore = (angles: number[]) => {
if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return
modelTargetAngles = angles
}
const createPanel = () => {
gui_panel = new GUI({ width: 310 })
gui_panel.close()
gui_panel.domElement.id = 'three-gui-panel'
const general = gui_panel.addFolder('General')
general.add(settings, 'Internal kinematic')
general.add(settings, 'Robot transform controls')
general.add(settings, 'Auto orient robot')
const kinematic = gui_panel.addFolder('Kinematics')
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
const visibility = gui_panel.addFolder('Visualization')
visibility.add(settings, 'Trace feet')
visibility.add(settings, 'Trace points', 1, 1000, 1)
visibility.add(settings, 'Target position')
visibility.add(settings, 'Smooth motion')
visibility.addColor(settings, 'Background')
}
const updateKinematicPosition = () => {
kinematicData.set([
settings.omega,
settings.phi,
settings.psi,
settings.xm,
settings.ym,
settings.zm
])
}
const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
Throttler.throttle(() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))), 100)
}
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(Math.min(zoom, 8), 30, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop()
if (ground) sceneManager.addGroundPlane()
const geometry = new SphereGeometry(0.1, 32, 16)
const material = new MeshBasicMaterial({ color: 0xffff00 })
target = new Mesh(geometry, material)
sceneManager.scene.add(target)
if (debug) {
sceneManager.addDragControl(updateAngles)
} }
if (sky) sceneManager.addSky() let lastTick = performance.now()
for (let i = 0; i < 4; i++) { const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
const geometry = new BufferGeometry()
const material = new LineBasicMaterial({ color: footColor() })
const line = new Line(geometry, material)
trace_lines.push(geometry)
sceneManager.scene.add(line)
}
}
const renderTraceLines = (foot_positions: Vector3[]) => { let body_state = {
if (!settings['Trace feet']) { omega: 0,
if (!feet_trace.length) return phi: 0,
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1))) psi: 0,
feet_trace = new Array(4).fill([]) xm: 0,
return ym: 0.5,
zm: 0,
feet: planners[ModesEnum.Idle].default_feet_pos
} }
trace_lines.forEach((line, i) => { let settings = {
feet_trace[i].push(foot_positions[i]) 'Internal kinematic':false,
feet_trace[i] = feet_trace[i].slice(-settings['Trace points']) 'Robot transform controls':false,
line.setFromPoints(feet_trace[i]) 'Auto orient robot':true,
}) 'Trace feet':debug,
} 'Trace points': 30,
'Fix camera on robot': true,
const calculate_kinematics = () => { 'Smooth motion': true,
if (sceneManager.isDragging || !settings['Internal kinematic']) return 'omega': 0,
const position: body_state_t = { 'phi': 0,
omega: settings.omega, 'psi': 0,
phi: settings.phi, 'xm': 0,
psi: settings.psi, 'ym': 0.7,
xm: settings.xm, 'zm': 0,
ym: settings.ym, 'Background': "black"
zm: settings.zm,
feet: body_state.feet
} }
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i])) onMount(async () => {
modelTargetAngles = new_angles await cacheModelFiles()
} await createScene();
if (!isEmbeddedApp && panel) createPanel();
servoAngles.subscribe(updateAnglesFromStore)
});
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => { onDestroy(() => {
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return canvas.remove()
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y)) gui_panel?.destroy()
});
robot.position.z = smooth(robot.position.z, -settings.xm, 0.1) const updateAnglesFromStore = (angles: number[]) => {
robot.position.x = smooth(robot.position.x, -settings.zm, 0.1) if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return
robot.rotation.z = smooth(robot.rotation.z, degToRad(-settings.phi + $mpu.heading + 90), 0.1) modelTargetAngles = angles;
robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1)
robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1)
}
const update_camera = (robot: URDFRobot) => {
if (!settings['Fix camera on robot']) return
sceneManager.orbit.target = robot.position.clone()
}
const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end
}
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(outControllerData)
const data = {
stop: controlData[0],
lx: controlData[1],
ly: controlData[2],
rx: controlData[3],
ry: controlData[4],
h: controlData[5],
s: controlData[6],
s1: controlData[7]
}
body_state.ym = ((data.h + 127) * 0.75) / 100
let planner = planners[get(mode)]
const delta = performance.now() - lastTick
lastTick = performance.now()
body_state = planner.step(body_state, data, delta)
settings.omega = body_state.omega
settings.phi = body_state.phi
settings.psi = body_state.psi
settings.xm = body_state.xm
settings.ym = body_state.ym
settings.zm = body_state.zm
}
const update_robot_position = (robot: URDFRobot) => {
if (!settings['Robot transform controls']) return
settings.omega = radToDeg(robot.rotation.y)
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90
settings.psi = radToDeg(robot.rotation.x) + 90
settings.xm = robot.position.z * 100
settings.zm = -robot.position.x * 100
}
const updateTargetPosition = () => {
target.visible = settings['Target position']
target.position.x = smooth(target.position.x, target_position.x, 0.5)
target.position.z = smooth(target.position.z, target_position.z, 0.5)
}
const render = () => {
const robot = sceneManager.model
if (!robot) return
const toes = toeWorldPositions(robot)
renderTraceLines(toes)
update_camera(robot)
update_gait()
calculate_kinematics()
update_robot_position(robot)
sceneManager.transformControl.showX = settings['Robot transform controls']
sceneManager.transformControl.showY = settings['Robot transform controls']
sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i],
0.1
)
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
} }
orient_robot(robot, toes) const createPanel = () => {
updateTargetPosition() 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, 'Smooth motion')
visibility.addColor(settings, 'Background')
}
const updateKinematicPosition = () => {
kinematicData.set([
settings.omega,
settings.phi,
settings.psi,
settings.xm,
settings.ym,
settings.zm
])
}
const cacheModelFiles = async () => {
let data = await fetch('/stl.zip').then((data) => data.arrayBuffer());
var files = uzip.parse(data);
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const url = new URL(path, window.location.href);
fileService.saveFile(url.toString(), data);
}
};
const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI);
Throttler.throttle(() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))), 100)
};
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(8, 30, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 0.9 })
.addAmbientLight({ color: 0xffffff, intensity: 0.6 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop();
if (ground) sceneManager
.addGroundPlane()
.addGridHelper({ size: 30, divisions: 25 })
const geometry = new SphereGeometry(0.1, 32, 16 );
const material = new MeshBasicMaterial( { color: 0xffff00 } );
target = new Mesh(geometry, material);
if (debug) {
sceneManager.scene.add(target);
sceneManager.addDragControl(updateAngles)
}
if (sky) sceneManager.addSky()
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry();
const material = new LineBasicMaterial({ color: footColor() });
const line = new Line(geometry, material);
trace_lines.push(geometry);
sceneManager.scene.add(line);
}
};
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
feet_trace = new Array(4).fill([])
return
}
trace_lines.forEach((line, i) => {
feet_trace[i].push(foot_positions[i])
feet_trace[i] = feet_trace[i].slice(-settings['Trace points'])
line.setFromPoints(feet_trace[i]);
})
}
const calculate_kinematics = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const position:body_state_t = {
omega: settings.omega,
phi: settings.phi,
psi: settings.psi,
xm: settings.xm,
ym: settings.ym,
zm: settings.zm,
feet: body_state.feet
}
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]));
modelTargetAngles = new_angles;
}
const orient_robot = (robot: URDFRobot, toes:Vector3[]) => {
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y));
robot.position.z = smooth(robot.position.z, -settings.xm, 0.1);
robot.position.x = smooth(robot.position.x, -settings.zm, 0.1);
robot.rotation.z = smooth(robot.rotation.z, degToRad(-settings.phi + $mpu.heading + 90), 0.1);
robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1);
robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1);
}
const update_camera = (robot:URDFRobot) => {
if (!settings['Fix camera on robot']) return
sceneManager.orbit.target = robot.position.clone()
}
const smooth = (start:number, end:number, amount:number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end
}
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(outControllerData)
const data = {
stop: controlData[0],
lx: controlData[1],
ly: controlData[2],
rx: controlData[3],
ry: controlData[4],
h: controlData[5],
s: controlData[6],
};
body_state.ym = ((data.h + 127) * 0.75) / 100;
let planner = planners[get(mode)]
const delta = performance.now() - lastTick
lastTick = performance.now()
body_state = planner.step(body_state, data, delta);
settings.omega = body_state.omega
settings.phi = body_state.phi
settings.psi = body_state.psi
settings.xm = body_state.xm
settings.ym = body_state.ym
settings.zm = body_state.zm
}
const update_robot_position = (robot:URDFRobot) => {
if (!settings['Robot transform controls']) return
settings.omega = radToDeg(robot.rotation.y)
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading -90
settings.psi = radToDeg(robot.rotation.x) + 90
settings.xm = robot.position.z * 100
settings.zm = -robot.position.x * 100
}
const updateTargetPosition = () => {
target.position.x = smooth(target.position.x, target_position.x, 0.5)
target.position.z = smooth(target.position.z, target_position.z, 0.5)
}
const render = () => {
const robot = sceneManager.model;
if (!robot) return;
const toes = toeWorldPositions(robot)
renderTraceLines(toes)
update_camera(robot)
update_gait()
calculate_kinematics()
update_robot_position(robot)
sceneManager.transformControl.showX = settings['Robot transform controls']
sceneManager.transformControl.showY = settings['Robot transform controls']
sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth((robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI), modelTargetAngles[i], 0.1);
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]));
}
orient_robot(robot, toes)
updateTargetPosition();
};
</script> </script>
<svelte:window onresize={sceneManager.fillParent} /> <svelte:window on:resize={sceneManager.fillParent} />
<canvas bind:this={canvas}></canvas> <canvas bind:this={canvas}></canvas>
-92
View File
@@ -1,92 +0,0 @@
export { default as Connection } from '~icons/mdi/connection'
export { default as Users } from '~icons/mdi/users'
export { default as Settings } from '~icons/mdi/settings'
export { default as MdiController } from '~icons/mdi/controller'
export { default as Devices } from '~icons/mdi/devices'
export { default as Camera } from '~icons/mdi/camera-outline'
export { default as Rotate3d } from '~icons/mdi/rotate-3d'
export { default as MotorOutline } from '~icons/mdi/motor-outline'
export { default as Health } from '~icons/mdi/stethoscope'
export { default as Folder } from '~icons/mdi/folder-outline'
export { default as Update } from '~icons/mdi/reload'
export { default as Router } from '~icons/mdi/router'
export { default as AP } from '~icons/mdi/access-point'
export { default as Remote } from '~icons/mdi/network'
export { default as Copyright } from '~icons/mdi/copyright'
export { default as NTP } from '~icons/mdi/clock-check'
export { default as Metrics } from '~icons/mdi/report-bar'
export { default as MdiEyeOutline } from '~icons/mdi/eye-outline'
export { default as MdiEyeOffOutline } from '~icons/mdi/eye-off-outline'
export { default as Github } from '~icons/mdi/github'
export { default as Avatar } from '~icons/mdi/user-circle'
export { default as Logout } from '~icons/mdi/logout'
export { default as Record } from '~icons/mdi/radio-button-unchecked'
export { default as MdiFullscreen } from '~icons/mdi/fullscreen'
export { default as MdiFullscreenExit } from '~icons/mdi/fullscreen-exit'
export { default as WiFi } from '~icons/tabler/wifi'
export { default as WiFi0 } from '~icons/tabler/wifi-0'
export { default as WiFi1 } from '~icons/tabler/wifi-1'
export { default as WiFi2 } from '~icons/tabler/wifi-2'
export { default as WifiOff } from '~icons/tabler/wifi-off'
export { default as MdiWeatherSunny } from '~icons/mdi/weather-sunny'
export { default as MdiMoonAndStars } from '~icons/mdi/moon-and-stars'
export { default as Hamburger } from '~icons/mdi/hamburger-menu'
export { default as FileIcon } from '~icons/mdi/file'
export { default as FolderIcon } from '~icons/mdi/folder-outline'
export { default as FolderOpenOutline } from '~icons/mdi/folder-open-outline'
export { default as Down } from '~icons/tabler/chevron-down'
export { default as Cancel } from '~icons/tabler/x'
export { default as Check } from '~icons/tabler/check'
export { default as Login } from '~icons/tabler/login'
export { default as Loader } from '~icons/tabler/loader-2'
export { default as error } from '~icons/tabler/circle-x'
export { default as success } from '~icons/tabler/circle-check'
export { default as warning } from '~icons/tabler/alert-triangle'
export { default as info } from '~icons/tabler/info-circle'
export { default as Power } from '~icons/tabler/power'
export { default as MAC } from '~icons/tabler/dna-2'
export { default as Home } from '~icons/tabler/home'
export { default as SSID } from '~icons/tabler/router'
export { default as DNS } from '~icons/tabler/address-book'
export { default as Gateway } from '~icons/tabler/torii'
export { default as Subnet } from '~icons/tabler/grid-dots'
export { default as Channel } from '~icons/tabler/antenna'
export { default as Scan } from '~icons/tabler/radar-2'
export { default as Add } from '~icons/tabler/circle-plus'
export { default as Edit } from '~icons/tabler/pencil'
export { default as Delete } from '~icons/tabler/trash'
export { default as Network } from '~icons/tabler/router'
export { default as Reload } from '~icons/tabler/reload'
export { default as Firmware } from '~icons/tabler/refresh-alert'
export { default as CloudDown } from '~icons/tabler/cloud-download'
export { default as Server } from '~icons/tabler/server'
export { default as Clock } from '~icons/tabler/clock'
export { default as UTC } from '~icons/tabler/clock-pin'
export { default as Stopwatch } from '~icons/tabler/24-hours'
export { default as CPU } from '~icons/tabler/cpu'
export { default as CPP } from '~icons/tabler/binary'
export { default as Sleep } from '~icons/tabler/zzz'
export { default as FactoryReset } from '~icons/tabler/refresh-dot'
export { default as Speed } from '~icons/tabler/activity'
export { default as Flash } from '~icons/tabler/device-sd-card'
export { default as Pyramid } from '~icons/tabler/pyramid'
export { default as Sketch } from '~icons/tabler/chart-pie'
export { default as Heap } from '~icons/tabler/box-model'
export { default as Temperature } from '~icons/tabler/temperature'
export { default as SDK } from '~icons/tabler/sdk'
export { default as Prerelease } from '~icons/tabler/test-pipe'
export { default as Error } from '~icons/tabler/circle-x'
export { default as OTA } from '~icons/tabler/file-upload'
export { default as Warning } from '~icons/tabler/alert-triangle'
export { default as AddUser } from '~icons/tabler/user-plus'
export { default as Admin } from '~icons/tabler/key'
export { default as Save } from '~icons/tabler/device-floppy'
@@ -1,26 +0,0 @@
<script lang="ts">
import { MdiEyeOffOutline, MdiEyeOutline } from "../icons";
interface Props {
show?: boolean;
value?: string;
id?: string;
}
let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props();
let type = $derived(show ? 'text' : 'password');
const handleInput = (e: any) => value = e.target.value
const togglePassword = () => show = !show
</script>
<label class="input input-bordered flex items-center gap-2">
<input {type} class="grow" {value} oninput={handleInput} {id} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div onclick={togglePassword} role="button" tabindex="0">
<MdiEyeOffOutline class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}" />
<MdiEyeOutline class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}" />
</div>
</label>
@@ -1,34 +0,0 @@
<script lang="ts">
interface Props {
min?: number
max?: number
step?: number
value?: any
oninput?: any
}
let {
min = 0,
max = 100,
step = 1,
value = $bindable((max - min) / 2),
...rest
}: Props = $props()
</script>
<input
type="range"
style="writing-mode: vertical-lr; direction: rtl"
class="cursor-pointer"
{min}
{max}
{step}
bind:value
{...rest} />
<style>
input[type='range']::-webkit-slider-runnable-track {
background: oklch(var(--p) / 1);
border-radius: var(--rounded-box, 1rem);
}
</style>
-2
View File
@@ -1,2 +0,0 @@
export { default as PasswordInput } from './InputPassword.svelte';
export { default as VerticalSlider } from './VerticalSlider.svelte';
@@ -1,11 +0,0 @@
<script lang="ts">
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
</script>
<div class="box-border overflow-hidden flex-1">
{@render children?.()}
</div>
@@ -1,37 +0,0 @@
<script lang="ts">
import WidgetContainer from './WidgetContainer.svelte';
import { WidgetComponents, type WidgetContainerConfig, isWidgetConfig } from '$lib/stores/application';
import Widget from './Widget.svelte';
interface Props {
container: WidgetContainerConfig;
}
let { container }: Props = $props();
</script>
<div class="w-full h-full flex flex-col overflow-hidden">
<div
class="flex w-full h-full"
class:flex-row={container.layout === 'column'}
class:flex-col={container.layout === 'row'}
class:flex-wrap={container.layout === 'wrap'}
>
{#each container.widgets as widget, index (widget.id + '-' + index)}
<Widget>
{#if isWidgetConfig(widget)}
{@const SvelteComponent = WidgetComponents[widget.component]}
<SvelteComponent {...widget.props} />
{:else if widget.widgets}
<WidgetContainer container={widget} />
{/if}
</Widget>
{#if index !== container.widgets.length - 1}
<div
class="divider bg-base-300 m-0"
class:divider-horizontal={container.layout === 'column'}
></div>
{/if}
{/each}
</div>
</div>
@@ -1,15 +0,0 @@
<script lang="ts">
import { Github } from "../icons";
interface Props {
github: any;
}
let { github }: Props = $props();
</script>
{#if github.active}
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
<Github class="h-5 w-5" />
</a>
{/if}
@@ -1,14 +0,0 @@
<script>
import logo from '$lib/assets/logo512.png';
/** @type {{appName: any}} */
let { appName } = $props();
</script>
<a
href="/"
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
>
<img src={logo} alt="Logo" class="h-12 w-12" />
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
</a>
-178
View File
@@ -1,178 +0,0 @@
<script lang="ts">
import { page } from '$app/state'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import GithubButton from '../menu/GithubButton.svelte'
import LogoButton from '../menu/LogoButton.svelte'
import MenuList from '../menu/MenuList.svelte'
import {
Connection,
Settings,
MdiController,
Devices,
Camera,
Rotate3d,
MotorOutline,
Health,
Folder,
Update,
WiFi,
Router,
AP,
Copyright,
Metrics
} from '$lib/components/icons'
import appEnv from 'app-env'
const features = useFeatureFlags()
const appName = page.data.app_name
const copyright = page.data.copyright
const github = { href: 'https://github.com/' + page.data.github, active: true }
type menuItem = {
title: string
icon: ConstructorOfATypedSvelteComponent
href?: string
feature: boolean
active?: boolean
submenu?: menuItem[]
}
let menuItems = $state<menuItem[]>([])
$effect(() => {
menuItems = [
{
title: 'Connection',
icon: WiFi,
href: '/connection',
feature: !appEnv.VITE_USE_HOST_NAME
},
{
title: 'Controller',
icon: MdiController,
href: '/controller',
feature: true
},
{
title: 'Peripherals',
icon: Devices,
feature: true,
submenu: [
{
title: 'I2C',
icon: Connection,
href: '/peripherals/i2c',
feature: true
},
{
title: 'Camera',
icon: Camera,
href: '/peripherals/camera',
feature: $features.camera
},
{
title: 'Servo',
icon: MotorOutline,
href: '/peripherals/servo',
feature: true
},
{
title: 'IMU',
icon: Rotate3d,
href: '/peripherals/imu',
feature: $features.imu || $features.mag || $features.bmp
}
]
},
{
title: 'WiFi',
icon: WiFi,
feature: true,
submenu: [
{
title: 'WiFi Station',
icon: Router,
href: '/wifi/sta',
feature: true
},
{
title: 'Access Point',
icon: AP,
href: '/wifi/ap',
feature: true
}
]
},
{
title: 'System',
icon: Settings,
feature: true,
submenu: [
{
title: 'System Status',
icon: Health,
href: '/system/status',
feature: true
},
{
title: 'File System',
icon: Folder,
href: '/system/filesystem',
feature: true
},
{
title: 'System Metrics',
icon: Metrics,
href: '/system/metrics',
feature: $features.analytics
},
{
title: 'Firmware Update',
icon: Update,
href: '/system/update',
feature: $features.ota || $features.upload_firmware || $features.download_firmware
}
]
}
] as menuItem[]
})
const { menuClicked } = $props()
function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => {
item.active = item.title === targetTitle
item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle
})
})
menuItems = menuItems
menuClicked()
}
$effect(() => {
setActiveMenuItem(page.data.title)
})
const updateMenu = (event: any) => {
setActiveMenuItem(event.details)
}
</script>
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
<LogoButton {appName} />
<MenuList {menuItems} select={updateMenu} class="grow flex-nowrap overflow-y-auto" level="0" />
<div class="divider my-0"></div>
<div class="flex items-center justify-between">
<GithubButton {github} />
<div class="flex items-center justify-end text-sm gap-2">
<Copyright class="h-4 w-4" />{copyright}
</div>
</div>
</div>
@@ -1,48 +0,0 @@
<script lang="ts">
import MenuList from './MenuList.svelte'
type MenuItem = {
title: string
icon: ConstructorOfATypedSvelteComponent
href?: string
feature: boolean
active?: boolean
submenu?: MenuItem[]
}
let { level, menuItems, select, class: klass } = $props()
const selectMenuItem = (title: string) => {
select(title)
}
</script>
<ul class={klass + ' menu'}>
{#each menuItems as MenuItem[] as menuItem, i (menuItem.title)}
{#if menuItem.feature}
<li>
{#if menuItem.submenu}
<details open={menuItem.submenu.some(subItem => subItem.active)}>
<summary class="text-lg font-bold">
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</summary>
<div class="pl-4">
<MenuList menuItems={menuItem.submenu} level={level + 1} {select} class={klass} />
</div>
</details>
{:else}
<a
href={menuItem.href}
class="font-bold"
class:bg-base-100={menuItem.active}
class:text-lg={level === 0}
class:text-md={level === 1}
onclick={() => selectMenuItem(menuItem.title)}>
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</a>
{/if}
</li>
{/if}
{/each}
</ul>
@@ -1,10 +0,0 @@
<script lang="ts">
import { isFullscreen, toggleFullscreen } from '$lib/stores';
import { MdiFullscreenExit, MdiFullscreen } from '../icons';
const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen);
</script>
<button onclick={toggleFullscreen}>
<SvelteComponent class="h-7 w-7" />
</button>
@@ -1,33 +0,0 @@
<script lang="ts">
import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from "../icons";
interface Props {
showDBm?: boolean;
rssi?: number;
}
let { showDBm = false, rssi = 0 }: Props = $props();
const getWiFiIcon = () => {
if (rssi === 0) return WifiOff;
if (rssi >= -55) return WiFi;
if (rssi >= -75) return WiFi2;
if (rssi >= -85) return WiFi1;
return WiFi0;
};
const SvelteComponent = $derived(getWiFiIcon());
</script>
<div class="indicator">
<div class="tooltip tooltip-left" data-tip={rssi + " dBm"}>
{#if showDBm}
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
{rssi} dBm
</span>
{/if}
<div class="h-7 w-7">
<SvelteComponent class="absolute inset-0 h-full w-full" />
</div>
</div>
</div>
@@ -1,34 +0,0 @@
<script lang="ts">
import { useFeatureFlags } from '$lib/stores';
import { modals } from 'svelte-modals';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { api } from '$lib/api';
import { Cancel, Power } from '../icons';
const features = useFeatureFlags();
const postSleep = async () => await api.post('/api/system/sleep');
const confirmSleep = () => {
modals.open(ConfirmDialog, {
title: 'Confirm Power Down',
message: 'Are you sure you want to switch off the device?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Switch Off', icon: Power }
},
onConfirm: () => {
modals.close();
postSleep();
}
});
};
</script>
{#if $features.sleep}
<div class="flex-none">
<button class="btn btn-square btn-ghost h-9 w-10" onclick={confirmSleep}>
<Power class="text-error h-9 w-9" />
</button>
</div>
{/if}
@@ -1,10 +0,0 @@
<script lang="ts">
import { mode, modes } from "$lib/stores";
const deactivate = async () => {
mode.set(modes.indexOf('deactivated'));
};
</script>
<button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button>
@@ -1,9 +0,0 @@
<script lang="ts">
import { MdiWeatherSunny, MdiMoonAndStars } from "../icons";
</script>
<label class="swap swap-rotate">
<input type="checkbox" value="light" class="theme-controller" />
<MdiWeatherSunny class="swap-off h-7 w-7" />
<MdiMoonAndStars class="swap-on h-7 w-7" />
</label>
@@ -1,111 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { modals } from 'svelte-modals';
import { notifications } from '$lib/components/toasts/notifications';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import { compareVersions } from 'compare-versions';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { GithubRelease } from '$lib/types/models';
import { useFeatureFlags } from '$lib/stores/featureFlags';
import { Cancel, CloudDown, Firmware } from '../icons';
const features = useFeatureFlags();
interface Props {
update?: boolean;
}
let { update = $bindable(false) }: Props = $props();
let firmwareVersion: string = $state('');
let firmwareDownloadLink: string = $state('');
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
};
const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${page.data.github}/releases/latest`,
{ headers }
);
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository');
return;
}
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
const results = result.inner;
update = false;
firmwareVersion = '';
if (compareVersions(results.tag_name, $features.firmware_version) === 1) {
// iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin
if (
results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($features.firmware_built_target)
) {
update = true;
firmwareVersion = results.tag_name;
firmwareDownloadLink = results.assets[i].browser_download_url;
notifications.info('Firmware update available.', 5000);
}
}
}
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url });
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
}
onMount(async () => {
if ($features.download_firmware) {
await getGithubAPI();
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000); // once per hour
}
});
function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url);
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
});
}
});
}
</script>
{#if update}
<div class="indicator flex-none">
<button
class="btn btn-square btn-ghost h-9 w-9"
onclick={() => confirmGithubUpdate(firmwareDownloadLink)}
>
<span
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
>
{firmwareVersion}
</span>
<Firmware class="h-7 w-7" />
</button>
</div>
{/if}
@@ -1,6 +0,0 @@
<script lang="ts">
import { selectedView, views } from "$lib/stores/application";
import Selector from "../widget/Selector.svelte";
</script>
<Selector bind:selectedOption={$selectedView} options={$views.map((v) => v.name)} />
@@ -1,38 +0,0 @@
<script lang="ts">
import { page } from '$app/state'
import { telemetry } from '$lib/stores/telemetry'
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte'
import SleepButton from './SleepButton.svelte'
import ThemeButton from './ThemeButton.svelte'
import FullscreenButton from './FullscreenButton.svelte'
import StopButton from './StopButton.svelte'
import ViewSelector from './ViewSelector.svelte'
import { Hamburger } from '../icons'
</script>
<div class="navbar bg-base-300 sticky top-0 z-10 h-12 min-h-fit drop-shadow-lg lg:h-16 gap-2 pr-0">
<div class="flex flex-1 gap-2">
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button">
<Hamburger class="h-6 w-auto" />
</label>
{#if page.data.title === 'Controller'}
<ViewSelector />
{:else}
<h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1>
{/if}
</div>
<UpdateIndicator />
<FullscreenButton />
<ThemeButton />
<RssiIndicator rssi={$telemetry.rssi.rssi} />
<SleepButton />
<StopButton />
</div>
+10 -8
View File
@@ -2,33 +2,35 @@
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications';
import { error, info, success, warning } from '../icons'; import error from '~icons/tabler/circle-x';
import success from '~icons/tabler/circle-check';
import warning from '~icons/tabler/alert-triangle';
import info from '~icons/tabler/info-circle';
export let theme = {
/** @type {{theme?: any, icon?: any}} */
let { theme = {
error: 'alert-error', error: 'alert-error',
success: 'alert-success', success: 'alert-success',
warning: 'alert-warning', warning: 'alert-warning',
info: 'alert-info' info: 'alert-info'
}, icon = { };
export let icon = {
error: error, error: error,
success: success, success: success,
warning: warning, warning: warning,
info: info info: info
} } = $props(); };
</script> </script>
<div class="toast toast-end mr-4 z-20"> <div class="toast toast-end mr-4 z-20">
{#each $notifications as notification (notification.id)} {#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]}
<div <div
animate:flip={{ duration: 400 }} animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}" class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }} in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }} out:fly={{ x: 100, duration: 400 }}
> >
<SvelteComponent class="h-6 w-6 shrink-0" /> <svelte:component this={icon[notification.type]} class="h-6 w-6 flex-shrink-0" />
<span>{notification.message}</span> <span>{notification.message}</span>
</div> </div>
{/each} {/each}
@@ -1,103 +0,0 @@
<script lang="ts">
import { daisyColor } from "$lib/utilities";
import { Chart, registerables } from "chart.js";
import { onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { slide } from "svelte/transition";
let chartElement: HTMLCanvasElement = $state();
let chart: Chart;
interface Props {
label: any;
data: number[];
title: any;
}
let { label, data, title }: Props = $props();
Chart.register(...registerables);
onMount(() => {
chart = new Chart(chartElement, {
type: 'line',
data: {
labels: data,
datasets: [
{
label,
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data,
yAxisID: 'y'
},
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
},
elements: {
point: {
radius: 0
}
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: title,
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
},
position: 'left',
min: 0,
max: 100,
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
setInterval(() => {
chart.data.labels = data
chart.data.datasets[0].data = data
}, 500);
})
</script>
<div class="w-full h-full overflow-x-auto">
<div
class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<canvas bind:this={chartElement}></canvas>
</div>
</div>
@@ -1,20 +0,0 @@
<script lang="ts">
interface Props {
options?: string[];
selectedOption?: string;
change: () => void;
[key: string]: any;
}
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props();
</script>
<select
bind:value={selectedOption}
{...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"
>
{#each options as option}
<option value={option}>{option}</option>
{/each}
</select>
+183 -393
View File
@@ -4,449 +4,239 @@ import { fromInt8 } from './utilities';
const { sin } = Math; const { sin } = Math;
export interface gait_state_t { export interface gait_state_t {
step_height: number; step_height: number;
step_x: number; step_x: number;
step_z: number; step_z: number;
step_angle: number; step_angle: number;
step_velocity: number; step_velocity: number;
step_depth: number; step_depth: number;
} }
export interface ControllerCommand { export interface ControllerCommand {
stop: number; stop: number;
lx: number; lx: number;
ly: number; ly: number;
rx: number; rx: number;
ry: number; ry: number;
h: number; h: number;
s: number; s: number;
s1: number;
} }
export abstract class GaitState { export abstract class GaitState {
protected abstract name: string; protected abstract name: string;
protected dt = 0.02; protected static body_state: body_state_t;
protected body_state!: body_state_t;
protected gait_state: gait_state_t = {
step_height: 0.4,
step_x: 0,
step_z: 0,
step_angle: 0,
step_velocity: 1,
step_depth: 0.002
};
public get default_feet_pos() { public get default_feet_pos() {
return [ return [
[1, -1, 1, 1], [1, -1, 1, 1],
[1, -1, -1, 1], [1, -1, -1, 1],
[-1, -1, 1, 1], [-1, -1, 1, 1],
[-1, -1, -1, 1] [-1, -1, -1, 1]
]; ];
} }
protected get default_height() { protected get default_height() {
return 0.5; return 0.5;
} }
begin() { begin() {
console.log('Starting', this.name); console.log('Starting', this.name);
} }
end() { end() {
console.log('Ending', this.name); console.log('Ending', this.name);
} }
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
this.map_command(command); return body_state;
this.body_state = body_state; }
this.dt = dt / 1000;
return body_state;
}
map_command(command: ControllerCommand) { map_command(command: ControllerCommand): gait_state_t {
const newCommand = { return {
step_height: 0.4 + (command.s1 / 128 + 1) / 2, step_height: 0.4 + Math.abs(command.ry / 128),
step_x: Math.floor(fromInt8(command.ly, -1, 1) * 10) / 10, step_x: (Math.floor(fromInt8(command.ly, -1, 1) * 10) / 10) * 3,
step_z: -(Math.floor(fromInt8(command.lx, -1, 1) * 10) / 10), step_z: -(Math.floor(fromInt8(command.lx, -1, 1) * 10) / 10) * 3,
step_velocity: command.s / 128 + 1, step_velocity: command.s / 128 + 1,
step_angle: command.rx / 128, step_angle: 0,
step_depth: 0.002 step_depth: 0.2
}; };
}
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 {
protected name = 'Calibration';
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
body_state.omega = 0;
body_state.phi = 0;
body_state.psi = 0;
body_state.xm = 0;
body_state.ym = this.default_height * 10;
body_state.zm = 0;
body_state.feet = this.default_feet_pos;
return body_state;
}
} }
export class RestState extends GaitState { export class RestState extends GaitState {
protected name = 'Rest'; protected name = 'Rest';
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
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, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
body_state.omega = 0; body_state.omega = 0;
body_state.phi = command.rx / 8; body_state.phi = command.rx / 8;
body_state.psi = command.ry / 8; body_state.psi = command.ry / 8;
body_state.xm = command.ly / 2 / 100; body_state.xm = command.ly / 2 / 100;
body_state.zm = command.lx / 2 / 100; body_state.zm = command.lx / 2 / 100;
body_state.feet = this.default_feet_pos; body_state.feet = this.default_feet_pos;
return body_state; return body_state;
} }
} }
abstract class PhaseGaitState extends GaitState { abstract class PhaseGaitState extends GaitState {
protected tick = 0; protected tick = 0;
protected phase = 0; protected phase = 0;
protected phase_time = 0; protected phase_time = 0;
protected abstract num_phases: number; protected abstract num_phases: number;
protected abstract phase_speed_factor: number; protected abstract phase_speed_factor: number;
protected abstract swing_stand_ratio: number; protected abstract swing_stand_ratio: number;
protected contact_phases!: number[][]; protected contact_phases!: number[][];
protected shifts!: number[][]; protected shifts!: number[][];
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { protected body_state!: body_state_t;
super.step(body_state, command, dt); protected gait_state!: gait_state_t;
this.update_phase(); protected dt = 0.02;
this.update_body_position();
this.update_feet_positions();
return this.body_state;
}
update_phase() { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
this.phase_time += this.dt * this.phase_speed_factor * this.gait_state.step_velocity; this.body_state = body_state;
this.gait_state = this.map_command(command);
this.dt = dt / 1000;
this.update_phase();
this.update_body_position();
this.update_feet_positions();
return this.body_state;
}
if (this.phase_time >= 1) { update_phase() {
this.phase += 1; this.phase_time += this.dt * this.phase_speed_factor * this.gait_state.step_velocity;
if (this.phase == this.num_phases) this.phase = 0;
this.phase_time = 0;
}
}
update_body_position() { if (this.phase_time >= 1) {
if (this.num_phases === 4) return; this.phase += 1;
if (this.phase == this.num_phases) this.phase = 0;
this.phase_time = 0;
}
}
const shift = this.shifts[Math.floor(this.phase / 2)]; update_body_position() {
if (this.num_phases === 4) return;
this.body_state.xm += (shift[0] - this.body_state.xm) * this.dt * 4; const shift = this.shifts[Math.floor(this.phase / 2)];
this.body_state.zm += (shift[2] - this.body_state.zm) * this.dt * 4;
}
update_feet_positions() { this.body_state.xm += (shift[0] - this.body_state.xm) * this.dt * 4;
for (let i = 0; i < 4; i++) { this.body_state.zm += (shift[2] - this.body_state.zm) * this.dt * 4;
this.body_state.feet[i] = this.update_foot_position(i); }
}
}
update_foot_position(index: number): number[] { update_feet_positions() {
const contact = this.contact_phases[index][this.phase]; for (let i = 0; i < 4; i++) {
return contact ? this.stand(index) : this.swing(index); this.body_state.feet[i] = this.update_foot_position(i);
} }
}
stand(index: number): number[] { update_foot_position(index: number): number[] {
const delta_pos = [ const contact = this.contact_phases[index][this.phase];
-this.gait_state.step_x * this.dt * this.swing_stand_ratio, return contact ? this.stand(index) : this.swing(index);
0, }
-this.gait_state.step_z * this.dt * this.swing_stand_ratio
];
this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0]; stand(index: number): number[] {
this.body_state.feet[index][1] = this.default_feet_pos[index][1]; const delta_pos = [
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2]; -this.gait_state.step_x * this.dt * this.swing_stand_ratio,
return this.body_state.feet[index]; 0,
} -this.gait_state.step_z * this.dt * this.swing_stand_ratio
];
swing(index: number): number[] { this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0];
const delta_pos = [this.gait_state.step_x * this.dt, 0, this.gait_state.step_z * this.dt]; this.body_state.feet[index][1] = this.default_feet_pos[index][1];
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2];
return this.body_state.feet[index];
}
if (this.gait_state.step_x == 0) { swing(index: number): number[] {
delta_pos[0] = const delta_pos = [this.gait_state.step_x * this.dt, 0, this.gait_state.step_z * this.dt];
(this.default_feet_pos[index][0] - this.body_state.feet[index][0]) * this.dt * 8;
}
if (this.gait_state.step_z == 0) { if (this.gait_state.step_x == 0) {
delta_pos[2] = delta_pos[0] =
(this.default_feet_pos[index][2] - this.body_state.feet[index][2]) * this.dt * 8; (this.default_feet_pos[index][0] - this.body_state.feet[index][0]) * this.dt * 8;
} }
this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0]; if (this.gait_state.step_z == 0) {
this.body_state.feet[index][1] = delta_pos[2] =
this.default_feet_pos[index][1] + (this.default_feet_pos[index][2] - this.body_state.feet[index][2]) * this.dt * 8;
sin(this.phase_time * Math.PI) * this.gait_state.step_height; }
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2];
return this.body_state.feet[index]; this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0];
} this.body_state.feet[index][1] =
this.default_feet_pos[index][1] +
sin(this.phase_time * Math.PI) * this.gait_state.step_height;
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2];
return this.body_state.feet[index];
}
} }
export class FourPhaseWalkState extends PhaseGaitState { export class FourPhaseWalkState extends PhaseGaitState {
protected name = 'Four phase walk'; protected name = 'Four phase walk';
protected num_phases = 4; protected num_phases = 4;
protected phase_speed_factor = 6; protected phase_speed_factor = 2.5;
protected contact_phases = [ protected contact_phases = [
[1, 0, 1, 1], [1, 0, 1, 1],
[1, 1, 1, 0], [1, 1, 1, 0],
[1, 1, 1, 0], [1, 1, 1, 0],
[1, 0, 1, 1] [1, 0, 1, 1]
]; ];
protected swing_stand_ratio = 1 / (this.num_phases - 1); protected swing_stand_ratio = 1 / (this.num_phases - 1);
begin() { begin() {
super.begin(); super.begin();
} }
end() { end() {
super.end(); super.end();
} }
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt); return super.step(body_state, command, dt);
} }
} }
export class EightPhaseWalkState extends PhaseGaitState { export class EightPhaseWalkState extends PhaseGaitState {
protected name = 'Eight phase walk'; protected name = 'Eight phase walk';
protected num_phases = 8; protected num_phases = 8;
protected phase_speed_factor = 4; protected phase_speed_factor = 1.5;
protected contact_phases = [ protected contact_phases = [
[1, 0, 1, 1, 1, 1, 1, 1], [1, 0, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 0, 1, 1], [1, 1, 1, 0, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 0], [1, 1, 1, 1, 1, 0, 1, 1],
[1, 1, 1, 0, 1, 1, 1, 1] [1, 1, 1, 1, 1, 1, 1, 0]
]; ];
protected shifts = [ protected shifts = [
[-0.05, 0, -0.2], [-0.3, 0, -0.2],
[0.3, 0, 0.2], [-0.3, 0, 0.2],
[-0.05, 0, 0.2], [0.3, 0, -0.2],
[0.3, 0, -0.2] [0.3, 0, 0.2]
]; ];
protected swing_stand_ratio = 1 / (this.num_phases - 1); protected swing_stand_ratio = 1 / (this.num_phases - 1);
begin() { begin() {
super.begin(); super.begin();
} }
end() { end() {
super.end(); super.end();
} }
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt); return super.step(body_state, command, dt);
} }
} }
export class BezierState extends GaitState {
protected name = 'Bezier';
protected phase = 0;
protected phase_num = 0;
protected step_length: number = 0;
offset = [0, 0.5, 0.5, 0];
begin() {
super.begin();
}
end() {
super.end();
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super.step(body_state, command, dt);
this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2);
if (this.gait_state.step_x < 0) {
this.step_length = -this.step_length;
}
this.update_phase();
this.update_feet_positions();
return this.body_state;
}
update_phase() {
this.phase += this.dt * this.gait_state.step_velocity * 2;
if (this.phase >= 1) {
this.phase_num += 1;
this.phase_num %= 2;
this.phase = 0;
}
}
update_feet_positions() {
for (let i = 0; i < 4; i++) {
this.body_state.feet[i] = this.update_foot_position(i);
}
}
update_foot_position(index: number): number[] {
let phase = this.phase + this.offset[index];
if (phase >= 1) {
phase -= 1;
}
this.body_state.feet[index][0] = this.default_feet_pos[index][0];
this.body_state.feet[index][1] = this.default_feet_pos[index][1];
this.body_state.feet[index][2] = this.default_feet_pos[index][2];
return phase <= 0.75 ?
this.stand_controller(index, phase / 0.75)
: this.swing_controller(index, (phase - 0.75) / (1 - 0.75));
}
stand_controller(index: number, phase: number) {
let depth = this.gait_state.step_depth;
return this.controller(index, phase, stance_curve, depth);
}
swing_controller(index: number, phase: number) {
let 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 X_POLAR = Math.cos(angle);
const Y_POLAR = Math.sin(angle);
const step = length * (1 - 2 * phase);
const X = step * X_POLAR;
const Z = step * Y_POLAR;
let Y = 0;
if (length !== 0) {
Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length));
}
return [X, Y, Z];
};
const yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => {
const 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 offsets = [
current_foot_pos[0] - default_foot_pos[0],
current_foot_pos[2] - default_foot_pos[2],
current_foot_pos[1] - default_foot_pos[1]
];
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2);
const offset_mod = Math.atan2(offset_mag, foot_mag);
return Math.PI / 2.0 + foot_dir + offset_mod;
};
const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => {
const control_points = get_control_points(length, angle, height);
const n = control_points.length - 1;
const point = [0, 0, 0];
for (let i = 0; i <= n; i++) {
const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i);
point[0] += bernstein_poly * control_points[i][0];
point[1] += bernstein_poly * control_points[i][1];
point[2] += bernstein_poly * control_points[i][2];
}
return point;
};
const get_control_points = (length: number, angle: number, height: number): number[][] => {
const X_POLAR = Math.cos(angle);
const Z_POLAR = Math.sin(angle);
const STEP = [
-length,
-length * 1.4,
-length * 1.5,
-length * 1.5,
-length * 1.5,
0.0,
0.0,
0.0,
length * 1.5,
length * 1.5,
length * 1.4,
length
];
const Y = [
0.0,
0.0,
height * 0.9,
height * 0.9,
height * 0.9,
height * 0.9,
height * 0.9,
height * 1.1,
height * 1.1,
height * 1.1,
0.0,
0.0
];
const control_points: number[][] = [];
for (let i = 0; i < STEP.length; i++) {
const X = STEP[i] * X_POLAR;
const Z = STEP[i] * Z_POLAR;
control_points.push([X, Y[i], Z]);
}
return control_points;
};
const comb = (n: number, k: number): number => {
if (k < 0 || k > n) return 0;
if (k === 0 || k === n) return 1;
k = Math.min(k, n - k);
let c = 1;
for (let i = 0; i < k; i++) {
c = (c * (n - i)) / (i + 1);
}
return c;
};
+169
View File
@@ -0,0 +1,169 @@
export type vector = { x: number; y: number };
export interface ControllerInput {
left: vector;
right: vector;
height: number;
speed: number;
s1: number;
}
export type GithubRelease = {
message: string;
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
};
export type JWT = { access_token: string };
export type angles = number[] | Int16Array;
export type WifiStatus = {
status: number;
local_ip: string;
mac_address: string;
rssi: number;
ssid: string;
bssid: string;
channel: number;
subnet_mask: string;
gateway_ip: string;
dns_ip_1: string;
dns_ip_2?: string;
};
export type WifiSettings = {
hostname: string;
priority_RSSI: boolean;
wifi_networks: NetworkItem[];
};
export type NetworkList = {
networks: NetworkItem[];
};
export type KnownNetworkItem = {
ssid: string;
password: string;
static_ip_config: boolean;
local_ip?: string;
subnet_mask?: string;
gateway_ip?: string;
dns_ip_1?: string;
dns_ip_2?: string;
};
export type NetworkItem = {
rssi: number;
ssid: string;
bssid: string;
channel: number;
encryption_type: number;
};
export type ApStatus = {
status: number;
ip_address: string;
mac_address: string;
station_num: number;
};
export type ApSettings = {
provision_mode: number;
ssid: string;
password: string;
channel: number;
ssid_hidden: boolean;
max_clients: number;
local_ip: string;
gateway_ip: string;
subnet_mask: string;
};
export type LightState = {
led_on: boolean;
};
export type NTPStatus = {
status: number;
utc_time: string;
local_time: string;
server: string;
uptime: number;
};
export type NTPSettings = {
enabled: boolean;
server: string;
tz_label: string;
tz_format: string;
};
export type Analytics = {
max_alloc_heap: number;
psram_size: number;
free_psram: number;
free_heap: number;
total_heap: number;
min_free_heap: number;
core_temp: number;
fs_total: number;
fs_used: number;
uptime: number;
};
export type StaticSystemInformation = {
esp_platform: string;
firmware_version: string;
cpu_freq_mhz: number;
cpu_type: string;
cpu_rev: number;
cpu_cores: number;
sketch_size: number;
free_sketch_space: number;
sdk_version: string;
arduino_version: string;
flash_chip_size: number;
flash_chip_speed: number;
cpu_reset_reason: string;
};
export type SystemInformation = Analytics & StaticSystemInformation;
export type CameraSettings = {
framesize: number;
quality: number;
brightness: number;
contrast: number;
saturation: number;
sharpness: number;
denoise: number;
special_effect: number;
wb_mode: number;
vflip: boolean;
hmirror: boolean;
};
export type File = number;
export interface Directory {
[key: string]: File | Directory;
}
export type Servo = {
name: string;
channel: number;
inverted: boolean;
angle: number;
center_angle: number;
};
export type ServoConfiguration = {
is_active: boolean;
servo_pwm_frequency: number;
servo_oscillator_frequency: number;
servos: Servo[];
};
+308 -336
View File
@@ -1,379 +1,351 @@
import { import {
Mesh, Mesh,
PerspectiveCamera, PerspectiveCamera,
PlaneGeometry, PlaneGeometry,
Scene, Scene,
WebGLRenderer, WebGLRenderer,
AmbientLight, AmbientLight,
DirectionalLight, DirectionalLight,
PCFSoftShadowMap, PCFSoftShadowMap,
type GridHelper, GridHelper,
ArrowHelper, ArrowHelper,
Vector3, Vector3,
FogExp2, FogExp2,
CanvasTexture, CanvasTexture,
type ColorRepresentation, type ColorRepresentation,
type WebGLRendererParameters, type WebGLRendererParameters,
MeshPhongMaterial, MeshPhongMaterial,
EquirectangularReflectionMapping, EquirectangularReflectionMapping,
ACESFilmicToneMapping, ACESFilmicToneMapping,
MathUtils, MathUtils,
Group, MeshStandardMaterial,
MeshBasicMaterial, Group
RepeatWrapping } from 'three';
} from 'three' import { Sky } from 'three/addons/objects/Sky.js';
import { Sky } from 'three/addons/objects/Sky.js' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { TransformControls } from 'three/examples/jsm/controls/TransformControls';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls' import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader';
import { Reflector } from 'three/examples/jsm/objects/Reflector.js' import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls';
import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader' import { sunCalculator } from './utilities/position-utilities';
import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls'
import { sunCalculator } from './utilities/position-utilities'
export const addScene = () => new Scene() export const addScene = () => new Scene();
interface position { interface position {
x?: number x?: number;
y?: number y?: number;
z?: number z?: number;
} }
interface light { interface light {
color?: ColorRepresentation color?: ColorRepresentation;
intensity?: number intensity?: number;
}
interface gridOptions {
divisions?: number;
size?: number;
} }
interface arrowOptions { interface arrowOptions {
origin: position origin: position;
direction: position direction: position;
length?: number length?: number;
color?: ColorRepresentation color?: ColorRepresentation;
} }
type directionalLight = position & light type directionalLight = position & light;
type gridHelperOptions = gridOptions & position;
export default class SceneBuilder { export default class SceneBuilder {
public scene: Scene public scene: Scene;
public camera!: PerspectiveCamera public camera!: PerspectiveCamera;
public ground!: Mesh public ground!: Mesh;
public renderer!: WebGLRenderer public renderer!: WebGLRenderer;
public orbit: OrbitControls public orbit: OrbitControls;
public callback: Function | undefined public callback: Function | undefined;
public gridHelper!: GridHelper public gridHelper!: GridHelper;
public model!: URDFRobot public model!: URDFRobot;
public liveStreamTexture!: CanvasTexture public liveStreamTexture!: CanvasTexture;
private fog!: FogExp2 private fog!: FogExp2;
private isLoaded: boolean = false private isLoaded: boolean = false;
public isDragging: boolean = false public isDragging: boolean = false;
highlightMaterial: any highlightMaterial: any;
sky!: Sky sky!: Sky;
transformControl: TransformControls transformControl: TransformControls;
public modelGroup!: Group public modelGroup!: Group;
constructor() { constructor() {
this.scene = new Scene() this.scene = new Scene();
if (this.scene.environment?.mapping) { if (this.scene.environment?.mapping) {
this.scene.environment.mapping = EquirectangularReflectionMapping this.scene.environment.mapping = EquirectangularReflectionMapping;
} }
return this return this;
} }
public addRenderer = (parameters?: WebGLRendererParameters) => { public addRenderer = (parameters?: WebGLRendererParameters) => {
this.renderer = new WebGLRenderer(parameters) this.renderer = new WebGLRenderer(parameters);
this.renderer.outputColorSpace = 'srgb' this.renderer.outputColorSpace = 'srgb';
this.renderer.shadowMap.enabled = true this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = PCFSoftShadowMap this.renderer.shadowMap.type = PCFSoftShadowMap;
this.renderer.toneMapping = ACESFilmicToneMapping this.renderer.toneMapping = ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 0.85 this.renderer.toneMappingExposure = 0.85;
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement) if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement);
return this return this;
} };
public addSky = () => { public addSky = () => {
this.sky = new Sky() this.sky = new Sky();
this.sky.scale.setScalar(450000) this.sky.scale.setScalar(450000);
this.scene.add(this.sky) this.scene.add(this.sky);
const effectController = { const effectController = {
turbidity: 10, turbidity: 10,
rayleigh: 3, rayleigh: 3,
mieCoefficient: 0.005, mieCoefficient: 0.005,
mieDirectionalG: 0.7, mieDirectionalG: 0.7,
elevation: sunCalculator.calculateSunElevation(), elevation: sunCalculator.calculateSunElevation(),
azimuth: 200, azimuth: 180,
exposure: this.renderer.toneMappingExposure exposure: this.renderer.toneMappingExposure
} };
const uniforms = this.sky.material.uniforms const uniforms = this.sky.material.uniforms;
uniforms['turbidity'].value = effectController.turbidity uniforms['turbidity'].value = effectController.turbidity;
uniforms['rayleigh'].value = effectController.rayleigh uniforms['rayleigh'].value = effectController.rayleigh;
uniforms['mieCoefficient'].value = effectController.mieCoefficient uniforms['mieCoefficient'].value = effectController.mieCoefficient;
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG uniforms['mieDirectionalG'].value = effectController.mieDirectionalG;
this.renderer.toneMappingExposure = 0.5 this.renderer.toneMappingExposure = 0.5;
const phi = MathUtils.degToRad(90 - effectController.elevation) const phi = MathUtils.degToRad(90 - effectController.elevation);
const theta = MathUtils.degToRad(effectController.azimuth) const theta = MathUtils.degToRad(effectController.azimuth);
const sun = new Vector3() const sun = new Vector3();
sun.setFromSphericalCoords(1, phi, theta) sun.setFromSphericalCoords(1, phi, theta);
uniforms['sunPosition'].value.copy(sun) uniforms['sunPosition'].value.copy(sun);
return this return this;
} };
public addPerspectiveCamera = (options: position) => { public addPerspectiveCamera = (options: position) => {
this.camera = new PerspectiveCamera() this.camera = new PerspectiveCamera();
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0) this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0);
this.scene.add(this.camera) this.scene.add(this.camera);
return this return this;
} };
public addGroundPlane = (options?: position) => { public addGroundPlane = (options?: position) => {
const checkerboardTexture = this.createCheckerboardTexture(1024, 2) var planeMaterial = new MeshStandardMaterial({ color: 0x808080, side: 2, opacity: 0.5 });
checkerboardTexture.wrapS = RepeatWrapping this.ground = new Mesh(new PlaneGeometry(), planeMaterial);
checkerboardTexture.wrapT = RepeatWrapping this.ground.rotation.x = -Math.PI / 2;
checkerboardTexture.repeat.set(100, 100) this.ground.scale.setScalar(30);
const checkerboardMat = new MeshBasicMaterial({ this.ground.position.set(options?.x ?? 0, options?.y ?? 0, options?.z ?? 0);
map: checkerboardTexture, this.ground.receiveShadow = true;
opacity: 0.1, this.scene.add(this.ground);
transparent: true return this;
}) };
const plane = new PlaneGeometry(400, 400) public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement);
this.orbit.minDistance = minDistance;
this.orbit.maxDistance = maxDistance;
this.orbit.autoRotate = autoRotate;
this.orbit.update();
return this;
};
this.ground = new Mesh(plane, checkerboardMat) public addAmbientLight = (options: light) => {
this.ground.rotation.x = -Math.PI / 2 const ambientLight = new AmbientLight(options.color, options.intensity);
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0) this.scene.add(ambientLight);
this.ground.receiveShadow = true return this;
this.scene.add(this.ground) };
const mirror = new Reflector(plane, { public addDirectionalLight = (options: directionalLight) => {
clipBias: 0.003, const directionalLight = new DirectionalLight(options.color, options.intensity);
textureWidth: window.innerWidth * window.devicePixelRatio, directionalLight.castShadow = true;
textureHeight: window.innerHeight * window.devicePixelRatio, directionalLight.shadow.camera.top = 10;
color: 0x00bfff directionalLight.shadow.camera.bottom = -10;
}) directionalLight.shadow.camera.right = 10;
mirror.rotateX(-Math.PI / 2) directionalLight.shadow.camera.left = -10;
this.scene.add(mirror) directionalLight.shadow.mapSize.set(4096, 4096);
return this directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
} this.scene.add(directionalLight);
return this;
};
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => { public addGridHelper = (options: gridHelperOptions) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement) this.gridHelper = new GridHelper(options.size, options.divisions);
this.orbit.minDistance = 5 this.gridHelper.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
this.orbit.maxDistance = maxDistance this.gridHelper.material.opacity = 0.2;
this.orbit.autoRotate = autoRotate this.gridHelper.material.depthWrite = false;
this.orbit.update() this.gridHelper.material.transparent = true;
return this this.scene.add(this.gridHelper);
} return this;
};
public addAmbientLight = (options: light) => { public addFogExp2 = (color: ColorRepresentation, density?: number) => {
const ambientLight = new AmbientLight(options.color, options.intensity) this.scene.fog = new FogExp2(color, density);
this.scene.add(ambientLight) return this;
return this };
}
public addDirectionalLight = (options: directionalLight) => { public fillParent = () => {
const directionalLight = new DirectionalLight(options.color, options.intensity) const parentElement = this.renderer.domElement.parentElement;
directionalLight.castShadow = true if (parentElement) {
directionalLight.shadow.camera.top = 10 const width = parentElement.clientWidth;
directionalLight.shadow.camera.bottom = -10 const height = parentElement.clientHeight;
directionalLight.shadow.camera.right = 10 this.handleResize(width, height);
directionalLight.shadow.camera.left = -10 }
directionalLight.shadow.mapSize.set(4096, 4096) return this;
};
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0) public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
this.scene.add(directionalLight) this.renderer.setSize(width, height);
return this this.renderer.setPixelRatio(window.devicePixelRatio);
} this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
return this;
};
private createCheckerboardTexture = (size: number, squares: number) => { public addRenderCb = (callback: Function) => {
const canvas = document.createElement('canvas') this.callback = callback;
canvas.width = size return this;
canvas.height = size };
const context = canvas.getContext('2d')
const squareSize = size / squares 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;
};
for (let y = 0; y < squares; y++) { public addArrowHelper = (options?: arrowOptions) => {
for (let x = 0; x < squares; x++) { const dir = new Vector3(
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000' options?.direction.x ?? 0,
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize) 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;
};
const texture = new CanvasTexture(canvas) private setJointValue(jointName: string, angle: number) {
texture.wrapS = texture.wrapT = RepeatWrapping if (!this.model) return;
texture.anisotropy = 16 if (!this.model.joints[jointName]) return;
return texture this.model.joints[jointName].setJointValue(angle);
} }
public addFogExp2 = (color: ColorRepresentation, density?: number) => { isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed';
this.scene.fog = new FogExp2(color, density)
return this
}
public fillParent = () => { highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
const parentElement = this.renderer.domElement.parentElement const traverse = (c: any) => {
if (parentElement) { if (c.type === 'Mesh') {
const width = parentElement.clientWidth if (revert) {
const height = parentElement.clientHeight c.material = c.__origMaterial;
this.handleResize(width, height) delete c.__origMaterial;
} } else {
return this c.__origMaterial = c.material;
} c.material = material;
}
}
public handleResize = (width = window.innerWidth, height = window.innerHeight) => { if (c === m || !this.isJoint(c)) {
this.renderer.setSize(width, height) for (let i = 0; i < c.children.length; i++) {
this.renderer.setPixelRatio(window.devicePixelRatio) const child = c.children[i];
this.camera.aspect = width / height if (!child.isURDFCollider) {
this.camera.updateProjectionMatrix() traverse(c.children[i]);
return this }
} }
}
};
traverse(m);
};
public addRenderCb = (callback: Function) => { public addTransformControls = (model: any) => {
this.callback = callback this.transformControl = new TransformControls(this.camera, this.renderer.domElement);
return this this.transformControl.addEventListener('dragging-changed', (event: any) => {
} this.orbit.enabled = !event.value;
this.isDragging = !event.value;
});
this.transformControl.attach(model);
this.scene.add(this.transformControl);
this.transformControl.setMode('rotate');
return this;
};
public startRenderLoop = () => { public addModel = (model: any) => {
this.renderer.setAnimationLoop(() => { this.modelGroup = new Group();
this.renderer.render(this.scene, this.camera) this.modelGroup.add(model);
this.orbit.update() this.model = model;
this.handleRobotShadow() this.scene.add(this.modelGroup);
if (this.callback) this.callback() return this;
if (!this.liveStreamTexture) return };
})
return this
}
public addArrowHelper = (options?: arrowOptions) => { public addDragControl = (updateAngle: any) => {
const dir = new Vector3( const highlightColor = '#FFFFFF';
options?.direction.x ?? 0, const highlightMaterial = new MeshPhongMaterial({
options?.direction.y ?? 0, shininess: 10,
options?.direction.z ?? 0 color: highlightColor,
) emissive: highlightColor,
const origin = new Vector3( emissiveIntensity: 0.25
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) { const dragControls = new PointerURDFDragControls(
if (!this.model) return this.scene,
if (!this.model.joints[jointName]) return this.camera,
this.model.joints[jointName].setJointValue(angle) 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);
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed' this.renderer.domElement.addEventListener('touchstart', (data) =>
dragControls._mouseDown(data.touches[0])
);
this.renderer.domElement.addEventListener('touchmove', (data) =>
dragControls._mouseMove(data.touches[0])
);
this.renderer.domElement.addEventListener('touchend', (data) =>
dragControls._mouseUp(data.touches[0])
);
return this;
};
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => { public toggleFog = () => {
const traverse = (c: any) => { this.scene.fog = this.scene.fog ? null : this.fog;
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)) { private handleRobotShadow = () => {
for (let i = 0; i < c.children.length; i++) { if (this.isLoaded) return;
const child = c.children[i] const intervalId = setInterval(() => {
if (!child.isURDFCollider) { this.model?.traverse((c) => (c.castShadow = true));
traverse(c.children[i]) }, 10);
} setTimeout(() => {
} clearInterval(intervalId);
} }, 1000);
} this.isLoaded = true;
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
}
} }
+37 -20
View File
@@ -1,38 +1,51 @@
import { Result } from '$lib/utilities/result'; import { Result } from '$lib/utilities/result';
import { browser } from '$app/environment';
class FileService { class FileService {
private dbPromise: Promise<Result<IDBDatabase, string>> | null = browser private dbName = 'fileStorageDB';
? this.openDatabase() private dbVersion = 1;
: null; private storeName = 'files';
private dbPromise: Promise<Result<IDBDatabase, string>>;
constructor() {
this.dbPromise = this.openDatabase();
}
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(this.dbName, this.dbVersion);
request.onupgradeneeded = () => {
request.result.createObjectStore('files');
};
request.onsuccess = () => resolve(Result.ok(request.result));
request.onerror = () => resolve(Result.err('Error opening database')); request.onerror = () => resolve(Result.err('Error opening database'));
request.onsuccess = () => resolve(Result.ok(request.result));
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
}); });
} }
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> { private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
if (!browser || !this.dbPromise)
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()) {
const store = dbResult.inner.transaction('files', mode).objectStore('files'); return Result.err('Database not initialized properly');
return Result.ok(store); }
const db = dbResult.inner;
const transaction = db.transaction(this.storeName, mode);
return Result.ok(transaction.objectStore(this.storeName));
} }
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 object store for writing');
}
const store = storeResult.inner;
return new Promise((resolve) => { return new Promise((resolve) => {
const request = storeResult.inner.put(file, key); const request = store.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'));
}); });
@@ -40,15 +53,19 @@ class FileService {
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 object store for reading');
}
const store = storeResult.inner;
return new Promise((resolve) => { return new Promise((resolve) => {
const request = storeResult.inner.get(key); const request = store.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 content 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 new FileService();
-67
View File
@@ -1,67 +0,0 @@
import { persistentStore } from '$lib/utilities';
import { get, type Writable } from 'svelte/store';
import Visualization from '$lib/components/Visualization.svelte';
import Stream from '$lib/components/Stream.svelte';
import ChartWidget from '$lib/components/widget/ChartWidget.svelte';
export interface WidgetConfig {
id: string | number;
component: keyof typeof WidgetComponents;
props?: Record<string, any>;
}
export interface WidgetContainerConfig {
id: string | number;
layout?: 'row' | 'column' | 'wrap';
header?: string;
widgets: Array<WidgetConfig | WidgetContainerConfig>;
}
export const isWidgetConfig = (
widget: WidgetConfig | WidgetContainerConfig
): widget is WidgetConfig => 'component' in widget;
export const WidgetComponents = {
Visualization,
Stream,
ChartWidget
};
interface View {
name: string;
content: WidgetContainerConfig;
}
const defaultViews: View[] = [
{
name: 'Stream',
content: {
id: 'root',
layout: 'column',
widgets: [{ id: 2, component: 'Stream' }]
}
},
{
name: '3D representation',
content: {
id: 'root',
layout: 'column',
widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }]
}
},
{
name: 'Split screen',
content: {
id: 'root',
widgets: [
{ id: 2, component: 'Stream' },
{ id: 2, component: 'Visualization', props: { debug: true } }
]
}
}
];
export const views: Writable<View[]> = persistentStore('views', defaultViews);
export const selectedView = persistentStore('selected_view', get(views)[0].name);
-20
View File
@@ -1,20 +0,0 @@
import { api } from '$lib/api';
import { notifications } from '$lib/components/toasts/notifications';
import { writable, type Writable } from 'svelte/store';
let featureFlagsStore: Writable<Record<string, boolean>>;
export function useFeatureFlags() {
if (!featureFlagsStore) {
featureFlagsStore = writable<Record<string, boolean>>({});
api.get<Record<string, boolean>>('/api/features').then((result) => {
if (result.isOk()) featureFlagsStore.set(result.inner);
else {
notifications.error('Feature flag could not be fetched', 2500);
}
});
}
return featureFlagsStore;
}
-24
View File
@@ -1,24 +0,0 @@
import { writable } from 'svelte/store';
export const isFullscreen = writable(false);
export function toggleFullscreen() {
isFullscreen.update((state) => {
!state ? document.documentElement.requestFullscreen() : document.exitFullscreen();
return !state;
});
}
export function enterFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
isFullscreen.set(true);
}
}
export function exitFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
isFullscreen.set(false);
}
}
+30 -21
View File
@@ -1,27 +1,36 @@
import { type IMU } from '$lib/types/models';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { IMU } from '$lib/types/models';
let imu_data = {
x: <number[]>[],
y: <number[]>[],
z: <number[]>[],
imu_temp: <number[]>[],
altitude: <number[]>[],
pressure: <number[]>[],
bmp_temp: <number[]>[]
};
const maxIMUData = 100; const maxIMUData = 100;
export const imu = (() => { function createIMU() {
const { subscribe, update } = writable({ const { subscribe, update } = writable(imu_data);
x: [] as number[],
y: [] as number[],
z: [] as number[],
heading: [] as number[],
altitude: [] as number[],
pressure: [] as number[],
bmp_temp: [] as number[]
});
const addData = (content: IMU) => { return {
update(data => { subscribe,
(Object.keys(content) as (keyof IMU)[]).forEach(key => { addData: (content: IMU) => {
data[key] = [...data[key], content[key]].slice(-maxIMUData); update((imu_data) => ({
}); ...imu_data,
return data; x: [...imu_data.x, content.x].slice(-maxIMUData),
}); y: [...imu_data.y, content.y].slice(-maxIMUData),
}; z: [...imu_data.z, content.z].slice(-maxIMUData),
imu_temp: [...imu_data.imu_temp, content.imu_temp].slice(-maxIMUData),
altitude: [...imu_data.altitude, content.altitude].slice(-maxIMUData),
pressure: [...imu_data.pressure, content.pressure].slice(-maxIMUData),
bmp_temp: [...imu_data.bmp_temp, content.bmp_temp].slice(-maxIMUData)
}));
}
};
}
return { subscribe, addData }; export const imu = createIMU();
})();
-5
View File
@@ -2,8 +2,3 @@ export * from './socket-store';
export * from './logging-store'; export * from './logging-store';
export * from './model-store'; export * from './model-store';
export * from './socket'; export * from './socket';
export * from './fullscreen';
export * from './telemetry';
export * from './analytics';
export * from './featureFlags';
export * from './location-store';
+29
View File
@@ -0,0 +1,29 @@
import { writable } from 'svelte/store';
export type LidarPoint = {
distance: number;
angle: number;
quality: number;
};
let lidar_data = {
points: <LidarPoint[]>[]
};
const maxLidarData = 600;
function createLidar() {
const { subscribe, update } = writable(lidar_data);
return {
subscribe,
addData: (lidarPoint: LidarPoint) => {
update((lidar_data) => ({
...lidar_data,
points: [...lidar_data.points, lidarPoint].slice(-maxLidarData)
}));
}
};
}
export const lidar = createLidar();
-5
View File
@@ -1,5 +0,0 @@
import { persistentStore } from '$lib/utilities';
import { writable } from 'svelte/store';
import appEnv from 'app-env';
export const location = appEnv.VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '');
+4 -12
View File
@@ -1,22 +1,14 @@
import type { ControllerInput } from '$lib/types/models'; import type { ControllerInput } from '$lib/models';
import { persistentStore } from '$lib/utilities/svelte-utilities'; import { persistentStore } from '$lib/utilities/svelte-utilities';
import { writable, type Writable } from 'svelte/store'; import { writable, type Writable } from 'svelte/store';
export const emulateModel = writable(true); export const emulateModel = writable(true);
export const jointNames = persistentStore('joint_names', <string[]>[]); export const jointNames = persistentStore('joint_names', []);
export const model = writable(); export const model = writable();
export const modes = [ export const modes = ['deactivated', 'idle', 'calibration', 'rest', 'stand', 'crawl', 'walk'] as const;
'deactivated',
'idle',
'calibration',
'rest',
'stand',
'crawl',
'walk'
] as const;
export type Modes = (typeof modes)[number]; export type Modes = (typeof modes)[number];
@@ -30,7 +22,7 @@ export enum ModesEnum {
Walk Walk
} }
export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated); export const mode: Writable<ModesEnum> = writable(ModesEnum.Walk);
export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0]); export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0]);
+4 -1
View File
@@ -1,5 +1,5 @@
import { writable, type Writable } from 'svelte/store'; import { writable, type Writable } from 'svelte/store';
import { type angles } from '$lib/types/models'; import { type angles } from '$lib/models';
export const servoAnglesOut: Writable<number[]> = writable([ export const servoAnglesOut: Writable<number[]> = writable([
0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90 0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
@@ -8,6 +8,7 @@ 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 battery = writable({});
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({});
@@ -15,6 +16,7 @@ export const distances = writable({});
export interface socketDataCollection { export interface socketDataCollection {
angles: Writable<angles>; angles: Writable<angles>;
logs: Writable<string[]>; logs: Writable<string[]>;
battery: Writable<unknown>;
mpu: Writable<unknown>; mpu: Writable<unknown>;
distances: Writable<unknown>; distances: Writable<unknown>;
} }
@@ -22,6 +24,7 @@ export interface socketDataCollection {
export const socketData = { export const socketData = {
angles: servoAngles, angles: servoAngles,
logs, logs,
battery,
mpu, mpu,
distances distances
}; };
+35 -25
View File
@@ -1,35 +1,45 @@
import type { DownloadOTA } from '$lib/types/models'; import type { Battery, DownloadOTA } from '$lib/types/models';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
let telemetry_data = { let telemetry_data = {
rssi: { rssi: {
rssi: 0 rssi: 0
}, },
download_ota: { battery: {
status: 'none', voltage: 100,
progress: 0, current: false
error: '' },
} download_ota: {
status: 'none',
progress: 0,
error: ''
}
}; };
function createTelemetry() { function createTelemetry() {
const { subscribe, set, update } = writable(telemetry_data); const { subscribe, set, update } = writable(telemetry_data);
return { return {
subscribe, subscribe,
setRSSI: (data: number) => { setRSSI: (data: number) => {
update(telemetry_data => ({ update((telemetry_data) => ({
...telemetry_data, ...telemetry_data,
rssi: { rssi: data } rssi: { rssi: data }
})); }));
}, },
setDownloadOTA: (data: DownloadOTA) => { setBattery: (data: Battery) => {
update(telemetry_data => ({ update((telemetry_data) => ({
...telemetry_data, ...telemetry_data,
download_ota: { status: data.status, progress: data.progress, error: data.error } battery: { voltage: data.voltage, current: data.current }
})); }));
} },
}; setDownloadOTA: (data: DownloadOTA) => {
update((telemetry_data) => ({
...telemetry_data,
download_ota: { status: data.status, progress: data.progress, error: data.error }
}));
}
};
} }
export const telemetry = createTelemetry(); export const telemetry = createTelemetry();
+55
View File
@@ -0,0 +1,55 @@
import { writable } from 'svelte/store';
import { goto } from '$app/navigation';
import { jwtDecode } from 'jwt-decode';
export type userProfile = {
username: string;
admin: boolean;
bearer_token: string;
};
type decodedJWT = {
username: string;
admin: boolean;
};
let empty = {
username: '',
admin: false,
bearer_token: ''
};
function createStore() {
const { subscribe, set } = writable(empty);
// retrieve store from sessionStorage / localStorage if available
const userdata = localStorage.getItem('user');
if (userdata) {
set(JSON.parse(userdata));
}
return {
subscribe,
init: (access_token: string) => {
const decoded: decodedJWT = jwtDecode(access_token);
const userdata = {
bearer_token: access_token,
username: decoded.username,
admin: decoded.admin
};
set(userdata);
// persist store in sessionStorage / localStorage
localStorage.setItem('user', JSON.stringify(userdata));
},
invalidate: () => {
console.log('Log out user');
set(empty);
// remove localStorage "user"
localStorage.removeItem('user');
// redirect to login page
goto('/');
}
};
}
export const user = createStore();
-17
View File
@@ -1,17 +0,0 @@
declare module 'three/src/math/MathUtils' {
export function generateUUID(): string;
export function clamp(value: number, min: number, max: number): number;
export function euclideanModulo(n: number, m: number): number;
export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number;
export function lerp(x: number, y: number, t: number): number;
export function smoothstep(x: number, min: number, max: number): number;
export function smootherstep(x: number, min: number, max: number): number;
export function randInt(low: number, high: number): number;
export function randFloat(low: number, high: number): number;
export function randFloatSpread(range: number): number;
export function degToRad(degrees: number): number;
export function radToDeg(radians: number): number;
export function isPowerOfTwo(value: number): boolean;
export function ceilPowerOfTwo(value: number): number;
export function floorPowerOfTwo(value: number): number;
}
+103 -138
View File
@@ -1,178 +1,143 @@
export type vector = { x: number; y: number };
export interface ControllerInput {
left: vector;
right: vector;
height: number;
speed: number;
s1: number;
}
export type GithubRelease = {
message: string;
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
};
export type angles = number[] | Int16Array;
export type WifiStatus = { export type WifiStatus = {
status: number; status: number;
local_ip: string; local_ip: string;
mac_address: string; mac_address: string;
rssi: number; rssi: number;
ssid: string; ssid: string;
bssid: string; bssid: string;
channel: number; channel: number;
subnet_mask: string; subnet_mask: string;
gateway_ip: string; gateway_ip: string;
dns_ip_1: string; dns_ip_1: string;
dns_ip_2?: string; dns_ip_2?: string;
}; };
export type WifiSettings = { export type WifiSettings = {
hostname: string; hostname: string;
priority_RSSI: boolean; priority_RSSI: boolean;
wifi_networks: KnownNetworkItem[]; wifi_networks: KnownNetworkItem[];
};
export type NetworkList = {
networks: NetworkItem[];
}; };
export type KnownNetworkItem = { export type KnownNetworkItem = {
ssid: string; ssid: string;
password: string; password: string;
static_ip_config: boolean; static_ip_config: boolean;
local_ip?: string; local_ip?: string;
subnet_mask?: string; subnet_mask?: string;
gateway_ip?: string; gateway_ip?: string;
dns_ip_1?: string; dns_ip_1?: string;
dns_ip_2?: string; dns_ip_2?: string;
}; };
export type NetworkItem = { export type NetworkItem = {
rssi: number; rssi: number;
ssid: string; ssid: string;
bssid: string; bssid: string;
channel: number; channel: number;
encryption_type: number; encryption_type: number;
}; };
export type ApStatus = { export type ApStatus = {
status: number; status: number;
ip_address: string; ip_address: string;
mac_address: string; mac_address: string;
station_num: number; station_num: number;
}; };
export type ApSettings = { export type ApSettings = {
provision_mode: number; provision_mode: number;
ssid: string; ssid: string;
password: string; password: string;
channel: number; channel: number;
ssid_hidden: boolean; ssid_hidden: boolean;
max_clients: number; max_clients: number;
local_ip: string; local_ip: string;
gateway_ip: string; gateway_ip: string;
subnet_mask: string; subnet_mask: string;
};
export type NTPStatus = {
status: number;
utc_time: string;
local_time: string;
server: string;
uptime: number;
};
export type RSSI = {
rssi: number;
ssid: string;
};
export type Battery = {
voltage: number;
current: boolean;
}; };
export type DownloadOTA = { export type DownloadOTA = {
status: string; status: string;
progress: number; progress: number;
error: string; error: string;
};
export type NTPSettings = {
enabled: boolean;
server: string;
tz_label: string;
tz_format: string;
}; };
export type Analytics = { export type Analytics = {
max_alloc_heap: number; max_alloc_heap: number;
psram_size: number; psram_size: number;
free_psram: number; free_psram: number;
free_heap: number; free_heap: number;
total_heap: number; total_heap: number;
min_free_heap: number; min_free_heap: number;
core_temp: number; core_temp: number;
fs_total: number; fs_total: number;
fs_used: number; fs_used: number;
uptime: number; uptime: number;
cpu0_usage: number; cpu0_usage: number;
cpu1_usage: number; cpu1_usage: number;
cpu_usage: number; cpu_usage: number;
}; };
export type Rssi = { export type Rssi = {
rssi: number; rssi: number;
ssid: string; ssid: string;
}; };
export type StaticSystemInformation = { export type StaticSystemInformation = {
esp_platform: string; esp_platform: string;
firmware_version: string; firmware_version: string;
cpu_freq_mhz: number; cpu_freq_mhz: number;
cpu_type: string; cpu_type: string;
cpu_rev: number; cpu_rev: number;
cpu_cores: number; cpu_cores: number;
sketch_size: number; sketch_size: number;
free_sketch_space: number; free_sketch_space: number;
sdk_version: string; sdk_version: string;
arduino_version: string; arduino_version: string;
flash_chip_size: number; flash_chip_size: number;
flash_chip_speed: number; flash_chip_speed: number;
cpu_reset_reason: string; cpu_reset_reason: string;
}; };
export type SystemInformation = Analytics & StaticSystemInformation; export type SystemInformation = Analytics & StaticSystemInformation;
export type IMU = { export type IMU = {
x: number; x: number;
y: number; y: number;
z: number; z: number;
heading: number; imu_temp: number;
altitude: number; altitude: number;
bmp_temp: number; bmp_temp: number;
pressure: number; pressure: number;
}; };
export interface I2CDevice { export interface I2CDevice {
address: number; address: number;
part_number: string; part_number: string;
name: string; name: string;
}
export type CameraSettings = {
framesize: number;
quality: number;
brightness: number;
contrast: number;
saturation: number;
sharpness: number;
denoise: number;
special_effect: number;
wb_mode: number;
vflip: boolean;
hmirror: boolean;
};
export type File = number;
export interface Directory {
[key: string]: File | Directory;
}
export type Servo = {
name: string;
channel: number;
inverted: boolean;
angle: number;
center_angle: number;
};
export type ServoConfiguration = {
is_active: boolean;
servo_pwm_frequency: number;
servo_oscillator_frequency: number;
servos: Servo[];
}; };
-14
View File
@@ -1,14 +0,0 @@
declare module 'uzip' {
interface UZIP {
parse(data: Uint8Array | ArrayBuffer): any;
compress(data: any): Uint8Array | ArrayBuffer;
compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer;
decompress(data: Uint8Array | ArrayBuffer): any;
decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer;
encode(data: any): Uint8Array | ArrayBuffer;
decode(data: Uint8Array | ArrayBuffer): any;
}
const uzip: UZIP;
export default uzip;
}
+1 -3
View File
@@ -4,6 +4,4 @@ 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 './location-utilities';
export * from './string-utilities';
export * from './color-utilities';
@@ -0,0 +1,9 @@
export const hostname = 'localhost'; //window.location.hostname;
export const isSecure = true; // window.location.protocol === 'https:';
export const location = 'localhost:5173'; //window.location; //import.meta.env.VITE_API_URL.replace('hostname', hostname);
const socketScheme = isSecure ? 'wss://' : 'ws://';
export const socketLocation = socketScheme + location; // import.meta.env.VITE_SOCKET_URL.replace('hostname', hostname);
-26
View File
@@ -2,35 +2,9 @@ import { Color, LoaderUtils, Vector3 } from 'three';
import URDFLoader, { type URDFRobot } from 'urdf-loader'; import URDFLoader, { type URDFRobot } from 'urdf-loader';
import { XacroLoader } from 'xacro-parser'; import { XacroLoader } from 'xacro-parser';
import { Result } from '$lib/utilities'; import { Result } from '$lib/utilities';
import { jointNames, model } from '$lib/stores';
import uzip from 'uzip';
import { fileService } from '$lib/services';
let model_xml: XMLDocument; let model_xml: XMLDocument;
export const populateModelCache = async () => {
await cacheModelFiles();
const modelRes = await loadModelAsync('/spot_micro.urdf.xacro');
if (modelRes.isOk()) {
const [urdf, JOINT_NAME] = modelRes.inner;
jointNames.set(JOINT_NAME);
model.set(urdf);
} else {
console.error(modelRes.inner, { exception: modelRes.exception });
}
};
export const cacheModelFiles = async () => {
let data = await fetch('/stl.zip');
var files = uzip.parse(await data.arrayBuffer());
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const url = new URL(path, window.location.href);
fileService.saveFile(url.toString(), data);
}
};
export const loadModelAsync = async ( export const loadModelAsync = async (
url: string url: string
): Promise<Result<[URDFRobot, string[]], string>> => { ): Promise<Result<[URDFRobot, string[]], string>> => {
+8 -8
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 isEmbeddedApp = import.meta.env.VITE_EMBEDDED_BUILD === 'true';
const savedValue = browser ? localStorage.getItem(key) : null;
const data: T = savedValue !== null ? JSON.parse(savedValue) : initialValue;
const store = writable<T>();
store.subscribe(value => { export const persistentStore = (key: string, initialValue: any) => {
if (browser) localStorage.setItem(key, JSON.stringify(value)); const savedValue = browser ? JSON.parse(localStorage.getItem(key) as string) : null;
const data = savedValue !== null ? savedValue : initialValue;
const store = writable(data);
store.subscribe((value) => {
browser && localStorage.setItem(key, JSON.stringify(value));
}); });
store.set(data);
return store; return store;
}; };
+102 -98
View File
@@ -1,125 +1,129 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte';
import { page } from '$app/state' import { user } from '$lib/stores/user';
import { Modals, modals } from 'svelte-modals' import { telemetry } from '$lib/stores/telemetry';
import Toast from '$lib/components/toasts/Toast.svelte' import { analytics } from '$lib/stores/analytics';
import { notifications } from '$lib/components/toasts/notifications' import type { userProfile } from '$lib/stores/user';
import { fade } from 'svelte/transition' import { page } from '$app/stores';
import '../app.css' import { Modals, closeModal } from 'svelte-modals';
import Menu from '../lib/components/menu/Menu.svelte' import Toast from '$lib/components/toasts/Toast.svelte';
import Statusbar from '../lib/components/statusbar/statusbar.svelte' import { notifications } from '$lib/components/toasts/notifications';
import { import { fade } from 'svelte/transition';
telemetry, import '../app.css';
analytics, import Menu from './menu.svelte';
ModesEnum, import Statusbar from './statusbar.svelte';
kinematicData, import Login from './login.svelte';
mode, import { ModesEnum, kinematicData, mode, outControllerData, servoAngles, servoAnglesOut, socket } from '$lib/stores';
outControllerData, import type { Analytics, Battery, DownloadOTA } from '$lib/types/models';
servoAngles, import { api } from '$lib/api';
servoAnglesOut,
socket,
location,
useFeatureFlags
} from '$lib/stores'
import type { Analytics, DownloadOTA } from '$lib/types/models'
interface Props {
children?: import('svelte').Snippet
}
let { children }: Props = $props() onMount(async () => {
if ($user.bearer_token !== '') {
await validateUser($user);
}
const ws_token = $page.data.features.security ? '?access_token=' + $user.bearer_token : '';
socket.init(`ws://${window.location.host}/ws/events${ws_token}`);
const features = useFeatureFlags() addEventListeners();
onMount(async () => { outControllerData.subscribe((data) => socket.sendEvent("input", {data}));
const ws = $location ? $location : window.location.host mode.subscribe((data) => socket.sendEvent("mode", {data}));
socket.init(`ws://${ws}/api/ws/events`) servoAnglesOut.subscribe((data) => socket.sendEvent("angles", {data}));
kinematicData.subscribe((data) => socket.sendEvent("position", {data}));
});
addEventListeners() onDestroy(() => {
removeEventListeners();
});
outControllerData.subscribe(data => socket.sendEvent('input', { data })) const addEventListeners = () => {
mode.subscribe(data => socket.sendEvent('mode', { data })) socket.on('open', handleOpen);
servoAnglesOut.subscribe(data => socket.sendEvent('angles', { data })) socket.on('close', handleClose);
kinematicData.subscribe(data => socket.sendEvent('position', { data })) socket.on('error', handleError);
}) socket.on('rssi', handleNetworkStatus);
socket.on('mode', (data:ModesEnum) => mode.set(data));
socket.on('angles', (angles:number[]) => { if (angles.length) servoAngles.set(angles)});
if ($page.data.features.analytics) socket.on('analytics', handleAnalytics);
if ($page.data.features.battery) socket.on('battery', handleBattery);
if ($page.data.features.download_firmware) socket.on('otastatus', handleOAT);
if ($page.data.features.sonar) socket.on('sonar', data => console.log(data))
};
onDestroy(() => { const removeEventListeners = () => {
removeEventListeners() socket.off('analytics', handleAnalytics);
}) socket.off('open', handleOpen);
socket.off('close', handleClose);
socket.off('rssi', handleNetworkStatus);
socket.off('battery', handleBattery);
socket.off('otastatus', handleOAT);
};
const addEventListeners = () => { async function validateUser(userdata: userProfile) {
socket.on('open', handleOpen) const result = await api.get('/api/verifyAuthorization')
socket.on('close', handleClose) if (result.isErr()){
socket.on('error', handleError) user.invalidate();
socket.on('rssi', handleNetworkStatus) console.error('Error:', result.inner);
socket.on('mode', (data: ModesEnum) => mode.set(data)) }
socket.on('analytics', handleAnalytics) }
socket.on('angles', (angles: number[]) => {
if (angles.length) servoAngles.set(angles)
})
features.subscribe(data => {
if (data?.download_firmware) socket.on('otastatus', handleOAT)
if (data?.sonar) socket.on('sonar', data => console.log(data))
})
}
const removeEventListeners = () => { const handleOpen = () => {
socket.off('analytics', handleAnalytics) notifications.success('Connection to device established', 5000);
socket.off('open', handleOpen) };
socket.off('close', handleClose)
socket.off('rssi', handleNetworkStatus)
socket.off('otastatus', handleOAT)
}
const handleOpen = () => { const handleClose = () => {
notifications.success('Connection to device established', 5000) notifications.error('Connection to device lost', 5000);
} telemetry.setRSSI(0);
};
const handleClose = () => { const handleError = (data: any) => console.error(data);
notifications.error('Connection to device lost', 5000)
telemetry.setRSSI(0)
}
const handleError = (data: any) => console.error(data) const handleAnalytics = (data: Analytics) => analytics.addData(data);
const handleAnalytics = (data: Analytics) => analytics.addData(data) const handleNetworkStatus = (data: number) => telemetry.setRSSI(data);
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data) const handleBattery = (data: Battery) => telemetry.setBattery(data);
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data) const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data);
let menuOpen = false;
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 h-screen"> {#if $page.data.features.security && $user.bearer_token === ''}
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} /> <Login />
<div class="drawer-content flex flex-col"> {:else}
<!-- Status bar content here --> <div class="drawer">
<Statusbar /> <input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
<div class="drawer-content flex flex-col">
<!-- Status bar content here -->
<Statusbar />
<!-- Main page content here --> <!-- Main page content here -->
{@render children?.()} <slot />
</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" />
<Menu menuClicked={() => (menuOpen = false)} /> <Menu
</div> on:menuClicked={() => menuOpen = false}
</div> />
</div>
</div>
{/if}
<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()} <div
<div slot="backdrop"
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"
transition:fade transition:fade
onclick={modals.closeAll}> on:click={closeModal}
</div> />
{/snippet}
</Modals> </Modals>
<Toast /> <Toast />
+20 -2
View File
@@ -1,4 +1,7 @@
export const prerender = false; import { jointNames, model } from '$lib/stores';
import { loadModelAsync } from '$lib/utilities/model-utilities';
export const prerender = true;
export const ssr = false; export const ssr = false;
const registerFetchIntercept = async () => { const registerFetchIntercept = async () => {
@@ -11,9 +14,24 @@ const registerFetchIntercept = async () => {
}; };
}; };
export const load = async () => { const loadModelFiles = async () => {
const modelRes = await loadModelAsync('/spot_micro.urdf.xacro');
if (modelRes.isOk()) {
const [urdf, JOINT_NAME] = modelRes.inner;
jointNames.set(JOINT_NAME);
model.set(urdf);
} else {
console.error(modelRes.inner, { exception: modelRes.exception });
}
};
export const load = async ({ fetch }) => {
await registerFetchIntercept(); await registerFetchIntercept();
await loadModelFiles();
const result = await fetch('/api/features');
const features = await result.json();
return { return {
features,
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',
+19 -24
View File
@@ -1,29 +1,24 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation' import type { PageData } from './$types';
import Visualization from '$lib/components/Visualization.svelte' import { notifications } from '$lib/components/toasts/notifications';
import { socket } from '$lib/stores' import Visualization from '$lib/components/Visualization.svelte';
import { onMount } from 'svelte'
onMount(() => { export let data: PageData;
socket.subscribe(isConnected => {
if (isConnected) {
goto('/controller')
}
})
})
</script> </script>
<div class="w-full h-full flex justify-center items-center"> <div class="hero bg-base-100 h-screen">
<div class="h-full flex flex-col"> <div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center">
<div class="grow-3 w-80 relative"> <div class="w-64 h-64">
<Visualization sky={false} orbit panel={false} ground={false} zoom={8} /> <Visualization sky={false} orbit panel={false} ground={false}/>
<div class="absolute bottom-0 w-full h-40 bg-gradient-to-t from-base-100 to-transparent"> </div>
</div> <div class="card-body w-80">
</div> <h2 class="card-title text-center text-2xl">Welcome to {data.app_name}</h2>
<div class="grow-3 flex justify-center"> <p class="py-6 text-center"></p>
<a class="btn btn-primary rounded-full" href={$socket ? '/controller' : '/connection'}> <a
Add Robot Dog class="btn btn-primary"
</a> href="/controller"
</div> on:click={() => notifications.success('You did it!', 1000)}>Begin</a
</div> >
</div>
</div>
</div> </div>
@@ -1,28 +0,0 @@
<script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte';
import { WiFi } from '$lib/components/icons';
import { location, socket, useFeatureFlags } from '$lib/stores';
const features = useFeatureFlags();
const update = () => {
const ws = $location ? $location : window.location.host;
socket.init(`ws://${ws}/api/ws/events`);
};
</script>
<SettingsCard collapsible={false}>
{#snippet icon()}
<WiFi class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet}
{#snippet title()}
<span >Connection</span>
{/snippet}
<div class="flex">
<label class="label w-32" for="server">Address:</label>
<input class="input" bind:value={$location} />
</div>
<button class="btn btn-primary" onclick={update}>Update</button>
</SettingsCard>
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
import { goto } from '$app/navigation';
export const load = (async () => {
goto('/');
return;
}) satisfies PageLoad;
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Connection from './Connection.svelte'; import NTP from './NTP.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 /> <NTP />
</div> </div>
@@ -2,6 +2,6 @@ import type { PageLoad } from './$types';
export const load = (async () => { export const load = (async () => {
return { return {
title: 'Connection' title: 'NTP'
}; };
}) satisfies PageLoad; }) satisfies PageLoad;
+263
View File
@@ -0,0 +1,263 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import Collapsible from '$lib/components/Collapsible.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
import { notifications } from '$lib/components/toasts/notifications';
import { TIME_ZONES } from './timezones';
import NTP from '~icons/tabler/clock-check';
import Server from '~icons/tabler/server';
import Clock from '~icons/tabler/clock';
import UTC from '~icons/tabler/clock-pin';
import Stopwatch from '~icons/tabler/24-hours';
import type { NTPSettings, NTPStatus } from '$lib/types/models';
import { api } from '$lib/api';
let ntpSettings: NTPSettings;
let ntpStatus: NTPStatus;
async function getNTPStatus() {
const result = await api.get<NTPStatus>('/api/ntpStatus');
if (result.isErr()){
console.error('Error:', result.inner);
return
}
ntpStatus = result.inner
}
async function getNTPSettings() {
const result = await api.get<NTPSettings>('/api/ntpSettings');
if (result.isErr()){
console.error('Error:', result.inner);
return
}
ntpSettings = result.inner
}
const interval = setInterval(async () => {
getNTPStatus();
}, 5000);
onDestroy(() => clearInterval(interval));
onMount(() => {
if (!$page.data.features.security || $user.admin) {
getNTPSettings();
}
});
let formField: any;
let formErrors = {
server: false
};
async function postNTPSettings(data: NTPSettings) {
const result = await api.post<NTPSettings>('/api/ntpSettings', data);
if (result.isErr()){
notifications.error('User not authorized.', 3000);
console.error('Error:', result.inner);
return
}
ntpSettings = result.inner
}
function handleSubmitNTP() {
let valid = true;
// Validate Server
// RegEx for IPv4
const regexExpIPv4 =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/;
const regexExpURL =
/[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?/i;
if (!regexExpURL.test(ntpSettings.server) && !regexExpIPv4.test(ntpSettings.server)) {
valid = false;
formErrors.server = true;
} else {
formErrors.server = false;
}
ntpSettings.tz_format = TIME_ZONES[ntpSettings.tz_label];
// Submit JSON to REST API
if (valid) {
postNTPSettings(ntpSettings);
//alert('Form Valid');
}
}
function convertSeconds(seconds: number) {
// Calculate the number of seconds, minutes, hours, and days
let minutes = Math.floor(seconds / 60);
let hours = Math.floor(minutes / 60);
let days = Math.floor(hours / 24);
// Calculate the remaining hours, minutes, and seconds
hours = hours % 24;
minutes = minutes % 60;
seconds = seconds % 60;
// Create the formatted string
let result = '';
if (days > 0) {
result += days + ' day' + (days > 1 ? 's' : '') + ' ';
}
if (hours > 0) {
result += hours + ' hour' + (hours > 1 ? 's' : '') + ' ';
}
if (minutes > 0) {
result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' ';
}
result += seconds + ' second' + (seconds > 1 ? 's' : '');
return result;
}
</script>
<SettingsCard collapsible={false}>
<Clock slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">Network Time</span>
<div class="w-full overflow-x-auto">
{#await getNTPStatus()}
<Spinner />
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div
class="mask mask-hexagon h-auto w-10 {ntpStatus.status === 1
? 'bg-success'
: 'bg-error'}"
>
<NTP
class="h-auto w-full scale-75 {ntpStatus.status === 1
? 'text-success-content'
: 'text-error-content'}"
/>
</div>
<div>
<div class="font-bold">Status</div>
<div class="text-sm opacity-75">
{ntpStatus.status === 1 ? 'Active' : 'Inactive'}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Server class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">NTP Server</div>
<div class="text-sm opacity-75">
{ntpStatus.server}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Clock class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Local Time</div>
<div class="text-sm opacity-75">
{new Intl.DateTimeFormat('en-GB', {
dateStyle: 'long',
timeStyle: 'long'
}).format(new Date(ntpStatus.local_time))}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<UTC class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">UTC Time</div>
<div class="text-sm opacity-75">
{new Intl.DateTimeFormat('en-GB', {
dateStyle: 'long',
timeStyle: 'long',
timeZone: 'UTC'
}).format(new Date(ntpStatus.utc_time))}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Stopwatch class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Uptime</div>
<div class="text-sm opacity-75">
{convertSeconds(ntpStatus.uptime)}
</div>
</div>
</div>
</div>
{/await}
</div>
{#if !$page.data.features.security || $user.admin}
<Collapsible open={false} on:closed={getNTPSettings}>
<span slot="title">Change NTP Settings</span>
<form
class="form-control w-full"
on:submit|preventDefault={handleSubmitNTP}
novalidate
bind:this={formField}
>
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
bind:checked={ntpSettings.enabled}
class="checkbox checkbox-primary"
/>
<span class="">Enable NTP</span>
</label>
<label class="label" for="server">
<span class="label-text text-md">Server</span>
</label>
<input
type="text"
min="3"
max="64"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.server
? 'border-error border-2'
: ''}"
bind:value={ntpSettings.server}
id="server"
required
/>
<label class="label" for="subnet">
<span class="label-text-alt text-error {formErrors.server ? '' : 'hidden'}"
>Must be a valid IPv4 address or URL</span
>
</label>
<label class="label" for="tz">
<span class="label-text text-md">Pick Time Zone</span>
</label>
<select class="select select-bordered" bind:value={ntpSettings.tz_label} id="tz">
{#each Object.entries(TIME_ZONES) as [tz_label, tz_format]}
<option value={tz_label}>{tz_label}</option>
{/each}
</select>
<div class="mt-6 place-self-end">
<button class="btn btn-primary" type="submit">Apply Settings</button>
</div>
</form>
</Collapsible>
{/if}
</SettingsCard>
+466
View File
@@ -0,0 +1,466 @@
export type TimeZones = {
[name: string]: string
};
export const TIME_ZONES: TimeZones = {
"Africa/Abidjan": "GMT0",
"Africa/Accra": "GMT0",
"Africa/Addis_Ababa": "EAT-3",
"Africa/Algiers": "CET-1",
"Africa/Asmara": "EAT-3",
"Africa/Bamako": "GMT0",
"Africa/Bangui": "WAT-1",
"Africa/Banjul": "GMT0",
"Africa/Bissau": "GMT0",
"Africa/Blantyre": "CAT-2",
"Africa/Brazzaville": "WAT-1",
"Africa/Bujumbura": "CAT-2",
"Africa/Cairo": "EET-2",
"Africa/Casablanca": "UNK-1",
"Africa/Ceuta": "CET-1CEST,M3.5.0,M10.5.0/3",
"Africa/Conakry": "GMT0",
"Africa/Dakar": "GMT0",
"Africa/Dar_es_Salaam": "EAT-3",
"Africa/Djibouti": "EAT-3",
"Africa/Douala": "WAT-1",
"Africa/El_Aaiun": "UNK-1",
"Africa/Freetown": "GMT0",
"Africa/Gaborone": "CAT-2",
"Africa/Harare": "CAT-2",
"Africa/Johannesburg": "SAST-2",
"Africa/Juba": "EAT-3",
"Africa/Kampala": "EAT-3",
"Africa/Khartoum": "CAT-2",
"Africa/Kigali": "CAT-2",
"Africa/Kinshasa": "WAT-1",
"Africa/Lagos": "WAT-1",
"Africa/Libreville": "WAT-1",
"Africa/Lome": "GMT0",
"Africa/Luanda": "WAT-1",
"Africa/Lubumbashi": "CAT-2",
"Africa/Lusaka": "CAT-2",
"Africa/Malabo": "WAT-1",
"Africa/Maputo": "CAT-2",
"Africa/Maseru": "SAST-2",
"Africa/Mbabane": "SAST-2",
"Africa/Mogadishu": "EAT-3",
"Africa/Monrovia": "GMT0",
"Africa/Nairobi": "EAT-3",
"Africa/Ndjamena": "WAT-1",
"Africa/Niamey": "WAT-1",
"Africa/Nouakchott": "GMT0",
"Africa/Ouagadougou": "GMT0",
"Africa/Porto-Novo": "WAT-1",
"Africa/Sao_Tome": "GMT0",
"Africa/Tripoli": "EET-2",
"Africa/Tunis": "CET-1",
"Africa/Windhoek": "CAT-2",
"America/Adak": "HST10HDT,M3.2.0,M11.1.0",
"America/Anchorage": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Anguilla": "AST4",
"America/Antigua": "AST4",
"America/Araguaina": "UNK3",
"America/Argentina/Buenos_Aires": "UNK3",
"America/Argentina/Catamarca": "UNK3",
"America/Argentina/Cordoba": "UNK3",
"America/Argentina/Jujuy": "UNK3",
"America/Argentina/La_Rioja": "UNK3",
"America/Argentina/Mendoza": "UNK3",
"America/Argentina/Rio_Gallegos": "UNK3",
"America/Argentina/Salta": "UNK3",
"America/Argentina/San_Juan": "UNK3",
"America/Argentina/San_Luis": "UNK3",
"America/Argentina/Tucuman": "UNK3",
"America/Argentina/Ushuaia": "UNK3",
"America/Aruba": "AST4",
"America/Asuncion": "UNK4UNK,M10.1.0/0,M3.4.0/0",
"America/Atikokan": "EST5",
"America/Bahia": "UNK3",
"America/Bahia_Banderas": "CST6CDT,M4.1.0,M10.5.0",
"America/Barbados": "AST4",
"America/Belem": "UNK3",
"America/Belize": "CST6",
"America/Blanc-Sablon": "AST4",
"America/Boa_Vista": "UNK4",
"America/Bogota": "UNK5",
"America/Boise": "MST7MDT,M3.2.0,M11.1.0",
"America/Cambridge_Bay": "MST7MDT,M3.2.0,M11.1.0",
"America/Campo_Grande": "UNK4",
"America/Cancun": "EST5",
"America/Caracas": "UNK4",
"America/Cayenne": "UNK3",
"America/Cayman": "EST5",
"America/Chicago": "CST6CDT,M3.2.0,M11.1.0",
"America/Chihuahua": "MST7MDT,M4.1.0,M10.5.0",
"America/Costa_Rica": "CST6",
"America/Creston": "MST7",
"America/Cuiaba": "UNK4",
"America/Curacao": "AST4",
"America/Danmarkshavn": "GMT0",
"America/Dawson": "MST7",
"America/Dawson_Creek": "MST7",
"America/Denver": "MST7MDT,M3.2.0,M11.1.0",
"America/Detroit": "EST5EDT,M3.2.0,M11.1.0",
"America/Dominica": "AST4",
"America/Edmonton": "MST7MDT,M3.2.0,M11.1.0",
"America/Eirunepe": "UNK5",
"America/El_Salvador": "CST6",
"America/Fort_Nelson": "MST7",
"America/Fortaleza": "UNK3",
"America/Glace_Bay": "AST4ADT,M3.2.0,M11.1.0",
"America/Godthab": "UNK3UNK,M3.5.0/-2,M10.5.0/-1",
"America/Goose_Bay": "AST4ADT,M3.2.0,M11.1.0",
"America/Grand_Turk": "EST5EDT,M3.2.0,M11.1.0",
"America/Grenada": "AST4",
"America/Guadeloupe": "AST4",
"America/Guatemala": "CST6",
"America/Guayaquil": "UNK5",
"America/Guyana": "UNK4",
"America/Halifax": "AST4ADT,M3.2.0,M11.1.0",
"America/Havana": "CST5CDT,M3.2.0/0,M11.1.0/1",
"America/Hermosillo": "MST7",
"America/Indiana/Indianapolis": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Knox": "CST6CDT,M3.2.0,M11.1.0",
"America/Indiana/Marengo": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Petersburg": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Tell_City": "CST6CDT,M3.2.0,M11.1.0",
"America/Indiana/Vevay": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Vincennes": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Winamac": "EST5EDT,M3.2.0,M11.1.0",
"America/Inuvik": "MST7MDT,M3.2.0,M11.1.0",
"America/Iqaluit": "EST5EDT,M3.2.0,M11.1.0",
"America/Jamaica": "EST5",
"America/Juneau": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Kentucky/Louisville": "EST5EDT,M3.2.0,M11.1.0",
"America/Kentucky/Monticello": "EST5EDT,M3.2.0,M11.1.0",
"America/Kralendijk": "AST4",
"America/La_Paz": "UNK4",
"America/Lima": "UNK5",
"America/Los_Angeles": "PST8PDT,M3.2.0,M11.1.0",
"America/Lower_Princes": "AST4",
"America/Maceio": "UNK3",
"America/Managua": "CST6",
"America/Manaus": "UNK4",
"America/Marigot": "AST4",
"America/Martinique": "AST4",
"America/Matamoros": "CST6CDT,M3.2.0,M11.1.0",
"America/Mazatlan": "MST7MDT,M4.1.0,M10.5.0",
"America/Menominee": "CST6CDT,M3.2.0,M11.1.0",
"America/Merida": "CST6CDT,M4.1.0,M10.5.0",
"America/Metlakatla": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Mexico_City": "CST6CDT,M4.1.0,M10.5.0",
"America/Miquelon": "UNK3UNK,M3.2.0,M11.1.0",
"America/Moncton": "AST4ADT,M3.2.0,M11.1.0",
"America/Monterrey": "CST6CDT,M4.1.0,M10.5.0",
"America/Montevideo": "UNK3",
"America/Montreal": "EST5EDT,M3.2.0,M11.1.0",
"America/Montserrat": "AST4",
"America/Nassau": "EST5EDT,M3.2.0,M11.1.0",
"America/New_York": "EST5EDT,M3.2.0,M11.1.0",
"America/Nipigon": "EST5EDT,M3.2.0,M11.1.0",
"America/Nome": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Noronha": "UNK2",
"America/North_Dakota/Beulah": "CST6CDT,M3.2.0,M11.1.0",
"America/North_Dakota/Center": "CST6CDT,M3.2.0,M11.1.0",
"America/North_Dakota/New_Salem": "CST6CDT,M3.2.0,M11.1.0",
"America/Ojinaga": "MST7MDT,M3.2.0,M11.1.0",
"America/Panama": "EST5",
"America/Pangnirtung": "EST5EDT,M3.2.0,M11.1.0",
"America/Paramaribo": "UNK3",
"America/Phoenix": "MST7",
"America/Port-au-Prince": "EST5EDT,M3.2.0,M11.1.0",
"America/Port_of_Spain": "AST4",
"America/Porto_Velho": "UNK4",
"America/Puerto_Rico": "AST4",
"America/Punta_Arenas": "UNK3",
"America/Rainy_River": "CST6CDT,M3.2.0,M11.1.0",
"America/Rankin_Inlet": "CST6CDT,M3.2.0,M11.1.0",
"America/Recife": "UNK3",
"America/Regina": "CST6",
"America/Resolute": "CST6CDT,M3.2.0,M11.1.0",
"America/Rio_Branco": "UNK5",
"America/Santarem": "UNK3",
"America/Santiago": "UNK4UNK,M9.1.6/24,M4.1.6/24",
"America/Santo_Domingo": "AST4",
"America/Sao_Paulo": "UNK3",
"America/Scoresbysund": "UNK1UNK,M3.5.0/0,M10.5.0/1",
"America/Sitka": "AKST9AKDT,M3.2.0,M11.1.0",
"America/St_Barthelemy": "AST4",
"America/St_Johns": "NST3:30NDT,M3.2.0,M11.1.0",
"America/St_Kitts": "AST4",
"America/St_Lucia": "AST4",
"America/St_Thomas": "AST4",
"America/St_Vincent": "AST4",
"America/Swift_Current": "CST6",
"America/Tegucigalpa": "CST6",
"America/Thule": "AST4ADT,M3.2.0,M11.1.0",
"America/Thunder_Bay": "EST5EDT,M3.2.0,M11.1.0",
"America/Tijuana": "PST8PDT,M3.2.0,M11.1.0",
"America/Toronto": "EST5EDT,M3.2.0,M11.1.0",
"America/Tortola": "AST4",
"America/Vancouver": "PST8PDT,M3.2.0,M11.1.0",
"America/Whitehorse": "MST7",
"America/Winnipeg": "CST6CDT,M3.2.0,M11.1.0",
"America/Yakutat": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Yellowknife": "MST7MDT,M3.2.0,M11.1.0",
"Antarctica/Casey": "UNK-8",
"Antarctica/Davis": "UNK-7",
"Antarctica/DumontDUrville": "UNK-10",
"Antarctica/Macquarie": "UNK-11",
"Antarctica/Mawson": "UNK-5",
"Antarctica/McMurdo": "NZST-12NZDT,M9.5.0,M4.1.0/3",
"Antarctica/Palmer": "UNK3",
"Antarctica/Rothera": "UNK3",
"Antarctica/Syowa": "UNK-3",
"Antarctica/Troll": "UNK0UNK-2,M3.5.0/1,M10.5.0/3",
"Antarctica/Vostok": "UNK-6",
"Arctic/Longyearbyen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Asia/Aden": "UNK-3",
"Asia/Almaty": "UNK-6",
"Asia/Amman": "EET-2EEST,M3.5.4/24,M10.5.5/1",
"Asia/Anadyr": "UNK-12",
"Asia/Aqtau": "UNK-5",
"Asia/Aqtobe": "UNK-5",
"Asia/Ashgabat": "UNK-5",
"Asia/Atyrau": "UNK-5",
"Asia/Baghdad": "UNK-3",
"Asia/Bahrain": "UNK-3",
"Asia/Baku": "UNK-4",
"Asia/Bangkok": "UNK-7",
"Asia/Barnaul": "UNK-7",
"Asia/Beirut": "EET-2EEST,M3.5.0/0,M10.5.0/0",
"Asia/Bishkek": "UNK-6",
"Asia/Brunei": "UNK-8",
"Asia/Chita": "UNK-9",
"Asia/Choibalsan": "UNK-8",
"Asia/Colombo": "UNK-5:30",
"Asia/Damascus": "EET-2EEST,M3.5.5/0,M10.5.5/0",
"Asia/Dhaka": "UNK-6",
"Asia/Dili": "UNK-9",
"Asia/Dubai": "UNK-4",
"Asia/Dushanbe": "UNK-5",
"Asia/Famagusta": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Asia/Gaza": "EET-2EEST,M3.5.5/0,M10.5.6/1",
"Asia/Hebron": "EET-2EEST,M3.5.5/0,M10.5.6/1",
"Asia/Ho_Chi_Minh": "UNK-7",
"Asia/Hong_Kong": "HKT-8",
"Asia/Hovd": "UNK-7",
"Asia/Irkutsk": "UNK-8",
"Asia/Jakarta": "WIB-7",
"Asia/Jayapura": "WIT-9",
"Asia/Jerusalem": "IST-2IDT,M3.4.4/26,M10.5.0",
"Asia/Kabul": "UNK-4:30",
"Asia/Kamchatka": "UNK-12",
"Asia/Karachi": "PKT-5",
"Asia/Kathmandu": "UNK-5:45",
"Asia/Khandyga": "UNK-9",
"Asia/Kolkata": "IST-5:30",
"Asia/Krasnoyarsk": "UNK-7",
"Asia/Kuala_Lumpur": "UNK-8",
"Asia/Kuching": "UNK-8",
"Asia/Kuwait": "UNK-3",
"Asia/Macau": "CST-8",
"Asia/Magadan": "UNK-11",
"Asia/Makassar": "WITA-8",
"Asia/Manila": "PST-8",
"Asia/Muscat": "UNK-4",
"Asia/Nicosia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Asia/Novokuznetsk": "UNK-7",
"Asia/Novosibirsk": "UNK-7",
"Asia/Omsk": "UNK-6",
"Asia/Oral": "UNK-5",
"Asia/Phnom_Penh": "UNK-7",
"Asia/Pontianak": "WIB-7",
"Asia/Pyongyang": "KST-9",
"Asia/Qatar": "UNK-3",
"Asia/Qyzylorda": "UNK-5",
"Asia/Riyadh": "UNK-3",
"Asia/Sakhalin": "UNK-11",
"Asia/Samarkand": "UNK-5",
"Asia/Seoul": "KST-9",
"Asia/Shanghai": "CST-8",
"Asia/Singapore": "UNK-8",
"Asia/Srednekolymsk": "UNK-11",
"Asia/Taipei": "CST-8",
"Asia/Tashkent": "UNK-5",
"Asia/Tbilisi": "UNK-4",
"Asia/Tehran": "UNK-3:30UNK,J79/24,J263/24",
"Asia/Thimphu": "UNK-6",
"Asia/Tokyo": "JST-9",
"Asia/Tomsk": "UNK-7",
"Asia/Ulaanbaatar": "UNK-8",
"Asia/Urumqi": "UNK-6",
"Asia/Ust-Nera": "UNK-10",
"Asia/Vientiane": "UNK-7",
"Asia/Vladivostok": "UNK-10",
"Asia/Yakutsk": "UNK-9",
"Asia/Yangon": "UNK-6:30",
"Asia/Yekaterinburg": "UNK-5",
"Asia/Yerevan": "UNK-4",
"Atlantic/Azores": "UNK1UNK,M3.5.0/0,M10.5.0/1",
"Atlantic/Bermuda": "AST4ADT,M3.2.0,M11.1.0",
"Atlantic/Canary": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Cape_Verde": "UNK1",
"Atlantic/Faroe": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Madeira": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Reykjavik": "GMT0",
"Atlantic/South_Georgia": "UNK2",
"Atlantic/St_Helena": "GMT0",
"Atlantic/Stanley": "UNK3",
"Australia/Adelaide": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
"Australia/Brisbane": "AEST-10",
"Australia/Broken_Hill": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
"Australia/Currie": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Darwin": "ACST-9:30",
"Australia/Eucla": "UNK-8:45",
"Australia/Hobart": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Lindeman": "AEST-10",
"Australia/Lord_Howe": "UNK-10:30UNK-11,M10.1.0,M4.1.0",
"Australia/Melbourne": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Perth": "AWST-8",
"Australia/Sydney": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Etc/GMT": "GMT0",
"Etc/GMT+0": "GMT0",
"Etc/GMT+1": "UNK1",
"Etc/GMT+10": "UNK10",
"Etc/GMT+11": "UNK11",
"Etc/GMT+12": "UNK12",
"Etc/GMT+2": "UNK2",
"Etc/GMT+3": "UNK3",
"Etc/GMT+4": "UNK4",
"Etc/GMT+5": "UNK5",
"Etc/GMT+6": "UNK6",
"Etc/GMT+7": "UNK7",
"Etc/GMT+8": "UNK8",
"Etc/GMT+9": "UNK9",
"Etc/GMT-0": "GMT0",
"Etc/GMT-1": "UNK-1",
"Etc/GMT-10": "UNK-10",
"Etc/GMT-11": "UNK-11",
"Etc/GMT-12": "UNK-12",
"Etc/GMT-13": "UNK-13",
"Etc/GMT-14": "UNK-14",
"Etc/GMT-2": "UNK-2",
"Etc/GMT-3": "UNK-3",
"Etc/GMT-4": "UNK-4",
"Etc/GMT-5": "UNK-5",
"Etc/GMT-6": "UNK-6",
"Etc/GMT-7": "UNK-7",
"Etc/GMT-8": "UNK-8",
"Etc/GMT-9": "UNK-9",
"Etc/GMT0": "GMT0",
"Etc/Greenwich": "GMT0",
"Etc/UCT": "UTC0",
"Etc/UTC": "UTC0",
"Etc/Universal": "UTC0",
"Etc/Zulu": "UTC0",
"Europe/Amsterdam": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Andorra": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Astrakhan": "UNK-4",
"Europe/Athens": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Belgrade": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Berlin": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Bratislava": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Brussels": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Bucharest": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Budapest": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Busingen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Chisinau": "EET-2EEST,M3.5.0,M10.5.0/3",
"Europe/Copenhagen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Dublin": "IST-1GMT0,M10.5.0,M3.5.0/1",
"Europe/Gibraltar": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Guernsey": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Helsinki": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Isle_of_Man": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Istanbul": "UNK-3",
"Europe/Jersey": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Kaliningrad": "EET-2",
"Europe/Kiev": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Kirov": "UNK-3",
"Europe/Lisbon": "WET0WEST,M3.5.0/1,M10.5.0",
"Europe/Ljubljana": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/London": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Luxembourg": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Madrid": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Malta": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Mariehamn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Minsk": "UNK-3",
"Europe/Monaco": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Moscow": "MSK-3",
"Europe/Oslo": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Paris": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Podgorica": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Prague": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Riga": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Rome": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Samara": "UNK-4",
"Europe/San_Marino": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Sarajevo": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Saratov": "UNK-4",
"Europe/Simferopol": "MSK-3",
"Europe/Skopje": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Sofia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Stockholm": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Tallinn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Tirane": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Ulyanovsk": "UNK-4",
"Europe/Uzhgorod": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Vaduz": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vatican": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vienna": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vilnius": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Volgograd": "UNK-4",
"Europe/Warsaw": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Zagreb": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Zaporozhye": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Zurich": "CET-1CEST,M3.5.0,M10.5.0/3",
"Indian/Antananarivo": "EAT-3",
"Indian/Chagos": "UNK-6",
"Indian/Christmas": "UNK-7",
"Indian/Cocos": "UNK-6:30",
"Indian/Comoro": "EAT-3",
"Indian/Kerguelen": "UNK-5",
"Indian/Mahe": "UNK-4",
"Indian/Maldives": "UNK-5",
"Indian/Mauritius": "UNK-4",
"Indian/Mayotte": "EAT-3",
"Indian/Reunion": "UNK-4",
"Pacific/Apia": "UNK-13UNK,M9.5.0/3,M4.1.0/4",
"Pacific/Auckland": "NZST-12NZDT,M9.5.0,M4.1.0/3",
"Pacific/Bougainville": "UNK-11",
"Pacific/Chatham": "UNK-12:45UNK,M9.5.0/2:45,M4.1.0/3:45",
"Pacific/Chuuk": "UNK-10",
"Pacific/Easter": "UNK6UNK,M9.1.6/22,M4.1.6/22",
"Pacific/Efate": "UNK-11",
"Pacific/Enderbury": "UNK-13",
"Pacific/Fakaofo": "UNK-13",
"Pacific/Fiji": "UNK-12UNK,M11.2.0,M1.2.3/99",
"Pacific/Funafuti": "UNK-12",
"Pacific/Galapagos": "UNK6",
"Pacific/Gambier": "UNK9",
"Pacific/Guadalcanal": "UNK-11",
"Pacific/Guam": "ChST-10",
"Pacific/Honolulu": "HST10",
"Pacific/Kiritimati": "UNK-14",
"Pacific/Kosrae": "UNK-11",
"Pacific/Kwajalein": "UNK-12",
"Pacific/Majuro": "UNK-12",
"Pacific/Marquesas": "UNK9:30",
"Pacific/Midway": "SST11",
"Pacific/Nauru": "UNK-12",
"Pacific/Niue": "UNK11",
"Pacific/Norfolk": "UNK-11UNK,M10.1.0,M4.1.0/3",
"Pacific/Noumea": "UNK-11",
"Pacific/Pago_Pago": "SST11",
"Pacific/Palau": "UNK-9",
"Pacific/Pitcairn": "UNK8",
"Pacific/Pohnpei": "UNK-11",
"Pacific/Port_Moresby": "UNK-10",
"Pacific/Rarotonga": "UNK10",
"Pacific/Saipan": "ChST-10",
"Pacific/Tahiti": "UNK10",
"Pacific/Tarawa": "UNK-12",
"Pacific/Tongatapu": "UNK-13",
"Pacific/Wake": "UNK-12",
"Pacific/Wallis": "UNK-12"
};
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import Controls from './Controls.svelte';
import { socket } from '$lib/stores';
import Spinner from '$lib/components/Spinner.svelte';
</script>
<div class="select-none">
{#if !$socket}
<div class="absolute left-0 flex flex-col w-screen h-screen justify-center items-center backdrop-blur-sm z-10">
<Spinner/>
<h2>Waiting for connection</h2>
</div>
{/if}
<Controls />
<slot/>
</div>
+10 -26
View File
@@ -1,31 +1,15 @@
<script lang="ts"> <script lang="ts">
import Controls from './Controls.svelte'; import Visualization from "$lib/components/Visualization.svelte";
import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte'; import Lidar from "$lib/components/Lidar.svelte";
import { selectedView, views } from '$lib/stores/application';
import { onMount } from 'svelte';
import { mpu, socket } from '$lib/stores';
import { imu } from '$lib/stores/imu';
import type { IMU } from '$lib/types/models';
let layout = $derived($views.find(v => v.name === $selectedView)!);
onMount(() => {
socket.on('imu', (data: IMU) => {
imu.addData(data);
if (data.heading)
mpu.update(mpuData => {
mpuData.heading = data.heading;
console.log(data.heading);
return mpuData;
});
});
});
</script> </script>
<div class="absolute top-0 select-none w-screen h-screen"> <div class="grow flex">
<Controls /> <div class="absolute h-screen w-full top-0 flex">
<div class="absolute w-full h-screen top-0 overflow-hidden lg:pt-16 pt-12"> <div class="flex-1 overflow-hidden">
<WidgetContainer container={layout.content} /> <Visualization debug />
</div>
<div class="flex-1">
<Lidar />
</div>
</div> </div>
</div> </div>
+103 -130
View File
@@ -1,150 +1,123 @@
<script lang="ts"> <script lang="ts">
import nipplejs from 'nipplejs' import nipplejs from 'nipplejs';
import { onMount } from 'svelte' import { onMount } from 'svelte';
import { capitalize, throttler, toInt8 } from '$lib/utilities' import { capitalize, throttler, toInt8 } from '$lib/utilities';
import { input, outControllerData, mode, modes, type Modes, ModesEnum } from '$lib/stores' import { input, outControllerData, mode, modes, type Modes, ModesEnum, socket } from '$lib/stores';
import type { vector } from '$lib/types/models' import type { vector } from '$lib/models';
import { VerticalSlider } from '$lib/components/input'
let throttle = new throttler() let throttle = new throttler();
let left: nipplejs.JoystickManager let left: nipplejs.JoystickManager;
let right: nipplejs.JoystickManager let right: nipplejs.JoystickManager;
let throttle_timing = 40 let throttle_timing = 40;
let data = new Array(8) let data = new Array(8);
onMount(() => { onMount(() => {
left = nipplejs.create({ left = nipplejs.create({
zone: document.getElementById('left') as HTMLElement, zone: document.getElementById('left') as HTMLElement,
color: '#15191e80', color: 'grey',
dynamicPage: true, dynamicPage: true,
mode: 'static', mode: 'static',
restOpacity: 1 restOpacity: 0.3
}) });
right = nipplejs.create({ right = nipplejs.create({
zone: document.getElementById('right') as HTMLElement, zone: document.getElementById('right') as HTMLElement,
color: '#15191e80', color: 'grey',
dynamicPage: true, dynamicPage: true,
mode: 'static', mode: 'static',
restOpacity: 1 restOpacity: 0.3
}) });
left.on('move', (_, data) => handleJoyMove('left', data.vector)) left.on('move', (_, data) => handleJoyMove('left', data.vector));
left.on('end', (_, __) => handleJoyMove('left', { x: 0, y: 0 })) left.on('end', (_, __) => handleJoyMove('left', { x: 0, y: 0 }));
right.on('move', (_, data) => handleJoyMove('right', data.vector)) right.on('move', (_, data) => handleJoyMove('right', data.vector));
right.on('end', (_, __) => handleJoyMove('right', { x: 0, y: 0 })) right.on('end', (_, __) => handleJoyMove('right', { x: 0, y: 0 }));
}) });
const handleJoyMove = (key: 'left' | 'right', data: vector) => { const handleJoyMove = (key: 'left' | 'right', data: vector) => {
input.update(inputData => { input.update((inputData) => {
inputData[key] = data inputData[key] = data;
return inputData return inputData;
}) });
throttle.throttle(updateData, throttle_timing) throttle.throttle(updateData, throttle_timing);
} };
const updateData = () => { const updateData = () => {
data[0] = 0 data[0] = 0;
data[1] = toInt8($input.left.x, -1, 1) data[1] = toInt8($input.left.x, -1, 1);
data[2] = toInt8($input.left.y, -1, 1) data[2] = toInt8($input.left.y, -1, 1);
data[3] = toInt8($input.right.x, -1, 1) data[3] = toInt8($input.right.x, -1, 1);
data[4] = toInt8($input.right.y, -1, 1) data[4] = toInt8($input.right.y, -1, 1);
data[5] = toInt8($input.height, 0, 100) data[5] = toInt8($input.height, 0, 100);
data[6] = toInt8($input.speed, 0, 100) data[6] = toInt8($input.speed, 0, 100);
data[7] = toInt8($input.s1, 0, 100) data[7] = toInt8($input.s1, 0, 100);
outControllerData.set(data) outControllerData.set(data);
} };
const handleKeyup = (event: KeyboardEvent) => { const handleKeyup = (event: KeyboardEvent) => {
const down = event.type === 'keydown' const down = event.type === 'keydown';
input.update(data => { input.update((data) => {
if (event.key === 'w') data.left.y = down ? 1 : 0 if (event.key === 'w') data.left.y = down ? -1 : 0;
if (event.key === 'a') data.left.x = 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 === 's') data.left.y = down ? 1 : 0;
if (event.key === 'd') data.left.x = down ? -1 : 0 if (event.key === 'd') data.left.x = down ? 1 : 0;
return data return data;
}) });
throttle.throttle(updateData, throttle_timing) throttle.throttle(updateData, throttle_timing);
} };
const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => { const handleRange = (event:Event, key: 'speed' | 'height' | 's1') => {
const value: number = event.target?.value const value:number = event.target?.value
input.update(inputData => { input.update((inputData) => {
inputData[key] = value inputData[key] = value;
return inputData return inputData;
}) });
throttle.throttle(updateData, throttle_timing) throttle.throttle(updateData, throttle_timing);
} }
const changeMode = (modeValue: Modes) => { const changeMode = (modeValue: Modes) => {
mode.set(modes.indexOf(modeValue)) mode.set(modes.indexOf(modeValue));
} };
</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 class="flex-1"></div> <div class="flex-1" />
<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="absolute bottom-0 right-0 p-4 z-10 gap-2 flex-col hidden lg:flex">
<div class="flex justify-center w-full"> <div class="flex justify-center w-full">
<kbd class="kbd">W</kbd> <kbd class="kbd">W</kbd>
</div> </div>
<div class="flex justify-center gap-2 w-full"> <div class="flex justify-center gap-2 w-full">
<kbd class="kbd">A</kbd> <kbd class="kbd">A</kbd>
<kbd class="kbd">S</kbd> <kbd class="kbd">S</kbd>
<kbd class="kbd">D</kbd> <kbd class="kbd">D</kbd>
</div> </div>
<div class="flex justify-center w-full"></div> <div class="flex justify-center w-full">
</div>
<div class="absolute bottom-0 z-10 flex items-end">
<div class="flex items-center flex-col bg-base-300 bg-opacity-50 p-3 pb-2 gap-2 rounded-tr-xl">
<VerticalSlider min={0} max={100} oninput={(e: Event) => handleRange(e, 'height')} />
<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 class="join">
{#each modes as modeValue}
<button
class="btn join-item"
class:btn-primary={$mode === modes.indexOf(modeValue)}
onclick={() => changeMode(modeValue)}>
{capitalize(modeValue)}
</button>
{/each}
</div>
{#if $mode === ModesEnum.Walk || $mode === ModesEnum.Crawl}
<div class="flex gap-4">
<div>
<label for="s1">S1</label>
<input
type="range"
name="s1"
min="0"
max="100"
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"
max="100"
oninput={e => handleRange(e, 'speed')}
class="range range-sm range-primary" />
</div>
</div> </div>
{/if}
</div> </div>
</div> <div class="absolute bottom-0 z-10 p-4 gap-4 flex items-end">
{#each modes as modeValue}
<button class="btn btn-outline" class:btn-active={$mode === modes.indexOf(modeValue)} on:click={() => changeMode(modeValue)}>
{capitalize(modeValue)}
</button>
{/each}
<div>
{#if $mode === ModesEnum.Walk || $mode === ModesEnum.Crawl}
<label for="s1">S1</label>
<input type="range" name="s1" min="0" max="100" on:input={(e) => handleRange(e, 's1')} class="range range-sm" />
<label for="speed">Speed</label>
<input type="range" name="speed" min="0" max="100" on:input={(e) => handleRange(e, 'speed')} class="range range-sm" />
{/if}
<label for="height">Height</label>
<input type="range" name="height" min="0" max="100" on:input={(e) => handleRange(e, 'height')} class="range range-sm" />
</div>
</div>
</div> </div>
<svelte:window onkeyup={handleKeyup} onkeydown={handleKeyup} /> <svelte:window on:keyup={handleKeyup} on:keydown={handleKeyup} />
@@ -0,0 +1,19 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { location } from '$lib/utilities';
let videoStream = `//${location}/api/stream`;
onDestroy(() => {
videoStream = '#';
});
</script>
<div class="w-full h-full">
<img
src={videoStream}
class="absolute object-cover blur-3xl w-full h-full -z-10"
alt="Live stream is down"
/>
<img src={videoStream} class="object-contain w-full h-full" alt="Live stream is down" />
</div>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import logo from '$lib/assets/logo512.png';
import InputPassword from '$lib/components/InputPassword.svelte';
import { user } from '$lib/stores/user';
import { notifications } from '$lib/components/toasts/notifications';
import { fade, fly } from 'svelte/transition';
import Login from '~icons/tabler/login';
import { api } from '$lib/api';
import type { JWT } from '$lib/models';
type SignInData = {
password: string;
username: string;
};
let username = '';
let password = '';
let loginFailed = false;
let token = { access_token: '' };
async function signInUser(data: SignInData) {
const result = await api.post<JWT>('/api/signIn', data)
if (result.isErr()){
username = '';
password = '';
notifications.error('Wrong Username or Password!', 5000);
loginFailed = true;
setTimeout(() => {
loginFailed = false;
}, 1500);
return
}
token = result.inner;
user.init(token.access_token);
username = $user.username;
notifications.success('User ' + username + ' signed in', 5000);
}
</script>
<div class="hero from-primary/30 to-secondary/30 min-h-screen bg-gradient-to-br">
<div
class="card lg:card-side bg-base-100 face shadow-2xl {loginFailed
? 'failure border-error border-2'
: ''}"
in:fly={{ delay: 200, y: 100, duration: 500 }}
out:fade={{ duration: 200 }}
>
<figure class="bg-base-200"><img src={logo} alt="Logo" class="h-auto w-48 lg:w-64" /></figure>
<div class="card-body w-80">
<h2 class="card-title text-2xl">Login</h2>
<form class="form-control w-full max-w-xs">
<label class="label" for="user">
<span class="label-text text-md">Username</span>
</label>
<input
type="text"
class="input input-bordered w-full max-w-xs"
id="user"
bind:value={username}
/>
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<InputPassword id="pwd" bind:value={password} />
<div class="card-actions mt-4 justify-end">
<button
class="btn btn-primary inline-flex items-center"
on:click={() => {
signInUser({ username, password });
}}><Login class="mr-2 h-5 w-5" /><span>Login</span></button
>
</div>
</form>
</div>
</div>
</div>
<style>
.failure {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
}
@keyframes shake {
10%,
90% {
transform: translatex(-1px);
}
20%,
80% {
transform: translatex(2px);
}
30%,
50%,
70% {
transform: translatex(-4px);
}
40%,
60% {
transform: translatex(4px);
}
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<script lang="ts">
import logo from '$lib/assets/logo512.png';
import MdiGithub from '~icons/mdi/github';
import MdiConnection from '~icons/mdi/connection';
import Users from '~icons/mdi/users';
import Settings from '~icons/mdi/settings';
import MdiController from '~icons/mdi/controller';
import Devices from '~icons/mdi/devices'
import Camera from '~icons/mdi/camera-outline';
import Rotate3d from '~icons/mdi/rotate-3d';
import MdiLandslideOutline from '~icons/mdi/landslide-outline';
import MotorOutline from '~icons/mdi/motor-outline';
import Health from '~icons/mdi/stethoscope';
import Folder from '~icons/mdi/folder-outline';
import Update from '~icons/mdi/reload';
import WiFi from '~icons/mdi/wifi';
import Router from '~icons/mdi/router';
import AP from '~icons/mdi/access-point';
import Remote from '~icons/mdi/network';
import Avatar from '~icons/mdi/user-circle';
import Logout from '~icons/mdi/logout';
import Copyright from '~icons/mdi/copyright';
import NTP from '~icons/mdi/clock-check';
import Metrics from '~icons/mdi/report-bar';
import { page } from '$app/stores';
import { user } from '$lib/stores/user';
import { createEventDispatcher } from 'svelte';
const appName = $page.data.app_name;
const copyright = $page.data.copyright;
const github = { href: 'https://github.com/' + $page.data.github, active: true };
type menuItem = {
title: string;
icon: ConstructorOfATypedSvelteComponent;
href?: string;
feature: boolean;
active?: boolean;
submenu?: subMenuItem[];
};
type subMenuItem = {
title: string;
icon: ConstructorOfATypedSvelteComponent;
href: string;
feature: boolean;
active: boolean;
};
let menuItems = [
{
title: 'Controller',
icon: MdiController,
href: '/controller',
feature: true,
},
{
title: 'Peripherals',
icon: Devices,
feature: true,
submenu: [
{
title: 'I2C',
icon: MdiConnection,
href: '/peripherals/i2c',
feature: true,
},
{
title: 'Camera',
icon: Camera,
href: '/peripherals/camera',
feature: $page.data.features.camera,
},
{
title: 'Servo',
icon: MotorOutline,
href: '/peripherals/servo',
feature: true,
},
{
title: 'IMU',
icon: Rotate3d,
href: '/peripherals/imu',
feature: $page.data.features.imu || $page.data.features.mag || $page.data.features.bmp,
},
{
title: 'Lidar',
icon: MdiLandslideOutline,
href: '/peripherals/lidar',
feature: true//$page.data.features.lidar,
}
]
},
{
title: 'Connections',
icon: Remote,
feature: $page.data.features.ntp,
submenu: [
{
title: 'NTP',
icon: NTP,
href: '/connections/ntp',
feature: $page.data.features.ntp,
}
]
},
{
title: 'WiFi',
icon: WiFi,
feature: true,
submenu: [
{
title: 'WiFi Station',
icon: Router,
href: '/wifi/sta',
feature: true,
},
{
title: 'Access Point',
icon: AP,
href: '/wifi/ap',
feature: true,
}
]
},
{
title: 'Users',
icon: Users,
href: '/user',
feature: $page.data.features.security && $user.admin,
},
{
title: 'System',
icon: Settings,
feature: true,
submenu: [
{
title: 'System Status',
icon: Health,
href: '/system/status',
feature: true,
},
{
title: 'File System',
icon: Folder,
href: '/system/filesystem',
feature: true,
},
{
title: 'System Metrics',
icon: Metrics,
href: '/system/metrics',
feature: $page.data.features.analytics,
},
{
title: 'Firmware Update',
icon: Update,
href: '/system/update',
feature:
($page.data.features.ota ||
$page.data.features.upload_firmware ||
$page.data.features.download_firmware) &&
(!$page.data.features.security || $user.admin),
}
]
}
] as menuItem[];
const dispatch = createEventDispatcher();
function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => {
item.active = item.title === targetTitle;
item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle;
});
});
menuItems = menuItems
dispatch('menuClicked');
}
$: setActiveMenuItem($page.data.title);
</script>
<div class="bg-base-200 text-base-content flex h-full w-80 flex-col p-4">
<!-- Sidebar content here -->
<a
href="/"
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
on:click={() => setActiveMenuItem('')}
>
<img src={logo} alt="Logo" class="h-12 w-12" />
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
</a>
<ul class="menu rounded-box menu-vertical flex-nowrap overflow-y-auto">
{#each menuItems as menuItem, i (menuItem.title)}
{#if menuItem.feature}
<li>
{#if menuItem.submenu}
<details>
<summary class="text-lg font-bold">
<svelte:component this={menuItem.icon} class="h-6 w-6" />
{menuItem.title}
</summary>
<ul>
{#each menuItem.submenu as subMenuItem}
{#if subMenuItem.feature}
<li class="hover-bordered">
<a
href={subMenuItem.href}
class:bg-base-100={subMenuItem.active}
class="text-ml font-bold"
on:click={() => {
setActiveMenuItem(subMenuItem.title);
menuItems = menuItems;
}}
><svelte:component
this={subMenuItem.icon}
class="h-5 w-5"
/>{subMenuItem.title}</a
>
</li>
{/if}
{/each}
</ul>
</details>
{:else}
<a
href={menuItem.href}
class:bg-base-100={menuItem.active}
class="text-lg font-bold"
on:click={() => {
setActiveMenuItem(menuItem.title);
menuItems = menuItems;
}}><svelte:component this={menuItem.icon} class="h-6 w-6" />{menuItem.title}</a
>
{/if}
</li>
{/if}
{/each}
</ul>
<div class="flex-col" />
<div class="flex-grow" />
{#if $page.data.features.security}
<div class="flex items-center">
<Avatar class="h-8 w-8" />
<span class="flex-grow px-4 text-xl font-bold">{$user.username}</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="btn btn-ghost"
on:click={() => {
user.invalidate();
}}
>
<Logout class="h-8 w-8 rotate-180" />
</div>
</div>
{/if}
<div class="divider my-0" />
<div class="flex items-center">
{#if github.active}
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer"
><MdiGithub class="h-5 w-5" /></a
>
{/if}
<div class="inline-flex flex-grow items-center justify-end text-sm">
<Copyright class="h-4 w-4" /><span class="px-2">{copyright}</span>
</div>
</div>
</div>
@@ -1,17 +1,14 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from "$lib/components/SettingsCard.svelte"; import SettingsCard from "$lib/components/SettingsCard.svelte";
import Camera from '~icons/mdi/camera-outline'
import Record from '~icons/mdi/radio-button-unchecked'
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";
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} <Camera slot="icon" 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" /> <span slot="title">Camera</span>
{/snippet}
{#snippet title()}
<span >Camera</span>
{/snippet}
<Stream /> <Stream />
<CameraSetting /> <CameraSetting />
</SettingsCard> </SettingsCard>
@@ -1,8 +1,8 @@
<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/models';
let settings:CameraSettings = $state() let settings:CameraSettings
const getCameraSettings = async () => { const getCameraSettings = async () => {
const result = await api.get<CameraSettings>('/api/camera/settings') const result = await api.get<CameraSettings>('/api/camera/settings')
@@ -27,7 +27,7 @@
<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" on:click={updateCameraSettings}>Update camera settings</button>
<label for="brightness"> <label for="brightness">
Brightness {settings.brightness} Brightness {settings.brightness}
+24 -40
View File
@@ -1,57 +1,41 @@
<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 MdiConnection from '~icons/mdi/connection';
import { socket } from '$lib/stores'; import { onDestroy, onMount } from "svelte";
import type { I2CDevice } from '$lib/types/models'; import { socket } from "$lib/stores";
import { Connection } from '$lib/components/icons'; import type { I2CDevice } from "$lib/types/models";
const i2cDevices = [ const i2cDevices = [
{ address: 30, part_number: 'HMC5883', name: '3-Axis Digital Compass/Magnetometer IC' }, {address:30, part_number: "HMC5883", name: "3-Axis Digital Compass/Magnetometer IC"},
{ address: 64, part_number: 'PCA9685', name: '16-channel PWM driver default address' }, {address: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, part_number: "MPU6050", name: "Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices"},
address: 104, {address:119, part_number: "BMP085", name: "Temp/Barometric"},
part_number: 'MPU6050',
name: 'Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices'
},
{ address: 119, part_number: 'BMP085', name: 'Temp/Barometric' }
]; ];
let active_devices: I2CDevice[] = $state([]); let active_devices:I2CDevice[] = [];
onMount(() => { onMount(() => {
socket.on('i2cScan', handleScan); socket.on('i2cScan', handleScan);
socket.sendEvent('i2cScan', ''); socket.sendEvent('i2cScan', "");
return () => socket.off('i2cScan', handleScan); })
});
onDestroy(() => {
socket.off('i2cScan', handleScan);
})
const handleScan = (data: any) => { const handleScan = (data: any) => {
active_devices = data.addresses.map( active_devices = data.addresses.map((address:number) => i2cDevices.find(device => device.address === address))
(address: number) => }
i2cDevices.find(device => device.address === address) || {
address,
part_number: 'Unknown',
name: 'Unknown'
}
);
};
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} <MdiConnection slot="icon" 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" /> <span slot="title">I<sup>2</sup>C</span>
{/snippet}
{#snippet title()}
<span >I<sup>2</sup>C</span>
{/snippet}
<div class="grid"> <div class="grid">
{#if active_devices.length === 0} {#each active_devices as device }
<div>No I2C devices found</div> <div>[{device.address.toString(16)}] {device.part_number} - {device.name}</div>
{:else} {/each}
{#each active_devices as device}
<div>[{device.address.toString(16)}] {device.part_number} - {device.name}</div>
{/each}
{/if}
</div> </div>
</SettingsCard> </SettingsCard>
+15 -21
View File
@@ -1,27 +1,25 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from "$lib/components/SettingsCard.svelte"; import SettingsCard from "$lib/components/SettingsCard.svelte";
import Rotate3d from '~icons/mdi/rotate-3d';
import { imu } from '$lib/stores/imu'; import { imu } from '$lib/stores/imu';
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
import { cubicOut } from "svelte/easing"; import { cubicOut } from "svelte/easing";
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { daisyColor } from "$lib/utilities"; import { daisyColor } from "$lib/DaisyUiHelper";
import { socket } from "$lib/stores"; import { socket } from "$lib/stores";
import type { IMU } from "$lib/types/models"; import type { IMU } from "$lib/types/models";
import { useFeatureFlags } from "$lib/stores/featureFlags"; import { page } from "$app/stores";
import { Rotate3d } from "$lib/components/icons";
const features = useFeatureFlags();
Chart.register(...registerables); Chart.register(...registerables);
let angleChartElement: HTMLCanvasElement = $state(); let angleChartElement: HTMLCanvasElement;
let angleChart: Chart; let angleChart: Chart;
let tempChartElement: HTMLCanvasElement = $state(); let tempChartElement: HTMLCanvasElement;
let tempChart: Chart; let tempChart: Chart;
let altitudeChartElement: HTMLCanvasElement = $state(); let altitudeChartElement: HTMLCanvasElement;
let altitudeChart: Chart; let altitudeChart: Chart;
const handleImu = (data: IMU) => { const handleImu = (data: IMU) => {
@@ -244,7 +242,7 @@
}) })
const updateData = () => { const updateData = () => {
if ($features.imu) { if ($page.data.features.imu) {
angleChart.data.labels = $imu.x; angleChart.data.labels = $imu.x;
angleChart.data.datasets[0].data = $imu.x; angleChart.data.datasets[0].data = $imu.x;
angleChart.data.datasets[1].data = $imu.y; angleChart.data.datasets[1].data = $imu.y;
@@ -254,7 +252,7 @@
angleChart.update('none'); angleChart.update('none');
} }
if ($features.bmp) { if ($page.data.features.bmp) {
tempChart.data.labels = $imu.bmp_temp; tempChart.data.labels = $imu.bmp_temp;
tempChart.data.datasets[0].data = $imu.bmp_temp; tempChart.data.datasets[0].data = $imu.bmp_temp;
tempChart.options.scales!.y!.min = Math.min(...$imu.bmp_temp) - 1; tempChart.options.scales!.y!.min = Math.min(...$imu.bmp_temp) - 1;
@@ -272,29 +270,25 @@
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} <Rotate3d slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<Rotate3d class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <span slot="title">IMU</span>
{/snippet} {#if $page.data.features.imu}
{#snippet title()}
<span >IMU</span>
{/snippet}
{#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> <canvas bind:this={angleChartElement} />
</div> </div>
</div> </div>
{/if} {/if}
{#if $features.bmp} {#if $page.data.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> <canvas bind:this={tempChartElement} />
</div> </div>
</div> </div>
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
@@ -302,7 +296,7 @@
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={altitudeChartElement}></canvas> <canvas bind:this={altitudeChartElement} />
</div> </div>
</div> </div>
{/if} {/if}
@@ -0,0 +1,7 @@
<script lang="ts">
import Lidar from './lidar.svelte';
</script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
<Lidar />
</div>
@@ -0,0 +1,95 @@
<script lang="ts">
import Lidar from "$lib/components/Lidar.svelte";
import SettingsCard from "$lib/components/SettingsCard.svelte";
import { lidar } from "$lib/stores/lidar";
import { onMount } from "svelte";
import { writable } from "svelte/store";
import { distance } from "three/examples/jsm/nodes/Nodes.js";
let port;
let reader;
let inputDone;
let inputStream;
let isConnected = false;
let buffer = '';
let lastLine = ""
onMount(() => {
navigator.serial.addEventListener("connect", (e) => {
console.log("Connected");
});
navigator.serial.addEventListener("disconnect", (e) => {
console.log("Disconnected");
});
navigator.serial.getPorts().then((ports) => {
// Initialize the list of available ports with `ports` on page load.
});
})
const connect = async () => {
try {
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
const decoder = new TextDecoderStream();
inputDone = port.readable.pipeTo(decoder.writable);
inputStream = decoder.readable.pipeThrough(new TransformStream(new LineBreakTransformer()));
reader = inputStream.getReader();
readLoop();
} catch (err) {
console.error('Failed to open serial port:', err);
}
}
async function readLoop() {
while (true) {
const { value, done } = await reader.read();
if (done) {
console.log('[readLoop] DONE', done);
reader.releaseLock();
break;
}
if (value.split(",").length !== 3) continue
const [distance, angle, quality] = value.split(",").map((val:string) => parseFloat(val))
const lidarData = { distance, angle, quality }
if (distance <1000 || distance > 40000 || quality < 40) continue
lidar.addData(lidarData)
}
}
class LineBreakTransformer {
container: string;
constructor() {
this.container = '';
}
transform(chunk: any, controller: { enqueue: (arg0: any) => any; }) {
let re = /\r\n|\n|\r/gm;
this.container += chunk;
const lines = this.container.split(re);
this.container = lines.pop() || "";
lines.forEach(line => controller.enqueue(line));
}
flush(controller: { enqueue: (arg0: string) => void; }) {
controller.enqueue(this.container);
}
}
</script>
<SettingsCard collapsible={false}>
<!-- <MdiConnection slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> -->
<span slot="title">Lidar</span>
<div>
<button on:click={connect} class="btn">Connect</button>
</div>
</SettingsCard>
<div class="h-96 w-96">
<div class="w-full h-full">
<Lidar />
</div>
</div>
@@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import Servos from './servos.svelte'; import Servos from './servos.svelte';
import ServoTable from './ServoTable.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">
<Servos /> <Servos />
<ServoTable />
</div> </div>
@@ -1,73 +0,0 @@
<script lang="ts">
import { api } from '$lib/api';
import { onMount } from 'svelte';
interface Props {
data?: any;
}
let { data = $bindable({
servos: []
}) }: Props = $props();
const updateValue = (event, index, key) => {
data.servos[index][key] = event.target.innerText;
};
const syncConfig = async () => {
await api.post('/api/servo/config', data);
};
onMount(async () => {
const result = await api.get('/api/servo/config');
if (result.isOk()) {
data = result.inner;
}
});
</script>
<div class="overflow-x-auto">
<table class="table table-xs">
<thead>
<tr>
<th>Center PWM</th>
<th>Center Angle</th>
<th>Direction</th>
<th>Conversion</th>
</tr>
</thead>
<tbody>
{#each data.servos as servo, index}
<tr>
<td
contenteditable="true"
onblur={syncConfig}
oninput={event => updateValue(event, index, 'center_pwm')}
>
{servo.center_pwm}
</td>
<td
contenteditable="true"
onblur={syncConfig}
oninput={event => updateValue(event, index, 'center_angle')}
>
{servo.center_angle}
</td>
<td
contenteditable="true"
onblur={syncConfig}
oninput={event => updateValue(event, index, 'direction')}
>
{servo.direction}
</td>
<td
contenteditable="true"
onblur={syncConfig}
oninput={event => updateValue(event, index, 'conversion')}
>
{servo.conversion}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Servo } from "$lib/models";
import { createEventDispatcher } from "svelte";
export let servo: Servo;
const dispatch = createEventDispatcher();
const sweep = () => {
dispatch('sweep', {channel: servo.channel});
};
</script>
<div>
<h2 class="text-lg">{ servo.name }</h2>
<div class="flex gap-2 items-center">
Is inverted <input type="checkbox" bind:checked={servo.inverted} class="toggle"/>
</div>
<div>
Middle position <input type="number" bind:value={servo.center_angle} class="input input-bordered input-sm max-w-xs"/>
</div>
<div class="relative mb-6">
<label for="labels-range-input" class="sr-only">Labels range</label>
<input id="labels-range-input" type="range" bind:value={servo.angle} min="0" max="180" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700">
<span class="text-sm text-gray-500 dark:text-gray-400 absolute start-0 -bottom-6">0</span>
<span class="text-sm text-gray-500 dark:text-gray-400 absolute start-1/2 -translate-x-1/2 rtl:translate-x-1/2 -bottom-6">90</span>
<span class="text-sm text-gray-500 dark:text-gray-400 absolute end-0 -bottom-6">180</span>
</div>
<button class="btn btn-neutral btn-sm" on:click={sweep}>Sweep range</button>
</div>
+33 -50
View File
@@ -1,75 +1,58 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import type { ServoConfiguration, Servo } from '$lib/types/models'; import type { ServoConfiguration, Servo } from '$lib/models';
import Spinner from '$lib/components/Spinner.svelte'; import MotorOutline from '~icons/mdi/motor-outline';
import ServoController from './servo.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import { socket } from '$lib/stores'; import { socket } from '$lib/stores';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { throttler as Throttler } from '$lib/utilities'; import { throttler as Throttler } from '$lib/utilities';
import { MotorOutline } from '$lib/components/icons';
let isLoading = false; let isLoading = false;
let active = $state(false); let active = false
let servoId = $state(0); let servoId = 0
const throttler = new Throttler(); const throttler = new Throttler()
const sweep = (event: any) => { const sweep = (event:any) => {
let channel = event.detail.channel; let channel = event.detail.channel;
socket.sendEvent('servoConfiguration', { servos: [{ channel, sweep: true }] }); socket.sendEvent('servoConfiguration', {servos:[{channel, sweep: true}]});
}; };
const activateServo = (event: any) => { const activateServo = (event:any) => {
socket.sendEvent('servoState', { active: 1 }); socket.sendEvent('servoState', {'active':1});
}; };
const deactivateServo = (event: any) => { const deactivateServo = (event:any) => {
socket.sendEvent('servoState', { active: 0 }); socket.sendEvent('servoState', {'active':0});
}; };
let pwm = $state(306); let pwm = 306;
const updatePWM = () => { const updatePWM = () => {
throttler.throttle(() => { throttler.throttle(() => {
socket.sendEvent('servoPWM', { servo_id: servoId, pwm }); socket.sendEvent('servoPWM', {servo_id:servoId, pwm});
}, 10); }, 10)
}; }
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} <MotorOutline slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<MotorOutline class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <span slot="title">Servo</span>
{/snippet} <input type="range" min="200" max="400" bind:value={pwm} on:input={updatePWM} class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700">
{#snippet title()}
<span >Servo</span>
{/snippet}
{pwm}
<input
type="range"
min="80"
max="600"
bind:value={pwm}
oninput={updatePWM}
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
{#if isLoading} {#if isLoading}
<Spinner /> <Spinner />
{:else} {:else}
<div class="flex flex-col"> <div class="flex flex-col">
<h2 class="text-lg">General servo configuration</h2> <h2 class="text-lg">General servo configuration</h2>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<label for="servoId">Servo active {servoId}</label> <label for="servoId">Servo active{servoId}</label>
<input type="range" min="0" max="11" step="1" bind:value={servoId} /> <input type="checkbox" class="toggle" bind:checked={active} on:change={active ? deactivateServo : activateServo}>
<input
type="checkbox"
class="toggle"
bind:checked={active}
onchange={active ? activateServo : deactivateServo}
/>
</span> </span>
</div> </div>
{/if} {/if}
</SettingsCard> </SettingsCard>
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
import { page } from '$app/stores';
import { telemetry } from '$lib/stores/telemetry';
import { openModal, closeModal } from 'svelte-modals';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import WiFiOff from '~icons/tabler/wifi-off';
import Hamburger from '~icons/tabler/menu-2';
import Power from '~icons/tabler/power';
import Cancel from '~icons/tabler/x';
import RssiIndicator from '$lib/components/RSSIIndicator.svelte';
import BatteryIndicator from '$lib/components/BatteryIndicator.svelte';
import UpdateIndicator from '$lib/components/UpdateIndicator.svelte';
import MdiWeatherSunny from '~icons/mdi/weather-sunny';
import MdiMoonAndStars from '~icons/mdi/moon-and-stars';
import { api } from '$lib/api';
import { mode, modes, socket } from '$lib/stores';
const postSleep = async () => await api.post('/api/sleep')
function confirmSleep() {
openModal(ConfirmDialog, {
title: 'Confirm Power Down',
message: 'Are you sure you want to switch off the device?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Switch Off', icon: Power }
},
onConfirm: () => {
closeModal();
postSleep();
}
});
}
const deactivate = async () => {
mode.set(modes.indexOf('deactivated'));
}
</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">
<div class="flex-1">
<!-- Page Hamburger Icon here -->
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button"
><Hamburger class="h-6 w-auto" /></label
>
<h1 class="px-2 text-xl font-bold lg:text-2xl">{$page.data.title}</h1>
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="indicator flex-none" on:click={deactivate}>
<Power class="h-7 w-7"/>
</div>
<div class="indicator flex-none">
<UpdateIndicator />
</div>
<div class="flex-none">
<label class="swap swap-rotate">
<input type="checkbox" value="light" class="theme-controller"/>
<MdiWeatherSunny class="swap-off h-7 w-7"/>
<MdiMoonAndStars class="swap-on h-7 w-7"/>
</label>
</div>
<div class="flex-none">
{#if $telemetry.rssi.disconnected}
<WiFiOff class="h-7 w-7" />
{:else}
<RssiIndicator showDBm={false} rssi_dbm={$telemetry.rssi.rssi} class="h-7 w-7" />
{/if}
</div>
{#if $page.data.features.battery}
<div class="flex-none">
<BatteryIndicator
voltage={$telemetry.battery.voltage}
current={$telemetry.battery.current}
class="h-7 w-7"
/>
</div>
{/if}
{#if $page.data.features.sleep}
<div class="flex-none">
<button class="btn btn-square btn-ghost h-9 w-10" on:click={confirmSleep}>
<Power class="text-error h-9 w-9" />
</button>
</div>
{/if}
</div>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import FileSystem from './FileSystem.svelte' import FileSystem from './FileSystem.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">
<FileSystem /> <FileSystem />
</div> </div>
+3 -3
View File
@@ -1,5 +1,5 @@
import type { PageLoad } from './$types' import type { PageLoad } from './$types';
export const load = (async () => { export const load = (async () => {
return { title: 'File System' } return { title: 'File System' };
}) satisfies PageLoad }) satisfies PageLoad;
+13 -6
View File
@@ -1,11 +1,18 @@
<script> <script>
import { FileIcon } from '$lib/components/icons' import FileIcon from '~icons/mdi/file';
import { createEventDispatcher } from 'svelte';
let { name, selected } = $props() export let name;
const dispatch = createEventDispatcher();
const updateSelected = async () => {
dispatch('selected', { name });
}
</script> </script>
<!-- svelte-ignore a11y_interactive_supports_focus --> <!-- svelte-ignore a11y-interactive-supports-focus -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<span role="button" class="flex pl-4 gap-2 items-center" onclick={selected}> <span role="button" class="flex pl-4 gap-2 items-center" on:click={updateSelected}>
<FileIcon />{name} <FileIcon/>{name}
</span> </span>
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from "$lib/components/SettingsCard.svelte"; import SettingsCard from "$lib/components/SettingsCard.svelte";
import Spinner from "$lib/components/Spinner.svelte"; import Spinner from "$lib/components/Spinner.svelte";
import FolderIcon from '~icons/mdi/folder-outline';
import Folder from "./Folder.svelte"; import Folder from "./Folder.svelte";
import { api } from "$lib/api"; import { api } from "$lib/api";
import type { Directory } from "$lib/types/models"; import type { Directory } from "$lib/models";
import { FolderIcon } from "$lib/components/icons";
let filename = $state(''); let filename = '';
const getFiles = async () => { const getFiles = async () => {
const result = await api.get<Directory>('/api/files') const result = await api.get<Directory>('/api/files')
@@ -38,12 +38,8 @@
} }
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} <FolderIcon slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<FolderIcon class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <span slot="title">File System</span>
{/snippet}
{#snippet title()}
<span >File System</span>
{/snippet}
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
{#await getFiles()} {#await getFiles()}
<Spinner /> <Spinner />

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