Compare commits

...

4 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
8 changed files with 698 additions and 8 deletions
+1
View File
@@ -1 +1,2 @@
output/ 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
+18 -5
View File
@@ -2,7 +2,7 @@ services:
mosquitto: mosquitto:
image: eclipse-mosquitto:latest image: eclipse-mosquitto:latest
container_name: mosquitto container_name: mosquitto
restart: unless-stopped restart: always
user: "1883:1883" user: "1883:1883"
ports: ports:
- "1883:1883" - "1883:1883"
@@ -10,6 +10,12 @@ services:
- ./mosquitto/config:/mosquitto/config - ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data - ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log - ./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: zigbee2mqtt:
image: ghcr.io/pine64/zigbee2mqtt:latest-dev # BLZ fork, NOT koenkk image: ghcr.io/pine64/zigbee2mqtt:latest-dev # BLZ fork, NOT koenkk
@@ -22,7 +28,11 @@ services:
volumes: volumes:
- ./zigbee2mqtt:/app/data - ./zigbee2mqtt:/app/data
devices: 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: environment:
TZ: America/Los_Angeles TZ: America/Los_Angeles
@@ -43,7 +53,8 @@ services:
container_name: nodered container_name: nodered
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- mosquitto mosquitto:
condition: service_healthy
user: "1000:1000" user: "1000:1000"
ports: ports:
- "1880:1880" - "1880:1880"
@@ -65,7 +76,8 @@ services:
container_name: doorbell-listener container_name: doorbell-listener
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- mosquitto mosquitto:
condition: service_healthy
volumes: volumes:
- ./doorbell-listener/state:/data/doorbell-listener - ./doorbell-listener/state:/data/doorbell-listener
environment: environment:
@@ -146,7 +158,8 @@ services:
container_name: party-lock container_name: party-lock
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- mosquitto mosquitto:
condition: service_healthy
volumes: volumes:
- ./mosquitto/config:/mosquitto/config - ./mosquitto/config:/mosquitto/config
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
+9 -1
View File
@@ -14,12 +14,20 @@ RUN apt-get update \
libpango-1.0-0 \ libpango-1.0-0 \
libpangoft2-1.0-0 \ libpangoft2-1.0-0 \
libgirepository-1.0-1 \ libgirepository-1.0-1 \
libgirepository1.0-dev \
gir1.2-glib-2.0 \ gir1.2-glib-2.0 \
gir1.2-gstreamer-1.0 \ gir1.2-gstreamer-1.0 \
gir1.2-gst-plugins-base-1.0 \ gir1.2-gst-plugins-base-1.0 \
&& rm -rf /var/lib/apt/lists/* && 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 \ RUN pip install --no-cache-dir \
"setuptools<80" \ "setuptools<80" \
Mopidy==3.4.2 \ Mopidy==3.4.2 \
+16 -1
View File
@@ -2,12 +2,15 @@
# /// script # /// script
# dependencies = [ # dependencies = [
# "paho-mqtt>=2.0", # "paho-mqtt>=2.0",
# "docker>=7.0",
# ] # ]
# /// # ///
import os import os
import stat import stat
import time
import docker
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto") MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto")
@@ -25,7 +28,13 @@ def rebuild_acl():
f.write("\n".join(lines) + "\n") f.write("\n".join(lines) + "\n")
os.chmod(ACL_PATH, stat.S_IRWXU) os.chmod(ACL_PATH, stat.S_IRWXU)
os.chown(ACL_PATH, 1883, 1883) os.chown(ACL_PATH, 1883, 1883)
os.system("docker kill -s HUP mosquitto 2>/dev/null || true") 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): def on_message(client, userdata, msg):
global locked global locked
@@ -42,7 +51,13 @@ def main():
rebuild_acl() rebuild_acl()
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_message = on_message client.on_message = on_message
while True:
try:
client.connect(MQTT_HOST, MQTT_PORT, 60) 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.subscribe("party-lock/set", qos=1)
client.loop_forever() client.loop_forever()
+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 "$@"