scanbridge

ESP32/ESP-IDF firmware bridging a ScanAvenger SA1100 barcode scanner from BLE to MQTT — scan out, accept/reject verdict back. Documents the vendor's undocumented GATT protocol and the quirks that make this scanner hard to talk to.

C MIT created updated
  • barcode
  • barcode-scanner
  • ble
  • ble-central
  • bluetooth-low-energy
  • esp-idf
  • esp32
  • esp32-s3
  • firmware
  • gatt
  • iot
  • mqtt
  • mqtt5
  • nimble
  • reverse-engineering
  • scanavenger
  • warehouse
readme · slastra/scanbridge

scanbridge

Firmware for an ESP32 that bridges a ScanAvenger SA1100 barcode scanner from BLE to MQTT over WiFi, so a scan reaches a backend and its accept/reject verdict reaches the operator without a phone in the middle.

It is not a data logger. Every scan owes an answer, and that premise shapes the whole design — see docs/decisions.md.

Target is a XIAO ESP32-C5. Phases 1-2 were developed on an ESP32-S3 bench board and the BLE central code carried over unchanged; the differences that did matter were all board-level, not chip-level, and are noted where they bite.

Also works with the NETUM C750, which is a different OEM family entirely -- a Feasycom FSC-BT825 module with its own command set -- selected as a profile in menuconfig. Same data path, every identifier different; the details are in docs/scanner.md.

Probably works with rebrands of the same scanner. The SA1100 is one badge on a widely-relabelled OEM design, and the %%SpecCode configuration dialect is shared: juitnow/juit-alacrity-setup documents 274 of the same codes for an "Alacrity" unit, and %%SpecCode15 is Battery Level in both. NETUM scanners stream the same CR-terminated ASCII over a notify characteristic. If you have one of those, docs/scanner.md is likely to apply — the GATT handles are the part worth checking first.

[SA1100] --BLE--> [ESP32] --MQTT--> [backend]
                     |                  |
                  LED feedback  <---- verdict

Requires ESP-IDF v6.0.2 or newer

Not a preference. On a development snapshot everything works right up to the point where data should arrive, and then silently never does — NimBLE never delivers a notification while connect, pair, discover, MTU and CCCD read-back all look perfect from both ends. Check idf.py --version before debugging anything else.

. ~/Projects/Esp/esp-idf-v6.0.2/export.sh
idf.py build
idf.py -p /dev/ttyACM0 flash monitor

Configuration

Credentials live in sdkconfig.defaults.local, which is gitignored and picked up automatically. Copy sdkconfig.defaults.local.example to start:

CONFIG_NETLINK_SSID="..."
CONFIG_NETLINK_PASS="..."
CONFIG_UPLINK_URI="mqtt://broker.lan:1883"

Everything else is idf.py menuconfig → Scanner BLE link / Network link / Uplink. Each option carries the measurement behind its default.

Nothing else belongs in a commit: an SSID and a broker hostname together are enough to identify a site, and a BSSID is enough to geolocate one.

Getting the scanner into BLE mode

Four barcodes, plain ASCII in Code 128, so they can be rendered on screen — zint --barcode=20 -d '%%EnterSet':

Payload Meaning
%%EnterSet enter setup mode
%%SpecCodeAC Bluetooth BLE mode
%%SpecCode99 pair with receiver
%%ExitSet exit setup mode

%%SpecCodeAA returns it to HID mode. Render that one before you need it.

Layout

Three components, none of which know about each other, and a main/ that is only wiring:

components/ble_scanner/   the SA1100 link: scan, connect, discover, subscribe,
                          reassemble notifications into whole barcodes
components/netlink/       WiFi station: pick an AP deliberately, stay on it, roam
components/uplink/        MQTT 5: publish scans, carry verdicts back, presence
main/                     the LED, the policy joining the three, one heartbeat

ble_scanner reports through callbacks rather than acting -- it does not drive the LED, publish anything, or know MQTT exists. That is the whole point of the split: the scanner link and what the application does about a scan change for unrelated reasons. main.c went from 867 lines to under 200 doing nothing but joining them.

Every component declares explicit REQUIRES. main does too, which is not the default -- an ESP-IDF main implicitly depends on every component in the build, and that hides what it actually uses.

