medOS ultra

Room Monitor System

Generic configurable smart-room telemetry dashboard with HomeKit-style devices.

8 min read diagramsUpdated 2026-05-14docs/architecture/room-monitor-system.md

A generic, configurable “smart room” dashboard with HomeKit-style device pairing. The same implementation drives ICU rooms, regular wards, OR suites, exam rooms, nursing-home suites, lobbies, retail spaces — any monitored physical space. The default content is fully neutral; per-tenant deployments override every label, device group, energy highlight, and section via TeleViewConfig + props on ``.

Quick glance

Surface Path Purpose
Live dashboard (component) web/packages/adt-kit/src/admission/components/wardview/TeleView/ The dashboard primitives — composable from any host
Live dashboard (page) /ipd/room-monitor?ward=<id> Standalone page with ward selector + link to admin
Admission ward view toggle /admission/wardview?view=tele Embedded in the existing ward-view chrome
In-patient ward view toggle /in-patient/wardview?view=tele Same toggle, in-patient ward chrome
IPD landing quick link /ipd/command-center → “Room Monitor” card One-click launcher from the IPD home
In-patient landing widget /in-patient → “Room Monitor” card Same launcher from the in-patient home
Admin / pairing page /admin/room-monitor Register, edit, remove, pair, send-test-reading
Realtime tables tele_devices + tele_device_readings (Supabase) Read-only mirror; MongoDB owns the canonical ledger

Architecture

Physical device  ──MQTT/BACnet/etc.──▶  Backend collector
                                              │
                                              ├──▶ MongoDB (canonical write ledger)
                                              │
                                              └──▶ Supabase (read-only mirror)
                                                      ↓
                                                postgres_changes
                                                      ↓
                                       useTeleRealtime  ◀──┐
                                              │            │
                                              │      every dashboard subscribes
                                              ↓            │
                                          TeleView ────────┘

Per project guardrails (web/CLAUDE.md):

  • MongoDB is the canonical write ledger.
  • Supabase is a read-only mirror. The frontend never writes to read-model tables in normal operation.
  • The admin pairing page is the one allowed exception while the backend collector pipeline is still under construction; the same code path becomes a “request → backend” call once the collector exists, with no UI changes.

Layers

1. Schema — web/supabase/migrations/20260513_room_telemetry_devices.sql

Two tables, both registered with the supabase_realtime publication:

  • tele_devices — registry. One row per physical/virtual device. Carries ward_id, category (hvac / ambient / lighting / network / energy / camera / medical), kind (free-form tag), bilingual labels, optional zone (for lighting), connected flag, optional connector_key resolving to a DeviceConnector implementation in diagnostic-devices/connectors.
  • tele_device_readings — latest reading per (device_id, reading_kind). Composite primary key keeps a single snapshot per kind; historical traces live in MongoDB.

RLS: read-all for anon; no INSERT / UPDATE / DELETE policies (only the service role can write). Apply with psql -f or via the Supabase SQL editor; the migration is idempotent (CREATE TABLE IF NOT EXISTS, etc.).

2. Service layer — web/src/services/tele-device.service.ts

teleDeviceService exposes:

Method Purpose
list({ wardId?, category? }) Fetch all devices, filtered
create(input) Insert; auto-generates an id if absent
update(id, patch) Field-level update
remove(id) Cascade-deletes readings via FK
toggleConnected(id, on) Convenience update
writeReading(input) Upsert one reading row — used by the admin “test reading” button
schemaApplied() Returns false when the migration hasn’t been applied — surfaces a friendly banner

Recognises both 42P01 (raw Postgres) and PGRST205 (Supabase REST schema-cache miss) and surfaces a single instruction: “Apply migration 20260513_room_telemetry_devices.sql, then refresh.”

3. Dashboard primitives — TeleView/

TeleView/
├── types.ts           ← TeleDevice, TeleSnapshot, TeleSections, TeleViewConfig
├── simulation.ts      ← useTeleSimulation() + defaultTeleConfig()
├── useTeleRealtime.ts ← Supabase subscription + initial fetch
├── cards.tsx          ← HvacCard, LightingCard, EnergyCard, NetworkCard, DeviceStatusCard, CameraCard, EnvironmentalCard, KpiCard, TeleCard
└── index.tsx          ← <TeleView> — composes everything

`` props:

Prop Default Notes
title "Room Monitor" Header text
wardName "Room" First KPI card label
wardSubtitle Smaller subtitle under wardName
config defaultTeleConfig() Device roster + groups + cameras + occupants
wardId Filters the Supabase subscription
sections all on { hvac?, camera?, lighting?, environmental?, energy?, network?, deviceStatus? }
occupantsLabel "Occupants" Second KPI card label
occupantsLabelTh Thai tooltip
devicesLabel "Devices" Suffix on KPI cards

A small chip next to the title shows the current data source:

  • 🟢 Live · Supabase when at least one device is registered for the ward and the channel is subscribed.
  • Simulation when no devices are registered or the tables aren’t applied yet — the local jitter sampler keeps the dashboard alive.

4. Pairing UX — web/src/containers/admin/room-monitor/

admin/room-monitor/
├── page.tsx             ← Admin page (table + manual-entry drawer + pairing trigger)
├── PairingFlow.tsx      ← Multi-step "Add accessory" dialog
└── factoryCatalog.ts    ← 17 device templates that the discovery scan returns

HomeKit-style pairing flow

