PUEO
BUILD CLEAN 0.4.50 GPL-3.0-or-later
The Pueo badge: an owl face drawn in neon-green circuit
              traces, its eyes radar screens, set in a metal shield with
              antennae and a WiFi arc above it and GPS, sub-GHz and NFC
              labelled around it, under a banner reading PUEO and another
              reading Multi-Radio Field Tool.

PUEO

The pueo is the Hawaiian short-eared owl. It hunts by ear, in the dark, for things it cannot see.

Five radios on a ten-dollar touchscreen ESP32: sub-GHz, 2.4 GHz, WiFi, Bluetooth and NFC, and a GPS to pin whatever you hear to a map.

RADIO SECTORS

Hover a sector, or tab into it, and the specs give way to the sort of thing that module prints, written to show the shape of the output,The sample output under each sector shows the shape of what that module prints. It is written, not captured off a board.

CC1101

SUB-GHZ

300–348 · 387–464 · 779–928 MHz

  • Replay Attack · Saved Profile
  • De Bruijn / Brute sweep
  • Jamming Detector
  • Reads Flipper .sub captures

Sample output

  • [cc1101] claim bus · owner=SUBGHZ
  • [cc1101] 433.920 MHz 2-FSK rssi -61 dBm
  • [sub] Protocol: Princeton Bit: 24
  • [sub] Key: 00 00 00 00 00 1A 2B 3C
  • [sub] RAW refused: timings, not a key

NRF24L01+PA+LNA

2.4 GHZ

2400–2525 MHz · 126 channels

  • Channel Scanner · Proto Kill
  • ESB Sniffer · ESB Replay
  • MouseJack Scan · Inject

Sample output

  • [nrf24] sweep ch 02..80
  • [nrf24] peak ch 46 -48 dBm
  • [esb] addr 0F:A1:C3:44:81 pld 18 crc ok
  • [esb] captured 24 frames → replay armed
  • [mousejack] unifying dongle: inject ready

PN532

NFC

13.56 MHz · ISO 14443A

  • Card Reader · Clone · Erase
  • Dump to card · Decode Access
  • Jam Reader · Tag Disrupt

Sample output

  • [pn532] firmware 1.6.7 sam configured
  • [nfc] uid 04:9A:33:B2:71:58:80 sak 08
  • [nfc] MIFARE Classic 1K 16 sectors
  • [nfc] sector 3 key A ff ff ff ff ff ff auth ok
  • [nfc] dump → /NFC/049A33B2.mfd

ATGM336H

GNSS

GPS + BeiDou · UART0 after handover

  • Wardriver · WiFi and BLE
  • WiGLE CSV export and upload
  • Satellite Scanner
  • Runs in the background

Sample output

  • [gps] gpio 1 handover: uart0 tx released
  • [gps] fix 3D sats 11/14 hdop 0.9
  • [wardrive] +148 nets +62 ble this run
  • [wigle] model=Pueo appRelease=0.4.50
  • [wigle] upload queued: 1 file

ESP32-WROOM-32 — host radio

WIFI + BLE

WIFI

  • Packet Monitor → pcap
  • Deauther · Beacon Spam
  • Karma · Captive Portal

BLUETOOTH

  • Spoofer · Sour Apple
  • AirTag Sniffer · Skimmer
  • BLE Rubber Ducky

ADDED BY THIS FORK

  • Surveillance · receive only
  • Drone Detector · Remote ID
  • Hunt · tracker direction finder
  • AP Tracker · parks on one channel
  • Fast Pair · CVE-2025-36911
  • File Transfer · the card over HTTP

Sample output

  • [wifi] ch 6 mgmt frames 18244 promiscuous
  • [eapol] M2 of 4: handshake recognised, writing pcap
  • [spotter] ALPR Flock Safety B4:1E:52:0C:7A:31 -58 dBm x312 41m
  • [spotter] ALPR Flock SSID 00:F4:8D:11:B2:60 ** corroborated
  • [spotter] BODYCAM Axon Enterprise 00:25:DF:4A:19:E2 BLE -49 dBm
  • [spotter] VEHICLE KARR BT module D8:3A:DD:41:0C:96 BLE -63 dBm

