Reason
@@ -2162,6 +2280,9 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
{e.reasonLabel}
{e.reasonCode}
+ {e.reasonText && e.reasonText !== e.reasonLabel ? (
+ {e.reasonText}
+ ) : null}
{e.workOrderId ?? "—"}
diff --git a/components/layout/AppShell.tsx b/components/layout/AppShell.tsx
index a08f882..06e5541 100644
--- a/components/layout/AppShell.tsx
+++ b/components/layout/AppShell.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { Menu } from "lucide-react";
import { Sidebar } from "@/components/layout/Sidebar";
+import { RouteAudit } from "@/components/perf/RouteAudit";
import { UtilityControls } from "@/components/layout/UtilityControls";
import { useI18n } from "@/lib/i18n/useI18n";
@@ -31,6 +32,7 @@ export function AppShell({
return (
+
diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx
index 04960d8..f7404d6 100644
--- a/components/layout/Sidebar.tsx
+++ b/components/layout/Sidebar.tsx
@@ -2,12 +2,14 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
-import { useEffect, useMemo, useState } from "react";
-import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react";
+import { useEffect, useMemo, useState, useTransition } from "react";
+import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
+const PERF_ENABLED = process.env.NEXT_PUBLIC_PERF_LOGS === "1";
+const NAV_MARK_KEY = "perf_nav_start";
type NavItem = {
href: string;
@@ -38,6 +40,8 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
const router = useRouter();
const { t } = useI18n();
const { screenlessMode } = useScreenlessMode();
+ const [isPending, startTransition] = useTransition();
+ const [pendingHref, setPendingHref] = useState
(null);
const [me, setMe] = useState<{
user?: { name?: string | null; email?: string | null };
org?: { name?: string | null };
@@ -93,13 +97,33 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
}
}, [screenlessMode, pathname, router]);
-
-
useEffect(() => {
- visibleItems.forEach((it) => {
- router.prefetch(it.href);
- });
- }, [router, visibleItems]);
+ if (!pendingHref) return;
+ if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
+ setPendingHref(null);
+ } else if (!isPending) {
+ setPendingHref(null);
+ }
+ }, [pathname, pendingHref, isPending]);
+
+ const markNavStart = (href: string) => {
+ if (!PERF_ENABLED) return;
+ try {
+ sessionStorage.setItem(
+ NAV_MARK_KEY,
+ JSON.stringify({
+ href,
+ from: pathname,
+ ts: Date.now(),
+ })
+ );
+ } catch {
+ // ignore
+ }
+ };
+
+ // Prefetch disabled: Next.js 16 has RSC prefetch bugs that can cause 404 on
+ // client-side navigation (see e.g. vercel/next.js#85374). Use fresh fetch on click.
const shellClass = [
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
@@ -126,23 +150,53 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
{visibleItems.map((it) => {
- const active = pathname === it.href || pathname.startsWith(it.href + "/");
+ const isCurrent = pathname === it.href;
+ const active = isCurrent || pathname.startsWith(it.href + "/");
+ const isPendingItem = isPending && pendingHref === it.href;
+ const navLocked = isPending;
const Icon = it.icon;
return (
router.prefetch(it.href)}
- onClick={onNavigate}
+ prefetch={false}
+ aria-disabled={navLocked}
+ onClick={(event) => {
+ if (
+ navLocked ||
+ event.defaultPrevented ||
+ event.button !== 0 ||
+ event.metaKey ||
+ event.altKey ||
+ event.ctrlKey ||
+ event.shiftKey
+ ) {
+ return;
+ }
+ if (isCurrent) {
+ onNavigate?.();
+ return;
+ }
+ event.preventDefault();
+ markNavStart(it.href);
+ setPendingHref(it.href);
+ startTransition(() => {
+ router.push(it.href);
+ });
+ onNavigate?.();
+ }}
className={[
"flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition",
active
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/20"
: "text-zinc-300 hover:bg-white/5 hover:text-white",
+ navLocked ? "pointer-events-none" : "",
+ navLocked && !isPendingItem ? "opacity-60" : "",
].join(" ")}
>
{t(it.labelKey)}
+ {isPendingItem ? : null}
);
})}
diff --git a/components/perf/RouteAudit.tsx b/components/perf/RouteAudit.tsx
new file mode 100644
index 0000000..117589d
--- /dev/null
+++ b/components/perf/RouteAudit.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useEffect } from "react";
+import { usePathname, useSearchParams } from "next/navigation";
+
+const PERF_ENABLED = process.env.NEXT_PUBLIC_PERF_LOGS === "1";
+const STORAGE_KEY = "perf_nav_start";
+
+type NavMark = {
+ href?: string;
+ from?: string;
+ ts: number;
+};
+
+function readNavMark(): NavMark | null {
+ try {
+ const raw = sessionStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as NavMark;
+ if (!parsed || typeof parsed.ts !== "number") return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+function clearNavMark() {
+ try {
+ sessionStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // ignore
+ }
+}
+
+export function RouteAudit() {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ useEffect(() => {
+ if (!PERF_ENABLED) return;
+
+ const params = searchParams?.toString();
+ const to = params ? `${pathname}?${params}` : pathname;
+ const mark = readNavMark();
+ if (!mark) return;
+
+ const durationMs = Date.now() - mark.ts;
+ const payload = {
+ from: mark.from ?? "",
+ to,
+ href: mark.href ?? "",
+ durationMs,
+ startedAt: mark.ts,
+ };
+
+ console.info("[perf.nav]", payload);
+ fetch("/api/debug/perf", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ event: "nav", data: payload }),
+ keepalive: true,
+ }).catch(() => {});
+
+ clearNavMark();
+ }, [pathname, searchParams]);
+
+ return null;
+}
diff --git a/dictionary_en_es.md b/dictionary_en_es.md
index 1755536..362ad1d 100644
--- a/dictionary_en_es.md
+++ b/dictionary_en_es.md
@@ -333,6 +333,20 @@ Main KPIs remain English in ES-MX (OEE, KPI, SKU, AVAILABILITY, PERFORMANCE, QUA
| settings.shiftCompLabel | Shift change compensation (min) | Compensación por cambio de turno (min) |
| settings.lunchBreakLabel | Lunch break (min) | Comida (min) |
| settings.shift.defaultName | Shift {index} | Turno {index} |
+| settings.shiftOverrides.title | Day-specific shifts | Turnos por día |
+| settings.shiftOverrides.subtitle | Optional overrides for individual days. | Sobrescrituras opcionales por día. |
+| settings.shiftOverrides.useDefault | Use default | Usar predeterminado |
+| settings.shiftOverrides.customize | Customize | Personalizar |
+| settings.shiftOverrides.inherits | Uses default shift schedule. | Usa el horario de turnos predeterminado. |
+| settings.shiftOverrides.dayOff | Day off (no shifts) | Día libre (sin turnos) |
+| settings.shiftOverrides.clear | Clear shifts | Borrar turnos |
+| settings.shiftOverrides.mon | Monday | Lunes |
+| settings.shiftOverrides.tue | Tuesday | Martes |
+| settings.shiftOverrides.wed | Wednesday | Miércoles |
+| settings.shiftOverrides.thu | Thursday | Jueves |
+| settings.shiftOverrides.fri | Friday | Viernes |
+| settings.shiftOverrides.sat | Saturday | Sábado |
+| settings.shiftOverrides.sun | Sunday | Domingo |
| settings.thresholds | Alert thresholds | Umbrales de alertas |
| settings.thresholdsSubtitle | Tune production health alerts. | Ajusta alertas de salud de producción. |
| settings.thresholds.appliesAll | Applies to all machines | Aplica a todas las máquinas |
diff --git a/downtime_menu.md b/downtime_menu.md
new file mode 100644
index 0000000..7b57547
--- /dev/null
+++ b/downtime_menu.md
@@ -0,0 +1,264 @@
+Downtime
+
+Material / Falta de material
+Material / Material incorrecto
+Material / Material contaminado
+Material / Atasco de material
+Material / Cambio de material
+Material / Otro
+
+Proceso / Temperatura fuera de rango
+Proceso / Parámetros incorrectos
+Proceso / Ajuste de proceso
+Proceso / Arranque o estabilización
+Proceso / Proceso inestable
+Proceso / Otro
+
+Calidad / Inspección de calidad
+Calidad / Defecto detectado
+Calidad / Espera de liberación
+Calidad / Rechazo de producción
+Calidad / Validación de primera pieza
+Calidad / Otro
+
+Seguridad / Paro de seguridad
+Seguridad / Guarda o puerta abierta
+Seguridad / Sensor de seguridad activado
+Seguridad / Bloqueo y etiquetado
+Seguridad / Reset de seguridad
+Seguridad / Otro
+
+Molde / Cambio de molde
+Molde / Ajuste de molde
+Molde / Limpieza de molde
+Molde / Falla de molde
+Molde / Problema de expulsión
+Molde / Otro
+
+Máquina / Alarma de máquina
+Máquina / Falla eléctrica
+Máquina / Falla mecánica
+Máquina / Falla neumática o hidráulica
+Máquina / Reinicio de máquina
+Máquina / Otro
+
+Automatización / Falla de robot
+Automatización / Falla de sensor
+Automatización / Pérdida de comunicación
+Automatización / Atasco de pieza
+Automatización / Reset de celda
+Automatización / Otro
+
+Operación / Falta de operador
+Operación / Error de operación
+Operación / Cambio de turno
+Operación / Espera de apoyo
+Operación / Limpieza o ajuste
+Operación / Otro
+
+Servicios / Falta de energía
+Servicios / Baja presión de aire
+Servicios / Falta de agua o enfriamiento
+Servicios / Falla de red o comunicación
+Servicios / Utilidad fuera de rango
+Servicios / Otro
+
+Scrap
+
+Material / Material incorrecto
+Material / Material contaminado
+Material / Humedad de material
+Material / Mezcla incorrecta
+Material / Color incorrecto
+Material / Otro
+
+Proceso / Parámetros incorrectos
+Proceso / Temperatura incorrecta
+Proceso / Presión incorrecta
+Proceso / Tiempo incorrecto
+Proceso / Proceso inestable
+Proceso / Otro
+
+Calidad / Defecto visual
+Calidad / Defecto dimensional
+Calidad / No cumple especificación
+Calidad / Defecto detectado en inspección
+Calidad / Pieza no liberada
+Calidad / Otro
+
+Molde / Rebaba
+Molde / Falta de llenado
+Molde / Problema de expulsión
+Molde / Desalineación
+Molde / Daño de molde
+Molde / Otro
+
+Manipulación / Pieza golpeada
+Manipulación / Pieza rayada
+Manipulación / Pieza deformada
+Manipulación / Daño por robot
+Manipulación / Daño por operador
+Manipulación / Otro
+
+
+Already implemented in node-red side:
+
+### Summary
+Implementaremos captura obligatoria de razón en pantalla táctil para microstop, macrostop y scrap (no para slow-cycle en v1), usando un selector breadcrumb en español de **2 niveles**.
+La taxonomía vendrá de **Control Tower settings** con **fallback a caché local**.
+La razón seleccionada viajará a Control Tower **enriqueciendo el payload actual de event** (/api/ingest/event).
+Para macrostops con refrescos periódicos, pediremos razón **una sola vez por incidente**.
+
+### Key Changes
+- **Catálogo de razones (backend settings + cache local)**
+ - Extender el flujo de Apply settings + update UI para aceptar y persistir (memory + file context) un catálogo versionado:
+ - reasonCatalog.downtime (árbol 2 niveles)
+ - reasonCatalog.scrap (árbol 2 niveles)
+ - Enviar al UI un nuevo topic (reasonCatalogData) para hidratar selector.
+ - Si CT no responde catálogo, usar última versión en caché local; no bloquear operación.
+
+- **UI táctil (breadcrumb)**
+ - Reusar UI global de anomalías + Home para abrir modal de razón con botones touch-first (mínimo 64px de alto, grid compacto).
+ - Breadcrumb de 2 pasos:
+ - Paso 1: categoría
+ - Paso 2: subrazón
+ - **Micro/Macro**: al presionar ACK, primero abrir selector de razón; al confirmar, enviar submit + ACK.
+ - **Scrap**: después de capturar cantidad (numpad), abrir selector de razón scrap antes de confirmar envío final.
+ - Evitar prompts repetidos en macro refresh usando incidentKey en frontend/backend (once per incident).
+
+- **Mensajería Node-RED (interfaces nuevas)**
+ - Nuevos mensajes desde UI:
+ - topic: "anomaly-reason-submit" con { event_id, incidentKey, reasonPath, reasonText, reasonType: "downtime" }
+ - action: "scrap-entry-with-reason" con { id, scrap, reasonPath, reasonText, reasonType: "scrap" }
+ - Mantener compatibilidad con rutas actuales (acknowledge-anomaly, scrap-entry) durante transición v1.
+ - Enriquecer eventos enviados por outbox con campos de razón:
+ - event.reason = { type, categoryId, categoryLabel, detailId, detailLabel, catalogVersion, incidentKey }
+
+- **Persistencia local y trazabilidad**
+ - Guardar razón en anomaly_events sin migración (v1) dentro de data_json y/o notes al momento de submit.
+ - Para scrap, persistir razón junto con evento outbox y opcionalmente en work_orders flujo de actualización si ya existe payload contextual.
+ - No usar stop_events en v1 (tabla existe pero hoy no está integrada al pipeline activo).
+
+### API / Interface Additions
+- **Settings contract (Control Tower -> Edge)**: agregar bloque reasonCatalog con árboles downtime y scrap, y version.
+- **Edge event payload (Edge -> Control Tower)**: agregar objeto reason dentro de event cuando aplique.
+- **Node-RED UI topics/actions nuevos**:
+ - reasonCatalogData
+ - anomaly-reason-submit
+ - scrap-entry-with-reason
+
+### Test Plan
+- **Catalog + fallback**
+ - Con catálogo remoto disponible: UI muestra opciones correctas en español.
+ - Sin catálogo remoto: UI usa caché local previa y sigue operando.
+- **Downtime reason flow**
+ - Microstop: ACK obliga razón, envía 1 evento con razón, actualiza estado local.
+ - Macrostop refrescado: solo primer ACK del incidente solicita razón; refrescos posteriores no repiten prompt.
+- **Scrap reason flow**
+ - Scrap manual: cantidad + razón obligatoria, persistencia local correcta y outbox event enriquecido.
+- **Outbox / CT integration**
+ - outbox_messages para msg_type=event incluye event.reason con shape esperado.
+ - Retries no pierden razón (payload intacto tras reintentos).
+- **UX touch**
+ - Botones utilizables en raspi touch (tap error bajo, sin overflow en 1280x800).
+ - Breadcrumb claro y navegable (atrás/adelante) sin bloquear otras pantallas fuera del modal.
+
+### Assumptions
+- Control Tower aceptará el enriquecimiento de event.reason en el endpoint actual /api/ingest/event.
+- El catálogo remoto será entregado desde settings de máquina/org y versionado.
+- En v1 no se requiere migración SQL; razón local se serializa en campos existentes.
+- slow-cycle permanece informativo sin razón obligatoria (según decisión actual)
+
+
+
+Click-Through Runbook (what to test on screen)
+Trigger a macrostop or microstop alert.
+Tap Acknowledge on anomaly panel/popup.
+Confirm downtime reason modal appears (Paso 1 category).
+Pick category -> confirm step 2 (subreason) appears.
+Pick subreason.
+Confirm:
+alert is removed
+no re-prompt on same macro incident refresh (incidentKey once-per-incident)
+event is queued as type=event to /api/ingest/event
+event payload includes both event.reason and event.downtime.
+Open scrap modal from Home.
+Enter scrap qty and submit.
+Confirm scrap reason modal appears (Paso 1 -> Paso 2).
+Pick subreason and submit.
+Confirm:
+work_orders.scrap_parts updates
+event is queued as type=event
+payload includes event.reason and event.downtime: null.
+Exact JSON sent to CT (POST /api/ingest/event)
+This is the HTTP body from outbox publisher (payload_json envelope).
+
+A) Downtime reason acknowledgment event
+{
+ "schemaVersion": "1.0",
+ "machineId": "M-EDGE-01",
+ "tsMs": 1710001234567,
+ "seq": "901",
+ "type": "event",
+ "payload": {
+ "event": {
+ "tsMs": 1710001234567,
+ "eventType": "downtime-acknowledged",
+ "anomalyType": "macrostop",
+ "eventId": 1710001112222,
+ "incidentKey": "macrostop:WO-100:1710000000000",
+ "reason": {
+ "type": "downtime",
+ "categoryId": "mecanico",
+ "categoryLabel": "Mecanico",
+ "detailId": "hidraulico",
+ "detailLabel": "Hidraulico",
+ "reasonText": "Mecanico > Hidraulico",
+ "catalogVersion": 3,
+ "incidentKey": "macrostop:WO-100:1710000000000"
+ },
+ "downtime": {
+ "incidentKey": "macrostop:WO-100:1710000000000",
+ "eventId": 1710001112222,
+ "anomalyType": "macrostop",
+ "acknowledgedAtMs": 1710001234567,
+ "reason": {
+ "type": "downtime",
+ "categoryId": "mecanico",
+ "categoryLabel": "Mecanico",
+ "detailId": "hidraulico",
+ "detailLabel": "Hidraulico",
+ "reasonText": "Mecanico > Hidraulico",
+ "catalogVersion": 3,
+ "incidentKey": "macrostop:WO-100:1710000000000"
+ }
+ }
+ }
+ }
+}
+B) Scrap manual entry with reason
+{
+ "schemaVersion": "1.0",
+ "machineId": "M-EDGE-01",
+ "tsMs": 1776472069609,
+ "seq": "902",
+ "type": "event",
+ "payload": {
+ "event": {
+ "tsMs": 1776472069609,
+ "eventType": "scrap-manual-entry",
+ "workOrderId": "WO-100",
+ "scrapDelta": 4,
+ "source": "home-ui",
+ "reason": {
+ "type": "scrap",
+ "categoryId": "calidad",
+ "categoryLabel": "Calidad",
+ "detailId": "rebaba",
+ "detailLabel": "Rebaba",
+ "reasonText": "Calidad > Rebaba",
+ "catalogVersion": 3
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/flows_file.json b/flows_file.json
new file mode 100644
index 0000000..65663d4
--- /dev/null
+++ b/flows_file.json
@@ -0,0 +1,4427 @@
+[
+ {
+ "id": "a79ad45246b8dac2",
+ "type": "tab",
+ "label": "Flow 2.1",
+ "disabled": false,
+ "info": "",
+ "env": []
+ },
+ {
+ "id": "e57aed349acfd46b",
+ "type": "tab",
+ "label": "WIFI Management",
+ "disabled": false,
+ "info": ""
+ },
+ {
+ "id": "57af108730736bbe",
+ "type": "subflow",
+ "name": "Outbox Enqueue v1 (1) (4) (2) (3) (1)",
+ "info": "",
+ "category": "",
+ "in": [
+ {
+ "x": 40,
+ "y": 40,
+ "wires": [
+ {
+ "id": "09fa1a0e5bad1601"
+ }
+ ]
+ }
+ ],
+ "out": [
+ {
+ "x": 1160,
+ "y": 40,
+ "wires": [
+ {
+ "id": "7076207a387b12f7",
+ "port": 0
+ }
+ ]
+ }
+ ],
+ "env": [],
+ "meta": {},
+ "color": "#DDAA99"
+ },
+ {
+ "id": "92105e5a6e17066a",
+ "type": "group",
+ "z": "a79ad45246b8dac2",
+ "name": "Start ",
+ "style": {
+ "stroke": "#92d04f",
+ "fill": "#addb7b",
+ "label": true
+ },
+ "nodes": [
+ "de67ca079c93b851",
+ "87824863aeff2e56",
+ "decce87e728471dd",
+ "45532a8396658999",
+ "8eebefb3ad72c2f1",
+ "59284a9f41e33142",
+ "28cb7dd965982931",
+ "ae5c83dacb5cf69b"
+ ],
+ "x": 1134,
+ "y": 139,
+ "w": 942,
+ "h": 142
+ },
+ {
+ "id": "93532ddff6698be0",
+ "type": "group",
+ "z": "a79ad45246b8dac2",
+ "name": "Cavity Settings ",
+ "style": {
+ "stroke": "#ff7f7f",
+ "fill": "#ffbfbf",
+ "label": true
+ },
+ "nodes": [
+ "f3328aef817bc98b"
+ ],
+ "x": 122,
+ "y": 507,
+ "w": 926,
+ "h": 306
+ },
+ {
+ "id": "d220d9594c926cec",
+ "type": "group",
+ "z": "a79ad45246b8dac2",
+ "name": "UI/UX",
+ "style": {
+ "fill": "#d1d1d1",
+ "label": true
+ },
+ "nodes": [
+ "8528aff42bbfaab3",
+ "96f7f24bf59c66c8",
+ "960d78315815d84a",
+ "9ca2e04793d78848",
+ "bd965a93d24d47a3",
+ "87f0530e5407fe7b",
+ "18b45938178386b9",
+ "8b84fb19459518b5",
+ "c93a5509431ca0ab",
+ "3fa28c427b0b06cb",
+ "ca38c135cc1b4372",
+ "c13a25c43b7de196",
+ "5a233615f7c12124",
+ "556e511f11ac14ac",
+ "251dedf6026a65d6",
+ "0c1b1beef6772a00",
+ "d658ad997084c48c",
+ "49b88cae8aed7b60",
+ "dac27750f3363b4c",
+ "c035374e89a59b5d",
+ "daaa086c95b94825",
+ "02aa92deb817cbab"
+ ],
+ "x": 214,
+ "y": 179,
+ "w": 672,
+ "h": 362
+ },
+ {
+ "id": "6331afda01e9c79f",
+ "type": "group",
+ "z": "a79ad45246b8dac2",
+ "name": "Work Orders",
+ "style": {
+ "stroke": "#9363b7",
+ "fill": "#dbcbe7",
+ "label": true
+ },
+ "nodes": [
+ "7003a0ca507e78b1",
+ "3fe9d69423cffcee",
+ "985337c608ae84eb",
+ "b0013e3d357766cb",
+ "11682c71a65e85f4",
+ "6ff6848b6061e42a",
+ "c2c9185e2a07e8e8",
+ "6ffa372a2f28189c",
+ "c318de0423a67dde",
+ "82ae34d532385180",
+ "6394e7b952fdd73e",
+ "c92f0da16b85d079",
+ "c211a24ab8802cea",
+ "75da58a36bdf1746",
+ "a26a5545125df363",
+ "93dce43986dec9ac",
+ "d661918dc9f12935",
+ "ac79b131321a4151",
+ "29b0a86da36acd72",
+ "cfcdbba099f65279",
+ "d6faedde4bce4968",
+ "20896582916b31a3",
+ "01667230e00d8e02",
+ "48a58c7d8a6d150a",
+ "6c7ed0fbe4d35c3c",
+ "b3cc06435fa6f3e2",
+ "c9b94806462e9787",
+ "d2dae8800dec5243",
+ "a79b5eb4b8ed05c2",
+ "1c2a7979a2b328e9",
+ "ae1b88bfb6abdcd6",
+ "ce531bdfc0e17971",
+ "8f77f5583ee4044e",
+ "877cd85357e5e304",
+ "6b865662639d7c1c",
+ "facc0ad120adeb3f",
+ "1b9646dc1d66ef41",
+ "452e154b10587c66",
+ "0a04246503cc5d61",
+ "1697a7a9087b9150"
+ ],
+ "x": 1034,
+ "y": 279,
+ "w": 1432,
+ "h": 682
+ },
+ {
+ "id": "d2f48952b3128551",
+ "type": "group",
+ "z": "a79ad45246b8dac2",
+ "name": "Anomaly System",
+ "style": {
+ "stroke": "#ffC000",
+ "fill": "#ffdf7f",
+ "label": true
+ },
+ "nodes": [
+ "fef02a862e99d54d",
+ "dd64a7dee6631b28",
+ "31ee0f315b7c57f5",
+ "5d027d17cbd51027",
+ "4591c2ec02653bf9"
+ ],
+ "x": 224,
+ "y": 19,
+ "w": 922,
+ "h": 102
+ },
+ {
+ "id": "31c776cf0af9a50d",
+ "type": "group",
+ "z": "a79ad45246b8dac2",
+ "name": "Alerts",
+ "style": {
+ "fill": "#bfdbef",
+ "label": true
+ },
+ "nodes": [
+ "bec32a06a835bcc1",
+ "1efbf4ece8685528",
+ "f888adccd274f4fd"
+ ],
+ "x": 234,
+ "y": 819,
+ "w": 512,
+ "h": 82
+ },
+ {
+ "id": "8699169113c62240",
+ "type": "group",
+ "z": "a79ad45246b8dac2",
+ "name": "Graphs",
+ "style": {
+ "fill": "#bfbfbf",
+ "label": true
+ },
+ "nodes": [
+ "15aa2bf7e2b87ff7",
+ "86855379bbf4b84d",
+ "7854fb5ff1aaafc2",
+ "397ad156aa93c126",
+ "4959b0d832ea3a4c"
+ ],
+ "x": 1194,
+ "y": 39,
+ "w": 802,
+ "h": 82
+ },
+ {
+ "id": "f3328aef817bc98b",
+ "type": "group",
+ "z": "a79ad45246b8dac2",
+ "g": "93532ddff6698be0",
+ "name": "Cavities Settings",
+ "style": {
+ "stroke": "#ffff00",
+ "fill": "#ffffbf",
+ "label": true
+ },
+ "nodes": [
+ "89f1f7c120478582"
+ ],
+ "x": 148,
+ "y": 533,
+ "w": 874,
+ "h": 254
+ },
+ {
+ "id": "89f1f7c120478582",
+ "type": "group",
+ "z": "a79ad45246b8dac2",
+ "g": "f3328aef817bc98b",
+ "name": "Settings",
+ "style": {
+ "stroke": "#92d04f",
+ "fill": "#ffffbf",
+ "label": true
+ },
+ "nodes": [
+ "3b1b85d30c420844",
+ "3147c5260b724fd0",
+ "878680578b71c66d",
+ "3d647a972d928c23",
+ "4a567e0ec4ddd90e",
+ "e355e9f249dbbdd1",
+ "4f6e0771dcbd3685",
+ "a4a3e1da9f07ef9f"
+ ],
+ "x": 174,
+ "y": 559,
+ "w": 822,
+ "h": 202
+ },
+ {
+ "id": "fc9634aabefee16b",
+ "type": "MySQLdatabase",
+ "name": "Edge Outbox",
+ "host": "127.0.0.1",
+ "port": "3306",
+ "db": "edge_outbox",
+ "tz": "",
+ "charset": "UTF8"
+ },
+ {
+ "id": "c42b40f4665bc79c",
+ "type": "mqtt-broker",
+ "name": "EMQX",
+ "broker": "mqtt.maliountech.com.mx",
+ "port": "1883",
+ "clientid": "",
+ "autoConnect": true,
+ "usetls": false,
+ "protocolVersion": "4",
+ "keepalive": "60",
+ "cleansession": true,
+ "autoUnsubscribe": true,
+ "birthTopic": "",
+ "birthQos": "0",
+ "birthPayload": "",
+ "birthMsg": {},
+ "closeTopic": "",
+ "closeQos": "0",
+ "closePayload": "",
+ "closeMsg": {},
+ "willTopic": "",
+ "willQos": "0",
+ "willPayload": "",
+ "willMsg": {},
+ "userProps": "",
+ "sessionExpiry": ""
+ },
+ {
+ "id": "c567195d86466cd5",
+ "type": "ui_tab",
+ "name": "Home",
+ "icon": "dashboard",
+ "order": 1,
+ "disabled": false,
+ "hidden": false
+ },
+ {
+ "id": "a1b2c3d4e5f60718",
+ "type": "ui_tab",
+ "name": "Alerts",
+ "icon": "warning",
+ "order": 3,
+ "disabled": false,
+ "hidden": false
+ },
+ {
+ "id": "b2c3d4e5f6a70182",
+ "type": "ui_tab",
+ "name": "Graphs",
+ "icon": "show_chart",
+ "order": 4,
+ "disabled": false,
+ "hidden": false
+ },
+ {
+ "id": "c3d4e5f6a7b80192",
+ "type": "ui_tab",
+ "name": "Help",
+ "icon": "help",
+ "order": 5,
+ "disabled": false,
+ "hidden": false
+ },
+ {
+ "id": "d4e5f6a7b8c90123",
+ "type": "ui_tab",
+ "name": "Settings",
+ "icon": "settings",
+ "order": 6,
+ "disabled": false,
+ "hidden": false
+ },
+ {
+ "id": "d1a1e2f3a4b5c6d7",
+ "type": "ui_tab",
+ "name": "Work Orders",
+ "icon": "list",
+ "order": 2,
+ "disabled": false,
+ "hidden": false
+ },
+ {
+ "id": "919b5b8d778e2b6c",
+ "type": "ui_group",
+ "name": "Default",
+ "tab": "c567195d86466cd5",
+ "order": 1,
+ "disp": false,
+ "width": "25",
+ "collapse": false,
+ "className": ""
+ },
+ {
+ "id": "e2f3a4b5c6d7e8f9",
+ "type": "ui_group",
+ "name": "Alerts Group",
+ "tab": "a1b2c3d4e5f60718",
+ "order": 1,
+ "disp": false,
+ "width": "25",
+ "collapse": false,
+ "className": ""
+ },
+ {
+ "id": "e3f4a5b6c7d8e9f0",
+ "type": "ui_group",
+ "name": "Graphs Group",
+ "tab": "b2c3d4e5f6a70182",
+ "order": 1,
+ "disp": false,
+ "width": "25",
+ "collapse": false,
+ "className": ""
+ },
+ {
+ "id": "e4f5a6b7c8d9e0f1",
+ "type": "ui_group",
+ "name": "Help Group",
+ "tab": "c3d4e5f6a7b80192",
+ "order": 1,
+ "disp": false,
+ "width": "25",
+ "collapse": false,
+ "className": ""
+ },
+ {
+ "id": "e5f6a7b8c9d0e1f2",
+ "type": "ui_group",
+ "name": "Settings Group",
+ "tab": "d4e5f6a7b8c90123",
+ "order": 1,
+ "disp": false,
+ "width": "25",
+ "collapse": false,
+ "className": ""
+ },
+ {
+ "id": "b99c269687d574aa",
+ "type": "ui_group",
+ "name": "Work Orders Group",
+ "tab": "d1a1e2f3a4b5c6d7",
+ "order": 1,
+ "disp": false,
+ "width": 25,
+ "collapse": false,
+ "className": ""
+ },
+ {
+ "id": "36767d095255d393",
+ "type": "ui_base",
+ "theme": {
+ "name": "theme-light",
+ "lightTheme": {
+ "default": "#0094CE",
+ "baseColor": "#0094CE",
+ "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif",
+ "edited": true,
+ "reset": false
+ },
+ "darkTheme": {
+ "default": "#097479",
+ "baseColor": "#097479",
+ "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif",
+ "edited": false
+ },
+ "customTheme": {
+ "name": "Untitled Theme 1",
+ "default": "#4B7930",
+ "baseColor": "#4B7930",
+ "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif"
+ },
+ "themeState": {
+ "base-color": {
+ "default": "#0094CE",
+ "value": "#0094CE",
+ "edited": false
+ },
+ "page-titlebar-backgroundColor": {
+ "value": "#0094CE",
+ "edited": false
+ },
+ "page-backgroundColor": {
+ "value": "#fafafa",
+ "edited": false
+ },
+ "page-sidebar-backgroundColor": {
+ "value": "#ffffff",
+ "edited": false
+ },
+ "group-textColor": {
+ "value": "#1bbfff",
+ "edited": false
+ },
+ "group-borderColor": {
+ "value": "#ffffff",
+ "edited": false
+ },
+ "group-backgroundColor": {
+ "value": "#ffffff",
+ "edited": false
+ },
+ "widget-textColor": {
+ "value": "#111111",
+ "edited": false
+ },
+ "widget-backgroundColor": {
+ "value": "#0094ce",
+ "edited": false
+ },
+ "widget-borderColor": {
+ "value": "#ffffff",
+ "edited": false
+ },
+ "base-font": {
+ "value": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif"
+ }
+ },
+ "angularTheme": {
+ "primary": "indigo",
+ "accents": "blue",
+ "warn": "red",
+ "background": "grey",
+ "palette": "light"
+ }
+ },
+ "site": {
+ "name": "Node-RED Dashboard",
+ "hideToolbar": "true",
+ "allowSwipe": "false",
+ "lockMenu": "false",
+ "allowTempTheme": "true",
+ "dateFormat": "DD/MM/YYYY",
+ "sizes": {
+ "sx": 48,
+ "sy": 48,
+ "gx": 6,
+ "gy": 6,
+ "cx": 6,
+ "cy": 6,
+ "px": 0,
+ "py": 0
+ }
+ }
+ },
+ {
+ "id": "09fa1a0e5bad1601",
+ "type": "function",
+ "z": "57af108730736bbe",
+ "name": "Prepare + Validate + Call next_seq",
+ "func": "// Outbox Enqueue v1 - Step 1\nconst config = global.get(\"config\") || {};\nconst out = msg.outbox || {};\nconst type = out.type;\nconst payload = out.payload;\n\nif (!type) throw new Error(\"Outbox Enqueue: missing msg.outbox.type\");\nif (!payload || typeof payload !== \"object\") throw new Error(\"Outbox Enqueue: missing/invalid msg.outbox.payload\");\n\n// Where to send later (Publisher will use this)\nconst endpointByType = {\n cycle: \"/api/ingest/cycle\",\n event: \"/api/ingest/event\",\n kpi: \"/api/ingest/kpi\",\n heartbeat: \"/api/ingest/heartbeat\",\n segment: \"/api/ingest/segment\", // future; ok to queue now even if not used yet\n};\n\nconst endpoint = out.endpoint || endpointByType[type];\nif (!endpoint) throw new Error(`Outbox Enqueue: unknown type '${type}' and no endpoint provided`);\n\n// Get machineId from msg or global config\nconst machineId = msg.machineId || config.machineId;\nif (!machineId) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Outbox waiting for pairing\" });\n return null;\n}\n\nconst schemaVersion = msg.schemaVersion || \"1.0\";\nconst tsMs = typeof msg.tsMs === \"number\" ? msg.tsMs : Date.now();\n\n// Stash meta for later nodes\nmsg._outboxMeta = { type, endpoint, machineId, schemaVersion, tsMs, payload };\n\nconst validators = {\n cycle: (p) => p && typeof p.cycle === \"object\",\n event: (p) => p && typeof p.event === \"object\",\n kpi: (p) => p && p.kpis && typeof p.kpis === \"object\",\n heartbeat: (p) => p && typeof p.status === \"string\",\n segment: (p) => p && typeof p === \"object\",\n};\n\nconst validator = validators[type];\nif (!validator || !validator(payload)) {\n node.warn(`Outbox Enqueue: invalid ${type} payload`);\n return null;\n}\n\n// Call stored procedure to get next seq (safe + persistent)\nmsg.topic = \"CALL next_seq(?);\";\nmsg.payload = [machineId];\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 220,
+ "y": 40,
+ "wires": [
+ [
+ "bc6f9f2b2471df84"
+ ]
+ ]
+ },
+ {
+ "id": "bc6f9f2b2471df84",
+ "type": "mysql",
+ "z": "57af108730736bbe",
+ "mydb": "fc9634aabefee16b",
+ "name": "CALL next_seq",
+ "x": 460,
+ "y": 40,
+ "wires": [
+ [
+ "d2569ede250eea9d"
+ ]
+ ]
+ },
+ {
+ "id": "d2569ede250eea9d",
+ "type": "function",
+ "z": "57af108730736bbe",
+ "name": "Build envelope + prepare INSERT",
+ "func": "// Outbox Enqueue v1 - Step 2\nconst meta = msg._outboxMeta;\nif (!meta) throw new Error(\"Outbox Enqueue: missing _outboxMeta\");\n\nfunction stripNil(obj) {\n if (!obj || typeof obj !== \"object\") return obj;\n const out = Array.isArray(obj) ? [] : {};\n for (const [k, v] of Object.entries(obj)) {\n if (v === undefined || v === null) continue; // <— key change\n out[k] = (v && typeof v === \"object\") ? stripNil(v) : v;\n }\n return out;\n}\n\n// Parse seq from MySQL result\n// mysql node often returns an array of result sets for CALL\nlet seq = null;\nconst res = msg.payload;\n\nif (Array.isArray(res)) {\n // common shape: [ [ { seq: 123 } ], ... ]\n if (Array.isArray(res[0]) && res[0][0] && res[0][0].seq != null) seq = res[0][0].seq;\n // sometimes: [ { seq: 123 } ]\n if (seq == null && res[0] && res[0].seq != null) seq = res[0].seq;\n}\n\nif (seq == null) throw new Error(\"Outbox Enqueue: could not parse seq from DB result\");\n\n\n\n\nfunction normalizeIsoDeep(obj) {\n if (!obj || typeof obj !== \"object\") return obj;\n if (Array.isArray(obj)) return obj.map(normalizeIsoDeep);\n\n for (const k of Object.keys(obj)) {\n const v = obj[k];\n\n // recurse first\n if (v && typeof v === \"object\") obj[k] = normalizeIsoDeep(v);\n\n // ISO fields: must be string|null\n if (k.toLowerCase().endsWith(\"iso\")) {\n const vv = obj[k];\n if (typeof vv === \"string\") continue;\n if (typeof vv === \"number\") obj[k] = new Date(vv).toISOString();\n else if (vv == null) obj[k] = null;\n else obj[k] = null; // <- kills {} forever\n }\n }\n return obj;\n}\n\n\n\n\n\nconst cleanPayload = normalizeIsoDeep(stripNil(meta.payload));\nconst envelope = {\n schemaVersion: meta.schemaVersion,\n machineId: meta.machineId,\n tsMs: meta.tsMs,\n seq: String(seq),\n type: meta.type,\n payload: cleanPayload\n};\n\n\n// Prepare insert into outbox_messages\nmsg.topic = `\nINSERT INTO outbox_messages\n(machine_id, msg_type, endpoint, schema_version, seq, ts_device_ms, payload_json, status, attempts, next_attempt_at)\nVALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, NULL);\n`.trim();\n\nmsg.payload = [\n meta.machineId,\n meta.type,\n meta.endpoint,\n meta.schemaVersion,\n Number(seq), // BIGINT\n meta.tsMs, // BIGINT\n JSON.stringify(envelope),\n];\n\n// Keep a copy for debugging\nmsg._envelope = envelope;\nmsg._seq = Number(seq);\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 720,
+ "y": 40,
+ "wires": [
+ [
+ "7076207a387b12f7"
+ ]
+ ]
+ },
+ {
+ "id": "7076207a387b12f7",
+ "type": "mysql",
+ "z": "57af108730736bbe",
+ "mydb": "fc9634aabefee16b",
+ "name": "Insert outbox_messages",
+ "x": 1010,
+ "y": 40,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "8528aff42bbfaab3",
+ "type": "ui_template",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "group": "919b5b8d778e2b6c",
+ "name": "Home Template",
+ "order": 0,
+ "width": "25",
+ "height": "25",
+ "format": "\n\n \n\n
\n \n
\n \n OEE
\n 0 %
\n \n \n Disponibilidad
\n 0 %
\n \n \n Rendimiento
\n 0 %
\n \n \n Calidad
\n 0 %
\n \n \n\n
\n
Orden de trabajo actual \n \n \n \n\n
\n \n Piezas buenas
\n 0
\n de 0
\n \n\n \n Maquina OFFLINE
\n Producción DETENIDA
\n \n\n \n \n {{ isProductionRunning ? 'DETENER' : 'COMENZAR' }}\n \n \n \n
\n \n Scrap\n \n\n \n
\n\n\n\n
\n
Orden de trabajo en proceso \n
{{ resumePrompt.id }}
\n
\n {{ resumePrompt.goodParts }} of {{ resumePrompt.targetQty }} partes completadas\n ({{ resumePrompt.progressPercent }}%)\n
\n
Ciclos: {{ resumePrompt.cycleCount }}
\n\n
\n \n Reaundar de {{ resumePrompt.goodParts }} piezas\n \n \n Reanudar de 0 (Advertencia: Progreso se perdera!)\n \n
\n
\n
\n\n
\n
{{ scrapPrompt.title || 'Orden de trabajo completada' }} \n
{{ scrapPrompt.orderId }}
\n
\n Producido {{ scrapPrompt.produced }} de {{ scrapPrompt.target }} piezas\n
\n\n
\n Scrap acumulado: {{ scrapPrompt.scrapSoFar }} \n
\n\n
\n {{ scrapPrompt.manual ? '¿Cuántas piezas de scrap quieres agregar ahora?' : 'Hubo piezas de scrap?' }}\n
\n\n \n
\n
{{ scrapPrompt.scrapCount || 0 }}
\n
{{ scrapPrompt.error }}
\n\n
\n 7 \n 8 \n 9 \n\n 4 \n 5 \n 6 \n\n 1 \n 2 \n 3 \n\n C \n 0 \n ⌫ \n
\n\n
\n \n Enviar Scrap\n \n
\n
\n\n \n
\n \n
\n \n \n Recuerdame si seguimos sobreproduciendo\n \n
\n\n
\n No, continuar producción\n \n
\n Si, Enviar Scrap\n \n
\n
\n
\n\n\n
\n
Selecciona razón de scrap \n
\n Paso {{ scrapReasonPrompt.step }} de 2\n | {{ scrapReasonPrompt.selectedCategory.label }} \n
\n\n
\n \n {{ c.label }}\n \n
\n\n
\n \n {{ d.label }}\n \n
\n\n
\n Atrás \n Cancelar \n
\n
\n
\n\n\n\n",
+ "storeOutMessages": true,
+ "fwdInMessages": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 380,
+ "y": 280,
+ "wires": [
+ [
+ "18b45938178386b9",
+ "ca38c135cc1b4372",
+ "fdb32dd27585bd3e"
+ ]
+ ]
+ },
+ {
+ "id": "96f7f24bf59c66c8",
+ "type": "ui_template",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "group": "e2f3a4b5c6d7e8f9",
+ "name": "Alerts Template",
+ "order": 0,
+ "width": "25",
+ "height": "25",
+ "format": "\n\n \n\n
\n \n
\n\n
\n Falta de material \n Maquina detenida \n Paro Emergencia \n \n\n
\n \n Tipo de incidente \n \n Defecto de calidad \n Problema con molde \n Temperatura fuera de rango \n Presión incorrecta \n Desviación tiempo ciclo \n Bloqueo seguridad \n Falla neumática \n Falla eléctrica \n Error de sensor \n Otro \n \n
\n \n Descripción \n \n
\n Mandar incidente \n \n
\n \n
\n\n",
+ "storeOutMessages": true,
+ "fwdInMessages": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 380,
+ "y": 360,
+ "wires": [
+ [
+ "18b45938178386b9",
+ "48bfce812bc1f09b"
+ ]
+ ]
+ },
+ {
+ "id": "960d78315815d84a",
+ "type": "ui_template",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "group": "e3f4a5b6c7d8e9f0",
+ "name": "Graphs Template",
+ "order": 0,
+ "width": "25",
+ "height": "25",
+ "format": "\n\n\n \n\n
\n \n
Graphs \n\n\n
\n \n OEE \n
\n \n\n \n Disponibilidad \n
\n \n\n \n Rendimiendo \n
\n \n\n \n Calidad \n
\n \n \n
\n \n
\n\n\n\n",
+ "storeOutMessages": true,
+ "fwdInMessages": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 390,
+ "y": 400,
+ "wires": [
+ [
+ "18b45938178386b9"
+ ]
+ ]
+ },
+ {
+ "id": "9ca2e04793d78848",
+ "type": "ui_template",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "group": "e4f5a6b7c8d9e0f1",
+ "name": "Help Template",
+ "order": 0,
+ "width": "25",
+ "height": "25",
+ "format": "\n\n \n\n
\n \n
\n\n
\n Acerca de este panel \n Esta interfaz monitorea los indicadores de Eficiencia Global del Equipo (OEE), el avance de producción y el registro de incidentes para operaciones de inyección de plástico. Navega entre las pestañas usando la barra lateral para acceder a órdenes de trabajo, monitoreo en tiempo real, gráficas de desempeño, registro de incidentes y configuración de máquina.
\n \n\n
\n Cómo empezar con una orden de trabajo \n Ve a la pestaña Órdenes de Trabajo y carga un archivo de Excel con tus órdenes, o selecciona una orden existente en la tabla. Haz clic en Cargar para activar una orden de trabajo y luego navega a la pestaña Inicio, donde verás los detalles de la orden actual. Antes de iniciar producción, configura tu molde en la pestaña Configuración, seleccionando un preset de molde o ingresando manualmente el número de cavidades.
\n \n\n
\n Ejecutando producción \n En la pestaña Inicio, presiona el botón INICIAR para comenzar la producción. El sistema registra conteo de ciclos, piezas buenas, scrap y el avance hacia tu cantidad objetivo. Presiona DETENER para pausar la producción. Monitorea en tiempo real los KPIs, incluyendo OEE, Disponibilidad, Rendimiento y Calidad mostrados en el tablero.
\n \n\n
\n Registro de incidentes \n Usa la pestaña Alertas para registrar incidentes de producción. Registra rápidamente problemas comunes con botones preconfigurados como Falta de Material, Máquina Detenida o Paro de Emergencia. Para un registro más detallado, selecciona un tipo de alerta en el menú desplegable, agrega notas y envía. Todos los incidentes se registran con sello de tiempo y se utilizan en los cálculos de Disponibilidad y OEE.
\n \n\n
\n Configuración de moldes \n En la pestaña Configuración, utiliza la sección de Preconfigurados de Molde para buscar tu molde por fabricante y nombre. Selecciona una configuración para cargar automáticamente el número de cavidades, o ajusta manualmente los campos de Configuración de Molde. Si tu molde no aparece, usa el botón Agregar Molde en la sección de Integraciones para crear un nuevo preset con fabricante, nombre y detalles de cavidades.
\n \n\n
\n Visualización de datos de desempeño \n La pestaña Gráficas muestra el historial de tendencias de OEE desglosado por Disponibilidad, Desempeño y Calidad. Usa estas gráficas para identificar patrones, dar seguimiento a mejoras y diagnosticar problemas recurrentes que afecten la eficiencia de tu producción
\n \n
\n \n
\n\n\n",
+ "storeOutMessages": true,
+ "fwdInMessages": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 380,
+ "y": 440,
+ "wires": [
+ [
+ "18b45938178386b9"
+ ]
+ ]
+ },
+ {
+ "id": "bd965a93d24d47a3",
+ "type": "ui_template",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "group": "e5f6a7b8c9d0e1f2",
+ "name": "Settings Template",
+ "order": 0,
+ "width": "25",
+ "height": "25",
+ "format": "\n\n \n\n
\n \n
\n\n
\n Moldes Preconfigurados \n \n
\n Fabricante \n \n Seleccionar fabricante... \n {{mfg}} \n \n
\n
\n Molde \n \n Seleccionar molde... \n \n
\n
\n\n \n \n
\n
Seleccionar un fabricante y el molde de las siguientes opciones.
\n
\n\n \n
\n
\n \n\n
\n\n
\n Integraciones \n \n
\n Connectar al ERP \n
\n
\n
No encuentras lo que buscas?
\n
Agregar Molde \n
\n
\n \n
\n Enlace Control Tower \n Ingresa el codigo de 5 caracteres que aparece en Control Tower para enlazar esta Raspberry Pi.
\n \n {{ pairingStatus }}
\n \n
\n WiFi \n Conecta esta Raspberry Pi a una red WiFi (sin salir del dashboard).
\n \n \n \n \n \n \n \n Estado\n \n \n \n {{ wifiBusy.scan ? 'Buscando…' : 'Escanear' }}\n \n \n \n {{ wifiBusy.apply ? 'Aplicando…' : 'Conectar' }}\n \n
\n \n \n {{ wifiStatusText }}\n
\n \n
\n Programación de Producción \n\n \n \n\n \n\n = 3\">\n + Agregar Turno\n \n\n \n \n \n
\n \n {{ isSaving ? 'Guardando...' : 'Guardar Ajustes' }}\n \n \n {{ saveStatus }}\n \n
\n\n
\n
\n \n
\n\n",
+ "storeOutMessages": true,
+ "fwdInMessages": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 390,
+ "y": 480,
+ "wires": [
+ [
+ "18b45938178386b9",
+ "3fa28c427b0b06cb",
+ "dac27750f3363b4c"
+ ]
+ ]
+ },
+ {
+ "id": "87f0530e5407fe7b",
+ "type": "ui_template",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "group": "b99c269687d574aa",
+ "name": "WO Template",
+ "order": 1,
+ "width": "25",
+ "height": "25",
+ "format": "\n\n\n\n",
+ "storeOutMessages": true,
+ "fwdInMessages": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 380,
+ "y": 320,
+ "wires": [
+ [
+ "18b45938178386b9",
+ "ca38c135cc1b4372"
+ ]
+ ]
+ },
+ {
+ "id": "18b45938178386b9",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "Tab navigation",
+ "func": "if (msg.ui_control && msg.ui_control.tab) {\n msg.payload = { tab: msg.ui_control.tab };\n delete msg.ui_control;\n return msg;\n}\nreturn null;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 620,
+ "y": 380,
+ "wires": [
+ [
+ "8b84fb19459518b5"
+ ]
+ ]
+ },
+ {
+ "id": "8b84fb19459518b5",
+ "type": "ui_ui_control",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "ui_control",
+ "events": "all",
+ "x": 800,
+ "y": 380,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "c93a5509431ca0ab",
+ "type": "ui_template",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "group": "",
+ "name": "General Style",
+ "order": 0,
+ "width": 0,
+ "height": 0,
+ "format": "",
+ "storeOutMessages": true,
+ "fwdInMessages": true,
+ "resendOnRefresh": true,
+ "templateScope": "global",
+ "className": "",
+ "x": 740,
+ "y": 340,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "fef02a862e99d54d",
+ "type": "ui_template",
+ "z": "a79ad45246b8dac2",
+ "g": "d2f48952b3128551",
+ "group": "919b5b8d778e2b6c",
+ "name": "Anomaly Alert System (Global)",
+ "order": 1,
+ "width": "25",
+ "height": "25",
+ "format": "\n\n\n\n 0\">{{activeAnomalyCount}} \n ⚠️ \n ALERTS \n \n\n\n\n \n\n
\n
\n
✅
\n
No active alerts
\n
All systems operating normally
\n
\n\n
\n \n
{{anomaly.description}}
\n
{{formatTimestamp(anomaly.tsMs)}}
\n
\n Acknowledge \n
\n
\n
\n
\n\n\n\n\n\n
\n
Selecciona razón de paro \n
{{reasonPrompt.anomalyTitle || 'Incidente de downtime'}}
\n
\n Paso {{reasonPrompt.step}} de 2 \n | {{reasonPrompt.selectedCategory.label}} \n
\n\n
\n \n {{c.label}}\n \n
\n\n
\n \n {{d.label}}\n \n
\n\n
\n Atrás \n Cancelar \n
\n
\n
\n\n\n",
+ "storeOutMessages": true,
+ "fwdInMessages": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 430,
+ "y": 60,
+ "wires": [
+ [
+ "dd64a7dee6631b28",
+ "cff3a2f29333543f"
+ ]
+ ]
+ },
+ {
+ "id": "dd64a7dee6631b28",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "d2f48952b3128551",
+ "name": "Handle Anomaly Acknowledgment",
+ "func": "// Handle anomaly acknowledgments and downtime-reason submit.\n// Output 1: SQL update for anomaly_events\n// Output 2: event payload for Build Event Outbox Payload\nconst anomaly = global.get(\"anomaly\") || {};\nconst topic = msg.topic || \"\";\n\nfunction persistAnomaly(next) {\n global.set(\"anomaly\", next);\n try {\n global.set(\"anomaly\", next, \"file\");\n } catch (err) {\n // ignore if file store is not configured\n }\n}\n\nfunction parseReason(payload) {\n const reasonPath = Array.isArray(payload.reasonPath) ? payload.reasonPath : [];\n const category = reasonPath[0] || {};\n const detail = reasonPath[1] || {};\n return {\n type: payload.reasonType || \"downtime\",\n categoryId: category.id || null,\n categoryLabel: category.label || null,\n detailId: detail.id || null,\n detailLabel: detail.label || null,\n reasonText: payload.reasonText || [\n category.label || \"\",\n detail.label || \"\"\n ].filter(Boolean).join(\" > \") || null,\n catalogVersion: payload.catalogVersion || null,\n incidentKey: payload.incidentKey || payload.incident_key || null\n };\n}\n\nfunction removeFromActive(eventId) {\n let activeAnomalies = anomaly.activeAnomalies || [];\n const idx = activeAnomalies.findIndex((a) => a.event_id === eventId || a.tsMs === eventId);\n if (idx !== -1) {\n activeAnomalies.splice(idx, 1);\n anomaly.activeAnomalies = activeAnomalies;\n }\n}\n\nif (topic === \"acknowledge-anomaly\" || topic === \"anomaly-reason-submit\") {\n const ackData = msg.payload || {};\n const eventId = ackData.event_id ?? ackData.tsMs;\n const ackTimestamp = typeof ackData.tsMs === \"number\" ? ackData.tsMs : Date.now();\n const incidentKey = ackData.incidentKey || ackData.incident_key || null;\n\n if (!eventId) {\n node.warn(\"[ANOMALY ACK] No event_id provided\");\n return null;\n }\n\n if (String(eventId).startsWith(\"temp_\")) {\n removeFromActive(eventId);\n persistAnomaly(anomaly);\n return null;\n }\n\n const sql = (\n \"UPDATE anomaly_events \" +\n \"SET status = 'acknowledged', acknowledged_at = \" + Number(ackTimestamp) + \" \" +\n \"WHERE event_timestamp = \" + Number(eventId)\n );\n const dbMsg = { topic: sql, payload: [] };\n\n removeFromActive(eventId);\n\n if (topic === \"anomaly-reason-submit\") {\n anomaly.reasonsByIncident = anomaly.reasonsByIncident || {};\n anomaly.ackedIncidentKeys = anomaly.ackedIncidentKeys || {};\n\n const reason = parseReason(ackData);\n if (incidentKey) {\n anomaly.reasonsByIncident[incidentKey] = reason;\n anomaly.ackedIncidentKeys[incidentKey] = true;\n }\n\n persistAnomaly(anomaly);\n\n const alreadySent = !!(incidentKey && anomaly.reasonEventsSent && anomaly.reasonEventsSent[incidentKey]);\n if (alreadySent) {\n return [dbMsg, null];\n }\n\n if (incidentKey) {\n anomaly.reasonEventsSent = anomaly.reasonEventsSent || {};\n anomaly.reasonEventsSent[incidentKey] = true;\n persistAnomaly(anomaly);\n }\n\n const eventTsMs = Number(ackData.tsMs) || Date.now();\n const anomalyType = ackData.anomalyType || ackData.anomaly_type || \"downtime\";\n const outEvent = {\n tsMs: eventTsMs,\n eventType: \"downtime-acknowledged\",\n anomalyType,\n eventId: eventId,\n incidentKey: incidentKey || null,\n // Keep reason attached to generic event payload for current ingest.\n reason,\n // Explicit separation for future split in Control Tower.\n downtime: {\n incidentKey: incidentKey || null,\n eventId: eventId,\n anomalyType,\n acknowledgedAtMs: eventTsMs,\n reason\n }\n };\n\n return [{ ...dbMsg }, { payload: outEvent, tsMs: eventTsMs }];\n }\n\n if (incidentKey) {\n anomaly.ackedIncidentKeys = anomaly.ackedIncidentKeys || {};\n anomaly.ackedIncidentKeys[incidentKey] = true;\n }\n persistAnomaly(anomaly);\n\n return [dbMsg, null];\n}\n\nreturn null;\n",
+ "outputs": 2,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 720,
+ "y": 60,
+ "wires": [
+ [
+ "31ee0f315b7c57f5"
+ ],
+ [
+ "4b42a4e4bb445e89"
+ ]
+ ]
+ },
+ {
+ "id": "de67ca079c93b851",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "g": "92105e5a6e17066a",
+ "name": "Simula Inyectora",
+ "props": [
+ {
+ "p": "payload"
+ }
+ ],
+ "repeat": "6",
+ "crontab": "",
+ "once": true,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "1",
+ "payloadType": "num",
+ "x": 1270,
+ "y": 180,
+ "wires": [
+ [
+ "59284a9f41e33142",
+ "9c42f5d026b5c7b4"
+ ]
+ ]
+ },
+ {
+ "id": "87824863aeff2e56",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "92105e5a6e17066a",
+ "name": "1,0",
+ "func": "// Get current global value (default to 0 if not set)\nconst state = global.get(\"state\") || {};\nlet estado = state.machineToggle || 0;\nlet stop = flow.get('stop') || false;\n\nif (stop) {\n // Manual stop active → force 0, don't reschedule\n state.machineToggle = 0;\nglobal.set(\"state\", state);\n msg.payload = 0;\n node.send(msg);\n return;\n}\n\n// Toggle between 1 and 0\nestado = estado === 1 ? 0 : 1;\n\n// Update the global variable\nstate.machineToggle = estado;\nglobal.set(\"state\", state);\n\n// Send it out\nmsg.payload = estado;\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1670,
+ "y": 200,
+ "wires": [
+ [
+ "decce87e728471dd"
+ ]
+ ]
+ },
+ {
+ "id": "3b1b85d30c420844",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "89f1f7c120478582",
+ "name": "Cavities Settings",
+ "func": "const settings = global.get(\"settings\") || {};\nconst state = global.get(\"state\") || {};\nconst moldByWorkOrder = state.moldByWorkOrder || {};\n\nconst persistSettings = () => {\n global.set(\"settings\", settings);\n try {\n global.set(\"settings\", settings, \"file\");\n } catch (err) {\n // ignore if file store is not configured\n }\n};\n\nconst persistMoldCache = () => {\n const cache = {\n lastMoldActive: state.lastMoldActive ?? null,\n lastMoldTotal: state.lastMoldTotal ?? null,\n moldByWorkOrder: state.moldByWorkOrder || {}\n };\n global.set(\"moldCache\", cache);\n try {\n global.set(\"moldCache\", cache, \"file\");\n } catch (err) {\n // ignore if file store is not configured\n }\n};\n\nconst persistState = () => {\n global.set(\"state\", state);\n};\n\nconst persistMoldSelection = (total, active) => {\n if (!Number.isFinite(active) || active <= 0) return;\n state.lastMoldActive = active;\n if (Number.isFinite(total) && total > 0) {\n state.lastMoldTotal = total;\n }\n if (state.activeWorkOrder?.id) {\n state.activeWorkOrder.cavities = active;\n if (Number.isFinite(total) && total > 0) {\n state.activeWorkOrder.cavities_total = total;\n }\n moldByWorkOrder[state.activeWorkOrder.id] = {\n total: Number.isFinite(total) && total > 0 ? total : (state.lastMoldTotal ?? null),\n active\n };\n }\n state.moldByWorkOrder = moldByWorkOrder;\n persistState();\n persistMoldCache();\n};\n\nconst buildCavitiesUpdate = (total, active) => {\n const activeId = state.activeWorkOrder?.id;\n if (!activeId || !Number.isFinite(active) || active <= 0) return null;\n const totalSafe = Number.isFinite(total) && total > 0 ? total : 0;\n return {\n topic: \"UPDATE work_orders SET cavities_total = COALESCE(NULLIF(?,0), cavities_total), cavities_active = COALESCE(NULLIF(?,0), cavities_active), updated_at = NOW() WHERE work_order_id = ?\",\n payload: [totalSafe, active, activeId]\n };\n};\n\nif (msg.topic === \"moldSettings\" && msg.payload) {\n const total = Number(msg.payload.total || 0);\n const active = Number(msg.payload.active || 0);\n\n // Store settings\n settings.moldTotal = total;\n settings.moldActive = active;\n persistSettings();\n\n persistMoldSelection(total, active);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `Saved: ${active}/${total}` });\n\n const dbMsg = buildCavitiesUpdate(total, active);\n\n msg.payload = { saved: true, total, active };\n return [msg, dbMsg];\n}\n\n// Handle preset selection\nif (msg.topic === \"selectMoldPreset\" && msg.payload) {\n const preset = msg.payload;\n const total = Number(preset.theoretical_cavities || 0);\n const active = Number(preset.functional_cavities || 0);\n\n // Store settings\n settings.moldTotal = total;\n settings.moldActive = active;\n persistSettings();\n\n persistMoldSelection(total, active);\n\n node.status({ fill: \"blue\", shape: \"dot\", text: `Preset: ${preset.mold_name}` });\n\n // Send to UI to update fields\n msg.topic = \"moldPresetSelected\";\n msg.payload = { total, active, presetName: preset.mold_name };\n\n const dbMsg = buildCavitiesUpdate(total, active);\n\n return [msg, dbMsg];\n}\n\n//node.status({ fill: \"red\", shape: \"ring\", text: \"Invalid payload\" });\nreturn [null, null];",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 470,
+ "y": 600,
+ "wires": [
+ [
+ "3d647a972d928c23"
+ ]
+ ]
+ },
+ {
+ "id": "3147c5260b724fd0",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "89f1f7c120478582",
+ "name": "Mold Presets Handler",
+ "func": "const topic = msg.topic || '';\nconst payload = msg.payload || {};\n\n// ===== IGNORE NON-MOLD TOPICS SILENTLY =====\n// These are KPI/dashboard messages not meant for this handler\nconst ignoredTopics = [\n 'machineStatus',\n 'kpis',\n 'chartsData',\n 'activeWorkOrder',\n 'workOrderCycle',\n 'workOrdersList',\n 'scrapPrompt',\n 'uploadStatus'\n];\n\nif (ignoredTopics.includes(topic) || topic === '') {\n return null; // Silent ignore\n}\n\n// Log only mold-related requests\nnode.warn(`Received: ${topic}`);\n\n// CRITICAL: Use a processing lock to prevent simultaneous requests\nlet dedupeKey = topic;\nif (topic === 'addMoldPreset') {\n dedupeKey = `add_${payload.manufacturer}_${payload.mold_name}`;\n} else if (topic === 'getMoldsByManufacturer') {\n dedupeKey = `getmolds_${payload.manufacturer}`;\n}\n\nconst lockKey = `lock_${dedupeKey}`;\nconst lastRequestKey = `last_request_${dedupeKey}`;\n\n// Check if currently processing this request\nif (flow.get(lockKey) === true) {\n node.warn(`${topic} already processing - duplicate blocked`);\n return null;\n}\n\n// Check timing\nconst now = Date.now();\nconst lastRequestTime = flow.get(lastRequestKey) || 0;\nif (now - lastRequestTime < 2000) {\n node.warn(`Duplicate ${topic} request ignored (within 2s)`);\n return null;\n}\n\n// Set lock IMMEDIATELY before any async operations\nflow.set(lockKey, true);\nflow.set(lastRequestKey, now);\n\n// Release lock after 3 seconds (safety timeout)\nsetTimeout(() => {\n flow.set(lockKey, false);\n}, 3000);\n\n// Load all presets (legacy)\nif (topic === 'loadMoldPresets') {\n msg._originalTopic = 'loadMoldPresets';\n msg.topic = 'SELECT * FROM mold_presets ORDER BY manufacturer, mold_name;';\n node.warn('Querying all presets');\n return msg;\n}\n\n// Search/filter presets (legacy)\nif (topic === 'searchMoldPresets') {\n const filters = msg.payload || {};\n const searchTerm = (filters.searchTerm || '').trim().replace(/['\\\"\\\\\\\\]/g, '');\n const manufacturer = (filters.manufacturer || '').replace(/['\\\"\\\\\\\\]/g, '');\n const theoreticalCavities = filters.theoreticalCavities || '';\n\n let query = 'SELECT * FROM mold_presets WHERE 1=1';\n\n if (searchTerm) {\n const searchPattern = `%${searchTerm}%`;\n query += ` AND (mold_name LIKE '${searchPattern.replace(/'/g, \"''\")}' OR manufacturer LIKE '${searchPattern.replace(/'/g, \"''\")}')`;\n }\n\n if (manufacturer && manufacturer !== 'All') {\n query += ` AND manufacturer = '${manufacturer.replace(/'/g, \"''\")}'`;\n }\n\n if (theoreticalCavities && theoreticalCavities !== '') {\n const cavities = Number(theoreticalCavities);\n if (!isNaN(cavities)) {\n query += ` AND theoretical_cavities = ${cavities}`;\n }\n }\n\n query += ' ORDER BY manufacturer, mold_name;';\n\n msg._originalTopic = 'searchMoldPresets';\n msg.topic = query;\n return msg;\n}\n\n// Get unique manufacturers for dropdown\nif (topic === 'getManufacturers') {\n msg._originalTopic = 'getManufacturers';\n msg.topic = 'SELECT DISTINCT manufacturer FROM mold_presets ORDER BY manufacturer;';\n node.warn('Querying manufacturers');\n return msg;\n}\n\n// Get molds for a specific manufacturer\nif (topic === 'getMoldsByManufacturer') {\n const data = msg.payload || {};\n const manufacturerRaw = (data.manufacturer || '').trim();\n if (!manufacturerRaw) {\n node.warn('No manufacturer provided');\n return null;\n }\n\n const manufacturerSafe = manufacturerRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n\n msg._originalTopic = 'getMoldsByManufacturer';\n msg.topic = `SELECT * FROM mold_presets WHERE manufacturer = '${manufacturerSafe}' ORDER BY mold_name;`;\n node.warn(`Querying molds for: ${manufacturerSafe}`);\n return msg;\n}\n\n// Add a new mold preset - CRITICAL: Strong deduplication\nif (topic === 'addMoldPreset') {\n const data = msg.payload || {};\n const manufacturerRaw = (data.manufacturer || '').trim();\n const moldNameRaw = (data.mold_name || '').trim();\n const theoreticalRaw = (data.theoretical || '').trim();\n const activeRaw = (data.active || '').trim();\n\n if (!manufacturerRaw || !moldNameRaw || !theoreticalRaw || !activeRaw) {\n node.status({ fill: 'red', shape: 'ring', text: 'Missing value' });\n node.warn('Missing required fields');\n return null;\n }\n\n // Additional safety check for already-processed flag\n if (msg._addMoldProcessed) {\n node.warn('addMoldPreset already processed flag detected, ignoring');\n return null;\n }\n msg._addMoldProcessed = true;\n\n const manufacturerSafe = manufacturerRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n const moldNameSafe = moldNameRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n const theoreticalSafe = theoreticalRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n const activeSafe = activeRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n\n msg._originalTopic = 'addMoldPreset';\n msg.topic =\n \"INSERT INTO mold_presets (manufacturer, mold_name, theoretical_cavities, functional_cavities) \" +\n \"VALUES ('\" + manufacturerSafe + \"', '\" + moldNameSafe + \"', \" + theoreticalSafe + \", \" + activeSafe + \");\";\n\n node.status({ fill: 'blue', shape: 'dot', text: 'Inserting mold...' });\n node.warn(`Inserting: ${manufacturerSafe} - ${moldNameSafe}`);\n return msg;\n}\n\nnode.warn(`Unknown topic: ${topic}`);\nreturn null;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 400,
+ "y": 660,
+ "wires": [
+ [
+ "878680578b71c66d"
+ ]
+ ]
+ },
+ {
+ "id": "878680578b71c66d",
+ "type": "mysql",
+ "z": "a79ad45246b8dac2",
+ "g": "89f1f7c120478582",
+ "mydb": "fc9634aabefee16b",
+ "name": "Mold Presets DB",
+ "x": 610,
+ "y": 660,
+ "wires": [
+ [
+ "3d647a972d928c23"
+ ]
+ ]
+ },
+ {
+ "id": "3d647a972d928c23",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "89f1f7c120478582",
+ "name": "Process DB Results",
+ "func": "// Replace function in \"Process DB Results\" node\n\nconst originalTopic = msg._originalTopic || '';\nconst dbResults = Array.isArray(msg.payload) ? msg.payload : [];\n\nif (!originalTopic) {\n return null;\n}\n\n// IMPORTANT: Clear socketid to prevent loops back to sender\ndelete msg._socketid;\ndelete msg.socketid;\n\n// Manufacturers query → list for first dropdown\nif (originalTopic === 'getManufacturers') {\n const manufacturers = dbResults\n .map(row => row.manufacturer)\n .filter((mfg, index, arr) => mfg && arr.indexOf(mfg) === index)\n .sort();\n\n msg.topic = 'manufacturersList';\n msg.payload = manufacturers;\n\n node.status({ fill: 'green', shape: 'dot', text: `${manufacturers.length} manufacturers` });\n return msg;\n}\n\n// Preset lists (legacy load/search)\nif (originalTopic === 'loadMoldPresets' || originalTopic === 'searchMoldPresets') {\n const presets = dbResults.map(row => ({\n mold_name: row.mold_name || '',\n manufacturer: row.manufacturer || '',\n theoretical_cavities: Number(row.theoretical_cavities) || 0,\n functional_cavities: Number(row.functional_cavities) || 0\n }));\n\n msg.topic = 'moldPresetsList';\n msg.payload = presets;\n\n node.status({ fill: 'green', shape: 'dot', text: `${presets.length} presets found` });\n return msg;\n}\n\n// Molds for selected manufacturer\nif (originalTopic === 'getMoldsByManufacturer') {\n const presets = dbResults.map(row => ({\n mold_name: row.mold_name || '',\n manufacturer: row.manufacturer || '',\n theoretical_cavities: Number(row.theoretical_cavities) || 0,\n functional_cavities: Number(row.functional_cavities) || 0\n }));\n\n msg.topic = 'moldPresetsList';\n msg.payload = presets;\n\n node.status({ fill: 'blue', shape: 'dot', text: `${presets.length} molds for manufacturer` });\n return msg;\n}\n\n// Result of inserting a new mold\nif (originalTopic === 'addMoldPreset') {\n msg.topic = 'addMoldResult';\n msg.payload = {\n success: true,\n result: msg.payload\n };\n\n node.status({ fill: 'green', shape: 'dot', text: 'Mold added' });\n return msg;\n}\n\nnode.status({ fill: 'yellow', shape: 'ring', text: 'Unknown topic: ' + originalTopic });\nreturn null;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 740,
+ "y": 600,
+ "wires": [
+ [
+ "e355e9f249dbbdd1"
+ ]
+ ]
+ },
+ {
+ "id": "3fa28c427b0b06cb",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "link out 1",
+ "mode": "link",
+ "links": [
+ "4a567e0ec4ddd90e"
+ ],
+ "x": 505,
+ "y": 480,
+ "wires": []
+ },
+ {
+ "id": "4a567e0ec4ddd90e",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "89f1f7c120478582",
+ "name": "link in 1",
+ "links": [
+ "3fa28c427b0b06cb"
+ ],
+ "x": 215,
+ "y": 660,
+ "wires": [
+ [
+ "3b1b85d30c420844",
+ "3147c5260b724fd0",
+ "4f6e0771dcbd3685",
+ "18f9a5f87e9604a0"
+ ]
+ ]
+ },
+ {
+ "id": "d661918dc9f12935",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Work Order buttons",
+ "func": "const config = global.get(\"config\") || {};\nconst settings = global.get(\"settings\") || {};\nconst state = global.get(\"state\") || {};\nconst reasonCatalog = settings.reasonCatalog || {};\n\nconst persistMoldCache = () => {\n const cache = {\n lastMoldActive: state.lastMoldActive ?? null,\n lastMoldTotal: state.lastMoldTotal ?? null,\n moldByWorkOrder: state.moldByWorkOrder || {}\n };\n global.set(\"moldCache\", cache);\n try {\n global.set(\"moldCache\", cache, \"file\");\n } catch (err) {\n // ignore if file store is not configured\n }\n};\n\nconst moldByWorkOrder = state.moldByWorkOrder || {};\nconst attachMold = (order) => {\n if (!order || !order.id) return;\n const mapped = moldByWorkOrder[order.id] || {};\n const cavities = Number(order.cavities ?? mapped.active ?? state.lastMoldActive ?? 0);\n if (!Number.isFinite(cavities) || cavities <= 0) return;\n const total = Number(order.cavities_total ?? order.cavitiesTotal ?? mapped.total ?? state.lastMoldTotal ?? settings.moldTotal ?? 0);\n order.cavities = cavities;\n if (Number.isFinite(total) && total > 0) {\n order.cavities_total = total;\n state.lastMoldTotal = total;\n }\n state.lastMoldActive = cavities;\n moldByWorkOrder[order.id] = {\n total: Number.isFinite(total) && total > 0 ? total : (state.lastMoldTotal ?? null),\n active: cavities\n };\n state.moldByWorkOrder = moldByWorkOrder;\n};\n\nconst finalize = (ret) => {\n global.set(\"state\", state);\n persistMoldCache();\n return ret;\n};\n\nconst normalizeReason = (payload, fallbackType) => {\n const path = Array.isArray(payload.reasonPath) ? payload.reasonPath : [];\n const category = path[0] || {};\n const detail = path[1] || {};\n return {\n type: payload.reasonType || fallbackType || \"downtime\",\n categoryId: category.id || null,\n categoryLabel: category.label || null,\n detailId: detail.id || null,\n detailLabel: detail.label || null,\n reasonText: payload.reasonText || [\n category.label || \"\",\n detail.label || \"\"\n ].filter(Boolean).join(\" > \") || null,\n catalogVersion: payload.catalogVersion || reasonCatalog.version || null,\n incidentKey: payload.incidentKey || null\n };\n};\n\nconst mode = msg._mode || '';\nswitch (msg.action) {\n case \"upload-excel\":\n msg._mode = \"upload\";\n return finalize([msg, null, null, null]);\n case \"refresh-work-orders\":\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY created_at DESC;\";\n return finalize([null, msg, null, null]);\n case \"start-work-order\": {\n msg._mode = \"start-check-progress\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for start\", msg);\n return finalize([null, null, null, null]);\n }\n\n // Store order data temporarily for after DB query\n flow.set(\"pendingWorkOrder\", order);\n\n // Query database to check for existing progress\n msg.topic = \"SELECT cycle_count, good_parts, scrap_parts, progress_percent, target_qty, cavities_total, cavities_active FROM work_orders WHERE work_order_id = ? LIMIT 1\";\n msg.payload = [order.id];\n\n //(`[START-WO] Checking progress for WO ${order.id}`);\n return finalize([null, msg, null, null]);\n }\n case \"resume-work-order\": {\n const now = Date.now();\n state.productionStartTime = now;\n state.actualRunTime = 0;\n state.lastStartChangeTime = now;\n state.runSeconds = 0;\n state.stopSeconds = 0;\n state.kpiLastTick = null;\n msg._mode = \"resume\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for resume\", msg);\n return finalize([null, null, null, null]);\n }\n\n //node.warn(`[RESUME-WO] Resuming WO ${order.id} with existing progress`);\n\n // Set status to RUNNING without resetting progress\n msg.topic = \"UPDATE work_orders SET status = CASE WHEN work_order_id = ? THEN 'RUNNING' ELSE 'PENDING' END, updated_at = CASE WHEN work_order_id = ? THEN NOW() ELSE updated_at END, cavities_total = COALESCE(NULLIF(?,0), cavities_total), cavities_active = COALESCE(NULLIF(?,0), cavities_active) WHERE status <> 'DONE'\";\n msg.payload = [order.id, order.id, Number(order.cavities_total || order.cavitiesTotal || 0), Number(order.cavities || 0)];\n msg.startOrder = order;\n\n // Load existing values into global state\n // IMPORTANT: Also set scrapParts so goodParts calculation is correct\n order.scrapParts = Number(order.scrapParts) || 0;\n order.goodParts = Number(order.goodParts) || 0;\n\n attachMold(order);\n\n state.activeWorkOrder = order;\n state.cycleCount = Number(order.cycleCount) || 0;\n state.activeOrderHasProgress = true;\n flow.set(\"lastMachineState\", 0);\n state.scrapPromptIssuedFor = null;\n\n //node.warn(`[RESUME-WO] Set cycleCount=${order.cycleCount}, scrapParts=${order.scrapParts}, goodParts=${order.goodParts}`);\n return finalize([null, null, msg, null]);\n }\n case \"restart-work-order\": {\n const now = Date.now();\n state.productionStartTime = now;\n state.actualRunTime = 0;\n state.lastStartChangeTime = now;\n state.runSeconds = 0;\n state.stopSeconds = 0;\n state.kpiLastTick = null;\n msg._mode = \"restart\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for restart\", msg);\n return finalize([null, null, null, null]);\n }\n\n //node.warn(`[RESTART-WO] Restarting WO ${order.id} - resetting progress to 0`);\n\n // Reset progress in database AND set status to RUNNING\n msg.topic = \"UPDATE work_orders SET status = CASE WHEN work_order_id = ? THEN 'RUNNING' ELSE 'PENDING' END, cycle_count = 0, good_parts = 0, scrap_parts = 0, progress_percent = 0, updated_at = NOW(), cavities_total = COALESCE(NULLIF(?,0), cavities_total), cavities_active = COALESCE(NULLIF(?,0), cavities_active) WHERE work_order_id = ? OR status = 'RUNNING'\";\n msg.payload = [order.id, order.id, Number(order.cavities_total || order.cavitiesTotal || 0), Number(order.cavities || 0), order.id];\n msg.startOrder = order;\n\n // Initialize global state to 0\n order.scrapParts = 0;\n order.goodParts = 0;\n\n attachMold(order);\n\n state.activeWorkOrder = order;\n state.cycleCount = 0;\n state.activeOrderHasProgress = false;\n flow.set(\"lastMachineState\", 0);\n state.scrapPromptIssuedFor = null;\n\n //node.warn(`[RESTART-WO] Reset cycleCount=0, scrap=0, good=0`);\n return finalize([null, null, msg, null]);\n }\n case \"complete-work-order\": {\n state.productionStartTime = null;\n state.runSeconds = 0;\n state.stopSeconds = 0;\n state.kpiLastTick = null;\n msg._mode = \"complete\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for complete\", msg);\n return finalize([null, null, null, null]);\n }\n\n // Get final values from global state before clearing\n const activeOrder = state.activeWorkOrder || {};\n const finalCycleCount = Number(state.cycleCount || 0);\n const finalGoodParts = Number(activeOrder.goodParts) || 0;\n const finalScrapParts = Number(activeOrder.scrapParts) || 0;\n\n //node.warn(`[COMPLETE] Persisting final values: cycles=${finalCycleCount}, good=${finalGoodParts}, scrap=${finalScrapParts}`);\n\n msg.completeOrder = order;\n\n // SQL: Persist final counts AND set status to DONE\n msg.topic = \"UPDATE work_orders SET status = 'DONE', cycle_count = ?, good_parts = ?, scrap_parts = ?, progress_percent = 100, updated_at = NOW() WHERE work_order_id = ?\";\n msg.payload = [finalCycleCount, finalGoodParts, finalScrapParts, order.id];\n\n // Clear ALL state on completion\n state.activeWorkOrder = null;\n state.activeOrderHasProgress = false;\n state.trackingEnabled = false;\n state.productionStarted = false;\n state.kpiStartupMode = false;\n state.operatingTime = 0;\n state.lastCycleTime = null;\n state.cycleCount = 0;\n flow.set(\"lastMachineState\", 0);\n state.scrapPromptIssuedFor = null;\n state.actualRunTime = 0;\n state.lastStateChangeTime = null;\n\n if (state.lastState && typeof state.lastState === \"object\") {\n state.lastState = {\n ...state.lastState,\n activeWorkOrder: null,\n cycleCount: 0,\n goodParts: 0,\n scrapParts: 0,\n cycleTime: 0,\n actualCycleTime: 0,\n trackingEnabled: false,\n productionStarted: false,\n tsMs: Date.now()\n };\n }\n\n // ============================================================\n // HIGH SCRAP DETECTION\n // ============================================================\n const targetQty = Number(activeOrder.target) || 0;\n const scrapCount = finalScrapParts;\n const scrapPercent = targetQty > 0 ? (scrapCount / targetQty) * 100 : 0;\n\n // Trigger: Scrap > 10% of target quantity\n let anomalyMsg = null;\n if (scrapPercent > 10 && targetQty > 0) {\n const severity = scrapPercent > 25 ? 'critical' : 'warning';\n\n const highScrapAnomaly = {\n anomaly_type: 'high-scrap',\n severity: severity,\n title: `High Waste Detected`,\n description: `Work order completed with ${scrapCount} scrap parts (${scrapPercent.toFixed(1)}% of target ${targetQty}). Why is there so much waste?`,\n data: {\n scrap_count: scrapCount,\n target_quantity: targetQty,\n scrap_percent: Math.round(scrapPercent * 10) / 10,\n good_parts: finalGoodParts,\n total_cycles: finalCycleCount\n },\n kpi_snapshot: {\n oee: (msg.kpis && msg.kpis.oee) || state.currentKPIs?.oee || 0,\n availability: (msg.kpis && msg.kpis.availability) || state.currentKPIs?.availability || 0,\n performance: (msg.kpis && msg.kpis.performance) || state.currentKPIs?.performance || 0,\n quality: (msg.kpis && msg.kpis.quality) || state.currentKPIs?.quality || 0\n },\n work_order_id: order.id,\n cycle_count: finalCycleCount,\n tsMs: Date.now(),\n status: 'active'\n };\n\n //node.warn(`[HIGH SCRAP] Detected ${scrapPercent.toFixed(1)}% scrap on work order ${order.id}`);\n\n // Send to Event Logger (output 5)\n anomalyMsg = {\n topic: \"anomaly-detected\",\n payload: [highScrapAnomaly]\n };\n }\n\n //node.warn('[COMPLETE] Cleared all state flags');\n return finalize([null, null, null, msg, anomalyMsg]);\n }\n case \"get-current-state\": {\n // Single truth for UI: state.lastState\n const s = state.lastState || {};\n\n const validWorkOrders = state.workOrdersById || null;\n if (state.activeWorkOrder?.id && validWorkOrders && !validWorkOrders[state.activeWorkOrder.id]) {\n node.warn(`[STATE] activeWorkOrder ${state.activeWorkOrder.id} not in work order list; clearing`);\n state.activeWorkOrder = null;\n }\n\n const activeOrder = (state.activeWorkOrder && state.activeWorkOrder.id)\n ? state.activeWorkOrder\n : null;\n\n const trackingEnabled =\n (typeof s.trackingEnabled === \"boolean\" ? s.trackingEnabled : (state.trackingEnabled || false));\n\n const productionStarted =\n (typeof s.productionStarted === \"boolean\" ? s.productionStarted : (state.productionStarted || false));\n\n const kpis =\n (s.kpis && typeof s.kpis === \"object\")\n ? s.kpis\n : (state.currentKPIs || { oee: 0, availability: 0, performance: 0, quality: 0 });\n\n const cavities = Number(activeOrder?.cavities ?? state.lastMoldActive ?? s.cavities ?? 0) || null;\n\n msg._mode = \"current-state\";\n msg.payload = {\n machineId: s.machineId ?? config.machineId ?? undefined,\n\n activeWorkOrder: activeOrder,\n\n // add the fields your UI / home tab might show\n cycleCount: s.cycleCount,\n goodParts: s.goodParts,\n scrapParts: s.scrapParts,\n cavities,\n\n cycleTime: s.cycleTime,\n actualCycleTime: s.actualCycleTime,\n\n trackingEnabled,\n productionStarted,\n kpis,\n reasonCatalog,\n\n tsMs: s.tsMs\n };\n\n return finalize([null, msg, null, null]);\n }\n case \"restore-session\": {\n // Query DB for any RUNNING work order on startup\n msg._mode = \"restore-query\";\n msg.topic = \"SELECT * FROM work_orders WHERE status = 'RUNNING' LIMIT 1\";\n msg.payload = [];\n //node.warn('[RESTORE] Checking for running work order on startup');\n return finalize([null, msg, null, null]);\n }\n case \"scrap-entry\": {\n const { id, scrap } = msg.payload || {};\n const scrapNum = Number(scrap) || 0;\n\n if (!id) {\n node.error(\"No work order id supplied for scrap entry\", msg);\n return finalize([null, null, null, null]);\n }\n\n const activeOrder = state.activeWorkOrder;\n if (activeOrder && activeOrder.id === id) {\n activeOrder.scrapParts = (Number(activeOrder.scrapParts) || 0) + scrapNum;\n state.activeWorkOrder = activeOrder;\n }\n\n state.scrapPromptIssuedFor = null;\n\n msg._mode = \"scrap-update\";\n msg.scrapEntry = { id, scrap: scrapNum, reason: null };\n // SQL with bound parameters for safety\n msg.topic = \"UPDATE work_orders SET scrap_parts = scrap_parts + ?, updated_at = NOW() WHERE work_order_id = ?\";\n msg.payload = [scrapNum, id];\n\n return finalize([null, null, msg, null]);\n }\n case \"scrap-entry-with-reason\": {\n const { id, scrap } = msg.payload || {};\n const scrapNum = Number(scrap) || 0;\n const reason = normalizeReason(msg.payload || {}, \"scrap\");\n\n if (!id) {\n node.error(\"No work order id supplied for scrap entry with reason\", msg);\n return finalize([null, null, null, null, null, null]);\n }\n\n const activeOrder = state.activeWorkOrder;\n if (activeOrder && activeOrder.id === id) {\n activeOrder.scrapParts = (Number(activeOrder.scrapParts) || 0) + scrapNum;\n state.activeWorkOrder = activeOrder;\n }\n\n state.scrapPromptIssuedFor = null;\n state.lastScrapReasonByOrder = state.lastScrapReasonByOrder || {};\n state.lastScrapReasonByOrder[id] = reason;\n\n const tsMs = Date.now();\n const outEvent = {\n tsMs,\n eventType: \"scrap-manual-entry\",\n workOrderId: id,\n scrapDelta: scrapNum,\n source: \"home-ui\",\n // Keep compatibility for current event ingest.\n reason,\n // Explicit split marker for future downtime-specific stream.\n downtime: null\n };\n\n msg._mode = \"scrap-update\";\n msg.scrapEntry = { id, scrap: scrapNum, reason };\n msg.topic = \"UPDATE work_orders SET scrap_parts = scrap_parts + ?, updated_at = NOW() WHERE work_order_id = ?\";\n msg.payload = [scrapNum, id];\n\n // Output 3 -> DB update path, Output 6 -> event outbox builder path.\n return finalize([null, null, msg, null, null, { payload: outEvent, tsMs }]);\n }\n case \"scrap-open\": {\n const active = state.activeWorkOrder || null;\n if (!active?.id) return finalize([null, null, null, null, null]);\n\n const good = Number(active.goodParts) || 0;\n const scrap = Number(active.scrapParts) || 0;\n const produced = good + scrap;\n\n msg._mode = \"scrap-prompt\";\n msg.scrapPrompt = {\n id: active.id,\n sku: active.sku || \"\",\n target: Number(active.target) || 0,\n produced,\n scrapSoFar: scrap,\n\n manual: true,\n title: \"Registrar scrap (parcial)\",\n enterMode: true,\n validateMax: false,\n reasonCatalog: reasonCatalog\n };\n\n // IMPORTANT: don't set msg.topic to a string here\n delete msg.topic;\n delete msg.payload;\n\n // Output 2 feeds Back to UI in your wiring\n return finalize([null, msg, null, null, null]);\n }\n\n\n case \"scrap-skip\": {\n const { id, remindAgain } = msg.payload || {};\n\n if (!id) {\n node.error(\"No work order id supplied for scrap skip\", msg);\n return finalize([null, null, null, null]);\n }\n\n if (remindAgain) {\n state.scrapPromptIssuedFor = null;\n }\n\n msg._mode = \"scrap-skipped\";\n return finalize([null, null, null, null]);\n }\n case \"start\": {\n // START with KPI timestamp init - FIXED\n const now = Date.now();\n const shifts = settings.shifts || [{ start: '06:00', end: '13:00' }];\n const shiftChangeComp = settings.shiftChangeCompensation || 10; // minutes\n const lunchBreak = settings.lunchBreakMinutes || 30; // minutes\n\n\n let totalShiftSeconds = 0;\n shifts.forEach(shift => {\n const [startH, startM] = (shift.start || '06:00').split(':').map(Number);\n const [endH, endM] = (shift.end || '15:00').split(':').map(Number);\n\n let startMinutes = startH * 60 + startM;\n let endMinutes = endH * 60 + endM;\n\n // Handle overnight shifts\n if (endMinutes <= startMinutes) {\n endMinutes += 24 * 60;\n }\n\n totalShiftSeconds += (endMinutes - startMinutes) * 60;\n });\n const compensationSeconds = shifts.length * shiftChangeComp * 60; // shift change per shift\n const lunchSeconds = lunchBreak * 60;\n const plannedProductionTime = Math.max(0, totalShiftSeconds - compensationSeconds - lunchSeconds);\n state.plannedProductionTime = plannedProductionTime;\n\n const existingCycles = Number(state.cycleCount || 0);\n const activeOrderState = state.activeWorkOrder || {};\n const orderGood = Number(activeOrderState.goodParts) || 0;\n const orderScrap = Number(activeOrderState.scrapParts) || 0;\n\n const hasProgressFlag = state.activeOrderHasProgress;\n\n // We consider there is progress only if this order has produced something\n const hasProgress = (typeof hasProgressFlag === 'boolean')\n ? hasProgressFlag\n : (\n existingCycles > 0 ||\n (orderGood + orderScrap) > 0\n );\n\n if (!hasProgress) {\n state.stopTime = 0; // Initialize stop time\n state.trackingEnabled = true;\n state.trackingEnabled = true;\n state.productionStarted = true;\n state.kpiStartupMode = true;\n state.kpiBuffer = [];\n state.lastKPIRecordTime = now - 60000;\n state.productionStartTime = now;\n state.lastMachineCycleTime = now;\n state.lastCycleTime = now;\n state.operatingTime = 0;\n state.actualRunTime = 0;\n state.lastStartChangeTime = now;\n state.lastCycleCompletionTime = null; // Will be set on first cycle\n state.runSeconds = 0;\n state.stopSeconds = 0;\n state.kpiLastTick = null;\n } else {\n state.trackingEnabled = true;\n state.productionStarted = true;\n // Do NOT go back into startup mode\n state.kpiStartupMode = false;\n }\n\n //node.warn('[START] Initialized: trackingEnabled=true, productionStarted=true, kpiStartupMode=true, operatingTime=0');\n //node.warn(`[START] Planned production time: ${(plannedProductionTime / 3600).toFixed(2)} hours`);\n\n const activeOrder = state.activeWorkOrder || {};\n msg._mode = \"production-state\";\n\n msg.payload = msg.payload || {};\n\n msg.trackingEnabled = true;\n msg.productionStarted = true;\n msg.machineOnline = true;\n\n msg.payload.trackingEnabled = true;\n msg.payload.productionStarted = true;\n msg.payload.machineOnline = true;\n\n return finalize([null, msg, null, null]);\n }\n case \"stop\": {\n state.trackingEnabled = false;\n state.productionStarted = false;\n //node.warn('[STOP] Set trackingEnabled=false, productionStarted=false');\n\n // Send UI update so button state reflects change\n msg._mode = \"production-state\";\n msg.payload = msg.payload || {};\n msg.trackingEnabled = false;\n msg.productionStarted = false;\n msg.machineOnline = true;\n msg.payload.trackingEnabled = false;\n msg.payload.productionStarted = false;\n msg.payload.machineOnline = true;\n\n const s = state.lastState || {};\n s.trackingEnabled = false;\n s.productionStarted = false;\n s.tsMs = Date.now();\n state.lastState = s;\n\n return finalize([null, msg, null, null]);\n }\n case \"start-tracking\": {\n const activeOrder = state.activeWorkOrder || {};\n\n if (!activeOrder.id) {\n return finalize([null, { topic: \"alert\", payload: \"Error: No active work order loaded.\" }, null, null]);\n }\n\n const now = Date.now();\n state.trackingEnabled = true;\n\n // reset KPI timers/counters (keep what you had)\n state.kpiBuffer = [];\n state.lastKPIRecordTime = now - 60000;\n state.lastMachineCycleTime = now;\n state.lastCycleTime = now;\n state.operatingTime = 0.001;\n\n // IMPORTANT: do NOT set productionStarted here (that’s machine physical state)\n const stateMsg = {\n _mode: \"production-state\",\n payload: {\n trackingEnabled: true,\n machineOnline: true\n }\n };\n\n return finalize([null, stateMsg, null, null]);\n }\n}\n",
+ "outputs": 6,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1150,
+ "y": 680,
+ "wires": [
+ [
+ "c211a24ab8802cea"
+ ],
+ [
+ "75da58a36bdf1746",
+ "d6faedde4bce4968",
+ "6b865662639d7c1c",
+ "53629ebb30f7c4fd",
+ "0500019de35fd310"
+ ],
+ [
+ "20896582916b31a3",
+ "75da58a36bdf1746",
+ "53629ebb30f7c4fd",
+ "c81504b31f50c862"
+ ],
+ [
+ "20896582916b31a3"
+ ],
+ [
+ "6ffa372a2f28189c"
+ ],
+ [
+ "4b42a4e4bb445e89"
+ ]
+ ]
+ },
+ {
+ "id": "ca38c135cc1b4372",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "link out 2",
+ "mode": "link",
+ "links": [
+ "c318de0423a67dde"
+ ],
+ "x": 585,
+ "y": 300,
+ "wires": []
+ },
+ {
+ "id": "c318de0423a67dde",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link in 2",
+ "links": [
+ "ca38c135cc1b4372"
+ ],
+ "x": 1305,
+ "y": 540,
+ "wires": [
+ [
+ "d661918dc9f12935"
+ ]
+ ]
+ },
+ {
+ "id": "b3cc06435fa6f3e2",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Build Insert SQL",
+ "func": "const rows = Array.isArray(msg.payload) ? msg.payload : [];\n\nconst values = rows\n .map((r) => {\n const id = String(r[\"Work Order ID\"] ?? \"\").trim();\n if (!id) return null;\n\n const sku = String(r[\"SKU\"] ?? \"\").trim();\n const targetQty = Number(r[\"Target Quantity\"]) || 0;\n const cycleTime =\n Number(r[\"Theoretical Cycle Time (Seconds)\"]) || 0;\n\n return [id, sku, targetQty, cycleTime, \"PENDING\"];\n })\n .filter(Boolean);\n\nif (!values.length) {\n return null;\n}\n\nmsg.topic = `\n INSERT INTO work_orders\n (work_order_id, sku, target_qty, cycle_time, status)\n VALUES ?\n ON DUPLICATE KEY UPDATE\n sku = VALUES(sku),\n target_qty = VALUES(target_qty),\n cycle_time = VALUES(cycle_time);\n`;\n\nmsg.payload = [values];\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1800,
+ "y": 500,
+ "wires": [
+ [
+ "20896582916b31a3"
+ ]
+ ]
+ },
+ {
+ "id": "20896582916b31a3",
+ "type": "mysql",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "mydb": "fc9634aabefee16b",
+ "name": "mariaDB",
+ "x": 1820,
+ "y": 920,
+ "wires": [
+ [
+ "0a04246503cc5d61"
+ ]
+ ]
+ },
+ {
+ "id": "15aa2bf7e2b87ff7",
+ "type": "mysql",
+ "z": "a79ad45246b8dac2",
+ "g": "8699169113c62240",
+ "mydb": "fc9634aabefee16b",
+ "name": "mariaDB (Graph Data)",
+ "x": 1600,
+ "y": 80,
+ "wires": [
+ [
+ "397ad156aa93c126"
+ ]
+ ]
+ },
+ {
+ "id": "d6faedde4bce4968",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Back to UI",
+ "func": "const mode = msg._mode || '';\nconst started = msg.startOrder || null;\nconst completed = msg.completeOrder || null;\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\n\nconst loadMoldCache = () => {\n let cache = null;\n try {\n cache = global.get(\"moldCache\", \"file\");\n } catch (err) {\n cache = null;\n }\n if (!cache || typeof cache !== \"object\") {\n cache = global.get(\"moldCache\") || {};\n }\n return cache;\n};\n\nconst moldCache = loadMoldCache();\nconst cachedByWorkOrder = (moldCache && typeof moldCache.moldByWorkOrder === \"object\")\n ? moldCache.moldByWorkOrder\n : {};\n\nstate.moldByWorkOrder = state.moldByWorkOrder || cachedByWorkOrder || {};\nif (!Number.isFinite(state.lastMoldActive) && Number.isFinite(moldCache.lastMoldActive)) {\n state.lastMoldActive = moldCache.lastMoldActive;\n}\nif (!Number.isFinite(state.lastMoldTotal) && Number.isFinite(moldCache.lastMoldTotal)) {\n state.lastMoldTotal = moldCache.lastMoldTotal;\n}\n\nconst persistMoldCache = () => {\n const cache = {\n lastMoldActive: state.lastMoldActive ?? null,\n lastMoldTotal: state.lastMoldTotal ?? null,\n moldByWorkOrder: state.moldByWorkOrder || {}\n };\n global.set(\"moldCache\", cache);\n try {\n global.set(\"moldCache\", cache, \"file\");\n } catch (err) {\n // ignore if file store is not configured\n }\n};\n\nconst attachMold = (order) => {\n if (!order || !order.id) return;\n const map = state.moldByWorkOrder || {};\n const mapped = map[order.id] || {};\n const resolved = Number(\n order.cavities ?? mapped.active ?? state.lastMoldActive ?? settings.moldActive ?? 0\n );\n if (!Number.isFinite(resolved) || resolved <= 0) return;\n order.cavities = resolved;\n state.lastMoldActive = resolved;\n map[order.id] = {\n total: Number.isFinite(mapped.total) && mapped.total > 0 ? mapped.total : (state.lastMoldTotal ?? null),\n active: resolved\n };\n state.moldByWorkOrder = map;\n};\n\nconst finalize = (ret) => {\n global.set(\"state\", state);\n persistMoldCache();\n return ret;\n};\nconst toIsoString = (v) => {\n if (!v) return null;\n if (typeof v === \"string\") return v;\n if (v instanceof Date) return v.toISOString();\n if (typeof v.toISOString === \"function\") return v.toISOString(); // date-like\n if (typeof v.toISO === \"function\") return v.toISO(); // luxon\n if (typeof v.format === \"function\") return v.format(); // moment\n return String(v);\n};\n\ndelete msg._mode;\ndelete msg.startOrder;\ndelete msg.completeOrder;\ndelete msg.action;\ndelete msg.filename;\n\n//node.warn(`[ROUTER] mode=\"${mode}\" action=\"${msg.action}\"`);\n\n// ========================================================\n// MODE: UPLOAD\n// ========================================================\nif (mode === \"upload\") {\n msg.topic = \"uploadStatus\";\n msg.payload = { message: \"✅ Work orders uploaded successfully.\" };\n return finalize([msg, null, null, null]);\n}\n\n// ========================================================\n// MODE: SELECT (Load Work Orders)\n// ========================================================\nif (mode === \"select\") {\n const rawRows = Array.isArray(msg.payload) ? msg.payload : [];\n msg.topic = \"workOrdersList\";\n msg.payload = rawRows.map(row => ({\n id: row.work_order_id ?? row.id ?? \"\",\n sku: row.sku ?? \"\",\n target: Number(row.target_qty ?? row.target ?? 0),\n goodParts: Number(row.good_parts ?? row.goodParts ?? 0),\n scrapParts: Number(row.scrap_count ?? row.scrap_parts ?? row.scrapParts ?? 0),\n progressPercent: Number(row.progress_percent ?? row.progress ?? 0),\n status: (row.status ?? \"PENDING\").toUpperCase(),\n lastUpdateIso: toIsoString(row.updated_at ?? row.last_update),\n cycleTime: Number(row.cycle_time ?? row.theoretical_cycle_time ?? 0)\n }));\n return finalize([msg, null, null, null]);\n}\n\n// ========================================================\n// MODE: START WORK ORDER\n// ========================================================\nif (mode === \"start\") {\n const order = started || {};\n attachMold(order);\n const kpis = msg.kpis || state.currentKPIs || {\n oee: 0, availability: 0, performance: 0, quality: 0\n };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: order.id || \"\",\n sku: order.sku || \"\",\n target: Number(order.target) || 0,\n goodParts: Number(order.goodParts) || 0,\n scrapParts: Number(order.scrapParts) || 0,\n cycleTime: Number(order.cycleTime || order.theoreticalCycleTime || 0),\n progressPercent: Number(order.progressPercent) || 0,\n lastUpdateIso: toIsoString(order.lastUpdateIso) || null,\n kpis: kpis\n }\n };\n\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: COMPLETE WORK ORDER\n// ========================================================\nif (mode === \"complete\") {\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: CYCLE UPDATE DURING PRODUCTION\n// ========================================================\nif (mode === \"cycle\") {\n const cycle = msg.cycle || {};\n\n const workOrderMsg = {\n topic: \"workOrderCycle\",\n payload: {\n id: cycle.id || \"\",\n sku: cycle.sku || \"\",\n target: Number(cycle.target) || 0,\n goodParts: Number(cycle.goodParts) || 0,\n scrapParts: Number(cycle.scrapParts) || 0,\n progressPercent: Number(cycle.progressPercent) || 0,\n lastUpdateIso: toIsoString(cycle.lastUpdateIso) || null,\n status: cycle.progressPercent >= 100 ? \"DONE\" : \"RUNNING\"\n }\n };\n\n const kpis = msg.kpis || state.currentKPIs || {\n oee: 0, availability: 0, performance: 0, quality: 0\n };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: cycle.id || \"\",\n sku: cycle.sku || \"\",\n target: Number(cycle.target) || 0,\n goodParts: Number(cycle.goodParts) || 0,\n scrapParts: Number(cycle.scrapParts) || 0,\n cycleTime: Number(cycle.cycleTime) || 0,\n progressPercent: Number(cycle.progressPercent) || 0,\n lastUpdateIso: toIsoString(cycle.lastUpdateIso) || new Date().toISOString(),\n kpis: kpis\n }\n };\n\n return finalize([workOrderMsg, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: MACHINE PRODUCTION STATE\n// ========================================================\nif (mode === \"production-state\") {\n const homeMsg = {\n topic: \"machineStatus\",\n payload: {\n machineOnline: msg.machineOnline ?? true,\n productionStarted: !!msg.productionStarted,\n trackingEnabled: msg.payload?.trackingEnabled ?? msg.trackingEnabled ?? false\n }\n };\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: CURRENT STATE (for tab switch sync)\n// ========================================================\nif (mode === \"current-state\") {\n const state = msg.payload || {};\n const homeMsg = {\n topic: \"currentState\",\n payload: {\n activeWorkOrder: state.activeWorkOrder,\n trackingEnabled: state.trackingEnabled,\n productionStarted: state.productionStarted,\n kpis: state.kpis\n }\n };\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: RESTORE QUERY (startup state recovery)\n// ========================================================\nif (mode === \"restore-query\") {\n const rows = Array.isArray(msg.payload) ? msg.payload : [];\n\n if (rows.length > 0) {\n const row = rows[0];\n const restoredOrder = {\n id: row.work_order_id || row.id || \"\",\n sku: row.sku || \"\",\n target: Number(row.target_qty || row.target || 0),\n goodParts: Number(row.good_parts || row.goodParts || 0),\n scrapParts: Number(row.scrap_parts || row.scrapParts || 0),\n progressPercent: Number(row.progress_percent || 0),\n cycleTime: Number(row.cycle_time || 0),\n lastUpdateIso: toIsoString(row.updated_at ?? row.last_update),\n cavities: Number(row.cavities_active || 0),\n cavities_total: Number(row.cavities_total || 0)\n };\n\n attachMold(restoredOrder);\n\n if (Number(restoredOrder.cavities) > 0) {\n state.lastMoldActive = Number(restoredOrder.cavities);\n }\n if (Number(restoredOrder.cavities_total) > 0) {\n state.lastMoldTotal = Number(restoredOrder.cavities_total);\n }\n if (restoredOrder.id && Number(restoredOrder.cavities) > 0) {\n const map = state.moldByWorkOrder || {};\n map[restoredOrder.id] = {\n total: Number(restoredOrder.cavities_total) > 0 ? Number(restoredOrder.cavities_total) : (state.lastMoldTotal ?? null),\n active: Number(restoredOrder.cavities)\n };\n state.moldByWorkOrder = map;\n }\n\n const restoredCycleCount = Number(row.cycle_count) || 0;\n const restoredGood = Number(row.good_parts || 0);\n const restoredScrap = Number(row.scrap_parts || 0);\n const hasProgress = restoredCycleCount > 0 || (restoredGood + restoredScrap) > 0;\n\n // Restore global state\n state.activeWorkOrder = restoredOrder;\n state.cycleCount = restoredCycleCount;\n // Auto-resume tracking for RUNNING work order\n state.trackingEnabled = true;\n state.productionStarted = true;\n state.kpiStartupMode = !hasProgress;\n\n // Reset tick so KPI integration doesn't jump\n state.kpiLastTick = null;\n\n let restoreTs = Date.parse(row.updated_at || \"\");\n if (!isFinite(restoreTs)) {\n restoreTs = Date.now();\n }\n state.lastMachineCycleTime = restoreTs;\n state.lastCycleCompletionTime = restoreTs;\n\n node.warn('[RESTORE] Restored work order: ' + restoredOrder.id + ' with ' + state.cycleCount + ' cycles');\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: restoredOrder\n };\n\n const stateMsg = {\n topic: \"machineStatus\",\n payload: {\n machineOnline: true,\n productionStarted: true,\n trackingEnabled: true\n }\n };\n\n // Set status back to RUNNING in database (if not already DONE)\n // This prevents user from having to \"Load\" the work order again\n const dbMsg = {\n topic: \"UPDATE work_orders SET status = 'RUNNING', updated_at = NOW() WHERE work_order_id = ? AND status != 'DONE'\",\n payload: [restoredOrder.id]\n };\n\n\n return finalize([dbMsg, [homeMsg, stateMsg], null, null]);\n } else {\n node.warn('[RESTORE] No running work order found');\n }\n return finalize([null, null, null, null]);\n}\n\n// ========================================================\n// MODE: SCRAP PROMPT\n// ========================================================\nif (mode === \"scrap-prompt\") {\n const prompt = msg.scrapPrompt || {};\n\n const homeMsg = { topic: \"scrapPrompt\", payload: prompt };\n const tabMsg = { ui_control: { tab: \"Home\" } };\n\n // output1: nothing\n // output2: home template\n // output3: tab navigation\n // output4: graphs template (unused here)\n return finalize([null, homeMsg, tabMsg, null]);\n}\n\n// ========================================================\n// MODE: SCRAP UPDATE\n// ========================================================\nif (mode === \"scrap-update\") {\n const activeOrder = state.activeWorkOrder || {};\n const kpis = msg.kpis || state.currentKPIs || {\n oee: 0, availability: 0, performance: 0, quality: 0\n };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: activeOrder.id || \"\",\n sku: activeOrder.sku || \"\",\n target: Number(activeOrder.target) || 0,\n goodParts: Number(activeOrder.goodParts) || 0,\n scrapParts: Number(activeOrder.scrapParts) || 0,\n cycleTime: Number(activeOrder.cycleTime) || 0,\n progressPercent: Number(activeOrder.progressPercent) || 0,\n lastUpdateIso: toIsoString(activeOrder.lastUpdateIso) || new Date().toISOString(),\n kpis: kpis\n }\n };\n\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: SCRAP COMPLETE\n// ========================================================\nif (mode === \"scrap-complete\") {\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: CHARTS → SEND REAL DATA TO GRAPH TEMPLATE\n// ========================================================\n//if (mode === \"charts\") {\n\n// const realOEE = msg.realOEE || state.realOEE || [];\n// const realAvailability = msg.realAvailability || state.realAvailability || [];\n// const realPerformance = msg.realPerformance || state.realPerformance || [];\n// const realQuality = msg.realQuality || state.realQuality || [];\n\n// const chartsMsg = {\n// topic: \"chartsData\",\n// payload: {\n// oee: realOEE,\n// availability: realAvailability,\n// performance: realPerformance,\n// quality: realQuality\n// }\n// };\n\n// Send ONLY to output #4\n// return [null, null, null, chartsMsg];\n//}\n\n\n// ========================================================\n// MODE: RESUME-PROMPT\n// ========================================================\nif (mode === \"resume-prompt\") {\n const order = msg.payload.order || null;\n\n if (order) {\n attachMold(order);\n state.activeWorkOrder = order;\n node.warn(\n `[RESUME-PROMPT] activeWorkOrder set to ${order.id}`\n );\n }\n\n const homeMsg = {\n topic: msg.topic || \"resumePrompt\",\n payload: msg.payload,\n };\n\n const kpis =\n msg.kpis ||\n state.currentKPIs || {\n oee: 0,\n availability: 0,\n performance: 0,\n quality: 0,\n };\n\n const activeMsg = order\n ? {\n topic: \"activeWorkOrder\",\n payload: {\n id: order.id || \"\",\n sku: order.sku || \"\",\n target: Number(order.target) || 0,\n goodParts: Number(order.goodParts) || 0,\n scrapParts: Number(order.scrapParts) || 0,\n cycleTime: Number(\n order.cycleTime ||\n order.theoreticalCycleTime ||\n 0\n ),\n progressPercent: Number(\n msg.payload?.progressPercent ??\n order.progressPercent ??\n 0\n ),\n lastUpdateIso: order.lastUpdateIso || null,\n kpis,\n },\n }\n : null;\n\n return finalize([\n null,\n activeMsg ? [activeMsg, homeMsg] : homeMsg,\n null,\n null,\n ]);\n}\n\n\n// ========================================================\n// DEFAULT\n// ========================================================\nreturn finalize([null, null, null, null]);",
+ "outputs": 4,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2190,
+ "y": 560,
+ "wires": [
+ [
+ "7003a0ca507e78b1"
+ ],
+ [
+ "452e154b10587c66"
+ ],
+ [
+ "d2dae8800dec5243"
+ ],
+ []
+ ]
+ },
+ {
+ "id": "7003a0ca507e78b1",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link out 3",
+ "mode": "link",
+ "links": [
+ "c13a25c43b7de196"
+ ],
+ "x": 2305,
+ "y": 520,
+ "wires": []
+ },
+ {
+ "id": "c13a25c43b7de196",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "link in 3",
+ "links": [
+ "7003a0ca507e78b1"
+ ],
+ "x": 275,
+ "y": 320,
+ "wires": [
+ [
+ "87f0530e5407fe7b"
+ ]
+ ]
+ },
+ {
+ "id": "b0013e3d357766cb",
+ "type": "book",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "",
+ "raw": false,
+ "x": 1810,
+ "y": 460,
+ "wires": [
+ [
+ "48a58c7d8a6d150a"
+ ]
+ ]
+ },
+ {
+ "id": "48a58c7d8a6d150a",
+ "type": "sheet",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "",
+ "sheetName": "Sheet1",
+ "x": 1930,
+ "y": 460,
+ "wires": [
+ [
+ "ac79b131321a4151"
+ ]
+ ]
+ },
+ {
+ "id": "ac79b131321a4151",
+ "type": "sheet-to-json",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "",
+ "raw": "false",
+ "range": "",
+ "header": "default",
+ "blankrows": false,
+ "x": 2070,
+ "y": 460,
+ "wires": [
+ [
+ "b3cc06435fa6f3e2"
+ ]
+ ]
+ },
+ {
+ "id": "c211a24ab8802cea",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Base64",
+ "func": "const filename =\n msg.filename ||\n (msg.meta && msg.meta.filename) ||\n (msg.payload && msg.payload.filename) ||\n msg.name ||\n 'upload.xlsx';\n\nconst candidates = [];\nif (typeof msg.payload === 'string') candidates.push(msg.payload);\nif (msg.payload && typeof msg.payload.payload === 'string') candidates.push(msg.payload.payload);\nif (msg.payload && typeof msg.payload.file === 'string') candidates.push(msg.payload.file);\nif (msg.payload && typeof msg.payload.base64 === 'string') candidates.push(msg.payload.base64);\nif (typeof msg.file === 'string') candidates.push(msg.file);\nif (typeof msg.data === 'string') candidates.push(msg.data);\n\nfunction stripDataUrl(s) {\n return (s && s.startsWith('data:')) ? s.split(',')[1] : s;\n}\n\nlet b64 = candidates.map(stripDataUrl).find(s => typeof s === 'string' && s.length > 0);\nif (!b64 && Buffer.isBuffer(msg.payload)) { msg.filename = filename; return msg; }\nif (!b64) { node.error('No base64 data found on msg', msg); return null; }\n\nmsg.payload = Buffer.from(b64, 'base64');\nmsg.filename = filename;\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1680,
+ "y": 460,
+ "wires": [
+ [
+ "b0013e3d357766cb"
+ ]
+ ]
+ },
+ {
+ "id": "452e154b10587c66",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link out 4",
+ "mode": "link",
+ "links": [
+ "5a233615f7c12124"
+ ],
+ "x": 2305,
+ "y": 560,
+ "wires": []
+ },
+ {
+ "id": "5a233615f7c12124",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "link in 4",
+ "links": [
+ "452e154b10587c66"
+ ],
+ "x": 275,
+ "y": 280,
+ "wires": [
+ [
+ "8528aff42bbfaab3"
+ ]
+ ]
+ },
+ {
+ "id": "c9b94806462e9787",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Refresh Trigger",
+ "func": "if (msg._mode === \"sync-work-orders\") {\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY updated_at DESC;\";\n return [msg, null];\n}\nif (msg._mode === \"start\" || msg._mode === \"complete\" || msg._mode === \"resume\" || msg._mode === \"restart\") {\n // Preserve original message for Back to UI (output 2)\n const originalMsg = {...msg};\n // Create select message for refreshing WO table (output 1)\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY updated_at DESC;\";\n return [msg, originalMsg];\n}\nif (msg._mode === \"cycle\" || msg._mode === \"production-state\") {\n return [null, msg];\n}\nif (msg._mode === \"scrap-prompt\") {\n return [null, msg];\n}\nif (msg._mode === \"restore-query\") {\n // Pass restore query results to Back to UI\n return [null, msg];\n}\nif (msg._mode === \"current-state\") {\n // Pass current state to Back to UI\n return [null, msg];\n}\nif (msg._mode === \"scrap-complete\") {\n // Preserve original message for Back to UI (output 2)\n const originalMsg = {...msg};\n // Create select message for refreshing WO table (output 1)\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY updated_at DESC;\";\n return [msg, originalMsg];\n}\nreturn [null, msg];",
+ "outputs": 2,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1960,
+ "y": 600,
+ "wires": [
+ [
+ "20896582916b31a3"
+ ],
+ [
+ "d6faedde4bce4968"
+ ]
+ ]
+ },
+ {
+ "id": "decce87e728471dd",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "92105e5a6e17066a",
+ "name": "Machine cycles",
+ "func": "const current = Number(msg.payload) || 0;\nconst now = Date.now();\n\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\nconst saveState = () => global.set(\"state\", state);\nconst finalize = (ret) => { saveState(); return ret; };\n\nlet zeroStreak = flow.get(\"zeroStreak\") || 0;\nzeroStreak = current === 0 ? zeroStreak + 1 : 0;\nflow.set(\"zeroStreak\", zeroStreak);\n\nconst prev = flow.get(\"lastMachineState\") ?? 0;\n\n// =============================================\n// TRACK ACTUAL RUN TIME (must happen BEFORE any early returns)\n// Only track if we have an active work order and tracking is enabled\n// =============================================\nconst activeOrder = state.activeWorkOrder;\nconst trackingEnabled = !!state.trackingEnabled;\n\n//if (trackingEnabled && activeOrder && activeOrder.id) {\n // =============================================\n // THRESHOLD-BASED STOPPAGE DETECTION\n // Runs on EVERY state change to detect gaps\n // =============================================\n /*\n const lastCycleTime = state.lastCycleCompletionTime || now;\n const timeSinceLastCycle = now - lastCycleTime;\n const deltaSeconds = timeSinceLastCycle / 1000;\n \n const thresholdMultiplier = settings.thresholdMultiplier || 1.5;\n const Tideal = Number(activeOrder.cycleTime) || 5; // seconds\n const ThresholdParo = Tideal * thresholdMultiplier;\n \n // Only analyze gaps when we have a previous cycle to compare against\n // and when transitioning TO state=1 (cycle completion)\n if (current === 1 && prev === 0 && state.lastCycleCompletionTime) {\n let operatingTime = state.operatingTime || 0;\n let stopTime = state.stopTime || 0;\n \n if (deltaSeconds <= ThresholdParo) {\n // Normal operation - all time counts as running\n operatingTime += deltaSeconds;\n } else {\n // Gap detected - split into running + stopped\n operatingTime += Tideal; // Credit one ideal cycle worth (machine was running for that)\n stopTime += (deltaSeconds - Tideal); // Rest is unplanned downtime\n node.warn(`[STOPPAGE] Detected ${(deltaSeconds - Tideal).toFixed(1)}s downtime (gap: ${deltaSeconds.toFixed(1)}s, threshold: ${ThresholdParo.toFixed(1)}s)`);\n }\n \n state.operatingTime = operatingTime;\n state.stopTime = stopTime;\n }\n \n // =============================================\n // LEGACY: State-based run time tracking (keep as backup/comparison)\n // =============================================\n if (prev === 1) {\n const lastStateChange = state.lastStateChangeTime || now;\n const runDuration = (now - lastStateChange) / 1000;\n\n if (runDuration > 0 && runDuration < 3600) {\n let actualRunTime = state.actualRunTime || 0;\n actualRunTime += runDuration;\n state.actualRunTime = actualRunTime;\n }\n }\n */\n//}\n\n// ALWAYS update state tracking (before any early returns)\nstate.lastStateChangeTime = now;\nflow.set(\"lastMachineState\", current);\n\n// =============================================\n// MACHINE ONLINE STATUS\n// =============================================\nstate.machineOnline = true;\n\nlet productionRunning = !!state.productionStarted;\nlet stateChanged = false;\n\nif (current === 1 && !productionRunning) {\n productionRunning = true;\n stateChanged = true;\n} else if (current === 0 && zeroStreak >= 2 && productionRunning) {\n productionRunning = false;\n stateChanged = true;\n}\n\nstate.productionStarted = productionRunning;\n\nconst stateMsg = stateChanged\n ? {\n _mode: \"production-state\",\n machineOnline: true,\n productionStarted: productionRunning\n }\n : null;\n\n// =============================================\n// EARLY EXIT CONDITIONS\n// =============================================\nconst cavities = Number(settings.moldActive) || 1;\nactiveOrder.cavities = cavities;\n\nif (!activeOrder || !activeOrder.id || cavities <= 0) {\n return finalize([null, stateMsg, { _triggerKPI: true }, null]);\n}\n\nif (!trackingEnabled) {\n return finalize([null, stateMsg, { _triggerKPI: true }, null]);\n}\n\n// Only count cycles on rising edge (0→1)\nif (prev === 1 || current !== 1) {\n return finalize([null, stateMsg, { _triggerKPI: true }, null]);\n}\n\n// =============================================\n// CYCLE COUNTING (only reached on 0→1 transition)\n// =============================================\n\nconst lastCompletion = state.lastCycleCompletionTime;\nif (lastCompletion) {\n const actualCycleTime = (now - lastCompletion) / 1000; // seconds\n state.lastActualCycleTime = actualCycleTime;\n}\n\nlet cycles = Number(state.cycleCount || 0) + 1;\nstate.cycleCount = cycles;\n// Track when this cycle completed (for gap analysis)\nstate.lastCycleCompletionTime = now;\n\n// Clear startup mode after first real cycle\nif (state.kpiStartupMode) {\n state.kpiStartupMode = false;\n node.warn('[MACHINE CYCLE] First cycle - cleared kpiStartupMode');\n}\n\nstate.lastMachineCycleTime = now;\nstate.saveKpis = 1\n\n// =============================================\n// PRODUCTION METRICS\n// =============================================\nconst scrapTotal = Number(activeOrder.scrapParts) || 0;\nconst totalProduced = cycles * cavities;\nconst produced = Math.max(0, totalProduced - scrapTotal);\nconst target = Number(activeOrder.target) || 0;\nconst progress = target > 0 ? Math.min(100, Math.round((produced / target) * 100)) : 0;\n\nactiveOrder.goodParts = produced;\nactiveOrder.progressPercent = progress;\nactiveOrder.lastUpdateIso = new Date().toISOString();\nstate.activeWorkOrder = activeOrder;\n\n// =============================================\n// SCRAP PROMPT CHECK\n// =============================================\nconst promptIssued = state.scrapPromptIssuedFor || null;\nif (!promptIssued && target > 0 && produced >= target) {\n state.scrapPromptIssuedFor = activeOrder.id;\n msg._mode = \"scrap-prompt\";\n msg.scrapPrompt = {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n target,\n produced\n };\n return finalize([null, msg, null, null]);\n}\n\n// =============================================\n// DATABASE UPDATE\n// =============================================\nconst dbMsg = {\n _mode: \"cycle\",\n cycle: {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n target,\n goodParts: produced,\n scrapParts: scrapTotal,\n cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0),\n progressPercent: progress,\n lastUpdateIso: activeOrder.lastUpdateIso,\n machineOnline: true,\n productionStarted: productionRunning\n },\n topic: \"UPDATE work_orders SET good_parts = ?, progress_percent = ?, updated_at = NOW() WHERE work_order_id = ?\",\n payload: [produced, progress, activeOrder.id]\n};\n\nconst kpiTrigger = { _triggerKPI: true };\n\nconst persistWorkOrder = {\n topic: \"UPDATE work_orders SET cycle_count = ?, good_parts = ?, scrap_parts = ?, progress_percent = ?, updated_at = NOW() WHERE work_order_id = ?\",\n payload: [cycles, produced, scrapTotal, progress, activeOrder.id]\n};\n\ndbMsg.cycleRow = {\n tsMs: now, // ms epoch\n cycle_count: cycles, // cycle counter\n actual_cycle_time: Number(state.lastActualCycleTime || 0),\n theoretical_cycle_time: Number(activeOrder.cycleTime || 0),\n work_order_id: String(activeOrder.id),\n sku: String(activeOrder.sku || \"\"),\n cavities: cavities,\n good_delta: cavities, // optional (1 cycle worth)\n scrap_total: scrapTotal, // optional\n};\n\n\nreturn finalize([dbMsg, stateMsg, kpiTrigger, persistWorkOrder]);",
+ "outputs": 4,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1860,
+ "y": 220,
+ "wires": [
+ [
+ "45532a8396658999",
+ "4e2f27bf287204d8",
+ "780fcb485d7bb033"
+ ],
+ [
+ "8eebefb3ad72c2f1",
+ "6020b3e4d7a5bff9"
+ ],
+ [],
+ [
+ "6c7ed0fbe4d35c3c"
+ ]
+ ]
+ },
+ {
+ "id": "45532a8396658999",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "92105e5a6e17066a",
+ "name": "link out 5",
+ "mode": "link",
+ "links": [
+ "29b0a86da36acd72"
+ ],
+ "x": 2015,
+ "y": 200,
+ "wires": []
+ },
+ {
+ "id": "29b0a86da36acd72",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link in 5",
+ "links": [
+ "45532a8396658999"
+ ],
+ "x": 1525,
+ "y": 480,
+ "wires": [
+ [
+ "20896582916b31a3"
+ ]
+ ]
+ },
+ {
+ "id": "8eebefb3ad72c2f1",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "92105e5a6e17066a",
+ "name": "link out 6",
+ "mode": "link",
+ "links": [
+ "6394e7b952fdd73e"
+ ],
+ "x": 2035,
+ "y": 240,
+ "wires": []
+ },
+ {
+ "id": "6394e7b952fdd73e",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link in 6",
+ "links": [
+ "8eebefb3ad72c2f1"
+ ],
+ "x": 1755,
+ "y": 580,
+ "wires": [
+ [
+ "c9b94806462e9787"
+ ]
+ ]
+ },
+ {
+ "id": "d2dae8800dec5243",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link out 7",
+ "mode": "link",
+ "links": [
+ "556e511f11ac14ac"
+ ],
+ "x": 2305,
+ "y": 600,
+ "wires": []
+ },
+ {
+ "id": "556e511f11ac14ac",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "link in 7",
+ "links": [
+ "d2dae8800dec5243"
+ ],
+ "x": 535,
+ "y": 420,
+ "wires": [
+ [
+ "18b45938178386b9"
+ ]
+ ]
+ },
+ {
+ "id": "e355e9f249dbbdd1",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "89f1f7c120478582",
+ "name": "link out 8",
+ "mode": "link",
+ "links": [
+ "251dedf6026a65d6"
+ ],
+ "x": 955,
+ "y": 660,
+ "wires": []
+ },
+ {
+ "id": "251dedf6026a65d6",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "link in 8",
+ "links": [
+ "e355e9f249dbbdd1",
+ "a4a3e1da9f07ef9f"
+ ],
+ "x": 275,
+ "y": 480,
+ "wires": [
+ [
+ "bd965a93d24d47a3"
+ ]
+ ]
+ },
+ {
+ "id": "75da58a36bdf1746",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Calculate KPIs",
+ "func": "// ========================================\n// KPI HEARTBEAT - Continuous OEE calculation\n// Runs every second from a dedicated inject node\n// ========================================\n\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\n\n// 1) Basic guards: only run when tracking an active work order\nconst trackingEnabled = state.trackingEnabled || false;\nconst activeOrder = state.activeWorkOrder || {};\nif (!trackingEnabled || !activeOrder.id) {\n return null;\n}\n\n// Detect work-order change and restore/reset KPI counters\nconst currentId = activeOrder.id;\nif (state.kpiOrderId !== currentId) {\n const byOrder = state.kpiByWorkOrder || {};\n const saved = byOrder[currentId];\n\n if (saved && state.activeOrderHasProgress) {\n state.productionStartTime = saved.productionStartTime;\n state.runSeconds = saved.runSeconds;\n state.stopSeconds = saved.stopSeconds;\n state.kpiStartupMode = !!saved.kpiStartupMode;\n state.kpiLastTick = null; // avoid huge dt jump\n } else {\n state.productionStartTime = Date.now();\n state.runSeconds = 0;\n state.stopSeconds = 0;\n state.kpiStartupMode = true;\n state.kpiLastTick = null;\n }\n\n state.kpiOrderId = currentId;\n state.perf = { lastCycleCount: 0, runSeconds: 0 };\n\n}\n\n\n// Production must have a start time\n//const productionStartTime = state.productionStartTime;\n//if (!productionStartTime) {\n// return null;\n//}\n\n// Optional startup mode: keep 100/100/100/100 until first real cycle\nlet kpiStartupMode = !!state.kpiStartupMode;\n\nif (kpiStartupMode && Number(state.cycleCount || 0) > 0) {\n state.kpiStartupMode = false;\n kpiStartupMode = false;\n}\n\n// 2) Time step (dt) since last KPI tick\nconst now = Date.now();\nlet lastTick = state.kpiLastTick;\nlet dt;\nif (!lastTick) {\n dt = 0; // first run: don't integrate any time yet\n} else {\n dt = (now - lastTick) / 1000;\n}\nstate.kpiLastTick = now;\n\n// Clamp weird dt values (e.g. after long pause)\nif (dt < 0 || dt > 5) {\n dt = 1;\n}\nlet productionStartTime = state.productionStartTime;\nif (!productionStartTime) {\n // if we’re tracking a valid order, start the clock now\n productionStartTime = now;\n state.productionStartTime = productionStartTime;\n}\n\n// 3) Scheduled production time so far (denominator for Availability)\nlet elapsedSeconds = (now - productionStartTime) / 1000;\nif (elapsedSeconds < 0) elapsedSeconds = 0;\n\nconst plannedProductionTime = Number(state.plannedProductionTime || 0);\n\n// scheduledSeconds = time in the shift that *should* be production\nlet scheduledSeconds = elapsedSeconds;\nif (plannedProductionTime > 0) {\n scheduledSeconds = Math.min(elapsedSeconds, plannedProductionTime);\n}\n\n// 4) Determine machine state from last cycle timestamp\nconst lastCycleTime = state.lastMachineCycleTime || null;\nlet machineState = state.machineState || \"IDLE\";\n\nif (lastCycleTime) {\n const sinceLastCycle = (now - lastCycleTime) / 1000;\n const idealCycleTime = Number(activeOrder.cycleTime) || 1;\n const thresholdMultiplier = Number(settings.thresholdMultiplier || 1.5);\n const stopThreshold = idealCycleTime * thresholdMultiplier;\n\n machineState = (sinceLastCycle <= stopThreshold) ? \"RUNNING\" : \"STOPPED\";\n state.machineState = machineState;\n\n if (kpiStartupMode && sinceLastCycle > stopThreshold) {\n state.kpiStartupMode = false;\n kpiStartupMode = false;\n }\n}\n// ---- Shift-based availability (daily) ----\nconst shifts = settings.shifts || [{ start: '06:00', end: '15:00' }];\nconst shiftChangeComp = Number(settings.shiftChangeCompensation ?? 10);\nconst lunchBreak = Number(settings.lunchBreakMinutes ?? 30);\n\nfunction hmToMinutes(hm) {\n const parts = String(hm || '00:00').split(':').map(Number);\n return (parts[0] || 0) * 60 + (parts[1] || 0);\n}\nfunction isInShift(nowMs) {\n const d = new Date(nowMs);\n const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0).getTime();\n return shifts.some(s => {\n let startM = hmToMinutes(s.start);\n let endM = hmToMinutes(s.end);\n let start = dayStart + startM * 60000;\n let end = dayStart + endM * 60000;\n if (end <= start) end += 24 * 60 * 60000; // overnight\n return nowMs >= start && nowMs <= end;\n });\n}\nfunction plannedDaySeconds() {\n let total = 0;\n shifts.forEach(s => {\n let startM = hmToMinutes(s.start);\n let endM = hmToMinutes(s.end);\n if (endM <= startM) endM += 24 * 60;\n total += (endM - startM) * 60;\n });\n const compensation = shifts.length * shiftChangeComp * 60;\n const lunch = lunchBreak * 60;\n return Math.max(0, total - compensation - lunch);\n}\n\nconst d = new Date(now);\nconst dayKey = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;\nif (state.availDayKey !== dayKey) {\n state.availDayKey = dayKey;\n state.availDowntimeSeconds = 0;\n}\n\nconst inShift = isInShift(now);\nconst plannedSeconds = plannedDaySeconds();\nif (inShift && dt > 0 && machineState !== \"RUNNING\") {\n state.availDowntimeSeconds = Number(state.availDowntimeSeconds || 0) + dt;\n}\nconst availabilityPct = plannedSeconds > 0\n ? ((plannedSeconds - (state.availDowntimeSeconds || 0)) / plannedSeconds) * 100\n : 0;\n\n\n// 5) Integrate run / stop time (numerators)\n// We keep these as smooth, per-second counters\nlet runSeconds = Number(state.runSeconds || 0);\nlet stopSeconds = Number(state.stopSeconds || 0);\n\n// During startup mode (before first cycle), don't integrate anything\nif (!kpiStartupMode && dt > 0 && scheduledSeconds > 0) {\n if (machineState === \"RUNNING\") {\n runSeconds += dt;\n } else if (machineState === \"STOPPED\") {\n stopSeconds += dt;\n }\n}\n\nstate.runSeconds = runSeconds;\nstate.stopSeconds = stopSeconds;\n\n// 6) Pull counters for Performance & Quality\nconst cycleCount = Number(state.cycleCount || 0);\nconst cavities = Number(\n activeOrder.cavities ??\n (state.moldByWorkOrder?.[activeOrder.id]?.active) ??\n state.lastMoldActive ??\n settings.moldActive ??\n 0\n);\nconst totalParts = cycleCount * cavities;\nconst totalCycles = cycleCount;\n\n\nconst scrapTotal = Number(activeOrder.scrapParts) || 0;\nconst goodParts = Math.max(0, totalParts - scrapTotal);\n\nconst idealCycleTime = Number(activeOrder.cycleTime) || 1;\n\n// 7) Compute KPIs\n\n// Helper to keep values sane\nfunction clampPercent(v) {\n if (!isFinite(v) || isNaN(v)) return 0;\n return Math.max(0, Math.min(100, v));\n}\n\nlet availability = 0;\nlet performance = 0;\nlet quality = 100;\nlet oee = 0;\n\n// Startup mode: everything at 100 until we leave it\nif (kpiStartupMode) {\n availability = 100;\n performance = 100;\n quality = 100;\n oee = 100;\n} else {\n // Availability = Run Time / Scheduled Production Time\n availability = availabilityPct;\n\n\n // Performance = (Ideal Cycle Time × Cycles) / Sum of actual cycle times\n const actualCycleTime = Number(state.lastActualCycleTime || 0);\n state.perf = state.perf || { lastCycleCount: 0, runSeconds: 0 };\n\n // Use same threshold you use for slow-cycle\n const perfThreshold = idealCycleTime * Number(settings.thresholdMultiplier || 1.5);\n\n if (cycleCount > state.perf.lastCycleCount && actualCycleTime > 0) {\n // Cap at threshold so time beyond becomes downtime, not performance loss\n const perfCycleTime = Math.min(actualCycleTime, perfThreshold);\n state.perf.runSeconds += perfCycleTime;\n state.perf.lastCycleCount = cycleCount;\n }\n\n if (state.perf.runSeconds > 0 && cycleCount > 0) {\n const idealTime = idealCycleTime * cycleCount;\n performance = (idealTime / state.perf.runSeconds) * 100;\n }\n\n // Quality = Good Parts / Total Parts\n if (totalParts > 0) {\n quality = (goodParts / totalParts) * 100;\n }\n\n availability = clampPercent(availability);\n performance = clampPercent(performance);\n quality = clampPercent(quality);\n\n // OEE = A × P × Q\n oee = (availability * performance * quality) / 10000;\n}\n\n// Clamp & round to 1 decimal\noee = clampPercent(oee);\n\nmsg.kpis = {\n availability: Math.round(availability * 10) / 10,\n performance: Math.round(performance * 10) / 10,\n quality: Math.round(quality * 10) / 10,\n oee: Math.round(oee * 10) / 10\n};\n\nstate.currentKPIs = msg.kpis;\nstate.kpiByWorkOrder = state.kpiByWorkOrder || {};\nstate.kpiByWorkOrder[activeOrder.id] = {\n productionStartTime: state.productionStartTime,\n runSeconds: state.runSeconds,\n stopSeconds: state.stopSeconds,\n kpiStartupMode: state.kpiStartupMode,\n kpiLastTick: state.kpiLastTick\n};\nglobal.set(\"state\", state);\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1380,
+ "y": 680,
+ "wires": [
+ [
+ "c9b94806462e9787",
+ "93dce43986dec9ac",
+ "ae1b88bfb6abdcd6",
+ "1b9646dc1d66ef41",
+ "6ff6848b6061e42a"
+ ]
+ ]
+ },
+ {
+ "id": "bec32a06a835bcc1",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "31c776cf0af9a50d",
+ "name": "Process Alert for DB",
+ "func": "// Process incoming alert\nif (msg.payload && msg.payload.action === 'alert') {\n const alert = msg.payload;\n\n const tsMs = typeof alert.tsMs === \"number\" ? alert.tsMs : Date.now();\n const timestamp = new Date(tsMs).toISOString().slice(0, 19).replace('T', ' ');\n\n // Prepare INSERT query\n const alertType = (alert.type || 'Unknown').replace(/'/g, \"''\"); // Escape quotes\n const description = (alert.description || '').replace(/'/g, \"''\"); // Escape quotes\n\n msg.topic = `\n INSERT INTO alerts_log (timestamp, alert_type, description)\n VALUES ('${timestamp}', '${alertType}', '${description}')\n `;\n\n node.status({\n fill: 'green',\n shape: 'dot',\n text: `Logging: ${alertType}`\n });\n\n // Store original message for passthrough\n msg._originalAlert = alert;\n\n return msg;\n}\n\nreturn null;",
+ "outputs": 1,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 440,
+ "y": 860,
+ "wires": [
+ [
+ "1efbf4ece8685528"
+ ]
+ ]
+ },
+ {
+ "id": "1efbf4ece8685528",
+ "type": "mysql",
+ "z": "a79ad45246b8dac2",
+ "g": "31c776cf0af9a50d",
+ "mydb": "fc9634aabefee16b",
+ "name": "Log Alert to DB",
+ "x": 640,
+ "y": 860,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "430250bfe7b10f2c",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "link in 9",
+ "links": [
+ "1c2a7979a2b328e9",
+ "86855379bbf4b84d"
+ ],
+ "x": 275,
+ "y": 400,
+ "wires": [
+ [
+ "960d78315815d84a"
+ ]
+ ]
+ },
+ {
+ "id": "1c2a7979a2b328e9",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link out 9",
+ "mode": "link",
+ "links": [
+ "430250bfe7b10f2c"
+ ],
+ "x": 1935,
+ "y": 660,
+ "wires": []
+ },
+ {
+ "id": "93dce43986dec9ac",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Record KPI History",
+ "func": "// Complete Record KPI History function with robust initialization and averaging\n\nconst state = global.get(\"state\") || {};\nconst saveState = () => global.set(\"state\", state);\n\n// ========== INITIALIZATION ==========\n// Initialize buffer\nlet buffer = state.kpiBuffer;\nif (!buffer || !Array.isArray(buffer)) {\n buffer = [];\n state.kpiBuffer = buffer;\n node.warn('[KPI History] Initialized kpiBuffer');\n}\n\n// Initialize last record time\nlet lastRecordTime = state.lastKPIRecordTime;\nif (!lastRecordTime || typeof lastRecordTime !== 'number') {\n // Set to 1 minute ago to ensure immediate recording on startup\n lastRecordTime = Date.now() - 60000;\n state.lastKPIRecordTime = lastRecordTime;\n node.warn('[KPI History] Initialized lastKPIRecordTime');\n}\n\n// ========== ACCUMULATE ==========\nconst kpis = msg.payload?.kpis || msg.kpis;\nif (!kpis) {\n node.warn('[KPI History] No KPIs in message, skipping');\n saveState();\n return null;\n}\n\nbuffer.push({\n tsMs: Date.now(),\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n});\n\n// Prevent buffer from growing too large (safety limit)\nif (buffer.length > 100) {\n buffer = buffer.slice(-60); // Keep last 60 entries\n node.warn('[KPI History] Buffer exceeded 100 entries, trimmed to 60');\n}\n\nstate.kpiBuffer = buffer;\nsaveState();\n\n// ========== CHECK IF TIME TO RECORD ==========\nconst now = Date.now();\nconst timeSinceLastRecord = now - lastRecordTime;\nconst ONE_MINUTE = 60 * 1000;\n\nif (timeSinceLastRecord < ONE_MINUTE) {\n // Not time to record yet\n return null; // Don't send to charts yet\n}\n\n// ========== CALCULATE AVERAGES ==========\nif (buffer.length === 0) {\n node.warn('[KPI History] Buffer empty at recording time, skipping');\n return null;\n}\n\nconst avg = {\n oee: buffer.reduce((sum, d) => sum + d.oee, 0) / buffer.length,\n availability: buffer.reduce((sum, d) => sum + d.availability, 0) / buffer.length,\n performance: buffer.reduce((sum, d) => sum + d.performance, 0) / buffer.length,\n quality: buffer.reduce((sum, d) => sum + d.quality, 0) / buffer.length\n};\n\nnode.warn(`[KPI History] Recording averaged KPIs from ${buffer.length} samples: OEE=${avg.oee.toFixed(1)}%`);\n\n// ========== RECORD TO HISTORY ==========\n// Load history arrays\nlet oeeHist = state.realOEE || [];\nlet availHist = state.realAvailability || [];\nlet perfHist = state.realPerformance || [];\nlet qualHist = state.realQuality || [];\n\n// Append averaged values\noeeHist.push({ tsMs: now, value: Math.round(avg.oee * 10) / 10 });\navailHist.push({ tsMs: now, value: Math.round(avg.availability * 10) / 10 });\nperfHist.push({ tsMs: now, value: Math.round(avg.performance * 10) / 10 });\nqualHist.push({ tsMs: now, value: Math.round(avg.quality * 10) / 10 });\n\n// Trim arrays (avoid memory explosion)\noeeHist = oeeHist.slice(-300);\navailHist = availHist.slice(-300);\nperfHist = perfHist.slice(-300);\nqualHist = qualHist.slice(-300);\n\n// Save\nstate.realOEE = oeeHist;\nstate.realAvailability = availHist;\nstate.realPerformance = perfHist;\nstate.realQuality = qualHist;\n\n// Update state\nstate.lastKPIRecordTime = now;\nstate.kpiBuffer = []; // Clear buffer\nsaveState();\n\n// Send to graphs\nreturn {\n topic: \"chartsData\",\n payload: {\n oee: oeeHist,\n availability: availHist,\n performance: perfHist,\n quality: qualHist\n }\n};\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1750,
+ "y": 660,
+ "wires": [
+ [
+ "1c2a7979a2b328e9"
+ ]
+ ]
+ },
+ {
+ "id": "d658ad997084c48c",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "Init on Deploy",
+ "props": [
+ {
+ "p": "payload"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": true,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "",
+ "payloadType": "date",
+ "x": 380,
+ "y": 220,
+ "wires": [
+ [
+ "0c1b1beef6772a00"
+ ]
+ ]
+ },
+ {
+ "id": "0c1b1beef6772a00",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "Initialize Global Variables",
+ "func": "node.warn(\"[INIT] Initializing global variables\");\n\nconst settings = global.get(\"settings\") || {};\nconst state = global.get(\"state\") || {};\n\nlet fileSettings = null;\ntry {\n fileSettings = global.get(\"settings\", \"file\");\n} catch (err) {\n fileSettings = null;\n}\nif (fileSettings && typeof fileSettings === \"object\") {\n if (settings.moldTotal == null && fileSettings.moldTotal != null) {\n settings.moldTotal = fileSettings.moldTotal;\n }\n if (settings.moldActive == null && fileSettings.moldActive != null) {\n settings.moldActive = fileSettings.moldActive;\n }\n if (!Array.isArray(settings.shifts) && Array.isArray(fileSettings.shifts)) {\n settings.shifts = fileSettings.shifts;\n }\n if (settings.shiftChangeCompensation == null && fileSettings.shiftChangeCompensation != null) {\n settings.shiftChangeCompensation = fileSettings.shiftChangeCompensation;\n }\n if (settings.lunchBreakMinutes == null && fileSettings.lunchBreakMinutes != null) {\n settings.lunchBreakMinutes = fileSettings.lunchBreakMinutes;\n }\n if (settings.thresholdMultiplier == null && fileSettings.thresholdMultiplier != null) {\n settings.thresholdMultiplier = fileSettings.thresholdMultiplier;\n }\n}\n\nlet moldCache = null;\ntry {\n moldCache = global.get(\"moldCache\", \"file\");\n} catch (err) {\n moldCache = null;\n}\nif (moldCache && typeof moldCache === \"object\") {\n if (!Number.isFinite(state.lastMoldActive) && Number.isFinite(moldCache.lastMoldActive)) {\n state.lastMoldActive = moldCache.lastMoldActive;\n }\n if (!Number.isFinite(state.lastMoldTotal) && Number.isFinite(moldCache.lastMoldTotal)) {\n state.lastMoldTotal = moldCache.lastMoldTotal;\n }\n if (!state.moldByWorkOrder && moldCache.moldByWorkOrder && typeof moldCache.moldByWorkOrder === \"object\") {\n state.moldByWorkOrder = moldCache.moldByWorkOrder;\n }\n}\n\n// KPI Buffer for averaging\nif (!Array.isArray(state.kpiBuffer)) {\n state.kpiBuffer = [];\n node.warn(\"[INIT] Set state.kpiBuffer to []\");\n}\n\n// Last KPI record time - set to 1 min ago for immediate first record\nif (typeof state.lastKPIRecordTime !== \"number\") {\n state.lastKPIRecordTime = Date.now() - 60000;\n node.warn(\"[INIT] Set state.lastKPIRecordTime\");\n}\n\n// Last machine cycle time - set to now to prevent immediate 0% availability\nif (typeof state.lastMachineCycleTime !== \"number\") {\n state.lastMachineCycleTime = Date.now();\n node.warn(\"[INIT] Set state.lastMachineCycleTime to prevent 0% availability on startup\");\n}\n\n// Last KPI values\nif (!state.lastKPIValues || typeof state.lastKPIValues !== \"object\") {\n state.lastKPIValues = {};\n node.warn(\"[INIT] Set state.lastKPIValues to {}\");\n}\n\n// KPI Startup Mode - ensure clean state on deploy\nstate.kpiStartupMode = false;\nnode.warn(\"[INIT] Set state.kpiStartupMode to false\");\n\n// Tracking flags - ensure clean state\nif (typeof state.trackingEnabled !== \"boolean\") {\n state.trackingEnabled = false;\n}\nif (typeof state.productionStarted !== \"boolean\") {\n state.productionStarted = false;\n}\n\n// Settings defaults\nif (!Array.isArray(settings.shifts) || settings.shifts.length === 0) {\n settings.shifts = [{ start: \"06:00\", end: \"15:00\" }];\n node.warn(\"[INIT] Set default shift: 06:00-15:00\");\n}\n\nif (typeof settings.shiftChangeCompensation !== \"number\") {\n settings.shiftChangeCompensation = 10;\n}\n\nif (typeof settings.lunchBreakMinutes !== \"number\") {\n settings.lunchBreakMinutes = 30;\n}\n\nif (typeof settings.thresholdMultiplier !== \"number\") {\n settings.thresholdMultiplier = 1.5;\n}\n\n// State defaults\nif (typeof state.operatingTime !== \"number\") {\n state.operatingTime = 0;\n}\n\nif (typeof state.stopTime !== \"number\") {\n state.stopTime = 0;\n}\n\nif (typeof state.plannedProductionTime !== \"number\") {\n state.plannedProductionTime = 0;\n}\n\nif (!Object.prototype.hasOwnProperty.call(state, \"lastCycleCompletionTime\")) {\n state.lastCycleCompletionTime = null;\n}\n\nglobal.set(\"settings\", settings);\ntry {\n global.set(\"settings\", settings, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\nglobal.set(\"state\", state);\nconst moldCacheOut = {\n lastMoldActive: state.lastMoldActive ?? null,\n lastMoldTotal: state.lastMoldTotal ?? null,\n moldByWorkOrder: state.moldByWorkOrder || {}\n};\nglobal.set(\"moldCache\", moldCacheOut);\ntry {\n global.set(\"moldCache\", moldCacheOut, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\n\nnode.warn(\"[INIT] Global variable initialization complete\");\n\n// Trigger restore-session to check for running work orders\nconst restoreMsg = { action: \"restore-session\" };\nreturn [null, restoreMsg];",
+ "outputs": 2,
+ "timeout": "",
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 630,
+ "y": 220,
+ "wires": [
+ [],
+ [
+ "ca38c135cc1b4372"
+ ]
+ ]
+ },
+ {
+ "id": "6c7ed0fbe4d35c3c",
+ "type": "switch",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "DB Guard (Cycles)",
+ "property": "topic",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "istype",
+ "v": "string",
+ "vt": "string"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 1,
+ "x": 1690,
+ "y": 380,
+ "wires": [
+ [
+ "20896582916b31a3"
+ ]
+ ]
+ },
+ {
+ "id": "86855379bbf4b84d",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "8699169113c62240",
+ "name": "link out 10",
+ "mode": "link",
+ "links": [
+ "430250bfe7b10f2c"
+ ],
+ "x": 1955,
+ "y": 80,
+ "wires": []
+ },
+ {
+ "id": "0a04246503cc5d61",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Progress Check Handler",
+ "func": "// Handle DB result from start-work-order progress check\nconst state = global.get(\"state\") || {};\nconst persistMoldCache = () => {\n const cache = {\n lastMoldActive: state.lastMoldActive ?? null,\n lastMoldTotal: state.lastMoldTotal ?? null,\n moldByWorkOrder: state.moldByWorkOrder || {}\n };\n global.set(\"moldCache\", cache);\n try {\n global.set(\"moldCache\", cache, \"file\");\n } catch (err) {\n // ignore if file store is not configured\n }\n};\n\nconst persistState = () => {\n global.set(\"state\", state);\n};\n\nif (msg._mode === \"start-check-progress\") {\n const order = flow.get(\"pendingWorkOrder\");\n\n if (!order || !order.id) {\n node.error(\"No pending work order found\", msg);\n return [null, null];\n }\n\n // Get progress from DB query result\n const dbRow = (Array.isArray(msg.payload) && msg.payload.length > 0) ? msg.payload[0] : null;\n const cycleCount = dbRow ? (Number(dbRow.cycle_count) || 0) : 0;\n const goodParts = dbRow ? (Number(dbRow.good_parts) || 0) : 0;\n const scrapParts = dbRow ? (Number(dbRow.scrap_parts) || 0) : 0;\n const targetQty = dbRow ? (Number(dbRow.target_qty) || 0) : (Number(order.target) || 0);\n const cavitiesTotal = dbRow ? (Number(dbRow.cavities_total) || 0) : 0;\n const cavitiesActive = dbRow ? (Number(dbRow.cavities_active) || 0) : 0;\n\n const hasProgress = cycleCount > 0 || (goodParts + scrapParts) > 0;\n state.activeOrderHasProgress = hasProgress;\n state.activeOrderId = order.id;\n\n\n node.warn(`[PROGRESS-CHECK] WO ${order.id}: cycles=${cycleCount}, good=${goodParts}, target=${targetQty}`);\n\n // Check if work order has existing progress\n if (hasProgress) {\n // Work order has progress - send prompt to UI\n node.warn(`[PROGRESS-CHECK] Work order has existing progress - sending prompt to UI`);\n\n if (Number.isFinite(cavitiesActive) && cavitiesActive > 0) {\n order.cavities = cavitiesActive;\n }\n if (Number.isFinite(cavitiesTotal) && cavitiesTotal > 0) {\n order.cavities_total = cavitiesTotal;\n }\n\n const promptMsg = {\n _mode: \"resume-prompt\",\n topic: \"resumePrompt\",\n payload: {\n id: order.id,\n sku: order.sku || \"\",\n cycleCount: cycleCount,\n goodParts: goodParts,\n targetQty: targetQty,\n progressPercent: targetQty > 0 ? Math.round((goodParts / targetQty) * 100) : 0,\n // Include full order object for resume/restart actions\n order: { ...order, cycleCount: cycleCount, goodParts: goodParts, scrapParts: scrapParts }\n }\n };\n\n persistState();\n persistMoldCache();\n return [null, promptMsg];\n } else {\n // No existing progress - proceed with normal start\n // But still use DB values (even if 0) to ensure DB is source of truth\n node.warn(`[PROGRESS-CHECK] No existing progress - proceeding with normal start`);\n\n state.activeOrderHasProgress = false;\n\n // Update order object with DB values (makes DB the source of truth)\n order.cycleCount = cycleCount; // Will be 0 from DB\n order.goodParts = goodParts; // Will be 0 from DB\n order.scrapParts = scrapParts; // Will be 0 from DB\n order.target = targetQty; // From DB\n\n const moldByWorkOrder = state.moldByWorkOrder || {};\n const mapped = moldByWorkOrder[order.id] || {};\n const resolvedCavities = Number(\n order.cavities ?? cavitiesActive ?? mapped.active ?? state.lastMoldActive ?? 0\n );\n const resolvedTotal = Number(\n order.cavities_total ?? cavitiesTotal ?? mapped.total ?? state.lastMoldTotal ?? 0\n );\n if (Number.isFinite(resolvedCavities) && resolvedCavities > 0) {\n order.cavities = resolvedCavities;\n state.lastMoldActive = resolvedCavities;\n }\n if (Number.isFinite(resolvedTotal) && resolvedTotal > 0) {\n order.cavities_total = resolvedTotal;\n state.lastMoldTotal = resolvedTotal;\n }\n if (Number.isFinite(resolvedCavities) && resolvedCavities > 0) {\n moldByWorkOrder[order.id] = {\n total: Number.isFinite(resolvedTotal) && resolvedTotal > 0 ? resolvedTotal : (state.lastMoldTotal ?? null),\n active: resolvedCavities\n };\n state.moldByWorkOrder = moldByWorkOrder;\n }\n\n const startMsg = {\n _mode: \"start\",\n startOrder: order,\n topic: \"UPDATE work_orders SET status = CASE WHEN work_order_id = ? THEN 'RUNNING' ELSE 'PENDING' END, updated_at = CASE WHEN work_order_id = ? THEN NOW() ELSE updated_at END, cavities_total = COALESCE(NULLIF(?,0), cavities_total), cavities_active = COALESCE(NULLIF(?,0), cavities_active) WHERE status <> 'DONE'\",\n payload: [order.id, order.id, Number(order.cavities_total || 0), Number(order.cavities || 0)]\n };\n\n // Initialize global state with DB values (even if 0)\n state.activeWorkOrder = order;\n persistState();\n state.cycleCount = cycleCount;\n persistState(); // Use DB value instead of hardcoded 0\n flow.set(\"lastMachineState\", 0);\n state.scrapPromptIssuedFor = null;\n persistState();\n persistMoldCache();\n\n node.warn(`[PROGRESS-CHECK] Initialized from DB: cycles=${cycleCount}, good=${goodParts}, scrap=${scrapParts}`);\n\n return [startMsg, null];\n }\n}\n\n// Pass through all other messages\nreturn [msg, null];",
+ "outputs": 2,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1910,
+ "y": 540,
+ "wires": [
+ [
+ "c9b94806462e9787"
+ ],
+ [
+ "d6faedde4bce4968"
+ ]
+ ]
+ },
+ {
+ "id": "ae1b88bfb6abdcd6",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Merge Cycle + KPI Data",
+ "func": "// ============================================================\n// DATA MERGER - Combines Cycle + KPI data for Anomaly Detector\n// ============================================================\n\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\n\n// Get KPIs from incoming message (from Calculate KPIs node)\nconst kpis = msg.kpis || msg.payload?.kpis || {};\n\n// Get cycle data from global context\nconst activeOrder = state.activeWorkOrder || {};\nconst cycleCount = state.cycleCount || 0;\nconst moldByWorkOrder = state.moldByWorkOrder || {};\nconst cavities = Number(\n activeOrder.cavities ??\n moldByWorkOrder[activeOrder.id]?.active ??\n state.lastMoldActive ??\n settings.moldActive ??\n 0\n);\nconst lastActualCycleTime = Number(state.lastActualCycleTime || 0);\n\n\n// Build cycle object with all necessary data\nconst cycle = {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n cycles: cycleCount,\n goodParts: Number(activeOrder.goodParts) || 0,\n scrapParts: Number(activeOrder.scrapParts) || 0,\n target: Number(activeOrder.target) || 0,\n cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0),\n progressPercent: Number(activeOrder.progressPercent) || 0,\n cavities: cavities,\n actualCycleTime: lastActualCycleTime\n\n};\n\n// Merge both into the message\nmsg.cycle = cycle;\nmsg.kpis = kpis;\n\n//node.warn(`[DATA MERGER] Merged cycle (count: ${cycleCount}) + KPIs (OEE: ${kpis.oee || 0}%) for anomaly detection`);\n\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1330,
+ "y": 480,
+ "wires": [
+ [
+ "a79b5eb4b8ed05c2"
+ ]
+ ]
+ },
+ {
+ "id": "a79b5eb4b8ed05c2",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Anomaly Detector",
+ "func": "\n// ============================================================\n// ANOMALY DETECTOR - FIXED VERSION\n// Key fixes:\n// 1. Removed duplicate suppression that blocks microstop alerts\n// 2. Added periodic updates for active macrostops\n// 3. Improved logging for debugging\n// ============================================================\n\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\nconst anomaly = global.get(\"anomaly\") || {};\n\nconst cycle = msg.cycle || {};\nconst kpis = msg.kpis || {};\nconst activeOrder = state.activeWorkOrder || {};\nconst cycleCountNow = Number(cycle.cycles || 0);\n\n\n// Must have active work order to detect anomalies\nif (!activeOrder.id) {\n return null;\n}\n\nconst theoreticalCycleTime = Number(activeOrder.cycleTime) || 0;\nconst now = Date.now();\n\n// Get or initialize anomaly tracking state\nlet anomalyState = global.get(\"anomalyState\") || {\n lastCycleTime: Number(state.lastMachineCycleTime || now),\n //lastCycleCount: cycleCountNow,\n lastCycleCount: 0,\n activeStoppageEvent: null,\n oeeHistory: [],\n performanceHistory: [],\n qualityHistory: [],\n activeOeeDrop: false,\n oeeLowStreak: 0,\n activeQualitySpike: false,\n qualityHighStreak: 0,\n lastSlowCycleCount: 0,\n lastCycleEventCount: 0,\n lastStoppageUpdateMs: 0\n};\n// Reset anomaly cycle baseline when work order changes\nif (anomalyState.work_order_id && anomalyState.work_order_id !== activeOrder.id) {\n node.warn(`[RESET] Work order changed ${anomalyState.work_order_id} -> ${activeOrder.id}. Resetting anomalyState counters.`);\n anomalyState.lastCycleCount = 0;\n anomalyState.lastCycleEventCount = 0;\n anomalyState.lastSlowCycleCount = 0;\n anomalyState.activeStoppageEvent = null;\n anomalyState.lastStoppageUpdateMs = 0;\n}\nanomalyState.work_order_id = activeOrder.id;\n\nif (!isFinite(anomalyState.lastCycleTime)) {\n anomalyState.lastCycleTime = Number(state.lastMachineCycleTime || now);\n}\nif (!isFinite(anomalyState.lastCycleCount)) {\n anomalyState.lastCycleCount = cycleCountNow;\n}\n\nconst stateLastCycleTime = Number(state.lastMachineCycleTime || 0);\n//node.warn(`[TS CHECK] state.lastMachineCycleTime=${stateLastCycleTime} iso=${stateLastCycleTime ? new Date(stateLastCycleTime).toISOString() : 'n/a'}`);\nlet lastCycleTime = Number(anomalyState.lastCycleTime || 0);\nif (stateLastCycleTime > 0 && (lastCycleTime <= 0 || stateLastCycleTime > lastCycleTime)) {\n lastCycleTime = stateLastCycleTime;\n}\nconst prevCount = Number(anomalyState.lastCycleCount || 0);\n\n// If PLC/logic reset the counter (or new batch), re-baseline\nif (cycleCountNow > 0 && prevCount > 0 && cycleCountNow < prevCount) {\n node.warn(`[RESET] cycle counter reset detected: ${prevCount} -> ${cycleCountNow}. Re-baselining.`);\n anomalyState.lastCycleCount = cycleCountNow;\n anomalyState.lastCycleTime = stateLastCycleTime > 0 ? stateLastCycleTime : now;\n anomalyState.lastCycleEventCount = cycleCountNow;\n anomalyState.lastSlowCycleCount = cycleCountNow;\n anomalyState.activeStoppageEvent = null;\n anomalyState.lastStoppageUpdateMs = 0;\n}\nconst hasNewCycle = cycleCountNow > Number(anomalyState.lastCycleCount || 0);\n//node.warn(`[CYCLE CHECK PRE] cycleCountNow: ${cycleCountNow}, lastCycleCount: ${anomalyState.lastCycleCount}, hasNewCycle: ${cycleCountNow > Number(anomalyState.lastCycleCount || 0)}`);\n//node.warn(`[TS CHECK] stateLastCycleTime=${stateLastCycleTime} iso=${stateLastCycleTime ? new Date(stateLastCycleTime).toISOString() : 'n/a'}`);\nif (hasNewCycle) {\n anomalyState.lastCycleCount = cycleCountNow;\n anomalyState.lastCycleTime = stateLastCycleTime > 0 ? stateLastCycleTime : now;\n lastCycleTime = anomalyState.lastCycleTime;\n}\n//node.warn(`[CYCLE CHECK] cycleCountNow: ${cycleCountNow}, lastCycleCount: ${anomalyState.lastCycleCount}, hasNewCycle: ${hasNewCycle}`);\n//node.warn(`[CYCLE CHECK] msg.cycle.cycles: ${msg.cycle.cycles}, type: ${typeof msg.cycle.cycles}`);\n// Configuration\nconst OEE_THRESHOLD = settings.oeeAlertThreshold || 90;\nconst HISTORY_WINDOW = 20;\nconst QUALITY_SPIKE_THRESHOLD = 5;\nconst PERFORMANCE_THRESHOLD = settings.performanceThreshold || 85;\nconst microMultiplier = Number(settings.thresholdMultiplier || 1.5);\nconst macroMultiplier = Math.max(\n microMultiplier,\n Number(settings.macroStoppageMultiplier || 5)\n);\n\n// NEW: Update interval for macrostop notifications (default 10 seconds)\nconst updateIntervalMs = Number(settings.stoppageUpdateIntervalMs || 10000);\n\nconst detectedAnomalies = [];\n\n// ============================================================\n// TIER 1: CYCLE CLASSIFICATION + STOPPAGE WATCHDOG\n// ============================================================\nif (theoreticalCycleTime > 0) {\n const actualCycleTime = Number(cycle.actualCycleTime || 0);\n const currentCycleCount = Number(cycle.cycles || 0);\n const lastCycleEventCount = Number(\n anomalyState.lastCycleEventCount ?? anomalyState.lastSlowCycleCount ?? 0\n );\n\n const microThresholdSec = theoreticalCycleTime * microMultiplier;\n const macroThresholdSec = theoreticalCycleTime * macroMultiplier;\n const timeSinceLastCycleSec = lastCycleTime > 0 ? (now - lastCycleTime) / 1000 : 0;\n\n // DEBUG LOGGING\n // node.warn(`[DEBUG] actualCycleTime: ${actualCycleTime}s, theoretical: ${theoreticalCycleTime}s`);\n // node.warn(`[DEBUG] microThreshold: ${microThresholdSec}s, macroThreshold: ${macroThresholdSec}s`);\n // node.warn(`[DEBUG] timeSinceLastCycle: ${timeSinceLastCycleSec}s, hasNewCycle: ${hasNewCycle}`);\n\n let resolvedStoppageThisCycle = false;\n\n // Resolve any active stoppage when a new cycle arrives\n if (anomalyState.activeStoppageEvent && hasNewCycle) {\n const resolvedDurationSec = actualCycleTime > 0 ? actualCycleTime : timeSinceLastCycleSec;\n\n detectedAnomalies.push({\n ...anomalyState.activeStoppageEvent,\n status: \"resolved\",\n resolved_at: now,\n auto_resolved: true,\n data: {\n ...anomalyState.activeStoppageEvent.data,\n stoppage_duration_seconds: Math.round(resolvedDurationSec)\n },\n tsMs: now\n });\n\n node.warn(`[RESOLVED] Stoppage resolved: ${anomalyState.activeStoppageEvent.anomaly_type}, duration: ${resolvedDurationSec}s`);\n anomalyState.activeStoppageEvent = null;\n anomalyState.lastStoppageUpdateMs = 0;\n resolvedStoppageThisCycle = true;\n }\n\n if (hasNewCycle) {\n //node.warn(`[CYCLE RESUME] New cycle detected! Count: ${cycleCountNow}, lastCount: ${anomalyState.lastCycleCount}`);\n //node.warn(`[CYCLE RESUME] lastCycleTime updated from ${lastCycleTime} to ${anomalyState.lastCycleTime}`);\n //node.warn(`[CYCLE RESUME] activeStoppageEvent cleared: ${anomalyState.activeStoppageEvent === null}`);\n }\n\n // Add before the periodic update section (around line ~270)\n // if (anomalyState.activeStoppageEvent) {\n // node.warn(`[WATCHDOG] Active stoppage exists: ${anomalyState.activeStoppageEvent.anomaly_type}`);\n //node.warn(`[WATCHDOG] timeSinceLastCycle: ${timeSinceLastCycleSec}s, lastCycleTime: ${lastCycleTime}, now: ${now}`);\n // }\n\n // Per-cycle classification (uses actualCycleTime)\n if (actualCycleTime > 0 && currentCycleCount > lastCycleEventCount) {\n let cycleEvent = null;\n\n const SLOW_MARGIN = Number(settings.slowCycleMarginPercent ?? 5); // 5% default\n\n if (\n actualCycleTime > 0 &&\n actualCycleTime > theoreticalCycleTime * (1 + SLOW_MARGIN / 100) &&\n actualCycleTime < microThresholdSec\n ) {\n const deltaPercent = ((actualCycleTime - theoreticalCycleTime) / theoreticalCycleTime) * 100;\n\n cycleEvent = {\n anomaly_type: \"slow-cycle\",\n severity: \"warning\",\n requires_ack: false,\n title: \"Slow Cycle Detected\",\n description: `Cycle took ${actualCycleTime.toFixed(1)}s (+${deltaPercent.toFixed(0)}% vs ${theoreticalCycleTime.toFixed(1)}s target)`,\n data: {\n actual_cycle_time: actualCycleTime,\n theoretical_cycle_time: theoreticalCycleTime,\n delta_percent: Math.round(deltaPercent),\n micro_threshold_multiplier: microMultiplier,\n macro_threshold_multiplier: macroMultiplier\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: currentCycleCount,\n tsMs: now\n };\n\n //node.warn(`[SLOW-CYCLE] ${actualCycleTime.toFixed(1)}s (expected ${theoreticalCycleTime}s)`);\n\n } else if (actualCycleTime >= microThresholdSec && actualCycleTime < macroThresholdSec) {\n cycleEvent = {\n anomaly_type: \"microstop\",\n severity: \"warning\",\n requires_ack: false,\n title: \"Microstop Detected\",\n description: `Cycle gap ${actualCycleTime.toFixed(1)}s (threshold: ${microThresholdSec.toFixed(1)}s)`,\n data: {\n stoppage_duration_seconds: Math.round(actualCycleTime),\n theoretical_cycle_time: theoreticalCycleTime,\n micro_threshold_multiplier: microMultiplier,\n macro_threshold_multiplier: macroMultiplier\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: currentCycleCount,\n tsMs: now,\n status: \"resolved\" // Changed to resolved since cycle completed\n };\n\n //node.warn(`[MICROSTOP] ${actualCycleTime.toFixed(1)}s gap detected`);\n\n } /*else if (actualCycleTime >= macroThresholdSec) {\n cycleEvent = {\n anomaly_type: \"macrostop\",\n severity: \"critical\",\n requires_ack: true,\n title: \"Macrostop Detected\",\n description: `Cycle gap ${actualCycleTime.toFixed(1)}s (threshold: ${macroThresholdSec.toFixed(1)}s)`,\n data: {\n stoppage_duration_seconds: Math.round(actualCycleTime),\n theoretical_cycle_time: theoreticalCycleTime,\n micro_threshold_multiplier: microMultiplier,\n macro_threshold_multiplier: macroMultiplier\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: currentCycleCount,\n tsMs: now,\n status: \"resolved\" // Changed to resolved since cycle completed\n };\n\n node.warn(`[MACROSTOP] ${actualCycleTime.toFixed(1)}s gap detected`);\n }*/\n\n // FIXED: Always push cycle events (removed duplicate suppression)\n if (cycleEvent) {\n detectedAnomalies.push(cycleEvent);\n //node.warn(`[CYCLE EVENT] Added ${cycleEvent.anomaly_type} to detectedAnomalies`);\n }\n\n anomalyState.lastCycleEventCount = currentCycleCount;\n anomalyState.lastSlowCycleCount = currentCycleCount;\n }\n\n // No new cycle yet: start or escalate stoppage\n if (!hasNewCycle && lastCycleTime > 0) {\n // Start stoppage once when we cross micro threshold\n if (!anomalyState.activeStoppageEvent && timeSinceLastCycleSec >= microThresholdSec) {\n const isMacro = timeSinceLastCycleSec >= macroThresholdSec;\n const anomalyType = isMacro ? \"macrostop\" : \"microstop\";\n const severity = isMacro ? \"critical\" : \"warning\";\n\n const stoppageEvent = {\n alert_id: `${anomalyType}:${activeOrder.id}:${lastCycleTime}`,\n anomaly_type: anomalyType,\n severity,\n requires_ack: true,\n title: isMacro ? \"Macrostop In Progress\" : \"Microstop In Progress\",\n description: `No cycles for ${timeSinceLastCycleSec.toFixed(0)}s (expected cycle every ${theoreticalCycleTime}s)`,\n data: {\n stoppage_duration_seconds: Math.round(timeSinceLastCycleSec),\n theoretical_cycle_time: theoreticalCycleTime,\n last_cycle_timestamp: lastCycleTime,\n micro_threshold_multiplier: microMultiplier,\n macro_threshold_multiplier: macroMultiplier\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: \"active\"\n };\n\n detectedAnomalies.push(stoppageEvent);\n anomalyState.activeStoppageEvent = stoppageEvent;\n anomalyState.lastStoppageUpdateMs = now;\n //node.warn(`[STOPPAGE START] ${anomalyType} started at ${timeSinceLastCycleSec.toFixed(0)}s`);\n }\n\n // Escalate micro -> macro once\n if (\n anomalyState.activeStoppageEvent &&\n anomalyState.activeStoppageEvent.anomaly_type === \"microstop\" &&\n timeSinceLastCycleSec >= macroThresholdSec\n ) {\n detectedAnomalies.push({\n ...anomalyState.activeStoppageEvent,\n status: \"resolved\",\n resolved_at: now,\n auto_resolved: true,\n requires_ack: false,\n data: {\n ...anomalyState.activeStoppageEvent.data,\n stoppage_duration_seconds: Math.round(timeSinceLastCycleSec)\n },\n tsMs: now\n });\n\n const macroEvent = {\n alert_id: `macrostop:${activeOrder.id}:${lastCycleTime}`,\n anomaly_type: \"macrostop\",\n severity: \"critical\",\n requires_ack: true,\n title: \"Macrostop In Progress\",\n description: `No cycles for ${timeSinceLastCycleSec.toFixed(0)}s (expected cycle every ${theoreticalCycleTime}s)`,\n data: {\n stoppage_duration_seconds: Math.round(timeSinceLastCycleSec),\n theoretical_cycle_time: theoreticalCycleTime,\n last_cycle_timestamp: lastCycleTime,\n micro_threshold_multiplier: microMultiplier,\n macro_threshold_multiplier: macroMultiplier\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: \"active\"\n };\n\n detectedAnomalies.push(macroEvent);\n anomalyState.activeStoppageEvent = macroEvent;\n anomalyState.lastStoppageUpdateMs = now;\n //node.warn(`[ESCALATION] Microstop -> Macrostop at ${timeSinceLastCycleSec.toFixed(0)}s`);\n }\n\n // NEW: Send periodic updates for active macrostops\n if (\n anomalyState.activeStoppageEvent &&\n anomalyState.activeStoppageEvent.anomaly_type === \"macrostop\"\n ) {\n const timeSinceLastUpdate = now - (anomalyState.lastStoppageUpdateMs || 0);\n \n if (timeSinceLastUpdate >= updateIntervalMs) {\n const prev = anomalyState.activeStoppageEvent;\n\n // 1) AUTO-ACK the previous active macrostop alert\n const autoAck = {\n ...prev,\n status: \"resolved\",\n resolved_at: now,\n auto_resolved: true,\n requires_ack: false,\n title: \"Macrostop Updated\",\n description: \"Auto-acknowledged due to periodic refresh\",\n tsMs: now,\n is_auto_ack: true\n };\n\n // 2) Send a NEW active macrostop alert with updated duration + NEW alert_id\n const refreshed = {\n ...prev,\n alert_id: `macrostop:${activeOrder.id}:${now}`, // new instance id so UI treats it as new\n status: \"active\",\n requires_ack: true,\n title: \"Macrostop Ongoing\",\n description: `Machine still stopped for ${timeSinceLastCycleSec.toFixed(0)}s (expected cycle every ${theoreticalCycleTime}s)`,\n data: {\n ...prev.data,\n stoppage_duration_seconds: Math.round(timeSinceLastCycleSec)\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n tsMs: now,\n is_update: true\n };\n\n detectedAnomalies.push(autoAck, refreshed);\n\n // IMPORTANT: set the active event to the refreshed one\n anomalyState.activeStoppageEvent = refreshed;\n anomalyState.lastStoppageUpdateMs = now;\n\n //node.warn(`[MACROSTOP REFRESH] Auto-acked previous, new duration: ${timeSinceLastCycleSec.toFixed(0)}s`);\n }\n }\n }\n}\n\n\n\n// ============================================================\n// TIER 2: OEE DROP DETECTION\n// Trigger: OEE falls below threshold\n// ============================================================\nconst currentOEE = Number(kpis.oee) || 0;\n\nif (currentOEE > 0) {\n const lowThreshold = OEE_THRESHOLD; // e.g. 90\n const recoveryThreshold = OEE_THRESHOLD + 2; // some hysteresis\n\n if (currentOEE < lowThreshold) {\n // Count consecutive low points\n anomalyState.oeeLowStreak = (anomalyState.oeeLowStreak || 0) + 1;\n\n const REQUIRED_STREAK = 3; // only alert after 3 bad readings\n\n // Only fire when we ENTER the bad zone\n if (!anomalyState.activeOeeDrop &&\n anomalyState.oeeLowStreak >= REQUIRED_STREAK) {\n\n let severity = 'warning';\n if (currentOEE < 75) severity = 'critical';\n\n detectedAnomalies.push({\n anomaly_type: 'oee-drop',\n severity,\n title: 'OEE Below Threshold',\n description: `OEE at ${currentOEE.toFixed(1)}% (threshold: ${OEE_THRESHOLD}%)`,\n data: {\n current_oee: currentOEE,\n threshold: OEE_THRESHOLD,\n delta: OEE_THRESHOLD - currentOEE\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: 'active'\n });\n\n anomalyState.activeOeeDrop = true;\n //node.warn(`[ANOMALY] OEE drop started at ${currentOEE.toFixed(1)}%`);\n }\n\n } else if (currentOEE >= recoveryThreshold) {\n // We are OUT of the bad zone\n anomalyState.oeeLowStreak = 0;\n\n if (anomalyState.activeOeeDrop) {\n detectedAnomalies.push({\n anomaly_type: 'oee-drop',\n severity: 'info',\n title: 'OEE Recovered',\n description: `OEE recovered to ${currentOEE.toFixed(1)}% (threshold: ${OEE_THRESHOLD}%)`,\n data: {\n current_oee: currentOEE,\n threshold: OEE_THRESHOLD\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: 'resolved'\n });\n\n anomalyState.activeOeeDrop = false;\n //node.warn(`[ANOMALY] OEE recovered to ${currentOEE.toFixed(1)}%`);\n }\n }\n}\n\n// Update OEE history for trend analysis\nanomalyState.oeeHistory.push({ tsMs: now, value: currentOEE });\nif (anomalyState.oeeHistory.length > HISTORY_WINDOW) {\n anomalyState.oeeHistory.shift(); // Keep only recent history\n}\n\n// ============================================================\n// TIER 2: QUALITY SPIKE DETECTION\n// Trigger: Sudden increase in scrap/defect rate\n// ============================================================\nconst totalParts = (cycle.goodParts || 0) + (cycle.scrapParts || 0);\nconst currentScrapRate =\n totalParts > 0 ? ((cycle.scrapParts || 0) / totalParts) * 100 : 0;\n\n// Keep history for trend\nanomalyState.qualityHistory.push({ tsMs: now, value: currentScrapRate });\nif (anomalyState.qualityHistory.length > HISTORY_WINDOW) {\n anomalyState.qualityHistory.shift();\n}\n\n// Only evaluate when we have enough data and enough volume in this cycle\nconst MIN_SAMPLES = 5;\nconst MIN_PARTS_THIS_CYCLE = 10;\n\nif (\n anomalyState.qualityHistory.length >= MIN_SAMPLES &&\n totalParts >= MIN_PARTS_THIS_CYCLE\n) {\n const recentHistory = anomalyState.qualityHistory.slice(0, -1); // exclude current\n const avgScrapRate =\n recentHistory.reduce((sum, p) => sum + p.value, 0) /\n recentHistory.length;\n\n const scrapRateIncrease = currentScrapRate - avgScrapRate;\n\n const SPIKE_DELTA = QUALITY_SPIKE_THRESHOLD || 5; // your existing config\n const MIN_SCRAP_RATE = 5; // ignore small scrap percentages\n const RECOVERY_MARGIN = 2; // how far back towards avg to consider \"recovered\"\n\n // ----- When scrap is HIGH / SPIKE ZONE -----\n if (\n currentScrapRate > MIN_SCRAP_RATE &&\n scrapRateIncrease > SPIKE_DELTA\n ) {\n // count how many consecutive \"bad\" cycles\n anomalyState.qualityHighStreak =\n (anomalyState.qualityHighStreak || 0) + 1;\n\n const REQUIRED_STREAK = 2; // require 2 consecutive bad cycles\n\n // fire ONLY when we ENTER the spike (not every cycle)\n if (\n !anomalyState.activeQualitySpike &&\n anomalyState.qualityHighStreak >= REQUIRED_STREAK\n ) {\n let severity = \"warning\";\n if (scrapRateIncrease > 10 || currentScrapRate > 15) {\n severity = \"critical\";\n }\n\n detectedAnomalies.push({\n anomaly_type: \"quality-spike\",\n severity,\n title: \"Quality Issue Detected\",\n description: `Scrap rate at ${currentScrapRate.toFixed(\n 1\n )}% (avg: ${avgScrapRate.toFixed(\n 1\n )}%, +${scrapRateIncrease.toFixed(1)}%)`,\n data: {\n current_scrap_rate: currentScrapRate,\n average_scrap_rate: avgScrapRate,\n increase: scrapRateIncrease,\n scrap_parts: cycle.scrapParts || 0,\n total_parts: totalParts\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: \"active\"\n });\n\n anomalyState.activeQualitySpike = true;\n /*node.warn(\n `[ANOMALY] Quality spike started: scrap ${currentScrapRate.toFixed(\n 1\n )}% (avg ${avgScrapRate.toFixed(1)}%)`\n );*/\n }\n } else {\n // ----- When scrap is NORMAL / RECOVERY -----\n anomalyState.qualityHighStreak = 0;\n\n // if we had an active spike, send a single \"resolved\" event\n if (\n anomalyState.activeQualitySpike &&\n currentScrapRate <= avgScrapRate + RECOVERY_MARGIN\n ) {\n detectedAnomalies.push({\n anomaly_type: \"quality-spike\",\n severity: \"info\",\n title: \"Quality Issue Resolved\",\n description: `Scrap rate back to ${currentScrapRate.toFixed(\n 1\n )}% (avg: ${avgScrapRate.toFixed(1)}%)`,\n data: {\n current_scrap_rate: currentScrapRate,\n average_scrap_rate: avgScrapRate\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: \"resolved\"\n });\n\n anomalyState.activeQualitySpike = false;\n /*node.warn(\n `[ANOMALY] Quality spike resolved: scrap ${currentScrapRate.toFixed(\n 1\n )}%`\n );*/\n }\n }\n}\n\n// ============================================================\n// TIER 2: PERFORMANCE DEGRADATION\n// Trigger: Consistent underperformance over time\n// ============================================================\nconst currentPerformance = Number(kpis.performance) || 0;\nanomalyState.performanceHistory.push({ tsMs: now, value: currentPerformance });\nif (anomalyState.performanceHistory.length > HISTORY_WINDOW) {\n anomalyState.performanceHistory.shift();\n}\n\n// Check for sustained poor performance (at least 10 data points)\nif (anomalyState.performanceHistory.length >= 10) {\n const recent10 = anomalyState.performanceHistory.slice(-10);\n const avgPerformance = recent10.reduce((sum, point) => sum + point.value, 0) / recent10.length;\n\n const PERF_LOW_THRESHOLD = PERFORMANCE_THRESHOLD; // 85%\n const PERF_RECOVERY_THRESHOLD = PERFORMANCE_THRESHOLD + 3; // 88% to recover\n\n // Check if we're in degraded state\n if (avgPerformance > 0 && avgPerformance < PERF_LOW_THRESHOLD) {\n // Count consecutive low readings\n anomalyState.performanceLowStreak = (anomalyState.performanceLowStreak || 0) + 1;\n\n const REQUIRED_STREAK = 3; // Need 3 consecutive low readings\n\n // Only fire ONCE when entering degraded state\n if (!anomalyState.activePerformanceDegradation &&\n anomalyState.performanceLowStreak >= REQUIRED_STREAK) {\n\n let severity = 'warning';\n if (avgPerformance < 75) {\n severity = 'critical';\n }\n\n detectedAnomalies.push({\n anomaly_type: 'performance-degradation',\n severity: severity,\n title: `Performance Degradation`,\n description: `Performance at ${avgPerformance.toFixed(1)}% (sustained over last 10 cycles)`,\n data: {\n average_performance: avgPerformance,\n current_performance: currentPerformance,\n threshold: PERF_LOW_THRESHOLD,\n sample_size: 10\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: 'active'\n });\n\n // Mark as active so we don't spam\n anomalyState.activePerformanceDegradation = true;\n //node.warn(`[ANOMALY] Performance degradation STARTED: ${avgPerformance.toFixed(1)}%`);\n }\n\n } else if (avgPerformance >= PERF_RECOVERY_THRESHOLD) {\n // Performance recovered\n anomalyState.performanceLowStreak = 0;\n\n // Only send recovery message if we were previously in degraded state\n if (anomalyState.activePerformanceDegradation) {\n detectedAnomalies.push({\n anomaly_type: 'performance-degradation',\n severity: 'info',\n title: 'Performance Recovered',\n description: `Performance recovered to ${avgPerformance.toFixed(1)}% (threshold: ${PERF_LOW_THRESHOLD}%)`,\n data: {\n average_performance: avgPerformance,\n current_performance: currentPerformance,\n threshold: PERF_LOW_THRESHOLD\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: 'resolved'\n });\n\n anomalyState.activePerformanceDegradation = false;\n //node.warn(`[ANOMALY] Performance degradation RESOLVED: ${avgPerformance.toFixed(1)}%`);\n }\n }\n}\n\n// ============================================================\n// TIER 3: PREDICTIVE ALERTS (Trend Analysis)\n// Predict issues before they become critical\n// ============================================================\nif (anomalyState.oeeHistory.length >= 15) {\n // Simple linear trend analysis on OEE\n const recent15 = anomalyState.oeeHistory.slice(-15);\n const firstHalf = recent15.slice(0, 7);\n const secondHalf = recent15.slice(-7);\n\n const avgFirstHalf = firstHalf.reduce((sum, p) => sum + p.value, 0) / firstHalf.length;\n const avgSecondHalf = secondHalf.reduce((sum, p) => sum + p.value, 0) / secondHalf.length;\n\n const oeeTrend = avgSecondHalf - avgFirstHalf;\n\n // Predict if OEE is trending downward significantly\n if (oeeTrend < -5 && avgSecondHalf > OEE_THRESHOLD * 0.95 && avgSecondHalf < OEE_THRESHOLD * 1.05) {\n detectedAnomalies.push({\n anomaly_type: 'predictive-oee-decline',\n severity: 'info',\n title: `Declining OEE Trend Detected`,\n description: `OEE trending down ${Math.abs(oeeTrend).toFixed(1)}% over last 15 cycles. Current: ${avgSecondHalf.toFixed(1)}%`,\n data: {\n trend: oeeTrend,\n first_half_avg: avgFirstHalf,\n second_half_avg: avgSecondHalf,\n prediction: 'OEE may drop below threshold soon'\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now\n });\n\n //node.warn(`[PREDICTIVE] OEE trending down: ${oeeTrend.toFixed(1)}%`);\n }\n}\n\n// Update last cycle time for next iteration\nanomalyState.lastCycleTime = lastCycleTime;\nglobal.set(\"anomalyState\", anomalyState);\n//anomaly.state = anomalyState;\n//global.set(\"anomaly\", anomaly);\n\n\n// ============================================================\n// OUTPUT\n// ============================================================\nif (detectedAnomalies.length > 0) {\n //node.warn(`[ANOMALY DETECTOR] Detected ${detectedAnomalies.length} anomaly/ies`);\n\n // Log each anomaly for debugging\n detectedAnomalies.forEach((a, i) => {\n node.warn(` [${i + 1}] ${a.anomaly_type} - ${a.title} - ${a.status || 'N/A'}`);\n });\n\n if (detectedAnomalies.length > 0) {\n node.warn(`[ANOMALY DETECTOR] Detected ${detectedAnomalies.length} anomaly/ies`);\n\n detectedAnomalies.forEach((a, i) => {\n node.warn(` [${i + 1}] ${a.anomaly_type} - ${a.title} - ${a.status || 'N/A'}`);\n });\n\n msg.topic = \"anomaly-detected\";\n msg.payload = detectedAnomalies;\n\n // Optional: also keep a copy if you want, but DON'T replace msg\n msg.originalMsg = msg.originalMsg || null; // avoid recursion\n msg._anomaly_source = \"anomaly_detector\";\n\n return msg;\n }\n return null;\n}\nif (detectedAnomalies.length > 0) {\n node.warn(`[ANOMALY DETECTOR] Detected ${detectedAnomalies.length} anomaly/ies`);\n detectedAnomalies.forEach((a, i) => {\n node.warn(` [${i + 1}] ${a.anomaly_type} - ${a.title} - ${a.status || 'N/A'}`);\n });\n\n msg.topic = \"anomaly-detected\";\n msg.payload = detectedAnomalies;\n msg._anomaly_source = \"anomaly_detector\";\n return msg;\n}\nreturn null;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1350,
+ "y": 440,
+ "wires": [
+ [
+ "6ffa372a2f28189c",
+ "c2c9185e2a07e8e8",
+ "4b42a4e4bb445e89",
+ "7a16d1c7c9dd7225"
+ ]
+ ]
+ },
+ {
+ "id": "6ffa372a2f28189c",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Event Logger (Simplified)",
+ "func": "// ============================================================\n// EVENT LOGGER - SIMPLIFIED (INSERTS ONLY)\n// Every anomaly gets inserted as a new row\n// ============================================================\n\nconst anomalies = msg.payload || [];\nconst settings = global.get(\"settings\") || {};\nconst anomalyStateStore = global.get(\"anomaly\") || {};\nconst reasonsByIncident = anomalyStateStore.reasonsByIncident || {};\n\nif (!Array.isArray(anomalies) || anomalies.length === 0) {\n return null;\n}\n\n// SQL escape helper\nconst esc = (v) => {\n if (v === null || v === undefined) return 'NULL';\n return \"'\" + String(v).replace(/\\\\/g, '\\\\').replace(/'/g, \"''\") + \"'\";\n};\n\nconst dbInserts = [];\nconst activeAnomalies = [];\nconst softNotifications = []; \n\n\nanomalies.forEach(anomaly => {\n const tsMs = Number(anomaly.tsMs) || Date.now();\n const woId = anomaly.work_order_id || '';\n const aType = anomaly.anomaly_type || 'unknown';\n const sev = anomaly.severity || 'warning';\n const title = anomaly.title || '';\n const desc = anomaly.description || '';\n const dataJson = JSON.stringify(anomaly.data || {});\n const kpiJson = JSON.stringify(anomaly.kpi_snapshot || {});\n const cycle = Number(anomaly.cycle_count) || 0;\n const requiresAck = anomaly.requires_ack !== false;\n const incidentKey = anomaly.incidentKey || (anomaly.data && anomaly.data.last_cycle_timestamp\n ? [aType, woId, String(anomaly.data.last_cycle_timestamp)].join(\":\")\n : (anomaly.alert_id || null));\n const reason = incidentKey ? (reasonsByIncident[incidentKey] || null) : null;\n\n // Build INSERT query\n const insertQuery = \n \"INSERT INTO anomaly_events \" +\n \"(`event_timestamp`, `work_order_id`, `anomaly_type`, `severity`, `title`, `description`, \" +\n \"`data_json`, `kpi_snapshot_json`, `status`, `cycle_count`, `occurrence_count`, `last_occurrence`) VALUES (\" +\n tsMs + \", \" +\n esc(woId) + \", \" +\n esc(aType) + \", \" +\n esc(sev) + \", \" +\n esc(title) + \", \" +\n esc(desc) + \", \" +\n esc(dataJson) + \", \" +\n esc(kpiJson) + \", \" +\n \"'active', \" +\n cycle + \", \" +\n \"1, \" +\n tsMs + \")\";\n\n dbInserts.push({ topic: insertQuery, payload: [] });\n\n // Add to active list for UI\n if (requiresAck) {\n // Hard alerts that go to the panel + require acknowledgment\n activeAnomalies.push({\n event_id: null,\n tsMs: tsMs,\n work_order_id: woId,\n anomaly_type: aType,\n incidentKey: incidentKey || null,\n severity: sev,\n title: title,\n description: desc,\n status: 'active',\n reason: reason,\n kpi_snapshot: anomaly.kpi_snapshot || {}\n });\n\n node.warn(`[EVENT LOGGER] Inserting ${aType}: ${title} (requires ack)`);\n } else {\n // Soft alerts (e.g. slow-cycle) -> just show a transient popup\n softNotifications.push({\n tsMs: tsMs,\n anomaly_type: aType,\n severity: sev,\n title: title,\n description: desc,\n requires_ack: false\n });\n\n node.warn(`[EVENT LOGGER] Logging soft anomaly ${aType}: ${title}`);\n }\n});\n\n// UI update message\nconst uiMsg = {\n topic: \"anomaly-ui-update\",\n payload: {\n activeCount: activeAnomalies.length,\n activeAnomalies: activeAnomalies,\n updates: activeAnomalies.map(a => ({ status: 'new', anomaly: a })),\n softNotifications: softNotifications,\n reasonCatalog: settings.reasonCatalog || null\n }\n};\n\nreturn [dbInserts, uiMsg];\n",
+ "outputs": 2,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1370,
+ "y": 400,
+ "wires": [
+ [
+ "a26a5545125df363",
+ "ce531bdfc0e17971",
+ "054477bae60d4b0f"
+ ],
+ [
+ "877cd85357e5e304",
+ "94719c2980aad7b0"
+ ]
+ ]
+ },
+ {
+ "id": "ce531bdfc0e17971",
+ "type": "split",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Split DB Inserts",
+ "splt": "\\n",
+ "spltType": "str",
+ "arraySplt": 1,
+ "arraySpltType": "len",
+ "stream": false,
+ "addname": "",
+ "x": 1940,
+ "y": 380,
+ "wires": [
+ [
+ "82ae34d532385180"
+ ]
+ ]
+ },
+ {
+ "id": "31ee0f315b7c57f5",
+ "type": "mysql",
+ "z": "a79ad45246b8dac2",
+ "g": "d2f48952b3128551",
+ "mydb": "fc9634aabefee16b",
+ "name": "Anomaly Events DB",
+ "x": 1020,
+ "y": 60,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "4902c4e2d14d3b9e",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "name": "Initialize OEE Threshold (90%)",
+ "props": [
+ {
+ "p": "payload"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": true,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "90",
+ "payloadType": "num",
+ "x": 360,
+ "y": 940,
+ "wires": [
+ [
+ "26cb8777ecafe137"
+ ]
+ ]
+ },
+ {
+ "id": "26cb8777ecafe137",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Set OEE Threshold Global",
+ "func": "// Initialize OEE alert threshold\nconst settings = global.get(\"settings\") || {};\nconst threshold = Number(msg.payload) || 90;\nsettings.oeeAlertThreshold = threshold;\nglobal.set(\"settings\", settings);\n\nnode.warn(`[CONFIG] OEE Alert Threshold set to ${threshold}%`);\n\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 680,
+ "y": 940,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "1b9646dc1d66ef41",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Save KPIs to Database",
+ "func": "// ============================================================\n// SAVE KPIs TO DATABASE - 1 SNAPSHOT PER CYCLE\n// ============================================================\n\nconst state = global.get(\"state\") || {};\nconst kpis = msg.kpis || {};\n\n// Rising edge guard: only save once per cycle\nconst saveFlag = state.saveKpis || 0;\nif (!saveFlag) {\n return null;\n}\nstate.saveKpis = 0;\nglobal.set(\"state\", state);\n\nconst dbInserts = [];\n\nconst activeOrder = state.activeWorkOrder || {};\nconst workorder_id = activeOrder.id;\nconst oee = Number(kpis.oee);\nconst performance = Number(kpis.performance);\nconst availability = Number(kpis.availability);\nconst quality = Number(kpis.quality);\nconst tsMs = Date.now();\n\nif (!workorder_id) {\n return null;\n}\n\nconst insertQuery =\n \"INSERT INTO kpi_snapshots \" +\n \"(work_order_id,oee_percent, performance_percent, availability_percent, quality_percent, timestamp) VALUES (\" +\n \"'\" + workorder_id + \"', \" +\n oee + \", \" +\n performance + \", \" +\n availability + \", \" +\n quality + \", \" +\n tsMs + \")\";\n\ndbInserts.push({ topic: insertQuery, payload: [] });\n\nreturn [dbInserts];\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1710,
+ "y": 720,
+ "wires": [
+ [
+ "3fe9d69423cffcee"
+ ]
+ ]
+ },
+ {
+ "id": "3fe9d69423cffcee",
+ "type": "mysql",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "mydb": "fc9634aabefee16b",
+ "name": "Save kpis to database",
+ "x": 1980,
+ "y": 700,
+ "wires": [
+ [
+ "8f77f5583ee4044e"
+ ]
+ ]
+ },
+ {
+ "id": "7854fb5ff1aaafc2",
+ "type": "template",
+ "z": "a79ad45246b8dac2",
+ "g": "8699169113c62240",
+ "name": "Format query 1",
+ "field": "topic",
+ "fieldType": "msg",
+ "format": "handlebars",
+ "syntax": "mustache",
+ "template": "SELECT\n oee_percent,\n availability_percent,\n quality_percent,\n performance_percent,\n tsMs\nFROM (\n SELECT\n oee_percent,\n availability_percent,\n quality_percent,\n performance_percent,\n timestamp AS tsMs\n FROM kpi_snapshots\n ORDER BY timestamp DESC\n LIMIT 50\n) AS t\nORDER BY tsMs ASC;\n",
+ "output": "str",
+ "x": 1380,
+ "y": 80,
+ "wires": [
+ [
+ "15aa2bf7e2b87ff7"
+ ]
+ ]
+ },
+ {
+ "id": "397ad156aa93c126",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "8699169113c62240",
+ "name": "Format Graph Data",
+ "func": "// Format Graph Data for KPI charts\n\n // Build labels and data arrays\n const labels = [];\n const oeeData = [];\n const availData = [];\n const perfData = [];\n const qualData = [];\n\n function bucketSeries(source, size) {\n const bucketSize = size || 5; // 3 points → 1 smoother point\n if (!Array.isArray(source) || source.length <= bucketSize) return source;\n\n const result = [];\n for (let i = 0; i < source.length; i += bucketSize) {\n const bucket = source.slice(i, i + bucketSize);\n const avgY = bucket.reduce((sum, p) => sum + Number(p.y || 0), 0) / bucket.length;\n const midPoint = bucket[Math.floor(bucket.length / 2)] || bucket[0];\n result.push({ x: midPoint.x, y: avgY });\n }\n return result;\n}\n\n \n msg.payload.forEach(row => {\n let x_value = new Date(row.tsMs); \n const dateObject = new Date(x_value);\n const year = dateObject.getFullYear();\n const month = dateObject.getMonth() + 1; // Months are 0-indexed\n const day = dateObject.getDate();\n const hours = dateObject.getHours();\n const minutes = dateObject.getMinutes();\n const seconds = dateObject.getSeconds();\n const formattedx_value = `${day.toString().padStart(2, '0')}-${month.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;\n\n oeeData.push({ x: formattedx_value, y: row.oee_percent });\n availData.push({ x: formattedx_value, y: row.availability_percent });\n perfData.push({ x: formattedx_value, y: row.performance_percent });\n qualData.push({ x: formattedx_value, y: row.quality_percent });\n });\n\n const smoothOee = bucketSeries(oeeData, 5);\n const smoothAvail = bucketSeries(availData, 5);\n const smoothPerf = bucketSeries(perfData, 5);\n const smoothQual = bucketSeries(qualData, 5);\n\n msg.graphData = {\n labels: labels,\n datasets: [\n { label: 'OEE %', data: smoothOee },\n { label: 'Availability %', data: smoothAvail },\n { label: 'Performance %', data: smoothPerf },\n { label: 'Quality %', data: smoothQual }\n ]\n };\n\n //node.warn(`[GRAPH DATA] Formatted ${labels.length} KPI history points`);\n\n delete msg.topic;\n delete msg.payload;\n return msg;\n\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1830,
+ "y": 80,
+ "wires": [
+ [
+ "86855379bbf4b84d"
+ ]
+ ]
+ },
+ {
+ "id": "48bfce812bc1f09b",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "link out 11",
+ "mode": "link",
+ "links": [
+ "f888adccd274f4fd"
+ ],
+ "x": 485,
+ "y": 360,
+ "wires": []
+ },
+ {
+ "id": "f888adccd274f4fd",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "31c776cf0af9a50d",
+ "name": "link in 10",
+ "links": [
+ "48bfce812bc1f09b"
+ ],
+ "x": 275,
+ "y": 860,
+ "wires": [
+ [
+ "bec32a06a835bcc1"
+ ]
+ ]
+ },
+ {
+ "id": "877cd85357e5e304",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link out 12",
+ "mode": "link",
+ "links": [
+ "5d027d17cbd51027"
+ ],
+ "x": 1685,
+ "y": 420,
+ "wires": []
+ },
+ {
+ "id": "5d027d17cbd51027",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "d2f48952b3128551",
+ "name": "link in 11",
+ "links": [
+ "877cd85357e5e304"
+ ],
+ "x": 265,
+ "y": 60,
+ "wires": [
+ [
+ "fef02a862e99d54d"
+ ]
+ ]
+ },
+ {
+ "id": "82ae34d532385180",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link out 13",
+ "mode": "link",
+ "links": [
+ "4591c2ec02653bf9"
+ ],
+ "x": 2065,
+ "y": 380,
+ "wires": []
+ },
+ {
+ "id": "4591c2ec02653bf9",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "d2f48952b3128551",
+ "name": "link in 12",
+ "links": [
+ "82ae34d532385180",
+ "a26a5545125df363"
+ ],
+ "x": 895,
+ "y": 80,
+ "wires": [
+ [
+ "31ee0f315b7c57f5"
+ ]
+ ]
+ },
+ {
+ "id": "a26a5545125df363",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link out 14",
+ "mode": "link",
+ "links": [
+ "4591c2ec02653bf9"
+ ],
+ "x": 2215,
+ "y": 420,
+ "wires": []
+ },
+ {
+ "id": "59284a9f41e33142",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "92105e5a6e17066a",
+ "name": "link out 15",
+ "mode": "link",
+ "links": [
+ "4959b0d832ea3a4c"
+ ],
+ "x": 1505,
+ "y": 200,
+ "wires": []
+ },
+ {
+ "id": "4959b0d832ea3a4c",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "8699169113c62240",
+ "name": "link in 13",
+ "links": [
+ "59284a9f41e33142"
+ ],
+ "x": 1235,
+ "y": 80,
+ "wires": [
+ [
+ "7854fb5ff1aaafc2"
+ ]
+ ]
+ },
+ {
+ "id": "c2c9185e2a07e8e8",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "link out 16",
+ "mode": "link",
+ "links": [],
+ "x": 1535,
+ "y": 440,
+ "wires": []
+ },
+ {
+ "id": "4f6e0771dcbd3685",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "89f1f7c120478582",
+ "name": "Shift Config Handler",
+ "func": "// Shift Config Handler\nconst topic = msg.topic || \"\";\nconst config = global.get(\"config\") || {};\nconst readOnly = config.settingsReadOnly !== false;\nconst settings = global.get(\"settings\") || {};\n\nif (readOnly && (topic === \"saveShiftConfig\" || topic === \"saveThresholdConfig\" || topic === \"saveAllSettings\")) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Read-only\" });\n return null;\n}\n\n// Save shift config\nif (topic === \"saveShiftConfig\") {\n const config = msg.payload || {};\n settings.shifts = config.shifts || [];\n settings.shiftChangeCompensation = config.shiftChangeCompensation || 10;\n settings.lunchBreakMinutes = config.lunchBreakMinutes || 30;\n global.set(\"settings\", settings);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `${config.shifts.length} sh ${config.shiftChangeCompensation} ch ${config.shiftChangeCompensation} lu` });\n return null;\n}\n\n// Save threshold config\nif (topic === \"saveThresholdConfig\") {\n const config = msg.payload || {};\n settings.thresholdMultiplier = config.thresholdMultiplier || 1.5;\n settings.macroStoppageMultiplier = config.macroStoppageMultiplier || 5;\n settings.oeeAlertThreshold = config.oeeAlertThreshold || 90;\n global.set(\"settings\", settings);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `Threshold: ${config.thresholdMultiplier}x / ${config.macroStoppageMultiplier}x` });\n return null;\n}\n\n// Load shift config\nif (topic === \"getShiftConfig\") {\n msg.topic = \"shiftConfigData\";\n msg.payload = {\n shifts: settings.shifts || [{ start: \"08:00\", end: \"16:00\" }],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n };\n return msg; // Send back to UI\n}\n\n// Save all settings at once\nif (topic === \"getShiftConfig\") {\n msg.topic = \"shiftConfigData\";\n msg.payload = {\n shifts: settings.shifts || [{ start: \"08:00\", end: \"16:00\" }],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n };\n return msg; // Send back to UI\n}\n\nreturn null;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 400,
+ "y": 720,
+ "wires": [
+ [
+ "a4a3e1da9f07ef9f"
+ ]
+ ]
+ },
+ {
+ "id": "a4a3e1da9f07ef9f",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "89f1f7c120478582",
+ "name": "link out 17",
+ "mode": "link",
+ "links": [
+ "251dedf6026a65d6"
+ ],
+ "x": 525,
+ "y": 720,
+ "wires": []
+ },
+ {
+ "id": "cfcdbba099f65279",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "KPI Tick",
+ "props": [
+ {
+ "p": "payload"
+ }
+ ],
+ "repeat": "1",
+ "crontab": "",
+ "once": true,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "1",
+ "payloadType": "num",
+ "x": 1200,
+ "y": 600,
+ "wires": [
+ [
+ "75da58a36bdf1746"
+ ]
+ ]
+ },
+ {
+ "id": "c673a20ca71779b4",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 2",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "false",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 2360,
+ "y": 180,
+ "wires": []
+ },
+ {
+ "id": "1f55fe974c4bb812",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "function 1",
+ "func": "let last = context.get('last') || 0;\nlet current = Number(msg.payload); \n\nif (current !== last) {\n context.set('last', current);\n return msg; \n}\n\nreturn null; \n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2400,
+ "y": 300,
+ "wires": [
+ [
+ "c673a20ca71779b4",
+ "decce87e728471dd"
+ ]
+ ]
+ },
+ {
+ "id": "6b865662639d7c1c",
+ "type": "switch",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "",
+ "property": "topic",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "istype",
+ "v": "string",
+ "vt": "string"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 1,
+ "x": 1530,
+ "y": 820,
+ "wires": [
+ [
+ "20896582916b31a3"
+ ]
+ ]
+ },
+ {
+ "id": "8f77f5583ee4044e",
+ "type": "change",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Reset saveKpis flag",
+ "rules": [
+ {
+ "t": "set",
+ "p": "state",
+ "pt": "global",
+ "to": "$merge([($globalContext(\"state\") ? $globalContext(\"state\") : {}), {\"saveKpis\": 0}])",
+ "tot": "jsonata"
+ }
+ ],
+ "action": "",
+ "property": "",
+ "from": "",
+ "to": "",
+ "reg": false,
+ "x": 2200,
+ "y": 660,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "9c42f5d026b5c7b4",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 4",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 1670,
+ "y": 140,
+ "wires": []
+ },
+ {
+ "id": "70e7dcc8c5c68c88",
+ "type": "rpi-gpio in",
+ "z": "a79ad45246b8dac2",
+ "name": "",
+ "pin": "17",
+ "intype": "up",
+ "debounce": "25",
+ "read": true,
+ "bcm": true,
+ "x": 2180,
+ "y": 220,
+ "wires": [
+ [
+ "1f55fe974c4bb812"
+ ]
+ ]
+ },
+ {
+ "id": "53629ebb30f7c4fd",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Build Current State Snapshot",
+ "func": "// Build Current State Snapshot (outbox payload)\n\nconst config = global.get(\"config\") || {};\nconst machineId = config.machineId;\nconst now = Date.now();\nconst state = global.get(\"state\") || {};\nconst snapshot = state.lastState;\nconst settings = global.get(\"settings\") || {};\nconst lastMoldActive = Number(state.lastMoldActive ?? 0);\nconst moldActive = Number(\n snapshot?.activeWorkOrder?.cavities ??\n lastMoldActive ??\n snapshot?.cavities ??\n settings.moldActive ??\n global.get(\"moldActive\") ??\n 0\n);\nconst cavities = moldActive > 0 ? moldActive : null;\nmsg.tsMs = now;\n\n// This is the state you already have (from UI or from your state builder)\nconst s = msg.payload || {};\n\nmsg._mode = \"current-state\";\n\n// what you POST to cloud\nmsg.payload = {\n machineId: machineId,\n tsMs: now,\n activeWorkOrder: s.activeWorkOrder ?? null,\n cycle_count: s.cycleCount ?? null,\n good_parts: s.goodParts ?? null,\n scrap_parts: s.scrapParts ?? null,\n cavities,\n cycleTime: s.cycleTime ?? null,\n actualCycleTime: s.actualCycleTime ?? null,\n trackingEnabled: s.trackingEnabled ?? null,\n productionStarted: s.productionStarted ?? null,\n kpis: s.kpis ?? null,\n};\n\n// what goes into outbox (MUST MATCH what you intend to send)\nconst p = msg.payload || {};\n\nmsg.outbox = {\n type: \"kpi\",\n payload: {\n tsMs: p.tsMs,\n activeWorkOrder: p.activeWorkOrder,\n cycle_count: p.cycle_count,\n good_parts: p.good_parts,\n scrap_parts: p.scrap_parts,\n cavities: p.cavities,\n cycleTime: p.cycleTime,\n actualCycleTime: p.actualCycleTime,\n trackingEnabled: p.trackingEnabled,\n productionStarted: p.productionStarted,\n kpis: p.kpis,\n },\n};\n\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2420,
+ "y": 980,
+ "wires": [
+ [
+ "6748920674e0b141"
+ ]
+ ]
+ },
+ {
+ "id": "4b42a4e4bb445e89",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Build Event Outbox Payload",
+ "func": "// Build event outbox payload (direct HTTP disabled)\n// Keep backward-compatible event shape while exposing an explicit downtime block.\n\nconst anomaly = global.get(\"anomaly\") || {};\nconst event = (msg.payload && typeof msg.payload === \"object\") ? { ...msg.payload } : {};\nconst tsMs = typeof event.tsMs === \"number\" ? event.tsMs : Date.now();\n\nconst incidentKey =\n event.incidentKey ||\n event.incident_key ||\n (event.data && event.data.last_cycle_timestamp\n ? [event.anomaly_type || event.anomalyType || \"event\", event.work_order_id || event.workOrderId || \"\", String(event.data.last_cycle_timestamp)].join(\":\")\n : null);\n\nconst reasonFromStore = incidentKey && anomaly.reasonsByIncident\n ? anomaly.reasonsByIncident[incidentKey]\n : null;\n\nif (!event.reason && reasonFromStore) {\n event.reason = reasonFromStore;\n}\n\nconst anomalyType = event.anomaly_type || event.anomalyType || null;\nconst isDowntimeType = anomalyType === \"microstop\" || anomalyType === \"macrostop\";\n\nif (!event.downtime) {\n event.downtime = isDowntimeType ? {\n incidentKey: incidentKey || null,\n anomalyType,\n durationSeconds: event.data && Number(event.data.stoppage_duration_seconds) || null\n } : null;\n}\n\nmsg.tsMs = tsMs;\nmsg.outbox = {\n type: \"event\",\n payload: { event },\n};\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2540,
+ "y": 780,
+ "wires": [
+ [
+ "c829b1bc4212b5d0"
+ ]
+ ]
+ },
+ {
+ "id": "eff0c00d7ea8fdb2",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "name": "",
+ "props": [
+ {
+ "p": "payload"
+ },
+ {
+ "p": "topic",
+ "vt": "str"
+ }
+ ],
+ "repeat": "5",
+ "crontab": "",
+ "once": true,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "",
+ "payloadType": "date",
+ "x": 2290,
+ "y": 700,
+ "wires": [
+ [
+ "451c6725a5ed57ef"
+ ]
+ ]
+ },
+ {
+ "id": "451c6725a5ed57ef",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Online HeartBeat",
+ "func": "// Heartbeat Producer (feeds Outbox Enqueue v1)\n\nconst config = global.get(\"config\") || {};\nconst machineId = msg.machineId || config.machineId;\nif (!machineId) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Heartbeat waiting for pairing\" });\n return null;\n}\nmsg.machineId = machineId;\n\n// Edge heartbeat = \"I am alive and running Node-RED\"\nconst status = \"ONLINE\";\n\n// pull these from globals if possible (set once at boot)\nconst ip = config.edgeIp || msg.ip || \"192.168.18.33\";\nconst fwVersion = config.fwVersion || \"raspi-nodered-1.0\";\nconst message = \"NR heartbeat\";\n\n// ---- DEDUPE / THROTTLE ----\n// Only enqueue if changed OR interval elapsed\nconst now = Date.now();\nmsg.tsMs = now;\nconst intervalMs = Number(config.heartbeatIntervalMs || 15000);\n\n// Only include \"stable\" fields in signature; don't include timestamps\nconst signature = JSON.stringify({ status, ip, fwVersion });\n\nconst last = flow.get(\"hb_last\");\nif (last && last.signature === signature && (now - last.tsMs) < intervalMs) {\n return null; // skip enqueue (prevents spamming)\n}\nflow.set(\"hb_last\", { signature, tsMs: now });\n\n// This is what Outbox Enqueue v1 consumes\nmsg.outbox = {\n type: \"heartbeat\",\n payload: { status, message, ip, fwVersion },\n};\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2540,
+ "y": 640,
+ "wires": [
+ [
+ "4d2e7dc38b60e4f1"
+ ]
+ ]
+ },
+ {
+ "id": "4e2f27bf287204d8",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Build Cycle Outbox Payload",
+ "func": "// Build cycle outbox payload (direct HTTP disabled)\n\nconst config = global.get(\"config\") || {};\nconst machineId = config.machineId;\n\nif (!msg.cycleRow) return null;\n\nmsg.tsMs = typeof msg.cycleRow.tsMs === \"number\" ? msg.cycleRow.tsMs : Date.now();\n\n// Deduplicate (extra safety)\nconst dedupeKey = `cc:${msg.cycleRow.cycle_count}`;\nconst lastKey = flow.get(\"lastCyclePostedKey\");\nif (lastKey === dedupeKey) return null;\nflow.set(\"lastCyclePostedKey\", dedupeKey);\n\n// For debugging visibility\nmsg._debug = {\n machineId: machineId,\n cycle_count: msg.cycleRow.cycle_count,\n actual_cycle_time: msg.cycleRow.actual_cycle_time,\n theoretical_cycle_time: msg.cycleRow.theoretical_cycle_time,\n};\n\nmsg.outbox = {\n type: \"cycle\",\n payload: { cycle: msg.cycleRow },\n};\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2540,
+ "y": 720,
+ "wires": [
+ [
+ "c829b1bc4212b5d0"
+ ]
+ ]
+ },
+ {
+ "id": "9fdaec698aee2f53",
+ "type": "http request",
+ "z": "a79ad45246b8dac2",
+ "d": true,
+ "name": "Legacy Direct Cycle HTTP (disabled)",
+ "method": "POST",
+ "ret": "txt",
+ "paytoqs": "ignore",
+ "url": "http://mis.maliountech.com.mx/api/ingest/cycle",
+ "tls": "",
+ "persist": false,
+ "proxy": "",
+ "insecureHTTPParser": false,
+ "authType": "",
+ "senderr": false,
+ "headers": [
+ {
+ "keyType": "Content-Type",
+ "keyValue": "",
+ "valueType": "other",
+ "valueValue": "application/json"
+ },
+ {
+ "keyType": "other",
+ "keyValue": "x-api-key",
+ "valueType": "other",
+ "valueValue": "e0113ab0688769179fce30a44d21e4fd0576747b0886e9a1"
+ }
+ ],
+ "x": 2770,
+ "y": 320,
+ "wires": [
+ [
+ "9d71d9bd38a5b328"
+ ]
+ ]
+ },
+ {
+ "id": "9d71d9bd38a5b328",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 9",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 2660,
+ "y": 180,
+ "wires": []
+ },
+ {
+ "id": "7d5e8036e1e35184",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "name": "",
+ "props": [
+ {
+ "p": "payload"
+ },
+ {
+ "p": "topic",
+ "vt": "str"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": 0.1,
+ "topic": "0",
+ "payload": "1",
+ "payloadType": "num",
+ "x": 2500,
+ "y": 360,
+ "wires": [
+ [
+ "1f55fe974c4bb812"
+ ]
+ ]
+ },
+ {
+ "id": "c829b1bc4212b5d0",
+ "type": "subflow:57af108730736bbe",
+ "z": "a79ad45246b8dac2",
+ "name": "Outbox Enqueue v1",
+ "x": 2920,
+ "y": 780,
+ "wires": [
+ [
+ "f31c2fd3512ef212"
+ ]
+ ]
+ },
+ {
+ "id": "20876fad729ff41b",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "name": "Publisher tick",
+ "props": [
+ {
+ "p": "payload"
+ },
+ {
+ "p": "topic",
+ "vt": "str"
+ }
+ ],
+ "repeat": "10",
+ "crontab": "",
+ "once": false,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "",
+ "payloadType": "date",
+ "x": 2480,
+ "y": 920,
+ "wires": [
+ [
+ "8b4c78f38af40e7b"
+ ]
+ ]
+ },
+ {
+ "id": "b825c4c276d2f924",
+ "type": "mysql",
+ "z": "a79ad45246b8dac2",
+ "mydb": "fc9634aabefee16b",
+ "name": "Fetch pending outbox",
+ "x": 3020,
+ "y": 880,
+ "wires": [
+ [
+ "7b6504cc916df65c"
+ ]
+ ]
+ },
+ {
+ "id": "8b4c78f38af40e7b",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Select pending batch",
+ "func": "// Assume data\n\n\n// Set the SQL query in msg.topic using named parameters\nmsg.topic = `SELECT id, machine_id, msg_type, endpoint, schema_version, seq, ts_device_ms,\n payload_json, attempts, next_attempt_at\nFROM outbox_messages\nWHERE status='pending'\n AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())\nORDER BY id ASC\nLIMIT 25;\n`\n// Set the values in msg.payload as an object\n\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2800,
+ "y": 880,
+ "wires": [
+ [
+ "b825c4c276d2f924",
+ "fcbdaf298f703384"
+ ]
+ ]
+ },
+ {
+ "id": "7b6504cc916df65c",
+ "type": "switch",
+ "z": "a79ad45246b8dac2",
+ "name": "Has rows?",
+ "property": "payload",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "eq",
+ "v": "",
+ "vt": "str"
+ },
+ {
+ "t": "neq",
+ "v": "",
+ "vt": "str"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 2,
+ "x": 3230,
+ "y": 880,
+ "wires": [
+ [],
+ [
+ "14c047a53a8cbec6"
+ ]
+ ]
+ },
+ {
+ "id": "14c047a53a8cbec6",
+ "type": "split",
+ "z": "a79ad45246b8dac2",
+ "name": "Split rows",
+ "splt": "\\n",
+ "spltType": "str",
+ "arraySplt": 1,
+ "arraySpltType": "len",
+ "stream": false,
+ "addname": "",
+ "property": "payload",
+ "x": 3420,
+ "y": 880,
+ "wires": [
+ [
+ "90c385d1d30658c4"
+ ]
+ ]
+ },
+ {
+ "id": "90c385d1d30658c4",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Build HTTP request",
+ "func": "const row = msg.payload; // row from DB\n\nconst config = global.get(\"config\") || {};\nconst base = config.cloudBaseUrl;\nconst apiKey = config.apiKey;\n\nconst machineId = config.machineId;\n\nif (machineId && row && row.machine_id && String(row.machine_id) !== String(machineId)) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Stale row (machine mismatch)\" });\n msg.topic = `\nUPDATE outbox_messages\nSET status='failed',\n last_http_status=?,\n last_error=?,\n next_attempt_at=NULL\nWHERE id=?;\n`.trim();\n msg.payload = [409, \"stale_machine\", Number(row.id)];\n return [null, msg];\n}\n\n\n\nif (!base || !apiKey) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Publisher waiting for pairing\" });\n return null;\n}\n\nconst baseUrl = String(base).replace(/\\/+$/, \"\");\nconst endpoint = String(row.endpoint || \"\");\nif (!endpoint.startsWith(\"/\")) throw new Error(\"Publisher: bad endpoint on row \" + row.id);\n\nmsg._row = row;\n\nmsg.method = row.method || \"POST\";\nmsg.url = baseUrl + endpoint;\n\nmsg.headers = {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": apiKey,\n};\n\n// payload_json may be string (most common) or object depending on mysql node\nlet payload = row.payload_json;\nif (typeof payload === \"string\") {\n try { payload = JSON.parse(payload); } catch (e) { }\n}\nmsg.payload = payload;\n\nreturn msg;\n",
+ "outputs": 2,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 3610,
+ "y": 880,
+ "wires": [
+ [
+ "36e94ca0eb6c7b2b",
+ "0f06fb1cea06f532"
+ ],
+ [
+ "6f6d4b62fb2255cb"
+ ]
+ ]
+ },
+ {
+ "id": "fa59b2417027ea74",
+ "type": "http request",
+ "z": "a79ad45246b8dac2",
+ "name": "Send outbox HTTP",
+ "method": "use",
+ "ret": "txt",
+ "paytoqs": "ignore",
+ "url": "",
+ "tls": "",
+ "persist": false,
+ "proxy": "",
+ "insecureHTTPParser": false,
+ "authType": "",
+ "senderr": false,
+ "headers": [],
+ "x": 3710,
+ "y": 680,
+ "wires": [
+ [
+ "b6ffffe0f2ccc999"
+ ]
+ ]
+ },
+ {
+ "id": "b6ffffe0f2ccc999",
+ "type": "switch",
+ "z": "a79ad45246b8dac2",
+ "name": "HTTP 200?",
+ "property": "statusCode",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "eq",
+ "v": "200",
+ "vt": "str"
+ },
+ {
+ "t": "neq",
+ "v": "200",
+ "vt": "str"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 2,
+ "x": 3850,
+ "y": 880,
+ "wires": [
+ [
+ "63324e8e561fe553"
+ ],
+ [
+ "71593e74f3bd2c84"
+ ]
+ ]
+ },
+ {
+ "id": "63324e8e561fe553",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Mark Sent",
+ "func": "// Build Sent Update (use this right after HTTP request success branch)\nconst row = msg._row; // <-- IMPORTANT\nconst status = Number(msg.statusCode ?? 0);\n\nmsg.topic = `\n UPDATE outbox_messages\n SET status='sent',\n sent_at=NOW(),\n last_http_status=?,\n last_error=NULL\n WHERE id=?;\n`.trim();\n\nmsg.payload = [status, Number(row.id)];\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 4020,
+ "y": 860,
+ "wires": [
+ [
+ "6f6d4b62fb2255cb"
+ ]
+ ]
+ },
+ {
+ "id": "71593e74f3bd2c84",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Retry",
+ "func": "const row = msg._row;\nconst attempts = Number(row.attempts || 0) + 1;\n\nconst retryConfig = {\n shortDelaySec: 5,\n mediumDelaySec: 30,\n longDelaySec: 180,\n mediumAfter: 5,\n longAfter: 20,\n errorMaxLen: 450,\n};\n\nconst status = Number(msg.statusCode || 0);\nlet err = \"\";\nif (msg.payload && typeof msg.payload === \"object\") {\n err = JSON.stringify(msg.payload).slice(0, retryConfig.errorMaxLen);\n} else if (msg.payload != null) {\n err = String(msg.payload).slice(0, retryConfig.errorMaxLen);\n} else {\n err = \"request_failed\";\n}\n\nif (status === 401 || status === 403) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Unauthorized - re-pair\" });\n msg.topic = `\nUPDATE outbox_messages\nSET status='failed',\n last_http_status=?,\n last_error=?,\n next_attempt_at=NULL\nWHERE id=?;\n`.trim();\n msg.payload = [ status || null, err, Number(row.id) ];\n return msg;\n}\n\n// Backoff policy\nlet delaySec = retryConfig.shortDelaySec;\nif (attempts <= retryConfig.mediumAfter) delaySec = retryConfig.shortDelaySec;\nelse if (attempts <= retryConfig.longAfter) delaySec = retryConfig.mediumDelaySec;\nelse delaySec = retryConfig.longDelaySec;\n\nmsg.topic = `\nUPDATE outbox_messages\nSET attempts=?,\n next_attempt_at=DATE_ADD(NOW(), INTERVAL ? SECOND),\n last_http_status=?,\n last_error=?\nWHERE id=?;\n`.trim();\n\nmsg.payload = [ attempts, delaySec, status || null, err, Number(row.id) ];\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 4010,
+ "y": 900,
+ "wires": [
+ [
+ "6f6d4b62fb2255cb"
+ ]
+ ]
+ },
+ {
+ "id": "6f6d4b62fb2255cb",
+ "type": "mysql",
+ "z": "a79ad45246b8dac2",
+ "mydb": "fc9634aabefee16b",
+ "name": "Update outbox status",
+ "x": 4450,
+ "y": 900,
+ "wires": [
+ [
+ "8beeff52fe4a548a"
+ ]
+ ]
+ },
+ {
+ "id": "6ff6848b6061e42a",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "State Accumulator global",
+ "func": "const config = global.get(\"config\") || {};\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\nconst machineId = config.machineId; // set this once at boot (see below)\nif (!machineId) { node.warn(\"lastState: missing config.machineId\"); return null; }\nconst active = state.activeWorkOrder || null;\nconst trackingEnabled = !!state.trackingEnabled;\nconst productionStarted = !!state.productionStarted;\nconst kpis = msg.kpis || state.currentKPIs || { oee: 0, availability: 0, performance: 0, quality: 0 };\nconst moldByWorkOrder = state.moldByWorkOrder || {};\nconst moldActive = Number(\n active?.cavities ??\n moldByWorkOrder[active?.id]?.active ??\n state.lastMoldActive ??\n settings.moldActive ??\n global.get(\"moldActive\") ??\n 0\n);\nconst cavities = moldActive > 0 ? moldActive : null;\n\nconst lastState = {\n machineId,\n activeWorkOrder: active,\n cycleCount: Number(state.cycleCount ?? 0),\n goodParts: Number(active?.goodParts ?? 0),\n scrapParts: Number(active?.scrapParts ?? 0),\n cavities,\n cycleTime: Number(active?.cycleTime ?? state.lastCycleTime ?? 0),\n actualCycleTime: Number(state.lastActualCycleTime ?? 0),\n trackingEnabled,\n productionStarted,\n kpis,\n tsMs: Date.now(),\n};\n\nstate.lastState = lastState;\nglobal.set(\"state\", state);\nmsg.lastState = lastState;\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1690,
+ "y": 760,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "6105642572f78ad7",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "name": "KPI state getter",
+ "props": [
+ {
+ "p": "payload"
+ },
+ {
+ "p": "topic",
+ "vt": "str"
+ }
+ ],
+ "repeat": "60",
+ "crontab": "",
+ "once": false,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "",
+ "payloadType": "date",
+ "x": 2210,
+ "y": 1020,
+ "wires": [
+ [
+ "bf85e52109378395"
+ ]
+ ]
+ },
+ {
+ "id": "bf85e52109378395",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Build KPI Outbox from lastState",
+ "func": "// Build KPI Outbox from lastState\n\nconst config = global.get(\"config\") || {};\nconst state = global.get(\"state\") || {};\nconst snapshot = state.lastState;\nconst settings = global.get(\"settings\") || {};\nconst moldByWorkOrder = state.moldByWorkOrder || {};\nconst moldActive = Number(settings.moldActive ?? global.get(\"moldActive\") ?? snapshot?.activeWorkOrder?.cavities ?? 0);\n//const cavities = moldActive > 0 ? moldActive : snapshot?.cavities ?? null;\n\nif (!snapshot) return null;\n\nconst machineId = snapshot.machineId || config.machineId;\nif (!machineId) return null;\n\nmsg.machineId = machineId;\n\nconst activeWorkOrder = snapshot?.activeWorkOrder ? { ...snapshot.activeWorkOrder } : null;\nif (activeWorkOrder?.lastUpdateIso && typeof activeWorkOrder.lastUpdateIso !== \"string\") {\n delete activeWorkOrder.lastUpdateIso;\n}\n\nconst cavitiesRaw = Number(\n activeWorkOrder?.cavities ??\n moldByWorkOrder[activeWorkOrder?.id]?.active ??\n snapshot?.cavities ??\n state.lastMoldActive ??\n settings.moldActive ??\n global.get(\"moldActive\") ??\n 0\n);\nconst cavities = cavitiesRaw > 0 ? cavitiesRaw : null;\n\n\n\nmsg.outbox = {\n type: \"kpi\",\n payload: {\n tsMs: snapshot.tsMs,\n activeWorkOrder,\n cycle_count: snapshot.cycleCount,\n good_parts: snapshot.goodParts,\n scrap_parts: snapshot.scrapParts,\n cavities,\n cycleTime: snapshot.cycleTime,\n actualCycleTime: snapshot.actualCycleTime,\n trackingEnabled: snapshot.trackingEnabled,\n productionStarted: snapshot.productionStarted,\n kpis: snapshot.kpis,\n },\n};\n\nmsg.tsMs = typeof snapshot.tsMs === \"number\" ? snapshot.tsMs : Date.now();\n\n// keep timestamp so you can see it ticking\nmsg.payload = msg.tsMs;\nmsg.topic = \"\";\n\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2450,
+ "y": 1020,
+ "wires": [
+ [
+ "159fc2d83f90a2f5"
+ ]
+ ]
+ },
+ {
+ "id": "f31c2fd3512ef212",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 1",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 3190,
+ "y": 1120,
+ "wires": []
+ },
+ {
+ "id": "8beeff52fe4a548a",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 3",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 4540,
+ "y": 1100,
+ "wires": []
+ },
+ {
+ "id": "6748920674e0b141",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 5",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 2590,
+ "y": 1220,
+ "wires": []
+ },
+ {
+ "id": "159fc2d83f90a2f5",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 6",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 3030,
+ "y": 1200,
+ "wires": []
+ },
+ {
+ "id": "36e94ca0eb6c7b2b",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 7",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 3400,
+ "y": 1080,
+ "wires": []
+ },
+ {
+ "id": "fcbdaf298f703384",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 8",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 3050,
+ "y": 1020,
+ "wires": []
+ },
+ {
+ "id": "780fcb485d7bb033",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 10",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 2010,
+ "y": 1080,
+ "wires": []
+ },
+ {
+ "id": "6020b3e4d7a5bff9",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 11",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 2000,
+ "y": 1140,
+ "wires": []
+ },
+ {
+ "id": "425972e7bba308b1",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "name": "Init config inject",
+ "props": [
+ {
+ "p": "payload"
+ },
+ {
+ "p": "topic",
+ "vt": "str"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": true,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "",
+ "payloadType": "date",
+ "x": 3250,
+ "y": 220,
+ "wires": [
+ [
+ "1ce5b39dbe343525"
+ ]
+ ]
+ },
+ {
+ "id": "18f9a5f87e9604a0",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "89f1f7c120478582",
+ "name": "Pair Machine Request",
+ "func": "const topic = msg.topic || \"\";\nif (topic != \"pairMachine\") {\n return null;\n}\n\nconst raw = (msg.payload && (msg.payload.code || msg.payload.pairingCode || msg.payload)) || \"\";\nconst code = String(raw).trim().toUpperCase().replace(/[^A-Z0-9]/g, \"\");\n\nif (code.length != 5) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Bad code\" });\n return [null, { topic: \"pairMachineResult\", payload: { ok: false, error: \"Codigo invalido.\" } }];\n}\n\nconst config = global.get(\"config\") || {};\nconst baseUrl = String(config.cloudBaseUrl || \"https://mis.maliountech.com.mx\").replace(/\\/+$/, \"\");\n\nmsg.method = \"POST\";\nmsg.url = baseUrl + \"/api/machines/pair\";\nmsg.headers = { \"Content-Type\": \"application/json\" };\nmsg.payload = { code: code };\n\nnode.status({ fill: \"blue\", shape: \"dot\", text: \"Pairing...\" });\nreturn [msg, null];\n",
+ "outputs": 2,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 430,
+ "y": 780,
+ "wires": [
+ [
+ "c56fb9ab2d2a814c"
+ ],
+ [
+ "a4a3e1da9f07ef9f"
+ ]
+ ]
+ },
+ {
+ "id": "c56fb9ab2d2a814c",
+ "type": "http request",
+ "z": "a79ad45246b8dac2",
+ "name": "Pair Machine HTTP",
+ "method": "use",
+ "ret": "obj",
+ "paytoqs": "ignore",
+ "url": "",
+ "tls": "",
+ "persist": false,
+ "proxy": "",
+ "insecureHTTPParser": false,
+ "authType": "",
+ "senderr": false,
+ "headers": [],
+ "x": 640,
+ "y": 780,
+ "wires": [
+ [
+ "19183bba78024339"
+ ]
+ ]
+ },
+ {
+ "id": "19183bba78024339",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "89f1f7c120478582",
+ "name": "Pair Machine Response",
+ "func": "let res = msg.payload;\nif (typeof res == \"string\") {\n try { res = JSON.parse(res); } catch (e) { res = { error: res }; }\n}\nres = res || {};\n\nconst ok = res.ok === true || res.success === true;\nconst cfg = res.config || res;\n\nif (ok && cfg && cfg.machineId && cfg.apiKey) {\n const current = global.get(\"config\") || {};\n current.cloudBaseUrl = cfg.cloudBaseUrl || current.cloudBaseUrl;\n current.machineId = cfg.machineId;\n current.apiKey = cfg.apiKey;\n current.orgId = cfg.orgId || current.orgId;\n global.set(\"config\", current);\n\n node.status({ fill: \"green\", shape: \"dot\", text: \"Paired\" });\n msg.topic = \"pairMachineResult\";\n msg.payload = { ok: true, machineId: current.machineId };\n return msg;\n}\n\nconst error = (res && (res.error || res.message)) || (msg.statusCode ? (\"HTTP \" + msg.statusCode) : \"Pairing failed\");\nnode.status({ fill: \"red\", shape: \"ring\", text: \"Pair failed\" });\nmsg.topic = \"pairMachineResult\";\nmsg.payload = { ok: false, error: error };\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 860,
+ "y": 780,
+ "wires": [
+ [
+ "a4a3e1da9f07ef9f",
+ "1343d2ba483a6b6f"
+ ]
+ ]
+ },
+ {
+ "id": "49b88cae8aed7b60",
+ "type": "link in",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "link into settings",
+ "links": [],
+ "x": 255,
+ "y": 440,
+ "wires": [
+ [
+ "bd965a93d24d47a3"
+ ]
+ ]
+ },
+ {
+ "id": "dac27750f3363b4c",
+ "type": "switch",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "Wifi switch",
+ "property": "topic",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "eq",
+ "v": "wifi:scan",
+ "vt": "str"
+ },
+ {
+ "t": "eq",
+ "v": "wifi:status",
+ "vt": "str"
+ },
+ {
+ "t": "eq",
+ "v": "wifi:apply",
+ "vt": "str"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 3,
+ "x": 700,
+ "y": 460,
+ "wires": [
+ [
+ "daaa086c95b94825"
+ ],
+ [
+ "02aa92deb817cbab"
+ ],
+ [
+ "c035374e89a59b5d"
+ ]
+ ]
+ },
+ {
+ "id": "02aa92deb817cbab",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "wifi:status from settings",
+ "mode": "link",
+ "links": [],
+ "x": 815,
+ "y": 460,
+ "wires": []
+ },
+ {
+ "id": "daaa086c95b94825",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "wifi:scan from settings",
+ "mode": "link",
+ "links": [],
+ "x": 815,
+ "y": 420,
+ "wires": []
+ },
+ {
+ "id": "c035374e89a59b5d",
+ "type": "link out",
+ "z": "a79ad45246b8dac2",
+ "g": "d220d9594c926cec",
+ "name": "wifi:apply from settings",
+ "mode": "link",
+ "links": [],
+ "x": 815,
+ "y": 500,
+ "wires": []
+ },
+ {
+ "id": "909a11d655bf2a01",
+ "type": "mqtt in",
+ "z": "a79ad45246b8dac2",
+ "name": "MQTT settings (org)",
+ "topic": "mis/org/+/settings/updated",
+ "qos": "2",
+ "datatype": "auto",
+ "broker": "c42b40f4665bc79c",
+ "nl": false,
+ "rap": true,
+ "rh": 0,
+ "inputs": 0,
+ "x": 240,
+ "y": 900,
+ "wires": [
+ [
+ "554bcce800133629"
+ ]
+ ]
+ },
+ {
+ "id": "f2f9e0ec2c27dc30",
+ "type": "mqtt in",
+ "z": "a79ad45246b8dac2",
+ "name": "MQTT settings (machine)",
+ "topic": "mis/org/+/machines/+/settings/updated",
+ "qos": "2",
+ "datatype": "auto",
+ "broker": "c42b40f4665bc79c",
+ "nl": false,
+ "rap": true,
+ "rh": 0,
+ "inputs": 0,
+ "x": 240,
+ "y": 940,
+ "wires": [
+ [
+ "554bcce800133629"
+ ]
+ ]
+ },
+ {
+ "id": "554bcce800133629",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Parse settings update",
+ "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || typeof payload !== \"object\") {\n payload = {};\n}\n\nconst topic = String(msg.topic || \"\");\nconst parts = topic.split(\"/\");\n\nfunction pickPart(label) {\n const idx = parts.indexOf(label);\n if (idx === -1) return null;\n return parts[idx + 1] || null;\n}\n\nconst orgId = payload.orgId || pickPart(\"org\");\nconst machineId = payload.machineId || pickPart(\"machines\");\n\nif (orgId) payload.orgId = orgId;\nif (machineId) payload.machineId = machineId;\n\nmsg.payload = payload;\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 480,
+ "y": 920,
+ "wires": [
+ [
+ "5e707b36c961c478"
+ ]
+ ]
+ },
+ {
+ "id": "5e707b36c961c478",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Fetch settings from Control Tower",
+ "func": "const config = global.get(\"config\") || {};\nconst base = config.cloudBaseUrl;\nconst apiKey = config.apiKey;\nconst machineId = config.machineId;\n\nif (!base || !apiKey || !machineId) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Settings fetch waiting for pairing\" });\n return null;\n}\n\nconst update = msg.payload || {};\nconst forced = msg.topic === \"settingsRefresh\" || msg.forceRefresh === true;\n\nif (!forced) {\n if (update.machineId && update.machineId !== machineId) {\n return null;\n }\n if (config.orgId && update.orgId && update.orgId !== config.orgId) {\n return null;\n }\n}\n\nconst baseUrl = String(base).replace(/\\/+$/, \"\");\nmsg.method = \"GET\";\nmsg.url = baseUrl + \"/api/settings/machines/\" + machineId;\nmsg.headers = {\n \"x-api-key\": apiKey,\n \"Accept\": \"application/json\"\n};\n\nmsg._settingsUpdate = {\n orgId: update.orgId || config.orgId,\n machineId: update.machineId || machineId,\n version: update.version\n};\nmsg._settingsRequestedAt = Date.now();\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 760,
+ "y": 920,
+ "wires": [
+ [
+ "f08e8bde2f88673a"
+ ]
+ ]
+ },
+ {
+ "id": "f08e8bde2f88673a",
+ "type": "http request",
+ "z": "a79ad45246b8dac2",
+ "name": "Fetch settings HTTP",
+ "method": "use",
+ "ret": "obj",
+ "paytoqs": "ignore",
+ "url": "",
+ "tls": "",
+ "persist": false,
+ "proxy": "",
+ "insecureHTTPParser": false,
+ "authType": "",
+ "senderr": false,
+ "headers": [],
+ "x": 1020,
+ "y": 920,
+ "wires": [
+ [
+ "1697a7a9087b9150"
+ ]
+ ]
+ },
+ {
+ "id": "1697a7a9087b9150",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Apply settings + update UI",
+ "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || payload.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings fetch failed\" });\n return [null, null];\n}\n\nconst effective = payload.effectiveSettings || payload.settings || payload;\nif (!effective || typeof effective !== \"object\") {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings payload missing\" });\n return [null, null];\n}\n\nconst defaults = effective.defaults || {};\nconst shiftSchedule = effective.shiftSchedule || {};\nconst thresholds = effective.thresholds || {};\nconst incomingCatalog = effective.reasonCatalog || null;\n\nconst settings = global.get(\"settings\") || {};\n\nconst nextMoldTotal = Number(defaults.moldTotal ?? settings.moldTotal ?? 0);\nconst nextMoldActive = Number(defaults.moldActive ?? settings.moldActive ?? 0);\n\nsettings.moldTotal = nextMoldTotal;\nsettings.moldActive = nextMoldActive;\n\nif (Array.isArray(shiftSchedule.shifts) && shiftSchedule.shifts.length) {\n settings.shifts = shiftSchedule.shifts.map((s) => ({\n start: s.start,\n end: s.end\n }));\n} else if (!Array.isArray(settings.shifts)) {\n settings.shifts = [{ start: \"08:00\", end: \"16:00\" }];\n}\n\nsettings.shiftChangeCompensation = Number(\n shiftSchedule.shiftChangeCompensationMin ?? settings.shiftChangeCompensation ?? 10\n);\nsettings.lunchBreakMinutes = Number(\n shiftSchedule.lunchBreakMin ?? settings.lunchBreakMinutes ?? 30\n);\n\nsettings.thresholdMultiplier = Number(\n thresholds.stoppageMultiplier ?? settings.thresholdMultiplier ?? 1.5\n);\nsettings.macroStoppageMultiplier = Number(\n thresholds.macroStoppageMultiplier ?? settings.macroStoppageMultiplier ?? 5\n);\nsettings.oeeAlertThreshold = Number(\n thresholds.oeeAlertThresholdPct ?? settings.oeeAlertThreshold ?? 90\n);\n\nconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {\n if (!Array.isArray(list)) return [];\n return list\n .map((c, idx) => {\n const categoryId = String(c.id || c.categoryId || (\"cat_\" + idx));\n const categoryLabel = String(c.label || c.categoryLabel || (fallbackLabelPrefix + \" \" + (idx + 1)));\n const detailsRaw = Array.isArray(c.children) ? c.children : (Array.isArray(c.details) ? c.details : []);\n const details = detailsRaw.map((d, jdx) => ({\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1)))\n }));\n return {\n id: categoryId,\n label: categoryLabel,\n children: details\n };\n })\n .filter((c) => c.label && c.children.length > 0);\n};\n\nconst currentCatalog = settings.reasonCatalog || {};\nconst nextCatalogVersion =\n Number(\n (incomingCatalog && incomingCatalog.version) ??\n effective.reasonCatalogVersion ??\n currentCatalog.version ??\n 1\n ) || 1;\n\nconst hasIncomingCatalog = !!(incomingCatalog && (Array.isArray(incomingCatalog.downtime) || Array.isArray(incomingCatalog.scrap)));\nconst normalizedIncoming = hasIncomingCatalog ? {\n version: nextCatalogVersion,\n downtime: normalizeCatalogItems(incomingCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(incomingCatalog.scrap || [], \"Scrap\")\n} : null;\n\nconst fallbackCatalog = {\n version: Number(currentCatalog.version || nextCatalogVersion || 1),\n downtime: normalizeCatalogItems(currentCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(currentCatalog.scrap || [], \"Scrap\")\n};\n\nsettings.reasonCatalog = normalizedIncoming || fallbackCatalog;\nsettings.reasonCatalog.version = Number(settings.reasonCatalog.version || 1);\n\n\nif (effective.version !== undefined) {\n settings.version = Number(effective.version);\n}\n\nglobal.set(\"settings\", settings);\ntry {\n global.set(\"settings\", settings, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\nglobal.set(\"moldActive\", settings.moldActive);\nglobal.set(\"moldTotal\", settings.moldTotal);\n\nconst config = global.get(\"config\") || {};\nif (effective.orgId && !config.orgId) {\n config.orgId = effective.orgId;\n global.set(\"config\", config);\n}\n\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Settings synced\" });\n\nconst uiConfigMsg = {\n topic: \"shiftConfigData\",\n payload: {\n shifts: settings.shifts || [],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n }\n};\n\n\nconst uiMoldMsg = {\n topic: \"moldPresetSelected\",\n payload: {\n total: settings.moldTotal || 0,\n active: settings.moldActive || 0\n }\n};\n\nconst readOnly = config.settingsReadOnly !== false;\nconst uiReadOnlyMsg = { topic: \"settingsReadOnly\", payload: readOnly };\nconst uiReasonCatalogMsg = {\n topic: \"reasonCatalogData\",\n payload: settings.reasonCatalog\n};\n\nnode.send([uiConfigMsg, null]);\nnode.send([uiMoldMsg, null]);\nnode.send([uiReadOnlyMsg, null]);\nnode.send([uiReasonCatalogMsg, null]);\n\nconst update = msg._settingsUpdate || {};\nconst orgId = update.orgId || config.orgId || effective.orgId;\nconst machineId = update.machineId || config.machineId;\nconst version = Number(effective.version ?? update.version ?? 0);\n\nif (!orgId || !machineId) {\n return [null, null];\n}\n\nconst prefix = String(config.mqttTopicPrefix || \"mis\").replace(/\\/+$/, \"\");\nconst ackTopic = prefix + \"/org/\" + orgId + \"/machines/\" + machineId + \"/settings/ack\";\n\nconst ackMsg = {\n topic: ackTopic,\n payload: JSON.stringify({\n type: \"settings_ack\",\n orgId,\n machineId,\n version,\n source: \"node-red\",\n ts: new Date().toISOString()\n })\n};\n\nreturn [null, ackMsg];\n",
+ "outputs": 2,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1280,
+ "y": 920,
+ "wires": [
+ [
+ "a4a3e1da9f07ef9f",
+ "8528aff42bbfaab3",
+ "fef02a862e99d54d"
+ ],
+ [
+ "ab95f8f541d7cd8b"
+ ]
+ ]
+ },
+ {
+ "id": "ab95f8f541d7cd8b",
+ "type": "mqtt out",
+ "z": "a79ad45246b8dac2",
+ "name": "MQTT settings ack",
+ "topic": "",
+ "qos": "2",
+ "retain": "false",
+ "respTopic": "",
+ "contentType": "",
+ "userProps": "",
+ "correl": "",
+ "expiry": "",
+ "broker": "c42b40f4665bc79c",
+ "x": 1420,
+ "y": 960,
+ "wires": []
+ },
+ {
+ "id": "477be4df85befec7",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "name": "Refresh settings (poll)",
+ "props": [
+ {
+ "p": "payload"
+ },
+ {
+ "p": "topic",
+ "vt": "str"
+ }
+ ],
+ "repeat": "600",
+ "crontab": "",
+ "once": true,
+ "onceDelay": 1,
+ "topic": "settingsRefresh",
+ "payload": "",
+ "payloadType": "date",
+ "x": 240,
+ "y": 980,
+ "wires": [
+ [
+ "5e707b36c961c478"
+ ]
+ ]
+ },
+ {
+ "id": "c92f0da16b85d079",
+ "type": "mqtt in",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "MQTT work orders (machine)",
+ "topic": "mis/org/+/machines/+/work_orders/updated",
+ "qos": "2",
+ "datatype": "auto",
+ "broker": "c42b40f4665bc79c",
+ "nl": false,
+ "rap": true,
+ "rh": 0,
+ "inputs": 0,
+ "x": 1260,
+ "y": 320,
+ "wires": [
+ [
+ "facc0ad120adeb3f",
+ "efbe1c9bd843b1ac"
+ ]
+ ]
+ },
+ {
+ "id": "facc0ad120adeb3f",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Parse work order update",
+ "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || typeof payload !== \"object\") {\n payload = {};\n}\n\nconst topic = String(msg.topic || \"\");\nconst parts = topic.split(\"/\");\n\nfunction pickPart(label) {\n const idx = parts.indexOf(label);\n if (idx === -1) return null;\n return parts[idx + 1] || null;\n}\n\nconst orgId = payload.orgId || pickPart(\"org\");\nconst machineId = payload.machineId || pickPart(\"machines\");\n\nif (orgId) payload.orgId = orgId;\nif (machineId) payload.machineId = machineId;\n\nmsg.payload = payload;\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1530,
+ "y": 320,
+ "wires": [
+ [
+ "11682c71a65e85f4"
+ ]
+ ]
+ },
+ {
+ "id": "11682c71a65e85f4",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Fetch work orders from Control Tower",
+ "func": "const config = global.get(\"config\") || {};\nconst base = config.cloudBaseUrl;\nconst apiKey = config.apiKey;\nconst machineId = config.machineId;\n\nif (!base || !apiKey || !machineId) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Work orders fetch waiting for pairing\" });\n return null;\n}\n\nconst update = msg.payload || {};\nif (update.machineId && update.machineId !== machineId) {\n return null;\n}\nif (config.orgId && update.orgId && update.orgId !== config.orgId) {\n return null;\n}\n\nconst baseUrl = String(base).replace(/\\/+$/, \"\");\nmsg.method = \"GET\";\nmsg.url = baseUrl + \"/api/work-orders/machines/\" + machineId;\nmsg.headers = {\n \"x-api-key\": apiKey,\n \"Accept\": \"application/json\"\n};\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1770,
+ "y": 320,
+ "wires": [
+ [
+ "01667230e00d8e02"
+ ]
+ ]
+ },
+ {
+ "id": "01667230e00d8e02",
+ "type": "http request",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Fetch work orders HTTP",
+ "method": "use",
+ "ret": "obj",
+ "paytoqs": "ignore",
+ "url": "",
+ "tls": "",
+ "persist": false,
+ "proxy": "",
+ "insecureHTTPParser": false,
+ "authType": "",
+ "senderr": false,
+ "headers": [],
+ "x": 2060,
+ "y": 320,
+ "wires": [
+ [
+ "985337c608ae84eb",
+ "5ca46afa62f2d693"
+ ]
+ ]
+ },
+ {
+ "id": "985337c608ae84eb",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "6331afda01e9c79f",
+ "name": "Upsert work orders to local DB",
+ "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || payload.ok === false) {\n node.status({\n fill: \"red\",\n shape: \"ring\",\n text: \"Work orders fetch failed\",\n });\n return null;\n}\n\nconst list = Array.isArray(payload.workOrders)\n ? payload.workOrders\n : Array.isArray(payload.orders)\n ? payload.orders\n : [];\n\nif (!list.length) {\n node.status({\n fill: \"yellow\",\n shape: \"ring\",\n text: \"No work orders\",\n });\n return null;\n}\n\nconst seen = new Set();\nconst values = [];\n\nlist.forEach((order) => {\n const id = String(\n order.workOrderId ||\n order.id ||\n order.work_order_id ||\n \"\"\n ).trim();\n\n if (!id || seen.has(id)) return;\n seen.add(id);\n\n const sku = String(order.sku || \"\").trim();\n\n const targetQtyRaw =\n order.targetQty ??\n order.target_qty ??\n order.target ??\n 0;\n\n const cycleTimeRaw =\n order.cycleTime ??\n order.theoreticalCycleTime ??\n order.theoretical_cycle_time ??\n 0;\n\n const targetQty = Number.isFinite(Number(targetQtyRaw))\n ? Math.trunc(Number(targetQtyRaw))\n : 0;\n\n const cycleTime = Number.isFinite(Number(cycleTimeRaw))\n ? Number(cycleTimeRaw)\n : 0;\n\n values.push([id, sku, targetQty, cycleTime, \"PENDING\"]);\n});\n\nif (!values.length) {\n node.status({\n fill: \"yellow\",\n shape: \"ring\",\n text: \"No valid work orders\",\n });\n return null;\n}\n\nmsg.topic = `\n INSERT INTO work_orders\n (work_order_id, sku, target_qty, cycle_time, status)\n VALUES ?\n ON DUPLICATE KEY UPDATE\n work_order_id = work_order_id;\n`;\n\nmsg.payload = [values];\nmsg._mode = \"sync-work-orders\";\n\nnode.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Synced ${values.length} work orders`,\n});\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2310,
+ "y": 320,
+ "wires": [
+ [
+ "20896582916b31a3",
+ "55bc2d9255199feb"
+ ]
+ ]
+ },
+ {
+ "id": "efbe1c9bd843b1ac",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 15",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 1670,
+ "y": 1340,
+ "wires": []
+ },
+ {
+ "id": "5ca46afa62f2d693",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 16",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 1860,
+ "y": 1300,
+ "wires": []
+ },
+ {
+ "id": "55bc2d9255199feb",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 17",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 2020,
+ "y": 1300,
+ "wires": []
+ },
+ {
+ "id": "8fead4461fa18ebf",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "name": "",
+ "props": [
+ {
+ "p": "payload"
+ },
+ {
+ "p": "topic",
+ "vt": "str"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "",
+ "payloadType": "date",
+ "x": 1350,
+ "y": 1240,
+ "wires": [
+ [
+ "11682c71a65e85f4"
+ ]
+ ]
+ },
+ {
+ "id": "c81504b31f50c862",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 18",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 1690,
+ "y": 1120,
+ "wires": []
+ },
+ {
+ "id": "7a16d1c7c9dd7225",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 19",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 2270,
+ "y": 1220,
+ "wires": []
+ },
+ {
+ "id": "cff3a2f29333543f",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 20",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 960,
+ "y": 220,
+ "wires": []
+ },
+ {
+ "id": "94719c2980aad7b0",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 21",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 1710,
+ "y": 1060,
+ "wires": []
+ },
+ {
+ "id": "054477bae60d4b0f",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 22",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 1770,
+ "y": 1000,
+ "wires": []
+ },
+ {
+ "id": "0f06fb1cea06f532",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "function 3",
+ "func": "const awo = msg.payload?.activeWorkOrder;\nif (awo) {\n const v = awo.lastUpdateIso;\n\n // If it's already a string, keep it\n if (typeof v === \"string\") {\n // ok\n } else if (v instanceof Date) {\n awo.lastUpdateIso = v.toISOString();\n } else if (v && typeof v.toISO === \"function\") {\n // Luxon DateTime\n awo.lastUpdateIso = v.toISO();\n } else if (v && typeof v.toISOString === \"function\") {\n // Some date-like objects\n awo.lastUpdateIso = v.toISOString();\n } else if (v && typeof v.format === \"function\") {\n // Moment-like\n awo.lastUpdateIso = v.format();\n } else {\n // Last resort: try to stringify, otherwise null it out\n awo.lastUpdateIso = v ? String(v) : null;\n }\n node.warn(`[lastUpdateIso] typeof=${typeof v} value=${v}`);\n node.warn(`[lastUpdateIso] JSON=${JSON.stringify(v)}`);\n}\n\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 3560,
+ "y": 740,
+ "wires": [
+ [
+ "fa59b2417027ea74"
+ ]
+ ]
+ },
+ {
+ "id": "4d2e7dc38b60e4f1",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Build Heartbeat HTTP",
+ "func": "// Build direct heartbeat HTTP request\nconst config = global.get(\"config\") || {};\nconst base = config.cloudBaseUrl;\nconst apiKey = config.apiKey;\n\nif (!base || !apiKey) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Heartbeat waiting for pairing\" });\n return null;\n}\n\nconst baseUrl = String(base).replace(/\\/+$/, \"\");\nmsg.method = \"POST\";\nmsg.url = baseUrl + \"/api/ingest/heartbeat\";\nmsg.headers = {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": apiKey,\n};\n\n// Use the same payload you already build in Online HeartBeat\nmsg.payload = {\n machineId: msg.machineId,\n tsMs: msg.tsMs, // device time; server will ignore for last_seen\n status: \"ONLINE\",\n message: \"NR heartbeat\",\n ip: (config.edgeIp || msg.ip || \"192.168.18.33\"),\n fwVersion: (config.fwVersion || \"raspi-nodered-1.0\"),\n};\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2800,
+ "y": 640,
+ "wires": [
+ [
+ "774f2e480531a60c"
+ ]
+ ]
+ },
+ {
+ "id": "774f2e480531a60c",
+ "type": "http request",
+ "z": "a79ad45246b8dac2",
+ "name": "Send Heartbeat",
+ "method": "use",
+ "ret": "txt",
+ "paytoqs": "ignore",
+ "url": "",
+ "tls": "",
+ "persist": false,
+ "proxy": "",
+ "insecureHTTPParser": false,
+ "authType": "",
+ "senderr": false,
+ "headers": [],
+ "x": 3040,
+ "y": 640,
+ "wires": [
+ [
+ "2a769f7cf7d78158"
+ ]
+ ]
+ },
+ {
+ "id": "2a769f7cf7d78158",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 24",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 3210,
+ "y": 660,
+ "wires": []
+ },
+ {
+ "id": "fdb32dd27585bd3e",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 25",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 970,
+ "y": 380,
+ "wires": []
+ },
+ {
+ "id": "0500019de35fd310",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 26",
+ "active": false,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 960,
+ "y": 420,
+ "wires": []
+ },
+ {
+ "id": "28cb7dd965982931",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "g": "92105e5a6e17066a",
+ "name": "",
+ "props": [
+ {
+ "p": "payload"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": true,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "1",
+ "payloadType": "num",
+ "x": 1260,
+ "y": 240,
+ "wires": [
+ [
+ "ae5c83dacb5cf69b"
+ ]
+ ]
+ },
+ {
+ "id": "ae5c83dacb5cf69b",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "g": "92105e5a6e17066a",
+ "name": "Simula Inyectora",
+ "func": "/**\n * Machine Cycle Simulator (0/1 square wave with realistic variance)\n *\n * How to use:\n * - Trigger this node ONCE (Inject once after deploy). It will start emitting 0/1 on its own.\n * - To stop: send a message with msg.payload = \"stop\" (or msg.topic=\"stop\")\n * - To reset state: msg.payload = \"reset\"\n *\n * Output:\n * - msg.payload = 0 or 1\n */\n\nconst CFG = {\n // Base: your 14.5s cycle => 7.25s half-period between toggles\n halfPeriodMs: 7250,\n\n // Small natural jitter during normal/perfect running\n jitterMs: 250, // +/- 250ms\n\n // Perfect behavior windows\n perfectRunMs: 30 * 60 * 1000, // 30 minutes perfect\n pEnterPerfect: 0.12, // chance to enter a perfect run when a phase ends\n\n // Random event probabilities (evaluated per toggle when NOT in perfect run)\n pSlowPhase: 0.10, // slow cycles phase\n pMicroStop: 0.06, // short pause\n pMacroStop: 0.008, // long stop\n\n // Microstop + macrostop durations\n microStopMs: [4000, 20000], // 4s–20s\n macroStopMs: [2 * 60 * 1000, 8 * 60 * 1000], // 2–8 minutes\n\n // Slow cycles\n slowFactor: [1.25, 1.9], // 25%–90% slower (halfPeriod multiplied)\n slowPhaseToggles: [6, 25], // lasts 6–25 toggles\n\n // Avoid back-to-back stops too frequently\n minGapMicroMs: 20 * 1000, // at least 20s between stops\n minGapMacroMs: 60 * 1000, // at least 60s between macrostops\n};\n\nfunction randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }\nfunction randFloat(min, max) { return Math.random() * (max - min) + min; }\nfunction pickMs(range) { return randInt(range[0], range[1]); }\nfunction clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }\n\nfunction clearTimer() {\n const t = context.get(\"timer\");\n if (t) clearTimeout(t);\n context.set(\"timer\", null);\n context.set(\"running\", false);\n}\n\nif (msg && (msg.payload === \"stop\" || msg.topic === \"stop\")) {\n clearTimer();\n node.status({ fill: \"grey\", shape: \"ring\", text: \"stopped\" });\n return null;\n}\n\nif (msg && msg.payload === \"reset\") {\n context.set(\"state\", 0);\n node.status({ fill: \"grey\", shape: \"dot\", text: \"reset to 0\" });\n return null;\n}\n\n// Store a template msg (so you can pass machineId/topic/etc. once)\nif (msg && typeof msg === \"object\") {\n const template = Object.assign({}, msg);\n delete template.payload; // we'll overwrite payload each emit\n context.set(\"template\", template);\n}\n\nlet running = context.get(\"running\") || false;\nlet timer = context.get(\"timer\");\nlet state = context.get(\"state\");\nif (state === undefined || state === null) state = 0;\n\nlet phase = context.get(\"phase\") || { type: \"idle\", until: 0, slowLeft: 0 };\nlet lastStopAt = context.get(\"lastStopAt\") || 0;\n\nfunction decideNextDelayMs() {\n const now = Date.now();\n\n // Perfect run active\n if (phase.type === \"perfect\" && now < phase.until) {\n const jitter = randInt(-CFG.jitterMs, CFG.jitterMs);\n return clamp(CFG.halfPeriodMs + jitter, 300, 30 * 60 * 1000);\n }\n\n // Slow phase active (counted in toggles)\n if (phase.type === \"slow\" && phase.slowLeft > 0) {\n phase.slowLeft -= 1;\n context.set(\"phase\", phase);\n\n const factor = randFloat(CFG.slowFactor[0], CFG.slowFactor[1]);\n const jitter = randInt(-CFG.jitterMs, CFG.jitterMs);\n node.status({ fill: \"blue\", shape: \"dot\", text: `slow (${phase.slowLeft} left)` });\n return clamp(Math.round(CFG.halfPeriodMs * factor) + jitter, 300, 30 * 60 * 1000);\n }\n\n // Phase ended → maybe enter a new perfect run\n if (Math.random() < CFG.pEnterPerfect) {\n phase = { type: \"perfect\", until: now + CFG.perfectRunMs, slowLeft: 0 };\n context.set(\"phase\", phase);\n node.status({ fill: \"green\", shape: \"dot\", text: \"perfect run\" });\n return decideNextDelayMs();\n }\n\n // Event selection (macro > micro > slow > normal)\n const sinceStop = now - lastStopAt;\n\n if (sinceStop > CFG.minGapMacroMs && Math.random() < CFG.pMacroStop) {\n lastStopAt = now;\n context.set(\"lastStopAt\", lastStopAt);\n node.status({ fill: \"red\", shape: \"dot\", text: \"MACRO STOP\" });\n return pickMs(CFG.macroStopMs);\n }\n\n if (sinceStop > CFG.minGapMicroMs && Math.random() < CFG.pMicroStop) {\n lastStopAt = now;\n context.set(\"lastStopAt\", lastStopAt);\n node.status({ fill: \"yellow\", shape: \"dot\", text: \"micro stop\" });\n return pickMs(CFG.microStopMs);\n }\n\n if (Math.random() < CFG.pSlowPhase) {\n phase = { type: \"slow\", until: 0, slowLeft: randInt(CFG.slowPhaseToggles[0], CFG.slowPhaseToggles[1]) };\n context.set(\"phase\", phase);\n return decideNextDelayMs();\n }\n\n // Normal running\n node.status({ fill: \"green\", shape: \"dot\", text: \"running\" });\n const jitter = randInt(-CFG.jitterMs, CFG.jitterMs);\n return clamp(CFG.halfPeriodMs + jitter, 300, 30 * 60 * 1000);\n}\n\nfunction emitTick() {\n // Toggle 0/1\n state = state ? 0 : 1;\n context.set(\"state\", state);\n\n const template = context.get(\"template\") || {};\n const out = Object.assign({}, template, { payload: state });\n\n node.send(out);\n\n // Schedule next emission\n const delay = decideNextDelayMs();\n const t = setTimeout(emitTick, delay);\n context.set(\"timer\", t);\n}\n\nif (!running) {\n context.set(\"running\", true);\n node.status({ fill: \"green\", shape: \"dot\", text: \"started\" });\n}\n\n// Don’t start multiple timers if you accidentally keep the old repeating inject\nif (!timer) {\n const t = setTimeout(emitTick, 100);\n context.set(\"timer\", t);\n}\n\nreturn null;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1440,
+ "y": 240,
+ "wires": [
+ [
+ "decce87e728471dd"
+ ]
+ ]
+ },
+ {
+ "id": "1343d2ba483a6b6f",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Persist pairing config",
+ "func": "const result = msg.payload || {};\nif (!result.ok) return null;\n\nconst config = global.get(\"config\") || {};\nif (!config.machineId || !config.apiKey) return null;\n\nconst settings = global.get(\"settings\") || {};\nconst shifts = settings.shifts || [{ start: \"08:00\", end: \"16:00\" }];\n\nmsg.topic = `\nINSERT INTO current_config (\n machine_id,\n api_key,\n org_id,\n cloud_base_url,\n shifts_json,\n shift_change_comp_min,\n lunch_break_min,\n threshold_multiplier,\n oee_alert_threshold,\n mold_total,\n mold_active,\n updated_at\n) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())\nON DUPLICATE KEY UPDATE\n api_key=VALUES(api_key),\n org_id=VALUES(org_id),\n cloud_base_url=VALUES(cloud_base_url),\n updated_at=NOW();\n`.trim();\n\nmsg.payload = [\n config.machineId,\n config.apiKey,\n config.orgId || null,\n config.cloudBaseUrl || null,\n JSON.stringify(shifts),\n Number(settings.shiftChangeCompensation ?? 10),\n Number(settings.lunchBreakMinutes ?? 30),\n Number(settings.thresholdMultiplier ?? 1.5),\n Number(settings.oeeAlertThreshold ?? 90),\n Number(settings.moldTotal ?? 0),\n Number(settings.moldActive ?? 0)\n];\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1000,
+ "y": 1060,
+ "wires": [
+ [
+ "8643b73f1c7529b2",
+ "a49cb4af069db47a"
+ ]
+ ]
+ },
+ {
+ "id": "8643b73f1c7529b2",
+ "type": "mysql",
+ "z": "a79ad45246b8dac2",
+ "mydb": "fc9634aabefee16b",
+ "name": "Mold Presets DB",
+ "x": 1230,
+ "y": 1060,
+ "wires": [
+ [
+ "e0c5327b7ff6f9ed"
+ ]
+ ]
+ },
+ {
+ "id": "9c649c3cbb1976bc",
+ "type": "mysql",
+ "z": "a79ad45246b8dac2",
+ "mydb": "fc9634aabefee16b",
+ "name": "Mold Presets DB",
+ "x": 3850,
+ "y": 220,
+ "wires": [
+ [
+ "ab0a2225018a1eb0",
+ "54bb41c2b73d2b34"
+ ]
+ ]
+ },
+ {
+ "id": "343d790f4dc48001",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "load config from DB",
+ "func": "msg.topic = `\nSELECT *\nFROM current_config\nWHERE machine_id IS NOT NULL AND machine_id <> ''\nORDER BY updated_at DESC\nLIMIT 1;\n`.trim();\nmsg.payload = [];\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 3650,
+ "y": 220,
+ "wires": [
+ [
+ "9c649c3cbb1976bc"
+ ]
+ ]
+ },
+ {
+ "id": "ab0a2225018a1eb0",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "apply config from DB",
+ "func": "const rows = msg.payload;\n\nconst normalizeRow = (row) => {\n const machineId = (row?.machine_id ?? row?.machineId ?? \"\").toString().trim();\n const apiKey = (\n row?.api_key ??\n row?.apiKey ??\n row?.apikey ??\n row?.x_api_key ??\n row?.xApiKey ??\n \"\"\n ).toString().trim();\n\n const orgId = row?.org_id ?? row?.orgId ?? null;\n const cloudBaseUrl = row?.cloud_base_url ?? row?.cloudBaseUrl ?? row?.base_url ?? null;\n\n return { machineId, apiKey, orgId, cloudBaseUrl };\n};\n\nlet normalized = null;\n\nif (Array.isArray(rows) && rows.length > 0) {\n normalized = normalizeRow(rows[0] || {});\n}\n\nif (!normalized || !normalized.machineId || !normalized.apiKey) {\n msg._configRetry = (msg._configRetry || 0) + 1;\n msg._configLoaded = false;\n return [null, msg]; // retry path\n}\n\nif (msg._configRetry > 12) return [null, null]; // optional stop after 12 tries\n\nconst config = global.get(\"config\") || {};\nconfig.machineId = normalized.machineId;\nconfig.apiKey = normalized.apiKey;\nif (normalized.orgId) config.orgId = normalized.orgId;\nif (normalized.cloudBaseUrl) config.cloudBaseUrl = normalized.cloudBaseUrl;\nif (config.settingsReadOnly === undefined) config.settingsReadOnly = true;\nif (!config.mqttTopicPrefix) config.mqttTopicPrefix = \"mis\";\n\nglobal.set(\"config\", config);\nmsg._configLoaded = true;\nreturn [msg, null];\n",
+ "outputs": 2,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 4080,
+ "y": 220,
+ "wires": [
+ [],
+ [
+ "d10b849115827426"
+ ]
+ ]
+ },
+ {
+ "id": "a246064e0f7b412a",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "d": true,
+ "name": "throttle + dedupe",
+ "func": "// KPI Gate: 1/min throttle + dedupe + fresh timestamp\nconst now = Date.now();\nconst minIntervalMs = 60000;\n\nconst payload = msg.outbox?.payload;\nif (!payload) return null;\n\n// stable signature (no tsMs)\nconst sig = JSON.stringify({\n workOrderId: payload.activeWorkOrder?.id || null,\n cycle_count: payload.cycle_count || 0,\n good_parts: payload.good_parts || 0,\n scrap_parts: payload.scrap_parts || 0,\n cavities: payload.cavities || null,\n cycleTime: payload.cycleTime || 0,\n actualCycleTime: payload.actualCycleTime || 0,\n trackingEnabled: !!payload.trackingEnabled,\n productionStarted: !!payload.productionStarted,\n kpis: payload.kpis || {},\n});\n\nconst lastSentAt = flow.get(\"kpi_lastSentAt\") || 0;\nconst lastSig = flow.get(\"kpi_lastSig\") || \"\";\n\n// Drop if too soon and unchanged\nif ((now - lastSentAt) < minIntervalMs && sig === lastSig) {\n return null;\n}\n\n// Update gate state\nflow.set(\"kpi_lastSentAt\", now);\nflow.set(\"kpi_lastSig\", sig);\n\n// Ensure fresh timestamp to avoid timeline weirdness\npayload.tsMs = now;\nmsg.outbox.payload = payload;\nmsg.tsMs = now;\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2410,
+ "y": 1080,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "d98b1d3591baa960",
+ "type": "inject",
+ "z": "a79ad45246b8dac2",
+ "name": "KPI minute tick",
+ "props": [
+ {
+ "p": "payload"
+ },
+ {
+ "p": "topic",
+ "vt": "str"
+ }
+ ],
+ "repeat": "60",
+ "crontab": "",
+ "once": false,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "",
+ "payloadType": "date",
+ "x": 2520,
+ "y": 840,
+ "wires": [
+ [
+ "2a3458eeba5bf634"
+ ]
+ ]
+ },
+ {
+ "id": "2a3458eeba5bf634",
+ "type": "function",
+ "z": "a79ad45246b8dac2",
+ "name": "Build KPI Minute Snapshot",
+ "func": "// Build KPI Minute Snapshot (single source of KPI outbox)\nconst config = global.get(\"config\") || {};\nconst state = global.get(\"state\") || {};\nconst machineId = config.machineId;\nif (!machineId) return null;\n\nconst now = Date.now();\n// Optional: align to minute boundary for clean timeline\nconst tsMs = now - (now % 60000);\n\nconst rawActive = state.activeWorkOrder || null;\nconst active = rawActive ? { ...rawActive } : null;\n\n// Normalize activeWorkOrder.lastUpdateIso to string if present\nif (active && active.lastUpdateIso != null) {\n const v = active.lastUpdateIso;\n if (typeof v === \"string\") {\n // ok\n } else if (v instanceof Date) {\n active.lastUpdateIso = v.toISOString();\n } else if (typeof v === \"number\") {\n active.lastUpdateIso = new Date(v).toISOString();\n } else if (v && typeof v.toISOString === \"function\") {\n active.lastUpdateIso = v.toISOString();\n } else if (v && typeof v.toISO === \"function\") {\n active.lastUpdateIso = v.toISO();\n } else if (v && typeof v.format === \"function\") {\n active.lastUpdateIso = v.format();\n } else {\n delete active.lastUpdateIso; // avoid invalid payload\n }\n}\n\nconst kpis = state.currentKPIs || { oee: 0, availability: 0, performance: 0, quality: 0 };\n\nconst cavitiesRaw = Number(\n active?.cavities ??\n state.lastMoldActive ??\n 0\n);\nconst cavities = cavitiesRaw > 0 ? cavitiesRaw : null;\n\nmsg.machineId = machineId;\nmsg.tsMs = tsMs;\n\nmsg.outbox = {\n type: \"kpi\",\n payload: {\n tsMs,\n activeWorkOrder: active,\n cycle_count: Number(state.cycleCount ?? 0),\n good_parts: Number(active?.goodParts ?? 0),\n scrap_parts: Number(active?.scrapParts ?? 0),\n cavities,\n cycleTime: Number(active?.cycleTime ?? state.lastCycleTime ?? 0),\n actualCycleTime: Number(state.lastActualCycleTime ?? 0),\n trackingEnabled: !!state.trackingEnabled,\n productionStarted: !!state.productionStarted,\n kpis,\n },\n};\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 2740,
+ "y": 840,
+ "wires": [
+ [
+ "c829b1bc4212b5d0"
+ ]
+ ]
+ },
+ {
+ "id": "1ce5b39dbe343525",
+ "type": "delay",
+ "z": "a79ad45246b8dac2",
+ "name": "",
+ "pauseType": "delay",
+ "timeout": "2",
+ "timeoutUnits": "seconds",
+ "rate": "1",
+ "nbRateUnits": "1",
+ "rateUnits": "second",
+ "randomFirst": "1",
+ "randomLast": "5",
+ "randomUnits": "seconds",
+ "drop": false,
+ "allowrate": false,
+ "outputs": 1,
+ "x": 3460,
+ "y": 220,
+ "wires": [
+ [
+ "343d790f4dc48001"
+ ]
+ ]
+ },
+ {
+ "id": "d10b849115827426",
+ "type": "switch",
+ "z": "a79ad45246b8dac2",
+ "name": "",
+ "property": "config.apiKey",
+ "propertyType": "global",
+ "rules": [
+ {
+ "t": "nempty"
+ },
+ {
+ "t": "empty"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 2,
+ "x": 4290,
+ "y": 220,
+ "wires": [
+ [],
+ [
+ "1ce5b39dbe343525"
+ ]
+ ]
+ },
+ {
+ "id": "a49cb4af069db47a",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 12",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 1210,
+ "y": 1160,
+ "wires": []
+ },
+ {
+ "id": "e0c5327b7ff6f9ed",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 27",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 1050,
+ "y": 1240,
+ "wires": []
+ },
+ {
+ "id": "54bb41c2b73d2b34",
+ "type": "debug",
+ "z": "a79ad45246b8dac2",
+ "name": "debug 13",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "false",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 4030,
+ "y": 140,
+ "wires": []
+ },
+ {
+ "id": "522e873437f9f5e5",
+ "type": "exec",
+ "z": "e57aed349acfd46b",
+ "command": "nmcli -t -f SSID dev wifi list | sed '/^$/d' | sort -u",
+ "addpay": false,
+ "append": "",
+ "useSpawn": "false",
+ "timer": "",
+ "winHide": false,
+ "oldrc": false,
+ "name": "scan",
+ "x": 430,
+ "y": 80,
+ "wires": [
+ [
+ "36411594b5625bdc"
+ ],
+ [],
+ []
+ ]
+ },
+ {
+ "id": "36411594b5625bdc",
+ "type": "function",
+ "z": "e57aed349acfd46b",
+ "name": "parseOptions",
+ "func": "var ssids = msg.payload.split('\\n').filter(s => !!s)\n\nssids = [...new Set(ssids)];\n\nmsg.options = ssids\nmsg.payload = null\n\nreturn msg;",
+ "outputs": 1,
+ "noerr": 0,
+ "x": 660,
+ "y": 67,
+ "wires": [
+ [
+ "4bc9df4498162011"
+ ]
+ ]
+ },
+ {
+ "id": "5b06fdd1ad029737",
+ "type": "inject",
+ "z": "e57aed349acfd46b",
+ "name": "",
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payload": "",
+ "payloadType": "date",
+ "x": 260,
+ "y": 221,
+ "wires": [
+ [
+ "522e873437f9f5e5",
+ "7b8be9ec47263246",
+ "a0ee571042e3c750"
+ ]
+ ]
+ },
+ {
+ "id": "3b1c50c5236f72f0",
+ "type": "switch",
+ "z": "e57aed349acfd46b",
+ "name": "ifWifi",
+ "property": "name",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "eq",
+ "v": "Wifi",
+ "vt": "str"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 1,
+ "x": 271,
+ "y": 80,
+ "wires": [
+ [
+ "522e873437f9f5e5",
+ "7b8be9ec47263246",
+ "a0ee571042e3c750"
+ ]
+ ]
+ },
+ {
+ "id": "7b8be9ec47263246",
+ "type": "exec",
+ "z": "e57aed349acfd46b",
+ "command": "nmcli -t -f ACTIVE,SSID dev wifi | awk -F: '$1==\"yes\"{print $2; exit}'",
+ "addpay": false,
+ "append": "",
+ "useSpawn": "false",
+ "timer": "",
+ "winHide": false,
+ "oldrc": false,
+ "name": "getInfo",
+ "x": 430,
+ "y": 181,
+ "wires": [
+ [
+ "d0c0087f325fd5ca"
+ ],
+ [],
+ []
+ ]
+ },
+ {
+ "id": "d0c0087f325fd5ca",
+ "type": "function",
+ "z": "e57aed349acfd46b",
+ "name": "parseInfo",
+ "func": "function grab(re){\n const m = msg.payload.match(re);\n return m ? m[1] : null;\n}\n\nconst ip = grab(/inet ([0-9\\.]+)/);\nconst mask = grab(/netmask ([0-9\\.]+)/);\nconst broadcast = grab(/broadcast ([0-9\\.]+)/);\n\nnode.send({topic:\"ip\", payload: ip});\nnode.send({topic:\"mask\", payload: mask});\nnode.send({topic:\"broadcast\", payload: broadcast});\n\nreturn null;\n",
+ "outputs": 1,
+ "timeout": "",
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 640,
+ "y": 168,
+ "wires": [
+ [
+ "c2ce2e0385a9884d"
+ ]
+ ]
+ },
+ {
+ "id": "4bc9df4498162011",
+ "type": "function",
+ "z": "e57aed349acfd46b",
+ "name": "toScanResult",
+ "func": "msg.topic = \"wifi:scan:result\";\nmsg.payload = msg.options || [];\ndelete msg.options;\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": "",
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 850,
+ "y": 60,
+ "wires": [
+ [
+ "1e16d19cbb14accf"
+ ]
+ ]
+ },
+ {
+ "id": "6fdefd93d9bc08a3",
+ "type": "function",
+ "z": "e57aed349acfd46b",
+ "name": "getPassphrase",
+ "func": "var data = msg.payload\n\nvar command = `wpa_passphrase \"${data.ssid}\" \"${data.password}\" | sed '/#psk=\".*\"/d'`\n \nmsg.payload = command\n\nreturn msg",
+ "outputs": 1,
+ "noerr": 0,
+ "x": 1060,
+ "y": 60,
+ "wires": [
+ [
+ "62c05f749e9775b1"
+ ]
+ ]
+ },
+ {
+ "id": "62c05f749e9775b1",
+ "type": "exec",
+ "z": "e57aed349acfd46b",
+ "command": "",
+ "addpay": true,
+ "append": "",
+ "useSpawn": "false",
+ "timer": "",
+ "oldrc": false,
+ "name": "",
+ "x": 1221,
+ "y": 60,
+ "wires": [
+ [
+ "9b3215773224c632"
+ ],
+ [],
+ []
+ ]
+ },
+ {
+ "id": "9b3215773224c632",
+ "type": "function",
+ "z": "e57aed349acfd46b",
+ "name": "updateWpasupplicant",
+ "func": "var template = `sudo tee /etc/wpa_supplicant/wpa_supplicant.conf < 0)) return;
+ if (isUpdate && status !== "resolved") return;
if ((eventType === "microstop" || eventType === "macrostop") && theoreticalSec && lastCycleTs == null) {
return;
}
@@ -345,9 +359,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
const key = `${channel}:${recipient.userId ?? recipient.contactId ?? recipient.email ?? recipient.phone ?? ""}`;
if (delivered.has(key)) continue;
+ const statusKey = status === "resolved" ? "resolved" : "active";
+ const ruleKey = `${rule.id}:${statusKey}`;
const allowed = await shouldSendNotification({
eventIds: notificationEventIds,
- ruleId: rule.id,
+ ruleId: ruleKey,
role: roleName,
channel,
contactId: recipient.contactId,
@@ -376,7 +392,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
machineId: event.machineId,
eventId: event.id,
eventType,
- ruleId: rule.id,
+ ruleId: ruleKey,
role: roleName,
channel,
contactId: recipient.contactId,
@@ -391,7 +407,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
machineId: event.machineId,
eventId: event.id,
eventType,
- ruleId: rule.id,
+ ruleId: ruleKey,
role: roleName,
channel,
contactId: recipient.contactId,
diff --git a/lib/alerts/getAlertsInboxData.ts b/lib/alerts/getAlertsInboxData.ts
index 9a07c53..5c4d8e6 100644
--- a/lib/alerts/getAlertsInboxData.ts
+++ b/lib/alerts/getAlertsInboxData.ts
@@ -1,3 +1,4 @@
+import { normalizeShiftOverrides } from "@/lib/settings";
import { prisma } from "@/lib/prisma";
const RANGE_MS: Record = {
@@ -21,6 +22,26 @@ type AlertsInboxParams = {
limit?: number;
};
+type AlertsInboxEvent = {
+ id: string;
+ ts: Date;
+ eventType: string;
+ severity: string;
+ title: string;
+ description?: string | null;
+ machineId: string;
+ machineName?: string | null;
+ location?: string | null;
+ workOrderId?: string | null;
+ sku?: string | null;
+ durationSec?: number | null;
+ status?: string | null;
+ shift?: string | null;
+ alertId?: string | null;
+ isUpdate?: boolean;
+ isAutoAck?: boolean;
+};
+
function pickRange(range: string, start?: Date | null, end?: Date | null) {
const now = new Date();
if (range === "custom") {
@@ -50,6 +71,19 @@ function safeBool(value: unknown) {
return value === true;
}
+function normalizeStatus(value?: string | null) {
+ if (!value) return null;
+ const raw = value.trim().toLowerCase();
+ if (!raw) return null;
+ if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
+ return "active";
+ }
+ if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
+ return "resolved";
+ }
+ return raw;
+}
+
function parsePayload(raw: unknown) {
let parsed: unknown = raw;
if (typeof raw === "string") {
@@ -131,17 +165,54 @@ function getLocalMinutes(ts: Date, timeZone: string) {
}
}
+const WEEKDAY_KEY_MAP: Record = {
+ Sun: "sun",
+ Mon: "mon",
+ Tue: "tue",
+ Wed: "wed",
+ Thu: "thu",
+ Fri: "fri",
+ Sat: "sat",
+};
+
+const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
+
+function getLocalDayKey(ts: Date, timeZone: string) {
+ try {
+ const weekday = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ weekday: "short",
+ }).format(ts);
+ return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
+ } catch {
+ return WEEKDAY_KEYS[ts.getUTCDay()];
+ }
+}
+
+type ShiftLike = {
+ name: string;
+ startTime?: string | null;
+ endTime?: string | null;
+ start?: string | null;
+ end?: string | null;
+ enabled?: boolean;
+};
+
function resolveShift(
- shifts: Array<{ name: string; startTime: string; endTime: string; enabled?: boolean }>,
+ shifts: ShiftLike[],
+ overrides: Record | undefined,
ts: Date,
timeZone: string
) {
- if (!shifts.length) return null;
+ const dayKey = getLocalDayKey(ts, timeZone);
+ const dayOverrides = overrides?.[dayKey];
+ const activeShifts = dayOverrides ?? shifts;
+ if (!activeShifts.length) return null;
const nowMin = getLocalMinutes(ts, timeZone);
- for (const shift of shifts) {
+ for (const shift of activeShifts) {
if (shift.enabled === false) continue;
- const start = parseTimeMinutes(shift.startTime);
- const end = parseTimeMinutes(shift.endTime);
+ const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
+ const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
if (start == null || end == null) continue;
if (start <= end) {
if (nowMin >= start && nowMin < end) return shift.name;
@@ -152,6 +223,34 @@ function resolveShift(
return null;
}
+function collapseAlertEvents(events: AlertsInboxEvent[]) {
+ const byAlert = new Map();
+ const passthrough: AlertsInboxEvent[] = [];
+
+ for (const ev of events) {
+ if (!ev.alertId) {
+ passthrough.push(ev);
+ continue;
+ }
+ const statusKey = ev.status === "resolved" ? "resolved" : "active";
+ const key = `${ev.alertId}:${statusKey}`;
+ const existing = byAlert.get(key);
+ if (!existing) {
+ byAlert.set(key, ev);
+ continue;
+ }
+ const pickNewest = statusKey === "resolved";
+ const shouldReplace = pickNewest
+ ? ev.ts.getTime() > existing.ts.getTime()
+ : ev.ts.getTime() < existing.ts.getTime();
+ if (shouldReplace) byAlert.set(key, ev);
+ }
+
+ const combined = [...passthrough, ...byAlert.values()];
+ combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
+ return combined;
+}
+
export async function getAlertsInboxData(params: AlertsInboxParams) {
const {
orgId,
@@ -213,12 +312,13 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
}),
prisma.orgSettings.findUnique({
where: { orgId },
- select: { timezone: true },
+ select: { timezone: true, shiftScheduleOverridesJson: true },
}),
]);
const timeZone = settings?.timezone || "UTC";
- const mapped = [];
+ const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
+ const mapped: AlertsInboxEvent[] = [];
for (const ev of events) {
const { payload, inner } = parsePayload(ev.data);
@@ -227,10 +327,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
- const shiftName = resolveShift(shifts, ev.ts, timeZone);
+ const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
if (normalizedShift && shiftName !== normalizedShift) continue;
- const statusLabel = rawStatus ? rawStatus.toLowerCase() : "unknown";
+ const statusLabel = normalizeStatus(rawStatus) ?? "unknown";
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
mapped.push({
@@ -254,8 +354,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
});
}
+ const finalEvents = includeUpdates ? mapped : collapseAlertEvents(mapped);
+
return {
range: { range: picked.range, start: picked.start, end: picked.end },
- events: mapped,
+ events: finalEvents,
};
}
diff --git a/lib/auth/requireSession.ts b/lib/auth/requireSession.ts
index 6b075aa..c288ed8 100644
--- a/lib/auth/requireSession.ts
+++ b/lib/auth/requireSession.ts
@@ -1,43 +1,98 @@
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
+import { logLine } from "@/lib/logger";
const COOKIE_NAME = "mis_session";
+const SESSION_CACHE_TTL_MS = 30000;
+const LAST_SEEN_TTL_MS = 300000;
-export async function requireSession() {
- const jar = await cookies();
- const sessionId = jar.get(COOKIE_NAME)?.value;
- if (!sessionId) return null;
+type SessionPayload = {
+ sessionId: string;
+ userId: string;
+ orgId: string;
+};
- const session = await prisma.session.findFirst({
- where: {
- id: sessionId,
- revokedAt: null,
- expiresAt: { gt: new Date() },
- },
- include: {
- user: {
- select: { isActive: true, emailVerifiedAt: true },
- },
- },
- });
+type CachedSession = {
+ value: SessionPayload;
+ expiresAt: number;
+};
- if (!session) return null;
+const sessionCache = new Map();
+const lastSeenCache = new Map();
- if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
- await prisma.session
- .update({ where: { id: session.id }, data: { revokedAt: new Date() } })
- .catch(() => {});
+function readCache(sessionId: string, now: number) {
+ const cached = sessionCache.get(sessionId);
+ if (!cached) return null;
+ if (cached.expiresAt <= now) {
+ sessionCache.delete(sessionId);
+ return null;
+ }
+ return cached.value;
+}
+
+function writeCache(sessionId: string, value: SessionPayload, now: number) {
+ sessionCache.set(sessionId, { value, expiresAt: now + SESSION_CACHE_TTL_MS });
+}
+
+function shouldUpdateLastSeen(sessionId: string, now: number) {
+ const last = lastSeenCache.get(sessionId) ?? 0;
+ if (now - last < LAST_SEEN_TTL_MS) return false;
+ lastSeenCache.set(sessionId, now);
+ return true;
+}
+
+export async function requireSession() {
+ try {
+ const jar = await cookies();
+ const sessionId = jar.get(COOKIE_NAME)?.value;
+ if (!sessionId) return null;
+
+ const now = Date.now();
+ const cached = readCache(sessionId, now);
+ if (cached) return cached;
+
+ const session = await prisma.session.findFirst({
+ where: {
+ id: sessionId,
+ revokedAt: null,
+ expiresAt: { gt: new Date() },
+ },
+ include: {
+ user: {
+ select: { isActive: true, emailVerifiedAt: true },
+ },
+ },
+ });
+
+ if (!session) return null;
+
+ if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
+ void prisma.session
+ .update({ where: { id: session.id }, data: { revokedAt: new Date() } })
+ .catch(() => {});
+ sessionCache.delete(sessionId);
+ lastSeenCache.delete(sessionId);
+ return null;
+ }
+
+ if (shouldUpdateLastSeen(sessionId, now)) {
+ void prisma.session
+ .update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
+ .catch(() => {});
+ }
+
+ const payload = {
+ sessionId: session.id,
+ userId: session.userId,
+ orgId: session.orgId,
+ };
+ writeCache(sessionId, payload, now);
+ return payload;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const stack = err instanceof Error ? err.stack : undefined;
+ logLine("requireSession.error", { message, stack });
+ console.error("[requireSession]", err);
return null;
}
-
- // Optional: update lastSeenAt (useful later)
- await prisma.session
- .update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
- .catch(() => {});
-
- return {
- sessionId: session.id,
- userId: session.userId,
- orgId: session.orgId,
- };
}
diff --git a/lib/financial/cache.ts b/lib/financial/cache.ts
new file mode 100644
index 0000000..8d85162
--- /dev/null
+++ b/lib/financial/cache.ts
@@ -0,0 +1,63 @@
+import { unstable_cache } from "next/cache";
+import { prisma } from "@/lib/prisma";
+import { computeFinancialImpact, type FinancialImpactParams } from "@/lib/financial/impact";
+
+export const FINANCIAL_CONFIG_TTL_SEC = 15;
+export const FINANCIAL_CONFIG_SWR_SEC = 45;
+export const FINANCIAL_IMPACT_TTL_SEC = 10;
+export const FINANCIAL_IMPACT_SWR_SEC = 30;
+
+async function loadFinancialConfig(orgId: string) {
+ const [org, locations, machines, products] = await Promise.all([
+ prisma.orgFinancialProfile.findUnique({ where: { orgId } }),
+ prisma.locationFinancialOverride.findMany({ where: { orgId }, orderBy: { location: "asc" } }),
+ prisma.machineFinancialOverride.findMany({ where: { orgId }, orderBy: { createdAt: "desc" } }),
+ prisma.productCostOverride.findMany({ where: { orgId }, orderBy: { sku: "asc" } }),
+ ]);
+
+ return { org, locations, machines, products };
+}
+
+export type FinancialConfigPayload = Awaited>;
+
+export async function getFinancialConfig(orgId: string, options?: { refresh?: boolean }) {
+ if (options?.refresh) {
+ return loadFinancialConfig(orgId);
+ }
+
+ const cached = unstable_cache(
+ () => loadFinancialConfig(orgId),
+ ["financial-config", orgId],
+ { revalidate: FINANCIAL_CONFIG_TTL_SEC, tags: [`financial-config:${orgId}`] }
+ );
+ return cached();
+}
+
+export async function getFinancialImpactCached(
+ params: FinancialImpactParams,
+ options?: { refresh?: boolean }
+) {
+ if (options?.refresh) {
+ return computeFinancialImpact(params);
+ }
+
+ const keyParts = [
+ "financial-impact",
+ params.orgId,
+ String(params.start.getTime()),
+ String(params.end.getTime()),
+ params.machineId ?? "",
+ params.location ?? "",
+ params.sku ?? "",
+ params.currency ?? "",
+ params.includeEvents ? "1" : "0",
+ ];
+
+ const cached = unstable_cache(
+ () => computeFinancialImpact(params),
+ keyParts,
+ { revalidate: FINANCIAL_IMPACT_TTL_SEC, tags: [`financial-impact:${params.orgId}`] }
+ );
+
+ return cached();
+}
diff --git a/lib/i18n/en.json b/lib/i18n/en.json
index e639a60..3a75b96 100644
--- a/lib/i18n/en.json
+++ b/lib/i18n/en.json
@@ -395,10 +395,24 @@
"settings.minutes": "minutes",
"settings.shiftHint": "Max 3 shifts, HH:mm",
"settings.shiftTo": "to",
- "settings.shiftCompLabel": "Shift change compensation (min)",
- "settings.lunchBreakLabel": "Lunch break (min)",
- "settings.shift.defaultName": "Shift {index}",
- "settings.thresholds": "Alert thresholds",
+ "settings.shiftCompLabel": "Shift change compensation (min)",
+ "settings.lunchBreakLabel": "Lunch break (min)",
+ "settings.shift.defaultName": "Shift {index}",
+ "settings.shiftOverrides.title": "Day-specific shifts",
+ "settings.shiftOverrides.subtitle": "Optional overrides for individual days.",
+ "settings.shiftOverrides.useDefault": "Use default",
+ "settings.shiftOverrides.customize": "Customize",
+ "settings.shiftOverrides.inherits": "Uses default shift schedule.",
+ "settings.shiftOverrides.dayOff": "Day off (no shifts)",
+ "settings.shiftOverrides.clear": "Clear shifts",
+ "settings.shiftOverrides.mon": "Monday",
+ "settings.shiftOverrides.tue": "Tuesday",
+ "settings.shiftOverrides.wed": "Wednesday",
+ "settings.shiftOverrides.thu": "Thursday",
+ "settings.shiftOverrides.fri": "Friday",
+ "settings.shiftOverrides.sat": "Saturday",
+ "settings.shiftOverrides.sun": "Sunday",
+ "settings.thresholds": "Alert thresholds",
"settings.thresholdsSubtitle": "Tune production health alerts.",
"settings.thresholds.appliesAll": "Applies to all machines",
"settings.thresholds.oee": "OEE alert threshold",
@@ -453,11 +467,12 @@
"financial.title": "Financial Impact",
"financial.subtitle": "Translate downtime, slow cycles, and scrap into money.",
"financial.ownerOnly": "Financial impact is available only to owners.",
- "financial.costsMoved": "Cost settings are now in",
- "financial.costsMovedLink": "Settings -> Financial",
- "financial.export.html": "HTML",
- "financial.export.csv": "CSV",
- "financial.totalLoss": "Total Loss",
+ "financial.costsMoved": "Cost settings are now in",
+ "financial.costsMovedLink": "Settings -> Financial",
+ "financial.export.html": "HTML",
+ "financial.export.csv": "CSV",
+ "financial.refresh": "Refresh",
+ "financial.totalLoss": "Total Loss",
"financial.currencyLabel": "Currency: {currency}",
"financial.noImpact": "No impact data yet.",
"financial.chart.title": "Lost Money Over Time",
diff --git a/lib/i18n/es-MX.json b/lib/i18n/es-MX.json
index 17ac58f..000a644 100644
--- a/lib/i18n/es-MX.json
+++ b/lib/i18n/es-MX.json
@@ -395,10 +395,24 @@
"settings.minutes": "minutos",
"settings.shiftHint": "Máx 3 turnos, HH:mm",
"settings.shiftTo": "a",
- "settings.shiftCompLabel": "Compensación por cambio de turno (min)",
- "settings.lunchBreakLabel": "Comida (min)",
- "settings.shift.defaultName": "Turno {index}",
- "settings.thresholds": "Umbrales de alertas",
+ "settings.shiftCompLabel": "Compensación por cambio de turno (min)",
+ "settings.lunchBreakLabel": "Comida (min)",
+ "settings.shift.defaultName": "Turno {index}",
+ "settings.shiftOverrides.title": "Turnos por día",
+ "settings.shiftOverrides.subtitle": "Sobrescrituras opcionales por día.",
+ "settings.shiftOverrides.useDefault": "Usar predeterminado",
+ "settings.shiftOverrides.customize": "Personalizar",
+ "settings.shiftOverrides.inherits": "Usa el horario de turnos predeterminado.",
+ "settings.shiftOverrides.dayOff": "Día libre (sin turnos)",
+ "settings.shiftOverrides.clear": "Borrar turnos",
+ "settings.shiftOverrides.mon": "Lunes",
+ "settings.shiftOverrides.tue": "Martes",
+ "settings.shiftOverrides.wed": "Miércoles",
+ "settings.shiftOverrides.thu": "Jueves",
+ "settings.shiftOverrides.fri": "Viernes",
+ "settings.shiftOverrides.sat": "Sábado",
+ "settings.shiftOverrides.sun": "Domingo",
+ "settings.thresholds": "Umbrales de alertas",
"settings.thresholdsSubtitle": "Ajusta alertas de salud de producción.",
"settings.thresholds.appliesAll": "Aplica a todas las máquinas",
"settings.thresholds.oee": "Umbral de alerta OEE",
@@ -453,11 +467,12 @@
"financial.title": "Impacto financiero",
"financial.subtitle": "Convierte paros, ciclos lentos y scrap en dinero.",
"financial.ownerOnly": "El impacto financiero solo está disponible para propietarios.",
- "financial.costsMoved": "Los costos ahora están en",
- "financial.costsMovedLink": "Configuración -> Finanzas",
- "financial.export.html": "HTML",
- "financial.export.csv": "CSV",
- "financial.totalLoss": "Pérdida total",
+ "financial.costsMoved": "Los costos ahora están en",
+ "financial.costsMovedLink": "Configuración -> Finanzas",
+ "financial.export.html": "HTML",
+ "financial.export.csv": "CSV",
+ "financial.refresh": "Actualizar",
+ "financial.totalLoss": "Pérdida total",
"financial.currencyLabel": "Moneda: {currency}",
"financial.noImpact": "Sin datos de impacto.",
"financial.chart.title": "Pérdida de dinero en el tiempo",
diff --git a/lib/i18n/useI18n.ts b/lib/i18n/useI18n.ts
index 848fbed..5a550f8 100644
--- a/lib/i18n/useI18n.ts
+++ b/lib/i18n/useI18n.ts
@@ -7,6 +7,7 @@ const LOCALE_COOKIE = "mis_locale";
const LOCALE_EVENT = "mis-locale-change";
function readCookieLocale(): Locale | null {
+ if (typeof document === "undefined") return null;
const match = document.cookie
.split(";")
.map((part) => part.trim())
@@ -18,6 +19,7 @@ function readCookieLocale(): Locale | null {
}
function readLocale(): Locale {
+ if (typeof document === "undefined") return defaultLocale;
const docLang = document.documentElement.getAttribute("lang");
if (docLang === "es-MX" || docLang === "en") return docLang;
return readCookieLocale() ?? defaultLocale;
diff --git a/lib/logger.ts b/lib/logger.ts
index f259a67..54fa625 100644
--- a/lib/logger.ts
+++ b/lib/logger.ts
@@ -3,6 +3,10 @@ import path from "path";
const LOG_PATH = process.env.LOG_FILE || "/tmp/mis-control-tower.log";
+export function getLogPath() {
+ return LOG_PATH;
+}
+
export function logLine(event: string, data: Record = {}) {
const line = JSON.stringify({
ts: new Date().toISOString(),
diff --git a/lib/machineAuthCache.ts b/lib/machineAuthCache.ts
index 4481628..0ea21ab 100644
--- a/lib/machineAuthCache.ts
+++ b/lib/machineAuthCache.ts
@@ -2,7 +2,7 @@ import { prisma } from "@/lib/prisma";
type MachineAuth = { id: string; orgId: string };
-const TTL_MS = 60_000;
+const TTL_MS = 10_000;
const MAX_SIZE = 1000;
const cache = new Map();
@@ -36,3 +36,12 @@ export async function getMachineAuth(machineId: string, apiKey: string) {
cache.set(key, { value: machine, expiresAt: now + TTL_MS });
return machine;
}
+
+export function invalidateMachineAuth(machineId: string) {
+ const prefix = `${machineId}:`;
+ for (const key of cache.keys()) {
+ if (key.startsWith(prefix)) {
+ cache.delete(key);
+ }
+ }
+}
diff --git a/lib/machines/withLatest.ts b/lib/machines/withLatest.ts
new file mode 100644
index 0000000..cf2edcd
--- /dev/null
+++ b/lib/machines/withLatest.ts
@@ -0,0 +1,113 @@
+import { prisma } from "@/lib/prisma";
+import type { OverviewMachineRow } from "@/lib/overview/types";
+
+type MachineBaseRow = Pick<
+ OverviewMachineRow,
+ "id" | "name" | "code" | "location" | "createdAt" | "updatedAt"
+>;
+
+type LatestHeartbeatRow = {
+ machineId: string;
+ ts: Date;
+ tsServer: Date | null;
+ status: string;
+ message?: string | null;
+ ip?: string | null;
+ fwVersion?: string | null;
+};
+
+type LatestKpiRow = {
+ machineId: string;
+ ts: Date;
+ oee?: number | null;
+ availability?: number | null;
+ performance?: number | null;
+ quality?: number | null;
+ workOrderId?: string | null;
+ sku?: string | null;
+ good?: number | null;
+ scrap?: number | null;
+ target?: number | null;
+ cycleTime?: number | null;
+};
+
+export async function fetchMachineBase(orgId: string): Promise {
+ return prisma.machine.findMany({
+ where: { orgId },
+ orderBy: { createdAt: "desc" },
+ select: {
+ id: true,
+ name: true,
+ code: true,
+ location: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ });
+}
+
+export async function fetchLatestHeartbeats(
+ orgId: string,
+ machineIds: string[]
+): Promise {
+ if (!machineIds.length) return [];
+ return prisma.machineHeartbeat.findMany({
+ where: { orgId, machineId: { in: machineIds } },
+ orderBy: [{ machineId: "asc" }, { tsServer: "desc" }],
+ distinct: ["machineId"],
+ select: {
+ machineId: true,
+ ts: true,
+ tsServer: true,
+ status: true,
+ message: true,
+ ip: true,
+ fwVersion: true,
+ },
+ });
+}
+
+export async function fetchLatestKpis(
+ orgId: string,
+ machineIds: string[]
+): Promise {
+ if (!machineIds.length) return [];
+ return prisma.machineKpiSnapshot.findMany({
+ where: { orgId, machineId: { in: machineIds } },
+ orderBy: [{ machineId: "asc" }, { ts: "desc" }],
+ distinct: ["machineId"],
+ select: {
+ machineId: true,
+ ts: true,
+ oee: true,
+ availability: true,
+ performance: true,
+ quality: true,
+ workOrderId: true,
+ sku: true,
+ good: true,
+ scrap: true,
+ target: true,
+ cycleTime: true,
+ },
+ });
+}
+
+export function mergeMachineOverviewRows(params: {
+ machines: MachineBaseRow[];
+ heartbeats: LatestHeartbeatRow[];
+ kpis?: LatestKpiRow[];
+ includeKpi?: boolean;
+}): OverviewMachineRow[] {
+ const { machines, heartbeats, kpis = [], includeKpi = false } = params;
+ const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row]));
+ const kpiMap = new Map(kpis.map((row) => [row.machineId, row]));
+
+ return machines.map((machine) => ({
+ ...machine,
+ latestHeartbeat: (heartbeatMap.get(machine.id) ?? null) as OverviewMachineRow["latestHeartbeat"],
+ latestKpi: includeKpi ? (kpiMap.get(machine.id) ?? null) : null,
+ heartbeats: undefined,
+ kpiSnapshots: undefined,
+ }));
+}
diff --git a/lib/overview/getOverviewData.ts b/lib/overview/getOverviewData.ts
index 7be00c5..8415b1a 100644
--- a/lib/overview/getOverviewData.ts
+++ b/lib/overview/getOverviewData.ts
@@ -1,5 +1,14 @@
import { prisma } from "@/lib/prisma";
import { normalizeEvent } from "@/lib/events/normalizeEvent";
+import { logLine } from "@/lib/logger";
+import { elapsedMs, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
+import type { OverviewEventRow, OverviewMachineRow } from "@/lib/overview/types";
+import {
+ fetchLatestHeartbeats,
+ fetchLatestKpis,
+ fetchMachineBase,
+ mergeMachineOverviewRows,
+} from "@/lib/machines/withLatest";
const ALLOWED_TYPES = new Set([
"slow-cycle",
@@ -37,157 +46,169 @@ export async function getOverviewData({
eventsWindowSec = 21600,
eventMachines = 6,
orgSettings,
-}: OverviewParams) {
- const machines = await prisma.machine.findMany({
- where: { orgId },
- orderBy: { createdAt: "desc" },
- select: {
- id: true,
- name: true,
- code: true,
- location: true,
- createdAt: true,
- updatedAt: true,
- heartbeats: {
- orderBy: { tsServer: "desc" },
- take: 1,
- select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
- },
- kpiSnapshots: {
- orderBy: { ts: "desc" },
- take: 1,
- select: {
- ts: true,
- oee: true,
- availability: true,
- performance: true,
- quality: true,
- workOrderId: true,
- sku: true,
- good: true,
- scrap: true,
- target: true,
- cycleTime: true,
+}: OverviewParams): Promise<{ machines: OverviewMachineRow[]; events: OverviewEventRow[] }> {
+ const perfEnabled = PERF_LOGS_ENABLED;
+ const timings: Record = {};
+ const totalStart = nowMs();
+
+ try {
+ const machinesStart = nowMs();
+ const machines = await fetchMachineBase(orgId);
+ if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
+
+ const heartbeatStart = nowMs();
+ const machineIds = machines.map((machine) => machine.id);
+ const heartbeats = await fetchLatestHeartbeats(orgId, machineIds);
+ if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
+
+ const kpiStart = nowMs();
+ const kpis = await fetchLatestKpis(orgId, machineIds);
+ if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
+
+ const machineRows: OverviewMachineRow[] = mergeMachineOverviewRows({
+ machines,
+ heartbeats,
+ kpis,
+ includeKpi: true,
+ });
+
+ const safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
+ const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
+
+ const topMachines = machineRows
+ .slice()
+ .sort((a, b) => {
+ const at = heartbeatTime(a.latestHeartbeat);
+ const bt = heartbeatTime(b.latestHeartbeat);
+ const atMs = at ? at.getTime() : 0;
+ const btMs = bt ? bt.getTime() : 0;
+ return btMs - atMs;
+ })
+ .slice(0, safeEventMachines);
+
+ const targetIds = topMachines.map((m) => m.id);
+
+ let events: OverviewEventRow[] = [];
+
+ if (targetIds.length) {
+ let settings = orgSettings ?? null;
+ if (!settings) {
+ const settingsStart = nowMs();
+ settings = await prisma.orgSettings.findUnique({
+ where: { orgId },
+ select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
+ });
+ if (perfEnabled) timings.orgSettingsQuery = elapsedMs(settingsStart);
+ }
+
+ const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
+ const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
+ const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
+
+ const eventsStart = nowMs();
+ const rawEvents = await prisma.machineEvent.findMany({
+ where: {
+ orgId,
+ machineId: { in: targetIds },
+ ts: { gte: windowStart },
},
- },
- },
- });
+ orderBy: { ts: "desc" },
+ take: Math.min(300, Math.max(60, targetIds.length * 40)),
+ select: {
+ id: true,
+ ts: true,
+ topic: true,
+ eventType: true,
+ severity: true,
+ title: true,
+ description: true,
+ requiresAck: true,
+ data: true,
+ workOrderId: true,
+ machineId: true,
+ machine: { select: { name: true } },
+ },
+ });
+ if (perfEnabled) timings.eventsQuery = elapsedMs(eventsStart);
- const machineRows = machines.map((m) => ({
- ...m,
- latestHeartbeat: m.heartbeats[0] ?? null,
- latestKpi: m.kpiSnapshots[0] ?? null,
- heartbeats: undefined,
- kpiSnapshots: undefined,
- }));
+ const normalizeStart = nowMs();
+ const normalized = rawEvents
+ .map((row) => ({
+ ...normalizeEvent(row, { microMultiplier, macroMultiplier }),
+ machineId: row.machineId,
+ machineName: row.machine?.name ?? null,
+ source: "ingested" as const,
+ }))
+ .filter((event) => event.ts);
+ if (perfEnabled) timings.eventsNormalize = elapsedMs(normalizeStart);
- const safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
- const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
+ const filterStart = nowMs();
+ const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
+ const isCritical = (event: (typeof allowed)[number]) => {
+ const severity = String(event.severity ?? "").toLowerCase();
+ return (
+ event.eventType === "macrostop" ||
+ event.requiresAck === true ||
+ severity === "critical" ||
+ severity === "error" ||
+ severity === "high"
+ );
+ };
- const topMachines = machineRows
- .slice()
- .sort((a, b) => {
- const at = heartbeatTime(a.latestHeartbeat);
- const bt = heartbeatTime(b.latestHeartbeat);
- const atMs = at ? at.getTime() : 0;
- const btMs = bt ? bt.getTime() : 0;
- return btMs - atMs;
- })
- .slice(0, safeEventMachines);
+ const filtered = eventsMode === "critical" ? allowed.filter(isCritical) : allowed;
- const targetIds = topMachines.map((m) => m.id);
+ const seen = new Set();
+ const deduped = filtered.filter((event) => {
+ const key = `${event.machineId}-${event.eventType}-${event.ts ?? ""}-${event.title}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
- let events = [] as Array<{
- id: string;
- ts: Date | null;
- topic: string;
- eventType: string;
- severity: string;
- title: string;
- description?: string | null;
- requiresAck: boolean;
- workOrderId?: string | null;
- machineId: string;
- machineName?: string | null;
- source: "ingested";
- }>;
+ deduped.sort((a, b) => {
+ const at = a.ts ? a.ts.getTime() : 0;
+ const bt = b.ts ? b.ts.getTime() : 0;
+ return bt - at;
+ });
- if (targetIds.length) {
- let settings = orgSettings ?? null;
- if (!settings) {
- settings = await prisma.orgSettings.findUnique({
- where: { orgId },
- select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
+ events = deduped.slice(0, 30);
+ if (perfEnabled) timings.eventsFilter = elapsedMs(filterStart);
+ }
+
+ if (perfEnabled) {
+ timings.total = elapsedMs(totalStart);
+ logLine("perf.overview.getOverviewData", {
+ orgId,
+ eventsMode,
+ eventsWindowSec,
+ eventMachines,
+ timings,
+ counts: {
+ machines: machineRows.length,
+ events: events.length,
+ targetMachines: targetIds.length,
+ },
});
}
- const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
- const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
- const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
-
- const rawEvents = await prisma.machineEvent.findMany({
- where: {
+ return { machines: machineRows, events };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const stack = err instanceof Error ? err.stack : undefined;
+ if (perfEnabled) {
+ timings.total = elapsedMs(totalStart);
+ logLine("perf.overview.getOverviewData.error", {
orgId,
- machineId: { in: targetIds },
- ts: { gte: windowStart },
- },
- orderBy: { ts: "desc" },
- take: Math.min(300, Math.max(60, targetIds.length * 40)),
- select: {
- id: true,
- ts: true,
- topic: true,
- eventType: true,
- severity: true,
- title: true,
- description: true,
- requiresAck: true,
- data: true,
- workOrderId: true,
- machineId: true,
- machine: { select: { name: true } },
- },
- });
-
- const normalized = rawEvents
- .map((row) => ({
- ...normalizeEvent(row, { microMultiplier, macroMultiplier }),
- machineId: row.machineId,
- machineName: row.machine?.name ?? null,
- source: "ingested" as const,
- }))
- .filter((event) => event.ts);
-
- const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
- const isCritical = (event: (typeof allowed)[number]) => {
- const severity = String(event.severity ?? "").toLowerCase();
- return (
- event.eventType === "macrostop" ||
- event.requiresAck === true ||
- severity === "critical" ||
- severity === "error" ||
- severity === "high"
- );
- };
-
- const filtered = eventsMode === "critical" ? allowed.filter(isCritical) : allowed;
-
- const seen = new Set();
- const deduped = filtered.filter((event) => {
- const key = `${event.machineId}-${event.eventType}-${event.ts ?? ""}-${event.title}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
-
- deduped.sort((a, b) => {
- const at = a.ts ? a.ts.getTime() : 0;
- const bt = b.ts ? b.ts.getTime() : 0;
- return bt - at;
- });
-
- events = deduped.slice(0, 30);
+ eventsMode,
+ eventsWindowSec,
+ eventMachines,
+ timings,
+ message,
+ stack,
+ });
+ }
+ logLine("getOverviewData.error", { message, stack });
+ console.error("[getOverviewData]", err);
+ return { machines: [], events: [] };
}
-
- return { machines: machineRows, events };
}
diff --git a/lib/overview/getOverviewSummary.ts b/lib/overview/getOverviewSummary.ts
new file mode 100644
index 0000000..d4748fd
--- /dev/null
+++ b/lib/overview/getOverviewSummary.ts
@@ -0,0 +1,102 @@
+import { logLine } from "@/lib/logger";
+import { elapsedMs, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
+import type { OverviewMachineRow } from "@/lib/overview/types";
+import {
+ fetchLatestHeartbeats,
+ fetchMachineBase,
+ mergeMachineOverviewRows,
+} from "@/lib/machines/withLatest";
+
+type OverviewSummaryParams = {
+ orgId: string;
+};
+
+const SUMMARY_CACHE_TTL_MS = 10000;
+const summaryCache = new Map();
+const summaryInFlight = new Map>();
+
+export async function getOverviewSummary({
+ orgId,
+}: OverviewSummaryParams): Promise<{ machines: OverviewMachineRow[] }> {
+ const now = Date.now();
+ const cached = summaryCache.get(orgId);
+ if (cached && cached.expiresAt > now) {
+ if (PERF_LOGS_ENABLED) {
+ logLine("perf.overview.summary", {
+ orgId,
+ cached: true,
+ timings: { total: 0 },
+ ageMs: now - cached.cachedAt,
+ counts: { machines: cached.value.length },
+ });
+ }
+ return { machines: cached.value };
+ }
+
+ const inFlight = summaryInFlight.get(orgId);
+ if (inFlight) return inFlight;
+
+ const promise = fetchOverviewSummary({ orgId })
+ .then((result) => {
+ summaryCache.set(orgId, {
+ value: result.machines,
+ cachedAt: now,
+ expiresAt: now + SUMMARY_CACHE_TTL_MS,
+ });
+ summaryInFlight.delete(orgId);
+ return result;
+ })
+ .catch((err) => {
+ summaryInFlight.delete(orgId);
+ throw err;
+ });
+
+ summaryInFlight.set(orgId, promise);
+ return promise;
+}
+
+async function fetchOverviewSummary({
+ orgId,
+}: OverviewSummaryParams): Promise<{ machines: OverviewMachineRow[] }> {
+ const perfEnabled = PERF_LOGS_ENABLED;
+ const totalStart = nowMs();
+ const timings: Record = {};
+
+ try {
+ const machinesStart = nowMs();
+ const machines = await fetchMachineBase(orgId);
+ if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
+
+ const heartbeatStart = nowMs();
+ const machineIds = machines.map((machine) => machine.id);
+ const heartbeats = await fetchLatestHeartbeats(orgId, machineIds);
+ if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
+
+ const machineRows: OverviewMachineRow[] = mergeMachineOverviewRows({
+ machines,
+ heartbeats,
+ includeKpi: false,
+ });
+
+ if (perfEnabled) {
+ timings.total = elapsedMs(totalStart);
+ logLine("perf.overview.summary", {
+ orgId,
+ timings,
+ counts: { machines: machineRows.length },
+ });
+ }
+
+ return { machines: machineRows };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const stack = err instanceof Error ? err.stack : undefined;
+ if (perfEnabled) {
+ timings.total = elapsedMs(totalStart);
+ logLine("perf.overview.summary.error", { orgId, timings, message, stack });
+ }
+ logLine("getOverviewSummary.error", { message, stack });
+ console.error("[getOverviewSummary]", err);
+ return { machines: [] };
+ }
+}
diff --git a/lib/overview/types.ts b/lib/overview/types.ts
new file mode 100644
index 0000000..548a981
--- /dev/null
+++ b/lib/overview/types.ts
@@ -0,0 +1,51 @@
+export type OverviewLatestHeartbeat = {
+ ts: Date;
+ tsServer?: Date | null;
+ status: string;
+ message?: string | null;
+ ip?: string | null;
+ fwVersion?: string | null;
+};
+
+export type OverviewLatestKpi = {
+ ts: Date;
+ oee?: number | null;
+ availability?: number | null;
+ performance?: number | null;
+ quality?: number | null;
+ workOrderId?: string | null;
+ sku?: string | null;
+ good?: number | null;
+ scrap?: number | null;
+ target?: number | null;
+ cycleTime?: number | null;
+};
+
+export type OverviewMachineRow = {
+ id: string;
+ name: string;
+ code?: string | null;
+ location?: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ latestHeartbeat: OverviewLatestHeartbeat | null;
+ latestKpi: OverviewLatestKpi | null;
+ heartbeats?: undefined;
+ kpiSnapshots?: undefined;
+};
+
+export type OverviewEventRow = {
+ id: string;
+ ts: Date | null;
+ topic: string;
+ eventType: string;
+ severity: string;
+ title: string;
+ description?: string | null;
+ requiresAck: boolean;
+ workOrderId?: string | null;
+ machineId: string;
+ machineName?: string | null;
+ source: "ingested";
+};
+
diff --git a/lib/perf/serverTiming.ts b/lib/perf/serverTiming.ts
new file mode 100644
index 0000000..c597c52
--- /dev/null
+++ b/lib/perf/serverTiming.ts
@@ -0,0 +1,18 @@
+import { performance } from "perf_hooks";
+
+export const PERF_LOGS_ENABLED = process.env.PERF_LOGS === "1";
+
+export function nowMs() {
+ return performance.now();
+}
+
+export function elapsedMs(startMs: number) {
+ return Math.round((performance.now() - startMs) * 100) / 100;
+}
+
+export function formatServerTiming(entries: Record) {
+ return Object.entries(entries)
+ .filter(([, value]) => Number.isFinite(value))
+ .map(([name, value]) => `${name};dur=${value.toFixed(1)}`)
+ .join(", ");
+}
diff --git a/lib/reasonCatalog.ts b/lib/reasonCatalog.ts
new file mode 100644
index 0000000..c2e12d0
--- /dev/null
+++ b/lib/reasonCatalog.ts
@@ -0,0 +1,200 @@
+import { readFile } from "fs/promises";
+import path from "path";
+
+type AnyRecord = Record;
+
+export type ReasonCatalogKind = "downtime" | "scrap";
+
+export type ReasonCatalogDetail = {
+ id: string;
+ label: string;
+};
+
+export type ReasonCatalogCategory = {
+ id: string;
+ label: string;
+ details: ReasonCatalogDetail[];
+};
+
+export type ReasonCatalog = {
+ version: number;
+ downtime: ReasonCatalogCategory[];
+ scrap: ReasonCatalogCategory[];
+};
+
+function isPlainObject(value: unknown): value is AnyRecord {
+ return !!value && typeof value === "object" && !Array.isArray(value);
+}
+
+function canonicalId(input: unknown, fallback = "item") {
+ const text = String(input ?? "")
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return text || fallback;
+}
+
+function buildReasonCode(categoryId: string, detailId: string) {
+ return `${canonicalId(categoryId)}__${canonicalId(detailId)}`.toUpperCase();
+}
+
+function toCategory(raw: unknown): ReasonCatalogCategory | null {
+ if (!isPlainObject(raw)) return null;
+ const labelRaw = String(raw.label ?? "").trim();
+ if (!labelRaw) return null;
+ const idRaw = String(raw.id ?? "").trim() || canonicalId(labelRaw, "category");
+ const detailsRaw =
+ (Array.isArray(raw.details) && raw.details) ||
+ (Array.isArray(raw.children) && raw.children) ||
+ (Array.isArray(raw.items) && raw.items) ||
+ [];
+
+ const details: ReasonCatalogDetail[] = [];
+ for (const detailRaw of detailsRaw) {
+ if (!isPlainObject(detailRaw)) continue;
+ const detailLabel = String(detailRaw.label ?? "").trim();
+ if (!detailLabel) continue;
+ const detailId = String(detailRaw.id ?? "").trim() || canonicalId(detailLabel, "detail");
+ details.push({ id: detailId, label: detailLabel });
+ }
+
+ if (!details.length) return null;
+ return { id: idRaw, label: labelRaw, details };
+}
+
+function normalizeKind(raw: unknown): ReasonCatalogCategory[] {
+ const arr =
+ (Array.isArray(raw) && raw) ||
+ (isPlainObject(raw) && Array.isArray(raw.categories) && raw.categories) ||
+ [];
+ const out: ReasonCatalogCategory[] = [];
+ for (const candidate of arr) {
+ const parsed = toCategory(candidate);
+ if (parsed) out.push(parsed);
+ }
+ return out;
+}
+
+export function normalizeReasonCatalog(raw: unknown): ReasonCatalog | null {
+ if (!isPlainObject(raw)) return null;
+ const downtime = normalizeKind(raw.downtime);
+ const scrap = normalizeKind(raw.scrap);
+ if (!downtime.length && !scrap.length) return null;
+ const versionNum = Number(raw.version);
+ const version = Number.isFinite(versionNum) ? Math.max(1, Math.trunc(versionNum)) : 1;
+ return {
+ version,
+ downtime,
+ scrap,
+ };
+}
+
+export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
+ const lines = markdown
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ const buckets: Record> = {
+ downtime: new Map(),
+ scrap: new Map(),
+ };
+ let activeKind: ReasonCatalogKind = "downtime";
+
+ for (const line of lines) {
+ const lowered = line.toLowerCase();
+ if (lowered === "downtime") {
+ activeKind = "downtime";
+ continue;
+ }
+ if (lowered === "scrap") {
+ activeKind = "scrap";
+ continue;
+ }
+
+ const slash = line.indexOf("/");
+ if (slash < 1 || slash === line.length - 1) continue;
+
+ const categoryLabel = line.slice(0, slash).trim();
+ const detailLabel = line.slice(slash + 1).trim();
+ if (!categoryLabel || !detailLabel) continue;
+
+ const categoryId = canonicalId(categoryLabel, "category");
+ const detailId = canonicalId(detailLabel, "detail");
+
+ const existing =
+ buckets[activeKind].get(categoryId) ?? {
+ id: categoryId,
+ label: categoryLabel,
+ details: [] as ReasonCatalogDetail[],
+ };
+ if (!existing.details.some((d) => d.id === detailId)) {
+ existing.details.push({ id: detailId, label: detailLabel });
+ }
+ buckets[activeKind].set(categoryId, existing);
+ }
+
+ return {
+ version: 1,
+ downtime: [...buckets.downtime.values()],
+ scrap: [...buckets.scrap.values()],
+ };
+}
+
+let catalogPromise: Promise | null = null;
+
+export async function loadFallbackReasonCatalog() {
+ if (!catalogPromise) {
+ catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
+ .then((raw) => parseReasonCatalogMarkdown(raw))
+ .catch(() => ({ version: 1, downtime: [], scrap: [] }));
+ }
+ return catalogPromise;
+}
+
+export function flattenReasonCatalog(catalog: ReasonCatalog, kind: ReasonCatalogKind) {
+ return (catalog[kind] ?? []).flatMap((category) =>
+ category.details.map((detail) => ({
+ kind,
+ categoryId: category.id,
+ categoryLabel: category.label,
+ detailId: detail.id,
+ detailLabel: detail.label,
+ reasonCode: buildReasonCode(category.id, detail.id),
+ reasonLabel: `${category.label} > ${detail.label}`,
+ }))
+ );
+}
+
+export function findCatalogReason(
+ catalog: ReasonCatalog | null | undefined,
+ kind: ReasonCatalogKind,
+ categoryId: unknown,
+ detailId: unknown
+) {
+ if (!catalog) return null;
+ const catId = canonicalId(categoryId, "");
+ const detId = canonicalId(detailId, "");
+ if (!catId || !detId) return null;
+ const category = (catalog[kind] ?? []).find((c) => canonicalId(c.id, "") === catId);
+ if (!category) return null;
+ const detail = category.details.find((d) => canonicalId(d.id, "") === detId);
+ if (!detail) return null;
+ return {
+ categoryId: category.id,
+ categoryLabel: category.label,
+ detailId: detail.id,
+ detailLabel: detail.label,
+ reasonCode: buildReasonCode(category.id, detail.id),
+ reasonLabel: `${category.label} > ${detail.label}`,
+ };
+}
+
+export function toReasonCode(categoryId: unknown, detailId: unknown) {
+ const cat = canonicalId(categoryId, "");
+ const det = canonicalId(detailId, "");
+ if (!cat || !det) return null;
+ return buildReasonCode(cat, det);
+}
diff --git a/lib/settings.ts b/lib/settings.ts
index def3ae3..4b920fe 100644
--- a/lib/settings.ts
+++ b/lib/settings.ts
@@ -18,6 +18,9 @@ export const DEFAULT_SHIFT = {
end: "15:00",
};
+export const SHIFT_OVERRIDE_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
+export type ShiftOverrideDay = (typeof SHIFT_OVERRIDE_DAYS)[number];
+
type AnyRecord = Record;
function isPlainObject(value: unknown): value is AnyRecord {
@@ -40,6 +43,7 @@ type SettingsRow = {
timezone: string;
shiftChangeCompMin?: number | null;
lunchBreakMin?: number | null;
+ shiftScheduleOverridesJson?: unknown;
stoppageMultiplier?: number | null;
macroStoppageMultiplier?: number | null;
oeeAlertThresholdPct?: number | null;
@@ -59,6 +63,13 @@ type ShiftRow = {
sortOrder?: number | null;
};
+type ShiftOverridePayload = {
+ name: string;
+ start: string;
+ end: string;
+ enabled: boolean;
+};
+
export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[]) {
const ordered = [...(shifts ?? [])].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const mappedShifts = ordered.map((s, idx) => ({
@@ -67,6 +78,13 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
end: s.endTime,
enabled: s.enabled !== false,
}));
+ const overrides = normalizeShiftOverrides(settings.shiftScheduleOverridesJson);
+
+ const defaults = normalizeDefaults(settings.defaultsJson);
+ const reasonCatalog =
+ isPlainObject(settings.defaultsJson) && "reasonCatalog" in settings.defaultsJson
+ ? (settings.defaultsJson as AnyRecord).reasonCatalog
+ : null;
return {
orgId: settings.orgId,
@@ -74,6 +92,7 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
timezone: settings.timezone,
shiftSchedule: {
shifts: mappedShifts,
+ overrides: overrides && Object.keys(overrides).length ? overrides : undefined,
shiftChangeCompensationMin: settings.shiftChangeCompMin,
lunchBreakMin: settings.lunchBreakMin,
},
@@ -85,7 +104,10 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct,
},
alerts: normalizeAlerts(settings.alertsJson),
- defaults: normalizeDefaults(settings.defaultsJson),
+ defaults,
+ reasonCatalog: reasonCatalog ?? undefined,
+ reasonCatalogData: reasonCatalog ?? undefined,
+ reasonCatalogVersion: Number((reasonCatalog as AnyRecord | null)?.version ?? 1),
updatedAt: settings.updatedAt,
updatedBy: settings.updatedBy,
};
@@ -169,6 +191,57 @@ export function validateShiftSchedule(shifts: unknown) {
return { ok: true, shifts: normalized as NormalizedShift[] };
}
+export function validateShiftOverrides(overrides: unknown) {
+ if (overrides === null) {
+ return { ok: true, overrides: null as Record | null } as const;
+ }
+ if (!isPlainObject(overrides)) {
+ return { ok: false, error: "shift overrides must be an object" } as const;
+ }
+
+ const normalized: Record = {};
+ for (const [key, value] of Object.entries(overrides)) {
+ if (!SHIFT_OVERRIDE_DAYS.includes(key as ShiftOverrideDay)) {
+ return { ok: false, error: `invalid shift override day: ${key}` } as const;
+ }
+ const shiftResult = validateShiftSchedule(value);
+ if (!shiftResult.ok) {
+ return { ok: false, error: `shift overrides ${key}: ${shiftResult.error}` } as const;
+ }
+ normalized[key] =
+ shiftResult.shifts?.map((s) => ({
+ name: s.name,
+ start: s.startTime,
+ end: s.endTime,
+ enabled: s.enabled !== false,
+ })) ?? [];
+ }
+
+ return { ok: true, overrides: normalized } as const;
+}
+
+export function normalizeShiftOverrides(raw: unknown) {
+ if (!isPlainObject(raw)) return undefined;
+ const out: Record = {};
+ for (const day of SHIFT_OVERRIDE_DAYS) {
+ const value = raw[day];
+ if (!Array.isArray(value)) continue;
+ const normalized = value
+ .map((entry, idx) => {
+ const record = isPlainObject(entry) ? entry : {};
+ const start = String(record.start ?? record.startTime ?? "").trim();
+ const end = String(record.end ?? record.endTime ?? "").trim();
+ if (!TIME_RE.test(start) || !TIME_RE.test(end)) return null;
+ const name = String(record.name ?? `Shift ${idx + 1}`).trim() || `Shift ${idx + 1}`;
+ const enabled = record.enabled !== false;
+ return { name, start, end, enabled };
+ })
+ .filter((entry): entry is ShiftOverridePayload => !!entry);
+ out[day] = normalized;
+ }
+ return out;
+}
+
export function validateShiftFields(shiftChangeCompensationMin?: unknown, lunchBreakMin?: unknown) {
if (shiftChangeCompensationMin != null) {
const v = Number(shiftChangeCompensationMin);
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..2180d5b
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,41 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+const COOKIE_NAME = "mis_session";
+
+export function middleware(req: NextRequest) {
+ try {
+ const { pathname, search } = req.nextUrl;
+
+ if (
+ pathname.startsWith("/_next") ||
+ pathname.startsWith("/favicon") ||
+ pathname.startsWith("/public") ||
+ pathname === "/login" ||
+ pathname === "/signup" ||
+ pathname === "/logout" ||
+ pathname.startsWith("/invite") ||
+ pathname.startsWith("/api")
+ ) {
+ return NextResponse.next();
+ }
+
+ const sessionId = req.cookies.get(COOKIE_NAME)?.value;
+
+ if (!sessionId) {
+ const url = req.nextUrl.clone();
+ url.pathname = "/login";
+ url.searchParams.set("next", pathname + search);
+ return NextResponse.redirect(url);
+ }
+
+ return NextResponse.next();
+ } catch (err) {
+ console.error("[middleware]", err);
+ return NextResponse.next();
+ }
+}
+
+export const config = {
+ matcher: ["/((?!_next/static|_next/image).*)"],
+};
diff --git a/package.json b/package.json
index 876c722..e179f3f 100644
--- a/package.json
+++ b/package.json
@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev",
- "build": "next build",
+ "dev": "next dev --turbopack",
+ "build": "next build --webpack",
"start": "next start",
"lint": "eslint"
},
diff --git a/prisma/migrations/20260310120000_add_shift_schedule_overrides/migration.sql b/prisma/migrations/20260310120000_add_shift_schedule_overrides/migration.sql
new file mode 100644
index 0000000..5647ae2
--- /dev/null
+++ b/prisma/migrations/20260310120000_add_shift_schedule_overrides/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "org_settings" ADD COLUMN "shift_schedule_overrides_json" JSONB;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1d08996..df21046 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -146,6 +146,7 @@ model Machine {
@@unique([orgId, name])
@@index([orgId])
+ @@index([orgId, createdAt])
}
model MachineHeartbeat {
@@ -166,6 +167,7 @@ model MachineHeartbeat {
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, ts])
+ @@index([orgId, machineId, tsServer])
}
model MachineKpiSnapshot {
@@ -310,6 +312,7 @@ model OrgSettings {
timezone String @default("UTC")
shiftChangeCompMin Int @default(10) @map("shift_change_comp_min")
lunchBreakMin Int @default(30) @map("lunch_break_min")
+ shiftScheduleOverridesJson Json? @map("shift_schedule_overrides_json")
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier")
diff --git a/rpi-case/.claude/skills/openscad/SKILL.md b/rpi-case/.claude/skills/openscad/SKILL.md
new file mode 100644
index 0000000..112ad11
--- /dev/null
+++ b/rpi-case/.claude/skills/openscad/SKILL.md
@@ -0,0 +1,232 @@
+---
+name: openscad
+description: "Create and render OpenSCAD 3D models. Generate preview images from multiple angles, extract customizable parameters, validate syntax, and export STL files for 3D printing platforms like MakerWorld."
+---
+
+# OpenSCAD Skill
+
+Create, validate, and export OpenSCAD 3D models. Supports parameter customization, visual preview from multiple angles, and STL export for 3D printing platforms like MakerWorld.
+
+## Prerequisites
+
+OpenSCAD must be installed. Install via Homebrew:
+```bash
+brew install openscad
+```
+
+## Tools
+
+This skill provides several tools in the `tools/` directory:
+
+### Preview Generation
+```bash
+# Generate a single preview image
+./tools/preview.sh model.scad output.png [--camera=x,y,z,tx,ty,tz,dist] [--size=800x600]
+
+# Generate multi-angle preview (front, back, left, right, top, iso)
+./tools/multi-preview.sh model.scad output_dir/
+```
+
+### STL Export
+```bash
+# Export to STL for 3D printing
+./tools/export-stl.sh model.scad output.stl [-D 'param=value']
+```
+
+### Parameter Extraction
+```bash
+# Extract customizable parameters from an OpenSCAD file
+./tools/extract-params.sh model.scad
+```
+
+### Validation
+```bash
+# Check for syntax errors and warnings
+./tools/validate.sh model.scad
+```
+
+## Visual Validation (Required)
+
+**Always validate your OpenSCAD models visually after creating or modifying them.**
+
+After writing or editing any OpenSCAD file:
+
+1. **Generate multi-angle previews** using `multi-preview.sh`
+2. **View each generated image** using the `read` tool
+3. **Check for issues** from multiple perspectives:
+ - Front/back: Verify symmetry, features, and proportions
+ - Left/right: Check depth and side profiles
+ - Top: Ensure top features are correct
+ - Isometric: Overall shape validation
+4. **Iterate if needed**: If something looks wrong, fix the code and re-validate
+
+This catches issues that syntax validation alone cannot detect:
+- Inverted normals or inside-out geometry
+- Misaligned features or incorrect boolean operations
+- Proportions that don't match the intended design
+- Missing or floating geometry
+- Z-fighting or overlapping surfaces
+
+**Never deliver an OpenSCAD model without visually confirming it looks correct from multiple angles.**
+
+## Workflow
+
+### 1. Creating an OpenSCAD Model
+
+Write OpenSCAD code with customizable parameters at the top:
+
+```openscad
+// Customizable parameters
+wall_thickness = 2; // [1:0.5:5] Wall thickness in mm
+width = 50; // [20:100] Width in mm
+height = 30; // [10:80] Height in mm
+rounded = true; // Add rounded corners
+
+// Model code below
+module main_shape() {
+ if (rounded) {
+ minkowski() {
+ cube([width - 4, width - 4, height - 2]);
+ sphere(r = 2);
+ }
+ } else {
+ cube([width, width, height]);
+ }
+}
+
+difference() {
+ main_shape();
+ translate([wall_thickness, wall_thickness, wall_thickness])
+ scale([1 - 2*wall_thickness/width, 1 - 2*wall_thickness/width, 1])
+ main_shape();
+}
+```
+
+Parameter comment format:
+- `// [min:max]` - numeric range
+- `// [min:step:max]` - numeric range with step
+- `// [opt1, opt2, opt3]` - dropdown options
+- `// Description text` - plain description
+
+### 2. Validate the Model
+```bash
+./tools/validate.sh model.scad
+```
+
+### 3. Generate Previews
+
+Generate preview images to visually validate the model:
+```bash
+./tools/multi-preview.sh model.scad ./previews/
+```
+
+This creates PNG images from multiple angles. Use the `read` tool to view them.
+
+### 4. Export to STL
+```bash
+./tools/export-stl.sh model.scad output.stl
+# With custom parameters:
+./tools/export-stl.sh model.scad output.stl -D 'width=60' -D 'height=40'
+```
+
+## Camera Positions
+
+Common camera angles for previews:
+- **Isometric**: `--camera=0,0,0,45,0,45,200`
+- **Front**: `--camera=0,0,0,90,0,0,200`
+- **Top**: `--camera=0,0,0,0,0,0,200`
+- **Right**: `--camera=0,0,0,90,0,90,200`
+
+Format: `x,y,z,rotx,roty,rotz,distance`
+
+## MakerWorld Publishing
+
+For MakerWorld, you typically need:
+1. STL file(s) exported via `export-stl.sh`
+2. Preview images (at least one good isometric view)
+3. A description of customizable parameters
+
+Consider creating a `model.json` with metadata:
+```json
+{
+ "name": "Model Name",
+ "description": "Description for MakerWorld",
+ "parameters": [...],
+ "tags": ["functional", "container", "organizer"]
+}
+```
+
+## Example: Full Workflow
+
+```bash
+# 1. Create the model (write .scad file)
+
+# 2. Validate syntax
+./tools/validate.sh box.scad
+
+# 3. Generate multi-angle previews
+./tools/multi-preview.sh box.scad ./previews/
+
+# 4. IMPORTANT: View and validate ALL preview images
+# Use the read tool on each PNG file to visually inspect:
+# - previews/box_front.png
+# - previews/box_back.png
+# - previews/box_left.png
+# - previews/box_right.png
+# - previews/box_top.png
+# - previews/box_iso.png
+# Look for geometry issues, misalignments, or unexpected results.
+# If anything looks wrong, go back to step 1 and fix it!
+
+# 5. Extract and review parameters
+./tools/extract-params.sh box.scad
+
+# 6. Export STL with default parameters
+./tools/export-stl.sh box.scad box.stl
+
+# 7. Export STL with custom parameters
+./tools/export-stl.sh box.scad box_large.stl -D 'width=80' -D 'height=60'
+```
+
+**Remember**: Never skip the visual validation step. Many issues (wrong dimensions, boolean operation errors, inverted geometry) are only visible when you actually look at the rendered model.
+
+## OpenSCAD Quick Reference
+
+### Basic Shapes
+```openscad
+cube([x, y, z]);
+sphere(r = radius);
+cylinder(h = height, r = radius);
+cylinder(h = height, r1 = bottom_r, r2 = top_r); // cone
+```
+
+### Transformations
+```openscad
+translate([x, y, z]) object();
+rotate([rx, ry, rz]) object();
+scale([sx, sy, sz]) object();
+mirror([x, y, z]) object();
+```
+
+### Boolean Operations
+```openscad
+union() { a(); b(); } // combine
+difference() { a(); b(); } // subtract b from a
+intersection() { a(); b(); } // overlap only
+```
+
+### Advanced
+```openscad
+linear_extrude(height) 2d_shape();
+rotate_extrude() 2d_shape();
+hull() { objects(); } // convex hull
+minkowski() { a(); b(); } // minkowski sum (rounding)
+```
+
+### 2D Shapes
+```openscad
+circle(r = radius);
+square([x, y]);
+polygon(points = [[x1,y1], [x2,y2], ...]);
+text("string", size = 10);
+```
diff --git a/rpi-case/.claude/skills/openscad/examples/parametric_box.scad b/rpi-case/.claude/skills/openscad/examples/parametric_box.scad
new file mode 100644
index 0000000..296ecd0
--- /dev/null
+++ b/rpi-case/.claude/skills/openscad/examples/parametric_box.scad
@@ -0,0 +1,92 @@
+// Parametric Box with Lid
+// A customizable storage box for 3D printing
+
+// === Box Parameters ===
+width = 60; // [20:200] Width in mm
+depth = 40; // [20:200] Depth in mm
+height = 30; // [10:150] Height in mm
+wall_thickness = 2; // [1:0.5:5] Wall thickness in mm
+
+// === Lid Parameters ===
+include_lid = true; // Include a separate lid
+lid_height = 8; // [5:30] Lid height in mm
+lid_tolerance = 0.3; // [0.1:0.1:0.8] Gap for lid fit
+
+// === Style Options ===
+corner_radius = 3; // [0:10] Corner rounding radius
+add_grip = true; // Add grip indents to lid
+
+// === Internal ===
+$fn = 32; // Smoothness
+
+// Rounded box module
+module rounded_box(w, d, h, r) {
+ if (r > 0) {
+ hull() {
+ for (x = [r, w-r]) {
+ for (y = [r, d-r]) {
+ translate([x, y, 0])
+ cylinder(h = h, r = r);
+ }
+ }
+ }
+ } else {
+ cube([w, d, h]);
+ }
+}
+
+// Main box body
+module box_body() {
+ difference() {
+ rounded_box(width, depth, height, corner_radius);
+
+ // Hollow inside
+ translate([wall_thickness, wall_thickness, wall_thickness])
+ rounded_box(
+ width - 2*wall_thickness,
+ depth - 2*wall_thickness,
+ height, // Open top
+ max(0, corner_radius - wall_thickness)
+ );
+ }
+}
+
+// Lid
+module lid() {
+ inner_w = width - 2*wall_thickness - 2*lid_tolerance;
+ inner_d = depth - 2*wall_thickness - 2*lid_tolerance;
+ lip_height = lid_height * 0.6;
+
+ difference() {
+ union() {
+ // Top cap
+ rounded_box(width, depth, wall_thickness, corner_radius);
+
+ // Inner lip
+ translate([wall_thickness + lid_tolerance, wall_thickness + lid_tolerance, -lip_height + wall_thickness])
+ rounded_box(inner_w, inner_d, lip_height, max(0, corner_radius - wall_thickness));
+ }
+
+ // Grip indents
+ if (add_grip) {
+ for (x = [width * 0.3, width * 0.7]) {
+ translate([x, -1, wall_thickness/2])
+ rotate([-90, 0, 0])
+ cylinder(h = 5, r = 3, $fn = 16);
+ translate([x, depth - 4, wall_thickness/2])
+ rotate([-90, 0, 0])
+ cylinder(h = 5, r = 3, $fn = 16);
+ }
+ }
+ }
+}
+
+// Render
+box_body();
+
+if (include_lid) {
+ // Position lid next to box for printing
+ translate([width + 10, 0, lid_height - wall_thickness])
+ rotate([180, 0, 0])
+ lid();
+}
diff --git a/rpi-case/.claude/skills/openscad/examples/phone_stand.scad b/rpi-case/.claude/skills/openscad/examples/phone_stand.scad
new file mode 100644
index 0000000..b3b7ba2
--- /dev/null
+++ b/rpi-case/.claude/skills/openscad/examples/phone_stand.scad
@@ -0,0 +1,95 @@
+// Adjustable Phone/Tablet Stand
+// Parametric stand with customizable angle and size
+
+// === Device Parameters ===
+device_width = 80; // [50:200] Device width in mm
+device_thickness = 12; // [6:20] Device thickness (with case)
+
+// === Stand Parameters ===
+stand_angle = 65; // [45:85] Viewing angle in degrees
+stand_depth = 80; // [50:150] Base depth in mm
+stand_height = 100; // [60:200] Back support height in mm
+
+// === Construction ===
+material_thickness = 4; // [2:0.5:8] Material thickness
+slot_depth = 15; // [10:30] How deep device sits in slot
+
+// === Features ===
+cable_hole = true; // Add cable pass-through hole
+cable_diameter = 15; // [8:25] Cable hole diameter
+add_feet = true; // Add anti-slip feet
+
+// === Quality ===
+$fn = 48;
+
+module stand_profile() {
+ // 2D profile of the stand side
+ polygon([
+ [0, 0], // Front bottom
+ [stand_depth, 0], // Back bottom
+ [stand_depth, material_thickness], // Back bottom inner
+ [stand_depth - material_thickness, material_thickness], // Base top back
+ [slot_depth + material_thickness, material_thickness], // Base top front (behind slot)
+ [slot_depth + material_thickness, slot_depth * tan(90 - stand_angle) + material_thickness], // Slot back
+ [material_thickness, slot_depth * tan(90 - stand_angle) + material_thickness + device_thickness / sin(stand_angle)], // Slot front top
+ [0, slot_depth * tan(90 - stand_angle) + material_thickness], // Front face bottom of slot
+ [0, 0] // Close
+ ]);
+}
+
+module back_support() {
+ // Back angled support
+ translate([stand_depth - material_thickness, 0, material_thickness]) {
+ rotate([0, -90 + stand_angle, 0]) {
+ cube([stand_height, device_width, material_thickness]);
+ }
+ }
+}
+
+module cable_cutout() {
+ if (cable_hole) {
+ translate([stand_depth/2, device_width/2, -1])
+ cylinder(h = material_thickness + 2, d = cable_diameter);
+ }
+}
+
+module foot() {
+ cylinder(h = 2, d1 = 10, d2 = 8);
+}
+
+module stand() {
+ difference() {
+ union() {
+ // Left side
+ linear_extrude(material_thickness)
+ stand_profile();
+
+ // Right side
+ translate([0, device_width - material_thickness, 0])
+ linear_extrude(material_thickness)
+ stand_profile();
+
+ // Base plate
+ cube([stand_depth, device_width, material_thickness]);
+
+ // Front lip
+ cube([material_thickness, device_width, slot_depth * tan(90 - stand_angle) + material_thickness]);
+
+ // Back support
+ back_support();
+ }
+
+ // Cable hole
+ cable_cutout();
+ }
+
+ // Feet
+ if (add_feet) {
+ translate([10, 10, 0]) foot();
+ translate([10, device_width - 10, 0]) foot();
+ translate([stand_depth - 10, 10, 0]) foot();
+ translate([stand_depth - 10, device_width - 10, 0]) foot();
+ }
+}
+
+stand();
diff --git a/rpi-case/.claude/skills/openscad/tools/common.sh b/rpi-case/.claude/skills/openscad/tools/common.sh
new file mode 100755
index 0000000..086d6ce
--- /dev/null
+++ b/rpi-case/.claude/skills/openscad/tools/common.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+# Common utilities for OpenSCAD tools
+
+# Find OpenSCAD executable
+find_openscad() {
+ # Check common locations
+ if command -v openscad &> /dev/null; then
+ echo "openscad"
+ return 0
+ fi
+
+ # macOS Application bundle
+ if [ -d "/Applications/OpenSCAD.app" ]; then
+ echo "/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD"
+ return 0
+ fi
+
+ # Homebrew on Apple Silicon
+ if [ -x "/opt/homebrew/bin/openscad" ]; then
+ echo "/opt/homebrew/bin/openscad"
+ return 0
+ fi
+
+ # Homebrew on Intel
+ if [ -x "/usr/local/bin/openscad" ]; then
+ echo "/usr/local/bin/openscad"
+ return 0
+ fi
+
+ return 1
+}
+
+# Check if OpenSCAD is available
+check_openscad() {
+ OPENSCAD=$(find_openscad) || {
+ echo "Error: OpenSCAD not found!"
+ echo ""
+ echo "Install OpenSCAD using one of:"
+ echo " brew install openscad"
+ echo " Download from https://openscad.org/downloads.html"
+ exit 1
+ }
+ export OPENSCAD
+}
+
+# Get version info
+openscad_version() {
+ check_openscad
+ $OPENSCAD --version 2>&1
+}
diff --git a/rpi-case/.claude/skills/openscad/tools/export-stl.sh b/rpi-case/.claude/skills/openscad/tools/export-stl.sh
new file mode 100755
index 0000000..51a1479
--- /dev/null
+++ b/rpi-case/.claude/skills/openscad/tools/export-stl.sh
@@ -0,0 +1,56 @@
+#!/bin/bash
+# Export OpenSCAD file to STL
+# Usage: export-stl.sh input.scad output.stl [-D 'var=value' ...]
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/common.sh"
+
+check_openscad
+
+if [ $# -lt 2 ]; then
+ echo "Usage: $0 input.scad output.stl [-D 'var=value' ...]"
+ echo ""
+ echo "Examples:"
+ echo " $0 box.scad box.stl"
+ echo " $0 box.scad box_large.stl -D 'width=80' -D 'height=60'"
+ exit 1
+fi
+
+INPUT="$1"
+OUTPUT="$2"
+shift 2
+
+# Collect -D parameters
+DEFINES=()
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -D)
+ shift
+ DEFINES+=("-D" "$1")
+ ;;
+ *)
+ echo "Unknown option: $1"
+ exit 1
+ ;;
+ esac
+ shift
+done
+
+# Ensure output directory exists
+mkdir -p "$(dirname "$OUTPUT")"
+
+echo "Exporting STL: $INPUT -> $OUTPUT"
+if [ ${#DEFINES[@]} -gt 0 ]; then
+ echo "Parameters: ${DEFINES[*]}"
+fi
+
+$OPENSCAD \
+ "${DEFINES[@]}" \
+ -o "$OUTPUT" \
+ "$INPUT"
+
+# Show file info
+SIZE=$(ls -lh "$OUTPUT" | awk '{print $5}')
+echo "STL exported: $OUTPUT ($SIZE)"
diff --git a/rpi-case/.claude/skills/openscad/tools/extract-params.sh b/rpi-case/.claude/skills/openscad/tools/extract-params.sh
new file mode 100755
index 0000000..390082b
--- /dev/null
+++ b/rpi-case/.claude/skills/openscad/tools/extract-params.sh
@@ -0,0 +1,147 @@
+#!/bin/bash
+# Extract customizable parameters from an OpenSCAD file
+# Usage: extract-params.sh input.scad [--json]
+#
+# Parses parameter declarations with special comments:
+# param = value; // [min:max] Description
+# param = value; // [min:step:max] Description
+# param = value; // [opt1, opt2] Description
+# param = value; // Description only
+
+set -e
+
+if [ $# -lt 1 ]; then
+ echo "Usage: $0 input.scad [--json]"
+ exit 1
+fi
+
+INPUT="$1"
+JSON_OUTPUT=false
+
+if [ "$2" = "--json" ]; then
+ JSON_OUTPUT=true
+fi
+
+if [ ! -f "$INPUT" ]; then
+ echo "Error: File not found: $INPUT"
+ exit 1
+fi
+
+# Extract parameters using Python for better parsing
+extract_params() {
+ python3 -c '
+import sys
+import re
+
+filename = sys.argv[1]
+in_block = 0
+
+with open(filename, "r") as f:
+ for line in f:
+ # Track block depth (skip params inside modules/functions)
+ in_block += line.count("{") - line.count("}")
+ if in_block > 0:
+ continue
+
+ # Match: varname = value; // comment
+ match = re.match(r"^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([^;]+);\s*(?://\s*(.*))?", line)
+ if not match:
+ continue
+
+ var_name = match.group(1)
+ value = match.group(2).strip()
+ comment = match.group(3) or ""
+
+ # Determine type
+ if value in ("true", "false"):
+ var_type = "boolean"
+ elif re.match(r"^-?\d+$", value):
+ var_type = "integer"
+ elif re.match(r"^-?\d*\.?\d+$", value):
+ var_type = "number"
+ elif value.startswith("\"") and value.endswith("\""):
+ var_type = "string"
+ value = value[1:-1] # Remove quotes
+ elif value.startswith("["):
+ var_type = "array"
+ else:
+ var_type = "expression"
+
+ # Parse comment for range/options
+ range_val = ""
+ options_val = ""
+ description = comment
+
+ range_match = re.match(r"\[([^\]]+)\]\s*(.*)", comment)
+ if range_match:
+ bracket_content = range_match.group(1)
+ description = range_match.group(2)
+
+ # Check if numeric range (contains :) or options (contains ,)
+ if ":" in bracket_content and not "," in bracket_content:
+ range_val = bracket_content
+ else:
+ options_val = bracket_content
+
+ # Output pipe-delimited
+ print(f"{var_name}|{value}|{var_type}|{range_val}|{options_val}|{description}")
+' "$INPUT"
+}
+
+if [ "$JSON_OUTPUT" = true ]; then
+ echo "["
+ first=true
+ while IFS='|' read -r name value type range options description; do
+ if [ "$first" = true ]; then
+ first=false
+ else
+ echo ","
+ fi
+
+ # Escape quotes in values
+ value=$(echo "$value" | sed 's/"/\\"/g')
+ description=$(echo "$description" | sed 's/"/\\"/g')
+
+ # Build JSON object
+ printf ' {\n'
+ printf ' "name": "%s",\n' "$name"
+ printf ' "value": "%s",\n' "$value"
+ printf ' "type": "%s"' "$type"
+
+ if [ -n "$range" ]; then
+ printf ',\n "range": "%s"' "$range"
+ fi
+ if [ -n "$options" ]; then
+ printf ',\n "options": "%s"' "$options"
+ fi
+ if [ -n "$description" ]; then
+ printf ',\n "description": "%s"' "$description"
+ fi
+ printf '\n }'
+ done < <(extract_params)
+ echo ""
+ echo "]"
+else
+ echo "Parameters in: $INPUT"
+ echo "==============================================="
+ printf "%-20s %-15s %-10s %s\n" "NAME" "VALUE" "TYPE" "CONSTRAINT/DESC"
+ echo "-----------------------------------------------"
+
+ while IFS='|' read -r name value type range options description; do
+ constraint=""
+ if [ -n "$range" ]; then
+ constraint="[$range]"
+ elif [ -n "$options" ]; then
+ constraint="[$options]"
+ fi
+ if [ -n "$description" ]; then
+ if [ -n "$constraint" ]; then
+ constraint="$constraint $description"
+ else
+ constraint="$description"
+ fi
+ fi
+
+ printf "%-20s %-15s %-10s %s\n" "$name" "$value" "$type" "$constraint"
+ done < <(extract_params)
+fi
diff --git a/rpi-case/.claude/skills/openscad/tools/multi-preview.sh b/rpi-case/.claude/skills/openscad/tools/multi-preview.sh
new file mode 100755
index 0000000..e17c01f
--- /dev/null
+++ b/rpi-case/.claude/skills/openscad/tools/multi-preview.sh
@@ -0,0 +1,68 @@
+#!/bin/bash
+# Generate preview images from multiple angles
+# Usage: multi-preview.sh input.scad output_dir/ [-D 'var=value']
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/common.sh"
+
+check_openscad
+
+if [ $# -lt 2 ]; then
+ echo "Usage: $0 input.scad output_dir/ [-D 'var=value' ...]"
+ exit 1
+fi
+
+INPUT="$1"
+OUTPUT_DIR="$2"
+shift 2
+
+# Collect -D parameters
+DEFINES=()
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -D)
+ shift
+ DEFINES+=("-D" "$1")
+ ;;
+ esac
+ shift
+done
+
+mkdir -p "$OUTPUT_DIR"
+
+# Get base name without extension
+BASENAME=$(basename "$INPUT" .scad)
+
+echo "Generating multi-angle previews for: $INPUT"
+echo "Output directory: $OUTPUT_DIR"
+echo ""
+
+# Define angles as name:camera pairs
+# Camera format: translate_x,translate_y,translate_z,rot_x,rot_y,rot_z,distance
+ANGLES="iso:0,0,0,55,0,25,0
+front:0,0,0,90,0,0,0
+back:0,0,0,90,0,180,0
+left:0,0,0,90,0,90,0
+right:0,0,0,90,0,-90,0
+top:0,0,0,0,0,0,0"
+
+echo "$ANGLES" | while IFS=: read -r angle camera; do
+ output="$OUTPUT_DIR/${BASENAME}_${angle}.png"
+
+ echo " Rendering $angle view..."
+ $OPENSCAD \
+ --camera="$camera" \
+ --imgsize="800,600" \
+ --colorscheme="Tomorrow Night" \
+ --autocenter \
+ --viewall \
+ "${DEFINES[@]}" \
+ -o "$output" \
+ "$INPUT" 2>/dev/null
+done
+
+echo ""
+echo "Generated previews:"
+ls -la "$OUTPUT_DIR"/${BASENAME}_*.png
diff --git a/rpi-case/.claude/skills/openscad/tools/preview.sh b/rpi-case/.claude/skills/openscad/tools/preview.sh
new file mode 100755
index 0000000..38d9644
--- /dev/null
+++ b/rpi-case/.claude/skills/openscad/tools/preview.sh
@@ -0,0 +1,74 @@
+#!/bin/bash
+# Generate a preview PNG from an OpenSCAD file
+# Usage: preview.sh input.scad output.png [options]
+#
+# Options:
+# --camera=x,y,z,rx,ry,rz,dist Camera position
+# --size=WxH Image size (default: 800x600)
+# -D 'var=value' Set parameter value
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/common.sh"
+
+check_openscad
+
+if [ $# -lt 2 ]; then
+ echo "Usage: $0 input.scad output.png [--camera=...] [--size=WxH] [-D 'var=val']"
+ echo ""
+ echo "Camera format: x,y,z,rotx,roty,rotz,distance"
+ echo "Common cameras:"
+ echo " Isometric: --camera=0,0,0,55,0,25,200"
+ echo " Front: --camera=0,0,0,90,0,0,200"
+ echo " Top: --camera=0,0,0,0,0,0,200"
+ exit 1
+fi
+
+INPUT="$1"
+OUTPUT="$2"
+shift 2
+
+# Defaults
+CAMERA="0,0,0,55,0,25,0"
+SIZE="800,600"
+DEFINES=()
+
+# Parse options
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --camera=*)
+ CAMERA="${1#--camera=}"
+ ;;
+ --size=*)
+ SIZE="${1#--size=}"
+ SIZE="${SIZE/x/,}"
+ ;;
+ -D)
+ shift
+ DEFINES+=("-D" "$1")
+ ;;
+ *)
+ echo "Unknown option: $1"
+ exit 1
+ ;;
+ esac
+ shift
+done
+
+# Ensure output directory exists
+mkdir -p "$(dirname "$OUTPUT")"
+
+# Run OpenSCAD
+echo "Rendering preview: $INPUT -> $OUTPUT"
+$OPENSCAD \
+ --camera="$CAMERA" \
+ --imgsize="${SIZE}" \
+ --colorscheme="Tomorrow Night" \
+ --autocenter \
+ --viewall \
+ "${DEFINES[@]}" \
+ -o "$OUTPUT" \
+ "$INPUT"
+
+echo "Preview saved to: $OUTPUT"
diff --git a/rpi-case/.claude/skills/openscad/tools/render-with-params.sh b/rpi-case/.claude/skills/openscad/tools/render-with-params.sh
new file mode 100755
index 0000000..2711759
--- /dev/null
+++ b/rpi-case/.claude/skills/openscad/tools/render-with-params.sh
@@ -0,0 +1,91 @@
+#!/bin/bash
+# Render OpenSCAD with parameters from a JSON file
+# Usage: render-with-params.sh input.scad params.json output.stl|output.png
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/common.sh"
+
+check_openscad
+
+if [ $# -lt 3 ]; then
+ echo "Usage: $0 input.scad params.json output.[stl|png]"
+ echo ""
+ echo "params.json format:"
+ echo ' {"width": 60, "height": 40, "include_lid": true}'
+ exit 1
+fi
+
+INPUT="$1"
+PARAMS_FILE="$2"
+OUTPUT="$3"
+
+if [ ! -f "$INPUT" ]; then
+ echo "Error: Input file not found: $INPUT"
+ exit 1
+fi
+
+if [ ! -f "$PARAMS_FILE" ]; then
+ echo "Error: Params file not found: $PARAMS_FILE"
+ exit 1
+fi
+
+# Build -D arguments from JSON
+DEFINES=()
+while IFS= read -r line; do
+ # Parse each key-value pair
+ key=$(echo "$line" | cut -d'=' -f1)
+ value=$(echo "$line" | cut -d'=' -f2-)
+
+ if [ -n "$key" ]; then
+ DEFINES+=("-D" "$key=$value")
+ fi
+done < <(
+ # Use python or jq to parse JSON to key=value lines
+ if command -v python3 &> /dev/null; then
+ python3 -c "
+import json
+with open('$PARAMS_FILE') as f:
+ params = json.load(f)
+for k, v in params.items():
+ if isinstance(v, bool):
+ print(f'{k}={str(v).lower()}')
+ elif isinstance(v, str):
+ print(f'{k}=\"{v}\"')
+ else:
+ print(f'{k}={v}')
+"
+ elif command -v jq &> /dev/null; then
+ jq -r 'to_entries | .[] | "\(.key)=\(.value)"' "$PARAMS_FILE"
+ else
+ echo "Error: Requires python3 or jq to parse JSON"
+ exit 1
+ fi
+)
+
+echo "Rendering with parameters from: $PARAMS_FILE"
+echo "Parameters: ${DEFINES[*]}"
+
+# Determine output type and set appropriate options
+EXT="${OUTPUT##*.}"
+case "$EXT" in
+ stl|STL)
+ $OPENSCAD "${DEFINES[@]}" -o "$OUTPUT" "$INPUT"
+ ;;
+ png|PNG)
+ $OPENSCAD "${DEFINES[@]}" \
+ --camera="0,0,0,55,0,25,0" \
+ --imgsize="800,600" \
+ --colorscheme="Tomorrow Night" \
+ --autocenter --viewall \
+ -o "$OUTPUT" "$INPUT"
+ ;;
+ *)
+ echo "Unsupported output format: $EXT"
+ echo "Supported: stl, png"
+ exit 1
+ ;;
+esac
+
+echo "Output saved: $OUTPUT"
diff --git a/rpi-case/.claude/skills/openscad/tools/validate.sh b/rpi-case/.claude/skills/openscad/tools/validate.sh
new file mode 100755
index 0000000..157bf76
--- /dev/null
+++ b/rpi-case/.claude/skills/openscad/tools/validate.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+# Validate an OpenSCAD file for syntax errors
+# Usage: validate.sh input.scad
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/common.sh"
+
+check_openscad
+
+if [ $# -lt 1 ]; then
+ echo "Usage: $0 input.scad"
+ exit 1
+fi
+
+INPUT="$1"
+
+if [ ! -f "$INPUT" ]; then
+ echo "Error: File not found: $INPUT"
+ exit 1
+fi
+
+echo "Validating: $INPUT"
+
+# Create temp file for output
+TEMP_OUTPUT=$(mktemp /tmp/openscad_validate.XXXXXX.echo)
+trap "rm -f $TEMP_OUTPUT" EXIT
+
+# Run OpenSCAD with echo output (fastest way to check syntax)
+# Using --export-format=echo just parses and evaluates without rendering
+if $OPENSCAD -o "$TEMP_OUTPUT" --export-format=echo "$INPUT" 2>&1; then
+ echo "✓ Syntax OK"
+
+ # Check for warnings in stderr
+ if [ -s "$TEMP_OUTPUT" ]; then
+ echo ""
+ echo "Echo output:"
+ cat "$TEMP_OUTPUT"
+ fi
+
+ exit 0
+else
+ echo "✗ Validation failed"
+ exit 1
+fi
diff --git a/rpi-case/previews/001/rpi5_industrial_case_001_back.png b/rpi-case/previews/001/rpi5_industrial_case_001_back.png
new file mode 100644
index 0000000..304881a
Binary files /dev/null and b/rpi-case/previews/001/rpi5_industrial_case_001_back.png differ
diff --git a/rpi-case/previews/001/rpi5_industrial_case_001_front.png b/rpi-case/previews/001/rpi5_industrial_case_001_front.png
new file mode 100644
index 0000000..f87e4a8
Binary files /dev/null and b/rpi-case/previews/001/rpi5_industrial_case_001_front.png differ
diff --git a/rpi-case/previews/001/rpi5_industrial_case_001_iso.png b/rpi-case/previews/001/rpi5_industrial_case_001_iso.png
new file mode 100644
index 0000000..bb82a5a
Binary files /dev/null and b/rpi-case/previews/001/rpi5_industrial_case_001_iso.png differ
diff --git a/rpi-case/previews/001/rpi5_industrial_case_001_left.png b/rpi-case/previews/001/rpi5_industrial_case_001_left.png
new file mode 100644
index 0000000..15100d5
Binary files /dev/null and b/rpi-case/previews/001/rpi5_industrial_case_001_left.png differ
diff --git a/rpi-case/previews/001/rpi5_industrial_case_001_right.png b/rpi-case/previews/001/rpi5_industrial_case_001_right.png
new file mode 100644
index 0000000..dfb5d3e
Binary files /dev/null and b/rpi-case/previews/001/rpi5_industrial_case_001_right.png differ
diff --git a/rpi-case/previews/001/rpi5_industrial_case_001_top.png b/rpi-case/previews/001/rpi5_industrial_case_001_top.png
new file mode 100644
index 0000000..caa2d46
Binary files /dev/null and b/rpi-case/previews/001/rpi5_industrial_case_001_top.png differ
diff --git a/rpi-case/rpi5_industrial_case_001.scad b/rpi-case/rpi5_industrial_case_001.scad
new file mode 100644
index 0000000..340ac9f
--- /dev/null
+++ b/rpi-case/rpi5_industrial_case_001.scad
@@ -0,0 +1,289 @@
+// ============================================================
+// RPi5 Industrial Enclosure for Luckfox DHX-10.1" Touchscreen
+// Version: 001
+// ============================================================
+
+// ── SCREEN PARAMETERS ───────────────────────────────────────
+scr_w = 236; // screen outer width (mm)
+scr_h = 144; // screen outer height (mm)
+scr_d = 19; // screen outer depth (mm)
+scr_active_w = 222; // active area width (mm) ← confirm
+scr_active_h = 130; // active area height (mm) ← confirm
+scr_mount_x = 75; // screen M2.5 mount pattern X (mm) ← verify
+scr_mount_y = 75; // screen M2.5 mount pattern Y (mm) ← verify
+
+// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
+pi_w = 85; // Pi board width (mm)
+pi_h = 56; // Pi board height (mm)
+pi_d = 17; // Pi board depth incl. tallest component (mm)
+pi_mnt_x = 58; // Pi mount hole pattern X (mm)
+pi_mnt_y = 49; // Pi mount hole pattern Y (mm)
+pi_standoff = 5; // standoff height between screen rear and Pi (mm)
+// Pi offset from screen center (positive = up, right)
+pi_offset_x = 0; // horizontal offset of Pi center from screen center
+pi_offset_y = 5; // vertical offset upward from screen center
+
+// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
+wall = 2.5; // wall thickness (mm)
+chamfer = 1.5; // external edge chamfer (mm)
+recess = 1.0; // screen recess depth in front bezel (mm)
+gap = 0.3; // fit clearance between bezel and rear cover
+
+// ── VENT PARAMETERS ──────────────────────────────────────────
+vent_w = 3; // vent slot width (mm)
+vent_l = 20; // vent slot length (mm)
+vent_sp = 4; // slot pitch (edge to edge) (mm)
+soc_vent_sz = 30; // SoC vent zone size (mm sq)
+
+// ── CABLE GLAND PARAMETERS ───────────────────────────────────
+gland_count = 2; // number of cable glands
+gland_dia = 16.5; // M16 clearance hole diameter (mm)
+gland_spacing = 40; // spacing between gland centers (mm)
+
+// ── PEDESTAL PARAMETERS ──────────────────────────────────────
+ped_tilt = 75; // tilt angle from vertical (deg) — screen tilts back
+ped_depth = 80; // foot depth front-to-back (mm)
+ped_width = 200; // foot width (mm)
+ped_thick = 6; // foot plate thickness (mm)
+ped_brace_h = 30; // height of triangular brace
+
+// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
+m3_dia = 3.4; // M3 clearance hole
+insert_dia = 4.2; // M3 heat-set insert OD
+insert_h = 6; // heat-set insert depth
+
+// ── DERIVED DIMENSIONS ───────────────────────────────────────
+// Total rear cavity depth = standoffs + Pi + cable headroom
+rear_d = pi_standoff + pi_d + 10; // 10 mm cable headroom
+// Outer enclosure size
+enc_w = scr_w + 2*wall;
+enc_h = scr_h + 2*wall;
+enc_d = rear_d + wall; // rear cover depth
+
+// Pi center position relative to screen center
+pi_cx = scr_w/2 + pi_offset_x;
+pi_cy = scr_h/2 + pi_offset_y;
+
+$fn = 48;
+
+// ============================================================
+// MODULES
+// ============================================================
+
+// Chamfered box (external chamfer via intersection with offset cube)
+module cbox(w, h, d, c=chamfer) {
+ hull() {
+ translate([c,c,0]) cube([w-2*c, h-2*c, d]);
+ translate([0,c,c]) cube([w, h-2*c, d-2*c]);
+ translate([c,0,c]) cube([w-2*c, h, d-2*c]);
+ }
+}
+
+// Rounded slot (for vents)
+module slot(len, w, d) {
+ r = w/2;
+ hull() {
+ translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
+ translate([0, len/2-r, 0]) cylinder(r=r, h=d);
+ }
+}
+
+// M2.5 mounting hole
+module m25_hole(d=10) {
+ cylinder(d=2.7, h=d);
+}
+
+// Heat-set insert boss + M3 hole
+module insert_boss(h=insert_h+4) {
+ difference() {
+ cylinder(d=insert_dia+3, h=h);
+ cylinder(d=insert_dia, h=insert_h);
+ translate([0,0,insert_h]) cylinder(d=m3_dia, h=h);
+ }
+}
+
+// Single vent slot row (horizontal slots)
+module vent_row(count, slot_len, slot_w, pitch, depth) {
+ for(i=[0:count-1]) {
+ translate([i*(slot_w+pitch), 0, 0])
+ slot(slot_len, slot_w, depth+0.1);
+ }
+}
+
+// ============================================================
+// FRONT BEZEL
+// ============================================================
+module front_bezel() {
+ difference() {
+ // Outer chamfered shell
+ cbox(enc_w, enc_h, wall + recess);
+
+ // Active display window (recessed by 1 mm, then open)
+ translate([(enc_w - scr_active_w)/2,
+ (enc_h - scr_active_h)/2,
+ -0.1])
+ cube([scr_active_w, scr_active_h, wall + recess + 0.2]);
+
+ // Bezel lip sits 1 mm over screen edge — recess pocket
+ translate([(enc_w - scr_w)/2,
+ (enc_h - scr_h)/2,
+ wall])
+ cube([scr_w, scr_h, recess + 0.1]);
+
+ // Corner M3 screw holes (through bezel flange, 4 corners)
+ for(x=[wall+6, enc_w-wall-6])
+ for(y=[wall+6, enc_h-wall-6])
+ translate([x, y, -0.1])
+ cylinder(d=m3_dia, h=wall+recess+0.2);
+ }
+}
+
+// ============================================================
+// REAR COVER
+// ============================================================
+module rear_cover() {
+ difference() {
+ union() {
+ // Main body
+ cbox(enc_w, enc_h, enc_d);
+
+ // Pedestal foot (integral)
+ pedestal_foot();
+
+ // Heat-set insert bosses at 4 corners (inside)
+ for(x=[wall+6, enc_w-wall-6])
+ for(y=[wall+6, enc_h-wall-6])
+ translate([x, y, enc_d])
+ rotate([180,0,0])
+ insert_boss();
+ }
+
+ // Hollow interior
+ translate([wall, wall, wall])
+ cube([scr_w, scr_h, enc_d]);
+
+ // ── PORT CUTOUTS ──────────────────────────────────────
+
+ // USB-C power + 2× HDMI on LEFT edge (Pi left side)
+ // Pi left edge X position in enclosure coords
+ pi_left_x = pi_cx - pi_w/2 + wall;
+ // USB-C power (Pi left edge, near bottom of Pi)
+ translate([-0.1,
+ pi_cy - 8 + wall,
+ wall + pi_standoff + 2])
+ cube([wall+0.2, 10, 10]);
+ // HDMI #1
+ translate([-0.1,
+ pi_cx - pi_w/2 + wall + 15,
+ wall + pi_standoff + 2])
+ cube([wall+0.2, 16, 8]);
+ // HDMI #2
+ translate([-0.1,
+ pi_cx - pi_w/2 + wall + 34,
+ wall + pi_standoff + 2])
+ cube([wall+0.2, 16, 8]);
+
+ // Ethernet RJ45 on RIGHT edge
+ translate([enc_w - wall - 0.1,
+ pi_cy + pi_h/2 - 22 + wall,
+ wall + pi_standoff + 1])
+ cube([wall+0.2, 22, 16]);
+
+ // USB-A ×4 on RIGHT edge
+ translate([enc_w - wall - 0.1,
+ pi_cy - pi_h/2 + wall + 2,
+ wall + pi_standoff + 1])
+ cube([wall+0.2, 50, 14]);
+
+ // GPIO header on TOP edge
+ translate([pi_cx - 30 + wall,
+ enc_h - wall - 0.1,
+ wall + pi_standoff])
+ cube([52, wall+0.2, 12]);
+
+ // USB-C touch on left side edge of SCREEN (not Pi)
+ translate([-0.1, enc_h/2 - 6, wall + scr_d - 5])
+ cube([wall+0.2, 12, 8]);
+
+ // ── COOLING VENTS ──────────────────────────────────────
+
+ // Bottom intake slots
+ translate([enc_w/2 - (5*(vent_w+vent_sp))/2, -0.1, wall+8])
+ rotate([-90, 0, 0])
+ vent_row(5, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // Top exhaust slots
+ translate([enc_w/2 - (5*(vent_w+vent_sp))/2,
+ enc_h - wall + 0.1,
+ wall+8])
+ rotate([90, 0, 0])
+ vent_row(5, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // SoC direct vent (rear panel, over Pi SoC area)
+ // SoC assumed ~center of Pi board
+ translate([pi_cx - soc_vent_sz/2 + wall,
+ pi_cy - soc_vent_sz/2 + wall,
+ enc_d - wall - 0.1]) {
+ count_soc = floor(soc_vent_sz / (vent_w + vent_sp));
+ for(i=[0:count_soc-1])
+ translate([i*(vent_w+vent_sp), soc_vent_sz/2-vent_l/2, 0])
+ slot(vent_l, vent_w, wall+0.2);
+ }
+
+ // ── CABLE GLANDS ──────────────────────────────────────
+ for(i=[0:gland_count-1]) {
+ cx = enc_w/2 + (i - (gland_count-1)/2) * gland_spacing;
+ translate([cx, -0.1, wall + gland_dia/2 + 4])
+ rotate([-90,0,0])
+ cylinder(d=gland_dia, h=wall+0.2);
+ }
+ }
+}
+
+// ============================================================
+// PEDESTAL FOOT (integral with rear cover)
+// ============================================================
+module pedestal_foot() {
+ // The foot projects from the bottom of the rear cover.
+ // It's a wedge that creates the tilt angle.
+ // When the assembly stands on the foot, the screen tilts back ped_tilt°.
+ //
+ // tilt_angle from vertical → wedge front height > back height.
+ // foot_front_h = ped_depth * tan(90-ped_tilt)
+ foot_front_h = ped_depth * tan(90 - ped_tilt);
+
+ foot_x0 = (enc_w - ped_width) / 2;
+
+ translate([foot_x0, 0, 0]) {
+ // Wedge base plate
+ hull() {
+ // Front edge (taller)
+ translate([0, -ped_depth, 0])
+ cube([ped_width, 0.1, foot_front_h + ped_thick]);
+ // Back edge (at enc base, flush)
+ translate([0, 0, 0])
+ cube([ped_width, 0.1, ped_thick]);
+ }
+
+ // Triangular side braces for rigidity
+ for(bx=[0, ped_width-ped_thick]) {
+ translate([bx, -ped_depth, 0])
+ linear_extrude(ped_thick)
+ polygon([[0,0],
+ [ped_depth, 0],
+ [ped_depth, foot_front_h]]);
+ }
+ }
+}
+
+// ============================================================
+// RENDER — exploded assembly view
+// ============================================================
+// Front bezel at Z=0 (face down for printing, shown face up)
+color("DarkSlateGray", 0.9)
+ translate([0, 0, enc_d + 5])
+ front_bezel();
+
+// Rear cover
+color("SlateGray", 0.9)
+ rear_cover();
diff --git a/rpi-case/rpi5_industrial_case_002.scad b/rpi-case/rpi5_industrial_case_002.scad
new file mode 100644
index 0000000..3f7e1e5
--- /dev/null
+++ b/rpi-case/rpi5_industrial_case_002.scad
@@ -0,0 +1,300 @@
+// ============================================================
+// RPi5 Industrial Enclosure — Luckfox DHX-10.1" Touchscreen
+// Version: 002
+// Fixes vs 001:
+// 1. Pedestal foot now projects from REAR FACE in -Z direction
+// 2. Tilt wedge orientation corrected (leans screen back, not forward)
+// 3. Cable glands moved to rear panel face (foot owns the bottom edge)
+// 4. GPIO cutout repositioned to match Pi board top-edge location
+// 5. Port cutout Z-depths corrected using pi_enc_cx/cy consistently
+// ============================================================
+
+// ── SCREEN PARAMETERS ───────────────────────────────────────
+scr_w = 236; // screen outer width (mm)
+scr_h = 144; // screen outer height (mm)
+scr_d = 19; // screen outer depth (mm)
+scr_active_w = 222; // active area width (mm) ← confirm
+scr_active_h = 130; // active area height (mm) ← confirm
+scr_mount_x = 75; // screen rear M2.5 mount pattern X (mm) ← verify
+scr_mount_y = 75; // screen rear M2.5 mount pattern Y (mm) ← verify
+
+// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
+pi_w = 85; // Pi board width (mm)
+pi_h = 56; // Pi board height (mm)
+pi_d = 17; // Pi board depth incl. tallest component (mm)
+pi_mnt_x = 58; // Pi mount hole pattern X (mm)
+pi_mnt_y = 49; // Pi mount hole pattern Y (mm)
+pi_standoff = 5; // standoff height: screen rear → Pi board (mm)
+pi_offset_x = 0; // Pi centre horizontal offset from screen centre (mm)
+pi_offset_y = 5; // Pi centre vertical offset upward from screen centre (mm)
+
+// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
+wall = 2.5; // wall thickness (mm)
+chamfer = 1.5; // external edge chamfer (mm)
+recess = 1.0; // screen recess depth in front bezel (mm)
+gap = 0.3; // bezel ↔ rear cover fit clearance (mm)
+
+// ── VENT PARAMETERS ──────────────────────────────────────────
+vent_w = 3; // vent slot width (mm)
+vent_l = 20; // vent slot length (mm)
+vent_sp = 4; // slot spacing edge-to-edge (mm)
+soc_vent_sz = 30; // SoC direct-vent zone size (mm, square)
+
+// ── CABLE GLAND PARAMETERS ───────────────────────────────────
+gland_count = 2; // number of M16 cable glands
+gland_dia = 16.5; // M16 clearance hole diameter (mm)
+gland_spacing= 40; // centre-to-centre spacing (mm)
+
+// ── PEDESTAL PARAMETERS ──────────────────────────────────────
+// ped_tilt = angle of screen from horizontal (deg).
+// 75° from horizontal = 15° lean-back from vertical (near-upright monitor stance).
+// The foot is a wedge that, when flat on a desk, holds the rear cover at
+// (90 - ped_tilt)° from vertical.
+ped_tilt = 75; // screen angle from horizontal (deg)
+ped_depth = 80; // foot plate depth front-to-back (mm)
+ped_width = 200; // foot plate width (mm)
+ped_thick = 6; // foot plate thickness (mm)
+
+// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
+m3_dia = 3.4; // M3 clearance hole diameter (mm)
+insert_dia = 4.2; // M3 heat-set insert OD (mm)
+insert_h = 6; // heat-set insert depth (mm)
+
+// ── DERIVED DIMENSIONS (do not edit) ─────────────────────────
+rear_d = pi_standoff + pi_d + 10; // rear cavity depth (10 mm cable headroom)
+enc_w = scr_w + 2*wall; // enclosure outer width
+enc_h = scr_h + 2*wall; // enclosure outer height
+enc_d = rear_d + wall; // rear cover total depth
+
+// Pi centre in enclosure coordinates (enclosure origin = rear-cover corner)
+pi_enc_cx = wall + scr_w/2 + pi_offset_x; // = 120.5 with defaults
+pi_enc_cy = wall + scr_h/2 + pi_offset_y; // = 79.5 with defaults
+
+// Z position of Pi board surface (measured from rear of rear cover)
+pi_z = wall + pi_standoff; // = 7.5 with defaults
+
+// Foot wedge geometry
+// foot_drop: how far the far tip drops below Y=0 so the bottom surface
+// becomes horizontal when the unit stands at ped_tilt from horizontal.
+foot_drop = ped_depth * tan(90 - ped_tilt); // ≈ 21.4 mm for ped_tilt=75
+
+$fn = 48;
+
+// ============================================================
+// PRIMITIVES
+// ============================================================
+
+// Chamfered box — chamfer on all 12 edges via hull of 3 axis-aligned cubes
+module cbox(w, h, d, c=chamfer) {
+ hull() {
+ translate([c,c,0]) cube([w-2*c, h-2*c, d ]);
+ translate([0,c,c]) cube([w, h-2*c, d-2*c ]);
+ translate([c,0,c]) cube([w-2*c, h, d-2*c ]);
+ }
+}
+
+// Rounded-end vent slot, length along Y, centred at origin
+module slot(len, w, d) {
+ r = w/2;
+ hull() {
+ translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
+ translate([0, len/2-r, 0]) cylinder(r=r, h=d);
+ }
+}
+
+// Row of n vent slots along X
+module vent_row(n, len, w, spacing, depth) {
+ for(i=[0:n-1])
+ translate([i*(w+spacing), 0, 0])
+ slot(len, w, depth);
+}
+
+// Heat-set insert boss (M3)
+module insert_boss(h=insert_h+4) {
+ difference() {
+ cylinder(d=insert_dia+3, h=h);
+ cylinder(d=insert_dia, h=insert_h);
+ translate([0,0,insert_h]) cylinder(d=m3_dia, h=h);
+ }
+}
+
+// ============================================================
+// PEDESTAL FOOT (integral with rear cover, no supports needed)
+// ============================================================
+// Geometry in model space (rear cover lying on its back, rear face = Z=0):
+// • Foot extends in the -Z direction from Z=0 (behind the rear face)
+// • Top surface is flush with the enclosure bottom at Y=0
+// • Bottom surface is angled: at Z=0 it is ped_thick below Y=0;
+// at Z=-ped_depth it is (ped_thick + foot_drop) below Y=0.
+// • When the unit stands on the desk the angled surface lies flat and the
+// screen tilts back (90-ped_tilt)° from vertical.
+// • Print orientation: rear cover face-down (foot on bed), zero supports.
+module pedestal_foot() {
+ foot_x0 = (enc_w - ped_width) / 2;
+
+ translate([foot_x0, 0, 0]) {
+ // Main wedge plate
+ hull() {
+ translate([0, -ped_thick, 0 ])
+ cube([ped_width, ped_thick, wall ]);
+ translate([0, -(ped_thick+foot_drop), -ped_depth])
+ cube([ped_width, ped_thick+foot_drop, wall ]);
+ }
+ // Left and right stiffening ribs
+ for(bx = [0, ped_width - ped_thick]) {
+ hull() {
+ translate([bx, -ped_thick, 0 ])
+ cube([ped_thick, ped_thick, wall ]);
+ translate([bx, -(ped_thick+foot_drop), -ped_depth])
+ cube([ped_thick, ped_thick+foot_drop, wall ]);
+ // Toe point keeps underside triangular (no saggy bridge)
+ translate([bx, -ped_thick, -ped_depth])
+ cube([ped_thick, ped_thick, wall ]);
+ }
+ }
+ }
+}
+
+// ============================================================
+// FRONT BEZEL
+// ============================================================
+module front_bezel() {
+ difference() {
+ cbox(enc_w, enc_h, wall + recess);
+
+ // Active display window (full cut-through)
+ translate([(enc_w-scr_active_w)/2, (enc_h-scr_active_h)/2, -0.1])
+ cube([scr_active_w, scr_active_h, wall+recess+0.2]);
+
+ // Recess pocket so bezel lip sits 1 mm over screen edge
+ translate([(enc_w-scr_w)/2, (enc_h-scr_h)/2, wall])
+ cube([scr_w, scr_h, recess+0.1]);
+
+ // M3 corner screw holes (4×)
+ for(x = [wall+6, enc_w-wall-6])
+ for(y = [wall+6, enc_h-wall-6])
+ translate([x, y, -0.1]) cylinder(d=m3_dia, h=wall+recess+0.2);
+ }
+}
+
+// ============================================================
+// REAR COVER
+// ============================================================
+module rear_cover() {
+ n_vent = 6;
+ vent_block_w = n_vent*(vent_w+vent_sp) - vent_sp; // total width of vent array
+
+ difference() {
+ union() {
+ cbox(enc_w, enc_h, enc_d);
+ pedestal_foot();
+
+ // M3 insert bosses at 4 corners (inner face)
+ for(x = [wall+6, enc_w-wall-6])
+ for(y = [wall+6, enc_h-wall-6])
+ translate([x, y, enc_d])
+ rotate([180,0,0]) insert_boss();
+ }
+
+ // ── HOLLOW INTERIOR ───────────────────────────────────
+ translate([wall, wall, wall]) cube([scr_w, scr_h, enc_d]);
+
+ // ── LEFT WALL: USB-C power + HDMI ×2 ─────────────────
+ // These are on the Pi's left short-edge (56 mm face), facing X=0.
+ // Cutout Y-centre is set near the Pi's lower half.
+ // Z positions follow port heights above PCB surface.
+ // (Short ribbon extensions needed to reach the wall at X=0.)
+ //
+ // USB-C power
+ translate([-0.1,
+ pi_enc_cy - pi_h/2 + 3,
+ pi_z + 2])
+ cube([wall+0.2, 11, 11]);
+ // HDMI 0
+ translate([-0.1,
+ pi_enc_cy - pi_h/2 + 16,
+ pi_z + 2])
+ cube([wall+0.2, 17, 9]);
+ // HDMI 1
+ translate([-0.1,
+ pi_enc_cy - pi_h/2 + 35,
+ pi_z + 2])
+ cube([wall+0.2, 17, 9]);
+
+ // ── RIGHT WALL: RJ45 + USB-A ×4 ──────────────────────
+ // RJ45 (top of Pi right edge in board orientation)
+ translate([enc_w-wall-0.1,
+ pi_enc_cy + pi_h/2 - 24,
+ pi_z + 1])
+ cube([wall+0.2, 22, 16]);
+ // USB-A ×4 (two stacked pairs, below RJ45 on right edge)
+ translate([enc_w-wall-0.1,
+ pi_enc_cy - pi_h/2 + 2,
+ pi_z + 1])
+ cube([wall+0.2, 50, 15]);
+
+ // ── TOP WALL: GPIO header (40-pin) ────────────────────
+ // GPIO is on the Pi's top long edge (85 mm edge at Y = pi_enc_cy + pi_h/2).
+ // Cutout aligns with the header strip X-extent (51 mm) centred on Pi.
+ // Z-extent: board surface + header height (~11 mm).
+ translate([pi_enc_cx - 26,
+ pi_enc_cy + pi_h/2 - 0.1,
+ pi_z])
+ cube([52, wall+0.2, 11]);
+
+ // ── USB-C TOUCH: screen side edge (left, near screen depth) ───
+ translate([-0.1,
+ enc_h/2 - 6,
+ wall + scr_d - 5])
+ cube([wall+0.2, 12, 8]);
+
+ // ── COOLING VENTS ─────────────────────────────────────
+
+ // Bottom intake slots (6 × 3×20 mm, 4 mm spacing)
+ translate([enc_w/2 - vent_block_w/2,
+ -0.1,
+ wall + 8])
+ rotate([-90, 0, 0])
+ vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // Top exhaust slots
+ translate([enc_w/2 - vent_block_w/2,
+ enc_h - wall + 0.1,
+ wall + 8])
+ rotate([90, 0, 0])
+ vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // SoC direct-vent array on rear panel, centred over Pi SoC
+ translate([pi_enc_cx - soc_vent_sz/2,
+ pi_enc_cy - soc_vent_sz/2,
+ enc_d - wall - 0.1]) {
+ n_soc = floor(soc_vent_sz / (vent_w + vent_sp));
+ for(i = [0:n_soc-1])
+ translate([i*(vent_w+vent_sp),
+ soc_vent_sz/2 - vent_l/2,
+ 0])
+ slot(vent_l, vent_w, wall+0.2);
+ }
+
+ // ── CABLE GLANDS: rear panel face, bottom area ────────
+ // Two M16 glands through the rear face (Z=0 plane).
+ // Positioned below the Pi, above the foot junction.
+ for(i = [0:gland_count-1]) {
+ cx = enc_w/2 + (i - (gland_count-1)/2) * gland_spacing;
+ translate([cx,
+ wall + gland_dia/2 + 4,
+ -0.1])
+ cylinder(d=gland_dia, h=wall+0.2);
+ }
+ }
+}
+
+// ============================================================
+// RENDER — exploded assembly (front bezel floats above rear cover)
+// ============================================================
+color("DarkSlateGray", 0.9)
+ translate([0, 0, enc_d + 10])
+ front_bezel();
+
+color("SlateGray", 0.85)
+ rear_cover();
diff --git a/rpi-case/rpi5_industrial_case_003.scad b/rpi-case/rpi5_industrial_case_003.scad
new file mode 100644
index 0000000..349a9da
--- /dev/null
+++ b/rpi-case/rpi5_industrial_case_003.scad
@@ -0,0 +1,328 @@
+// ============================================================
+// RPi5 Industrial Enclosure — Luckfox DHX-10.1" Touchscreen
+// Version: 003
+// Fixes vs 002:
+// 1. Kickstand completely redesigned — shorter, thinner, clearly
+// attached to rear cover bottom, triangular gussets on each side
+// 2. GPIO top-wall cutout removed (GPIO is fully internal; access
+// requires removing the rear cover, correct for industrial use)
+// 3. Pi cavity depth verified and annotated
+// 4. Bezel two-piece connection: corner bosses on rear cover +
+// matching through-holes on bezel — intentional removable joint
+// ============================================================
+
+// ── SCREEN PARAMETERS ───────────────────────────────────────
+scr_w = 236; // screen outer width (mm)
+scr_h = 144; // screen outer height (mm)
+scr_d = 19; // screen outer depth (mm)
+scr_active_w = 222; // active area width (mm) ← confirm with screen datasheet
+scr_active_h = 130; // active area height (mm) ← confirm with screen datasheet
+scr_mount_x = 75; // screen rear M2.5 hole pattern X (mm) ← verify
+scr_mount_y = 75; // screen rear M2.5 hole pattern Y (mm) ← verify
+
+// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
+pi_w = 85; // Pi board width (mm)
+pi_h = 56; // Pi board height (mm)
+pi_d = 17; // Pi board + tallest component height (mm)
+pi_mnt_x = 58; // Pi mount hole pattern X (mm)
+pi_mnt_y = 49; // Pi mount hole pattern Y (mm)
+pi_standoff = 5; // standoff height: screen rear face → Pi PCB (mm)
+pi_offset_x = 0; // Pi centre X offset from screen centre (mm)
+pi_offset_y = 5; // Pi centre Y offset upward from screen centre (mm)
+
+// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
+wall = 2.5; // wall thickness throughout (mm)
+chamfer = 1.5; // external edge chamfer size (mm)
+recess = 1.0; // screen recess depth in front bezel (mm)
+gap = 0.3; // bezel ↔ rear cover fit clearance (mm)
+
+// ── VENT PARAMETERS ──────────────────────────────────────────
+vent_w = 3; // vent slot width (mm)
+vent_l = 20; // vent slot length (mm)
+vent_sp = 4; // slot gap edge-to-edge (mm)
+soc_vent_sz = 30; // SoC direct-vent zone size (mm, square)
+
+// ── CABLE GLAND PARAMETERS ───────────────────────────────────
+gland_count = 2; // number of M16 cable glands
+gland_dia = 16.5; // M16 clearance hole diameter (mm)
+gland_spacing= 40; // centre-to-centre spacing (mm)
+
+// ── KICKSTAND PARAMETERS ─────────────────────────────────────
+// The kickstand is a flat plate + two triangular gussets, integral
+// with the rear cover bottom. When the unit stands on the kickstand
+// the plate lies flat on the desk and the screen tilts back
+// (90 - ks_tilt) degrees from vertical.
+ks_tilt = 75; // screen angle from horizontal when standing (deg)
+ // 75° from horiz = 15° lean-back from vertical
+ks_depth = 55; // plate reach behind rear face (mm) — shorter than 002
+ks_width = 180; // plate span across enclosure width (mm)
+ks_thick = 5; // plate thickness (mm)
+ks_gusset_h = 30; // gusset height up the rear cover face (mm)
+
+// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
+m3_dia = 3.4; // M3 clearance hole (mm)
+insert_dia = 4.2; // M3 heat-set insert OD (mm)
+insert_h = 6; // heat-set insert depth (mm)
+boss_od = insert_dia + 3.5; // insert boss outer diameter (mm)
+corner_inset = wall + boss_od/2 + 1; // corner boss/hole X and Y inset (mm)
+
+// ── DERIVED DIMENSIONS ───────────────────────────────────────
+//
+// Rear cavity depth check:
+// pi_standoff (5) + pi_d (17) + cable headroom (10) = 32 mm → rear_d
+// rear_d is the full depth of the rear cover cavity.
+// The screen body (scr_d=19 mm) is NOT included in rear_d;
+// the rear cover encloses only the space BEHIND the screen rear face.
+//
+rear_d = pi_standoff + pi_d + 10; // = 32 mm, Pi fits with 10 mm to spare
+enc_w = scr_w + 2*wall; // enclosure outer width (241 mm)
+enc_h = scr_h + 2*wall; // enclosure outer height (149 mm)
+enc_d = rear_d + wall; // rear cover total depth ( 34.5 mm)
+
+// Pi centre in enclosure XY coordinates (wall-offset from screen centre)
+pi_enc_cx = wall + scr_w/2 + pi_offset_x; // 120.5 mm with defaults
+pi_enc_cy = wall + scr_h/2 + pi_offset_y; // 79.5 mm with defaults
+
+// Z of Pi PCB surface, measured from rear cover rear face
+pi_z = wall + pi_standoff; // 7.5 mm with defaults
+
+// Kickstand tip drop: how far below Y=0 the far edge must sit so the
+// bottom surface is horizontal when the unit tilts to ks_tilt from horizontal
+ks_drop = ks_depth * tan(90 - ks_tilt); // ≈ 14.7 mm for ks_tilt=75
+
+$fn = 48;
+
+// ============================================================
+// PRIMITIVES
+// ============================================================
+
+// Chamfered rectangular box (all 12 edges, chamfer = c)
+module cbox(w, h, d, c=chamfer) {
+ hull() {
+ translate([c, c, 0]) cube([w-2*c, h-2*c, d ]);
+ translate([0, c, c]) cube([w, h-2*c, d-2*c ]);
+ translate([c, 0, c]) cube([w-2*c, h, d-2*c ]);
+ }
+}
+
+// Rounded-end vent slot: length along Y, width w, extrudes in +Z by d
+module slot(len, w, d) {
+ r = w/2;
+ hull() {
+ translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
+ translate([0, len/2-r, 0]) cylinder(r=r, h=d);
+ }
+}
+
+// Row of n vent slots stepping in X
+module vent_row(n, len, w, spacing, depth) {
+ for(i = [0:n-1])
+ translate([i*(w+spacing), 0, 0])
+ slot(len, w, depth);
+}
+
+// M3 heat-set insert boss (sits proud from an inner face)
+module insert_boss(total_h = insert_h + 4) {
+ difference() {
+ cylinder(d=boss_od, h=total_h);
+ cylinder(d=insert_dia, h=insert_h);
+ translate([0,0,insert_h]) cylinder(d=m3_dia, h=total_h);
+ }
+}
+
+// ============================================================
+// KICKSTAND
+// ============================================================
+// Geometry (all in rear-cover model space, rear face = Z=0 plane):
+//
+// Side view (Y-Z plane):
+//
+// Y=ks_gusset_h ─┐
+// │ ← gusset strip on rear face
+// Y=0 ───────────┼──────────────────────────────────── rear cover bottom
+// │╲ ← gusset triangle
+// plate ──┼─╲──────────────────────────────────────────
+// (ks_thick)│ ╲ (sloping, thicker at tip)
+// ╲ ╲___________________________________
+// Y=-(ks_thick+ks_drop) Z=-ks_depth
+//
+// The plate and gussets are extruded across ks_width in X.
+// The gussets (hull triangles) brace the plate against the rear face,
+// preventing the kickstand from snapping off at the root.
+//
+module kickstand() {
+ ks_x0 = (enc_w - ks_width) / 2;
+
+ translate([ks_x0, 0, 0]) {
+
+ // ── Main plate ────────────────────────────────────────
+ // Wedge: root at Z=0 is ks_thick tall;
+ // tip at Z=-ks_depth is (ks_thick+ks_drop) tall.
+ // Top surface flush with enclosure bottom (Y=0).
+ hull() {
+ // Root strip — along rear face
+ translate([0, -ks_thick, 0])
+ cube([ks_width, ks_thick, wall]);
+ // Tip strip — at full reach, thicker to keep plate horizontal
+ translate([0, -(ks_thick + ks_drop), -ks_depth])
+ cube([ks_width, ks_thick + ks_drop, wall]);
+ }
+
+ // ── Triangular gussets (left + right ends) ────────────
+ // Each gusset is a hull of three patches:
+ // A – vertical strip up the rear face (height = ks_gusset_h)
+ // B – small square at plate root (Y=-ks_thick, Z=0)
+ // C – small square at plate tip (Y=-(ks_thick+ks_drop), Z=-ks_depth)
+ for(bx = [0, ks_width - ks_thick]) {
+ hull() {
+ // A: attachment strip going up the rear face
+ translate([bx, 0, -wall])
+ cube([ks_thick, ks_gusset_h, wall]);
+ // B: plate root corner
+ translate([bx, -ks_thick, -wall])
+ cube([ks_thick, ks_thick, wall]);
+ // C: plate tip corner
+ translate([bx, -(ks_thick + ks_drop), -ks_depth])
+ cube([ks_thick, ks_thick, wall]);
+ }
+ }
+ }
+}
+
+// ============================================================
+// FRONT BEZEL
+// ============================================================
+// Two-piece design: bezel + rear cover join with M3 screws through
+// the bezel corners into heat-set inserts in the rear cover bosses.
+// The bezel is intentionally removable for Pi access.
+module front_bezel() {
+ difference() {
+ cbox(enc_w, enc_h, wall + recess);
+
+ // Active display window (full depth cut)
+ translate([(enc_w - scr_active_w)/2,
+ (enc_h - scr_active_h)/2,
+ -0.1])
+ cube([scr_active_w, scr_active_h, wall+recess+0.2]);
+
+ // 1 mm recess pocket — bezel lip grips screen edge
+ translate([(enc_w - scr_w)/2,
+ (enc_h - scr_h)/2,
+ wall])
+ cube([scr_w, scr_h, recess+0.1]);
+
+ // M3 screw clearance holes at 4 corners
+ for(x = [corner_inset, enc_w - corner_inset])
+ for(y = [corner_inset, enc_h - corner_inset])
+ translate([x, y, -0.1])
+ cylinder(d=m3_dia, h=wall+recess+0.2);
+ }
+}
+
+// ============================================================
+// REAR COVER
+// ============================================================
+module rear_cover() {
+ n_vent = 6;
+ vent_block_w = n_vent*(vent_w+vent_sp) - vent_sp;
+
+ difference() {
+ union() {
+ // Main body
+ cbox(enc_w, enc_h, enc_d);
+
+ // Kickstand (integral, no supports needed — prints face-down)
+ kickstand();
+
+ // Insert bosses at 4 corners (inner rear face, flush with enc_d)
+ for(x = [corner_inset, enc_w - corner_inset])
+ for(y = [corner_inset, enc_h - corner_inset])
+ translate([x, y, enc_d])
+ rotate([180, 0, 0])
+ insert_boss();
+ }
+
+ // ── HOLLOW INTERIOR ───────────────────────────────────
+ // Cavity = full screen footprint, from wall to enc_d (open toward bezel)
+ translate([wall, wall, wall])
+ cube([scr_w, scr_h, enc_d]);
+
+ // ── PORT CUTOUTS ──────────────────────────────────────
+ // NOTE: The Pi's port edges are internal (Pi centred on screen).
+ // Cutouts in the enclosure walls are reference openings for
+ // short cable extensions routed to the wall. Adjust Y/Z offsets
+ // to match your exact cable routing once screen mount is verified.
+
+ // LEFT WALL — USB-C power + HDMI ×2
+ // Approximate Y positions relative to Pi bottom edge
+ pi_bot = pi_enc_cy - pi_h/2;
+ // USB-C power
+ translate([-0.1, pi_bot + 3, pi_z + 2]) cube([wall+0.2, 11, 11]);
+ // HDMI 0
+ translate([-0.1, pi_bot + 16, pi_z + 2]) cube([wall+0.2, 17, 9]);
+ // HDMI 1
+ translate([-0.1, pi_bot + 35, pi_z + 2]) cube([wall+0.2, 17, 9]);
+
+ // RIGHT WALL — RJ45 + USB-A ×4
+ pi_top = pi_enc_cy + pi_h/2;
+ // RJ45
+ translate([enc_w-wall-0.1, pi_top - 24, pi_z + 1])
+ cube([wall+0.2, 22, 16]);
+ // USB-A ×4 (two stacked pairs)
+ translate([enc_w-wall-0.1, pi_bot + 2, pi_z + 1])
+ cube([wall+0.2, 50, 15]);
+
+ // BOTTOM WALL — USB-C touch connector on screen side edge
+ // (screen's own USB-C touch port, not Pi — sits at screen depth)
+ translate([-0.1,
+ enc_h/2 - 6,
+ wall + scr_d - 5])
+ cube([wall+0.2, 12, 8]);
+
+ // ── COOLING VENTS ─────────────────────────────────────
+
+ // Bottom intake — 6 slots through bottom wall
+ translate([enc_w/2 - vent_block_w/2, -0.1, wall + 8])
+ rotate([-90, 0, 0])
+ vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // Top exhaust — 6 slots through top wall
+ translate([enc_w/2 - vent_block_w/2,
+ enc_h - wall + 0.1,
+ wall + 8])
+ rotate([90, 0, 0])
+ vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // SoC direct-vent — slot array in rear panel centred over Pi SoC
+ translate([pi_enc_cx - soc_vent_sz/2,
+ pi_enc_cy - soc_vent_sz/2,
+ enc_d - wall - 0.1]) {
+ n_soc = floor(soc_vent_sz / (vent_w + vent_sp));
+ for(i = [0:n_soc-1])
+ translate([i*(vent_w+vent_sp),
+ soc_vent_sz/2 - vent_l/2,
+ 0])
+ slot(vent_l, vent_w, wall+0.2);
+ }
+
+ // ── CABLE GLANDS — rear panel, bottom area ────────────
+ // Two M16 glands through the rear face (Z=0 plane).
+ // Positioned below Pi, above kickstand root.
+ for(i = [0:gland_count-1]) {
+ cx = enc_w/2 + (i - (gland_count-1)/2) * gland_spacing;
+ translate([cx, wall + gland_dia/2 + 4, -0.1])
+ cylinder(d=gland_dia, h=wall+0.2);
+ }
+ }
+}
+
+// ============================================================
+// SCENE — exploded assembly view
+// Front bezel floats above rear cover to show the joint
+// ============================================================
+color("DarkSlateGray", 0.9)
+ translate([0, 0, enc_d + 12])
+ front_bezel();
+
+color("SlateGray", 0.85)
+ rear_cover();
diff --git a/rpi-case/rpi5_industrial_case_004.scad b/rpi-case/rpi5_industrial_case_004.scad
new file mode 100644
index 0000000..9a7a33a
--- /dev/null
+++ b/rpi-case/rpi5_industrial_case_004.scad
@@ -0,0 +1,338 @@
+// ============================================================
+// RPi5 Industrial Enclosure — Luckfox DHX-10.1" Touchscreen
+// Version: 004
+// Changes vs 003:
+// 1. KICKSTAND is now a separate, removable piece (own module + color)
+// - Full enc_w width (no shorter ks_width param)
+// - 3 prongs on rear edge that slide into slots in case bottom wall
+// - Tapered prong tips for easy insertion
+// 2. Prong slots added to rear cover bottom wall
+// 3. USB-C touch cutout on left wall REMOVED (per user request)
+// 4. Wall thickness increased 2.5 → 4 mm for rigidity
+// 5. Bezel screw holes now countersunk + clearly sized
+// 6. Insert bosses on rear cover made taller and more prominent
+// so the two-piece (bezel + rear cover) joint is obvious in renders
+// 7. Render shows THREE separate bodies: bezel / rear cover / kickstand
+// ============================================================
+
+// ── SCREEN PARAMETERS ───────────────────────────────────────
+scr_w = 236; // screen outer width (mm)
+scr_h = 144; // screen outer height (mm)
+scr_d = 19; // screen outer depth (mm)
+scr_active_w = 222; // active display area width (mm) ← confirm
+scr_active_h = 130; // active display area height (mm) ← confirm
+scr_mount_x = 75; // screen rear M2.5 hole pattern X (mm) ← verify
+scr_mount_y = 75; // screen rear M2.5 hole pattern Y (mm) ← verify
+
+// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
+pi_w = 85; // Pi board width (mm)
+pi_h = 56; // Pi board height (mm)
+pi_d = 17; // Pi board + tallest component (mm)
+pi_mnt_x = 58; // Pi mount hole pattern X (mm)
+pi_mnt_y = 49; // Pi mount hole pattern Y (mm)
+pi_standoff = 5; // standoff height screen-rear → Pi PCB (mm)
+pi_offset_x = 0; // Pi centre X offset from screen centre (mm)
+pi_offset_y = 5; // Pi centre Y offset upward from screen centre (mm)
+
+// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
+wall = 4.0; // wall thickness — increased for rigidity (mm)
+chamfer = 1.5; // external edge chamfer (mm)
+recess = 1.0; // screen recess in front bezel (mm)
+gap = 0.3; // bezel ↔ rear cover fit clearance (mm)
+
+// ── VENT PARAMETERS ──────────────────────────────────────────
+vent_w = 3; // vent slot width (mm)
+vent_l = 20; // vent slot length (mm)
+vent_sp = 4; // slot gap edge-to-edge (mm)
+soc_vent_sz = 30; // SoC direct-vent area size (mm)
+
+// ── CABLE GLAND PARAMETERS ───────────────────────────────────
+gland_count = 2; // number of M16 cable glands
+gland_dia = 16.5; // M16 clearance hole diameter (mm)
+gland_spacing= 40; // gland centre-to-centre (mm)
+
+// ── KICKSTAND PARAMETERS ─────────────────────────────────────
+// Separate removable piece — slides onto case bottom via 3 prongs.
+// Wedge shape: thin at rear (prong end), thick at front desk-contact end.
+// When flat on desk the screen sits at ks_tilt degrees from horizontal.
+ks_tilt = 75; // screen angle from horizontal when standing (deg)
+ // 75° from horiz ≈ 15° lean-back from vertical
+ks_depth = 60; // plate reach in -Z from case rear face (mm)
+ks_thick = 5; // plate thickness at thin (rear) end (mm)
+
+// Prong dimensions — 3 prongs slide into 3 slots in case bottom wall
+ks_prong_n = 3; // number of prongs
+ks_prong_w = 12; // prong width (X, mm)
+ks_prong_h = 14; // prong insertion height into cavity (mm)
+ks_prong_t = 5; // prong depth (Z, mm) — same as slot depth
+ks_prong_clr = 0.25; // diametral clearance for fit (mm)
+
+// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
+m3_dia = 3.4; // M3 clearance hole (mm)
+m3_cs_dia = 6.5; // M3 countersink diameter (mm)
+m3_cs_depth = 3.0; // countersink depth (mm)
+insert_dia = 4.2; // M3 heat-set insert OD (mm)
+insert_h = 6; // heat-set insert depth (mm)
+boss_od = 10; // insert boss outer diameter — prominent (mm)
+boss_h = insert_h + 5; // boss total height from inner face (mm)
+
+// ── DERIVED DIMENSIONS ───────────────────────────────────────
+rear_d = pi_standoff + pi_d + 10; // cavity depth = 32 mm
+enc_w = scr_w + 2*wall; // outer width = 244 mm
+enc_h = scr_h + 2*wall; // outer height = 152 mm
+enc_d = rear_d + wall; // rear cover depth = 36 mm
+
+// Corner inset for boss/screw centres (keeps them inside the wall)
+corner_inset = wall + boss_od/2 + 0.5; // ≈ 9.5 mm
+
+// Pi centre in enclosure coordinates
+pi_enc_cx = wall + scr_w/2 + pi_offset_x; // 122 mm
+pi_enc_cy = wall + scr_h/2 + pi_offset_y; // 81 mm
+pi_z = wall + pi_standoff; // 9 mm (Pi PCB Z from rear face)
+
+// Kickstand geometry
+ks_drop = ks_depth * tan(90 - ks_tilt); // ≈ 16 mm tip drop
+
+// Prong slot Z position — prongs sit right at the case rear face
+ks_prong_z = 0; // prong rear face flush with case Z=0
+
+$fn = 48;
+
+// ============================================================
+// PRIMITIVES
+// ============================================================
+
+module cbox(w, h, d, c=chamfer) {
+ hull() {
+ translate([c, c, 0]) cube([w-2*c, h-2*c, d ]);
+ translate([0, c, c]) cube([w, h-2*c, d-2*c]);
+ translate([c, 0, c]) cube([w-2*c, h, d-2*c]);
+ }
+}
+
+module slot(len, w, d) {
+ r = w/2;
+ hull() {
+ translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
+ translate([0, len/2-r, 0]) cylinder(r=r, h=d);
+ }
+}
+
+module vent_row(n, len, w, spacing, depth) {
+ for(i = [0:n-1])
+ translate([i*(w+spacing), 0, 0])
+ slot(len, w, depth);
+}
+
+// Heat-set insert boss
+module insert_boss() {
+ difference() {
+ cylinder(d=boss_od, h=boss_h);
+ cylinder(d=insert_dia, h=insert_h);
+ translate([0,0,insert_h]) cylinder(d=m3_dia, h=boss_h);
+ }
+}
+
+// Countersunk M3 hole (for bezel face)
+module m3_countersunk(depth) {
+ cylinder(d=m3_dia, h=depth+0.1);
+ translate([0, 0, depth - m3_cs_depth])
+ cylinder(d1=m3_dia, d2=m3_cs_dia, h=m3_cs_depth+0.1);
+}
+
+// Prong slot — cut from bottom wall, prong inserts up into cavity
+module prong_slot() {
+ translate([-ks_prong_w/2 - ks_prong_clr,
+ -0.1,
+ ks_prong_z - ks_prong_clr])
+ cube([ks_prong_w + 2*ks_prong_clr,
+ ks_prong_h + wall + 0.2,
+ ks_prong_t + 2*ks_prong_clr]);
+}
+
+// ============================================================
+// KICKSTAND (separate removable piece)
+// ============================================================
+//
+// Side view (Y-Z plane, unit standing on kickstand):
+//
+// Y=0 (case bottom) ─────────────────────────────
+// │↑↑↑ prongs (insert into case)
+// Y=-ks_thick ─────┼─────────────────────────────────┐
+// \ wedge plate (bottom surface │
+// \ angled so it lies flat when │
+// Y=-(ks_thick+ks_drop)\ screen tilts to ks_tilt) │
+// └──────────────────────────────┘
+// Z=0 Z=-ks_depth
+//
+module kickstand() {
+ ks_front_h = ks_thick + ks_drop; // total height at the front (desk-contact) edge
+
+ // Main wedge plate
+ hull() {
+ // Rear (thin) edge, at Z=0 — where prongs attach
+ translate([0, -ks_thick, 0])
+ cube([enc_w, ks_thick, wall]);
+ // Front (thick) edge, at Z=-ks_depth — rests on desk
+ translate([0, -ks_front_h, -ks_depth])
+ cube([enc_w, ks_front_h, wall]);
+ }
+
+ // Three prongs — evenly spaced, rise from Y=0 into case cavity
+ for(i = [0:ks_prong_n-1]) {
+ px = enc_w * (i+1) / (ks_prong_n+1);
+ translate([px - ks_prong_w/2, 0, ks_prong_z]) {
+ // Tapered tip so prong slides in easily
+ hull() {
+ // Base: full-width prong up to the tapered section
+ cube([ks_prong_w,
+ ks_prong_h - 2,
+ ks_prong_t]);
+ // Tip: narrowed 1 mm per side for 45° insertion chamfer
+ translate([1, ks_prong_h - 2, 0])
+ cube([ks_prong_w - 2, 2, ks_prong_t]);
+ }
+ }
+ }
+}
+
+// ============================================================
+// FRONT BEZEL
+// ============================================================
+// Removable front frame — held to rear cover by 4× M3 screws
+// through countersunk holes at each corner, threading into
+// heat-set inserts pressed into the rear cover's corner bosses.
+module front_bezel() {
+ difference() {
+ cbox(enc_w, enc_h, wall + recess);
+
+ // Active display window
+ translate([(enc_w - scr_active_w)/2,
+ (enc_h - scr_active_h)/2,
+ -0.1])
+ cube([scr_active_w, scr_active_h, wall+recess+0.2]);
+
+ // 1 mm recess pocket — bezel lip grips screen edge
+ translate([(enc_w - scr_w)/2,
+ (enc_h - scr_h)/2,
+ wall])
+ cube([scr_w, scr_h, recess+0.1]);
+
+ // 4× M3 countersunk screw holes at corners
+ for(x = [corner_inset, enc_w - corner_inset])
+ for(y = [corner_inset, enc_h - corner_inset])
+ translate([x, y, 0])
+ m3_countersunk(wall + recess);
+ }
+}
+
+// ============================================================
+// REAR COVER
+// ============================================================
+module rear_cover() {
+ n_vent = 6;
+ vent_block_w = n_vent*(vent_w+vent_sp) - vent_sp;
+
+ difference() {
+ union() {
+ cbox(enc_w, enc_h, enc_d);
+
+ // ── Insert bosses: 4 corners, proud on inner rear face ──
+ // These are clearly visible tall cylinders that receive the
+ // heat-set inserts; M3 screws from the bezel thread into them.
+ for(x = [corner_inset, enc_w - corner_inset])
+ for(y = [corner_inset, enc_h - corner_inset])
+ translate([x, y, enc_d])
+ rotate([180, 0, 0])
+ insert_boss();
+ }
+
+ // ── HOLLOW INTERIOR ───────────────────────────────────
+ translate([wall, wall, wall])
+ cube([scr_w, scr_h, enc_d]);
+
+ // ── PORT CUTOUTS ──────────────────────────────────────
+
+ // LEFT WALL — USB-C power + HDMI ×2 (Pi left short-edge ports)
+ pi_bot = pi_enc_cy - pi_h/2;
+ // USB-C power
+ translate([-0.1, pi_bot + 3, pi_z + 2]) cube([wall+0.2, 11, 11]);
+ // HDMI 0
+ translate([-0.1, pi_bot + 16, pi_z + 2]) cube([wall+0.2, 17, 9]);
+ // HDMI 1
+ translate([-0.1, pi_bot + 35, pi_z + 2]) cube([wall+0.2, 17, 9]);
+
+ // RIGHT WALL — RJ45 + USB-A ×4 (Pi right short-edge ports)
+ pi_top = pi_enc_cy + pi_h/2;
+ // RJ45
+ translate([enc_w-wall-0.1, pi_top - 24, pi_z + 1])
+ cube([wall+0.2, 22, 16]);
+ // USB-A ×4
+ translate([enc_w-wall-0.1, pi_bot + 2, pi_z + 1])
+ cube([wall+0.2, 50, 15]);
+
+ // NOTE: USB-C touch cutout (screen side) REMOVED per v004 request.
+ // GPIO header is internal — access by removing bezel + rear cover.
+
+ // ── COOLING VENTS ─────────────────────────────────────
+
+ // Bottom intake slots (through bottom wall)
+ translate([enc_w/2 - vent_block_w/2, -0.1, wall + 8])
+ rotate([-90, 0, 0])
+ vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // Top exhaust slots (through top wall)
+ translate([enc_w/2 - vent_block_w/2,
+ enc_h - wall + 0.1,
+ wall + 8])
+ rotate([90, 0, 0])
+ vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // SoC direct-vent on rear panel
+ translate([pi_enc_cx - soc_vent_sz/2,
+ pi_enc_cy - soc_vent_sz/2,
+ enc_d - wall - 0.1]) {
+ n_soc = floor(soc_vent_sz / (vent_w + vent_sp));
+ for(i = [0:n_soc-1])
+ translate([i*(vent_w+vent_sp),
+ soc_vent_sz/2 - vent_l/2,
+ 0])
+ slot(vent_l, vent_w, wall+0.2);
+ }
+
+ // ── CABLE GLANDS — rear panel, bottom area ────────────
+ for(i = [0:gland_count-1]) {
+ cx = enc_w/2 + (i - (gland_count-1)/2) * gland_spacing;
+ translate([cx, wall + gland_dia/2 + 4, -0.1])
+ cylinder(d=gland_dia, h=wall+0.2);
+ }
+
+ // ── KICKSTAND PRONG SLOTS — bottom wall ───────────────
+ // 3 slots matching kickstand prong positions and sizes.
+ // Slots pass through the bottom wall into the cavity so prongs
+ // engage wall + (ks_prong_h - wall) mm inside the cavity.
+ for(i = [0:ks_prong_n-1]) {
+ px = enc_w * (i+1) / (ks_prong_n+1);
+ translate([px, 0, ks_prong_z])
+ prong_slot();
+ }
+ }
+}
+
+// ============================================================
+// SCENE — three separate bodies, exploded for clarity
+// ============================================================
+
+// Front bezel — exploded up, face toward viewer
+color("DarkSlateGray", 0.92)
+ translate([0, 0, enc_d + 14])
+ front_bezel();
+
+// Rear cover — at origin
+color("SlateGray", 0.88)
+ rear_cover();
+
+// Kickstand — exploded below, separate piece
+color("DimGray", 0.85)
+ translate([0, -(ks_thick + ks_drop + 20), 0])
+ kickstand();
diff --git a/rpi-case/rpi5_industrial_case_005.scad b/rpi-case/rpi5_industrial_case_005.scad
new file mode 100644
index 0000000..917c9f9
--- /dev/null
+++ b/rpi-case/rpi5_industrial_case_005.scad
@@ -0,0 +1,354 @@
+// ============================================================
+// RPi5 Industrial Enclosure — 7" Capacitive Touchscreen
+// Version: 005
+//
+// ASSEMBLY LOGIC (read before printing):
+//
+// THREE separate printed parts:
+//
+// 1. REAR COVER — the main box. Open face points toward screen.
+// Four corner TOWERS rise from the open front face; each tower
+// has a self-tapping M3 pilot hole that opens toward the screen.
+// The kickstand prong columns rise from the inner bottom face.
+//
+// 2. FRONT BEZEL — the display frame. Four countersunk M3 holes at
+// the corners align with the rear cover's tower holes.
+// Assembly: lay screen face-down, place rear cover over it,
+// lay bezel over the front, drive 4× M3×30 self-tapping screws
+// from the bezel face through into the corner towers.
+//
+// 3. KICKSTAND — separate wedge plate. Slide its 3 prongs upward
+// into the 3 slots in the case bottom wall. The prong guide
+// columns inside the case prevent the prongs from falling into
+// the main cavity and give a solid 14 mm engagement.
+//
+// Changes vs 004:
+// - Screen resized to 164.9 × 124.27 mm (7" 1024×600 capacitive)
+// - Corner towers replace insert bosses: visible M3 holes on the
+// OPEN front face of the rear cover — no hidden geometry
+// - Prong guide columns (nested-difference CSG) give the slots a
+// closed ceiling so the kickstand prongs cannot fall through
+// - Left-wall USB-C touch cutout permanently removed
+// - Wall 4 mm retained
+// ============================================================
+
+// ── SCREEN PARAMETERS ───────────────────────────────────────
+scr_w = 164.9; // screen outer width (mm)
+scr_h = 124.27; // screen outer height (mm)
+scr_d = 12; // screen body depth (mm) ← confirm with calipers
+scr_active_w = 154; // active display width (mm) ← confirm
+scr_active_h = 90; // active display height (mm) ← confirm
+scr_mount_x = 75; // rear M2.5 hole pattern X (mm) ← verify
+scr_mount_y = 75; // rear M2.5 hole pattern Y (mm) ← verify
+
+// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
+pi_w = 85; // Pi board width (mm)
+pi_h = 56; // Pi board height (mm)
+pi_d = 17; // Pi board + tallest component (mm)
+pi_mnt_x = 58; // Pi mount hole pattern X (mm)
+pi_mnt_y = 49; // Pi mount hole pattern Y (mm)
+pi_standoff = 5; // standoff: screen rear → Pi PCB (mm)
+pi_offset_x = 0; // Pi centre X offset from screen centre (mm)
+pi_offset_y = 0; // Pi centre Y offset from screen centre (mm)
+
+// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
+wall = 4.0; // wall thickness (mm)
+chamfer = 1.5; // external edge chamfer (mm)
+recess = 1.0; // screen recess depth in front bezel (mm)
+
+// ── VENT PARAMETERS ──────────────────────────────────────────
+vent_w = 3; // slot width (mm)
+vent_l = 18; // slot length (mm)
+vent_sp = 4; // gap edge-to-edge (mm)
+soc_vent_sz = 28; // SoC vent zone (mm, square)
+
+// ── CABLE GLAND PARAMETERS ───────────────────────────────────
+gland_count = 2; // number of M16 cable glands
+gland_dia = 16.5; // M16 clearance hole diameter (mm)
+gland_spacing= 36; // gland centre-to-centre (mm)
+
+// ── KICKSTAND PARAMETERS ─────────────────────────────────────
+ks_tilt = 75; // screen angle from horizontal when standing (deg)
+ks_depth = 60; // plate reach behind rear face (mm)
+ks_thick = 5; // plate thickness at thin (prong) end (mm)
+ks_prong_n = 3; // number of prongs
+ks_prong_w = 12; // prong width in X (mm)
+ks_prong_h = 14; // prong engagement height (mm) — guided inside column
+ks_prong_t = 5; // prong thickness in Z (mm)
+ks_prong_clr = 0.25; // clearance per side for slide fit (mm)
+
+// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
+m3_dia = 3.4; // M3 clearance hole (mm)
+m3_pilot = 2.5; // M3 self-tapping pilot hole (mm)
+m3_cs_dia = 6.5; // M3 countersink OD (mm)
+m3_cs_depth = 3.5; // countersink depth (mm)
+// Corner tower — a full-depth solid pillar at each inner corner.
+// The M3 pilot hole is drilled from the open front face of the rear cover.
+tower_w = 10; // tower footprint width and depth (mm)
+tower_hole_d = 12; // M3 pilot hole depth from front face (mm)
+
+// ── DERIVED DIMENSIONS ───────────────────────────────────────
+rear_d = pi_standoff + pi_d + 10; // rear cavity depth = 32 mm
+enc_w = scr_w + 2*wall; // outer width = 172.9 mm
+enc_h = scr_h + 2*wall; // outer height = 132.27 mm
+enc_d = rear_d + wall; // rear cover depth = 36 mm
+
+// Pi centre in enclosure coordinates
+pi_enc_cx = wall + scr_w/2 + pi_offset_x;
+pi_enc_cy = wall + scr_h/2 + pi_offset_y;
+pi_z = wall + pi_standoff; // Pi PCB Z from rear face
+
+// Corner tower position — inset so tower is entirely within the wall zone
+// (tower must NOT overlap the screen footprint X=wall..wall+scr_w)
+tower_cx = wall/2; // tower centre offset from outer edge
+// Four tower centre positions
+tower_xs = [tower_cx, enc_w - tower_cx];
+tower_ys = [tower_cx, enc_h - tower_cx];
+
+// Kickstand wedge geometry
+ks_drop = ks_depth * tan(90 - ks_tilt); // tip drop ≈ 16 mm
+
+// Prong column Z extent (guide column behind and into cavity)
+col_z_size = ks_prong_t + 2*wall; // = 13 mm
+col_y_size = ks_prong_h; // = 14 mm (above inner bottom face)
+col_x_size = ks_prong_w + wall; // = 16 mm (centred on prong)
+
+$fn = 48;
+
+// ============================================================
+// PRIMITIVES
+// ============================================================
+
+module cbox(w, h, d, c=chamfer) {
+ hull() {
+ translate([c, c, 0]) cube([w-2*c, h-2*c, d ]);
+ translate([0, c, c]) cube([w, h-2*c, d-2*c ]);
+ translate([c, 0, c]) cube([w-2*c, h, d-2*c ]);
+ }
+}
+
+module slot(len, w, d) {
+ r = w/2;
+ hull() {
+ translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
+ translate([0, len/2-r, 0]) cylinder(r=r, h=d);
+ }
+}
+
+module vent_row(n, len, w, spacing, depth) {
+ for(i = [0:n-1])
+ translate([i*(w+spacing), 0, 0])
+ slot(len, w, depth);
+}
+
+// Countersunk M3 through-hole for bezel face
+module m3_countersunk(total_depth) {
+ cylinder(d=m3_dia, h=total_depth+0.1);
+ cylinder(d1=m3_cs_dia, d2=m3_dia, h=m3_cs_depth+0.1);
+}
+
+// Prong slot cutter — used in both the rear cover and the guide column
+// Origin at the prong centre-X, outer bottom face (Y=0), prong-Z start
+module prong_slot_cut() {
+ translate([-ks_prong_w/2 - ks_prong_clr,
+ -0.1,
+ -ks_prong_clr])
+ cube([ks_prong_w + 2*ks_prong_clr,
+ wall + ks_prong_h + 0.2,
+ ks_prong_t + 2*ks_prong_clr]);
+}
+
+// ============================================================
+// KICKSTAND (separate removable piece, print separately)
+// ============================================================
+module kickstand() {
+ ks_front_h = ks_thick + ks_drop; // height at the thick/front end
+
+ // Wedge plate — thin at prong end (Z=0), thick at desk end (Z=-ks_depth)
+ hull() {
+ translate([0, -ks_thick, 0])
+ cube([enc_w, ks_thick, wall]);
+ translate([0, -ks_front_h, -ks_depth])
+ cube([enc_w, ks_front_h, wall]);
+ }
+
+ // Three tapered prongs on the top edge (rear/thin end)
+ for(i = [0:ks_prong_n-1]) {
+ px = enc_w * (i+1) / (ks_prong_n+1);
+ translate([px - ks_prong_w/2, 0, 0]) {
+ // Body of prong
+ cube([ks_prong_w, ks_prong_h - 2, ks_prong_t]);
+ // Tapered tip (last 2 mm, narrowed 1 mm per side)
+ translate([0, ks_prong_h - 2, 0])
+ hull() {
+ cube([ks_prong_w, 0.01, ks_prong_t]);
+ translate([1, 2, 0])
+ cube([ks_prong_w - 2, 0.01, ks_prong_t]);
+ }
+ }
+ }
+}
+
+// ============================================================
+// FRONT BEZEL (removable — 4× M3 countersunk screws)
+// ============================================================
+module front_bezel() {
+ difference() {
+ cbox(enc_w, enc_h, wall + recess);
+
+ // Display window
+ translate([(enc_w - scr_active_w)/2,
+ (enc_h - scr_active_h)/2,
+ -0.1])
+ cube([scr_active_w, scr_active_h, wall+recess+0.2]);
+
+ // 1 mm recess pocket — bezel lip grips screen edge
+ translate([(enc_w - scr_w)/2,
+ (enc_h - scr_h)/2,
+ wall])
+ cube([scr_w, scr_h, recess+0.1]);
+
+ // 4× M3 countersunk screw holes, aligned to corner towers
+ for(x = tower_xs) for(y = tower_ys)
+ translate([x, y, 0])
+ m3_countersunk(wall + recess);
+ }
+}
+
+// ============================================================
+// REAR COVER
+// ============================================================
+//
+// CORNER TOWER DESIGN — replaces hidden insert bosses:
+// Each corner has a solid square tower (tower_w × tower_w) that
+// runs the FULL DEPTH of the cavity (from inner rear face to the
+// open front at Z=enc_d). An M3 pilot hole enters from the front
+// face (Z=enc_d) and goes tower_hole_d into the tower body.
+// Because the tower reaches the front opening, the holes are
+// plainly visible from the front of the assembled unit — no
+// hidden geometry.
+//
+// PRONG COLUMN DESIGN — prevents prongs falling into cavity:
+// A solid rectangular column rises from the inner bottom face at
+// each prong position. The prong slot cuts through the bottom wall
+// AND the column. The column ceiling is at Y=wall+ks_prong_h,
+// which acts as the hard stop for the prong — it cannot travel
+// beyond that height. The column is added AFTER the main interior
+// hollow is subtracted (nested-difference CSG), so the hollow
+// does not remove it.
+//
+module rear_cover() {
+ n_vent = 5;
+ vent_block_w = n_vent*(vent_w+vent_sp) - vent_sp;
+
+ difference() {
+
+ // ── SOLID GEOMETRY ────────────────────────────────────
+ union() {
+
+ // 1. Main shell with interior already removed
+ // (nested so subsequent additions are NOT removed by hollow)
+ difference() {
+ cbox(enc_w, enc_h, enc_d);
+ // Interior hollow — from rear inner face to front opening
+ translate([wall, wall, wall])
+ cube([scr_w, scr_h, enc_d]);
+ }
+
+ // 2. Corner towers — full cavity height, clearly visible from front
+ for(x = tower_xs) for(y = tower_ys)
+ translate([x - tower_w/2, y - tower_w/2, wall])
+ cube([tower_w, tower_w, enc_d - wall]);
+
+ // 3. Prong guide columns — solid pillars on inner bottom face
+ // One per prong, gives 14 mm of guided engagement
+ for(i = [0:ks_prong_n-1]) {
+ px = enc_w * (i+1) / (ks_prong_n+1);
+ translate([px - col_x_size/2,
+ wall,
+ 0])
+ cube([col_x_size, col_y_size, col_z_size]);
+ }
+ }
+
+ // ── ALL CUTOUTS ───────────────────────────────────────
+
+ // Corner tower M3 pilot holes (from front/open face, going inward)
+ for(x = tower_xs) for(y = tower_ys)
+ translate([x, y, enc_d + 0.1])
+ rotate([180, 0, 0])
+ cylinder(d=m3_pilot, h=tower_hole_d);
+
+ // ── PORT CUTOUTS ──────────────────────────────────────
+ pi_bot = pi_enc_cy - pi_h/2;
+ pi_top = pi_enc_cy + pi_h/2;
+
+ // LEFT WALL — USB-C power + HDMI ×2
+ translate([-0.1, pi_bot + 3, pi_z + 2]) cube([wall+0.2, 11, 11]);
+ translate([-0.1, pi_bot + 16, pi_z + 2]) cube([wall+0.2, 17, 9]);
+ translate([-0.1, pi_bot + 35, pi_z + 2]) cube([wall+0.2, 17, 9]);
+
+ // RIGHT WALL — RJ45 + USB-A ×4
+ translate([enc_w-wall-0.1, pi_top - 24, pi_z + 1])
+ cube([wall+0.2, 22, 16]);
+ translate([enc_w-wall-0.1, pi_bot + 2, pi_z + 1])
+ cube([wall+0.2, 50, 15]);
+
+ // ── COOLING VENTS ─────────────────────────────────────
+
+ // Bottom intake
+ translate([enc_w/2 - vent_block_w/2, -0.1, wall + 6])
+ rotate([-90, 0, 0])
+ vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // Top exhaust
+ translate([enc_w/2 - vent_block_w/2, enc_h - wall + 0.1, wall + 6])
+ rotate([90, 0, 0])
+ vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // SoC direct-vent on rear panel
+ translate([pi_enc_cx - soc_vent_sz/2,
+ pi_enc_cy - soc_vent_sz/2,
+ enc_d - wall - 0.1]) {
+ n_soc = floor(soc_vent_sz / (vent_w + vent_sp));
+ for(i = [0:n_soc-1])
+ translate([i*(vent_w+vent_sp), soc_vent_sz/2 - vent_l/2, 0])
+ slot(vent_l, vent_w, wall+0.2);
+ }
+
+ // ── CABLE GLANDS — rear panel face ────────────────────
+ for(i = [0:gland_count-1]) {
+ cx = enc_w/2 + (i - (gland_count-1)/2) * gland_spacing;
+ translate([cx, wall + gland_dia/2 + 4, -0.1])
+ cylinder(d=gland_dia, h=wall+0.2);
+ }
+
+ // ── KICKSTAND PRONG SLOTS ─────────────────────────────
+ // Each slot cuts through the outer bottom wall AND the guide
+ // column above it. The column ceiling at Y=wall+ks_prong_h
+ // is the hard stop — the prong cannot fall through.
+ for(i = [0:ks_prong_n-1]) {
+ px = enc_w * (i+1) / (ks_prong_n+1);
+ translate([px, 0, 0])
+ prong_slot_cut();
+ }
+ }
+}
+
+// ============================================================
+// SCENE — three parts exploded for visual clarity
+// ============================================================
+
+// FRONT BEZEL — floated forward (toward viewer)
+color("DarkSlateGray", 0.92)
+ translate([0, 0, enc_d + 14])
+ front_bezel();
+
+// REAR COVER — at origin
+color("SlateGray", 0.88)
+ rear_cover();
+
+// KICKSTAND — floated below the case to show it is a separate piece
+color("DimGray", 0.85)
+ translate([0, -(ks_thick + ks_drop + 20), 0])
+ kickstand();
diff --git a/rpi-case/rpi5_industrial_case_006.scad b/rpi-case/rpi5_industrial_case_006.scad
new file mode 100644
index 0000000..2f76e2a
--- /dev/null
+++ b/rpi-case/rpi5_industrial_case_006.scad
@@ -0,0 +1,338 @@
+// ============================================================
+// RPi5 Industrial Enclosure — 7" Capacitive Touchscreen
+// Version: 006
+//
+// ASSEMBLY (three printed parts):
+//
+// REAR COVER — box, open face toward screen.
+// • 4 corner towers, full cavity height, M3 pilot hole from front.
+// • 3 prong guide columns on inner bottom face — the columns give
+// the prong slots a solid ceiling so nothing falls through.
+// • ALL SIDE WALLS ARE SOLID. No port holes.
+//
+// FRONT BEZEL — display frame.
+// • 4× M3 countersunk holes, corners, aligned to tower holes.
+// • Screw route: bezel face → screen gap → tower pilot hole.
+// • Use M3 × 30 mm self-tapping screws.
+//
+// KICKSTAND — separate removable wedge plate.
+// • Main wedge behind case creates the tilt.
+// • Thin ledge extends UNDER the rear of the case 14 mm.
+// The ledge carries the 3 prongs which enter the case
+// through the BOTTOM face only — rear wall stays solid.
+// • Slide ledge+prongs under the case from behind; prongs
+// click up into their columns and lock the stand.
+//
+// Changes vs 005:
+// • Right-wall USB-A rectangle REMOVED (and all side-wall cuts).
+// Both left and right walls are now fully solid.
+// • Prong slots moved to Z = 5.5–10.5 mm (inside cavity).
+// Rear face (Z = 0) is completely uncut — no holes on back.
+// • Kickstand gains a 14 mm ledge so prongs reach the new slot Z.
+// • Cable glands removed (no rear-face holes).
+// ============================================================
+
+// ── SCREEN PARAMETERS ───────────────────────────────────────
+scr_w = 164.9; // screen outer width (mm)
+scr_h = 124.27; // screen outer height (mm)
+scr_d = 12; // screen body depth (mm) ← confirm with calipers
+scr_active_w = 154; // active area width (mm) ← confirm from datasheet
+scr_active_h = 90; // active area height (mm) ← confirm from datasheet
+scr_mount_x = 75; // rear M2.5 mount pattern X (mm) ← verify
+scr_mount_y = 75; // rear M2.5 mount pattern Y (mm) ← verify
+
+// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
+pi_w = 85; // Pi board width (mm)
+pi_h = 56; // Pi board height (mm)
+pi_d = 17; // Pi board + tallest component (mm)
+pi_mnt_x = 58; // Pi mount pattern X (mm)
+pi_mnt_y = 49; // Pi mount pattern Y (mm)
+pi_standoff = 5; // standoff: screen rear → Pi PCB (mm)
+pi_offset_x = 0; // Pi centre X offset from screen centre (mm)
+pi_offset_y = 0; // Pi centre Y offset from screen centre (mm)
+
+// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
+wall = 4.0; // wall thickness (mm)
+chamfer = 1.5; // external edge chamfer (mm)
+recess = 1.0; // screen recess depth in front bezel (mm)
+
+// ── VENT PARAMETERS ──────────────────────────────────────────
+vent_w = 3;
+vent_l = 18;
+vent_sp = 4;
+soc_vent_sz = 28;
+
+// ── KICKSTAND PARAMETERS ─────────────────────────────────────
+ks_tilt = 75; // screen angle from horizontal when standing (deg)
+ks_depth = 60; // wedge reach behind rear face (mm)
+ks_thick = 5; // plate/ledge thickness (mm)
+
+// Prong dimensions
+ks_prong_n = 3; // number of prongs
+ks_prong_w = 12; // prong width in X (mm)
+ks_prong_h = 14; // prong engagement height (mm)
+ks_prong_t = 5; // prong thickness in Z (mm)
+ks_prong_clr = 0.25; // clearance per side (mm)
+
+// Ledge: extends under the case so prongs reach inside the cavity
+// without cutting through the rear wall.
+ks_ledge = 14; // ledge depth in +Z under case (mm)
+ // must be > wall + ks_prong_t + margin
+
+// Z start of prong slot measured from rear face.
+// Must be > wall so the slot never touches the rear face.
+ks_prong_z0 = wall + 1.5; // = 5.5 mm — slot from 5.25 to 10.75 mm
+
+// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
+m3_dia = 3.4; // M3 clearance hole (mm)
+m3_pilot = 2.5; // M3 self-tapping pilot (mm)
+m3_cs_dia = 6.5; // M3 countersink OD (mm)
+m3_cs_depth = 3.5; // countersink depth (mm)
+tower_w = 9; // corner tower footprint (mm square)
+tower_hole_d = 14; // pilot hole depth into tower from front face (mm)
+corner_inset = 7; // tower/hole centre from outer edge (mm)
+
+// ── DERIVED ──────────────────────────────────────────────────
+rear_d = pi_standoff + pi_d + 10; // cavity depth = 32 mm
+enc_w = scr_w + 2*wall; // 172.9 mm
+enc_h = scr_h + 2*wall; // 132.27 mm
+enc_d = rear_d + wall; // 36 mm
+
+pi_enc_cx = wall + scr_w/2 + pi_offset_x;
+pi_enc_cy = wall + scr_h/2 + pi_offset_y;
+pi_z = wall + pi_standoff;
+
+ks_drop = ks_depth * tan(90 - ks_tilt); // wedge tip drop ≈ 16 mm
+
+// Guide column dimensions (added back inside cavity after hollow)
+col_w = ks_prong_w + wall; // 16 mm — solid wall each side of slot
+col_h = ks_prong_h; // 14 mm — prong engagement
+col_d = ks_ledge; // 14 mm — same as ledge depth
+
+$fn = 48;
+
+// ============================================================
+// PRIMITIVES
+// ============================================================
+
+module cbox(w, h, d, c=chamfer) {
+ hull() {
+ translate([c, c, 0]) cube([w-2*c, h-2*c, d ]);
+ translate([0, c, c]) cube([w, h-2*c, d-2*c ]);
+ translate([c, 0, c]) cube([w-2*c, h, d-2*c ]);
+ }
+}
+
+module slot(len, w, d) {
+ r = w/2;
+ hull() {
+ translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
+ translate([0, len/2-r, 0]) cylinder(r=r, h=d);
+ }
+}
+
+module vent_row(n, len, w, spacing, depth) {
+ for(i = [0:n-1])
+ translate([i*(w+spacing), 0, 0])
+ slot(len, w, depth);
+}
+
+module m3_countersunk(total_d) {
+ cylinder(d=m3_dia, h=total_d+0.1);
+ cylinder(d1=m3_cs_dia, d2=m3_dia, h=m3_cs_depth+0.1);
+}
+
+// Prong slot cutter — called with origin at (prong_cx, 0, ks_prong_z0).
+// Cuts bottom wall (Y: -0.1 → wall) + guide column (Y: wall → wall+col_h).
+// Z extent stays within [ks_prong_z0-clr, ks_prong_z0+ks_prong_t+clr]
+// which is entirely inside the cavity — rear face untouched.
+module prong_slot_cut() {
+ translate([-ks_prong_w/2 - ks_prong_clr,
+ -0.1,
+ -ks_prong_clr])
+ cube([ks_prong_w + 2*ks_prong_clr,
+ wall + col_h + 0.2,
+ ks_prong_t + 2*ks_prong_clr]);
+}
+
+// ============================================================
+// KICKSTAND (separate removable piece — print separately)
+// ============================================================
+//
+// Side cross-section (Y-Z plane):
+//
+// Y=0 (case bottom) ─────┬─────────────────┐ ← ledge top (fits under case)
+// Y=-ks_thick ─────┴─────────────────┘ ← ledge bottom
+// Z=ks_ledge Z=0 │
+// │ ← wedge (behind case)
+// thick ╲ │ thin
+// desk contact → ────────╲─┘
+// Z=-ks_depth Z=0
+//
+// The ledge (Z=0→ks_ledge) slides under the case rear.
+// Prongs rise from Y=0 at Z=ks_prong_z0, entering the case bottom slots.
+// The wedge (Z=-ks_depth→0) rests on the desk and creates the tilt.
+//
+module kickstand() {
+ ks_front_h = ks_thick + ks_drop; // thick end of wedge
+
+ // 1. Wedge behind case (Z = -ks_depth → 0)
+ hull() {
+ translate([0, -ks_thick, 0 ]) cube([enc_w, ks_thick, wall]);
+ translate([0, -ks_front_h, -ks_depth]) cube([enc_w, ks_front_h, wall]);
+ }
+
+ // 2. Ledge under rear of case (Z = 0 → ks_ledge)
+ translate([0, -ks_thick, 0])
+ cube([enc_w, ks_thick, ks_ledge]);
+
+ // 3. Three prongs rising from ledge top (Y=0) into case bottom slots
+ for(i = [0:ks_prong_n-1]) {
+ px = enc_w * (i+1) / (ks_prong_n+1);
+ translate([px - ks_prong_w/2, 0, ks_prong_z0]) {
+ // Main shaft
+ cube([ks_prong_w, ks_prong_h - 2, ks_prong_t]);
+ // Tapered tip (45° chamfer for easy insertion)
+ translate([0, ks_prong_h - 2, 0])
+ hull() {
+ cube([ks_prong_w, 0.01, ks_prong_t ]);
+ translate([1, 2, 0])
+ cube([ks_prong_w-2, 0.01, ks_prong_t ]);
+ }
+ }
+ }
+}
+
+// ============================================================
+// FRONT BEZEL
+// ============================================================
+module front_bezel() {
+ ci = corner_inset;
+ difference() {
+ cbox(enc_w, enc_h, wall + recess);
+
+ // Display window
+ translate([(enc_w - scr_active_w)/2,
+ (enc_h - scr_active_h)/2, -0.1])
+ cube([scr_active_w, scr_active_h, wall+recess+0.2]);
+
+ // 1 mm recess pocket grips screen edge
+ translate([(enc_w - scr_w)/2,
+ (enc_h - scr_h)/2, wall])
+ cube([scr_w, scr_h, recess+0.1]);
+
+ // 4× M3 countersunk screw holes at corners
+ for(x = [ci, enc_w-ci])
+ for(y = [ci, enc_h-ci])
+ translate([x, y, 0])
+ m3_countersunk(wall + recess);
+ }
+}
+
+// ============================================================
+// REAR COVER
+// ============================================================
+module rear_cover() {
+ ci = corner_inset;
+ n_vent = 5;
+ vbw = n_vent*(vent_w+vent_sp) - vent_sp; // vent block width
+
+ difference() {
+
+ // ── SOLID GEOMETRY (nested CSG) ───────────────────────
+ union() {
+
+ // A) Shell with interior already removed.
+ // Nested so the additions below are NOT eaten by the hollow.
+ difference() {
+ cbox(enc_w, enc_h, enc_d);
+ translate([wall, wall, wall])
+ cube([scr_w, scr_h, enc_d]);
+ }
+
+ // B) Corner towers — full cavity height.
+ // M3 pilot hole is drilled from the open front face,
+ // clearly visible when looking into the case before assembly.
+ for(x = [ci, enc_w-ci])
+ for(y = [ci, enc_h-ci])
+ translate([x - tower_w/2, y - tower_w/2, wall])
+ cube([tower_w, tower_w, enc_d - wall]);
+
+ // C) Prong guide columns.
+ // One solid column per prong, rising from the inner bottom
+ // face (Y=wall) by col_h=14 mm, spanning Z=0→col_d=14 mm.
+ // The prong slot cuts through this column, giving a closed
+ // ceiling at Y=wall+col_h — prongs cannot fall through.
+ // The column Z=0→5.25 mm is NOT cut by the slot, so the
+ // rear face (Z=0) remains completely solid at these spots.
+ for(i = [0:ks_prong_n-1]) {
+ px = enc_w * (i+1) / (ks_prong_n+1);
+ translate([px - col_w/2, wall, 0])
+ cube([col_w, col_h, col_d]);
+ }
+ }
+
+ // ── CUTOUTS (applied to everything above) ─────────────
+
+ // Corner tower pilot holes from open front face
+ for(x = [ci, enc_w-ci])
+ for(y = [ci, enc_h-ci])
+ translate([x, y, enc_d + 0.1])
+ rotate([180, 0, 0])
+ cylinder(d=m3_pilot, h=tower_hole_d);
+
+ // Cooling — bottom intake slots (through bottom wall, Y direction)
+ translate([enc_w/2 - vbw/2, -0.1, wall + 6])
+ rotate([-90, 0, 0])
+ vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // Cooling — top exhaust slots (through top wall, Y direction)
+ translate([enc_w/2 - vbw/2, enc_h - wall + 0.1, wall + 6])
+ rotate([90, 0, 0])
+ vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
+
+ // Cooling — SoC direct vent on rear panel
+ translate([pi_enc_cx - soc_vent_sz/2,
+ pi_enc_cy - soc_vent_sz/2,
+ enc_d - wall - 0.1]) {
+ n_soc = floor(soc_vent_sz / (vent_w + vent_sp));
+ for(i = [0:n_soc-1])
+ translate([i*(vent_w+vent_sp),
+ soc_vent_sz/2 - vent_l/2, 0])
+ slot(vent_l, vent_w, wall+0.2);
+ }
+
+ // Kickstand prong slots — bottom face ONLY.
+ // Slot Z: [ks_prong_z0-clr, ks_prong_z0+ks_prong_t+clr]
+ // = [5.25, 10.75] mm — entirely past the rear wall (Z=0-4 mm).
+ // Rear face at Z=0 is untouched.
+ for(i = [0:ks_prong_n-1]) {
+ px = enc_w * (i+1) / (ks_prong_n+1);
+ translate([px, 0, ks_prong_z0])
+ prong_slot_cut();
+ }
+
+ // NOTE: All side-wall port cutouts removed — both left and right
+ // walls are solid. Access to Pi ports via short extension cables
+ // routed through user-drilled holes as needed for the installation.
+ }
+}
+
+// ============================================================
+// SCENE — three parts exploded for inspection
+// ============================================================
+
+// Front bezel — floated forward
+color("DarkSlateGray", 0.92)
+ translate([0, 0, enc_d + 14])
+ front_bezel();
+
+// Rear cover — at origin
+color("SlateGray", 0.88)
+ rear_cover();
+
+// Kickstand — floated below and behind to show it is separate
+// In real use: slide it up from below until prongs click into columns.
+color("DimGray", 0.85)
+ translate([0, -(ks_thick + ks_drop + 20), 0])
+ kickstand();
diff --git a/security_risks.md b/security_risks.md
new file mode 100644
index 0000000..b46d0ea
--- /dev/null
+++ b/security_risks.md
@@ -0,0 +1,113 @@
+# Security Risks Review (mis-control-tower)
+
+This review focuses on the risks highlighted in the tweet you shared: backend-first architecture, trusting the client, column/role escalation, and missing rate limits.
+
+Good news: this project is backend-first and uses Prisma on the server, not direct-to-DB from the client. Prisma itself is not the core risk here. The main issues are around authorization scope, secret handling, and rate limiting.
+
+## Critical: Cross-org reminder trigger + weak auth fallback
+
+### What can go wrong
+Any logged-in user can trigger the reminders job if the secret is not set, and the job queries across all orgs. This can spam email reminders to users in other orgs.
+
+### Source
+- Auth fallback to "any session" when secret missing: `app/api/downtime/actions/reminders/route.ts:40`
+- Cross-org query (no `orgId` filter): `app/api/downtime/actions/reminders/route.ts:62`
+
+### Why this maps to the tweet
+This is a classic "missing backend guardrails" and "rate limits/abuse" problem.
+
+### Fix ideas
+- Require `DOWNTIME_ACTION_REMINDER_SECRET` in all environments (fail closed if missing).
+- If you want session-based access, also require:
+ - role check (OWNER/ADMIN), and
+ - explicit `orgId` scoping in the `findMany` query.
+- Consider also logging who triggered it.
+
+---
+
+## High: Invite token exposure + invite claim risk
+
+### What can go wrong
+A regular member can retrieve active invite tokens and then accept invites intended for other people.
+
+### Source
+- Members GET has no role check: `app/api/org/members/route.ts:23`
+- Members GET returns raw invite tokens: `app/api/org/members/route.ts:52`
+- Accepting an invite creates a user for the invite email and marks it verified based only on the token: `app/api/invites/[token]/route.ts:93`, `app/api/invites/[token]/route.ts:98`
+
+### Why this maps to the tweet
+This is a "hidden columns / privilege escalation" flavor of bug: sensitive fields (tokens) are being exposed to users who should not see them.
+
+### Fix ideas
+- Add a role check to `GET /api/org/members` (OWNER/ADMIN only).
+- Do not return invite tokens from the API (or only return to OWNER/ADMIN).
+- Optional hardening:
+ - Bind invites more tightly to identity (e.g., require proof of email ownership), or
+ - require the invite acceptance flow to complete a verification step before granting access.
+
+---
+
+## Medium: Pairing code brute force path to machine API keys
+
+### What can go wrong
+Pairing codes are short and the pairing endpoint returns the machine API key. Without rate limiting, attackers can attempt many codes and occasionally succeed.
+
+### Source
+- Pairing codes length = 5: `lib/pairingCode.ts:5`
+- Pair endpoint returns `apiKey`: `app/api/machines/pair/route.ts:56`
+
+### Why this maps to the tweet
+This aligns with "rate limits are not optional anymore" and "don’t trust defaults."
+
+### Fix ideas
+- Add rate limiting to `/api/machines/pair` (by IP and/or code prefix).
+- Increase pairing code entropy (length and/or attempt tracking).
+- Track failed attempts and temporarily disable pairing for a machine after too many failures.
+
+---
+
+## Medium: Missing rate limiting on high-abuse endpoints
+
+### What can go wrong
+Attackers can brute-force or abuse endpoints to consume resources and/or trigger unwanted actions.
+
+### Source (representative endpoints)
+- Login: `app/api/login/route.ts:20`
+- Signup: `app/api/signup/route.ts:26`
+- Pairing: `app/api/machines/pair/route.ts:12`
+- Ingest: `app/api/ingest/kpi/route.ts:35`, `app/api/ingest/heartbeat/route.ts:33`, `app/api/ingest/event/route.ts:60`, `app/api/ingest/reason/route.ts:11`
+
+### Why this maps to the tweet
+This is directly item #10 in the tweet: rate limits at auth, API routes, and webhooks/ingest.
+
+### Fix ideas
+- Apply rate limiting to:
+ - auth endpoints (`/api/login`, `/api/signup`, invite acceptance),
+ - pairing (`/api/machines/pair`),
+ - ingest endpoints (especially if publicly reachable).
+- Even a simple KV-based limiter or middleware-based limiter is a large improvement.
+
+---
+
+## Not the core risk: Prisma usage
+
+### Observation
+This project uses Prisma server-side via Next.js route handlers and server components. I did not see direct DB calls from the browser.
+
+### Source (representative)
+- Session enforcement in API routes: `lib/auth/requireSession.ts:42`
+- Server-side data access in routes: `app/api/machines/route.ts:31`, `app/api/settings/route.ts:146`
+
+### Why this matters
+This avoids the tweet’s main direct-to-DB + RLS pitfalls, but you still need strong authorization and rate limiting in your own backend.
+
+---
+
+## Quick fix priority order
+
+1. Lock down `POST /api/downtime/actions/reminders` (fail closed + org scoping).
+2. Lock down `GET /api/org/members` and stop exposing invite tokens.
+3. Add rate limiting to pairing and auth endpoints.
+4. Consider increasing pairing code entropy + attempt tracking.
+
+If you want, I can implement the first two fixes in small, safe patches.
diff --git a/snappy.md b/snappy.md
new file mode 100644
index 0000000..c71dbee
--- /dev/null
+++ b/snappy.md
@@ -0,0 +1,235 @@
+# Snappy UX plan (Next.js)
+
+## Goals
+- Make every navigation feel instant (<50ms feedback) via loading UI and disabled re-clicks.
+- Reduce server and data latency for heavy pages (Overview, Reports).
+- Keep data accurate while allowing slight staleness for Settings/Financial (seconds).
+
+## Constraints
+- Data-heavy pages with large payloads and expensive queries.
+- Users click multiple times when no feedback is shown.
+
+## Success targets
+- Navigation feedback in <50ms (loading/skeleton/pending state).
+- P95 server response under 300-500ms for most queries; worst cases hidden behind progressive loading.
+- No multi-click queueing; one navigation at a time.
+
+---
+
+## Phase 1: Audit and baseline (completed)
+
+### What was instrumented
+- Server timing + payload logging on Overview, Reports, Reports Filters, Machines APIs.
+- Per-step timings inside `getOverviewData` (machines query, events query, normalize/filter).
+- Client nav timing hooks were added but not captured due to service env/build config.
+
+### Baseline results (from `/tmp/mis-control-tower.log`)
+- Aggregate stats (cold + warm averaged)
+ - Client nav (`perf.client` nav duration)
+ - Avg: ~38ms; p50: ~51ms; p95: ~67ms; min: ~5ms; max: ~82ms.
+ - Overview API (`/api/overview`) total
+ - Avg: ~3.07s; p50: ~1.73s; p95: ~8.61s; min: ~1.20s; max: ~21.54s.
+ - `getOverviewData` total
+ - Avg: ~1.29s; p50: ~1.26s; p95: ~1.35s; min: ~1.15s; max: ~2.41s.
+ - Machines query (inside Overview)
+ - Avg: ~1.27s; p50: ~1.25s; p95: ~1.33s; min: ~1.13s; max: ~2.38s.
+ - Machines API (`/api/machines`) total
+ - Avg: ~1.26s; p50: ~1.25s; p95: ~1.36s; min: ~1.13s; max: ~1.52s.
+ - Reports API (`/api/reports`) total
+ - Avg: ~3.81s; p50: ~468ms; p95: ~18.14s; min: ~168ms; max: ~26.56s.
+ - Reports filters (`/api/reports/filters`) total
+ - Avg: ~4.07s; p50: ~367ms; p95: ~16.61s; min: ~57ms; max: ~23.78s.
+ - Reports payload size
+ - Avg: ~406KB; p50: ~406KB; p95: ~407KB.
+
+- Overview (`/api/overview`)
+ - Total: ~1.3–2.5s across samples (best ~1.2s, spikes up to ~2.5s).
+ - `getOverviewData` total: ~1.15–1.36s typically; one sample ~2.4s.
+ - **Machines query dominates**: ~1.12–1.33s (primary bottleneck).
+ - Events query: ~5–35ms (minor).
+ - Payload: ~13KB.
+
+- Machines (`/api/machines`)
+ - Total: ~1.15–1.33s per call for 3 machines.
+ - **Machines query dominates**: ~1.15–1.33s.
+ - Payload: ~1.6KB.
+
+- Reports (`/api/reports`)
+ - Typical total: ~170–225ms (later runs), earlier spikes up to ~16s (pre-fix or cold).
+ - Query timings combined: ~130–200ms.
+ - Row counts: ~1.8k KPI rows, ~6.2k cycles, ~736 events.
+ - **Payload size ~406KB** (largest).
+
+- Reports filters (`/api/reports/filters`)
+ - Typical total: ~56–68ms (later runs), earlier spikes up to ~23s (pre-fix or cold).
+ - Query timings: ~30–40ms.
+ - Payload: ~51B.
+
+### Findings
+- The dominant latency contributor is the **machines query** used by Overview and Machines endpoints.
+- Reports payload is large (~406KB), which impacts UI responsiveness even when queries are moderate.
+- Large outliers (multi-second totals) likely come from non-query overhead (session lookup, DB connection wait, or cold start); these need targeted checks.
+- Reports and reports filters show totals that are far larger than the summed query timings, confirming significant overhead outside the measured DB queries.
+- Client end-to-end nav timing (`perf.client`) is now captured; p95 is ~67ms, slightly above the 50ms target.
+- Baseline summaries should average cold and warm samples together for now.
+
+### Data captured
+- Logs are stored at `/tmp/mis-control-tower.log`.
+- Events include: `perf.overview.api`, `perf.overview.getOverviewData`, `perf.machines.api`, `perf.reports.api`, `perf.reports.filters`.
+
+Update
+- Client nav timing is now captured via `/api/debug/perf` (`perf.client` events).
+- API timings now include auth/preQuery/postQuery with coldStart/uptimeMs when enabled.
+
+---
+
+## Phase 2: Instant feedback (UX)
+
+### 1) Global route loading
+- Add `app/(app)/loading.tsx` with a lightweight skeleton for the shell.
+- Ensure each heavy route also has its own `loading.tsx` for targeted skeletons.
+
+### 2) Sidebar pending state
+- Use `useTransition` to mark a pending navigation.
+- Disable repeated clicks and show a subtle spinner on the active item.
+- Optional: debounce repeated clicks for 300-500ms.
+
+### 3) Suspense boundaries
+- Wrap the slowest sections (events, charts, tables) in `` with skeletons.
+- Ensure initial shell renders immediately even if data is still loading.
+
+Deliverables
+- Users always see visual feedback within a single frame.
+- Double-clicks do not queue up extra navigations.
+
+Progress
+- Added route-level loading skeletons for the app shell and heavy routes.
+- Sidebar uses `useTransition` with a pending spinner and blocks repeat clicks.
+- Added Suspense + lazy loading for the Overview timeline and Reports charts.
+
+---
+
+## Phase 3: Split heavy pages (Overview + Reports)
+
+### Overview (split)
+- First paint: show lightweight summary data (machines list + latest heartbeat + tiny event count).
+- Defer: fetch full event stream and detailed KPIs via client API after initial render.
+- Use an explicit "Load more" or lazy loading for event details.
+
+Implementation sketch
+- Create a `getOverviewSummary` for the initial server render.
+- Create a client fetch (`/api/overview?detail=1`) for detailed events and charts.
+- Replace large data arrays with preview-sized payloads.
+
+Progress
+- Overview now uses `getOverviewSummary` for first paint, and `/api/overview?detail=1` for deferred detail fetch.
+- Summary responses are cached in-memory with TTL + in-flight de-dupe (`perf.overview.summary` shows cache hits).
+- Reports charts are lazy-loaded with placeholders; heavy chart blocks render after the shell.
+
+### Reports (split)
+- Render the report shell and filters immediately.
+- Lazy-load heavy charts with `next/dynamic` and loading placeholders.
+- Fetch chart data on demand (per chart or on viewport with IntersectionObserver).
+- Paginate any large tables or use virtualization.
+
+Deliverables
+- Overview/Reports initial response is fast and small.
+- Deep detail loads after the UI is already visible.
+
+---
+
+## Phase 4: Caching + data freshness
+
+### 1) Page-level caching
+- Remove `force-dynamic` where it is not required.
+- Use `revalidate` on pages that can be stale for a few seconds (Settings, Financial).
+
+### 2) Data cache for Prisma queries
+- Wrap stable fetchers in `unstable_cache` with short TTL and tags (per org).
+- Add manual refresh button on Settings/Financial to bypass cache when needed.
+
+### 3) API cache headers
+- Use `ETag` and `If-None-Match` where possible.
+- For logged-in data, use `private` caching with short max-age.
+
+Deliverables
+- Fewer full recomputes for repeated navigations.
+- Settings/Financial feel instant, but still correct.
+
+Progress
+- Added session cache + throttled `lastSeenAt` updates to reduce auth overhead spikes.
+- Added cached GETs with short TTL + per-org tags for Settings + Financial config/impact.
+- Added refresh bypass (`?refresh=1`) and a refresh button on Financial.
+- Added ETag + private cache headers for Settings + Financial config, plus private cache headers for Financial impact.
+- Restored `force-dynamic` on the authenticated layout to avoid static render errors from `cookies()`.
+
+---
+
+## Phase 5: Query + payload tuning
+
+- Reduce `select` fields to only what the UI needs on first render.
+- Cap `take` sizes with clear UI controls to load more.
+- Add indexes for `orgId + ts` combos used in orderBy filters.
+- Consider summary tables for expensive aggregations.
+
+Progress
+- Split machine fetch into base + latest heartbeat/KPI queries to avoid nested relation orderBy/take on large tables.
+- Added indexes for heartbeat tsServer lookup and machine ordering by orgId + createdAt.
+- Machines base query dropped to low ms; new hotspots are latest heartbeat (~250-300ms) and latest KPI (~800-900ms).
+- Overview/Machines now log `heartbeatsQuery` + `kpiQuery` to track the new bottlenecks.
+
+---
+
+## What helped most
+- Overview split + summary cache: repeat navigations are instant and detail loads later.
+- Route-level loading + pending state: immediate feedback reduced double-clicks.
+- Session cache + throttled lastSeen: reduced non-query overhead spikes.
+- Short TTL caches with refresh bypass: Settings/Financial feel instant without losing correctness.
+- Query shape changes: removed nested relation ordering and shifted load to targeted queries.
+
+## Methodology / optimization strategy
+- Instrument first, measure cold + warm, and store logs.
+- Use timing breakdowns to find the dominant step.
+- Improve perceived performance early (skeletons, pending state).
+- Split payloads into summary + deferred detail.
+- Cache low-risk data with short TTL + refresh bypass and ETag for 304s.
+- Tune queries with smaller selects, indexes, and safer query shapes; consider denormalizing if needed.
+
+## Validation
+- Measure navigation feedback time (click to loading UI). Goal: <50ms.
+- Track p95 TTFB and payload size for Overview and Reports before/after.
+- Confirm that repeated clicks no longer add latency or duplicated requests.
+
+---
+
+## Open opportunities
+- Optimize latest KPI query (index on `orgId + machineId + tsServer` or denormalize latest KPI onto `Machine`).
+- Reduce Reports payload size (trim fields, paginate, or virtualize tables).
+- Consider summary tables/materialized views for heavy aggregates.
+
+## Further implementation plan (later)
+1) Latest KPI/heartbeat acceleration
+ - Add index for KPI lookups by server time: `@@index([orgId, machineId, tsServer])`.
+ - Switch KPI “latest” ordering to `tsServer` to match index.
+ - Optional: denormalize `latestHeartbeat` + `latestKpi` onto `Machine` and update on ingest.
+ - Add background backfill job for legacy machines.
+
+2) Machines + Overview caching
+ - Increase summary cache TTL (30-60s) to raise hit rates.
+ - Add per-org cache invalidation when a heartbeat/KPI ingests.
+ - Add ETag handling to `/api/machines` (similar to overview detail).
+
+3) Reports payload trim
+ - Reduce fields in `reports` response to the chart/minimum.
+ - Add pagination for large tables (KPIs/cycles/scrap).
+ - Add “Download full dataset” endpoint separate from UI view.
+
+4) Connection + ORM tuning
+ - Enable Prisma query logging to identify slow SQL.
+ - Evaluate connection pool size and cold-start behavior in serverless.
+ - Move heavy aggregates to `GROUP BY` at DB level with indexes.
+
+5) UX refinements
+ - Add inline “last updated” timestamp in Overview/Reports headers.
+ - Show cache-hit badges when content is served from cache.
+ - Add optional “refresh” on the overview to re-fetch detail data.