Posted on — Leave a comment

Repurposing a Hive Hub: Backing Up the eMMC and Getting a Root Shell Over USB

The Hive Hub (model SHA-533, the Centrica/British Gas “Hive Nano3 Hub”) turns up cheap and unwanted now that the service it was built for has moved on. Inside it is a surprisingly capable little computer: a Texas Instruments AM6232A (dual Cortex-A53 Sitara, the AM62x family), 8 GB of Samsung eMMC, an ESP32-MINI-1 for Wi-Fi and Bluetooth, and a Silicon Labs EFR32MG21 Zigbee chip, in a case with three LEDs. I wanted one as a general-purpose Linux box on my network. This post is how I got a root shell, working SSH and my own Wi-Fi onto it, without soldering anything except one jumper wire, and without leaving the device unrecoverable. It includes a script that does the rewriting automatically, with the caveats about what I have and have not tested.

Read this first. This works on a device you own, by editing its storage over USB. A mistake can leave it unbootable, and the hub holds a certificate-based identity for a cloud service that no longer matters to me, but might to you. Make the full backup in step 2 and keep it. Everything below was done by hand on my own hub; the script that chains the steps together is new and has been tested only against a fake disk image (details at the end).

What you need

  • A Linux PC with dfu-util, e2fsprogs (for debugfs), openssl, python3 and curl.
  • A USB-C cable. The hub is powered from the USB-C port, so it needs nothing else.
  • One short jumper wire to tie the test pad labelled B7 to 3.3 V. That pulls the boot-mode strap so the AM6232’s mask ROM waits for USB DFU instead of booting from eMMC.
  • Strongly recommended: a USB-serial adapter on the AM6232’s UART header. I did my debugging through one, and the UART is the only way in if networking is wrong.
  • A patched first-stage bootloader (tiboot3.bin), explained next.

How the entry point works

With B7 high, the ROM boot code in the AM6232 exposes a DFU device and will accept a first-stage bootloader over USB. It must be a correctly signed image for this silicon, and the production hub uses a “HS-FS” part, so the build target is tiboot3-am62x-hs-fs-evm.bin. TI’s own U-Boot already knows how to be a DFU target from that stage, but it can only offer RAM, not the eMMC. So I patched TI’s U-Boot (the 2023.04 branch, pinned to the November 2023 commit that matches the hub’s SYSFW 9.1.8 firmware) to do two extra things from inside the SPL, the early loader that runs on the Cortex-R5:

  1. Initialise the eMMC and expose it over DFU as raw block targets: the whole main area, the two hardware boot partitions, and the rootfs partition by itself.
  2. Allow writes to it.

That avoids the rest of the boot chain (TF-A, OP-TEE, full U-Boot) entirely. I did try to build the whole chain and hit a TF-A error I never solved (Timeout waiting for thread SP_RESPONSE to fill), so the stock chain on the eMMC is what I use for actual boots. The pivot was deliberate: it is much safer to touch only the storage than to replace the boot chain.

The patch, in words

  • common/spl/spl_ram.c: initialise MMC device 0 before starting DFU (the DFU boot path never probes the eMMC itself), then register the dfu_alt_info_emmc targets alongside the RAM one.
  • drivers/dfu/dfu_mmc.c: stub out the file-based DFU back end in SPL builds (raw only), and fix a 32-bit overflow in the size calculation. Without this, a 7.28 GiB pull silently stops at 3,523,215,360 bytes and dfu-util reports a clean “Upload done”. Cast to u64 before multiplying.
  • common/spl/Kconfig: new SPL_DFU_MMC option. common/cli.c: two #ifdef CONFIG_CMDLINE changed to CONFIG_IS_ENABLED(CMDLINE) so SPL builds compile.
  • include/environment/ti/k3_dfu.env: the target list. The size fields are in 512-byte sectors, not bytes (my first attempt passed bytes, got 16 million sectors, and overflowed):
    dfu_alt_info_emmc=
    	backup.mainarea raw 0x0 0xe90000 mmcpart 0;
    	backup.boot0 raw 0x0 0x2000 mmcpart 1;
    	backup.boot1 raw 0x0 0x2000 mmcpart 2;
    	install.rootfsA raw 0x7be8 0x600000 mmcpart 0;
    	restore.bootpart raw 0x8 0x7be0 mmcpart 0
  • In the config, start from am62x_evm_r5_usbdfu_defconfig and enable CONFIG_SPL_DFU_MMC, CONFIG_DFU_MMC, CONFIG_SPL_MMC_WRITE (without it, blk_dwrite() silently writes zero blocks and you only learn from a final “MMC operation failed”) and CONFIG_SPL_DFU_NO_RESET.