THE PANEL

One board: the 3.5″ ESP32-3248S035R, 320×480 on an ST7796, resistive touch. The 2.8″ ESP32-2432S028R was built every release up to 0.4.13 and never booted, so what shipped for it was an image that compiled rather than one that worked, and it is gone after that. There is no runtime detection and never was: flashing this on the smaller board is a dark screen rather than an error. The renders below are this panel, 320×480 drawn at three times size; they were redrawn for it when it became the reference board.

Every feature is two taps from the main menu. RFID/NFC, GPS, Surveillance and the drone detector used to sit behind a tile called More and take three; that tile is gone and they have their own. Rendered, not photographed. Tools/render_screens.py reads the bitmaps out of icon.h and the 5×7 and 16 px fonts out of TFT_eSPI, implements the dozen drawing primitives the menus call, and replays them at the coordinates the source gives. Shown a third larger than the panel, which is what it takes for a 5×7 font to hold together on a screen; click any one for the full 3× render.

The Pueo boot screen on a 320 by 480 panel. Three dim lines at the top read Pueo 0.4.50, multi-radio field tool, and by magikh0e, based on ESP32-DIV by CiferTech. Below them the owl mark in white, crossed by fine horizontal scanlines. Under the mark five boot log lines fall away and dim as they go, reading boot ready, boot settings loaded from SD, boot BLE slash WiFi background deferred, boot classic BT RAM released, and boot start. At the bottom a green spectrum trace sits on a magenta horizon, with a purple perspective grid running away below it. The Pueo main menu: eight rounded tiles in two columns - WiFi, NRF24, Detect, GPS down the left and Bluetooth, SubGHz, RFID/NFC, System down the right - with WiFi selected and filled in orange, above a status bar showing battery, Bluetooth, WiFi, temperature and SD icons. The Pueo Bluetooth submenu: eleven tiles in three columns - BLE Jammer, BLE Spoofer, Sour Apple, AirTag Spoofer, AirTag Sniffer, Sniffer, BLE Scanner, BLE Rubber Ducky, Skimmer Detect, Hunt and Fast Pair - each an icon above its name, with AirTag Spoofer filled orange. A header reads Bluetooth on the left and 11 features on the right. A Main Menu bar runs along the bottom. There is no page button: the whole menu is on one screen.

THE SHARED BUS

Four devices, one MISO

CC1101, NRF24, PN532 and the SD card share VSPI. The display is the one that does not share: it has HSPI to itself, and on this board the touch controller rides along behind TOUCH_CS rather than taking pins of its own.

What conflicts is not the clock. SCK and MOSI are outputs and fan out to every pad without trouble, but MISO is an input and exactly one GPIO can drive it. SpiBus makes ownership explicit and re-points the matrix on handover.

It was five before the 2.8″ was dropped, and the fifth was the touch controller, which is where the arbitration came from. The whole story is on the hardware page.

Concretely: LSatan's CC1101 driver ends every register access with SPI.end(), which resets the peripheral. Where the CC1101 has a bus to itself that is harmless. Here it resets the one the SD card and the touch controller are also on, and flakiness that looks like a driver bug is usually something of this shape. What else was found this way →

GPIO 25  NRF24 CSN    free: touch is on HSPI
GPIO 16  NRF24 CE     was RGB blue
GPIO 17  PN532 SS     was RGB green
GPIO  1  GPS RX       was UART0 TX
GPIO 18  VSPI SCK     19 MISO
GPIO 23  VSPI MOSI
GPIO 21  CC1101 CS    27 is the backlight

SURVEILLANCE — WHAT IS IN RANGE

The mark Surveillance opens on: an owl in orange silhouette perched facing forward, both eyes wide, with straight sight-lines running out to six pieces of surveillance hardware arranged above it - a pole-mounted licence-plate camera, a body-worn camera, a dashcam, a CCTV dome, a car and a radio mast. The word Surveillance sits below in white. The Surveillance screen listing six detections, each three lines deep. Three red rows read ALPR Flock Safety, ALPR Flock SSID marked with a red double asterisk, and BODYCAM Axon Enterprise heard over BLE. An orange row beneath them reads VEHICLE KARR BT module, also over BLE. Two grey rows at the bottom read ALPR Liteon and ALPR LAA, not a vendor. Each row carries a MAC address, the radio, signal strength, a sighting count and how long it has been in range.

There is no alert. A camera is a row, and so is anything else that announces itself; the colour is the grade: red for strong, orange for likely, grey for a hint worth nothing on its own. A row's grade rises the moment a stronger signature matches the same address.

Two hundred and eighty-three signatures over nine tables, a hundred and sixteen of them red. The largest table is 135 IEEE blocks belonging to the vendor named on the row (Flock, Axon, Ring, Hikvision, Axis, Verkada, Avigilon, Wyze, Flipper), then 78 fragments matched anywhere in a device name and 47 BLE company and service pairs. The rest are small: seven BLE advertised names, five network names, five manufacturer-data patterns, three service-data patterns, two 128-bit service UUIDs and one fixed address. The whole list is on the signature list.

Orange is where the vendor is real but wider than the product. Motorola Solutions and Genetec both sell plate readers among a great deal else, and 0xFD5A is assigned to Samsung rather than to SmartTag. Pwnagotchi is orange for the opposite reason: its de:ad:be:ef:de:ad is not an allocation at all, it is a convention, and a convention is something anything else can wear. Grey is mostly contract manufacturers whose radios are in everything, plus Amazon (who own Ring, and Echo, and Kindle) one unregistered block, and three addresses that are locally administered rather than assigned to anybody: two are Hak5 defaults, one is simply made up.

Every OUI was checked against the IEEE registry before it went in, and every service UUID against the Bluetooth SIG's assigned numbers. That moved several rows down a grade from the project they came from, and rejected one outright: a prefix published elsewhere as "modern Axon" is registered to nobody.

The ** in the right margin means two different signatures matched one address, an OUI and a network name, say. That is what lifts a likely to strong, and it is the difference between a guess and a finding.

The orange row is a KARR module, a dealer-installed immobiliser UC San Diego estimates is in at least a million vehicles, and in roughly half of those, the owner does not know it is there, because they never bought it. Likely rather than strong, and named as a module rather than a finding: its shared-key flaw was patched in July 2026, the fix is applied by hand, and nothing in the advertisement says whether it has been. The wardriver will not upload these to WiGLE (the published attack starts with a name query against exactly that database) though the log on the card still keeps them.

The last field on each row is how long the thing has been in range, because a camera is bolted to a pole and a phone walks past. Surveillance never transmits, never associates, never deauthenticates.

How the signatures and the scoring work →

ALPR cameras Body cameras Smart glasses Vehicle modules Fixed cameras Item trackers Pentest kit WiFi + BLE

GET IT

Source and merged images, both here and on GitHub. A module that will not answer, a board that will not flash, a wiring question: those go in Discussions. Something reproducibly wrong goes in Issues.

Eighty-two seconds through the menus and the screens that matter. 4.2 MB, with sound. The device shots in it are the renderer's own output at 0.4.34. Every screen in it still looks like this; what has changed since is behind them, and 0.4.50's entry in the changelog says what.

pueo-0.4.50-merged.bin           flash this · 1.9 MB
pueo-0.4.50-beacon-35-merged.bin bench transmitter · 1.1 MB
pueo-0.4.50-src.zip              6.9 MB
pueo-0.4.50.sha256               checksums
pueo-changelog.txt               every release

12a9bd1e699d99bb83b7c93635e57b5acdbc76ac830b5513a7a5b0d755cc120a  merged.bin
28a19f5ecb11d9fdd8947b77be1e11225388ee24c784316b71bbd4521722c324  beacon-35-merged.bin
d5f07180e53202594f5857af2d4206606cdf3de5143a79dff955337e12d1836b  src.zip
The merged image reproduces: build the archive anywhere and this digest comes back. It holds for every release from 0.2.1 on, each checked by rebuilding the archive at a different path and comparing. It was not true of 0.1.0 or 0.2.0: NimBLE's assert macros bake __FILE__ into the firmware, so those two carry seventeen strings naming the absolute path they were built at. -ffile-prefix-map fixes it, and the file form of the flag rather than the macro form is the part that matters, the macro form cleans __FILE__ and leaves the paths in the ELF's debug info, which the app descriptor then hashes.

