Pocket digital beast of burden. A standalone diagnostics instrument for the Seeed Wio Terminal — the little sibling of Scottina.
Same kiosk language as the mother project — CRT phosphor palette, semiotic pictograms, navigable tiles that appear as their hardware is detected — on a 320×240 panel driven by a 5-way switch instead of a touchscreen.
It boots, scans the bus, and logs on its own. It depends on Scottina, Scottina Light, and CanTick for nothing.
This firmware is a passive field instrument. That constraint is deliberate,
and it is enforced in the code rather than left to good intentions — see
include/scope.h.
Permitted
- Passive observation: I²C bus scan, UART listen/autobaud, CAN passive sniff, onboard sensor reads.
- Raw capture to SD, and decode/replay of that capture.
The one sanctioned transmit exception
- CAN heartbeat / ACK replies required to remain a valid node on a live bus. There is deliberately no code path — and no UI affordance — for composing or injecting an arbitrary CAN frame.
Explicitly out of scope, do not add
- Any offensive or attack tooling; arbitrary frame injection.
- Network mapping (ARP sweep, port scan, packet capture, host enumeration). The RTL8720DN AT-firmware layer cannot do these anyway; the Wi-Fi surface is limited to scan / connect / report-own-IP.
| Board | Seeed Wio Terminal |
| MCU | ATSAMD51P19 — 512 KB flash, 256 KB RAM |
| Display | 320×240 TFT |
| Input | 5-way switch + 3 buttons (A / B / C) |
| Onboard | LIS3DHTR accelerometer, mic, buzzer, light sensor, microSD, RTL8720DN Wi-Fi/BLE |
| Expansion | 2× Grove, 40-pin header |
| CAN | External I²C CAN-BUS module (Longan 1030017 / Seeed 113020111) — MCP2515 + MCP2551 behind an ATmega168PA, register protocol at I²C 0x25. The SAMD51's native CAN is not cleanly broken out |
Two I²C buses are scanned: Wire (Grove) and Wire1 (internal).
Three different parts answer to "Grove MCP2515" and all three are probed, in
this order. Read the board name, not the LEDs — the I²C and UART modules are
both 20×40 mm and both carry TX/RX LEDs silkscreened TX and RX, which is a
very easy way to misidentify the part you are holding.
| Part | Socket | Cost |
|---|---|---|
I²C CAN-BUS (Iengon I2C CAN BUS, addr 0x25) |
left Grove — the I²C socket | none |
| Serial CAN-BUS (UART) | 40-pin header, pins 8 + 10 (Serial1) |
none |
| Serial CAN-BUS (UART) | right Grove, needs a crossover | borrows SERCOM4 from the accelerometer |
| Bare MCP2515 | 40-pin header SPI | none |
The I²C module is the best of the three: it reports true DLC, a true
extended-ID flag and RTR, and checksums every frame. The UART sibling reports
none of those — ask canfe::dlcReported() before
trusting len/ext/rtr.
Measured on the bench: 0x25 answered 4 probes out of 8, with SDA and SCL
properly pulled up throughout. The ATmega168PA is a firmware I²C slave that also
has an MCP2515 to service and simply misses address matches while busy.
Two consequences, both fixed here, and both of which had Light reporting no CAN front end while the module sat there answering every other request:
i2cbus::scan()used to require two ACKs in a row. It now requires two out of four tries — still enough to reject the cold-begin phantom ACK (that artifact answers once and never again), but no longer a coin flip for a busy slave.- Every register access in the CAN front end retries (8 attempts). A single-shot read of this part is a coin flip.
Header wiring — module TX (yellow, Grove pin 1) → header pin 10 (BCM15, RX); module RX (white, Grove pin 2) → header pin 8 (BCM14, TX).
The right Grove socket needs the two signal wires swapped, because Grove pin 1
is the module's TX and on that socket pin
1 is D0 = PB08 = SERCOM4/PAD[0], the only pad a SAMD51 USART can transmit
on. Straight through, the module's TX meets ours and our RX meets its RX.
Grove power on this board is not gated behind OUTPUT_CTR_5V /
OUTPUT_CTR_3V3 (PC14/PC15) — measured; toggling them changes nothing at the
socket.
Both working sockets are probed at boot, header first. The right Grove socket
costs something real: D0/D1 are PB08/PB09, and the SAMD51 pin-mux gives those
pads exactly one peripheral each — SERCOM4/PAD[0] and SERCOM4/PAD[1], with
no second column to fall back on. This board already spends SERCOM4 on Wire1,
the internal bus carrying the LIS3DHTR. One peripheral, two jobs.
So it is lent per screen: the CAN screen borrows SERCOM4 while it is open
and hands it straight back on the way out. Everywhere else the accelerometer
owns it and Vibration behaves exactly as before. The I²C scan skips Wire1
while it is lent rather than reporting the accelerometer as missing.
The Arduino core forces the mechanism: Wire.cpp hard-defines
SERCOM4_0..3_Handler for Wire1, so the stock Uart class cannot be linked
against sercom4 at all. The vector table is relocated to RAM at boot and those
four entries are swapped on handover.
v1-foundation. The skeleton, the discovery spine, and the black-box logger are
real; the decode layer is not yet written.
Working
- Screen framework (
enter/tick/exit/onButton) with a nav stack. - Boot splash — phosphor rain, wordmark, staged progress bar.
- I²C scanner — two-pass scan across both buses, a known-device registry,
and
WHO_AM_I-style verification to resolve address collisions. Unresolved collisions raise a pick-list; the choice is persisted to SD. - Autodetect → tile. Tiles materialise only when their hardware answers, and
the launcher re-scans on tick, so hot-plugged hardware shows up on its own.
CAN is the one deliberate exception: finding that front end means
transmitting a
+++knock, so the probe cannot free-run on a tick and the tile is permanent, with its badge carrying the empty-socket state. - CAN passive sniffer — three front ends probed (I²C
0x25, then the UART module on either socket, then a header-SPI MCP2515), then live IDs with per-ID frame rates, DLC and payloads, plus raw timestamped capture to SD. Receive only; there is no send path in the code at all. - Vibration — live RMS / peak / trace off the onboard LIS3DHTR, with SD logging.
- Serial — UART autobaud (marine-first ordering: NMEA 0183 at 4800, AIS at 38400), byte dump, manual baud pick on lock failure.
- Logs — on-device browser over the SD captures.
- Raw logger — rolling files, size cap, and a minimum-free-space floor so a long capture cannot silently fill the card.
- Config in three tiers (see below), never hard-failing on a missing or malformed file.
- Boot smoke self-test over serial.
Not yet implemented
- CAN decode / dash — the sniffer is raw only.
- DBC + PGN registries, 11-bit → DBC and 29-bit → N2K routing, fast-packet reassembly.
- Replay of an SD capture through the decoders.
- Audio FFT, Wi-Fi utility, captive-portal DBC/PGN loader.
The full specification and build sequence live in
WioTerminal-Island-v1-TODO.md.
Requires PlatformIO.
pio run # build
pio run -t upload # flash
pio device monitor # 115200 baudAdjust upload_port / monitor_port in platformio.ini to
match your board's serial device.
Dependencies are resolved by PlatformIO: Seeed_Arduino_LCD, the LIS3DHTR
driver, Seeed Arduino FS, Seeed Arduino SFUD, and ArduinoJson 7.
platformio.ini builds with -DSL_TEST_HOOK=1, which lets you drive the UI over
serial without hands on the device:
a b c buttons A / B / C
u d l r 5-way directions
p 5-way press
Drop that flag for a field build.
Launcher — 5-way to move across the tile grid, PRESS or A to open a tile,
B to cycle the theme.
Inside a screen — C goes back. Per-screen actions are printed in the header
hint: A toggles logging on the Vibration screen, restarts the autobaud probe on
Serial, and so on.
The card is optional — the firmware boots and scans without one — but it is where config and captures live. Directories are created on first mount.
/config.json Tier 3: authored off-device (or pushed by the dock)
/config/i2c.json Tier 2: persisted I²C disambiguation choices
/config/ui.json Tier 2: persisted theme
/logs/*.log raw captures
/tables/*.json decode tables, pushed by the dock
The dock may write only /tables/ and /config.json, may read only /logs/,
and may delete only /logs/ — and only against a verified hash. Tier-2 files are
the user's own on-device choices and are not readable over the dock at all.
Every key is optional. A missing, unreadable, or malformed file falls back to built-in defaults with a note on serial — bad config never hard-fails the boot.
{
"wifi": { "ssid": "", "pass": "" },
"can": { "bitrate": 0, "link_baud": 0, "cs_pin": 50 },
"uart": { "baud": 0 },
"log": { "max_bytes": 8388608, "min_free_mb": 32 }
}uart.baud of 0 means autobaud, and so does can.link_baud — that one is
the baud of the link to the module, walked across the five rates its firmware
supports. Pin it to skip the walk and to make a failure report itself instead of
being papered over by a fallback.
can.bitrate of 0 does not mean autobaud; it means leave the CAN bitrate
exactly as the module was last configured. Bitrate is set, never scanned. An
MCP2515 sitting on a live bus at the wrong bitrate does not fail quietly — it
misreads every frame and answers with error frames, damaging the bus it was
brought along to observe. Set it to one of the module's 18 rates (250000 for
NMEA 2000 and J1939, 500000 for most vehicles) and it is applied over AT+C.
cs_pin belongs to the legacy header-SPI MCP2515 path and defaults to the
board's PIN_SPI_SS, which is 50 on the Wio Terminal.
Config is split by how often it changes and how painful it is to type on a 5-way switch: Tier 1 is auto-detected (I²C addresses, UART baud, CAN bitrate), Tier 2 is picked on-device and persisted, Tier 3 is authored off-device and SD JSON is its single source of truth.
Three CRT-inspired skins, ported from the mother project. Chrome is monochrome — one phosphor colour and its shades — while the traffic-light status colours stay vivid across all three, so a warning always reads as a warning.
green— classic green phosphoramber— P3 amber phosphorlight— sterile clinical white; chrome is greyscale, and only ok/warn/bad carry colour, for meaning alone
Cycle with B on the launcher. The choice persists to /config/ui.json.
Every boot prints a smoke self-test — LCD geometry, the internal accelerometer's
WHO_AM_I, the I²C scan census, SD state, config provenance, and a logger
write→close→stat→cleanup round-trip:
SMOKE begin Scottina Light v1-foundation
SMOKE lcd=PASS 320x240
SMOKE i2c.imu=PASS Wire1 0x18 WHO_AM_I=0x33
...
SMOKE result=PASS checks=8 failures=0
SMOKE end
include/
scope.h scope banner + product identity — read this first
scottina_light.h the whole public surface
theme.h palette struct
pictograms.h glyph enum
tft_setup.h TFT_eSPI config, force-included by the build
src/
main.cpp boot sequence + smoke self-test
core_i2c.cpp bus, probe, two-pass scan, known-device registry
core_storage.cpp SD mount, three-tier config, raw logger
core_ui.cpp input, chrome, nav stack
screens.cpp launcher + the five screens
splash.cpp phosphor rain
theme.cpp the three palettes
pictograms.cpp vector glyphs
Two details worth knowing before you touch the low level:
The cold-begin phantom ACK. On a bus with no pull-ups — nothing plugged into
Grove — the SAMD51 SERCOM reports a spurious ACK for whichever address is probed
first after a cold begin(). Verified on hardware: a cold probe of 0x42 ACKs,
and every probe after it correctly NACKs. i2cbus::beginBus() burns one throwaway
transaction to absorb that false positive, and it must run before anything scans.
No enumerator may be named RTC or ADC. CMSIS defines both as object-like
macros in samd51p19a.h, and the preprocessor does not respect enum scope.
Light has no battery-backed RTC. Standalone, it does not know what time it is, so its SD logs are stamped in seconds-since-power-on — useless for lining up against anything Scottina Prime saw. Docking is what fixes that.
Plug Light into Prime over USB and a sync runs itself: Prime pushes the wall
clock, pushes the enabled decode tables into /tables/, and pulls the black-box
logs off the card. Prime always wins — nothing is ever edited on Light, and a
stale table is overwritten rather than merged. You watch; you don't drive.
The wire contract is DOCK-PROTOCOL.md, mirrored verbatim in
the kilodash repo. Three things are worth knowing without reading it:
- Diagnostics only, still. The dock is provisioning and retrieval: it can set
the clock, write tables, and read closed logs. It cannot trigger
transmission, start a capture, or execute anything — see
include/scope.h. Docking does not weaken that contract. - Docking suspends logging, and says so. The SD layer permits exactly one
open file at a time, so the dock cannot serve a log file while the logger holds
the handle. A dock session therefore closes the active capture and reopens a
fresh one on
BYE. The gap is written into the new file's header, along with the clock epoch and its quality (ntp/rtc/unsynced) — a clock Prime could not vouch for is labelled as such, never laundered into the record as truth. A 10 s watchdog resumes logging if the cable is yanked mid-session, so a dead dock can never leave the black box switched off. - A delete is verified or it does not happen. Logs are only unlinked after Prime returns the sha256 of what it actually received and Light agrees. A pull that verifies nothing is a copy; a delete that verifies nothing is data loss.
Nothing on Light reads /tables/ yet — the decode layer is not written — so a
successful table push has no visible effect on the device. That is correct: the
dock provisions ahead of its consumer.
The protocol's conformance vectors live in
dock-vectors.json and are shared with kilodash. The frame
codec and the path reject-pass are free of Arduino and SD so they run against all
47 of them on a laptop:
pio test -e nativeScottina is the mother project — the front panel. Scottina Light is its Wio Terminal sibling, sharing the visual language and the per-screen model but none of the code and none of the runtime dependency. Where a subsystem exists on both, the screen registry order and the colour keys follow the mother.
Standalone by design.
by Scott McLeslie