/* Camera setup wizard + ROI marker + health dashboard. * * Tre hovedseksjoner: * 1. CameraSetupPage — toppnivå, vises som ny tab på automat-detail * 2. CameraWizard — flytter operatør gjennom binding av 8 cams * 3. RoiMarker — canvas der operatør tegner shelf-rektangler i kamerabildet * * Brukerflyt: * Step 1: Velg en cam_id som skal bindes (1-8) * Step 2: System triggrer identifikasjon * - Dør-cam (1/3/5/7): "trykk dette så åpner én dør, fortell oss hvilken" * - Bilde-cam (2/4/6/8): "trykk dette så tas et bilde, fortell oss hvilken hylle" * Step 3: Operatør bekrefter → backend binder * Step 4: ROI-marking av synlige hyller */ const CameraSetupPage = ({ machine, lang, onReload }) => { const t = I18N[lang]; const [tab, setTab] = React.useState('overview'); // overview|wizard|health|roi const [cameras, setCameras] = React.useState([]); const [unbound, setUnbound] = React.useState([]); const [layout, setLayout] = React.useState(null); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const refresh = React.useCallback(async () => { setBusy(true); setError(null); try { const [cams, unb, lay] = await Promise.all([ window.api.camerasList(machine.id), window.api.camerasListUnbound(), window.api.camerasExpectedLayout(machine.id), ]); setCameras(cams); setUnbound(unb); setLayout(lay); } catch (e) { setError(e.message); } setBusy(false); }, [machine.id]); React.useEffect(() => { refresh(); }, [refresh]); return (
{[ { k: 'overview', label: lang === 'no' ? 'Oversikt' : 'Overview', icon: 'camera' }, { k: 'wizard', label: lang === 'no' ? 'Setup-wizard' : 'Setup wizard', icon: 'plus' }, { k: 'health', label: lang === 'no' ? 'Helse' : 'Health', icon: 'heart' }, ].map(tb => (
setTab(tb.k)}> {tb.label}
))}
{error &&
❌ {error}
} {tab === 'overview' && } {tab === 'wizard' && } {tab === 'health' && }
); }; /* ===== Oversiktstabell — viser alle 8 cams + status + handlinger ===== */ const CameraOverview = ({ machine, cameras, unbound, layout, lang, onRefresh }) => { const [busyCam, setBusyCam] = React.useState(null); const [roiTarget, setRoiTarget] = React.useState(null); const expected = layout?.expected_cams || []; const camById = Object.fromEntries(cameras.map(c => [c.cam_id, c])); const identifyLed = async (cid) => { setBusyCam(cid); try { await window.api.camerasIdentifyLed(machine.id, cid); } catch (e) { alert(e.message); } setBusyCam(null); }; const unbind = async (cid) => { if (!confirm(lang === 'no' ? `Frikoble cam ${cid}? MAC går tilbake til unbound-listen.` : `Unbind cam ${cid}?`)) return; setBusyCam(cid); try { await window.api.camerasUnbind(machine.id, cid); await onRefresh(); } catch (e) { alert(e.message); } setBusyCam(null); }; const runHealth = async () => { setBusyCam('health'); try { await window.api.camerasRunHealth(machine.id); alert('Helsesjekk-kommando sendt — vent 15-30 sek + refresh'); } catch (e) { alert(e.message); } setBusyCam(null); }; return ( <>
{cameras.length}/{expected.length} {lang === 'no' ? 'bundet' : 'bound'} {unbound.length > 0 && ` · ${unbound.length} ${lang === 'no' ? 'venter' : 'pending'}`}
{expected.map(spec => { const cam = camById[spec.cam_id]; const isBound = !!cam; const status = cam?.health_status || 'unknown'; return ( ); })}
Cam {lang === 'no' ? 'Plassering' : 'Position'} {lang === 'no' ? 'Type' : 'Type'} {lang === 'no' ? 'Hyller' : 'Shelves'} {lang === 'no' ? 'Dører' : 'Doors'} MAC Status {lang === 'no' ? 'Sist sett' : 'Last seen'} {lang === 'no' ? 'Handlinger' : 'Actions'}
{spec.cam_id} {spec.position} {spec.has_doors ? {lang === 'no' ? 'Dør+kamera' : 'Door+cam'} : {lang === 'no' ? 'Kun kamera' : 'Cam only'}} {spec.shelves.join(', ')} {spec.doors.length > 0 ? `${spec.doors[0]}-${spec.doors[spec.doors.length-1]}` : '—'} {cam?.mac_address || —} {isBound ? status === 'ok' ? OK : status === 'degraded' ? Degraded : status === 'offline' ? Offline : Ukjent : {lang === 'no' ? 'Ikke bundet' : 'Unbound'}} {cam?.last_seen_at ? new Date(cam.last_seen_at).toLocaleString('no-NO', { dateStyle: 'short', timeStyle: 'short' }) : '—'}
{isBound && ( )} {isBound && ( )} {isBound && ( )}
{roiTarget && setRoiTarget(null)} onSaved={() => { setRoiTarget(null); onRefresh(); }} />} ); }; /* ===== Wizard for å binde unbound cams sekvensielt ===== */ const CameraWizard = ({ machine, cameras, unbound, layout, lang, onRefresh }) => { const [step, setStep] = React.useState('select'); // select|identify|bind|done const [selectedMac, setSelectedMac] = React.useState(null); const [doorInput, setDoorInput] = React.useState(''); const [shelfInput, setShelfInput] = React.useState(''); const [suggestion, setSuggestion] = React.useState(null); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const selected = unbound.find(u => u.mac_address === selectedMac); const expectedCams = layout?.expected_cams || []; const boundIds = new Set(cameras.map(c => c.cam_id)); const missing = expectedCams.filter(s => !boundIds.has(s.cam_id)); if (unbound.length === 0 && missing.length === 0) { return (
{lang === 'no' ? 'Alle 8 kameraer er bundet!' : 'All 8 cameras are bound!'}
); } if (unbound.length === 0) { return (
⏳ {lang === 'no' ? 'Venter på at nye kameraer dukker opp på MQTT (provisioning//hello)…' : 'Waiting for new cameras to appear on MQTT…'}
{missing.length > 0 && ( <>{lang === 'no' ? 'Mangler' : 'Missing'}: cam {missing.map(m => m.cam_id).join(', ')} )}
); } // STEP 1: Velg en unbound device + start identify if (step === 'select') { const startIdentify = async (mac) => { setSelectedMac(mac); setErr(null); setBusy(true); const dev = unbound.find(u => u.mac_address === mac); try { if (dev?.has_mcp_expander) { // Dør-cam — trigger relé 0 await window.api.camerasIdentifyDoor(mac, {}); setStep('identify'); } else { // Bilde-cam — ta bilde await window.api.camerasCaptureTest(mac, {}); setStep('identify'); } } catch (e) { setErr(e.message); } setBusy(false); }; return (

