diff --git a/infra/compose.yaml b/infra/compose.yaml index bbd7521..d1dcda6 100644 --- a/infra/compose.yaml +++ b/infra/compose.yaml @@ -40,7 +40,7 @@ services: volumes: - ./zigbee2mqtt:/app/data devices: - - source: /dev/serial/by-path/platform-3f980000.usb-usb-0:1.1.2:1.0-port0 + - source: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 target: /dev/ttyUSB1 permissions: rw entrypoint: ["/app/data/scripts/entrypoint.sh"] diff --git a/infra/zigbee2mqtt/FLASHING.md b/infra/zigbee2mqtt/FLASHING.md new file mode 100644 index 0000000..feadada --- /dev/null +++ b/infra/zigbee2mqtt/FLASHING.md @@ -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. diff --git a/infra/zigbee2mqtt/R3_706_dongle_v1.00.01.bin b/infra/zigbee2mqtt/R3_706_dongle_v1.00.01.bin new file mode 100644 index 0000000..5615f31 Binary files /dev/null and b/infra/zigbee2mqtt/R3_706_dongle_v1.00.01.bin differ diff --git a/infra/zigbee2mqtt/partition_cfg_2M.toml b/infra/zigbee2mqtt/partition_cfg_2M.toml new file mode 100644 index 0000000..22342d9 --- /dev/null +++ b/infra/zigbee2mqtt/partition_cfg_2M.toml @@ -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 + diff --git a/justfile b/justfile index ca3aa0b..5dc7e1c 100644 --- a/justfile +++ b/justfile @@ -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' diff --git a/policy/compose.rego b/policy/compose.rego new file mode 100644 index 0000000..51a6b32 --- /dev/null +++ b/policy/compose.rego @@ -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]) +}