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
5 changed files with 296 additions and 49 deletions
+44 -49
View File
@@ -29,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
@@ -94,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
@@ -167,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
@@ -249,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
@@ -266,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:
@@ -340,3 +333,5 @@ spec:
- name: data
persistentVolumeClaim:
claimName: otbr-thread
- name: radio
emptyDir: {}
+5
View File
@@ -7,3 +7,8 @@ resources:
- network-attachment.yaml
- pvc.yaml
- service.yaml
- thread-migrate.yaml
configMapGenerator:
- name: thread-migrate
files:
- thread-migrate.py
+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
@@ -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