Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 e9d0a18f93 fix(otbr): start thread-migrate with no nodes
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The first node to migrate is the Garderobe motion sensor, which first
has to be back on Home Assistant's fabric.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185Qb5YGpVVU2fVua5WqACW
2026-11-08 13:03:57 +01:00
claude-botandClaude Opus 5.5 4bb040d5a7 feat(otbr): migrate Matter nodes onto otbr's Thread network
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
A Job moves the Matter-over-Thread nodes in NODE_IDS from the Nest's
network onto otbr's over their existing CASE session, without
re-commissioning: ArmFailSafe, RemoveNetwork(old),
AddOrUpdateThreadNetwork(otbr dataset), ConnectNetwork, then
CommissioningComplete over the new network; a node that does not
complete reverts via its fail-safe. The dataset comes from otbr's REST
API and is never logged. First node: 5 (S28-48).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185Qb5YGpVVU2fVua5WqACW
2026-11-08 12:54:41 +01:00
k.renner bdd36d3587 Merge pull request 'fix(otbr): bridge the radio from a socat sidecar over a Unix socket' (#1608) from fix/otbr-no-runtime-install into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1608
2026-11-08 09:55:21 +00:00
claude-botandClaude Opus 5.5 dfb8a0834c fix(otbr): bridge the radio from a socat sidecar over a Unix socket
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
otbr installed socat from apt at every start, which the namespace
egress baseline denies, so it crash-looped. otbr-agent now speaks
spinel+hdlc+unix to a socket served by a native alpine/socat sidecar
that bridges to slzb-thread:6638 (openthread/otbr image of 2026-11-07;
Unix stream sockets landed in OpenThread #13785). otbr-egress allows
only that TCP link (S28-48).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:52:15 +01:00
k.renner 04176ba165 Merge pull request 'feat(otbr): run an own Thread network on the SLZB-06P7' (#1606) from feat/otbr-own-thread-network into main
Reviewed-on: #1606
2026-11-08 09:35:16 +00:00
claude-botandClaude Opus 5.5 dba936b736 feat(otbr): run an own Thread network on the SLZB-06P7
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
otbr comes back with its own network instead of joining the Nest's
NEST-PAN-339C, so the Nest Hub can leave the IoT VLAN (S28-46/S28-48).
The bootstrap Job forms it (dataset init new, channel 15, name
Suedstr28-Thread) on a fresh otbr-thread PVC; the Nest dataset
ExternalSecret is dropped and the Flux Kustomization un-suspended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:32:31 +01:00
k.renner 33c04895f2 Merge pull request 'fix(authentik): turn off error reporting' (#1604) from fix/authentik-error-reporting-off into main
ci/woodpecker/cron/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Reviewed-on: #1604
2026-11-07 21:39:45 +00:00
9 changed files with 330 additions and 100 deletions
+27 -21
View File
@@ -1,14 +1,18 @@
---
# One-shot seed Job. Idempotent — exits 0 immediately if the OTBR pod
# already has an active dataset configured. On a clean install (PVC
# empty, otbr-agent loaded with --auto-attach=0 so it boots into the
# `disabled` state without crashing), it runs the proven bring-up
# sequence:
# One-shot Job that forms otbr's own Thread network. Idempotent — exits 0
# immediately if the OTBR pod already has an active dataset. On a clean
# install (PVC empty, otbr-agent loaded with --auto-attach=0 so it boots
# into the `disabled` state without crashing), it runs:
# 1. ot-ctl ifconfig up (enables PHY on the radio)
# 2. ot-ctl dataset set active <hex> (commits dataset; SetMacKey
# now succeeds because the radio is enabled)
# 3. ot-ctl thread start (MLE attaches; state goes detached → child
# → router → leader)
# 2. ot-ctl dataset init new, channel, networkname, commit active
# (random network key, PAN ID, extended PAN ID and mesh-local
# prefix; SetMacKey succeeds because the radio is enabled)
# 3. ot-ctl thread start (MLE attaches; state goes detached → leader)
#
# Channel 15 sits between Wi-Fi channels 1 and 6, clear of the 2.4 GHz
# APs (channels 1, 6, 11) and of Zigbee on channel 25. The dataset,
# including the network key, lives only on the otbr-thread PVC and in
# Home Assistant's thread.datasets (read over the OTBR REST API).
#
# This sequence is the workaround for SLZB-06P7 firmware behavior where
# `dataset set active` triggers an immediate KeyManager push that fails
@@ -76,11 +80,10 @@ spec:
# (mismatched skew can break exec streaming).
image: docker.io/alpine/kubectl:1.37.2@sha256:5e2de75d37e811ba6eb92e06890b1b3263eae4f0a1c17b49269edec589b1352a
env:
- name: DATASET_TLV
valueFrom:
secretKeyRef:
name: otbr-active-dataset
key: active-dataset.tlv
- name: THREAD_CHANNEL
value: "15"
- name: THREAD_NETWORK_NAME
value: "Suedstr28-Thread"
command:
- sh
- -c
@@ -89,8 +92,8 @@ spec:
NS=home-assistant
SEL=app.kubernetes.io/name=otbr
# otbr is disabled (replicas: 0, see deployment.yaml). With
# the force annotation, every image bump re-creates this Job;
# With otbr scaled to 0 there is no pod to seed. With the
# force annotation, every image bump re-creates this Job;
# exit 0 instead of waiting for a pod that never comes.
replicas=$(kubectl -n "$NS" get deployment otbr \
-o jsonpath='{.spec.replicas}')
@@ -132,16 +135,19 @@ spec:
exit 0
fi
echo "no active dataset; seeding..."
echo "no active dataset; forming ${THREAD_NETWORK_NAME} on channel ${THREAD_CHANNEL}..."
# Order matters: ifconfig up first so the radio's PHY is
# ENABLED before dataset commit triggers SetMacKey. Without
# this, SetMacKey returns INVALID_STATE and otbr-agent
# fatal-exits.
kubectl -n "$NS" exec "$pod" -c otbr -- ot-ctl ifconfig up
kubectl -n "$NS" exec "$pod" -c otbr -- \
ot-ctl dataset set active "$DATASET_TLV"
kubectl -n "$NS" exec "$pod" -c otbr -- ot-ctl thread start
ot() { kubectl -n "$NS" exec "$pod" -c otbr -- ot-ctl "$@"; }
ot ifconfig up
ot dataset init new
ot dataset channel "$THREAD_CHANNEL"
ot dataset networkname "$THREAD_NETWORK_NAME"
ot dataset commit active
ot thread start
# Verify Thread came up.
for i in $(seq 1 30); do
+50 -60
View File
@@ -5,16 +5,11 @@ metadata:
labels:
app.kubernetes.io/name: otbr
spec:
# DISABLED (replicas: 0). Running otbr broke Matter: its radio is RF-isolated
# enough from the Nest mesh that it forms a rival partition of the same
# network, and the two bridge only over Ethernet — fast enough for a ping,
# too slow for the fragmented CASE handshake, so devices stay discoverable
# while every commissioning attempt times out.
#
# The instability is at the Thread partition layer, not in the config below.
# Re-enable only once co-BR integration is verified to MERGE: one partition,
# one OMR prefix, and the Nest routers visible in otbr's router table.
replicas: 0
# otbr runs its own Thread network, formed by otbr-dataset-bootstrap. It is
# not a co-BR of the Nest's NEST-PAN-339C: its radio does not hear that mesh
# well enough to merge partitions, and partitions bridged only over
# Ethernet are too slow for Matter's CASE handshake.
replicas: 1
strategy:
type: Recreate
selector:
@@ -34,49 +29,48 @@ spec:
# RCP is reached over the network (slzb-thread.renner.uno).
nodeSelector:
net.renner.uno/iot-vlan: "true"
initContainers:
# The Thread RCP is a network-attached radio (spinel over TCP).
# otbr-agent speaks spinel+hdlc over a Unix stream socket, which this
# native sidecar serves on the shared emptyDir and bridges to the
# radio, one TCP connection per agent connection. A PTY would not
# work here: devpts is per-container.
- name: radio-bridge
image: alpine/socat:1.8.1.3@sha256:466c8a0383e16463fabdb7df01008292fb78d07da60224a46cb5d3142ce122ed
imagePullPolicy: IfNotPresent
restartPolicy: Always
args:
- -d
- UNIX-LISTEN:/run/thread/spinel.sock,fork,unlink-early,mode=600
- TCP:slzb-thread.renner.uno:6638
startupProbe:
exec:
command: ["test", "-S", "/run/thread/spinel.sock"]
periodSeconds: 1
failureThreshold: 30
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: radio
mountPath: /run/thread
containers:
# The Thread RCP is a network-attached radio (Serial-over-IP), so the
# spinel transport needs a TCP-to-PTY bridge. Two obvious approaches
# do not work and should not be retried:
#
# - `spinel+hdlc+forkpty://` needs socat, which the upstream image
# does not ship; the URL fails with a silent ENOENT and otbr-agent
# dies right after logging it.
# - A sidecar cannot provide it either: devpts is per-container, so
# a PTY created next door is invisible here and the symlink dangles.
#
# Hence socat is installed at startup and run inside this container.
- name: otbr
image: openthread/otbr:latest@sha256:51f9af250252f4faae85d0197574a7c17ada873c78799854461a93e3e42cf324
image: openthread/otbr:latest@sha256:cada244b39d1f43cb12d5f93694b4621db3b51c28219a05bbffac0f898d1403e
command:
- bash
- -c
- |
set -euo pipefail
if ! command -v socat >/dev/null; then
echo "otbr: installing socat from apt"
apt-get update -qq
apt-get install -y -qq socat
fi
echo "otbr: starting TCP↔PTY bridge to ${SLZB_HOST}:${SLZB_PORT}"
# Match the HA addon's socat invocation byte-for-byte
# (rootfs/etc/s6-overlay/s6-rc.d/socat-otbr-tcp/run): the
# known-working reference for SMLight network-attached
# Thread radios. ignoreeof keeps the data loop alive when
# otbr-agent transiently closes the PTY (e.g. on agent
# restart from livenessProbe), instead of socat exiting
# and racing to recreate the PTY symlink.
( while true; do
socat -d \
"pty,raw,echo=0,link=/tmp/ttyThread,ignoreeof" \
"tcp:${SLZB_HOST}:${SLZB_PORT}"
echo "otbr: socat bridge exited; restarting in 2s" >&2
sleep 2
done ) &
until [ -e /tmp/ttyThread ]; do
echo "otbr: waiting for serial bridge PTY at /tmp/ttyThread"
sleep 1
done
# SLZB-06P7 firmware crashes otbr-agent if a saved dataset
# is loaded before the radio's PHY is enabled: dataset
# commit triggers KeyManager → otPlatRadioSetMacKey, which
@@ -99,8 +93,8 @@ spec:
/app/etc/docker/test/docker_entrypoint.sh &
ENTRYPOINT_PID=$!
# Wait for the daemon socket to come up. Up to 90s of
# attempts at 2s intervals — covers slow apt installs,
# spinel handshake, and agent init on this radio.
# attempts at 2s intervals — covers the spinel handshake
# and agent init on this radio.
for i in $(seq 1 45); do
if ot-ctl state >/dev/null 2>&1; then
break
@@ -172,16 +166,9 @@ spec:
fi
wait "$ENTRYPOINT_PID"
env:
- name: SLZB_HOST
value: "slzb-thread.renner.uno"
- name: SLZB_PORT
value: "6638"
# Match the HA addon's RADIO_URL byte-for-byte. baud is
# omitted because PTYs ignore termios speed; uart-flow-
# control is a no-op on a PTY but stays in to mirror the
# known-working reference exactly.
# The radio-bridge sidecar's socket.
- name: RADIO_URL
value: "spinel+hdlc+uart:///tmp/ttyThread?uart-flow-control"
value: "spinel+hdlc+unix:///run/thread/spinel.sock"
# Use the macvlan attachment as the backbone interface. mDNS
# advertisements, SRP server listening, and RA reception all
# happen on this NIC. eth0 (the Cilium pod CIDR) is the
@@ -254,6 +241,8 @@ spec:
# and re-election on every restart).
- name: data
mountPath: /var/lib/thread
- name: radio
mountPath: /run/thread
# Probes must watch the *protocol*, not just the process.
#
# The upstream entrypoint backgrounds otbr-agent and ends in
@@ -271,8 +260,7 @@ spec:
# from this image, so probes go through ot-ctl, not REST.
#
# startupProbe: the agent's control socket answers. Covers the
# apt install, the socat TCP↔PTY bridge to the SLZB radio, and
# the spinel handshake. Generous: 10s × 60 = 10 minutes. While
# spinel handshake with the SLZB radio and agent init. Generous: 10s × 60 = 10 minutes. While
# it runs, the other two probes are suspended, so the entrypoint
# gets to finish activating Thread before liveness may act.
startupProbe:
@@ -344,4 +332,6 @@ spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: otbr-data
claimName: otbr-thread
- name: radio
emptyDir: {}
-15
View File
@@ -1,15 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: otbr-active-dataset
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: otbr-active-dataset # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: apps/otbr
+5 -1
View File
@@ -3,8 +3,12 @@ kind: Kustomization
namespace: home-assistant
resources:
- dataset-bootstrap.yaml
- externalsecret.yaml
- deployment.yaml
- network-attachment.yaml
- pvc.yaml
- service.yaml
- thread-migrate.yaml
configMapGenerator:
- name: thread-migrate
files:
- thread-migrate.py
+1 -1
View File
@@ -9,7 +9,7 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: otbr-data
name: otbr-thread
spec:
accessModes:
- ReadWriteOnce
+166
View File
@@ -0,0 +1,166 @@
"""Move Matter-over-Thread nodes onto otbr's Thread network.
Per node, over the existing CASE session (no re-commissioning):
ArmFailSafe -> RemoveNetwork(old) -> AddOrUpdateThreadNetwork(otbr dataset)
-> ConnectNetwork(new) -> CommissioningComplete over the new network.
If CommissioningComplete never arrives, the fail-safe expires and the node
reverts to its previous network configuration on its own.
The dataset (incl. network key) is read from otbr's REST API and only ever
passed to matter-server; it is never logged.
"""
import asyncio
import logging
import os
import sys
import aiohttp
from chip.clusters import Objects as C
from matter_server.client import MatterClient
OTBR_URL = os.environ["OTBR_URL"]
MATTER_SERVER_URL = os.environ["MATTER_SERVER_URL"]
NODE_IDS = [int(n) for n in os.environ["NODE_IDS"].split()]
FAILSAFE_SECONDS = int(os.environ.get("FAILSAFE_SECONDS", "180"))
EXT_PAN_ATTR = "0/53/4" # ThreadNetworkDiagnostics.ExtendedPanId
MAX_NETWORKS_ATTR = "0/49/0" # NetworkCommissioning.MaxNetworks
log = logging.getLogger("thread-migrate")
def extended_pan_id(tlv: bytes) -> bytes:
"""Return the Extended PAN ID (TLV type 2) of an operational dataset."""
i = 0
while i + 2 <= len(tlv):
tlv_type, length = tlv[i], tlv[i + 1]
if tlv_type == 2:
return tlv[i + 2 : i + 2 + length]
i += 2 + length
raise ValueError("dataset has no Extended PAN ID")
async def read_dataset(session: aiohttp.ClientSession) -> bytes:
async with session.get(
f"{OTBR_URL}/node/dataset/active", headers={"Accept": "text/plain"}
) as resp:
resp.raise_for_status()
return bytes.fromhex((await resp.text()).strip())
async def read_value(client: MatterClient, node_id: int, path: str):
values = await client.read_attribute(node_id, path)
return values[path]
async def command(client: MatterClient, node_id: int, cmd, timeout: float = 60):
return await asyncio.wait_for(
client.send_device_command(node_id, 0, cmd), timeout=timeout
)
async def migrate(client: MatterClient, node_id: int, tlv: bytes, new_ext: bytes) -> bool:
current = await read_value(client, node_id, EXT_PAN_ATTR)
log.info("node %s: extended PAN %016x", node_id, current)
if current == int.from_bytes(new_ext, "big"):
log.info("node %s: already on otbr's network", node_id)
return True
old_ext = current.to_bytes(8, "big")
max_networks = await read_value(client, node_id, MAX_NETWORKS_ATTR)
log.info("node %s: ArmFailSafe %ss", node_id, FAILSAFE_SECONDS)
resp = await command(
client,
node_id,
C.GeneralCommissioning.Commands.ArmFailSafe(
expiryLengthSeconds=FAILSAFE_SECONDS, breadcrumb=1
),
)
log.info("node %s: ArmFailSafe -> %s", node_id, resp)
if max_networks <= 1:
resp = await command(
client,
node_id,
C.NetworkCommissioning.Commands.RemoveNetwork(networkID=old_ext, breadcrumb=2),
)
log.info("node %s: RemoveNetwork(old) -> %s", node_id, resp)
resp = await command(
client,
node_id,
C.NetworkCommissioning.Commands.AddOrUpdateThreadNetwork(
operationalDataset=tlv, breadcrumb=3
),
)
log.info("node %s: AddOrUpdateThreadNetwork -> %s", node_id, resp)
# The node leaves the old network while answering; a missing response
# is expected.
try:
resp = await command(
client,
node_id,
C.NetworkCommissioning.Commands.ConnectNetwork(networkID=new_ext, breadcrumb=4),
timeout=90,
)
log.info("node %s: ConnectNetwork -> %s", node_id, resp)
except Exception as err: # noqa: BLE001
log.info("node %s: ConnectNetwork without response (%r)", node_id, err)
deadline = asyncio.get_running_loop().time() + FAILSAFE_SECONDS - 20
while asyncio.get_running_loop().time() < deadline:
try:
resp = await command(
client,
node_id,
C.GeneralCommissioning.Commands.CommissioningComplete(),
timeout=30,
)
log.info("node %s: CommissioningComplete -> %s", node_id, resp)
break
except Exception as err: # noqa: BLE001
log.info("node %s: CommissioningComplete not yet (%r)", node_id, err)
await asyncio.sleep(10)
else:
log.error("node %s: no CommissioningComplete; fail-safe reverts it", node_id)
return False
current = await read_value(client, node_id, EXT_PAN_ATTR)
ok = current == int.from_bytes(new_ext, "big")
log.info("node %s: extended PAN now %016x (%s)", node_id, current, "ok" if ok else "MISMATCH")
return ok
async def main() -> int:
async with aiohttp.ClientSession() as session:
tlv = await read_dataset(session)
new_ext = extended_pan_id(tlv)
log.info("otbr network extended PAN %s", new_ext.hex())
client = MatterClient(MATTER_SERVER_URL, session)
ready = asyncio.Event()
listener = asyncio.create_task(client.start_listening(ready))
await ready.wait()
failed = []
for node_id in NODE_IDS:
try:
if not await migrate(client, node_id, tlv, new_ext):
failed.append(node_id)
except Exception: # noqa: BLE001
log.exception("node %s: migration failed", node_id)
failed.append(node_id)
listener.cancel()
await client.disconnect()
if failed:
log.error("failed nodes: %s", failed)
return 1
return 0
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
sys.exit(asyncio.run(main()))
+49
View File
@@ -0,0 +1,49 @@
---
# Moves the Matter-over-Thread nodes in NODE_IDS from the Nest's Thread
# network onto otbr's, without re-commissioning (see thread-migrate.py).
# Idempotent: nodes already on otbr's network are skipped. NODE_IDS are
# matter-server node IDs.
apiVersion: batch/v1
kind: Job
metadata:
name: thread-migrate
annotations:
# Changing NODE_IDS changes the pod template; recreate the Job for it.
kustomize.toolkit.fluxcd.io/force: enabled
spec:
# A failed node reverts by itself via its fail-safe; no blind retries.
backoffLimit: 0
template:
metadata:
labels:
app.kubernetes.io/name: thread-migrate
spec:
restartPolicy: Never
containers:
- name: thread-migrate
# Same image as matter-server: ships matter_server.client and the
# chip cluster definitions matching the server.
image: ghcr.io/matter-js/python-matter-server:8.1.2
command: ["python3", "-I", "/scripts/thread-migrate.py"]
env:
# Empty: nothing to migrate.
- name: NODE_IDS
value: ""
- name: OTBR_URL
value: "http://otbr.home-assistant.svc.cluster.local:8081"
- name: MATTER_SERVER_URL
value: "ws://matter-server.home-assistant.svc.cluster.local:5580/ws"
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 256Mi
volumeMounts:
- name: scripts
mountPath: /scripts
readOnly: true
volumes:
- name: scripts
configMap:
name: thread-migrate
@@ -91,8 +91,6 @@ spec:
name: flux-system
path: ./kubernetes/apps/otbr
prune: true
# Not in use.
suspend: true
wait: true
# decryption block dropped 2026-07-13: path is sops-free — secrets come
# from OpenBao via ESO (MIGRATION-sops-openbao.md).
@@ -541,6 +541,38 @@ spec:
- port: "443"
protocol: TCP
---
# otbr: the radio-bridge sidecar's spinel link to the SLZB-06P7 Thread radio.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: otbr-egress
namespace: home-assistant
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: otbr
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "slzb-thread.renner.uno"
toPorts:
- ports:
- port: "6638"
protocol: TCP
---
# mqtt-account-controller: credential delivery to deliver.esp32 targets in mqtt-accounts.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy