Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da72ab3cba | |||
| 9e85a6d538 |
+21
-11
@@ -11,14 +11,26 @@ services:
|
||||
- ./mosquitto/data:/mosquitto/data
|
||||
- ./mosquitto/log:/mosquitto/log
|
||||
healthcheck:
|
||||
test: ["CMD", "mosquitto_pub", "-p", "1883", "-t", "healthcheck", "-m", "ok", "-r", "--quiet"]
|
||||
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
|
||||
image: ghcr.io/pine64/zigbee2mqtt:latest-dev # BLZ fork, NOT koenkk
|
||||
container_name: zigbee2mqtt
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -28,8 +40,8 @@ services:
|
||||
volumes:
|
||||
- ./zigbee2mqtt:/app/data
|
||||
devices:
|
||||
- source: /dev/serial/by-path/platform-3f980000.usb-usb-0:1.1.2:1.0-port0
|
||||
target: /dev/ttyUSB0
|
||||
- source: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
|
||||
target: /dev/ttyUSB1
|
||||
permissions: rw
|
||||
entrypoint: ["/app/data/scripts/entrypoint.sh"]
|
||||
command: ["/sbin/tini", "--", "node", "index.js"]
|
||||
@@ -59,12 +71,12 @@ services:
|
||||
ports:
|
||||
- "1880:1880"
|
||||
volumes:
|
||||
- ./nodered:/data # bind: settings.js, flows.json, start.sh, package.json
|
||||
- /data/node_modules # anonymous: preserves build-time npm install
|
||||
- ./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)
|
||||
@@ -109,7 +121,7 @@ services:
|
||||
- /run/dbus:size=1M
|
||||
- /run/user/0:size=1M
|
||||
cap_add:
|
||||
- SYS_ADMIN # bluetoothd needs CAP_SYS_ADMIN for HCI
|
||||
- SYS_ADMIN # bluetoothd needs CAP_SYS_ADMIN for HCI
|
||||
- NET_ADMIN
|
||||
|
||||
# Spotify Connect: phone's Spotify app shows "Klubhaus" as a Connect target.
|
||||
@@ -143,7 +155,7 @@ services:
|
||||
- "6680:6680"
|
||||
volumes:
|
||||
- ./mopidy/data:/var/lib/mopidy
|
||||
- ./mopidy/music:/music # drop FLACs in infra/mopidy/music/
|
||||
- ./mopidy/music:/music # drop FLACs in infra/mopidy/music/
|
||||
environment:
|
||||
TZ: America/Los_Angeles
|
||||
QOBUZ_APP_ID: "${QOBUZ_APP_ID:-}"
|
||||
@@ -170,5 +182,3 @@ services:
|
||||
|
||||
volumes:
|
||||
librespot_cache:
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# ThirdReality BL706 USB Dongle — Firmware Flashing Reference
|
||||
|
||||
Based on the [ThirdReality Zigbee 3.0 USB Dongle](https://github.com/thirdreality/ThirdReality-Zigbee-3.0-USB-dongle)
|
||||
repo (cloned at `/opt/david/src/ThirdReality-Zigbee-3.0-USB-dongle/`).
|
||||
|
||||
## Hardware
|
||||
|
||||
- Chip: Bouffalo Lab **BL706** (NOT BL702 — different firmware!)
|
||||
- USB bridge: CH340 (`1a86:7523`, appears as `/dev/ttyUSB0`)
|
||||
- Protocol: **BLZ** (not ZiGate — firmware v1.00+)
|
||||
- Config: `adapter: blz`, `baudrate: 2000000` in zigbee2mqtt `configuration.yaml`
|
||||
|
||||
## CRITICAL: BL702 vs BL706 firmware
|
||||
|
||||
The ThirdReality dongle uses a **BL706** chip. The DevCube flashing tool
|
||||
takes `--chipname=bl702` (flash layout), but the **firmware binary itself**
|
||||
must be built for BL706, not BL702.
|
||||
|
||||
Firmware files named `R3_bl702_dongle.bin` (v1.00.07, v1.00.12) are for
|
||||
**BL702 chips** and will NOT boot on BL706. Always use firmware files
|
||||
named `R3_706_dongle*.bin`.
|
||||
|
||||
## Working firmware: BLZ v1.00.01 (confirmed on BL706)
|
||||
|
||||
Saved in this repo at:
|
||||
- `infra/zigbee2mqtt/R3_706_dongle_v1.00.01.bin`
|
||||
- `infra/zigbee2mqtt/partition_cfg_2M.toml`
|
||||
|
||||
## Entering Bootloader Mode
|
||||
|
||||
Hold the **boot button** (pinhole on the dongle) while plugging into USB.
|
||||
Release after ~1 second. **No LED** when in bootloader mode.
|
||||
|
||||
## Flashing
|
||||
|
||||
```bash
|
||||
BFLASH="/opt/david/src/ThirdReality-Zigbee-3.0-USB-dongle/Flashing-Tool
|
||||
/BouffaloLabDevCube-v1.9.0/bflb_iot_tool-ubuntu"
|
||||
FIRMWARE="./infra/zigbee2mqtt/R3_706_dongle_v1.00.01.bin"
|
||||
PARTITION="./infra/zigbee2mqtt/partition_cfg_2M.toml"
|
||||
|
||||
$BFLASH \
|
||||
--chipname=bl702 \
|
||||
--interface=uart \
|
||||
--port=/dev/ttyUSB0 \
|
||||
--baudrate=2000000 \
|
||||
--xtal=32M \
|
||||
--firmware=$FIRMWARE \
|
||||
--pt=$PARTITION
|
||||
```
|
||||
|
||||
### Critical flags
|
||||
|
||||
| Flag | Value | Why |
|
||||
|---|---|---|
|
||||
| `--chipname` | `bl702` | Flash layout (not chip model — BL706 uses BL702 layout) |
|
||||
| `--interface` | `uart` | CH340 provides serial passthrough |
|
||||
| `--baudrate` | `2000000` | Must match the dongle's bootloader rate |
|
||||
| `--xtal` | `32M` | Crystal frequency for bootheader generation |
|
||||
| `--pt` | `partition_cfg_2M.toml` | **Must use 2M** — firmware overflows 1M |
|
||||
|
||||
## After Flashing
|
||||
|
||||
1. Tool prints `[All Success]` with SHA verification
|
||||
2. **Fully unplug** the dongle (wait 5s for power drain)
|
||||
3. Replug **without** holding any button
|
||||
4. LED should light up immediately — chip is running the new firmware
|
||||
|
||||
## Firmware Compatibility
|
||||
|
||||
| Version | File name | BL706 compatible? |
|
||||
|---|---|---|
|
||||
| BLZ v1.00.01 | `R3_706_dongle_v1.00.01.bin` | **YES** (confirmed working) |
|
||||
| BLZ v1.00.07 | `R3_bl702_dongle.bin` (misnamed) | NO — built for BL702 |
|
||||
| BLZ v1.00.12 | `R3_bl702_dongle.bin` (misnamed) | NO — built for BL702 |
|
||||
| Legacy ZiGate v0.00.21 | `R3_706_dongle.bin` | Untested (no BLZ support) |
|
||||
|
||||
The v1.00.01 firmware file is in the ThirdReality repo at
|
||||
`Image/Coordinator/blz/v1.00.01/R3_706_dongle_v1.00.01/`.
|
||||
|
||||
## Recurring Issue
|
||||
|
||||
After power loss, the BL706 firmware can corrupt itself (reports
|
||||
`deviceId: 0xBEEF`, `addEndpoint` fails with `frameId:21`). The chip
|
||||
responds to UART resets but refuses all application-layer commands.
|
||||
The fix is always to **reflash with BL706-compatible firmware** —
|
||||
there's no software recovery.
|
||||
Binary file not shown.
@@ -3,7 +3,7 @@ mqtt:
|
||||
base_topic: zigbee2mqtt
|
||||
server: mqtt://mosquitto:1883
|
||||
serial:
|
||||
port: /dev/ttyUSB0
|
||||
port: /dev/ttyUSB1
|
||||
adapter: blz
|
||||
baudrate: 2000000
|
||||
rtscts: false
|
||||
@@ -42,20 +42,20 @@ frontend:
|
||||
port: 8080
|
||||
permit_join: false
|
||||
groups:
|
||||
'1':
|
||||
"1":
|
||||
friendly_name: all_plugs
|
||||
devices:
|
||||
- Squiggle
|
||||
- Sideboard Lamp
|
||||
- Bamboo Lights
|
||||
devices:
|
||||
'0xffffb40e0604fbd0':
|
||||
"0xffffb40e0604fbd0":
|
||||
friendly_name: Squiggle
|
||||
'0xffffb40e06065536':
|
||||
"0xffffb40e06065536":
|
||||
friendly_name: Door Button
|
||||
'0xffffb40e0603c9e4':
|
||||
"0xffffb40e0603c9e4":
|
||||
friendly_name: Sideboard Lamp
|
||||
'0xffffb40e06050af6':
|
||||
"0xffffb40e06050af6":
|
||||
friendly_name: Bamboo Lights
|
||||
'0xffffb40e0603ef11':
|
||||
"0xffffb40e0603ef11":
|
||||
friendly_name: DJ Booth
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
[pt_table]
|
||||
#partition table is 4K in size
|
||||
address0 = 0x1000
|
||||
address1 = 0x2000
|
||||
|
||||
[[pt_entry]]
|
||||
type = 0
|
||||
name = "FW"
|
||||
device = 0
|
||||
address0 = 0x3000
|
||||
size0 = 0x127000
|
||||
address1 = 0x12A000
|
||||
size1 = 0xCE000
|
||||
# compressed image must set len,normal image can left it to 0
|
||||
len = 0
|
||||
activeindex = 0
|
||||
age = 0
|
||||
|
||||
[[pt_entry]]
|
||||
type = 1
|
||||
name = "mfg"
|
||||
device = 0
|
||||
address0 = 0x12A000
|
||||
size0 = 0xCE000
|
||||
address1 = 0
|
||||
size1 = 0
|
||||
# compressed image must set len,normal image can left it to 0
|
||||
len = 0
|
||||
activeindex = 0
|
||||
age = 0
|
||||
|
||||
[[pt_entry]]
|
||||
type = 2
|
||||
name = "PSM"
|
||||
device = 0
|
||||
address0 = 0x1F8000
|
||||
size0 = 0x2000
|
||||
address1 = 0
|
||||
size1 = 0
|
||||
# compressed image must set len,normal image can left it to 0
|
||||
len = 0
|
||||
activeindex = 0
|
||||
age = 0
|
||||
|
||||
[[pt_entry]]
|
||||
type = 3
|
||||
name = "backup"
|
||||
device = 0
|
||||
address0 = 0x1FA000
|
||||
size0 = 0x1000
|
||||
address1 = 0
|
||||
size1 = 0
|
||||
# compressed image must set len,normal image can left it to 0
|
||||
len = 0
|
||||
activeindex = 0
|
||||
age = 0
|
||||
|
||||
[[pt_entry]]
|
||||
type = 4
|
||||
name = "media"
|
||||
device = 0
|
||||
address0 = 0x1FB000
|
||||
size0 = 0x4000
|
||||
address1 = 0
|
||||
size1 = 0
|
||||
# compressed image must set len,normal image can left it to 0
|
||||
len = 0
|
||||
activeindex = 0
|
||||
age = 0
|
||||
|
||||
[[pt_entry]]
|
||||
type = 5
|
||||
name = "factory"
|
||||
device = 0
|
||||
address0 = 0x1FF000
|
||||
size0 = 0x1000
|
||||
address1 = 0
|
||||
size1 = 0
|
||||
# compressed image must set len,normal image can left it to 0
|
||||
len = 0
|
||||
activeindex = 0
|
||||
age = 0
|
||||
|
||||
@@ -1,88 +1,6 @@
|
||||
# nr-flow-validator — Declarative Node-RED flows with conftest validation
|
||||
|
||||
# nr-flow-validator — Declarative Node-RED flows + infra stack config
|
||||
set export
|
||||
|
||||
# Default: generate a flow, validate, and show results
|
||||
default: validate
|
||||
|
||||
# ---- Flow generation ----
|
||||
|
||||
# Generate Node-RED flow JSON from a YAML spec
|
||||
generate FLOW="zigbee-monitor":
|
||||
@echo "=== Generating {{FLOW}} ==="
|
||||
python3 scripts/flow2json.py flows/{{FLOW}}.yaml > output/{{FLOW}}.json
|
||||
@echo "Wrote output/{{FLOW}}.json"
|
||||
|
||||
# Generate all flows
|
||||
generate-all:
|
||||
@for f in flows/*.yaml; do \
|
||||
name=$(basename "$f" .yaml); \
|
||||
just generate "$name"; \
|
||||
done
|
||||
|
||||
# ---- Validation ----
|
||||
|
||||
# Validate a generated flow against Rego policies
|
||||
validate FLOW="zigbee-monitor": generate
|
||||
@echo "=== Validating {{FLOW}} ==="
|
||||
conftest test output/{{FLOW}}.json -p policy/ --namespace nodered --no-color
|
||||
|
||||
# Validate all flows
|
||||
validate-all:
|
||||
@for f in output/*.json; do \
|
||||
echo "=== Validating $(basename "$f") ==="; \
|
||||
conftest test "$$f" -p policy/ --namespace nodered --no-color; \
|
||||
done
|
||||
|
||||
# ---- Deployment ----
|
||||
|
||||
# Deploy flow to Node-RED instance
|
||||
deploy FLOW="zigbee-monitor" HOST="http://192.168.81.147:1880": generate validate
|
||||
@echo "=== Deploying {{FLOW}} to {{HOST}} ==="
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}" \
|
||||
-X POST {{HOST}}/flows \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Node-RED-Deployment-Type: full" \
|
||||
-d @output/{{FLOW}}.json
|
||||
@echo ""
|
||||
|
||||
# Quick deploy (skip validation)
|
||||
deploy-quick FLOW="zigbee-monitor" HOST="http://192.168.81.147:1880": generate
|
||||
@echo "=== Quick deploy {{FLOW}} to {{HOST}} ==="
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}" \
|
||||
-X POST {{HOST}}/flows \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Node-RED-Deployment-Type: full" \
|
||||
-d @output/{{FLOW}}.json
|
||||
@echo ""
|
||||
|
||||
# ---- Viewing ----
|
||||
|
||||
# Fetch and display current flows from Node-RED
|
||||
pull HOST="http://192.168.81.147:1880":
|
||||
curl -s {{HOST}}/flows | python3 -m json.tool
|
||||
|
||||
# Open dashboard in browser
|
||||
dashboard:
|
||||
xdg-open http://192.168.81.147:1880/dashboard 2>/dev/null || open http://192.168.81.147:1880/dashboard 2>/dev/null || echo "Open: http://192.168.81.147:1880/dashboard"
|
||||
|
||||
# ---- Development ----
|
||||
|
||||
# Watch for changes and re-validate
|
||||
watch FLOW="zigbee-monitor":
|
||||
@while true; do \
|
||||
inotifywait -q -e modify flows/{{FLOW}}.yaml policy/*.rego; \
|
||||
clear; just validate {{FLOW}}; \
|
||||
done
|
||||
|
||||
# Create a new flow from template
|
||||
new NAME:
|
||||
@cp flows/zigbee-monitor.yaml flows/{{NAME}}.yaml
|
||||
@echo "Created flows/{{NAME}}.yaml — edit and run 'just validate {{NAME}}'"
|
||||
|
||||
# ---- Diagnostics ----
|
||||
# All commands target the live RPi at $RPI_HOST (default root@192.168.81.147).
|
||||
# Override with: just RPI_HOST=user@host mqtt-pub topic msg
|
||||
PATH := "/home/david/.local/share/mise/shims:" + env("PATH")
|
||||
|
||||
RPI_HOST := "root@192.168.81.147"
|
||||
MQTT_HOST := "192.168.81.147"
|
||||
@@ -90,124 +8,220 @@ MQTT_PORT := "1883"
|
||||
RPI_DIR := "/root"
|
||||
NR_PORT := "1880"
|
||||
|
||||
# Run a command on the RPi over SSH
|
||||
rpi CMD *ARGS:
|
||||
ssh -o ConnectTimeout=5 {{RPI_HOST}} '{{CMD}} {{ARGS}}'
|
||||
# Default: generate + validate the zigbee-monitor flow + validate infra config
|
||||
default: nodered-validate infra-validate
|
||||
|
||||
# Publish to MQTT broker on the RPi
|
||||
mqtt-pub TOPIC PAYLOAD:
|
||||
uv run --with paho-mqtt python3 scripts/mqtt.py pub --topic '{{TOPIC}}' --payload '{{PAYLOAD}}'
|
||||
# ============================================================
|
||||
# NODERED — flow generation, validation, deployment, inspection
|
||||
# ============================================================
|
||||
|
||||
# Subscribe to MQTT broker on the RPi for N seconds
|
||||
mqtt-sub TOPIC SECONDS="5":
|
||||
uv run --with paho-mqtt python3 scripts/mqtt.py sub --topic '{{TOPIC}}' --seconds {{SECONDS}}
|
||||
# Compile a flow YAML into deployable Node-RED JSON
|
||||
nodered-generate FLOW="zigbee-monitor":
|
||||
@echo "=== Generating {{FLOW}} ==="
|
||||
python3 scripts/flow2json.py flows/{{FLOW}}.yaml > output/{{FLOW}}.json
|
||||
@echo "Wrote output/{{FLOW}}.json"
|
||||
|
||||
# Publish a button action and watch what /set topics fire (E2E test)
|
||||
mqtt-button ACTION="single":
|
||||
uv run --with paho-mqtt python3 scripts/diag.py mqtt-button --action {{ACTION}}
|
||||
# Compile all flow YAMLs
|
||||
nodered-generate-all:
|
||||
@for f in flows/*.yaml; do \
|
||||
name=$(basename "$f" .yaml); \
|
||||
just nodered-generate "$name"; \
|
||||
done
|
||||
|
||||
# Show live Node-RED flow summary (node count, function names, button wires, mqtt-out topics)
|
||||
nr-show:
|
||||
# Run Rego policy checks against a generated Node-RED flow
|
||||
nodered-validate FLOW="zigbee-monitor": nodered-generate
|
||||
@echo "=== Validating {{FLOW}} ==="
|
||||
conftest test output/{{FLOW}}.json -p policy/ --namespace nodered --no-color
|
||||
|
||||
# Run Rego checks against all generated flows
|
||||
nodered-validate-all:
|
||||
@for f in output/*.json; do \
|
||||
echo "=== Validating $(basename "$f") ==="; \
|
||||
conftest test "$$f" -p policy/ --namespace nodered --no-color; \
|
||||
done
|
||||
|
||||
# Deploy a validated flow to the Node-RED server, then validate the compose stack
|
||||
nodered-deploy FLOW="zigbee-monitor" HOST="http://192.168.81.147:1880": nodered-generate nodered-validate
|
||||
@echo "=== Deploying {{FLOW}} to {{HOST}} ==="
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}" \
|
||||
-X POST {{HOST}}/flows \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Node-RED-Deployment-Type: full" \
|
||||
-d @output/{{FLOW}}.json
|
||||
@echo ""
|
||||
just infra-validate
|
||||
|
||||
# Deploy a flow without Rego validation (faster, use after local edits)
|
||||
nodered-deploy-skip-checks FLOW="zigbee-monitor" HOST="http://192.168.81.147:1880": nodered-generate
|
||||
@echo "=== Deploying {{FLOW}} to {{HOST}} (skip Rego checks) ==="
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}" \
|
||||
-X POST {{HOST}}/flows \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Node-RED-Deployment-Type: full" \
|
||||
-d @output/{{FLOW}}.json
|
||||
@echo ""
|
||||
|
||||
# Fetch current flows from the Node-RED server (raw JSON)
|
||||
nodered-pull HOST="http://192.168.81.147:1880":
|
||||
curl -s {{HOST}}/flows | python3 -m json.tool
|
||||
|
||||
# Show live Node-RED flow summary (node types, function names, wire topology)
|
||||
nodered-show:
|
||||
python3 scripts/diag.py show
|
||||
|
||||
# Diff deployed Node-RED flow against repo (excluding random uuid ids)
|
||||
nr-diff FLOW="zigbee-monitor": generate
|
||||
# Diff the generated flow against what's deployed (ids excluded)
|
||||
nodered-diff FLOW="zigbee-monitor": nodered-generate
|
||||
python3 scripts/diag.py diff output/{{FLOW}}.json
|
||||
|
||||
# Tail Node-RED container logs
|
||||
nr-logs:
|
||||
# Tail the Node-RED container logs
|
||||
nodered-logs:
|
||||
ssh {{RPI_HOST}} 'docker logs --tail 50 -f nodered'
|
||||
|
||||
# Tail zigbee2mqtt container logs
|
||||
z2m-logs:
|
||||
ssh {{RPI_HOST}} 'docker logs --tail 50 -f zigbee2mqtt'
|
||||
# Open the Node-RED editor in browser
|
||||
nodered-editor:
|
||||
xdg-open http://{{MQTT_HOST}}:{{NR_PORT}} 2>/dev/null || open http://{{MQTT_HOST}}:{{NR_PORT}} 2>/dev/null || echo "Open: http://{{MQTT_HOST}}:{{NR_PORT}}"
|
||||
|
||||
# Tail mosquitto container logs
|
||||
mqtt-logs:
|
||||
ssh {{RPI_HOST}} 'docker logs --tail 50 -f mosquitto'
|
||||
# Open the Node-RED Dashboard 2.0 SPA in browser
|
||||
nodered-dashboard:
|
||||
xdg-open http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard 2>/dev/null || open http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard 2>/dev/null || echo "Open: http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard"
|
||||
|
||||
# Restart a service container
|
||||
restart SERVICE:
|
||||
# Watch flow YAML and Rego policy files; re-validate on change
|
||||
nodered-watch FLOW="zigbee-monitor":
|
||||
@while true; do \
|
||||
inotifywait -q -e modify flows/{{FLOW}}.yaml policy/*.rego; \
|
||||
clear; just nodered-validate {{FLOW}}; \
|
||||
done
|
||||
|
||||
# Scaffold a new flow YAML from the zigbee-monitor template
|
||||
nodered-new NAME:
|
||||
@cp flows/zigbee-monitor.yaml flows/{{NAME}}.yaml
|
||||
@echo "Created flows/{{NAME}}.yaml — edit and run 'just nodered-validate {{NAME}}'"
|
||||
|
||||
# ============================================================
|
||||
# INFRA — git sync, compose validation, remote host management
|
||||
# ============================================================
|
||||
|
||||
# Commit local changes, push to Gitea, pull on RPi, validate compose
|
||||
infra-deploy MESSAGE="sync infra":
|
||||
@echo "=== Committing ==="
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m '{{MESSAGE}}'
|
||||
@echo "=== Pushing to Gitea ==="
|
||||
git push
|
||||
@echo "=== Pulling on RPi ==="
|
||||
ssh {{RPI_HOST}} "cd {{RPI_DIR}}/nr-flow-validator && git pull"
|
||||
@echo "=== Validating compose stack ==="
|
||||
just infra-validate
|
||||
@echo "=== Done ==="
|
||||
|
||||
# Run Rego policy checks against the compose file and zigbee2mqtt config
|
||||
infra-validate:
|
||||
conftest test infra/compose.yaml -p policy/ --namespace compose \
|
||||
--data infra/zigbee2mqtt/configuration.yaml
|
||||
|
||||
# Run an arbitrary command on the RPi over SSH
|
||||
infra-ssh CMD *ARGS:
|
||||
ssh -o ConnectTimeout=5 {{RPI_HOST}} '{{CMD}} {{ARGS}}'
|
||||
|
||||
# Restart any Docker service on the RPi (e.g. just infra-restart mosquitto)
|
||||
infra-restart SERVICE:
|
||||
ssh {{RPI_HOST}} 'docker restart {{SERVICE}}'
|
||||
|
||||
# Restart the full stack
|
||||
restart-all:
|
||||
# Restart the full stack on the RPi
|
||||
infra-restart-all:
|
||||
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'
|
||||
# Tail the mosquitto MQTT broker logs
|
||||
mosquitto-logs:
|
||||
ssh {{RPI_HOST}} 'docker logs --tail 50 -f mosquitto'
|
||||
|
||||
# Restart just the doorbell-listener
|
||||
restart-doorbell:
|
||||
ssh {{RPI_HOST}} 'docker restart doorbell-listener'
|
||||
# ============================================================
|
||||
# ZIGBEE — zigbee2mqtt management
|
||||
# ============================================================
|
||||
|
||||
# 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"
|
||||
# Tail the zigbee2mqtt container logs
|
||||
zigbee-logs:
|
||||
ssh {{RPI_HOST}} 'docker logs --tail 50 -f zigbee2mqtt'
|
||||
|
||||
# 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:
|
||||
# Apply the BLZ adapter bridge.js patch (idempotent).
|
||||
# Required after every fresh ghcr.io/pine64/zigbee2mqtt image pull.
|
||||
zigbee-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"
|
||||
@echo "Done. Verify with: just zigbee-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":
|
||||
# Open permit_join and watch for device_joined events; auto-rename rejoining devices
|
||||
zigbee-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:
|
||||
# Rename a zigbee2mqtt device by its IEEE address
|
||||
zigbee-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:
|
||||
# List all paired zigbee devices with their friendly names
|
||||
zigbee-devices:
|
||||
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
|
||||
z2m-state:
|
||||
# Show per-device persisted state (on/off, brightness, etc.)
|
||||
zigbee-state:
|
||||
ssh {{RPI_HOST}} 'cat {{RPI_DIR}}/zigbee2mqtt/state.json' | python3 -m json.tool | head -60
|
||||
|
||||
# Open the Node-RED editor
|
||||
nr-editor:
|
||||
xdg-open http://{{MQTT_HOST}}:{{NR_PORT}} 2>/dev/null || open http://{{MQTT_HOST}}:{{NR_PORT}} 2>/dev/null || echo "Open: http://{{MQTT_HOST}}:{{NR_PORT}}"
|
||||
# ============================================================
|
||||
# MQTT — publish, subscribe, watch, simulate devices
|
||||
# ============================================================
|
||||
|
||||
# Open the Dashboard 2.0 SPA
|
||||
nr-dashboard:
|
||||
xdg-open http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard 2>/dev/null || open http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard 2>/dev/null || echo "Open: http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard"
|
||||
# Publish a message to any MQTT topic on the RPi broker
|
||||
mqtt-pub TOPIC PAYLOAD:
|
||||
uv run --with paho-mqtt python3 scripts/mqtt.py pub --topic '{{TOPIC}}' --payload '{{PAYLOAD}}'
|
||||
|
||||
# Watch live MQTT traffic (matches the z2m base_topic by default)
|
||||
# Subscribe to an MQTT topic for N seconds and print messages
|
||||
mqtt-sub TOPIC SECONDS="5":
|
||||
uv run --with paho-mqtt python3 scripts/mqtt.py sub --topic '{{TOPIC}}' --seconds {{SECONDS}}
|
||||
|
||||
# Simulate a door button press (single|double|hold) and watch the /set response
|
||||
mqtt-simulate-doorbell ACTION="single":
|
||||
uv run --with paho-mqtt python3 scripts/diag.py mqtt-button --action {{ACTION}}
|
||||
|
||||
# Print every MQTT message matching a topic pattern (default: all zigbee2mqtt traffic)
|
||||
mqtt-watch TOPIC="zigbee2mqtt/#":
|
||||
uv run --with paho-mqtt python3 scripts/mqtt.py watch --topic '{{TOPIC}}'
|
||||
|
||||
# ============================================================
|
||||
# DOORBELL — ntfy-to-smart-plug bridge management
|
||||
# ============================================================
|
||||
|
||||
# Tail the doorbell-listener container logs
|
||||
doorbell-logs:
|
||||
ssh {{RPI_HOST}} 'docker logs --tail 50 -f doorbell-listener'
|
||||
|
||||
# Build and restart the doorbell listener after local edits
|
||||
doorbell-rebuild:
|
||||
ssh {{RPI_HOST}} 'cd {{RPI_DIR}}/nr-flow-validator && docker compose build doorbell-listener && docker compose up -d doorbell-listener'
|
||||
|
||||
# Send a test ntfy alert to trigger the doorbell listener
|
||||
doorbell-test 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"
|
||||
|
||||
# ============================================================
|
||||
# MISC — standalone service logs
|
||||
# ============================================================
|
||||
|
||||
# Tail the audio-bridge container logs
|
||||
audio-bridge-logs:
|
||||
ssh {{RPI_HOST}} 'docker logs --tail 50 -f audio-bridge'
|
||||
|
||||
# Tail the party-lock container logs
|
||||
party-lock-logs:
|
||||
ssh {{RPI_HOST}} 'docker logs --tail 50 -f party-lock'
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package compose
|
||||
import future.keywords.if
|
||||
import future.keywords.contains
|
||||
import future.keywords.in
|
||||
|
||||
# Every service must have a restart policy
|
||||
deny_no_restart contains msg if {
|
||||
some name, svc in input.services
|
||||
not svc.restart
|
||||
msg := sprintf("service %s: missing restart policy", [name])
|
||||
}
|
||||
|
||||
# zigbee2mqtt must use the Pine64 BLZ fork, not upstream koenkk
|
||||
deny_wrong_image contains msg if {
|
||||
svc := input.services.zigbee2mqtt
|
||||
not startswith(svc.image, "ghcr.io/pine64/zigbee2mqtt")
|
||||
msg := sprintf("zigbee2mqtt: image must be ghcr.io/pine64/zigbee2mqtt (got %s)", [svc.image])
|
||||
}
|
||||
|
||||
# zigbee2mqtt must have the correct entrypoint
|
||||
deny_missing_entrypoint contains msg if {
|
||||
svc := input.services.zigbee2mqtt
|
||||
svc.entrypoint != ["/app/data/scripts/entrypoint.sh"]
|
||||
msg := "zigbee2mqtt: entrypoint must be [\"/app/data/scripts/entrypoint.sh\"]"
|
||||
}
|
||||
|
||||
# zigbee2mqtt must have the correct command (tini wrapper)
|
||||
deny_wrong_command contains msg if {
|
||||
svc := input.services.zigbee2mqtt
|
||||
svc.command != ["/sbin/tini", "--", "node", "index.js"]
|
||||
msg := sprintf("zigbee2mqtt: command must be /sbin/tini -- node index.js (got %s)", [svc.command])
|
||||
}
|
||||
|
||||
# Device sources should use stable by-path or by-id symlinks, not raw /dev/ttyUSB*
|
||||
deny_raw_tty contains msg if {
|
||||
some name, svc in input.services
|
||||
some dev in svc.devices
|
||||
startswith(dev.source, "/dev/ttyUSB")
|
||||
msg := sprintf("service %s: device source %s uses raw tty path (use /dev/serial/by-*)", [name, dev.source])
|
||||
}
|
||||
|
||||
# Only audio-bridge should run privileged
|
||||
deny_unexpected_privileged contains msg if {
|
||||
some name, svc in input.services
|
||||
svc.privileged
|
||||
name != "audio-bridge"
|
||||
msg := sprintf("service %s: privileged: true (only audio-bridge should be privileged)", [name])
|
||||
}
|
||||
|
||||
# zigbee2mqtt must depend on mosquitto
|
||||
deny_missing_dep contains msg if {
|
||||
svc := input.services.zigbee2mqtt
|
||||
not svc.depends_on
|
||||
msg := "zigbee2mqtt: missing depends_on (should depend on mosquitto)"
|
||||
}
|
||||
|
||||
deny_missing_dep contains msg if {
|
||||
svc := input.services.zigbee2mqtt
|
||||
svc.depends_on
|
||||
not "mosquitto" in object.keys(svc.depends_on)
|
||||
msg := "zigbee2mqtt: must depend on mosquitto"
|
||||
}
|
||||
|
||||
# nodered must depend on mosquitto with condition: service_healthy
|
||||
deny_nodered_dep_healthy contains msg if {
|
||||
svc := input.services.nodered
|
||||
dep := svc.depends_on.mosquitto
|
||||
dep.condition != "service_healthy"
|
||||
msg := "nodered: mosquitto dependency must have condition: service_healthy"
|
||||
}
|
||||
|
||||
# zigbee2mqtt should not use host networking
|
||||
deny_host_network contains msg if {
|
||||
svc := input.services.zigbee2mqtt
|
||||
svc.network_mode == "host"
|
||||
msg := "zigbee2mqtt: should not use host network mode"
|
||||
}
|
||||
|
||||
# zigbee2mqtt must mount zigbee2mqtt/ as /app/data
|
||||
has_z2m_vol if {
|
||||
some vol in input.services.zigbee2mqtt.volumes
|
||||
startswith(vol, "./zigbee2mqtt:/app/data")
|
||||
}
|
||||
|
||||
deny_missing_volume contains msg if {
|
||||
svc := input.services.zigbee2mqtt
|
||||
not has_z2m_vol
|
||||
msg := "zigbee2mqtt: must have volume mapping ./zigbee2mqtt:/app/data"
|
||||
}
|
||||
|
||||
# Cross-reference: zigbee2mqtt device target must match its config port.
|
||||
# Load configuration.yaml via --data flag:
|
||||
# conftest test infra/compose.yaml --data infra/zigbee2mqtt/configuration.yaml
|
||||
deny_port_mismatch contains msg if {
|
||||
svc := input.services.zigbee2mqtt
|
||||
some dev in svc.devices
|
||||
cfg := data.configuration.serial.port
|
||||
dev.target != cfg
|
||||
msg := sprintf("zigbee2mqtt: device target %s does not match config port %s", [dev.target, cfg])
|
||||
}
|
||||
Reference in New Issue
Block a user