{lang === 'no' ? 'Velg en uidentifisert enhet' : 'Select an unidentified device'}

{unbound.map(u => ( ))}
MACFirmwareTypeSett
{u.mac_address} {u.firmware_version || '—'} {u.has_mcp_expander ? Dør-cam (MCP detected) : Bilde-cam} {new Date(u.last_seen_at).toLocaleString('no-NO', { timeStyle: 'short' })}
{err &&
❌ {err}
}
); } // STEP 2: Operatør forteller hvilken dør / hylle de så if (step === 'identify' && selected) { const submitDoor = async () => { const doorNum = parseInt(doorInput, 10); if (!doorNum || doorNum < 1 || doorNum > 24) { setErr(lang === 'no' ? 'Dør må være 1-24' : 'Door must be 1-24'); return; } setBusy(true); try { const r = await window.api.camerasIdentifyDoor(selected.mac_address, { door_number: doorNum }); setSuggestion(r); setStep('bind'); } catch (e) { setErr(e.message); } setBusy(false); }; const submitShelf = async () => { const shelfNum = parseInt(shelfInput, 10); if (!shelfNum || shelfNum < 1 || shelfNum > 24) { setErr(lang === 'no' ? 'Hylle må være 1-24' : 'Shelf must be 1-24'); return; } setBusy(true); try { const r = await window.api.camerasCaptureTest(selected.mac_address, { observed_shelf: shelfNum }); setSuggestion(r); setStep('bind'); } catch (e) { setErr(e.message); } setBusy(false); }; const retrigger = async () => { try { if (selected.has_mcp_expander) await window.api.camerasIdentifyDoor(selected.mac_address, {}); else await window.api.camerasCaptureTest(selected.mac_address, {}); } catch (e) { setErr(e.message); } }; return (