One more SPL gotcha: its tiny printf mangles %llu and corrupts every argument after it on the same line, so print sector counts, not byte counts, in debug output.

To build, you need the pinned TI U-Boot, the matching ti-linux-firmware commit (532db4d1703f, “Update firmwares to v09.01.08”) and an arm-none-eabi or arm-linux-gnueabihf cross compiler:

git clone https://git.ti.com/git/ti-u-boot/ti-u-boot.git && cd ti-u-boot
git checkout c1c2bdf2725            # ti-u-boot-2023.04-next, 2023-11-29
git apply hivehub-spl-dfu-mmc.patch
export ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
make am62x_evm_r5_usbdfu_defconfig
scripts/config -e SPL_DFU_MMC -e DFU_MMC -e SPL_MMC_WRITE -e SPL_DFU_NO_RESET
make olddefconfig
make BINMAN_INDIRS=/path/to/ti-linux-firmware -j8
# result: tiboot3-am62x-hs-fs-evm.bin  (use this as tiboot3.bin)

I should be straight about that recipe: it is reconstructed from the config of the build I used (the options are the ones in my working .config) and from the patch, and I have not re-run a clean build from these exact commands while writing this post. If your tiboot3.bin does not offer backup.mainarea as a DFU target, it is not the right build.

Step 1: Put the hub in DFU mode

  1. Unplug the hub. Tie pad B7 to 3.3 V.
  2. Plug the USB-C cable into the PC. dfu-util -l should show a TI device with bootloader and SocId alt-settings.

The board is powered from the cable, so there is no hot-swap: every time you want to go back to this state you unplug and replug. After the SPL starts, it stays in its own DFU session until you cut the power.

Step 2: Take a full backup (don’t skip it)

dfu-util -a bootloader -D tiboot3.bin
dfu-util -a backup.mainarea -U backup.mainarea.img   # 7,818,182,656 bytes
dfu-util -a backup.boot0   -U backup.boot0.img       # 4 MiB
dfu-util -a backup.boot1   -U backup.boot1.img       # 4 MiB

Check the sizes exactly. The eMMC has 15,269,888 sectors of 512 bytes in the main area, plus two 4 MiB boot partitions where the ROM actually finds the production tiboot3.bin (boot0 is FAT32, boot1 FAT12). A pull that is a few percent short means the overflow bug is back. The RPMB area (512 KB) needs the authenticated RPMB protocol and is not included; the script does not touch it either.

What is on the disk

The stock system is Linux 6.1 (TI’s kernel) with OSTree: mmcblk0p1 is a FAT boot partition, mmcblk0p2 is the ext4 rootfs holding an OSTree deployment, p3 is a legacy recovery image, and p4 holds /var and /persistent. Two things are worth knowing before you change anything:

  • The real boot configuration is /boot/loader/uEnv.txt inside the rootfs (a symlink to loader.0), not in the FAT partition. I wasted a recovery cycle restoring the FAT partition to fix a boot problem it had nothing to do with.
  • U-Boot’s environment is not persisted (it prints Loading Environment from nowhere on every boot), so fw_setenv and the A/B boot counter do nothing useful. Keep your DFU backup as the safety net.
  • The stock firmware has a factory-restore.service that, if /boot/uboot/factory.rst exists at boot, runs blkdiscard -s on mmcblk0p4. Don’t create that file.

