Refactored TILES smart home control interface from a monolithic 1000+ line single-file application to a modern, modular ES6+ architecture
tiles.js filemodules/
├── config/
│ └── SettingsManager.js # Configuration management
├── api/
│ ├── HubitatAPI.js # API wrapper
│ └── RequestQueue.js # Request queue management
├── devices/
│ ├── BaseDevice.js # Abstract device class
│ ├── LightDevice.js # Light-specific logic
│ ├── SwitchDevice.js # Switch logic
│ ├── DimmerDevice.js # Dimmer with roundSlider
│ ├── LockDevice.js # Lock handling
│ ├── ThermostatDevice.js # Thermostat with modes
│ └── DeviceFactory.js # Device instantiation
├── ui/
│ ├── UIManager.js # UI coordinator
│ ├── PanelManager.js # Panel visibility control
│ └── OverlayManager.js # Modal/overlay handling
├── websocket/
│ └── WebSocketManager.js # Real-time updates
├── state/
│ └── DeviceStateManager.js # Centralized state
└── utils/
├── constants.js # Constants & config
└── helpers.js # Utility functions
TilesApp.js # Main orchestrator
tiles-modular.js # Entry point
1. Configuration Layer
SettingsManager.js - Loads settings.json, validates, builds API URLs2. API Layer
HubitatAPI.js - All Hubitat API interactionsRequestQueue.js - Manages concurrent requests, prevents overload3. State Management
DeviceStateManager.js - Single source of truth for device states4. Device Classes (Polymorphic)
BaseDevice (abstract)
├── LightDevice // Bulb icons, on/off
├── SwitchDevice // Generic switches
├── DimmerDevice // roundSlider integration
├── LockDevice // Lock/unlock UI
└── ThermostatDevice // Temperature control, mode buttons
5. Device Factory
DeviceFactory.js - Creates appropriate device instances based on capabilities6. WebSocket Layer
WebSocketManager.js - Real-time device updates7. UI Layer
UIManager.js - Coordinates all UI operations8. Utilities
constants.js - Magic numbers, CSS selectors, timeoutshelpers.js - trimLabel, debounce, smartDevice detection, etc.9. Main Application
TilesApp.js - Orchestrates initialization, coordinates all modulestiles-modular.js - Entry point, creates and starts TilesAppARCHITECTURE.md - System design and patternsMIGRATION.md - How to migrate from old to newQUICK_REFERENCE.md - Developer referenceServer logs showed 404 errors:
GET /modules/ui/PanelManager.js HTTP/1.1" 404
GET /modules/ui/OverlayManager.js HTTP/1.1" 404
PanelManager.js (181 lines)
OverlayManager.js (177 lines)
showLoading())showError())showSuccess())showConnectionError())showCustom())Hubitat hub sending POST requests to Flask server:
192.168.10.72 - - [29/Oct/2025 23:50:37] "POST / HTTP/1.1" 405
Before:
@app.route('/') # Only accepts GET
def index():
return send_from_directory('app', 'index.html')
After:
@app.route('/', methods=['GET'])
def index():
logger.info("Serving index.html")
return send_from_directory('app', 'index.html')
@app.route('/', methods=['POST'])
def webhook():
# Acknowledges Hubitat POST webhooks
data = request.get_json(silent=True) or {}
logger.info(f"Received POST webhook from {request.remote_addr}")
return jsonify({"status": "received"}), 200
Improvements:
AxiosError: Network Error, code: 'ERR_NETWORK'
Failed to get devices
Initially appeared to be Cross-Origin Resource Sharing issue.
“Old version worked without CORS issues, must be something in refactoring”
Not CORS - settings.json had correct credentials, but user hadn’t saved the file after editing.
Lights not appearing in lights container. Example:
{
"name": "Light Living Room on Home 1",
"label": "Light Living Room",
"capabilities": ["SwitchLevel", "Switch", ...]
}
Issue 1: Invented Logic Instead of Original
Issue 2: Wrong Priority Order
// WRONG (what was deployed):
if (isSwitchLevel) { // Checked first
return DEVICE_TYPES.DIMMER;
} else if (isLight) { // Never reached for dimmers with "light" in name
return DEVICE_TYPES.LIGHT;
}
Issue 3: Inverted Button Filter
// WRONG:
if (isButton && !deviceData.type.includes('button')) {
return; // SKIP - backwards!
}
1. Device Type Detection (Lines 60-88) Copied EXACT logic from original tiles.js:
const isLock = e.capabilities.find(el => el === "Lock");
const isLight = e.name.toLowerCase().includes("light") || e.label.toLowerCase().includes("light");
const isSwitchLevel = e.capabilities.includes("SwitchLevel");
const hasSwitchCapability = e.capabilities.some(capability => ["Switch", "switch"].includes(capability));
const isSwitch = hasSwitchCapability && !isSwitchLevel;
2. Fixed Priority Order
// CORRECT priority:
if (isLock) {
return DEVICE_TYPES.LOCK;
} else if (isLight) { // Check name/label FIRST
return DEVICE_TYPES.LIGHT;
} else if (isSwitchLevel) { // Then check capability
return DEVICE_TYPES.DIMMER;
} else if (isSwitch) {
return DEVICE_TYPES.SWITCH;
}
3. Fixed Button Filter (Lines 95-117) Copied EXACT logic from original tiles.js lines 314-320:
let notAButton = !e.capabilities.find(el => el.toLowerCase().includes("button"));
notAButton = notAButton
? notAButton
: e.type.toLowerCase().includes("button")
? false
: true;
if (notAButton && (isLight || isLock || isSwitchLevel || isSwitch)) {
// Create device
}
“All dimmers are dimmers, but all dimmers are not lights. All lights are not dimmers, but some dimmers are lights.”
Solution: Check for “light” in name/label BEFORE checking dimmer capability. This ensures:
/mnt/project/DeviceFactory.js - Fixed device categorization logic/mnt/user-data/outputs/DeviceFactory.js - Clean corrected version provided✅ ES6 Modules (import/export)
✅ Classes with inheritance
✅ Async/await throughout (no callbacks)
✅ Arrow functions
✅ Template literals
✅ Destructuring
✅ Const/let (no var)
✅ Promises with proper error handling
✅ Map/Set data structures
✅ Optional chaining (?.)
✅ Nullish coalescing (??)
✅ All modules created and delivered ✅ All 404 errors resolved (PanelManager, OverlayManager added) ✅ All 405 errors resolved (Flask POST handler added) ✅ Network errors resolved (credentials verified) ✅ Device categorization fixed (lights appearing correctly) ✅ Application loading successfully ✅ Server logs clean (all 200/304 responses)
Old:
<script src="tiles.js"></script>
New:
<script type="module" src="tiles-modular.js"></script>
All other files remain unchanged (HTML, CSS, settings.json).
DeviceFactory.js:49 Unknown device type for Smoke Sensor Minerva
DeviceFactory.js:49 Unknown device type for Smoke Sensor Office
DeviceFactory.js:49 Unknown device type for Soldering Iron Button
DeviceFactory.js:49 Unknown device type for Temp Sensor Solar Batteries Cabinet
DeviceFactory.js:49 Unknown device type for Vacuum and door alarm cancel button
Note: These are sensors/buttons without Switch/SwitchLevel/Lock/Thermostat capabilities - they correctly don’t get rendered.
"showOfflineDevices": false option from settings.jsonCompleted standalone power meter implementation:
PowerMeterDevice.js class extending BaseDevice for devices with PowerMeter capability but no Switch capabilityDeviceFactory.js to instantiate PowerMeterDevice objects instead of returning nullUIManager._getDeviceCategory()UIManager._renderPowerMeters() to display power meters in navbar:
NAV_ROW selector to constants.js for navbar row targetingWebSocketManager._handlePowerChange():
WebSocket device lookup failure:
evt.deviceId to string in WebSocketManager._handleMessage(): String(evt.deviceId)DeviceStateManager attribute format handling:
device.attributes.find is not a function error when updating attributesupdateAttribute() to handle both array and object formats, matching BaseDevice.getAttribute() pattern.light-tile, .light-tile-icon, .light-tile-state, .light-tile-name_openModal(), _closeModal(), _createModalHTML(), etc.isDimmable(), getLevel(), setLevel(), setValue()setValue() error (was calling non-existent method).switch-tile, .switch-tile-icon, .switch-tile-state, .switch-tile-nameFile: helpers.js
createDeviceIcon(): Creates appropriate icon based on device nameupdateDeviceIcon(): Updates icon colors based on state (deprecated - now recreate instead)Icons Added:
bi-menu-upbi-droplet-halfbi-cloud-fog2-fillbi-arrow-repeatbi-fanbi-dropletbi-robotbi-lightning-charge-fillbi-plug-fillbi-camera-video-fillbi-cup-strawbi-thermometer-halfbi-droplet-fillbi-powerAdded to tiles.css:
Key CSS Classes:
.light-tile, .light-tile-on, .light-tile-off.switch-tile, .switch-tile-on, .switch-tile-offSymptoms:
Debugging Findings:
/mnt/project files may be outdated vs actual running codeFiles to Check in Next Session:
Three Panel Types - Unified Tile Design:
Shared Components:
Design Philosophy:
Console Test Results:
DeviceMap exists: false
DeviceMap size: undefined
Device 27: undefined (not in map)
Switches panel: undefined
this.element.text() which destroys tile structure_updateTileState()Power meter WebSocket updates replace entire tile content with raw concatenated text:
?v=timestamp)Power meter tiles were displaying merged text instead of proper tile structure:
WebSocketManager.js line 177 was using jQuery .text() to update power values:
// WRONG - destroys all child elements:
const switchElement = $(`#${deviceId}switch`);
switchElement.text(`${displayName} \n ${value}W`);
What .text() does: Replaces ALL HTML content with plain text, destroying:
<div class="switch-tile-icon">)<div class="switch-tile-state">)<div class="switch-tile-name">)Changed _handlePowerChange() to call the device’s own updateUI() method instead:
// CORRECT - preserves tile structure:
_handlePowerChange(device, value, displayName) {
device.updateUI(); // Device knows how to update itself properly
// Update standalone power meter button (unchanged)
const deviceId = device.getId();
const mainPowElement = $(`#pwr${deviceId}`);
if (mainPowElement.length) {
mainPowElement.text(`${value} Watts`);
}
}
Why this works: SwitchDevice.updateUI() → _updateTileState() uses targeted selectors to update only the state element:
const stateElement = $(`#state-${this.getId()}`);
stateElement.text(stateText); // Updates only the state div, preserves structure
Never use .text() or .html() on container elements. Always update specific child elements using targeted selectors to preserve structure.
app/modules/websocket/WebSocketManager.js - Fixed _handlePowerChange() method✅ RESOLVED - Power meter tiles now update correctly via WebSocket while maintaining proper tile structure.
Problem: Power meter tiles displayed merged concatenated text instead of proper tile structure when WebSocket sent power updates:
Root Cause:
WebSocketManager.js line 177 was using jQuery .text() method which replaces ALL HTML content with plain text:
// WRONG - destroys all child elements:
const switchElement = $(`#${deviceId}switch`);
switchElement.text(`${displayName} \n ${value}W`);
This destroyed:
<div class="switch-tile-icon">)<div class="switch-tile-state">)<div class="switch-tile-name">)Solution:
Changed _handlePowerChange() to call device’s own updateUI() method:
// CORRECT - preserves tile structure:
_handlePowerChange(device, value, displayName) {
device.updateUI(); // Device knows how to update itself properly
// Update standalone power meter button (unchanged)
const deviceId = device.getId();
const mainPowElement = $(`#pwr${deviceId}`);
if (mainPowElement.length) {
mainPowElement.text(`${value} Watts`);
}
}
Why this works: SwitchDevice.updateUI() → _updateTileState() uses targeted selectors to update only the state element while preserving tile structure.
Files Modified:
app/modules/websocket/WebSocketManager.jsRequirement: Any dimmer set to a value > 0 must send “on” command first if it has Switch capability.
Solution:
Modified BaseDevice.setLevel() to automatically turn on the device before setting level:
async setLevel(level) {
if (!this.isDimmable()) {
throw new Error(`Device ${this.getLabel()} does not support level control`);
}
const clampedLevel = Math.max(0, Math.min(99, level));
// Turn on device first if level > 0 and device has Switch capability
if (clampedLevel > 0 && this.hasCapability('Switch')) {
await this.sendCommand('on');
}
return this.sendCommand('setLevel', clampedLevel);
}
Files Modified:
app/modules/devices/BaseDevice.jsProblem: Device names invisible on iPhone portrait mode (768px and below) for lights and dimmers. Switches displayed names correctly.
Root Cause:
Base CSS for .light-tile-name and .dimmer-tile-name lacked critical flexbox properties that switches had:
flex-shrink: 0 → allowed name div to shrink to zero heightmargin-top: 5px → didn’t push to bottom of tilemax-height: 32px and line-height: 1.2 → took too much spaceIn mobile’s compressed 120px height tiles, icon and state consumed all space, causing name to collapse.
Solution:
Updated base CSS for both .light-tile-name and .dimmer-tile-name to match working switch pattern:
.light-tile-name,
.dimmer-tile-name {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
word-wrap: break-word;
overflow-wrap: break-word;
line-height: 1.1; /* ← Tighter (was 1.2) */
max-height: 22px; /* ← Smaller (was 32px) */
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
margin-top: auto; /* ← Push to bottom (was 5px) */
flex-shrink: 0; /* ← NEW: Prevent shrinking */
}
Mobile media query already had correct font-size (9px) but base CSS prevented display.
Files Modified:
app/tiles.cssNever use .text() or .html() on container elements. Always update specific child elements using targeted selectors to preserve DOM structure. This principle applies across the entire tile system.
✅ WebSocket power updates - Now preserve tile structure
✅ Dimmer auto-on - Automatically turns on device when setting level > 0
✅ Mobile device names - Visible on all tile types in portrait mode
All three tile types (lights, dimmers, switches) now have consistent behavior and display correctly across desktop and mobile viewports.
Comprehensive debugging and redesign of thermostat modal and UI controls for both desktop and mobile platforms. Addressed iOS-specific issues, button state management, CSS styling, and slider positioning.
setHeatingSetpoint):focus state after touch events without explicit blurCSS_CLASSES.THERMOSTAT_MODE_BTN contains multiple class names ('btn thermostat-mode-btn'), causing jQuery selector .btn thermostat-mode-btn to fail (searches for descendant instead of both classes)event.target captured text nodes inside buttons instead of button elementsupdateUI() didn’t clear focus state when updating button classessetHeatingSetpoint regardless of thermostat modesetCoolingSetpoint in cool mode, setHeatingSetpoint in heat moderadius: 130 didn’t adapt to responsive container sizingapp/modules/devices/ThermostatDevice.jsLocation: _handleModeChange() method (lines ~315-351)
Added event handling to prevent propagation and explicit focus clearing:
async _handleModeChange(mode, event) {
// Prevent event propagation to parent modal
if (event) {
event.stopPropagation();
event.preventDefault();
}
try {
// Update UI immediately (optimistic update)
// Use currentTarget to get the button element, not the text node
const clickedButton = $(event.currentTarget);
$(`#${this.modesId} .${CSS_CLASSES.THERMOSTAT_MODE_BTN}`).removeClass('active');
clickedButton.addClass('active');
await this.sendCommand(mode);
} catch (error) {
console.error(`Failed to set mode for ${this.getLabel()}:`, error);
}
}
Key changes:
event.stopPropagation() prevents modal refreshevent.currentTarget instead of event.target ensures button element is selected.blur() call (unnecessary, caused issues)Updated button handler (line ~162):
.on('click', (e) => this._handleModeChange(mode.name, e));
Location: _handleTurboToggle() method (lines ~333-351)
Applied same event handling pattern:
async _handleTurboToggle(event) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
// ... rest of logic
}
Updated turbo handler (line ~182):
.on('click', (e) => this._handleTurboToggle(e));
Location: updateUI() method (lines ~285-291)
Changed from broken multi-class selector to simple tag selector:
// OLD (broken): $(`#${this.modesId} .${CSS_CLASSES.THERMOSTAT_MODE_BTN}`)
// NEW (working):
const allButtons = $(`#${this.modesId} button`);
allButtons.removeClass('active');
targetButton.addClass('active');
Why this works: Avoids jQuery misinterpreting 'btn thermostat-mode-btn' as descendant selector.
Location: _handleTemperatureChange() method (lines ~299-318)
Added logic to send appropriate command based on current mode:
async _handleTemperatureChange(evt) {
const newTemp = evt.value;
const currentMode = this.getAttribute('thermostatMode') || 'auto';
try {
// Send appropriate command based on current mode
if (currentMode === 'cool') {
await this.sendCommand('setCoolingSetpoint', newTemp);
} else if (currentMode === 'heat') {
await this.sendCommand('setHeatingSetpoint', newTemp);
} else {
// For auto mode, set both
await this.sendCommand('setHeatingSetpoint', newTemp);
await this.sendCommand('setCoolingSetpoint', newTemp);
}
} catch (error) {
console.error(`Failed to set temperature for ${this.getLabel()}:`, error);
}
}
Location: _initializeSlider() method (lines ~87-114)
Changed from fixed radius to dynamic calculation:
_initializeSlider(setpoint, currentTemp, outdoorTemp) {
const sliderElement = $(`#${this.elementId}`);
if (sliderElement.length === 0) {
console.warn(`Slider element for ${this.getLabel()} not found`);
return;
}
// Calculate radius dynamically based on container size
const containerWidth = sliderElement.width();
const calculatedRadius = Math.floor(containerWidth / 2);
sliderElement.roundSlider({
sliderType: "min-range",
radius: calculatedRadius, // Was: 130 (fixed)
width: 20,
// ... rest of config
});
this.slider = sliderElement.data('roundSlider');
}
Key insight: No padding subtraction needed - roundSlider handles internal spacing automatically.
app/tiles.cssLocation: .thermostat class (lines ~119-135)
BEFORE:
.thermostat {
width: clamp(200px, 40vmin, 300px);
height: clamp(200px, 40vmin, 300px);
border-radius: 50%;
box-shadow: -3px -1px 20px 20px rgba(255, 255, 255, 0.03),
20px 13px 50px 5px black;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: linear-gradient(146deg, #00000094, #ffffff03);
}
AFTER:
.thermostat {
width: clamp(200px, 40vmin, 300px);
height: clamp(200px, 40vmin, 300px);
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: transparent;
}
Removed:
border-radius: 50% - unnecessary, slider draws its own circlebox-shadow effects - dated 3D appearancebackground - cleaner with transparentLocation: .thermostat .rs-control (lines ~160-167)
BEFORE:
.thermostat .rs-control {
position: relative !important;
width: 85% !important;
height: 85% !important;
margin: auto !important;
}
AFTER:
.thermostat .rs-control {
position: relative !important;
width: 100% !important;
height: 100% !important;
margin: auto !important;
}
Rationale: 85% sizing was compensating for removed background circle, 100% allows proper centering.
Location: .thermostat .rs-container (lines ~169-171)
BEFORE:
.thermostat .rs-container {
position: relative !important;
}
AFTER:
.thermostat .rs-container {
position: relative !important;
margin: 0 auto !important;
}
Added: Horizontal centering with auto margins.
app/mobile.cssLocation: .thermostat .rs-control (lines ~42-49)
BEFORE:
.thermostat .rs-control {
width: 100% !important;
height: 100% !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
overflow: visible !important;
}
AFTER:
.thermostat .rs-control {
position: relative !important;
width: 100% !important;
height: 100% !important;
margin: auto !important;
overflow: visible !important;
}
Removed: Absolute positioning (top/left/transform) that conflicted with flexbox centering.
app/modules/websocket/WebSocketManager.jsLocation: Multiple methods (lines 86, 196, 217)
Added console logging for debugging (kept active for now):
// Line 86 - Message handler
console.log(`🌐 WS: ${evt.displayName} | ${evt.name} = ${evt.value}`);
// Line 196 - Thermostat mode handler
console.log(`🌡️ Thermostat mode update for ${device.getLabel()}, calling updateUI()`);
// Line 217 - Temperature handler
console.log(`🌡️ Temperature update for ${device.getLabel()}, calling updateUI()`);
Purpose: Verify WebSocket events are triggering UI updates correctly.
event.currentTarget for the element the handler is attached toevent.target only when you specifically want the clicked element (which could be a child)stopPropagation() is essential when nesting interactive elements'btn thermostat-mode-btn' becomes .btn .thermostat-mode-btn (descendant selector).btn.thermostat-mode-btn or simple tag selectors button insteadMath.floor(containerWidth / 2) provides perfect radius calculation_handleModeChange(): Event propagation, focus management, optimistic updates_handleTurboToggle(): Same event handling pattern_handleTemperatureChange(): Mode-aware command selectionupdateUI(): Fixed button selector_initializeSlider(): Dynamic radius calculation.thermostat: Removed shadows, gradients, border-radius.thermostat .rs-control: Changed 85% to 100% sizing.thermostat .rs-container: Added margin: 0 auto.thermostat .rs-control: Removed absolute positioning, kept relativeCompleted: All thermostat UI issues resolved Tested: Desktop Chrome, iOS Safari, mobile portrait Logs: Debug logging active for continued monitoring Performance: No performance degradation observed
When stable, remove or comment out debug logs in:
WebSocketManager.js lines 86, 196, 217ThermostatDevice.js if any logs were added_handleModeChange() firessendCommand()updateUI() confirms/corrects button stateactive class_openModal() creates modalstopPropagation() to prevent modal interaction_setupModalHandler()createElement() adds thermostat DOM elementssetTimeout(() => _initializeSlider(), 0) ensures DOM readyThermostat mode buttons had inconsistent sizing and childish appearance:
File: app/tiles.css
Complete redesign of thermostat button styling (lines 226-285):
.thermostat-modes {
display: grid;
grid-template-columns: repeat(4, 80px);
gap: 0.5rem;
justify-content: center;
margin-top: 1rem;
}
.thermostat-mode-btn {
width: 80px;
height: 36px;
padding: 0;
border-radius: 0.35rem;
font-size: 0.8rem;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.15);
transition: all 0.2s ease;
opacity: 0.6;
color: rgba(255, 255, 255, 0.9);
}
.thermostat-mode-btn.active {
opacity: 1;
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.2);
}
/* Row 1: Primary modes */
.mode-off {
background-color: #2C3E50;
}
.mode-auto {
background-color: #34495E;
}
.mode-heat {
background-color: #A94442;
}
.mode-cool {
background-color: #4A7BA7;
}
/* Row 2: Fan modes - span 2 columns each */
.mode-fan_only {
grid-column: span 2;
background-color: #5D6D7E;
width: auto;
}
.mode-fan_auto {
grid-column: span 2;
background-color: #566573;
width: auto;
}
/* Turbo button - full width below fan modes */
.mode-turbo {
grid-column: 1 / -1;
background-color: #A94442;
width: auto;
}
Key Changes:
transform: scale() animations, subtle borders onlyVisual Hierarchy:
| Row 1: Off | Auto | Heat | Cool (4 equal buttons) |
| Row 2: Fan On | Fan Auto (2 buttons spanning 2 columns each) |
Result: Professional, sober appearance with clean grid alignment and consistent sizing throughout.
Session successfully resolved all reported thermostat UI issues through systematic debugging, following the hypothetico-deductive methodology. Key insight: sometimes the “mouse beats the elephant” - simpler solutions (no padding math, basic selectors) often work better than over-engineered approaches. The refactored modular architecture made debugging easier by isolating device-specific logic in ThermostatDevice.js.
Final deliverables include functional modal behavior, proper button state management, mode-aware temperature commands, clean visual styling, and professional button design.