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.

Posted on — Leave a comment

HP 8xSFF SAS Backplane Reverse Engineering, and the Pico Bay Controller Behind It

The whole backplane: eight drive connectors in two banks of four, with the HP logo in the middle.
The whole backplane: eight drive connectors in two banks of four, with the HP logo in the middle.

I’m building a 2U NAS around two HP DL380 G7 eight-bay small form factor drive cages, and each cage comes with an HP 496074-001 SAS backplane. The drives sit happily on it, but the three little LEDs in each bay (blue, green and amber) are controlled by two tiny microcontrollers speaking a proprietary protocol, and I wanted to drive them myself. So this turned into a reverse engineering project: work out how the backplane really works, replace its firmware with something I understand, and draw the schematic, because I couldn’t find a published one.

This is a long post because it covers a few days of poking. Everything here was measured on my own boards. Where I’m guessing, I say so.

What’s on the board

Each backplane has two PIC18F24K20 microcontrollers (28 pins, no crystal, running from their internal oscillator). PIC A looks after bays 1 to 4 and PIC B looks after bays 5 to 8. The 3.3 V rail comes from a small linear regulator, a TC1264-3.3 (800 mA, SOT-223, marked 1264-33 / VDB1006) fed from the 5 V pins of the power connector.

There is a 10-pin Mini-Fit Jr power connector (J13, the only Molex part on the board) that carries more than power: two of its pins are an I2C bus and another is an “address strap” analogue input. There are also a 3-pin I2C header (J44), two plain 0.1 inch 2-pin headers (J14 beside PIC A, and an unpopulated J43 beside PIC B), and two ICSP programming headers (J11 for PIC A and J12 for PIC B). The two SAS connectors are SFF-8087 types, J1 (silkscreen IPASS1) feeding PIC A and bays 1 to 4, and J6 (IPASS2) feeding PIC B and bays 5 to 8.

The top of the board, with the power connector, regulator and the two PICs.
The top of the board, with the power connector, regulator and the two PICs.
PIC A (U1 on the silkscreen) with its ICSP header J11 and the small J14 header, next to the power connector.
PIC A (U1 on the silkscreen) with its ICSP header J11 and the small J14 header, next to the power connector.
The second PIC (U2) and the small resistors around it.
The second PIC (U2) and the small resistors around it.
A PIC beside one of the SFF-8087 connectors (the silkscreen reads IPASS), with the sideband traces running to the chip.
A PIC beside one of the SFF-8087 connectors (the silkscreen reads IPASS), with the sideband traces running to the chip.
The silkscreen: 'SAS Backplane', copyright 2007-2008 Hewlett-Packard, with the PCB part number and the HDD1 and HDD2 connectors.
The silkscreen: ‘SAS Backplane’, copyright 2007-2008 Hewlett-Packard, with the PCB part number and the HDD1 and HDD2 connectors.
Block diagram of the backplane
The block diagram. Green lines are verified on the board, amber dashed lines are inferred, grey dotted lines are not traced yet.

Reading the chips

I used a Microchip ICD 4 with MPLAB X’s command-line programmer. A few practical notes, since they cost me time:

  • The ICD 4 does not power the target, so the backplane needs its own bench supply, and I ended up needing the 12 V rail connected as well.
  • Pin 1 orientation on the ICSP header matters, and I got it wrong the first time.
  • While the ICD 4 is attached it holds the PIC in reset. Unplug the lead to let it run.
  • The programming lines are the same pins the PICs use to detect whether a drive is fitted in two of the bays, so pull any drives out of those bays before you programme.

The first good news was that neither chip is read protected. Before doing anything else I took a full backup of both chips (program memory, configuration bits, user ID and the little data EEPROM) and kept it somewhere safe. I’m not publishing the dumps, because it’s HP’s firmware, but I did compare them: the program code on the two chips is identical, and only the EEPROM differs, in a handful of bytes that tell each chip which half of the backplane it is.

Finding out what the firmware does

There’s no source, so I wrote a small PIC18 disassembler and read the start-up code. That gave me the list of which pins are outputs and which are inputs, and the rest followed from watching the board:

  • Thirteen outputs: the LED pins, plus one that toggles constantly (I’ll come back to that).
  • An I2C slave on the usual hardware pins.
  • Two analogue inputs, and a handful of digital inputs.

The oddest discovery was how the chip decides its I2C address. It doesn’t use a fixed address or a jumper. It measures a voltage on one pin, averages four ADC readings, and uses the result to pick an address from a table. That voltage comes from pin 9 of the power connector. It goes straight to PIC A, and through a 750 Ω resistor to PIC B, with 5.6 kΩ to ground on each chip’s input. PIC B also reads a voltage with pin 9 grounded, which that network can’t produce on its own, so I think there is a pull-up on it that I haven’t found yet. With pin 9 floating, both chips answered at the same address and collided. Grounding pin 9 live, with the board running, moved them to two separate addresses and I could talk to each chip individually.

The protocol itself is a simple framed command set: a type byte, a length, a command and a checksum that is the two’s complement of the sum. Replies start with a status byte. The documented behaviour I found was a firmware version query, an LED self-test that chases through every LED in each bay, a command that lights the blue “locate” LEDs, and some housekeeping queries.

The mistake

One command in the stock firmware writes to the chip’s EEPROM. I sent it a few times with junk while trying to work out what it did, and the chip’s behaviour changed afterwards. I’d corrupted its configuration area. It was recoverable only because I had backed everything up first.

The restore itself taught me something worth passing on: with Microchip’s ipecmd, the option that programs only the EEPROM (-ME) also erases the flash and resets the configuration bits. The safe way to restore is to programme the whole original hex file with the plain programme option and then read everything back and compare. I did that, and the round trip came back byte-for-byte identical.

The lesson is the usual one: back up first, and don’t send commands you don’t understand to firmware you can’t easily reflash.

Mapping the LEDs and the presence pins

Each bay has three LEDs and one “is a drive fitted” input. I found them by lighting one pin at a time and watching, and by hot-swapping drives while reading the port registers. All of the LED pins are active low (pulling the pin low lights the LED), and the drive-detect pins are active low too.

Bay (PIC A / PIC B) Blue (locate) Green (activity) Amber (error) Drive detect
1 / 5 RA2 (pin 4) RA4 (pin 6) RC2 (pin 13) RA7 (pin 9)
2 / 6 RA3 (pin 5) RA5 (pin 7) RB3 (pin 24) RA6 (pin 10)
3 / 7 RB5 (pin 26) RC0 (pin 11) RC7 (pin 18) RB7 (pin 28)
4 / 8 RB4 (pin 25) RC1 (pin 12) RC6 (pin 17) RB6 (pin 27)

Drive detect turned out to be neat. Pin 5 of each drive’s power connector (a ground pin on the drive side) goes through a 330 Ω resistor to the PIC’s detect pin, and the pin sits at about 7.5 to 10 kΩ to ground with no drive fitted, so a pull-up is implied. Fit a drive and its ground pulls the pin low. All eight bays are the same.

One thing I want to be clear about: drive activity is not wired to any input on the PICs. A drive with no data link never changed any pin apart from insert and remove. Early on I wrongly concluded that the PICs couldn’t see drives at all, from a run where I hadn’t actually moved the drive, and I invented a theory to explain it. I retracted both when I realised. Bad science on my part, and I’m glad I caught it.

Replacing the firmware

I wrote a replacement in C for the XC8 compiler. It is an I2C slave with a pointer-and-auto-increment register map, so a host can set a colour mask for each LED group, read the raw port pins (which tells you which bays have drives fitted), read the two ADC channels, and, for debugging, take direct control of the port latches. The two chips of one backplane get consecutive addresses (0x30 and 0x31 on the first, 0x32 and 0x33 on the second), set at build time. I flashed all four chips across the two backplanes, verified each byte for byte, and ran a slow colour twinkle across both as a test. The original images remain restorable.

The host side started life as an ESP32 acting as an I2C master. It has since been replaced by a Raspberry Pi Pico 2, which also runs the fans and the front panel; that is the second half of this post. On the first cage the register interface works end to end (colour masks, drive-detect and the ADC channels). The chips on the second cage are flashed and verified, but I haven’t exercised their registers yet.

The side channel: SGPIO

The weird output that toggles constantly bothered me. In the stock firmware, one interrupt handler on a single input pin runs a long state machine that alternates between sampling one input and driving that output. Four pins, one clock, one load strobe, one data in and one data out. That is exactly the shape of SGPIO (SFF-8485), the serial bus a SAS controller uses to tell a backplane which LEDs to light.

The decoded behaviour per bay is three bits each way. From the controller, the three bits drive the green activity, blue locate and amber error LEDs. Back to the controller, the chip reports drive presence. That also explains why the stock I2C interface only exposes the blue LED: green and amber were meant to come from the HBA.

The pin mapping, which I confirmed with a meter:

PIC pin Role SFF-8087 pin on J1 / J6 Verified how
23 (RB2) SClock A8 (SB0) matched-length trace pair seen, not buzzed (the row is hidden under the connector)
21 (RB0) SLoad A9 (SB1) same matched pair
22 (RB1) SDataOut B10 (SB4) buzzed
16 (RC5) SDataIn B11 (SB5) buzzed

The standard sideband assignment for these connectors comes from a 2007 T10 document, and the pins I could reach (B row) matched it exactly: B7, B9 and B12 are ground, B10 goes to pin 22 and B11 goes to pin 16. The A row sits underneath the surface-mount connector body where I couldn’t get a probe in, but the two lines that I couldn’t reach arrive as a length-matched pair at PIC pins 23 and 21, which fits a clock and a strobe.

Does it work with my HBAs? I don’t know. The cards are LSI 9211-8i (SAS2008), and reports of whether those drive the sideband in IT mode are mixed. I’ve built a passive capture mode for the replacement firmware that counts clock edges and records raw samples over I2C, so the first test will be a definite yes or no. It isn’t flashed yet.

The LED circuit

Then I started tracing the LED network by hand, with the board powered down, measuring resistance and diode drops in circuit. This is the awkward part: much of the board is hidden under surface-mount parts and I only managed one bay properly.

  • Green and amber: the anode goes to the 3.3 V net, and the cathode goes through a 68 Ω resistor straight to the PIC pin. That is about 19 mA per LED, comfortably inside the PIC’s limit.
  • Blue: the anode goes directly to the 5 V rail. The cathode goes through a 120 Ω resistor to the drain of a small SOT-23 N-channel MOSFET. The source of that MOSFET goes straight to the PIC pin, and its gate is tied permanently to 3.3 V.

That FET is a nice little piece of design. A blue LED needs about 3 V, which a 3.3 V supply can barely give, so HP runs it from 5 V. But a 3.3 V microcontroller pin must not see 5 V. With the gate held at 3.3 V, the transistor conducts when the PIC pulls its pin low (lighting the LED) and switches off when the pin is high, so the 5 V side never reaches the chip. There are eight of them, one per blue LED: Q7, Q17, Q6, Q8, Q3, Q9, Q4 and Q5 for bays 1 to 8. Each bay also has a dual LED (CR1, CR3, CR5 and so on) and a separate green one (CR2, CR4, CR6 and so on). The part is marked 701 on mine, while the ON Semiconductor 2N7002L datasheet lists the marking as 702, so I’m treating it as a 2N7002L-class device until I find out otherwise.