The callbacks run on the NimBLE host task. That is a documented contract in ble_scanner.h, not an accident: do not block in them and do not touch flash. A scan handler that blocks stalls connection events, and on the single-core C5 it stalls the radio scheduler with them.

Status LED

A single WS2812, pin and colour order set by idf.py menuconfig -> Status LED. On the production XIAO ESP32-C5 it is external, defaulting to GPIO10 (the D10 pad) in the ordinary GRB order. The S3 bench devkit had one onboard on GPIO48, and was the oddity: it wanted RGB.

What the firmware decides — link state, and the immediate cue that a scan went out. None of it waits on the network:

Colour Meaning
dim blue ready: scanner attached and broker reachable
amber cannot answer — scanner or broker missing
green flash scan sent, an answer is expected
amber flash scan held, no link, no answer coming
red flash scan lost — rescan it

What the backend decides — everything else. The verdict arrives as a colour, a brightness, a blink count and a sustain, and the firmware renders it without an opinion about what it means. Green-for-good and latched-red-for-bad are conventions scanbridge-verdict happens to send; a different backend can choose differently without the device changing.

Idle is dim blue rather than off on purpose: dark has to mean broken, or a dead board and a working idle one look identical.

The WiFi lamp

The pixel above says whether the device can answer a scan. It cannot say why not: one amber covers a missing scanner, a station that never associated, and an unreachable broker.

So WiFi goes on a second lamp -- the board's own LED, no wiring -- where it costs the operator no extra vocabulary:

Onboard LED Meaning
off firmware not running
slow blink WiFi down: joining, retrying, or no SSID configured
solid WiFi up, station has an IP

Off is not a WiFi state, for the same reason idle is not dark.

This matters most on a bare board. The XIAO ESP32-C5 has no addressable LED -- a yellow user LED on GPIO27 and a charge indicator, nothing more -- so until a WS2812 is wired to D10 the entire colour vocabulary above renders to nobody, and this lamp is the only thing the device can say.

Topics

The device names itself from its MAC — scanbridge-a1b2c3 — and owns three topics:

Topic Direction QoS Retained
scanbridge/<id>/scan uplink 1 no
scanbridge/<id>/status uplink 1 yes (also the last will)
scanbridge/<id>/verdict downlink 1 no
scanbridge/<id>/config downlink 1 no
scanbridge/<id>/health uplink 0 no
{"seq":100,"id":"scanbridge-a1b2c3","code":"123123","uptime_ms":11840}

Health is on the network, not the console

Every 20 s the device publishes the same line its heartbeat logs:

{"id":"scanbridge-a1b2c3","uptime_ms":860843,
 "ble":"ble=up itvl=15.00ms scans=33 bat=61% links=4 wait=210ms setup=700ms",
 "wifi":"wifi=up rssi=-43 ch=153 drops=0 roams=0",
 "mqtt":"mqtt=up sent=33 queued=0 verdicts=25 avg=161ms",
 "ota":"ota=idle",
 "battery_pct":61,
 "bridge_mv":3964,"bridge_bat":"ok",
 "verdicts":25,"verdict_avg_ms":161}

Two batteries, deliberately distinguished. battery_pct is the scanner's, as the scanner reports it (null until it has been asked). bridge_mv is this board's cell, read through the XIAO's divider and calibrated -- the one that matters on a floor, since a bridge going flat is a bridge that vanishes from the broker with every scan after it unanswered. bridge_bat says why the number is what it is: ok, raw:no-efuse-cal if the silicon lacks calibration data and the reading is nominal, or the init step that failed.

The ble line also carries link telemetry, because the console on this board cannot be relied on and "why did the scanner take eight seconds to attach" has to be answerable from the broker: wait is how long the bridge scanned before it saw the scanner advertise (including any time the scanner spent asleep, so it only measures the bridge on a wake), setup is connect to subscribed, and while detached the line shows fails, how long the last link lasted and the HCI reason it ended.

QoS 0 and not retained, unlike status. Presence is a fact worth keeping for a late subscriber; health is true for twenty seconds, and a retained copy would go on asserting a round trip measured hours ago.

