TILES is a web-based smart home control interface designed for Hubitat Elevation home automation hubs. The
application provides real-time device control and monitoring through a responsive, mobile-first dashboard
interface. The system supports multiple device types including lights, switches, dimmers, thermostats, locks,
and power meters.
The architecture follows a modular ES6+ design pattern, featuring a complete separation of concerns across
specialized modules for API communication, state management, device abstraction, and UI rendering. Real-time
updates are achieved through SSE (Server-Sent Events) via a webhook-dispatcher service. The system supports
native multi-hub routing (commands route to each device's native hub), a three-layer preference system
(memory → localStorage → PostgreSQL), and a cache-first initialization pattern via DataCacheManager.
Core Architecture Principles: The system implements a factory pattern for device
instantiation, a publish-subscribe pattern for state management, and an inheritance-based device class
hierarchy. Each device type extends a common BaseDevice class, ensuring consistent behavior while allowing
specialized functionality per device category.
Primary Capabilities
Device Control: On/off switching, dimmer level adjustment, thermostat mode and
temperature control, lock management
Real-Time Updates: WebSocket-based instant state synchronization from Hubitat hub
events
Multi-Hub Native Routing: Commands route to each device's native hub via per-device
hub_ip tagging; Hub Mesh mirror devices filtered out automatically
Preference Persistence: Three-layer preference system (memory → localStorage →
PostgreSQL) with live UI propagation on change
Cache-First Loading: DataCacheManager renders from localStorage on reload, then
background-refreshes from hub APIs
Responsive Design: Mobile-first UI with modal overlays for touch device interaction
Power Monitoring: Real-time power consumption display for PowerMeter-capable devices
Location Modes: Hub mode switching (Home, Away, Night, etc.) across all configured hubs
Modular application architecture with native import/export
DOM Manipulation
jQuery 3.x
DOM operations, event handling, AJAX utilities
HTTP Client
axios
REST API communication with request cancellation support
UI Framework
Bootstrap 5
Responsive grid, modals, components
Specialized UI
roundSlider
Circular thermostat temperature controls
Icons
Bootstrap Icons
Device state iconography (locks, lights, etc.)
Web Server
Flask + Gunicorn
Static file serving in production
Containerization
Docker
Consistent deployment environment
Real-Time
Native WebSocket
Hubitat event socket connection
3. Architecture Overview
Layered Architecture: The application follows a clear separation between presentation,
business logic, and data access layers. The modular design enables independent testing, maintenance, and
extension of individual components without affecting the broader system.
graph TB
subgraph CLIENT["Client Browser"]
subgraph APP["TilesApp (Orchestrator)"]
INIT["Application Bootstrap"]
end
subgraph UI_LAYER["UI Layer"]
UIM["UIManager"]
PM["PanelManager"]
OM["OverlayManager"]
end
subgraph DEVICE_LAYER["Device Layer"]
DF["DeviceFactory"]
BD["BaseDevice"]
LD["LightDevice"]
DD["DimmerDevice"]
SD["SwitchDevice"]
LKD["LockDevice"]
TD["ThermostatDevice"]
PMD["PowerMeterDevice"]
end
subgraph STATE_LAYER["State Layer"]
DSM["DeviceStateManager"]
SM["SettingsManager"]
end
subgraph COMM_LAYER["Communication Layer"]
API["HubitatAPI"]
RQ["RequestQueue"]
WSM["WebSocketManager"]
end
end
subgraph HUBITAT["Hubitat Elevation Hub"]
MAKER["Maker API"]
EVTSOCK["Event Socket"]
DEVICES["Physical Devices"]
end
%% Initialization Flow
INIT --> SM
INIT --> API
INIT --> DSM
INIT --> DF
INIT --> UIM
INIT --> WSM
%% Device Creation
DF --> BD
BD --> LD
BD --> DD
BD --> SD
BD --> LKD
BD --> TD
BD --> PMD
%% UI Rendering
UIM --> PM
UIM --> OM
DF --> UIM
%% State Management
DSM --> BD
WSM --> DSM
%% API Communication
API --> RQ
API --> MAKER
WSM --> EVTSOCK
%% Hubitat Internal
MAKER --> DEVICES
DEVICES --> EVTSOCK
%% Styling
classDef orchestrator fill:#e1f5fe,stroke:#0277bd,stroke-width:2px
classDef ui fill:#fff3e0,stroke:#f57c00,stroke-width:2px
classDef device fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px
classDef state fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
classDef comm fill:#fce4ec,stroke:#c2185b,stroke-width:2px
classDef hubitat fill:#e0e0e0,stroke:#424242,stroke-width:2px
class INIT orchestrator
class UIM,PM,OM ui
class DF,BD,LD,DD,SD,LKD,TD,PMD device
class DSM,SM state
class API,RQ,WSM comm
class MAKER,EVTSOCK,DEVICES hubitat
4. Module Architecture
The application is structured as a collection of ES6 modules, each with a single responsibility. The modular
architecture enables clean dependency management through native JavaScript imports and provides clear
boundaries between system components.
graph LR
subgraph ENTRY["Entry Point"]
TILES["tiles-modular.js"]
end
subgraph CORE["Core Module"]
TA["TilesApp.js"]
end
subgraph API_MOD["API Modules"]
HAPI["HubitatAPI.js"]
RQ["RequestQueue.js"]
end
subgraph CONFIG["Config Modules"]
SETTINGS["SettingsManager.js"]
end
subgraph DEVICES_MOD["Device Modules"]
FACTORY["DeviceFactory.js"]
BASE["BaseDevice.js"]
LIGHT["LightDevice.js"]
DIMMER["DimmerDevice.js"]
SWITCH["SwitchDevice.js"]
LOCK["LockDevice.js"]
THERMO["ThermostatDevice.js"]
POWER["PowerMeterDevice.js"]
end
subgraph STATE_MOD["State Modules"]
DSM["DeviceStateManager.js"]
end
subgraph UI_MOD["UI Modules"]
UIM["UIManager.js"]
PANEL["PanelManager.js"]
OVERLAY["OverlayManager.js"]
end
subgraph WS_MOD["WebSocket Modules"]
WSM["WebSocketManager.js"]
end
subgraph UTILS["Utility Modules"]
CONST["constants.js"]
HELP["helpers.js"]
end
TILES --> TA
TA --> HAPI
TA --> SETTINGS
TA --> FACTORY
TA --> DSM
TA --> UIM
TA --> WSM
HAPI --> RQ
FACTORY --> BASE
BASE --> LIGHT
BASE --> DIMMER
BASE --> SWITCH
BASE --> LOCK
BASE --> THERMO
BASE --> POWER
UIM --> PANEL
UIM --> OVERLAY
%% Utility dependencies
TA --> CONST
FACTORY --> CONST
BASE --> HELP
WSM --> CONST
Panel visibility: toggle buttons for device categories, panel show/hide state
OverlayManager
OverlayManager.js
Loading/error overlays: full-screen messages during load or errors
5. Device Classification System
Classification Logic: Device type determination follows a priority-based algorithm that
examines capabilities and naming conventions. The key insight is that device naming (e.g., "light" in label)
takes precedence over raw capabilities for user-facing categorization, ensuring lights with dimming
capability appear in the Lights panel rather than Dimmers.
flowchart TD
START["Device Data from API"] --> LOCK_CHECK{"Has Lock capability?"}
LOCK_CHECK -->|Yes| LOCK_TYPE["LOCK"]
LOCK_CHECK -->|No| LIGHT_CHECK{"Name/label contains 'light'?"}
LIGHT_CHECK -->|Yes| LIGHT_SWITCH{"Has Switch or SwitchLevel?"}
LIGHT_CHECK -->|No| DIMMER_CHECK{"Has SwitchLevel?"}
LIGHT_SWITCH -->|Yes| LIGHT_TYPE["LIGHT"]
LIGHT_SWITCH -->|No| OTHER
DIMMER_CHECK -->|Yes| DIMMER_TYPE["DIMMER"]
DIMMER_CHECK -->|No| SWITCH_CHECK{"Has Switch only?"}
SWITCH_CHECK -->|Yes| SWITCH_TYPE["SWITCH"]
SWITCH_CHECK -->|No| THERMO_CHECK{"Has Thermostat?"}
THERMO_CHECK -->|Yes| THERMO_TYPE["THERMOSTAT"]
THERMO_CHECK -->|No| POWER_CHECK{"Has PowerMeter only?"}
POWER_CHECK -->|Yes| POWER_TYPE["POWER_METER"]
POWER_CHECK -->|No| OTHER["Unsupported Device"]
%% Dual Creation Path
LIGHT_TYPE --> DUAL_CHECK{"Also has SwitchLevel?"}
DUAL_CHECK -->|Yes| DUAL["Create BOTH LightDevice AND DimmerDevice"]
DUAL_CHECK -->|No| SINGLE_LIGHT["Create LightDevice only"]
%% Styling
classDef decision fill:#fff3cd,stroke:#856404
classDef deviceType fill:#d4edda,stroke:#155724
classDef special fill:#cce5ff,stroke:#004085
class LOCK_CHECK,LIGHT_CHECK,LIGHT_SWITCH,DIMMER_CHECK,SWITCH_CHECK,THERMO_CHECK,POWER_CHECK,DUAL_CHECK decision
class LOCK_TYPE,LIGHT_TYPE,DIMMER_TYPE,SWITCH_TYPE,THERMO_TYPE,POWER_TYPE deviceType
class DUAL,SINGLE_LIGHT special
Device Type Priority Order
// Priority order for device classification (DeviceFactory._determineDeviceType)const isLock = capabilities.includes("Lock");
const isLight = name.toLowerCase().includes("light") ||
label.toLowerCase().includes("light");
const isSwitchLevel = capabilities.includes("SwitchLevel");
const isSwitch = hasSwitchCapability && !isSwitchLevel;
const isThermostat = capabilities.includes("Thermostat");
const isPowerMeterOnly = capabilities.includes("PowerMeter") && !hasSwitchCapability;
// Classification priority:// 1. Lock capability → LOCKS panel// 2. Name/label "light" AND Switch/SwitchLevel → LIGHTS panel// 3. SwitchLevel (non-light) → DIMMERS panel// 4. Switch only → SWITCHES panel// 5. Thermostat → THERMOSTATS panel// 6. PowerMeter only → Power display in nav
Dual-Panel Devices: Dimmable lights (devices with "light" in name AND SwitchLevel
capability) are instantiated twice: once as a LightDevice for the Lights panel (simple on/off button) and
once as a DimmerDevice for the Dimmers panel (tile with level control modal). This provides both quick
toggle access and detailed control without duplicating business logic.
Device Class Hierarchy
Class
Extends
Panel
UI Element
Key Features
BaseDevice
—
—
—
Abstract base: ID, label, attributes, commands, state subscription, modal infrastructure
Lock/unlock with Bootstrap Icons, state-based colors
ThermostatDevice
BaseDevice
Thermostats
Circular Slider
roundSlider control, mode buttons, turbo support, temperature tooltip
PowerMeterDevice
BaseDevice
Nav Bar
Badge
Real-time wattage display, main meter highlight
6. State Management
Publish-Subscribe Pattern: The DeviceStateManager implements a centralized state store with
subscription-based change notification. Device instances subscribe to their own state changes during
construction, enabling automatic UI updates when WebSocket events arrive.
sequenceDiagram
participant WS as WebSocketManager
participant DSM as DeviceStateManager
participant DEV as Device Instance
participant UI as Device UI Element
Note over WS,UI: Initial Subscription (during device creation)
DEV->>DSM: subscribe(deviceId, callback)
DSM-->>DEV: returns unsubscribe function
Note over WS,UI: Real-time Update Flow
WS->>WS: Receive WebSocket message
WS->>DSM: updateAttribute(deviceId, name, value)
DSM->>DSM: Update internal state
DSM->>DEV: Notify via callback(name, value)
DEV->>DEV: updateAttribute(name, value)
DEV->>UI: updateUI()
UI->>UI: Reflect new state visually
State Flow Implementation
// DeviceStateManager: Subscription registration
subscribe(deviceId, callback) {
if (!this.listeners.has(deviceId)) {
this.listeners.set(deviceId, []);
}
this.listeners.get(deviceId).push(callback);
// Return unsubscribe function for cleanupreturn () => {
const callbacks = this.listeners.get(deviceId);
const index = callbacks.indexOf(callback);
if (index > -1) callbacks.splice(index, 1);
};
}
// BaseDevice: Auto-subscription during construction
_setupStateListener() {
this.stateManager.subscribe(this.getId(), (attributeName, value) => {
this.updateAttribute(attributeName, value);
this.updateUI(); // Subclass implementation
});
}
Attribute Update Handling
The state manager handles both array-format and object-format attributes from the Hubitat API, ensuring
compatibility across different device types and API responses:
SSE/Webhook Architecture (January 2026): The system uses Server-Sent Events (SSE) for
real-time browser updates. A shared webhook-dispatcher service receives events from Hubitat's Maker API
and fans them out to multiple applications. TILES receives webhooks and broadcasts via SSE to connected browsers.
Key Design Insight: Gunicorn with multiple workers creates isolated memory spaces -
each worker has its own event_subscribers list. SSE requires workers=1 with
worker_class="gthread" so all threads share the subscriber list. This was the root cause
of SSE events not reaching browsers (fixed January 2026).
flowchart TB
subgraph HUBITAT["Hubitat Hub"]
PHY["Physical Device"]
DRIVER["Device Driver"]
MAKER["Maker API POST webhook"]
end
subgraph DISPATCHER["webhook-dispatcher :5050"]
RECV["Receive Event"]
UNWRAP["Unwrap content object"]
FANOUT["Fan out to targets"]
end
subgraph TILES["TILES Backend (Flask/Gunicorn)"]
WEBHOOK["/api/webhook/event"]
BROADCAST["_broadcast_event()"]
QUEUE["Subscriber Queues"]
SSE["/api/events/stream"]
end
subgraph BROWSER["Browser"]
EVTSRC["EventSource"]
WSM["WebSocketManager"]
DSM["DeviceStateManager"]
DEVICE["Device Instance"]
DOM["DOM Element"]
end
PHY -->|"State Change"| DRIVER
DRIVER -->|"Event Publish"| MAKER
MAKER -->|"POST JSON"| RECV
RECV --> UNWRAP
UNWRAP --> FANOUT
FANOUT -->|"POST"| WEBHOOK
WEBHOOK --> BROADCAST
BROADCAST --> QUEUE
QUEUE -->|"yield event"| SSE
SSE -->|"SSE stream"| EVTSRC
EVTSRC --> WSM
WSM -->|"updateAttribute()"| DSM
DSM -->|"Notify"| DEVICE
DEVICE -->|"updateUI()"| DOM
%% Styling
classDef hubitat fill:#e0e0e0,stroke:#424242
classDef dispatcher fill:#fff3cd,stroke:#856404
classDef tiles fill:#d4edda,stroke:#155724
classDef browser fill:#e3f2fd,stroke:#1976d2
class PHY,DRIVER,MAKER hubitat
class RECV,UNWRAP,FANOUT dispatcher
class WEBHOOK,BROADCAST,QUEUE,SSE tiles
class EVTSRC,WSM,DSM,DEVICE,DOM browser
Webhook Dispatcher Architecture
Shared Container Design: Both TILES and 0_SMART_HOME define a webhook-dispatcher service
with identical container_name: webhook-dispatcher. Docker only runs ONE instance - whichever
project starts first wins. The dispatcher forwards events to all configured targets regardless of which
project spawned it.
# docker-compose.yml - Webhook Dispatcher Service
webhook-dispatcher:
build:
context: .
dockerfile: Dockerfile.dispatcher
container_name: webhook-dispatcher # IDENTICAL in both projects
ports:
- "5050:5050"
environment:
WEBHOOK_TARGETS: "http://tiles-app:80/api/webhook/event,http://host.docker.internal:5001/api/webhook/event"
extra_hosts:
- "host.docker.internal:host-gateway"
Gunicorn Configuration for SSE
# gunicorn.conf.py - CRITICAL for SSE to work# IMPORTANT: SSE requires single worker OR gevent (shared memory for event_subscribers)# Multiple sync workers have isolated memory - SSE clients in worker A won't receive# events from webhooks hitting worker B
workers = 1# Single worker for SSE to work
worker_class = "gthread"# Threaded worker - shares memory within process
threads = 4# Handle concurrent requests via threads
Hubitat Maker API: All device commands and state queries flow through Hubitat's Maker API.
The HubitatAPI module wraps axios for HTTP requests with built-in timeout handling, request cancellation, and
multi-hub support for coordinated mode changes.
API Endpoint Structure
// Base URL construction
http://{hub_ip}/apps/api/{app_number}/{endpoint}?access_token={token}
// Example endpoints:
/devices/all // Get all devices
/devices/{id} // Get device details
/devices/{id}/{command} // Send command (on, off, toggle)
/devices/{id}/{command}/{value} // Send command with value (setLevel/75)
/modes/all // Get all location modes
/modes/{id} // Set active mode
Request Handling
Method
Endpoint
Purpose
Features
getAllDevices()
/devices/all
Initial device load
Sorts by label alphabetically
getDevice(id)
/devices/{id}
Single device refresh
Cancellable request token
sendCommand(id, cmd, val)
/devices/{id}/{cmd}/{val}
Device control
15s timeout, cancellation support
getModes()
/modes/all
Load location modes
Identifies active mode
setMode(id)
/modes/{id}
Change location mode
Propagates to all configured hubs
Multi-Hub Mode Synchronization
// setMode propagates to all configured hubsasync setMode(modeId) {
// Set on primary hubawait axios.get(this.settings.buildURL(`/modes/${modeId}`));
// Propagate to other configured hubsawaitthis._setModeOnOtherHubs(modeId);
}
_setModeOnOtherHubs(modeId) {
const otherHubs = this.settings.getOtherHubs();
return Promise.all(otherHubs.map(hub => {
const url = this.settings.buildHubURL(hub, `/modes/${modeId}`);
return axios.get(url).catch(err => {
console.error(`Failed to set mode on hub ${hub.ip}`);
returnnull; // Continue with other hubs
});
}));
}
9. UI Component Architecture
Panel-Based Organization: Devices are rendered into category-specific panels (Lights,
Switches, Dimmers, Locks, Thermostats). The PanelManager handles visibility toggling via navigation buttons,
while UIManager coordinates rendering and overlay display.
Panel Structure
Panel
Container ID
Toggle Button
Device Types
Lights
#lights / #lightsCol
#lightsToggle
LightDevice instances
Switches
#switches / #otherSwitchesCol
#switchesToggle
SwitchDevice instances
Dimmers
#dimmers / #dimmersCol
#dimmersToggle
DimmerDevice instances
Locks
#rowLocks / #locksCol
#locksToggle
LockDevice instances
Thermostats
#thermostats / #thermostatsCol
#thremostatsToggle
ThermostatDevice instances
Mobile Modal System
Touch devices trigger modal overlays for detailed device control. The BaseDevice class provides shared modal
infrastructure including vertical sliders for dimmers and Bootstrap modals for thermostats:
The ThermostatDevice implements a circular roundSlider control with mode buttons, fan speed control,
turbo mode, power meter association, and a temperature tooltip that respects the user's °F/°C preference.
Mouse scroll is scoped to the slider handle/track only (not the entire tile) to prevent accidental setpoint changes
while scrolling the page.
// roundSlider initialization — mouseScrollAction disabled, scoped to handle/track only
_initializeSlider(setpoint) {
const containerWidth = sliderElement.width();
const calculatedRadius = Math.floor(containerWidth / 2);
sliderElement.roundSlider({
sliderType: "min-range",
radius: calculatedRadius,
min: 62, max: 86,
startAngle: 315,
mouseScrollAction: false,
tooltipFormat: (args) => this._formatTooltip(args.value),
change: (evt) => this._handleTemperatureChange(evt)
});
// Scoped scroll: only handle/track elements respond to mouse wheel
sliderElement.find('.rs-handle, .rs-path-color, .rs-range-color, .rs-bar')
.on('mousewheel DOMMouseScroll', (e) => slider._elementScroll(e));
}
// Temperature conversion respects user preference (°F or °C)
_convertTemp(tempF, unit) {
if (unit === 'C') return Math.round((parseFloat(tempF) - 32) * 5 / 9);
return Math.round(parseFloat(tempF));
}
// Mode-aware temperature commandasync _handleTemperatureChange(evt) {
const mode = this.getAttribute('thermostatMode');
if (mode === 'cool') {
awaitthis.sendCommand('setCoolingSetpoint', evt.value);
} else if (mode === 'heat') {
awaitthis.sendCommand('setHeatingSetpoint', evt.value);
} else {
// Auto mode: set both setpointsawaitthis.sendCommand('setHeatingSetpoint', evt.value);
awaitthis.sendCommand('setCoolingSetpoint', evt.value);
}
}
10. Multi-Hub Native Routing
Native Hub Routing: Each device is tagged with its native hub's IP, app number, and token
during discovery. Commands are routed directly to the device's native hub, bypassing Hub Mesh forwarding.
This eliminates cross-hub latency and prevents commands from reaching hubs that don't own the device.
Device Discovery Flow
// Backend: _fetch_native_devices_from_hub() tags each device with hub metadatafor device in devices:
device['_hub_ip'] = hub_config['hub_ip']
device['_hub_name'] = hub_config['hub_name']
device['_hub_app'] = hub_config['hub_app']
device['_hub_token'] = hub_config['hub_token']
// Hub Mesh mirror devices are filtered out (hubMeshDisabled attribute = mesh-linked)
devices = [d for d in devices if not _is_mesh_linked(d)]
Command Routing
// Frontend: BaseDevice.sendCommand() includes hub_ip in POST bodyawaitthis.api.sendCommand(deviceId, command, value, this.hubIp);
// Backend: /api/device/:id/command resolves credentials from hub_ip
hub_cfg = _get_hub_by_ip(hub_ip_from_body)
url = f"http://{hub_ip}/apps/api/{app_number}/{endpoint}?access_token={token}"
Hub Link (Blue Dot)
Each device tile has a blue dot linking to its Hubitat device page. The link uses the device's native
hubIp, falling back to the main hub IP only if the native hub is unknown:
Three-Layer Persistence: User preferences are stored across three layers for reliability
and performance. In-memory cache provides instant reads, localStorage ensures persistence across page
reloads, and PostgreSQL serves as the source of truth across sessions and devices.
Cache-First Architecture: On page reload, the app renders instantly from localStorage cache,
then background-refreshes from hub APIs. Cold starts (no cache) fetch everything first. This eliminates the
loading delay on subsequent visits.
The diffDevices() function compares devices by structural properties to detect additions,
removals, and changes without triggering unnecessary re-renders:
Containerized Deployment: The application runs in a Docker container with Flask/Gunicorn
serving static files. The container exposes port 80 for direct HTTP access without reverse proxy
requirements.
graph TB
subgraph NETWORK["Local Network"]
CLIENT["Browser Client"]
HUBITAT["Hubitat Hub :80 HTTP :80 WebSocket"]
end
subgraph SERVER["Docker Host (Ubuntu)"]
subgraph CONTAINER["tiles-app Container"]
GUNICORN["Gunicorn WSGI"]
FLASK["Flask App"]
STATIC["Static Files /app/app/"]
end
end
CLIENT -->|":80 HTTP"| GUNICORN
GUNICORN --> FLASK
FLASK --> STATIC
CLIENT <-->|"REST API"| HUBITAT
CLIENT <-->|"WebSocket"| HUBITAT
%% Styling
classDef client fill:#e3f2fd,stroke:#1976d2
classDef server fill:#e8f5e9,stroke:#388e3c
classDef hubitat fill:#fff3e0,stroke:#f57c00
class CLIENT client
class GUNICORN,FLASK,STATIC server
class HUBITAT hubitat
Docker Configuration
# docker-compose.yml
name: tiles
services:
tiles:
build:
context: .
dockerfile: Dockerfile
container_name: tiles-app
ports:
- "80:80"
restart: unless-stopped
volumes:
- ./app:/app/app # Live reload during development
Network Requirements
Connection
Protocol
Port
Purpose
Client → TILES
HTTP
80
Static file serving (HTML, JS, CSS)
Client → Hubitat
HTTP
80
Maker API REST calls
Client → Hubitat
WebSocket
80
Event socket for real-time updates
14. Configuration System
External Configuration: Hub credentials are stored in environment variables (injected via
Docker/start.sh), served by Flask at /api/settings. The SettingsManager validates required
fields during load and delegates UI preferences to PreferenceManager.
Environment Variables (Native Hubs)
# Per-hub configuration (1-3 native hubs, MAIN hub excluded from TILES)
TILES_HUB_1_IP=192.168.10.70
TILES_HUB_1_APP=42
TILES_HUB_1_TOKEN=your-maker-api-token-hub1
TILES_HUB_2_IP=192.168.10.71
TILES_HUB_2_APP=43
TILES_HUB_2_TOKEN=your-maker-api-token-hub2
TILES_HUB_3_IP=192.168.10.72
TILES_HUB_3_APP=44
TILES_HUB_3_TOKEN=your-maker-api-token-hub3
Constants Configuration
Application constants are centralized in constants.js for easy maintenance:
Category
Constant
Value
Purpose
Timeouts
REQUEST_TIMEOUT
15000ms
API request timeout
Timeouts
RELOAD_PAGE
10 hours
Automatic page refresh interval
Timeouts
WEBSOCKET_RECONNECT_BASE
5000ms
Base delay for reconnection attempts
WebSocket
MAX_RECONNECT_ATTEMPTS
5
Maximum reconnection tries before giving up
Thermostat
Min/Max Temperature
62°F / 86°F
Slider range limits
15. Key Design Decisions
Rationale Documentation: The following decisions were made deliberately based on project
requirements, maintainability goals, and lessons learned during development.
1. ES6 Modules Over Bundlers
Decision: Use native ES6 modules with browser import/export rather than webpack or other
bundlers.
Rationale: Modern browsers support ES6 modules natively. Avoiding build tooling reduces
complexity, eliminates transpilation issues, and allows direct debugging of source files. The application
size does not warrant the overhead of a build pipeline.
2. jQuery for DOM Manipulation
Decision: Retain jQuery despite ES6+ availability of native DOM methods.
Rationale: jQuery provides consistent cross-browser behavior, particularly for event
delegation and animation. The roundSlider library requires jQuery. Existing codebase familiarity reduces
refactoring risk.
3. Factory Pattern for Device Creation
Decision: Centralize device instantiation in DeviceFactory rather than inline construction.
Rationale: Device type determination logic is complex (priority-based with multiple
conditions). Centralizing this logic makes it testable and maintainable. The factory also handles the
dual-instantiation case for dimmable lights.
4. Pub/Sub State Management
Decision: Implement a custom publish-subscribe pattern in DeviceStateManager rather than
using a state management library (Redux, MobX).
Rationale: The state model is simple (device attributes keyed by ID). A lightweight custom
implementation avoids dependency bloat and integrates naturally with the device class hierarchy. Devices
self-subscribe during construction.
5. Device Classification Priority
Decision: Name/label-based classification ("light" keyword) takes precedence over
capability-only detection.
Rationale: Users expect devices named "Kitchen Light" to appear in the Lights panel even if
they have dimmer capability. Pure capability detection would scatter lights across Lights and Dimmers
panels. The "dimmable light creates two instances" pattern preserves both organizational clarity and full
functionality.
6. Optimistic UI Updates
Decision: Update UI immediately on user interaction, before server confirmation.
Rationale: Provides responsive feel on touch devices. WebSocket events correct any
discrepancies within milliseconds. The alternative (wait for server response) creates noticeable lag that
degrades user experience.
7. Native Hub Routing Over Hub Mesh Forwarding
Decision: Tag each device with its native hub IP during discovery and route commands directly,
rather than relying on Hub Mesh to forward commands between hubs.
Rationale: Hub Mesh forwarding adds latency, may silently fail, and the device ID may not
exist on the forwarding hub's Maker API. Native routing is deterministic and eliminates cross-hub
dependencies.
8. Cache-First Initialization
Decision: Render from localStorage cache on page load, then background-refresh from hub APIs.
Rationale: Hub API calls to fetch all devices take 2-5 seconds. Rendering from cache
provides instant page load on subsequent visits. The DataCacheManager's structural diff ensures that only
actual changes (new devices, removed devices, label/type/hub changes) trigger re-renders.
9. Three-Layer Preference Persistence
Decision: Store preferences in memory + localStorage + PostgreSQL rather than localStorage alone.
Rationale: localStorage is per-browser and can be cleared by the user. PostgreSQL provides
cross-device persistence and backup. In-memory cache eliminates JSON parse overhead on frequent reads.
Async DB writes (fire-and-forget) ensure the UI never blocks on network latency.
10. Automatic Page Reload
Decision: Force page reload every 10 hours.
Rationale: Long-running browser sessions can accumulate memory leaks or stale state. A
periodic refresh ensures clean state. The 10-hour interval balances freshness with avoiding disruptive
reloads during active use.