Phases 1 and 2 give you excellent data — but it lives in tables, charts, and log entries. Phase 3 maps all of that data onto a spatial model of your actual house so the question "which room is cold right now and why?" can be answered by looking at the screen rather than reading numbers.
RoomSketcher (and similar tools like Planner 5D, Floorplanner, or even a hand-drawn plan scanned flat) produces the background image layer. The web UI doesn't need a special format — a clean PNG screenshot is all it needs. The sensor positions are stored separately in a JSON config file, so the image never needs to be re-exported when sensors are added or moved.
floor_basement.png, floor_1.png, floor_2.png.
| Setting | Value | Why |
|---|---|---|
| View | 2D Floor Plan | Clean top-down — no perspective distortion |
| Resolution | 1600 × 1200px minimum | Sharp on retina/4K screens when scaled down in the UI |
| Background | White or very light gray | Heat map overlay colors need light background to show clearly |
| Walls | Medium weight, dark gray | Visible through semi-transparent overlay |
| Furniture | Remove or keep minimal | Equipment markers get confused with furniture symbols |
| Labels | Room names on — measurements off | Room names help at-a-glance orientation |
| Format | PNG | Lossless — no JPEG compression artifacts around wall edges |
This is the bridge between your floor plan images and your live MQTT data. Every sensor and piece of equipment gets a position expressed as a percentage of the image's width and height. Percentages means the config works regardless of what screen size or zoom level the dashboard is viewed at.
// floor_plan_config.json // Run the position editor tool (see Step 4) to set x/y values // x: 0 = left edge, 100 = right edge of the floor plan image // y: 0 = top edge, 100 = bottom edge { "floors": [ { "id": "floor_1", "label": "Main Floor", "image": "static/floor_1.png", "sensors": [ { "id": "aqara_living_room", "label": "Living Room", "mqtt_temp": "aqara/living_room/temperature", "mqtt_humid": "aqara/living_room/humidity", "x": 22.5, // percent of image width "y": 61.0 // percent of image height }, { "id": "aqara_bedroom_1", "label": "Bedroom 1", "mqtt_temp": "aqara/bedroom_1/temperature", "mqtt_humid": "aqara/bedroom_1/humidity", "x": 55.2, "y": 19.0 } ], "equipment": [ { "id": "erv_unit_1", "type": "erv", "label": "ERV 1", "mqtt_state": "erv/unit1/sensor/fan_mode/state", "mqtt_power": "erv/unit1/sensor/power_draw", "x": 4.5, "y": 44.0 }, { "id": "hp_zone_1", "type": "heatpump", "label": "HP Zone", "mqtt_state": "heatpump/unit1/mode", "mqtt_setpoint": "heatpump/unit1/target_temperature", "x": 41.0, "y": 57.5 } ] }, { "id": "basement", "label": "Basement", "image": "static/floor_basement.png", "sensors": [ { "id": "aqara_basement", "label": "Mechanical Room", "mqtt_temp": "aqara/basement/temperature", "mqtt_humid": "aqara/basement/humidity", "x": 50.0, "y": 50.0 } ], "equipment": [ { "id": "erv_unit_2", "type": "erv", "label": "ERV 2", "mqtt_state": "erv/unit2/sensor/fan_mode/state", "mqtt_power": "erv/unit2/sensor/power_draw", "x": 12.0, "y": 35.0 } ] } ] }
The heat map is rendered in real time on an HTML5 Canvas element that sits directly on top of your floor plan image. It uses Inverse Distance Weighting (IDW) — a simple algorithm that interpolates the temperature at any pixel based on its distance from all known sensor readings. Sensors closer to a pixel have more influence than sensors further away.
// JavaScript — floor plan canvas renderer // Runs in the browser as part of the FastAPI-served web UI const COLOR_SCALE = [ { temp: 55, color: [70, 130, 200] }, // cold blue { temp: 65, color: [100, 180, 150] }, // teal — comfortable cool { temp: 70, color: [122, 196, 160] }, // green — ideal { temp: 74, color: [220, 160, 80] }, // amber — warm { temp: 80, color: [220, 90, 50] }, // orange — hot ]; function tempToColor(tempF) { for (let i = 0; i < COLOR_SCALE.length - 1; i++) { const lo = COLOR_SCALE[i], hi = COLOR_SCALE[i+1]; if (tempF >= lo.temp && tempF <= hi.temp) { const t = (tempF - lo.temp) / (hi.temp - lo.temp); return lo.color.map((c, j) => Math.round(c + t * (hi.color[j] - c))); } } return COLOR_SCALE[COLOR_SCALE.length - 1].color; } function idwInterpolate(px, py, sensors, power = 2) { // px, py: pixel position as fraction of canvas width/height let weightedSum = 0, totalWeight = 0; for (const s of sensors) { const dx = px - s.x / 100; const dy = py - s.y / 100; const dist = Math.sqrt(dx*dx + dy*dy); if (dist < 0.001) return s.temp; // at the sensor itself const w = 1 / Math.pow(dist, power); weightedSum += w * s.temp; totalWeight += w; } return weightedSum / totalWeight; } function renderHeatmap(canvas, sensors, alpha = 0.45) { const ctx = canvas.getContext('2d'); const { width, height } = canvas; const step = 6; // pixel step — lower = smoother but slower const imgData = ctx.createImageData(width, height); for (let y = 0; y < height; y += step) { for (let x = 0; x < width; x += step) { const temp = idwInterpolate(x/width, y/height, sensors); const [r,g,b] = tempToColor(temp); // Fill a step×step block for performance for (let dy = 0; dy < step && y+dy < height; dy++) { for (let dx = 0; dx < step && x+dx < width; dx++) { const idx = ((y+dy) * width + (x+dx)) * 4; imgData.data[idx] = r; imgData.data[idx+1] = g; imgData.data[idx+2] = b; imgData.data[idx+3] = Math.round(alpha * 255); } } } } ctx.putImageData(imgData, 0, 0); } // Refresh every 30 seconds when sensor data updates via MQTT async function refreshFloorPlan() { const data = await fetch('/api/indoor').then(r => r.json()); const config = await fetch('/api/floor_plan_config').then(r => r.json()); const floor = config.floors.find(f => f.id === currentFloor); const sensors = floor.sensors.map(s => ({ x: s.x, y: s.y, temp: data[s.id]?.temperature ?? 68 })); renderHeatmap(document.getElementById('heatmap-canvas'), sensors); updateMarkers(floor, data); }
Rather than manually measuring pixel coordinates, the dashboard includes a one-time setup tool that lets you click directly on the floor plan image to place each sensor. It writes the percentages back to floor_plan_config.json automatically.
# Access the position editor at: http://your-server:8080/setup/floor-plan # Editor workflow: # 1. Select a floor from the dropdown # 2. Select a sensor or equipment item from the sidebar list # 3. Click its location on the floor plan image # 4. x/y percentages are calculated and saved automatically # 5. Repeat for all sensors and equipment # 6. Toggle "Edit Mode" off to switch back to live view
Phase 3 replaces the existing Phase 1/2 dashboard with a single unified application. The floor plan is the default landing view. All existing data panels from Phases 1 and 2 become tabs in the navigation bar — nothing is removed, just reorganized spatially.
| Sensor type | Popover shows |
|---|---|
| Aqara temp/humidity | Current temp, humidity, dewpoint. 24h sparkline for both. Time since last reading. Battery level if applicable. |
| ERV unit | Current fan mode and speed. Intake temp, exhaust temp, CFM. Power draw now and today's total. Filter life remaining. One-click mode override (Min/Med/Max). |
| Heat pump zone | Current mode and setpoint. Actual room temp. Compressor state and outdoor temp (from CN105). Current watts from Emporia. Active automation reason. |
static/ directory of the FastAPI backend. Confirm images load at the right path./api/indoor endpoint to the heat map renderer. Add the equipment marker status layer. Test that clicking a marker shows correct live data. Tune the IDW power factor to taste — start at 2, increase if the gradient looks too smeared.