Step 3: Open it up

The approach that worked is to edit the OSTree deployment’s /etc in place, offline, on a copy of the rootfs partition, and write it back. That keeps Hive’s own boot chain, kernel and device tree (which already drive the Wi-Fi, the LEDs and the Zigbee chip), and avoids the unsolved bootloader problem. The changes are:

  1. Root password. /etc/shadow says root:*, so nothing can match. Replace it with an openssl passwd -6 hash.
  2. Serial login. Only the framebuffer getty was enabled. Link serial-getty@ttyS2.service into getty.target.wants; the AM6232’s debug UART then gives you a login prompt at 115200 8N1.
  3. SSH. There is no sshd. I used dropbear from Debian 11 (bullseye) arm64 packages with their three libraries (libtomcrypt1, libtommath1, libcrypt1). Bullseye specifically, because the hub’s glibc is 2.35 and a binary built against an older glibc runs on a newer one but not the other way round. One more trap: host keys generated on a PC in OpenSSH/PEM format crash dropbear with Early exit: String too long and systemd restarts it in a loop. Dropbear wants its own key format. The script runs it with -R, which generates the host keys itself on the first connection.
  4. The firewall. /etc/nftables.conf allows only the cloud ports (svc_tcp_ports = {8883, 9124}) and rejects everything else, by design. Add 22.
  5. Wi-Fi. The stock Wi-Fi is a standard wpa_supplicant, but wrapped in Hive’s own scripts, and something in it (most likely supplicant-error-action.sh, which has a .restore backup file) quietly restored the factory access-point config (ap_scan=2, open network “Hivehub-sha-…”) at some point during my reboot loops. I lost a whole evening believing the board had stopped booting; it was booting fine and had left my network. Rejoining is not enough on its own: ap_scan is read only at start-up. The fix is to bypass Hive’s wrapper completely: a normal wpa_supplicant-wlan0.conf in /etc/wpa_supplicant, the Debian wpa_supplicant@wlan0 template unit enabled, and the Hive wrapper (wpa-supplicant-ctl.service) masked.
  6. The Hive cloud services (optional). The AlertMe/Centrica stack, including a Java daemon, a cloud registration service and telemetry timers, can be masked. Masking is reversible: it is just a symlink to /dev/null. I disabled these live over SSH once I had a shell, and left nanohub-hostname.service alone because it only sets the hostname.

The script

The source, with my notes on the boot chain and stock firmware, is on GitLab: git.experimental-engineering.co.uk/root/hive-hub-tools. You can also download both files here (the script, and the U-Boot patch described above):

This does steps 2 and 3. It takes the backup with size checks; refuses to carry on if the rootfs it pulls does not match the backup; applies the edits to a copy; runs e2fsck on the copy; and only then asks you to type YES before it writes anything. After the write it reads the partition back and compares it.

export TIBOOT3=./tiboot3.bin
export WIFI_SSID='your-network' WIFI_PSK='your-passphrase'
export SSH_PUBKEY=~/.ssh/id_ed25519.pub STRIP_CLOUD=1
./hivehub-jailbreak.sh check
./hivehub-jailbreak.sh selftest            # no hardware needed
./hivehub-jailbreak.sh backup ~/hivehub    # replug into DFU mode after this if asked
./hivehub-jailbreak.sh patch  ~/hivehub
#!/bin/bash
# Hive Hub SHA-533 (TI AM6232A): back up the eMMC and unlock SSH/Wi-Fi, over USB only.
#
#   hivehub-jailbreak.sh check                 tools + files present?
#   hivehub-jailbreak.sh backup  DIR           pull main area, boot0, boot1 into DIR
#   hivehub-jailbreak.sh patch   DIR           pull rootfs, edit a COPY, verify, (ask) write back
#   hivehub-jailbreak.sh selftest              run the edit logic against a fake rootfs image
#
# Needs: dfu-util, e2fsprogs (debugfs), openssl, python3, ar/tar/xz, curl.
# The board must be in ROM DFU mode (test pad B7 tied to 3V3, USB-C to this PC) and
# SPL_TIBOOT3 must be the patched R5 SPL (see the build notes in the blog post).
# Settings come from the environment:
#   TIBOOT3=path   WIFI_SSID=...  WIFI_PSK=...  SSH_PUBKEY=~/.ssh/id_ed25519.pub
#   ROOT_PASSWORD=...  (optional, default: ask)  STRIP_CLOUD=1 (mask the Centrica services)
set -euo pipefail

