My Home Assistant floor plan uses Blender for the expensive rendering and a custom Lovelace card for the live state. The browser does not run a 3D scene. It combines pre-rendered images, selects a floor and sends normal Home Assistant service calls.
That split keeps the visual model independent of the automation system. Replacing a lamp does not require rebuilding the house in Blender; it requires updating the room's entity mapping.
Render assets once, compose them in the card
View the rendering and control paths at full size.
The assets need a consistent camera, scale and coordinate system. Each light overlay stores its position relative to the base image. Changing the camera between renders breaks alignment even when every PNG looks reasonable on its own.
There are two separate concerns in the browser: the selected view determines which day/night floor layers are visible; the current light states determine what is drawn into those layers. Keeping those concerns separate turned out to prevent a subtle bug later.
Configure the room mapping explicitly
The custom card is registered as custom:kroit-floorplan-card. A YAML-mode panel can load a separate room map. This example uses invented room names and entities; it does not expose my household layout.
# dashboard.yaml
views:
- title: House
path: house
type: panel
cards:
- type: custom:kroit-floorplan-card
mode: house
return_after: 120
title: House
rooms: !include rooms.yaml
# rooms.yaml — keys must match room IDs or floor/name keys in the asset model
EG/Studio:
name: Studio
lights:
- light.studio_ceiling
- light.studio_desk
temperature: sensor.studio_temperature
presence: binary_sensor.studio_presence
EG/Hall:
name: Hall
lights:
- switch.hall_light_relay
The room override parser accepts a string, an array of entities or an object with attributes such as lights, temperature and presence. An explicit entity mapping is marked fixed, so later automatic discovery cannot overwrite it.
Automatic mapping considers visible light.* entities in matching areas and floors. It excludes hidden registry entries. It does not collect every switch.*: a wall panel can expose switches for its screen, backlight or configuration. Mapping those as room lights would make a floor-plan tap change the wrong setting. A relay that really controls a light belongs in an explicit room override.
This is configuration for my custom card, not a built-in Home Assistant card. A different card must implement the same options before this YAML is useful. Home Assistant's custom-card API documents how the host provides hass and configuration to a card.
Composite the light, then restore the base silhouette
The core renderer is short enough to inspect. In this excerpt, cv holds a canvas, its base image and positioned light overlays. Room state has already been normalized to on, bri and color.
_draw(cv, tod) {
if (!cv.img) return;
const g = cv.c.getContext('2d', {willReadFrequently: true});
g.globalCompositeOperation = 'source-over';
g.globalAlpha = 1;
g.clearRect(0, 0, cv.w, cv.h);
g.drawImage(cv.img, 0, 0);
const lights = cv.lights_src || cv.lights;
g.globalCompositeOperation = 'lighter';
for (const room of this._rooms) {
if (!room.on) continue;
const overlay = lights[room.id];
if (!overlay || !overlay.img) continue;
const intensity = Math.max(0.15, room.bri) * (tod === 'day' ? 0.2 : 1);
g.globalAlpha = Math.min(1, intensity);
g.drawImage(this._tinted(overlay.img, room.color), overlay.x, overlay.y);
}
g.globalAlpha = 1;
g.globalCompositeOperation = 'destination-in';
g.drawImage(cv.img, 0, 0);
g.globalCompositeOperation = 'source-over';
}
The lighter operation adds the illumination. It also adds alpha, which can reveal pixels outside the intended base silhouette. Drawing the base again with destination-in restores that silhouette. Resetting the composition mode matters because the same canvas is reused for later state updates.
Daytime overlays use a factor of 0.2; nighttime overlays use full intensity. The minimum 0.15 is a visibility choice for the floor-plan UI, not a model of physical light output. Tinted overlays are cached by color. With multiple active lights in a room, the card uses the first active light's attributes; it does not simulate their combined light field.
State reads and service calls stay in Home Assistant
The card reads entity state through hass.states. A click uses hass.callService for a mapped entity's domain. For a single known light, the operation looks like this:
const entityId = 'light.studio_ceiling';
const state = this._hass.states[entityId];
if (state && !['unknown', 'unavailable'].includes(state.state)) {
await this._hass.callService('light', 'toggle', {entity_id: entityId});
}
This is an illustrative one-entity handler, not the complete room toggle implementation. The full card also handles explicitly mapped relay switches. The render should follow the next Home Assistant state update, so a failed service call does not leave a locally invented “on” state on the floor plan.
The regression worth keeping
A light update once had the potential to reveal a hidden day/night layer. That is the kind of bug a screenshot of the initial view misses. Layer visibility is now derived from both the selected view and time of day:
V.cv.day.c.style.opacity = V.show && !night ? 1 : 0;
V.cv.night.c.style.opacity = V.show && night ? 1 : 0;
The regression test selects a view, updates light state and checks that the hidden floor remains hidden after redraw. Other card tests exercise explicit room mappings and service calls with a fake Home Assistant object. In the card's source checkout, they run without a Home Assistant server:
node --test tests/kroit-floorplan-card.test.cjs
Floor changes use CSS transitions; prefers-reduced-motion disables them. The useful visual checks are a narrow tablet, a hidden floor receiving state updates, an unavailable entity and a long room name. The architecture can stay small because Blender owns the rendered appearance and Home Assistant owns the actual device state.