Skip to content

Instantly share code, notes, and snippets.

@laanwj
Last active April 1, 2026 13:42
Show Gist options
  • Select an option

  • Save laanwj/927802ab565f2c8c3b81315a9c3632ed to your computer and use it in GitHub Desktop.

Select an option

Save laanwj/927802ab565f2c8c3b81315a9c3632ed to your computer and use it in GitHub Desktop.

Revisions

  1. laanwj revised this gist Apr 1, 2026. 1 changed file with 1 addition and 13 deletions.
    14 changes: 1 addition & 13 deletions ha-rpi5-podman-setup.md
    Original file line number Diff line number Diff line change
    @@ -481,19 +481,7 @@ sudo systemctl restart otbr.service

    ---

    ## 7. RPi5-specific notes

    - **Power supply**: Use the official 27W (5V/5A) USB-C PSU. Underpowered supplies
    cause instability under load.
    - **Cooling**: Active cooling (fan case) is required. The Pi 5 throttles aggressively
    without it.
    - **USB 3.0 RF interference**: USB 3.0 ports emit noise in the 2.4 GHz band, which
    degrades Zigbee/Thread/BLE radio performance. Use a short USB extension cable
    between the ZBT-2 and the Pi to add physical distance.

    ---

    ## 8. Troubleshooting
    ## 7. Troubleshooting

    | Symptom | Check |
    |---|---|
  2. laanwj revised this gist Mar 31, 2026. 1 changed file with 162 additions and 6 deletions.
    168 changes: 162 additions & 6 deletions ha-rpi5-podman-setup.md
    Original file line number Diff line number Diff line change
    @@ -132,8 +132,8 @@ This creates three named Podman volumes. Data is stored under

    ### 3.3 OTBR

    Replace `XXXXXXXXXX` with your actual ZBT-2 serial in both `AddDevice` and
    the `DEVICE` environment variable.
    Replace `XXXXXXXXXX` with your actual ZBT-2 serial in `AddDevice`.
    The host's stable `by-id` path is mapped to `/dev/zbt2` inside the container.

    ```bash
    sudo tee /etc/containers/systemd/otbr.container > /dev/null << 'UNIT'
    @@ -147,10 +147,10 @@ ContainerName=otbr
    Image=ghcr.io/ownbee/hass-otbr-docker
    Network=host
    AddCapability=SYS_ADMIN NET_ADMIN
    AddDevice=/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00
    AddDevice=/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00:/dev/zbt2
    AddDevice=/dev/net/tun
    Volume=otbr.volume:/var/lib/thread
    Environment=DEVICE="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00"
    Environment=DEVICE="/dev/zbt2"
    # BACKBONE_IF is not used (no forwarding/NAT64), but the container's startup
    # script crashes if it's unset and eth0 doesn't exist. Set to your actual
    # interface name to avoid this.
    @@ -189,7 +189,7 @@ Network=host
    SecurityLabelDisable=true
    Volume=matter-server.volume:/data
    Volume=/run/dbus:/run/dbus:ro
    Exec=--storage-path /data --paa-root-cert-dir /data/credentials --bluetooth-adapter 0
    Exec=--storage-path /data --paa-root-cert-dir /data/credentials --bluetooth-adapter 0 --listen-address 127.0.0.1
    [Service]
    Restart=always
    @@ -300,12 +300,168 @@ Open `http://<pi-ip>:8123` in a browser. Complete the onboarding wizard first.
    4. Enter the WebSocket URL: `ws://localhost:5580/ws`
    5. Confirm.

    ### 5.3 Commission a Matter device
    ### 5.3 Commission a Matter device (via phone)

    1. In the Matter integration, click **Add Matter Device**
    2. Enter the device's setup code or scan its QR code (via the HA Companion app)
    3. Thread devices are automatically commissioned via OTBR

    **Note:** The phone must be on the same subnet as the Pi, and must be able to
    discover the OTBR via mDNS. On Android, Google Play Services handles the BLE
    commissioning -- if the phone is on a different subnet, it will fail with
    "Your device needs a Thread border router." IPv6 forwarding must also be
    enabled on the Pi for the phone to reach the Thread mesh (see section 5.4).

    ### 5.4 IPv6 forwarding for commissioning

    Commissioning requires the commissioning device (phone or laptop) to reach
    the Thread mesh via IPv6. Enable forwarding, but restrict it to traffic
    between your LAN and the Thread mesh only (blocking Thread devices from
    reaching the internet):

    ```bash
    sudo sysctl net.ipv6.conf.all.forwarding=1
    sudo sysctl net.ipv6.conf.all.accept_ra=2
    sudo ip6tables -P FORWARD DROP
    sudo ip6tables -A FORWARD -i eth0 -o wpan0 -j ACCEPT
    sudo ip6tables -A FORWARD -i wpan0 -o eth0 -j ACCEPT
    ```

    Replace `eth0` with your actual network interface. The `accept_ra=2` setting
    ensures the Pi still accepts Router Advertisements despite forwarding being on.

    ### 5.5 Commission a Matter device (from Linux with Python CHIP controller)

    This runs in a purpose-built container that includes all dependencies, BLE
    support, and PAA root certificates.

    **Build the commissioning container** (one-time, from a machine with Podman):

    Create a file called `Containerfile`:

    ```dockerfile
    FROM python:3.13-slim

    RUN apt-get update && apt-get install -y --no-install-recommends \
    libcairo2-dev \
    libgirepository1.0-dev \
    libdbus-1-dev \
    libnl-route-3-dev \
    pkg-config \
    gcc \
    git \
    && rm -rf /var/lib/apt/lists/*

    RUN pip install --no-cache-dir \
    home-assistant-chip-core \
    home-assistant-chip-repl \
    home-assistant-chip-clusters \
    aiohttp

    # Fetch PAA root certificates
    RUN git clone --depth 1 --filter=blob:none --sparse \
    https://github.com/project-chip/connectedhomeip.git /opt/paa-certs \
    && cd /opt/paa-certs \
    && git sparse-checkout set credentials/development/paa-root-certs \
    && rm -rf .git

    # Create the /data directory that chip-core hardcodes
    RUN mkdir -p /data

    ENTRYPOINT ["chip-repl", "-t", "/opt/paa-certs/credentials/development/paa-root-certs"]
    ```

    Build it (with sudo, so root's Podman store has it for the run step):

    ```bash
    sudo podman build -t matter-commission .
    ```

    **Run the commissioning REPL:**

    Must run as root for D-Bus/BlueZ access.

    ```bash
    sudo podman run --rm -it --network=host --privileged \
    -v /run/dbus:/run/dbus \
    -v /run/avahi-daemon/socket:/var/run/avahi-daemon/socket \
    localhost/matter-commission
    ```

    **Inside the REPL, commission the device:**

    Make sure that mDNS on the `OTBR_HOST` is reachable from the commissioning host and not blocked by a firewall, or a different subnet.

    ```python
    import aiohttp, json
    from chip.setup_payload import SetupPayload

    # --- Configuration ---
    OTBR_HOST = "192.168.x.x" # IP of the Pi running OTBR
    MATTER_SERVER = "192.168.x.x" # IP of the Pi running matter-server
    QR_CODE = "MT:..." # MT: string from device packaging

    # --- Parse QR code ---
    payload = SetupPayload().ParseQrCode(QR_CODE)
    pin = payload.setup_passcode
    disc = payload.long_discriminator

    # --- Fetch Thread dataset from OTBR ---
    async with aiohttp.ClientSession() as session:
    resp = await session.get(
    f"http://{OTBR_HOST}:8081/node/dataset/active",
    headers={"Accept": "text/plain"})
    dataset = bytes.fromhex(await resp.text())

    # --- Commission the device over BLE ---
    # (make sure the device is in pairing mode first)
    await devCtrl.CommissionThread(
    discriminator=disc, setupPinCode=pin, nodeId=1,
    threadOperationalDataset=dataset)

    # --- Open commissioning window for HA's matter-server ---
    params = await devCtrl.OpenCommissioningWindow(
    nodeid=1, timeout=300, iteration=1000, discriminator=2365, option=1)
    manual_code = params.setupManualCode

    # --- Hand off to HA's matter-server ---
    async with aiohttp.ClientSession() as session:
    ws = await session.ws_connect(f"ws://{MATTER_SERVER}:5580/ws")
    print(await ws.receive_json()) # server info

    await ws.send_json({"message_id": "1", "command": "start_listening"})

    await ws.send_json({
    "message_id": "2",
    "command": "set_thread_dataset",
    "args": {"dataset": dataset.hex()}
    })

    await ws.send_json({
    "message_id": "3",
    "command": "commission_with_code",
    "args": {"code": manual_code, "network_only": True}
    })

    # Wait for all responses (commissioning may take a while)
    while True:
    msg = await ws.receive_json()
    print(msg)
    if msg.get('message_id') == '3':
    break

    await ws.close()

    # --- Remove chip-repl's fabric from the device (HA's fabric remains) ---
    await devCtrl.UnpairDevice(1)
    ```

    Exit the REPL (`exit()`), then **clean up** the container:

    ```bash
    sudo podman rmi localhost/matter-commission
    ```

    ---

    ## 6. Updating
  3. laanwj created this gist Mar 28, 2026.
    363 changes: 363 additions & 0 deletions ha-rpi5-podman-setup.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,363 @@
    # Home Assistant on Raspberry Pi 5 (Debian 13) with Podman

    Matter + Thread setup with ZBT-2 dongle, using Podman in rootful host-network mode.

    ## Architecture

    ```
    +-----------------+
    | Home Assistant | :8123 (web UI)
    | (container) |
    +----+-------+----+
    | |
    ws://:5580 | | http://:8081
    v v
    +-----------+--+ +--+-----------+
    | matter-server| | OTBR | :8080 (OTBR web UI)
    | (container) | | (container) |
    +--------------+ +------+-------+
    |
    /dev/serial/by-id/...
    |
    [ ZBT-2 ]
    (Thread RCP radio)
    ```

    All containers run with `--network=host`. Startup order: OTBR -> matter-server -> Home Assistant.

    ---

    ## 1. Prerequisites

    ### 1.1 Install packages

    ```bash
    sudo apt update
    sudo apt install -y podman avahi-daemon avahi-utils libnss-mdns python3-pip
    ```

    Verify:

    ```bash
    podman --version # should be 5.4.x+
    systemctl is-active avahi-daemon
    ```

    IP forwarding is intentionally **not** enabled. The Thread mesh is kept isolated
    from the LAN -- devices can only communicate with the matter-server on the host,
    not with the internet or other LAN hosts. If you later need to debug OTBR
    routing issues, you can capture traffic on `wpan0` with Wireshark/tcpdump.

    ---

    ## 2. Identify and flash the ZBT-2 dongle

    ### 2.1 Find the device path

    ```bash
    ls -l /dev/serial/by-id/ | grep ZBT-2
    ```

    You should see something like:

    ```
    usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00 -> ../../ttyACM0
    ```

    Note the full path -- you'll use it everywhere below. We'll call it `$ZBT2_DEV`:

    ```bash
    # Set this to YOUR actual device path:
    export ZBT2_DEV="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00"
    ```

    ### 2.2 Flash Thread RCP firmware

    The ZBT-2 ships with Zigbee firmware. It must be flashed to Thread RCP mode.
    It cannot run both protocols simultaneously.

    ```bash
    pip install --break-system-packages universal-silabs-flasher
    ```

    Download the latest RCP firmware from
    <https://github.com/NabuCasa/silabs-firmware-builder/releases> --
    look for a file named like `zbt2_openthread_rcp_*.gbl`.

    ```bash
    # Example (check the releases page for the latest version):
    wget https://github.com/NabuCasa/silabs-firmware-builder/releases/download/v2026.02.23/zbt2_openthread_rcp_2.7.2.0_GitHub-fb0446f53_gsdk_2025.6.2.gbl

    universal-silabs-flasher \
    --device "$ZBT2_DEV" \
    flash \
    --profile zbt2 \
    --firmware zbt2_openthread_rcp_2.7.2.0_GitHub-fb0446f53_gsdk_2025.6.2.gbl
    ```

    Wait for it to complete. The dongle will reconnect on the same path.

    ---

    ## 3. Container setup (Quadlet systemd units)

    Quadlet files go in `/etc/containers/systemd/` (rootful). Podman's systemd
    generator automatically converts them to services. After creating or editing
    any of these files, run `sudo systemctl daemon-reload`.

    ### 3.1 Determine your network interface

    ```bash
    ip route | grep default
    # e.g. "default via 192.168.1.1 dev eth0" or "... dev end0"
    ```

    Use whatever interface name appears (commonly `eth0`, `end0`, or `wlan0`).
    Replace `eth0` in the files below if yours differs.

    ### 3.2 Volumes

    ```bash
    for vol in homeassistant otbr matter-server; do
    sudo tee /etc/containers/systemd/${vol}.volume > /dev/null << UNIT
    [Volume]
    VolumeName=${vol}
    UNIT
    done
    ```

    This creates three named Podman volumes. Data is stored under
    `/var/lib/containers/storage/volumes/`. You can inspect or back them up with
    `podman volume inspect <name>` and `podman volume export <name>`.

    ### 3.3 OTBR

    Replace `XXXXXXXXXX` with your actual ZBT-2 serial in both `AddDevice` and
    the `DEVICE` environment variable.

    ```bash
    sudo tee /etc/containers/systemd/otbr.container > /dev/null << 'UNIT'
    [Unit]
    Description=OpenThread Border Router
    After=network-online.target
    Wants=network-online.target
    [Container]
    ContainerName=otbr
    Image=ghcr.io/ownbee/hass-otbr-docker
    Network=host
    AddCapability=SYS_ADMIN NET_ADMIN
    AddDevice=/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00
    AddDevice=/dev/net/tun
    Volume=otbr.volume:/var/lib/thread
    Environment=DEVICE="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00"
    # BACKBONE_IF is not used (no forwarding/NAT64), but the container's startup
    # script crashes if it's unset and eth0 doesn't exist. Set to your actual
    # interface name to avoid this.
    Environment=BACKBONE_IF="eth0"
    Environment=FLOW_CONTROL=1
    Environment=FIREWALL=0
    Environment=NAT64=0
    Environment=BAUDRATE=460800
    Environment=OTBR_REST_PORT=8081
    Environment=OTBR_WEB_PORT=8080
    Environment=AUTOFLASH_FIRMWARE=0
    PodmanArgs=--privileged
    [Service]
    Restart=always
    TimeoutStartSec=900
    [Install]
    WantedBy=multi-user.target
    UNIT
    ```

    ### 3.4 Matter Server

    ```bash
    sudo tee /etc/containers/systemd/matter-server.container > /dev/null << 'UNIT'
    [Unit]
    Description=Python Matter Server
    After=network-online.target otbr.service
    Wants=network-online.target
    [Container]
    ContainerName=matter-server
    Image=ghcr.io/matter-js/python-matter-server:stable
    Network=host
    SecurityLabelDisable=true
    Volume=matter-server.volume:/data
    Volume=/run/dbus:/run/dbus:ro
    Exec=--storage-path /data --paa-root-cert-dir /data/credentials --bluetooth-adapter 0
    [Service]
    Restart=always
    TimeoutStartSec=900
    [Install]
    WantedBy=multi-user.target
    UNIT
    ```

    ### 3.5 Home Assistant

    ```bash
    sudo tee /etc/containers/systemd/homeassistant.container > /dev/null << 'UNIT'
    [Unit]
    Description=Home Assistant
    After=network-online.target matter-server.service otbr.service
    Wants=network-online.target
    [Container]
    ContainerName=homeassistant
    Image=ghcr.io/home-assistant/home-assistant:stable
    Network=host
    Volume=homeassistant.volume:/config
    Volume=/run/dbus:/run/dbus:ro
    Environment=TZ="Europe/London"
    Environment=DISABLE_JEMALLOC=true
    PodmanArgs=--privileged
    [Service]
    Restart=always
    TimeoutStartSec=900
    [Install]
    WantedBy=multi-user.target
    UNIT
    ```

    Change `TZ="Europe/London"` to your timezone (e.g. `America/New_York`,
    `Europe/Berlin`). Full list: `timedatectl list-timezones`.

    ---

    ## 4. Pull images and start

    ```bash
    sudo systemctl daemon-reload

    # Pull all images first (can take a while on Pi):
    sudo podman pull ghcr.io/ownbee/hass-otbr-docker
    sudo podman pull ghcr.io/matter-js/python-matter-server:stable
    sudo podman pull ghcr.io/home-assistant/home-assistant:stable

    # Start in order:
    sudo systemctl start otbr.service
    sudo systemctl start matter-server.service
    sudo systemctl start homeassistant.service
    ```

    They will auto-start on boot (Quadlet's `WantedBy=multi-user.target` handles this).

    ### 4.1 Verify

    ```bash
    sudo systemctl status otbr.service matter-server.service homeassistant.service
    sudo podman ps
    ```

    Check logs if something looks wrong:

    ```bash
    journalctl -u otbr.service -f
    journalctl -u matter-server.service -f
    journalctl -u homeassistant.service -f
    ```

    OTBR creates a `wpan0` interface on the host:

    ```bash
    ip addr show wpan0
    ```

    Test the OTBR REST API:

    ```bash
    curl http://localhost:8081/node/state
    # Should return: "leader" or "router" or "child" (once the Thread network forms)
    ```

    ---

    ## 5. Home Assistant UI configuration

    Open `http://<pi-ip>:8123` in a browser. Complete the onboarding wizard first.

    ### 5.1 Add the OpenThread Border Router integration

    1. **Settings > Devices & Services > + Add Integration**
    2. Search for **"OpenThread Border Router"**
    3. Enter the URL: `http://localhost:8081`
    4. Confirm. Thread network info will appear under **Settings > Devices & Services > Thread**.

    ### 5.2 Add the Matter integration

    1. **Settings > Devices & Services > + Add Integration**
    2. Search for **"Matter"**
    3. **Uncheck** "Use the Matter Server add-on" (you're running your own)
    4. Enter the WebSocket URL: `ws://localhost:5580/ws`
    5. Confirm.

    ### 5.3 Commission a Matter device

    1. In the Matter integration, click **Add Matter Device**
    2. Enter the device's setup code or scan its QR code (via the HA Companion app)
    3. Thread devices are automatically commissioned via OTBR

    ---

    ## 6. Updating

    There's no auto-update with Container installs. To update:

    ```bash
    sudo podman pull ghcr.io/home-assistant/home-assistant:stable
    sudo systemctl restart homeassistant.service

    sudo podman pull ghcr.io/matter-js/python-matter-server:stable
    sudo systemctl restart matter-server.service

    sudo podman pull ghcr.io/ownbee/hass-otbr-docker
    sudo systemctl restart otbr.service
    ```

    ---

    ## 7. RPi5-specific notes

    - **Power supply**: Use the official 27W (5V/5A) USB-C PSU. Underpowered supplies
    cause instability under load.
    - **Cooling**: Active cooling (fan case) is required. The Pi 5 throttles aggressively
    without it.
    - **USB 3.0 RF interference**: USB 3.0 ports emit noise in the 2.4 GHz band, which
    degrades Zigbee/Thread/BLE radio performance. Use a short USB extension cable
    between the ZBT-2 and the Pi to add physical distance.

    ---

    ## 8. Troubleshooting

    | Symptom | Check |
    |---|---|
    | OTBR won't start | `journalctl -u otbr.service` -- look for device permission errors. Verify `$ZBT2_DEV` exists and has Thread RCP firmware. |
    | No `wpan0` interface | OTBR needs `--privileged` and `/dev/net/tun`. Check `ip link show wpan0`. |
    | Matter server won't connect | Verify port 5580 is listening: `ss -tlnp \| grep 5580` |
    | HA can't find OTBR | Verify `curl http://localhost:8081/node/state` returns a response. |
    | mDNS discovery not working | `systemctl is-active avahi-daemon` -- must be running. All containers must use host networking. |
    | Thread devices unreachable | Check IPv6 forwarding: `sysctl net.ipv6.conf.all.forwarding` (must be 1). Check `ip -6 route` for Thread prefixes. |
    | `SQUASHFS` or boot errors from USB SSD | Use USB 2.0 port for boot media (Pi 5 known issue). Not relevant if booting from SD/NVMe. |

    ---

    ## References

    - [HA Container install docs](https://www.home-assistant.io/installation/linux#install-home-assistant-container)
    - [python-matter-server Docker docs](https://github.com/matter-js/python-matter-server/blob/main/docs/docker.md)
    - [ownbee/hass-otbr-docker](https://github.com/ownbee/hass-otbr-docker)
    - [NabuCasa silabs-firmware-builder releases](https://github.com/NabuCasa/silabs-firmware-builder/releases)
    - [HA Matter integration](https://www.home-assistant.io/integrations/matter/)
    - [HA OTBR integration](https://www.home-assistant.io/integrations/otbr/)
    - [Podman Quadlet docs](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html)
    - [Home Assistant Connect ZBT-2](https://www.home-assistant.io/connect/zbt-2/)