There are also two 100 µF electrolytic capacitors by every drive connector, one on the 5 V rail and one on the 12 V rail.

The 100 µF electrolytic capacitors beside a drive connector.
The 100 µF electrolytic capacitors beside a drive connector.

I’ve only measured bay 1 on PIC A. The rest of the board is drawn on the schematic as assumed identical (amber and dashed) rather than measured, and I’d rather the drawing said so than pretended otherwise.

The schematic

I generate the drawing from a script, so it can be corrected as I trace more of the board. There are nine A3 sheets in the PDF. The first is the block diagram above, the next three are the wired component-level schematics, and the remaining sheets are the pin maps, the connector pinouts, the per-bay table and the firmware interface.

Schematic sheet 2: power, I2C and drive power
Sheet 2: power connector, regulator, I2C bus and the drive power circuit.
Schematic sheet 3: PIC A circuit
Sheet 3: the PIC A circuit, with every LED branch, the drive-detect lines and the J1 sideband.
Schematic sheet 4: PIC B circuit
Sheet 4: the PIC B circuit, the same layout for bays 5 to 8 and J6.
Connector pinouts, with a render of the power connector
The connector pinouts, including a render of the female 2×5 Mini-Fit Jr power connector with its pin numbers.
Per-bay pin table and the SGPIO frame
The per-bay pin table and the SGPIO frame as the stock firmware handles it.

Reading it is straightforward: black solid lines are measured, amber dashed lines are inferred, and grey dotted lines have not been traced. The reference designators J1, J6, J11, J12, J14 and J44 come from the board’s silkscreen. Anything with an asterisk, like Q1*, RB1* or DG2*, is a name I invented to keep the drawing readable.

The PDF is at the end of the post.

Controlling it all: from an ESP32 to a Pico 2

The backplanes now speak a simple I2C register protocol, but something has to talk to them, and the same something has to run four case fans, read temperature probes and press the server’s power button. I started with an ESP32, then realised I had Raspberry Pi Pico 2 boards on the shelf and no WiFi requirement, so the controller is now a plain Pico 2 that plugs into a spare internal USB header on the motherboard. USB powers it and carries all the traffic: status, configuration, alerts and firmware updates.

What hangs off it:

  • I2C to the two backplanes (the J44 headers), which gives the NAS control of every bay LED.
  • Four PWM fans with tach, each through one 2N3904 that pulls the PWM line low (open collector), so it works with any fan pull-up. A jumper decides what the fans do if the Pico is off or hung: they go to full speed.
  • A 1-Wire bus for DS18B20 temperature probes.
  • Power and reset: two transistors across the front-panel header pins, in parallel with the real buttons, plus drivers for the 12 V LED rings in the case buttons and sensing of the motherboard’s own power and drive-activity LED signals.
Pinout of the Pico 2 bay controller, with every used pin labelled and wired to its connector.
The pinout, in the style of the official Pico diagram: fan PWM and tach interleaved on GP6 to GP13, 1-Wire on GP14, I2C on GP4 and GP5.

A few design decisions that are worth passing on:

  • I don’t rely on the RP2350’s internal pull resistors. The A2 stepping has a known erratum that makes the pull-downs unreliable, and pull-ups are weak for a fan tach anyway, so the tach lines have a real 10 kΩ and the 1-Wire bus a real 4.7 kΩ.
  • I swapped some pins from my first plan so that fan PWM and tach pins alternate. That makes the wiring to each fan header run straight across instead of crossing, which matters when you are building it on stripboard.
  • The fan headers are stacked in one column, so the four 12 V pins (and the four grounds) can be joined by a heavy wire under the board.
The Pico bay controller schematic, drawn with real wires in functional blocks.
The controller schematic, drawn with real wires rather than net labels: one block per fan, the I2C and 1-Wire headers, the two panel LED stages, the two button circuits and the 12 V input. The full-size PDF is at the end of the post.

I generated the drawing from a script and checked it electrically: it knows the intended connections, and it fails if any wire shorts two nets, leaves one open or crosses another. A stripboard layout then came out of the stripboard-editor.com solver at 31 by 49 holes, after a couple of attempts. None of this has been built yet, so treat it as a design, not a tested board.

Fans that learn

The Pico runs the fans itself, so they keep working if the NAS is off or crashed. Each fan has a temperature curve of up to eight points, hysteresis so it doesn’t hunt, and slew limiting so it doesn’t lurch. Three temperatures matter: a target the fan should settle at, a limit above which it adds 10 per cent per degree on top of the curve, and a critical temperature at which it goes to full speed.

On top of the curve there is a learned correction. Every thirty seconds, if the zone is warmer than the target, the correction for the current temperature band (2 °C wide) goes up a step; if it is comfortably cooler, it goes down. Over hours the fan finds the quietest speed that holds the target, band by band. It is saved to flash at most every fifteen minutes, so the flash isn’t worn out.

Calibration measures each fan’s start speed and its RPM at every speed, and it also tests the polarity. Fans differ: some run faster at a low PWM duty (inverted). It runs the fan at 20 per cent and then 80 per cent, and if the fan is faster at 20 it flips the software invert flag for that fan.

When a fan dies

A fan that is being driven but sends no tach pulses for eight seconds is treated as failed. Most servers respond by sending every other fan to full power, which is loud. I wanted something calmer: fans are grouped by what they cool, and when one fails the healthy fans in the group are raised by the ratio of total to healthy (so with four fans and one failed, the others go up by a third), capped at 85 per cent by default. Learning pauses while they are covering, so the learned curve isn’t polluted. A critical temperature still means full speed.

The Pico also notices a fan that is turning, but far too slowly (under half its calibrated speed for thirty seconds) and reports it. Every change of state goes into a numbered ring of events and is sent to the NAS straight away, so a host that was busy can still ask for what it missed.

NAS//OS: the control panel

The NAS operating system I’m writing is the control panel for all this. You create zones: a zone is a set of fans plus whatever they cool, drives by cage or bay and temperature probes by their unique ID. The NAS sends the Pico the hottest drive temperature in each zone from SMART, and keeps the Pico’s configuration in step with the zones by comparing what it reads back, so a Pico that has lost its settings is put right on the next poll. Failed fans, slow fans, a zone above its limit, a missing probe or a lost link raise an e-mail alert, and the alert clears when the problem does.

Telling the probes apart is the awkward bit with 1-Wire, because they are identical. The NAS has a “find a probe” button: it remembers every probe’s temperature, you warm one in your hand, and the one that heats up is highlighted so you can name it and say where it sits.

I was caught out on one thing: I’d written my own 1-Wire routines, JSON writer and checksum in the Pico firmware. Then I looked at what the ecosystem already provides and replaced them with the OneWire, DallasTemperature, ArduinoJson and CRC32 libraries. It’s less code to maintain and far less to get wrong.

It has been tested against simulations, not hardware: unit tests on the control logic (failure cover, limits, polarity detection), a whole-firmware simulation on the PC with a model fan and a thermal model, and 38 checks of the NAS side against a simulated Pico. The real test starts when I build the board.

What I still don’t know

  • Whether my 9211-8i cards drive the SGPIO lines at all (test pending).
  • The pad-level confirmation of the sideband clock and load pins (hidden row).
  • Where the SAS lanes go (they bypass the PICs).
  • The LED network on every bay except the first.
  • What J14 and J43 are for. Pin 1 is ground and pin 2 goes through a 10 kΩ resistor to pin 26 (RB5) of the nearest PIC, with no divider to ground. That pin is also the blue LED output for bay 3 (bay 7 on the second chip), which makes me suspect a factory test tap, but I haven’t proved it.
  • What the PICs’ analogue input RA1 is for. On the second chip it is a 1 kΩ and roughly 8.2 kΩ divider with a capacitor, reading a constant 0.891 of the supply, and on the first chip the trace vanishes down a blind via. My guess is a fixed hardware-ID strap, a bit like the address strap, but I haven’t proved it.
  • Pull-up values, the regulator’s capacitors and the PIC B strap divider.
  • How the Pico controller behaves on real hardware: the board is not built yet, and I haven’t run the 1-Wire library against a real probe on the RP2350.

What I’d do differently

  • Back up the chips before touching anything, and verify the backup by reading it back.
  • Don’t experiment with write commands on the only copy of the firmware.
  • Check that I have actually changed the physical state before drawing conclusions from a log.
  • Draw the schematic as you go. Doing it properly exposed a couple of places where I’d misattributed a connection in my notes, such as putting the interrupt on the wrong pin and the FETs on the wrong LED colour.

I’ll follow this up when the SGPIO capture test is done, when I’ve been through the rest of the bays with a meter, and when the controller board has been built and has met a real fan failure.

Downloads

Posted on — Leave a comment

ESPHome Environmental Sensor Prototype – ESP32-C3, SCD41, SEN55 & OLED

Environmental Sensor Prototype
Environmental Sensor Prototype

I’ve had a CO₂ monitor in the master bedroom for a while now, built around a Sensirion SCD41 on a plain ESP32 dev board running ESPHome, and feeding Home Assistant. Since then it has picked up a Sensirion SEN55 for particulates, VOC and NOx (I’ve had one of those apart too – [link to the SEN55 teardown once it’s published]), and I finally got round to what I really wanted – a display on the unit itself, so I can see the numbers without opening a dashboard. That meant a different board: an ESP32-C3 with a 0.42″ OLED built in, and a new name to match, since it’s no longer just a CO₂ sensor. To be clear up front, this is still a prototype – everything is on a breadboard for now, with no enclosure – so treat this as a build log rather than a finished design.

The OLED on these boards is tiny – a 72×40 pixel window in the middle of a standard SSD1306’s 128×64 RAM – and it’s hard-wired to I²C on GPIO5 (SDA) and GPIO6 (SCL). Those two pins are broken out to the header as well, so the sensors just share the same bus: the OLED at 0x3C, the SCD41 at 0x62 and the SEN55 at 0x69. One thing to watch is that the SEN55 needs 5V on its supply pin (from the board’s 5V pin), with the I²C lines pulled up to 3.3V. The SCD41 is happy on 3.3V.

As it stands, everything is just plugged into a breadboard, with the big SEN55 module out at one end and the SCD41 breakout sat next to the ESP32 board. It works well enough for developing the software and finding out what needs fixing, which is exactly what this stage is for.

ESPHome already has a “SSD1306 72×40” display model, which applies the column offset for the odd panel position by itself, so the display config is short. Two differences from the old board: the C3 has native USB rather than a USB-serial chip, so the logger has to be pointed at USB_SERIAL_JTAG, and the 26MHz crystal override the old board needed doesn’t apply any more (this one reports 40MHz like it should).

i2c:
  id: i2c_bus
  sda: GPIO5
  scl: GPIO6
  scan: true

logger:
  hardware_uart: USB_SERIAL_JTAG

display:
  - platform: ssd1306_i2c
    id: oled
    model: "SSD1306 72x40"
    address: 0x3C
    update_interval: 1s

There isn’t room for more than one reading at a time, so the display cycles through nine pages, three seconds each: CO₂, temperature, humidity, PM1.0, PM2.5, PM4.0, PM10, VOC index and NOx index. Rather than nine separate pages, the drawing code is a single lambda with a small table of label, unit, sensor and decimal places, and a global page counter that a 3 second interval bumps along. Anything without a value yet shows dashes instead of a bogus number.

Readings Close Up
Readings Close Up