One image, and the name changed

pueo-0.4.50-merged.bin is the 3.5″ ESP32-3248S035R. Up to 0.4.13 that name meant the 2.8″ board and the 3.5″ carried a -35 suffix. It is the other way round now, and there is no 2.8″ image at all.

If you have been reaching for the unsuffixed file out of habit on a 2.8″ board, it will not work and it will not say so: the panel is chosen at compile time, there is no runtime detection, and the symptom is a dark screen rather than an error. 0.4.13 is the last release with an image for that board, and it is kept for good.

That image was built every release and never booted: it compiled clean and reproduced from its own archive, and nobody put it on a board. That is not support, which is why it is not claimed as support any more.

esptool.py --chip esp32 -b 921600 -p COM7 write_flash 0x0 pueo-0.4.50-merged.bin

-b 921600 because esptool defaults to 115200 and this board handles the fast rate: the write takes about 19 seconds instead of two and a half minutes. It is also what the build script uses, since that is the first UploadSpeed the ESP32 core offers for this board.

The rate affects the upload and never the result. esptool verifies a SHA-256 of each region it writes, so a rate the cable cannot carry fails during the handshake or the write rather than corrupting anything quietly. If it refuses to sync, drop to -b 115200 before suspecting the board: a marginal cable or a USB hub shows up as a baud problem first. Do not go above 921600, which is where the CH340 on these panels stops being reliable for the sake of a few seconds.

The archive is an explicit include list, not the whole repo: the fork still carries upstream's prebuilt ELF and map files, a flash tool and a 9 MB model, none of which you need to build it. The merged image is there because building it yourself takes a 1 GB toolchain download, not as a statement about what it does once it is on a board.

Two older releases are kept, and both are held by name rather than by age, because each is the last of something. 0.4.13 (sums) is the last release with a 2.8″ image in it. 0.2.1 (sums) is the last containing RF24, which is GPL-2.0-only, so it is the last of a distinct licence state; it is 2.8″ only, from before the 3.5″ was a target at all.

The release before this one is kept too, by age rather than by name: 0.4.49 (sums) is still served and drops off when the release after this one is cut. Everything else is pruned as it is superseded. 0.3.5 is missing on purpose rather than by age: it painted over the top of the first row on every paged submenu and was pruned rather than offered, because an old release is worth keeping and a briefly broken one is not. Every pruned release's digests stay in the changelog.

BUILD IT

An Arduino sketch, so arduino-cli is the short path. The archive carries a script that pins the core and every library into roots of its own, so it cannot fight another Arduino install. Libraries land beside the sketch; the core lands outside it, which is deliberate and is the third item below.

tools/build.sh setup # once, core + libraries, ~1 GB tools/build.sh # compile tools/build.sh merge # single flash image at offset 0 tools/build.sh upload COM7

Four things will bite you if you do it by hand

esp32 core 2.0.10, exactly.
The 3.x line is IDF 5, which dropped esp_event_loop.h. The sketch includes it, so 3.x dies on the first file.
Pinned library versions.
Library Manager hands you the newest and three of them broke their APIs: ArduinoJson 7 dropped StaticJsonDocument, NimBLE 2.x dropped NimBLEAdvertisedDeviceCallbacks and NimBLESecurity, arduinoFFT 2.x replaced the arduinoFFT class with a template. Roughly twenty compile errors that look like code bugs and are not.
Windows MAX_PATH.
The core compiles with -fno-rtti, which selects the no-rtti libstdc++ multilib, and the path to one header came to 259 characters against a 260 limit. The compiler reports the header as missing while it sits right there. Keep the toolchain root short, and extract the archive somewhere short too, because NimBLE's sources sit deep enough to hit the same wall.
TFT_eSPI needs its User_Setup.
The CYD variant, copied over the library's default, or you get a blank screen and no error.

