/* 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 (
<>
Refresh
{lang === 'no' ? 'Kjør helsesjekk nå' : 'Run health check now'}
{cameras.length}/{expected.length} {lang === 'no' ? 'bundet' : 'bound'}
{unbound.length > 0 && ` · ${unbound.length} ${lang === 'no' ? 'venter' : 'pending'}`}
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'}
{expected.map(spec => {
const cam = camById[spec.cam_id];
const isBound = !!cam;
const status = cam?.health_status || 'unknown';
return (
{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 && (
identifyLed(spec.cam_id)}
title={lang === 'no' ? 'Blink LED' : 'Blink LED'}>
💡
)}
{isBound && (
setRoiTarget(spec.cam_id)} title="ROI">
ROI
)}
{isBound && (
unbind(spec.cam_id)}>
{lang === 'no' ? 'Frikoble' : 'Unbind'}
)}
);
})}
{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(', ')}>
)}
Refresh
);
}
// 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'}
MAC Firmware Type Sett
{unbound.map(u => (
{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' })}
startIdentify(u.mac_address)}>
{lang === 'no' ? 'Start ID' : 'Start ID'}
))}
{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' ? 'Bekreft' : 'Confirm'}
🔁 {lang === 'no' ? 'Trigg på nytt' : 'Re-trigger'}
>
) : (
<>
📷 {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 />
{lang === 'no' ? 'Bekreft' : 'Confirm'}
🔁 {lang === 'no' ? 'Ta nytt bilde' : 'Re-capture'}
>
)}
{err &&
❌ {err}
}
setStep('select')}>
← {lang === 'no' ? 'Tilbake' : 'Back'}
);
}
// 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 && }
✓ {lang === 'no' ? 'Bind enheten' : 'Bind device'}
setStep('identify')}>
← {lang === 'no' ? 'Tilbake' : 'Back'}
{err &&
❌ {err}
}
);
}
// STEP 4: Done!
if (step === 'done') {
return (
{lang === 'no' ? 'Bundet!' : 'Bound!'}
{missing.length - 1 > 0 && (
{missing.length - 1} {lang === 'no' ? 'igjen' : 'remaining'}
)}
{
setStep('select'); setSelectedMac(null); setDoorInput(''); setShelfInput('');
setSuggestion(null); setErr(null);
}}>
{lang === 'no' ? 'Neste enhet' : 'Next device'}
);
}
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 && (
)}
{lang === 'no' ? 'Ta nytt bilde' : 'Capture now'}
Reload
{lang === 'no' ? 'Tøm' : 'Clear'}
✓ {lang === 'no' ? 'Lagre' : 'Save'} ({boxes.length}/{expectedShelves.length})
{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
{loading ? '…' : 'Refresh'}
{history.length === 0 ? (
{lang === 'no' ? 'Ingen helsesjekker registrert ennå. Kontrolleren kjører timetlige sjekker automatisk.' : 'No checks yet'}
) : (
Tid
Cam
Status
Heap
RSSI
MCP
Latency
Firmware
Feil
{history.map((h, i) => (
{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 });