This exists because the heartbeat used to be a log line only, which is a console-shaped assumption in a device meant to run with nothing plugged into it. The verdict round trip is measured here.

The verdict is an LED command

The reply is a rendering instruction, not a judgement:

{"seq":100,"color":"#00c800","brightness":40,"blink":1,"sustain":800}
field meaning absent
color #RRGGBB amber
brightness 0–100 percent 40
blink on/off pulses before settling 0
sustain ms to hold after blinking; 0 = until the next scan 0

The firmware has no opinion about what any of it means. Green for good and latched red for bad are conventions the backend picks, so changing them is a restart of a host tool rather than a reflash. That is the whole reason the protocol looks like this — see docs/decisions.md.

Every field is clamped, and a verdict that would render as darkness is refused: a dark device is how a dead one looks, and those must not be confusable. An unreadable verdict shows amber and latches — it must resemble neither approval nor a clean rejection.

MQTT 5, for request/response (the verdict is matched to its scan by the protocol, not by parsing a payload), will-delay, and message expiry. The reasoning is in docs/decisions.md.

Only one backend at a time

Every backend subscribes to scanbridge/+/scan and answers what it hears, so two running at once both reply and the device shows whichever landed last. This is not theoretical — it bit twice during development, once as a forgotten process answering from a warm cache and producing a green where an amber was expected, and it looks exactly like a firmware bug.

The firmware cannot help here: it renders the verdict it is given and has no way to know two arrived. Preventing it is the backend's job, and the pattern that works is for a backend to answer only devices it has claimed rather than everything on the topic.

Updates

Serial flashing this board needs someone standing at it holding two buttons (see Serial notes), which does not scale past one device on one bench. So firmware goes over the air:

./tools/scanbridge-update build/scanbridge.bin

It serves the image over HTTP from this machine, publishes the URL and its SHA256 to scanbridge/<id>/config, and watches until the device comes back:

image  build/scanbridge.bin  1.47 MB
sha256 4c8f2b...
device scanbridge-a1b2c3  (running 112a6a4)
serving http://192.0.2.5:41337/scanbridge.bin
  ota=12%
  ota=87%
  ota=idle

back up: d72961f  (was 112a6a4)

The request is a plain JSON command, so nothing stops you sending it by hand:

{"cmd":"ota","url":"http://desk.lan:8080/scanbridge.bin","sha256":"4c8f2b..."}

A bad update undoes itself

Two things have to be true before the device keeps a new image, and neither is "it booted".

The SHA256 is checked against what landed in flash, not what was sent, and before the slot is made bootable. A truncated download, a proxy serving something else, or a write that did not stick all fail the same way: the update is refused and the running image is untouched.

Then rollback. The bootloader keeps the previous image until the new one confirms itself, and that confirmation is deliberately gated on reaching the broker rather than on reaching app_main. An image that boots, joins WiFi, and cannot reach the broker is a device that answers nothing -- which is the exact failure this whole design exists to prevent -- so it never confirms, and the next reboot reverts it. Nobody has to visit the device.

The reboot also waits for the scan queue to drain, up to 15 s. The queue is in RAM and every scan in it is owed an answer; an update is never worth an unanswered item. The wait is bounded, because a wedged uplink would otherwise mean a device that can never be updated again.

scanbridge/<id>/config is not authenticated, like everything else here. Anyone on the LAN can ask the device to install firmware from a URL they choose. That is a bigger hole than forging a scan and it closes the same way, with the brief's provisioning flow -- do not put this on a floor as it stands.

scanbridge-watch

tools/scanbridge-watch subscribes to every device at once and prints scans as they land, with presence tracked alongside.

./tools/scanbridge-watch -H 192.0.2.10
connected to 192.0.2.10:1883, waiting for scans
00:07:21  online   scanbridge-a1b2c3 vaa33ab8
00:07:39  OFFLINE  scanbridge-a1b2c3 vaa33ab8
00:07:39  online   scanbridge-a1b2c3 vaa33ab8
00:07:40  #100   scanbridge-a1b2c3  test

--json emits one object per line for piping. It needs only paho-mqtt, which the shebang fetches through uv — there is nothing to install.

scanbridge-verdict