-ffile-prefix-map is what makes the image reproducible: without the file form of the flag the build path lands in the ELF's debug info, and the app descriptor carries a SHA-256 of that ELF, so the same code built at two paths gives two different images.

STATUS — WHAT IS AND IS NOT TESTED

What is verified

Compiles clean under -Wall -Wextra at 57% of flash and 37% of RAM, 57% because 0.4.2 moved to a single 3.00 MB app partition rather than the pair of 1.88 MB slots an over-the-air update needs, which nothing here uses. Several fixes are verified at the symbol level: the linked image was diffed symbol by symbol to prove a refactor changed nothing, and the overrides were checked with nm and objdump rather than assumed. 59 host check scripts run on every release. The thirty-nine that count their cases assert 379,416 between them: the WPA handshake classifier, the Fast Pair frames and probe, the Flipper .sub reader, the AirTag parser, the advertisement walker, the rotating-tail detector, the OUI signatures against the IEEE registry, the surveillance matching primitives and filter, and the logo scale. The other nineteen arrive as a verdict rather than a count: that no two pins collide, that every menu table is fully initialised, that nothing drawn runs off the panel, that no source file carries a control byte, and that the documentation's own numbers still match the source. All of that on a host, but as of 0.4.1 the receive side is no longer only on a host: a second board running PueoBeacon transmits what each detector looks for, and every one of them has been shown to receive it.

Two results came off a board rather than off an argument. The key-frame reserve was measured under traffic that dropped 1,387 of 4,268 frames, 32 per cent, with four EAPOL messages arriving consecutively and every one reaching the card; the resulting files were then checked at byte level against Wireshark's own WPA reference capture rather than with the parser they are a fixture for. And a Flipper .sub has been imported, stored and read back on the panel as 433.92 MHz, 24 bits, 0x123456, which is what the parser's own transcription says that file is. That one matters because it is the first time the C++ parser and the Python model behind 61,453 assertions have agreed on a real file on real hardware rather than only on each other.

What is not

Features inherited from upstream are inherited, not audited. The Fast Pair probe's crypto path has never derived a key against a real Provider. The camera signatures have matched real hardware once: a Flock ALPR raised a row on the panel. Nothing was captured or logged off it, so that is one signature and the matching path confirmed together, on one device, on one occasion, and every other signature in the table is still untested against the hardware it names. The bench beacon proves only the matching path, by transmitting a known OUI, which is a different claim from the signature being the right one. Treat the GPIO 1 handover in particular as a prediction: it is the one piece of this with no verification behind it beyond the datasheet and the core source. Where a claim has been checked closely, the changelog entry that did it says so.

What ran on hardware

Pueo ran on a real board for the first time on 2026-09-20. Display, bitmap rendering, the menus, touch, the WiFi scanner, the packet monitor, Surveillance and the new tracker hunt all work. Nothing is wired to it yet, so every transmit claim above stands exactly where it did, including the GPIO 1 handover, still a prediction.

The drone detector’s own alert was raised on the board against the beacon transmitting Remote ID, mark and fade and notes and all. It had been drawing Hunt’s owl as its feature mark since 0.3.9. A borrowed mark that nothing would ever fail on, and that told anyone looking at the screen the wrong thing about what they had opened.

Stealth Mode was walked through on the board at 0.4.4: every feature that transmits refused and named itself, every feature that listens carried on. That is a check of the gates rather than of the radios. It says the firmware declines to call the transmit paths, not that a spectrum analyser watched the antenna go quiet. The gates themselves are enumerated by a host check, so the list cannot shrink without failing it.

The receive side is no longer in that bracket. As of 0.4.1 a second board runs PueoBeacon, which transmits every signal this firmware looks for, and all six detection paths have been shown to work against it: Surveillance on WiFi OUI, on a BLE company and service pair, and on a BLE name; Drones on WiFi Beacon and on BLE; Hunt's picker and gauge; and Fast Pair on both payload shapes, which is the distinction that parser exists to draw. None of it was in doubt on paper and two thirds of it had never moved a byte off a radio.

