Fixes build warning and errors

This commit is contained in:
Rune Harlyk
2025-07-10 21:32:28 +02:00
committed by Rune Harlyk
parent c8ee64d7f4
commit d529eaa201
22 changed files with 2053 additions and 2116 deletions
+9 -20
View File
@@ -2,25 +2,17 @@
import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition';
import { Check } from './icons';
import { exitBeforeEnter } from 'svelte-modals';
// provided by <Modals />
interface Props {
isOpen: boolean;
title: string;
message: string;
onDismiss: any;
dismiss?: any;
}
import { exitBeforeEnter, type ModalProps } from 'svelte-modals';
let {
isOpen,
title,
message,
onDismiss,
dismiss = { label: 'Dismiss', icon: Check }
}: Props = $props();
labels = {
dismiss: { label: 'Dismiss', icon: Check },
},
}: ModalProps = $props();
</script>
{#if isOpen}
@@ -29,11 +21,9 @@
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap
>
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"
>
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>
<p class="text-base-content mb-1 text-start">{message}</p>
@@ -41,9 +31,8 @@
<div class="flex justify-end gap-2">
<button
class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onDismiss}
>
<dismiss.icon class="mr-2 h-5 w-5" /><span>{dismiss.label}</span>
onclick={onDismiss}>
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span>
</button>
</div>
</div>
@@ -1,15 +1,15 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import * as THREE from 'three'
import { imu } from '$lib/stores/imu'
import SceneBuilder from '$lib/sceneBuilder'
import { onMount, onDestroy } from 'svelte';
import * as THREE from 'three';
import { imu } from '$lib/stores/imu';
import SceneBuilder from '$lib/sceneBuilder';
let canvas: HTMLCanvasElement = $state()
let sceneBuilder: SceneBuilder
let cube: THREE.Mesh
let targetRotation = new THREE.Euler()
let lastUpdateTime = 0
const LERP_SPEED = 5 // rotations per second
let canvas: HTMLCanvasElement;
let sceneBuilder: SceneBuilder;
let cube: THREE.Mesh;
let targetRotation = new THREE.Euler();
let lastUpdateTime = 0;
const LERP_SPEED = 5; // rotations per second
const initThreeJS = () => {
sceneBuilder = new SceneBuilder()
@@ -18,59 +18,59 @@
.addOrbitControls(1, 10, false)
.addAmbientLight({ color: 0x404040, intensity: 0.5 })
.addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 })
.fillParent()
.fillParent();
const geometry = new THREE.BoxGeometry(1, 1, 1)
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshPhongMaterial({
color: 0x00ff00,
transparent: true,
opacity: 0.8
})
cube = new THREE.Mesh(geometry, material)
sceneBuilder.scene.add(cube)
opacity: 0.8,
});
cube = new THREE.Mesh(geometry, material);
sceneBuilder.scene.add(cube);
sceneBuilder.addRenderCb(() => {
if (!cube) return
const currentTime = performance.now()
const deltaTime = (currentTime - lastUpdateTime) / 1000 // convert to seconds
lastUpdateTime = currentTime
if (!cube) return;
const currentTime = performance.now();
const deltaTime = (currentTime - lastUpdateTime) / 1000; // convert to seconds
lastUpdateTime = currentTime;
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime)
cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor)
cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor)
cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor)
})
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime);
cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor);
cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor);
cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor);
});
sceneBuilder.startRenderLoop()
}
sceneBuilder.startRenderLoop();
};
const updateOrientation = () => {
if (!cube) return
if (!cube) return;
const y = -$imu.x[$imu.x.length - 1] || 0
const x = $imu.y[$imu.y.length - 1] || 0
const z = -$imu.z[$imu.z.length - 1] || 0
const y = -$imu.x[$imu.x.length - 1] || 0;
const x = $imu.y[$imu.y.length - 1] || 0;
const z = -$imu.z[$imu.z.length - 1] || 0;
targetRotation.set(
THREE.MathUtils.degToRad(x),
THREE.MathUtils.degToRad(y),
THREE.MathUtils.degToRad(z)
)
}
);
};
onMount(() => {
initThreeJS()
})
initThreeJS();
});
onDestroy(() => {
sceneBuilder?.renderer?.dispose()
})
sceneBuilder?.renderer?.dispose();
});
$effect(() => {
if ($imu) {
updateOrientation()
updateOrientation();
}
})
});
</script>
<div class="h-60 w-60 border-2 border-base-300 rounded-md">
+168 -166
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import { onDestroy, onMount } from 'svelte';
import {
BufferGeometry,
Line,
@@ -10,8 +10,8 @@
SphereGeometry,
Vector3,
type NormalBufferAttributes,
type Object3DEventMap
} from 'three'
type Object3DEventMap,
} from 'three';
import {
ModesEnum,
kinematicData,
@@ -21,56 +21,55 @@
servoAnglesOut,
servoAngles,
mpu,
jointNames
} from '$lib/stores'
jointNames,
} from '$lib/stores';
import {
extractFootColor,
populateModelCache,
throttler,
getToeWorldPositions
} from '$lib/utilities'
import SceneBuilder from '$lib/sceneBuilder'
import { lerp, degToRad } from 'three/src/math/MathUtils'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import Kinematic, { type body_state_t } from '$lib/kinematic'
getToeWorldPositions,
} from '$lib/utilities';
import SceneBuilder from '$lib/sceneBuilder';
import { lerp, degToRad } from 'three/src/math/MathUtils';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
import 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'
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 {
sky?: boolean
orbit?: boolean
panel?: boolean
debug?: boolean
ground?: boolean
sky?: boolean;
orbit?: boolean;
panel?: boolean;
debug?: boolean;
ground?: boolean;
}
let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props()
let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props();
let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement = $state()
let sceneManager = $state(new SceneBuilder());
let canvas: HTMLCanvasElement;
let currentModelAngles: number[] = new Array(12).fill(0)
let modelTargetAngles: number[] = new Array(12).fill(0)
let gui_panel: GUI
let Throttler = new throttler()
let currentModelAngles: number[] = new Array(12).fill(0);
let modelTargetAngles: number[] = new Array(12).fill(0);
let gui_panel: GUI;
let Throttler = new throttler();
let feet_trace = new Array(4).fill([])
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
let target: Object3D<Object3DEventMap>
let feet_trace = new Array(4).fill([]);
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = [];
let target: Object3D<Object3DEventMap>;
let target_position = { x: 0, z: 0, yaw: 0 }
let target_position = { x: 0, z: 0, yaw: 0 };
let kinematic = new Kinematic()
let kinematic = new Kinematic();
let planners = {
[ModesEnum.Deactivated]: new IdleState(),
@@ -79,11 +78,11 @@
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Crawl]: new EightPhaseWalkState(),
[ModesEnum.Walk]: new BezierState()
}
let lastTick = performance.now()
[ModesEnum.Walk]: new BezierState(),
};
let lastTick = performance.now();
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1];
let body_state = {
omega: 0,
@@ -92,8 +91,8 @@
xm: 0,
ym: 0.5,
zm: 0,
feet: kinematic.getDefaultFeetPos()
}
feet: kinematic.getDefaultFeetPos(),
};
let settings = {
'Internal kinematic': true,
@@ -110,52 +109,52 @@
xm: 0,
ym: 0.7,
zm: 0,
Background: 'black'
}
Background: 'black',
};
onMount(async () => {
await populateModelCache()
await createScene()
servoAngles.subscribe(updateAnglesFromStore)
if (panel) createPanel()
})
await populateModelCache();
await createScene();
servoAngles.subscribe(updateAnglesFromStore);
if (panel) createPanel();
});
onDestroy(() => {
canvas.remove()
gui_panel?.destroy()
})
canvas.remove();
gui_panel?.destroy();
});
const updateAnglesFromStore = (angles: number[]) => {
if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return
modelTargetAngles = angles
}
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'
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 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 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 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([
@@ -164,14 +163,17 @@
settings.psi,
settings.xm,
settings.ym,
settings.zm
])
}
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)
}
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI);
Throttler.throttle(
() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))),
100
);
};
const createScene = async () => {
sceneManager
@@ -185,46 +187,46 @@
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop()
.startRenderLoop();
if (ground) sceneManager.addGroundPlane()
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)
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)
sceneManager.addDragControl(updateAngles);
}
if (sky) sceneManager.addSky()
if (sky) sceneManager.addSky();
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry()
const material = new LineBasicMaterial({ color: extractFootColor() })
const line = new Line(geometry, material)
trace_lines.push(geometry)
sceneManager.scene.add(line)
}
const geometry = new BufferGeometry();
const material = new LineBasicMaterial({ color: extractFootColor() });
const line = new Line(geometry, material);
trace_lines.push(geometry);
sceneManager.scene.add(line);
}
};
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
feet_trace = new Array(4).fill([])
return
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])
})
}
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
if (sceneManager.isDragging || !settings['Internal kinematic']) return;
const position: body_state_t = {
omega: settings.omega,
phi: settings.phi,
@@ -232,37 +234,37 @@
xm: settings.xm,
ym: settings.ym,
zm: settings.zm,
feet: body_state.feet
}
feet: body_state.feet,
};
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
modelTargetAngles = new_angles
}
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))
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.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)
}
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()
}
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
}
return settings['Smooth motion'] ? lerp(start, end, amount) : end;
};
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(outControllerData)
if (sceneManager.isDragging || !settings['Internal kinematic']) return;
const controlData = get(outControllerData);
const data = {
stop: controlData[0],
lx: controlData[1],
@@ -271,67 +273,67 @@
ry: controlData[4],
h: controlData[5],
s: controlData[6],
s1: controlData[7]
}
body_state.ym = ((data.h + 127) * 0.35) / 100
s1: controlData[7],
};
body_state.ym = ((data.h + 127) * 0.35) / 100;
let planner = planners[get(mode)]
const delta = performance.now() - lastTick
lastTick = performance.now()
let planner = planners[get(mode)];
const delta = performance.now() - lastTick;
lastTick = performance.now();
body_state = planner.step(body_state, data, delta)
body_state = planner.step(body_state, data, delta);
settings.omega = body_state.omega
settings.phi = body_state.phi
settings.psi = body_state.psi
settings.xm = body_state.xm
settings.ym = body_state.ym
settings.zm = body_state.zm
}
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
}
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)
}
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 robot = sceneManager.model;
if (!robot) return;
const toes = getToeWorldPositions(robot)
const toes = getToeWorldPositions(robot);
renderTraceLines(toes)
update_camera(robot)
update_gait()
calculate_kinematics()
update_robot_position(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']
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]))
);
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]));
}
orient_robot(robot, toes)
updateTargetPosition()
}
orient_robot(robot, toes);
updateTargetPosition();
};
</script>
<svelte:window onresize={sceneManager.fillParent} />
@@ -1,6 +1,10 @@
<script lang="ts">
import WidgetContainer from './WidgetContainer.svelte';
import { WidgetComponents, type WidgetContainerConfig, isWidgetConfig } from '$lib/stores/application';
import {
WidgetComponents,
type WidgetContainerConfig,
isWidgetConfig,
} from '$lib/stores/application';
import Widget from './Widget.svelte';
interface Props {
@@ -15,8 +19,7 @@
class="flex w-full h-full"
class:flex-row={container.layout === 'column'}
class:flex-col={container.layout === 'row'}
class:flex-wrap={container.layout === 'wrap'}
>
class:flex-wrap={container.layout === 'wrap'}>
{#each container.widgets as widget, index (widget.id + '-' + index)}
<Widget>
{#if isWidgetConfig(widget)}
@@ -29,8 +32,8 @@
{#if index !== container.widgets.length - 1}
<div
class="divider bg-base-300 m-0"
class:divider-horizontal={container.layout === 'column'}
></div>
class:divider-horizontal={container.layout === 'column'}>
</div>
{/if}
{/each}
</div>
+53 -53
View File
@@ -1,9 +1,9 @@
<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 { 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,
@@ -20,28 +20,28 @@
AP,
Copyright,
Metrics,
DNS
} from '$lib/components/icons'
import appEnv from 'app-env'
DNS,
} from '$lib/components/icons';
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public';
const features = useFeatureFlags()
const features = useFeatureFlags();
const appName = page.data.app_name
const appName = page.data.app_name;
const copyright = page.data.copyright
const copyright = page.data.copyright;
const github = { href: 'https://github.com/' + page.data.github, active: true }
const github = { href: 'https://github.com/' + page.data.github, active: true };
type menuItem = {
title: string
icon: ConstructorOfATypedSvelteComponent
href?: string
feature: boolean
active?: boolean
submenu?: menuItem[]
}
title: string;
icon: ConstructorOfATypedSvelteComponent;
href?: string;
feature: boolean;
active?: boolean;
submenu?: menuItem[];
};
let menuItems = $state<menuItem[]>([])
let menuItems = $state<menuItem[]>([]);
$effect(() => {
menuItems = [
@@ -49,13 +49,13 @@
title: 'Connection',
icon: WiFi,
href: '/connection',
feature: !appEnv.VITE_USE_HOST_NAME
feature: !PUBLIC_VITE_USE_HOST_NAME,
},
{
title: 'Controller',
icon: MdiController,
href: '/controller',
feature: true
feature: true,
},
{
title: 'Peripherals',
@@ -66,27 +66,27 @@
title: 'I2C',
icon: Connection,
href: '/peripherals/i2c',
feature: true
feature: true,
},
{
title: 'Camera',
icon: Camera,
href: '/peripherals/camera',
feature: $features.camera
feature: $features.camera,
},
{
title: 'Servo',
icon: MotorOutline,
href: '/peripherals/servo',
feature: true
feature: true,
},
{
title: 'IMU',
icon: Rotate3d,
href: '/peripherals/imu',
feature: $features.imu || $features.mag || $features.bmp
}
]
feature: $features.imu || $features.mag || $features.bmp,
},
],
},
{
title: 'WiFi',
@@ -97,21 +97,21 @@
title: 'WiFi Station',
icon: Router,
href: '/wifi/sta',
feature: true
feature: true,
},
{
title: 'Access Point',
icon: AP,
href: '/wifi/ap',
feature: true
feature: true,
},
{
title: 'mDNS',
icon: DNS,
href: '/wifi/mdns',
feature: true
}
]
feature: true,
},
],
},
{
title: 'System',
@@ -122,51 +122,51 @@
title: 'System Status',
icon: Health,
href: '/system/status',
feature: true
feature: true,
},
{
title: 'File System',
icon: Folder,
href: '/system/filesystem',
feature: true
feature: true,
},
{
title: 'System Metrics',
icon: Metrics,
href: '/system/metrics',
feature: true
feature: true,
},
{
title: 'Firmware Update',
icon: Update,
href: '/system/update',
feature: $features.ota || $features.upload_firmware || $features.download_firmware
}
]
}
] as menuItem[]
})
feature: $features.ota || $features.upload_firmware || $features.download_firmware,
},
],
},
] as menuItem[];
});
const { menuClicked } = $props()
const { menuClicked } = $props();
function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => {
item.active = item.title === targetTitle
item.active = item.title === targetTitle;
item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle
})
})
menuItems = menuItems
menuClicked()
subItem.active = subItem.title === targetTitle;
});
});
menuItems = menuItems;
menuClicked();
}
$effect(() => {
setActiveMenuItem(page.data.title)
})
setActiveMenuItem(page.data.title);
});
const updateMenu = (event: any) => {
setActiveMenuItem(event.details)
}
setActiveMenuItem(event.details);
};
</script>
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
@@ -25,7 +25,7 @@
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
'X-GitHub-Api-Version': '2022-11-28',
};
const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${page.data.github}/releases/latest`,
@@ -44,13 +44,13 @@
update = false;
firmwareVersion = '';
if (compareVersions(results.tag_name, $features.firmware_version) === 1) {
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
// iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin
if (
results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($features.firmware_built_target)
results.assets[i].name.includes($features.firmware_built_target as string)
) {
update = true;
firmwareVersion = results.tag_name;
@@ -82,14 +82,14 @@
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 }
confirm: { label: 'Update', icon: CloudDown },
},
onConfirm: () => {
postGithubDownload(url);
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
onConfirm: () => modals.closeAll(),
});
}
},
});
}
</script>
@@ -98,11 +98,9 @@
<div class="indicator flex-none">
<button
class="btn btn-square btn-ghost h-9 w-9"
onclick={() => confirmGithubUpdate(firmwareDownloadLink)}
>
onclick={() => confirmGithubUpdate(firmwareDownloadLink)}>
<span
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
>
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" />
@@ -1,11 +1,11 @@
<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";
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 chartElement: HTMLCanvasElement;
let chart: Chart;
interface Props {
@@ -30,36 +30,36 @@
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data,
yAxisID: 'y'
yAxisID: 'y',
},
]
],
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
display: true,
},
tooltip: {
mode: 'index',
intersect: false
}
intersect: false,
},
},
elements: {
point: {
radius: 0
}
radius: 0,
},
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
color: daisyColor('--bc', 10),
},
ticks: {
color: daisyColor('--bc')
color: daisyColor('--bc'),
},
display: false
display: false,
},
y: {
type: 'linear',
@@ -69,35 +69,33 @@
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
weight: 'bold',
},
},
position: 'left',
min: 0,
max: 100,
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
color: daisyColor('--bc'),
},
border: { color: daisyColor('--bc', 10) },
},
},
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
setInterval(() => {
chart.data.labels = data
chart.data.datasets[0].data = data
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 }}
>
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<canvas bind:this={chartElement}></canvas>
</div>
</div>
@@ -2,7 +2,7 @@
interface Props {
options?: string[];
selectedOption?: string;
change: () => void;
change?: () => void;
[key: string]: any;
}
@@ -12,8 +12,7 @@
<select
bind:value={selectedOption}
{...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"
>
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}">
{#each options as option}
<option value={option}>{option}</option>
{/each}
+3 -3
View File
@@ -2,13 +2,13 @@ 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>>;
let featureFlagsStore: Writable<Record<string, boolean | string>>;
export function useFeatureFlags() {
if (!featureFlagsStore) {
featureFlagsStore = writable<Record<string, boolean>>({});
featureFlagsStore = writable<Record<string, boolean | string>>({});
api.get<Record<string, boolean>>('/api/features').then((result) => {
api.get<Record<string, boolean>>('/api/features').then(result => {
if (result.isOk()) featureFlagsStore.set(result.inner);
else {
notifications.error('Feature flag could not be fetched', 2500);
+2 -2
View File
@@ -1,5 +1,5 @@
import { persistentStore } from '$lib/utilities';
import { writable } from 'svelte/store';
import appEnv from 'app-env';
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public';
export const location = appEnv.VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '');
export const location = PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '');
-27
View File
@@ -1,27 +0,0 @@
import { readable } from 'svelte/store';
export const heading = readable(0, (set) => {
const updateHeading = (e: any) => {
let alpha;
if (e.webkitCompassHeading) alpha = e.webkitCompassHeading;
else if (e.alpha) alpha = e.alpha;
else {
let q = e.target.quaternion;
alpha =
Math.atan2(2 * q[0] * q[1] + 2 * q[2] * q[3], 1 - 2 * q[1] * q[1] - 2 * q[2] * q[2]) *
(180 / Math.PI);
if (alpha < 0) alpha += 360;
}
set(alpha);
};
if ('AbsoluteOrientationSensor' in window) {
var sensor = new window.AbsoluteOrientationSensor({ frequency: 60 }) as any;
sensor.addEventListener('reading', updateHeading);
sensor.start();
} else if (window.DeviceMotionEvent) window.addEventListener('deviceorientation', updateHeading);
return () => {
if ('AbsoluteOrientationSensor' in window) sensor.removeEventListener('reading', updateHeading);
window.addEventListener('deviceorientation', updateHeading);
};
});
+1 -1
View File
@@ -17,7 +17,7 @@ const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => {
}
return JSON.parse(data as string);
} catch (error) {
console.error(`Could not decode data: ${data} - ${error}`);
console.error(`Could not decode data: ${new Uint8Array(data as ArrayBuffer)} - ${error}`);
}
return null;
};
+3 -3
View File
@@ -6,8 +6,8 @@ const registerFetchIntercept = async () => {
const fileService = (await import('$lib/services/file-service')).default;
window.fetch = async (resource, config) => {
let url = resource instanceof Request ? resource.url : resource.toString();
let file = await fileService.getFile(url);
return file.isOk() ? new Response(file.inner) : originalFetch(resource, config);
let file = await fileService?.getFile(url);
return file?.isOk() ? new Response(file.inner) : originalFetch(resource, config);
};
};
@@ -17,6 +17,6 @@ export const load = async () => {
title: 'Spot micro controller',
github: 'runeharlyk/SpotMicroESP32-Leika',
app_name: 'Spot Micro Controller',
copyright: '2024 Rune Harlyk'
copyright: '2025 Rune Harlyk',
};
};
+88 -88
View File
@@ -1,51 +1,51 @@
<script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte'
import { imu } from '$lib/stores/imu'
import { Chart, registerables } from 'chart.js'
import { cubicOut } from 'svelte/easing'
import { slide } from 'svelte/transition'
import { onDestroy, onMount } from 'svelte'
import { socket } from '$lib/stores'
import type { IMU } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Rotate3d } from '$lib/components/icons'
import SettingsCard from '$lib/components/SettingsCard.svelte';
import { imu } from '$lib/stores/imu';
import { Chart, registerables } from 'chart.js';
import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import { onDestroy, onMount } from 'svelte';
import { socket } from '$lib/stores';
import type { IMU } from '$lib/types/models';
import { useFeatureFlags } from '$lib/stores/featureFlags';
import { Rotate3d } from '$lib/components/icons';
Chart.register(...registerables)
Chart.register(...registerables);
const features = useFeatureFlags()
let intervalId: number
const features = useFeatureFlags();
let intervalId: ReturnType<typeof setInterval> | number;
let angleChartElement: HTMLCanvasElement = $state()
let tempChartElement: HTMLCanvasElement = $state()
let altitudeChartElement: HTMLCanvasElement = $state()
let angleChartElement: HTMLCanvasElement;
let tempChartElement: HTMLCanvasElement;
let altitudeChartElement: HTMLCanvasElement;
let angleChart: Chart
let tempChart: Chart
let altitudeChart: Chart
let angleChart: Chart;
let tempChart: Chart;
let altitudeChart: Chart;
const getChartColors = () => {
const style = getComputedStyle(document.body)
const style = getComputedStyle(document.body);
return {
primary: style.getPropertyValue('--color-primary'),
secondary: style.getPropertyValue('--color-secondary'),
accent: style.getPropertyValue('--color-accent'),
background: style.getPropertyValue('--color-background')
}
}
background: style.getPropertyValue('--color-background'),
};
};
const createBaseChartConfig = (bgColor: string) => ({
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: { display: true },
tooltip: { mode: 'index', intersect: false }
tooltip: { mode: 'index', intersect: false },
},
elements: { point: { radius: 1 } },
scales: {
x: {
grid: { color: bgColor },
ticks: { color: bgColor },
display: false
display: false,
},
y: {
type: 'linear',
@@ -54,14 +54,14 @@
max: 10,
grid: { color: bgColor },
ticks: { color: bgColor },
border: { color: bgColor }
}
}
})
border: { color: bgColor },
},
},
});
const initializeCharts = () => {
const colors = getChartColors()
const baseConfig = createBaseChartConfig(colors.background)
const colors = getChartColors();
const baseConfig = createBaseChartConfig(colors.background);
angleChart = new Chart(angleChartElement, {
type: 'line',
@@ -73,7 +73,7 @@
backgroundColor: colors.primary,
borderWidth: 2,
data: $imu.x,
yAxisID: 'y'
yAxisID: 'y',
},
{
label: 'y',
@@ -81,7 +81,7 @@
backgroundColor: colors.secondary,
borderWidth: 2,
data: $imu.y,
yAxisID: 'y'
yAxisID: 'y',
},
{
label: 'z',
@@ -89,9 +89,9 @@
backgroundColor: colors.accent,
borderWidth: 2,
data: $imu.z,
yAxisID: 'y'
}
]
yAxisID: 'y',
},
],
},
options: {
...baseConfig,
@@ -103,12 +103,12 @@
display: true,
text: 'Angle [°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
})
font: { size: 16, weight: 'bold' },
},
},
},
},
});
tempChart = new Chart(tempChartElement, {
type: 'line',
@@ -120,9 +120,9 @@
backgroundColor: colors.secondary,
borderWidth: 2,
data: $imu.bmp_temp,
yAxisID: 'y'
}
]
yAxisID: 'y',
},
],
},
options: {
...baseConfig,
@@ -134,12 +134,12 @@
display: true,
text: 'Temperature [C°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
})
font: { size: 16, weight: 'bold' },
},
},
},
},
});
altitudeChart = new Chart(altitudeChartElement, {
type: 'line',
@@ -151,9 +151,9 @@
backgroundColor: colors.primary,
borderWidth: 2,
data: $imu.altitude,
yAxisID: 'y'
}
]
yAxisID: 'y',
},
],
},
options: {
...baseConfig,
@@ -165,60 +165,60 @@
display: true,
text: 'Altitude [M]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
})
}
font: { size: 16, weight: 'bold' },
},
},
},
},
});
};
const updateChartData = (chart: Chart, data: number[], label: string) => {
chart.data.labels = data
chart.data.datasets[0].data = data
chart.options.scales!.y!.min = Math.min(...data) - 1
chart.options.scales!.y!.max = Math.max(...data) + 1
chart.update('none')
}
chart.data.labels = data;
chart.data.datasets[0].data = data;
chart.options.scales!.y!.min = Math.min(...data) - 1;
chart.options.scales!.y!.max = Math.max(...data) + 1;
chart.update('none');
};
const updateData = () => {
if ($features.imu) {
angleChart.data.labels = $imu.x
angleChart.data.datasets[0].data = $imu.x
angleChart.data.datasets[1].data = $imu.y
angleChart.data.datasets[2].data = $imu.z
angleChart.data.labels = $imu.x;
angleChart.data.datasets[0].data = $imu.x;
angleChart.data.datasets[1].data = $imu.y;
angleChart.data.datasets[2].data = $imu.z;
const allValues = [...$imu.x, ...$imu.y, ...$imu.z]
angleChart.options.scales!.y!.min = Math.min(...allValues) - 1
angleChart.options.scales!.y!.max = Math.max(...allValues) + 1
angleChart.update('none')
const allValues = [...$imu.x, ...$imu.y, ...$imu.z];
angleChart.options.scales!.y!.min = Math.min(...allValues) - 1;
angleChart.options.scales!.y!.max = Math.max(...allValues) + 1;
angleChart.update('none');
}
if ($features.bmp) {
updateChartData(tempChart, $imu.bmp_temp, 'Temperature')
updateChartData(altitudeChart, $imu.altitude, 'Altitude')
}
updateChartData(tempChart, $imu.bmp_temp, 'Temperature');
updateChartData(altitudeChart, $imu.altitude, 'Altitude');
}
};
onMount(() => {
socket.on('imu', (data: IMU) => {
console.log(data)
imu.addData(data)
})
console.log(data);
imu.addData(data);
});
initializeCharts()
intervalId = setInterval(updateData, 200)
})
initializeCharts();
intervalId = setInterval(updateData, 200);
});
onDestroy(() => {
socket.off('imu')
clearInterval(intervalId)
})
socket.off('imu');
clearInterval(intervalId);
});
</script>
<SettingsCard collapsible={false}>
{#snippet icon()}
<Rotate3d class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<Rotate3d class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet}
{#snippet title()}
<span>IMU</span>
@@ -1,13 +1,5 @@
<script lang="ts">
import SystemMetrics from './SystemMetrics.svelte';
import { goto } from '$app/navigation';
import { useFeatureFlags } from '$lib/stores/featureFlags';
const features = useFeatureFlags();
if (!$features.analytics) {
goto('/');
}
</script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
@@ -12,16 +12,16 @@
Chart.register(...registerables);
let cpuChartElement: HTMLCanvasElement = $state();
let cpuChartElement: HTMLCanvasElement;
let cpuChart: Chart;
let heapChartElement: HTMLCanvasElement = $state();
let heapChartElement: HTMLCanvasElement;
let heapChart: Chart;
let filesystemChartElement: HTMLCanvasElement = $state();
let filesystemChartElement: HTMLCanvasElement;
let filesystemChart: Chart;
let temperatureChartElement: HTMLCanvasElement = $state();
let temperatureChartElement: HTMLCanvasElement;
let temperatureChart: Chart;
onMount(() => {
@@ -36,7 +36,7 @@
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $analytics.cpu0_usage,
yAxisID: 'y'
yAxisID: 'y',
},
{
label: 'Cpu usage core 1',
@@ -44,7 +44,7 @@
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $analytics.cpu1_usage,
yAxisID: 'y'
yAxisID: 'y',
},
{
label: 'Cpu usage total',
@@ -52,36 +52,36 @@
backgroundColor: daisyColor('--s', 50),
borderWidth: 2,
data: $analytics.cpu_usage,
yAxisID: 'y'
yAxisID: 'y',
},
]
],
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
display: true,
},
tooltip: {
mode: 'index',
intersect: false
}
intersect: false,
},
},
elements: {
point: {
radius: 0
}
radius: 0,
},
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
color: daisyColor('--bc', 10),
},
ticks: {
color: daisyColor('--bc')
color: daisyColor('--bc'),
},
display: false
display: false,
},
y: {
type: 'linear',
@@ -91,20 +91,20 @@
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
weight: 'bold',
},
},
position: 'left',
min: 0,
max: 100,
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
color: daisyColor('--bc'),
},
border: { color: daisyColor('--bc', 10) },
},
},
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
heapChart = new Chart(heapChartElement, {
type: 'line',
@@ -118,36 +118,36 @@
borderWidth: 2,
data: $analytics.used_heap,
fill: true,
yAxisID: 'y'
}
]
yAxisID: 'y',
},
],
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
display: true,
},
tooltip: {
mode: 'index',
intersect: false
}
intersect: false,
},
},
elements: {
point: {
radius: 0
}
radius: 0,
},
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
color: daisyColor('--bc', 10),
},
ticks: {
color: daisyColor('--bc')
color: daisyColor('--bc'),
},
display: false
display: false,
},
y: {
type: 'linear',
@@ -157,20 +157,20 @@
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
weight: 'bold',
},
},
position: 'left',
min: 0,
max: Math.round($analytics.total_heap[0]),
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
color: daisyColor('--bc'),
},
border: { color: daisyColor('--bc', 10) },
},
},
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
filesystemChart = new Chart(filesystemChartElement, {
type: 'line',
@@ -184,36 +184,36 @@
borderWidth: 2,
data: $analytics.fs_used,
fill: true,
yAxisID: 'y'
}
]
yAxisID: 'y',
},
],
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
display: true,
},
tooltip: {
mode: 'index',
intersect: false
}
intersect: false,
},
},
elements: {
point: {
radius: 0
}
radius: 0,
},
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
color: daisyColor('--bc', 10),
},
ticks: {
color: daisyColor('--bc')
color: daisyColor('--bc'),
},
display: false
display: false,
},
y: {
type: 'linear',
@@ -223,20 +223,20 @@
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
weight: 'bold',
},
},
position: 'left',
min: 0,
max: Math.round($analytics.fs_total[0]),
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
color: daisyColor('--bc'),
},
border: { color: daisyColor('--bc', 10) },
},
},
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
temperatureChart = new Chart(temperatureChartElement, {
type: 'line',
@@ -249,36 +249,36 @@
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $analytics.core_temp,
yAxisID: 'y'
}
]
yAxisID: 'y',
},
],
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
display: true,
},
tooltip: {
mode: 'index',
intersect: false
}
intersect: false,
},
},
elements: {
point: {
radius: 0
}
radius: 0,
},
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
color: daisyColor('--bc', 10),
},
ticks: {
color: daisyColor('--bc')
color: daisyColor('--bc'),
},
display: false
display: false,
},
y: {
type: 'linear',
@@ -288,20 +288,20 @@
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
weight: 'bold',
},
},
position: 'left',
suggestedMin: 20,
suggestedMax: 100,
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
color: daisyColor('--bc'),
},
border: { color: daisyColor('--bc', 10) },
},
},
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
setInterval(updateData, 500);
});
@@ -340,8 +340,7 @@
<div class="w-full overflow-x-auto">
<div
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={cpuChartElement}></canvas>
</div>
</div>
@@ -349,24 +348,21 @@
<div class="w-full overflow-x-auto">
<div
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={heapChartElement}></canvas>
</div>
</div>
<div class="w-full overflow-x-auto">
<div
class="flex w-full flex-col space-y-1 h-52"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<canvas bind:this={filesystemChartElement}></canvas>
</div>
</div>
<div class="w-full overflow-x-auto">
<div
class="flex w-full flex-col space-y-1 h-52"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<canvas bind:this={temperatureChartElement}></canvas>
</div>
</div>
@@ -1,16 +1,16 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import { modals } from 'svelte-modals'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import SettingsCard from '$lib/components/SettingsCard.svelte'
import Spinner from '$lib/components/Spinner.svelte'
import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import type { SystemInformation, Analytics } from '$lib/types/models'
import { socket } from '$lib/stores/socket'
import { api } from '$lib/api'
import { convertSeconds } from '$lib/utilities'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import { onDestroy, onMount } from 'svelte';
import { modals } from 'svelte-modals';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import type { SystemInformation, Analytics } from '$lib/types/models';
import { socket } from '$lib/stores/socket';
import { api } from '$lib/api';
import { convertSeconds } from '$lib/utilities';
import { useFeatureFlags } from '$lib/stores/featureFlags';
import {
Cancel,
Power,
@@ -27,37 +27,42 @@
Flash,
Folder,
Temperature,
Stopwatch
} from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte'
import ActionButton from './ActionButton.svelte'
Stopwatch,
} from '$lib/components/icons';
import StatusItem from '$lib/components/StatusItem.svelte';
import ActionButton from './ActionButton.svelte';
const features = useFeatureFlags()
const features = useFeatureFlags();
let systemInformation: SystemInformation = $state()
let systemInformation: SystemInformation | null = $state(null);
async function getSystemStatus() {
const result = await api.get<SystemInformation>('/api/system/status')
const result = await api.get<SystemInformation>('/api/system/status');
if (result.isErr()) {
console.error('Error:', result.inner)
return
console.error('Error:', result.inner);
return;
}
systemInformation = result.inner
return systemInformation
systemInformation = result.inner;
return systemInformation;
}
const postFactoryReset = async () => await api.post('/api/system/reset')
const postFactoryReset = async () => await api.post('/api/system/reset');
const postSleep = async () => await api.post('api/sleep')
const postSleep = async () => await api.post('api/sleep');
onMount(() => socket.on('analytics', handleSystemData))
onMount(() => socket.on('analytics', handleSystemData));
onDestroy(() => socket.off('analytics', handleSystemData))
onDestroy(() => socket.off('analytics', handleSystemData));
const handleSystemData = (data: Analytics) => {
if (systemInformation) {
systemInformation = {
...systemInformation,
...(data as unknown as SystemInformation),
};
}
};
const handleSystemData = (data: Analytics) =>
(systemInformation = { ...systemInformation, ...data })
const postRestart = async () => await api.post('/api/system/restart')
const postRestart = async () => await api.post('/api/system/restart');
function confirmRestart() {
modals.open(ConfirmDialog, {
@@ -65,13 +70,13 @@
message: 'Are you sure you want to restart the device?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Restart', icon: Power }
confirm: { label: 'Restart', icon: Power },
},
onConfirm: () => {
modals.close()
postRestart()
}
})
modals.close();
postRestart();
},
});
}
function confirmReset() {
@@ -80,13 +85,13 @@
message: 'Are you sure you want to reset the device to its factory defaults?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Factory Reset', icon: FactoryReset }
confirm: { label: 'Factory Reset', icon: FactoryReset },
},
onConfirm: () => {
modals.close()
postFactoryReset()
}
})
modals.close();
postFactoryReset();
},
});
}
function confirmSleep() {
@@ -95,21 +100,21 @@
message: 'Are you sure you want to put the device into sleep?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Sleep', icon: Sleep }
confirm: { label: 'Sleep', icon: Sleep },
},
onConfirm: () => {
modals.close()
postSleep()
}
})
modals.close();
postSleep();
},
});
}
interface ActionButtonDef {
icon: any
label: string
onClick: () => void
type?: string
condition?: () => boolean
icon: any;
label: string;
onClick: () => void;
type?: string;
condition?: () => boolean;
}
const actionButtons: ActionButtonDef[] = [
@@ -117,20 +122,20 @@
icon: Sleep,
label: 'Sleep',
onClick: confirmSleep,
condition: () => Boolean($features.sleep)
condition: () => Boolean($features.sleep),
},
{
icon: Power,
label: 'Restart',
onClick: confirmRestart
onClick: confirmRestart,
},
{
icon: FactoryReset,
label: 'Factory Reset',
onClick: confirmReset,
type: 'secondary'
}
]
type: 'secondary',
},
];
</script>
<SettingsCard collapsible={false}>
@@ -144,7 +149,8 @@
<div class="w-full overflow-x-auto">
{#await getSystemStatus()}
<Spinner />
{:then nothing}
{:then}
{#if systemInformation}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
@@ -201,9 +207,10 @@
<StatusItem
icon={Folder}
title="File System (Used / Total)"
description={`${((systemInformation.fs_used / systemInformation.fs_total) * 100).toFixed(
1
)} % of ${systemInformation.fs_total / 1000000} MB used (${
description={`${(
(systemInformation.fs_used / systemInformation.fs_total) *
100
).toFixed(1)} % of ${systemInformation.fs_total / 1000000} MB used (${
(systemInformation.fs_total - systemInformation.fs_used) / 1000000
}
MB free)`} />
@@ -227,6 +234,7 @@
title="Reset Reason"
description={systemInformation.cpu_reset_reason} />
</div>
{/if}
{/await}
</div>
@@ -19,10 +19,10 @@
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
'X-GitHub-Api-Version': '2022-11-28',
};
const result = await api.get(`https://api.github.com/repos/${page.data.github}/releases`, {
headers
headers,
});
if (result.isErr()) {
console.error('Error:', result.inner);
@@ -58,7 +58,7 @@
message:
'No matching firmware was found for the current device. Upload the firmware manually or build from sources.',
dismiss: { label: 'OK', icon: Check },
onDismiss: () => modals.close()
onDismiss: () => modals.close(),
});
return;
}
@@ -68,14 +68,14 @@
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 }
confirm: { label: 'Update', icon: CloudDown },
},
onConfirm: () => {
postGithubDownload(url);
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
onConfirm: () => modals.closeAll(),
});
}
},
});
}
</script>
@@ -91,10 +91,7 @@
<Spinner />
{:then githubReleases}
<div class="relative w-full overflow-visible">
<div
class="overflow-x-auto"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="overflow-x-auto" transition:slide|local={{ duration: 300, easing: cubicOut }}>
<table class="table w-full table-auto">
<thead>
<tr class="font-bold">
@@ -108,26 +105,21 @@
{#each githubReleases as release}
<tr
class={(
compareVersions(
$features.firmware_version,
release.tag_name
) === 0
compareVersions($features.firmware_version as string, release.tag_name) === 0
) ?
'bg-primary text-primary-content'
: 'bg-base-100 h-14'}
>
: 'bg-base-100 h-14'}>
<td align="left" class="text-base font-semibold">
<a
href={release.html_url}
class="link link-hover"
target="_blank"
rel="noopener noreferrer">{release.name}</a
></td
>
></td>
<td align="center" class="hidden min-h-full align-middle sm:block">
<div class="my-2">
{new Intl.DateTimeFormat('en-GB', {
dateStyle: 'medium'
dateStyle: 'medium',
}).format(new Date(release.published_at))}
</div>
</td>
@@ -137,13 +129,12 @@
{/if}
</td>
<td align="center">
{#if compareVersions($features.firmware_version, release.tag_name) != 0}
{#if compareVersions($features.firmware_version as string, release.tag_name) != 0}
<button
class="btn btn-ghost btn-circle btn-sm"
onclick={() => {
confirmGithubUpdate(release.assets);
}}
>
}}>
<CloudDown class="text-secondary h-6 w-6" />
</button>
{/if}
@@ -157,9 +148,7 @@
{:catch error}
<div class="alert alert-error shadow-lg">
<Error class="h-6 w-6 shrink-0" />
<span
>Please connect to a network with internet access to perform a firmware update.</span
>
<span>Please connect to a network with internet access to perform a firmware update.</span>
</div>
{/await}
</SettingsCard>
@@ -6,11 +6,11 @@
import { api } from '$lib/api';
import { Cancel, OTA, Warning } from '$lib/components/icons';
let files: FileList = $state();
let files: FileList | undefined = $state();
async function uploadBIN() {
const formData = new FormData();
formData.append('file', files[0]);
formData.append('file', files![0]);
const result = await api.post('/api/firmware', formData);
if (result.isErr()) console.error('Error:', result.inner);
}
@@ -21,12 +21,12 @@
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Upload', icon: OTA }
confirm: { label: 'Upload', icon: OTA },
},
onConfirm: () => {
modals.close();
uploadBIN();
}
},
});
}
</script>
@@ -41,8 +41,8 @@
<div class="alert alert-warning shadow-lg">
<Warning class="h-6 w-6 shrink-0" />
<span
>Uploading a new firmware (.bin) file will replace the existing firmware. You may upload
a (.md5) file first to verify the uploaded firmware.
>Uploading a new firmware (.bin) file will replace the existing firmware. You may upload a
(.md5) file first to verify the uploaded firmware.
</span>
</div>
@@ -52,6 +52,5 @@
class="file-input file-input-bordered file-input-secondary mt-4 w-full"
bind:files
accept=".bin,.md5"
onchange={confirmBinUpload}
/>
onchange={confirmBinUpload} />
</SettingsCard>
+77 -76
View File
@@ -1,74 +1,71 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy'
import { preventDefault } from 'svelte/legacy';
import { onMount, onDestroy } from 'svelte'
import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { PasswordInput } from '$lib/components/input'
import SettingsCard from '$lib/components/SettingsCard.svelte'
import { notifications } from '$lib/components/toasts/notifications'
import Spinner from '$lib/components/Spinner.svelte'
import type { ApSettings, ApStatus } from '$lib/types/models'
import { api } from '$lib/api'
import { useFeatureFlags } from '$lib/stores'
import { AP, Devices, Home, MAC } from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte'
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { PasswordInput } from '$lib/components/input';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import { notifications } from '$lib/components/toasts/notifications';
import Spinner from '$lib/components/Spinner.svelte';
import type { ApSettings, ApStatus } from '$lib/types/models';
import { api } from '$lib/api';
import { AP, Devices, Home, MAC } from '$lib/components/icons';
import StatusItem from '$lib/components/StatusItem.svelte';
const features = useFeatureFlags()
let apSettings: ApSettings | null = $state(null);
let apStatus: ApStatus | null = $state(null);
let apSettings: ApSettings = $state()
let apStatus: ApStatus = $state()
let formField: any = $state()
let formField: any = $state();
async function getAPStatus() {
const result = await api.get<ApStatus>('/api/wifi/ap/status')
const result = await api.get<ApStatus>('/api/wifi/ap/status');
if (result.isErr()) {
console.error('Error:', result.inner)
return
console.error('Error:', result.inner);
return;
}
apStatus = result.inner
return apStatus
apStatus = result.inner;
return apStatus;
}
async function getAPSettings() {
const result = await api.get<ApSettings>('/api/wifi/ap/settings')
const result = await api.get<ApSettings>('/api/wifi/ap/settings');
if (result.isErr()) {
console.error('Error:', result.inner)
return
console.error('Error:', result.inner);
return;
}
apSettings = result.inner
return apSettings
apSettings = result.inner;
return apSettings;
}
const interval = setInterval(async () => {
getAPStatus()
}, 5000)
getAPStatus();
}, 5000);
onDestroy(() => clearInterval(interval))
onDestroy(() => clearInterval(interval));
onMount(getAPSettings)
onMount(getAPSettings);
let provisionMode = [
{
id: 0,
text: `Always`
text: `Always`,
},
{
id: 1,
text: `When WiFi Disconnected`
text: `When WiFi Disconnected`,
},
{
id: 2,
text: `Never`
}
]
text: `Never`,
},
];
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning';
let apStatusVariant: Variant[] = ['success', 'error', 'warning']
let apStatusVariant: Variant[] = ['success', 'error', 'warning'];
let apStatusDescription = ['Active', 'Inactive', 'Lingering']
let apStatusDescription = ['Active', 'Inactive', 'Lingering'];
let formErrors = $state({
ssid: false,
@@ -76,80 +73,81 @@
max_clients: false,
local_ip: false,
gateway_ip: false,
subnet_mask: false
})
subnet_mask: false,
});
async function postAPSettings(data: ApSettings) {
const result = await api.post<ApSettings>('/api/wifi/ap/settings', data)
const result = await api.post<ApSettings>('/api/wifi/ap/settings', data);
if (result.isErr()) {
notifications.error('User not authorized.', 3000)
console.error('Error:', result.inner)
return
notifications.error('User not authorized.', 3000);
console.error('Error:', result.inner);
return;
}
notifications.success('Access Point settings updated.', 3000)
apSettings = result.inner
notifications.success('Access Point settings updated.', 3000);
apSettings = result.inner;
}
function handleSubmitAP() {
let valid = true
if (!apSettings) return;
let valid = true;
// Validate SSID
if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) {
valid = false
formErrors.ssid = true
valid = false;
formErrors.ssid = true;
} else {
formErrors.ssid = false
formErrors.ssid = false;
}
// Validate Channel
let channel = Number(apSettings.channel)
let channel = Number(apSettings.channel);
if (1 > channel || channel > 13) {
valid = false
formErrors.channel = true
valid = false;
formErrors.channel = true;
} else {
formErrors.channel = false
formErrors.channel = false;
}
// Validate max_clients
let maxClients = Number(apSettings.max_clients)
let maxClients = Number(apSettings.max_clients);
if (1 > maxClients || maxClients > 8) {
valid = false
formErrors.max_clients = true
valid = false;
formErrors.max_clients = true;
} else {
formErrors.max_clients = false
formErrors.max_clients = false;
}
// RegEx for IPv4
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
/\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/;
// Validate gateway IP
if (!regexExp.test(apSettings.gateway_ip)) {
valid = false
formErrors.gateway_ip = true
valid = false;
formErrors.gateway_ip = true;
} else {
formErrors.gateway_ip = false
formErrors.gateway_ip = false;
}
// Validate Subnet Mask
if (!regexExp.test(apSettings.subnet_mask)) {
valid = false
formErrors.subnet_mask = true
valid = false;
formErrors.subnet_mask = true;
} else {
formErrors.subnet_mask = false
formErrors.subnet_mask = false;
}
// Validate local IP
if (!regexExp.test(apSettings.local_ip)) {
valid = false
formErrors.local_ip = true
valid = false;
formErrors.local_ip = true;
} else {
formErrors.local_ip = false
formErrors.local_ip = false;
}
// Submit JSON to REST API
if (valid) {
postAPSettings(apSettings)
postAPSettings(apSettings);
}
}
</script>
@@ -164,7 +162,8 @@
<div class="w-full overflow-x-auto">
{#await getAPStatus()}
<Spinner />
{:then nothing}
{:then}
{#if apStatus}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
@@ -180,6 +179,7 @@
<StatusItem icon={Devices} title="AP Clients" description={apStatus.station_num} />
</div>
{/if}
{/await}
</div>
@@ -190,7 +190,8 @@
</div>
{#await getAPSettings()}
<Spinner />
{:then nothing}
{:then}
{#if apSettings}
<div
class="flex flex-col gap-2 p-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
@@ -292,9 +293,8 @@
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.local_ip ? 'border-error border-2' : (
''
)}"
class="input input-bordered w-full {formErrors.local_ip ? 'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
@@ -359,6 +359,7 @@
</div>
</form>
</div>
{/if}
{/await}
</div>
</SettingsCard>
+12 -28
View File
@@ -6,15 +6,9 @@
import type { NetworkItem, NetworkList } from '$lib/types/models';
import { api } from '$lib/api';
import { AP, Network, Reload, Cancel } from '$lib/components/icons';
import { modals, exitBeforeEnter } from 'svelte-modals';
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals';
// provided by <Modals />
interface Props {
isOpen: boolean;
storeNetwork: any;
}
let { isOpen, storeNetwork }: Props = $props();
let { isOpen, storeNetwork }: ModalProps = $props();
const encryptionType = [
'Open',
@@ -25,14 +19,14 @@
'WPA2 Enterprise',
'WPA3 PSK',
'WPA2 WPA3 PSK',
'WAPI PSK'
'WAPI PSK',
];
let listOfNetworks: NetworkItem[] = $state([]);
let scanActive = $state(false);
let pollingId: number;
let pollingId: ReturnType<typeof setTimeout> | number;
async function scanNetworks() {
scanActive = true;
@@ -77,17 +71,13 @@
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap
>
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"
>
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">Scan Networks</h2>
<div class="divider my-2"></div>
<div class="overflow-y-auto">
{#if scanActive}<div
class="bg-base-100 flex flex-col items-center justify-center p-6"
>
{#if scanActive}<div class="bg-base-100 flex flex-col items-center justify-center p-6">
<AP class="text-secondary h-32 w-32 shrink animate-ping stroke-2" />
<p class="mt-8 text-2xl">Scanning ...</p>
</div>
@@ -102,18 +92,14 @@
storeNetwork(network.ssid);
}}
role="button"
tabindex="0"
>
tabindex="0">
<div class="mask mask-hexagon bg-primary h-auto w-10 shrink-0">
<Network
class="text-primary-content h-auto w-full scale-75"
/>
<Network class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">{network.ssid}</div>
<div class="text-sm opacity-75">
Security: {encryptionType[network.encryption_type]},
Channel: {network.channel}
Security: {encryptionType[network.encryption_type]}, Channel: {network.channel}
</div>
</div>
<div class="grow"></div>
@@ -129,16 +115,14 @@
<button
class="btn btn-primary inline-flex flex-none items-center"
disabled={scanActive}
onclick={scanNetworks}
>
onclick={scanNetworks}>
<Reload class="mr-2 h-5 w-5" /><span>Scan again</span>
</button>
<div class="grow"></div>
<button
class="btn btn-warning text-warning-content inline-flex flex-none items-center"
onclick={() => modals.close()}
>
onclick={() => modals.close()}>
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
</button>
</div>
+134 -128
View File
@@ -1,19 +1,19 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { modals } from 'svelte-modals'
import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { notifications } from '$lib/components/toasts/notifications'
import DragDropList, { VerticalDropZone, reorder, type DropEvent } from 'svelte-dnd-list'
import SettingsCard from '$lib/components/SettingsCard.svelte'
import { PasswordInput } from '$lib/components/input'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import ScanNetworks from './Scan.svelte'
import Spinner from '$lib/components/Spinner.svelte'
import InfoDialog from '$lib/components/InfoDialog.svelte'
import type { KnownNetworkItem, WifiSettings, WifiStatus } from '$lib/types/models'
import { socket } from '$lib/stores'
import { api } from '$lib/api'
import { onMount, onDestroy } from 'svelte';
import { modals } from 'svelte-modals';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { notifications } from '$lib/components/toasts/notifications';
import DragDropList, { VerticalDropZone, reorder, type DropEvent } from 'svelte-dnd-list';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import { PasswordInput } from '$lib/components/input';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ScanNetworks from './Scan.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import InfoDialog from '$lib/components/InfoDialog.svelte';
import type { KnownNetworkItem, WifiSettings, WifiStatus } from '$lib/types/models';
import { socket } from '$lib/stores';
import { api } from '$lib/api';
import {
Cancel,
Delete,
@@ -31,9 +31,9 @@
DNS,
Add,
Scan,
Edit
} from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte'
Edit,
} from '$lib/components/icons';
import StatusItem from '$lib/components/StatusItem.svelte';
let networkEditable: KnownNetworkItem = $state({
ssid: '',
@@ -43,22 +43,22 @@
subnet_mask: undefined,
gateway_ip: undefined,
dns_ip_1: undefined,
dns_ip_2: undefined
})
dns_ip_2: undefined,
});
let static_ip_config = $state(false)
let static_ip_config = $state(false);
let newNetwork: boolean = $state(true)
let showNetworkEditor: boolean = $state(false)
let newNetwork: boolean = $state(true);
let showNetworkEditor: boolean = $state(false);
let wifiStatus: WifiStatus = $state()
let wifiSettings: WifiSettings = $state()
let wifiStatus: WifiStatus | null = $state(null);
let wifiSettings: WifiSettings | null = $state(null);
let dndNetworkList: KnownNetworkItem[] = $state([])
let dndNetworkList: KnownNetworkItem[] = $state([]);
let showWifiDetails = $state(false)
let showWifiDetails = $state(false);
let formField: any = $state()
let formField: any = $state();
let formErrors = $state({
ssid: false,
@@ -66,156 +66,157 @@
gateway_ip: false,
subnet_mask: false,
dns_1: false,
dns_2: false
})
dns_2: false,
});
let formErrorhostname = $state(false)
let formErrorhostname = $state(false);
async function getWifiStatus() {
const result = await api.get<WifiStatus>('/api/wifi/sta/status')
const result = await api.get<WifiStatus>('/api/wifi/sta/status');
if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner)
return
console.error(`Error occurred while fetching: `, result.inner);
return;
}
wifiStatus = result.inner
return wifiStatus
wifiStatus = result.inner;
return wifiStatus;
}
async function getWifiSettings() {
const result = await api.get<WifiSettings>('/api/wifi/sta/settings')
const result = await api.get<WifiSettings>('/api/wifi/sta/settings');
if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner)
return
console.error(`Error occurred while fetching: `, result.inner);
return;
}
wifiSettings = result.inner
dndNetworkList = wifiSettings.wifi_networks
return wifiSettings
wifiSettings = result.inner;
dndNetworkList = wifiSettings.wifi_networks;
return wifiSettings;
}
onDestroy(() => socket.off('WiFiSettings'))
onDestroy(() => socket.off('WiFiSettings'));
onMount(() => {
socket.on<WifiSettings>('WiFiSettings', data => {
wifiSettings = data
dndNetworkList = wifiSettings.wifi_networks
})
})
wifiSettings = data;
dndNetworkList = wifiSettings.wifi_networks;
});
});
async function postWiFiSettings(data: WifiSettings) {
const result = await api.post<WifiSettings>('/api/wifi/sta/settings', data)
const result = await api.post<WifiSettings>('/api/wifi/sta/settings', data);
if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner)
notifications.error('User not authorized.', 3000)
return
console.error(`Error occurred while fetching: `, result.inner);
notifications.error('User not authorized.', 3000);
return;
}
wifiSettings = result.inner
notifications.success('Wi-Fi settings updated.', 3000)
wifiSettings = result.inner;
notifications.success('Wi-Fi settings updated.', 3000);
}
function validateHostName() {
if (!wifiSettings) return false;
if (wifiSettings.hostname.length < 3 || wifiSettings.hostname.length > 32) {
formErrorhostname = true
formErrorhostname = true;
} else {
formErrorhostname = false
formErrorhostname = false;
// Update global wifiSettings object
wifiSettings.wifi_networks = dndNetworkList
wifiSettings.wifi_networks = dndNetworkList;
// Post to REST API
postWiFiSettings(wifiSettings)
console.log(wifiSettings)
postWiFiSettings(wifiSettings);
console.log(wifiSettings);
}
}
function validateWiFiForm(event: SubmitEvent) {
event.preventDefault()
let valid = true
event.preventDefault();
let valid = true;
// Validate SSID
if (networkEditable.ssid.length < 3 || networkEditable.ssid.length > 32) {
valid = false
formErrors.ssid = true
valid = false;
formErrors.ssid = true;
} else {
formErrors.ssid = false
formErrors.ssid = false;
}
networkEditable.static_ip_config = static_ip_config
networkEditable.static_ip_config = static_ip_config;
if (networkEditable.static_ip_config) {
// RegEx for IPv4
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
/\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/;
// Validate gateway IP
if (!regexExp.test(networkEditable.gateway_ip!)) {
valid = false
formErrors.gateway_ip = true
valid = false;
formErrors.gateway_ip = true;
} else {
formErrors.gateway_ip = false
formErrors.gateway_ip = false;
}
// Validate Subnet Mask
if (!regexExp.test(networkEditable.subnet_mask!)) {
valid = false
formErrors.subnet_mask = true
valid = false;
formErrors.subnet_mask = true;
} else {
formErrors.subnet_mask = false
formErrors.subnet_mask = false;
}
// Validate local IP
if (!regexExp.test(networkEditable.local_ip!)) {
valid = false
formErrors.local_ip = true
valid = false;
formErrors.local_ip = true;
} else {
formErrors.local_ip = false
formErrors.local_ip = false;
}
// Validate DNS 1
if (!regexExp.test(networkEditable.dns_ip_1!)) {
valid = false
formErrors.dns_1 = true
valid = false;
formErrors.dns_1 = true;
} else {
formErrors.dns_1 = false
formErrors.dns_1 = false;
}
// Validate DNS 2
if (!regexExp.test(networkEditable.dns_ip_2!)) {
valid = false
formErrors.dns_2 = true
valid = false;
formErrors.dns_2 = true;
} else {
formErrors.dns_2 = false
formErrors.dns_2 = false;
}
} else {
formErrors.local_ip = false
formErrors.subnet_mask = false
formErrors.gateway_ip = false
formErrors.dns_1 = false
formErrors.dns_2 = false
formErrors.local_ip = false;
formErrors.subnet_mask = false;
formErrors.gateway_ip = false;
formErrors.dns_1 = false;
formErrors.dns_2 = false;
}
// Submit JSON to REST API
if (valid) {
if (newNetwork) {
dndNetworkList.push(networkEditable)
dndNetworkList.push(networkEditable);
} else {
dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable)
dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable);
}
addNetwork()
dndNetworkList = [...dndNetworkList] //Trigger reactivity
showNetworkEditor = false
addNetwork();
dndNetworkList = [...dndNetworkList]; //Trigger reactivity
showNetworkEditor = false;
}
}
function scanForNetworks() {
modals.open(ScanNetworks, {
storeNetwork: (network: string) => {
addNetwork()
networkEditable.ssid = network
showNetworkEditor = true
modals.close()
}
})
addNetwork();
networkEditable.ssid = network;
showNetworkEditor = true;
modals.close();
},
});
}
function addNetwork() {
newNetwork = true
newNetwork = true;
networkEditable = {
ssid: '',
password: '',
@@ -224,14 +225,14 @@
subnet_mask: undefined,
gateway_ip: undefined,
dns_ip_1: undefined,
dns_ip_2: undefined
}
dns_ip_2: undefined,
};
}
function handleEdit(index: number) {
newNetwork = false
showNetworkEditor = true
networkEditable = dndNetworkList[index]
newNetwork = false;
showNetworkEditor = true;
networkEditable = dndNetworkList[index];
}
function confirmDelete(index: number) {
@@ -240,20 +241,20 @@
message: 'Are you sure you want to delete this network?',
labels: {
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'Delete', icon: Delete }
confirm: { label: 'Delete', icon: Delete },
},
onConfirm: () => {
// Check if network is currently been edited and delete as well
if (dndNetworkList[index].ssid === networkEditable.ssid) {
addNetwork()
addNetwork();
}
// Remove network from array
dndNetworkList.splice(index, 1)
dndNetworkList = [...dndNetworkList] //Trigger reactivity
showNetworkEditor = false
modals.close()
}
})
dndNetworkList.splice(index, 1);
dndNetworkList = [...dndNetworkList]; //Trigger reactivity
showNetworkEditor = false;
modals.close();
},
});
}
function checkNetworkList() {
@@ -263,21 +264,21 @@
message:
'You have reached the maximum number of networks. Please delete one to add another.',
dismiss: { label: 'OK', icon: Check },
onDismiss: () => modals.close()
})
return false
onDismiss: () => modals.close(),
});
return false;
} else {
return true
return true;
}
}
function onDrop({ detail: { from, to } }: CustomEvent<DropEvent>) {
if (!to || from === to) {
return
return;
}
dndNetworkList = reorder(dndNetworkList, from.index, to.index)
console.log(dndNetworkList)
dndNetworkList = reorder(dndNetworkList, from.index, to.index);
console.log(dndNetworkList);
}
</script>
@@ -291,7 +292,8 @@
<div class="w-full overflow-x-auto">
{#await getWifiStatus()}
<Spinner />
{:then nothing}
{:then}
{#if wifiStatus}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
@@ -310,7 +312,7 @@
<button
class="btn btn-circle btn-ghost btn-sm modal-button"
onclick={() => {
showWifiDetails = !showWifiDetails
showWifiDetails = !showWifiDetails;
}}>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {(
@@ -339,6 +341,7 @@
<StatusItem icon={DNS} title="DNS" description={wifiStatus.dns_ip_1} />
</div>
{/if}
{/if}
{/await}
</div>
@@ -349,14 +352,15 @@
</div>
{#await getWifiSettings()}
<Spinner />
{:then nothing}
{:then}
{#if wifiSettings}
<div class="relative w-full overflow-visible">
<button
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-16"
onclick={() => {
if (checkNetworkList()) {
addNetwork()
showNetworkEditor = true
addNetwork();
showNetworkEditor = true;
}
}}>
<Add class="h-6 w-6" /></button>
@@ -364,8 +368,8 @@
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-0"
onclick={() => {
if (checkNetworkList()) {
scanForNetworks()
showNetworkEditor = true
scanForNetworks();
showNetworkEditor = true;
}
}}>
<Scan class="h-6 w-6" /></button>
@@ -379,19 +383,19 @@
itemSize={60}
itemCount={dndNetworkList.length}
on:drop={onDrop}>
{#snippet children({ index })}
{#snippet children({ index }: { index: number })}
<StatusItem icon={Router} title={dndNetworkList[index].ssid}>
<div class="space-x-0 px-0 mx-0">
<button
class="btn btn-ghost btn-sm"
onclick={() => {
handleEdit(index)
handleEdit(index);
}}>
<Edit class="h-6 w-6" /></button>
<button
class="btn btn-ghost btn-sm"
onclick={() => {
confirmDelete(index)
confirmDelete(index);
}}>
<Delete class="text-error h-6 w-6" />
</button>
@@ -519,7 +523,8 @@
bind:value={networkEditable.gateway_ip}
required />
<label class="label" for="gateway">
<span class="label-text-alt text-error {formErrors.gateway_ip ? '' : 'hidden'}"
<span
class="label-text-alt text-error {formErrors.gateway_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
</label>
</div>
@@ -597,6 +602,7 @@
</div>
</form>
</div>
{/if}
{/await}
</div>
</SettingsCard>