{lang === 'no' ? 'Identifiser enhet' : 'Identify device'} {selected.mac_address}

{selected.has_mcp_expander ? ( <>
🚪 {lang === 'no' ? 'En dør har akkurat åpnet seg på automaten.' : 'A door just opened on the machine.'}
{lang === 'no' ? 'Hvilket dørnummer var det? (1-24)' : 'Which door number was it? (1-24)'}
setDoorInput(e.target.value)} className="input" style={{ width: 100 }} placeholder="1-24" autoFocus />
) : ( <>
📷 {lang === 'no' ? 'Et bilde ble nettopp tatt.' : 'A picture was just taken.'}
{lang === 'no' ? 'Sjekk live-bildet (hvis tilgjengelig) — hvilken hylle ser du i øvre venstre hjørne?' : 'Check the live image — which shelf is in the top-left corner?'}
setShelfInput(e.target.value)} className="input" style={{ width: 100 }} placeholder="1-24" autoFocus />
)}
{err &&
❌ {err}
}
); } // STEP 3: Bekreft bindingen if (step === 'bind' && selected && suggestion) { const camId = suggestion.suggested_cam_id; const doBind = async () => { setBusy(true); try { await window.api.camerasBind({ mac_address: selected.mac_address, automat_id: machine.id, cam_id: camId, }); await onRefresh(); setStep('done'); } catch (e) { setErr(e.message); } setBusy(false); }; return (

{lang === 'no' ? 'Bekreft binding' : 'Confirm binding'}

