Compare commits

...

32 Commits

Author SHA1 Message Date
david 97c30acfdd deploy: device port fix, entrypoint CMD, mosquitto healthcheck 2026-07-11 04:37:06 -07:00
david ba3e172dda fix: power outage auto-recovery improvements
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
2026-07-09 18:56:36 -07:00
david 5383edd4ae cyd-dashboard: add CYD plug dashboard sketch
4-button UI for zigbee2mqtt smart plugs with party-lock toggle,
touch trace animation, and DJ Booth double-tap protection.
2026-07-02 23:34:28 -07:00
david ad9a5b4e02 party-lock: use Docker SDK instead of docker kill CLI
The docker binary is not available inside the party-lock container,
so switch to the Python Docker SDK via unix socket.
2026-07-02 23:33:43 -07:00
david 67bb68b097 party-lock: fix ACL file permissions (0700, owner 1883)
Mosquitto 2.1.2 silently skips ACL file if world-readable or wrong owner.
Listener now sets proper permissions on ACL file writes.
Add CYD lock flash feedback on blocked tap.
2026-07-02 20:46:21 -07:00
david 3e06bc9e4b party-lock: global ACL block all zigbee2mqtt/+/set
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.
2026-07-02 20:14:41 -07:00
david 36f9d0a284 party-lock: add MQTT ACL-based plug lock service
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.
2026-07-02 20:04:11 -07:00
david 12bfd7fb09 mopidy: use libgirepository-1.0-1 (Debian 12 slim variant package name)
python:3.12-slim-bookworm uses the older variant of libgirepository.
The package name with dashes (libgirepository-1.0-1) is what's available.
2026-07-01 08:40:15 -07:00
david 9faa5e15a1 mopidy: use libgirepository-2.0-0 (the -2.0 package variant) 2026-07-01 08:38:41 -07:00
david 9f1479edf6 mopidy: fix libgirepository package name (1.0-1 not 2.0-0)
Debian bookworm uses libgirepository1.0-1, not libgirepository-2.0-0.
The .0-0 suffix was a typo.
2026-07-01 08:36:48 -07:00
david 6ff5e91952 mopidy: add gstreamer + PyGObject apt deps
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
2026-07-01 08:35:35 -07:00
david ae088c2d9f mopidy: pin setuptools<80 (pkg_resources removed in setuptools 80+)
setuptools 80+ removed pkg_resources module. Mopidy imports pkg_resources
at startup, so we need a version that still bundles it.
2026-07-01 08:33:46 -07:00
david 0da1fbf9db mopidy: add setuptools to fix 'No module named pkg_resources'
Python 3.12 slim doesn't bundle setuptools. mopidy imports pkg_resources
from setuptools; without it, mopidy crashes on startup.
2026-07-01 08:11:03 -07:00
david e2e9492a8d librespot: add with-libmdns feature for Spotify Connect discovery
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.
2026-07-01 07:38:40 -07:00
david b2e44369a9 librespot: fix CMD args (--enable-audio-cache is invalid; --initial-volume)
v0.8.0 librespot uses --disable-audio-cache (audio cache is on by default).
Renamed and added --initial-volume 100 for full startup volume.
2026-07-01 07:24:50 -07:00
david 131aa9d698 librespot: remove conflicting command override (Dockerfile CMD is enough)
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.
2026-07-01 07:23:45 -07:00
david 134fd4a0ed audio-bridge: switch /run/dbus + /run/user/0 from bind-mount to tmpfs
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.
2026-07-01 07:22:42 -07:00
david 3e730942ba audio-bridge: full pipewire-pulse.conf with context.modules
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.
2026-07-01 07:16:42 -07:00
david fff67f2baa audio-bridge: custom pipewire-pulse.conf with TCP 4713 listener
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)
2026-07-01 07:14:20 -07:00
david 6c896f5165 audio-bridge: start pipewire-pulse separately with TCP 4713 listener
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)
2026-07-01 07:12:02 -07:00
david 6b55a16581 audio-bridge: remove --print-pid from dbus session (broken in containers)
dbus-daemon --print-pid=N opens fd N for writing; under tini this fails
with 'Writing to pipe: Invalid argument'. Just use --fork for daemonization.
2026-07-01 07:09:54 -07:00
david c2f14b60aa audio-bridge: fix bluetoothd path + add session dbus + cleanup stale pid
- 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)
2026-07-01 07:06:25 -07:00
david 32b9189f2f librespot: add native-tls feature (required by librespot-oauth)
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.
2026-07-01 05:52:07 -07:00
david e5b52fa56f librespot: switch from cross-compile to native ARM64 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).
2026-07-01 05:00:22 -07:00
david 573821426c audio: add PipeWire+BT audio-bridge, mopidy (Qobuz+local), librespot (Spotify Connect)
- 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.
2026-07-01 04:49:15 -07:00
david 09a4328960 doorbell-listener: poll every 5s (was 30s) for tighter lag
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.
2026-07-01 02:46:40 -07:00
david 7ed170acd4 doorbell-listener: 1s flash interval (was 2s), 14 toggles total
Each blink is now 1s (was 2s); 14 toggles over 13s gives sharper
'rapid fire' cadence. Even count keeps the plug ending at its
starting state.
2026-07-01 02:45:23 -07:00
david 66bc573dfd nodered: inline-write start.sh to avoid COPY+chmod permission issue
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.
2026-07-01 02:13:23 -07:00
david 1ac9ef948b nodered: bake Dashboard 2.0 into Dockerfile for idempotent install
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.
2026-07-01 01:50:39 -07:00
david 3f0c85c41b zigbee2mqtt: add per-device pairing notes (3RSB22BZ button modes)
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.
2026-07-01 01:35:04 -07:00
david fda24c18a2 zigbee2mqtt: add z2m-repair / z2m-rename recipes for rejoin
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
2026-07-01 01:25:38 -07:00
david c2de5ba85b zigbee2mqtt: patch Bridge TypeError on BLZ coordinator
The Pine64 BLZ fork's zigbee2mqtt image crashes on start because
Bridge.start() calls firstCoordinatorEndpoint().deviceIeeeAddress
before the onMQTTMessage listener is registered. The BLZ coordinator
does not register its endpoint (the coordinator is the dongle itself,
no ZDO loopback), so firstCoordinatorEndpoint() returns undefined.

The crash happens in publishInfo() BEFORE onMQTTMessage is wired up,
which means every bridge MQTT request (permit_join, device rename,
group ops, etc.) silently no-ops — even though the message is
received.

Add infra/zigbee2mqtt/scripts/blz-bridge-patch.js that applies two
surgical fixes (idempotent):

  1. Optional chain on the .deviceIeeeAddress access so the missing
     endpoint doesn't throw.
  2. Move onMQTTMessage registration BEFORE publishInfo/devices/
     groups/definitions, and wrap each publish in try/catch so
     Bridge.start() completes even if a single publish fails.

