Merge pull request #849 from qitechgmbh/769-add-an-alert-or-warning-in-the-frontend-for-filament-out-of-spec

769 add an alert or warning in the frontend for filament out of spec
This commit is contained in:
Robin Krämer authored and GitHub committed 2025-12-18 12:00:52 +01:00
commit 59cf2cea2a
10 files changed
+278 -11

No files matched your search

+11
View File
@@ -7878,6 +7878,17 @@
"node": ">=0.10.0"
}
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+7 -1
View File
@@ -9,6 +9,7 @@ import { RouterProvider } from "@tanstack/react-router";
import { Toaster } from "./components/ui/sonner";
import { enableMapSet } from "immer";
import { useGlobalLogStreaming } from "./hooks/useGlobalLogStreaming";
import { GlobalLaserToastManager } from "./setup/GlobalLaserToastManager";
export default function App() {
const { i18n } = useTranslation();
@@ -21,7 +22,12 @@ export default function App() {
updateAppLanguage(i18n);
}, [i18n]);
return <RouterProvider router={router} />;
return (
<>
<GlobalLaserToastManager />
<RouterProvider router={router} />
</>
);
}
// enable immer MapSet plugin
@@ -21,6 +21,12 @@ export function Laser1Page() {
icon: "lu:ChartSpline",
},
// {
// link: "settings",
// activeLink: "settings",
// title: "Config",
// icon: "lu:Settings",
// },
// {
// link: "manual",
// activeLink: "manual",
// title: "Manual",
@@ -42,6 +42,7 @@ export const stateEventDataSchema = z.object({
higher_tolerance: z.number(),
lower_tolerance: z.number(),
target_diameter: z.number(),
in_tolerance: z.boolean(),
}),
});
@@ -68,24 +69,25 @@ export type Laser1NamespaceStore = {
// Constants for time durations
const TWENTY_MILLISECOND = 20;
const ONE_SECOND = 1000;
const HALF_SECOND = 500;
const FIVE_SECOND = 5 * ONE_SECOND;
const ONE_HOUR = 60 * 60 * ONE_SECOND;
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
HALF_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: x_diameter, insert: addXDiameter } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries(TWENTY_MILLISECOND, HALF_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: y_diameter, insert: addYDiameter } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries(TWENTY_MILLISECOND, HALF_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: roundness, insert: addRoundness } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
HALF_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
@@ -102,7 +104,7 @@ export const createLaser1NamespaceStore = (): StoreApi<Laser1NamespaceStore> =>
diameter: diameter,
x_diameter,
y_diameter,
roundness: roundness,
roundness,
};
});
@@ -0,0 +1,160 @@
import React, { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useMainNamespace } from "@/client/mainNamespace";
import { useLaser1Namespace } from "@/machines/laser/laser1/laser1Namespace";
import { laser1 } from "@/machines/properties";
import type { MachineIdentificationUnique } from "@/machines/types";
import { Icon } from "@/components/Icon";
/**
* Global manager that toasts laser StateEvent changes.
* If machineIdentification is omitted it will attempt to discover the laser via the main namespace.
*
*/
export function GlobalLaserToastManager({
machineIdentification,
}: {
machineIdentification?: MachineIdentificationUnique;
}) {
const main = useMainNamespace();
const [discovered, setDiscovered] =
useState<MachineIdentificationUnique | null>(null);
// Prefer explicit prop, otherwise use discovered id
const effectiveId = machineIdentification ?? discovered;
// Attempt to discover the single laser machine from the main namespace
useEffect(() => {
if (machineIdentification) return;
const machinesEvent = (main as any)?.machines;
if (!machinesEvent) {
setDiscovered(null);
return;
}
// Defensive shapes: try common structures
let list: any[] = [];
if (Array.isArray(machinesEvent?.data?.machines))
list = machinesEvent.data.machines;
else if (Array.isArray(machinesEvent?.data)) list = machinesEvent.data;
else if (Array.isArray(machinesEvent?.machines))
list = machinesEvent.machines;
else if (machinesEvent && typeof machinesEvent === "object") {
list = Object.values(machinesEvent).filter(
(v) => v && typeof v === "object",
);
}
const match = list.find((entry) => {
const mi =
entry?.machine_identification ||
entry?.machine_identification_unique?.machine_identification ||
entry?.device_machine_identification?.machine_identification;
if (!mi) return false;
return (
mi.vendor === (laser1.machine_identification as any).vendor &&
mi.machine === (laser1.machine_identification as any).machine
);
});
if (match) {
const serial =
match?.serial ??
match?.machine_identification_unique?.serial ??
match?.device_machine_identification?.machine_identification_unique
?.serial ??
null;
if (serial != null && !Number.isNaN(Number(serial))) {
setDiscovered({
machine_identification: laser1.machine_identification,
serial: Number(serial),
});
return;
}
}
setDiscovered(null);
}, [main, machineIdentification]);
return effectiveId ? (
<LaserToastWatcher machineIdentification={effectiveId} />
) : null;
}
/**
* Watches StateEvent for the laser namespace and shows toasts for non-default states.
* Component is non-visual.
*/
function LaserToastWatcher({
machineIdentification,
}: {
machineIdentification: MachineIdentificationUnique;
}) {
const { state } = useLaser1Namespace(machineIdentification);
// Deduplicate toasts by event timestamp
const lastToastTs = useRef<number | string | null>(null);
useEffect(() => {
if (!state) return;
const eventTs = (state as any)?.ts ?? null;
const inTolerance = (state as any)?.data?.laser_state.in_tolerance;
const isDefault = !!(state as any)?.data?.is_default_state;
const toastId = eventTs?.toString() ?? String(Date.now());
if (isDefault) return;
// Only show toast if laser is out of tolerance and not already shown for this timestamp
if (!inTolerance && lastToastTs.current !== eventTs) {
lastToastTs.current = eventTs;
// Sonner toast call
toast(
<div className="flex w-100 flex-col gap-3 rounded-xl border border-red-400 bg-red-600 p-4 text-white shadow-xl backdrop-blur-sm transition-all duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon
name="lu:TriangleAlert"
className="h-5 w-5 text-yellow-200"
/>
<strong className="text-lg font-semibold tracking-wide">
Warning
</strong>
</div>
<button
className="rounded-md p-1 text-2xl font-bold text-white/80 hover:bg-red-500 hover:text-white focus:ring-2 focus:ring-white/30 focus:outline-none"
onClick={() => {
toast.dismiss(toastId);
lastToastTs.current = null;
}}
aria-label="Close"
>
×
</button>
</div>
<p className="text-base leading-snug text-red-50">
Laser diameter is <strong>out of tolerance</strong>.<br />
Please check the filament immediately.
</p>
</div>,
{
id: toastId,
duration: Infinity,
position: "top-center",
style: {
background: "transparent",
padding: 0,
boxShadow: "none",
border: "none",
},
},
);
}
if (inTolerance) {
lastToastTs.current = null;
}
}, [state]);
return null;
}
+4
View File
@@ -29,6 +29,10 @@ impl MachineAct for LaserMachine {
};
self.update();
if self.did_change_state {
self.emit_state();
}
// more than 33ms have passed since last emit (30 "fps" target)
if now.duration_since(self.last_measurement_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.emit_live_values();
+2
View File
@@ -49,6 +49,8 @@ pub struct LaserState {
pub lower_tolerance: f64,
/// target diameter in mm
pub target_diameter: f64,
/// tolerance bool
pub in_tolerance: bool,
}
pub enum LaserEvents {
+44 -5
View File
@@ -11,10 +11,10 @@ use smol::{
lock::RwLock,
};
use socketioxide::extract::SocketRef;
use units::Length;
use crate::AsyncThreadMessage;
use std::{sync::Arc, time::Instant};
use units::f64::*;
use units::length::millimeter;
pub mod act;
@@ -41,12 +41,18 @@ pub struct LaserMachine {
y_diameter: Option<Length>,
roundness: Option<f64>,
target_diameter: Length,
higher_tolerance: Length,
lower_tolerance: Length,
in_tolerance: bool,
//laser target configuration
laser_target: LaserTarget,
/// Will be initialized as false and set to true by emit_state
/// This way we can signal to the client that the first state emission is a default state
emitted_default_state: bool,
did_change_state: bool,
}
impl Machine for LaserMachine {
@@ -111,9 +117,10 @@ impl LaserMachine {
pub fn build_state_event(&self) -> StateEvent {
let laser = LaserState {
higher_tolerance: self.laser_target.higher_tolerance.get::<millimeter>(),
lower_tolerance: self.laser_target.lower_tolerance.get::<millimeter>(),
higher_tolerance: self.higher_tolerance.get::<millimeter>(),
lower_tolerance: self.lower_tolerance.get::<millimeter>(),
target_diameter: self.laser_target.diameter.get::<millimeter>(),
in_tolerance: self.in_tolerance,
};
StateEvent {
@@ -129,23 +136,28 @@ impl LaserMachine {
higher_tolerance: self.laser_target.higher_tolerance.get::<millimeter>(),
lower_tolerance: self.laser_target.lower_tolerance.get::<millimeter>(),
target_diameter: self.laser_target.diameter.get::<millimeter>(),
in_tolerance: self.in_tolerance,
},
};
self.namespace.emit(LaserEvents::State(state.build()));
self.did_change_state = false;
}
pub fn set_higher_tolerance(&mut self, higher_tolerance: f64) {
self.laser_target.higher_tolerance = Length::new::<millimeter>(higher_tolerance);
self.higher_tolerance = Length::new::<millimeter>(higher_tolerance);
self.laser_target.higher_tolerance = self.higher_tolerance;
self.emit_state();
}
pub fn set_lower_tolerance(&mut self, lower_tolerance: f64) {
self.laser_target.lower_tolerance = Length::new::<millimeter>(lower_tolerance);
self.lower_tolerance = Length::new::<millimeter>(lower_tolerance);
self.laser_target.lower_tolerance = self.lower_tolerance;
self.emit_state();
}
pub fn set_target_diameter(&mut self, target_diameter: f64) {
self.target_diameter = Length::new::<millimeter>(target_diameter);
self.laser_target.diameter = Length::new::<millimeter>(target_diameter);
self.emit_state();
}
@@ -172,6 +184,29 @@ impl LaserMachine {
}
}
///
/// Calculates if the current diameter is inside of the tolerance
///
fn calculate_in_tolerance(&mut self) -> bool {
let diameter_epsilon: f64 = 0.0001; // 0.0001 mm
// early return true if the diameter is 0 to prevent warning happening before start
if self.diameter.get::<millimeter>() < diameter_epsilon {
self.in_tolerance = true;
return true;
}
let top = self.target_diameter + self.higher_tolerance;
let bottom = self.target_diameter - self.lower_tolerance;
if self.diameter > top || self.diameter < bottom {
self.in_tolerance = false;
} else {
self.in_tolerance = true;
}
self.in_tolerance
}
pub fn update(&mut self) {
let laser_data = smol::block_on(async { self.laser.read().await.get_data().await });
self.diameter = Length::new::<millimeter>(
@@ -192,6 +227,10 @@ impl LaserMachine {
.cloned();
self.roundness = self.calculate_roundness();
if self.in_tolerance != self.calculate_in_tolerance() {
self.did_change_state = true;
}
}
}
+5
View File
@@ -47,9 +47,14 @@ impl MachineNewTrait for LaserMachine {
laser_target,
emitted_default_state: false,
diameter: Length::ZERO,
target_diameter: Length::ZERO,
x_diameter: None,
y_diameter: None,
roundness: None,
lower_tolerance: Length::new::<millimeter>(0.05),
higher_tolerance: Length::new::<millimeter>(0.05),
in_tolerance: true,
did_change_state: true,
};
Ok(laser_machine)
+32
View File
@@ -0,0 +1,32 @@
use super::LaserMachine;
use control_core::machines::new::MachineAct;
use std::time::{Duration, Instant};
/// Implements the `MachineAct` trait for the `LaserMachine`.
///
/// # Parameters
/// - `_now_ts`: The current timestamp of type `Instant`.
///
/// # Returns
/// A pinned `Future` that resolves to `()` and is `Send`-safe. The future encapsulates the asynchronous behavior of the `act` method.
///
/// # Description
/// This method is called to perform periodic actions for the `LaserMachine`. Specifically:
/// - It checks if the time elapsed since the last measurement emission exceeds 20 milliseconds.
/// - If the condition is met, it asynchronously emits live values at 60 FPS.
///
/// The method ensures that the diameter value is updated approximately 60 times per second.
///
impl MachineAct for LaserMachine {
fn act(&mut self, now: Instant) {
self.update();
if self.did_change_state {
self.emit_state();
}
// more than 33ms have passed since last emit (30 "fps" target)
if now.duration_since(self.last_measurement_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.emit_live_values();
self.last_measurement_emit = now;
}
}
}