PUEO

Firmware

OVERVIEW

Pueo is open-source firmware for a handheld multi-radio field tool built on
the ESP32 CYD, the "cheap yellow display", a touchscreen ESP32 that
costs about ten dollars. The board is the 3.5″ ESP32-3248S035R. Bolted to it: a
CC1101 for sub-GHz, an NRF24L01+PA+LNA for 2.4 GHz, a PN532 for NFC
and an ATGM336H for GPS, in a printed enclosure zoned to keep the
radios apart.

It covers WiFi and BLE reconnaissance, sub-GHz capture and replay, NFC read
and clone, GPS wardriving and jam detection. It is a fork of CiferTech's
ESP32-DIV, which is MIT; this fork is GPL-3.0-or-later. It diverges
mainly in the parts
that decide whether the hardware works at all: a board profile that resolves
the pin conflicts in the stock CYD map, a single owner for the SPI bus the SD
card and all three radios share (the display is not on it, it has HSPI to
itself), and a build that compiles clean under -Wall -Wextra rather
than suppressing its own warnings.

There is no shortage of ESP32 multi-tool firmware, and most of it shares an
ancestor. The work here went into the layer underneath the features: getting
the radios and the card to share one SPI bus without standing on each other.
Four devices here, and five on the 2.8″ ESP32-2432S028R this was written
against before that panel was dropped, where the touch controller was on the
bus too and was the one that hurt.

The screens are on the front page: boot, the main menu and a submenu, and Surveillance with six detections on it.

FEATURE LIST

The Pueo WiFi submenu: twelve tiles in three columns - Packet Monitor, Beacon Spammer, WiFi Deauther, Probe Request Flood, Deauth Detector, WiFi Scanner, Captive Portal, Hidden SSID Revealer, WPS Scanner, ARP Scanner, Karma Attack and AP Tracker - each an icon above its name, with AP Tracker filled orange. A header reads WiFi on the left and 12 features on the right, and a Main Menu bar runs along the bottom. There is no page button: all twelve are on one screen. Probe Request Flood is wrapped onto three lines, the only label in the firmware that needs more than two.
Every entry in the menus, as of 0.4.47. + marks what this fork added; the
rest is upstream's, reworked where the pin map or the shared bus demanded it.

WiFi
  Packet Monitor        promiscuous capture to a pcap on the card, and it
                        recognises WPA handshakes in what it was already recording
  Beacon Spammer        floods fabricated beacons
  WiFi Deauther         transmits deauthentication frames
  Probe Request Flood   floods fabricated probe requests
  Deauth Detector       listens for deauthentication frames
  WiFi Scanner          lists networks in range
  Captive Portal        serves a portal page and records what is typed into it
  Hidden SSID Revealer  names hidden networks from the probes that reach them
  WPS Scanner           flags networks advertising WPS
  ARP Scanner           walks a joined network for live hosts
  Karma Attack          answers probe requests to draw clients in
+ AP Tracker            pick an AP, park on its channel, walk it down by
                        signal. Beacons arrive about ten times a second, so
                        the needle moves while you move; a scan sweep samples
                        once every 1.7 s at best and transmits to do it.
                        Receive only after the one scan that fills the picker

Bluetooth
  BLE Jammer            unmodulated carrier across the advertising channels
  BLE Spoofer           fabricated Apple, Samsung and Google pairing adverts,
                        plus Swift Pair and Flipper Zero
  Sour Apple            Continuity nearby-action prompts at nearby iPhones
  AirTag Spoofer        "a new AirTag is nearby" setup prompts
  AirTag Sniffer        separated trackers, and tails that rotate identity to
                        avoid being noticed
  Sniffer               raw advertisement log
  BLE Scanner           lists devices in range, and a detail view that
                        decodes the appearance field and names the services
                        the SIG assigned. Info connects to the one device
                        you have open and reads its Device Information
                        Service: manufacturer, model, serial, and the
                        firmware, hardware and software versions. The only
                        thing in the scanner that opens a connection, and it
                        only reads. The scan itself is active unless stealth
                        is on, so it was never the silent one
  BLE Rubber Ducky      keystroke injection over BLE HID
  Skimmer Detect        card-skimmer module signatures