Add 'just z2m-patch-bridge' to apply the patch on the live container
after fresh image pulls.
2026-07-01 01:22:56 -07:00
22 changed files with 1432 additions and 11 deletions
+1
View File
@@ -1 +1,2 @@
output/
secrets.h
+45
View File
@@ -0,0 +1,45 @@
// ══════════════════════════════════════════════════════════
// TFT_eSPI User_Setup for ESP32-32E-4" (Hosyond 320x480)
// This file is copied over vendor/esp32-32e-4/TFT_eSPI/User_Setup.h
// by the install-libs-32e-4 task.
// ══════════════════════════════════════════════════════════
#define USER_SETUP_ID 201
// ── Driver ──
#define ST7796_DRIVER
// ── Resolution ──
#define TFT_WIDTH 320
#define TFT_HEIGHT 480
// ── SPI Pins (from lcdwiki.com/4.0inch_ESP32-32E_Display) ──
#define TFT_MISO 12
#define TFT_MOSI 13
#define TFT_SCLK 14
#define TFT_CS 15
#define TFT_DC 2
#define TFT_RST -1 // tied to EN, handled by board
// ── Backlight ──
#define TFT_BL 27
#define TFT_BACKLIGHT_ON HIGH
// ── Touch (XPT2046 resistive) ──
#define TOUCH_CS 33
#define TOUCH_IRQ 36
// ── SPI speed ──
#define SPI_FREQUENCY 40000000
#define SPI_READ_FREQUENCY 20000000
#define SPI_TOUCH_FREQUENCY 2500000
// ── Misc ──
#define LOAD_GLCD
#define LOAD_FONT2
#define LOAD_FONT4
#define LOAD_FONT6
#define LOAD_FONT7
#define LOAD_FONT8
#define LOAD_GFXFF
#define SMOOTH_FONT
+586
View File
@@ -0,0 +1,586 @@
// CYD Plug Dashboard — 4-button UI for zigbee2mqtt smart plugs
//
// Hardware: Hosyond ESP32-32E 4" (320x480, ST7796, XPT2046 touch)
// TFT pins: User_Setup.h (vendored from klubhaus-doorbell)
// Backlight: GPIO 27
// Touch CS: GPIO 33, IRQ: GPIO 36
//
// MQTT broker: 192.168.81.147:1883 (set in secrets.h)
// Subscribe: zigbee2mqtt/<plug> (state JSON {"state":"ON"})
// Publish: zigbee2mqtt/<plug>/set (TOGGLE)
//
// Layout: 2x2 grid of buttons, status bar at top
// ┌─────────────────────────────┐
// │ WiFi✓ MQTT✓ Squiggle● ON │
// ├──────────────┬──────────────┤
// │ Squiggle │ Sideboard │
// │ ON │ OFF │
// ├──────────────┼──────────────┤
// │ Bamboo Lights│ DJ Booth │
// │ ON │ OFF │
// └──────────────┴──────────────┘
#include <Arduino.h>
#include <TFT_eSPI.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <esp_random.h>
#include "secrets.h"
// ── Plugs ──────────────────────────────────────────────────────
struct Plug {
const char* name;
const char* topic;
bool state;
};
static Plug plugs[] = {
{"Squiggle", "zigbee2mqtt/Squiggle"},
{"Sideboard", "zigbee2mqtt/Sideboard Lamp"},
{"Bamboo", "zigbee2mqtt/Bamboo Lights"},
{"DJ Booth", "zigbee2mqtt/DJ Booth"},
};
static const int PLUG_COUNT = sizeof(plugs) / sizeof(plugs[0]);
// ── Party Lock ─────────────────────────────────────────────────
#define PARTY_LOCK_TOPIC "party-lock/set"
bool partyLocked = false;
bool partyLockPending = false;
// ── Double-tap ────────────────────────────────────────────────
#define DOUBLE_TAP_MS 2000
static int doubleTapIdx = -1;
static uint32_t doubleTapTime = 0;
// ── State query ───────────────────────────────────────────────
static int queryIdx = -1;
static uint32_t queryAt = 0;
// ── Display ────────────────────────────────────────────────────
#define PIN_LCD_BL 27
#define TFT_ROTATION 1 // landscape (480x320)
TFT_eSPI tft = TFT_eSPI();
// ── MQTT ───────────────────────────────────────────────────────
WiFiClient wifi;
PubSubClient mqtt(wifi);
String clientId;
bool mqttConnected = false;
bool wifiConnected = false;
// ── Touch ──────────────────────────────────────────────────────
// TFT_eSPI handles touch via TOUCH_CS / TOUCH_IRQ from User_Setup.h.
// Calibration from cyd-calibrate sketch:
// raw_x → screen_x (linear, both axes increase rightward)
// raw_y → screen_y (inverted — high raw_y = top of screen)
// Range: raw_x 31..454, raw_y 32..281
#define TOUCH_MIN_RAW_X 31
#define TOUCH_MAX_RAW_X 454
#define TOUCH_MIN_RAW_Y 32
#define TOUCH_MAX_RAW_Y 281
// ── Layout ─────────────────────────────────────────────────────
#define STATUS_BAR_H 28
#define BUTTON_GAP 6
#define BUTTON_PAD 6
struct Button {
int x, y, w, h;
int plug_index;
};
Button buttons[PLUG_COUNT];
// Screen-off button in the upper-right corner of the status bar.
// "OFF" at textSize(2) needs ~48px; make button 64px wide with padding.
#define SCREEN_OFF_W 64
#define SCREEN_OFF_H STATUS_BAR_H
#define SCREEN_OFF_X (tft.width() - SCREEN_OFF_W - 4)
#define SCREEN_OFF_Y 0
// Party lock toggle in the status bar (between MQTT and OFF).
#define LOCK_BTN_W 64
#define LOCK_BTN_H STATUS_BAR_H
#define LOCK_BTN_X (SCREEN_OFF_X - LOCK_BTN_W - 4)
#define LOCK_BTN_Y 0
bool displayOn = true;
// ── Colors ─────────────────────────────────────────────────────
#define COL_BG 0x0000
#define COL_STATUS_BG 0x18E3 // dark blue
#define COL_BTN_OFF 0x2945 // dark gray
#define COL_BTN_ON 0x07E0 // green
#define COL_BORDER 0x4208
#define COL_TEXT 0xFFFF
#define COL_TEXT_DIM 0xAD55
#define COL_OK 0x07E0 // green dot
#define COL_BAD 0xF800 // red dot
#define COL_TRACE 0x5D5D // gray trace dot
// ── Forward decls ──────────────────────────────────────────────
void connectWifi();
void connectMqtt();
void mqttCallback(char* topic, byte* payload, unsigned int length);
void publishToggle(int idx);
void drawStatusBar();
void drawButton(int idx);
void drawAll();
int buttonAt(int tx, int ty);
void layoutButtons();
uint32_t buttonColor(const Plug& p);
// ── Forward decls ──────────────────────────────────────────────
void drawButton(int idx);
void drawAll();
uint32_t buttonColor(const Plug& p);
int buttonAt(int tx, int ty);
void pushTrace(int x, int y);
void drawTraces();
void pruneTraces();
// ── Trace particles ────────────────────────────────────────────
#define MAX_TRACES 100
#define TRACE_FADE_MS 1000
struct Trace {
int x, y;
uint32_t time;
};
static Trace traces[MAX_TRACES];
static int traceHead = 0;
static int traceCount = 0;
static uint32_t lastTraceDraw = 0;
// ── Setup ──────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(200);
Serial.println();
Serial.println("=== cyd-dashboard ===");
// Backlight on
pinMode(PIN_LCD_BL, OUTPUT);
digitalWrite(PIN_LCD_BL, HIGH);
// TFT init
tft.init();
tft.setRotation(TFT_ROTATION);
tft.fillScreen(COL_BG);
// Show "booting" message
tft.setTextColor(COL_TEXT, COL_BG);
tft.setTextSize(2);
tft.setCursor(10, 10);
tft.println("booting...");
layoutButtons();
// MQTT client id with random suffix so reconnects don't collide
uint32_t r = esp_random();
char buf[32];
snprintf(buf, sizeof(buf), "cyd-%08X", r);
clientId = buf;
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(mqttCallback);
mqtt.setBufferSize(512);
connectWifi();
// Touch self-test: print raw Z to confirm XPT2046 is responding
uint16_t tz = tft.getTouchRawZ();
Serial.printf("touch self-test: raw Z=%u (non-zero = controller detected)\n", tz);
drawAll();
}
// ── Loop ───────────────────────────────────────────────────────
void loop() {
if (WiFi.status() != WL_CONNECTED) {
wifiConnected = false;
// (Power note: WiFi TX on marginal hubs (unpowered USB) can cause brownouts.
// If disconnects recur every ~5s, move board to direct USB or powered hub.)
connectWifi();
} else {
wifiConnected = true;
}
if (wifiConnected && !mqtt.connected()) {
connectMqtt();
}
mqtt.loop();
// Touch polling — simple debounced tap.
// From cyd-calibrate: raw_x → screen_x (linear), raw_y → screen_y (inverted).
// Clamp because the OFF button sits at sy=0 and user taps above the
// calibrated max produce negative sy without clamping.
// touchWasDown: only act on finger-DOWN edge, ignore drag/repeat.
// lastTraceX/Y: last position where a trace dot was drawn for drag feedback.
static bool touchWasDown = false;
static int lastTraceX = -1, lastTraceY = -1;
static uint32_t last_touch = 0;
uint16_t tx, ty;
bool nowTouching = tft.getTouch(&tx, &ty, 100);
if (nowTouching && !touchWasDown && millis() - last_touch > 250) {
int sx = constrain(map(tx, TOUCH_MIN_RAW_X, TOUCH_MAX_RAW_X, 0, tft.width()),
0, tft.width());
int sy = constrain(map(ty, TOUCH_MIN_RAW_Y, TOUCH_MAX_RAW_Y, tft.height(), 0),
0, tft.height());
// Zone classification (for debug + action)
const char* zone = "unknown";
if (!displayOn) {
zone = "WAKE";
} else if (sy < STATUS_BAR_H) {
if (sx >= SCREEN_OFF_X) zone = "OFF-btn";
else if (sx >= LOCK_BTN_X) zone = "lock-btn";
else zone = "status-bar";
} else {
int idx = buttonAt(sx, sy);
if (idx >= 0) zone = plugs[idx].name;
else zone = "between-buttons";
}
Serial.printf("touch raw=(%u,%u) screen=(%d,%d) zone=%s (off_btn=%d..%d)\n",
tx, ty, sx, sy, zone, SCREEN_OFF_X, SCREEN_OFF_X + SCREEN_OFF_W);
if (!displayOn) {
digitalWrite(PIN_LCD_BL, HIGH);
displayOn = true;
Serial.println("display: on (wake)");
last_touch = millis();
return;
}
// Party lock toggle: in the status bar between MQTT and OFF.
if (sy < STATUS_BAR_H &&
sx >= LOCK_BTN_X && sx < LOCK_BTN_X + LOCK_BTN_W) {
doubleTapIdx = -1;
partyLocked = !partyLocked;
mqtt.publish(PARTY_LOCK_TOPIC, partyLocked ? "ON" : "OFF");
drawStatusBar();
Serial.printf("party-lock: %s\n", partyLocked ? "ON" : "OFF");
last_touch = millis();
return;
}
// Screen-off button: any tap in the upper-right of the status bar counts,
// even if sy would extrapolate slightly negative without clamping.
if (sy < STATUS_BAR_H &&
sx >= SCREEN_OFF_X && sx < SCREEN_OFF_X + SCREEN_OFF_W) {
digitalWrite(PIN_LCD_BL, LOW);
displayOn = false;
Serial.println("display: off");
last_touch = millis();
return;
}
int idx = buttonAt(sx, sy);
if (idx >= 0) {
char dbg[64];
snprintf(dbg, sizeof(dbg), "tap %s at (%d,%d) locked=%d",
plugs[idx].name, sx, sy, partyLocked);
mqtt.publish("cyd-debug/touch", dbg);
if (partyLocked) {
tft.fillRoundRect(LOCK_BTN_X, LOCK_BTN_Y, LOCK_BTN_W, LOCK_BTN_H, 4, COL_BAD);
tft.setTextColor(COL_TEXT, COL_BAD);
tft.setTextSize(2);
tft.setCursor(LOCK_BTN_X + 6, 4);
tft.print("LOCK");
delay(150);
drawStatusBar();
} else if (idx == 3) {
// DJ Booth requires double-tap
uint32_t now = millis();
if (doubleTapIdx == 3 && now - doubleTapTime < DOUBLE_TAP_MS) {
doubleTapIdx = -1;
publishToggle(idx);
} else {
doubleTapIdx = 3;
doubleTapTime = now;
drawButton(idx);
tft.setTextColor(COL_TEXT_DIM, COL_BTN_OFF);
tft.setTextSize(1);
tft.setCursor(buttons[idx].x + 4, buttons[idx].y + buttons[idx].h - 14);
tft.print("tap again");
}
} else {
doubleTapIdx = -1;
publishToggle(idx);
}
last_touch = millis();
}
touchWasDown = true;
} else if (nowTouching && touchWasDown) {
int sx = constrain(map(tx, TOUCH_MIN_RAW_X, TOUCH_MAX_RAW_X, 0, tft.width()), 0, tft.width());
int sy = constrain(map(ty, TOUCH_MIN_RAW_Y, TOUCH_MAX_RAW_Y, tft.height(), 0), 0, tft.height());
pushTrace(sx, sy);
touchWasDown = true;
} else if (!nowTouching) {
touchWasDown = false;
}
// Trace particle upkeep: prune + draw every 30ms
if (millis() - lastTraceDraw > 30) {
pruneTraces();
drawTraces();
lastTraceDraw = millis();
}
// Double-tap timeout: reset if user didn't tap again in time
if (doubleTapIdx >= 0 && millis() - doubleTapTime >= DOUBLE_TAP_MS) {
doubleTapIdx = -1;
if (PLUG_COUNT > 3) drawButton(3);
}
// Delayed state query via /get to bypass party-lock ACL
if (queryIdx >= 0 && millis() >= queryAt) {
char get_topic[64];
snprintf(get_topic, sizeof(get_topic), "%s/get", plugs[queryIdx].topic);
mqtt.publish(get_topic, "{\"state\":\"\"}");
Serial.printf("queried state for %s via /get\n", plugs[queryIdx].name);
queryIdx = -1;
}
}
// ── Trace particles ────────────────────────────────────────────
int buttonAt(int tx, int ty);
void pushTrace(int x, int y) {
if (buttonAt(x, y) >= 0) return;
traces[traceHead] = {x, y, millis()};
traceHead = (traceHead + 1) % MAX_TRACES;
if (traceCount < MAX_TRACES) traceCount++;
}
void drawTraces() {
uint32_t now = millis();
for (int i = 0; i < traceCount; i++) {
int idx = (traceHead - traceCount + i + MAX_TRACES) % MAX_TRACES;
uint32_t age = now - traces[idx].time;
if (age > TRACE_FADE_MS) continue;
int bright = 31 - (31 * age / TRACE_FADE_MS);
if (bright < 0) bright = 0;
uint16_t color = tft.color565(bright, bright, bright);
tft.fillCircle(traces[idx].x, traces[idx].y, 3, color);
}
}
void pruneTraces() {
uint32_t now = millis();
int prune = 0;
while (prune < traceCount) {
int idx = (traceHead - traceCount + prune + MAX_TRACES) % MAX_TRACES;
if (now - traces[idx].time <= TRACE_FADE_MS) break;
tft.fillCircle(traces[idx].x, traces[idx].y, 3, COL_BG);
prune++;
}
if (prune > 0) {
traceCount -= prune;
}
}
// ── WiFi ───────────────────────────────────────────────────────
void connectWifi() {
if (WiFi.status() == WL_CONNECTED) {
if (!wifiConnected) {
Serial.printf("WiFi: connected to %s, IP=%s\n",
WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
wifiConnected = true;
drawStatusBar();
}
return;
}
static int ssid_idx = 0;
static uint32_t last_attempt = 0;
static uint32_t first_try_at = 0;
if (millis() - last_attempt < 5000) return; // back off between attempts
// If we've been trying the same SSID for >15s without success, give up and move on
if (first_try_at == 0) first_try_at = millis();
if (millis() - first_try_at > 15000) {
Serial.printf("WiFi: giving up on %s after 15s\n", WIFI_SSIDS[ssid_idx]);
WiFi.disconnect();
ssid_idx = (ssid_idx + 1) % WIFI_NETWORK_COUNT;
first_try_at = millis();
last_attempt = millis();
return;
}
Serial.printf("WiFi: trying %s\n", WIFI_SSIDS[ssid_idx]);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSIDS[ssid_idx], WIFI_PASSPHRASES[ssid_idx]);
last_attempt = millis();
}
// ── MQTT ───────────────────────────────────────────────────────
void connectMqtt() {
if (mqtt.connected()) return;
Serial.printf("MQTT: connecting as %s\n", clientId.c_str());
if (mqtt.connect(clientId.c_str())) {
Serial.println("MQTT: connected");
mqttConnected = true;
// Subscribe to all plug state topics
for (int i = 0; i < PLUG_COUNT; i++) {
mqtt.subscribe(plugs[i].topic);
}
mqtt.subscribe(PARTY_LOCK_TOPIC);
mqtt.publish(PARTY_LOCK_TOPIC, partyLocked ? "ON" : "OFF");
// Query current state from each plug via /get to bypass party-lock ACL.
for (int i = 0; i < PLUG_COUNT; i++) {
char get_topic[64];
snprintf(get_topic, sizeof(get_topic), "%s/get", plugs[i].topic);
mqtt.publish(get_topic, "{\"state\":\"\"}");
}
} else {
Serial.printf("MQTT: failed rc=%d\n", mqtt.state());
mqttConnected = false;
}
drawStatusBar();
}
void mqttCallback(char* topic, byte* payload, unsigned int length) {
// Party lock state
if (strcmp(topic, PARTY_LOCK_TOPIC) == 0) {
char s[4];
int len = min((unsigned int)3, length);
memcpy(s, payload, len);
s[len] = 0;
bool new_state = (strcmp(s, "ON") == 0);
if (partyLocked != new_state) {
partyLocked = new_state;
drawStatusBar();
}
return;
}
// Match topic to a plug
int idx = -1;
for (int i = 0; i < PLUG_COUNT; i++) {
if (strcmp(topic, plugs[i].topic) == 0) { idx = i; break; }
}
if (idx < 0) return;
// Parse JSON, look for "state"
StaticJsonDocument<256> doc;
DeserializationError err = deserializeJson(doc, payload, length);
if (err) {
Serial.printf("JSON parse err on %s: %s\n", topic, err.c_str());
return;
}
const char* state = doc["state"] | "";
bool new_state = (strcmp(state, "ON") == 0);
if (plugs[idx].state != new_state) {
Serial.printf("state update: %s -> %s\n", plugs[idx].name, state);
plugs[idx].state = new_state;
}
drawButton(idx);
}
void publishToggle(int idx) {
if (!mqtt.connected()) return;
char topic[64];
snprintf(topic, sizeof(topic), "%s/set", plugs[idx].topic);
mqtt.publish(topic, "{\"state\":\"TOGGLE\"}");
Serial.printf("published TOGGLE on %s\n", topic);
// Schedule a state query 600ms later so display converges to real state
queryIdx = idx;
queryAt = millis() + 600;
}
// ── Drawing ────────────────────────────────────────────────────
void layoutButtons() {
// 2x2 grid below status bar
int grid_top = STATUS_BAR_H + BUTTON_PAD;
int grid_h = tft.height() - grid_top - BUTTON_PAD;
int grid_w = tft.width() - 2 * BUTTON_PAD;
int btn_w = (grid_w - BUTTON_GAP) / 2;
int btn_h = (grid_h - BUTTON_GAP) / 2;
for (int i = 0; i < PLUG_COUNT; i++) {
int col = i % 2;
int row = i / 2;
buttons[i] = {
BUTTON_PAD + col * (btn_w + BUTTON_GAP),
grid_top + row * (btn_h + BUTTON_GAP),
btn_w, btn_h,
i,
};
}
}
void drawStatusBar() {
tft.fillRect(0, 0, tft.width(), STATUS_BAR_H, COL_STATUS_BG);
tft.setTextSize(2);
tft.setTextColor(COL_TEXT, COL_STATUS_BG);
// WiFi dot
tft.fillCircle(10, STATUS_BAR_H / 2, 4, wifiConnected ? COL_OK : COL_BAD);
tft.setCursor(20, 6);
tft.print(WiFi.localIP());
// MQTT dot (left of screen-off button)
int mqtt_x = tft.width() / 2 - 20;
tft.fillCircle(mqtt_x, STATUS_BAR_H / 2, 4, mqttConnected ? COL_OK : COL_BAD);
tft.setCursor(mqtt_x + 10, 6);
tft.print("MQTT");
// Party lock toggle (between MQTT and OFF)
uint32_t lock_bg = partyLocked ? COL_BAD : COL_STATUS_BG;
tft.fillRoundRect(LOCK_BTN_X, LOCK_BTN_Y, LOCK_BTN_W, LOCK_BTN_H, 4, lock_bg);
tft.drawRoundRect(LOCK_BTN_X, LOCK_BTN_Y, LOCK_BTN_W, LOCK_BTN_H, 4, COL_BORDER);
tft.setTextColor(COL_TEXT, lock_bg);
tft.setTextSize(2);
tft.setCursor(LOCK_BTN_X + 6, 4);
tft.print(partyLocked ? "LOCK" : "UNLK");
// Screen-off button (upper-right corner)
tft.fillRoundRect(SCREEN_OFF_X, SCREEN_OFF_Y, SCREEN_OFF_W, SCREEN_OFF_H, 4, COL_BORDER);
tft.setTextColor(COL_TEXT, COL_BORDER);
tft.setCursor(SCREEN_OFF_X + 10, 4);
tft.print("OFF");
}
void drawButton(int idx) {
const Plug& p = plugs[idx];
const Button& b = buttons[idx];
uint32_t bg = buttonColor(p);
tft.fillRoundRect(b.x, b.y, b.w, b.h, 8, bg);
tft.drawRoundRect(b.x, b.y, b.w, b.h, 8, COL_BORDER);
// Plug name (top) — textSize(2) keeps "Bamboo Lights" within ~237px button
tft.setTextColor(COL_TEXT, bg);
tft.setTextSize(2);
tft.setCursor(b.x + 12, b.y + 10);
tft.println(p.name);
// State (bottom)
tft.setTextSize(4);
int state_y = b.y + b.h - 40;
tft.setCursor(b.x + 12, state_y);
tft.println(p.state ? "ON" : "OFF");
}
void drawAll() {
drawStatusBar();
for (int i = 0; i < PLUG_COUNT; i++) drawButton(i);
}
uint32_t buttonColor(const Plug& p) {
if (p.state) return COL_BTN_ON;
return COL_BTN_OFF;
}
int buttonAt(int tx, int ty) {
if (ty < STATUS_BAR_H) return -1;
for (int i = 0; i < PLUG_COUNT; i++) {
const Button& b = buttons[i];
if (tx >= b.x && tx < b.x + b.w && ty >= b.y && ty < b.y + b.h) return i;
}
return -1;
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
// Copy this to secrets.h and fill in real values.
// secrets.h is gitignored.
// WiFi networks to try in order
static const char* WIFI_SSIDS[] = {
"Dobro Veče",
};
static const char* WIFI_PASSPHRASES[] = {
"goodnight",
};
static const int WIFI_NETWORK_COUNT = 1;
// MQTT broker
#define MQTT_HOST "192.168.81.147"
#define MQTT_PORT 1883
+27
View File
@@ -0,0 +1,27 @@
# Audio bridge: PipeWire + PulseAudio bridge + BlueZ
# Routes audio from librespot / mopidy / phone-Bluetooth to USB DAC (Qudelix 5K)
# Cross-built for linux/arm64 on x86_64 build host via buildx + qemu.
FROM alpine:3.20
# PipeWire = audio router, handles ALSA sink + bluetooth A2DP sink + PA bridge.
# wireplumber = PipeWire session manager (loads bluetooth modules on demand).
# bluez = Bluetooth stack (needed for "bluetooth target" — phone → Pi audio).
RUN apk add --no-cache \
pipewire \
pipewire-pulse \
wireplumber \
bluez \
alsa-utils \
alsa-plugins-pulse \
dbus \
tini
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY pipewire-pulse.conf /etc/pipewire/pipewire-pulse.conf
# D-Bus socket dir for bluetoothd / wireplumber / pipewire
VOLUME ["/run/dbus", "/run/user/0"]
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
# audio-bridge entrypoint
# Starts DBus (system + session), BlueZ, WirePlumber, then PipeWire.
# PA clients (librespot, mopidy) connect via pipewire-pulse bridge on TCP localhost:4713.
set -e
mkdir -p /run/dbus /run/user/0
chmod 0755 /run/dbus
chmod 0700 /run/user/0
# Clean up stale pid files from previous container runs (host volume persists).
rm -f /run/dbus/dbus.pid /run/user/0/dbus.pid
# System DBus (needed by bluetoothd, wireplumber)
dbus-daemon --system --fork
sleep 0.5
# Session DBus (needed by PipeWire for portal/jackdbus-detect modules).
# Use XDG_RUNTIME_DIR=/run/user/0 so dbus-launch puts socket there.
export XDG_RUNTIME_DIR=/run/user/0
dbus-daemon --session --address=unix:path=/run/user/0/bus --fork
sleep 0.3
# BlueZ (Bluetooth stack). --compat = legacy mode for older audio devices.
# Disabled if no /sys/class/bluetooth — wireplumber will skip BT modules.
if [ -d /sys/class/bluetooth ]; then
/usr/lib/bluetooth/bluetoothd --compat --nodetach &
sleep 0.5
echo "[audio-bridge] bluetoothd started"
else
echo "[audio-bridge] no bluetooth hardware — skipping bluetoothd"
fi
# WirePlumber first (manages PipeWire session/policy, loads BT modules on demand)
wireplumber &
sleep 1
# PipeWire (audio server). Listens on its own native socket.
pipewire &
# PipeWire-Pulse daemon: PA-compatible bridge. Custom config in
# /etc/pipewire/pipewire-pulse.conf sets server.address = [unix:native, tcp:4713]
# so PA clients (librespot, mopidy) connect via tcp:127.0.0.1:4713.
sleep 1
exec pipewire-pulse
+49
View File
@@ -0,0 +1,49 @@
# PipeWire-Pulse config: full default config + TCP 4713 listener.
# This is a complete config (not an override); we add tcp:4713 alongside
# the default unix:native socket so PA clients (librespot, mopidy) can
# connect from sibling containers via PULSE_SERVER=tcp:127.0.0.1:4713.
context.properties = {
## Configure properties in the system.
#mem.warn-mlock = false
#mem.allow-mlock = true
#mem.mlock-all = false
#log.level = 2
#default.clock.quantum-limit = 8192
}
context.spa-libs = {
audio.convert.* = audioconvert/libspa-audioconvert
support.* = support/libspa-support
}
context.modules = [
{ name = libpipewire-module-rt
args = {
nice.level = -11
}
flags = [ ifexists nofail ]
}
{ name = libpipewire-module-protocol-native }
{ name = libpipewire-module-client-node }
{ name = libpipewire-module-adapter }
{ name = libpipewire-module-metadata }
{ name = libpipewire-module-protocol-pulse
args = {
# server.address is set in pulse.properties below
}
}
]
pulse.cmd = [
{ cmd = "load-module" args = "module-always-sink" flags = [ ] }
]
pulse.properties = {
server.address = [
"unix:native"
"tcp:4713"
]
}
+104 -10
View File
@@ -2,7 +2,7 @@ services:
mosquitto:
image: eclipse-mosquitto:latest
container_name: mosquitto
restart: unless-stopped
restart: always
user: "1883:1883"
ports:
- "1883:1883"
@@ -10,6 +10,12 @@ services:
- ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log
healthcheck:
test: ["CMD", "mosquitto_pub", "-p", "1883", "-t", "healthcheck", "-m", "ok", "-r", "--quiet"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
zigbee2mqtt:
image: ghcr.io/pine64/zigbee2mqtt:latest-dev # BLZ fork, NOT koenkk
@@ -22,7 +28,11 @@ services:
volumes:
- ./zigbee2mqtt:/app/data
devices:
- /dev/ttyUSB0:/dev/ttyUSB0
- source: /dev/serial/by-path/platform-3f980000.usb-usb-0:1.1.2:1.0-port0
target: /dev/ttyUSB0
permissions: rw
entrypoint: ["/app/data/scripts/entrypoint.sh"]
command: ["/sbin/tini", "--", "node", "index.js"]
environment:
TZ: America/Los_Angeles
@@ -39,20 +49,22 @@ services:
# TZ: America/Los_Angeles
#
nodered:
image: nodered/node-red:latest
build: ./nodered
container_name: nodered
restart: unless-stopped
depends_on:
- mosquitto
mosquitto:
condition: service_healthy
user: "1000:1000"
ports:
- "1880:1880"
volumes:
- ./nodered:/data
- ./nodered:/data # bind: settings.js, flows.json, start.sh, package.json
- /data/node_modules # anonymous: preserves build-time npm install
environment:
TZ: America/Los_Angeles
DASHBOARD_BASE_HREF: /dashboard/
command: ["sh", "/data/start.sh"]
command: ["sh", "/data/start.sh"]
# Listens to ntfy.sh for doorbell events and toggles a smart plug on each alert.
# Topic: ALERT_klubhaus_topic_test (matches ESP32 firmware DEBUG_MODE suffix)
@@ -64,17 +76,99 @@ services:
container_name: doorbell-listener
restart: unless-stopped
depends_on:
- mosquitto
mosquitto:
condition: service_healthy
volumes:
- ./doorbell-listener/state:/data/doorbell-listener
environment:
MQTT_HOST: mosquitto
MQTT_PORT: "1883"
PLUG_TOPIC: "zigbee2mqtt/Sideboard Lamp/set"
POLL_INTERVAL: "30"
POLL_INTERVAL: "5"
MAX_AGE_SECONDS: "180"
FLASH_INTERVAL_SECONDS: "2"
FLASH_DURATION_SECONDS: "15"
FLASH_INTERVAL_SECONDS: "1"
FLASH_DURATION_SECONDS: "14"
TZ: America/Los_Angeles
# Audio router: PipeWire + WirePlumber + PulseAudio bridge + BlueZ.
# Routes audio from librespot / mopidy / phone-Bluetooth to USB DAC (Q5K)
# via ALSA. Falls back to RPi 3.5mm / HDMI if no USB DAC.
# PEQ is applied on the DAC side (Q5K's Qudelix app, 20 bands), not here.
# NOTE: /run/dbus + /run/user/0 use tmpfs (NOT host bind-mount) — the host's
# dbus socket is /run/dbus/system_bus_socket and bind-mounting our container's
# socket overwrites it, breaking dockerd's ability to talk to systemd.
audio-bridge:
build: ./audio-bridge
container_name: audio-bridge
restart: unless-stopped
privileged: true
network_mode: host
devices:
- /dev/snd:/dev/snd
tmpfs:
- /run/dbus:size=1M
- /run/user/0:size=1M
cap_add:
- SYS_ADMIN # bluetoothd needs CAP_SYS_ADMIN for HCI
- NET_ADMIN
# Spotify Connect: phone's Spotify app shows "Klubhaus" as a Connect target.
# Outputs audio to audio-bridge via PulseAudio (TCP localhost:4713).
# Built locally on RPi (cross-compile is too slow under qemu).
librespot:
build: ./librespot
image: infra-librespot:latest
container_name: librespot
restart: unless-stopped
depends_on:
- audio-bridge
network_mode: host
# CMD is set in Dockerfile; do not override here.
volumes:
- librespot_cache:/var/cache/librespot
# Mopidy: Qobuz + local files + internet radio. Web UI on http://rpi:6680.
# Outputs audio to audio-bridge via PulseAudio (localhost:4713).
# Get Qobuz app_id + secret from https://developer.qobuz.com/api/v1/oauth
# Built locally from python:3.12-slim + pip install (no ghcr.io image).
mopidy:
build: ./mopidy
image: infra-mopidy:latest
container_name: mopidy
restart: unless-stopped
depends_on:
- audio-bridge
network_mode: host
ports:
- "6680:6680"
volumes:
- ./mopidy/data:/var/lib/mopidy
- ./mopidy/music:/music # drop FLACs in infra/mopidy/music/
environment:
TZ: America/Los_Angeles
QOBUZ_APP_ID: "${QOBUZ_APP_ID:-}"
QOBUZ_APP_SECRET: "${QOBUZ_APP_SECRET:-}"
QOBUZ_USERNAME: "${QOBUZ_USERNAME:-}"
QOBUZ_PASSWORD: "${QOBUZ_PASSWORD:-}"
# Party lock: blocks state changes to DJ Booth plug when locked.
# Watches party-lock/dj-booth/set and swaps mosquitto ACL file.
party-lock:
build: ./party-lock
container_name: party-lock
restart: unless-stopped
depends_on:
mosquitto:
condition: service_healthy
volumes:
- ./mosquitto/config:/mosquitto/config
- /var/run/docker.sock:/var/run/docker.sock
environment:
MQTT_HOST: mosquitto
MQTT_PORT: "1883"
TZ: America/Los_Angeles
volumes:
librespot_cache:
+45
View File
@@ -0,0 +1,45 @@
# Librespot (Spotify Connect) for ARM64
# Multi-stage Docker build. Run on ARM64 host (RPi 3B+ etc).
# Audio output: PulseAudio (connects to audio-bridge via TCP localhost:4713).
FROM rust:1.85.1-bookworm AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpulse-dev \
libasound2-dev \
&& rm -rf /var/lib/apt/lists/*
ARG LIBRESPOT_VERSION=v0.8.0
RUN git clone --depth 1 --branch ${LIBRESPOT_VERSION} \
https://github.com/librespot-org/librespot.git /src
WORKDIR /src
# Limit parallelism to 2 (RPi 3B+ has 4 cores but only 1GB RAM).
# Lower = less memory pressure, slower build. Higher = OOM risk.
# Features: pulseaudio (audio out) + native-tls (TLS) + with-libmdns (Spotify Connect
# discovery via mDNS — required for the device to appear in phone Spotify apps).
RUN cargo build --release --jobs 2 \
--no-default-features \
--features="pulseaudio-backend,native-tls,with-libmdns"
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
libpulse0 \
libasound2 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/target/release/librespot /usr/local/bin/librespot
VOLUME ["/var/cache/librespot"]
ENV PULSE_SERVER=tcp:127.0.0.1:4713
CMD ["librespot", \
"--name", "Klubhaus", \
"--backend", "pulseaudio", \
"--bitrate", "320", \
"--initial-volume", "100", \
"--cache", "/var/cache/librespot"]
+46
View File
@@ -0,0 +1,46 @@
# Mopidy music server with Qobuz + local files
# Builds from python:3.12-slim + pip (no ghcr.io dependency)
FROM python:3.12-slim-bookworm
# System deps for mopidy + extensions + GStreamer playback + PyGObject
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gstreamer1.0-tools \
gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly \
libcairo2 \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libgirepository-1.0-1 \
gir1.2-glib-2.0 \
gir1.2-gstreamer-1.0 \
gir1.2-gst-plugins-base-1.0 \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3-gi \
python3-gi-cairo \
&& rm -rf /var/lib/apt/lists/*
# Expose the system Python's gi module to Python 3.12 (/usr/local).
ENV PYTHONPATH=/usr/lib/python3/dist-packages:${PYTHONPATH}
RUN pip install --no-cache-dir \
"setuptools<80" \
Mopidy==3.4.2 \
Mopidy-Qobuz \
Mopidy-Local
# Default config (overridable via bind mount)
COPY mopidy.conf /config/mopidy.conf
VOLUME ["/var/lib/mopidy", "/music"]
EXPOSE 6680
# Run as the unprivileged "mopidy" user from python base
USER nobody
CMD ["mopidy", "--config", "/config/mopidy.conf"]
+38
View File
@@ -0,0 +1,38 @@
[core]
cache_dir = /var/lib/mopidy/cache
config_dir = /config
data_dir = /var/lib/mopidy/data
max_tracklist_length = 10000
[logging]
verbosity = info
format = %(levelname)-8s [%(name)s] %(message)s
# Audio output → PipeWire (via PulseAudio bridge on TCP localhost:4713).
# PipeWire handles routing to Q5K (USB) or fallback to RPi 3.5mm/HDMI.
[audio]
output = pulsesink server=127.0.0.1 name=mopidy
mixer_volume = 100
buffer_time = 1000
[http]
enabled = true
hostname = 0.0.0.0
port = 6680
zeroconf = Klubhaus Mopidy
# Qobuz streaming. Fill in credentials via env vars (set in compose.yaml):
# QOBUZ_APP_ID, QOBUZ_APP_SECRET, QOBUZ_USERNAME, QOBUZ_PASSWORD
[qobuz]
enabled = true
app_id = ${QOBUZ_APP_ID}
app_secret = ${QOBUZ_APP_SECRET}
username = ${QOBUZ_USERNAME}
password = ${QOBUZ_PASSWORD}
# Local music files (mount /music in compose)
[local]
enabled = true
media_dirs = /music
scan_timeout = 5000
playlists_dir = /var/lib/mopidy/playlists
+1
View File
@@ -0,0 +1 @@
topic readwrite #
+2
View File
@@ -1,6 +1,8 @@
listener 1883
allow_anonymous true
acl_file /mosquitto/config/acl.conf
persistence true
persistence_location /mosquitto/data
log_dest file /mosquitto/log/mosquitto.log
+44
View File
@@ -0,0 +1,44 @@
# Node-RED container with Dashboard 2.0 baked in
# Build once; restart containers freely. NR's automatic package.json install
# at boot is slow and requires internet; this Dockerfile install is cached
# and reproducible.
#
# Docker volume pattern: ./nodered:/data is a bind mount (settings.js,
# flows.json, start.sh, package.json are host-owned); /data/node_modules
# is an anonymous volume so the bind mount doesn't clobber the installed
# deps. Docker copies the image's node_modules into the volume on first start.
FROM nodered/node-red:5.0.0
# Install Dashboard 2.0 (and any future deps) at image-build time.
# Versions are pinned in infra/nodered/package.json for reproducibility.
# The NR base image defaults USER to node-red (uid 1000), so switch to root
# for the install (writes to /data/node_modules need root then chown).
USER root
COPY package.json /data/package.json
WORKDIR /data
RUN npm install --omit=dev --no-audit --no-fund \
&& chown -R 1000:1000 /data/node_modules \
&& echo "[Dockerfile] installed @flowfuse/node-red-dashboard"
# start.sh handles the SPA base-href patch (idempotent: grep guard).
# Inline-write + chmod so we don't hit the COPY-then-chmod permission issue
# (the base image's /data ACLs block chmod on COPY'd files even as root).
RUN printf "%s\n" \
"#!/bin/sh" \
"INDEX=/data/node_modules/@flowfuse/node-red-dashboard/dist/index.html" \
"BASE_HREF=\"\${DASHBOARD_BASE_HREF:-/dashboard/}\"" \
"if [ -f \"\$INDEX\" ] && ! grep -q \"<base href\" \"\$INDEX\"; then" \
" sed -i \"s|<meta name=\\\"description\\\"|<base href=\\\"\$BASE_HREF\\\">" \
" <meta name=\\\"description\\\"|\" \"\$INDEX\"" \
" echo \"[start.sh] PATCHED base href=\$BASE_HREF\"" \
"fi" \
"cd /usr/src/node-red" \
"exec node \$NODE_OPTIONS node_modules/node-red/red.js \$FLOWS" \
> /data/start.sh \
&& chmod +x /data/start.sh \
&& chown 1000:1000 /data/start.sh
# Drop back to NR's default user; userDir is /data via the compose volume mount.
USER node-red
WORKDIR /usr/src/node-red
+9
View File
@@ -0,0 +1,9 @@
{
"name": "node-red-project",
"description": "Node-RED flows for Klubhaus zigbee-monitor",
"version": "0.0.1",
"private": true,
"dependencies": {
"@flowfuse/node-red-dashboard": "1.30.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
COPY party-lock.py /app/party-lock.py
WORKDIR /app
CMD ["uv", "run", "party-lock.py"]
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "paho-mqtt>=2.0",
# "docker>=7.0",
# ]
# ///
import os
import stat
import time
import docker
import paho.mqtt.client as mqtt
MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
ACL_PATH = "/mosquitto/config/acl.conf"
locked = False
def rebuild_acl():
lines = []
if locked:
lines.append("topic deny zigbee2mqtt/+/set")
lines.append("topic readwrite #")
with open(ACL_PATH, "w") as f:
f.write("\n".join(lines) + "\n")
os.chmod(ACL_PATH, stat.S_IRWXU)
os.chown(ACL_PATH, 1883, 1883)
try:
dc = docker.DockerClient(base_url="unix://var/run/docker.sock")
mosq = dc.containers.get("mosquitto")
mosq.kill(signal="HUP")
except Exception:
pass
time.sleep(0.05)
def on_message(client, userdata, msg):
global locked
payload = msg.payload.decode().strip().upper()
if payload == "ON":
locked = True
elif payload == "OFF":
locked = False
else:
return
rebuild_acl()
def main():
rebuild_acl()
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_message = on_message
while True:
try:
client.connect(MQTT_HOST, MQTT_PORT, 60)
break
except Exception as e:
print(f"mqtt connect failed: {e}; retrying in 5s", flush=True)
time.sleep(5)
client.subscribe("party-lock/set", qos=1)
client.loop_forever()
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
# Zigbee device notes
Per-device quirks discovered during setup. Keep this file in sync as we
encounter new devices.
## ThirdReality Smart Button (3RSB22BZ) — Door Button
**Has two pairing modes** — the button will only work with zigbee2mqtt
in **Standard ZigBee mode**.
| Mode | LED indicator | zigbee2mqtt compatible? |
|---|---|---|
| Standard ZigBee | blinks **blue only** | yes |
| Echo ZigBee | blinks **blue + red alternating** | no |
Switching modes depends on firmware version:
- **Firmware ≥ 1.00.22** (most common): press the **back reset button**
(pinhole) **5 times within 5 seconds**.
- **Firmware < 1.00.22**: hold the **front button + back reset button**
simultaneously for 5 seconds.
The front button does NOT trigger a reset — only the back pinhole does.
Holding the front button for any length of time only emits Zigbee
`action` events (`single`, `hold`, `release`).
If the button joins but zigbee2mqtt can't interview it correctly,
check the LED — it's almost certainly in Echo mode.
## ThirdReality Smart Plug Zigbee PROD (3RSP02028BZ / similar)
Factory reset / pairing mode: **press and hold the side button for
>10 seconds** until the LED flashes (red = ZigBee mode, green = BLE mode).
Most plugs ship in **BLE mode** out of the box. To switch to ZigBee:
hold button + insert into outlet + hold 10s + release + press button
**twice immediately**. LED should flash **red** (ZigBee).
To re-join an existing ZigBee network (e.g. after the dongle was
swapped), **power-cycle** is usually sufficient — no reset needed. The
plug retains the network_key and rejoins automatically when permit_join
is open on the coordinator.
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/* Patch zigbee2mqtt bridge.js to tolerate BLZ adapter quirks.
The Pine64 `latest-dev` image of zigbee2mqtt crashes on start when used with
the BLZ (BL702) dongle because the Bridge extension calls
`firstCoordinatorEndpoint().deviceIeeeAddress` and the BLZ coordinator never
registers its endpoint — `firstCoordinatorEndpoint()` returns undefined.
The crash happens inside `Bridge.start()` BEFORE the `onMQTTMessage` listener
is registered, so permit_join (and every other bridge MQTT request) silently
no-ops.
This script applies two surgical patches:
1. Optional-chaining on the `deviceIeeeAddress` access so the missing endpoint
doesn't throw.
2. Move `onMQTTMessage` registration to BEFORE the publishInfo/devices/groups/
definitions calls AND wrap each publish in try/catch — so Bridge.start()
completes even if a single publish throws, and permit_join still works.
Run inside the zigbee2mqtt container after image pull:
docker exec zigbee2mqtt node /app/blz-bridge-patch.js
Idempotent: re-running is safe (sed -i replaces whole matched block).
*/
const fs = require("fs");
const BRIDGE_PATH = process.argv[2] || "/app/dist/extension/bridge.js";
function patch(content) {
let changed = false;
// Patch 1: optional chain on firstCoordinatorEndpoint().deviceIeeeAddress
const oldAccess = "ieee_address: this.zigbee.firstCoordinatorEndpoint().deviceIeeeAddress,";
const newAccess = "ieee_address: this.zigbee.firstCoordinatorEndpoint()?.deviceIeeeAddress,";
if (content.includes(oldAccess) && !content.includes(newAccess)) {
content = content.replace(oldAccess, newAccess);
changed = true;
console.log("[patch 1] added optional chaining on deviceIeeeAddress");
} else if (content.includes(newAccess)) {
console.log("[patch 1] already applied");
} else {
console.warn("[patch 1] target line not found, skipping");
}
// Patch 2: move onMQTTMessage registration to BEFORE publishInfo/devices/groups/definitions
const oldStartTail = ` await this.publishInfo();
await this.publishDevices();
await this.publishGroups();
await this.publishDefinitions();
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);`;
const newStartTail = ` this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
// BLZ adapter workaround: each publish wrapped so Bridge.start completes
// even if a single publish fails (e.g. BLZ coordinator endpoint missing).
for (const [name, fn] of [["publishInfo", this.publishInfo], ["publishDevices", this.publishDevices], ["publishGroups", this.publishGroups], ["publishDefinitions", this.publishDefinitions]]) {
try { await fn.call(this); } catch (err) { logger_1.default.error("Bridge " + name + " failed: " + err.message); }
}`;
if (content.includes(oldStartTail)) {
content = content.replace(oldStartTail, newStartTail);
changed = true;
console.log("[patch 2] moved onMQTTMessage before publish calls + added try/catch");
} else if (content.includes(newStartTail)) {
console.log("[patch 2] already applied");
} else {
console.warn("[patch 2] target block not found, skipping");
}
return { content, changed };
}
const original = fs.readFileSync(BRIDGE_PATH, "utf8");
const { content, changed } = patch(original);
if (!changed) {
console.log("no changes needed");
process.exit(0);
}
// Backup original once (only if .bak does not exist)
const bak = BRIDGE_PATH + ".bak";
if (!fs.existsSync(bak)) {
fs.writeFileSync(bak, original);
console.log("backup written to " + bak);
}
fs.writeFileSync(BRIDGE_PATH, content);
console.log("patched " + BRIDGE_PATH);
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
cd /app && node /app/data/scripts/blz-bridge-patch.js
exec docker-entrypoint.sh "$@"
+37 -1
View File
@@ -155,9 +155,45 @@ doorbell-publish TITLE BODY="Someone is at the door":
doorbell-rebuild:
ssh {{RPI_HOST}} 'cd {{RPI_DIR}}/nr-flow-validator && docker compose build doorbell-listener && docker compose up -d doorbell-listener'
# Patch zigbee2mqtt's bridge.js for BLZ adapter compatibility.
# The Pine64 latest-dev image crashes on start because Bridge.start() calls
# firstCoordinatorEndpoint().deviceIeeeAddress BEFORE registering the
# onMQTTMessage listener, so permit_join (and every other bridge MQTT
# request) silently no-ops.
#
# Run this after every fresh image pull OR after the zigbee2mqtt container
# is recreated (the patch is lost on image rebuild).
z2m-patch-bridge:
@echo "Copying patch script into zigbee2mqtt container..."
ssh {{RPI_HOST}} 'docker cp {{justfile_directory()}}/infra/zigbee2mqtt/scripts/blz-bridge-patch.js zigbee2mqtt:/tmp/blz-bridge-patch.js'
@echo "Applying patch (idempotent)..."
ssh {{RPI_HOST}} 'docker exec zigbee2mqtt node /tmp/blz-bridge-patch.js'
@echo "Restarting zigbee2mqtt..."
ssh {{RPI_HOST}} 'docker restart zigbee2mqtt'
@echo "Done. Verify with: just z2m-logs"
# Open permit_join + watch for device_joined events; auto-rename any
# rejoining device back to its original friendly name. Use after the
# zigbee2mqtt database has been wiped, to recover from a fresh state
# without manually re-pairing each device via the web UI.
#
# First put each device into pairing mode (ThirdReality smart plugs:
# hold the side button for ~5 seconds until the LED blinks rapidly).
# Then run this and press a device's button within the time window.
z2m-repair TIME="120":
uv run --with paho-mqtt python3 {{justfile_directory()}}/scripts/z2m_repair.py --time {{TIME}}
# Rename a zigbee2mqtt device by IEEE address. Use after a device
# rejoins with a fresh ieee (or for any rename).
z2m-rename IEEE NAME:
ssh {{RPI_HOST}} 'mosquitto_pub -h localhost \
-t "zigbee2mqtt/bridge/request/device/rename" \
-m "{\"from\":\"{{IEEE}}\",\"to\":\"{{NAME}}\",\"transaction\":\"rename-{{NAME}}\"}"'
@echo "Renamed {{IEEE}} -> {{NAME}}"
# Show paired zigbee devices and their friendly names
z2m-devices:
ssh {{RPI_HOST}} 'cat {{RPI_DIR}}/zigbee2mqtt/configuration.yaml' > /tmp/z2m.yaml
ssh {{RPI_HOST}} 'cat {{RPI_DIR}}/nr-flow-validator/infra/zigbee2mqtt/configuration.yaml' > /tmp/z2m.yaml
python3 scripts/diag.py z2m-devices /tmp/z2m.yaml
# Show per-device persisted state from z2m
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Auto-rejoin helper: open permit_join, watch for device_joined events,
and rename any rejoining device back to its original friendly name.
Why: after the zigbee2mqtt database is wiped, devices need to rejoin. They
already know the network (network_key + pan_id + channel preserved on
RPi), so they will rejoin automatically when woken. This script:
1. Publishes permit_join for `time` seconds (default 120).
2. Subscribes to bridge/event on the local MQTT broker.
3. For each device_joined event with a known IEEE, sends a
bridge/request/device/rename call to restore the friendly name.
Run from anywhere with network access to the RPi's mosquitto broker:
uv run --with paho-mqtt scripts/z2m_repair.py --time 120
Mapping is hardcoded from the backup `state.json` / `configuration.yaml`
preserved from the previous working stack on the RPi. Override with
--mapping to add or change entries.
"""
import argparse
import json
import sys
import time
import paho.mqtt.client as mqtt
# IEEE -> friendly name, from the last known good state.
DEFAULT_MAPPING = {
"0xffffb40e0604fbd0": "Squiggle",
"0xffffb40e06065536": "Door Button",
"0xffffb40e0603c9e4": "Sideboard Lamp",
"0xffffb40e06050af6": "Bamboo Lights",
"0xffffb40e0603ef11": "DJ Booth",
}
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--host", default="192.168.81.147")
p.add_argument("--port", type=int, default=1883)
p.add_argument("--time", type=int, default=120,
help="permit_join window in seconds (default 120)")
p.add_argument("--mapping", help="JSON file overriding default mapping")
args = p.parse_args()
mapping = dict(DEFAULT_MAPPING)
if args.mapping:
with open(args.mapping) as f:
override = json.load(f)
mapping.update(override)
print(f"[repair] mapping {len(mapping)} known devices:")
for ieee, name in mapping.items():
print(f" {ieee} -> {name}")
joined: set[str] = set()
renamed: set[str] = set()
def on_connect(client, userdata, flags, reason, properties=None):
print(f"[repair] connected to mqtt://{args.host}:{args.port}")
client.subscribe("zigbee2mqtt/bridge/event")
client.publish(
"zigbee2mqtt/bridge/request/permit_join",
json.dumps({"time": args.time, "transaction": "repair-1"}),
)
print(f"[repair] permit_join opened for {args.time}s")
print(f"[repair] waiting for device_joined events...")
print(f"[repair] press Ctrl-C to abort")
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload)
except (ValueError, TypeError):
return
if payload.get("type") != "device_joined":
return
ieee = payload.get("data", {}).get("ieee_address")
if not ieee or ieee in joined:
return
joined.add(ieee)
name = mapping.get(ieee, f"<unknown-{ieee[-6:]}>")
if name.startswith("<unknown-"):
print(f"[repair] joined (NEW): {ieee} -> {name}")
print(f"[repair] (will not auto-rename; use: just z2m-rename {ieee} <name>)")
return
if ieee in renamed:
return
# Ask zigbee2mqtt to rename
client.publish(
"zigbee2mqtt/bridge/request/device/rename",
json.dumps({"from": ieee, "to": name, "transaction": f"rename-{ieee}"}),
)
renamed.add(ieee)
print(f"[repair] joined + renamed: {ieee} -> {name}")
if len(renamed) >= len(mapping):
print(f"[repair] all {len(mapping)} known devices rejoined and renamed")
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
client.on_connect = on_connect
client.on_message = on_message
try:
client.connect(args.host, args.port, keepalive=60)
client.loop_start()
time.sleep(args.time + 5)
print(f"[repair] permit_join window elapsed ({args.time}s)")
print(f"[repair] joined: {len(joined)}, renamed: {len(renamed)} of {len(mapping)}")
missing = set(mapping) - joined
if missing:
print(f"[repair] missing (need re-pair):")
for ieee in missing:
print(f" {ieee} ({mapping[ieee]})")
client.loop_stop()
client.disconnect()
except KeyboardInterrupt:
print(f"\n[repair] aborted")
client.loop_stop()
client.disconnect()
return 130
return 0 if len(renamed) == len(mapping) else 1
if __name__ == "__main__":
sys.exit(main())