diff --git a/electron/package-lock.json b/electron/package-lock.json index e3da1829..35412e97 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -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", diff --git a/electron/src/App.tsx b/electron/src/App.tsx index 1786dd3e..4dac8561 100644 --- a/electron/src/App.tsx +++ b/electron/src/App.tsx @@ -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 ; + return ( + <> + + + + ); } // enable immer MapSet plugin diff --git a/electron/src/machines/laser/laser1/Laser1Page.tsx b/electron/src/machines/laser/laser1/Laser1Page.tsx index 6d54c98d..849b3ebc 100644 --- a/electron/src/machines/laser/laser1/Laser1Page.tsx +++ b/electron/src/machines/laser/laser1/Laser1Page.tsx @@ -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", diff --git a/electron/src/machines/laser/laser1/laser1Namespace.ts b/electron/src/machines/laser/laser1/laser1Namespace.ts index af757d9b..234290e3 100644 --- a/electron/src/machines/laser/laser1/laser1Namespace.ts +++ b/electron/src/machines/laser/laser1/laser1Namespace.ts @@ -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 => diameter: diameter, x_diameter, y_diameter, - roundness: roundness, + roundness, }; }); diff --git a/electron/src/setup/GlobalLaserToastManager.tsx b/electron/src/setup/GlobalLaserToastManager.tsx new file mode 100644 index 00000000..b5a888d8 --- /dev/null +++ b/electron/src/setup/GlobalLaserToastManager.tsx @@ -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(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 ? ( + + ) : 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(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( +
+
+
+ + + Warning + +
+ +
+ +

+ Laser diameter is out of tolerance.
+ Please check the filament immediately. +

+
, + { + id: toastId, + duration: Infinity, + position: "top-center", + style: { + background: "transparent", + padding: 0, + boxShadow: "none", + border: "none", + }, + }, + ); + } + if (inTolerance) { + lastToastTs.current = null; + } + }, [state]); + + return null; +} diff --git a/machines/src/laser/act.rs b/machines/src/laser/act.rs index ea71160b..501dafc2 100644 --- a/machines/src/laser/act.rs +++ b/machines/src/laser/act.rs @@ -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(); diff --git a/machines/src/laser/api.rs b/machines/src/laser/api.rs index 2cc9abc1..923c8b32 100644 --- a/machines/src/laser/api.rs +++ b/machines/src/laser/api.rs @@ -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 { diff --git a/machines/src/laser/mod.rs b/machines/src/laser/mod.rs index a7c27a5b..26ffc0a0 100644 --- a/machines/src/laser/mod.rs +++ b/machines/src/laser/mod.rs @@ -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, roundness: Option, + 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::(), - lower_tolerance: self.laser_target.lower_tolerance.get::(), + higher_tolerance: self.higher_tolerance.get::(), + lower_tolerance: self.lower_tolerance.get::(), target_diameter: self.laser_target.diameter.get::(), + in_tolerance: self.in_tolerance, }; StateEvent { @@ -129,23 +136,28 @@ impl LaserMachine { higher_tolerance: self.laser_target.higher_tolerance.get::(), lower_tolerance: self.laser_target.lower_tolerance.get::(), target_diameter: self.laser_target.diameter.get::(), + 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::(higher_tolerance); + self.higher_tolerance = Length::new::(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::(lower_tolerance); + self.lower_tolerance = Length::new::(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::(target_diameter); self.laser_target.diameter = Length::new::(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::() < 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::( @@ -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; + } } } diff --git a/machines/src/laser/new.rs b/machines/src/laser/new.rs index 7dc94765..f6ee3add 100644 --- a/machines/src/laser/new.rs +++ b/machines/src/laser/new.rs @@ -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::(0.05), + higher_tolerance: Length::new::(0.05), + in_tolerance: true, + did_change_state: true, }; Ok(laser_machine) diff --git a/server/src/machines/laser/act.rs b/server/src/machines/laser/act.rs new file mode 100644 index 00000000..70c8312a --- /dev/null +++ b/server/src/machines/laser/act.rs @@ -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; + } + } +}