A full project website (with the same content as this README, in a browsable format) lives in
docs/— opendocs/index.htmllocally, or enable GitHub Pages on this repo (Settings → Pages → Deploy from branch →/docs) to serve it athttps://0xknowles.github.io/Ruby/.
Warning
Educational and authorized-testing use only. Ruby passively captures WiFi and Bluetooth traffic from everything in range, not just your own devices — and can optionally transmit real 802.11 deauthentication frames (see Active deauth — off by default, gated behind an explicit target list). Only point it at networks and devices you own, or have explicit written authorization to test. Capturing traffic from networks you don't control, and especially transmitting deauthentication frames at them, is illegal in most jurisdictions (wiretapping/interception and RF-interference statutes both apply) regardless of how small or "hobbyist" the device is. This project is published for security research and education. You are solely responsible for how you use it and for knowing the laws that apply where you use it.
Ruby is open-source custom firmware for the Xteink X3 and X4 e-ink readers that turns the hardware into something else entirely: a stealthy, long-battery-life passive RF signal analyzer with a small digital companion living in the corner of the screen.
It is not a book reader. It does not read EPUBs, sync with KOReader, or talk to OPDS catalogs. It listens — passively, receive-only, by default — to the WiFi and Bluetooth Low Energy traffic already in the air around it, catalogs what it hears into an encrypted on-device log, and Ruby reacts with a changing expression as a visual front-end for that catalog — closer to Pwnagotchi's mood faces than to a pet whose appearance grows or evolves. A separate Level 1-5 badge does track lifetime EXP (see Meet Ruby), but leveling only ever changes that badge's number, never the creature's art.
- Hardware: Xteink X3 or X4, ESP32-C3 (single-core RISC-V, no PSRAM), 792×528 e-ink panel, runs in landscape (the panel's native orientation). One firmware binary for both; hardware is detected at runtime, not chosen at build time.
- Capture: 802.11 monitor-mode WiFi sniffing across all channels (beacons, probe requests/responses, WPA 4-way-handshake message detection) plus passive BLE advertisement scanning — neither path associates, pairs, or connects to anything by default.
- Log: every observation is deduplicated for the on-screen stats and independently written to an AES-256-GCM encrypted daily log on the SD card, decryptable on-device (no PC required) or via a bundled Python script.
- Opt-in, off by default: raw plaintext
.pcaphandshake export for offline auditing with hashcat/hcxpcapngtool(with on-device PMKID-capable-capture visibility), and an active-deauth capability gated by a two-list whitelist/blacklist system — see Active deauth for why it's currently confirmed non-functional on this hardware. - On-device management: a live network-scan picker to build the whitelist/blacklist without a PC, a Power-button action you can set to Screenshot/Pause/Refresh, and a Settings screen for every capture toggle.
- Vendor lookup, always on, no setting to flip: Recent Devices falls back to an optional vendor-name lookup by MAC OUI for entries with no advertised SSID/name — see Vendor OUI lookup.
- Level 1-5 badge + header EXP bar: tracks lifetime EXP (handshakes worth far more than routine unique-device sightings) beside Ruby's name chip, with a matching progress bar next to the battery badge — see Meet Ruby. Cosmetic only; the creature's art never changes.
- License: MIT. Single PlatformIO environment (
default), built and static-analyzed on every push via GitHub Actions.
The easiest way to get Ruby onto real hardware is to flash a prebuilt release straight from your browser — no toolchain install required.
- Grab the latest
firmware-default-v*.binfrom the Releases page. - Plug the X3/X4 into your computer with a USB-C cable.
- Open CrossPoint Reader's flash tool in a Chromium-based browser (Chrome, Edge, or Opera — the page uses the Web Serial API to talk to the device directly, which Firefox and Safari don't support).
- Select the device's serial port when prompted, point the tool at the
.binyou downloaded, and follow its on-screen flashing steps. It writes directly over USB; nothing leaves your machine. - Once it finishes, power-cycle the device. It should boot straight to the splash art above and land on the dashboard.
This is the same web-based flashing tool used for CrossPlant/CrossInk-family firmware on this hardware, since Ruby shares its low-level display/power/storage bring-up with that project (see Where this comes from).
Prefer to build it yourself, audit the source first, or you're developing against it? See Building from source below.
The creature's shape doesn't change — it's one fixed form, already fully itself. What changes frame to frame is its expression, redrawn in a small fixed corner box on the dashboard as a live reaction to what's been heard most recently over the air:
There's no stage, nothing to "feed" — just a mood, the same idea as Pwnagotchi's faces, driven
entirely by SignalCatalog's counts rather than anything cosmetic. Separately, a small Level
1-5 badge sits next to the "RUBY" name chip, tracking lifetime EXP: a captured handshake is
worth 2000 EXP, a newly-seen unique device (AP/client/BLE) worth 1 — weighted that unevenly
because a handshake is roughly 2000x rarer in practice, so the two end up contributing comparably
to leveling over a typical session instead of one drowning out the other. Leveling only ever
changes that badge's number, never the art above. A matching progress bar sits in the header, next
to the battery badge, filling up toward the next level, with an "N/M EXP" label beside it —
EXP earned so far this level and the fixed total that level spans (not a countdown; "MAX" once
level 5 is hit). Crossing into a new level briefly shows a "LEVEL UP → Lv.N" banner (see
Screens).
CURIOUS specifically requires several new-unique sightings within a short window, not just one —
a single new device reads as ordinary CONTENT instead, so CURIOUS stays meaningful ("something's
actually picking up") even in a busy RF environment where new devices show up constantly. Pausing
capture (see Screens) shows BORED immediately, since nothing's being observed anymore,
rather than freezing whatever mood was showing right before Pause.
Ruby also has something to say about it. A thought bubble running along the bottom edge of her box
(RubyThoughts.h), tailing up into her face, shows a quirky, hacker-flavored one-liner tied to whichever mood is currently showing — "ooh, what's
this OUI?" while CURIOUS, "netstat -a: empty :(" while LONELY — updating on the same ~1.2s cadence as
the art itself. It's briefly replaced by a pointed speech bubble reacting to something that just
happened — a new AP/client/BLE device, a captured handshake, a level up, a nearby deauth alert — each
with 9 possible lines to pick from so it isn't the same one every time, before fading back to the
ambient thought bubble.
When the device is asleep, it shows a different, much larger piece of art instead:
- Capture.
WifiSnifferputs the ESP32-C3's radio into 802.11 monitor mode and hops across channels, extracting beacon/probe-request SSIDs, MAC addresses, and — when it hears one — the presence of a WPA 4-way handshake and which message number it is. The radio holds its current channel for a few seconds whenever it sees part of a handshake, instead of hopping away and missing the rest of it mid-exchange.BleScannerruns a passive BLE scan (it never sends a SCAN_REQ) and records advertised MACs, names, and manufacturer-data length. Neither path associates, pairs, or connects to anything, and BLE scanning never transmits. WiFi capture is passive by default too, unless active deauth is deliberately turned on — an explicit, off-by-default opt-in covered in its own section below. - Catalog. Every observation is deduplicated by
SignalCatalogagainst a bounded in-RAM hash set. The first time a given MAC/role is seen, that's a "unique" event: it increments a lifetime counter and nudges the creature's expression. - Log. Independently of deduplication, every observation is encrypted (AES-256-GCM via mbedtls, one record per packet) and appended to a daily log file on the SD card. The AES key is derived from a random seed generated on first boot plus this chip's eFuse MAC address and lives only in internal NVS flash — pulling the SD card gets you ciphertext with no key beside it. Note this means every sighting is logged, not just first-ever ones — a busy RF environment (a handful of phones re-advertising over BLE every few hundred milliseconds, a dozen APs beaconing every ~100ms) can write a meaningful volume of records per hour. That's intentional for a capture tool, but budget SD card space and export/rotate accordingly. See Exporting captures.
- React. The creature's shape (
RubyManager) never changes — see Meet Ruby above for what each expression means, when it shows, and how the separate Level 1-5 badge tracks lifetime EXP alongside it without touching the art. - Display. The dashboard is mostly static: a header, Ruby's box with its live Mood readout,
boxed RF stat panels, a footer. The creature lives in a fixed corner box that redraws on its
own ~1.2s cadence using
HalDisplay::FAST_REFRESHwithout ever touching (orclearScreen()-ing) any pixel outside that box — seeRubySpriteRenderer.handDashboardActivity.hfor the full explanation of why that reads as a genuine partial refresh on hardware that has no windowed-update API.
- Dashboard (home) — the creature, a Level 1-5 badge beside its "RUBY" name chip and a
matching EXP progress bar in the header next to the battery badge (see
Meet Ruby), its current Mood, an "-- ACTIVE --"/"-- PAUSED --" tag under it
(always shown, not just while paused, so the layout below never shifts between the two states).
Beside the creature's box, a RECENT DEVICES card shows a handful of the latest observations
in full detail (type/MAC, signal bars + RSSI + time-ago, device name), and a SIGNAL HISTORY
card next to it stacks two sections — both cards extend down the full height of the box + Mood
column beside them, not just the box's own height. HANDSHAKES (top) is a plain "N ago" text
list of recent captures, newest first, persisted across reboots (unlike Recent Devices' live,
RAM-only feed) so it stays genuinely historical rather than resetting to empty on every power-on
— handshakes are too rare to track meaningfully via a feed any ordinary AP/client/BLE traffic
cycles through in moments anyway. SIGNAL (bottom) is the same recent-observations feed's RSSI
as a live bar chart. Below that, SIGNALS (unique AP/client/BLE/handshake counts, plus a
"Last Hand Shook:" row — slightly larger than the others — showing the BSSID of the most recent
handshake capture, "none yet" until the first one) and
CAPTURE STATUS (a single "Capture" row naming whichever of WiFi monitor/BLE scan is actually
active, log size, this session's uptime, and lifetime Total Uptime across every boot) sit side by
side. A bubble along the bottom edge of Ruby's box, tailing up into her face, shows a quirky,
hacker-flavored one-liner reflecting Ruby's current mood, briefly replaced by a speech bubble
reacting to a new device, a captured handshake, a level up, or a nearby deauth alert (see
Meet Ruby). A banner briefly announces "DEAUTH NEARBY: network", "LEVEL UP →
Lv.N", or "HANDSHAKE CAPTURED: network" (in that priority order) when any of them happens —
the network name resolved from the frame's BSSID via the same live scan cache
Active deauth's network picker uses, falling back to the raw MAC address if it
hasn't advertised a beacon recently enough to still be cached.
Confirm→ Settings,Left→ toggle Pause (same button pauses and resumes WiFi/BLE capture — no screen change, and it stopsDeauthEnginetoo since that only ever fires from the observation stream capture produces),Right→ Export/Maintenance,Up→ Recent Devices,Down→ Log Viewer,Back→ force a full ghost-clearing refresh. - Settings — toggle WiFi/BLE capture (BLE off by default — see Hardware for why), adjust WiFi channel dwell time, lock WiFi monitor to a single channel instead of hopping all 13 (see Raw handshake capture for why that matters), set the ghost-clear refresh interval, choose what a short Power-button tap does (Refresh / Screenshot / Pause — a long hold is always Sleep, not configurable), turn on raw handshake capture or active deauth (both off by default), manage the whitelist/blacklist (opens a live network scan to add/remove targets — see Active deauth), reveal the log's AES key, wipe the log, or reset the Dashboard's SIGNALS counters (and Ruby's Level/EXP, handshake-capture history, and lifetime Total Uptime, all kept in sync with it) back to zero.
- Export/Maintenance — how to pull captures off the SD card, plus the current session's record count and (when any exist) raw-capture file stats, a PMKID-capable-capture count, and deauth burst/frame counters.
- Recent Devices — a live, RAM-only feed of the last 16 WiFi/BLE observations (type, MAC,
RSSI, SSID/name, time since seen), newest first, read-only, falling back to a vendor-name
lookup (see Vendor OUI lookup) for entries with no SSID/name of their
own. Each entry's RSSI reading is paired with a small 4-bar signal-strength icon (like a phone's
signal indicator) for an at-a-glance read on how strong the observation was, without parsing the
dBm number. Laid out as a dense multi-column grid so all 16 entries always fit on screen at
once — this expanded, read-only view is where Dashboard's own narrower RECENT DEVICES card sends
you for the full feed. This is separate from both
SignalCatalog(which deliberately never retains which specific MACs it has seen — only dedup counts) and the encrypted log (which retains everything, but only ever encrypted at rest). Nothing shown here is persisted; it's lost on reboot along with the rest of RAM. To manage the active-deauth target lists, use Settings' Whitelist/Blacklist rows instead (a dedicated live network scan — see Active deauth). - Log Viewer — browses the encrypted capture log on the device itself, no PC required. See
On-device log decryption below for how that's possible without any
key-entry UI.
Left/Rightswitch between daily log files,Up/Downpage through records within a file — around 27 at a time on this panel (computed from the same dense grid layout Recent Devices uses, not a fixed page size), newest first.
Everything above is deliberately metadata-only: the encrypted log records that a handshake happened and which message numbers were seen, never the key material itself. Raw handshake capture is a separate, opt-in capability for a different purpose — auditing the password strength of networks you own, offline, with tools like hashcat or hcxpcapngtool.
- Off by default. Turn it on at
Settings → Raw handshake capture. The Settings screen shows a warning explaining the tradeoff below before you flip it on. - When enabled, it captures the WPA 4-way-handshake frames verbatim (ANonce/SNonce/MIC included)
plus the SSID-bearing beacon for each network involved, and writes them to a standard libpcap
file at
/.ruby/pcap/YYYYMMDD.pcap— the same format hashcat/hcxpcapngtool already expect, so a captured file can be fed straight in to attempt a crack (e.g. converted to hashcat's.22000format viahcxpcapngtool). - These files are plaintext, unlike everything else Ruby writes to SD. A cracking tool needs
the exact bytes that went over the air, so this one path can't be routed through the AES-256-GCM
encrypted log the way every other observation is.
MaintenanceActivitycalls this out explicitly whenever raw-capture files exist. - The Export screen also shows a PMKID-capable count — how many captured message-1 frames
carry the PMKID key-data element, each one independently crackable via hashcat's
-m 22000PMKID mode without needing the rest of the handshake at all. - Turn it back off when you're not actively auditing — it's meant to be run deliberately, not left on as a background default.
Realistic capture rates are low — 0-2 handshakes/hour of passive listening is normal, not
broken. WiFi monitor mode hops all 13 channels by default, spending wifiChannelDwellMs (300ms)
on each, so any single channel is only actually being listened to roughly 1/13th of the time. A
real 4-way handshake completes in well under a second, and a stationary network's clients don't
renegotiate one often on their own — combine an inherently rare event with an ~8% chance of being
on the right channel when it happens, and a low hourly count is just the math working as expected.
Two real levers to improve it:
Settings → WiFi channel scopelocks monitor mode onto one known channel instead of hopping all 13 (see it per-network in the live scan behindWhitelist/Blacklist), raising that channel's duty cycle to 100% — at the cost of missing everything on the other 12.- Active deauth, below, is the bigger lever: it forces a handshake to happen now, on the channel already being listened to, instead of waiting on one to happen by chance.
By itself, this path only ever listens — nothing here transmits or provokes a handshake into happening; it records what a passive monitor already sees handshakes doing on their own. A held channel lock while a handshake is in progress (see How it works) helps catch the full exchange, but a real 4-way handshake completing on its own is still not guaranteed. Active deauth (below) is the opt-in answer to that gap — see its own section for the current status of its transmit capability.
Always on, no setting to flip, costs a few hundred bytes: DeauthDetector watches the
deauth/disassoc management frames WifiSniffer already classifies and flags when their rate
spikes — 5 or more within a 5-second window triggers a 15-second alert. This is evidence that
something is actively attacking a nearby network, not necessarily this device — it's a passive
counter, transmits nothing, and never changes what gets captured.
This is deliberately independent of Active deauth below: a half-duplex radio
can't hear its own transmission on the way out, so DeauthEngine's own bursts (were they ever to
actually reach the air — see the status below) would be physically invisible to this same
receive path anyway. In practice today it's moot regardless, since those bursts are confirmed
rejected by the WiFi driver before transmission.
The Dashboard's Nearby DeAuth row and banner (see Screens) are this detector's only UI surface — there's no separate history or export for it, just a live indicator.
Status: confirmed NOT functional on this hardware, via real-hardware testing. The transient-
WIFI_MODE_STAapproach did fix the earlier crash-loop (many bursts fired across a real session with no crash). But every burst'sesp_wifi_80211_tx()call was rejected by the WiFi driver itself — it logswifi: unsupport frame type: 0c0once per rejected frame — matching ESP-IDF's own documented behavior: its raw-TX allowlist (beacon/probe request/probe response/action/non-QoS data) hard-codes out deauth specifically, at the driver level. Bursts still get attempted, counted, and logged (seeSettings → Active deauth/Export's ACTIVE DEAUTH card, which shows a Rejected by driver count), but nothing actually reaches the air. The only known way past this — patching ESP-IDF's precompiledlibnet80211.ato weaken/overrideieee80211_raw_frame_sanity_check— is a real, community-used technique, but every example found targets classic ESP32/S2/S3 (Xtensa); this device is an ESP32-C3 (RISC-V), a different toolchain with no confirmed working precedent, and even on the platforms it's most tried on it isn't reliable. Not attempted here as a result — passive capture (below), optionally withSettings → WiFi channel scopelocked to a known target's channel, is the only capture path confirmed to actually work on this firmware.
Passive capture alone often isn't enough to actually catch a handshake — a real 4-way handshake completes in well under a second, and the radio has to already be parked on the right channel when it happens. Active deauth is meant to close that gap by transmitting real 802.11 deauthentication frames at a target network, forcing a client to reconnect so the resulting handshake lands in raw capture above instead of waiting — often in vain — for one to happen on its own. On this hardware, per the status above, it doesn't currently manage to transmit anything.
- Off by default, and requires raw handshake capture to also be on — forcing a handshake
nobody's capturing verbatim would just be disruption for nothing. Turn it on at
Settings → Active deauth. - Every target is gated by two genuinely independent lists (
TargetList, backed by/.ruby/targets.txton the SD card):- Whitelist non-empty — only the BSSIDs on it are attacked. The blacklist is ignored in this case.
- Whitelist empty, blacklist non-empty — every BSSID Ruby sees is attacked except the ones on the blacklist.
- Both empty (the default) — nothing is attacked, even with the feature switched on.
- Manage both from
Settings → Whitelist/Settings → Blacklist— each opens a live network scan (like picking a network to connect to); pressConfirmon an entry to add or remove it, a[+]marker shows current membership. Or edit/.ruby/targets.txtdirectly on a PC with the SD card out — entries live under[whitelist]/[blacklist]section headers.
- Each targeted BSSID gets a short burst of deauth frames, then a 30-second cooldown before it's attacked again, and attacks stop entirely for a BSSID once its handshake has been captured — this isn't meant to be a sustained flood against any one network. (Currently moot on this hardware per the status above — no frame actually transmits regardless of cooldown/targeting.)
- Frame transmission uses
esp_wifi_80211_tx(), the same raw-TX primitive most community ESP32 deauther projects use, but ESP-IDF's own driver rejects the deauth frame type outright before it ever reaches the air — see the status note above.
Treat this as real RF interference against whatever it targets, should the underlying driver restriction ever get lifted (by this project or a future ESP-IDF release). Transmitting deauthentication frames at a network you don't own or don't have explicit authorization to test is illegal in most jurisdictions, regardless of how small the transmitting device is. Only enable this against networks you own or are explicitly authorized to audit — the both-lists-empty default exists so that flipping the setting on can never itself put you outside that boundary; you have to deliberately add a target first.
Recent Devices falls back to a vendor-name lookup, by MAC address prefix, for entries that don't
already have an SSID or advertised name. This needs a user-supplied file at /.ruby/oui.txt — not
shipped with the firmware — one AABBCC<TAB>Vendor Name entry per line, the same format IEEE's
public OUI registry export or Wireshark's manuf file already use. Without that file present,
unlabeled entries just show "(no name)" as before. No database ships in firmware, so this costs
nothing when the file isn't present. A cold lookup still scans the file from scratch (it can't
assume the file is sorted), but a small 32-entry result cache means the same vendor, looked up
repeatedly across redraws of the same handful of on-screen devices — the normal case, since Recent
Devices/Device Log redraw the same ~16 entries every tick rather than a fresh batch each time —
only ever touches the SD card once. A curated subset (common consumer/IoT vendors, say) will still
feel snappier than a full ~50,000-entry registry on that first cold lookup.
A passive deauth/disassoc detector, BLE tracker detector, and persistent AP history were all prototyped alongside this on real hardware and then pulled back out: the AP history table alone added a fixed ~14 KB of permanent RAM use (256 entries, unlike every sibling live-scan cache in this codebase which stays well under 2 KB), and this chip has only ~380 KB total with no PSRAM to absorb it. That was enough to reliably starve NimBLE's controller init of the memory it needs, so BLE passive scan silently stopped working — a worse tradeoff than the awareness features were worth. Reliable BLE capture won out.
Same target as upstream CrossPlant/CrossInk: Xteink X3 or X4, ESP32-C3 (single-core RISC-V,
~380 KB usable RAM, no PSRAM), 792×528 e-ink panel. The dashboard runs in landscape
(792×528 logical) — the panel's own native orientation, needing no rotation math to draw into.
MappedInputManager is a direct, orientation-unaware passthrough from logical buttons
(Back/Confirm/Left/Right/Up/Down/Power) to hardware — screen orientation is purely a rendering
concern, so it has no effect on what any button does. What does change with orientation is
Chrome::drawFooterHints: in landscape, the on-screen button-hint pills move from a horizontal
bar along the bottom to a vertical strip along the right edge, with rotated text — see
ui/Chrome.cpp for the coordinate-geometry reasoning behind that specific edge.
The ESP32-C3 has integrated WiFi 802.11 b/g/n and Bluetooth 5 (LE only) — both capture paths run on the same radio hardware the original firmware used for book downloads and OTA updates.
BLE passive scan is off by default, unlike WiFi monitor capture. Real-hardware testing found
that starting it fragments the same DMA-capable memory pool EncryptedLog's hardware AES needs
down to just a few KB, immediately at boot — tripping a proactive safety restart every single
time, identically, before the device ever became interactive. Restarting alone didn't fix anything
since the exact same fragmentation reproduced on the very next boot, so the device sat in an
endless ~1-second restart loop that looked and felt exactly like a hang. A crash-loop guard now
forces BLE (and raw handshake capture and active deauth, the other two "extra" capture paths) back
off automatically after 3 restarts in a row, so a device stuck in that loop recovers into a
working state on its own — but the underlying memory pressure that causes it in the first place
isn't fixed, just contained. Turn BLE on deliberately, in Settings, with that in mind.
BLE passive scan and raw handshake capture can't both be on. Further testing found BLE alone
settles at a steep but survivable ~63 KB of heap use once running — fine on its own — but turning
raw handshake capture on while BLE is already running (or the other way around) is what actually
tips the DMA-capable pool into fragmenting badly enough to crash-loop. Settings now refuses to
enable either one while the other is already active, so hitting this no longer means waiting out
a restart loop — just turn the one you don't need off first.
The UI font is Space Mono (SIL OFL 1.1) — a monospace
face chosen to read as "instrument readout" rather than "app UI," which fits a device whose whole
job is displaying raw RF telemetry (see lib/EpdFont/builtinFonts/all.h). Boxed elements (the
footer's button tabs, the battery badge, Ruby's box, Settings' selected row) use a shared
rounded-outline style (Chrome::kCardRadius) instead of solid filled highlights — outlines read as
"buttons" without being the heaviest, most ghost-prone thing on the screen after a FAST_REFRESH.
All of Ruby's art (splash, mood faces, sleep art — see Meet Ruby) is source BMP
under bmp/, baked into the firmware image at build time so a freshly flashed device
shows it immediately with no SD card setup required. It can still be overridden per-file at
/bmp/<name>.bmp on the SD card without recompiling — see bmp/README.md for
the full format/size table and how to swap in your own art.
Ruby is built on CrossPlant's hardware foundation — the same e-ink display driver, input handling, power management, SD storage, font rendering, and activity/screen framework that make CrossPlant (and its own ancestor, CrossInk) boot and draw on real Xteink hardware. Everything above that foundation — the RF capture layer, the encrypted log, the creature, every screen — is new.
| Layer | What it is |
|---|---|
freeink-sdk/, lib/hal, lib/GfxRenderer, lib/EpdFont |
Hardware bring-up: display driver, buttons, power, SD card, fonts, graphics primitives. Carried over from CrossPlant/CrossInk largely unmodified — this is what makes the firmware boot on real hardware. |
lib/RfCapture |
New. 802.11 monitor-mode WiFi sniffing, passive BLE advertisement scanning, the opt-in raw-frame path behind .pcap export (with on-device PMKID visibility), and the opt-in DeauthEngine/TargetList active-deauth capability. |
lib/SignalCatalog |
New. Deduplicates observations into "have I seen this MAC before" and lifetime unique-device counters, plus a small RAM-only ring of recent sightings for the Recent Devices screen and a live dedup-by-BSSID scan cache for the network-picker screens. |
lib/RubyLog |
New. AES-256-GCM encrypted append-only capture log. |
src/ruby |
New. The creature: procedurally-rendered (no bitmap art pipeline for the logic — see bmp/ for the actual source art), fed by SignalCatalog. |
src/activities/* |
New. Dashboard, Settings, device list, log viewer, network picker (targets/), and Export/Maintenance screens replace CrossPlant's reader/browser/pet activities entirely. |
src/CaptureControl.h/.cpp, src/Screenshot.h/.cpp |
New. Session-only capture pause and framebuffer-to-BMP screenshot, both reachable from the Power button (see Screens). |
Reading-specific subsystems (EPUB/TXT/XTC rendering, the file browser, OPDS, KOReader sync, WiFi-connected file transfer, the Lexend Deca/Bitter/Charein reading fonts, i18n) were removed, not disabled — there is no code path for them left in this repo.
pio run -e default # compile
pio run -e default -t upload # flash over USBThere is a single PlatformIO environment (default). Unlike upstream CrossPlant's tiny/
xlarge split — which existed to omit different reading-font sizes depending on flash budget —
Ruby ships one fixed, small UI font set, so there's no equivalent tradeoff to build variants
around. The same binary supports both X3 and X4: hardware is detected at runtime
(HalGPIO::deviceIsX3()), not at build time.
.github/workflows/ci.yml builds this env and runs pio check
static analysis on every push/PR.
The Log Viewer screen (Dashboard → Down) decrypts and browses the log right on the device,
with no key ever typed in. This works because the AES-256 key that encrypted a given file never
actually left the device that wrote it: EncryptedLog derives it once at boot (hardware TRNG seed
- eFuse MAC, see What "encrypted" means here) and keeps it resident
in RAM for as long as the firmware is running. Reading a record back is just
mbedtls_gcm_auth_decryptwith that same in-memory key — the same primitivewriteEnvelopeuses in reverse, plus GCM's built-in authentication tag check, which doubles as corruption/tamper detection for free.
Random access to record N is O(1) rather than a scan from the start of the file: every record is
encrypted from a fixed-size 39-byte LogRecordPlaintext, so every on-disk envelope is exactly the
same 70 bytes (kLogRecordEnvelopeSize), and record N always starts at byte N * 70. That's what
lets the Log Viewer jump straight to "the last 8 records" or "the 8 before that" via
EncryptedLog::decryptRecordRange() without touching anything else in the file.
The PC-side path still exists and is still the only way to get the plaintext off the device entirely (Log Viewer only ever displays it on-screen):
pip install -r scripts/requirements.txt
python3 scripts/decrypt_log.py --key <64 hex chars> 20260717.pclogThere is no USB/WiFi transfer protocol — power the device off, pull the SD card, and copy files to a computer:
- Encrypted log —
/.ruby/log/*.pclog. Decrypt with the command in On-device log decryption above and the key from Settings → Reveal log key. - Raw handshake captures (only present if you turned the feature on) —
/.ruby/pcap/*.pcap, already plaintext. Load these directly into hashcat orhcxpcapngtool; see Raw handshake capture above. - Screenshots (only present if you used them) —
/.ruby/screenshots/*.bmp, plain uncompressed 1-bit bitmaps. Set Settings → Power button (tap) → Screenshot to save one with a short tap of the Power button.
/.ruby/oui.txt (vendor OUI lookup, see Vendor OUI lookup) goes the other
direction — it's a file you supply yourself, not one Ruby writes, so there's nothing to export
for it.
The log is AES-256-GCM encrypted at rest so that a lost or stolen SD card doesn't hand over a plaintext capture history. It is not anonymized — record contents are the same MAC addresses, SSIDs, and RSSI values a passive analyzer would see over the air, deliberately, so the log is actually useful for its purpose. Treat it and the device itself the same way you'd treat any other RF capture tool: know the rules that apply where you use it, and don't point it at networks or people without a reason you'd stand behind.
This does not apply to raw handshake capture (see above): those .pcap files are plaintext
by necessity and are off by default specifically because they don't get this protection.
Book-reading, remote sync, and WiFi-client features made no sense for a device whose entire value proposition is that it never associates with a network:
- Epub/Txt/Xtc readers, file browser, OPDS, KOReader sync — no reading surface exists.
- WiFi station mode / "join network" / web server / WebDAV / OTA-over-HTTP — dropped
entirely. The device's WiFi radio only ever runs in monitor mode; it never has an IP address.
Firmware updates are USB-only (flash a release or
pio run -t upload), and capture export is SD-card-only. - Multi-language reading fonts (Lexend Deca, Bitter, Charein) and i18n — the UI is English- only dashboard chrome, not paragraphs of book text, so ~57 MB of glyph tables and the translation pipeline were both unnecessary.
- Auto-sleep on inactivity — a reader auto-sleeps when you stop pressing buttons because reading is a foreground activity. A passive analyzer's entire job is to sit still and listen with no buttons pressed; auto-sleeping on that basis would defeat the point. Sleep here is always an explicit long-press of the Power button — a short tap does whatever Settings → Power button (tap) is set to instead.
- A "Lore" progression screen — an earlier build unlocked short flavor-text entries as captures accumulated. It read as a pet-sim feature bolted onto a recon tool rather than something that earned its screen real estate, so it was removed outright in favor of Pause on the same Dashboard button.
MIT, same as upstream CrossPlant/CrossInk. freeink-sdk/ carries its own MIT license and
third-party attribution — see freeink-sdk/NOTICE.






