Compare commits
36 Commits
c1551c3d1e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97c30acfdd | |||
| ba3e172dda | |||
| 5383edd4ae | |||
| ad9a5b4e02 | |||
| 67bb68b097 | |||
| 3e06bc9e4b | |||
| 36f9d0a284 | |||
| 12bfd7fb09 | |||
| 9faa5e15a1 | |||
| 9f1479edf6 | |||
| 6ff5e91952 | |||
| ae088c2d9f | |||
| 0da1fbf9db | |||
| e2e9492a8d | |||
| b2e44369a9 | |||
| 131aa9d698 | |||
| 134fd4a0ed | |||
| 3e730942ba | |||
| fff67f2baa | |||
| 6c896f5165 | |||
| 6b55a16581 | |||
| c2f14b60aa | |||
| 32b9189f2f | |||
| e5b52fa56f | |||
| 573821426c | |||
| 09a4328960 | |||
| 7ed170acd4 | |||
| 66bc573dfd | |||
| 1ac9ef948b | |||
| 3f0c85c41b | |||
| fda24c18a2 | |||
| c2de5ba85b | |||
| 758edc0a9a | |||
| 1ef49652b0 | |||
| c9ea2b9393 | |||
| 90927ccc0e |
@@ -1 +1,2 @@
|
||||
output/
|
||||
secrets.h
|
||||
|
||||
@@ -32,8 +32,9 @@ All diagnostic commands live in the justfile. Run `just --list` to see them.
|
||||
- `just mqtt-button [action]` — E2E test: publish a button action and watch which `/set` topics fire
|
||||
- `just z2m-devices` — paired zigbee devices + group membership
|
||||
- `just mqtt-pub TOPIC PAYLOAD` / `just mqtt-sub TOPIC` / `just mqtt-watch` — raw MQTT
|
||||
- `just nr-logs` / `just z2m-logs` / `just mqtt-logs` — container log tails
|
||||
- `just nr-logs` / `just z2m-logs` / `just mqtt-logs` / `just doorbell-logs` — container log tails
|
||||
- `just restart SERVICE` / `just restart-all` — docker restart
|
||||
- `just doorbell-publish "Title" "Body"` — publish a test alert to ntfy
|
||||
- `just rpi CMD` — run any command on the RPi over SSH
|
||||
|
||||
`scripts/mqtt.py` and `scripts/diag.py` are the underlying helpers. Override the target host with `just RPI_HOST=user@host mqtt-button single`.
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+124
-5
@@ -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,17 +49,126 @@ 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"]
|
||||
|
||||
# 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)
|
||||
# Action: TOGGLE on zigbee2mqtt/Sideboard Lamp/set
|
||||
# On a fresh alert, flash the plug: N toggles spaced FLASH_INTERVAL_SECONDS
|
||||
# so the total runtime is FLASH_DURATION_SECONDS.
|
||||
doorbell-listener:
|
||||
build: ./doorbell-listener
|
||||
container_name: doorbell-listener
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
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: "5"
|
||||
MAX_AGE_SECONDS: "180"
|
||||
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:
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Persisted listener state — last seen ntfy message id
|
||||
state/
|
||||
__pycache__/
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY doorbell-listener.py .
|
||||
|
||||
CMD ["uv", "run", "--no-cache", "doorbell-listener.py"]
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "requests",
|
||||
# "paho-mqtt>=2.0",
|
||||
# ]
|
||||
# ///
|
||||
"""Doorbell ntfy listener.
|
||||
|
||||
Polls one or more ntfy topics and toggles a smart plug via zigbee2mqtt every
|
||||
time a new "message" event arrives.
|
||||
|
||||
Why polling: matches the doorbell firmware's poll-with-since pattern, trivial
|
||||
to persist last-seen message id per topic for restart resilience, no need for
|
||||
long-lived HTTP connections inside Docker.
|
||||
|
||||
Topics: ALERT_klubhaus_topic_test (development / smoke tests)
|
||||
ALERT_klubhaus_topic (production)
|
||||
Action: publish {"state": "TOGGLE"} to zigbee2mqtt/Sideboard Lamp/set
|
||||
|
||||
Env vars (all optional):
|
||||
MQTT_HOST default "mosquitto"
|
||||
MQTT_PORT default 1883
|
||||
PLUG_TOPIC default "zigbee2mqtt/Sideboard Lamp/set"
|
||||
POLL_INTERVAL seconds between polls, default 30
|
||||
STATE_DIR default "/data/doorbell-listener"
|
||||
NTFY_TOPICS comma-separated override, default "ALERT_klubhaus_topic_test,ALERT_klubhaus_topic"
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import requests
|
||||
|
||||
DEFAULT_TOPICS = "ALERT_klubhaus_topic_test,ALERT_klubhaus_topic"
|
||||
NTFY_TOPICS = [t.strip() for t in os.environ.get("NTFY_TOPICS", DEFAULT_TOPICS).split(",") if t.strip()]
|
||||
|
||||
MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto")
|
||||
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
PLUG_TOPIC = os.environ.get("PLUG_TOPIC", "zigbee2mqtt/Sideboard Lamp/set")
|
||||
STATE_DIR = Path(os.environ.get("STATE_DIR", "/data/doorbell-listener"))
|
||||
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30"))
|
||||
# Drop messages older than this many seconds (replay protection after restart).
|
||||
MAX_AGE_SECONDS = int(os.environ.get("MAX_AGE_SECONDS", "180"))
|
||||
# On a fresh alert, flash the plug: TOGGLE every FLASH_INTERVAL_SECONDS,
|
||||
# repeated so the total runtime is FLASH_DURATION_SECONDS. Count is rounded
|
||||
# UP to the nearest even integer so the lamp ends in the state it started
|
||||
# in (an even number of toggles returns to the original state).
|
||||
FLASH_INTERVAL_SECONDS = float(os.environ.get("FLASH_INTERVAL_SECONDS", "2"))
|
||||
FLASH_DURATION_SECONDS = float(os.environ.get("FLASH_DURATION_SECONDS", "15"))
|
||||
|
||||
|
||||
def flash_count() -> int:
|
||||
"""Number of toggles per alert. Always >= 2 and even."""
|
||||
n = max(2, math.ceil(FLASH_DURATION_SECONDS / FLASH_INTERVAL_SECONDS))
|
||||
if n % 2:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def last_id_path(topic: str) -> Path:
|
||||
safe = topic.replace("/", "_").replace(" ", "_")
|
||||
return STATE_DIR / f"last_id_{safe}"
|
||||
|
||||
|
||||
def load_last_id(topic: str) -> str:
|
||||
p = last_id_path(topic)
|
||||
if p.exists():
|
||||
return p.read_text().strip()
|
||||
return ""
|
||||
|
||||
|
||||
def save_last_id(topic: str, msg_id: str) -> None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
last_id_path(topic).write_text(msg_id)
|
||||
|
||||
|
||||
def start_flash(client: mqtt.Client, count: int, interval: float) -> None:
|
||||
"""Spawn a thread that publishes `count` TOGGLEs spaced `interval` seconds.
|
||||
|
||||
Each alert gets its own thread; overlapping flashes will publish toggles
|
||||
concurrently but z2m just flips state per toggle, so the lamp alternates
|
||||
regardless of how many threads are running.
|
||||
"""
|
||||
def run() -> None:
|
||||
log(f"flash: starting {count} toggles @ {interval}s")
|
||||
for i in range(count):
|
||||
payload = json.dumps({"state": "TOGGLE"})
|
||||
client.publish(PLUG_TOPIC, payload)
|
||||
log(f"flash {i + 1}/{count} -> {PLUG_TOPIC}")
|
||||
if i < count - 1:
|
||||
time.sleep(interval)
|
||||
log("flash: complete")
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
def poll_topic(client: mqtt.Client, topic: str, last_id: str) -> str:
|
||||
"""Poll one topic. Returns the latest message id seen (or last_id)."""
|
||||
url = f"https://ntfy.sh/{topic}/json?poll=1"
|
||||
if last_id:
|
||||
url += f"&since={last_id}"
|
||||
log(f"[{topic}] polling {url}")
|
||||
|
||||
r = requests.get(url, stream=True, timeout=(10, 65))
|
||||
r.raise_for_status()
|
||||
|
||||
newest_id = last_id
|
||||
now = time.time()
|
||||
n_flashes = flash_count()
|
||||
for line in r.iter_lines(decode_unicode=True):
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event.get("event") != "message":
|
||||
continue
|
||||
|
||||
msg_id = event.get("id", "")
|
||||
msg_time = event.get("time", 0)
|
||||
title = event.get("title", "")
|
||||
message = event.get("message", "")
|
||||
|
||||
# Update last_id for every message we see, even stale ones, so
|
||||
# restarts don't replay them.
|
||||
if msg_id:
|
||||
newest_id = msg_id
|
||||
save_last_id(topic, msg_id)
|
||||
|
||||
# Freshness filter: only act on recent messages.
|
||||
age = int(now - msg_time) if msg_time else None
|
||||
if age is not None and age > MAX_AGE_SECONDS:
|
||||
log(f"[{topic}] skip stale: id={msg_id} age={age}s > {MAX_AGE_SECONDS}s")
|
||||
continue
|
||||
|
||||
log(f"[{topic}] alert: id={msg_id} age={age}s title={title!r} message={message!r}")
|
||||
start_flash(client, n_flashes, FLASH_INTERVAL_SECONDS)
|
||||
|
||||
return newest_id
|
||||
|
||||
|
||||
def main() -> None:
|
||||
log("doorbell-listener starting")
|
||||
log(f" ntfy topics: {NTFY_TOPICS}")
|
||||
log(f" mqtt broker: {MQTT_HOST}:{MQTT_PORT}")
|
||||
log(f" plug topic: {PLUG_TOPIC}")
|
||||
log(f" poll interval: {POLL_INTERVAL}s")
|
||||
log(f" max age: {MAX_AGE_SECONDS}s")
|
||||
n_flashes = flash_count()
|
||||
log(f" flash: {n_flashes} toggles @ {FLASH_INTERVAL_SECONDS}s "
|
||||
f"({n_flashes * FLASH_INTERVAL_SECONDS}s total, even -> ends at start)")
|
||||
log(f" state dir: {STATE_DIR}")
|
||||
|
||||
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
||||
while True:
|
||||
try:
|
||||
client.connect(MQTT_HOST, MQTT_PORT, 60)
|
||||
break
|
||||
except Exception as e:
|
||||
log(f"mqtt connect failed: {e}; retrying in 5s")
|
||||
time.sleep(5)
|
||||
client.loop_start()
|
||||
|
||||
last_ids = {topic: load_last_id(topic) for topic in NTFY_TOPICS}
|
||||
for topic, lid in last_ids.items():
|
||||
log(f" [{topic}] resume from last_id: {lid or '(none)'}")
|
||||
|
||||
while True:
|
||||
for topic in NTFY_TOPICS:
|
||||
try:
|
||||
last_ids[topic] = poll_topic(client, topic, last_ids[topic])
|
||||
except requests.exceptions.RequestException as e:
|
||||
log(f"[{topic}] http error: {e}")
|
||||
except Exception as e:
|
||||
log(f"[{topic}] error: {e!r}")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
topic readwrite #
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -9,6 +9,34 @@ serial:
|
||||
rtscts: false
|
||||
advanced:
|
||||
log_level: debug
|
||||
network_key:
|
||||
- 146
|
||||
- 30
|
||||
- 124
|
||||
- 1
|
||||
- 70
|
||||
- 32
|
||||
- 235
|
||||
- 196
|
||||
- 27
|
||||
- 113
|
||||
- 122
|
||||
- 230
|
||||
- 7
|
||||
- 177
|
||||
- 134
|
||||
- 45
|
||||
pan_id: 3926
|
||||
ext_pan_id:
|
||||
- 225
|
||||
- 105
|
||||
- 42
|
||||
- 213
|
||||
- 37
|
||||
- 25
|
||||
- 93
|
||||
- 235
|
||||
|
||||
frontend:
|
||||
enabled: true
|
||||
port: 8080
|
||||
|
||||
@@ -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);
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
cd /app && node /app/data/scripts/blz-bridge-patch.js
|
||||
|
||||
exec docker-entrypoint.sh "$@"
|
||||
@@ -132,11 +132,68 @@ restart SERVICE:
|
||||
|
||||
# Restart the full stack
|
||||
restart-all:
|
||||
ssh {{RPI_HOST}} 'docker restart mosquitto zigbee2mqtt nodered'
|
||||
ssh {{RPI_HOST}} 'docker restart mosquitto zigbee2mqtt nodered doorbell-listener'
|
||||
|
||||
# Tail doorbell-listener container logs
|
||||
doorbell-logs:
|
||||
ssh {{RPI_HOST}} 'docker logs --tail 50 -f doorbell-listener'
|
||||
|
||||
# Restart just the doorbell-listener
|
||||
restart-doorbell:
|
||||
ssh {{RPI_HOST}} 'docker restart doorbell-listener'
|
||||
|
||||
# Publish a test alert to ntfy (used for E2E testing the listener).
|
||||
# Usage: just doorbell-publish "Title" "Body"
|
||||
doorbell-publish TITLE BODY="Someone is at the door":
|
||||
curl -sS -X POST "https://ntfy.sh/ALERT_klubhaus_topic_test" \
|
||||
-H "Title: {{TITLE}}" \
|
||||
-H "Priority: high" \
|
||||
-d "{{BODY}}" \
|
||||
-w "\nHTTP %{http_code}\n"
|
||||
|
||||
# Build + (re)start the listener (after edits to infra/doorbell-listener/)
|
||||
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
|
||||
|
||||
Executable
+127
@@ -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())
|
||||
Reference in New Issue
Block a user