The first thing I noticed was that the zeros looked like the letter D. On a 1-bit display with no anti-aliasing, at 10 or 11 pixels high, Roboto renders a zero as a plain rounded box – and that’s exactly what a D looks like too. I rendered the glyphs at the real pixel size with no smoothing before touching the hardware (below). Switching to Roboto Mono, which has a slashed zero, doesn’t help at that size: the slash collapses into a horizontal bar and the zero looks like an 8 instead. Inconsolata Bold keeps a clearly recognisable slashed zero at this size, so that’s used for the small text (at 11px). Roboto Mono Medium works fine for the big value at 22px, where there’s room for the slash to survive. At that size the widest thing the display needs to show is a five character value like 123.4, which is 66 pixels wide, so it just fits in the 72.

Zeroes At 10-11px – Roboto, Roboto Mono, Inconsolata Bold
Zeroes At 10-11px – Roboto, Roboto Mono, Inconsolata Bold

The next problem is more about physics than fonts. Humidity depends on temperature, and the SCD41 works its relative humidity out using its own reported temperature. If that temperature is reading warm – from the sensor’s own self-heating, say – the humidity comes out low. I’d been correcting this with a flat offset on each: a temperature offset, and a humidity offset of +3%. But that’s only right at one particular temperature and humidity.

What actually stays constant is the amount of water vapour in the air. Correct the temperature, and the relative humidity follows from the ratio of saturation vapour pressures at the two temperatures, using the Magnus formula for the saturation pressure:

RH_corrected = RH_raw * es(T_raw) / es(T_corrected)
es(T) = 6.112 * exp(17.62 * T / (243.12 + T))   [hPa]

In ESPHome, that’s a template sensor that reads the raw temperature and humidity and the live-tunable temperature offset:

lambda: |-
  if (id(humidity_raw).has_state() && id(temp_raw).has_state()) {
    auto es = [](float t) { return 6.112f * expf(17.62f * t / (243.12f + t)); };
    float t_raw = id(temp_raw).state;
    float t_corr = t_raw + id(temp_cal_offset).state;
    float rh = id(humidity_raw).state * es(t_raw) / es(t_corr)
             + id(humidity_cal_offset).state;
    return clamp(rh, 0.0f, 100.0f);
  } else {
    return {};
  }

Checking this against the numbers I had from the old board: with a -0.8°C temperature offset, the physics alone gives +2.68% RH at those conditions, against the flat +3.0% I’d dialled in by hand. So the old offset was almost entirely doing this job anyway, and the humidity offset is now just a small residual trim (I’ve set it to 0.5%). The difference is that this now follows the temperature: at 26°C and 45% RH the correction is about +2.2%, at 20°C and 60% it’s about +3.1%, and a flat offset can’t do that.

To check the calibration I sat the sensor about a metre from a Sonoff temperature and humidity sensor in the living room. I’d like to say the readings matched straight away, but they didn’t – I’d just been handling it, and the sensors take a good while to settle after being picked up. Body heat warmed the temperature reading, and my breath sent the CO₂ up to 2005ppm on the first readings, which had dropped to around 660ppm in under half an hour. It’s worth waiting for things to settle before touching any offsets. Once it had, the numbers agreed well. This is all with the bare breadboard layout, in free air. The SCD41’s temperature offset is a compile-time setting, and ESPHome’s default of 4°C is intended for a bare board in free air – once this goes in an enclosure, self-heating will be worse (more like 6-10°C as a rule of thumb), so all of these numbers will need going through again.

One thing the SEN55 can do that I hadn’t exposed is a forced fan clean, which spins the fan at maximum speed for about 10 seconds to blow dust off the blades. The sensor does this by itself every week by default, but it’s useful to trigger manually after a fan speed warning or a dusty spell. ESPHome has an action for it (sen5x.start_fan_autoclean), so there’s now a button in Home Assistant, and also a long press (2 seconds or more) of the BOOT button on the board – GPIO9, so it’s just a normal input while the chip is running. Both call the same script, which sets a flag so the OLED shows “Cleaning” for the duration instead of PM readings that the cleaning burst would skew, and ignores a second trigger if one is already running.

script:
  - id: sen55_fan_clean
    mode: single
    then:
      - globals.set:
          id: fan_cleaning
          value: 'true'
      - sen5x.start_fan_autoclean: sen55
      - delay: 12s
      - globals.set:
          id: fan_cleaning
          value: 'false'

binary_sensor:
  - platform: gpio
    internal: true
    pin:
      number: GPIO9
      mode: INPUT_PULLUP
      inverted: true
    filters:
      - delayed_on: 30ms
    on_click:
      min_length: 2s
      max_length: 60s
      then:
        - script.execute: sen55_fan_clean

ESPHome’s SEN5x component doesn’t expose the sensor’s device status register, so I read it directly over I²C once a minute and turn the bits that matter into diagnostic binary sensors in Home Assistant: fan speed warning, fan failure, laser failure and gas sensor error. The fan and laser failure bits are sticky – they stay set after the fault clears until the register is cleared or the sensor is power cycled – so there’s a button that sends the clear command as well. There’s also a “Sensor Runtime” counter, stored in flash and added to every five minutes, since the uptime sensor resets on every boot and these sensors have a finite service life. It was seeded with the hours from the old board when the sensors moved over, and there’s a button to zero it when a sensor gets swapped.

A couple of gotchas from the changeover. Home Assistant treats a new board as a completely new device, with all-new entities and no history. I removed the old device, added the new one, and then renamed the new entities back to the old entity IDs, so the automations, dashboard cards and recorded history carried straight over. And on the very first boot the SCD41 didn’t respond at all – ESPHome marked it as failed in the log, and a failed component isn’t retried until the next reboot. A reset was all it needed, and it’s been fine since.

The full config is below, in case it’s useful. As this is still a prototype, expect it to change as the build develops.

Full Config

esphome:
  # Node name is capped at 31 chars by ESPHome, so "environmental" is abbreviated
  # here - the user-facing name below is the full one.
  name: master-bedroom-env-sensor
  friendly_name: "Master Bedroom Environmental Sensor"

esp32:
  # ESP32-C3 board with the integrated 0.42" (72x40) SSD1306 OLED. No board
  # definition exists for the specific module, so the generic C3 devkit is used.
  # (The old esp32dev board's 26MHz crystal override doesn't apply to the C3.)
  board: esp32-c3-devkitm-1
  variant: esp32c3
  framework:
    type: esp-idf

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

logger:
  # The C3 has native USB and no UART bridge, so the default UART0 logger is not
  # what the USB port is wired to.
  hardware_uart: USB_SERIAL_JTAG

api:

ota:
  - platform: esphome
    password: !secret ota_password

web_server:
  version: 3

# The OLED is hard-wired to GPIO5 (SDA) / GPIO6 (SCL) on this board, so the
# sensors share that bus: SCD41 0x62, SEN55 0x69, OLED 0x3C.
i2c:
  id: i2c_bus
  sda: GPIO5
  scl: GPIO6
  scan: true

# Cumulative powered-on time, surviving reboots - the uptime sensor resets on
# every boot, which is no use for tracking sensor service life.
#
# The runtime tracks the SCD41/SEN55 hardware, not the board, and both were moved
# over from the old ESP32, so this is seeded with the old board's last reported
# value (790.917h at 2026-09-20 11:46 UTC, from HA history, just before it was
# unplugged). initial_value only takes effect on the first flash - restore_value
# takes over from flash after that.
globals:
  - id: sensor_runtime_seconds
    type: uint32_t
    restore_value: yes
    initial_value: '2847300'

  # Raw SEN55 Device Status Register (I2C command 0xD206) - not exposed by
  # ESPHome's sen5x component, read directly on the bus. Bit layout per the
  # Sensirion SEN5x datasheet section 5.4: bit 21 speed warning, bit 7 gas
  # sensor error, bit 6 RHT comms error, bit 5 laser failure, bit 4 fan
  # failure. Bits 4 and 5 are sticky - they do NOT self-clear even once the
  # fault condition resolves, only via Clear Device Status (0xD210) or a
  # power cycle.
  - id: sen55_device_status
    type: uint32_t
    restore_value: no
    initial_value: '0'

  # Index of the reading currently shown on the OLED.
  - id: display_page
    type: int
    restore_value: no
    initial_value: '0'

  # True while a SEN55 fan cleaning cycle is running, so the OLED can say so
  # instead of showing PM readings that the cleaning burst will skew.
  - id: fan_cleaning
    type: bool
    restore_value: no
    initial_value: 'false'

interval:
  # Accumulated in coarse 5-minute ticks rather than continuously: this global is
  # flash-backed, and every change to it eventually costs an NVS write. A tick
  # this size keeps writes down to ~288/day while losing at most 5 minutes of
  # the partial period on reboot - irrelevant against a service life in years.
  - interval: 5min
    then:
      - lambda: 'id(sensor_runtime_seconds) += 300;'

  - interval: 60s
    then:
      - lambda: |-
          if (id(sen55)->is_failed()) return;
          uint16_t raw[2];
          // MSW first, then LSW, per the datasheet's Read Device Status response layout.
          if (id(sen55)->get_register(0xD206, raw, 2, 20)) {
            id(sen55_device_status) = (uint32_t(raw[0]) << 16) | raw[1];
          }

  # Advance the OLED to the next reading. Keep in step with the table length in
  # the display lambda below.
  - interval: 3s
    then:
      - lambda: 'id(display_page) = (id(display_page) + 1) % 9;'

# SEN55 fan cleaning, shared by the "Clean SEN55 Fan" HA button and the long-press
# of the board's BOOT button. mode: single ignores a second trigger while a clean
# is already running instead of restarting the sequence.
script:
  - id: sen55_fan_clean
    mode: single
    then:
      - globals.set:
          id: fan_cleaning
          value: 'true'
      - sen5x.start_fan_autoclean: sen55
      # Datasheet: cleaning takes 10 seconds; hold the OLED message a little longer.
      - delay: 12s
      - globals.set:
          id: fan_cleaning
          value: 'false'

font:
  # Inconsolata Bold for the small text: at 10-11px on a 1-bit OLED both Roboto's
  # and Roboto Mono's zero collapse into a box that looks like a "D". Inconsolata's
  # slashed zero stays distinct at this size. 'PM2.5' + 'µg/m³' is 55px wide at
  # size 11, inside the 72px window.
  - file: "gfonts://Inconsolata@700"
    id: font_small
    size: 11
    glyphs: ' !%()+-_.,:/°µ³²0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
  # Roboto Mono for the big value: on a 1-bit OLED, Roboto's zero renders as a
  # rounded rectangle that reads as a "D". Roboto Mono's zero is slashed. Size 22
  # keeps a 5-character value ("123.4") at 66px, inside the 72px window.
  - file: "gfonts://Roboto Mono@500"
    id: font_big
    size: 22
    glyphs: '0123456789.-'

