Document Version 5.0.0 — Auth, KPI Analytics, Camera/NVR, Multi-Hub v2 — April 2026
Supersedes v4.0.0 (March 2026) — Multi-Hub Native Routing, PreferenceManager, DataCacheManager
MOBIUS.TILES is a self-hosted smart-home control interface for Hubitat Elevation hubs. It provides a real-time tile dashboard for controlling lights, switches, dimmers, thermostats, locks, sensors, and cameras — with multi-hub support, per-user preferences, session-based authentication, historical KPI analytics, and WebRTC camera streams from a MediaMTX NVR.
| Layer | Technology | Version / Notes |
|---|---|---|
| Frontend | Vanilla ES6+ JavaScript Modules, jQuery (DOM helpers only), Bootstrap Icons | No framework. ~45 CSS files, ~30 JS modules. |
| Backend | Flask + Gunicorn | Python 3.12. 2500+ line app.py. SSE, REST, webhook receiver. |
| Database | PostgreSQL 16 | Auth, sessions, user prefs, hub config, device selections, KPI readings. Row-Level Security enabled. |
| Reverse Proxy | Nginx 1.27-alpine | SSL termination, static asset serving, upstream proxy to Flask. |
| Container | Docker + docker-compose | Services: nginx, tiles (Flask), postgres, postgrest. |
| WebSocket / SSE | Native WebSocket (Hubitat), SSE (Flask streaming) | Hubitat sends device state changes; Flask fans out via SSE to all browser tabs. |
| Camera | MediaMTX NVR (separate service), WHEP/WebRTC, MJPEG fallback | Streams accessed directly from browser via WHEP endpoint. NVR API for camera list/snaps. |
| Device Protocol | Hubitat Maker API (REST + WebSocket) | Multi-hub: main hub + N additional hubs. Commands routed per device's origin hub IP. |
| Module | File | Responsibility |
|---|---|---|
| TilesApp | TilesApp.js | Main orchestrator. Auth check → hub config load → device fetch → manager init → render loop. |
| HubitatAPI | HubitatAPI.js | Queue-managed REST calls to Hubitat Maker API. Handles retries and per-hub routing. |
| RequestQueue | RequestQueue.js | Serialized request queue preventing race conditions on rapid device commands. |
| SettingsManager | SettingsManager.js | Loads hub IP, app number, access tokens, NVR URL, font size, transparency from /api/settings. |
| PreferenceManager | PreferenceManager.js | Namespace-keyed preference storage: in-memory → localStorage → PostgreSQL. Debounced DB sync. |
| IlluminanceManager | IlluminanceManager.js | Tracks ambient light level from illuminance sensors; used for auto-mode suggestions. |
| DeviceStateManager | DeviceStateManager.js | Central state store. Observer pattern: subscribe(deviceId, cb) → updateAttribute() → notify all listeners. |
| WebSocketManager | WebSocketManager.js | Hubitat WebSocket connection. Receives device events, reconnects on drop, propagates to StateManager. |
| DeviceFactory | DeviceFactory.js | Creates typed device instances from raw API data. Priority-based type detection (see §5). |
| BaseDevice | BaseDevice.js | Abstract base: device ID, label, hub IP/name, attribute store, state subscription, createElement() / updateUI() lifecycle. |
| ThermostatDevice | ThermostatDevice.js | Round-slider temperature control, heat/cool/auto/off modes, fan control, setpoint ±, power meter display. |
| SensorDevice | SensorDevice.js | Multi-type: water, motion, contact, presence, temperature, illuminance, humidity. Type detected from capabilities. |
| UIManager | UIManager.js | Main UI coordinator: section rendering, search, camera init, section switching, KPI dashboard lifecycle. |
| SectionManager | SectionManager.js | Custom section definition and display. Keyword auto-matching for device grouping. |
| BackgroundManager | BackgroundManager.js | Background image/color management per device tile. Handles upload and default backgrounds. |
| SensorKpiDashboard | SensorKpiDashboard.js | 7-card KPI analytics dashboard. Historic daily-avg bar charts, power fleet, binary-state grids. Pushes readings to DB, fetches history. Includes KpiModal (double-click → full-viewport overlay with settings tabs). |
| CameraStreamManager | CameraStreamManager.js | WebRTC lifecycle: creates/destroys MediaMTXWebRTCReader instances per camera. Pause/resume on section switch. |
| MediaMTXWebRTCReader | MediaMTXWebRTCReader.js | WHEP client: RTCPeerConnection setup, ICE negotiation, stream → <video>. Falls back to MJPEG snapshots on unsupported browsers. |
| DataCacheManager | DataCacheManager.js | localStorage + background polling cache for device lists. Structural diff detection triggers re-render only on change. |
| DeviceSectionizer | DeviceSectionizer.js | Groups devices into sections by capability and label keywords. Fibonacci grid column sizing. |
| FullscreenHandler | FullscreenHandler.js | Full-viewport device detail view. Expands tile to fullscreen with extended attribute display. |
| UserManagementModal | UserManagementModal.js | Admin UI: list users, toggle auth, reset passwords, delete users. Calls /api/auth/users/*. |
Devices arriving from the Hubitat Maker API carry a type field and a capabilities array.
DeviceFactory uses a priority-ordered detection chain to produce the correct subclass instance.
| Priority | Class | Detection Criteria |
|---|---|---|
| 1 | ThermostatDevice | Has ThermostatOperatingState or Thermostat capability |
| 2 | LockDevice | Has Lock capability |
| 3 | DimmerDevice | Has SwitchLevel capability |
| 4 | ButtonDevice | Has PushableButton or HoldableButton |
| 5 | SensorDevice | Has MotionSensor, ContactSensor, PresenceSensor, WaterSensor, TemperatureMeasurement, IlluminanceMeasurement, or RelativeHumidityMeasurement |
| 6 | PowerMeterDevice | Has PowerMeter only (no Switch) |
| 7 | SwitchDevice | Has Switch capability (with optional power meter flag) |
| 8 | LightDevice | Has Light capability or type string "Color Bulb" / "Dimmer Switch" |
| 9 | BaseDevice | Fallback for unclassified devices |
DeviceStateManager is the single source of truth for runtime device attribute state.
It decouples the WebSocket event pipeline from the UI rendering layer.
// Register a listener for a specific device
const unsub = stateManager.subscribe(deviceId, (newState) => {
// newState: string or object depending on attribute type
device.updateAttribute(attrName, newState);
device.updateUI();
});
// Unsubscribe on cleanup
unsub();
Hubitat pushes device state changes to POST /api/webhook/event.
Flask enqueues the event and fans it out to all connected browser tabs via a Server-Sent Events stream at
GET /api/events/stream.
# Hubitat webhook payload example
{
"deviceId": "42",
"name": "switch",
"value": "on",
"displayName": "Living Room Lamp"
}
Camera tiles use WHEP (WebRTC-HTTP Egress Protocol) to stream directly from MediaMTX. The browser negotiates an RTCPeerConnection via HTTP offer/answer exchange with the NVR's WHEP endpoint — no Flask proxy involved in the media path.
// WHEP stream setup (simplified)
const pc = new RTCPeerConnection();
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(whepEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp
});
const answer = await resp.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
// ICE candidates added → stream plays in <video>
| Connection Type | Strategy |
|---|---|
| Hubitat WebSocket | Exponential backoff: 1s → 2s → 4s … cap 32s. Resets on successful message. |
| SSE (EventSource) | Browser native reconnect + app-level heartbeat check every 30s. |
| WebRTC (WHEP) | oniceconnectionstatechange → reconnect on failed / disconnected. Falls back to MJPEG snapshot on persistent failure. |
| Method | Path | Description |
|---|---|---|
| GET | /api/devices | All devices merged from all configured hubs |
| GET | /api/devices/native | Devices with hubIp and hubName tags for multi-hub routing |
| POST | /api/device/<id>/command | Send command to device. Body: {command, value?}. Routed to device's origin hub. |
| GET | /api/device/<id>/info | Full device details: attributes, commands, capabilities |
| Method | Path | Description |
|---|---|---|
| GET | /api/hub-config | Current hub configuration (IP, app number, token, additional hubs) |
| POST | /api/hub-config | Save hub configuration |
| POST | /api/hub-config/test | Test hub connectivity and Maker API access |
| GET | /api/hub-names | Hub names list (used for label-stripping in device display) |
| GET | /api/settings | Merged settings: hub config + appearance + NVR URL |
| Method | Path | Description |
|---|---|---|
| GET | /api/user/preferences | All preferences for current user (all categories) |
| GET | /api/user/preferences/<category> | Preferences by category (appearance, devices, sensors, kpi, …) |
| GET | /api/user/preferences/<category>/<key> | Single preference value |
| PUT | /api/user/preferences/<category>/<key> | Set single preference. Body: {value} |
| DELETE | /api/user/preferences/<category>/<key> | Delete preference key |
| POST | /api/user/preferences/migrate | One-time migration of localStorage snapshot to DB |
| Method | Path | Description |
|---|---|---|
| GET | /api/nvr/cameras | Camera list from NVR (proxied from MediaMTX API) |
| POST | /api/nvr/reload-config | Invalidate NVR camera config cache |
| GET | /api/nvr/snap/<camera_id> | Latest snapshot JPEG (proxied from NVR) |
| GET | /api/nvr/stream/<camera_id>/hls | HLS stream URL (fallback for non-WebRTC clients) |
| Method | Path | Description |
|---|---|---|
| POST | /api/kpi/readings | Ingest batch sensor readings. Body: {readings: [{deviceId, label, metric, value, ts}]}. Called every 30s from frontend. |
| GET | /api/kpi/history/daily | Daily fleet-average data. Params: metric, days (default 30, max 180), device_ids (optional CSV), fleet=0 for per-device rows. |
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/login | Login. Body: {username, password}. Returns session token. |
| POST | /api/auth/logout | Invalidate current session |
| GET | /api/auth/session | Validate current session. Returns user info. |
| POST | /api/auth/register | Register new user (if registration enabled by admin) |
| POST | /api/auth/change-password | Change own password |
| GET | /api/auth/users | List all users (admin only) |
| PATCH | /api/auth/users/<id> | Admin: update user (role, active, auth_enabled) |
| DELETE | /api/auth/users/<id> | Admin: delete user |
| POST | /api/auth/users/<id>/reset-password | Admin: reset user password |
| POST | /api/users/<id>/toggle-auth | Toggle per-user auth requirement |
| Method | Path | Description |
|---|---|---|
| GET | /health | Health check (uptime, DB status) |
| GET | /api/status | Server uptime, version, connected hub count |
| GET | /api/logs | Last 100 log records (memory buffer) |
| POST | /api/webhook/event | Hubitat device state webhook receiver |
| POST | /api/webhook/mode | Hubitat mode change webhook receiver |
| GET | /api/events/stream | Server-Sent Events stream for real-time browser updates |
The UI is organized into named sections. The landing page shows section tiles; clicking a tile navigates to that section's device grid.
| Section | Content | Notes |
|---|---|---|
| Landing / Overview | Section selector grid with status pills | Fibonacci grid layout; shows live values (temp, power, battery) |
| Lights | Light + Dimmer tiles | On/off, brightness slider, color picker |
| Switches | Switch tiles (with optional power meter) | Toggle, power display if capable |
| Thermostats | Thermostat round-slider tiles | Setpoint, mode buttons, fan control, power meter link |
| Locks | Lock tiles | Lock/unlock with visual padlock state |
| Environment | KPI Dashboard (7 cards) | Indoor/outdoor temp, power fleet, contacts, presence, motion. See §10. |
| Cameras | Camera WebRTC tiles | Live WHEP streams, fullscreen on click. See §11. |
| Custom sections | User-defined device groups | Keyword-matched or manually curated |
Device tiles within a section are arranged using a Fibonacci sequence (1, 1, 2, 3, 5…) column count
that adapts to container width. DeviceSectionizer calculates the optimal column distribution
based on device count and screen size, creating a visually balanced non-uniform grid.
The Environment section renders the SensorKpiDashboard — a self-contained
analytics module with 7 cards. No external chart libraries; pure SVG + DOM.
| Card | Type | Data Source | Visualization |
|---|---|---|---|
| INDOOR TEMP | Historic | Temperature sensors (excluding outdoor) | Daily fleet-avg bar chart (last 14 days). X = day abbreviation, Y = °F/°C. |
| OUTDOOR TEMP | Historic | Auto-detected (outdoorTemperature attr / "outdoor" label / user-selected) | Daily fleet-avg bar chart. Settings: device checklist. |
| POWER MAIN | Historic | Single user-selected power device (fallback: first power device) | Daily avg bar chart. Settings: device radio selector. |
| POWER | Fleet (live) | All power devices except main + ignored | Horizontal bars sorted by current W. Total in header. Refreshes every 30s. |
| CONTACTS | Status Grid | All contact sensors (deduplicated by label) | 12×12 px squares. Orange = open, dark = closed. Real-time via StateManager. |
| PRESENCE | Status Grid | All presence sensors (deduplicated by label) | 12×12 px squares. Green = present, dark = away. |
| MOTION | Status Grid | All motion sensors (deduplicated by label) | 12×12 px squares. Purple = active, dark = inactive. |
LAN devices configured natively on multiple Hubitat hubs appear in multiple device pools with identical
labels. _dedupeByLabel(devices) keeps only the first occurrence per normalized label,
applied to: continuous metrics, motion, contact, presence, and battery pools.
Double-click any KPI card → KpiModal overlay (full viewport, no backdrop
close, × button). Two tabs:
preferenceManager.set('kpi', key, value).'kpi')| Key | Type | Description |
|---|---|---|
outdoorTempDeviceIds | string[] | Device IDs explicitly selected as outdoor temperature sources |
mainPowerDeviceId | string | Device ID for the Power Main historic card |
ignoredPowerDeviceIds | string[] | Device IDs excluded from the Power fleet card |
Camera tiles stream live video from a MediaMTX NVR instance using WHEP (WebRTC-HTTP Egress Protocol). The media path is entirely browser ↔ NVR — Flask only proxies the camera list and snapshot endpoints.
UIManager calls CameraStreamManager.init(nvrBaseUrl) on app startCameraStreamManager.start(deviceId, nvrCameraId, videoEl) creates a MediaMTXWebRTCReaderCameraStreamManager.pauseAll() — closes RTCPeerConnection to save bandwidthSession-based authentication with bcrypt password hashing. All API routes (except /health,
/api/auth/login) are protected by the @login_required decorator.
When enabled by admin in Security settings: clients on the same /24 subnet as the
TILES host are automatically authenticated as the admin user without a login prompt.
Checked on every request; subnet comparison uses ipaddress module.
Result cached for 30 seconds.
PostgreSQL RLS is enabled on all user-data tables. Users can only read/write their own rows. Admin role bypasses RLS for user management operations.
TILES supports a main Hubitat hub plus N additional hubs. All device pools are merged at
/api/devices/native; each device is tagged with its origin hubIp and
hubName. Commands are routed directly to the device's hub.
# Flask: POST /api/device/{id}/command
# Device carries its origin hubIp → route command there
target_url = f"http://{device.hubIp}/apps/api/{app_id}/devices/{device_id}/{command}"
Each device tile shows a small blue dot linking to the device's page on its originating Hubitat hub.
Link uses device.hubIp — not the main hub's IP — ensuring the link is always correct
for devices from additional hubs.
PreferenceManager provides a namespace-keyed preference API with three-layer persistence:
| Layer | Storage | Latency | Scope |
|---|---|---|---|
| 1 — Memory | In-process Map | Synchronous | Current tab lifetime |
| 2 — localStorage | Browser localStorage | Synchronous | Browser / origin |
| 3 — PostgreSQL | user_preferences table | Async (debounced 500ms) | Per-user, all devices |
// Read (sync, from memory cache)
const unit = preferenceManager.get('sensors', 'tempUnit', 'F');
// Write (sync to memory+localStorage, async to DB)
preferenceManager.set('kpi', 'mainPowerDeviceId', deviceId);
| Category | Keys (examples) |
|---|---|
appearance | fontSize, transparency, backgroundMode, nightModeEnabled |
sensors | tempUnit (F/C), indoorSensors, outdoorSensors |
devices | hiddenDevices, deviceOrder, sectionOrder |
kpi | mainPowerDeviceId, ignoredPowerDeviceIds, outdoorTempDeviceIds |
hub_config | additionalHubs, hubNames |
security | trustedNetworkEnabled |
DataCacheManager caches the device list in localStorage and polls
/api/devices/native in the background. A structural diff compares the
new device list against the cached version — only triggers a UI re-render when devices are added,
removed, or their type changes. Attribute-only changes flow through the WebSocket pipeline instead.
// Cache registration
cacheManager.register({
key: 'devices',
endpoint: '/api/devices/native',
ttl: 300_000, // 5 min
onChange: (devices) => uiManager.renderDevices(devices)
});
TIMESTAMPTZ. JSON columns use JSONB for index support.
users (
id SERIAL PRIMARY KEY,
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- bcrypt
role VARCHAR(16) DEFAULT 'user', -- 'admin' | 'user'
active BOOLEAN DEFAULT TRUE,
auth_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ
)
sessions (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
session_token UUID UNIQUE NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
expires_at TIMESTAMPTZ NOT NULL,
timeout_minutes INTEGER DEFAULT 1440,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_activity TIMESTAMPTZ DEFAULT NOW()
)
-- Indexes: session_token, user_id, expires_at
user_preferences (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
category VARCHAR(64) NOT NULL, -- 'kpi', 'sensors', 'appearance', …
preference_key VARCHAR(128) NOT NULL,
preference_value JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, category, preference_key)
)
user_settings (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
setting_key VARCHAR(128) NOT NULL,
setting_value JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, setting_key)
)
hub_configurations (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
hub_type VARCHAR(16) NOT NULL, -- 'main' | 'additional'
hub_ip VARCHAR(64) NOT NULL,
app_number VARCHAR(16),
access_token VARCHAR(255),
hub_name VARCHAR(128),
hub_order INTEGER DEFAULT 0,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
UNIQUE (user_id, hub_type) WHERE hub_type = 'main'
)
kpi_sensor_readings (
id BIGSERIAL PRIMARY KEY,
device_id VARCHAR(128) NOT NULL,
label VARCHAR(255),
metric VARCHAR(32) NOT NULL, -- 'temperature', 'power', 'outdoorTemperature'
value DOUBLE PRECISION NOT NULL,
ts BIGINT NOT NULL -- Unix ms timestamp
)
-- Index: (metric, ts) for daily-average range queries
-- Populated by POST /api/kpi/readings every 30 seconds from the frontend
device_selections (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
selection_type VARCHAR(64), -- 'preferred_lock', 'temperature_sensor', …
device_id VARCHAR(64),
linked_device_id VARCHAR(64),
selection_metadata JSONB,
UNIQUE (user_id, selection_type, device_id)
)
device_matter_map (
id SERIAL PRIMARY KEY,
hubitat_device_id VARCHAR(64) UNIQUE NOT NULL,
hubitat_device_label VARCHAR(255),
matter_node_id VARCHAR(64),
matter_endpoint_id VARCHAR(32),
device_type VARCHAR(64),
active BOOLEAN DEFAULT TRUE
)
| Service | Image | Role | Ports |
|---|---|---|---|
| nginx | nginx:1.27-alpine |
Reverse proxy, SSL termination, static file serving (./app), upstream to tiles:5000 |
80, 443 (host) |
| tiles | Custom Flask image | Flask/Gunicorn backend. SSE, REST API, webhook receiver. | 5000 (internal) |
| postgres | postgres:16-alpine |
Auth, preferences, hub config, KPI readings. Persisted volume. | 5435 (host) |
| postgrest | postgrest/postgrest |
Auto-generated REST API over PostgreSQL (internal tooling) | 3000 (internal) |
| Host Path | Container Path | Hot Reload? |
|---|---|---|
./app | /app/app | ✅ Yes — browser refresh sufficient |
./app.py | /app/app.py | ⚠️ File syncs, but Python module caching requires ./start.sh restart |
./nginx/nginx.conf | /etc/nginx/nginx.conf | ❌ Requires nginx reload |
tiles_pg_data (named volume) | /var/lib/postgresql/data | N/A — persistent DB storage |
| Variable | Description |
|---|---|
TILES_HTTP_PORT / TILES_HTTPS_PORT | Public listen ports |
TILES_SSL_ENABLED | Enable HTTPS (requires certs) |
TILES_POSTGRES_HOST/PORT/DB/USER/PASSWORD | PostgreSQL connection |
TILES_API_TOKEN | Optional Bearer token for API auth (session-only if unset) |
TILES_SECRET_KEY | Flask session signing key (auto-generated if unset) |
TILES_HOST_IP | Host IP for trusted-network subnet check |
NVR_BASE_URL | MediaMTX NVR base URL for camera proxying |
# gunicorn.conf.py (key settings for SSE)
worker_class = "gthread"
workers = 2
threads = 8
timeout = 120
keepalive = 5
# SSE requires long-lived connections — sync workers would block