// screens-simulator.jsx — Virtual Demo Stand // // Wires to the simulation service via Keeper.api: // • simulatorDevices() → GET /simulator/devices // • simulatorScenariosList() → GET /simulator/scenarios // • simulatorScenarioCreate(payload) → POST /simulator/scenarios // • simulatorScenarioStart(id) → POST /simulator/scenarios/{id}/start // • simulatorScenarioTick(id) → POST /simulator/scenarios/{id}/tick // • simulatorScenarioTelemetry(id) → GET /simulator/scenarios/{id}/telemetry // • simulatorScenarioStop(id) → POST /simulator/scenarios/{id}/stop // // Falls back silently to window.DATA.devices when the backend is offline, // mapping the demo device records to the minimal shape the screen needs. /* ─── CP-16 Virtual Demo Stand ────────────────────────────── */ function SimulatorStand() { // ── Scenario options ───────────────────────────────────────────────────── const SCENARIOS = [ { id: "normal", label: "Норма", icon: "ri-checkbox-circle-line", color: "#26972B" }, { id: "heatwave", label: "Тепловой удар", icon: "ri-temp-hot-line", color: "#E5342B" }, { id: "irrigation_leak", label: "Утечка полива", icon: "ri-drop-line", color: "#FF7A00" }, { id: "low_pollination", label: "Низкое опыление", icon: "ri-bug-line", color: "#a04200" }, ]; const EVENT_INJECTIONS = [ { id: "pest_outbreak", label: "Очаг вредителей", icon: "ri-bug-2-line", scenario_id: "low_pollination", summary: "Сценарий backend: low_pollination", default_duration_sec: 360, }, { id: "weather_change", label: "Резкая смена погоды", icon: "ri-thunderstorms-line", scenario_id: "heatwave", summary: "Сценарий backend: heatwave", default_duration_sec: 300, }, { id: "equipment_failure", label: "Сбой оборудования", icon: "ri-alarm-warning-line", scenario_id: "irrigation_leak", summary: "Сценарий backend: irrigation_leak", default_duration_sec: 420, }, ]; // ── Component state ────────────────────────────────────────────────────── const [devices, setDevices] = React.useState([]); // VirtualDeviceSpec[] const [loading, setLoading] = React.useState(true); // initial devices fetch const [error, setError] = React.useState(null); // fetch error message const [usingFallback, setUsingFallback] = React.useState(false); // true = offline demo data const [selectedDevice, setSelectedDevice] = React.useState(null); // device id string const [scenario, setScenario] = React.useState("normal"); const [sample, setSample] = React.useState(null); // last telemetry sample const [sampleLoading, setSampleLoading] = React.useState(false); const [sampleError, setSampleError] = React.useState(null); const [scenarioStatus, setScenarioStatus] = React.useState(null); // "sending"|"ok"|"error" const [scenarioRunState, setScenarioRunState] = React.useState("idle"); // "idle"|"running"|"stopping" const [activeScenarioId, setActiveScenarioId] = React.useState(null); const [activeInjectionId, setActiveInjectionId] = React.useState(null); const [remainingSec, setRemainingSec] = React.useState(null); const [duration, setDuration] = React.useState(300); // seconds const autoTickTimerRef = React.useRef(null); const countdownTimerRef = React.useRef(null); const statusResetTimerRef = React.useRef(null); const tickInFlightRef = React.useRef(false); const activeScenarioIdRef = React.useRef(null); const runDeadlineRef = React.useRef(null); // ── Fetch virtual device catalogue on mount ────────────────────────────── React.useEffect(() => { let cancelled = false; setLoading(true); setError(null); Keeper.api.simulatorDevices() .then((data) => { if (cancelled) return; // API returns array of VirtualDeviceSpec objects; normalise to at least // { id, name, type } so the UI always has something to display. const list = Array.isArray(data) ? data : (data.devices || []); setDevices(list); setSelectedDevice(list.length > 0 ? (list[0].device_id || list[0].id) : null); setUsingFallback(false); }) .catch(() => { if (cancelled) return; // Backend offline — fall back to window.DATA.devices, mapping to minimal shape. const fallback = (window.DATA && window.DATA.devices || []).map(d => ({ device_id: d.id, name: d.name, type: d.type, zone: d.zone, icon: d.icon, _demo: true, })); setDevices(fallback); setSelectedDevice(fallback.length > 0 ? fallback[0].device_id : null); setUsingFallback(true); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, []); function toVirtualDeviceSpec(device) { return { device_id: device.device_id || device.id || selectedDevice, name: device.name || "Virtual Device", greenhouse_id: device.greenhouse_id || "gh-default", zone_id: device.zone_id || device.zone || "zone-1", firmware_version: device.firmware_version || "1.0.2", battery_pct: typeof device.battery_pct === "number" ? device.battery_pct : 90, charging: typeof device.charging === "boolean" ? device.charging : false, mesh_hop_count: typeof device.mesh_hop_count === "number" ? device.mesh_hop_count : 1, gate_mode: device.gate_mode || "AUTO", sample_interval_sec: typeof device.sample_interval_sec === "number" ? device.sample_interval_sec : 60, }; } function pickTelemetryByDevice(events, deviceId) { if (!Array.isArray(events) || events.length === 0) return null; return events.find((event) => event.device_id === deviceId) || events[0] || null; } function clearStatusResetTimer() { if (statusResetTimerRef.current) { clearTimeout(statusResetTimerRef.current); statusResetTimerRef.current = null; } } function scheduleStatusReset(timeoutMs) { clearStatusResetTimer(); statusResetTimerRef.current = setTimeout(() => { setScenarioStatus(null); statusResetTimerRef.current = null; }, timeoutMs); } function clearAutoTickLoop() { if (autoTickTimerRef.current) { clearInterval(autoTickTimerRef.current); autoTickTimerRef.current = null; } if (countdownTimerRef.current) { clearInterval(countdownTimerRef.current); countdownTimerRef.current = null; } runDeadlineRef.current = null; tickInFlightRef.current = false; setRemainingSec(null); } async function tickAndLoadScenarioSample(scenarioId, deviceId, scen) { await Keeper.api.simulatorScenarioTick(scenarioId); const telemetry = await Keeper.api.simulatorScenarioTelemetry(scenarioId); const data = pickTelemetryByDevice(telemetry, deviceId); if (data) { setSample(data); setSampleError(null); return; } const synth = buildSyntheticSample(deviceId, scen); setSample(synth); setSampleError("Телеметрия сценария пока недоступна — показаны синтетические данные"); } async function stopActiveScenario(reason = "manual") { const runningId = activeScenarioIdRef.current; clearAutoTickLoop(); if (!runningId) { setScenarioRunState("idle"); return; } setScenarioRunState("stopping"); try { await Keeper.api.simulatorScenarioStop(runningId); } catch (_) { // Ignore stop failures during cleanup/switch to keep UI responsive. } finally { if (activeScenarioIdRef.current === runningId) { activeScenarioIdRef.current = null; setActiveScenarioId(null); } setScenarioRunState("idle"); if (reason === "auto-complete") { setScenarioStatus("ok"); scheduleStatusReset(2500); } } } function startAutoTickLoop(scenarioId, deviceId, scen, tickIntervalSec, runDurationSec) { clearAutoTickLoop(); const deadlineMs = Date.now() + (runDurationSec * 1000); runDeadlineRef.current = deadlineMs; setRemainingSec(runDurationSec); const updateCountdown = () => { if (!runDeadlineRef.current) return; const left = Math.max(0, Math.ceil((runDeadlineRef.current - Date.now()) / 1000)); setRemainingSec(left); if (left <= 0 && activeScenarioIdRef.current === scenarioId) { void stopActiveScenario("auto-complete"); } }; countdownTimerRef.current = setInterval(updateCountdown, 1000); updateCountdown(); autoTickTimerRef.current = setInterval(async () => { if (tickInFlightRef.current || activeScenarioIdRef.current !== scenarioId) return; if (runDeadlineRef.current && Date.now() >= runDeadlineRef.current) { void stopActiveScenario("auto-complete"); return; } tickInFlightRef.current = true; try { await tickAndLoadScenarioSample(scenarioId, deviceId, scen); } catch (_) { const synth = buildSyntheticSample(deviceId, scen); setSample(synth); setSampleError("Сервис недоступен — показаны синтетические данные"); } finally { tickInFlightRef.current = false; } }, Math.max(1, tickIntervalSec) * 1000); } // ── Sample telemetry for the currently selected device + scenario ───────── async function fetchSample() { if (!selectedDevice) return; setSampleLoading(true); setSampleError(null); if (!activeScenarioIdRef.current) { const synth = buildSyntheticSample(selectedDevice, scenario); setSample(synth); setSampleError("Сценарий не активирован — показаны синтетические данные"); setSampleLoading(false); return; } try { await tickAndLoadScenarioSample(activeScenarioIdRef.current, selectedDevice, scenario); } catch (_) { // Generate a local synthetic sample so the demo still looks alive // even when the simulator service is unreachable. const synth = buildSyntheticSample(selectedDevice, scenario); setSample(synth); setSampleError("Сервис недоступен — показаны синтетические данные"); } finally { setSampleLoading(false); } } // ── Push a scenario activation to the simulation service ───────────────── async function activateScenario(nextScenario = scenario) { if (!selectedDevice) return; const scenarioName = nextScenario || "normal"; setScenarioStatus("sending"); clearStatusResetTimer(); try { const selected = devices.find((device) => (device.device_id || device.id) === selectedDevice); if (!selected) throw new Error("selected device not found"); if (activeScenarioIdRef.current) { await stopActiveScenario("restart"); } const existing = await Keeper.api.simulatorScenariosList(); const runningForDevice = Array.isArray(existing) ? existing.find((item) => { if (item.status !== "running" || !Array.isArray(item.devices)) return false; return item.devices.some((d) => (d.device_id || d.id) === selectedDevice); }) : null; if (runningForDevice && runningForDevice.scenario_id) { await Keeper.api.simulatorScenarioStop(runningForDevice.scenario_id); } const created = await Keeper.api.simulatorScenarioCreate({ name: scenarioName, devices: [toVirtualDeviceSpec(selected)], tick_interval_sec: Math.max(1, selected.sample_interval_sec || 60), meta: { requested_duration_sec: String(duration), }, }); const scenarioId = created && created.scenario_id; if (!scenarioId) throw new Error("missing scenario_id"); await Keeper.api.simulatorScenarioStart(scenarioId); await tickAndLoadScenarioSample(scenarioId, selectedDevice, scenarioName); setScenario(scenarioName); activeScenarioIdRef.current = scenarioId; setActiveScenarioId(scenarioId); setScenarioRunState("running"); startAutoTickLoop( scenarioId, selectedDevice, scenarioName, Math.max(1, selected.sample_interval_sec || 60), duration, ); setScenarioStatus("ok"); scheduleStatusReset(3000); } catch (_) { setScenarioRunState("idle"); setScenarioStatus("error"); scheduleStatusReset(4000); } } async function handleDeviceSelect(nextDeviceId) { if (nextDeviceId === selectedDevice) return; if (activeScenarioIdRef.current) { await stopActiveScenario("switch"); } setSelectedDevice(nextDeviceId); setSample(null); setSampleError(null); setScenarioStatus(null); } async function handleScenarioSelect(nextScenario) { if (nextScenario === scenario) return; if (activeScenarioIdRef.current) { await stopActiveScenario("switch"); } setScenario(nextScenario); setActiveInjectionId(null); setSample(null); setSampleError(null); setScenarioStatus(null); } async function injectEvent(eventId) { const config = EVENT_INJECTIONS.find((item) => item.id === eventId); if (!config) return; setActiveInjectionId(config.id); setDuration(config.default_duration_sec); await activateScenario(config.scenario_id); } // Stop timers and active scenario when leaving the screen. React.useEffect(() => { return () => { clearStatusResetTimer(); clearAutoTickLoop(); if (activeScenarioIdRef.current) { Keeper.api.simulatorScenarioStop(activeScenarioIdRef.current).catch(() => {}); } }; }, []); // ── Helpers ─────────────────────────────────────────────────────────────── // Build a plausible synthetic TelemetryEvent when the backend is offline. // Shape mirrors TelemetryEvent sub-objects from keeper_contracts/telemetry.py // so the display logic works identically for live and synthetic samples. function buildSyntheticSample(deviceId, scen) { const base = { device_id: deviceId, scenario: scen, timestamp: new Date().toISOString(), }; if (scen === "heatwave") { return { ...base, microclimate: { temperature_c: 38.4, humidity_pct: 28.1, co2_ppm: 920.0, insolation_lux: 22000.0 }, irrigation: { substrate_moisture_pct: 55.0, drip_flow_lh: 1.4, substrate_ec: 3.1, feed_ec: 2.8, drain_ec: 3.6 }, pollination: { departures: 98, returns: 91, activity_index: 0.93 }, power: { battery_pct: 61, charging: false }, connectivity: { online: true, signal_dbm: -72, mesh_hop_count: 2 }, }; } if (scen === "irrigation_leak") { return { ...base, microclimate: { temperature_c: 23.1, humidity_pct: 91.4, co2_ppm: 760.0, insolation_lux: 16800.0 }, irrigation: { substrate_moisture_pct: 47.0, drip_flow_lh: 2.9, substrate_ec: 2.6, feed_ec: 2.6, drain_ec: 3.6 }, pollination: { departures: 135, returns: 131, activity_index: 0.97 }, power: { battery_pct: 54, charging: false }, connectivity: { online: true, signal_dbm: -68, mesh_hop_count: 1 }, }; } if (scen === "low_pollination") { return { ...base, microclimate: { temperature_c: 22.8, humidity_pct: 65.0, co2_ppm: 780.0, insolation_lux: 17500.0 }, irrigation: { substrate_moisture_pct: 62.0, drip_flow_lh: 1.2, substrate_ec: 2.4, feed_ec: 2.1, drain_ec: 2.0 }, pollination: { departures: 52, returns: 38, activity_index: 0.73 }, power: { battery_pct: 73, charging: false }, connectivity: { online: true, signal_dbm: -65, mesh_hop_count: 1 }, }; } // normal return { ...base, microclimate: { temperature_c: 22.4, humidity_pct: 68.2, co2_ppm: 812.0, insolation_lux: 18400.0 }, irrigation: { substrate_moisture_pct: 61.0, drip_flow_lh: 1.2, substrate_ec: 2.4, feed_ec: 2.1, drain_ec: 2.0 }, pollination: { departures: 142, returns: 138, activity_index: 0.97 }, power: { battery_pct: 84, charging: false }, connectivity: { online: true, signal_dbm: -62, mesh_hop_count: 1 }, }; } // Format a raw value for display (round numbers, replace "." with "," for RU locale) function fmt(v) { if (v == null) return "—"; if (typeof v === "number") return v.toFixed(1).replace(".", ","); return String(v); } function fmtDurationShort(sec) { if (!sec || sec <= 0) return "—"; if (sec < 60) return `${sec} c`; if (sec < 3600) return `${Math.round(sec / 60)} мин`; return "1 ч"; } const activeScenarioMeta = SCENARIOS.find(s => s.id === scenario) || SCENARIOS[0]; const activeDevice = devices.find(d => (d.device_id || d.id) === selectedDevice); const isWalkthroughDeviceDone = Boolean(selectedDevice); const isWalkthroughConfigDone = Boolean(activeScenarioId) || scenario !== "normal" || duration !== 300 || Boolean(activeInjectionId); const isWalkthroughRunDone = Boolean(activeScenarioId); const isWalkthroughSampleDone = Boolean(sample); const walkthroughSteps = [ { id: "device", icon: "ri-cpu-line", title: "Выбрать устройство", details: "Назначьте виртуальный сенсор для сценария", done: isWalkthroughDeviceDone, }, { id: "configure", icon: "ri-sliders-line", title: "Настроить сценарий", details: "Выберите режим, длительность или инъекцию события", done: isWalkthroughConfigDone, }, { id: "run", icon: "ri-play-circle-line", title: "Запустить прогон", details: "Отправьте сценарий и дождитесь статуса «Выполняется»", done: isWalkthroughRunDone, }, { id: "review", icon: "ri-bar-chart-2-line", title: "Разобрать телеметрию", details: "Проверьте KPI и объясните реакцию системы", done: isWalkthroughSampleDone, }, ]; const walkthroughDoneCount = walkthroughSteps.filter((step) => step.done).length; const walkthroughProgressPct = Math.round((walkthroughDoneCount / walkthroughSteps.length) * 100); const walkthroughCurrentIndex = walkthroughSteps.findIndex((step) => !step.done); const walkthroughFocusIndex = walkthroughCurrentIndex === -1 ? walkthroughSteps.length - 1 : walkthroughCurrentIndex; const walkthroughFocusStepId = walkthroughSteps[walkthroughFocusIndex].id; const walkthroughStatusLabel = walkthroughDoneCount === walkthroughSteps.length ? "Готово" : `Шаг ${walkthroughFocusIndex + 1} из ${walkthroughSteps.length}`; const walkthroughStepStates = walkthroughSteps.map((step, index) => { const state = step.done ? "done" : index === walkthroughFocusIndex ? "current" : "pending"; return Object.assign({}, step, { state }); }); const walkthroughHint = (() => { if (!isWalkthroughDeviceDone) { return { icon: "ri-mouse-line", title: "Выберите любое устройство из каталога", message: "Для демо-тренинга начинаем с фиксации устройства в левой колонке.", actionLabel: "", actionKind: "none", }; } if (!isWalkthroughConfigDone) { return { icon: "ri-lightbulb-line", title: "Подсказка: используйте готовый training-профиль", message: "Для sales-показа удобно включить «Тепловой удар» и длительность 10 минут.", actionLabel: "Применить training-профиль", actionKind: "preset", }; } if (!isWalkthroughRunDone) { return { icon: "ri-play-line", title: "Сценарий готов к запуску", message: "Нажмите запуск, чтобы получить живой поток телеметрии для демонстрации.", actionLabel: "Запустить сейчас", actionKind: "run", }; } if (!isWalkthroughSampleDone) { return { icon: "ri-radar-line", title: "Сценарий выполняется", message: "Снимите свежий sample, чтобы показать KPI, JSON payload и динамику.", actionLabel: "Загрузить sample", actionKind: "sample", }; } return { icon: "ri-checkbox-circle-line", title: "Walkthrough завершён", message: "Можно повторить с другим событием или остановить текущий прогон.", actionLabel: activeScenarioId ? "Остановить сценарий" : "", actionKind: activeScenarioId ? "stop" : "none", }; })(); async function handleWalkthroughAction() { if (walkthroughHint.actionKind === "preset") { await handleScenarioSelect("heatwave"); setDuration(600); setActiveInjectionId(null); return; } if (walkthroughHint.actionKind === "run") { await activateScenario(); return; } if (walkthroughHint.actionKind === "sample") { await fetchSample(); return; } if (walkthroughHint.actionKind === "stop") { await stopActiveScenario("manual"); } } // ── Render ──────────────────────────────────────────────────────────────── return (
{/* ── Page header ─────────────────────────────────────────────────── */}
Виртуальный стенд
Симуляция телеметрии устройств · пробуйте сценарии без реального оборудования
{usingFallback && ( Симулятор офлайн · демо-данные )} {activeScenarioId ? ( ) : ( )}
{/* ── Loading skeleton ─────────────────────────────────────────────── */} {loading && (
Загрузка каталога виртуальных устройств…
)} {!loading && ( <>

Guided Walkthrough · Sales / Training

Пошаговый режим помогает быстро провести демо на стенде
{walkthroughStatusLabel}
    {walkthroughStepStates.map((step, index) => (
  1. {step.state === "done" ? : (index + 1)}
    {step.title}
    {step.details}
    {step.state === "done" ? "готово" : step.state === "current" ? "сейчас" : "далее"}
  2. ))}
{walkthroughHint.title}
{walkthroughHint.message}
{walkthroughHint.actionKind !== "none" && ( )}
{/* ── Left panel: device selector + scenario picker ─────────────── */}
{/* Device catalogue */}

Виртуальные устройства

{devices.length} шт.
{devices.length === 0 && (
Устройства не найдены
)} {devices.map(d => { const devId = d.device_id || d.id; const isSelected = devId === selectedDevice; return (
{ void handleDeviceSelect(devId); }} style={{ cursor: "pointer" }} >
{d.name || devId} {d.type || d.device_type || "—"} {d.zone ? " · " + d.zone : ""}
{d._demo && ( demo )}
); })}
{/* Scenario picker */}

Сценарий

{SCENARIOS.map(s => ( ))}
{/* Duration stepper */}
Длительность
{[60, 300, 600, 3600].map(sec => ( ))}
{/* Event injection controls */}

Инъекция событий

{EVENT_INJECTIONS.map((eventConfig) => ( ))}
События мапятся на backend-сценарии: pest outbreak → low_pollination, weather change → heatwave, equipment failure → irrigation_leak.
{/* ── Right panel: telemetry sample ─────────────────────────────── */}
{/* Device + scenario context header */}
{activeDevice ? (activeDevice.name || selectedDevice) : "Устройство не выбрано"}
{selectedDevice} · сценарий: {activeScenarioMeta.label}
{activeScenarioId && (
Сессия: {activeScenarioId} · осталось: {remainingSec != null ? `${remainingSec} c` : "—"}
)}
{/* Sample error banner */} {sampleError && (
{sampleError}
)} {/* No sample yet placeholder */} {!sample && !sampleLoading && (
Нет данных
Нажмите «Обновить» или «Пробник», чтобы получить телеметрию
)} {/* Telemetry sample cards */} {sample && ( <> {/* ── Microclimate sub-object ─────────────────────────────── */}
Микроклимат
{[ { path: ["microclimate","temperature_c"], label: "Температура", unit: "°C", icon: "ri-temp-hot-line", color: "#FF7A00" }, { path: ["microclimate","humidity_pct"], label: "Влажность", unit: "%", icon: "ri-drop-line", color: "#007BFB" }, { path: ["microclimate","co2_ppm"], label: "CO₂", unit: "ppm", icon: "ri-bubble-chart-line", color: "#7FB539" }, { path: ["microclimate","insolation_lux"], label: "Освещённость", unit: "лк", icon: "ri-sun-line", color: "#E8B84A" }, ].map(({ path, label, unit, icon, color }) => { const val = sample[path[0]] ? sample[path[0]][path[1]] : sample[path[1]]; return (
{label}
{fmt(val)}{unit}
); })}
{/* ── Irrigation sub-object ───────────────────────────────── */}
Полив и питание
{[ { path: ["irrigation","substrate_ec"], label: "EC субстрата", unit: "mS/cm", icon: "ri-flask-line", color: "#FFB300" }, { path: ["irrigation","drain_ec"], label: "EC дренажа", unit: "mS/cm", icon: "ri-flask-line", color: "#FF7A00" }, { path: ["irrigation","drip_flow_lh"], label: "Расход полива",unit: "л/ч", icon: "ri-water-flash-line", color: "#007BFB" }, { path: ["irrigation","substrate_moisture_pct"],label: "Влажность субстрата",unit:"%",icon: "ri-drop-line", color: "#26972B" }, { path: ["irrigation","feed_ec"], label: "EC раствора", unit: "mS/cm", icon: "ri-test-tube-line", color: "#7FB539" }, { path: ["power","battery_pct"], label: "Батарея", unit: "%", icon: "ri-battery-line", color: "#a04200" }, ].map(({ path, label, unit, icon, color }) => { const val = sample[path[0]] ? sample[path[0]][path[1]] : sample[path[1]]; return (
{label}
{fmt(val)}{unit}
); })}
{/* Raw JSON payload for transparency */}
Сырые данные · TelemetryEvent {sample.timestamp && ( {new Date(sample.timestamp).toLocaleTimeString("ru-RU")} )}
                    
                      {JSON.stringify(sample, null, 2)}
                    
                  
)} {/* Scenario lifecycle status */} {(scenarioStatus || activeScenarioId) && (
{activeScenarioId ? "Сценарий выполняется (автотик)" : scenarioStatus === "sending" ? "Отправка сценария…" : scenarioStatus === "ok" ? "Сценарий запущен" : "Ошибка активации сценария"}
{selectedDevice} · {activeScenarioMeta.label} · {activeScenarioId ? `до завершения ${remainingSec != null ? `${remainingSec} c` : "—"}` : fmtDurationShort(duration)}
)}
)}
); } window.SimulatorStand = SimulatorStand;