Overview

This is a revised overview of how my Home Assistant installation is put together. A lot has changed since the first version in March. The hardware is different, most of the add-ons I listed are gone, and the dashboards have been rebuilt from scratch. Version numbers, names and addresses are left out or anonymised, and the code snippets are illustrative examples rather than my real settings.

KeyValue
Installation TypeHome Assistant OS, bare metal
HardwareODROID-N2 (aarch64)
SizeAbout 280 devices, 2,000 entities, 70+ integration entries, 11 areas
Device fleets10 ESPHome nodes, 29 Zigbee devices, 6 Bluetooth sensors, a 433 MHz gateway
DatabaseSQLite recorder, about 1 GB
NetworkLAN only: on the server VLAN with the rest of the home lab, plus a second leg on the IoT VLAN

Architecture

Home Assistant sits at the centre of several radio and network paths. Zigbee devices come in through Zigbee2MQTT and the Mosquitto broker. ESPHome nodes on the IoT network talk to it over the native API. Bluetooth sensors are picked up directly and through two ESPHome Bluetooth proxies.

flowchart LR Clients["Phones, PCs,
wall dashboard"] -->|HTTPS| HA["Home Assistant OS"] ZB["Zigbee coordinator"] --> Z2M["Zigbee2MQTT"] --> MQTT["MQTT broker"] --> HA RF["433 MHz gateway"] --> MQTT ESP["ESPHome nodes
(IoT network)"] -->|native API| HA BLE["Bluetooth sensors"] --> PROXY["Bluetooth proxies"] --> HA HA --> LAB["Home lab integrations
(hypervisor, NAS, UPS, DNS)"] HA --> BK["Encrypted backups"] HA -->|SMTP| MAIL["LAN mail relay"] HA -->|push| PHONE["Companion app"]

A few decisions shaped the whole setup:

DecisionWhy
Stays outside my passkey SSOIt has its own users, and the companion app needs its own login
LAN onlyEverything is used at home or over VPN
Dashboards edited over the WebSocket API, sources in gitSame as the UI does it, no restart, and the repo is the source of truth
Config repo is an allowlist snapshot, not a live checkoutThe config directory holds secrets and machine state; only listed files are copied out, then scanned
ESPHome secrets in one secrets fileDevice YAML can then be versioned safely
A second network leg on the IoT VLANSome TVs only accept remote control from their own subnet (see below)

The core configuration.yaml stays small. A minimal version of mine looks like this; everything else is configured in the UI:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
default_config:

frontend:
  themes: !include_dir_merge_named themes
  extra_module_url:
    - /local/example/custom-icons.js   # registers a custom icon set

automation: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
sensor: !include sensor.yaml
template: !include template.yaml
plant: !include plant.yaml

Add-ons and Integrations

The add-on list is much shorter than in March: Mosquitto, Zigbee2MQTT, the ESPHome Device Builder, Music Assistant, an SSH terminal (protection mode left on), Samba and the File editor. InfluxDB, Grafana, Node-RED, the local voice stack and the Zabbix agent are gone. Long-term graphs now come from Home Assistant’s own statistics, and infrastructure metrics live in my Prometheus and Grafana stack.

HACS supplies a handful of community integrations (Proxmox sensors, Bambu Lab, RAPT, Sonoff) and three frontend cards: mini-graph-card, apexcharts-card and card-mod. Home Assistant Cloud provides the Google Assistant link.

PurposeIntegrations
Home lab monitoringProxmox (read-only custom role, never admin), Portainer, Synology DSM, NUT, Pi-hole, UniFi Network, Certificate Expiry
Climate and weatherMet.no, OpenWeatherMap, a 433 MHz weather station, generic thermostats
MediaGoogle Cast, Spotify, Music Assistant, Samsung TV, Android TV Remote
DevicesESPHome, MQTT, Xiaomi BLE, RAPT, Sonoff, Tuya, HomeKit Controller, Bambu Lab P1S

The biggest entity sources are Proxmox, UniFi, Portainer and MQTT, each with roughly 300 to 400 entities.


Devices and Logic

The ESPHome nodes do real work: brewery pumps, heaters and thermostats for Rolling Hills Brewery, keg temperature and CO₂ pressure, the garage door, an IT cupboard fan, an LED matrix and two Bluetooth proxies.

The most interesting recent one is a small ESP32-C3 IR board controlling a Daikin split system. Using ESPHome’s native daikin climate platform with both a transmitter and a receiver means Home Assistant follows the real remote too. Decoding that remote needed two receiver changes, which I found with a raw dump. For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
remote_receiver:
  id: ir_rx
  pin: GPIO4          # whichever pin your IR receiver uses
  idle: 20ms          # split the remote's blocks so the state block decodes alone
  tolerance: 35%      # this remote's short gaps drift past the default 25 %

climate:
  - platform: daikin
    name: "Lobby AC"
    transmitter_id: ir_tx
    receiver_id: ir_rx
    sensor: lobby_temperature

Four template sensors do a lot of the dashboard work: lights on, doors open, plants dry and low batteries. Each one counts entities in a given state and is shown as a badge only when it matters. A minimal version looks like:

1
2
3
4
5
6
template:
  - sensor:
      - name: "Doors open"
        state: >
          {{ ['binary_sensor.front_door', 'binary_sensor.back_door']
             | select('is_state', 'on') | list | count }}

Four Mi Flora plant sensors feed the plant integration, with limits written out per plant. The integration’s own problem text says “conductivity low”; a small template sensor per plant rewrites it in plain words (“fertiliser low”) and says “OK” when all is well, which reads better on a tile.

Automations are still deliberately few, and each does one obvious thing:

  • bedside lamps on a switch and a schedule, and a shake-to-toggle Zigbee cube
  • a spoken alert when the fridge has been open for two minutes
  • a tap on the IR board’s touch pad toggles the lobby light
  • the 3D printer’s chamber light goes off at 11 pm if it was left on
  • an e-mail to the household when a plant needs water, naming the plant and where it is, with a morning reminder until it’s watered
  • an e-mail to the admin mailbox when any battery drops below 30 % (replace soon) and again below 20 % (replace now), once per device per threshold
  • the phone is switched to high-accuracy location while I’m away, and back when I’m home

The robot vacuum is a Tuya model whose Home Assistant entity accepts commands but never reports a state. A template vacuum wraps it: its state comes from the vacuum’s mode, and its actions are forwarded to the real one. (Mine had been forwarding them to itself, so the dashboard buttons did nothing.)

The battery thresholds come from two template sensors that list the devices under 30 % and 20 %. The automation only mails devices that newly appear in a list, so nothing repeats while a battery stays low and a restart doesn’t re-send:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
template:
  - sensor:
      - name: "Batteries below 30"
        state: "{{ this.attributes.entities | default([]) | count }}"
        attributes:
          entities: >-
            {{ states.sensor
               | selectattr('attributes.device_class', 'eq', 'battery')
               | selectattr('state', 'is_number')
               | rejectattr('entity_id', 'search', 'phone|ups')
               | selectattr('state', 'lt', '30')
               | map(attribute='entity_id') | list }}

(Simplified: the real one compares numbers, not strings, and skips a plant sensor’s false 0 %.)


Notifications

E-mail goes through my LAN mail relay, which only delivers to an allowlist of recipients, so nothing on the network can mail arbitrary outside addresses. Adding a household member’s address meant one line on the relay.

Home Assistant 2026.10 moved SMTP from YAML to the UI and deprecated the old notify.<name> actions (YAML stops working in 2027.1, the actions in 2027.3). Each recipient is now its own notify entity, and automations send to it like this:

1
2
3
4
5
6
action: notify.send_message
target:
  entity_id: notify.household_email
data:
  title: "Plants need watering: Plant 2 (by the window) 10 %"
  message: "Soil moisture below 15 %."

Phone push goes to the companion app through Google’s push service. The app can also hold a permanent connection, but on my Xiaomi phone that means a permanent notification, and hiding it stopped the connection. Once the app’s notification and battery settings were fixed, plain push worked, so I don’t use the permanent connection.


Dashboards

The suite is now two repo-managed dashboards. Home · PC is the default: a four-column command centre (now, needs attention, rooms, home lab) with a tab per topic, and the room pages as sub-pages. Home · Mobile is one column: problems only, thumb-sized controls and picture tiles to every topic and room. A per-device default puts the mobile one on my phone. The original UI dashboard and the first hub version were retired once everything they did had moved across.

Design rules for the suite:

  • One theme and one picture style. Line-art room and topic pictures and the same colours as my other lab sites.
  • Problems only. “Needs attention” shows a card only while something is wrong, otherwise one “All quiet” line.
  • One colour per measurement, on every tile and chart:
MeasurementColourMeasurementColour
Temperature#FF7043CPU#26A69A
Humidity#29B6F6Memory / disk#5C6BC0
Power#FFCA28Wind#90A4AE
Rain#1E88E5Gravity (SG)#D5BC26
  • Rows and columns. Every row fills its section and has one height. In the sections layout a section spanning N columns has 12 × N grid units, not 12, which I learned after sizing everything for 12. Plain tiles beside graph tiles are made the same height, and a small script checks every row before a push.
  • Compact controls. Climate tiles with mode buttons and a −/+ target instead of big thermostat dials, grouped by room.
  • Plain-word status. Plant tiles are green “OK” or red with the problem, swapped by a visibility condition.
  • Short, averaged graph windows. Six hours for climate, three for power, with averaging so lines don’t saw-tooth. For example, a tile with a trend graph:
1
2
3
4
5
6
7
type: tile
entity: sensor.office_temperature
color: "#FF7043"
features:
  - type: trend-graph
    hours_to_show: 6
    detail: false

The weather station gets proper history charts: temperature and humidity, wind and gusts, a week of hourly means from long-term statistics, and rainfall as bars per hour and per day. And the map now draws routes: the built-in map page shows where everyone is and their recent stops, but a route needs a map card with a history window:

1
2
3
4
type: map
entities:
  - person.example
hours_to_show: 24

Layout tweaks can still be made in the UI editor; they’re then copied back into the YAML so the repo stays the source of truth. Changes are made in YAML, converted to JSON, checked so every entity ID exists, backed up from the live config, and then saved over the WebSocket API. The message is the same one the UI sends, for example:

1
{"id": 1, "type": "lovelace/config/save", "url_path": "home-example", "config": {"views": []}}

HTTPS and Backups

Home Assistant serves a real Let’s Encrypt certificate for its internal name, issued and pushed by my DNS-01 certificate automation. Renewal restarts core every couple of months, which is expected.

Backups use Home Assistant’s built-in encrypted full backups, kept in more than one place including off-site. The encryption key is also stored outside Home Assistant, because a backup you can’t decrypt isn’t a backup.


A TV That Only Listens to Its Own Subnet

The bedroom TV, a 2020 Samsung, would not pair with Home Assistant. Every attempt failed at once with ms.channel.timeOut, and the TV never showed its “Allow” prompt. Its settings were all correct: access notification on, nothing denied in its device list. Restarting it, even pulling the plug, changed nothing.

The useful test was to repeat the pairing request from a machine on the TV’s own subnet. I gave one of the hypervisor nodes a temporary address on the TV’s network for a minute and sent the same request from there. The prompt appeared straight away. A token issued that way still didn’t work from Home Assistant’s server VLAN. So the TV doesn’t just pair locally: it refuses every remote-control connection from another subnet.

The fix was to put Home Assistant on the TV’s network rather than the other way round:

  1. The TV moved to the IoT VLAN, where TVs belong anyway, with a fixed address and a DNS name.

  2. Home Assistant got a second interface on that VLAN. Its switch port already carried the IoT VLAN tagged, so Home Assistant OS only needed a VLAN interface on the same cable. For example:

    1
    2
    
    # illustrative: add a tagged VLAN interface with DHCP on the existing network port
    ha network vlan eth0 <vlan-id> --ipv4-method auto --ipv6-method disabled
    

    Its main address and default route stay on the server VLAN; only traffic to IoT devices uses the new leg.

  3. Pairing then worked first time. The integration needed one reload before it accepted commands.

Switching the TV on is a separate problem. In standby it drops off the network, so the integration can’t reach it. It does listen for a Wake-on-LAN packet, but only one sent to the IoT VLAN’s broadcast address. A unicast packet goes nowhere, because nothing knows the sleeping TV’s hardware address any more. The Wake on LAN integration sends that broadcast, and an automation on the Samsung integration’s “turn on” trigger presses it, so the TV’s power button works from any dashboard tile.


Tradeoffs

Advantages

  • Dashboards are versioned and reviewable, and can be rebuilt from git
  • Secrets never leave the box; the config repo is checked before every commit
  • Small, readable YAML; the UI handles the rest
  • Fewer add-ons means fewer things to update

Limitations

  • Two editing paths (UI and repo) need discipline, or a push overwrites UI edits
  • The plant integration is YAML-only and needs a restart to change
  • Discovery-based integrations need regular tidying
  • A second network leg is one more thing the inter-VLAN firewall rules have to account for, so they were reviewed alongside the change

Lessons Learned

  • A 433 MHz gateway hears the whole street. Its discovery created devices for every neighbour’s sensor. Deleting them in the UI isn’t enough; the retained MQTT discovery messages bring them back.
  • Clean the registry. A tidy-up took me from about 3,900 entities to 2,000, and from 860 devices to 280. Some devices only look orphaned: the 3D printer’s entities vanish whenever it’s off.
  • Never hand-edit .storage while Home Assistant runs. It rewrites those files from memory. Use the API.
  • Raw IR dumps beat guessing. Two receiver settings were the whole difference between “ignores the remote” and “follows it perfectly”.
  • ESPHome upgrades can break lambdas. Read the compile error; a local test build reproduces it quickly.
  • Averaged, short graphs are easier to read than long raw ones.
  • Mi Flora batteries often read 0 % over passive Bluetooth. Check the sensor in person, and don’t let a 0 % reading raise a battery alarm.
  • Aqara sensors are fussy about routers. A fridge door sensor was offline for five weeks before anyone noticed. It only rejoined when I allowed joining through the coordinator alone and kept it awake by pressing its button during pairing.
  • Some devices report batteries rarely. After a battery change, a Zigbee2MQTT “reconfigure” forces a fresh reading.
  • Alert on the thing that does the cooling. A loose plug left the IT-cupboard fan offline for days while the disks warmed up. The dashboard now shows an alert whenever the fan’s switch is unavailable.
  • When a device silently refuses, test from its own subnet. The TV’s instant rejection looked like a settings problem; a one-minute test from a temporary address on its network showed it was the subnet.
  • Read the Repairs page after an upgrade. It flagged the SMTP change months before it would have broken anything.

Comments