MOBIUS.TILES — Engineering Systems Architecture

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

Table of Contents

1. System Overview

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.

Primary Capabilities

v5.0.0 additions: Full authentication layer (AuthManager, SessionManager, JWT-style tokens), KPI Analytics subsystem with PostgreSQL time-series storage, WebRTC camera streaming (MediaMTX WHEP), per-user device backgrounds, admin user management UI, trusted-network auto-login.

2. Technology Stack

LayerTechnologyVersion / 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.

3. Architecture Overview

graph TB subgraph BROWSER["Browser (Vanilla JS)"] direction TB APP["TilesApp.js\n(orchestrator)"] UM["UIManager"] DSM["DeviceStateManager"] WSM["WebSocketManager"] CAM["CameraStreamManager\n+ MediaMTXWebRTCReader"] KPI["SensorKpiDashboard\n(7 KPI cards)"] AUTH_FE["AuthManager (FE)"] PM["PreferenceManager"] DCM["DataCacheManager"] end subgraph NGINX["Nginx (reverse proxy / SSL)"] NX["nginx:1.27-alpine\nports 80/443"] end subgraph FLASK["Flask / Gunicorn"] API["REST API\n/api/*"] WEBHOOK["Webhook receiver\n/api/webhook/*"] SSE["SSE stream\n/api/events/stream"] KPIBE["KPI routes\n/api/kpi/*"] AUTHBE["Auth routes\n/api/auth/*"] end subgraph PG["PostgreSQL 16"] USERS["users / sessions"] PREFS["user_preferences"] HUBCFG["hub_configurations"] KPIDB["kpi_sensor_readings"] end subgraph HUB["Hubitat Hubs"] H1["Main Hub"] HN["Additional Hubs (1..N)"] end subgraph NVR["MediaMTX NVR"] WHEP["WHEP endpoints\n(WebRTC)"] NVRAPI["NVR REST API\n(cameras/snaps)"] end BROWSER --> NX NX --> FLASK FLASK --> PG WSM -->|WebSocket| H1 WSM -->|WebSocket| HN H1 -->|webhook POST| WEBHOOK HN -->|webhook POST| WEBHOOK WEBHOOK --> SSE SSE -->|EventSource| WSM CAM -->|WHEP direct| WHEP API -->|proxy GET| NVRAPI KPI -->|POST readings| KPIBE KPI -->|GET history| KPIBE KPIBE --> KPIDB

4. Module Architecture

graph TD subgraph ENTRY["Entry Point"] TA["TilesApp.js"] end subgraph API_MOD["API"] HA["HubitatAPI.js"] RQ["RequestQueue.js"] end subgraph CFG["Config"] SM["SettingsManager.js"] PRFM["PreferenceManager.js"] ILL["IlluminanceManager.js"] end subgraph DEV_MOD["Devices"] DF["DeviceFactory.js"] BD["BaseDevice.js"] LD["LightDevice.js"] DD["DimmerDevice.js"] SD["SwitchDevice.js"] TD["ThermostatDevice.js"] LKD["LockDevice.js"] PMD["PowerMeterDevice.js"] SND["SensorDevice.js"] BTD["ButtonDevice.js"] end subgraph STATE_MOD["State"] DSM["DeviceStateManager.js"] end subgraph UI_MOD["UI"] UIM["UIManager.js"] PAN["PanelManager.js"] OVL["OverlayManager.js"] SEC["SectionManager.js"] BGM["BackgroundManager.js"] KPI["SensorKpiDashboard.js"] FSH["FullscreenHandler.js"] AUTHU["UserManagementModal.js"] end subgraph CAM_MOD["Camera"] CSM["CameraStreamManager.js"] WRT["MediaMTXWebRTCReader.js"] end subgraph CACHE_MOD["Cache"] DCM["DataCacheManager.js"] end subgraph WS_MOD["WebSocket"] WSM["WebSocketManager.js"] end subgraph UTIL_MOD["Utils"] DSZ["DeviceSectionizer.js"] CNS["constants.js"] HLP["helpers.js"] end TA --> HA & SM & PRFM & DSM & WSM & UIM & DCM HA --> RQ UIM --> PAN & OVL & SEC & BGM & KPI & FSH & AUTHU & CSM CSM --> WRT DF --> BD BD --> LD & DD & SD & TD & LKD & PMD & SND & BTD DSM --> BD UIM --> DSZ & CNS