+ Hunt                  Find a tracker already known to be there. Picks one
                        from what is in range (Find My, Tile, SmartTag,
                        Eddystone) then a needle that swings with signal
                        strength. Shows the direction to walk, never a distance
+ Fast Pair             Google Fast Pair scan, and a CVE-2025-36911 probe

2.4 GHz: NRF24
  Scanner               channel activity sweep
  Proto Kill            carrier across a chosen protocol's channels
  ESB Sniffer           Enhanced ShockBurst capture
  ESB Replay            replays what it captured
  MouseJack Scan        finds vulnerable wireless keyboards and mice
  MouseJack Inject      keystroke injection into them

Sub-GHz: CC1101
  Replay Attack         capture and re-send
  SubGHz Jammer         keys the transmitter on the chosen frequency
  De Bruijn / Brute     sweeps a keyspace
  Jamming Detector      listens for interference
  Saved Profile         stored captures, browsable without a radio fitted
+ Import .sub           reads a Flipper key file into a profile
+ Export .sub           writes a profile back out as one
+ Freq Analyser         sweeps the band and holds the peak, so you can
                        see which frequency a remote is on

NFC: PN532
  Card Reader           read a tag
  Card Clone            write it to another
  Erase                 blank a tag
  Dump                  full contents to the card
  Decode Access         read the access bits
  Jam Reader            hold a reader busy
  Tag Disrupt           and Disrupt Emulate

Detect
+ Surveillance          283 signatures over ten kinds: ALPR (Flock,
                        Motorola, Genetec) and its accessories, body cameras
                        (Axon), fixed cameras and doorbells (Ring, Hikvision,
                        Axis, Verkada, Avigilon, Wyze), smart glasses
                        (Meta/Ray-Ban), item trackers (Tile, SmartTag),
                        vehicle modules (KARR), pentest kit (Flipper,
                        Pwnagotchi, Hak5, O.MG) and mesh nodes (Meshtastic).
                        WiFi and BLE, receive only: never transmits, never
                        associates
+ Drone Detector        ASTM F3411 Broadcast Remote ID, on both the WiFi
                        and BLE paths. Operator location, aircraft position,
                        altitude, speed and the UAS ID. Receive only

Surveillance and the drone detector sat under Bluetooth until 0.4.0,
then under a tile called More until 0.4.11. Neither is a Bluetooth
feature: both listen on WiFi and BLE, and the menu they were in said otherwise.

More held four things that were not one category and it is gone. RFID/NFC
and GPS are separate hardware and have their own tiles now, alongside
Detect, which is the pair of passive detectors. The two slots came from
Settings and About, which are rows in System now, the menu
that used to be called Tools.
Surveillance and the drone detector are what this fork is for and they were
two taps in behind a label that said nothing.

GPS: ATGM336H
  Wardriver             logs WiFi and BLE against a fix, exports WiGLE CSV, and
                        uploads it; runs in the background. The upload
                        withholds vehicle modules; the log on the card
                        keeps them
  Satellite Scanner     fix quality and satellites in view

System
  Serial Monitor        a terminal on the USB port
  Update Firmware       from the card
  Touch Calibrate       and the calibration is stored
  SD File Manager       browse and delete
+ Reset SD              counts the card first, then removes /pueo and
                        nothing else. The card itself is never touched.
                        Two presses, and the arming lapses after five
                        seconds
+ File Transfer         serves the card read only over its own WPA2
                        access point, so a capture comes off without
                        pulling the card. See what's added.
  Settings              the rows below. They are a System entry rather than
                        a tile of their own
  About                 version, board, and the licence

Settings
  Brightness            backlight, and it is stored
  Theme                 dark or light
  Accent                seven presets
