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
+32 -43
View File
@@ -1,51 +1,40 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap'; import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { Check } from './icons'; import { Check } from './icons';
import { exitBeforeEnter } from 'svelte-modals'; import { exitBeforeEnter, type ModalProps } from 'svelte-modals';
// provided by <Modals /> let {
isOpen,
interface Props { title,
isOpen: boolean; message,
title: string; onDismiss,
message: string; labels = {
onDismiss: any; dismiss: { label: 'Dismiss', icon: Check },
dismiss?: any; },
} }: ModalProps = $props();
let {
isOpen,
title,
message,
onDismiss,
dismiss = { label: 'Dismiss', icon: Check }
}: Props = $props();
</script> </script>
{#if isOpen} {#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
<div <div
role="dialog" class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg">
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" <h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
transition:fly={{ y: 50 }} <div class="divider my-2"></div>
use:exitBeforeEnter <p class="text-base-content mb-1 text-start">{message}</p>
use:focusTrap <div class="divider my-2"></div>
> <div class="flex justify-end gap-2">
<div <button
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg" class="btn btn-warning text-warning-content inline-flex items-center"
> onclick={onDismiss}>
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2> <labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span>
<div class="divider my-2"></div> </button>
<p class="text-base-content mb-1 text-start">{message}</p> </div>
<div class="divider my-2"></div>
<div class="flex justify-end gap-2">
<button
class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onDismiss}
>
<dismiss.icon class="mr-2 h-5 w-5" /><span>{dismiss.label}</span>
</button>
</div>
</div>
</div> </div>
</div>
{/if} {/if}
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte';
import * as THREE from 'three' import * as THREE from 'three';
import { imu } from '$lib/stores/imu' import { imu } from '$lib/stores/imu';
import SceneBuilder from '$lib/sceneBuilder' import SceneBuilder from '$lib/sceneBuilder';
let canvas: HTMLCanvasElement = $state() let canvas: HTMLCanvasElement;
let sceneBuilder: SceneBuilder let sceneBuilder: SceneBuilder;
let cube: THREE.Mesh let cube: THREE.Mesh;
let targetRotation = new THREE.Euler() let targetRotation = new THREE.Euler();
let lastUpdateTime = 0 let lastUpdateTime = 0;
const LERP_SPEED = 5 // rotations per second const LERP_SPEED = 5; // rotations per second
const initThreeJS = () => { const initThreeJS = () => {
sceneBuilder = new SceneBuilder() sceneBuilder = new SceneBuilder()
@@ -18,59 +18,59 @@
.addOrbitControls(1, 10, false) .addOrbitControls(1, 10, false)
.addAmbientLight({ color: 0x404040, intensity: 0.5 }) .addAmbientLight({ color: 0x404040, intensity: 0.5 })
.addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 }) .addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 })
.fillParent() .fillParent();
const geometry = new THREE.BoxGeometry(1, 1, 1) const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshPhongMaterial({ const material = new THREE.MeshPhongMaterial({
color: 0x00ff00, color: 0x00ff00,
transparent: true, transparent: true,
opacity: 0.8 opacity: 0.8,
}) });
cube = new THREE.Mesh(geometry, material) cube = new THREE.Mesh(geometry, material);
sceneBuilder.scene.add(cube) sceneBuilder.scene.add(cube);
sceneBuilder.addRenderCb(() => { sceneBuilder.addRenderCb(() => {
if (!cube) return if (!cube) return;
const currentTime = performance.now() const currentTime = performance.now();
const deltaTime = (currentTime - lastUpdateTime) / 1000 // convert to seconds const deltaTime = (currentTime - lastUpdateTime) / 1000; // convert to seconds
lastUpdateTime = currentTime lastUpdateTime = currentTime;
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime) const lerpFactor = Math.min(1, LERP_SPEED * deltaTime);
cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor) cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor);
cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor) cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor);
cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor) cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor);
}) });
sceneBuilder.startRenderLoop() sceneBuilder.startRenderLoop();
} };
const updateOrientation = () => { const updateOrientation = () => {
if (!cube) return if (!cube) return;
const y = -$imu.x[$imu.x.length - 1] || 0 const y = -$imu.x[$imu.x.length - 1] || 0;
const x = $imu.y[$imu.y.length - 1] || 0 const x = $imu.y[$imu.y.length - 1] || 0;
const z = -$imu.z[$imu.z.length - 1] || 0 const z = -$imu.z[$imu.z.length - 1] || 0;
targetRotation.set( targetRotation.set(
THREE.MathUtils.degToRad(x), THREE.MathUtils.degToRad(x),
THREE.MathUtils.degToRad(y), THREE.MathUtils.degToRad(y),
THREE.MathUtils.degToRad(z) THREE.MathUtils.degToRad(z)
) );
} };
onMount(() => { onMount(() => {
initThreeJS() initThreeJS();
}) });
onDestroy(() => { onDestroy(() => {
sceneBuilder?.renderer?.dispose() sceneBuilder?.renderer?.dispose();
}) });
$effect(() => { $effect(() => {
if ($imu) { if ($imu) {
updateOrientation() updateOrientation();
} }
}) });
</script> </script>
<div class="h-60 w-60 border-2 border-base-300 rounded-md"> <div class="h-60 w-60 border-2 border-base-300 rounded-md">
+168 -166
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte';
import { import {
BufferGeometry, BufferGeometry,
Line, Line,
@@ -10,8 +10,8 @@
SphereGeometry, SphereGeometry,
Vector3, Vector3,
type NormalBufferAttributes, type NormalBufferAttributes,
type Object3DEventMap type Object3DEventMap,
} from 'three' } from 'three';
import { import {
ModesEnum, ModesEnum,
kinematicData, kinematicData,
@@ -21,56 +21,55 @@
servoAnglesOut, servoAnglesOut,
servoAngles, servoAngles,
mpu, mpu,
jointNames jointNames,
} from '$lib/stores' } from '$lib/stores';
import { import {
extractFootColor, extractFootColor,
populateModelCache, populateModelCache,
throttler, throttler,
getToeWorldPositions getToeWorldPositions,
} from '$lib/utilities' } from '$lib/utilities';
import SceneBuilder from '$lib/sceneBuilder' import SceneBuilder from '$lib/sceneBuilder';
import { lerp, degToRad } from 'three/src/math/MathUtils' import { lerp, degToRad } from 'three/src/math/MathUtils';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js' import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
import Kinematic, { type body_state_t } from '$lib/kinematic' import Kinematic, { type body_state_t } from '$lib/kinematic';
import { import {
BezierState, BezierState,
CalibrationState, CalibrationState,
EightPhaseWalkState, EightPhaseWalkState,
FourPhaseWalkState,
IdleState, IdleState,
RestState, RestState,
StandState StandState,
} from '$lib/gait' } from '$lib/gait';
import { radToDeg } from 'three/src/math/MathUtils.js' import { radToDeg } from 'three/src/math/MathUtils.js';
import type { URDFRobot } from 'urdf-loader' import type { URDFRobot } from 'urdf-loader';
import { get } from 'svelte/store' import { get } from 'svelte/store';
interface Props { interface Props {
sky?: boolean sky?: boolean;
orbit?: boolean orbit?: boolean;
panel?: boolean panel?: boolean;
debug?: boolean debug?: boolean;
ground?: boolean ground?: boolean;
} }
let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props() let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props();
let sceneManager = $state(new SceneBuilder()) let sceneManager = $state(new SceneBuilder());
let canvas: HTMLCanvasElement = $state() let canvas: HTMLCanvasElement;
let currentModelAngles: number[] = new Array(12).fill(0) let currentModelAngles: number[] = new Array(12).fill(0);
let modelTargetAngles: number[] = new Array(12).fill(0) let modelTargetAngles: number[] = new Array(12).fill(0);
let gui_panel: GUI let gui_panel: GUI;
let Throttler = new throttler() let Throttler = new throttler();
let feet_trace = new Array(4).fill([]) let feet_trace = new Array(4).fill([]);
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = [] let trace_lines: BufferGeometry<NormalBufferAttributes>[] = [];
let target: Object3D<Object3DEventMap> 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 = { let planners = {
[ModesEnum.Deactivated]: new IdleState(), [ModesEnum.Deactivated]: new IdleState(),
@@ -79,11 +78,11 @@
[ModesEnum.Rest]: new RestState(), [ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(), [ModesEnum.Stand]: new StandState(),
[ModesEnum.Crawl]: new EightPhaseWalkState(), [ModesEnum.Crawl]: new EightPhaseWalkState(),
[ModesEnum.Walk]: new BezierState() [ModesEnum.Walk]: new BezierState(),
} };
let lastTick = performance.now() 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 = { let body_state = {
omega: 0, omega: 0,
@@ -92,8 +91,8 @@
xm: 0, xm: 0,
ym: 0.5, ym: 0.5,
zm: 0, zm: 0,
feet: kinematic.getDefaultFeetPos() feet: kinematic.getDefaultFeetPos(),
} };
let settings = { let settings = {
'Internal kinematic': true, 'Internal kinematic': true,
@@ -110,52 +109,52 @@
xm: 0, xm: 0,
ym: 0.7, ym: 0.7,
zm: 0, zm: 0,
Background: 'black' Background: 'black',
} };
onMount(async () => { onMount(async () => {
await populateModelCache() await populateModelCache();
await createScene() await createScene();
servoAngles.subscribe(updateAnglesFromStore) servoAngles.subscribe(updateAnglesFromStore);
if (panel) createPanel() if (panel) createPanel();
}) });
onDestroy(() => { onDestroy(() => {
canvas.remove() canvas.remove();
gui_panel?.destroy() gui_panel?.destroy();
}) });
const updateAnglesFromStore = (angles: number[]) => { const updateAnglesFromStore = (angles: number[]) => {
if (sceneManager.isDragging) return if (sceneManager.isDragging) return;
if (settings['Internal kinematic']) return if (settings['Internal kinematic']) return;
modelTargetAngles = angles modelTargetAngles = angles;
} };
const createPanel = () => { const createPanel = () => {
gui_panel = new GUI({ width: 310 }) gui_panel = new GUI({ width: 310 });
gui_panel.close() gui_panel.close();
gui_panel.domElement.id = 'three-gui-panel' gui_panel.domElement.id = 'three-gui-panel';
const general = gui_panel.addFolder('General') const general = gui_panel.addFolder('General');
general.add(settings, 'Internal kinematic') general.add(settings, 'Internal kinematic');
general.add(settings, 'Robot transform controls') general.add(settings, 'Robot transform controls');
general.add(settings, 'Auto orient robot') general.add(settings, 'Auto orient robot');
const kinematic = gui_panel.addFolder('Kinematics') const kinematic = gui_panel.addFolder('Kinematics');
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen();
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen() kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen();
const visibility = gui_panel.addFolder('Visualization') const visibility = gui_panel.addFolder('Visualization');
visibility.add(settings, 'Trace feet') visibility.add(settings, 'Trace feet');
visibility.add(settings, 'Trace points', 1, 1000, 1) visibility.add(settings, 'Trace points', 1, 1000, 1);
visibility.add(settings, 'Target position') visibility.add(settings, 'Target position');
visibility.add(settings, 'Smooth motion') visibility.add(settings, 'Smooth motion');
visibility.addColor(settings, 'Background') visibility.addColor(settings, 'Background');
} };
const updateKinematicPosition = () => { const updateKinematicPosition = () => {
kinematicData.set([ kinematicData.set([
@@ -164,14 +163,17 @@
settings.psi, settings.psi,
settings.xm, settings.xm,
settings.ym, settings.ym,
settings.zm settings.zm,
]) ]);
} };
const updateAngles = (name: string, angle: number) => { const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI) modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI);
Throttler.throttle(() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))), 100) Throttler.throttle(
} () => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))),
100
);
};
const createScene = async () => { const createScene = async () => {
sceneManager sceneManager
@@ -185,46 +187,46 @@
.addTransformControls(sceneManager.model) .addTransformControls(sceneManager.model)
.fillParent() .fillParent()
.addRenderCb(render) .addRenderCb(render)
.startRenderLoop() .startRenderLoop();
if (ground) sceneManager.addGroundPlane() if (ground) sceneManager.addGroundPlane();
const geometry = new SphereGeometry(0.1, 32, 16) const geometry = new SphereGeometry(0.1, 32, 16);
const material = new MeshBasicMaterial({ color: 0xffff00 }) const material = new MeshBasicMaterial({ color: 0xffff00 });
target = new Mesh(geometry, material) target = new Mesh(geometry, material);
sceneManager.scene.add(target) sceneManager.scene.add(target);
if (debug) { if (debug) {
sceneManager.addDragControl(updateAngles) sceneManager.addDragControl(updateAngles);
} }
if (sky) sceneManager.addSky() if (sky) sceneManager.addSky();
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry() const geometry = new BufferGeometry();
const material = new LineBasicMaterial({ color: extractFootColor() }) const material = new LineBasicMaterial({ color: extractFootColor() });
const line = new Line(geometry, material) const line = new Line(geometry, material);
trace_lines.push(geometry) trace_lines.push(geometry);
sceneManager.scene.add(line) sceneManager.scene.add(line);
} }
} };
const renderTraceLines = (foot_positions: Vector3[]) => { const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) { if (!settings['Trace feet']) {
if (!feet_trace.length) return if (!feet_trace.length) return;
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1))) trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)));
feet_trace = new Array(4).fill([]) feet_trace = new Array(4).fill([]);
return return;
} }
trace_lines.forEach((line, i) => { trace_lines.forEach((line, i) => {
feet_trace[i].push(foot_positions[i]) feet_trace[i].push(foot_positions[i]);
feet_trace[i] = feet_trace[i].slice(-settings['Trace points']) feet_trace[i] = feet_trace[i].slice(-settings['Trace points']);
line.setFromPoints(feet_trace[i]) line.setFromPoints(feet_trace[i]);
}) });
} };
const calculate_kinematics = () => { const calculate_kinematics = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return if (sceneManager.isDragging || !settings['Internal kinematic']) return;
const position: body_state_t = { const position: body_state_t = {
omega: settings.omega, omega: settings.omega,
phi: settings.phi, phi: settings.phi,
@@ -232,37 +234,37 @@
xm: settings.xm, xm: settings.xm,
ym: settings.ym, ym: settings.ym,
zm: settings.zm, zm: settings.zm,
feet: body_state.feet feet: body_state.feet,
} };
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i])) let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]));
modelTargetAngles = new_angles modelTargetAngles = new_angles;
} };
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => { const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return if (settings['Robot transform controls'] || !settings['Auto orient robot']) return;
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y)) robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y));
robot.position.z = smooth(robot.position.z, -settings.xm, 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.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.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.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.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1);
} };
const update_camera = (robot: URDFRobot) => { const update_camera = (robot: URDFRobot) => {
if (!settings['Fix camera on robot']) return if (!settings['Fix camera on robot']) return;
sceneManager.orbit.target = robot.position.clone() sceneManager.orbit.target = robot.position.clone();
} };
const smooth = (start: number, end: number, amount: number) => { const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end return settings['Smooth motion'] ? lerp(start, end, amount) : end;
} };
const update_gait = () => { const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return if (sceneManager.isDragging || !settings['Internal kinematic']) return;
const controlData = get(outControllerData) const controlData = get(outControllerData);
const data = { const data = {
stop: controlData[0], stop: controlData[0],
lx: controlData[1], lx: controlData[1],
@@ -271,67 +273,67 @@
ry: controlData[4], ry: controlData[4],
h: controlData[5], h: controlData[5],
s: controlData[6], s: controlData[6],
s1: controlData[7] s1: controlData[7],
} };
body_state.ym = ((data.h + 127) * 0.35) / 100 body_state.ym = ((data.h + 127) * 0.35) / 100;
let planner = planners[get(mode)] let planner = planners[get(mode)];
const delta = performance.now() - lastTick const delta = performance.now() - lastTick;
lastTick = performance.now() lastTick = performance.now();
body_state = planner.step(body_state, data, delta) body_state = planner.step(body_state, data, delta);
settings.omega = body_state.omega settings.omega = body_state.omega;
settings.phi = body_state.phi settings.phi = body_state.phi;
settings.psi = body_state.psi settings.psi = body_state.psi;
settings.xm = body_state.xm settings.xm = body_state.xm;
settings.ym = body_state.ym settings.ym = body_state.ym;
settings.zm = body_state.zm settings.zm = body_state.zm;
} };
const update_robot_position = (robot: URDFRobot) => { const update_robot_position = (robot: URDFRobot) => {
if (!settings['Robot transform controls']) return if (!settings['Robot transform controls']) return;
settings.omega = radToDeg(robot.rotation.y) settings.omega = radToDeg(robot.rotation.y);
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90 settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90;
settings.psi = radToDeg(robot.rotation.x) + 90 settings.psi = radToDeg(robot.rotation.x) + 90;
settings.xm = robot.position.z * 100 settings.xm = robot.position.z * 100;
settings.zm = -robot.position.x * 100 settings.zm = -robot.position.x * 100;
} };
const updateTargetPosition = () => { const updateTargetPosition = () => {
target.visible = settings['Target position'] target.visible = settings['Target position'];
target.position.x = smooth(target.position.x, target_position.x, 0.5) target.position.x = smooth(target.position.x, target_position.x, 0.5);
target.position.z = smooth(target.position.z, target_position.z, 0.5) target.position.z = smooth(target.position.z, target_position.z, 0.5);
} };
const render = () => { const render = () => {
const robot = sceneManager.model const robot = sceneManager.model;
if (!robot) return if (!robot) return;
const toes = getToeWorldPositions(robot) const toes = getToeWorldPositions(robot);
renderTraceLines(toes) renderTraceLines(toes);
update_camera(robot) update_camera(robot);
update_gait() update_gait();
calculate_kinematics() calculate_kinematics();
update_robot_position(robot) update_robot_position(robot);
sceneManager.transformControl.showX = settings['Robot transform controls'] sceneManager.transformControl.showX = settings['Robot transform controls'];
sceneManager.transformControl.showY = settings['Robot transform controls'] sceneManager.transformControl.showY = settings['Robot transform controls'];
sceneManager.transformControl.showZ = settings['Robot transform controls'] sceneManager.transformControl.showZ = settings['Robot transform controls'];
for (let i = 0; i < $jointNames.length; i++) { for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth( currentModelAngles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI), (robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i], modelTargetAngles[i],
0.1 0.1
) );
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i])) robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]));
} }
orient_robot(robot, toes) orient_robot(robot, toes);
updateTargetPosition() updateTargetPosition();
} };
</script> </script>
<svelte:window onresize={sceneManager.fillParent} /> <svelte:window onresize={sceneManager.fillParent} />
@@ -1,37 +1,40 @@
<script lang="ts"> <script lang="ts">
import WidgetContainer from './WidgetContainer.svelte'; import WidgetContainer from './WidgetContainer.svelte';
import { WidgetComponents, type WidgetContainerConfig, isWidgetConfig } from '$lib/stores/application'; import {
import Widget from './Widget.svelte'; WidgetComponents,
type WidgetContainerConfig,
isWidgetConfig,
} from '$lib/stores/application';
import Widget from './Widget.svelte';
interface Props { interface Props {
container: WidgetContainerConfig; container: WidgetContainerConfig;
} }
let { container }: Props = $props(); let { container }: Props = $props();
</script> </script>
<div class="w-full h-full flex flex-col overflow-hidden"> <div class="w-full h-full flex flex-col overflow-hidden">
<div <div
class="flex w-full h-full" class="flex w-full h-full"
class:flex-row={container.layout === 'column'} class:flex-row={container.layout === 'column'}
class:flex-col={container.layout === 'row'} class:flex-col={container.layout === 'row'}
class:flex-wrap={container.layout === 'wrap'} class:flex-wrap={container.layout === 'wrap'}>
> {#each container.widgets as widget, index (widget.id + '-' + index)}
{#each container.widgets as widget, index (widget.id + '-' + index)} <Widget>
<Widget> {#if isWidgetConfig(widget)}
{#if isWidgetConfig(widget)} {@const SvelteComponent = WidgetComponents[widget.component]}
{@const SvelteComponent = WidgetComponents[widget.component]} <SvelteComponent {...widget.props} />
<SvelteComponent {...widget.props} /> {:else if widget.widgets}
{:else if widget.widgets} <WidgetContainer container={widget} />
<WidgetContainer container={widget} /> {/if}
{/if} </Widget>
</Widget> {#if index !== container.widgets.length - 1}
{#if index !== container.widgets.length - 1} <div
<div class="divider bg-base-300 m-0"
class="divider bg-base-300 m-0" class:divider-horizontal={container.layout === 'column'}>
class:divider-horizontal={container.layout === 'column'} </div>
></div> {/if}
{/if} {/each}
{/each} </div>
</div>
</div> </div>
+53 -53
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state';
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags';
import GithubButton from '../menu/GithubButton.svelte' import GithubButton from '../menu/GithubButton.svelte';
import LogoButton from '../menu/LogoButton.svelte' import LogoButton from '../menu/LogoButton.svelte';
import MenuList from '../menu/MenuList.svelte' import MenuList from '../menu/MenuList.svelte';
import { import {
Connection, Connection,
Settings, Settings,
@@ -20,28 +20,28 @@
AP, AP,
Copyright, Copyright,
Metrics, Metrics,
DNS DNS,
} from '$lib/components/icons' } from '$lib/components/icons';
import appEnv from 'app-env' import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public';
const features = useFeatureFlags() const features = useFeatureFlags();
const appName = page.data.app_name const appName = page.data.app_name;
const copyright = page.data.copyright const copyright = page.data.copyright;
const github = { href: 'https://github.com/' + page.data.github, active: true } const github = { href: 'https://github.com/' + page.data.github, active: true };
type menuItem = { type menuItem = {
title: string title: string;
icon: ConstructorOfATypedSvelteComponent icon: ConstructorOfATypedSvelteComponent;
href?: string href?: string;
feature: boolean feature: boolean;
active?: boolean active?: boolean;
submenu?: menuItem[] submenu?: menuItem[];
} };
let menuItems = $state<menuItem[]>([]) let menuItems = $state<menuItem[]>([]);
$effect(() => { $effect(() => {
menuItems = [ menuItems = [
@@ -49,13 +49,13 @@
title: 'Connection', title: 'Connection',
icon: WiFi, icon: WiFi,
href: '/connection', href: '/connection',
feature: !appEnv.VITE_USE_HOST_NAME feature: !PUBLIC_VITE_USE_HOST_NAME,
}, },
{ {
title: 'Controller', title: 'Controller',
icon: MdiController, icon: MdiController,
href: '/controller', href: '/controller',
feature: true feature: true,
}, },
{ {
title: 'Peripherals', title: 'Peripherals',
@@ -66,27 +66,27 @@
title: 'I2C', title: 'I2C',
icon: Connection, icon: Connection,
href: '/peripherals/i2c', href: '/peripherals/i2c',
feature: true feature: true,
}, },
{ {
title: 'Camera', title: 'Camera',
icon: Camera, icon: Camera,
href: '/peripherals/camera', href: '/peripherals/camera',
feature: $features.camera feature: $features.camera,
}, },
{ {
title: 'Servo', title: 'Servo',
icon: MotorOutline, icon: MotorOutline,
href: '/peripherals/servo', href: '/peripherals/servo',
feature: true feature: true,
}, },
{ {
title: 'IMU', title: 'IMU',
icon: Rotate3d, icon: Rotate3d,
href: '/peripherals/imu', href: '/peripherals/imu',
feature: $features.imu || $features.mag || $features.bmp feature: $features.imu || $features.mag || $features.bmp,
} },
] ],
}, },
{ {
title: 'WiFi', title: 'WiFi',
@@ -97,21 +97,21 @@
title: 'WiFi Station', title: 'WiFi Station',
icon: Router, icon: Router,
href: '/wifi/sta', href: '/wifi/sta',
feature: true feature: true,
}, },
{ {
title: 'Access Point', title: 'Access Point',
icon: AP, icon: AP,
href: '/wifi/ap', href: '/wifi/ap',
feature: true feature: true,
}, },
{ {
title: 'mDNS', title: 'mDNS',
icon: DNS, icon: DNS,
href: '/wifi/mdns', href: '/wifi/mdns',
feature: true feature: true,
} },
] ],
}, },
{ {
title: 'System', title: 'System',
@@ -122,51 +122,51 @@
title: 'System Status', title: 'System Status',
icon: Health, icon: Health,
href: '/system/status', href: '/system/status',
feature: true feature: true,
}, },
{ {
title: 'File System', title: 'File System',
icon: Folder, icon: Folder,
href: '/system/filesystem', href: '/system/filesystem',
feature: true feature: true,
}, },
{ {
title: 'System Metrics', title: 'System Metrics',
icon: Metrics, icon: Metrics,
href: '/system/metrics', href: '/system/metrics',
feature: true feature: true,
}, },
{ {
title: 'Firmware Update', title: 'Firmware Update',
icon: Update, icon: Update,
href: '/system/update', href: '/system/update',
feature: $features.ota || $features.upload_firmware || $features.download_firmware feature: $features.ota || $features.upload_firmware || $features.download_firmware,
} },
] ],
} },
] as menuItem[] ] as menuItem[];
}) });
const { menuClicked } = $props() const { menuClicked } = $props();
function setActiveMenuItem(targetTitle: string) { function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => { menuItems.forEach(item => {
item.active = item.title === targetTitle item.active = item.title === targetTitle;
item.submenu?.forEach(subItem => { item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle subItem.active = subItem.title === targetTitle;
}) });
}) });
menuItems = menuItems menuItems = menuItems;
menuClicked() menuClicked();
} }
$effect(() => { $effect(() => {
setActiveMenuItem(page.data.title) setActiveMenuItem(page.data.title);
}) });
const updateMenu = (event: any) => { const updateMenu = (event: any) => {
setActiveMenuItem(event.details) setActiveMenuItem(event.details);
} };
</script> </script>
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content"> <div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
@@ -1,111 +1,109 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { modals } from 'svelte-modals'; import { modals } from 'svelte-modals';
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'; import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import { compareVersions } from 'compare-versions'; import { compareVersions } from 'compare-versions';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import type { GithubRelease } from '$lib/types/models'; import type { GithubRelease } from '$lib/types/models';
import { useFeatureFlags } from '$lib/stores/featureFlags'; import { useFeatureFlags } from '$lib/stores/featureFlags';
import { Cancel, CloudDown, Firmware } from '../icons'; import { Cancel, CloudDown, Firmware } from '../icons';
const features = useFeatureFlags(); const features = useFeatureFlags();
interface Props { interface Props {
update?: boolean; update?: boolean;
}
let { update = $bindable(false) }: Props = $props();
let firmwareVersion: string = $state('');
let firmwareDownloadLink: string = $state('');
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};
const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${page.data.github}/releases/latest`,
{ headers }
);
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository');
return;
}
if (result.isErr()) {
console.error('Error:', result.inner);
return;
} }
let { update = $bindable(false) }: Props = $props(); const results = result.inner;
update = false;
firmwareVersion = '';
let firmwareVersion: string = $state(''); if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
let firmwareDownloadLink: string = $state(''); // iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) {
async function getGithubAPI() { // check if the asset is of type *.bin
const headers = { if (
accept: 'application/vnd.github+json', results.assets[i].name.includes('.bin') &&
'X-GitHub-Api-Version': '2022-11-28' results.assets[i].name.includes($features.firmware_built_target as string)
}; ) {
const result = await api.get<GithubRelease>( update = true;
`https://api.github.com/repos/${page.data.github}/releases/latest`, firmwareVersion = results.tag_name;
{ headers } firmwareDownloadLink = results.assets[i].browser_download_url;
); notifications.info('Firmware update available.', 5000);
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository');
return;
}
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
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) { async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url }); const result = await api.post('/api/downloadUpdate', { download_url: url });
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner); console.error('Error:', result.inner);
return; return;
}
} }
}
onMount(async () => { onMount(async () => {
if ($features.download_firmware) { if ($features.download_firmware) {
await getGithubAPI(); await getGithubAPI();
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000); // once per hour setInterval(async () => await getGithubAPI(), 60 * 60 * 1000); // once per hour
} }
}); });
function confirmGithubUpdate(url: string) { function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, { modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device', title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?', message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: { labels: {
cancel: { label: 'Abort', icon: Cancel }, cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown } confirm: { label: 'Update', icon: CloudDown },
}, },
onConfirm: () => { onConfirm: () => {
postGithubDownload(url); postGithubDownload(url);
modals.open(GithubUpdateDialog, { modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll() onConfirm: () => modals.closeAll(),
});
}
}); });
} },
});
}
</script> </script>
{#if update} {#if update}
<div class="indicator flex-none"> <div class="indicator flex-none">
<button <button
class="btn btn-square btn-ghost h-9 w-9" class="btn btn-square btn-ghost h-9 w-9"
onclick={() => confirmGithubUpdate(firmwareDownloadLink)} onclick={() => confirmGithubUpdate(firmwareDownloadLink)}>
> <span
<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>
{firmwareVersion} <Firmware class="h-7 w-7" />
</span> </button>
<Firmware class="h-7 w-7" /> </div>
</button>
</div>
{/if} {/if}
@@ -1,103 +1,101 @@
<script lang="ts"> <script lang="ts">
import { daisyColor } from "$lib/utilities"; import { daisyColor } from '$lib/utilities';
import { Chart, registerables } from "chart.js"; import { Chart, registerables } from 'chart.js';
import { onMount } from "svelte"; import { onMount } from 'svelte';
import { cubicOut } from "svelte/easing"; import { cubicOut } from 'svelte/easing';
import { slide } from "svelte/transition"; import { slide } from 'svelte/transition';
let chartElement: HTMLCanvasElement = $state(); let chartElement: HTMLCanvasElement;
let chart: Chart; let chart: Chart;
interface Props { interface Props {
label: any; label: any;
data: number[]; data: number[];
title: any; title: any;
} }
let { label, data, title }: Props = $props(); let { label, data, title }: Props = $props();
Chart.register(...registerables); Chart.register(...registerables);
onMount(() => { onMount(() => {
chart = new Chart(chartElement, { chart = new Chart(chartElement, {
type: 'line', type: 'line',
data: { data: {
labels: data, labels: data,
datasets: [ datasets: [
{ {
label, label,
borderColor: daisyColor('--p'), borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--p', 50),
borderWidth: 2, borderWidth: 2,
data, data,
yAxisID: 'y' yAxisID: 'y',
}, },
] ],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false,
} },
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0,
} },
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--bc', 10),
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: title, text: title,
color: daisyColor('--bc'), color: daisyColor('--bc'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold',
} },
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: 100, max: 100,
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--bc', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--bc', 10) },
} },
} },
} },
}); });
setInterval(() => { setInterval(() => {
chart.data.labels = data chart.data.labels = data;
chart.data.datasets[0].data = data chart.data.datasets[0].data = data;
}, 500); }, 500);
}) });
</script> </script>
<div class="w-full h-full overflow-x-auto"> <div class="w-full h-full overflow-x-auto">
<div <div
class="flex w-full flex-col space-y-1 h-60" class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}>
> <canvas bind:this={chartElement}></canvas>
<canvas bind:this={chartElement}></canvas> </div>
</div>
</div> </div>
+13 -14
View File
@@ -1,20 +1,19 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
options?: string[]; options?: string[];
selectedOption?: string; selectedOption?: string;
change: () => void; change?: () => void;
[key: string]: any; [key: string]: any;
} }
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props(); let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props();
</script> </script>
<select <select
bind:value={selectedOption} bind:value={selectedOption}
{...rest} {...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}" class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}">
> {#each options as option}
{#each options as option} <option value={option}>{option}</option>
<option value={option}>{option}</option> {/each}
{/each}
</select> </select>
+11 -11
View File
@@ -2,19 +2,19 @@ import { api } from '$lib/api';
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications';
import { writable, type Writable } from 'svelte/store'; import { writable, type Writable } from 'svelte/store';
let featureFlagsStore: Writable<Record<string, boolean>>; let featureFlagsStore: Writable<Record<string, boolean | string>>;
export function useFeatureFlags() { export function useFeatureFlags() {
if (!featureFlagsStore) { 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); if (result.isOk()) featureFlagsStore.set(result.inner);
else { else {
notifications.error('Feature flag could not be fetched', 2500); notifications.error('Feature flag could not be fetched', 2500);
} }
}); });
} }
return featureFlagsStore; return featureFlagsStore;
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { persistentStore } from '$lib/utilities'; import { persistentStore } from '$lib/utilities';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import 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); return JSON.parse(data as string);
} catch (error) { } catch (error) {
console.error(`Could not decode data: ${data} - ${error}`); console.error(`Could not decode data: ${new Uint8Array(data as ArrayBuffer)} - ${error}`);
} }
return null; return null;
}; };
+14 -14
View File
@@ -2,21 +2,21 @@ export const prerender = false;
export const ssr = false; export const ssr = false;
const registerFetchIntercept = async () => { const registerFetchIntercept = async () => {
const { fetch: originalFetch } = window; const { fetch: originalFetch } = window;
const fileService = (await import('$lib/services/file-service')).default; const fileService = (await import('$lib/services/file-service')).default;
window.fetch = async (resource, config) => { window.fetch = async (resource, config) => {
let url = resource instanceof Request ? resource.url : resource.toString(); let url = resource instanceof Request ? resource.url : resource.toString();
let file = await fileService.getFile(url); let file = await fileService?.getFile(url);
return file.isOk() ? new Response(file.inner) : originalFetch(resource, config); return file?.isOk() ? new Response(file.inner) : originalFetch(resource, config);
}; };
}; };
export const load = async () => { export const load = async () => {
await registerFetchIntercept(); await registerFetchIntercept();
return { return {
title: 'Spot micro controller', title: 'Spot micro controller',
github: 'runeharlyk/SpotMicroESP32-Leika', github: 'runeharlyk/SpotMicroESP32-Leika',
app_name: 'Spot Micro Controller', app_name: 'Spot Micro Controller',
copyright: '2024 Rune Harlyk' copyright: '2025 Rune Harlyk',
}; };
}; };
+88 -88
View File
@@ -1,51 +1,51 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte';
import { imu } from '$lib/stores/imu' import { imu } from '$lib/stores/imu';
import { Chart, registerables } from 'chart.js' import { Chart, registerables } from 'chart.js';
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition';
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte';
import { socket } from '$lib/stores' import { socket } from '$lib/stores';
import type { IMU } from '$lib/types/models' import type { IMU } from '$lib/types/models';
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags';
import { Rotate3d } from '$lib/components/icons' import { Rotate3d } from '$lib/components/icons';
Chart.register(...registerables) Chart.register(...registerables);
const features = useFeatureFlags() const features = useFeatureFlags();
let intervalId: number let intervalId: ReturnType<typeof setInterval> | number;
let angleChartElement: HTMLCanvasElement = $state() let angleChartElement: HTMLCanvasElement;
let tempChartElement: HTMLCanvasElement = $state() let tempChartElement: HTMLCanvasElement;
let altitudeChartElement: HTMLCanvasElement = $state() let altitudeChartElement: HTMLCanvasElement;
let angleChart: Chart let angleChart: Chart;
let tempChart: Chart let tempChart: Chart;
let altitudeChart: Chart let altitudeChart: Chart;
const getChartColors = () => { const getChartColors = () => {
const style = getComputedStyle(document.body) const style = getComputedStyle(document.body);
return { return {
primary: style.getPropertyValue('--color-primary'), primary: style.getPropertyValue('--color-primary'),
secondary: style.getPropertyValue('--color-secondary'), secondary: style.getPropertyValue('--color-secondary'),
accent: style.getPropertyValue('--color-accent'), accent: style.getPropertyValue('--color-accent'),
background: style.getPropertyValue('--color-background') background: style.getPropertyValue('--color-background'),
} };
} };
const createBaseChartConfig = (bgColor: string) => ({ const createBaseChartConfig = (bgColor: string) => ({
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { display: true }, legend: { display: true },
tooltip: { mode: 'index', intersect: false } tooltip: { mode: 'index', intersect: false },
}, },
elements: { point: { radius: 1 } }, elements: { point: { radius: 1 } },
scales: { scales: {
x: { x: {
grid: { color: bgColor }, grid: { color: bgColor },
ticks: { color: bgColor }, ticks: { color: bgColor },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
@@ -54,14 +54,14 @@
max: 10, max: 10,
grid: { color: bgColor }, grid: { color: bgColor },
ticks: { color: bgColor }, ticks: { color: bgColor },
border: { color: bgColor } border: { color: bgColor },
} },
} },
}) });
const initializeCharts = () => { const initializeCharts = () => {
const colors = getChartColors() const colors = getChartColors();
const baseConfig = createBaseChartConfig(colors.background) const baseConfig = createBaseChartConfig(colors.background);
angleChart = new Chart(angleChartElement, { angleChart = new Chart(angleChartElement, {
type: 'line', type: 'line',
@@ -73,7 +73,7 @@
backgroundColor: colors.primary, backgroundColor: colors.primary,
borderWidth: 2, borderWidth: 2,
data: $imu.x, data: $imu.x,
yAxisID: 'y' yAxisID: 'y',
}, },
{ {
label: 'y', label: 'y',
@@ -81,7 +81,7 @@
backgroundColor: colors.secondary, backgroundColor: colors.secondary,
borderWidth: 2, borderWidth: 2,
data: $imu.y, data: $imu.y,
yAxisID: 'y' yAxisID: 'y',
}, },
{ {
label: 'z', label: 'z',
@@ -89,9 +89,9 @@
backgroundColor: colors.accent, backgroundColor: colors.accent,
borderWidth: 2, borderWidth: 2,
data: $imu.z, data: $imu.z,
yAxisID: 'y' yAxisID: 'y',
} },
] ],
}, },
options: { options: {
...baseConfig, ...baseConfig,
@@ -103,12 +103,12 @@
display: true, display: true,
text: 'Angle [°]', text: 'Angle [°]',
color: colors.background, color: colors.background,
font: { size: 16, weight: 'bold' } font: { size: 16, weight: 'bold' },
} },
} },
} },
} },
}) });
tempChart = new Chart(tempChartElement, { tempChart = new Chart(tempChartElement, {
type: 'line', type: 'line',
@@ -120,9 +120,9 @@
backgroundColor: colors.secondary, backgroundColor: colors.secondary,
borderWidth: 2, borderWidth: 2,
data: $imu.bmp_temp, data: $imu.bmp_temp,
yAxisID: 'y' yAxisID: 'y',
} },
] ],
}, },
options: { options: {
...baseConfig, ...baseConfig,
@@ -134,12 +134,12 @@
display: true, display: true,
text: 'Temperature [C°]', text: 'Temperature [C°]',
color: colors.background, color: colors.background,
font: { size: 16, weight: 'bold' } font: { size: 16, weight: 'bold' },
} },
} },
} },
} },
}) });
altitudeChart = new Chart(altitudeChartElement, { altitudeChart = new Chart(altitudeChartElement, {
type: 'line', type: 'line',
@@ -151,9 +151,9 @@
backgroundColor: colors.primary, backgroundColor: colors.primary,
borderWidth: 2, borderWidth: 2,
data: $imu.altitude, data: $imu.altitude,
yAxisID: 'y' yAxisID: 'y',
} },
] ],
}, },
options: { options: {
...baseConfig, ...baseConfig,
@@ -165,60 +165,60 @@
display: true, display: true,
text: 'Altitude [M]', text: 'Altitude [M]',
color: colors.background, color: colors.background,
font: { size: 16, weight: 'bold' } font: { size: 16, weight: 'bold' },
} },
} },
} },
} },
}) });
} };
const updateChartData = (chart: Chart, data: number[], label: string) => { const updateChartData = (chart: Chart, data: number[], label: string) => {
chart.data.labels = data chart.data.labels = data;
chart.data.datasets[0].data = data chart.data.datasets[0].data = data;
chart.options.scales!.y!.min = Math.min(...data) - 1 chart.options.scales!.y!.min = Math.min(...data) - 1;
chart.options.scales!.y!.max = Math.max(...data) + 1 chart.options.scales!.y!.max = Math.max(...data) + 1;
chart.update('none') chart.update('none');
} };
const updateData = () => { const updateData = () => {
if ($features.imu) { if ($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;
angleChart.data.datasets[2].data = $imu.z angleChart.data.datasets[2].data = $imu.z;
const allValues = [...$imu.x, ...$imu.y, ...$imu.z] const allValues = [...$imu.x, ...$imu.y, ...$imu.z];
angleChart.options.scales!.y!.min = Math.min(...allValues) - 1 angleChart.options.scales!.y!.min = Math.min(...allValues) - 1;
angleChart.options.scales!.y!.max = Math.max(...allValues) + 1 angleChart.options.scales!.y!.max = Math.max(...allValues) + 1;
angleChart.update('none') angleChart.update('none');
} }
if ($features.bmp) { if ($features.bmp) {
updateChartData(tempChart, $imu.bmp_temp, 'Temperature') updateChartData(tempChart, $imu.bmp_temp, 'Temperature');
updateChartData(altitudeChart, $imu.altitude, 'Altitude') updateChartData(altitudeChart, $imu.altitude, 'Altitude');
} }
} };
onMount(() => { onMount(() => {
socket.on('imu', (data: IMU) => { socket.on('imu', (data: IMU) => {
console.log(data) console.log(data);
imu.addData(data) imu.addData(data);
}) });
initializeCharts() initializeCharts();
intervalId = setInterval(updateData, 200) intervalId = setInterval(updateData, 200);
}) });
onDestroy(() => { onDestroy(() => {
socket.off('imu') socket.off('imu');
clearInterval(intervalId) clearInterval(intervalId);
}) });
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#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}
{#snippet title()} {#snippet title()}
<span>IMU</span> <span>IMU</span>
+2 -10
View File
@@ -1,15 +1,7 @@
<script lang="ts"> <script lang="ts">
import SystemMetrics from './SystemMetrics.svelte'; import SystemMetrics from './SystemMetrics.svelte';
import { goto } from '$app/navigation';
import { useFeatureFlags } from '$lib/stores/featureFlags';
const features = useFeatureFlags();
if (!$features.analytics) {
goto('/');
}
</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">
<SystemMetrics /> <SystemMetrics />
</div> </div>
+351 -355
View File
@@ -1,373 +1,369 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
import { daisyColor } from '$lib/utilities'; import { daisyColor } from '$lib/utilities';
import { analytics } from '$lib/stores/analytics'; import { analytics } from '$lib/stores/analytics';
import { Metrics } from '$lib/components/icons'; import { Metrics } from '$lib/components/icons';
Chart.register(...registerables); Chart.register(...registerables);
let cpuChartElement: HTMLCanvasElement = $state(); let cpuChartElement: HTMLCanvasElement;
let cpuChart: Chart; let cpuChart: Chart;
let heapChartElement: HTMLCanvasElement = $state(); let heapChartElement: HTMLCanvasElement;
let heapChart: Chart; let heapChart: Chart;
let filesystemChartElement: HTMLCanvasElement = $state(); let filesystemChartElement: HTMLCanvasElement;
let filesystemChart: Chart; let filesystemChart: Chart;
let temperatureChartElement: HTMLCanvasElement = $state(); let temperatureChartElement: HTMLCanvasElement;
let temperatureChart: Chart; let temperatureChart: Chart;
onMount(() => { onMount(() => {
cpuChart = new Chart(cpuChartElement, { cpuChart = new Chart(cpuChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.cpu_usage, labels: $analytics.cpu_usage,
datasets: [ datasets: [
{ {
label: 'Cpu usage core 0', label: 'Cpu usage core 0',
borderColor: daisyColor('--p'), borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--p', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.cpu0_usage, data: $analytics.cpu0_usage,
yAxisID: 'y' yAxisID: 'y',
}, },
{ {
label: 'Cpu usage core 1', label: 'Cpu usage core 1',
borderColor: daisyColor('--p'), borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--p', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.cpu1_usage, data: $analytics.cpu1_usage,
yAxisID: 'y' yAxisID: 'y',
}, },
{ {
label: 'Cpu usage total', label: 'Cpu usage total',
borderColor: daisyColor('--s'), borderColor: daisyColor('--s'),
backgroundColor: daisyColor('--s', 50), backgroundColor: daisyColor('--s', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.cpu_usage, data: $analytics.cpu_usage,
yAxisID: 'y' yAxisID: 'y',
}, },
] ],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false,
} },
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0,
} },
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--bc', 10),
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: 'Cpu usage [%]', text: 'Cpu usage [%]',
color: daisyColor('--bc'), color: daisyColor('--bc'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold',
} },
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: 100, max: 100,
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--bc', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--bc', 10) },
} },
} },
} },
}); });
heapChart = new Chart(heapChartElement, { heapChart = new Chart(heapChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.uptime, labels: $analytics.uptime,
datasets: [ datasets: [
{ {
label: 'Used Heap', label: 'Used Heap',
borderColor: daisyColor('--p'), borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--p', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.used_heap, data: $analytics.used_heap,
fill:true, fill: true,
yAxisID: 'y' yAxisID: 'y',
} },
] ],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false,
} },
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0,
} },
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--bc', 10),
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: 'Heap [kb]', text: 'Heap [kb]',
color: daisyColor('--bc'), color: daisyColor('--bc'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold',
} },
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: Math.round($analytics.total_heap[0]), max: Math.round($analytics.total_heap[0]),
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--bc', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--bc', 10) },
} },
} },
} },
}); });
filesystemChart = new Chart(filesystemChartElement, { filesystemChart = new Chart(filesystemChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.uptime, labels: $analytics.uptime,
datasets: [ datasets: [
{ {
label: 'File System Used', label: 'File System Used',
borderColor: daisyColor('--p'), borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--p', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.fs_used, data: $analytics.fs_used,
fill:true, fill: true,
yAxisID: 'y' yAxisID: 'y',
} },
] ],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false,
} },
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0,
} },
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--bc', 10),
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: 'File System [kb]', text: 'File System [kb]',
color: daisyColor('--bc'), color: daisyColor('--bc'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold',
} },
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: Math.round($analytics.fs_total[0]), max: Math.round($analytics.fs_total[0]),
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--bc', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--bc', 10) },
} },
} },
} },
}); });
temperatureChart = new Chart(temperatureChartElement, { temperatureChart = new Chart(temperatureChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.uptime, labels: $analytics.uptime,
datasets: [ datasets: [
{ {
label: 'Core Temperature', label: 'Core Temperature',
borderColor: daisyColor('--p'), borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50), backgroundColor: daisyColor('--p', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.core_temp, data: $analytics.core_temp,
yAxisID: 'y' yAxisID: 'y',
} },
] ],
}, },
options: { options: {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
plugins: { plugins: {
legend: { legend: {
display: true display: true,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false intersect: false,
} },
}, },
elements: { elements: {
point: { point: {
radius: 0 radius: 0,
} },
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: daisyColor('--bc', 10) color: daisyColor('--bc', 10),
}, },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
display: false display: false,
}, },
y: { y: {
type: 'linear', type: 'linear',
title: { title: {
display: true, display: true,
text: 'Core Temperature [°C]', text: 'Core Temperature [°C]',
color: daisyColor('--bc'), color: daisyColor('--bc'),
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold',
} },
}, },
position: 'left', position: 'left',
suggestedMin: 20, suggestedMin: 20,
suggestedMax: 100, suggestedMax: 100,
grid: { color: daisyColor('--bc', 10) }, grid: { color: daisyColor('--bc', 10) },
ticks: { ticks: {
color: daisyColor('--bc') color: daisyColor('--bc'),
}, },
border: { color: daisyColor('--bc', 10) } border: { color: daisyColor('--bc', 10) },
} },
} },
} },
}); });
setInterval(updateData, 500); setInterval(updateData, 500);
}); });
function updateData() { function updateData() {
cpuChart.data.labels = $analytics.cpu_usage; cpuChart.data.labels = $analytics.cpu_usage;
cpuChart.data.datasets[0].data = $analytics.cpu0_usage; cpuChart.data.datasets[0].data = $analytics.cpu0_usage;
cpuChart.data.datasets[1].data = $analytics.cpu1_usage; cpuChart.data.datasets[1].data = $analytics.cpu1_usage;
cpuChart.data.datasets[2].data = $analytics.cpu_usage; cpuChart.data.datasets[2].data = $analytics.cpu_usage;
cpuChart.update('none'); cpuChart.update('none');
heapChart.data.labels = $analytics.uptime; heapChart.data.labels = $analytics.uptime;
heapChart.data.datasets[0].data = $analytics.used_heap; heapChart.data.datasets[0].data = $analytics.used_heap;
heapChart.options.scales!.y!.max = Math.ceil($analytics.total_heap[0]); heapChart.options.scales!.y!.max = Math.ceil($analytics.total_heap[0]);
heapChart.update('none'); heapChart.update('none');
filesystemChart.data.labels = $analytics.uptime; filesystemChart.data.labels = $analytics.uptime;
filesystemChart.data.datasets[0].data = $analytics.fs_used; filesystemChart.data.datasets[0].data = $analytics.fs_used;
heapChart.options.scales!.y!.max = Math.ceil($analytics.fs_total[0]); heapChart.options.scales!.y!.max = Math.ceil($analytics.fs_total[0]);
filesystemChart.update('none'); filesystemChart.update('none');
temperatureChart.data.labels = $analytics.uptime; temperatureChart.data.labels = $analytics.uptime;
temperatureChart.data.datasets[0].data = $analytics.core_temp; temperatureChart.data.datasets[0].data = $analytics.core_temp;
temperatureChart.update('none'); temperatureChart.update('none');
} }
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<Metrics class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <Metrics class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span >System Metrics</span> <span>System Metrics</span>
{/snippet} {/snippet}
<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={cpuChartElement}></canvas>
<canvas bind:this={cpuChartElement}></canvas> </div>
</div> </div>
</div>
<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={heapChartElement}></canvas>
<canvas bind:this={heapChartElement}></canvas> </div>
</div> </div>
</div> <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-52"
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>
<canvas bind:this={filesystemChartElement}></canvas> </div>
</div> <div class="w-full overflow-x-auto">
</div> <div
<div class="w-full overflow-x-auto"> class="flex w-full flex-col space-y-1 h-52"
<div transition:slide|local={{ duration: 300, easing: cubicOut }}>
class="flex w-full flex-col space-y-1 h-52" <canvas bind:this={temperatureChartElement}></canvas>
transition:slide|local={{ duration: 300, easing: cubicOut }} </div>
> </div>
<canvas bind:this={temperatureChartElement}></canvas>
</div>
</div>
</SettingsCard> </SettingsCard>
+132 -124
View File
@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte';
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
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 { slide } from 'svelte/transition' import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing';
import type { SystemInformation, Analytics } from '$lib/types/models' import type { SystemInformation, Analytics } from '$lib/types/models';
import { socket } from '$lib/stores/socket' import { socket } from '$lib/stores/socket';
import { api } from '$lib/api' import { api } from '$lib/api';
import { convertSeconds } from '$lib/utilities' import { convertSeconds } from '$lib/utilities';
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags';
import { import {
Cancel, Cancel,
Power, Power,
@@ -27,37 +27,42 @@
Flash, Flash,
Folder, Folder,
Temperature, Temperature,
Stopwatch Stopwatch,
} from '$lib/components/icons' } from '$lib/components/icons';
import StatusItem from '$lib/components/StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte';
import ActionButton from './ActionButton.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() { async function getSystemStatus() {
const result = await api.get<SystemInformation>('/api/system/status') const result = await api.get<SystemInformation>('/api/system/status');
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner);
return return;
} }
systemInformation = result.inner systemInformation = result.inner;
return systemInformation 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) => const postRestart = async () => await api.post('/api/system/restart');
(systemInformation = { ...systemInformation, ...data })
const postRestart = async () => await api.post('/api/system/restart')
function confirmRestart() { function confirmRestart() {
modals.open(ConfirmDialog, { modals.open(ConfirmDialog, {
@@ -65,13 +70,13 @@
message: 'Are you sure you want to restart the device?', message: 'Are you sure you want to restart the device?',
labels: { labels: {
cancel: { label: 'Abort', icon: Cancel }, cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Restart', icon: Power } confirm: { label: 'Restart', icon: Power },
}, },
onConfirm: () => { onConfirm: () => {
modals.close() modals.close();
postRestart() postRestart();
} },
}) });
} }
function confirmReset() { function confirmReset() {
@@ -80,13 +85,13 @@
message: 'Are you sure you want to reset the device to its factory defaults?', message: 'Are you sure you want to reset the device to its factory defaults?',
labels: { labels: {
cancel: { label: 'Abort', icon: Cancel }, cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Factory Reset', icon: FactoryReset } confirm: { label: 'Factory Reset', icon: FactoryReset },
}, },
onConfirm: () => { onConfirm: () => {
modals.close() modals.close();
postFactoryReset() postFactoryReset();
} },
}) });
} }
function confirmSleep() { function confirmSleep() {
@@ -95,21 +100,21 @@
message: 'Are you sure you want to put the device into sleep?', message: 'Are you sure you want to put the device into sleep?',
labels: { labels: {
cancel: { label: 'Abort', icon: Cancel }, cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Sleep', icon: Sleep } confirm: { label: 'Sleep', icon: Sleep },
}, },
onConfirm: () => { onConfirm: () => {
modals.close() modals.close();
postSleep() postSleep();
} },
}) });
} }
interface ActionButtonDef { interface ActionButtonDef {
icon: any icon: any;
label: string label: string;
onClick: () => void onClick: () => void;
type?: string type?: string;
condition?: () => boolean condition?: () => boolean;
} }
const actionButtons: ActionButtonDef[] = [ const actionButtons: ActionButtonDef[] = [
@@ -117,20 +122,20 @@
icon: Sleep, icon: Sleep,
label: 'Sleep', label: 'Sleep',
onClick: confirmSleep, onClick: confirmSleep,
condition: () => Boolean($features.sleep) condition: () => Boolean($features.sleep),
}, },
{ {
icon: Power, icon: Power,
label: 'Restart', label: 'Restart',
onClick: confirmRestart onClick: confirmRestart,
}, },
{ {
icon: FactoryReset, icon: FactoryReset,
label: 'Factory Reset', label: 'Factory Reset',
onClick: confirmReset, onClick: confirmReset,
type: 'secondary' type: 'secondary',
} },
] ];
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
@@ -144,89 +149,92 @@
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
{#await getSystemStatus()} {#await getSystemStatus()}
<Spinner /> <Spinner />
{:then nothing} {:then}
<div {#if systemInformation}
class="flex w-full flex-col space-y-1" <div
transition:slide|local={{ duration: 300, easing: cubicOut }}> class="flex w-full flex-col space-y-1"
<StatusItem transition:slide|local={{ duration: 300, easing: cubicOut }}>
icon={CPU} <StatusItem
title="Chip" icon={CPU}
description={`${systemInformation.cpu_type} Rev ${systemInformation.cpu_rev}`} /> title="Chip"
description={`${systemInformation.cpu_type} Rev ${systemInformation.cpu_rev}`} />
<StatusItem <StatusItem
icon={SDK} icon={SDK}
title="SDK Version" title="SDK Version"
description={`ESP-IDF ${systemInformation.sdk_version} / Arduino ${systemInformation.arduino_version}`} /> description={`ESP-IDF ${systemInformation.sdk_version} / Arduino ${systemInformation.arduino_version}`} />
<StatusItem <StatusItem
icon={CPP} icon={CPP}
title="Firmware Version" title="Firmware Version"
description={systemInformation.firmware_version} /> description={systemInformation.firmware_version} />
<StatusItem <StatusItem
icon={Speed} icon={Speed}
title="CPU Frequency" title="CPU Frequency"
description={`${systemInformation.cpu_freq_mhz} MHz ${ description={`${systemInformation.cpu_freq_mhz} MHz ${
systemInformation.cpu_cores == 2 ? 'Dual Core' : 'Single Core' systemInformation.cpu_cores == 2 ? 'Dual Core' : 'Single Core'
}`} /> }`} />
<StatusItem <StatusItem
icon={Heap} icon={Heap}
title="Heap (Free / Max Alloc)" title="Heap (Free / Max Alloc)"
description={`${systemInformation.free_heap} / ${systemInformation.max_alloc_heap} bytes`} /> description={`${systemInformation.free_heap} / ${systemInformation.max_alloc_heap} bytes`} />
<StatusItem <StatusItem
icon={Pyramid} icon={Pyramid}
title="PSRAM (Size / Free)" title="PSRAM (Size / Free)"
description={`${systemInformation.psram_size} / ${systemInformation.psram_size} bytes`} /> description={`${systemInformation.psram_size} / ${systemInformation.psram_size} bytes`} />
<StatusItem <StatusItem
icon={Sketch} icon={Sketch}
title="Sketch (Used / Free)" title="Sketch (Used / Free)"
description={`${( description={`${(
(systemInformation.sketch_size / systemInformation.free_sketch_space) * (systemInformation.sketch_size / systemInformation.free_sketch_space) *
100 100
).toFixed(1)} % of ).toFixed(1)} % of
${systemInformation.free_sketch_space / 1000000} MB used (${ ${systemInformation.free_sketch_space / 1000000} MB used (${
(systemInformation.free_sketch_space - systemInformation.sketch_size) / 1000000 (systemInformation.free_sketch_space - systemInformation.sketch_size) / 1000000
} MB free)`} /> } MB free)`} />
<StatusItem <StatusItem
icon={Flash} icon={Flash}
title="Flash Chip (Size / Speed)" title="Flash Chip (Size / Speed)"
description={`${systemInformation.flash_chip_size / 1000000} MB / ${ description={`${systemInformation.flash_chip_size / 1000000} MB / ${
systemInformation.flash_chip_speed / 1000000 systemInformation.flash_chip_speed / 1000000
} MHz`} /> } MHz`} />
<StatusItem <StatusItem
icon={Folder} icon={Folder}
title="File System (Used / Total)" title="File System (Used / Total)"
description={`${((systemInformation.fs_used / systemInformation.fs_total) * 100).toFixed( description={`${(
1 (systemInformation.fs_used / systemInformation.fs_total) *
)} % of ${systemInformation.fs_total / 1000000} MB used (${ 100
(systemInformation.fs_total - systemInformation.fs_used) / 1000000 ).toFixed(1)} % of ${systemInformation.fs_total / 1000000} MB used (${
} (systemInformation.fs_total - systemInformation.fs_used) / 1000000
}
MB free)`} /> MB free)`} />
<StatusItem <StatusItem
icon={Temperature} icon={Temperature}
title="Core Temperature" title="Core Temperature"
description={`${ description={`${
systemInformation.core_temp == 53.33 ? systemInformation.core_temp == 53.33 ?
'NaN' 'NaN'
: systemInformation.core_temp.toFixed(2) + ' °C' : systemInformation.core_temp.toFixed(2) + ' °C'
}`} /> }`} />
<StatusItem <StatusItem
icon={Stopwatch} icon={Stopwatch}
title="Uptime" title="Uptime"
description={convertSeconds(systemInformation.uptime)} /> description={convertSeconds(systemInformation.uptime)} />
<StatusItem <StatusItem
icon={Power} icon={Power}
title="Reset Reason" title="Reset Reason"
description={systemInformation.cpu_reset_reason} /> description={systemInformation.cpu_reset_reason} />
</div> </div>
{/if}
{/await} {/await}
</div> </div>
@@ -1,165 +1,154 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { modals } from 'svelte-modals'; import { modals } from 'svelte-modals';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Spinner from '$lib/components/Spinner.svelte'; import Spinner from '$lib/components/Spinner.svelte';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import { compareVersions } from 'compare-versions'; import { compareVersions } from 'compare-versions';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'; import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import InfoDialog from '$lib/components/InfoDialog.svelte'; import InfoDialog from '$lib/components/InfoDialog.svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { useFeatureFlags } from '$lib/stores'; import { useFeatureFlags } from '$lib/stores';
import { Error, Cancel, Check, CloudDown, Github, Prerelease } from '$lib/components/icons'; import { Error, Cancel, Check, CloudDown, Github, Prerelease } from '$lib/components/icons';
const features = useFeatureFlags(); const features = useFeatureFlags();
async function getGithubAPI() { async function getGithubAPI() {
const headers = { const headers = {
accept: 'application/vnd.github+json', accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28' 'X-GitHub-Api-Version': '2022-11-28',
}; };
const result = await api.get(`https://api.github.com/repos/${page.data.github}/releases`, { const result = await api.get(`https://api.github.com/repos/${page.data.github}/releases`, {
headers headers,
});
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
return result.inner as any;
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/firmware/download', { download_url: url });
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
}
function confirmGithubUpdate(assets: any) {
let url = '';
// iterate over assets and find the correct one
for (let i = 0; i < assets.length; i++) {
// check if the asset is of type *.bin
if (
assets[i].name.includes('.bin') &&
assets[i].name.includes($features.firmware_built_target)
) {
url = assets[i].browser_download_url;
}
}
if (url === '') {
// if no asset was found, use the first one
modals.open(InfoDialog, {
title: 'No matching firmware found',
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(),
});
return;
}
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(),
}); });
if (result.isErr()) { },
console.error('Error:', result.inner); });
return; }
}
return result.inner as any;
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/firmware/download', { download_url: url });
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
}
function confirmGithubUpdate(assets: any) {
let url = '';
// iterate over assets and find the correct one
for (let i = 0; i < assets.length; i++) {
// check if the asset is of type *.bin
if (
assets[i].name.includes('.bin') &&
assets[i].name.includes($features.firmware_built_target)
) {
url = assets[i].browser_download_url;
}
}
if (url === '') {
// if no asset was found, use the first one
modals.open(InfoDialog, {
title: 'No matching firmware found',
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()
});
return;
}
modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url);
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
});
}
});
}
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<Github class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" /> <Github class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span>Github Firmware Manager</span> <span>Github Firmware Manager</span>
{/snippet} {/snippet}
{#await getGithubAPI()} {#await getGithubAPI()}
<Spinner /> <Spinner />
{:then githubReleases} {:then githubReleases}
<div class="relative w-full overflow-visible"> <div class="relative w-full overflow-visible">
<div <div class="overflow-x-auto" transition:slide|local={{ duration: 300, easing: cubicOut }}>
class="overflow-x-auto" <table class="table w-full table-auto">
transition:slide|local={{ duration: 300, easing: cubicOut }} <thead>
> <tr class="font-bold">
<table class="table w-full table-auto"> <th align="left">Release</th>
<thead> <th align="center" class="hidden sm:block">Release Date</th>
<tr class="font-bold"> <th align="center">Experimental</th>
<th align="left">Release</th> <th align="center">Install</th>
<th align="center" class="hidden sm:block">Release Date</th> </tr>
<th align="center">Experimental</th> </thead>
<th align="center">Install</th> <tbody>
</tr> {#each githubReleases as release}
</thead> <tr
<tbody> class={(
{#each githubReleases as release} compareVersions($features.firmware_version as string, release.tag_name) === 0
<tr ) ?
class={( 'bg-primary text-primary-content'
compareVersions( : 'bg-base-100 h-14'}>
$features.firmware_version, <td align="left" class="text-base font-semibold">
release.tag_name <a
) === 0 href={release.html_url}
) ? class="link link-hover"
'bg-primary text-primary-content' target="_blank"
: 'bg-base-100 h-14'} rel="noopener noreferrer">{release.name}</a
> ></td>
<td align="left" class="text-base font-semibold"> <td align="center" class="hidden min-h-full align-middle sm:block">
<a <div class="my-2">
href={release.html_url} {new Intl.DateTimeFormat('en-GB', {
class="link link-hover" dateStyle: 'medium',
target="_blank" }).format(new Date(release.published_at))}
rel="noopener noreferrer">{release.name}</a </div>
></td </td>
> <td align="center">
<td align="center" class="hidden min-h-full align-middle sm:block"> {#if release.prerelease}
<div class="my-2"> <Prerelease class="text-accent h-5 w-5" />
{new Intl.DateTimeFormat('en-GB', { {/if}
dateStyle: 'medium' </td>
}).format(new Date(release.published_at))} <td align="center">
</div> {#if compareVersions($features.firmware_version as string, release.tag_name) != 0}
</td> <button
<td align="center"> class="btn btn-ghost btn-circle btn-sm"
{#if release.prerelease} onclick={() => {
<Prerelease class="text-accent h-5 w-5" /> confirmGithubUpdate(release.assets);
{/if} }}>
</td> <CloudDown class="text-secondary h-6 w-6" />
<td align="center"> </button>
{#if compareVersions($features.firmware_version, release.tag_name) != 0} {/if}
<button </td>
class="btn btn-ghost btn-circle btn-sm" </tr>
onclick={() => { {/each}
confirmGithubUpdate(release.assets); </tbody>
}} </table>
> </div>
<CloudDown class="text-secondary h-6 w-6" /> </div>
</button> {:catch error}
{/if} <div class="alert alert-error shadow-lg">
</td> <Error class="h-6 w-6 shrink-0" />
</tr> <span>Please connect to a network with internet access to perform a firmware update.</span>
{/each} </div>
</tbody> {/await}
</table>
</div>
</div>
{: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
>
</div>
{/await}
</SettingsCard> </SettingsCard>
@@ -1,57 +1,56 @@
<script lang="ts"> <script lang="ts">
import { modals } from 'svelte-modals'; import { modals } from 'svelte-modals';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { Cancel, OTA, Warning } from '$lib/components/icons'; import { Cancel, OTA, Warning } from '$lib/components/icons';
let files: FileList = $state(); let files: FileList | undefined = $state();
async function uploadBIN() { async function uploadBIN() {
const formData = new FormData(); const formData = new FormData();
formData.append('file', files[0]); formData.append('file', files![0]);
const result = await api.post('/api/firmware', formData); const result = await api.post('/api/firmware', formData);
if (result.isErr()) console.error('Error:', result.inner); if (result.isErr()) console.error('Error:', result.inner);
} }
function confirmBinUpload() { function confirmBinUpload() {
modals.open(ConfirmDialog, { modals.open(ConfirmDialog, {
title: 'Confirm Flashing the Device', title: 'Confirm Flashing the Device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?', message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: { labels: {
cancel: { label: 'Abort', icon: Cancel }, cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Upload', icon: OTA } confirm: { label: 'Upload', icon: OTA },
}, },
onConfirm: () => { onConfirm: () => {
modals.close(); modals.close();
uploadBIN(); uploadBIN();
} },
}); });
} }
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
{#snippet icon()} {#snippet icon()}
<OTA class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" /> <OTA class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<span>Upload Firmware</span> <span>Upload Firmware</span>
{/snippet} {/snippet}
<div class="alert alert-warning shadow-lg"> <div class="alert alert-warning shadow-lg">
<Warning class="h-6 w-6 shrink-0" /> <Warning class="h-6 w-6 shrink-0" />
<span <span
>Uploading a new firmware (.bin) file will replace the existing firmware. You may upload >Uploading a new firmware (.bin) file will replace the existing firmware. You may upload a
a (.md5) file first to verify the uploaded firmware. (.md5) file first to verify the uploaded firmware.
</span> </span>
</div> </div>
<input <input
type="file" type="file"
id="binFile" id="binFile"
class="file-input file-input-bordered file-input-secondary mt-4 w-full" class="file-input file-input-bordered file-input-secondary mt-4 w-full"
bind:files bind:files
accept=".bin,.md5" accept=".bin,.md5"
onchange={confirmBinUpload} onchange={confirmBinUpload} />
/>
</SettingsCard> </SettingsCard>
+248 -247
View File
@@ -1,74 +1,71 @@
<script lang="ts"> <script lang="ts">
import { preventDefault } from 'svelte/legacy' import { preventDefault } from 'svelte/legacy';
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing';
import { PasswordInput } from '$lib/components/input' import { PasswordInput } from '$lib/components/input';
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte';
import type { ApSettings, ApStatus } from '$lib/types/models' import type { ApSettings, ApStatus } from '$lib/types/models';
import { api } from '$lib/api' import { api } from '$lib/api';
import { useFeatureFlags } from '$lib/stores' import { AP, Devices, Home, MAC } from '$lib/components/icons';
import { AP, Devices, Home, MAC } from '$lib/components/icons' import StatusItem from '$lib/components/StatusItem.svelte';
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 formField: any = $state();
let apStatus: ApStatus = $state()
let formField: any = $state()
async function getAPStatus() { 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()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner);
return return;
} }
apStatus = result.inner apStatus = result.inner;
return apStatus return apStatus;
} }
async function getAPSettings() { 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()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner);
return return;
} }
apSettings = result.inner apSettings = result.inner;
return apSettings return apSettings;
} }
const interval = setInterval(async () => { const interval = setInterval(async () => {
getAPStatus() getAPStatus();
}, 5000) }, 5000);
onDestroy(() => clearInterval(interval)) onDestroy(() => clearInterval(interval));
onMount(getAPSettings) onMount(getAPSettings);
let provisionMode = [ let provisionMode = [
{ {
id: 0, id: 0,
text: `Always` text: `Always`,
}, },
{ {
id: 1, id: 1,
text: `When WiFi Disconnected` text: `When WiFi Disconnected`,
}, },
{ {
id: 2, 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({ let formErrors = $state({
ssid: false, ssid: false,
@@ -76,80 +73,81 @@
max_clients: false, max_clients: false,
local_ip: false, local_ip: false,
gateway_ip: false, gateway_ip: false,
subnet_mask: false subnet_mask: false,
}) });
async function postAPSettings(data: ApSettings) { 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()) { if (result.isErr()) {
notifications.error('User not authorized.', 3000) notifications.error('User not authorized.', 3000);
console.error('Error:', result.inner) console.error('Error:', result.inner);
return return;
} }
notifications.success('Access Point settings updated.', 3000) notifications.success('Access Point settings updated.', 3000);
apSettings = result.inner apSettings = result.inner;
} }
function handleSubmitAP() { function handleSubmitAP() {
let valid = true if (!apSettings) return;
let valid = true;
// Validate SSID // Validate SSID
if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) { if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) {
valid = false valid = false;
formErrors.ssid = true formErrors.ssid = true;
} else { } else {
formErrors.ssid = false formErrors.ssid = false;
} }
// Validate Channel // Validate Channel
let channel = Number(apSettings.channel) let channel = Number(apSettings.channel);
if (1 > channel || channel > 13) { if (1 > channel || channel > 13) {
valid = false valid = false;
formErrors.channel = true formErrors.channel = true;
} else { } else {
formErrors.channel = false formErrors.channel = false;
} }
// Validate max_clients // Validate max_clients
let maxClients = Number(apSettings.max_clients) let maxClients = Number(apSettings.max_clients);
if (1 > maxClients || maxClients > 8) { if (1 > maxClients || maxClients > 8) {
valid = false valid = false;
formErrors.max_clients = true formErrors.max_clients = true;
} else { } else {
formErrors.max_clients = false formErrors.max_clients = false;
} }
// RegEx for IPv4 // RegEx for IPv4
const regexExp = 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 // Validate gateway IP
if (!regexExp.test(apSettings.gateway_ip)) { if (!regexExp.test(apSettings.gateway_ip)) {
valid = false valid = false;
formErrors.gateway_ip = true formErrors.gateway_ip = true;
} else { } else {
formErrors.gateway_ip = false formErrors.gateway_ip = false;
} }
// Validate Subnet Mask // Validate Subnet Mask
if (!regexExp.test(apSettings.subnet_mask)) { if (!regexExp.test(apSettings.subnet_mask)) {
valid = false valid = false;
formErrors.subnet_mask = true formErrors.subnet_mask = true;
} else { } else {
formErrors.subnet_mask = false formErrors.subnet_mask = false;
} }
// Validate local IP // Validate local IP
if (!regexExp.test(apSettings.local_ip)) { if (!regexExp.test(apSettings.local_ip)) {
valid = false valid = false;
formErrors.local_ip = true formErrors.local_ip = true;
} else { } else {
formErrors.local_ip = false formErrors.local_ip = false;
} }
// Submit JSON to REST API // Submit JSON to REST API
if (valid) { if (valid) {
postAPSettings(apSettings) postAPSettings(apSettings);
} }
} }
</script> </script>
@@ -164,22 +162,24 @@
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
{#await getAPStatus()} {#await getAPStatus()}
<Spinner /> <Spinner />
{:then nothing} {:then}
<div {#if apStatus}
class="flex w-full flex-col space-y-1" <div
transition:slide|local={{ duration: 300, easing: cubicOut }}> class="flex w-full flex-col space-y-1"
<StatusItem transition:slide|local={{ duration: 300, easing: cubicOut }}>
icon={AP} <StatusItem
title="Status" icon={AP}
variant={apStatusVariant[apStatus.status]} title="Status"
description={apStatusDescription[apStatus.status]} /> variant={apStatusVariant[apStatus.status]}
description={apStatusDescription[apStatus.status]} />
<StatusItem icon={Home} title="IP Address" description={apStatus.ip_address} /> <StatusItem icon={Home} title="IP Address" description={apStatus.ip_address} />
<StatusItem icon={MAC} title="MAC Address" description={apStatus.mac_address} /> <StatusItem icon={MAC} title="MAC Address" description={apStatus.mac_address} />
<StatusItem icon={Devices} title="AP Clients" description={apStatus.station_num} /> <StatusItem icon={Devices} title="AP Clients" description={apStatus.station_num} />
</div> </div>
{/if}
{/await} {/await}
</div> </div>
@@ -190,175 +190,176 @@
</div> </div>
{#await getAPSettings()} {#await getAPSettings()}
<Spinner /> <Spinner />
{:then nothing} {:then}
<div {#if apSettings}
class="flex flex-col gap-2 p-0" <div
transition:slide|local={{ duration: 300, easing: cubicOut }}> class="flex flex-col gap-2 p-0"
<form transition:slide|local={{ duration: 300, easing: cubicOut }}>
class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2" <form
onsubmit={preventDefault(handleSubmitAP)} class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2"
novalidate onsubmit={preventDefault(handleSubmitAP)}
bind:this={formField}> novalidate
<div> bind:this={formField}>
<label class="label" for="apmode"> <div>
<span class="label-text">Provide Access Point ...</span> <label class="label" for="apmode">
</label> <span class="label-text">Provide Access Point ...</span>
<select </label>
class="select select-bordered w-full" <select
id="apmode" class="select select-bordered w-full"
bind:value={apSettings.provision_mode}> id="apmode"
{#each provisionMode as mode} bind:value={apSettings.provision_mode}>
<option value={mode.id}> {#each provisionMode as mode}
{mode.text} <option value={mode.id}>
</option> {mode.text}
{/each} </option>
</select> {/each}
</div> </select>
<div> </div>
<label class="label" for="ssid"> <div>
<span class="label-text text-md">SSID</span> <label class="label" for="ssid">
</label> <span class="label-text text-md">SSID</span>
<input </label>
type="text" <input
class="input input-bordered invalid:border-error w-full invalid:border-2 {( type="text"
formErrors.ssid class="input input-bordered invalid:border-error w-full invalid:border-2 {(
) ? formErrors.ssid
'border-error border-2' ) ?
: ''}" 'border-error border-2'
bind:value={apSettings.ssid} : ''}"
id="ssid" bind:value={apSettings.ssid}
min="2" id="ssid"
max="32" min="2"
required /> max="32"
<label class="label" for="ssid"> required />
<span class="label-text-alt text-error {formErrors.ssid ? '' : 'hidden'}" <label class="label" for="ssid">
>SSID must be between 2 and 32 characters long</span> <span class="label-text-alt text-error {formErrors.ssid ? '' : 'hidden'}"
</label> >SSID must be between 2 and 32 characters long</span>
</div> </label>
</div>
<div> <div>
<label class="label" for="pwd"> <label class="label" for="pwd">
<span class="label-text text-md">Password</span> <span class="label-text text-md">Password</span>
</label> </label>
<PasswordInput bind:value={apSettings.password} id="pwd" /> <PasswordInput bind:value={apSettings.password} id="pwd" />
</div> </div>
<div> <div>
<label class="label" for="channel"> <label class="label" for="channel">
<span class="label-text text-md">Preferred Channel</span> <span class="label-text text-md">Preferred Channel</span>
</label> </label>
<input <input
type="number" type="number"
min="1" min="1"
max="13" max="13"
class="input input-bordered invalid:border-error w-full invalid:border-2 {( class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrors.channel formErrors.channel
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
bind:value={apSettings.channel} bind:value={apSettings.channel}
id="channel" id="channel"
required /> required />
<label class="label" for="channel"> <label class="label" for="channel">
<span class="label-text-alt text-error {formErrors.channel ? '' : 'hidden'}" <span class="label-text-alt text-error {formErrors.channel ? '' : 'hidden'}"
>Must be channel 1 to 13</span> >Must be channel 1 to 13</span>
</label> </label>
</div> </div>
<div> <div>
<label class="label" for="clients"> <label class="label" for="clients">
<span class="label-text text-md">Max Clients</span> <span class="label-text text-md">Max Clients</span>
</label> </label>
<input <input
type="number" type="number"
min="1" min="1"
max="8" max="8"
class="input input-bordered invalid:border-error w-full invalid:border-2 {( class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrors.max_clients formErrors.max_clients
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
bind:value={apSettings.max_clients} bind:value={apSettings.max_clients}
id="clients" id="clients"
required /> required />
<label class="label" for="clients"> <label class="label" for="clients">
<span class="label-text-alt text-error {formErrors.max_clients ? '' : 'hidden'}" <span class="label-text-alt text-error {formErrors.max_clients ? '' : 'hidden'}"
>Maximum 8 clients allowed</span> >Maximum 8 clients allowed</span>
</label> </label>
</div> </div>
<div> <div>
<label class="label" for="localIP"> <label class="label" for="localIP">
<span class="label-text text-md">Local IP</span> <span class="label-text text-md">Local IP</span>
</label> </label>
<input <input
type="text" 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"
minlength="7" maxlength="15"
maxlength="15" size="15"
size="15" bind:value={apSettings.local_ip}
bind:value={apSettings.local_ip} id="localIP"
id="localIP" required />
required /> <label class="label" for="localIP">
<label class="label" for="localIP"> <span class="label-text-alt text-error {formErrors.local_ip ? '' : 'hidden'}"
<span class="label-text-alt text-error {formErrors.local_ip ? '' : 'hidden'}" >Must be a valid IPv4 address</span>
>Must be a valid IPv4 address</span> </label>
</label> </div>
</div>
<div> <div>
<label class="label" for="gateway"> <label class="label" for="gateway">
<span class="label-text text-md">Gateway IP</span> <span class="label-text text-md">Gateway IP</span>
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full {formErrors.gateway_ip ? 'border-error border-2' class="input input-bordered w-full {formErrors.gateway_ip ? 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={apSettings.gateway_ip} bind:value={apSettings.gateway_ip}
id="gateway" id="gateway"
required /> required />
<label class="label" for="gateway"> <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> >Must be a valid IPv4 address</span>
</label> </label>
</div> </div>
<div> <div>
<label class="label" for="subnet"> <label class="label" for="subnet">
<span class="label-text text-md">Subnet Mask</span> <span class="label-text text-md">Subnet Mask</span>
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full {formErrors.subnet_mask ? 'border-error border-2' class="input input-bordered w-full {formErrors.subnet_mask ? 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={apSettings.subnet_mask} bind:value={apSettings.subnet_mask}
id="subnet" id="subnet"
required /> required />
<label class="label" for="subnet"> <label class="label" for="subnet">
<span class="label-text-alt text-error {formErrors.subnet_mask ? '' : 'hidden'}" <span class="label-text-alt text-error {formErrors.subnet_mask ? '' : 'hidden'}"
>Must be a valid IPv4 address</span> >Must be a valid IPv4 address</span>
</label> </label>
</div> </div>
<label class="label my-auto cursor-pointer justify-start gap-4"> <label class="label my-auto cursor-pointer justify-start gap-4">
<input <input
type="checkbox" type="checkbox"
bind:checked={apSettings.ssid_hidden} bind:checked={apSettings.ssid_hidden}
class="checkbox checkbox-primary" /> class="checkbox checkbox-primary" />
<span class="">Hide SSID</span> <span class="">Hide SSID</span>
</label> </label>
<div class="place-self-end"> <div class="place-self-end">
<button class="btn btn-primary" type="submit">Apply Settings</button> <button class="btn btn-primary" type="submit">Apply Settings</button>
</div> </div>
</form> </form>
</div> </div>
{/if}
{/await} {/await}
</div> </div>
</SettingsCard> </SettingsCard>
+115 -131
View File
@@ -1,147 +1,131 @@
<script lang="ts"> <script lang="ts">
import { focusTrap } from 'svelte-focus-trap'; import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'; import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte';
import type { NetworkItem, NetworkList } from '$lib/types/models'; import type { NetworkItem, NetworkList } from '$lib/types/models';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { AP, Network, Reload, Cancel } from '$lib/components/icons'; 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 /> let { isOpen, storeNetwork }: ModalProps = $props();
interface Props {
isOpen: boolean; const encryptionType = [
storeNetwork: any; 'Open',
'WEP',
'WPA PSK',
'WPA2 PSK',
'WPA WPA2 PSK',
'WPA2 Enterprise',
'WPA3 PSK',
'WPA2 WPA3 PSK',
'WAPI PSK',
];
let listOfNetworks: NetworkItem[] = $state([]);
let scanActive = $state(false);
let pollingId: ReturnType<typeof setTimeout> | number;
async function scanNetworks() {
scanActive = true;
await api.get('/api/wifi/scan');
if ((await pollingResults()) == false) {
pollingId = setInterval(() => pollingResults(), 1000);
} }
return;
}
let { isOpen, storeNetwork }: Props = $props(); async function pollingResults() {
const result = await api.get<NetworkList>('/api/wifi/networks');
const encryptionType = [ if (result.isErr()) {
'Open', console.error(`Error occurred while fetching: `, result.inner);
'WEP', return false;
'WPA PSK',
'WPA2 PSK',
'WPA WPA2 PSK',
'WPA2 Enterprise',
'WPA3 PSK',
'WPA2 WPA3 PSK',
'WAPI PSK'
];
let listOfNetworks: NetworkItem[] = $state([]);
let scanActive = $state(false);
let pollingId: number;
async function scanNetworks() {
scanActive = true;
await api.get('/api/wifi/scan');
if ((await pollingResults()) == false) {
pollingId = setInterval(() => pollingResults(), 1000);
}
return;
} }
let response = result.inner;
async function pollingResults() { listOfNetworks = response.networks;
const result = await api.get<NetworkList>('/api/wifi/networks'); scanActive = false;
if (result.isErr()) { if (listOfNetworks.length) {
console.error(`Error occurred while fetching: `, result.inner); clearInterval(pollingId);
return false; pollingId = 0;
}
let response = result.inner;
listOfNetworks = response.networks;
scanActive = false;
if (listOfNetworks.length) {
clearInterval(pollingId);
pollingId = 0;
}
return listOfNetworks.length;
} }
return listOfNetworks.length;
}
onMount(() => { onMount(() => {
scanNetworks(); scanNetworks();
}); });
onDestroy(() => { onDestroy(() => {
if (pollingId) { if (pollingId) {
clearInterval(pollingId); clearInterval(pollingId);
pollingId = 0; pollingId = 0;
} }
}); });
</script> </script>
{#if isOpen} {#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
<div <div
role="dialog" 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="pointer-events-none fixed inset-0 z-50 flex items-center justify-center" <h2 class="text-base-content text-start text-2xl font-bold">Scan Networks</h2>
transition:fly={{ y: 50 }} <div class="divider my-2"></div>
use:exitBeforeEnter <div class="overflow-y-auto">
use:focusTrap {#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" />
<div <p class="mt-8 text-2xl">Scanning ...</p>
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>
> {:else}
<h2 class="text-base-content text-start text-2xl font-bold">Scan Networks</h2> <ul class="menu">
<div class="divider my-2"></div> {#each listOfNetworks as network, i}
<div class="overflow-y-auto"> <li>
{#if scanActive}<div <!-- svelte-ignore a11y_click_events_have_key_events -->
class="bg-base-100 flex flex-col items-center justify-center p-6" <div
> class="bg-base-200 rounded-btn my-1 flex items-center space-x-3 hover:scale-[1.02] active:scale-[0.98]"
<AP class="text-secondary h-32 w-32 shrink animate-ping stroke-2" /> onclick={() => {
<p class="mt-8 text-2xl">Scanning ...</p> storeNetwork(network.ssid);
}}
role="button"
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" />
</div>
<div>
<div class="font-bold">{network.ssid}</div>
<div class="text-sm opacity-75">
Security: {encryptionType[network.encryption_type]}, Channel: {network.channel}
</div> </div>
{:else} </div>
<ul class="menu"> <div class="grow"></div>
{#each listOfNetworks as network, i} <RssiIndicator showDBm={true} rssi={network.rssi} />
<li> </div>
<!-- svelte-ignore a11y_click_events_have_key_events --> </li>
<div {/each}
class="bg-base-200 rounded-btn my-1 flex items-center space-x-3 hover:scale-[1.02] active:scale-[0.98]" </ul>
onclick={() => { {/if}
storeNetwork(network.ssid); </div>
}} <div class="divider my-2"></div>
role="button" <div class="flex flex-wrap justify-end gap-2">
tabindex="0" <button
> class="btn btn-primary inline-flex flex-none items-center"
<div class="mask mask-hexagon bg-primary h-auto w-10 shrink-0"> disabled={scanActive}
<Network onclick={scanNetworks}>
class="text-primary-content h-auto w-full scale-75" <Reload class="mr-2 h-5 w-5" /><span>Scan again</span>
/> </button>
</div>
<div>
<div class="font-bold">{network.ssid}</div>
<div class="text-sm opacity-75">
Security: {encryptionType[network.encryption_type]},
Channel: {network.channel}
</div>
</div>
<div class="grow"></div>
<RssiIndicator showDBm={true} rssi={network.rssi} />
</div>
</li>
{/each}
</ul>
{/if}
</div>
<div class="divider my-2"></div>
<div class="flex flex-wrap justify-end gap-2">
<button
class="btn btn-primary inline-flex flex-none items-center"
disabled={scanActive}
onclick={scanNetworks}
>
<Reload class="mr-2 h-5 w-5" /><span>Scan again</span>
</button>
<div class="grow"></div> <div class="grow"></div>
<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"
onclick={() => modals.close()} onclick={() => modals.close()}>
> <Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span> </button>
</button> </div>
</div>
</div>
</div> </div>
</div>
{/if} {/if}
+375 -369
View File
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte';
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals';
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing';
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications';
import DragDropList, { VerticalDropZone, reorder, type DropEvent } from 'svelte-dnd-list' import DragDropList, { VerticalDropZone, reorder, type DropEvent } from 'svelte-dnd-list';
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte';
import { PasswordInput } from '$lib/components/input' import { PasswordInput } from '$lib/components/input';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ScanNetworks from './Scan.svelte' import ScanNetworks from './Scan.svelte';
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte';
import InfoDialog from '$lib/components/InfoDialog.svelte' import InfoDialog from '$lib/components/InfoDialog.svelte';
import type { KnownNetworkItem, WifiSettings, WifiStatus } from '$lib/types/models' import type { KnownNetworkItem, WifiSettings, WifiStatus } from '$lib/types/models';
import { socket } from '$lib/stores' import { socket } from '$lib/stores';
import { api } from '$lib/api' import { api } from '$lib/api';
import { import {
Cancel, Cancel,
Delete, Delete,
@@ -31,9 +31,9 @@
DNS, DNS,
Add, Add,
Scan, Scan,
Edit Edit,
} from '$lib/components/icons' } from '$lib/components/icons';
import StatusItem from '$lib/components/StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte';
let networkEditable: KnownNetworkItem = $state({ let networkEditable: KnownNetworkItem = $state({
ssid: '', ssid: '',
@@ -43,22 +43,22 @@
subnet_mask: undefined, subnet_mask: undefined,
gateway_ip: undefined, gateway_ip: undefined,
dns_ip_1: 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 newNetwork: boolean = $state(true);
let showNetworkEditor: boolean = $state(false) let showNetworkEditor: boolean = $state(false);
let wifiStatus: WifiStatus = $state() let wifiStatus: WifiStatus | null = $state(null);
let wifiSettings: WifiSettings = $state() 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({ let formErrors = $state({
ssid: false, ssid: false,
@@ -66,156 +66,157 @@
gateway_ip: false, gateway_ip: false,
subnet_mask: false, subnet_mask: false,
dns_1: false, dns_1: false,
dns_2: false dns_2: false,
}) });
let formErrorhostname = $state(false) let formErrorhostname = $state(false);
async function getWifiStatus() { 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()) { if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner);
return return;
} }
wifiStatus = result.inner wifiStatus = result.inner;
return wifiStatus return wifiStatus;
} }
async function getWifiSettings() { 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()) { if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner);
return return;
} }
wifiSettings = result.inner wifiSettings = result.inner;
dndNetworkList = wifiSettings.wifi_networks dndNetworkList = wifiSettings.wifi_networks;
return wifiSettings return wifiSettings;
} }
onDestroy(() => socket.off('WiFiSettings')) onDestroy(() => socket.off('WiFiSettings'));
onMount(() => { onMount(() => {
socket.on<WifiSettings>('WiFiSettings', data => { socket.on<WifiSettings>('WiFiSettings', data => {
wifiSettings = data wifiSettings = data;
dndNetworkList = wifiSettings.wifi_networks dndNetworkList = wifiSettings.wifi_networks;
}) });
}) });
async function postWiFiSettings(data: WifiSettings) { 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()) { if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner);
notifications.error('User not authorized.', 3000) notifications.error('User not authorized.', 3000);
return return;
} }
wifiSettings = result.inner wifiSettings = result.inner;
notifications.success('Wi-Fi settings updated.', 3000) notifications.success('Wi-Fi settings updated.', 3000);
} }
function validateHostName() { function validateHostName() {
if (!wifiSettings) return false;
if (wifiSettings.hostname.length < 3 || wifiSettings.hostname.length > 32) { if (wifiSettings.hostname.length < 3 || wifiSettings.hostname.length > 32) {
formErrorhostname = true formErrorhostname = true;
} else { } else {
formErrorhostname = false formErrorhostname = false;
// Update global wifiSettings object // Update global wifiSettings object
wifiSettings.wifi_networks = dndNetworkList wifiSettings.wifi_networks = dndNetworkList;
// Post to REST API // Post to REST API
postWiFiSettings(wifiSettings) postWiFiSettings(wifiSettings);
console.log(wifiSettings) console.log(wifiSettings);
} }
} }
function validateWiFiForm(event: SubmitEvent) { function validateWiFiForm(event: SubmitEvent) {
event.preventDefault() event.preventDefault();
let valid = true let valid = true;
// Validate SSID // Validate SSID
if (networkEditable.ssid.length < 3 || networkEditable.ssid.length > 32) { if (networkEditable.ssid.length < 3 || networkEditable.ssid.length > 32) {
valid = false valid = false;
formErrors.ssid = true formErrors.ssid = true;
} else { } 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) { if (networkEditable.static_ip_config) {
// RegEx for IPv4 // RegEx for IPv4
const regexExp = 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 // Validate gateway IP
if (!regexExp.test(networkEditable.gateway_ip!)) { if (!regexExp.test(networkEditable.gateway_ip!)) {
valid = false valid = false;
formErrors.gateway_ip = true formErrors.gateway_ip = true;
} else { } else {
formErrors.gateway_ip = false formErrors.gateway_ip = false;
} }
// Validate Subnet Mask // Validate Subnet Mask
if (!regexExp.test(networkEditable.subnet_mask!)) { if (!regexExp.test(networkEditable.subnet_mask!)) {
valid = false valid = false;
formErrors.subnet_mask = true formErrors.subnet_mask = true;
} else { } else {
formErrors.subnet_mask = false formErrors.subnet_mask = false;
} }
// Validate local IP // Validate local IP
if (!regexExp.test(networkEditable.local_ip!)) { if (!regexExp.test(networkEditable.local_ip!)) {
valid = false valid = false;
formErrors.local_ip = true formErrors.local_ip = true;
} else { } else {
formErrors.local_ip = false formErrors.local_ip = false;
} }
// Validate DNS 1 // Validate DNS 1
if (!regexExp.test(networkEditable.dns_ip_1!)) { if (!regexExp.test(networkEditable.dns_ip_1!)) {
valid = false valid = false;
formErrors.dns_1 = true formErrors.dns_1 = true;
} else { } else {
formErrors.dns_1 = false formErrors.dns_1 = false;
} }
// Validate DNS 2 // Validate DNS 2
if (!regexExp.test(networkEditable.dns_ip_2!)) { if (!regexExp.test(networkEditable.dns_ip_2!)) {
valid = false valid = false;
formErrors.dns_2 = true formErrors.dns_2 = true;
} else { } else {
formErrors.dns_2 = false formErrors.dns_2 = false;
} }
} else { } else {
formErrors.local_ip = false formErrors.local_ip = false;
formErrors.subnet_mask = false formErrors.subnet_mask = false;
formErrors.gateway_ip = false formErrors.gateway_ip = false;
formErrors.dns_1 = false formErrors.dns_1 = false;
formErrors.dns_2 = false formErrors.dns_2 = false;
} }
// Submit JSON to REST API // Submit JSON to REST API
if (valid) { if (valid) {
if (newNetwork) { if (newNetwork) {
dndNetworkList.push(networkEditable) dndNetworkList.push(networkEditable);
} else { } else {
dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable) dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable);
} }
addNetwork() addNetwork();
dndNetworkList = [...dndNetworkList] //Trigger reactivity dndNetworkList = [...dndNetworkList]; //Trigger reactivity
showNetworkEditor = false showNetworkEditor = false;
} }
} }
function scanForNetworks() { function scanForNetworks() {
modals.open(ScanNetworks, { modals.open(ScanNetworks, {
storeNetwork: (network: string) => { storeNetwork: (network: string) => {
addNetwork() addNetwork();
networkEditable.ssid = network networkEditable.ssid = network;
showNetworkEditor = true showNetworkEditor = true;
modals.close() modals.close();
} },
}) });
} }
function addNetwork() { function addNetwork() {
newNetwork = true newNetwork = true;
networkEditable = { networkEditable = {
ssid: '', ssid: '',
password: '', password: '',
@@ -224,14 +225,14 @@
subnet_mask: undefined, subnet_mask: undefined,
gateway_ip: undefined, gateway_ip: undefined,
dns_ip_1: undefined, dns_ip_1: undefined,
dns_ip_2: undefined dns_ip_2: undefined,
} };
} }
function handleEdit(index: number) { function handleEdit(index: number) {
newNetwork = false newNetwork = false;
showNetworkEditor = true showNetworkEditor = true;
networkEditable = dndNetworkList[index] networkEditable = dndNetworkList[index];
} }
function confirmDelete(index: number) { function confirmDelete(index: number) {
@@ -240,20 +241,20 @@
message: 'Are you sure you want to delete this network?', message: 'Are you sure you want to delete this network?',
labels: { labels: {
cancel: { label: 'Cancel', icon: Cancel }, cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'Delete', icon: Delete } confirm: { label: 'Delete', icon: Delete },
}, },
onConfirm: () => { onConfirm: () => {
// Check if network is currently been edited and delete as well // Check if network is currently been edited and delete as well
if (dndNetworkList[index].ssid === networkEditable.ssid) { if (dndNetworkList[index].ssid === networkEditable.ssid) {
addNetwork() addNetwork();
} }
// Remove network from array // Remove network from array
dndNetworkList.splice(index, 1) dndNetworkList.splice(index, 1);
dndNetworkList = [...dndNetworkList] //Trigger reactivity dndNetworkList = [...dndNetworkList]; //Trigger reactivity
showNetworkEditor = false showNetworkEditor = false;
modals.close() modals.close();
} },
}) });
} }
function checkNetworkList() { function checkNetworkList() {
@@ -263,21 +264,21 @@
message: message:
'You have reached the maximum number of networks. Please delete one to add another.', 'You have reached the maximum number of networks. Please delete one to add another.',
dismiss: { label: 'OK', icon: Check }, dismiss: { label: 'OK', icon: Check },
onDismiss: () => modals.close() onDismiss: () => modals.close(),
}) });
return false return false;
} else { } else {
return true return true;
} }
} }
function onDrop({ detail: { from, to } }: CustomEvent<DropEvent>) { function onDrop({ detail: { from, to } }: CustomEvent<DropEvent>) {
if (!to || from === to) { if (!to || from === to) {
return return;
} }
dndNetworkList = reorder(dndNetworkList, from.index, to.index) dndNetworkList = reorder(dndNetworkList, from.index, to.index);
console.log(dndNetworkList) console.log(dndNetworkList);
} }
</script> </script>
@@ -291,53 +292,55 @@
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
{#await getWifiStatus()} {#await getWifiStatus()}
<Spinner /> <Spinner />
{:then nothing} {:then}
<div {#if wifiStatus}
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<StatusItem
icon={AP}
title="Status"
variant={wifiStatus.status === 3 ? 'success' : 'error'}
description={wifiStatus.status === 3 ? 'Connected' : 'Inactive'} />
{#if wifiStatus.status === 3}
<StatusItem icon={SSID} title="SSID" description={wifiStatus.ssid} />
<StatusItem icon={Home} title="IP Address" description={wifiStatus.local_ip} />
<StatusItem icon={WiFi} title="RSSI" description={`${wifiStatus.rssi} dBm`}>
<button
class="btn btn-circle btn-ghost btn-sm modal-button"
onclick={() => {
showWifiDetails = !showWifiDetails
}}>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {(
showWifiDetails
) ?
'rotate-180'
: ''}" />
</button>
</StatusItem>
{/if}
</div>
<!-- Folds open -->
{#if showWifiDetails}
<div <div
class="flex w-full flex-col space-y-1 pt-1" class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}> transition:slide|local={{ duration: 300, easing: cubicOut }}>
<StatusItem icon={MAC} title="MAC Address" description={wifiStatus.mac_address} /> <StatusItem
icon={AP}
title="Status"
variant={wifiStatus.status === 3 ? 'success' : 'error'}
description={wifiStatus.status === 3 ? 'Connected' : 'Inactive'} />
<StatusItem icon={Channel} title="Channel" description={wifiStatus.channel} /> {#if wifiStatus.status === 3}
<StatusItem icon={SSID} title="SSID" description={wifiStatus.ssid} />
<StatusItem icon={Gateway} title="Gateway IP" description={wifiStatus.gateway_ip} /> <StatusItem icon={Home} title="IP Address" description={wifiStatus.local_ip} />
<StatusItem icon={Subnet} title="Subnet Mask" description={wifiStatus.subnet_mask} /> <StatusItem icon={WiFi} title="RSSI" description={`${wifiStatus.rssi} dBm`}>
<button
<StatusItem icon={DNS} title="DNS" description={wifiStatus.dns_ip_1} /> class="btn btn-circle btn-ghost btn-sm modal-button"
onclick={() => {
showWifiDetails = !showWifiDetails;
}}>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {(
showWifiDetails
) ?
'rotate-180'
: ''}" />
</button>
</StatusItem>
{/if}
</div> </div>
<!-- Folds open -->
{#if showWifiDetails}
<div
class="flex w-full flex-col space-y-1 pt-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<StatusItem icon={MAC} title="MAC Address" description={wifiStatus.mac_address} />
<StatusItem icon={Channel} title="Channel" description={wifiStatus.channel} />
<StatusItem icon={Gateway} title="Gateway IP" description={wifiStatus.gateway_ip} />
<StatusItem icon={Subnet} title="Subnet Mask" description={wifiStatus.subnet_mask} />
<StatusItem icon={DNS} title="DNS" description={wifiStatus.dns_ip_1} />
</div>
{/if}
{/if} {/if}
{/await} {/await}
</div> </div>
@@ -349,254 +352,257 @@
</div> </div>
{#await getWifiSettings()} {#await getWifiSettings()}
<Spinner /> <Spinner />
{:then nothing} {:then}
<div class="relative w-full overflow-visible"> {#if wifiSettings}
<button <div class="relative w-full overflow-visible">
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-16" <button
onclick={() => { class="btn btn-primary text-primary-content btn-md absolute -top-14 right-16"
if (checkNetworkList()) { onclick={() => {
addNetwork() if (checkNetworkList()) {
showNetworkEditor = true addNetwork();
} showNetworkEditor = true;
}}> }
<Add class="h-6 w-6" /></button> }}>
<button <Add class="h-6 w-6" /></button>
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-0" <button
onclick={() => { class="btn btn-primary text-primary-content btn-md absolute -top-14 right-0"
if (checkNetworkList()) { onclick={() => {
scanForNetworks() if (checkNetworkList()) {
showNetworkEditor = true scanForNetworks();
} showNetworkEditor = true;
}}> }
<Scan class="h-6 w-6" /></button> }}>
<Scan class="h-6 w-6" /></button>
<div <div
class="overflow-x-auto space-y-1" class="overflow-x-auto space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}> transition:slide|local={{ duration: 300, easing: cubicOut }}>
<DragDropList <DragDropList
id="networks" id="networks"
type={VerticalDropZone} type={VerticalDropZone}
itemSize={60} itemSize={60}
itemCount={dndNetworkList.length} itemCount={dndNetworkList.length}
on:drop={onDrop}> on:drop={onDrop}>
{#snippet children({ index })} {#snippet children({ index }: { index: number })}
<StatusItem icon={Router} title={dndNetworkList[index].ssid}> <StatusItem icon={Router} title={dndNetworkList[index].ssid}>
<div class="space-x-0 px-0 mx-0"> <div class="space-x-0 px-0 mx-0">
<button <button
class="btn btn-ghost btn-sm" class="btn btn-ghost btn-sm"
onclick={() => { onclick={() => {
handleEdit(index) handleEdit(index);
}}> }}>
<Edit class="h-6 w-6" /></button> <Edit class="h-6 w-6" /></button>
<button <button
class="btn btn-ghost btn-sm" class="btn btn-ghost btn-sm"
onclick={() => { onclick={() => {
confirmDelete(index) confirmDelete(index);
}}> }}>
<Delete class="text-error h-6 w-6" /> <Delete class="text-error h-6 w-6" />
</button> </button>
</div> </div>
</StatusItem> </StatusItem>
{/snippet} {/snippet}
</DragDropList> </DragDropList>
</div>
</div>
<div class="divider mb-0"></div>
<div
class="flex flex-col gap-2 p-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<form class="" onsubmit={validateWiFiForm} novalidate bind:this={formField}>
<div class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2">
<div>
<label class="label" for="channel">
<span class="label-text text-md">Host Name</span>
</label>
<input
type="text"
min="1"
max="32"
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrorhostname
) ?
'border-error border-2'
: ''}"
bind:value={wifiSettings.hostname}
id="channel"
required />
<label class="label" for="channel">
<span class="label-text-alt text-error {formErrorhostname ? '' : 'hidden'}"
>Host name must be between 2 and 32 characters long</span>
</label>
</div>
<label class="label inline-flex cursor-pointer content-end justify-start gap-4">
<input
type="checkbox"
bind:checked={wifiSettings.priority_RSSI}
class="checkbox checkbox-primary sm:-mb-5" />
<span class="sm:-mb-5">Connect to strongest WiFi</span>
</label>
</div> </div>
</div>
{#if showNetworkEditor} <div class="divider mb-0"></div>
<div class="divider my-0"></div> <div
<div class="flex flex-col gap-2 p-0"
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2" transition:slide|local={{ duration: 300, easing: cubicOut }}>
transition:slide|local={{ duration: 300, easing: cubicOut }}> <form class="" onsubmit={validateWiFiForm} novalidate bind:this={formField}>
<div class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2">
<div> <div>
<label class="label" for="ssid"> <label class="label" for="channel">
<span class="label-text text-md">SSID</span> <span class="label-text text-md">Host Name</span>
</label> </label>
<input <input
type="text" type="text"
min="1"
max="32"
class="input input-bordered invalid:border-error w-full invalid:border-2 {( class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrors.ssid formErrorhostname
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
bind:value={networkEditable.ssid} bind:value={wifiSettings.hostname}
id="ssid" id="channel"
min="2"
max="32"
required /> required />
<label class="label" for="ssid"> <label class="label" for="channel">
<span class="label-text-alt text-error {formErrors.ssid ? '' : 'hidden'}" <span class="label-text-alt text-error {formErrorhostname ? '' : 'hidden'}"
>SSID must be between 3 and 32 characters long</span> >Host name must be between 2 and 32 characters long</span>
</label> </label>
</div> </div>
<div> <label class="label inline-flex cursor-pointer content-end justify-start gap-4">
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<PasswordInput bind:value={networkEditable.password} id="pwd" />
</div>
<label
class="label inline-flex cursor-pointer content-end justify-start gap-4 mt-2 sm:mb-4">
<input <input
type="checkbox" type="checkbox"
bind:checked={static_ip_config} bind:checked={wifiSettings.priority_RSSI}
class="checkbox checkbox-primary sm:-mb-5" /> class="checkbox checkbox-primary sm:-mb-5" />
<span class="sm:-mb-5">Static IP Config?</span> <span class="sm:-mb-5">Connect to strongest WiFi</span>
</label> </label>
</div> </div>
{#if static_ip_config}
{#if showNetworkEditor}
<div class="divider my-0"></div>
<div <div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2" class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }}> transition:slide|local={{ duration: 300, easing: cubicOut }}>
<div> <div>
<label class="label" for="localIP"> <label class="label" for="ssid">
<span class="label-text text-md">Local IP</span> <span class="label-text text-md">SSID</span>
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full {formErrors.local_ip ? class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrors.ssid
) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" bind:value={networkEditable.ssid}
maxlength="15" id="ssid"
size="15" min="2"
bind:value={networkEditable.local_ip} max="32"
id="localIP"
required /> required />
<label class="label" for="localIP"> <label class="label" for="ssid">
<span class="label-text-alt text-error {formErrors.local_ip ? '' : 'hidden'}" <span class="label-text-alt text-error {formErrors.ssid ? '' : 'hidden'}"
>Must be a valid IPv4 address</span> >SSID must be between 3 and 32 characters long</span>
</label>
</div>
<div>
<label class="label" for="gateway">
<span class="label-text text-md">Gateway IP</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.gateway_ip ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.gateway_ip}
required />
<label class="label" for="gateway">
<span class="label-text-alt text-error {formErrors.gateway_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
</label> </label>
</div> </div>
<div> <div>
<label class="label" for="subnet"> <label class="label" for="pwd">
<span class="label-text text-md">Subnet Mask</span> <span class="label-text text-md">Password</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.subnet_mask ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.subnet_mask}
required />
<label class="label" for="subnet">
<span
class="label-text-alt text-error {formErrors.subnet_mask ? '' : 'hidden'}">
Must be a valid IPv4 address
</span>
</label> </label>
<PasswordInput bind:value={networkEditable.password} id="pwd" />
</div> </div>
<div> <label
<label class="label" for="gateway"> class="label inline-flex cursor-pointer content-end justify-start gap-4 mt-2 sm:mb-4">
<span class="label-text text-md">DNS 1</span>
</label>
<input <input
type="text" type="checkbox"
class="input input-bordered w-full {formErrors.dns_1 ? 'border-error border-2' bind:checked={static_ip_config}
: ''}" class="checkbox checkbox-primary sm:-mb-5" />
minlength="7" <span class="sm:-mb-5">Static IP Config?</span>
maxlength="15" </label>
size="15"
bind:value={networkEditable.dns_ip_1}
required />
<label class="label" for="gateway">
<span class="label-text-alt text-error {formErrors.dns_1 ? '' : 'hidden'}">
Must be a valid IPv4 address
</span>
</label>
</div>
<div>
<label class="label" for="subnet">
<span class="label-text text-md">DNS 2</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_2 ? 'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_2}
required />
<label class="label" for="subnet">
<span class="label-text-alt text-error {formErrors.dns_2 ? '' : 'hidden'}">
Must be a valid IPv4 address
</span>
</label>
</div>
</div> </div>
{/if} {#if static_ip_config}
{/if} <div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
<div>
<label class="label" for="localIP">
<span class="label-text text-md">Local IP</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.local_ip ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.local_ip}
id="localIP"
required />
<label class="label" for="localIP">
<span class="label-text-alt text-error {formErrors.local_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
</label>
</div>
<div class="divider mb-2 mt-0"></div> <div>
<div class="mx-4 flex flex-wrap justify-end gap-2"> <label class="label" for="gateway">
<button class="btn btn-primary" type="submit" disabled={!showNetworkEditor}> <span class="label-text text-md">Gateway IP</span>
{newNetwork ? 'Add Network' : 'Update Network'} </label>
</button> <input
<button class="btn btn-primary" type="button" onclick={validateHostName}> type="text"
Apply Settings class="input input-bordered w-full {formErrors.gateway_ip ?
</button> 'border-error border-2'
</div> : ''}"
</form> minlength="7"
</div> maxlength="15"
size="15"
bind:value={networkEditable.gateway_ip}
required />
<label class="label" for="gateway">
<span
class="label-text-alt text-error {formErrors.gateway_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span>
</label>
</div>
<div>
<label class="label" for="subnet">
<span class="label-text text-md">Subnet Mask</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.subnet_mask ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.subnet_mask}
required />
<label class="label" for="subnet">
<span
class="label-text-alt text-error {formErrors.subnet_mask ? '' : 'hidden'}">
Must be a valid IPv4 address
</span>
</label>
</div>
<div>
<label class="label" for="gateway">
<span class="label-text text-md">DNS 1</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_1 ? 'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_1}
required />
<label class="label" for="gateway">
<span class="label-text-alt text-error {formErrors.dns_1 ? '' : 'hidden'}">
Must be a valid IPv4 address
</span>
</label>
</div>
<div>
<label class="label" for="subnet">
<span class="label-text text-md">DNS 2</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_2 ? 'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_2}
required />
<label class="label" for="subnet">
<span class="label-text-alt text-error {formErrors.dns_2 ? '' : 'hidden'}">
Must be a valid IPv4 address
</span>
</label>
</div>
</div>
{/if}
{/if}
<div class="divider mb-2 mt-0"></div>
<div class="mx-4 flex flex-wrap justify-end gap-2">
<button class="btn btn-primary" type="submit" disabled={!showNetworkEditor}>
{newNetwork ? 'Add Network' : 'Update Network'}
</button>
<button class="btn btn-primary" type="button" onclick={validateHostName}>
Apply Settings
</button>
</div>
</form>
</div>
{/if}
{/await} {/await}
</div> </div>
</SettingsCard> </SettingsCard>