Module Responsibilities

ModuleFileResponsibility
TilesAppTilesApp.jsMain orchestrator. Auth check → hub config load → device fetch → manager init → render loop.
HubitatAPIHubitatAPI.jsQueue-managed REST calls to Hubitat Maker API. Handles retries and per-hub routing.
RequestQueueRequestQueue.jsSerialized request queue preventing race conditions on rapid device commands.
SettingsManagerSettingsManager.jsLoads hub IP, app number, access tokens, NVR URL, font size, transparency from /api/settings.
PreferenceManagerPreferenceManager.jsNamespace-keyed preference storage: in-memory → localStorage → PostgreSQL. Debounced DB sync.
IlluminanceManagerIlluminanceManager.jsTracks ambient light level from illuminance sensors; used for auto-mode suggestions.
DeviceStateManagerDeviceStateManager.jsCentral state store. Observer pattern: subscribe(deviceId, cb)updateAttribute() → notify all listeners.
WebSocketManagerWebSocketManager.jsHubitat WebSocket connection. Receives device events, reconnects on drop, propagates to StateManager.
DeviceFactoryDeviceFactory.jsCreates typed device instances from raw API data. Priority-based type detection (see §5).
BaseDeviceBaseDevice.jsAbstract base: device ID, label, hub IP/name, attribute store, state subscription, createElement() / updateUI() lifecycle.
ThermostatDeviceThermostatDevice.jsRound-slider temperature control, heat/cool/auto/off modes, fan control, setpoint ±, power meter display.
SensorDeviceSensorDevice.jsMulti-type: water, motion, contact, presence, temperature, illuminance, humidity. Type detected from capabilities.
UIManagerUIManager.jsMain UI coordinator: section rendering, search, camera init, section switching, KPI dashboard lifecycle.
SectionManagerSectionManager.jsCustom section definition and display. Keyword auto-matching for device grouping.
BackgroundManagerBackgroundManager.jsBackground image/color management per device tile. Handles upload and default backgrounds.
SensorKpiDashboardSensorKpiDashboard.js7-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).
CameraStreamManagerCameraStreamManager.jsWebRTC lifecycle: creates/destroys MediaMTXWebRTCReader instances per camera. Pause/resume on section switch.
MediaMTXWebRTCReaderMediaMTXWebRTCReader.jsWHEP client: RTCPeerConnection setup, ICE negotiation, stream → <video>. Falls back to MJPEG snapshots on unsupported browsers.
DataCacheManagerDataCacheManager.jslocalStorage + background polling cache for device lists. Structural diff detection triggers re-render only on change.
DeviceSectionizerDeviceSectionizer.jsGroups devices into sections by capability and label keywords. Fibonacci grid column sizing.
FullscreenHandlerFullscreenHandler.jsFull-viewport device detail view. Expands tile to fullscreen with extended attribute display.
UserManagementModalUserManagementModal.jsAdmin UI: list users, toggle auth, reset passwords, delete users. Calls /api/auth/users/*.

5. Device Classification System

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.

Type Detection Priority

PriorityClassDetection Criteria
1ThermostatDeviceHas ThermostatOperatingState or Thermostat capability
2LockDeviceHas Lock capability
3DimmerDeviceHas SwitchLevel capability
4ButtonDeviceHas PushableButton or HoldableButton
5SensorDeviceHas MotionSensor, ContactSensor, PresenceSensor, WaterSensor, TemperatureMeasurement, IlluminanceMeasurement, or RelativeHumidityMeasurement
6PowerMeterDeviceHas PowerMeter only (no Switch)
7SwitchDeviceHas Switch capability (with optional power meter flag)
8LightDeviceHas Light capability or type string "Color Bulb" / "Dimmer Switch"
9BaseDeviceFallback for unclassified devices

Device Class Hierarchy

classDiagram BaseDevice <|-- LightDevice BaseDevice <|-- DimmerDevice BaseDevice <|-- SwitchDevice BaseDevice <|-- ThermostatDevice BaseDevice <|-- LockDevice BaseDevice <|-- PowerMeterDevice BaseDevice <|-- SensorDevice BaseDevice <|-- ButtonDevice class BaseDevice { +id, label, hubIp, hubName +attributes: Map +createElement(): HTMLElement +updateUI() +getAttribute(name): any +subscribe(cb) +hasPowerMeter: bool } class ThermostatDevice { +setpoint, mode, fanMode +outdoorTemperature +renderRoundSlider() +renderModeButtons() +renderPowerDisplay() } class SensorDevice { +sensorType: string +getSensorType(): string +renderAlert() }

6. State Management

DeviceStateManager is the single source of truth for runtime device attribute state. It decouples the WebSocket event pipeline from the UI rendering layer.

sequenceDiagram participant HUB as Hubitat Hub participant WH as /api/webhook/event participant SSE as SSE Stream participant WS as WebSocketManager participant DSM as DeviceStateManager participant DEV as Device Instance participant UI as Browser UI HUB->>WH: POST device state change WH->>SSE: broadcast event SSE->>WS: EventSource message WS->>DSM: updateAttribute(deviceId, attr, value) DSM->>DSM: update internal Map DSM->>DEV: notify subscriber cb(newValue) DEV->>DEV: updateAttribute(attr, value) DEV->>UI: updateUI() — DOM patch

Subscription API

// 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();

7. Real-Time Communication

SSE / Webhook Pipeline

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"
}

WebRTC Camera Streams

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>

Reconnection Strategy

Connection TypeStrategy
Hubitat WebSocketExponential 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.

8. API Reference

Device Operations

MethodPathDescription
GET/api/devicesAll devices merged from all configured hubs
GET/api/devices/nativeDevices with hubIp and hubName tags for multi-hub routing
POST/api/device/<id>/commandSend command to device. Body: {command, value?}. Routed to device's origin hub.
GET/api/device/<id>/infoFull device details: attributes, commands, capabilities

Hub Configuration

MethodPathDescription
GET/api/hub-configCurrent hub configuration (IP, app number, token, additional hubs)
POST/api/hub-configSave hub configuration
POST/api/hub-config/testTest hub connectivity and Maker API access
GET/api/hub-namesHub names list (used for label-stripping in device display)
GET/api/settingsMerged settings: hub config + appearance + NVR URL

User Preferences

MethodPathDescription
GET/api/user/preferencesAll 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/migrateOne-time migration of localStorage snapshot to DB

NVR / Camera

MethodPathDescription
GET/api/nvr/camerasCamera list from NVR (proxied from MediaMTX API)
POST/api/nvr/reload-configInvalidate NVR camera config cache
GET/api/nvr/snap/<camera_id>Latest snapshot JPEG (proxied from NVR)
GET/api/nvr/stream/<camera_id>/hlsHLS stream URL (fallback for non-WebRTC clients)

KPI Analytics

MethodPathDescription
POST/api/kpi/readingsIngest batch sensor readings. Body: {readings: [{deviceId, label, metric, value, ts}]}. Called every 30s from frontend.
GET/api/kpi/history/dailyDaily fleet-average data. Params: metric, days (default 30, max 180), device_ids (optional CSV), fleet=0 for per-device rows.

Authentication

MethodPathDescription
POST/api/auth/loginLogin. Body: {username, password}. Returns session token.
POST/api/auth/logoutInvalidate current session
GET/api/auth/sessionValidate current session. Returns user info.
POST/api/auth/registerRegister new user (if registration enabled by admin)
POST/api/auth/change-passwordChange own password
GET/api/auth/usersList 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-passwordAdmin: reset user password
POST/api/users/<id>/toggle-authToggle per-user auth requirement

System

MethodPathDescription
GET/healthHealth check (uptime, DB status)
GET/api/statusServer uptime, version, connected hub count
GET/api/logsLast 100 log records (memory buffer)
POST/api/webhook/eventHubitat device state webhook receiver
POST/api/webhook/modeHubitat mode change webhook receiver
GET/api/events/streamServer-Sent Events stream for real-time browser updates

9. UI Architecture

Section Structure

The UI is organized into named sections. The landing page shows section tiles; clicking a tile navigates to that section's device grid.

SectionContentNotes
Landing / OverviewSection selector grid with status pillsFibonacci grid layout; shows live values (temp, power, battery)
LightsLight + Dimmer tilesOn/off, brightness slider, color picker
SwitchesSwitch tiles (with optional power meter)Toggle, power display if capable
ThermostatsThermostat round-slider tilesSetpoint, mode buttons, fan control, power meter link
LocksLock tilesLock/unlock with visual padlock state
EnvironmentKPI Dashboard (7 cards)Indoor/outdoor temp, power fleet, contacts, presence, motion. See §10.
CamerasCamera WebRTC tilesLive WHEP streams, fullscreen on click. See §11.
Custom sectionsUser-defined device groupsKeyword-matched or manually curated

Fibonacci Grid Layout

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.

10. KPI Analytics System

The Environment section renders the SensorKpiDashboard — a self-contained analytics module with 7 cards. No external chart libraries; pure SVG + DOM.

Card Types

CardTypeData SourceVisualization
INDOOR TEMPHistoricTemperature sensors (excluding outdoor)Daily fleet-avg bar chart (last 14 days). X = day abbreviation, Y = °F/°C.
OUTDOOR TEMPHistoricAuto-detected (outdoorTemperature attr / "outdoor" label / user-selected)Daily fleet-avg bar chart. Settings: device checklist.
POWER MAINHistoricSingle user-selected power device (fallback: first power device)Daily avg bar chart. Settings: device radio selector.
POWERFleet (live)All power devices except main + ignoredHorizontal bars sorted by current W. Total in header. Refreshes every 30s.
CONTACTSStatus GridAll contact sensors (deduplicated by label)12×12 px squares. Orange = open, dark = closed. Real-time via StateManager.
PRESENCEStatus GridAll presence sensors (deduplicated by label)12×12 px squares. Green = present, dark = away.
MOTIONStatus GridAll motion sensors (deduplicated by label)12×12 px squares. Purple = active, dark = inactive.

Data Flow

sequenceDiagram participant KD as SensorKpiDashboard participant DEV as Device Instances participant BE as Flask /api/kpi/* participant DB as kpi_sensor_readings KD->>DEV: poll getAttribute() every 30s KD->>BE: POST /api/kpi/readings (batch) BE->>DB: INSERT readings KD->>BE: GET /api/kpi/history/daily?metric=temperature&days=14 DB-->>BE: daily AVG per day BE-->>KD: [{day, avg, min, max}] KD->>KD: _fillDayGaps() — full 14-day array KD->>KD: _buildHistoricSVG() → SVG bars rendered

Deduplication

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.

Modal Interaction

Double-click any KPI card → KpiModal overlay (full viewport, no backdrop close, × button). Two tabs:

Preference Keys (namespace: 'kpi')

KeyTypeDescription
outdoorTempDeviceIdsstring[]Device IDs explicitly selected as outdoor temperature sources
mainPowerDeviceIdstringDevice ID for the Power Main historic card
ignoredPowerDeviceIdsstring[]Device IDs excluded from the Power fleet card

11. Camera & NVR Integration

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.

Stream Architecture

graph LR subgraph BROWSER["Browser"] CSM["CameraStreamManager"] WRT["MediaMTXWebRTCReader"] VID["<video> element"] end subgraph NVR["MediaMTX NVR"] WHEP["WHEP endpoint\n/api/external/stream/{id}/whep"] SNAP["Snapshot\n/api/external/stream/{id}/jpeg"] end subgraph FLASK["Flask"] PROXY["/api/nvr/snap/{id}"] CAMLIST["/api/nvr/cameras"] end CSM --> WRT WRT -->|SDP offer/answer| WHEP WHEP -->|RTP/SRTP| VID WRT -->|fallback MJPEG| SNAP FLASK --> PROXY PROXY --> SNAP FLASK --> CAMLIST

Lifecycle

  1. UIManager calls CameraStreamManager.init(nvrBaseUrl) on app start
  2. When the camera section becomes visible, CameraStreamManager.start(deviceId, nvrCameraId, videoEl) creates a MediaMTXWebRTCReader
  3. Reader negotiates WHEP: POST SDP offer → receive SDP answer → set remote description → ICE candidates → stream plays
  4. When section hides: CameraStreamManager.pauseAll() — closes RTCPeerConnection to save bandwidth
  5. On section show: streams restart from step 2
  6. MJPEG fallback activates if WebRTC fails after 3 retries or on unsupported browser

12. Authentication & User Management

Session-based authentication with bcrypt password hashing. All API routes (except /health, /api/auth/login) are protected by the @login_required decorator.

Auth Flow

sequenceDiagram participant U as User Browser participant FE as AuthManager (JS) participant BE as /api/auth/* participant DB as users / sessions U->>FE: App loads FE->>BE: GET /api/auth/session alt session valid BE-->>FE: {userId, username, role} FE->>FE: proceed to app else no session FE->>FE: render login modal U->>FE: submit credentials FE->>BE: POST /api/auth/login {username, password} BE->>DB: bcrypt verify DB-->>BE: match BE->>DB: INSERT session (UUID token, expiry) BE-->>FE: Set-Cookie session_token FE->>FE: reload app end

Trusted Network Auto-Login

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.

Row-Level Security

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.

13. Multi-Hub Native Routing

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.

Device Discovery Flow

graph LR A["GET /api/devices/native"] --> B["Fetch devices from main hub"] A --> C["Fetch devices from hub 2..N"] B --> D["Tag: hubIp=main, hubName=Main"] C --> E["Tag: hubIp=hubN, hubName=Hub N"] D --> F["Merge & return"] E --> F F --> G["DeviceFactory creates instances\nwith hubIp stored"]

Command Routing

# 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}"

Hub Link (Blue Dot)

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.

14. PreferenceManager & Settings Propagation

PreferenceManager provides a namespace-keyed preference API with three-layer persistence:

LayerStorageLatencyScope
1 — MemoryIn-process MapSynchronousCurrent tab lifetime
2 — localStorageBrowser localStorageSynchronousBrowser / origin
3 — PostgreSQLuser_preferences tableAsync (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);

Preference Categories

CategoryKeys (examples)
appearancefontSize, transparency, backgroundMode, nightModeEnabled
sensorstempUnit (F/C), indoorSensors, outdoorSensors
deviceshiddenDevices, deviceOrder, sectionOrder
kpimainPowerDeviceId, ignoredPowerDeviceIds, outdoorTempDeviceIds
hub_configadditionalHubs, hubNames
securitytrustedNetworkEnabled

15. DataCacheManager & Device Polling

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)
});

16. Database Schema

PostgreSQL 16. Row-Level Security (RLS) enabled on all user-data tables. All timestamps are TIMESTAMPTZ. JSON columns use JSONB for index support.

Authentication & Sessions

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

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 Configuration

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 Analytics

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 Metadata

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
)

17. Deployment Architecture

Docker Services

ServiceImageRolePorts
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)

Volume Mounts

Host PathContainer PathHot 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/dataN/A — persistent DB storage

Environment Variables

VariableDescription
TILES_HTTP_PORT / TILES_HTTPS_PORTPublic listen ports
TILES_SSL_ENABLEDEnable HTTPS (requires certs)
TILES_POSTGRES_HOST/PORT/DB/USER/PASSWORDPostgreSQL connection
TILES_API_TOKENOptional Bearer token for API auth (session-only if unset)
TILES_SECRET_KEYFlask session signing key (auto-generated if unset)
TILES_HOST_IPHost IP for trusted-network subnet check
NVR_BASE_URLMediaMTX NVR base URL for camera proxying

Gunicorn Configuration

# 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

Startup Sequence

graph LR A["./start.sh"] --> B["Docker build\n(if changed)"] B --> C["docker compose up -d"] C --> D["postgres: init schema"] C --> E["tiles: Flask start\n_init_kpi_tables()\nsync_env_to_config()"] C --> F["nginx: proxy ready"] E --> G["Gunicorn workers\nready"]