TIBOOT3=${TIBOOT3:-./tiboot3.bin}
ROOTFS_SECTOR_COUNT=$((0x600000))      # install.rootfsA = 3 GiB at sector 0x7be8
BACKUP_SIZES="backup.mainarea:7818182656 backup.boot0:4194304 backup.boot1:4194304"
CLOUD_UNITS="device-abstraction reflector hubreg hub-control-client.timer hub-metrics.timer \
alertme-hub-delete-angstrom alertmehub-osrelease-logger hub-state-monitor mqtt_mdns greengrass \
hub-bluetooth2 hsr wpa-supplicant-ctl"

die() { echo "ERROR: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null || die "missing tool: $1"; }

dfu_ready() { dfu-util -l 2>/dev/null | grep -q "$1"; }

push_spl() {
  [ -f "$TIBOOT3" ] || die "TIBOOT3 not found: $TIBOOT3"
  if dfu_ready 'name="backup.mainarea"'; then echo "SPL DFU session already running."; return; fi
  dfu_ready 'name="bootloader"' || die "no ROM DFU device (B7 high, USB-C connected?)"
  dfu-util -a bootloader -D "$TIBOOT3"
  for _ in $(seq 20); do dfu_ready 'name="backup.mainarea"' && return; sleep 1; done
  die "SPL did not expose the backup.* targets (wrong tiboot3.bin?)"
}

cmd_check() {
  for t in dfu-util debugfs openssl python3 ar tar xz curl; do need $t; echo "ok  $t"; done
  [ -f "$TIBOOT3" ] && echo "ok  $TIBOOT3" || echo "-- $TIBOOT3 missing"
}

cmd_backup() {
  local d=${1:?directory}; mkdir -p "$d"; need dfu-util; push_spl
  for e in $BACKUP_SIZES; do
    n=${e%%:*}; want=${e##*:}
    dfu-util -a "$n" -U "$d/$n.img"
    got=$(stat -c %s "$d/$n.img")
    [ "$got" -eq "$want" ] || die "$n: got $got bytes, expected $want (truncated pull - do not continue)"
    echo "ok  $n  $got bytes"
  done
  (cd "$d" && sha256sum backup.*.img > SHA256SUMS)
  echo "Backup complete. Keep it somewhere safe; it contains the device's identity and certs."
}

# ---- offline edit of an ext4 image (the OSTree rootfs) ------------------------------
dbg() { debugfs -w -R "$1" "$IMG" 2>&1 | grep -v '^debugfs ' || true; }
dput() { # local-file target-path mode(octal, e.g. 0100644)
  dbg "rm $2" >/dev/null; dbg "write $1 $2" >/dev/null
  dbg "sif $2 mode $3" >/dev/null; dbg "sif $2 uid 0" >/dev/null; dbg "sif $2 gid 0" >/dev/null
}
dexists() { ! debugfs -R "stat $1" "$IMG" 2>&1 | grep -q "not found"; }   # debugfs exits 0 either way
dmkdir() { dexists "$1" || { dmkdir "$(dirname "$1")"; dbg "mkdir $1" >/dev/null; }; }
dread() { debugfs -R "cat $1" "$IMG" 2>/dev/null; }
dlink() { dmkdir "$(dirname "$2")"; dbg "rm $2" >/dev/null; dbg "symlink $2 $1" >/dev/null; }

find_deploy() {
  local os d
  os=$(debugfs -R "ls -p /ostree/deploy" "$IMG" 2>/dev/null | awk -F/ '$6!="." && $6!=".." && $6!="" {print $6}' | head -1)
  [ -n "$os" ] || die "no /ostree/deploy/* found - is this the rootfs partition?"
  d=$(debugfs -R "ls -p /ostree/deploy/$os/deploy" "$IMG" 2>/dev/null | awk -F/ '$6 ~ /\.0$/ {print $6}' | head -1)
  [ -n "$d" ] || die "no deployment under /ostree/deploy/$os/deploy"
  echo "/ostree/deploy/$os/deploy/$d"
}

fetch_deb() { # package -> path to .deb (Debian 11 arm64: older glibc than the device, so it runs)
  local pkg=$1 w=$WORK/debs idx rel
  mkdir -p "$w"
  for base in http://deb.debian.org/debian http://archive.debian.org/debian; do
    [ -f "$w/Packages" ] && break
    curl -fsSL "$base/dists/bullseye/main/binary-arm64/Packages.xz" 2>/dev/null | xz -dc >"$w/Packages.tmp" \
      && [ -s "$w/Packages.tmp" ] && mv "$w/Packages.tmp" "$w/Packages" && echo "$base" >"$w/base"
  done
  [ -f "$w/Packages" ] || die "cannot fetch the Debian 11 package index"
  rel=$(awk -v p="$pkg" '$1=="Package:"{c=($2==p)} c&&$1=="Filename:"{print $2; exit}' "$w/Packages")
  [ -n "$rel" ] || die "package $pkg not found"
  [ -f "$w/$(basename "$rel")" ] || curl -fsSL "$(cat "$w/base")/$rel" -o "$w/$(basename "$rel")"
  echo "$w/$(basename "$rel")"
}

apply_edits() {
  local DEP; DEP=$(find_deploy); echo "deployment: $DEP"
  local E=$DEP/etc U=$DEP/usr
  mkdir -p "$WORK/x"

  # 1. root password (stock shadow has root:* so nothing can ever match)
  local pw=${ROOT_PASSWORD:-}
  [ -n "$pw" ] || { read -rsp "New root password: " pw; echo; }
  local hash; hash=$(openssl passwd -6 "$pw")
  dread "$E/shadow" | awk -v h="$hash" -F: 'BEGIN{OFS=":"} $1=="root"{$2=h} {print}' >"$WORK/x/shadow"
  grep -q "^root:\\\$6\\\$" "$WORK/x/shadow" || die "shadow edit failed"
  dput "$WORK/x/shadow" "$E/shadow" 0100640

  # 2. serial console login on the AM62 UART (ttyS2)
  dlink /lib/systemd/system/serial-getty@.service "$E/systemd/system/getty.target.wants/serial-getty@ttyS2.service"

  # 3. dropbear + its three runtime libraries from Debian 11 arm64
  local pkg deb
  for pkg in dropbear-bin libtomcrypt1 libtommath1 libcrypt1; do
    deb=$(fetch_deb "$pkg"); rm -rf "$WORK/x/$pkg"; mkdir -p "$WORK/x/$pkg"
    (cd "$WORK/x/$pkg" && ar x "$deb" && tar xf data.tar.* )
  done
  put_tree() { # srcdir/relpath -> $U/relpath-ish (files only)
    local src=$1 rel=$2 tgt=$3 mode=$4
    [ -e "$src/$rel" ] || die "missing in deb: $rel"
    dmkdir "$(dirname "$tgt")"; dput "$src/$rel" "$tgt" "$mode"
  }
  put_tree "$WORK/x/dropbear-bin" usr/sbin/dropbear "$U/sbin/dropbear" 0100755
  put_tree "$WORK/x/dropbear-bin" usr/bin/dropbearkey "$U/bin/dropbearkey" 0100755
  for lib in "$WORK"/x/libtomcrypt1/usr/lib/aarch64-linux-gnu/libtomcrypt.so.1.* \
             "$WORK"/x/libtommath1/usr/lib/aarch64-linux-gnu/libtommath.so.1.* \
             "$WORK"/x/libcrypt1/lib/aarch64-linux-gnu/libcrypt.so.1.*; do
    [ -f "$lib" ] || die "library not found in deb: $lib"
    b=$(basename "$lib"); dput "$lib" "$U/lib/aarch64-linux-gnu/$b" 0100644
    dlink "$b" "$U/lib/aarch64-linux-gnu/${b%.*.*}" 2>/dev/null || true
  done

  # 4. systemd unit. -R makes dropbear create its own host keys on first connection (the
  #    PEM/OpenSSH keys made on a PC do NOT load: "String too long").
  cat >"$WORK/x/dropbear.service" <<'UNIT'
[Unit]
Description=Dropbear SSH server
After=network.target
[Service]
ExecStartPre=/bin/mkdir -p /etc/dropbear
ExecStart=/usr/sbin/dropbear -F -R -E
Restart=always
[Install]
WantedBy=multi-user.target
UNIT
  dput "$WORK/x/dropbear.service" "$E/systemd/system/dropbear.service" 0100644
  dlink /etc/systemd/system/dropbear.service "$E/systemd/system/multi-user.target.wants/dropbear.service"

  # 5. authorised key for root (home on this image is /home/root)
  if [ -n "${SSH_PUBKEY:-}" ] && [ -f "$SSH_PUBKEY" ]; then
    local home; home=$(dread "$E/passwd" | awk -F: '$1=="root"{print $6}')
    [ -n "$home" ] || home=/home/root
    dmkdir "$(dirname "$DEP$home")/root"   # no-op if present
    dmkdir "$DEP$home/.ssh"; dbg "sif $DEP$home/.ssh mode 040700" >/dev/null
    dput "$SSH_PUBKEY" "$DEP$home/.ssh/authorized_keys" 0100600
  fi

  # 6. firewall: stock nftables allows only 8883/9124 then rejects everything
  dread "$E/nftables.conf" >"$WORK/x/nft.conf"
  python3 - "$WORK/x/nft.conf" <<'PY'
import re, sys
p = sys.argv[1]; s = open(p).read()
m = re.search(r'(svc_tcp_ports\s*=\s*\{)([^}]*)(\})', s)
if not m: sys.exit("svc_tcp_ports not found in nftables.conf")
if not re.search(r'\b22\b', m.group(2)):
    s = s[:m.end(2)] + ", 22" + s[m.end(2):]
open(p, "w").write(s)
PY
  dput "$WORK/x/nft.conf" "$E/nftables.conf" 0100644

  # 7. Wi-Fi with the standard Debian template unit, and stop the AlertMe script reverting it
  if [ -n "${WIFI_SSID:-}" ]; then
    mkdir -p "$WORK/x" && {
      echo "ctrl_interface=DIR=/run/wpa_supplicant GROUP=netdev"
      echo "update_config=1"; echo "country=GB"
      printf 'network={\n\tssid="%s"\n\tpsk="%s"\n}\n' "$WIFI_SSID" "${WIFI_PSK:?WIFI_PSK}"
    } >"$WORK/x/wpa.conf"
    dput "$WORK/x/wpa.conf" "$E/wpa_supplicant/wpa_supplicant-wlan0.conf" 0100600
    dlink /lib/systemd/system/wpa_supplicant@.service "$E/systemd/system/multi-user.target.wants/wpa_supplicant@wlan0.service"
    dbg "rm $E/systemd/system/multi-user.target.wants/wpa-supplicant-ctl.service" >/dev/null
    dlink /dev/null "$E/systemd/system/wpa-supplicant-ctl.service"      # mask
  fi

  # 8. optional: mask the Hive/Centrica cloud services (masking is reversible: delete the link)
  if [ "${STRIP_CLOUD:-0}" = 1 ]; then
    for u in $CLOUD_UNITS; do
      case $u in *.timer|*.service) n=$u;; *) n=$u.service;; esac
      dlink /dev/null "$E/systemd/system/$n"
    done
  fi
  echo "edits applied"
}

