- C++ 67.7%
- C 32.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .cache/clangd/index | ||
| .vscode | ||
| docs | ||
| splash_art | ||
| src | ||
| .DS_Store | ||
| .gitignore | ||
| compile_commands.json | ||
| platformio.ini | ||
ChipScanner
Firmware for a handheld 134.2 kHz FDX-B animal-microchip scanner built on an ESP32. It reads ISO 11784/11785 transponders over a serial RFID module, shows results on a 128×64 OLED, logs readings to a microSD card with an RTC timestamp, and can optionally look an ID up against a backend over WiFi. Navigation is via four buttons; an inactivity timeout drops the device into deep sleep.
Disclaimer: Most parts of this software were build using Claude Opus 4.8. The underlying electrical logic and prototype and pcb (wip) was designed by humans tho.
- MCU board: ESP32-WROOM-32 (DevKit) or Wemos/LOLIN D1 Mini ESP32 — same pin map
- Framework: Arduino-ESP32, built with PlatformIO
- Version: 0.6.3
Features
- Boot splash, then a button-navigable menu UI
- One-line status bar: clock · WiFi signal bars · battery gauge
- Scan FDX-B chips and show the conventional
country + nationalID - Save readings to
microSDas CSV, browse them later, view detail - Optional WiFi backend lookup of a scanned ID (POST + JSON response)
- Settings persisted to flash (NVS): WiFi on/off, brightness, sleep timeout, 24 h clock, plus stored WiFi/API credentials
- WiFi info screen (status / SSID / IP / RSSI)
- Battery gauge from the cell voltage on the SM5308's
BATnode via the ESP32 ADC, with a low-battery blink - Read feedback via the WL-134 reader's own onboard field-detect LED (no separate ESP32-driven indicator)
- Deep sleep on inactivity, wake on a button press; optional peripheral power-gate output for a future low-power board
- Capture path for the FDX-B optional temperature data block (see caveats)
- OTA firmware updates — wireless push (ArduinoOTA, replaces the cable) and on-device pull from a URL (for fielded units)
- Serial bring-up diagnostics (I²C scan, subsystem status, raw RFID byte dump, live button states)
Hardware
| Part | Role | Bus |
|---|---|---|
| ESP32-WROOM-32 / D1 Mini ESP32 | MCU | — |
| SSD1309 128×64 OLED | display | I²C 0x3C |
| DS3231 (HW-084 module) | real-time clock | I²C 0x68 (board also has AT24C32 0x57, unused) |
| HW-125 microSD adapter | reading storage | SPI (VSPI) |
| WL-134 FDX-B reader (134.2 kHz, TTL serial, 9600) | tag reader | UART2 |
| 4 × momentary buttons | UP / DOWN / OK / BACK | GPIO |
| SM5308 power module (charge + 5 V boost) | power; cell voltage read via ADC | ADC1 (BAT node) |
| (fallback) Li-ion cell + 2:1 divider | battery sense | ADC1 |
Pin map (config.h)
| Function | GPIO |
|---|---|
| I²C SDA / SCL | 21 / 22 |
| SD SCK / MISO / MOSI / CS | 18 / 19 / 23 / 5 |
| Buttons UP / DOWN / OK / BACK | 32 / 33 / 25 / 26 |
| Status LED | removed (GPIO12 left spare — see below) |
| RFID RX (from module TXD) | 16 |
| RFID enable → WL-134 RST pin (active-low) | 14 |
| Battery ADC (SM5308 BAT node via 2:1 divider)* | 34 |
| Peripheral power gate (custom board) | 27 |
WIP *tap BAT (pin 6). Per the datasheet, BAT is the boost input pin, connected directly to the Li-ion positive terminal, with a working range of 3.0–4.4 V. That is your battery voltage, and it's the same node as the module's B+ pad — you don't need to trace any LED lanes. Run it through a 2:1 divider into GPIO34 and read it with the ESP32 ADC: BAT (B+) --[R1 100k]--+--> GPIO34 | [R2 100k] 4.2 V -> 2.1 V at the pin | GND
The discrete status LED was removed: the WL-134 reader has its own onboard LED that lights on field detection, so a separate ESP32-driven one was redundant. Its old pin (GPIO12) is deliberately left unused rather than reassigned — it's a boot-strapping pin (MTDI) whose state at reset selects the flash voltage, so an external load there can prevent booting.
Buttons are wired between their GPIO and GND (internal pull-ups, active-low).
OK doubles as the deep-sleep wake source (ext0). On the D1 Mini ESP32, confirm
each of GPIO 32/33/25/26 is broken out on your specific board revision.
Display panel
The UI targets the SSD1309 128×64 panel: a 12 px status bar (clock · WiFi ·
battery), a four-row menu (the home menu fits with no scrolling; longer menus get
a scrollbar), the full chip ID on the result screen, a thermometer glyph for
temperature, and a sonar-style scan animation. The panel is instantiated directly
in main.cpp, and computeLayout() derives all metrics from the measured panel
size at boot. If it stays blank, switch the constructor to the NONAME2 variant
in main.cpp, or set OLED_RESET_PIN if your module has a RES line.
⚠️ Level-shift the RFID line
The reader is powered from 5 V and its TXD idles/swings at ~5 V logic. ESP32
GPIOs are not 5 V tolerant. Put a logic level shifter (or a 1k/2k divider)
on the module TXD → GPIO16 line:
module TXD --[1k]--+--> GPIO16
|
[2k]
|
GND
The module only transmits (no RX), so a one-way shift is enough. The WL-134 wants 9 V from a battery/LDO for best range and draws ~140 mA; it also runs at 5 V, but a switching supply (like the SM5308 boost) reduces read distance — its datasheet recommends a linear supply. Share ground. Effective read range depends heavily on the antenna and supply.
Power model
There is no software shutdown. A hardware switch is the real on/off: the
ESP32 either has power (running or in deep sleep) or it doesn't. The "Sleep"
menu item and the inactivity timeout both enter ESP32 deep sleep (OLED off);
pressing OK wakes it with a full restart through setup().
Build & flash
Requires PlatformIO Core (CLI) or the PlatformIO IDE extension.
pio run -e esp32dev -t upload # build + flash over USB
pio device monitor # serial console @ 115200
pio run -e esp32dev_ota -t upload # build + flash wirelessly (see Firmware updates)
Dependencies are pinned in platformio.ini (U8g2, RTClib, ArduinoJson);
PlatformIO fetches them automatically. SD, SPI, Wire, WiFi, HTTPClient,
HTTPUpdate, ArduinoOTA, Preferences and FS come from the ESP32 Arduino core.
The board uses the min_spiffs partition table (two ~1.9 MB app slots) so OTA
has room; storage lives on the SD card, not internal flash, so the small SPIFFS
partition is unused. Changing the partition table needs one USB flash — do
the first flash over cable, after which OTA works.
If upload fails with "Unable to verify flash chip connection / serial noise",
lower upload_speed to 115200, use a known-good data USB cable, and flash with
peripherals disconnected so the regulator doesn't brown out mid-write.
Editor note (VSCodium): the PlatformIO build engine works fine, but Microsoft's
C/C++ IntelliSense extension is licensed for Microsoft products only. Use the
clangd extension instead and run pio run -t compiledb to generate
compile_commands.json, or just build from the terminal.
Using the device
Navigation: UP/DOWN move the highlight, OK selects, BACK goes up a level. UP/DOWN auto-repeat when held. A button is ignored until it has been seen released once after boot, which immunizes the UI against floating-pin noise.
Home menu: Scan chip, Past readings, Settings, Sleep.
Scanning: choose Scan chip, hold the tag within ~4 cm. The WL-134's own onboard LED lights when it detects the field; on a successful read the result screen shows the full chip ID, the registering country decoded from the ID's country field (Western/Northern Europe; manufacturer and test code ranges are labelled as such), and a temperature line if one was decoded. OK saves it to SD, UP/DOWN runs an optional API lookup that shows returned info inline, and BACK discards and keeps scanning. Holding the same chip near the coil will not re-trigger within a short window.
Past readings: newest-first list; OK opens a detail view. In detail, OK runs the optional backend lookup (if WiFi + API are configured).
Settings: WiFi on/off, Brightness (Min/Low/Med/High/Max),
Set time, Sleep timeout
(Off / 30 s / 1 m / 2 m / 5 m), WiFi info, Update firmware, Deep sleep now,
About, Back. Every change is written to flash immediately. Update firmware
runs an on-device pull OTA from FW_URL — see Firmware updates.
Set time: UP/DOWN change the underlined field, OK advances to the next, BACK cancels. (Day range is not month-aware yet — it allows e.g. Feb 31.)
Settings & persistence
Settings live in ESP32 NVS (namespace cfg) in a flash partition, so they
survive power-off, deep sleep, and re-flashing. Defaults on first boot:
WiFi off, 60 s sleep, 24 h clock, High brightness, empty credentials. To wipe everything
back to defaults, erase flash: pio run -t erase.
WiFi/API credentials are not entered on-device yet (4-button text entry is painful). Instead they live in a local, git-ignored header:
cp src/secrets.example.h src/secrets.h # then edit src/secrets.h
// src/secrets.h
#define WIFI_SSID "YourNetwork"
#define WIFI_PASS "YourPassword"
#define API_URL "" // optional lookup backend, "" if unused
#define OTA_PASSWORD "s3cret" // password for wireless (push) OTA
#define FW_URL "" // firmware .bin URL for on-device (pull) OTA
secrets.h is in .gitignore, so your real values never reach the repo;
secrets.example.h (committed) documents the format. The build works even if
secrets.h is absent — credentials just default to empty and WiFi stays off.
These compile-time credentials take precedence over anything stored in NVS and
are re-applied on every boot, so editing secrets.h and re-flashing is enough —
no flash erase needed. When a SSID is compiled in, WiFi defaults to on. You
can still toggle WiFi on/off at runtime from the Settings menu.
SD card format
Readings are appended to /readings.csv:
epoch,country,national,hexid,tempC,datahex
1717000000,900,123456789012,0384001C8AF34B,,
epoch— unix time from the RTC (0 if the clock isn't set)country/national— decoded FDX-B fieldshexid— the raw 7 ID bytes as hex (used for the API lookup)tempC— decoded temperature, blank when nonedatahex— raw FDX-B data-block bytes, blank when the reader sends none
The card should be FAT32. Old 4-column files (without the temp columns) are still read correctly.
Temperature support — read this
Temperature-sensing FDX-B chips carry their reading in the optional 24-bit data
block of the telegram. The encoding is well understood (the open-source Flipper
Zero FDX-B decoder, for instance, maps the low 8 bits with °F = 74 + 0.2·n and
an odd-parity check), so it is not secret.
The practical limitation is the reader, not the math: the WL-134 pre-decodes
the tag and emits only an ID-only ASCII frame (country + national ID) over its
UART — it never sends the data block. So with this module you will not get a
temperature, and tempC stays blank.
The firmware is nonetheless temperature-ready: the frame parser is
length-tolerant and captures any data-block bytes a reader appends, always logs
them raw (datahex), and runs a calibratable decode in decodeTemperature()
(drivers.cpp). The current decode is a clearly-marked placeholder; replace it
with the real mapping once you can capture samples from a reader that exposes the
block. The intended path for full temperature support is the planned custom
board (see Roadmap).
WiFi backend lookup (optional)
When WiFi is enabled, connected, and an API URL is set, the detail screen's OK action POSTs the scanned ID to your backend:
POST <api> Content-Type: application/json
{ "uid": "<base64 of the 7 raw ID bytes>" }
It expects a JSON response and reads a text field named info (falling back to
name) for display. Adapt those field names in Api::lookup() to your backend.
Connect/read timeouts are capped at 3 s so a slow server can't lock the UI. Only
plain HTTP is implemented; HTTPS would need WiFiClientSecure.
Battery sensing
Power comes from an SM5308 module (single-chip Li-ion charger + 5 V boost +
battery-level LED display). The firmware reads the cell voltage with the ESP32
ADC, tapping the module's BAT node (its B+ pad — the Li-ion positive
terminal, 3.0–4.4 V) through a 2:1 divider into GPIO34:
BAT (B+) --[R1]--+--> GPIO34
|
[R2] (R1 == R2 -> 4.2 V cell reads 2.1 V at the pin)
|
GND
Reads use analogReadMilliVolts() (factory-calibrated), averaged over 8 samples
and scaled by BATT_DIVIDER, mapped between BATT_EMPTY_MV/BATT_FULL_MV
(default 3300/4200 mV — set BATT_FULL_MV to your SM5308 float-voltage variant).
An unconnected pin / USB-only power reads outside the plausible range and shows
as "no battery" (hollow icon). BATT_LOW_PCT sets the low-battery blink
threshold. ESP32 ADC linearity is imperfect — calibrate the end points against a
meter if you need accuracy. The divider draws a little current from the cell
continuously (100k/100k ≈ 21 µA); use 220k/220k with a 100 nF cap across R2 if
standby drain matters.
Why not tap the SM5308's level LEDs? The four battery bars are multiplexed across only three driver pins, so they can't be read as static GPIO levels. The chip's dedicated MCU-readout mode (tie LED1→BAT, read a square wave on LED2) reports only charge state (charging / full / low / fault), not a percentage. Reading the
BATvoltage directly is simpler, gives an actual level, and is immune to the LED multiplexing and the module's 32 s display auto-off.
Firmware updates (OTA)
Two over-the-air paths, both requiring WiFi. They share an on-OLED progress bar.
Wireless push (replaces the cable, for development)
Build in PlatformIO and upload over WiFi with the esp32dev_ota environment:
pio run -e esp32dev_ota -t upload
The device advertises OTA_HOSTNAME.local (default tranymonscanner.local, set
in config.h) via mDNS; upload_port in platformio.ini must match it. If mDNS
name resolution is flaky on your network, target the IP instead (the WiFi info
screen shows it):
pio run -e esp32dev_ota -t upload --upload-port 192.168.1.42
Requirements and notes:
- The device must be awake and connected to receive a push. Deep sleep drops the WiFi/OTA listener, so during development set Sleep timeout to Off.
- Set
OTA_PASSWORDinsecrets.hand uncommentupload_flags = --auth=...in theesp32dev_otaenv. Without a password, anyone on the LAN can flash the device. The transfer itself is unencrypted, so treat push OTA as a trusted-network (development) tool. - The device reboots into the new image automatically when the transfer completes.
On-device pull (for fielded units)
Set FW_URL in secrets.h to an https:// firmware .bin URL, then choose
Settings → Update firmware. The device downloads the image over TLS,
self-flashes with a progress bar, and reboots. HTTP_UPDATE_NO_UPDATES
("Up to date") is returned if your server is version-aware and 304s the request
(the current FW_VERSION is sent as a header); a plain static file server will
simply re-flash on each run. Bump FW_VERSION in config.h each release so
About reflects what actually got installed.
TLS / certificate. The pull path uses WiFiClientSecure and pins the
Let's Encrypt root (ISRG Root X1, valid to 2035) — not the ~90-day leaf, so cert
renewals don't require reflashing. Paste the PEM into OTA_ROOT_CA in
drivers.cpp (obtain it yourself from https://letsencrypt.org/certs/isrgrootx1.pem).
Your server must serve the full chain (leaf + LE intermediate); Caddy and a
default nginx do this automatically. Plain http:// is intentionally not
supported.
Integrity. The Update layer verifies the image's own SHA-256 (and that it
fits the OTA slot) before ever switching to it, so a corrupt or truncated
download can't boot — this is automatic, and over TLS the transport is
integrity-checked too.
Signed images / Secure Boot are deliberately out of scope: this is open firmware, so authenticity matters less than an authenticated pipe + integrity.
On the final board, PERIPH_EN_PIN (GPIO27) drives a P-MOSFET / load switch
feeding the RTC, OLED and microSD. Entering deep sleep (via the Sleep /
Deep sleep now items or the inactivity timeout) cuts that rail; waking — which
is a full restart through setup() — restores it. Power::begin() powers the
rail first thing at boot (with a short settle delay before the RTC/OLED/SD are
touched), and before sleeping the firmware drives the gate off and latches the
pin (gpio_hold) so it stays off through deep sleep. The DS3231 keeps time on
its own backup cell while its VCC is cut. Set PERIPH_EN_PIN to -1 to disable.
Two hardware notes for that rail to behave:
- Put a pull resistor on the load switch's enable to its OFF state. The pin latch is belt-and-suspenders; the pull is the real guarantee that the rail stays off if the GPIO ever floats.
- Keep the I²C pull-ups on the same switched rail as the OLED/RTC. If they're on always-on 3.3 V, they'll back-power the unpowered chips through their protection diodes during sleep, wasting current and defeating the gate.
Separately, the RFID reader has its own gate (RFID_PWR_PIN, GPIO14): the
firmware enables the reader only while the Scan screen is open and disables it
the moment you leave, saving its current (~140 mA at 9 V per the WL-134
datasheet) during normal menu use — not just in deep sleep. This drives the
WL-134's own RST pin, which is active-LOW (low = held in reset), so
RFID_PWR_ON = HIGH means "running". Note RST is a reset, not a hard supply
cut, so it stops the reader scanning but may not drop its full current unless its
supply is also switched — add a pull-down on this line too if you want it
guaranteed low through sleep. Set RFID_PWR_PIN to -1 to leave it always on.
On power-up the firmware flushes the serial buffer so the reader's first bytes
don't corrupt a read.
Bring-up diagnostics
Set DEBUG_SERIAL to 1 in config.h (default on) for a 115200-baud console:
- boot banner + wake cause
- I²C scan (expect
0x3COLED,0x68RTC,0x57EEPROM) - RTC / SD / WiFi status
- raw RFID bytes as hex while the scan screen is open. Two reader output formats
are auto-detected: a binary frame (
AA 0F 08 00 … XOR BB) and an ASCII frame (02STX, hex digits, optional CR/LF,03ETX). Both carry the same 7 ID bytes (2 country + 5 national) and decode identically. - twice-a-second button states
BTN U1 D1 O1 B1(1 = released, 0 = pressed) — a pin stuck at0untouched is a wiring/floating fault
Set it to 0 for normal use to quiet the serial traffic.
Project layout
chipscanner/
├── platformio.ini build config + pinned libraries
├── README.md
├── .gitignore excludes secrets.h, .pio/, editor cruft
└── src/
├── config.h pins, constants, the Reading struct, wiring notes
├── secrets.example.h credential template (committed)
├── secrets.h your real credentials (git-ignored, you create this)
├── drivers.h service-layer declarations
├── drivers.cpp Settings/Buttons/Power/Batt/Rfid/Store/Net/Api/Ota
├── country.h/.cpp FDX-B country-code -> name lookup
├── datetime.h small date helpers (daysInMonth, leap-aware)
├── boot_splash.h 128x64 XBM shown on cold boot (startup logo)
├── credits.h third-party attributions (Credits screen text)
├── sleep_splash.h 128x64 XBM shown when entering deep sleep
└── main.cpp UI state machine, screens, rendering, setup/loop
Roadmap / known limitations
- Custom EM4095 board — an analog 134.2 kHz front-end with FDX-B biphase decoding on the ESP32, recovering the full frame (and thus the temperature data block) instead of relying on a pre-decoding serial module. Wire the peripheral power gate here for real battery life.
- On-device WiFi credential entry (likely a temporary config AP rather than 4-button text entry).
- Month-aware day validation in the time-set screen.
Store::getRecent()re-reads the whole CSV when the list opens — fine for hundreds of rows, sluggish for many thousands.- HTTPS (pinned cert) and signed-image verification for the backend lookup and the pull-OTA update path.
- Optional buzzer for audible read confirmation.
License & credits
Third-party works reused in the firmware are attributed on the device under
Settings → About → OK (a scrollable Credits screen). The text is a single
constant in src/credits.h — edit it freely; it word-wraps to the OLED, so line
breaks are optional. List each reused work with its creator and license. For
CC-BY-NC-SA-4.0 material,
attribution must name the work, its creator, and the license.