{suggestion.base_door && }
{err &&
❌ {err}
}
); } // STEP 4: Done! if (step === 'done') { return (
{lang === 'no' ? 'Bundet!' : 'Bound!'} {missing.length - 1 > 0 && ( {missing.length - 1} {lang === 'no' ? 'igjen' : 'remaining'} )}
); } return null; }; /* ===== ROI-marker: canvas der operatør tegner rektangler over hyller ===== */ const RoiMarker = ({ machine, camId, cameras, lang, onClose, onSaved }) => { const cam = cameras.find(c => c.cam_id === camId); // Hardkodet shelves-per-cam fra layout const SHELVES = { 1: [19, 20, 22, 23], 2: [20, 21, 23, 24], 3: [13, 14, 16, 17], 4: [14, 15, 17, 18], 5: [7, 8, 10, 11], 6: [8, 9, 11, 12], 7: [1, 2, 4, 5], 8: [2, 3, 5, 6], }; const expectedShelves = SHELVES[camId] || []; const [boxes, setBoxes] = React.useState( (cam?.roi_config || []).map(r => ({ shelf_id: r.shelf_id, bbox: r.bbox, rotation_deg: r.rotation_deg || 0 })) ); const [drawing, setDrawing] = React.useState(null); // {x, y} mens man drar const [currentShelfIdx, setCurrentShelfIdx] = React.useState(0); const [busy, setBusy] = React.useState(false); const containerRef = React.useRef(null); // Hent bilde med JWT-auth via authedImageUrl (lager blob-URL) const [imageUrl, setImageUrl] = React.useState(null); const [imageErr, setImageErr] = React.useState(null); const reloadImage = React.useCallback(() => { setImageErr(null); window.authedImageUrl(window.api.camerasLastFrameUrl(machine.id, camId)) .then(setImageUrl) .catch(e => setImageErr(e.message)); }, [machine.id, camId]); React.useEffect(() => { reloadImage(); }, [reloadImage]); const captureNow = async () => { setBusy(true); try { await window.api.camerasCaptureNow(machine.id, camId); alert(lang === 'no' ? 'Bilde-kommando sendt. Reload bildet om ~5 sek.' : 'Capture sent. Reload in ~5s.'); } catch (e) { alert(e.message); } setBusy(false); }; // Pointer Events (2026-09-04): virker likt for mus OG touch — før var // ROI-tegning umulig på mobil/nettbrett (kun mouse-eventer, og touch-drag // scrollet siden i stedet). touchAction:'none' + setPointerCapture holder // gesten i containeren. const onMouseDown = (e) => { if (currentShelfIdx >= expectedShelves.length) return; try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} const rect = containerRef.current.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; const y = (e.clientY - rect.top) / rect.height; setDrawing({ startX: x, startY: y, x, y }); }; const onMouseMove = (e) => { if (!drawing) return; const rect = containerRef.current.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; const y = (e.clientY - rect.top) / rect.height; setDrawing({ ...drawing, x, y }); }; const onMouseUp = () => { if (!drawing) return; const x = Math.min(drawing.startX, drawing.x); const y = Math.min(drawing.startY, drawing.y); const w = Math.abs(drawing.x - drawing.startX); const h = Math.abs(drawing.y - drawing.startY); if (w > 0.02 && h > 0.02) { const shelfId = expectedShelves[currentShelfIdx]; const newBoxes = boxes.filter(b => b.shelf_id !== shelfId); newBoxes.push({ shelf_id: shelfId, bbox: [x, y, w, h], rotation_deg: 0 }); setBoxes(newBoxes); setCurrentShelfIdx(Math.min(currentShelfIdx + 1, expectedShelves.length)); } setDrawing(null); }; const save = async () => { setBusy(true); try { await window.api.camerasSaveRoi(machine.id, camId, boxes); onSaved(); } catch (e) { alert(e.message); } setBusy(false); }; const clear = () => { setBoxes([]); setCurrentShelfIdx(0); }; return ( <>

ROI · Cam {camId}

{lang === 'no' ? `Tegn rektangler rundt hver hylle i bildet — neste hylle: ${expectedShelves[currentShelfIdx] || 'ferdig'}` : `Draw rectangles around each shelf — next: ${expectedShelves[currentShelfIdx] || 'done'}`}
setDrawing(null)}> {imageUrl && ( )} {!imageUrl && (
{imageErr ? `❌ ${imageErr}` : '⏳ Laster…'}
)} {boxes.map((b) => (
{b.shelf_id}
))} {drawing && (
)}
{boxes.length}/{expectedShelves.length} {lang === 'no' ? 'tegnet' : 'drawn'}
); }; /* ===== Helsesjekk-historikk + live status ===== */ const CameraHealth = ({ machine, lang }) => { const [history, setHistory] = React.useState([]); const [loading, setLoading] = React.useState(true); const refresh = React.useCallback(async () => { setLoading(true); try { setHistory(await window.api.camerasHealth(machine.id, 100)); } catch (e) { console.warn(e); } setLoading(false); }, [machine.id]); React.useEffect(() => { refresh(); }, [refresh]); // Grupper per cam_id for tidslinje const byCam = {}; history.forEach(h => { if (!byCam[h.cam_id]) byCam[h.cam_id] = []; byCam[h.cam_id].push(h); }); return (
{lang === 'no' ? 'Helsesjekk-historikk' : 'Health check history'}
{history.length} sjekker
{history.length === 0 ? (
{lang === 'no' ? 'Ingen helsesjekker registrert ennå. Kontrolleren kjører timetlige sjekker automatisk.' : 'No checks yet'}
) : ( {history.map((h, i) => ( ))}
Tid Cam Status Heap RSSI MCP Latency Firmware Feil
{new Date(h.check_at).toLocaleString('no-NO', { dateStyle: 'short', timeStyle: 'medium' })} #{h.cam_id} {h.status === 'ok' ? OK : h.status === 'timeout' ? Timeout : h.status === 'missing_mcp' ? MCP missing : {h.status}} {h.free_heap ? `${Math.round(h.free_heap / 1024)} KB` : '—'} {h.rssi != null ? `${h.rssi} dB` : '—'} {h.mcp_detected === true ? '✓' : h.mcp_detected === false ? '✗' : '—'} {h.response_time_ms ? `${h.response_time_ms} ms` : '—'} {h.firmware || '—'} {h.error && {h.error}}
)}
); }; Object.assign(window, { CameraSetupPage, CameraOverview, CameraWizard, RoiMarker, CameraHealth });