# 0.42" OLED: a 72x40 window in the middle of the SSD1306's 128x64 RAM. ESPHome's
# "SSD1306 72x40" model applies the column offset itself.
display:
  - platform: ssd1306_i2c
    id: oled
    model: "SSD1306 72x40"
    address: 0x3C
    update_interval: 1s
    lambda: |-
      if (id(fan_cleaning)) {
        it.printf(36, 14, id(font_small), TextAlign::CENTER, "Cleaning");
        it.printf(36, 27, id(font_small), TextAlign::CENTER, "SEN55 fan");
        return;
      }

      struct Reading {
        const char *label;
        const char *unit;
        sensor::Sensor *sensor;
        int decimals;
      };
      // One entry per OLED page - if this length changes, update the modulus in
      // the 3s interval above.
      const Reading pages[] = {
        {"CO2",      "ppm",   id(co2_sensor),      0},
        {"Temp",     "°C",    id(temp_corrected),  1},
        {"Humidity", "%",     id(humidity_corrected), 1},
        {"PM1.0",    "µg/m³", id(pm_1_0),          1},
        {"PM2.5",    "µg/m³", id(pm_2_5),          1},
        {"PM4.0",    "µg/m³", id(pm_4_0),          1},
        {"PM10",     "µg/m³", id(pm_10_0),         1},
        {"VOC",      "index", id(voc_index),       0},
        {"NOx",      "index", id(nox_index),       0},
      };
      const Reading &r = pages[id(display_page) % 9];

      // Label top-left, unit top-right, value filling the rest.
      it.printf(0, 0, id(font_small), TextAlign::TOP_LEFT, "%s", r.label);
      it.printf(71, 0, id(font_small), TextAlign::TOP_RIGHT, "%s", r.unit);
      if (r.sensor->has_state() && !std::isnan(r.sensor->state)) {
        it.printf(36, 27, id(font_big), TextAlign::CENTER, "%.*f", r.decimals, r.sensor->state);
      } else {
        it.printf(36, 27, id(font_big), TextAlign::CENTER, "--");
      }

# Live-tunable fine calibration, exposed to HA so you can dial these in against
# a reference thermometer/hygrometer without reflashing. Values persist across reboots.
#
# The temperature offset carries over what was dialled in on the old board
# (-0.8C). The humidity offset is now only a RESIDUAL trim: Humidity (Corrected)
# is recomputed from the corrected temperature (see below), which accounts for
# almost all of what the old flat +3.0% was doing (+2.68% at the old conditions,
# leaving ~+0.3%; 0.5 is the nearest step). initial_value only applies on first
# flash, so an already-flashed board keeps whatever value is stored on it.
# Re-check both against a reference once the sensor has settled.
number:
  - platform: template
    name: "Temperature Calibration Offset"
    id: temp_cal_offset
    icon: mdi:thermometer-plus
    unit_of_measurement: "°C"
    min_value: -10
    max_value: 10
    step: 0.1
    initial_value: -0.8
    restore_value: true
    optimistic: true

  - platform: template
    name: "Humidity Calibration Offset"
    id: humidity_cal_offset
    icon: mdi:water-percent
    unit_of_measurement: "%"
    min_value: -20
    max_value: 20
    step: 0.5
    initial_value: 0.5
    restore_value: true
    optimistic: true

sensor:
  - platform: scd4x
    id: scd41
    co2:
      name: "CO2"
      id: co2_sensor
    temperature:
      name: "Temperature (Raw)"
      id: temp_raw
    humidity:
      name: "Humidity (Raw)"
      id: humidity_raw
    address: 0x62
    update_interval: 60s
    automatic_self_calibration: true
    # Onboard self-heating compensation, fed back into the sensor's own RH calc.
    # 4C is ESPHome's own default for a bare board in free air - if this ends up
    # in an enclosure, expect more like 6-10C. Re-tune against a reference and
    # reflash once you have real comparison data; this one's compile-time only.
    temperature_offset: 4°C
    measurement_mode: periodic

  - platform: template
    name: "Temperature (Corrected)"
    id: temp_corrected
    unit_of_measurement: "°C"
    device_class: temperature
    state_class: measurement
    accuracy_decimals: 1
    lambda: |-
      if (id(temp_raw).has_state()) {
        return id(temp_raw).state + id(temp_cal_offset).state;
      } else {
        return {};
      }
    update_interval: 60s

  # RH is a function of temperature: the SCD41 computed its RH using the (biased)
  # temperature it reports as Temperature (Raw), so when the temperature is
  # corrected, the RH has to be re-derived, not just nudged by a flat offset. The
  # vapour pressure in the air doesn't change; only the saturation pressure at
  # the true temperature does:
  #     RH_corrected = RH_raw * es(T_raw) / es(T_corrected)
  # with es from the Magnus formula (hPa, valid -45..60C). The Humidity Calibration
  # Offset is then only a small residual trim on top of that.
  - platform: template
    name: "Humidity (Corrected)"
    id: humidity_corrected
    unit_of_measurement: "%"
    device_class: humidity
    state_class: measurement
    accuracy_decimals: 1
    lambda: |-
      if (id(humidity_raw).has_state() && id(temp_raw).has_state()) {
        auto es = [](float t) { return 6.112f * expf(17.62f * t / (243.12f + t)); };
        float t_raw = id(temp_raw).state;
        float t_corr = t_raw + id(temp_cal_offset).state;
        float rh = id(humidity_raw).state * es(t_raw) / es(t_corr) + id(humidity_cal_offset).state;
        return clamp(rh, 0.0f, 100.0f);
      } else {
        return {};
      }
    update_interval: 60s

  - platform: sen5x
    id: sen55
    pm_1_0:
      name: "PM 1.0"
      id: pm_1_0
    pm_2_5:
      name: "PM 2.5"
      id: pm_2_5
    pm_4_0:
      name: "PM 4.0"
      id: pm_4_0
    pm_10_0:
      name: "PM 10.0"
      id: pm_10_0
    voc:
      name: "VOC Index"
      id: voc_index
    nox:
      name: "NOx Index"
      id: nox_index
    temperature:
      name: "Temperature (SEN55)"
    humidity:
      name: "Humidity (SEN55)"
    address: 0x69
    update_interval: 60s
    acceleration_mode: low
    store_baseline: true

  - platform: template
    name: "Sensor Runtime"
    id: sensor_runtime
    icon: mdi:timer-sand
    unit_of_measurement: "h"
    device_class: duration
    state_class: total_increasing
    accuracy_decimals: 1
    entity_category: diagnostic
    lambda: 'return id(sensor_runtime_seconds) / 3600.0f;'
    update_interval: 60s

  - platform: uptime
    name: "Uptime"

  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: 60s

  - platform: internal_temperature
    name: "ESP32 Internal Temperature"
    entity_category: diagnostic
    # Chip die temperature, not ambient - runs hotter than the room due to
    # CPU/WiFi load. Diagnostic only, not a substitute for the SCD41 reading.

binary_sensor:
  # The board's BOOT button (GPIO9, active low with an external pull-up). Holding
  # it for 2s or more, then releasing, runs a SEN55 fan clean. Internal: it needs
  # no HA entity. GPIO9 is a strapping pin, but that only matters if it is held
  # low while the chip resets - pressing it while running is harmless.
  - platform: gpio
    id: boot_button
    internal: true
    pin:
      number: GPIO9
      mode: INPUT_PULLUP
      inverted: true
    filters:
      - delayed_on: 30ms
    on_click:
      min_length: 2s
      max_length: 60s
      then:
        - script.execute: sen55_fan_clean

  - platform: template
    name: "SEN55 Fan Speed Warning"
    icon: mdi:fan-alert
    device_class: problem
    entity_category: diagnostic
    lambda: 'return id(sen55_device_status) & (1UL << 21);'

  - platform: template
    name: "SEN55 Fan Failure"
    icon: mdi:fan-off
    device_class: problem
    entity_category: diagnostic
    # Sticky - stays on after a one-off blockage clears until Clear Sensor Faults is pressed.
    lambda: 'return id(sen55_device_status) & (1UL << 4);'

  - platform: template
    name: "SEN55 Laser Failure"
    icon: mdi:alert-decagram
    device_class: problem
    entity_category: diagnostic
    # Sticky - datasheet: not cleared automatically even once current is back in range.
    lambda: 'return id(sen55_device_status) & (1UL << 5);'

  - platform: template
    name: "SEN55 Gas Sensor Error"
    icon: mdi:molecule
    device_class: problem
    entity_category: diagnostic
    lambda: 'return id(sen55_device_status) & (1UL << 7);'

button:
  # Manual trigger for the SEN55's fan-cleaning routine (spins the fan at maximum
  # speed for ~10s to blow dust off the blades). The sensor already does this by
  # itself weekly; this is for after a Fan Speed Warning or a dusty stretch.
  # Only valid while the sensor is measuring, which it always is here.
  - platform: template
    name: "Clean SEN55 Fan"
    icon: mdi:fan-auto
    entity_category: config
    on_press:
      - script.execute: sen55_fan_clean

  - platform: template
    name: "Clear SEN55 Sensor Faults"
    icon: mdi:alert-remove
    entity_category: config
    on_press:
      - lambda: 'id(sen55)->write_command(uint16_t(0xD210));'

  - platform: template
    name: "CO2 Forced Calibration (420ppm outdoor air)"
    icon: mdi:target
    on_press:
      - scd4x.perform_forced_calibration:
          value: 420
          id: scd41

  # Zero the hour counter when a sensor is physically swapped out, so the
  # figure keeps tracking the hardware actually installed rather than the board.
  - platform: template
    name: "Reset Sensor Runtime"
    icon: mdi:timer-refresh
    entity_category: config
    on_press:
      - globals.set:
          id: sensor_runtime_seconds
          value: '0'
      - component.update: sensor_runtime

  - platform: restart
    name: "Restart"
Posted on — Leave a comment

Sensirion SEN55 Environmental Sensor Module Teardown

Fan & Air Ports
Fan & Air Ports

Here’s something a bit different – the Sensirion SEN55, an all-in-one air quality module which measures particulates (PM1.0, 2.5, 4.0 & 10), VOC and NOx indices, and humidity & temperature, over I²C or UART. I wanted to see what was inside without wrecking it, so this is a non-destructive teardown.

The obvious features from the outside are the fan on the left, and the two rectangular air ports on the right hand end. The fan is a YCCFAN YDM2010C05F, a 5V brushless unit rated at 0.11A. It has three wires (yellow, green and pink) rather than the usual two, and the third is probably a tacho signal, which would fit with the sensor being able to report a fan speed warning. The housing is split into 3 parts, the “top” smallest part has a HEPA filter underneath for creating clean “sheath” air, ensuring that dust does not get on the PM sensor element itself. The larger of the two parts appears to be either bonded or clipped somewhere I can’t see, so the teardown did not go any further than the top cover and the metallic shield.

Air Path
Air Path

With the shield removed, the inside of one half is a moulded air duct. The channel walls guide the airflow from the fan around to the measurement chamber, and there’s a small steel coil spring standing in a pocket in the middle of it. The spring contacts the PCB in the bottom of the hole, and ensures that the shield is electrically grounded for EMI suppression. There are lots of round holes and bosses scattered over the moulding, some of which presumably line up with the posts that hold the PCB in place in the other half.

PCB Underside
PCB Underside

The other half carries the PCB, and this side is entirely test points, with no components fitted at all – the actual chips must be on the far side, under the optical chamber. The board is held in with five black plastic posts, which look to have been heat-staked (melted over), so the PCB stays where it is unless I want to break something, which is as far as a non-destructive teardown goes. At top left is a 10-pad footprint marked TP34, which looks like a pogo-pin (Tag-Connect style) programming or test connector. The board is marked 5273025_V5, with a DataMatrix code near the bottom.