+ Stealth Mode          receive only. Every feature whose job is to transmit
                        refuses to start and says so; the scans that transmit
                        while looking like receivers go passive. The two
                        transmit paths with no menu entry of their own, the
                        Fast Pair probe and the scanner's Info read, refuse
                        inside the function that would transmit
  Auto Scan             background WiFi and BLE scanning
+ SD Logging            a master switch and one per feature, Surveillance,
                        Jam Detector, Packet Monitor, ESB Sniffer, Wardriver.
                        Off means the card is left alone
+ Boot Lock             a password asked for before the menu. Salted,
                        stretched SHA-256 in NVS, never on the card

The list scrolls, so it is not capped by what the shorter panel can show.

Not here any more. IR Remote, Record and Universal Controller were dropped:
140 KB of firmware that could never run on a board with no IR hardware.

A word on what these claims are worth. The entries marked + were
written here and are described in detail under
What's added. The rest came with the fork, and the
work went into making them build and share a bus rather than into auditing
what each one does. Where one has been read closely it has usually turned
out to do something slightly different from its name.

WHAT'S DIFFERENT

The rest of the features are upstream's work, and the interesting ones are a
lot of it. What follows is a small delta, almost all of it about one
specific pile of hardware: a CYD with three radios and an SD card on a
single SPI bus.
Most of these only misbehave in that combination, which is exactly why they
were still there to find.

One owner for the SPI bus. Four devices share VSPI here, and it was
five on the 2.8″ panel this was written against: the touch controller was on
it too, which is one more than the wiring suggests and the only input device
on the board. This panel's XPT2046 sits on the display's bus instead, so it
never contends.

The resource that conflicts is the GPIO matrix rather than the clock: SCK and
MOSI are peripheral outputs and can fan out to several pads, but MISO is an
input, and exactly one GPIO can drive it. On the 2.8″ touch reads GPIO 39 and
everything else reads GPIO 19, so whichever attached last owned the bus.
Pueo makes ownership explicit and re-points the matrix on handover.

The CC1101 driver closes the bus rather than the transaction. LSatan's
driver ends every register access with SPI.end(), which runs spiStopBus() and
resets the peripheral. On a board where the CC1101 has a bus to itself
(which is most boards, and certainly how the driver is usually used), that is
harmless. Here it resets the peripheral the display's touch controller and
the SD card are also on. ESP32-DIV carries comments describing the symptoms
("leaves the SD card dead until something else re-inits the bus"), so the
behaviour was known; this traces it to the driver and ends the transaction
instead, holding the SPI mutex so another task cannot interleave a transfer.

A one-byte overrun in the deauth frame builders. Both write frame[26] on a
26-byte array. The reason code actually lives at offsets 24–25 and the send
only transmits 26 bytes, so the write ran off the end without ever reaching
the air. It has been landing in alignment padding, which is why nothing ever
came of it: move a variable and it would stop being free. Sent upstream as
#277.

A log that wrote past the end of itself, and rebooted the board. The BLE
Sniffer shifts its display lines up by one for each new entry, over as many
lines as the screen can hold, and its buffer holds sixteen. On a 320×480
panel that zone holds thirty-one, so fifteen assignments landed past the end
of the array and onto the members after it. One of those is the scan
pointer, and the next scan called into it: StoreProhibited,
EXCVADDR 0x20, reboot.

It was already two past the end at 240×320, where they fell in padding and
nothing visible happened, the taller panel moved the landing site onto a
pointer rather than introducing the fault. The helper that answers "how many
lines fit" takes the caller's buffer size now and clamps to it, and the
argument is not optional, so the next log zone cannot repeat this by
forgetting. Two other logs were over by six and had not been opened yet.

Found by decoding the panic rather than reading for it: the backtrace named
the function, the disassembly named the offset, and the offset named the
member.

A brightness step that wraps. Stepping up from anywhere in 248–254 drops
the backlight to almost off: the caller passes brightness+8 as an int, the
parameter was a uint8_t, and the truncation happens before the clamp inside
can see it. There is a clamp there for exactly this, written one scope too
late to work.

