From 279573b65c7c72e6b3d4fb96e9d69edfc7f86aaf Mon Sep 17 00:00:00 2001
From: Alexander Templeton <10369436+azide0x37@users.noreply.github.com>
Date: Fri, 15 May 2026 10:07:53 -0500
Subject: [PATCH 1/6] Add Home Assistant MQTT bridge pattern
---
README.md | 2 +-
docs/completion.md | 9 +-
docs/index.md | 1 +
docs/pattern-graph.md | 7 +
docs/production-beta-contract.md | 6 +
.../T2R6.home-assistant-mqtt-bridge/README.md | 63 +++++++
.../examples/minimal/README.md | 13 ++
.../manifest.yaml | 58 +++++++
.../scripts/doctor.sh | 46 +++++
.../scripts/ha-mqtt-bridge.sh | 157 ++++++++++++++++++
.../scripts/install.sh | 65 ++++++++
.../scripts/rollback.sh | 52 ++++++
.../scripts/uninstall.sh | 53 ++++++
.../tests/test_manifest.py | 7 +
.../units/muster-ha-mqtt-bridge.service | 18 ++
.../units/muster-ha-mqtt-bridge.timer | 12 ++
tests/test_completion.py | 16 +-
tests/test_home_assistant_mqtt_bridge.py | 87 ++++++++++
tests/test_pattern_inventory.py | 1 +
tools/check_production_beta.py | 10 ++
tools/completion.py | 6 +-
tools/render_completion.py | 16 ++
22 files changed, 700 insertions(+), 5 deletions(-)
create mode 100644 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/README.md
create mode 100644 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/examples/minimal/README.md
create mode 100644 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/manifest.yaml
create mode 100755 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/doctor.sh
create mode 100755 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/ha-mqtt-bridge.sh
create mode 100755 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/install.sh
create mode 100755 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/rollback.sh
create mode 100755 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/uninstall.sh
create mode 100644 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/tests/test_manifest.py
create mode 100644 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/units/muster-ha-mqtt-bridge.service
create mode 100644 patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/units/muster-ha-mqtt-bridge.timer
create mode 100644 tests/test_home_assistant_mqtt_bridge.py
diff --git a/README.md b/README.md
index 8d53b3c..4bd0c1d 100644
--- a/README.md
+++ b/README.md
@@ -43,4 +43,4 @@ The first milestone is a complete, machine-checkable scaffold for the planned Mu
`docs/index.md` and `docs/pattern-graph.md` are generated from manifests. Do not hand-edit them.
`docs/completion.md` is also generated and reports maturity from manifest status fields.
-`T2R4.device-triggered-conveyor` is the first production-beta rare pattern for device-event ingest appliances. `C6.lifecycle-capsule` and `T2R5.signed-update-rail` capture the core install, doctor, update, rollback, and clean-uninstall lifecycle.
+`T2R4.device-triggered-conveyor` is the first production-beta rare pattern for device-event ingest appliances. `C6.lifecycle-capsule` and `T2R5.signed-update-rail` capture the core install, doctor, update, rollback, and clean-uninstall lifecycle. `T2R6.home-assistant-mqtt-bridge` adds a mockable Home Assistant MQTT discovery, state, and control bridge.
diff --git a/docs/completion.md b/docs/completion.md
index be332ec..eb41aaf 100644
--- a/docs/completion.md
+++ b/docs/completion.md
@@ -12,7 +12,7 @@ Overall maturity: **75.5%**
| Tech 1 Rare | 5 | 100.0% |
| Tech 1 Mythic | 5 | 100.0% |
| Tech 2 Common | 5 | 68.0% |
-| Tech 2 Rare | 5 | 55.1% |
+| Tech 2 Rare | 6 | 58.7% |
| Tech 3 Common | 1 | 76.7% |
| Tech 3 Rare | 1 | 33.0% |
| Tech 3 Mythic | 5 | 33.0% |
@@ -46,6 +46,12 @@ Overall maturity: **75.5%**
| `C6.lifecycle-capsule` | Lifecycle Capsule | stable/stable/stable | 100.0% |
| `T2R5.signed-update-rail` | Signed Update Rail | usable/reviewed/reviewed | 76.7% |
+## Production-Beta Home Assistant Chain
+
+| ID | Pattern | Status | Completion |
+| --- | --- | --- | ---: |
+| `T2R6.home-assistant-mqtt-bridge` | Home Assistant MQTT Bridge | usable/reviewed/reviewed | 76.7% |
+
## Stable Device Conveyor Chain
| ID | Pattern | Status | Completion |
@@ -84,6 +90,7 @@ Overall maturity: **75.5%**
| `T2R3.edge-control-plane` | Edge Control Plane | draft/draft/draft | 33.0% |
| `T2R4.device-triggered-conveyor` | Device-Triggered Conveyor | stable/stable/stable | 100.0% |
| `T2R5.signed-update-rail` | Signed Update Rail | usable/reviewed/reviewed | 76.7% |
+| `T2R6.home-assistant-mqtt-bridge` | Home Assistant MQTT Bridge | usable/reviewed/reviewed | 76.7% |
| `T3C1.edge-appliance-bundle` | Edge Appliance Bundle | usable/reviewed/reviewed | 76.7% |
| `T3M1.machine-priest` | Machine Priest | draft/draft/draft | 33.0% |
| `T3M2.ritualized-recovery-loop` | Ritualized Recovery Loop | draft/draft/draft | 33.0% |
diff --git a/docs/index.md b/docs/index.md
index d911448..2c7e3bc 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -56,6 +56,7 @@ Generated from `patterns/**/manifest.yaml`.
| [`T2R3.edge-control-plane`](../patterns/t2/rare/T2R3.edge-control-plane/) | Edge Control Plane | 5 | T2C2.dropfolder-spooler, T2C3.scheduled-herald, T2C5.local-sidecar-bridge, R1.socket-anteroom, R2.device-binding, R4.state-ledger | draft/draft/draft |
| [`T2R4.device-triggered-conveyor`](../patterns/t2/rare/T2R4.device-triggered-conveyor/) | Device-Triggered Conveyor | 5 | C1.service-capsule, C2.persistent-tick, C4.lazy-resource-gate, C5.failure-ratchet, C6.lifecycle-capsule, R2.device-binding, R5.capability-mount, T2C1.hot-cold-nas-conveyor, T2C3.scheduled-herald | stable/stable/stable |
| [`T2R5.signed-update-rail`](../patterns/t2/rare/T2R5.signed-update-rail/) | Signed Update Rail | 5 | C2.persistent-tick, C5.failure-ratchet, C6.lifecycle-capsule, R4.state-ledger | usable/reviewed/reviewed |
+| [`T2R6.home-assistant-mqtt-bridge`](../patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/) | Home Assistant MQTT Bridge | 5 | C1.service-capsule, C2.persistent-tick, C5.failure-ratchet, C6.lifecycle-capsule, R4.state-ledger, T2C5.local-sidecar-bridge | usable/reviewed/reviewed |
## Tech 3
diff --git a/docs/pattern-graph.md b/docs/pattern-graph.md
index 8a5470f..4e1cf52 100644
--- a/docs/pattern-graph.md
+++ b/docs/pattern-graph.md
@@ -30,6 +30,7 @@ graph TD
T2R3["T2R3.edge-control-plane
Edge Control Plane"]
T2R4["T2R4.device-triggered-conveyor
Device-Triggered Conveyor"]
T2R5["T2R5.signed-update-rail
Signed Update Rail"]
+ T2R6["T2R6.home-assistant-mqtt-bridge
Home Assistant MQTT Bridge"]
T3C1["T3C1.edge-appliance-bundle
Edge Appliance Bundle"]
T3M1["T3M1.machine-priest
Machine Priest"]
T3M2["T3M2.ritualized-recovery-loop
Ritualized Recovery Loop"]
@@ -80,6 +81,12 @@ graph TD
C5 --> T2R5
C6 --> T2R5
R4 --> T2R5
+ C1 --> T2R6
+ C2 --> T2R6
+ C5 --> T2R6
+ C6 --> T2R6
+ R4 --> T2R6
+ T2C5 --> T2R6
T2C1 --> T3C1
T2C3 --> T3C1
T2C4 --> T3C1
diff --git a/docs/production-beta-contract.md b/docs/production-beta-contract.md
index 6a5e96c..f4fa5d0 100644
--- a/docs/production-beta-contract.md
+++ b/docs/production-beta-contract.md
@@ -64,3 +64,9 @@ Network storage must not block boot. Mounts use lazy materialization, bounded ti
A physical device event must hand off to systemd through udev `SYSTEMD_WANTS`; udev must not run the ingest itself.
Before ingest starts, the job must prove the destination capability and confirm enough hot-storage capacity. If capacity is temporarily unavailable because the hot/cold conveyor is still flushing, the job records `waiting_for_capacity`, runs a drain command or waits for the drain timer, and continues when capacity becomes available. If the timeout expires, it exits as a temporary failure and leaves inspectable state.
+
+## Home Assistant MQTT Contract
+
+Home Assistant MQTT bridge patterns must be broker-optional during validation. Discovery, state, and control traffic are first represented as local outbox and inbox files so a doctor or unit test can prove topic names, payloads, and command bounds without network credentials.
+
+Control payloads must be narrow and explicitly accepted. The beta bridge accepts only tokenized entity names and `ON` or `OFF` commands until a deployment adds reviewed broker credentials, TLS, and command authorization.
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/README.md b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/README.md
new file mode 100644
index 0000000..57d4547
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/README.md
@@ -0,0 +1,63 @@
+# Home Assistant MQTT Bridge
+
+## Intent
+
+Publish Home Assistant MQTT discovery payloads, bridge local appliance state into MQTT-style topics, and consume bounded Home Assistant control commands without requiring a broker during validation.
+
+## Production beta contract
+
+Target platform is Debian/Raspberry Pi OS with systemd timers. The beta artifact is deliberately mockable: discovery, state, and command traffic are represented as files under `mqtt-outbox/` and `mqtt-control/` until a deployment supplies reviewed broker plumbing. Discovery uses a single Home Assistant MQTT device payload with status sensors, a restart button, and an enabled switch. The bridge only accepts tokenized entity names plus allowlisted `enabled` and `restart` control payloads by default.
+
+## When to use this
+
+Use this when an appliance should appear in Home Assistant through MQTT discovery and expose a narrow control surface that can be proven locally before connecting to a real broker.
+
+## When not to use this
+
+Do not use this when the appliance needs arbitrary Home Assistant entities, templated payloads, retained MQTT credentials in the repository, or unaudited command topics.
+
+## System shape
+
+`muster-ha-mqtt-bridge.timer` periodically starts `muster-ha-mqtt-bridge.service`. The service runs `scripts/ha-mqtt-bridge.sh --once --apply`, which emits one Home Assistant device discovery payload, publishes the current enabled state, and processes any pending command file from the control inbox.
+
+## Subpatterns
+
+- `C1.service-capsule`
+- `C2.persistent-tick`
+- `C5.failure-ratchet`
+- `C6.lifecycle-capsule`
+- `R4.state-ledger`
+- `T2C5.local-sidecar-bridge`
+
+## Files
+
+- `manifest.yaml` declares the pattern contract.
+- `units/muster-ha-mqtt-bridge.*` show the periodic bridge service.
+- `scripts/ha-mqtt-bridge.sh` implements mockable discovery, state publish, and control handling.
+- `scripts/rollback.sh` restores the previous published state.
+- `scripts/uninstall.sh` removes installed artifacts while preserving state by default.
+- `scripts/install.sh` and `scripts/doctor.sh` provide dry-run install and mock verification.
+
+## Installation
+
+Run `scripts/install.sh` without arguments first. It prints the service, timer, helper, and config copy plan. Use `MUSTER_ROOT=/tmp/root scripts/install.sh --apply` for staged verification, or `scripts/install.sh --apply` as root after configuring `/etc/muster/home-assistant-mqtt-bridge.env`.
+
+## Verification
+
+Run `scripts/doctor.sh`. It writes a mocked Home Assistant device discovery payload, publishes enabled state, processes `OFF` and restart control commands, and proves rollback restores the previous state.
+
+## Failure modes
+
+Malformed entity names, unsupported control payloads, missing previous state, missing installed files, and invalid unit files fail closed. The mock outbox preserves topic-to-file mappings in `topics.log` for operator inspection.
+
+## Rollback
+
+Run `scripts/rollback.sh --apply` with `MUSTER_ROOT` for a staged root or as root on the target host. Rollback restores the previous `state.json` snapshot and queues a replacement MQTT state payload.
+
+## Security notes
+
+Broker credentials are intentionally outside the repository. Keep command topics narrow, map each command to an explicit local action, and do not enable real broker publishing until the deployment has credential storage, TLS, and command authorization reviewed.
+
+## Future work
+
+Add a broker adapter with TLS-only defaults, support more entity classes, and emit state-ledger events for every discovery, state, control, and rollback action.
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/examples/minimal/README.md b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/examples/minimal/README.md
new file mode 100644
index 0000000..9f6350a
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/examples/minimal/README.md
@@ -0,0 +1,13 @@
+# Minimal T2R6 Home Assistant MQTT Bridge
+
+This sketch shows the smallest local artifact set for `T2R6.home-assistant-mqtt-bridge`.
+
+Start in mock mode:
+
+```sh
+patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/ha-mqtt-bridge.sh --once
+```
+
+The first run writes Home Assistant discovery and state payloads under a mock `mqtt-outbox/` directory. A Home Assistant command can be tested by writing `ON` or `OFF` to `mqtt-control/relay.cmd`, then running `scripts/ha-mqtt-bridge.sh --control`.
+
+Use `MUSTER_ROOT=/tmp/root scripts/install.sh --apply` for staged-root verification before copying the reviewed service and timer onto a host.
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/manifest.yaml b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/manifest.yaml
new file mode 100644
index 0000000..42510b7
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/manifest.yaml
@@ -0,0 +1,58 @@
+id: T2R6.home-assistant-mqtt-bridge
+name: Home Assistant MQTT Bridge
+tech_level: 2
+rarity: rare
+mrl: 5
+summary: >
+ Publish Home Assistant MQTT discovery payloads, bridge appliance state into MQTT topics, and accept bounded control commands through a mockable local inbox before a real broker is enabled.
+provides:
+ - home_assistant_mqtt_autodiscovery
+ - appliance_mqtt_telemetry
+ - scoped_mqtt_controls
+requires:
+ - C1.service-capsule
+ - C2.persistent-tick
+ - C5.failure-ratchet
+ - C6.lifecycle-capsule
+ - R4.state-ledger
+ - T2C5.local-sidecar-bridge
+subpatterns:
+ - C1.service-capsule
+ - C2.persistent-tick
+ - C5.failure-ratchet
+ - C6.lifecycle-capsule
+ - R4.state-ledger
+ - T2C5.local-sidecar-bridge
+composes_with:
+ - T2R4.device-triggered-conveyor
+ - T3R1.multi-resource-orchestrator
+lifecycle:
+ managed: true
+ install_modes:
+ - dry_run
+ - staged_root
+ - apply
+ doctor_modes:
+ - mock
+ - staged_root
+ rollback: artifact_generation
+ uninstall: artifacts_only
+ update: none
+artifacts:
+ units:
+ - units/muster-ha-mqtt-bridge.service
+ - units/muster-ha-mqtt-bridge.timer
+ scripts:
+ - scripts/ha-mqtt-bridge.sh
+ - scripts/install.sh
+ - scripts/doctor.sh
+ - scripts/rollback.sh
+ - scripts/uninstall.sh
+ tests:
+ - tests/test_manifest.py
+ examples:
+ - examples/minimal/README.md
+status:
+ implementation: usable
+ docs: reviewed
+ tests: reviewed
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/doctor.sh b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/doctor.sh
new file mode 100755
index 0000000..3d2edeb
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/doctor.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env sh
+set -eu
+
+pattern_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+test -f "$pattern_dir/manifest.yaml"
+test -f "$pattern_dir/README.md"
+test -f "$pattern_dir/units/muster-ha-mqtt-bridge.service"
+test -f "$pattern_dir/units/muster-ha-mqtt-bridge.timer"
+test -x "$pattern_dir/scripts/ha-mqtt-bridge.sh"
+test -x "$pattern_dir/scripts/install.sh"
+test -x "$pattern_dir/scripts/rollback.sh"
+test -x "$pattern_dir/scripts/uninstall.sh"
+
+if command -v systemd-analyze >/dev/null 2>&1; then
+ systemd-analyze verify "$pattern_dir/units/muster-ha-mqtt-bridge.service" "$pattern_dir/units/muster-ha-mqtt-bridge.timer"
+fi
+
+mock_root=$(mktemp -d "${TMPDIR:-/tmp}/muster-t2r6-doctor.XXXXXX")
+cleanup() {
+ rm -rf "$mock_root"
+}
+trap cleanup EXIT INT TERM
+
+MUSTER_MOCK_ROOT="$mock_root" "$pattern_dir/scripts/ha-mqtt-bridge.sh" --discover >/dev/null
+test -s "$mock_root/run/muster/home-assistant-mqtt-bridge/mqtt-outbox/homeassistant_device_muster_bridge_config.json"
+grep '"restart_service"' "$mock_root/run/muster/home-assistant-mqtt-bridge/mqtt-outbox/homeassistant_device_muster_bridge_config.json" >/dev/null
+grep '"enabled"' "$mock_root/run/muster/home-assistant-mqtt-bridge/mqtt-outbox/homeassistant_device_muster_bridge_config.json" >/dev/null
+
+MUSTER_MOCK_ROOT="$mock_root" "$pattern_dir/scripts/ha-mqtt-bridge.sh" --state enabled ON >/dev/null
+grep '"state":"ON"' "$mock_root/run/muster/home-assistant-mqtt-bridge/state.json" >/dev/null
+
+printf '%s\n' "OFF" > "$mock_root/run/muster/home-assistant-mqtt-bridge/mqtt-control/enabled.cmd"
+MUSTER_MOCK_ROOT="$mock_root" "$pattern_dir/scripts/ha-mqtt-bridge.sh" --control >/dev/null
+grep '"state":"OFF"' "$mock_root/run/muster/home-assistant-mqtt-bridge/state.json" >/dev/null
+test -f "$mock_root/run/muster/home-assistant-mqtt-bridge/mqtt-control/enabled.cmd.processed"
+
+printf '%s\n' "PRESS" > "$mock_root/run/muster/home-assistant-mqtt-bridge/mqtt-control/restart.cmd"
+MUSTER_MOCK_ROOT="$mock_root" "$pattern_dir/scripts/ha-mqtt-bridge.sh" --control >/dev/null
+grep '"control":"restart"' "$mock_root/run/muster/home-assistant-mqtt-bridge/control-result.json" >/dev/null
+
+MUSTER_MOCK_ROOT="$mock_root" "$pattern_dir/scripts/ha-mqtt-bridge.sh" --state enabled ON >/dev/null
+MUSTER_MOCK_ROOT="$mock_root" "$pattern_dir/scripts/ha-mqtt-bridge.sh" --state enabled OFF >/dev/null
+MUSTER_ROOT="$mock_root" "$pattern_dir/scripts/rollback.sh" --apply >/dev/null
+grep '"state":"ON"' "$mock_root/run/muster/home-assistant-mqtt-bridge/state.json" >/dev/null
+
+printf '%s\n' "ok: T2R6.home-assistant-mqtt-bridge"
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/ha-mqtt-bridge.sh b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/ha-mqtt-bridge.sh
new file mode 100755
index 0000000..7184df7
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/ha-mqtt-bridge.sh
@@ -0,0 +1,157 @@
+#!/usr/bin/env sh
+set -eu
+
+apply=0
+discover=0
+once=0
+control=0
+state_entity=
+state_value=
+
+usage() {
+ printf '%s\n' "Bridge local appliance state to Home Assistant MQTT payloads."
+ printf '%s\n' "Default mode is mock filesystem MQTT; use --apply for runtime paths."
+ printf '%s\n' "Modes: --discover, --state ENTITY STATE, --control, --once."
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --apply) apply=1; shift ;;
+ --discover) discover=1; shift ;;
+ --once) once=1; shift ;;
+ --control) control=1; shift ;;
+ --state)
+ shift
+ if [ "$#" -lt 2 ]; then
+ printf '%s\n' "--state requires ENTITY and STATE" >&2
+ exit 2
+ fi
+ state_entity=$1
+ state_value=$2
+ shift 2
+ ;;
+ -h|--help) usage; exit 0 ;;
+ *) printf '%s\n' "unknown argument: $1" >&2; exit 2 ;;
+ esac
+done
+
+if [ "$discover" -eq 0 ] && [ "$once" -eq 0 ] && [ "$control" -eq 0 ] && [ -z "$state_entity" ]; then
+ once=1
+fi
+
+root=${MUSTER_ROOT:-}
+if [ "$apply" -eq 1 ]; then
+ base_root=$root
+else
+ mock_root=${MUSTER_MOCK_ROOT:-${TMPDIR:-/tmp}/muster-ha-mqtt-bridge}
+ base_root=$mock_root
+fi
+
+state_dir=${STATE_DIR:-$base_root/run/muster/home-assistant-mqtt-bridge}
+outbox_dir=${MQTT_OUTBOX_DIR:-$state_dir/mqtt-outbox}
+control_dir=${MQTT_CONTROL_DIR:-$state_dir/mqtt-control}
+ledger_dir=${LEDGER_DIR:-$base_root/var/lib/muster/home-assistant-mqtt-bridge}
+node_id=${HA_NODE_ID:-muster_bridge}
+device_name=${HA_DEVICE_NAME:-Muster Home Assistant MQTT Bridge}
+discovery_prefix=${HA_DISCOVERY_PREFIX:-homeassistant}
+base_topic=${MQTT_BASE_TOPIC:-muster/home-assistant-mqtt-bridge}
+state_file="$state_dir/state.json"
+
+mkdir -p "$state_dir" "$outbox_dir" "$control_dir" "$ledger_dir"
+
+validate_token() {
+ case "$1" in
+ ""|*[!A-Za-z0-9_.:-]*)
+ printf '%s\n' "invalid token: $1" >&2
+ exit 2
+ ;;
+ esac
+}
+
+topic_file() {
+ printf '%s' "$1" | tr '/+' '__'
+}
+
+publish() {
+ topic=$1
+ payload=$2
+ file="$outbox_dir/$(topic_file "$topic").json"
+ printf '%s\n' "$payload" > "$file"
+ printf '%s\t%s\n' "$topic" "$file" >> "$outbox_dir/topics.log"
+}
+
+publish_discovery() {
+ discovery_topic="$discovery_prefix/device/$node_id/config"
+ availability_topic="$base_topic/availability"
+ state_topic="$base_topic/state"
+ enabled_state_topic="$base_topic/enabled/state"
+ enabled_command_topic="$base_topic/cmd/enabled/set"
+ restart_command_topic="$base_topic/cmd/restart"
+ payload=$(printf '{"device":{"identifiers":["%s"],"name":"%s"},"origin":{"name":"Muster Home Assistant MQTT Bridge","sw_version":"1"},"availability":{"topic":"%s"},"components":{"health":{"platform":"sensor","name":"Health","unique_id":"%s_health","state_topic":"%s","value_template":"{{ value_json.health }}"},"rip_state":{"platform":"sensor","name":"Rip State","unique_id":"%s_rip_state","state_topic":"%s","value_template":"{{ value_json.rip_state }}"},"conveyance_status":{"platform":"sensor","name":"Conveyance Status","unique_id":"%s_conveyance_status","state_topic":"%s","value_template":"{{ value_json.conveyance_status }}"},"restart_service":{"platform":"button","name":"Restart Service","unique_id":"%s_restart_service","command_topic":"%s","payload_press":"PRESS"},"enabled":{"platform":"switch","name":"Enabled","unique_id":"%s_enabled","state_topic":"%s","command_topic":"%s","payload_on":"ON","payload_off":"OFF"}}}' "$node_id" "$device_name" "$availability_topic" "$node_id" "$state_topic" "$node_id" "$state_topic" "$node_id" "$state_topic" "$node_id" "$restart_command_topic" "$node_id" "$enabled_state_topic" "$enabled_command_topic")
+ publish "$discovery_topic" "$payload"
+ printf '%s\n' "$discovery_topic" > "$ledger_dir/discovery-topic"
+}
+
+publish_state() {
+ entity=$1
+ value=$2
+ validate_token "$entity"
+ validate_token "$value"
+ topic="$base_topic/$entity/state"
+ if [ -f "$state_file" ]; then
+ cp "$state_file" "$ledger_dir/last-state.json"
+ printf '%s\n' "$topic" > "$ledger_dir/last-topic"
+ fi
+ payload=$(printf '{"entity":"%s","state":"%s","state_topic":"%s","source":"mockable-mqtt"}' "$entity" "$value" "$topic")
+ printf '%s\n' "$payload" > "$state_file"
+ publish "$topic" "$payload"
+}
+
+process_control() {
+ found=0
+ for command_file in "$control_dir"/*.cmd; do
+ [ -e "$command_file" ] || continue
+ found=1
+ entity=$(basename "$command_file" .cmd)
+ command=$(sed -n '1p' "$command_file" | tr -d '\r\n')
+ validate_token "$entity"
+ case "$entity:$command" in
+ enabled:ON|enabled:OFF)
+ publish_state "$entity" "$command"
+ printf '{"control":"enabled","state":"%s","result":"accepted"}\n' "$command" > "$state_dir/control-result.json"
+ publish "$base_topic/control/result" "$(cat "$state_dir/control-result.json")"
+ ;;
+ restart:PRESS|restart:RESTART)
+ printf '{"control":"restart","result":"accepted","action":"restart_service"}\n' > "$state_dir/control-result.json"
+ publish "$base_topic/control/result" "$(cat "$state_dir/control-result.json")"
+ ;;
+ *)
+ printf '%s\n' "rejected command for $entity: $command" >&2
+ mv "$command_file" "$command_file.rejected"
+ exit 1
+ ;;
+ esac
+ mv "$command_file" "$command_file.processed"
+ done
+ if [ "$found" -eq 0 ]; then
+ printf '%s\n' "ok: no pending control commands"
+ fi
+}
+
+if [ "$discover" -eq 1 ] || [ "$once" -eq 1 ]; then
+ publish_discovery
+fi
+
+if [ "$once" -eq 1 ] && [ ! -f "$state_file" ]; then
+ publish_state relay OFF
+fi
+
+if [ -n "$state_entity" ]; then
+ publish_state "$state_entity" "$state_value"
+fi
+
+if [ "$control" -eq 1 ] || [ "$once" -eq 1 ]; then
+ process_control
+fi
+
+printf '%s\n' "ok: Home Assistant MQTT bridge wrote $outbox_dir"
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/install.sh b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/install.sh
new file mode 100755
index 0000000..d4dbe9d
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/install.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env sh
+set -eu
+
+root=${MUSTER_ROOT:-}
+apply=0
+unit_dir="$root/etc/systemd/system"
+lib_dir="$root/usr/local/lib/muster/home-assistant-mqtt-bridge"
+config_dir="$root/etc/muster"
+config_file="$config_dir/home-assistant-mqtt-bridge.env"
+
+usage() {
+ printf '%s\n' "Install T2R6.home-assistant-mqtt-bridge artifacts."
+ printf '%s\n' "Default mode is dry-run; use --apply to copy files."
+ printf '%s\n' "Set MUSTER_ROOT to perform a staged-root install without touching the host."
+}
+
+for arg in "$@"; do
+ case "$arg" in
+ --apply) apply=1 ;;
+ -h|--help) usage; exit 0 ;;
+ *) printf '%s\n' "unknown argument: $arg" >&2; exit 2 ;;
+ esac
+done
+
+pattern_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+
+run() {
+ if [ "$apply" -eq 1 ]; then
+ "$@"
+ else
+ printf 'dry-run:'
+ printf ' %s' "$@"
+ printf '\n'
+ fi
+}
+
+if [ "$apply" -eq 1 ] && [ -z "$root" ] && [ "$(id -u)" -ne 0 ]; then
+ printf '%s\n' "--apply requires root unless MUSTER_ROOT is set." >&2
+ exit 1
+fi
+
+run install -d -m 0755 "$unit_dir" "$lib_dir" "$config_dir"
+run install -m 0644 "$pattern_dir/units/muster-ha-mqtt-bridge.service" "$unit_dir/muster-ha-mqtt-bridge.service"
+run install -m 0644 "$pattern_dir/units/muster-ha-mqtt-bridge.timer" "$unit_dir/muster-ha-mqtt-bridge.timer"
+run install -m 0755 "$pattern_dir/scripts/ha-mqtt-bridge.sh" "$lib_dir/ha-mqtt-bridge.sh"
+run install -m 0755 "$pattern_dir/scripts/rollback.sh" "$lib_dir/rollback.sh"
+run install -m 0755 "$pattern_dir/scripts/uninstall.sh" "$lib_dir/uninstall.sh"
+
+if [ "$apply" -eq 1 ]; then
+ if [ ! -f "$config_file" ]; then
+ {
+ printf 'HA_NODE_ID=muster_bridge\n'
+ printf 'HA_DEVICE_NAME=Muster Home Assistant MQTT Bridge\n'
+ printf 'HA_DISCOVERY_PREFIX=homeassistant\n'
+ printf 'MQTT_BASE_TOPIC=muster/home-assistant-mqtt-bridge\n'
+ printf 'MQTT_OUTBOX_DIR=\n'
+ printf 'MQTT_CONTROL_DIR=\n'
+ } > "$config_file"
+ chmod 0644 "$config_file"
+ fi
+else
+ printf '%s\n' "dry-run: preserve or create $config_file"
+fi
+
+printf '%s\n' "install plan complete for T2R6.home-assistant-mqtt-bridge"
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/rollback.sh b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/rollback.sh
new file mode 100755
index 0000000..3cfb8bf
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/rollback.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env sh
+set -eu
+
+root=${MUSTER_ROOT:-}
+apply=0
+state_dir="$root/run/muster/home-assistant-mqtt-bridge"
+outbox_dir=${MQTT_OUTBOX_DIR:-$state_dir/mqtt-outbox}
+ledger_dir=${LEDGER_DIR:-$root/var/lib/muster/home-assistant-mqtt-bridge}
+state_file="$state_dir/state.json"
+last_state="$ledger_dir/last-state.json"
+last_topic="$ledger_dir/last-topic"
+
+usage() {
+ printf '%s\n' "Rollback T2R6.home-assistant-mqtt-bridge to the previous published state."
+ printf '%s\n' "Default mode is dry-run; use --apply to restore state and queue a replacement MQTT payload."
+ printf '%s\n' "Set MUSTER_ROOT for staged-root rollback."
+}
+
+for arg in "$@"; do
+ case "$arg" in
+ --apply) apply=1 ;;
+ -h|--help) usage; exit 0 ;;
+ *) printf '%s\n' "unknown argument: $arg" >&2; exit 2 ;;
+ esac
+done
+
+if [ ! -f "$last_state" ]; then
+ printf '%s\n' "no previous bridge state recorded: $last_state" >&2
+ exit 1
+fi
+
+if [ "$apply" -eq 1 ] && [ -z "$root" ] && [ "$(id -u)" -ne 0 ]; then
+ printf '%s\n' "--apply requires root unless MUSTER_ROOT is set." >&2
+ exit 1
+fi
+
+topic_file() {
+ printf '%s' "$1" | tr '/+' '__'
+}
+
+topic=$(cat "$last_topic" 2>/dev/null || printf '%s\n' "muster/home-assistant-mqtt-bridge/enabled/state")
+
+if [ "$apply" -eq 1 ]; then
+ mkdir -p "$state_dir" "$outbox_dir"
+ cp "$last_state" "$state_file"
+ cp "$last_state" "$outbox_dir/$(topic_file "$topic").json"
+ printf '%s\t%s\n' "$topic" "$outbox_dir/$(topic_file "$topic").json" >> "$outbox_dir/topics.log"
+else
+ printf '%s\n' "dry-run: restore $state_file from $last_state"
+fi
+
+printf '%s\n' "rollback plan complete for T2R6.home-assistant-mqtt-bridge"
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/uninstall.sh b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/uninstall.sh
new file mode 100755
index 0000000..010723d
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/scripts/uninstall.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env sh
+set -eu
+
+root=${MUSTER_ROOT:-}
+apply=0
+purge_state=0
+unit_dir="$root/etc/systemd/system"
+lib_dir="$root/usr/local/lib/muster/home-assistant-mqtt-bridge"
+config_file="$root/etc/muster/home-assistant-mqtt-bridge.env"
+state_dir="$root/run/muster/home-assistant-mqtt-bridge"
+ledger_dir="$root/var/lib/muster/home-assistant-mqtt-bridge"
+
+usage() {
+ printf '%s\n' "Cleanly uninstall T2R6.home-assistant-mqtt-bridge artifacts."
+ printf '%s\n' "Default mode is dry-run; use --apply to remove bridge units and scripts."
+ printf '%s\n' "Config, state, and ledgers are preserved unless --purge-state is explicit."
+}
+
+for arg in "$@"; do
+ case "$arg" in
+ --apply) apply=1 ;;
+ --purge-state) purge_state=1 ;;
+ -h|--help) usage; exit 0 ;;
+ *) printf '%s\n' "unknown argument: $arg" >&2; exit 2 ;;
+ esac
+done
+
+if [ "$apply" -eq 1 ] && [ -z "$root" ] && [ "$(id -u)" -ne 0 ]; then
+ printf '%s\n' "--apply requires root unless MUSTER_ROOT is set." >&2
+ exit 1
+fi
+
+remove_path() {
+ if [ "$apply" -eq 1 ]; then
+ rm -rf "$1"
+ else
+ printf '%s\n' "dry-run: remove $1"
+ fi
+}
+
+remove_path "$unit_dir/muster-ha-mqtt-bridge.service"
+remove_path "$unit_dir/muster-ha-mqtt-bridge.timer"
+remove_path "$lib_dir"
+
+if [ "$purge_state" -eq 1 ]; then
+ remove_path "$config_file"
+ remove_path "$state_dir"
+ remove_path "$ledger_dir"
+else
+ printf '%s\n' "preserve bridge config, runtime state, and lifecycle ledger"
+fi
+
+printf '%s\n' "uninstall plan complete for T2R6.home-assistant-mqtt-bridge"
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/tests/test_manifest.py b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/tests/test_manifest.py
new file mode 100644
index 0000000..6f8bdb8
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/tests/test_manifest.py
@@ -0,0 +1,7 @@
+from pathlib import Path
+
+
+def test_manifest_and_readme_exist() -> None:
+ root = Path(__file__).resolve().parents[1]
+ assert (root / "manifest.yaml").exists()
+ assert (root / "README.md").exists()
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/units/muster-ha-mqtt-bridge.service b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/units/muster-ha-mqtt-bridge.service
new file mode 100644
index 0000000..9f3ad9a
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/units/muster-ha-mqtt-bridge.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=Muster Home Assistant MQTT bridge
+Wants=network-online.target
+After=network-online.target
+
+[Service]
+Type=oneshot
+User=muster
+Group=muster
+EnvironmentFile=-/etc/muster/home-assistant-mqtt-bridge.env
+ExecStart=/usr/local/lib/muster/home-assistant-mqtt-bridge/ha-mqtt-bridge.sh --once --apply
+StateDirectory=muster/home-assistant-mqtt-bridge
+RuntimeDirectory=muster/home-assistant-mqtt-bridge
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/run/muster/home-assistant-mqtt-bridge /var/lib/muster/home-assistant-mqtt-bridge
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/units/muster-ha-mqtt-bridge.timer b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/units/muster-ha-mqtt-bridge.timer
new file mode 100644
index 0000000..c107bab
--- /dev/null
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/units/muster-ha-mqtt-bridge.timer
@@ -0,0 +1,12 @@
+[Unit]
+Description=Muster Home Assistant MQTT bridge timer
+
+[Timer]
+OnBootSec=45s
+OnUnitActiveSec=30s
+RandomizedDelaySec=5s
+Persistent=true
+Unit=muster-ha-mqtt-bridge.service
+
+[Install]
+WantedBy=timers.target
diff --git a/tests/test_completion.py b/tests/test_completion.py
index a11fbf7..19527da 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -3,6 +3,7 @@
from tools.completion import (
DEVICE_TRIGGERED_CONVEYOR_CHAIN,
FLAGSHIP_CHAIN,
+ HOME_ASSISTANT_CHAIN,
LIFECYCLE_CHAIN,
PRODUCTION_BETA_STATUSES,
STABLE_DEVICE_CONVEYOR_CHAIN,
@@ -17,7 +18,7 @@
class CompletionTests(unittest.TestCase):
def test_overall_completion_reflects_flagship_status(self) -> None:
patterns = validate_all()
- self.assertEqual(len(patterns), 33)
+ self.assertEqual(len(patterns), 34)
self.assertEqual(overall_percent(patterns), 75.5)
def test_grouped_completion_scores(self) -> None:
@@ -26,7 +27,7 @@ def test_grouped_completion_scores(self) -> None:
self.assertEqual(groups[(1, "rare")], 100.0)
self.assertEqual(groups[(1, "mythic")], 100.0)
self.assertEqual(groups[(2, "common")], 68.0)
- self.assertEqual(groups[(2, "rare")], 55.1)
+ self.assertEqual(groups[(2, "rare")], 58.7)
self.assertEqual(groups[(3, "common")], 76.7)
def test_flagship_rows_are_production_beta(self) -> None:
@@ -59,6 +60,17 @@ def test_lifecycle_rows_are_production_beta(self) -> None:
)
self.assertIn(row.percent, {76.7, 100.0})
+ def test_home_assistant_rows_are_production_beta(self) -> None:
+ rows = {row.pattern_id: row for row in completion_rows(validate_all())}
+ self.assertEqual(set(rows).intersection(HOME_ASSISTANT_CHAIN), HOME_ASSISTANT_CHAIN)
+ for pattern_id in HOME_ASSISTANT_CHAIN:
+ row = rows[pattern_id]
+ self.assertIn(
+ {"implementation": row.implementation, "docs": row.docs, "tests": row.tests},
+ PRODUCTION_BETA_STATUSES,
+ )
+ self.assertEqual(row.percent, 76.7)
+
def test_all_tech_1_rows_are_complete(self) -> None:
rows = completion_rows(validate_all())
for row in rows:
diff --git a/tests/test_home_assistant_mqtt_bridge.py b/tests/test_home_assistant_mqtt_bridge.py
new file mode 100644
index 0000000..2733b66
--- /dev/null
+++ b/tests/test_home_assistant_mqtt_bridge.py
@@ -0,0 +1,87 @@
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+T2R6 = ROOT / "patterns/t2/rare/T2R6.home-assistant-mqtt-bridge"
+
+
+def run_script(script: Path, *args: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
+ full_env = os.environ.copy()
+ full_env.update(env)
+ return subprocess.run([str(script), *args], cwd=ROOT, env=full_env, text=True, capture_output=True, check=True)
+
+
+class HomeAssistantMqttBridgeTests(unittest.TestCase):
+ def test_discovery_state_and_control_are_mockable(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ env = {"MUSTER_MOCK_ROOT": str(root)}
+ script = T2R6 / "scripts/ha-mqtt-bridge.sh"
+
+ run_script(script, "--discover", env=env)
+ outbox = root / "run/muster/home-assistant-mqtt-bridge/mqtt-outbox"
+ discovery = outbox / "homeassistant_device_muster_bridge_config.json"
+ self.assertTrue(discovery.exists())
+ discovery_text = discovery.read_text()
+ self.assertIn('"restart_service"', discovery_text)
+ self.assertIn('"enabled"', discovery_text)
+ self.assertIn("muster/home-assistant-mqtt-bridge/cmd/restart", discovery_text)
+ self.assertIn("muster/home-assistant-mqtt-bridge/cmd/enabled/set", discovery_text)
+
+ run_script(script, "--state", "enabled", "ON", env=env)
+ state = root / "run/muster/home-assistant-mqtt-bridge/state.json"
+ self.assertIn('"state":"ON"', state.read_text())
+
+ control_dir = root / "run/muster/home-assistant-mqtt-bridge/mqtt-control"
+ (control_dir / "enabled.cmd").write_text("OFF\n")
+ run_script(script, "--control", env=env)
+ self.assertIn('"state":"OFF"', state.read_text())
+ self.assertTrue((control_dir / "enabled.cmd.processed").exists())
+
+ (control_dir / "restart.cmd").write_text("PRESS\n")
+ run_script(script, "--control", env=env)
+ result = root / "run/muster/home-assistant-mqtt-bridge/control-result.json"
+ self.assertIn('"control":"restart"', result.read_text())
+ self.assertTrue((control_dir / "restart.cmd.processed").exists())
+
+ def test_rollback_restores_previous_state_payload(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ env = {"MUSTER_MOCK_ROOT": str(root)}
+ script = T2R6 / "scripts/ha-mqtt-bridge.sh"
+
+ run_script(script, "--state", "enabled", "ON", env=env)
+ run_script(script, "--state", "enabled", "OFF", env=env)
+ run_script(T2R6 / "scripts/rollback.sh", "--apply", env={"MUSTER_ROOT": str(root)})
+
+ state = root / "run/muster/home-assistant-mqtt-bridge/state.json"
+ self.assertIn('"state":"ON"', state.read_text())
+ outbox_state = root / "run/muster/home-assistant-mqtt-bridge/mqtt-outbox/muster_home-assistant-mqtt-bridge_enabled_state.json"
+ self.assertIn('"state":"ON"', outbox_state.read_text())
+
+ def test_install_and_uninstall_support_staged_roots(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ env = {"MUSTER_ROOT": str(root)}
+ run_script(T2R6 / "scripts/install.sh", "--apply", env=env)
+
+ self.assertTrue((root / "etc/systemd/system/muster-ha-mqtt-bridge.service").exists())
+ self.assertTrue((root / "usr/local/lib/muster/home-assistant-mqtt-bridge/ha-mqtt-bridge.sh").exists())
+ config = root / "etc/muster/home-assistant-mqtt-bridge.env"
+ config.write_text(config.read_text() + "USER_SETTING=kept\n")
+
+ run_script(T2R6 / "scripts/install.sh", "--apply", env=env)
+ self.assertIn("USER_SETTING=kept", config.read_text())
+
+ run_script(T2R6 / "scripts/uninstall.sh", "--apply", env=env)
+ self.assertFalse((root / "etc/systemd/system/muster-ha-mqtt-bridge.service").exists())
+ self.assertFalse((root / "usr/local/lib/muster/home-assistant-mqtt-bridge").exists())
+ self.assertTrue(config.exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_pattern_inventory.py b/tests/test_pattern_inventory.py
index 78b26c9..67afc9b 100644
--- a/tests/test_pattern_inventory.py
+++ b/tests/test_pattern_inventory.py
@@ -16,6 +16,7 @@ def test_flagship_pattern_exists(self) -> None:
self.assertIn("T2R4.device-triggered-conveyor", ids)
self.assertIn("C6.lifecycle-capsule", ids)
self.assertIn("T2R5.signed-update-rail", ids)
+ self.assertIn("T2R6.home-assistant-mqtt-bridge", ids)
if __name__ == "__main__":
diff --git a/tools/check_production_beta.py b/tools/check_production_beta.py
index fa45451..3ec9df0 100644
--- a/tools/check_production_beta.py
+++ b/tools/check_production_beta.py
@@ -132,6 +132,16 @@
"scripts/uninstall.sh",
"examples/minimal/README.md",
},
+ "T2R6.home-assistant-mqtt-bridge": {
+ "units/muster-ha-mqtt-bridge.service",
+ "units/muster-ha-mqtt-bridge.timer",
+ "scripts/ha-mqtt-bridge.sh",
+ "scripts/install.sh",
+ "scripts/doctor.sh",
+ "scripts/rollback.sh",
+ "scripts/uninstall.sh",
+ "examples/minimal/README.md",
+ },
}
diff --git a/tools/completion.py b/tools/completion.py
index 7b0b8ba..884d982 100644
--- a/tools/completion.py
+++ b/tools/completion.py
@@ -43,7 +43,11 @@
"T2R5.signed-update-rail",
}
-PRODUCTION_BETA_PATTERNS = FLAGSHIP_CHAIN | DEVICE_TRIGGERED_CONVEYOR_CHAIN | LIFECYCLE_CHAIN
+HOME_ASSISTANT_CHAIN = {
+ "T2R6.home-assistant-mqtt-bridge",
+}
+
+PRODUCTION_BETA_PATTERNS = FLAGSHIP_CHAIN | DEVICE_TRIGGERED_CONVEYOR_CHAIN | LIFECYCLE_CHAIN | HOME_ASSISTANT_CHAIN
PRODUCTION_BETA_STATUS = {
"implementation": "usable",
diff --git a/tools/render_completion.py b/tools/render_completion.py
index 054447a..c694cf5 100644
--- a/tools/render_completion.py
+++ b/tools/render_completion.py
@@ -5,6 +5,7 @@
from completion import (
DEVICE_TRIGGERED_CONVEYOR_CHAIN,
FLAGSHIP_CHAIN,
+ HOME_ASSISTANT_CHAIN,
LIFECYCLE_CHAIN,
STABLE_DEVICE_CONVEYOR_CHAIN,
completion_rows,
@@ -87,6 +88,21 @@ def render() -> str:
status = f"{row.implementation}/{row.docs}/{row.tests}"
lines.append(f"| `{row.pattern_id}` | {row.name} | {status} | {row.percent}% |")
+ lines.extend(
+ [
+ "",
+ "## Production-Beta Home Assistant Chain",
+ "",
+ "| ID | Pattern | Status | Completion |",
+ "| --- | --- | --- | ---: |",
+ ]
+ )
+ for row in rows:
+ if row.pattern_id not in HOME_ASSISTANT_CHAIN:
+ continue
+ status = f"{row.implementation}/{row.docs}/{row.tests}"
+ lines.append(f"| `{row.pattern_id}` | {row.name} | {status} | {row.percent}% |")
+
lines.extend(
[
"",
From ca619483b699cede05a404042b107ddc0558675a Mon Sep 17 00:00:00 2001
From: Alexander Templeton <10369436+azide0x37@users.noreply.github.com>
Date: Fri, 15 May 2026 11:10:51 -0500
Subject: [PATCH 2/6] Fix stable service capsule doctor in CI
---
.../C1.service-capsule/scripts/doctor.sh | 16 ++++++++---
tests/test_stable_tech1_patterns.py | 27 +++++++++++++++++++
2 files changed, 40 insertions(+), 3 deletions(-)
diff --git a/patterns/t1/common/C1.service-capsule/scripts/doctor.sh b/patterns/t1/common/C1.service-capsule/scripts/doctor.sh
index 3470c1b..31a7b26 100755
--- a/patterns/t1/common/C1.service-capsule/scripts/doctor.sh
+++ b/patterns/t1/common/C1.service-capsule/scripts/doctor.sh
@@ -16,8 +16,20 @@ test -f "$pattern_dir/README.md"
test -f "$pattern_dir/units/example.service"
test -x "$pattern_dir/scripts/service-capsule-run.sh"
+mock_root=${MUSTER_MOCK_ROOT:-${TMPDIR:-/tmp}/muster-c1-doctor}
+mkdir -p "$mock_root/etc/muster" "$mock_root/run/muster" "$mock_root/var/lib/muster/service-capsule"
+
if command -v systemd-analyze >/dev/null 2>&1; then
- systemd-analyze verify "$pattern_dir/units/example.service"
+ verify_unit="$mock_root/example.service"
+ awk \
+ -v readme="$pattern_dir/README.md" \
+ -v runner="$pattern_dir/scripts/service-capsule-run.sh" \
+ '
+ /^Documentation=/ { print "Documentation=file:" readme; next }
+ /^ExecStart=/ { print "ExecStart=" runner " --apply"; next }
+ { print }
+ ' "$pattern_dir/units/example.service" > "$verify_unit"
+ systemd-analyze verify "$verify_unit"
elif [ "$strict" -eq 1 ]; then
printf '%s\n' "systemd-analyze is required in --strict mode" >&2
exit 1
@@ -25,8 +37,6 @@ else
printf '%s\n' "warn: systemd-analyze not found; skipped unit verification"
fi
-mock_root=${MUSTER_MOCK_ROOT:-${TMPDIR:-/tmp}/muster-c1-doctor}
-mkdir -p "$mock_root/etc/muster" "$mock_root/run/muster" "$mock_root/var/lib/muster/service-capsule"
MUSTER_MOCK_ROOT="$mock_root" "$pattern_dir/scripts/service-capsule-run.sh" >/dev/null
test -d "$mock_root/run/muster"
test -s "$mock_root/run/muster/service-capsule.json"
diff --git a/tests/test_stable_tech1_patterns.py b/tests/test_stable_tech1_patterns.py
index 3388106..5a8e010 100644
--- a/tests/test_stable_tech1_patterns.py
+++ b/tests/test_stable_tech1_patterns.py
@@ -28,6 +28,33 @@ class StableTech1PatternTests(unittest.TestCase):
def test_stable_tech1_gate_passes(self) -> None:
check_stable_tech1(validate_all())
+ def test_service_capsule_doctor_verifies_staged_unit(self) -> None:
+ pattern = ROOT / "patterns/t1/common/C1.service-capsule"
+ doctor = pattern / "scripts/doctor.sh"
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ bin_dir = root / "bin"
+ bin_dir.mkdir()
+ fake_systemd_analyze = bin_dir / "systemd-analyze"
+ fake_systemd_analyze.write_text(
+ "#!/usr/bin/env sh\n"
+ "set -eu\n"
+ "test \"$1\" = verify\n"
+ "unit=\"$2\"\n"
+ "! grep -q 'Documentation=file:README.md' \"$unit\"\n"
+ "! grep -q '/usr/local/lib/muster/service-capsule-run.sh' \"$unit\"\n"
+ "grep -q 'Documentation=file:/.*/README.md' \"$unit\"\n"
+ "grep -q '/scripts/service-capsule-run.sh --apply' \"$unit\"\n"
+ )
+ fake_systemd_analyze.chmod(0o755)
+ run_script(
+ doctor,
+ env={
+ "MUSTER_MOCK_ROOT": str(root / "mock"),
+ "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
+ },
+ )
+
def test_dropfolder_processes_and_records_failures(self) -> None:
pattern = ROOT / "patterns/t1/common/C3.dropfolder-trigger"
script = pattern / "scripts/dropfolder-process.sh"
From 3b0df6eb8a1f90dfee2374f90b55d3b29d7742fa Mon Sep 17 00:00:00 2001
From: Alexander Templeton <10369436+azide0x37@users.noreply.github.com>
Date: Fri, 15 May 2026 15:56:39 -0500
Subject: [PATCH 3/6] Document T2R6 telemetry budget
---
patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/README.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/README.md b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/README.md
index 57d4547..e3f1dfa 100644
--- a/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/README.md
+++ b/patterns/t2/rare/T2R6.home-assistant-mqtt-bridge/README.md
@@ -58,6 +58,10 @@ Run `scripts/rollback.sh --apply` with `MUSTER_ROOT` for a staged root or as roo
Broker credentials are intentionally outside the repository. Keep command topics narrow, map each command to an explicit local action, and do not enable real broker publishing until the deployment has credential storage, TLS, and command authorization reviewed.
+## Telemetry budget
+
+Telemetry collectors must be bounded, cheap, and stale-aware. Do not recursively scan large remote mounts, slow NAS trees, or unbounded directories during a periodic bridge tick. Publish counts, sampled entries, filesystem accounting, or precomputed state-ledger facts instead, and make partial snapshots prefer the currently active appliance lifecycle over older retained handoff state.
+
## Future work
Add a broker adapter with TLS-only defaults, support more entity classes, and emit state-ledger events for every discovery, state, control, and rollback action.
From 20c60b145ce3869c021b17e50afe6a4ee4ab3af9 Mon Sep 17 00:00:00 2001
From: Alexander Templeton <10369436+azide0x37@users.noreply.github.com>
Date: Fri, 15 May 2026 15:59:22 -0500
Subject: [PATCH 4/6] Fix stable persistent tick doctor in CI
---
.../C2.persistent-tick/scripts/doctor.sh | 23 ++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/patterns/t1/common/C2.persistent-tick/scripts/doctor.sh b/patterns/t1/common/C2.persistent-tick/scripts/doctor.sh
index a54fd3e..4212537 100755
--- a/patterns/t1/common/C2.persistent-tick/scripts/doctor.sh
+++ b/patterns/t1/common/C2.persistent-tick/scripts/doctor.sh
@@ -17,8 +17,27 @@ test -f "$pattern_dir/units/example.service"
test -f "$pattern_dir/units/example.timer"
test -x "$pattern_dir/scripts/persistent-tick-run.sh"
+mock_root=${MUSTER_MOCK_ROOT:-${TMPDIR:-/tmp}/muster-c2-doctor}
+mkdir -p "$mock_root/run/muster" "$mock_root/var/lib/muster/persistent-tick"
+
if command -v systemd-analyze >/dev/null 2>&1; then
- systemd-analyze verify "$pattern_dir/units/example.service" "$pattern_dir/units/example.timer"
+ verify_service="$mock_root/example.service"
+ verify_timer="$mock_root/example.timer"
+ awk \
+ -v readme="$pattern_dir/README.md" \
+ -v runner="$pattern_dir/scripts/persistent-tick-run.sh" \
+ '
+ /^Documentation=/ { print "Documentation=file:" readme; next }
+ /^ExecStart=/ { print "ExecStart=" runner " --apply"; next }
+ { print }
+ ' "$pattern_dir/units/example.service" > "$verify_service"
+ awk \
+ -v readme="$pattern_dir/README.md" \
+ '
+ /^Documentation=/ { print "Documentation=file:" readme; next }
+ { print }
+ ' "$pattern_dir/units/example.timer" > "$verify_timer"
+ systemd-analyze verify "$verify_service" "$verify_timer"
elif [ "$strict" -eq 1 ]; then
printf '%s\n' "systemd-analyze is required in --strict mode" >&2
exit 1
@@ -26,8 +45,6 @@ else
printf '%s\n' "warn: systemd-analyze not found; skipped unit verification"
fi
-mock_root=${MUSTER_MOCK_ROOT:-${TMPDIR:-/tmp}/muster-c2-doctor}
-mkdir -p "$mock_root/run/muster" "$mock_root/var/lib/muster/persistent-tick"
MUSTER_MOCK_ROOT="$mock_root" "$pattern_dir/scripts/persistent-tick-run.sh" >/dev/null
test -d "$mock_root/var/lib/muster/persistent-tick"
test -s "$mock_root/run/muster/persistent-tick.json"
From e0b2e5e0b3a3215bb0c1ac0fef3b1d5495828d10 Mon Sep 17 00:00:00 2001
From: Alexander Templeton <10369436+azide0x37@users.noreply.github.com>
Date: Fri, 15 May 2026 16:01:58 -0500
Subject: [PATCH 5/6] Verify stable units from staged paths
---
tools/check_stable_tech1.py | 59 +++++++++++++++++++++++++++++++++++++
1 file changed, 59 insertions(+)
diff --git a/tools/check_stable_tech1.py b/tools/check_stable_tech1.py
index 198b853..21dcf91 100644
--- a/tools/check_stable_tech1.py
+++ b/tools/check_stable_tech1.py
@@ -2,6 +2,8 @@
import os
from pathlib import Path
+import re
+import shutil
import subprocess
import tempfile
@@ -47,6 +49,55 @@ def _check_scripts_executable(pattern: Pattern) -> None:
raise PatternError(f"{path}: stable Tech 1 script must be executable")
+def _rewritten_unit(pattern: Pattern, unit: Path) -> str:
+ readme = pattern.path.parent / "README.md"
+ script_dir = pattern.path.parent / "scripts"
+ lines: list[str] = []
+ for line in unit.read_text().splitlines():
+ if line.startswith("Documentation="):
+ lines.append(f"Documentation=file:{readme}")
+ continue
+ if line.startswith("ExecStart=/usr/local/lib/muster/"):
+ match = re.match(r"ExecStart=/usr/local/lib/muster/([^ ]+)(.*)", line)
+ if match:
+ script = script_dir / match.group(1)
+ if script.exists():
+ lines.append(f"ExecStart={script}{match.group(2)}")
+ continue
+ lines.append(line)
+ return "\n".join(lines) + "\n"
+
+
+def _verify_units(pattern: Pattern, tmp: Path) -> None:
+ if shutil.which("systemd-analyze") is None:
+ return
+
+ units: list[Path] = []
+ for relpath in pattern.data["artifacts"].get("units", []):
+ unit = pattern.path.parent / relpath
+ if unit.suffix in {".automount", ".mount", ".path", ".service", ".socket", ".timer"}:
+ units.append(unit)
+ if not units:
+ return
+
+ verify_units: list[str] = []
+ for unit in units:
+ rewritten = tmp / unit.name
+ rewritten.write_text(_rewritten_unit(pattern, unit))
+ verify_units.append(str(rewritten))
+
+ result = subprocess.run(
+ ["systemd-analyze", "verify", *verify_units],
+ cwd=ROOT,
+ text=True,
+ capture_output=True,
+ timeout=20,
+ )
+ if result.returncode != 0:
+ output = (result.stdout + result.stderr).strip()
+ raise PatternError(f"{pattern.path}: stable Tech 1 unit verification failed: {output}")
+
+
def _run_doctor(pattern: Pattern) -> None:
doctor = pattern.path.parent / "scripts" / "doctor.sh"
if not doctor.exists():
@@ -55,8 +106,16 @@ def _run_doctor(pattern: Pattern) -> None:
raise PatternError(f"{doctor}: stable Tech 1 doctor must be executable")
with tempfile.TemporaryDirectory(prefix=f"muster-{pattern.id}-") as tmp:
+ tmp_path = Path(tmp)
+ _verify_units(pattern, tmp_path)
+ fake_bin = tmp_path / "bin"
+ fake_bin.mkdir()
+ fake_systemd_analyze = fake_bin / "systemd-analyze"
+ fake_systemd_analyze.write_text("#!/usr/bin/env sh\nexit 0\n")
+ fake_systemd_analyze.chmod(0o755)
env = os.environ.copy()
env["MUSTER_MOCK_ROOT"] = tmp
+ env["PATH"] = f"{fake_bin}{os.pathsep}{env.get('PATH', '')}"
result = subprocess.run(
[str(doctor)],
cwd=ROOT,
From e374744522c84bb157c76dbe4c3c52460e52b481 Mon Sep 17 00:00:00 2001
From: Alexander Templeton <10369436+azide0x37@users.noreply.github.com>
Date: Fri, 15 May 2026 16:03:42 -0500
Subject: [PATCH 6/6] Resolve lifecycle unit staging paths
---
tools/check_stable_tech1.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tools/check_stable_tech1.py b/tools/check_stable_tech1.py
index 21dcf91..986a171 100644
--- a/tools/check_stable_tech1.py
+++ b/tools/check_stable_tech1.py
@@ -57,8 +57,8 @@ def _rewritten_unit(pattern: Pattern, unit: Path) -> str:
if line.startswith("Documentation="):
lines.append(f"Documentation=file:{readme}")
continue
- if line.startswith("ExecStart=/usr/local/lib/muster/"):
- match = re.match(r"ExecStart=/usr/local/lib/muster/([^ ]+)(.*)", line)
+ if line.startswith("ExecStart=/"):
+ match = re.match(r"ExecStart=(?:[^ ]*/)?([^/ ]+)(.*)", line)
if match:
script = script_dir / match.group(1)
if script.exists():