Old heartbeat trusted the retained zigbee2mqtt/bridge/state topic, which stays
{"state":"online"} even while the container crash-loops (6900+ restarts
went undetected). New logic: RestartCount increase since last run =
authoritative DOWN signal; container running = secondary guard; bridge state
is informational only (z2m publishes it on transition, not continuously, so
missing in a short window is normal).
- doorbell-listener.py: poll private ntfy topics with auth= (Bearer token) to stop 403/429; add probe-tag routing to DoorbellProbe
- scripts/kuma-heartbeat.sh: publish health canary to ntfy.sh with Authorization: Bearer (was anonymous -> rate limited -> false down in Kuma); read token from .secrets/ntfy-token
Add build_plugs() to flow2json.py: a flow can declare a plugs: table
({id, name, channels?}) plus single_plugs/hold_plugs lists and the
generator emits all plumbing automatically — state-in node, /set out node,
dashboard widget, and (for multi-channel devices) a mapper function that
toggles each channel (state_1/state_2) instead of the ignored shared
{state:TOGGLE} payload.
Rewrite zigbee-monitor.yaml to the declarative form; Dual Outlet - Couch
is multi-channel (channels: [1,2]). Adding a plug is one line, no manual
node/wire duplication. Single press toggles 5 devices (couch via mapper
verified E2E ON/OFF), hold-release still DJ Booth only.
Refs: david/nr-flow-validator#2
Dual plug only responds to per-channel state_1/state_2 commands, not the
shared {state:TOGGLE} payload. Route single-filter through a couch-mapper
that emits {"state_1":"TOGGLE","state_2":"TOGGLE"} so both outlets flip.
Also adds state-in node, /set out node, and dashboard widget showing
state_1/state_2. Verified E2E + 567 Rego checks pass.
Refs: david/nr-flow-validator#2
Mosquitto:
- Change restart: always (was unless-stopped) to survive daemon restarts
- Add healthcheck (pub/sub ping) for depends_on conditions
- All downstream services use condition: service_healthy
Party-lock:
- Add MQTT connection retry loop (was crashing on DNS race after restart)
Zigbee2mqtt:
- Add entrypoint wrapper that auto-applies BLZ bridge patch at startup
- Bridge patch no longer needs manual 'just z2m-patch-bridge' after image pulls
Mopidy:
- Add python3-gi + python3-gi-cairo via apt (fixes 'No module named gi')
- Set PYTHONPATH to expose system gi module to Python 3.12
Global party lock toggled via party-lock/set ON/OFF.
When locked, mosquitto ACL denies ALL zigbee2mqtt/+/set publishes.
Add lock button to CYD status bar.
Listener watches party-lock/<plug>/set and dynamically generates
mosquitto ACL deny rules. Test against bamboo first, then dj-booth.
Add lock button to CYD dashboard.
mopidy needs:
- gstreamer plugins (good/bad/ugly for audio formats)
- libgirepository + gir bindings (PyGObject) for gi module
- gstreamer-1.0 gir bindings for gst module
Without zeroconf backend, librespot fails with 'Credentials are required
if discovery and oauth login are disabled'. The default features include
with-libmdns but our --no-default-features build excluded it.
Add with-libmdns so the device advertises itself via mDNS and shows up
in phone Spotify apps as a Connect target.
compose.yaml command: > syntax was being parsed as '--name' being the
executable, not an arg to librespot. Use Dockerfile CMD instead which
already includes the right args.
The host's /run/dbus/system_bus_socket is the standard dbus socket for
systemd. Bind-mounting the container's socket onto /run/dbus overwrites
the host's socket, breaking dockerd's ability to manage cgroups via
systemd (manifests as 'org.freedesktop.systemd1 was not provided by any
.service files' on subsequent docker run).
Use tmpfs instead — ephemeral container-local sockets that don't conflict
with host services.
Alpine's pipewire-pulse is a minimal build that doesn't load
/usr/share/pipewire/pipewire-pulse.conf (no context.modules).
Provide a full config that includes all required modules.
Alpine's pipewire-pulse binary is stripped (-a option removed); the
only way to configure listening address is via config file. Add
/etc/pipewire/pipewire-pulse.conf that overrides server.address to
include tcp:4713 alongside the default unix:native socket.
Server addresses now:
- unix:native (for in-container clients)
- tcp:4713 (for sibling containers connecting via PULSE_SERVER env var)
Alpine's pipewire-pulse package ships the daemon binary but doesn't
auto-load the PA bridge module on alpine. We need to start pipewire-pulse
as a separate process with -a tcp:4713 so PA clients (librespot, mopidy)
can connect via TCP.
Layout:
- pipewire: audio server (native protocol)
- pipewire-pulse: PA-compat bridge, listens on tcp:4713 + unix:/tmp/pulse-socket
- wireplumber: session manager (loads BT modules, hot-plug handling)
- bluetoothd at /usr/lib/bluetooth/bluetoothd (not in PATH on alpine)
- Add session dbus via dbus-daemon --session (pipewire needs it)
- Clean up stale dbus.pid files on startup (host volume persists)
oauth2 crate gates TLS backends behind features; without native-tls,
librespot-oauth fails to compile with compile_error!. The default
features include native-tls, but we use --no-default-features for
minimal build.
Cross-compile on x86_64 with qemu emulation takes >30min and hits
60-90min ceiling due to slow crate-by-crate aarch64 build under qemu.
Native ARM64 build on RPi 3B+ with --jobs 2 (1GB RAM) is much faster.
Rust 1.85.1 installed via rustup on RPi (precompiled aarch64 toolchain).
- audio-bridge: alpine + pipewire + pipewire-pulse + wireplumber + bluez + dbus + tini
Routes audio from librespot/mopidy/phone-BT to USB DAC (Q5K) via ALSA.
Privileged + host network for bluetoothd HCI access.
- mopidy: built from python:3.12-slim-bookworm + pip (no ghcr.io dep).
Qobuz (auth from developer.qobuz.com) + local files. PA bridge to audio-bridge.
- librespot: built from rust:1.85.1 on RPi (cross-compile too slow under qemu).
Spotify Connect target named 'Klubhaus'. PA bridge to audio-bridge.
Both mopidy and librespot build locally to avoid ghcr.io auth/rate-limit issues.
PEQ stays on Q5K side (Qudelix app, 20 bands) for source-independent EQ.
ntfy /json?poll=1 is regular polling, not long-poll. Worst-case
lag = 2 × POLL_INTERVAL × N_topics = 120s at 30s. Reducing to 5s
caps lag at ~20s with 2 topics. For sub-second latency, switch to
ntfy SSE (/<topic>/sse) - tracked separately.
The NR base image's /data has restrictive ACLs that block chmod on
COPY'd files even as root. Writing start.sh inline with printf + chmod
sidesteps this.
NR's automatic package.json install at container start was slow and
required internet. Move npm install to image build time, preserve the
installed node_modules via an anonymous volume so the bind mount of
./nodered:/data doesn't clobber them.
Document the ThirdReality Smart Button (3RSB22BZ) pairing mode quirk
that bit us today: the button has Standard ZigBee (blue only) and
Echo ZigBee (blue + red) modes, and only the former works with
zigbee2mqtt. Switching requires 5 quick presses of the back reset
button (firmware >= 1.00.22), NOT holding the front button.
Also document the Smart Plug Zigbee PROD pairing procedure and the
BLE->ZigBee mode switch.
Add scripts/z2m_repair.py that:
- Opens permit_join for N seconds (default 120)
- Subscribes to bridge/event on the local MQTT broker
- For each device_joined event, auto-renames the device back to its
original friendly name (hardcoded IEEE->name mapping from the
last known good state, overridable via --mapping).
After the zigbee2mqtt database was wiped, the user had to manually
pair each device via the web UI. With this recipe, devices rejoin
automatically when woken (network_key preserved), and the script
restores friendly names as they join.
Add justfile recipes:
just z2m-repair [TIME=120] - run the auto-repair script
just z2m-rename IEEE NAME - rename a single device by IEEE