A stand-in backend, so the accept/reject path and its timing can be exercised without one. It answers every scan using the Response Topic and Correlation Data the device nominated.

./tools/scanbridge-verdict                      # accept everything
./tools/scanbridge-verdict --reject-prefix BAD  # reject codes starting BAD
./tools/scanbridge-verdict --delay-ms 250       # simulate a slow backend

The rejection rule is deliberately trivial. The point is the timing and the round trip, not the business logic.

Partitions

partitions.csv replaces the stock single-app table. The default gives the app 1 MB, and BLE plus WiFi already needs ~1 MB — it overflowed the moment WiFi landed, at 96% full with nothing else added.

Two things bite here and neither announces itself:

  • The default flash size is 2 MB. On a 4 MB board that silently truncates the table, and gen_esp32part.py reports it as a bare code=2 with no message. CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y is required.
  • nvs keeps the stock offset and size on purpose. Moving it wipes the scanner bond on every repartition.

There are no OTA slots. Dual OTA plus a flash-backed queue does not fit on 4 MB alongside both radios — that is the brief's flash-budget risk, arriving early, and it is the concrete reason the production target needs the C5's 8 MB.

Serial notes

Flashing the C5, and why the console goes quiet

Two separate USB-Serial/JTAG problems bite here. Both are the peripheral's, not this firmware's, and the workflow below exists to route around them.

A core reset does not re-sample the boot straps. The USB-Serial/JTAG peripheral can only trigger a core reset, so once the board is in download mode, GPIO28 stays sampled low even after BOOT is physically released, and every Hard resetting via RTS pin... drops it straight back into download mode. --after watchdog-reset does re-sample them; idf.py flash uses neither flag and so cannot be used on this board as it stands.

A reset issued through the USJ can kill the peripheral. This is esp-idf#18996, reported on the S3 and reproduced here on a C5 rev v1.0 with IDF v6.0.2. The peripheral dies at the bootloader-to-application handoff: the application boots and runs perfectly -- WiFi associates, MQTT connects, scans publish -- while the console is dead in both directions and the device stays enumerated. Host writes time out, so esptool cannot even get back in. Only a physical EN reset recovers it.

It is not intermittent here the way it is for the reporter. --after watchdog-reset wedged the console on every boot; --before default-reset against a running application is worse still, stopping the application and wedging the peripheral without reaching download mode, which needs a BOOT+RESET to undo. A physical RESET has never once failed.

So: reset the board by hand, and never ask esptool to do it.

# 1. hold BOOT, tap RESET, release BOOT   -- enters download mode
# 2. flash, and do not let esptool reset anything afterwards
esptool --port /dev/ttyACM0 --chip esp32c5 \
    --before no-reset --after no-reset \
    write-flash --flash-size 8MB \
    0x2000 build/bootloader/bootloader.bin \
    0x8000 build/partition_table/partition-table.bin \
    0x10000 build/scanbridge.bin
# 3. tap RESET                            -- runs the app, console alive

--after watchdog-reset saves the third step and costs the console. That is a fair trade when flashing a device that reports over MQTT anyway, and a bad one when debugging.

The reporter also has a software workaround -- a watchdog task that notices stalled TX delivery and re-attaches the peripheral, recovering in about six seconds without anyone touching the board. Not implemented here. Worth it if the console ever becomes load-bearing again; health moved onto MQTT partly so that it would not.

The S3's native USB

The S3's native USB (/dev/ttyACM0) has three habits worth knowing:

  • Only one reader at a time. A second gets nothing while the first consumes the bytes, which looks exactly like a dead device.
  • Killing a uv run wrapper does not kill the child holding the port. Check with fuser /dev/ttyACM0.
  • Output written while no host had the port open is discarded — open the port first, then pulse DTR/RTS, or you lose the boot log.

Further reading

The findings are kept out of this file on purpose; it should stay readable.

docs/scanner.md the SA1100's undocumented protocol and its quirks — useful with any rebrand of the same OEM firmware
docs/measurements.md every number taken on hardware, with the method: coexistence, latency, AP selection, stacks, power
docs/decisions.md why the firmware is shaped this way, including the decisions that were reversed and what reversed them

Licence

MIT — see LICENSE.