Four steps in a single dialog with a top stepper:

  1. Discover — pulsing concentric rings around a central icon while the simulated scan runs. 6 random templates from factoryCatalog fade in over ~2s as cards with brand + blurb + RSSI. Each card is clickable.
  2. Pair — large product icon + synthesised serial. 8-digit code input in a monospaced field; an autofill link drops the right code (since this is a demo). A 1.2s spinner simulates the secure handshake before advancing.
  3. Configure — friendly name (bilingual), Ward / Room ID (dropdown of known wards or free-text), optional zone (for lighting templates). Add to room triggers teleDeviceService.create().
  4. Done — green check, summary card with the saved device, “Pair another” / “Done” buttons.

The same flow lives behind the Pair new accessory button on the admin page. The previous direct-form path is still available as Manual entry for power users.

Factory catalogue

17 templates spanning every category. Each carries: model + brand + emoji + accent + category + kind + bilingual default label + zone options (for lighting) + connector key (for auto-resolution against diagnostic-devices/connectors). Drop a new entry in FACTORY_TEMPLATES and it appears in the discovery scan immediately — no schema changes required.

5. Realtime stream — useTeleRealtime

Per-ward subscription to both tele_devices and tele_device_readings. Gracefully handles three failure modes:

  • Supabase not configured (placeholder env) → silently bails, dashboard uses simulation.
  • Tables not applied yet → 42P01 / PGRST205 swallowed, dashboard uses simulation.
  • No devices registered for this ward → hasLiveData = false, dashboard uses simulation.

State returned: { hasLiveData, isConnected, devices, readings }. teleRealtimeToDevices(state) maps the registry rows into the TeleDevice[] shape consumed by useTeleSimulation. The simulation runs over the real device list when present — connected devices show live values, disconnected devices stay in Standby.

Configuration model

Everything that’s hard-coded in the screenshot is overridable:

  • Title: prop or config.title
  • Occupancy label: prop or config.occupantsLabel
  • Visible sections: sections={{ camera: false, energy: false }} etc.
  • Device groups (rows in the “Device Status” card): config.deviceGroups
  • Energy highlights (rows under the bar chart): config.energyHighlights
  • Cameras: config.cameras
  • Occupant count: config.occupants (live count comes from the realtime device list in production)

Three demonstration presets ship in the sandbox target (?target=TeleView): hospital, nursing-home, office. Same component, three deployments, no fork.

Deployment

To turn on live data

  1. Apply the migration:

    psql "$SUPABASE_DB_URL" -f web/supabase/migrations/20260513_room_telemetry_devices.sql
    

    Or paste it into the Supabase SQL editor.

  2. Pair at least one device through /admin/room-monitor — either via the Pair new accessory flow (recommended) or Manual entry.

  3. (Until the backend collector ships) Click the ⚡ icon on a device row to push a test reading. The “Simulation” chip on the dashboard flips to “Live · Supabase” within ~250ms.

To wire a real backend collector

The collector runs server-side (Moleculer service, Deno edge function, or a separate process). On every device reading it should:

  1. Write the canonical record to MongoDB.
  2. Upsert into tele_device_readings via service-role credentials (onConflict: 'device_id,reading_kind').
  3. Update tele_devices.last_seen_at + connected when the device appears or drops off.

The frontend changes nothing — useTeleRealtime is already listening.

Gaps that remain

These need attention before a production rollout:

  1. Reading-value pipeuseTeleRealtime collects readings into a map but doesn’t yet feed them into the AmbientSnapshot / EnergySnapshot slots. Today only device PRESENCE flows into the dashboard; reading values still come from the jitter simulation. Wire state.readings into a derived snapshot in TeleView/index.tsx, falling back per-field when a reading is missing.

  2. Backend collector — see above. The pairing flow writes directly to Supabase as a demo path; production should funnel through the backend so MongoDB stays canonical.

  3. Real discoveryfactoryCatalog.pickRandomTemplates() is a simulation. Production discovery should be driven by an mDNS / BLE / BACnet scan exposed by the collector.

Files at a glance

File Purpose
web/supabase/migrations/20260513_room_telemetry_devices.sql Schema
web/src/services/tele-device.service.ts CRUD service
web/packages/adt-kit/src/admission/components/wardview/TeleView/types.ts Type definitions
web/packages/adt-kit/src/admission/components/wardview/TeleView/simulation.ts Default config + jitter engine
web/packages/adt-kit/src/admission/components/wardview/TeleView/useTeleRealtime.ts Supabase subscription
web/packages/adt-kit/src/admission/components/wardview/TeleView/cards.tsx Card primitives
web/packages/adt-kit/src/admission/components/wardview/TeleView/index.tsx ``
web/packages/adt-kit/src/admission/components/wardview/useWardBedsRealtime.ts Bed realtime hook (separate from telemetry)
web/packages/adt-kit/src/admission/components/wardview/WardView.tsx Ward + Patient + Tele toggle (admission)
web/packages/adt-kit/src/in-patient/components/wardview/WardView.tsx Ward + Patient + Tele toggle (in-patient)
web/src/containers/admin/room-monitor/page.tsx Admin page
web/src/containers/admin/room-monitor/PairingFlow.tsx HomeKit-style flow
web/src/containers/admin/room-monitor/factoryCatalog.ts Discovery templates
web/src/containers/ipd/room-monitor/page.tsx Standalone Room Monitor page
web/src/containers/ipd-command-center/components/IpdLandingHub.tsx IPD landing quick-link
web/packages/adt-kit/src/in-patient/components/dashboard/RoomMonitorWidget.tsx In-patient landing widget
web/src/routes/IPDRoutes.tsx /ipd/room-monitor route
web/src/routes/AdminRoutes.tsx /admin/room-monitor route
web/sandbox/registry.ts Sandbox entries: TeleView, PatientCardView, RoomMonitorAdmin
Ask Anything