Warnings turned back on. Upstream builds with -w and links with
-zmuldefs, which is common enough in Arduino projects and keeps a build log
readable. Turning both off is where most of the above came from. The one
duplicate symbol that was load-bearing (an override of the IDF's raw-frame
sanity check, without which frame injection does not work) is handled by
weakening the SDK symbol instead, so the linker can go back to catching the
accidental ones. It found a real one immediately: the CC1101 driver's spi
flag and TFT_eSPI's spi bus object had been folded onto the same address,
putting a one-byte variable on top of the display's SPI bus number.

A jammer that configured three radios onto one chip. Both jammers set up
radio1, radio2 and radio3 over three channel groups, which reads as
twelve channels across three modules. Pueo has one module, and its board
profile maps all three chip selects onto it, so every call went to the same
chip three times over. The setup loop had its own problem independent of
that: it called startConstCarrier once per channel, and each call replaces
the last, so only the final entry of each group ever took effect. The hopping
that did work was elsewhere, in the feature loop, picking a random channel
every millisecond and writing it three times. It is now round-robin on a
dwell, written once, which is also what removing the GPLv2-only radio
driver required, so the two landed together. The channel tables themselves
are a separate problem, reported as #259: they hold
WiFi and Zigbee protocol channel numbers where the radio wants an index.

A scanner that threw the shape away.
The 2.4 GHz scanner sweeps every channel with the NRF24's carrier detector,
ten passes each, and counts how many saw a carrier. It then draws a bar per
channel, which answers where there is energy and cannot answer what kind of
thing is making it, because what distinguishes things in this band is how
they move rather than where they sit.

This paragraph used to say fifty passes against a calibrated noise floor,
and call that better measurement than most of what is published for this
part. Fifty is the one-shot Scan and Cal buttons, not the continuous view.
And at the time the noise floor was measured and never used: Cal filled an
array, named the worst channel, and nothing subtracted it from anything. A
compliment resting on two things that were not so.

0.4.44 fixed that half. Cal now runs one clean sweep from a
cleared start and the display subtracts the floor per channel before
smoothing, so the bars and the waterfall see the same corrected numbers. It
was broken three further ways that went unnoticed only because the result
was unused: the counter was never cleared between runs, the array
accumulated across presses, and the scale matched nothing the display
counted in. None of it has run on a board, because the NRF24 is not wired.
The 2.4 GHz waterfall: a framed plot headed 2.4 GHz Waterfall, with time running left to right and channel top to bottom, labelled 2.40 at the left, time in the middle and 2.52 at the right. Against a dark blue noise floor three shapes stand out. A solid horizontal band of green, yellow and red across the full width, about a fifth of the height, is a WiFi channel sitting still. Two dotted horizontal lines are Bluetooth advertising on fixed channels. A series of yellow diagonals running down and to the right, wrapping back to the top, is a frequency hopper. Above the plot, three lines of text read Scanner ready, Peak Ch37 2.437GHz, and Active 31 channels.

Three shapes, one picture: the solid band is a WiFi channel sitting still, the dotted lines are Bluetooth advertising on fixed channels, and the diagonals are a hopper walking. None of that is visible in a bar chart.

A waterfall puts time across the plot and channel down it, one column per
sweep. It reuses the sweep the bars already do rather than adding one, so the
two views cost one sweep between them and cannot disagree about what was
measured. Toggled with Fall, on one of the two nav slots the scanner left
empty.

Time runs across rather than down because scrolling would need a framebuffer
to shift or a readback the panel does not offer, and advancing a column needs
neither. It keeps no history: 128 channels times the plot height is kilobytes
however it is packed, and there is no DRAM for it, so the drawn pixels are
the only record and leaving the screen loses them.