ST1 Interface
ST1 Interface

Closer in at the top right, the silkscreen gives the pinout of the module’s ST1 connector: 1: VDD, 2: GND, 3: SDA / Rx, 4: SCL / Tx, 5: Int.Sel and 6: Boot 0. There is no specification in the datasheet for UART, just I²C, and Pin 5 is specified in the datasheet as “connect to GND”. Pin 6, “Boot 0”, is interesting – presumably a bootloader entry pin for the internal microcontroller, and not something that’s needed in normal use.

Test Points
Test Points

The rest of the board is a field of numbered test points, running from TP1 up to at least TP36. That’s very much a production test fixture layout, designed for a bed-of-nails jig rather than a person with a multimeter, but they’d be useful for getting at the internal signals if you had a set of fine probes and some patience.

Fan Pads & Serial Code
Fan Pads & Serial Code

At the bottom edge, next to the fan, are three small pads, which are probably where the fan’s three wires connect to the board – the fan leads emerge close by. The DataMatrix code alongside will be for production traceability.

Having seen how directly the fan is coupled to the air path, it’s easy to see why Sensirion built a fan cleaning routine into the sensor: it runs the fan at maximum speed for about 10 seconds to shift accumulated dust, and by default the module does this automatically once a week. It can also be triggered manually through the interface.

 

Posted on — 1 Comment

Chinese Android DMD Mini Projector Teardown

Cover Removed
Cover Removed

Here’s a DMD Android-based projector from China. This has quite good resolution at 720p native, although the install of Android present is very outdated, and the internal battery has a very short life due to the power draw of the unit.

The top of the main PCB is above, and this has one half of the DMD chipset, and the SoC, Flash & RAM for the Android part of the projector.

Touchpad
Touchpad

The top of the unit has touch buttons and mousepad for operation, in addition to an IR remote, the bottom of the lid moulding has the touch controller for those buttons and pad.

Mainboard
Mainboard

Removing the mainboard shows the rest of the chipset. The large IC at bottom left is one half of the DMD control chipset, and the large IC above that is a video switch to enable either the built in Android or external HDMI interfaces. The rest of the components here are power supply and general support.

Projector Module
Projector Module

Under the mainboard is the projector module itself. Mostly heatsink just to cope with the high power LEDs, there’s a fan in the rear section that keeps everything cool.

DMD Heatsink
DMD Heatsink

The DMD module is definitely from a development kit – it’s hidden behind this small heatsink. The wheel in the foreground is for main focus.

LED Module
LED Module

On the front of the unit, behind the copper plate is the main RGB LED module, which is switched by the DMD chipset for full colour output from the main lens to the left.

Posted on — Leave a comment

Vectawave VBA1000-18

Laboratory Amplifier
Laboratory Amplifier

Here’s something that in my primary job as an EMC Test Engineer gets a lot of use – a laboratory grade amplifier for performing the Immunity testing phase of an EMC suite. I figured it was about time to crack them open & have a look at what makes them tick. I’ll start with the 100kHz-1GHz model in this post.

Main PSU
Main PSU

The main PSU providing power to the amplifier & control components is a 150W Meanwell unit in this example. While Meanwell have some fairly decent supplies, they are overall very hit & miss on reliability & especially in the compliance domain – the RSP-1000 & RSP2400 versions claim to be EMC Class B Conducted compliant, and definitely are not! They also claim Radiated Immunity to Industrial levels, but I’ve seen them fail this test, by reducing their output voltage by a factor of 10!

Cooling Fan
Cooling Fan

A 120mm 12V fan on the rear panel draws cooling air over the internal components. These amplifiers are Class A, so they do generate quite some heat due to the inefficiencies of this operational mode.

RF Amplifier Module
RF Amplifier Module

All the wideband RF magic is contained in this module. What’s unusual about these lab amps is the number of decades of operation – RF amplifiers are usually rather narrowband, and it’s very difficult to construct a wideband amplifier that has even close to a flat frequency response. There aren’t many connections; just the RF I/O connectors on SMAs, a ground, main power supply & finally an Enable input.

Control PCB
Control PCB

There’s a small PCB inside to do the safety interlocks, which are BNC connections on the back, as well as temperature monitoring & overtemperature shutdown. This just has a small PIC microcontroller & a few passives.

Main Amplifier PCB
Main Amplifier PCB

After taking the top off the amplifier module, a small PCB is all that’s in there, with single-sided construction. By the look the rear copper layer is all ground plane. Power supply & enable inputs are dealt with at the top of the board, with the pre-amp stage at bottom right, and finally the main PA stage bottom centre. This appears to be a push-pull design, with dual MOSFETs at all RF stages, and splitters/combiners at the I/O respectively.

Posted on — Leave a comment

JVC CV-F503PAL Viewfinder

EVF Bottom
EVF Bottom

It’s time for another CRT-based post! This time it’s a Matsushita 40CB4-based viewfinder from 1980. These came from JVC colour tube cameras, and are physically massive! I’ve already worked out the pinout on the multicore cable here & attached a usable connector.

Information Label
Information Label

There’s lots of information on the bottom label of this unit, including a notification in German that the X-Ray emission from the device is shielded, and the maximum CRT acceleration voltage is 8kV. This last point is a bit strange, since the 40CB4 datasheet states a maximum design voltage on the final anode of 6kV. I did measure the voltage at the anode cup, and it’s stable at 7.15kV!

Internal Overview
Internal Overview

Popping a couple of screws out & removing the enormous eye cup allows the top cover to come off. This unit is very tightly packed inside, with 3 PCBs! The magnifying lens (~45mm diameter) & mirror on the left enlarge & turn the image so the camera operator can see it. The CRT’s at the top, with a copper grounding band around the face.

CRT Face
CRT Face

The CRT face is neatly slotted into the plastic housing, with the tally LEDs to the right of it. The legends on the LEDs are naturally printed in reverse, since they’re intended for viewing through the mirror. The optical section is entirely spray painted black for better contrast & stray light control.

Electronics Area
Electronics Area

The other 5″ of the casing is packed with electronics to support the CRT. The bottom PCB even “wraps around” to the right, with copper staples connecting the boards together. The CRT tube itself is hiding inside the deflection yoke and under a foam pad at the top of the unit. It’s secured in place with a copper grounding strap wrapped around the face of the tube, and a spring. A single screw secures the strap to the case. This also provides a local earth for the outer Aquadag coating on the tube, which is the negative plate of the final anode smoothing capacitor. The other plate is formed by the coating on the inside of the CRT glass, and the dielectric is of course the glass itself.
The main anode wire snakes off from the flyback at bottom left to the anode cup at the top of the CRT, insulating the 7kV from everything else.

There aren’t many reasons to jack up the final anode voltage on a CRT, past the design voltage of the tube as JVC have done here. The only plausible one I could think of is to increase brightness of the image, but the Sony 4CB4-based units I have only run at around 3kV, and those are plenty bright enough! The downside here of course, is that with higher energy electrons and a brighter image, the phosphor of the CRT will wear out faster.
Increasing voltage also increases X-Ray emission, but even at the 8kV maximum, such X-Rays would be so weak as to not make it through the CRT faceplate glass, which is loaded with lead anyway.

There’s not much sense in yanking the tube out of this unit, it’s identical to the Sony one’s I’ve done in the past:

Bare 40CB4 CRT
Bare 40CB4 CRT
Voltage Regulator Pass Transistor
Voltage Regulator Pass Transistor

Slotted into a gap above the CRT is a large TO-220 2SD476 NPN transistor, this is part of the power circuit; it’s the series pass element regulating the input 12v down to 9v for all the remaining circuits. The aluminium sheet on the back provides some heatsinking as this part gets warm! Current draw of this unit with no video input is about 260mA.

CRT Removed & Vertical Board
CRT Removed & Vertical Board

Popping the CRT out allows a view of the Vertical & Power Board. This hosts the input linear voltage regulator, and the vertical output control circuitry. Hiding at the back on the right is a NEC µPC1031H2 Vertical Deflection Controller.

Horizontal & Video Boards
Horizontal & Video Boards

Separating the boards reveals the other two PCBs, the Video & Horizontal/HV boards. The bottom board takes care of the Horizontal output, and the lefthand board the video input, sync separation & amplification.

Horizontal Control IC
Horizontal Control IC

The Horizontal section is controlled by a Matsushita AN294 device. Unfortunately I haven’t been able to locate a datasheet for this one. The flyback transformer (Horizontal Output) is on the right, with another smaller transformer I don’t quite know the purpose of. Oddly, there’s no large switching transistor for the horizontal output stage – as the PCB notes the flyback as being a “HVM” this may in fact be more tightly integrated with some of the drive circuitry in the potting. There aren’t anywhere near as many pins on it as a usual CRT flyback has either.
This section also generates the other voltages required by the CRT – 2.8v for the filament, and ~80v & ~400v for the grids.

Video Board
Video Board

A closeup of the video board shows the relative simplicity. Only 4 transistors are doing all the video actions. There is relatively high voltage on here though – an 85v feed from the power supply which is the rail voltage for the video amplifier to the CRT cathode/G1.

Focus & Horizontal Hold Adjustments
Focus & Horizontal Hold Adjustments

There’s a few adjustments available on this unit, here are the Focus & Horizontal Frequency (Hold) pots. Amazingly, even though this unit is about 43 years old at the time of writing, the unit fired straight up when I applied power & a composite video signal, with no adjustments required at all!

Unfortunately there’s no service manual or schematics that I could find on the web for this or the camera it came from, but connections to make this unit work are very simple:

Wire ColourSignal Name
YELLOW+12V DC
BLACKGND
Coax - CoreComposite Video 1v p-p
Coax - ShieldComposite Video GND

There’s another blogger who’s done this particular viewfinder, but they didn’t dig into the unit anywhere near as far as I usually do:

https://hex.ro/wp/blog/matsushita-40cb4-mini-crt/

This blog has a lot of CRT-based viewfinder teardowns, mainly from newer cameras. It’s an interesting read.

Datasheets for the important parts (the ones I could find!) below:



 

Posted on — Leave a comment

Chinese Two-Channel Bluetooth Audio Power Amplifier Teardown

Herdio Amplifier
Herdio Amplifier

Here’s a very cheap audio amplifier, from the usual suspects in Shenzhen. This is a Herdio AMP-BTX01 Bluetooth Audio Amplifier. This is a stereo unit, but the quality is going to be piss poor!

Main PCB
Main PCB

Cracking the casing, which is just clipped together reveals the sparsely populated PCB. There’s a 12v power input on the right, along with a line-level input. The speaker outputs are on the left. Between the two are only a few components to do the audio signal processing.

Bluetooth Radio
Bluetooth Radio

There’s just the Bluetooth radio IC, an AC21BP0A931. Not much is available on this IC, I certainly can’t find a datasheet, but it has all the low-level audio stuff, a microcontroller & a 2.4GHz Bluetooth radio integrated into a single IC. There’s a 5v linear regulator hiding behind the electrolytic caps to provide a power rail for this. The only other components here are a ruck of SMD ceramic coupling capacitors in the audio lines to the amplifier ICs.

Amplifier ICs
Amplifier ICs

The final components are these Chinese FET audio amplifier ICs, rated at 15W per channel, and driving a speak each in Bridge mode – these are stereo amps.

Posted on — 2 Comments

S830 eBike LCD Display Teardown

