Hardware
Context about the device the project will run on. Collected on 2026-09-26 from the live system and from the old project ~/sharm (which also contains client/pizero2w/, the previous software for this same hardware).
Access
ssh $PI_HOST(host from.env), passwordless sudo, Wi-Fi only.- Old project:
~/sharmon the Pi itself (git, binary assets in git-lfs). Python venv:~/venv(created with--system-site-packages, needed for the systempython3-pigpio/python3-smbus). ~/.envexportsPV_ACCESS_KEY(Picovoice);source ~/.envbefore running the wake word.- RaspiDR:
~/$PI_DIR(default~/raspidr, deployed with./deploy.shfrom the Mac), venv.venv, keys in.envthere. Architecture: architecture.md; wake word training: wakeword_training.md.
Board and OS
| Board | Raspberry Pi Zero 2 W Rev 1.0 |
| RAM | 416 MB + 512 MB swap (/var/swap) — memory is tight, can't handle heavy processes |
| OS | Debian 12 bookworm, arm64, kernel 6.12.47+rpt-rpi-v8 |
| Python | 3.11.2 |
| Wi-Fi | wlan0 |
The system hasn't been updated since 2025-09-30 (last apt). The clock uses fake-hwclock; after a long downtime the time at boot is "old" at first, then syncs via NTP.
/boot/firmware/config.txt (relevant parts)
dtparam=i2c_arm=on
dtparam=i2s=on
dtparam=spi=on
enable_uart=1
dtparam=audio=on
dtoverlay=vc4-kms-v3d
arm_64bit=1
[all]
dtoverlay=i2s-mmap
dtoverlay=wm8960-soundcard
#dtoverlay=w1-gpio # removed 2026-09-27: no sensors, and w1_bus_master polled GPIO4 at ~30% of a coreComponents
| Component | Interface | Pins (BCM) | Status as of 2026-09-26 |
|---|---|---|---|
| WM8960 Audio Codec HAT (SB Components) | I2S + I2C1 0x1a | 18, 19, 20, 21 (I2S), 2/3 (I2C), 17 | ✅ working, card 0 |
| TPA3118 amplifier | analog, after the WM8960 | — | — |
| UPS-Lite V1.3 (XiaoJ), CW2015 fuel gauge | I2C1 0x62 + power-good | 2/3 (I2C), 4 | ✅ working |
| NeoPixel ring, 7 × WS2812B, GRB | SPI MOSI | 10 | ✅ SPI enabled |
| Rotary encoder with button | GPIO via pigpiod | A=27, B=22, BTN=23 | — |
| 1-Wire | w1-gpio | 4 | overlay removed 2026-09-27 (unused, see below) |
Pinout (40-pin header)
+-----+-----------+---------+ +-----+-----------+---------+
| Pin | Name | | | Pin | Name | |
+-----+-----------+---------+ +-----+-----------+---------+
| 1 | 3.3V | | | 2 | 5V | |
| 3 | SDA1 | WM8960* | | 4 | 5V | Neopix |
| 5 | SCL1 | WM8960* | | 6 | GND | Neopix |
| 7 | GPIO4 | UPS | | 8 | TXD0 | |
| 9 | GND | | | 10 | RXD0 | |
| 11 | GPIO17 | WM8960_ | | 12 | GPIO18 | WM8960 |
| 13 | GPIO27 | Enc_A | | 14 | GND | Enc_Gnd |
| 15 | GPIO22 | Enc_B | | 16 | GPIO23 | Enc_Btn |
| 17 | 3.3V | Enc_Vcc | | 18 | GPIO24 | WM8960? |
| 19 | MOSI | Neopix | | 20 | GND | WM8960 |
| 21 | MISO | | | 22 | GPIO25 | |
| 23 | SCLK | | | 24 | CE0 | |
| 25 | GND | WM8960 | | 26 | CE1 | |
| 27 | SDA0 | | | 28 | SCL0 | |
| 29 | GPIO5 | | | 30 | GND | |
| 31 | GPIO6 | | | 32 | GPIO12 | |
| 33 | GPIO13 | | | 34 | GND | WM8960 |
| 35 | GPIO19 | WM8960 | | 36 | GPIO16 | |
| 37 | GPIO26 | | | 38 | GPIO20 | WM8960 |
| 39 | GND | | | 40 | GPIO21 | WM8960 |
+-----+-----------+---------+ +-----+-----------+---------+Free: GPIO 5, 6, 12, 13, 16, 24(?), 25, 26, UART 14/15 (taken by serial-getty@ttyS0), MISO/SCLK/CE0/CE1 (SPI is enabled, but only MOSI is used).
I2C buses: /dev/i2c-1 (GPIO2/3, all devices are here), /dev/i2c-2 (HDMI DDC, empty).
WM8960 Audio HAT
- Info: https://learn.sb-components.co.uk/Audio-Codec-HAT-for-Raspberry-Pi
- Drivers:
- codec: the stock
snd-soc-wm8960.kofrom the RPi kernel (kernel/sound/soc/codecs/); - card glue: since 2026-09-26, the stock
snd_soc_simple_cardfrom the kernel. The customsnd_soc_wm8960_soundcard(fork https://github.com/Fl0p/WM8960-Audio-HAT, dkms; a copy of simple-card patched for 6.12) is blacklisted in/etc/modprobe.d/blacklist-wm8960-soundcard.conf: it registered under the same nameasoc-simple-card, so the stock one didn't load. Rollback: delete the file, reboot. - the
wm8960-soundcard.dtbooverlay and thewm8960-soundcard.serviceservice come from the Waveshare repository. - After switching to the stock glue, the right microphone channel started responding weakly to voice (see below); before that it was a flat noise floor. The reason for the difference is unknown.
- codec: the stock
- At boot, the
wm8960-soundcard.serviceservice (/usr/bin/wm8960-soundcard) waits for0x1aon I2C1, applies the overlay, recreates the symlinks/etc/asound.conf→/etc/wm8960-soundcard/asound.confand/var/lib/alsa/asound.state, then runsalsactl restore. Log:/var/log/wm8960-soundcard.log. - ALSA:
pcm.!default= asym, playback →dmix(ipc_key 555555) onhw:wm8960soundcard, capture →dsnoop2ch (ipc_key 666666). Simultaneous playback and capture work. - Card 0 mixer (
amixer -c 0):Speaker(127 = +6 dB, see below),Headphone(0%),Playback,Capture. The card itself has noMastercontrol, butamixer set Master(without-c, default device) works; the oldtest_ai.pyused it to change the volume from the encoder. Hardware volume of the codec:amixer -c 0 set Speaker 5%+. - The WM8960 is write-only over I2C:
i2cdetect -rdoesn't show it; with the driver loaded, the address shows up asUU.
Microphones (soldered on the HAT, inputs LINPUT1/RINPUT1, boost +29 dB, Capture +12 dB). According to the schematic of the Waveshare reference board (https://files.waveshare.com/upload/f/fa/WM8960_Audio_HAT_Schematic.pdf; the SB Components board appears to be a copy of it): two analog MEMS AOS3729A, powered directly from 3.3 V (not from MICBIAS), each through its own chain: MIC2 → L5 → C21 → LINPUT1 (L), MIC1 → L3 → C14 → RINPUT1 (R). LINPUT2/3, RINPUT2/3 are not connected. The board is labeled LEFT / RIGHT. There is also a K1 button on GPIO17.
- How we investigated (2026-09-26): covering a mic with a finger barely works (sound gets around through the board); more reliable are scratching with a fingernail and disabling the input PGAs/
DATSELwith a tone from the speakers as the source. Ruled out: the driver (the codec is stock, and the glue was also switched to stock),MICB(MEMS powered from 3.3 V), theLINPUT2/3/RINPUT2/3inputs (empty, −73 dBFS), a channel swap in I2S. - Diagnosis result (registers, 2026-09-26): the whole signal goes through the left input PGA (
LINPUT1): disabling the left PGA removes the sound, disabling the right one changes nothing;DATSELmoves the signal between channels as expected, i.e. I2S/LRCLK/driver are fine. On the board: MIC1 (labeled RIGHT) →LINPUT1(left channel); covering it mutes the sound; MIC2 (labeled LEFT) doesn't reach the codec on any input. The routing differs from the Waveshare schematic; MIC2 has an open circuit (the soldering looks intact). The −50 dBFS in the right channel is leakage inside the codec from the left one, not a microphone. - Set and saved:
ADC Data Output Select=Left Data = Left ADC; Right Data = Left ADC(DATSEL=1): the working microphone is recorded to both channels (mono duplicate), so recordings play from both speakers. - Test scripts:
src/tools/micmeter.py(L/R level on the ring + record/playback),src/tools/micprobe.py(step-by-step "which mic goes to which channel" test with color prompts). Checking all hardware at once:src/tools/hwtest.py. - PulseAudio (
pulseaudio.socket, a user service that starts on every ssh login) grabs the card →aplay/arecordvia dmix/dsnoop getDevice or resource busy, and on startup it resetsSpeakerto its own volume. Before working with audio:systemctl --user stop pulseaudio.socket pulseaudio.service. - The volume is set to maximum and saved in
/etc/wm8960-soundcard/wm8960_asound.state(the original is.orignext to it):Speaker127 (+6 dB),Speaker AC/DC5. - The left channel has a strong noise floor in silence, −20…−35 dBFS with a ~100 Hz hum.
- The loop "1 kHz tone from the speakers → microphone" at
Speaker127 gives ~−7 dBFS in the working channel; it can serve as an automatic check of the capture path without a human.
Check:
aplay -l
aplay /usr/share/sounds/alsa/Front_Center.wav
arecord -d 3 -f cd -t wav /tmp/test.wav && aplay /tmp/test.wavUPS-Lite battery (CW2015)
UPS-Lite V1.3 board with a CW2015 fuel gauge.
- Instructions: https://github.com/linshuqin329/UPS-Lite/blob/master/UPS-Lite_V1.3_CW2015/Instructions for UPS-Lite V1.3.pdf
- The board sits under the Pi and makes contact via pogo pins to the header solder joints: 5V, GND, SDA (pin 3), SCL (pin 5), GPIO4 (pin 7). The battery is soldered on, so it can't be disconnected (to power-cycle the CW2015).
- I2C1, address
0x62. Registers:0x00VERSION (ours is0x70),0x02VCELL,0x04SOC,0x0AMODE.- Words are read via
read_word_dataand need a byte swap (struct.unpack("<H", struct.pack(">H", raw))). - VCELL:
raw * 0.305 / 1000→ volts (≈4.04 V on battery, ≈4.15 V while charging). - SOC:
raw / 256→ percent. - Quick-start:
write_word_data(0x62, 0x0A, 0x30)on initialization.
- Words are read via
- GPIO4 = power-good:
HIGHmeans external power (micro-USB on the UPS) is connected,LOWmeans running on battery. Verified both ways, including back whenw1-gpiowas loaded on the same pin. - The
w1-gpiooverlay (1-Wire, GPIO4 by default) was removed on 2026-09-27: with no sensors attached the kernel threadw1_bus_master1kept searching the bus at ~30% of one core and "found" phantom devices (00-6c0000000000,00-ac0000000000) on the power-good pin. The charger signal reads fine without it. - Battery on 2026-09-26: ~95–97% in the morning; 85% (3.96 V, on battery) in the evening, after a reboot the gauge answered again with the board untouched.
- MODE
0x0A: bits0xC0= sleep (readings freeze).knob.pywakes the gauge with0x00if it finds it asleep; it doesn't quick-start (0x30), which would throw away the gauge's learned estimate.hwtest.py upsdoes quick-start. - Reads fail with
EIOabout 1 time in 3 even with the board sitting still: at 100 kHz in a strict rhythm (2 fails in every 5 reads 50 ms apart), at 10 kHz (dtparam=i2c_arm_baudrate=10000, tried 2026-09-26) just as often but scattered — a lower speed doesn't help, so it's left at the default 100 kHz. Likely electrical (supply noise).knob.pyretries each read up to 5 times, 60 ms apart; that gets an answer every time (usually on the 1st–4th try). - RaspiDR reads it in
src/knob.pyeveryBATTERY_POLL_Sseconds and watches GPIO4 (pigpio callback, 50 ms glitch filter); see architecture.md.
⚠️ The main known issue is the contact of the signal pogo pins. Power still gets through (the Pi runs on battery), but 0x62 isn't visible on the bus (Errno 121 / EIO, i2cdetect is empty). Software and drivers have nothing to do with it; the fix is to reseat the board straight. If the UPS "disappears", first reseat the board, and only then look into software. To check: jumper wires for SDA/SCL/GPIO4 to pins 3/5/7.
Check:
sudo /usr/sbin/i2cdetect -y 1 # should show UU at 0x1a and 62 at 0x62
sudo /usr/sbin/i2cget -y 1 0x62 0x04 w # SOC (bytes swapped)
pinctrl get 4 # hi = charger connectedHistory: until 2025-09-30 there was a Waveshare UPS HAT (C) based on the INA219 (0x43); a deleted INA219.py from it remains in git — it has nothing to do with the current hardware.
NeoPixel ring (RGB LED)
- 7 WS2812B LEDs, color order GRB, DIN → GPIO10 (MOSI, pin 19), power 5V/GND (pins 4/6).
- The
adafruit-circuitpython-neopixellibrary (+Adafruit-Blinka) outputs via SPI, sodtparam=spi=onis required, and sudo is not needed. - Working parameters:
neopixel.NeoPixel(board.D10, 7, brightness=0.2, auto_write=True, pixel_order=neopixel.GRB). - Earlier attempts used GPIO14, GPIO12 and
pigpio/rpi_ws281x; we settled on SPI MOSI. - Turn it off when done:
pixels.fill((0, 0, 0)); pixels.deinit().
Rotary encoder with button
- A = GPIO27 (pin 13), B = GPIO22 (pin 15), button = GPIO23 (pin 16), VCC = 3.3V (pin 17), GND (pin 14).
- All three inputs use the internal
PUD_UPpull-up; the button is active-low. - Driven via
pigpiod(callback onEITHER_EDGE), glitch filter 100 µs on all three pins. - Decoding (from
rotary_encoder.py):(A<<1)|Bstates accumulate in a buffer and are processed only on return to the stable11; direction is determined by the pair (previous unique state, current):0001/0111/1110/1000→ CCW,0010/1011/1101/0100→ CW. One click = one step.pulses_per_rotation=80is a nominal value for converting to degrees. - The button watchdog is disabled by default (
watchdog_ms=0). - RaspiDR:
src/knob.pydecodes with a quadrature table (±1 per edge, 4 edges = one detent) and polls the button every 20 ms (two low reads = pressed); see architecture.md.
pigpiod
- Service with the override
/etc/systemd/system/pigpiod.service.d/override.conf:ExecStart=/usr/bin/pigpiod -t 0 -s 10 -x 0x08C00010-t 0: PWM as the clock source, so it doesn't conflict with I2S audio (with-t 1the audio broke).-s 10: sample rate 10 µs.-x 0x08C00010: mask of the GPIOs pigpio is allowed to touch: 4, 22, 23, 27. This is a restriction for pigpio, not protection of the pin from the kernel (whilew1-gpiowas loaded, it held GPIO4 in the kernel too).
- The
RotaryEncoderandUPSclasses crash ifpigpiodisn't running.
Software from the old project
The previous code for this hardware lives on the Pi in ~/sharm/client/pizero2w/.
| File | What it does |
|---|---|
rotary_encoder.py | RotaryEncoder class, callbacks set_rotation_callback(dir, pos, deg, rot), set_button_callback(level, tick) |
ups.py | UPS class (context manager, optional background thread), events on_battery_change, on_power_change, on_low_battery |
wake_word_detector.py | WakeWordDetector: Picovoice Porcupine + PyAudio, .ppn next to the script |
test_ai.py | integration: wake word hey-pee-dar → sound sounds/hello_*.wav + blue flash; hey-pipi or the button → radio via mpv; encoder → volume |
test_ups.py, test_ups_events.py, test_encoder.py, test_neopixel.py | manual hardware smoke tests |
firstboot.sh | initial setup of a clean image (config.txt, packages, pigpiod override, WM8960 driver, reboot) |
Picovoice is historical context only and must not be considered for current RaspiDR work. They rejected the free trial request, and even a paid evaluation is not self-service: it requires a contact form and approval through their sales team. This makes test and production keys too difficult to obtain and the service unsuitable as a project dependency.
Python packages in ~/venv: adafruit-circuitpython-neopixel, Adafruit-Blinka, pigpio, smbus2 (+ system smbus), pvporcupine 3.0.5, PyAudio, RPi.GPIO/rpi-lgpio, rpi-ws281x.
The old code launched audio subprocesses (aplay, mpv) with XDG_RUNTIME_DIR=/tmp/xdg_runtime.
Triki BLE token
A Żabka Triki bottle cap (nRF52810 + LSM6DSL, firmware 3.2.1-A) used as a wireless knob by src/triki.py. Community notes: Flopsstuff/triki, TrikiEmu (the Żappka app's traffic), TrikiMute (sleep timing). What we checked on ours:
- Advertising:
Triki <serial>, random static address, ADV_IND ~16/s, the payload never changes (manufacturer dataa00a). No pairing, the link is open. - GATT: Nordic UART Service — RX
6e400002-…(commands), TX6e400003-…(notify),6e400004-…(bit 0 = LED); Battery0x2A19; Device Information with only the firmware revision. - IMU stream: write
20 10 00 D0 07 <rate LE16> 03to RX (±16 g, ±2000 °/s, 26/52/104/208/416 Hz) → answer21 00 00 00 00, then 14-byte frames22 <button> gx gy gz ax ay az(int16 LE; /14.286 = °/s, /2048 = g), cut across notifications at will. Byte 1 = the button (00/01, 4 frames per quick press; the LED doesn't blink on presses while connected).20 00 00 00 00 00 00stops the stream — and then presses aren't reported either. - Axes: +Z out of the PCB (the «Ż» side), −Z out of the metal cap; at rest the sky-facing axis reads +1 g. The gyro zero offset of ours is ~(2.0, −4.4, −0.3) °/s.
- Other opcodes belong to the Żappka app's session authentication (
0a/09+ a random id, answered with a device key). Bare0a,42,44reset the token (42also leaves the LED on),46does nothing; none of them makes it sleep. There's no known sleep or power-off command. - Sleep: it advertises ~180 s (measured 172–180 s) after a button press or a disconnect, then sleeps; a press while advertising restarts the timer (disconnect 14:27:09, press ~14:28:30, gone 14:31:31). Connected, it never sleeps.
- Pi Bluetooth: a process killed mid-session leaves BlueZ holding the link — the token stays awake and invisible (
bluetoothctl disconnect <addr>;triki.pydrops such links on start). The Broadcom controller stops delivering advertising reports a few seconds into a continuous LE scan — from all devices at once, although the scan stays on (btmon: interval = window = 11.25 ms, no duplicate filter); only disabling and re-enabling the scan revives it, which BlueZ does every 10.24 s (the kernel's LE discovery timeout) → dead zones of up to 9 s. Scanning 0.2 s every second never hits it: 88 windows, none empty; the token is caught within ≤1 s in 95% of the windows.
Gotchas and observations
i2cdetect/i2cgetlive in/usr/sbin; a regular user over ssh doesn't have them inPATH, so callsudo /usr/sbin/i2cdetect.- A Python I2C scan via
read_bytedoesn't see devices claimed by a driver (0x1a) and can't tell them apart from an empty address;i2cdetectis more reliable. dmesgshows constant Wi-Fi errors (brcmfmac ... timeout,mmc1: Controller never released inhibit bit(s),scan error -110): SDIO to the Wi-Fi chip is unstable. The connection stays up, but ssh may hang.- Now and then the Wi-Fi firmware fails to load at boot (
brcmfmac: brcmf_sdio_verifymemory: Downloaded RAM image is corrupted,dongle nvram file download failed): there is nowlan0for the whole boot, the wake word still works, but STT fails withTemporary failure in name resolution. Seen on 2026-09-28 after two zero-length boots in a row, with the battery at ~2% on the charger. Theraspidr-wifi-guardunit (src/tools/wifi_guard.sh) handles it: nowlan045 s after start → reloadbrcmfmac, still none after 30 s → reboot (at most once an hour). - Power is stable:
vcgencmd get_throttled=0x0both on battery and on the charger. - Idle temperature ~47 °C.