It ships in 0.4.44 and has never run, and the picture above is a render rather than a photograph. The NRF24 needs three solder joints and the shared SPI bus before
anything can show it on a panel, so no board has opened this view. The image above comes
from the same tool that draws the rest of the screenshots here, in the
panel's own fonts at 320 by 480, with synthetic traffic shaped like the real
thing. It is on this page because the shapes being tellable apart is the
whole argument for the view, and that is a question a picture can answer and
a description cannot.
A capture that did not stop when you left it. Exiting Packet
Monitor left the radio in promiscuous mode with the callback still
installed, and the pcap on the card still open. The next feature to start
then inherited a receiver configured by the last one, which is the same
class of problem as the filter below and harder to see, because nothing is
on screen to say the capture is still running. Every call site has a
matching teardown now, asserted by a check that was confirmed load bearing
by deleting one of the two. Reported as #274 and sent
upstream as #276.

A capture that silently recorded half the air. The promiscuous filter is
global: whatever one feature asks for stays in force for the next. Two
features asked for management frames only and nothing ever set it back, so
Packet Monitor (which wants data frames, since it writes them to its pcap
and counts them on screen) saw management frames only if the captive portal
or Karma had run first in the same session. No error, no empty file, just a
capture missing everything that was not a beacon. Every feature that reads
frames now states what it wants each time it starts.

Six screens drawing to a 320 px height on a 480 px panel. An earlier
sweep replaced the hard-coded panel dimensions and missed these, in one case
on adjacent lines:

  constexpr int SCREEN_WIDTH  = PUEO_SCREEN_W;   // swept
  constexpr int SCREEN_HEIGHT = 320;             // missed

because the check that found the first named SCREEN_WIDTH in its
constexpr pattern and SCREEN_HEIGHT only in its #define one, and
these are constexpr. Two Bluetooth log views showed 26 lines where 40 fit;
the WiFi content bottom fell back to 320, clipping every screen without a
touch nav bar. The check matches the shape now rather than two spellings of
it.

Settings that were written and never read. The boot sequence loaded
settings inside #if BOARD_HAS_ESP32S3. A CYD takes the other branch,
which applied touch defaults and printed “settings defaults (SD deferred)”,
and nothing anywhere else called the loader. Deferred meant never:
brightness, theme, accent and auto scan were saved to the card by Save and
read back by nobody, on every boot since the first release.

It had been deferred for a real reason, an SD mount right after display init
that rebooted the board, and the specific cause of that is compiled out on
this hardware now. The loader also says which of four things happened
(loaded, none saved yet, unreadable, no card) because its return value
says only whether anything went wrong, and a card with no settings on it
was reporting as “loaded”.

Verified on the board rather than argued for: a fresh card boots
settings: none saved yet, and after a save and a power
cycle the same line reads settings: loaded from SD. That
round trip had never once completed on this hardware.

A buzzer guard that was always true. The sub-GHz replay beep is wrapped in
#ifdef BUZZER_PIN, and the default is -1, the “no buzzer”
sentinel. A defined macro is defined whatever its value, so every board took
that branch and called ledcAttachPin(-1, 7). It tests the value now.
The 3.5″ does have a way to make a noise (an amplifier on GPIO 26,
enabled on GPIO 4, active low) though no speaker is fitted to it as
shipped.

A settings switch that controlled nothing. NeoPixel toggled, saved and
reloaded, and was read in exactly one place: the screen drawing its own row.
There is no NeoPixel driver in the tree and never was. It is gone. A
switch that answers the question is worse than a missing feature, because
someone turns it off, the light carries on, and what they conclude is that
their board is broken.

Every Apple device displayed as the letter L. The BLE scanner's detail
view drew manufacturer data with String((char*)raw.c_str()), which stops
at the first zero byte. Apple's company identifier is 4C 00, so an iPhone
showed as L and nothing after it, and Apple devices are most of what
anyone opens that screen to look at. Microsoft's 06 00 gave a control
character instead. Both fields are hex now, and the Tx Power line no longer
prints a number when nothing advertised one.

