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 WebSocket connections to the Hubitat hub's event socket.
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 Support: Configuration for multiple Hubitat hubs with coordinated mode
management
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:
WebSocket Architecture: The system connects to Hubitat's native event socket
(ws://hub-ip/eventsocket) for real-time device state updates. This eliminates polling overhead and provides
instant UI synchronization when device states change through any control method (physical, app, automation).
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:
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
11. Configuration System
External Configuration: All hub-specific settings are stored in settings.json, keeping
credentials and network configuration separate from application code. The SettingsManager validates required
fields during load and provides URL building utilities.
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
12. 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. 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.