cmd_patch() {
  local d=${1:?directory}; mkdir -p "$d"; need debugfs; need openssl; need python3
  WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
  [ -f "$d/backup.mainarea.img" ] || die "run 'backup' first (and keep that backup)"
  push_spl
  dfu-util -a install.rootfsA -U "$d/rootfsA.orig"
  [ "$(stat -c %s "$d/rootfsA.orig")" -eq $((ROOTFS_SECTOR_COUNT*512)) ] || die "short rootfs pull"
  cmp -n $((ROOTFS_SECTOR_COUNT*512)) "$d/rootfsA.orig" \
      <(dd if="$d/backup.mainarea.img" bs=512 skip=$((0x7be8)) count=$ROOTFS_SECTOR_COUNT 2>/dev/null) \
      || die "rootfs differs from the main-area backup (is the board running something else?)"
  cp "$d/rootfsA.orig" "$d/rootfsA.new"; IMG=$d/rootfsA.new
  apply_edits
  e2fsck -fn "$IMG" || die "filesystem check failed on the edited copy - NOT writing"
  echo; echo "Edited image: $IMG  (original untouched: $d/rootfsA.orig)"
  read -rp "Write it to the board's eMMC now? Type YES: " a; [ "$a" = YES ] || { echo "not written"; return; }
  dfu-util -a install.rootfsA -D "$IMG"
  dfu-util -a install.rootfsA -U "$d/rootfsA.readback"
  cmp "$IMG" "$d/rootfsA.readback" && echo "written and verified. Unplug, release B7, power up."
}