S830 LCD
S830 LCD

I’ve been doing quite a bit with eBikes of late, given the current fuel costs in the UK, so the bike I managed to get from work needed a couple of upgrades. Above is one of the best controllers I’ve found, an S830 model. This is very full featured, with Speedometer, Odometer, Trip counter, programmable assist power levels & battery monitoring.

Rear
Rear

There’s not much on the rear panel of the device, I’m not even sure who actually manufactures these! There’s a USB port on the bottom for charging a mobile phone etc while riding. The cables are potted into the back of the casing for water protection.

Main PCB
Main PCB

Removing the 4 screws from around the perimeter of the rear panel allows the front to come off with some peeling – there’s silicone & a gasket to keep water out! The module is driven by a Nuvoton N76E003AT20 8051-based Flash microcontroller. Most of the PCB is actually taken up by the DC-DC voltage regulation to be able to handle a very wide input voltage of from around 24V – 84V depending on what battery pack the eBike has, since these are run direct from the battery. The LCD only has a few connections, so it’s probably SPI or I²C controlled.

Harness Connections
Harness Connections

All the connections into the harness are marked on the board, which might be handy should the cable need replacing. There are also a few that aren’t used at all, but all the components to support them are populated on the PCB.

Rear Panel
Rear Panel

The rear panel has the sealed-in cables, and the USB port module, which is also completely potted. It’s likely there’s another DC-DC under that potting, given there’s clearly a PCB there.

Posted on — Leave a comment

Schaffner PNW2050 Combination Wave Impulse Network Teardown

PNW2050 Impulse Network
PNW2050 Impulse Network

Here’s another module from the decommissioned impulse testing rig – the pulse forming network for the Surge generator section. Lots of high-energy high-voltage parts should be hiding in here.

Resistor Side
Resistor Side

Pulling the cover off shows there’s not a lot to the network, but it’s a collection of rather exotic parts. The row of 4 devices on the back panel are Mercury Displacement Relays – these don’t have solid contacts as such, but a pool of metallic Mercury that gets pushed up by a magnetic plunger into contact with both terminals. I’ll do a separate post on these not very common parts. A couple of small chokes wound on large nylon studding are in the centre, along with an adjustable wirewound resistor. On the right is the main energy storage capacitor, rated at 10kV – this thing is enormous, and about half the size of a house brick. The large vitreous wirewound resistor next to it is the safety discharge resistor, switched by the normally closed Mercury relay on the far right. Charging & impulse discharging are handled by the other three relays.

Relay Side
Relay Side

Another view shows a very large stack of high voltage pulse rated resistor pucks, there are three in this stack, held together by a large bolt down the centre.

Posted on — Leave a comment

MV730 GPS Tracker Teardown

Time for another Chinese gadget! This time it’s a tiny GPS tracker, designed to look like a standard vehicle relay, the MV730.

Only a partial teardown on this one, since I need the thing to work afterwards, and the unit is very tightly integrated. Not only does this unit look like a relay, it’s exactly the same size. Some serious compaction of the electronics has been done here to get things into such a small package.

GSM Module
GSM Module

On one side is the PCB with the GSM module (it’s on the other side of the board), and the SIM socket. There are also a pair of LEDs to show both GPS & GSM status. The module on this has both GPS & GSM functions, so the active antenna is connected directly to the PCB here.

There is a very small-pitch 4 pin connector just under the antenna connection, I would assume this is for a TTL serial connection to the radio module.

GPS Antenna
GPS Antenna

The GPS antenna is attached to the top of the assembly by a sticky pad, which has here been peeled off. The other vertical PCB is visible here also, which contains the power supply for the tracker. As this unit can handle anything from 9-90V (allegedly), there’s going to be some DC-DC action on that board. The serial number is also on this PCB, redacted for privacy 😉

Module Top
Module Top

even the centre of the unit is packed with parts. There’s a plastic frame for general support, and this also has the foil GSM antenna stuck to it. There’s also an 80mAh backup Lithium Ion cell, and an actual relay hiding in the middle which is for the “Fuel Cut” feature of this tracker.

Posted on — Leave a comment

Hunting Hivolt VPM 9 HV Module Teardown

Hunting Hivolt PSU
Hunting Hivolt PSU

Another quick teardown post! This is a high voltage supply module from Hunting HiVolt – a company that hasn’t been around in some time. This is adjustable, and takes a standard 230v AC mains input to output a +1kV rail & a -250V rail.

Label End
Label End

There is a rating plate with some identifying marks, but I’ve not been able to find any information on these units anywhere.

HV Outputs
HV Outputs

The other end of the unit has the mains input, and the HV outputs on binding posts, along with an Earth connection.

Cover Removed
Cover Removed

A couple of screws allows the cover to come off, revealing the electronics inside. The mains is led straight to a transformer, that outputs 24V to run the DC-DC converter.

High Voltage Transformer
High Voltage Transformer

The main HV transformer is on the left, and the switching transistors on the right, attached to a heatsink.

Transformer Windings
Transformer Windings

These transformer windings are always pretty, no doubt done by machine & varnished for mechanical support & extra insulation.

Control IC
Control IC

Control is done at the other side of the PCB, underneath the output PCB. Just a standard PWM controller.

Voltage Multiplier Section
Voltage Multiplier Section

The output voltage from the transformer is rectified & smoothed with capacitors, and there is also a voltage doubler hiding under here – this helps reduce the amount of insulation required on the transformer windings, since they only need to handle half the output voltage.

Posted on — 1 Comment

HP Pavilion x360 14-dy0518sa – Linux Experience

Recently I figured it was about time I treated myself to a brand-new laptop, and as I was also in the market for a tablet at the same time, I figured I’d go with one of the “convertible” types that are about these days.

The HP x360 series are locally available to me for fairly reasonable prices, so I decided to go with one of these. With a Core i7-1165G7 11th Gen CPU, and factory with 16GB of RAM (upgradeable), and 512GB of NVMe SSD for storage as standard, I figured that this would be a snappy little laptop just for my general day to day use. Unfortunately my local supplier (Curry’s PC World), don’t actually give the model number, but it’s their stock code 676743.

As every machine does, they come with Windows pre-loaded, which is rather a pain. I’ve not used Windows in a production environment for over a decade, and I don’t plan on starting again now, so the first thingto do is image the NVMe SSD (having a backup of the original image is handy), and firing up a Linux Mint bootable USB. I did need to enter the BIOS to disable Secure Boot for this to work correctly.

There were some issues with the standard version of Mint 20.3 – namely no touch screen and no mousepad. It’s certainly not uncommon for really new hardware to have support issues in minstream builds of Linux, and as Mint 20.3 by default runs on Kernel version 5.4, this needed to be upgraded. (For reference Mint Edge Edition does come with a later version of the kernel as standard, but I didn’t have an image to hand).

The touchscreen controller and mousepad are both Elan products – an ELAN0749 04F3:31A5 for the mouse, and an ELAN2514 04F3:2CF3 for the touchscreen. Both are operated over I²C.

Once the kernel was updated to version 5.13, there were no more issues with the mousepad, and the touchscreen works es you expect a touchscreen to work. The only bug I’ve found so far is with the included smart pen – once the pen is detected finger touch stops working, and the only way to get it back is a full reboot, which is most annoying. This isn’t a massive issue for me, but hopefully driver support for the ELAN touchscreen controllers will improve with time.

Another upgrade I did straight away since I had the parts hanging around was to upgrade the RAM to a full 32GB. This is a very simple swapover – the bottom of the laptop does need to be removed, the screws for which are underneath the rubber feet on the bottom panel, which can be peeled back gently with fine tweezers for access. There are 5 screws in total.

Before the RAM upgrade, the system was quick under Linux – with a full 32GB there is much more capability. The NVMe drive is also replaceable, so this will probably get upgraded with time to at least 1TB.

Overall, I’m very happy with this HP laptop, I will post an update after I’ve had some time to get to know it better.

Posted on — 5 Comments

FieldBest I Optical Power Meter Teardown

Optical Power Meter
Optical Power Meter

Since I’m building optical power meters at the moment, I figured it was time to see what the Chinese have to offer in this department, and these meters were on offer over on AliExpress, at over 50% off. This is the Fieldbest I meter, and I got two versions – the 1mW-2W & 10mW-50W model, with thermopile sensors. A graphical LCD provides the user interface, and there is also USB serial present for logging to a PC with software. These units appear to be made by a company called “Fastlaser Tech”, but I can’t seem to find much about them.

Rear Panel
Rear Panel

The rear panel has the power input, USB & the signal connection on BNC from the sensor head.

Front Cover
Front Cover

Inside the front cover there is a standard HX1230 LCD, and the flex to the front panel buttons. The LCD itself is rather roughly hot-glued in place.

Boards Assembled
Boards Assembled

The PCB stack normally sits inside the rear cover, ans holds all the electronics required to run the meter. The main microcontroller is on a separate daughterboard, and looks a lot like a dev board! The input stages from the sensor head are on the left hand side of the PCB.

MCU Board
MCU Board

Here’s the MCU daughterboard, hosting the support components for the STM32F107 microcontroller. There’s a USB port on this board too, not sure what that one does yet.

MCU Board Rear
MCU Board Rear

Here’s the other side of the PCB, with a small EEPROM, and voltage regulator. The programming pins are on the bottom left.

Mainboard
Mainboard

Here’s the mainboard without the MCU daughterboard. Not much on these boards, very sparsely populated indeed!

Voltage Regulator
Voltage Regulator

There’s a AMS1117-5.0 regulator on the right, which takes the 9-12v input from the external PSU down to a 5v rail, very simple.

Sensor Head Input Circuitry
Sensor Head Input Circuitry

The input stage & A/D converter are on the left side of the board, consisting of an LT6004 dual Op-Amp, likely configured as a transimpedance amplifier,  and a small A/D converter, which has had the number scrubbed off. It’s been said many times before that this is pointless, as it’s not too difficult to work out what parts have been used. In this case, everything matches to the LTC1286 12-Bit successive approximation sampling converter from Linear Tech, and it’s being controlled via SPI from the main microcontroller. A TL431 in a SOT23-3 package handles the need for a local stable voltage reference.



Posted on — Leave a comment

Brsk Updates – A New Network Link

I posted a couple of weeks back that I was soon to be the recipient of a new gigabit symmetric link to the intertubes – and there was a bit of a caveat of their use of CG-NAT due to the exhaustion of the IPv4 address space.

To compensate I’ve routed everything over a VPN link in prep for this new connection, which does work fantastically well.

After a chat with their support guys – and I did actually manage to speak to someone in their Networks team, not just a customer support droid(!), they will soon be introducing a service where I can add a public IPv4 to my account, so I’ll be able to run at full gigabit speeds without the 100Mbit restriction of this VPN connection!

I’m told that this will be within the next couple of months, which is around the same lead time as the connection itself, so hopefully this will be live just in time for my new fibre.

More updates to come as this progresses!

Posted on — Leave a comment

Melles-Griot 05-LHR-088 Helium-Neon Laser

Beam
05-LHR-088-097 Tube
05-LHR-088-097 Tube

Here’s another Helium-Neon to add to the electric glassware collection, a 2mW Melles-Griot 05-LHR-088 which I pulled from the optical assembly from an ancient Spectra-Physics SP910 laser level. This tube is a standard Red 632.8nm with Random beam polarisation. The specifications are in the table below:

Minimum Optical Powere/2 Beam DiameterDivergencec/2L Mode SpacingSupply Opr/Start VoltsNominal CurrentDiameter/Length
2mW0.63mm1.4mR641MHz1.82/10kV4.5mA29/241mm
Tube Label
Tube Label

The label shows that this tube was manufactured back in 1987. 34 years at the time of writing! There is a slight amount of brown deposit in the main bore, indicating the tube has seen some use, however it’s nowhere near as bad as the Barcode scanner tube I have, which must have spent many years operating. I am assuming the “2.6” hand-written onto the label next to the serial number was the power measured at the factory.

Anode End
Anode End

This tube is old enough to have 3-screw locking/adjustment collars on the mirror mounts. This one is at the anode end of the tube, with a cap over the HR mirror for protection.

OC Mirror
OC Mirror

The OC (Cathode) end of the tube has the same locking collar.

Tube Mounts
Tube Mounts

The original tube mounts are reattached here, made from Aluminium with silicone rubber pads.

Tube Running
Tube Running

Here’s the tube running on a LaserDrive 4.5mA PSU. The camera doesn’t quite pick up the colour of the discharge, it’s more salmon-pink.

Bore Closeup
Bore Closeup

A closeup of the bore more accurately shows the colour of the glow discharge in the tube.

Beam

Finally, the output beam from the tube. This one actually measured at 3.04mW on a Coherent LaserCheck, a bit higher than the assumed factory measurement of 2.6mW!

Posted on — Leave a comment

Infrastructure Updates & The Cost Of Living

Everyone who reads this website will know about the cost of living crisis – this is a global problem brought about by the combination of the Covid-19 pandemic, and the unfortunate situation in Ukraine at the present time.

For us in the UK, the costs of energy have skyrocketed in recent months, with our government permitting the energy companies to charge us all an extra £600 per year in electricity alone. Given I run servers, this isn’t particularly good news.

The cost of running the network infrastructure behind this blog is not insignificant – just the server alone runs me around £30-£40 per month just in power, and add onto that the £45 in connectivity costs. I don’t meter the power taken by the router, switch etc, so the total cost will be marginally higher.
Given the increases that have hit, I expect to see a 3x increase in power costs to run this blog on the current hardware – an unsustainable jump.

I’ve got a couple of Raspberry Pi’s floating around, at the moment uncommitted to any tasks, so I thought I’d use one of these. A Pi CM4 module in a support PCB with dual network links should so just fine. I ogirinally got this to build a Pi based router (what it was designed for) – nevermind!

I’m using the new 64-Bit version of Raspberry Pi OS, and a 128GB MicroSD card for storage, since this board doesn’t have an open PCIe lane for a NVMe drive.

A quick setup with Nginx, php-fpm & MariaDB & I’m in business! The site is now running on a Raspberry Pi! As a plus, everything does seem to be quicker than the old Apache setup too. I have for now dropped a few features of the site, just until I get a feel of how quick this new setup is going to be, but they should be back soon!

Posted on — 1 Comment

FTTP Has Arrived – With Some Caveats

For 90% of the time I’ve run this website, it’s been run from a server in my house, over a domestic-grade ADSL connection. The problem has always been the very asymmetric download/upload bandwidth provided by domestic ISPs. Normally it’s about a 10:1 ratio these days (the current connection is 350MBit DL / 20MBit UL, which is even worse). Going back a few years, this wasn’t much of an issue – 99.9% of general ISP customers didn’t need a high upload rate, as they were only requesting web addresses, etc. Nowadays especially with the rise of online video services & social media, upload rates are more important than ever, since a good proportion of the population is uploading their own content daily.

Despite these changes in how the networks are utilised by the end users, the upload rates haven’t really improved much, which I’ve always found baffling.

Considering the bandwidth limitations imposed by such a connection on upload rates, the site’s done pretty well over the years, even though it can be a touch slow at times. I’ve been looking at changing this situation for a long time, watching to see if any of the direct fibre ISPs in the UK were going to get around to serving my area, such as Gigafibre, CityFibre or HyperOptic, but they unfortunately haven’t yet appeared anywhere near my locale.

Cue the entry of a new ISP: Brsk. These guys are building an entirely new fibre network, and just so happen to be doing the network build in my direct area at the moment. For only £5 more a month than I’m paying for the service I have now, I can now obtain full gigabit symmetric connectivity!

There’s one small caveat to the service, which wouldn’t bother the average member of the public, is a serious block to how I use my internet connectivity – they don’t hand out globally addressable IPv4 addresses. This is ostensibly due to the global IPv4 address shortage. Instead, they use CGNAT to address their endpoints, which renders me unable to route anything out to the global internet from my connection. Luckily there are ways around this issue.

I do have access to some public IPv4 addresses that are currently unallocated, in another networking installation, which also runs a VPN into my home network. So it’s just a matter of hooking up my primary server to that VPN, and routing one of the spare IPv4s over the private link from the router at the other site. This will limit thing somewhat, as the other site link is a 100MBit symmetric leased-line, however that’s still an 80MBit improvement in upload rate overall.

This latter solution of routing a public IP over VPN to a private endpoint is something I’ve already done in preparation – and it works great. The firewall on the site router provides extra protection for the server, and even with the VPN overhead it’s just as quick as it was before.

Since they’re still building out the network, I’ve only been able to place a preorder, and the connection will be installed (allegedly) sometime in July. Once the new link is in, I can get rid of my existing provider.

Once the link’s installed I’ll provide some updates!

Posted on — 3 Comments

DIY Thermal Laser Power Sensor Build

TEC On Heatsink
TEC On Heatsink
TEC On Heatsink

I’ve been looking for ways to build a DIY Laser Power meter for some time now, but I had no way to calibrate anything. I have been aware of DIY thermopile sensors with TECs – but no way to verify any results until now. Since I have my Gentec meter to calibrate against, I can finally get on with the project.
In the photo above, is the Peltier/TEC module mounted to the heatsink with thermal compound. In this case it’s a TEC1-12706, pulled from a cheapo dehumidifier. The cold face (with the part number printing) is against the heatsink, and the hot face will serve as the beam target. This was cleaned with solvent, and roughed up a bit with silicon carbide abrasive paper – the abrasive needs to be harder than the Alumina ceramic the module is constructed from.

Optical Coating
Optical Coating

To be any good as an optical power sensor, the front face of the module needs to be coated with something to absorb as much energy from the laser beam as possible. In this case, black paint was used, as it’s completely matte when dried. Lampblack also works, and this can be coated onto a sensor face with just a wax candle, but this is far too fragile to be any practical use (being just carbon, it’s much more resistant to thermal damage from the laser beams though!).

Coated Sensor
Coated Sensor

After two coats of the paint are applied to the front face of the module, the sensor head is complete. Try to get this as smooth as possible for best results. I designed & 3D printed a retention bracket for the module, and matches up with the screws that hold the fan on the finned side. This also has a block on the bottom which I threaded 1/4-20 to fit a standard tripod thread.

Like all commercial laser power sensors, the beam should be expanded as much as possible to fill the full face of the sensor. A focused high-power beam will quickly destroy the coating!

After completion, the sensor needs to be characterised. For this I set a diode module to be as close to 1W as possible, according to my calibrated meter, and applied the beam to the constructed sensor. A load impedance of 68Ω was placed across the output leads as a load. For this unit, I obtained a reading of 83.5mV/W of applied power. Even for low power levels, the fan does need to be running on the back of the heatsink, as the cold side of the sensor heating up will skew the reading.

After calibration at 1W optical power, I then ran some more tests at higher powers – 2W gave exactly double the output voltage, and throughout the power range I am able to test, the sensor seems to be entirely linear in operation.

Posted on — Leave a comment

Efratom LPRO-101 Rubidium Frequency Reference Teardown

Rubidium Glow
Internal Overview
Internal Overview

Time for another Rubidium Standard Teardown! This one was supposed to happen a year ago, however I completely forgot about this unit. This is an Efratom / Datum LPRO-101 Rb standard, which does differ somewhat internally from the previous unit I tore down. Above is the unit with the Mu-Metal top cover unclipped. The PCB is very tightly packed with components, and this unit dates to approx 1999. The way all of these units operate is with a standard Quartz oscillator, and locking that to a Rubidium physics package to gain the stability of an atomic reference.

Servo Section
Servo Section

The bottom left corner of the board has the C-Field control & servo section, with the C-Field (Frequency Adjust) pot on the left, with the selectable tuning resistor. There’s a mountain of 74 series glue logic in this unit, and will be visible in every shot. The adjustment pot can be accessed through a tube in the top cover with an adjustment tool.

VCXO Section
VCXO Section

Bottom right is the 20MHz VCXO section, with the main crystal in the TO-3 can wrapped in a heatsink at the bottom right. Again there’s more space for selectable components here, with a blank spot for another ceramic cap – most likely to further tune the operating frequency. One of the main regulators is here as well, an LM7805 in the TO-220 package.

Synthesizer Section
Synthesizer Section

Here’s the RF synthesizer, used to indirectly generate the 6.8GHz hyperfine transistion frequency of Rubidium. The synth here frequency multiplies the 20MHz main clock to 60MHz, and feeds this through a coaxial cable into a Step Recovery Diode, mounted inside the microwave cavity with the Rb cell. This section also sweeps the frequency to be able to obtain physics lock when powered up.

Physics Package
Physics Package

This is the very important section of the oscillator – the Rubidium Physics package. This section is heated to high temperature – 100°C for the lamp (the small section on the left), and 70°C for the vapour cell & microwave cavity (the larger section on the right).

Rubidium Lamphouse
Rubidium Lamphouse

The Rb spectral source is hiding inside this small casting, with a barrel tuning capacitor on the side. In these units, the lamps are driven with RF, through a coil of wire wrapped around the glass bulb of the lamp itself. In my case, I managed to pick up a 156MHz signal in this area with a spectrum analyser, so I can only assume this is the drive frequency for the lamp. The main RF drive MOSFET, an MRF160 sits underneath the lamp housing. The driver is a Colpitts oscillator, and drives the lamp with about 4W of RF power. The lamp is heated with a MOSFET thermally bonded to the other side of the housing, which can’t be seen here.

Microwave Cavity
Microwave Cavity

The other end of the physics package has the Rubidium Vapour cell, photodetector & step recovery diode housed in a microwave cavity. The coax cable feeding the 60MHz signal from the synthesizer can be seen going through a passthrough in the brass plate. Inside is the SRD & photodetector. This section is heated by further thermally bonded MOSFETs on the sides of the cavity housing.

Rubidium Lamp Bulb
Rubidium Lamp Bulb

Loosening the locknut on the lamp housing, and gently unscrewing the gold-plated holder allows removal of the bulb. The tiny bead of Rubidium metal can be just seen in the pinch of the bulb, with a couple of spots on the outer part of the bulb. The lamp voltage on this unit was around 6.21v, however after removing the lamp & giving it a clean, and warming it to get the Rubidium to re-condense in the pinch got the voltage up to 7v – this is plenty healthy for one of these.
There’s definitely some wear though – there’s a slightly yellow tinge to the glass, and from what I have read in a couple of scientific papers on the subject of Rb Lamp Failure Modes, this is probably Rubidium Oxide, caused by an interaction between the metal & the glass.

 