Building the beacon found six faults in one evening, and it is worth saying that two of them were in the beacon itself. A WiFi transmit path that was silently refused, and five decoys sharing one MAC, which made a vehicle match vanish into a smart-glasses row. The other four were here: three list screens that only flicker once something is actually in range, and a tracker picker that never repainted when a tracker arrived. Every one of them needed a transmitter to exist before it could be seen.

A day on hardware found more than a month of reading had. Three features were unreachable (Surveillance, Fast Pair and Hunt) because a submenu selection is dispatched by two hand-maintained chains, one for the buttons and one for the touch bar, and the touch one stopped after the first entry on that page. They then had no visible way out: the exit is the centre slot and those three labelled it with an empty string. And the bar they did label was painted and then wiped by the same setup, because redraw(true) starts with a fillScreen. Each of the three hid the next.

The BLE sniffer rebooted the board. Its log shifts 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. Found by decoding the panic rather than by reading for it: the backtrace named the function, the disassembly named the offset, and the offset named the member. It was already two past the end on the 2.8″, where they fell in padding.

The board was a 3.5″ ESP32-3248S035R, Sunton's, 55.50 × 101.50 × 5.80 mm, ST7796 rather than ILI9341, 320×480 rather than 240×320, and an XPT2046 on the display's own SPI bus rather than the separate touch bus the 2.8″ used. It became a supported target rather than a surprise, with its own flash image beside the 2.8″ one, and in the end the only one, since it is the panel anybody has actually run.

Every feature screen was still drawing into the top-left 240×320 of it. The menu grid had been made panel-aware; nothing else had, and the dimensions were not in one place to fix but in 221, declarations, status-bar icon rows laid out from x=240, and coordinates in drawing calls. They read one pair of constants now, and a check holds the line.

Two things the panel split did not reach, both of which shipped in the first cut of 0.3.4 and neither of which announced itself. TFT_eSPI reads its configuration from a copy inside the installed library, so the 2.8″ image was built with the 3.5″'s driver, size and backlight pin, the exact bug the split existed to fix, still in the artifact claiming to fix it. Caught by rebuilding the archive at a different path: one image reproduced and the other did not. And the touch bus flag was set for both boards, which would have driven touch through the display's SPI on the panel whose controller is not there.

The datasheet then disagreed with the pin map, and the datasheet was for the wrong board. It said GPIO 4 was an audio amplifier's enable on the 3.5″ rather than the RGB LED's red channel, and the NRF24's chip select was on it, an amplifier keyed at SPI rates. The select was moved: 4 on the 2.8″, 25 on the 3.5″, where the pin is free because that board does not need it for touch.

On 2026-09-23 the pin was driven on a board here and read. GPIO 4 is the LED's red channel on this board too (4 red, 16 blue, 17 green, 22 nothing), and the amplifier was lcdwiki's, on a part this is not built on. The chip select stays on 25 because 25 is free and the map is published, but it was dodging a hazard that is not there. Nothing was soldered either time, which is the only reason either version cost nothing.

Two findings were not board-specific. The touch calibrator recorded a point and advanced without waiting for the finger to lift, so one press satisfied all four targets in milliseconds and returned four nearly identical readings. Its endpoint arithmetic also used min() and max() across the corner pairs, which assumes raw counts rise with the screen coordinate and skews the result on any panel where they fall. Both would have been just as wrong on the 2.8″ board, and neither was reachable from a host check, which is the argument for this section existing.

Emulation does not fill the gap

The merged image boots under Espressif's QEMU through the ROM loader and the second-stage bootloader, then asserts in esp_flash_init_default_chip: the shipped QEMU targets the IDF 5/6 line and this is IDF 4.4. Moot anyway, QEMU models no display, no touch and no radios, which is to say none of the parts worth testing.

For educational and research use only. Use only on networks and devices you own or have written permission to test. Several of these tools transmit, jam, or impersonate. Unauthorised use is illegal in most jurisdictions.