A corrupted GPS fix that looked like a real one.
stripChecksum() truncated an NMEA sentence at the
* and threw the two hex digits after it away. Nothing
then checked them. A corrupted RMC is still a well formed RMC, with a
plausible latitude and a plausible timestamp, and the wardriver wrote it to
the card as a fix like any other.

Sentences are verified against their own checksum now, before anything parses
them, and one arriving with no checksum at all is rejected rather than
accepted. That second half matters more than it sounds: truncation is the
corruption this link is most likely to suffer, and a sentence cut before its
* would otherwise sail through with its remaining
fields intact and wrong. Not sent upstream.

Sub-GHz features that locked the board when the radio was absent.
Opening any of them with no CC1101 fitted drew the feature's screen and then
stopped responding. There was no presence check; the driver's
Init() simply never returned.

It reads PARTNUM and VERSION
back over SPI now and checks the answer is one a CC1101 would actually give,
twice, because a floating line can look like a version once. All five
features that touch the radio go through the guard. Sent upstream as
#257.

A capture that dropped the only frames worth having. Packet
Monitor writes a pcap to the card and drops frames when its pool fills,
which on a busy channel is constantly. Harmless for ordinary traffic and
fatal for a WPA handshake: four frames arrive together, in a burst, at the
moment the channel is busiest, and losing one makes the other three
worthless. A bigger pool was tried and the board refuted it, because a
bigger pool fills later and the burst still arrives afterwards. Key frames
have eight slots of their own now that nothing else can take. The
handshake section has the
measurements. Reported upstream as #275, where the
pool is three slots on the non-S3 profile and a handshake cannot survive it.

Stealth Mode did not cover Web OTA. The switch is meant to mean
nothing transmits, and it gated every feature whose job is to transmit. Web
OTA is not one of those: it joins an access point and serves HTTP over it,
which transmits just as much and reads as an update screen. The gate sits on
the update path rather than on the screen, because the SD path beside it
reads the card and transmits nothing. The check that keeps the list honest
now asserts where each gate lives, so a gate that moves still reads as
present and one that goes does not.

A screen that refused to draw rather than drawing worse. With no
sprite allocated, the Satellite Scanner printed the largest free block and
the size it wanted, and stopped there. It renders straight to the panel now.
A viewport is what makes that work without touching the renderer, because
inside one the drawing calls are already relative to its origin.

Saved Profile gated on a radio it does not need. It lists records
off the card, shows one and deletes one, and only Send drives the CC1101, so
a profile imported from a .sub could not be
read back at all on a board with no module fitted. Both refusals moved onto
the send, which also fixed a Stealth panel saying "Saved Profile transmits,
so it is switched off" about a screen that was about to do no such thing.
The send refuses an unmappable protocol before it asks whether a radio is
fitted, because no radio would change that answer.

A jammer that kept transmitting after you left it. Backing out of
the SubGHz Jammer while it ran did not stop it. jammingRunning
was only ever cleared by the toggle button, so exiting any other way left the
radio keyed with nothing on screen to say so. Sent upstream as #255.

A scratch network with a published password. The WiFi tools raise a
SoftAP to push raw frames through WIFI_IF_AP. Nothing
is meant to associate with it, but its passphrase was a fixed string in the
source, so every unit running this firmware brought up a visible WPA2 network
whose key is in a public repository. It is hidden and open now, and carries no
key at all. Sent upstream as #258.

Menu tables that could be short. The menus are parallel arrays, a
table of labels beside a table of icon pointers, walked by the same index.
Nothing checked they were the same length, so a short icon table is a null
dereference at a menu position nobody happens to visit while testing. A check
now asserts both are fully initialised. Sent upstream as #256.

None of this is a criticism of a project that works well on the hardware it
was built for. Fork something onto a board it has not met and this is the
sort of thing you find.

