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(fordebugfs),openssl,python3andcurl. - 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:
- 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.
- 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 thedfu_alt_info_emmctargets 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 anddfu-utilreports a clean “Upload done”. Cast tou64before multiplying.common/spl/Kconfig: newSPL_DFU_MMCoption.common/cli.c: two#ifdef CONFIG_CMDLINEchanged toCONFIG_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_defconfigand enableCONFIG_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”) andCONFIG_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
- Unplug the hub. Tie pad B7 to 3.3 V.
- Plug the USB-C cable into the PC.
dfu-util -lshould show a TI device withbootloaderandSocIdalt-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.txtinside the rootfs (a symlink toloader.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 nowhereon every boot), sofw_setenvand the A/B boot counter do nothing useful. Keep your DFU backup as the safety net. - The stock firmware has a
factory-restore.servicethat, if/boot/uboot/factory.rstexists at boot, runsblkdiscard -sonmmcblk0p4. 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:
- Root password.
/etc/shadowsaysroot:*, so nothing can match. Replace it with anopenssl passwd -6hash. - Serial login. Only the framebuffer getty was enabled. Link
serial-getty@ttyS2.serviceintogetty.target.wants; the AM6232’s debug UART then gives you a login prompt at 115200 8N1. - 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 withEarly exit: String too longand 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. - The firewall.
/etc/nftables.confallows only the cloud ports (svc_tcp_ports = {8883, 9124}) and rejects everything else, by design. Add 22. - Wi-Fi. The stock Wi-Fi is a standard
wpa_supplicant, but wrapped in Hive’s own scripts, and something in it (most likelysupplicant-error-action.sh, which has a.restorebackup 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_scanis read only at start-up. The fix is to bypass Hive’s wrapper completely: a normalwpa_supplicant-wlan0.confin/etc/wpa_supplicant, the Debianwpa_supplicant@wlan0template unit enabled, and the Hive wrapper (wpa-supplicant-ctl.service) masked. - 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 leftnanohub-hostname.servicealone 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
selftestbuilds 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 thate2fsckis 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/roothome 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.orgfor 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’stimertrigger set in the device tree, not an application. Writenonetotriggerbefore you setbrightnessor the trigger overrides you. The kernel also haspanic(lights on a kernel panic, no userspace needed) andnetdev(flashes with interface activity), which suit this nicely. - A real distribution. I built a Debian 12 arm64 root with
debootstrap --foreignon the PC, copied it across, and ran the second stage on the hub itself in abusybox chroot, which avoids needing qemu.mkfs.ext4 -dpopulates an image from a directory with no loop mount. Note that dropbear has nosftp-server, so usecat 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_RESPONSEtimeout.
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.
















































































































































