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
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.
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
Pueo carrier board # design notes: why it cannot be a shield, pinout, power budget
Getting Started with the ESP32-WROOM-32 # the module, powering it, GPIO cautions
Getting Started with ESP32 Development # toolchains, the build loop, failed uploads
ESP Flasher # get an image onto a board from the browser
Flipper Zero ESP32 WiFi board # the other way to bolt a radio onto a handheld
ESP32-DIV # upstream, by CiferTech
SmartRC-CC1101-Driver-Lib # the CC1101 driver, by LSatan
CC1101 datasheet # where the 6.5 MHz burst ceiling comes from