When a module is not there. Three of the radios are
add-on boards, and a feature that needs one says so rather than failing at
it. The nRF24 features report No nRF24 and name the
pins: MISO, CSN, CE, SCK and MOSI. The sub-GHz features report
No CC1101 with MISO, CS, SCK and MOSI. RFID/NFC names
the PN532 with MISO, MOSI, SCK and SS, and with its DIP switches,
CH1 off, CH2 on: a PN532 left in I2C mode is the
commonest reason one is fitted, wired and silent, and nothing on the board
says which mode it is in. The submenu says it in its footer as well, probed
once and remembered, so you can see which of a menu's features will work
before pressing one.

GPS is deliberately not in that list. There is no
cheap probe for it: the module answers by emitting NMEA when it is ready, so
a brief look that found none would report no GPS for one still waking up.
Saying nothing beats saying something false.

The apps that listen to it wait instead, and report what they heard. The
Wardriver's GPS card reads WAIT while the module is
talking and has not fixed, NONE after five seconds in
which not one byte arrived, naming GPIO 1 as the pin to check, and
LOST when a module that was talking stops for eight.
The Satellites screen shows no GPS and
GPS lost for the same two. The test is bytes on the
wire rather than sentences parsed, because a module at the wrong baud rate is
present and should not be reported missing.

BUGS THAT WERE OURS

The section above is a delta against upstream: their code, read closely
because it had to share a bus with two more radios. This one is not. These
were written here, shipped here and found here. A page that only ever names
somebody else's mistakes is telling you something about its author rather
than about the firmware.

A BLE scan that starved the WiFi one. Surveillance and the drone detector
both listen on two radios, and there is one radio. A BLE scan whose window
equals its interval asks for it continuously, and the coexistence arbiter
then hands the WiFi side almost nothing, which is invisible in a
BLE-only feature and ruinous in these two.

It was measured rather than reasoned about, because the bench beacon counts
what it sends: 495 plate-reader probe requests transmitted, and Surveillance
found the first of them after about five minutes. A 100 ms interval with a
50 ms window is half the airtime each, and the first probe now arrives
immediately. Hunt and Fast Pair keep a full window. They have no WiFi side
to starve.

A drone list that came back blank. The screen skips redrawing a line whose
text it already drew, which is what keeps it from flickering. Re-entering it
cleared the aircraft table and the panel and never told that cache, so every
line matched what the cache believed was on screen and nothing was drawn:
aircraft still being tracked, on a screen showing nothing. The function that
clears it had been written for exactly this and was never called.

Both were found by the bench beacon rather than by reading. That is most of
the argument for having built it: a detector that finds nothing looks
exactly like a room with nothing in it, and until something in the room was
transmitting on purpose there was no way to tell those apart.

Six screens of text that did not fit. The 3.5″ panel is the denser of
the two, 165 ppi against the 2.8″'s 143, so identical pixels are
smaller text on the bigger screen, and 0.4.7 moved the list
screens to a 16 px font to fix that. Six places kept the 8 px font's
numbers underneath.

Three were caught during that release. The fourth was Fast Pair's device
list, the main screen of the feature, drawing three lines 11 px apart
in a 54 px row, each clearing a band too short to erase the line before it.
It shipped that way.

The last two were of a different kind. TFT_eSPI does not clip and does not
wrap: a line wider than the screen is drawn anyway, off both edges, and what
survives is the middle. Hunt's gauge spent two releases reading
7 dBm    best -45    143 se, and the LOST screen (whose entire
job at that moment is to say what went wrong) was 34 characters wide on a
26 character panel.

None of the six was visible from the firmware. It compiled clean and ran.
They were found one at a time by four different means, and the last two only
because the screenshots on this site are replayed from the firmware's own
coordinates by a script that had drifted, correcting the script drew the
overflow the board had been showing all along.

That is the argument for check_text_pitch.py and
check_text_fits.py, which are two rules between them. Inside a
function that sets the body font, a vertical measurement cannot be a literal,
because no literal is right for both an 8 px font and a 16 px one. And a
centred line's width is 6 × size × length, which either fits the panel
or does not. Ten flagged on the first run of the first one, nine of them
unknown. Between them they would have caught all six in the release that
introduced them.