cmd_selftest() {
  need debugfs; need mkfs.ext4; need e2fsck; need python3; need openssl
  WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
  local t=$WORK/tree dep=$WORK/tree/ostree/deploy/os/deploy/abc123.0
  mkdir -p "$dep/etc/systemd/system/multi-user.target.wants" "$dep/etc/wpa_supplicant" "$dep/usr/sbin" "$dep/usr/bin" \
           "$dep/usr/lib/aarch64-linux-gnu" "$dep/home/root"
  printf 'root:*:19000:0:99999:7:::\n' >"$dep/etc/shadow"
  printf 'root:x:0:0:root:/home/root:/bin/sh\n' >"$dep/etc/passwd"
  printf 'define svc_tcp_ports = {8883, 9124}\n' >"$dep/etc/nftables.conf"
  printf 'x' >"$dep/etc/systemd/system/multi-user.target.wants/wpa-supplicant-ctl.service"
  IMG=$WORK/fake.img; mkfs.ext4 -q -F -d "$t" "$IMG" 64M
  ssh-keygen -q -t ed25519 -N '' -f "$WORK/k" ; export SSH_PUBKEY=$WORK/k.pub
  export ROOT_PASSWORD=testpw WIFI_SSID=testnet WIFI_PSK=testpass STRIP_CLOUD=1
  apply_edits
  e2fsck -fn "$IMG" >/dev/null && echo "PASS fsck"
  local D=/ostree/deploy/os/deploy/abc123.0
  chk() { debugfs -R "$1" "$IMG" 2>/dev/null | grep -q "$2" && echo "PASS $3" || { echo "FAIL $3"; FAIL=1; }; }
  FAIL=0
  chk "cat $D/etc/shadow" 'root:\$6\$' "root hash set"
  chk "cat $D/etc/nftables.conf" '9124, 22}' "port 22 allowed"
  chk "stat $D/usr/sbin/dropbear" 'Mode:  0755' "dropbear mode"
  chk "stat $D/etc/shadow" 'Mode:  0640' "shadow mode"
  chk "ls $D/etc/systemd/system/getty.target.wants" 'serial-getty@ttyS2' "serial getty enabled"
  chk "ls $D/etc/systemd/system/multi-user.target.wants" 'dropbear.service' "dropbear enabled"
  chk "ls $D/etc/systemd/system" 'hubreg.service' "cloud masked"
  chk "cat $D/home/root/.ssh/authorized_keys" 'ssh-ed25519' "ssh key installed"
  chk "cat $D/etc/wpa_supplicant/wpa_supplicant-wlan0.conf" 'ssid="testnet"' "wifi config"
  [ -n "${KEEP:-}" ] && cp "$IMG" "$KEEP"
  [ "$FAIL" = 0 ] && echo "SELFTEST OK" || { echo "SELFTEST FAILED"; exit 1; }
}