rubidiumRubidium GlowA final photo shows the very pretty colour of these lamps – it’s a pastel purple colour, and surprisingly the camera picks this up very well.



Posted on — 1 Comment

Gentec TPM-310 Optical Power Meter Teardown

Front Panel
Front Panel
Front Panel

Here we have an optical power meter, vintage 1991! This is a Gentec unit, with scaling to to handle up to 10W with a suitable powerhead. Powered either with 4 PP3 9v batteries, or from a 24v DC jack on the rear, this unit is quite versatile. I managed to get this for very little money on eBay – similar units cost over £1,500 new – without a powerhead.

Meter Movement
Meter Movement

The unit is completely analogue, with no digital circuitry at all. The meter movement has a mirror on the scale for parallax correction. Under the movement are the main power switch & battery test switch, which uses the meter itself to show battery level.

User Controls
User Controls

The right hand side of the unit has the Zero adjustment, the range switch, and the DB15 powerhead input connector.

Board Left
Board Left

Taking the unit apart, with just 4 allen screws on the case reveals the mainboard. There’s very little back here! The active components are just Op-Amps – An OP07 ultralow offset in the bottom left corner is most likely the front-end amplifier, along with a few LF442ACN precision JFET input devices in the same area. The other amplifiers are LT1001 precision devices, with a 10mA output current capability. Most of the passives in this area are also high-stability & high precision parts.

Board Right
Board Right

The other side of the board handles the meter movement, and the power input section. There is a small daughterboard with another LT1001 Op-Amp on board, along with some passives, and the battery inputs go into here, however I’m not exactly sure what this is doing – there is another connection to the rear panel 1v analogue output BNC jack, so it may be the driver for that section. The 24v input is a single DC rail, however the 4 PP3 battery holders are wired as parallel pairs of batteries back to back, so a split +9v/-9v supply is generated.

Main PCB
Main PCB

An overall view of the board shows the wiring back to the battery holder, 1v analogue output jack & DC input connector.

Posted on — Leave a comment

Cornelius Carbonator 2000 Teardown

Water Carbonator

 

Water Carbonator
Water Carbonator

To run my postmix rig, I needed a carbonator, to turn tap water into soda water. These units are very common in pubs in the UK, and are very simple in construction. The unit is fed water at about 3 bar from the water main & food-grade CO² at about 5 bar from a regulator, and soda water appears at the outlet.

Pump Motor
Pump Motor

To raise the incoming water pressure above the pressure in the carbonator tank, the unit contains this rather large vane pump, with a 230v induction motor for mechanical power. There’s a solenoid valve on the inlet to prevent water flow when the pump is off, and the motor is automatically controlled.

Electrical
Electrical

Above the pump is the electrical wiring & control. This is a simple level control, and reads a conductance probe inserted into top of the carbonator tank, activating the pump when the level in the tank drops below a minimum level, and shutting it down when a max level is reached. There’s also a timeout function, which shuts everything down on lockout if the pump runs longer than a preset time interval as protection in case of such issues as a leak in the system, or dry running without a water feed.

Carbon Vane Pump
Carbon Vane Pump

Coupled to the front of the motor is a carbon vane pump. This type is very commonly seen in food-grade equipment, such as these units, and commercial coffee machines. The unit has a suction strainer, and a built-in pressure relief valve to stop the pipework exploding or the motor from burning out if there’s a blockage in the outlet line.

Carbonator Tank
Carbonator Tank

The final & main part of the unit is this 115mm diameter stainless steel tank, with fittings at the top. This is pressurised with CO², and water is sprayed into it through an orifice. This spray increases the surface area of the incoming water, making it absorb as much of the gas as possible. There’s a pressure relief valve at the top, just in case the internal pressure reaches dangerous levels.

 

Posted on — Leave a comment

DeWalt Clone 18v Battery Pack Teardown

Clone Battery Pack
Clone Battery Pack
Clone Battery Pack

I bought two of these packs for my new DeWalt combi drill, as the branded packs are very expensive and there’s unlikely to be much of a difference in cells used. I was a little mistaken on this, as you’ll see!

Battery Terminals
Battery Terminals

The top of the unit has the battery connections, with all the cell balance lines, along with an ID pin, and a TH pin. The TH pin connects a 10K NTC thermistor up to B+.

Battery Label
Battery Label

The bottom of the unit has the rating & warning label. This claims to be a 6Ah pack, but it’s nowhere even close!

Top Cover Removed
Top Cover Removed

Removing 4 Torx screws gives access to the internals. The cells are all fitted into a plastic holder. There’s a small PCB holding the connector pins, and all the cells connect up to this for the balance outputs. There’s a small PCB on the side of the pack that holds the components for the battery meter.

Connector PCB
Connector PCB

The back of the connector PCB has some passive components, but not much else. There’s the connections for the NTC at the bottom right of the board. There is an IC on this board, on the other side, but without desoldering all the pins, it’s not possible to see properly as it’s mainly hidden by the connector frame.

Battery Indicator LEDs
Battery Indicator LEDs

Not much on the front of the battery meter board, apart from the 3 bright green LEDs and the tactile switch.

Battery Indicator Components
Battery Indicator Components

The other side has a small ST microcontroller, a TL431 shunt reference, and some other passives.

Cell Links
Cell Links

All the cells are linked together with hefty interconnects, as expected for a high current pack. This is laid out as a 5S2P configuration, giving 21V max, 15V flat. Unfortunately I can’t see any markings on the cells – they may be off brand, or pulls from other products.

I figured as I always do with Chinese battery packs that the stated capacity of 6Ah was too good to be true, so I ran my usual cycle tests to see what the real capacity was, and was met with the following results:

Cycle NumberPack APack B
13969mAh3917mAh
23963mAh3947mAh
33961mAh3956mAh
43953mAh3959mAh

As can be seen from the table, these packs are 4Ah, not 6Ah. 2Ah cells sort of make sense for a pack like this, as the high-current capacity cells tend to be a lower capacity.

Posted on — Leave a comment

Clone DCB090 DeWalt USB Battery Adaptor Teardown

DCB090 Clone USB Adaptor
DCB090 Clone USB Adaptor
DCB090 Clone USB Adaptor

Since I got my new DeWalt combi drill, I needed a way to charge the batteries without having to resort to sticking blade terminals into the pack connectors – I didn’t purchase the branded charger, mainly due to cost. I also have a very capable multi-chemistry charger that handles multi-cell lithium packs with no issues, so I saw no need to replicate things. This little gadget was ordered just for it’s main pack connector; I can then use this to make up a charger adaptor cable. What this normally does is allow the use of DeWalt XR battery packs to charge mobile devices via 5v USB outputs, so there’s going to be some kind of DC-DC converter in here. There’s also a “charge level indicator” built in, which doesn’t actually do anything sensible – even on a flat battery pack, showing a single LED on it’s charge indicator shows the full 3 LEDs on this unit.
The remaining feature is a trio of white LEDs to function as a torch, but it’s less than stellar in the brightness department. Given that there’s not much in the way of control inside the battery packs themselves, I reckon this unit could actually overdischarge a pack, causing damage.

Torch LED & Charge Indicator
Torch LED & Charge Indicator

The top of the unit has a large label with windows in for the various LEDs, and a pad covering the tactile switch to operate the torch function.

Label
Label

The label on the side indicates the unit will operate down to 10.8v, good for the 3S packs, as well as the 18/20V packs.

Pack Connector
Pack Connector

Here’s what I was after – the battery pack connector. This has the full compliment of pins for all the balance taps too.

Casing Opened
Casing Opened

Removing a label, and a single screw gives access to the internals. There’s not much in here apart from a large PCB, with a few components.

Main PCB
Main PCB

The PCB is pretty sparse. There’s a microcontroller in the top right corner that does the torch LED switching, and the “battery indicator LEDs”. This is completely unmarked, which is very common now for Chinese microcontrollers. The only way I’m identifying this one is via a decap operation on the IC!
The USB ports have MOSFETs in their negative pin paths, probably to switch off the ports if they’re overloaded. The data pins are bridged together on one port, and connected to the DC-DC converter on the other port.

DC-DC Converter
DC-DC Converter

The main DC-DC converter IC is in the bottom right corner of the board, next to the input pins. This is an IP6503S multi-protocol USB charging converter, with a 24W power limit. This explains why the data pins of one of the USB ports is connected back here – it’s doing some communications with the connected device for fast charging. Chinese datasheet below.

Posted on — 5 Comments

Yellow Jacket Titan P51-870 Digital Manifold Teardown

Front Panel & LCD
Front Panel & LCD
Front Panel & LCD

New tool time! I figured now I’m a fully ticketed member of the F-Gas community, I’d treat myself to passing the course by buying a decent set of refrigeration gauges. This is the Yellow Jacket P51-870 Titan manifold, a fully digital unit with all the useful functions built in. Basically an electronic module attached on the top of the standard Titan manifold, this unit performs all the regular functions I’d normally need either a calculator for, or other tools. The front of the unit has just a power button, LED & a large resistive touch TFT panel for display.

Rear Panel
Rear Panel

The rear panel has the ports for charging the internal battery, which is micro USB – this is also used to download log data to a PC from a system processing run. There are 4 3.5mm jacks for the external temperature probes, and vacuum sensor.

Rear Cover Removed
Rear Cover Removed

Removing 4 Torx screws in the back panel allows the clamshell case to come apart, showing the mainboard, and the pressure transducers screwed into the manifold. The aux jacks & the USB charging & data port are supported on small vertical PCBs plugged into the mainboard via 0.1″ headers.

Main PCB Overview
Main PCB Overview

With the pressure transducers unplugged from their looms to the mainboard, the module is free from the manifold section.

Main Microcontroller
Main Microcontroller

The muscle of the operation here is a Freescale (now NXP) Kinetis K2 Series MK22FN512VLH12 ARM microcontroller. With a Cortex-M4 core at 120MHz, there’s a bit of beef here. The LCD & touch overlay is controlled by a Bridgetek FT810Q Embedded Video Engine. The video controller communicates with the microcontroller via SPI, and the LCD via parallel RGB. There’s some SPI Flash memory up on the left, for log data storage, a Winbond W25Q32JV 32Mbit part. Just under that is a pressure sensor, which I’ve been so far unable to pull a part number off. This is required to assist in calibration of the main pressure transducers.



Switching Section
Switching Section

In the top right corner of the board is a 74HC595 shift register, with quite a few discrete transistors & diodes hanging around it. I suspect this is used to switch between two vacuum sensors when both are plugged in – from looking at the waveforms present on the sensor interface, the power does appear to be switched ON/OFF on a single sensor at about 1Hz.

My guess at the moment is that the sensor communications are over I²C, by the 4-wire connection, and the very obvious clock & data line on the connector, but I haven’t yet looked deeply into this.

Main Power Supplies
Main Power Supplies

Next to the battery connector (the battery itself is a single LiPo pouch cell, double-sided taped to the front shell, behind the display), are a selection of DC-DC converters, providing all the required voltage rails. No doubt there’s lithium charging control going on here too.

Bluetooth Module
Bluetooth Module

Wireless connectivity is provided for by a Silicon Labs Blue Gecko BGM111A256V2 Bluetooth 4.2 SoC module. These are also fairly powerful parts, with a full ARM Cortex M4 microcontroller hiding inside, clocked at 40MHz. There are as a result two programming headers on this board, in the top left corner, for both this part & the main microcontroller.