A submenu that wrote down which entry was Back. The SubGHz
handler compared against the literal 5, which was Back until an entry was
inserted above it. Back moved to 6 and the literal then named the new
feature, so choosing that feature went to the main menu and choosing Back
did nothing, falling through to a switch with no case for 6. Two symptoms
from one constant, compiling cleanly, with every check green: the menu
tables were right, and the index was written down in a third place nothing
looked at. Both SubGHz branches derive it now, and so do NRF24's two, which
were correct for seven entries and primed to break identically.

A screen wired to buttons this board does not have. The import
screen read isPhysicalButtonPressed for Next, Prev and
Import. The PCF8574 is disabled here, so that is a constant false, and the
screen drew, scrolled nowhere and imported nothing while still being
possible to leave, because Exit falls through to the touch bar. The check
that now refuses this scans with comments stripped: its first version passed
on the text of a comment naming the touch calls, found by deleting the
handler and watching it stay green.

A Brightness setting that had never done anything. On the 3.5″ panel
the backlight is GPIO 27. The sketch had been attaching its PWM
channel to GPIO 21 since that panel was supported, a pad with
nothing on the end of it.

Everything around it was already right and said so. TFT_eSPI's setup branches
per panel, 21 and 27, and drives that pin HIGH when the display starts, which is
why the screen lit and nobody looked further. The board profile argued in a
comment that 27 was the backlight here, and moved CC1101's chip select onto
21 on exactly that basis. It never said it in code, so the shared header kept the
generic CYD default of 21 for both panels.

Which left the chip select and the backlight on the same pin in every 3.5″
build. That half was silent too, because nothing is soldered to the CC1101 yet, so
the select never toggles. Wire the radio up first and the symptom would have been a
backlight strobing at chip-select rates, which reads as a
display fault, not a pin-map one.

check_pinmap.py printed “no collisions” throughout. It
reads the display's backlight pin. That is how it knows which pins the
display needs, and the hardware page has a paragraph claiming this makes exactly
this class of mistake fail the build. It never compared that pin against the one
the sketch drives. It does now, per panel.

The same look at the board found the wiring guide describing a different one:
four JST breakouts that this board does not have, and three solder joints where
there are six. Both corrected.

LICENCE & CREDIT

Pueo is a fork of ESP32-DIV by CiferTech, which is MIT. This fork's own code
is GPL-3.0-or-later. CiferTech's notice is kept verbatim, those parts stay
available under MIT from upstream, and the boot screen says where it all came
from.

The CC1101 driver is LSatan's SmartRC-CC1101-Driver-Lib, also MIT, vendored
with its changes documented in the tree rather than patched at build time.

A warning about the archived images. 0.2.1 and everything before it link RF24,
which is GPL-2.0-only, alongside arduinoFFT (GPL-3.0-or-later) and
NimBLE-Arduino (Apache-2.0). Those cannot lawfully be combined in one binary:
GPLv2-only and GPLv3 each demand the whole work be distributable under their
own terms and neither permits the other. It has been that way since 0.1.0,
inherited from the dependency set rather than introduced here. The source was
never affected, and nor is anything you build yourself.

RF24 is gone as of 0.2.2. It turned out almost nothing used it (the
MouseJack, ESB and skimmer features already drove the chip's registers
directly, and it survived only in the two jammers), so it was replaced with
a small register interface of our own. Nothing else in the tree is
GPLv2-only, so 0.2.2 and anything built from current source is distributable
as GPL-3.0-or-later, with the ordinary obligation that whoever gets the
binary can get the source. The archived 0.2.1 and earlier images still
contain it.

Use it on radios you are allowed to transmit on and networks you are allowed
to touch. Sub-GHz and 2.4 GHz transmit power, and the bands you may use at
all, are your jurisdiction's problem to define and yours to respect.

SEE ALSO