The home-automation / IoT support layer — the infrastructure that feeds Home Assistant. Built to grow: MQTT is the first member; zigbee2mqtt, node-red, esphome, zwave-js-ui, frigate, etc. drop into this same stack + broker as needed.
A Homelab.Stacks.* submodule
(mounts at stacks/SmartHome), converged by the Fallout engine.
flowchart LR
MATE["🚗 Leapmotor Mate<br/>CT 4100"] -->|publish| BROKER
ESP["📟 ESPHome<br/>CT 6003"] -->|publish| BROKER
BROKER["📮 Mosquitto broker<br/>CT 6000 · 10.40.26.247<br/>auth + HA-discovery"]
BROKER <-->|"pub/sub + discovery"| HA
MATTER["🔌 matter-server<br/>CT 6001"] -. "WebSocket" .-> HA
ZBGW["📡 Zigbee gateway<br/>10.40.0.21 · EZSP + BLE proxy"] -. "ZHA + BLE" .-> HA
AIR["📺 aircast<br/>CT 6002 · AirPlay"]
HA["🏠 Home Assistant<br/>CT 6005 · 10.10.0.21<br/>Homelab 1010 + IoT leg"]
PANG["🔐 Pangolin<br/>SSO ingress"] -->|homeassistant.iot.chrison.dev| HA
subgraph IOT["IoT VLAN 1040"]
MATE
ESP
BROKER
MATTER
AIR
ZBGW
end
classDef bus fill:#dbeafe,stroke:#2563eb;
class BROKER bus;
CTID block 6000–6099 for LXC members (declared in stack.yaml).
Home Assistant moved into the block on 2026-08-09 (CT 6005) — the retired HAOS VM 2000 and
Leapmotor Mate (CT 4100) are the remaining out-of-block entries, both superseded.
| ID | Member | Kind | Net | Role | Status |
|---|---|---|---|---|---|
| 6000 | mqtt | LXC (Mosquitto) | IoT 1040 · 10.40.26.247 |
The message bus | ✅ live |
| 6001 | matter-server | Docker host | IoT 1040 · 10.40.62.181 |
Matter/Thread controller (ex-HA add-on) | ✅ live |
| 6002 | aircast | Docker host | IoT 1040 · 10.40.147.133 |
Chromecast→AirPlay bridge (ex-HA add-on) | ✅ live |
| 6003 | esphome | LXC (native) | IoT 1040 · 10.40.60.203 (reserved) |
ESP firmware dashboard/builder (ex-HA add-on) | ✅ live (#251) |
| 6004 | podman-host | LXC (rootless Podman) | IoT 1040 | Quadlet host — runs Leapmotor Mate (ADR-0009) | ✅ live |
| 6005 | homeassistant | Docker host (HA Container) | Homelab 1010 + IoT 1040 | The hub | ✅ live — replaced VM 2000 |
| 4100 | leapmotor-mate | Docker host | IoT 1040 · 10.40.169.225 (reserved) |
Leapmotor C10 companion → publishes to the broker | 🔎 adopted in-place, out-of-block |
| 2000 | homeassistant | VM (HAOS) | legacy 192.168.179.102 |
Former hub | ⛔ retired — stopped, onboot: false, kept as rollback |
Both ran as HAOS add-ons; now standalone. Same images the add-ons wrap, layered on a
thin Docker host (app: docker), CT-local state, internal-only.
- matter-server (
ghcr.io/home-assistant-libs/python-matter-server): HA drives it over WebSocket — point HA's Matter integration atws://10.40.62.181:5580/ws.⚠️ Matter needs IPv6. VLAN 1040 currently sends no Router Advertisements, so device commissioning will fail (chip … Network is unreachable) until IPv6 is enabled on the IoT VLAN (UniFi → network 1040 → IPv6/RA). The WS↔HA path works over IPv4 regardless. Its community-scripts native installer is disabled upstream (python-matter-server archived 2026-06-23), hence the Docker-host route. - aircast (AirConnect,
1activegeek/airconnect):network_mode: hostso mDNS/RTP reach the LAN; discovers Chromecasts and re-advertises them as AirPlay. No HA dependency.
Create-path fix (2026-07-17): community-scripts'
build.funcgained a host "LXC-stack upgrade available?" gate that prompts viaread </dev/tty— fatal to non-interactive SSH creates once apve-container/lxc-pveupdate is pending. The engine now passesDISABLE_UPDATE=yes PHS_SILENT=1(build.func's own unattended escapes) on every create. SeeInfrastructure/engine/Converge/CommunityScriptsCreator.cs.
Native community-scripts install (app: mqtt → ct/mqtt.sh → Eclipse Mosquitto
2.x). The installer ships a hardened default.conf (allow_anonymous false,
password_file /etc/mosquitto/passwd, listener 1883) but no password file —
so the broker rejects everyone until users are added.
# on CT 6000 — one user per client, then lock the file + restart
mosquitto_passwd -b -c /etc/mosquitto/passwd leapmotor '<pw>'
mosquitto_passwd -b /etc/mosquitto/passwd homeassistant '<pw>'
chown mosquitto:mosquitto /etc/mosquitto/passwd && chmod 600 /etc/mosquitto/passwd
systemctl restart mosquittoPasswords are generated and stored in Bitwarden (items mqtt · leapmotor,
mqtt · homeassistant). Anonymous is off; a wrong password gets not authorised.
The broker's IP is DHCP-reserved (10.40.26.247) so clients have a stable address.
- Leapmotor Mate (CT 4100) — an IoT service, so it lives on VLAN 1040
alongside the broker (moved off Homelab 1010 to avoid an inter-VLAN hop; the
IoT VLAN is firewalled off from 1010). Enable in Mate → Settings → MQTT:
broker
10.40.26.247:1883, userleapmotor, discovery on (prefixhomeassistant). Mate publishes state toleapmotor/<VIN>/…and HA-discovery configs tohomeassistant/sensor/…. Seeleapmotor-mate/for the app itself — the dedicated-account requirement + first-run cert wizard. - Home Assistant (VM 2000) — reaches the broker over its legacy NIC (legacy →
1040 routes fine). Manual step: Settings → Devices & Services → Add
Integration → MQTT → broker
10.40.26.247, port1883, userhomeassistant+ password. HA then auto-discovers every Mate entity.
HA also has an idle IoT NIC (
net1, tag 1040,link_down=1) intended for future L2 chatter with IoT devices. It's not needed for MQTT and is left untouched — enabling it is a deliberate HAOS-side change, not done here.
homeassistant.lxc.yaml — HA Container via community-scripts
ct/homeassistant.sh (Docker on Debian 13 running ghcr.io/home-assistant/home-assistant:stable
with --net=host). Migrated from the HAOS VM on 2026-08-09 (Homelab#250).
| Internal | homeassistant.iot.chrison.internal and homeassistant.homelab.chrison.internal → 10.10.0.21 |
| External | homeassistant.iot.chrison.dev and homeassistant.lab.chrison.dev — Pangolin SSO |
| Sizing | 4 cores / 4096 MB / 32 GB — not the script's 2/2048/16 default, which is the exact shape that timed out HA's bootstrap on the VM (Homelab#382) |
| NICs | net0 Homelab 1010 10.10.0.21 (default route) · net1 IoT 1040 10.40.0.22 (device-facing, no default route) |
Updates are the community-scripts update_script (docker pull + recreate), deliberately
outside converge — that maintained upgrade path is the reason for using it. Converge owns the CT
(size, NICs, lifecycle) and nothing inside it. No Supervisor, so no add-on store and no
HA-managed backup.
Nothing was restored from VM 2000. It had 75 config entries of which only 19 loaded, and 530 of its 701 entities were unavailable — mostly ghosts from the previous rental. CT 6005 runs 21 entries, all loaded. Discovery-based integrations repopulated themselves: MQTT came back with the same 81 entities without intervention.
The plan assumed a full re-pair. It was wrong, and the correction is worth keeping: both zha
entries on VM 2000 being not_loaded with 0 entities meant nothing was connected to the
coordinator, not that the network was lost. ZHA's config flow offered reuse_settings — only
shown when the radio's NVRAM holds an existing network — so CT 6005 adopted the live network
(pan_id F881, channel 25). Sleepy battery devices still have to transmit once before ZHA
re-interviews them, but no device was re-paired.
Probe the coordinator before assuming a Zigbee network is gone. not_loaded is a client-side
symptom.
The Xiaomi/BTHome sensors arrive over the gateway's ESPHome bluetooth_proxy, not a USB dongle
— which is what makes running HA in an LXC viable at all. That same firmware also carries a legacy
ble_monitor bridge that floods the log; see
docs/devices/tube-zb-gw-efr32/ before touching the
gateway's Bluetooth switch or the ESPHome allow_service_calls option.
homeassistant.vm.yaml is now spec.manage: retired with
onboot: false, and VM 2000 is stopped, not destroyed — it is the rollback (qm start 2000).
retired is load-bearing: converge refuses to recreate it even when named in --only, which a tag
and a comment do not (Homelab#362).
The shape captures the container. It does not capture HA's .storage: config entries and their
credentials, the floor/area registry, the ZHA device database, integration options, or anything
installed through HACS. That gap is tracked in #27, which is blocked on deciding how secrets
are handled — .storage holds live credentials and the Zigbee network key.
This stack owns its own Fallout pipeline. ./build.sh here validates and packages —
it does not deploy; see Deploying for that.
./build.sh # ValidateShapes — the same engine `validate` the superproject runs
./build.sh Bundle # + dist/smarthome-<version>.tar.gz and a manifest
./build.sh Release --dry-run # + resolve the version, without publishing
./build.sh Bundle --skip ValidateShapes # macOS/Windows: the portable validator is linux-x64 onlyFallout 10.4 is public on nuget.org, so nothing here needs a package-feed PAT. The one
credential is SCHEMA_RO_PAT (contents:read on the private superproject), used to
download the portable validator from its schema-v1 release.
Releases are the deploy unit. A merge to main that changes a shape or a service asset
cuts a GitHub Release: the bundle plus a MANIFEST.md recording the commit, the build time,
and every member's ID and lifecycle. A deploy resolves the tag to that bundle, so what was
validated is what ships. Docs-only merges don't release — the artifact would be identical.
Versions are SemVer derived from the labels on the PRs merged since the last tag:
| Label on any merged PR | Bump | What it means here |
|---|---|---|
breaking-change |
major | a member's ctid, or anything that forces recreating a guest |
enhancement |
minor | a new member or capability |
| anything else | patch | fixes, dependencies, housekeeping |
So the label set at PR-creation time decides the version; a PR merged without one is a patch
bump and lands under "Other Changes". See .github/release.yml.
Converge runs from the superproject, which owns the engine and the cluster credentials (Proxmox API + SSH to the nodes, on the self-hosted runner):
# in Chrison-Homelab/Homelab
./build.sh Preview --stack SmartHome # dry-run (read-only)
./build.sh Deploy --stack SmartHome # apply — creates/updates LXC membersThe retired HA VM (2000) carries
manage: retired, soDeploynever writes or recreates it — naming it in--onlydoes not override that (#325, Homelab#362).
| Path | Purpose |
|---|---|
stack.yaml |
Stack defaults + CTID block (6000–6099), IoT VLAN 1040. |
mqtt.lxc.yaml |
Mosquitto broker — CT 6000. |
matter-server.lxc.yaml · aircast.lxc.yaml · esphome.lxc.yaml |
The ex-HA-add-on members (CT 6001–6003). |
leapmotor-mate.lxc.yaml |
Adopted Docker-host LXC (CT 4100, out-of-block) — the C10 companion. |
leapmotor-mate/ |
Its compose + certs + .env.example + service README. |
homeassistant.lxc.yaml |
Home Assistant — CT 6005, the hub. Dual-homed, converge-managed. |
homeassistant.vm.yaml |
The retired HAOS VM (2000) — stopped, kept as the rollback. |
build/Build.cs · build.sh · build.ps1 |
This stack's Fallout pipeline — validate, bundle, release. |
.github/release.yml |
Label taxonomy. Drives the generated notes and the version bump. |
.github/workflows/build.yml · release.yml |
PR gate, and the Production release. |