case "${1:-}" in
  check) cmd_check;; backup) shift; cmd_backup "$@";; patch) shift; cmd_patch "$@";;
  selftest) cmd_selftest;; *) sed -n '2,13p' "$0"; exit 1;;
esac

What I have and haven’t verified

  • Verified on my hub, by hand: the SPL DFU read and write (byte-for-byte read-back comparison, including a full 7.28 GiB pull), editing the rootfs with debugfs, a working root login, dropbear SSH, joining my Wi-Fi, and stripping the cloud services.
  • Verified for the script: its selftest builds a fake ext4 image with an OSTree-style layout and checks that every edit lands with the right mode and ownership, that the nftables edit works, and that e2fsck is clean afterwards. That test downloads the real Debian packages. It proves the file-editing logic, not the hardware.
  • Not verified: running the whole script end to end against a hub. In particular, the by-hand version I used enabled the Wi-Fi live, over the UART; the offline Wi-Fi route in the script (a new config plus the template unit) is based on what I found afterwards on the running system and has not been tried from cold. It also assumes the same OSTree layout, /home/root home directory and unit names as my unit. If it fails, you still have the UART shell and the backup.
  • Bullseye has reached end of life, so the script falls back to archive.debian.org for the dropbear packages if the main mirror no longer carries them.

