cloudsium Made to be shared.
← All notes
Jakob Boghdady

A Home Assistant floor plan rendered in Blender

From Blender renders to a Lovelace canvas: explicit entity mapping, additive light overlays, alpha masking and a regression test for hidden floors.

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

Blender produces day and night base images and room-light overlays. A Lovelace card composites the assets, maps rooms to entity IDs, reads hass.states and calls Home Assistant services.

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.