After it boots

You get a Debian-ish OSTree system with a root shell on the UART and SSH over Wi-Fi. A few things I found useful:

  • LEDs. There are three: hub:green:led1, hub:blue:led2, hub:red:led3. The blinking blue is just the kernel’s timer trigger set in the device tree, not an application. Write none to trigger before you set brightness or the trigger overrides you. The kernel also has panic (lights on a kernel panic, no userspace needed) and netdev (flashes with interface activity), which suit this nicely.
  • A real distribution. I built a Debian 12 arm64 root with debootstrap --foreign on the PC, copied it across, and ran the second stage on the hub itself in a busybox chroot, which avoids needing qemu. mkfs.ext4 -d populates an image from a directory with no loop mount. Note that dropbear has no sftp-server, so use cat file | ssh host "cat > dest" rather than scp.

What doesn’t work

  • Zigbee. The EFR32MG21 is wired to the SoC over SPI (EZSP-SPI) and runs Hive’s own NCP firmware. The kernel driver gets into a retry loop (SPI response failed: -11) at around 180 s of uptime on every boot I observed. My goal was to use the hub as a router extending my existing Zigbee network. I could not get it to join as one: the host-side library I used has no router-join path, and replicating Silicon Labs’ own join sequence still failed against the stock NCP firmware. Every community EFR32 firmware image I could find is UART-only, so nobody has published SPI firmware to replace it. Reflashing it needs the 10-pin programming header and EFR32-side pin names I don’t have. I stopped there on purpose.
  • A full custom boot chain. Still blocked by the TF-A SP_RESPONSE timeout.

If you try this and find anything the script gets wrong, I’d like to hear about it, particularly if you have the schematic for the Zigbee side.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.