Pueo - changelog ================ Firmware for a handheld multi-radio field tool on the ESP32-3248S035R. Forked from CiferTech's ESP32-DIV at 90f7967, 2026-09-01. Features inherited from upstream are inherited, not audited. Where one has been read closely it is said so in the entry that did it. Digests for every release, pruned ones included, are at the end of this file. 0.4.50 - 2026-10-16 ------------------- Two the fork inherited, both found by reading upstream's open pull requests rather than by anything misbehaving here. * THE WIFI AND ZIGBEE JAMMER MODES TRANSMITTED IN THE WRONG PLACE. hopStep() hands a table entry to Nrf24Raw::hopTo(), which writes RF_CH: a carrier at 2400 + n MHz. Two of the tables held the protocols' own channel numbers instead. WiFi was {1..12}, which as RF_CH is 2401 to 2412 MHz, while 802.11 channels 1 to 12 occupy 2412 to 2467. Zigbee was {11, 15, 20, 25}, which is 2411 to 2425 MHz, while 802.15.4 puts those four at 2405, 2425, 2450 and 2475. So WiFi mode sat below every channel but the bottom edge of the first, and Zigbee mode hit one of its four by coincidence. Both transmitted. Neither was where the menu entry said, and nothing on screen would have shown it: the numbers are plausible either way. The arithmetic was never unknown in this firmware. bandTag(), which labels the channel analyser's readings a thousand lines further up the same file, carries both conversions and always has. The tables are derived through WIFI_RF_CH and ZIGBEE_RF_CH now, so the unit is visible at every entry rather than implied by a bare number. ble_channels and bluetooth_channels were already right, which is what makes this a slip rather than a misunderstanding: BLE advertising channels 37, 38 and 39 are 2402, 2426 and 2480 MHz, and the table says {2, 26, 80}. Whoever wrote these knew the conversion for two protocols and did not apply it to the other two. The channel selections are unchanged: WiFi 1 to 12 without 13, and the same four Zigbee channels. Widening either is a decision about what to transmit on, not a correction to where the existing ones land. check_band_tag.py grew nineteen assertions tying the jammer to the two conversions it already tested the scanner against. The macros have to exist, agree with the standards, leave no bare literal in either table, and land every channel inside the ISM band. Putting the old table back fails them. * THE BLE SNIFFER REPAINTED ON EVERY ADVERTISEMENT ONCE FULL. At MAX_DEVICES, processNewDevice() called addLine("Max devices reached!") and returned. addLine() shifts the whole visible log down by one, copying that many Strings, and then repaints it over SPI, and all of it ran inside the NimBLE advertised-device callback. Somewhere busy enough to fill the table is somewhere advertisements arrive many times a second, so the behaviour got worse exactly where it mattered: a callback that should return promptly instead repainting the screen and churning the heap. Throttled to once every five seconds. The cap stays at 32. Raising it is a memory decision rather than part of this, since each entry carries three Strings. 0.4.49 - 2026-10-10 ------------------- The SD icon could not say no. * THE STATUS BAR CLAIMED A CARD WHETHER OR NOT ONE WAS FITTED. SD_CD, the card-detect pin, is defined only for BOARD_ESP32_DIV_V2. On this board and on the V1 the other branch of updateSdCardStatus() resolved to a bare "true", so sdCardPresent latched true on the first call at boot and could never change again. The bar drew the green card icon with an empty slot, "SD Card detected" printed on every boot, and bitmap_icon_nullsdcard was unreachable: the empty-slot icon has never once been drawn on the reference board. The user guide pointed at that icon as the thing to check before starting anything that captures, which is the one job it could not do. It follows the filesystem mount now. Without a detect pin the honest claim is not "a card is present" but "a card has been read", and that is not as late as it sounds: settingsLoad() runs in setup() and calls sdRetryMount(), so a fitted card is mounted within the first seconds and the icon settles there. Which makes the empty-slot icon worth more than it would have been. It now also appears for a card the device cannot read, and the usual reason is the format: anything larger than 32 GB tends to arrive as exFAT and this firmware mounts FAT32 only. That was previously invisible. Nothing was at risk of being lost. isSDCardAvailable() does a real SD.cardType() check and every capture gates on it, so a feature that could not write already refused to start. It was the pre-flight indicator that lied, not the recording path. * THE READ THAT REPORTS IT MUST NOT PROBE THE CARD. updateSdCardStatus() runs from the status bar every two seconds, on a bus shared with the CC1101 and the NRF24, so it reads a cached mount flag rather than calling SD.cardType() itself. The boot mount is deferred on this board for the same family of reason: performing it right after tft.init() used to reboot the device. * AND THE PROPERTY IS ASSERTED NOW. check_status_bar.py gained six checks, including that the no-detect-pin branch does not resolve to a constant and that the guide does not promise the icon tracks the slot. Reverting the fix fails two of them. Nothing caught this before because every part of it was individually reasonable. 0.4.48 - 2026-10-09 ------------------- A boot splash of this project's own. The skull was upstream's and the owl was a still. * THE SCREEN NOW SAYS WHERE IT GOT TO. Boot was one pass of CiferTech's skull animation and then the owl held for a second and a half: 2.2 seconds of blocking delay with nothing initialising behind it, and nothing on screen that the device could not have drawn before it was written. It is one scene now. A perspective grid runs to a horizon, a spectrum trace sits on the horizon, the owl is above it, and the firmware's own [boot] lines fall out from under the mark, dimming as they drop and gone before they reach the trace. The shape is borrowed, and from an unlikely place: Sprout, a desk companion sketch for this same panel. What made it worth copying is that none of it is stored. The grid is twelve horizontals spaced as i*i so they crowd toward the horizon, plus rays to a vanishing point; the trace is two sines and two carriers; the fade is an integer mix. The only bitmap is the owl, which icon.h already carried. So it cost 24 bytes of flash against the two marks it replaced, and no RAM at all. An intro built from frames would have been the expensive version of the same idea. * IT STEPS A LINE AT A TIME, AND THAT IS A MEASUREMENT. A pixel scroll means clearing and redrawing the log band every frame. The band is 320 x 110, about 35 kpx, which is roughly 21 ms of SPI at this clock, so a 1.5 second travel would spend most of itself in fillRect and still look slow. Five steps cost five redraws, and a terminal printing one line at a time is the right look anyway. The step timer is still a timer. The honest version drives the lines off real boot progress so the splash ends when [boot] ready is true, which turns the delay into a progress indicator. That is a setup() reordering and has not been done. * IT SHOWED A LINE THE FIRMWARE NEVER PRINTS. The log had six entries and the fifth was "[boot] SD mounted, /pueo ok", which exists nowhere in the tree. It was written to fill the space, it compiled, and it would have shipped. A boot log is the surface a reader trusts to tell them where startup got to. One that includes a line the firmware cannot emit is worse than no log, because it reads as evidence. Five lines now, the real ones in the real order, checked against what the board actually prints. What caught it was reading the serial before flashing rather than after, and that was being done for an unrelated reason: both boards arrive as a CH340 on the same port, so the only way to know which one is on the end of the cable is to ask it. The banner answered the question it was asked and a different one as well. * AND IT CAN BE SEEN WITHOUT A BOARD. tools/render_screens.py draws the splash, so it parses the real 1-bpp owl out of icon.h and the real 5x7 cells out of TFT_eSPI and produces what the panel produces. It earned that immediately: the first version put the last log line at y=352 and the spectrum peaks at 350. The line pitch is 16 and the fade stops 44 above the horizon because of what it showed. displayLogo() is now uncalled and stays anyway, until the new screen has been lived with. Removing it also orphans PUEO_LOGO_HAS_WORDMARK and the layout arithmetic in Branding.h that goes with it. Riding along, none of it reaching the device: the bench build gained a parts list, five stages with drawings for four of them, the soldering notes the build guide always had, what the SD card has to be formatted to and why a 64 GB one will not mount, and the flash mode. PUEO.md stopped claiming two panels and an SPI bus on a connector. The user guide said ten tables where there are nine, and said it because check_sig_counts.py was asserting the kind count against the word "tables". README caught up with a feature recount it missed. The S7V8 silkscreen turns out to name the regulator family and not its output voltage, which is a thing to meter rather than to trust. 0.4.47 - 2026-10-08 ------------------- The beacon says which version it is over serial. It said it onto the panel and nowhere else. * A BOARD YOU COULD IDENTIFY BUT NOT DATE. The bench transmitter printed "[beacon] start" and nothing more, with its version on the TFT alone, so the only way to know what one was running was to be looking at it. Over USB a board flashed weeks ago read exactly like one flashed a minute ago. That is worse here than it would be on the detector, because this board exists to be the known-good half of a bench pair. A pair is only known-good if both halves can be read, and this half could not be. It now prints: [beacon] start, bench transmitter 0.4.47 which settles both questions in one line, and uses the same words the panel does rather than a second phrasing for the same thing. The detector has printed its version since it had one. This is the beacon catching up, not a new idea. * WHY IT CAME UP. Both boards arrive as a CH340 on the same COM port, so the port says nothing about which is on the end of the cable. tools/build.sh carries a note about a detector that was overwritten with the beacon image exactly that way, and the fix there was to announce the role before uploading. The banner is the other half of that: it already identified the board, and now it dates it too. Riding along, none of it reaching the device: the README carries the new Pueo badge, its alt text lost an em dash that check_prose.py refuses, the sentence listing the kinds in words gained the tenth one that 0.4.46 added to the tables, and the Sponsor dropdown is down to a single entry. 0.4.46 - 2026-10-07 ------------------- Four signatures named the wrong kind of thing, one of them for a long time. Nine more were missing. * SHOTSPOTTER WAS FILED AS A PLATE READER. D4:11:D6 is registered to ShotSpotter, so the vendor was right and Conf::Strong was earned. The company makes acoustic gunshot detection. Kind::Alpr told somebody standing under a microphone array that their number plate was being read, which is a different claim about a different device, and the only one of the two the screen could show. Flock's Raven is the same kind of sensor from a different vendor, and its three GATT entries were in the same wrong place. Seven rows between them, and no existing kind fitted either, so there is a Kind::Acoustic. That is a tenth kind and a tenth row on the filter; the panel takes it, with the last line at y=308 against a footer that starts at 446. * AND THREE MORE, IN THE SAME SWEEP. Utility, Inc. makes the BodyWorn camera line. Its two blocks were Kind::Accessory, which is the kind a charging dock gets. Five Motorola Solutions blocks were Kind::Alpr. The comment directly above them already said "one of its addresses is far more likely a handheld radio than a camera", and then filed them as plate readers and hedged with Conf::Likely. Hedging the confidence does not fix a wrong category: a Likely ALPR still reads as a plate reader to whoever is holding the device. They are Kind::Vehicle. Nine Silicon Laboratories blocks told two different stories about the same company's chips. Three said "SiLabs (ALPR?)" and six said "FS battery?", all nine registered to Silicon Laboratories, and the six asserted a product the registry does not support. They say the same thing now and stay Weak, for the reason that was already written down: the name is the stronger signal and kNameInSigs carries it. * NINE BLOCKS ADDED, OUT OF A HUNDRED AND EIGHTY-TWO ON OFFER. RF Sentinel (CIS-C0/RFSentinel, GPL-3.0) is an Android app doing Spotter's job on a phone, and it keeps a prefix list this one does not know about. It carries 182 OUI prefixes missing from here. Most are not worth taking. 111 score under 60 on their own scale, and 23 of the confident ones are MA-M or MA-S assignments living inside shared IEEE pool blocks: eight under 70:B3:D5, seven under 00:50:C2, three under 8C:1F:64. Matching here is memcmp over three bytes, so taking those would mean truncating to the pool block and reporting every small vendor inside it as a plate reader. That is a trap rather than a gap, and the right fix is a wider matcher rather than a wider claim. Eleven were plain MA-L blocks. Each was looked up in the IEEE registry rather than carried over, and ten agreed: Gatsometer and Redflex as traffic enforcement cameras, WatchGuard Video, VIEVU, Zepcam, ICOP Digital and International Police Technologies as police video, Patrol Products Consortium as patrol-car upfit, and Decatur Electronics as radar, that last one Likely because the same radar is sold into sports timing. D8:1F:65 is not among them. They attribute it to Axon from field observation and the registry returns "Private", which is exactly what check_oui_registry.py calls asserting something the registry does not say. Cellebrite is held back rather than rejected: a phone forensic appliance is worth knowing about and there is no honest kind for it here, which is a decision rather than an addition. 283 signatures now, 135 of them OUI rows, 61 of those Strong. * THE CHECK THAT CONSULTS THE REGISTRY COULD NOT RUN. standards-oui.ieee.org answers urllib's default User-Agent with HTTP 418, and curl with a rejection page served as 200. Either way check_oui_registry.py --fetch produced no registry, and the one check that verifies a vendor claim against its authority had quietly become unusable. It sends a browser User-Agent now, and refuses a response too short to be the registry rather than caching it, because a cache full of filter-page HTML fails every row for the wrong reason. * THE PUBLISHED LIST IS GENERATED AGAIN. magikh0e/surveillance-signatures publishes this header as CSV, JSON and a README, and its CONTRIBUTING.md tells people not to edit those files because "they will be overwritten by the next regeneration". Nothing performed that regeneration. There was no extraction script in either repository, so the published snapshot was whatever had last been made by hand and a row changed here reached it only if somebody remembered. One had already drifted: 82:6B:F2 reads "LAA, not a vendor" here and was published with an empty label. tools/extract_signatures.py rewrites the rows and the counts and leaves the prose alone, because the table titles, the grading notes and the paragraph on why Mesh rows belong in a surveillance list were written rather than derived. tools/check_signatures_export.py fails when the published files and this header disagree, and says nothing when the list is not cloned beside this tree, which is the case inside the source archive. 0.4.45 - 2026-10-07 ------------------- A reset that removes Pueo's own directories, and the four kilobytes of static memory it took to fit the screen in. * SYSTEM > RESET SD. It removes /pueo and nothing else. That is the whole of what this firmware writes: captures, logs, sub-GHz profiles, ESB dumps, ducky scripts, the captive portal files, the SSID list and the settings, so removing it puts the device back to how it came. The card itself is never touched and there is no option to touch it. A handheld with a "format the card" button is a way to lose somebody's photographs, and nothing outside /pueo was put there by this firmware. The screen counts first. It shows how many files and how many bytes are about to go before anything is armed, because "are you sure" is a question nobody can answer and a total is one they can. It also says that /pueo/config holds the settings, so a wipe reverts brightness, theme, accent and auto-scan. That is a surprise worth having before rather than after. Wipe arms on the first press and fires on the second, and the arming lapses after five seconds. A confirmation that stays armed is one that gets confirmed by whoever picks the device up next. A partial wipe reports itself as partial. sdTreeRemove returns the number of entries it could not remove, so a card that refuses one file does not get reported as a successful reset, leaving somebody to wonder later why a capture is still there. * THE SCOPE IS ONE FUNCTION, AND IT IS TESTED. Nothing in the tree removed a directory with anything in it before this: the file manager deletes one entry at a time and its own comment notes that rmdir fails otherwise. So a recursive remove is new code, it is the one operation here with no undo, and the card belongs to whoever owns it. sdTreeGuarded decides, and both the measure and the delete go through it, so there is a single function to be sure of rather than a scope test at each call site that the next call site forgets. The recursion is bounded at six levels as well, because a corrupt directory entry on a FAT volume is not a thing to find out about through a stack overflow. tools/check_sd_reset.py holds it to eight paths it must accept and twenty-seven it must refuse. Two of those refusals were failures when the check was first run against the code. "/pueo-backup" is a different directory that begins with the same five characters, which a prefix test written without its separator deletes. "/pueo/.." begins with "/pueo/" and names the card root: nothing builds that path today, since the walkers descend using names from openNextFile and the SD layer does not return the "." and ".." a FAT directory holds on disk, but that is the library's behaviour rather than this code's and the wrong thing for a recursive delete to rest on. Both segments are refused outright now. * ONE FFT SCRATCH PAIR INSTEAD OF TWO. The packet monitor kept double vReal[256] and vImag[256] at file scope in wifi.cpp, and the sub-GHz spectrum kept vRealSUB and vImagSUB in subghz.cpp. Same sample count, same 5 kHz rate, same shape, and both are scratch in the strict sense: every frame fills all 256 entries, transforms in place, reads the magnitudes out and keeps nothing. So the second pair was four kilobytes of static DRAM holding a copy of what the first pair holds, and the two features cannot run at once: the dispatcher runs one feature's loop to its exit before returning to the menu. They share one pair now, declared in shared.h and defined in utils.cpp, which belongs to neither of them. This is not housekeeping. Static DRAM is the first thing this part runs out of, and the Reset SD screen would not link: sixteen bytes of state overflowed dram0_0_seg by a hundred and four. The .sub export screen hit the same wall two releases ago and overflowed by sixteen. Both were paid for by cutting the feature that asked, which is the right order, and this is the first saving here that costs nothing at all. Global variables are at 120,500 bytes where they were at 124,564. tools/check_fft_scratch.py asserts there is exactly one pair. A second one is easy to add without noticing, because a new spectrum feature wants a buffer and declaring its own is the obvious move, and nothing about it fails: the build just gets four kilobytes closer to the wall and the next small feature pays. * TWO NAV LABELS SAT ON SLOTS NOTHING READ. setTouchNavLabels takes its five slots positionally, as (left, down, center, up, right), while a handler names its button instead. So a label can sit on one slot with the code reading another, and the result is a labelled button that does nothing beside an unlabelled one that works. Reset SD was written that way, and so was the Freq Analyser in 0.4.42: both put their extra action on down with the handler on BTN_UP. Neither had been pressed on a board. All 342 assertions in tools/check_nav_labels.py passed. It has a sixth rule now. A labelled side slot must have its pin named somewhere in the enclosing namespace, whatever reads it, because there are four spellings in this tree and enumerating them reported eleven of ducky's working buttons as dead. Namespace scope and not file scope: utils.cpp reads BTN_UP in an unrelated function a thousand lines away, and a file-level test passes the exact bug the rule was written for. 0.4.44 - 2026-10-07 ------------------- Cal measures a noise floor and the scanner subtracts it. It did neither. * THE BUTTON WORKED AND ITS RESULT WENT NOWHERE. calibrateBackgroundNoise swept the band, filled backgroundNoise[], and printed the worst channel. The only code that ever read that array back was the loop finding the largest entry for the message. Nothing subtracted it from anything, so the scanner drew the same bars before and after a calibration. Found while correcting a changelog paragraph that claimed the sweep ran "against a calibrated noise floor". It did not, and the claim had been on the website as well. * AND THE MEASUREMENT ITSELF WAS WRONG THREE WAYS. channel[] was never cleared, so a calibration started from whatever the last Scan had left in it, and the second of its two rounds counted the first round again: the stored value was 2*round1 + round2. backgroundNoise[] was never cleared either, so pressing Cal twice stored twice the floor. Then /= 5 against a hundred and fifty passes' worth of accumulation, which is a scale matching nothing the display counts in. None of this mattered while the result was unused, which is presumably why it survived. * WHAT IT DOES NOW. One clean run of CAL_PASSES over every channel from a cleared start, stored as hits out of CAL_PASSES so the display can scale it to its own sweep count. display() takes the floor off each channel before smoothing, clamped at zero. Before smoothing matters: the bars and the waterfall share s_smoothValues, and the whole argument for the second view is that it cannot disagree with the first about what was measured. Zero is the safe default. Until Cal runs, backgroundNoise[] is all zero and nothing is subtracted, which is what the scanner did for its entire life before this. * CAL SAYS WHAT IT DID, BECAUSE NOW IT DOES SOMETHING. It reports the worst channel and its count out of CAL_PASSES, then that it is subtracting from here on. A number that silently removes signal has to announce itself. It also carries the hazard: calibrate while a network is up and you subtract that network, and the scanner will then show a quiet channel where there is a busy one. That is the worst failure a spectrum view can have, because it looks like a working instrument. Cal always re-measures from zero, so standing somewhere quiet and pressing it again is the fix. * NOT RUN. The NRF24 needs three solder joints and the shared SPI bus, and none of that is wired, so nothing here has been on a board. The arithmetic is checkable by reading and the behaviour is not. Fifty-five check scripts. 0.4.43 - 2026-10-06 ------------------- A waterfall for the 2.4 GHz scanner. One feature, and it has not run. * THE SCANNER THREW THE SHAPE AWAY. It sweeps every channel with the NRF24's carrier detector, ten passes each, and counts how many of them saw a carrier. Then it draws a bar per channel. An earlier version of this entry said fifty passes against a calibrated noise floor, and called that better measurement than most of what is published for this part. Fifty is the one-shot Scan and Cal buttons; the continuous view is ten. And the noise floor is measured and then dropped: calibrateBackgroundNoise fills backgroundNoise[], and the only thing that reads it back is the loop finding the largest entry for the "Calibrate done" message. Nothing subtracts it. See below. A bar chart answers where there is energy. It 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: BLE advertising sits on three fixed channels, WiFi is a steady 20 MHz block, a proprietary hopper walks. A snapshot shows none of that and thirty seconds of history shows all of it. * TIME ACROSS, CHANNEL DOWN, ONE COLUMN PER SWEEP. The obvious layout is the other way up, time scrolling downward, and it is wrong here: scrolling needs a framebuffer to shift or a readback the panel does not offer. Advancing a column costs one vertical line of writes per sweep and needs neither. Toggled with Fall, on one of the two nav slots the scanner left empty. * NO HISTORY BUFFER. THE SCREEN IS THE BUFFER. 128 channels times the plot height is kilobytes however it is packed, and there is no DRAM for it: adding one String to a SubGHz screen overflowed dram0_0_seg by 16 bytes in 0.4.42. So the drawn pixels are the only record, and leaving the screen loses the history. A real limitation rather than one worked around, and the right trade for a view whose value is in watching it live. * ONE SWEEP, TWO RENDERERS. display() already produced s_smoothValues once per sweep and handed it to the bars. The waterfall is a second consumer of that same array, so the two views cost one sweep between them and cannot disagree about what was measured. * WHAT IS VERIFIED, AND WHAT IS NOT. Nothing about this has run. The NRF24 needs three solder joints and the shared SPI bus, and none of that is wired, so no board can open this view. What exists instead is a render: tools/render_screens.py draws it in the panel's own fonts at 320 by 480, with synthetic traffic shaped like the real thing. That is on the website, marked as a render rather than a photograph, because the whole argument for the view is that a block, a three-channel burst and a diagonal are tellable apart at this size, and that is a question a picture answers and a description does not. The same tool found two defects in the Freq Analyser one release ago, so it is worth more than illustration, but it is not a substitute for the hardware. +612 bytes of flash, +8 of RAM. Fifty-five check scripts. 0.4.42 - 2026-10-06 ------------------- Sub-GHz: a .sub goes back out as well as in, a protocol with no mapping stops pretending it has one, and a screen for finding the frequency. * PROTOCOL 0 IS AN ABSENCE, NOT A NUMBER. An unmapped protocol parsed Ok with protocol 0, the import stored it without comment, the browser printed a bare "0", and rc-switch's setProtocol() clamps anything below 1 up to 1. So importing a CAME file and pressing Send transmitted Princeton timing under CAME's name, did nothing, and reported success. The table is not widened. The comment that says guessing a timing produces a profile which transmits something subtly wrong is right, and widening it was the wrong fix for the wrong problem. Protocol 0 is loud in all three places instead: the import names the protocol it cannot send, the browser shows "none", and the send refuses. The send refuses it before it checks for a radio. A profile with no mapping cannot be transmitted by any radio, so answering "fit a CC1101" sends somebody after hardware that would not change the answer, and it buried the one refusal needing no hardware behind one that does, which is how it came to be untestable on a board with no module. * A ROLLING CODE IS A DIFFERENT ANSWER, NOT A WEAKER ONE. KeeLoq, Somfy, Star Line, Security+ and the rest carry a counter, so a captured one is good for nothing: the receiver has moved past it before you can transmit. Every one is longer than 32 bits, so they already failed on bit count, and "bit count out of range" is true and sends somebody looking for a shorter capture. Named rather than detected, because the names are facts and the detection would be guesswork. This changes the sentence, not the outcome, and nothing in it helps transmit one. * SUBGHZ > EXPORT .SUB. SubFile::write renders a key file and the screen writes one per profile to /pueo/subghz. A capture otherwise leaves this device only as a packed binary nothing else reads, and the five EEPROM slots rotate older ones into that format as new ones arrive. TE is omitted rather than invented: the EEPROM budget is exactly full at five 28-byte records, so there is nowhere to store one, and a reader that needs TE is better off applying its own default than a guess from here. A profile with protocol 0 cannot be written at all, because the record never stored the name; those rows are marked on the list rather than refusing on the press. * SUBGHZ > FREQ ANALYSER. Replay Attack wants a frequency chosen before it will listen, and a remote on the wrong one is indistinguishable from a remote that is not transmitting. Eighteen entries, eighteen ambiguous attempts. This parks on each in turn and reads the CC1101's RSSI. Peak hold with a slow decay is what makes it usable. A key fob transmits for about 200 ms, which an instantaneous bar chart has forgotten by the time you look up. Receive only, so no Stealth gate, same as the Jamming Detector. check_stealth.py asserts the premise rather than the conclusion: both file screens touch no radio at all, and the analyser tunes and reads but never keys. A feature gets left off the gated list by not transmitting on the day it was written, so the claim that lets it off is the one worth pinning. * DRAM RAN OUT, AND THE FIX CAME FROM THE CODE THAT CAUSED IT. Adding the export screen overflowed dram0_0_seg by 16 bytes and the image would not link. The two file screens share one status String, being the same screen pointing opposite ways and never both up, and the analyser keeps the held peak only, the decay already showing a signal going away. * WHAT IS VERIFIED, AND WHAT IS NOT. On hardware: a .sub imports and reads back as 433.92 MHz, 24 bits, 0x123456, and exports byte-for-byte to the expected file. The round trip closes in both directions, which means the C++ parser and writer and the Python transcription behind 61,453 assertions now agree on a real file on a real board rather than only on each other. Saved Profile opens and lists with no CC1101 fitted, and Send refuses and returns to the list. Not run on hardware: the Freq Analyser. It needs a CC1101, which is not connected yet. Its 1200 us settle time after each retune is a plausible figure rather than a measured one, and if the bars read the previous frequency that is the number to raise. * WHY NOW. The website had already been written as though these shipped, including a menu list headed "as of 0.4.41" carrying two screens 0.4.41 does not have. check_site_menu.py could not catch that: it compares the page against the working tree, which is ahead of the release by design. Fifty-five check scripts. 0.4.41 - 2026-10-06 ------------------- Reading a Flipper .sub in, two menu bugs found while testing it, and a screen that would not open to show the result. * SUBGHZ > IMPORT .SUB. SubFile could parse a .sub from the day it was written, with 60,891 assertions behind it, and nothing called it. The blockers were a profile record declared three times, so an import had no single type to write through, and a saveProfile that could not store anything without also prompting and drawing. The screen lists .sub files in /pueo/subghz and imports the selected one: parse, make room, store, in that order, so the steps that can fail happen before anything is written. It creates the directory if the card has never had one, because that folder only appears once a profile has been exported and the screen was otherwise telling somebody to make a folder it could make itself. No Stealth gate: it touches no radio, reads one file and writes one EEPROM record. There is nothing here to refuse. The name is the filename without its extension. A .sub carries no name of its own, and prompting would put a keyboard between choosing a file and finding out whether it parses, which is the wrong way round. Every failure says which one it was. SubFile::resultText already tells a RAW capture from a missing field from a frequency this radio cannot reach, and those are three different things to do next. * THE SCREEN READS THE TOUCH BAR, BECAUSE THAT IS THE ONLY INPUT HERE. Next, Prev and Import were wired to isPhysicalButtonPressed, which is a constant false on a board with no PCF8574, which this is. Exit worked, because featureExitButtonPressed falls through to the touch bar, so the screen drew, scrolled nowhere and imported nothing while still being possible to leave. It compiled and every check passed. check_button_waits.py now refuses a namespace that reads physical buttons and nothing else. It scans with comments stripped: the first version passed on the text of a comment naming the touch calls, which was found by deleting the handler and watching the check stay green. * GATED ON THE SEND, NOT ON THE SCREEN. Saved Profile refused to open without a CC1101, and under Stealth Mode. It lists records off the card, shows one and deletes one, and only Send drives the radio. So a profile imported from a .sub could not be read back on a board with no module fitted, which is how this was found. Both refusals moved into transmitProfile(). Stealth::refuseAction() and cc1101ReadyForAction() are the existing refusals without the leaving, so declining to transmit no longer closes the list behind it. The radio is brought up at setup only when it is both fitted and allowed, rather than sitting in RX for a screen that is going to read the card. The Stealth refusal also said "Saved Profile transmits, so it is switched off" about a screen that was about to do no such thing. check_stealth.py carries a column for where the gate lives, so a gate that moves still reads as present and a gate that goes does not. Both new assertions were confirmed load bearing by breaking them. * WHAT IS VERIFIED, AND WHAT IS NOT. On hardware, end to end: the menu entry opens, the card mounts over the bus it shares with the CC1101, the directory is created and scanned, an empty one reports as empty, a .sub parses and stores against a reported slot, and Saved Profile reads the record back as 433.92 MHz, 24 bits, 0x123456. That is what the parser's own transcription says the sample is, which is the thing worth checking: the C++ parser and the Python transcription that 60,891 assertions are written against now agree on a real file on a real board, rather than only on each other. Read back with no CC1101 fitted, which the screen would not have allowed before this release. The record also carries Princeton as rc-switch protocol 1, which the screen shows and the card confirms. storeProfile() syncs the table to /pueo/subghz/profiles_current.bin on every write, and tools/decode_profiles.py reads that file against the record layout and against the parser's own frequency and bit bounds, so the stored bytes can be checked off the card without the device. * ONE PROFILE RECORD, BECAUSE THERE WERE THREE. SubGhzProfile at file scope and Profile declared again in replayat and in SavedProfile, all describing the same twenty-eight bytes of EEPROM. The export path used one, saveProfile another, the browser the third, and nothing kept them in step. They agreed, which is the only reason records written by one were readable by the others. PROFILE_SIZE was doubled the same way, a constexpr and two macros, each shadowing it from its own namespace on. tools/check_profile_record.py, 12 assertions, including the field order and widths: EEPROM holds records written by earlier firmware, so changing that is changing a file format. * NO SUBMENU WRITES DOWN WHICH ENTRY IS BACK. Adding the import broke two things from one constant. The handler compared against the literal 5, which was Back until the new entry took that index. Choosing the import went to the main menu, because it took the branch meant for Back; choosing Back did nothing, because it fell through to a switch with no case for 6. It compiled and every check passed, including the one for menu tables. The tables were right. The index was written down in a third place that 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. An assertion in check_menu_tables.py names the function, line and number. Found by running it. There is no wrong output to notice here, only one entry doing the wrong thing and another doing nothing. Fifty-five check scripts. 0.4.40 - 2026-10-06 ------------------- Nothing reaches the device. No file under ESP32-DIV/ changed, so the image is 0.4.39's apart from the version string. What is in it is a second handshake fixture and the assertions that read it. * THE CLEAN HANDSHAKE, BESIDE THE ONE WITH RETRIES. tools/fixtures/ptm-eapol-handshake-once.pcap, 45 frames. Exactly one of each message, no retransmission, so M1 M2 M3 M4 and nothing to tell apart. The one already there has two M1 and two M3, which is what made it worth keeping: a parser that counts EAPOL frames rather than classifying them passes a four-frame count and gets the wrong answer. This is the other shape, and the pair is the point. One catches a counter, the other catches a classifier that only works when the messages happen to be distinct, and a fixture that only ever shows one shape cannot do both. * WHERE IT CAME FROM. Off 0.4.39, from a capture where the general pool dropped 1,387 of 4,268 frames, 32 per cent, while these four arrived consecutively at records 1978 to 1982 and every one reached the card. That is the reserve under the pressure that used to destroy the burst rather than on a quiet channel where any pool would have held. Verified in the file by raw byte search before trimming, not by the parser it is a fixture for: four SNAP sequences, four frames carrying them, all at offset 26, all EAPOL-Key type 3, key_info matching Wireshark's own WPA reference capture to the bit. The frame count reconciles with what the device reported, 2,881 written against 2,881 records. * check_eapol.py is 124,344 assertions, eleven more than 0.4.39: the source and licence the manifest requires, the file being present, and its frame, EAPOL, encrypted-frame, header-layout and four message counts. Confirmed load bearing by dropping a record from the fixture. Fifty-four check scripts. 0.4.39 - 2026-10-06 ------------------- The Satellite Scanner draws without a sprite instead of refusing to draw. * ON LOW MEMORY THE SCREEN WAS UNUSABLE. When no sprite allocated, the panel printed "Display buffer failed" with the largest free block and the size one needed, and stopped there. It renders straight to the panel area now. renderPanelGx is a template over its draw target and works on the TFT as well as on a sprite; a viewport is what makes that true without touching the renderer, because TFT_eSPI reports the viewport from width() and height() and offsets every coordinate into it, so the panel-relative Y values land below the status bar and fillScreen clips to the panel rather than wiping the bar. The 800 ms retry above it still runs, so this is the degraded path rather than a dead end and the screen upgrades itself as soon as a sprite fits. * FROM UPSTREAM, AND HALF OF IT WAS ALREADY HERE. cifertech/ESP32-DIV#263 by maprolim fixes the same thing upstream. Its other half was already in this tree independently: kScanSpriteHMin is 96 against the 200 they lowered to 90, and the 8bpp retry bug they do not mention was fixed when that constant was set, because each height used to be tried at 8 and then at 16 and 16 needs twice the memory, so the second attempt could never succeed after the first had just failed. * WHAT THE FIFTH ARGUMENT DOES. setViewport's vpDatum defaults to true and that default is the mechanism rather than a detail: it is what makes width() return the viewport instead of the screen. Read out of TFT_eSPI.cpp rather than assumed. With it false the renderer would draw at screen scale inside a clipped window, and the symptom would be a layout bug a long way from its cause. * WHAT IT COSTS. The card named the largest free block and the size a sprite needed. Those were the two numbers worth having and serial is not visible from that screen, so losing them is a real loss, taken on purpose: a panel that draws beats a number explaining why one does not, and the missing double buffer announces itself by flickering. * Three assertions in check_screen_dims.py, which already owns the panel's geometry and a viewport is that geometry temporarily redefined: every viewport is reset, every setViewport keeps the datum argument, and the no-sprite path still calls renderPanelGx. Each confirmed by breaking it. Fifty-four check scripts. 0.4.38 - 2026-10-05 ------------------- Leaving Packet Monitor did not stop it. * THE FEATURE RAN ON AFTER THE OPERATOR HAD GONE. ptmLoop has two exit branches that clear promiscuous mode and close the capture file. The dispatch reaches neither: its own loop checks the exit button after calling ptmLoop and tears the feature down itself, clearing feature_exit_requested on the way, then breaks. Nothing called pcapStop. So leaving the screen left the radio promiscuous with the callback installed and the file open and still being written to, until something else reinitialised WiFi. The file was closed lazily by the next pcapStart, a run later. ptmTeardown now clears promiscuous mode, closes the file and resets the ready flag, and the dispatch calls it on both ways out of both of its copies. It is safe to call twice, so the branches inside ptmLoop stay: they are correct and one of them may become reachable again. * WHICH IS WHY THE CAPTURE SUMMARY HAD NEVER PRINTED. Six runs across three builds, and not once. It was not a lucky miss: the line sat on the two exit branches that nothing reaches, so it was being printed at the start of the following capture, about the previous one, if at all. One copy now, in pcapDisableAndCloseFile, which runs exactly once per capture whatever reached it and before pcapStop zeroes the counters. The check asserts where it sits rather than how many copies exist, because counting copies is what passed while the behaviour did not. * WHAT IT SAYS, FIRST TIME OUT. written=266 dropped=56 keyframes_lost=0 That is the shape worth reading. Fifty-six ordinary frames the general pool could not hold, on a busy channel, which is expected and was always happening. No key frame lost, because those have eight slots of their own that nothing else can take. The condition that destroyed every handshake before 0.4.37 is still present in the traffic and no longer reaches the frames that matter. * tools/check_pcap_pool.py, 21 assertions. Three new: that every ptmLoop call site has a matching teardown, that the teardown closes the file, and that it takes the radio out of promiscuous mode. Confirmed by deleting one of the two teardown calls. The EAPOL parser, the tracker, the reserve and the on-screen arithmetic are unchanged from 0.4.37. 0.4.37 - 2026-10-05 ------------------- Packet Monitor reported handshakes it had thrown away. Measured on the board: the tracker counted the key frames and the file it was writing held none of them. * WHAT WAS WRONG. A capture slot is taken in the promiscuous callback, filled, and handed to a drain that writes it to the card and gives the slot back. That drain runs in ptmLoop, a dozen records a pass, behind delay(10) and a screen redraw, so capture throughput is paced by the display refresh. A four-way handshake is four frames inside a few milliseconds, arriving while the channel is busiest with the association that caused them. The pool empties during that burst and the frames that matter are the ones that do not fit. The first attempt raised the pool from three slots to sixteen, on the grounds that three was obviously too few and the S3 profile had ten. That was a bet and the board called it: sixteen slots, four key frames seen, four not written. Depth cannot fix this, because the window that has to be covered is however long the screen takes. * WHAT FIXED IT. Eight slots above the general pool that only a frame the tracker has just recognised as an EAPOL key frame may take. A beacon cannot reach them however busy the channel is, so the four frames the feature exists for cannot be crowded out by the traffic around them. Eight is two complete exchanges. Reserve slots are returned to their own free list, in the drain and on close. Returning one to the general list would hand it to the next beacon and the reserve would bleed away across a session, which is a fault that only appears an hour in. The general pool is sixteen anyway and the drain takes twenty-four records a pass instead of twelve. Neither is load bearing now. On hardware, after: six key frames seen, all written. One network with M2 and M3. Before, across five captures and nineteen completed associations on the parked channel: nothing. * AND IT SAID SO ANYWAY, WHICH IS THE HALF THAT MATTERED. The tracker runs before the frame-size check and before the queue. That ordering is deliberate and stays: whether a handshake is recognised should not depend on whether it fits the capture. It also meant the screen read HS 1/1 and the console said key frames seen however few reached the card, and the comment beside the indicator asserted the frames were already in the pcap. A capture missing a handshake is a bad afternoon. A capture missing a handshake the device said it made is worse, because the first one gets noticed. A key frame the writer could not take is now counted. The indicator shows HS u/t -n in red, both stop lines carry keyframes_lost, and the first M2+M3 line says all written or says the pcap is incomplete. That line is what found the sixteen-slot failure, one build after it was added. * tools/check_pcap_pool.py, 15 assertions. That a reserve exists, that it holds a whole exchange, that only a key frame reaches it, that both return paths route by origin, and that every early return between the tracker and the write accounts for a key frame, which is how this comes back: a new return added above the write, dropping frames nothing counts. Confirmed by introducing each fault and watching the matching assertion fail. * A REAL HANDSHAKE IN THE FIXTURES. tools/fixtures/ptm-eapol-handshake-qos.pcap, 46 frames, off this hardware, and the first capture from this device that could contain one. Six key frames because the access point retried M1 and M3, which is ordinary and worth keeping: a count of EAPOL frames reaching four proves nothing, and these are two M1, one M2, two M3 and one M4. Every frame is QoS data with the payload at offset 26 rather than 24, which is the layout a fixed-offset parser reads two bytes late and the one no fixture covered. check_eapol.py classifies all six from the real key_info bytes. Addresses and SSIDs are replaced by tools/anonymise_capture.py, which learns what the addresses are from header fields and then replaces each of them wherever it occurs. It used to enumerate the fields instead, and leaked three times: short control frames, the current access point field in a reassociation request, and neighbour BSSIDs in the body of an 802.11k action frame. Enumeration was the wrong shape. Fifty-four check scripts. The EAPOL parser, the tracker and the on-screen arithmetic are unchanged. Nothing was wrong with reading a handshake; what was wrong was keeping it. 0.4.36 - 2026-10-05 ------------------- Stealth Mode was not receive only. Web OTA transmitted through it, which is the one thing the switch exists to prevent. * WEB OTA IS GATED, AND IT NEVER WAS. Stealth Mode refuses twenty tools and made no mention of firmware updates. System -> Firmware Update -> Web OTA scanned for a network, joined one and served HTTP over it, with the switch reading ON throughout and the screen promising silence. The scan was the quieter half and the worse one. selectWiFiNetwork() called WiFi.scanNetworks() with no arguments at all, and the no-argument form defaults passive to false, so a probe request carrying this device's address went out on every channel before anything was joined. Somebody checking that the radio was quiet would have seen a network picker, not a transmitter. Both halves are closed. performWebOTAUpdate() refuses at the top and names itself, and the scan takes Stealth::on() as its third argument like the other nine scans in the tree. The gate is on the web half rather than on the Firmware Update screen. The SD half beside it reads a file off the card and transmits nothing, and making stealth cost an offline update would be a cost it never had to have. Twenty-one tools now, in the user guide's box, the README and the site. * THE CHECK COULD NOT HAVE FOUND IT. check_stealth.py looks for an active scan two ways, a literal false in the third slot and the two-argument form that defaults to one. Both patterns need a comma, so neither could match a call with no arguments. One call site in the tree was written that way and it was the ungated one. There is a third pattern now. Reintroducing the bare call fails the check, which is how it was confirmed rather than assumed. func_body() had its own version of the same blind spot: it took the first occurrence of a signature, so a file that declares a function before defining it handed back the braces of whatever followed the declaration. Web OTA is declared at wifi.cpp:9525 and defined at 10437, so the gate read as absent once it was there. It now takes the occurrence where a brace comes before a semicolon, which is what separates a definition from a declaration, and works whether the list names a whole signature or the start of one. * THE SIGNATURE BREAKDOWN IS CHECKED, NOT JUST THE TOTAL. New, tools/check_sig_tables.py, 23 assertions. check_sig_counts.py asserts the pages say 274 and they did, while the surveillance page listed eight tables under a sentence promising nine, with BLE advertised name missing and the OUI row reading 118 against a table of 126. The total was right because the two errors cancelled. So this reads the breakdown: every table's row count wherever a page prints one, that a page claiming nine tables lists nine, and the strong, likely and weak split quoted for the OUI table. Fifty-three check scripts now, all passing. Nothing else reaches the device. The only source change is the gate and the scan argument, both in wifi.cpp. 0.4.35 - 2026-10-05 ------------------- Nothing reaches the device. The only file under ESP32-DIV/ is SpotterSignatures.h and its diff is comments: no row moved, no grade changed, no table resized. The image is 0.4.34's apart from the version string. * THE ENCLOSURE SOURCES COME OUT OF THE REPOSITORY. make_release.sh has stripped .scad from the archive since 0.3.8, and its comment said the files "stay in the repository, where they are edited, and go no further". That was true of archives and not of the repository: this one is public, so sitting in docs/pueo was publication. The half of the decision being enforced was not the half that mattered. Untracked and ignored now. They stay on disk and stay editable, and the assertion in make_release.sh stays as well, because docs/pueo still ships wholesale and a .scad that reappears there must not ride along. This stops future publication and nothing more. The files are in the history of a public repository and in anything already cloned. * THE SIGNATURES ARE PUBLISHED SEPARATELY. All 266 of them, as Markdown, CSV and JSON, with a writeup on what the three confidence levels mean, how they interact, and how to pick one. Extracted from SpotterSignatures.h, so the header stays the source and the list stays readable without opening it. The corroboration rule is written out exactly there for the first time: highest confidence seen wins, a differently labelled match marks the device corroborated, and a corroborated Likely becomes Strong. Weak is never promoted, or two common radios in one device would read as surveillance hardware. * THE CHECK COUNT WAS WRONG IN BOTH DIRECTIONS. This file said 53 at 0.4.33 and 0.4.34 and the website said 42. There were 50 then and there are 50 now, and none has been removed. Running all of them and reading each one's own summary gives 34 that count their cases, asserting 378,587 between them, and 16 that return a verdict. The first attempt at that tally was also wrong, crediting check_eapol's 124,315 assertions to check_doc_counts, whose job is to print other scripts' numbers back. * ISSUE TEMPLATES, AND WHAT THEY ASK FIRST. The bug form asks whether Stealth was on, because the twenty features that transmit refuse to start under it by design, so "it will not start" is more often that than a fault. It also notes that the GPS screen closes the serial console, so a GPS report with no serial output is expected. 50 checks pass. 0.4.34 - 2026-10-03 ------------------- One comment. The image is 0.4.33's apart from the version string. * THE SILENT GPS PATH IS CONFIRMED ON HARDWARE, AND THE THRESHOLD IS NOT. With no module fitted, the Satellites screen shows "no GPS" and the Wardriver's GPS card shows the NONE pill with "No data on GPIO 1. Check GPS wiring." Those are separate paths sharing only gpsLinkState(): the pill, the text row and the repaint condition through WardMainSnap are the Wardriver's own, so that is two results rather than one observed twice. Before 0.4.29 the Satellites corner stayed blank with no module. Its staleness test was guarded by lastGsvMs != 0, so the one state it could not report was a module that had never spoken at all. kGpsSilentMs is not confirmed by any of that, and the comment beside it no longer reads as though it were. Nothing with a module in it has been timed from power to first sentence, and one slower than five seconds would show NONE at startup and look exactly like a wiring fault. That is the one way this can still be wrong, and it wants a module to settle. 53 checks pass. 0.4.33 - 2026-10-03 ------------------- Tooling. Nothing in ESP32-DIV/ or PueoBeacon/ was touched and build.sh changes only a comment, so the image is 0.4.32's apart from the version string. * TWO STALE-COUNT DETECTORS WERE PERMANENTLY FALSE. check_sig_counts.py and check_stealth.py each carried a literal 0x08 where a word boundary was meant, left by a bash double-quoted string eating a backslash escape. A regex that requires a backspace character cannot match HTML. What that cost is precise. Both checks assert two things: that the site states the current count, which worked, and that it no longer states an older one, which could never fire. A page could carry the right count and a stale one together and both passed. That is the same shape as added.html saying "Three settings" while the device had four, which is exactly what those halves exist to prevent. Proved by planting a stale count on index.html and watching each name it. The first attempt planted "twenty features" for check_stealth, which is the current count and correctly not flagged; "Nineteen features" is caught. * CHECK_CONTROL_BYTES.PY, because that was the fourth in one session. Nothing shows a byte like that. The editor draws nothing, the diff looks right, and git is content; only cat -A or a byte-level search finds it. Three of the four were harmless, including one in a build.sh comment where "arduino\build-detector" had become "arduinouild-detector", the escape eating the b of build. Anything below 0x20 that is not tab, newline or carriage return, plus DEL. Form feed is included rather than allowed: nothing here uses it as a page separator, and allowing it because something might is weakening the rule in advance of a need. 194 files, the firmware tree and the site when .publish.local says where it is, and it fails if the globs match nothing so it cannot report green over an empty scan. The tree was surveyed before the rule was written, which is how the build.sh one turned up. Writing the rule first and meeting the exceptions as failures is how a check gets weakened until it means nothing. Proved by putting the byte back into check_stealth.py and watching this name the file, the line and the rendered <08>, while check_stealth itself exited 0 with the byte still in it. * THE LAST TWO TRANSCRIPTION CHECKS ARE PINNED. check_sub_parse and check_tracker_follow were left alone at 0.4.32 because neither named a C function that exists. Reading them answered it: the first names SubFile.cpp in its opening line and its parse() says "SubFile::parse(), transcribed", the second names TrackerFollow.cpp and says its constants come from the header. SubFile.cpp holds parseU32, parseKey and parse, so the extraction was checked rather than assumed: the body that comes back opens "parse(const char* text, size_t len, Parsed* out)" and contains both the RAW refusal and FreqOutOfRange. TrackerFollow.cpp gives up sighting, pickLane, lookUp and qualifiesByRate. Both also transcribe constants, which a hash of a function body does not cover: a frequency bound or a lane width that drifts is wrong in a way no function hash sees. guard_consts compares them by value, so the report names the one that moved and gives both numbers. Ten for the tracker and two for the .sub parser. All six transcription checks are pinned now. None of this says a transcription is correct. It stops one being wrong without anybody knowing. 53 checks pass. 0.4.32 - 2026-10-03 ------------------- Tooling. Nothing in ESP32-DIV/ or PueoBeacon/ was touched and build.sh is unchanged, so the image is 0.4.31's apart from the version string. * SIX CHECKS WERE ASSERTING THEIR OWN TRANSCRIPTION. An audit asked which checks would still pass if their subject vanished. Twelve of the then fifty-one had no obvious zero guard; blanking each testable subject found one that passed over nothing, which 0.4.31 fixed. Seven could not be reached that way, and the reason turned out to matter more than the original question: they do not read a sketch file at all. Six of them reimplement an algorithm in Python and test the reimplementation. The Find My parser, the EAPOL payload walk, the Fast Pair request and response, Spotter's address merging, the .sub parser and Hunt's follow heuristic. Only check_nmea_checksum reads the firmware. Demonstrated rather than argued. Dropping both locally-administered tests from Spotter's findOrAdd, which is the under-reporting bug check_spotter_merge's own docstring calls "the failure that matters here", left it printing "16 merge rules hold" and exiting 0. This project has met it before: check_deploy_pueo_prune once transcribed the arithmetic and passed cleanly against a script whose sort -V had been replaced with sort, which was treated as a fault and fixed by calling the real functions. Executing the C is not available here, so the next best thing is to notice when it moves. transcript_guard.py pins a hash of the function body. Comments stripped and whitespace collapsed before hashing, because a tripwire that fires on a reformat is one people learn to re-pin without reading, which is worse than not having one. Verified both ways: a comment edit leaves the digest alone, and removing the guard changes it. Four are pinned, mapped from each check naming its own subject rather than from guessing. check_spotter_merge to Spotter.cpp findOrAdd, check_airtag_parse to bluetooth.cpp parseAppleAdv, check_eapol to Eapol.cpp classify and findPayload, check_fastpair_probe to FastPairProbe.cpp parseResponse and buildRequest. Two of them were tested by breaking the C and watching the check fail. check_sub_parse and check_tracker_follow are deliberately not pinned. Neither names a C function that exists; the identifiers in them are their own Python. A wrong pin is worse than none, because it fires on changes to code the check has nothing to do with. What it does not do, said in the guard as well as here: it cannot tell you a transcription is correct. It stops one being wrong without anybody knowing. 52 checks pass. 0.4.31 - 2026-10-03 ------------------- Tooling. Nothing in ESP32-DIV/ or PueoBeacon/ was touched, but the image is not 0.4.30's image plus a version string this time: build.sh changes what reaches the ELF, and the app descriptor carries a SHA-256 of that. * CHECK_TOUCH_TARGETS.PY SPENT A RELEASE MEASURING NOTHING. All three of its patterns required a literal integer. The two button_x2 assignments in the sketch are "x_position + TILE_W", button_y2 the same, and "yPos + " no longer appears at all, so it matched zero sites. Both its assertions are of the form "not bad_x", which is true of an empty list, so it printed "every row hit box spans the panel" and "2 checks passed" having examined nothing. It went vacuous because of the work it was written for. It exists because five list menus tested x <= 220 on a 320 px panel. Those lists became tile grids with a shared gridHit(), the literals went with them, and nothing said the check had lost its subject. It resolves expressions now, against the integer constants the sketch declares, and measures the submenu grid directly from GRID_* since that is what the menus are. x_position and y_position are loop locals, seeded with the extreme each can take. And it counts what it measured, prints that count, and fails below a floor. Not a zero test: renaming button_x2 away took it from three horizontal boxes to one, and one is not zero, so a zero test passed that too. Losing most of the subject is the same failure as losing all of it and harder to see. Verified by experiment. Renaming button_x2 away fails on the floor. Widening GRID_GAP_X to 50 shrinks the computed tile until the far column reaches 270 of 320, which fails naming the box and the 50 px of dead screen, the original bug in new clothes. Untouched it passes over six hit boxes. Found by asking which checks would still pass if their subject vanished. A grep for zero guards flagged twelve of fifty-one; blanking each testable subject found this was the only one that passed over nothing. check_adv_nonconn.py and two others noticed. * CHECK_GRID_CAPACITY.PY said TypeError, and could not see an empty menu. Blanking the sketch made it raise "unsupported operand type(s) for *: 'NoneType'", which says nothing about which constant went missing. It names the constant now. And it had the same fault as the above: ino_table() returning an empty list makes "len(items) <= slots" true, so a menu table renamed or removed passed as a menu that comfortably fits. Every table has to resolve, and the run says how many labels it measured, which is 53 across 8 menus. * TWO SOURCE TREES SHARED ONE BUILD DIRECTORY. BUILD_PATH was keyed on the role alone, so every checkout built into build-$PUEO_ROLE. Verifying a release extracts the published source to a second tree and builds it, which overwrote whatever the working tree had built; cutting 0.4.27, merge failed there because compile had been run in the other tree. Keyed on a hash of the resolved repo path now. Each build directory records its tree in a sidecar file beside it, and claiming one removes any whose tree has gone, with its -warnings pair. Beside it, not inside: arduino-cli clears --build-path on a full rebuild, so a marker inside survived an incremental build and vanished exactly when a rebuild happened, which made the pruning quietly stop working for whichever tree had most recently rebuilt. * AND THE PART THAT NEARLY BROKE EVERY RELEASE CLAIM. Per-tree build directories changed the images produced. Identical source in two trees differed in 64 bytes: the 32 byte app_elf_sha256 at app+0xB0 and the 33 byte image digest that follows from it. The compiled code was the same. That is enough to fail "rebuilds byte-identical from the published archive", which is the claim every release makes. prefix_maps already rewrites $REPO and $PUEO_ARDUINO_ROOT, which is what lets two trees agree. $BUILD_PATH lives under $PUEO_ARDUINO_ROOT, so the root was rewritten and the rest of the path survived: the ELF carried arduino\build-detector-\sketch\ApTracker.cpp. $BUILD_PATH is mapped to a fixed tag now, listed last. Last matters: gcc applies the last matching -ffile-prefix-map, and listing it first left the path in the debug info exactly as before. Established by searching the ELF for the hash rather than by reading the manual. Verified both directions: identical source in two trees at different paths now produces identical bytes, and pueo-0.4.30-src.zip still rebuilds byte-identical using its own archived build.sh. Reproducibility across paths was an accident of every tree using the same directory name. It is a property now. * THE SITE SAID THREE SETTINGS AND THE DEVICE HAS HAD FOUR SINCE 0.4.19. Reported while this was being cut. JSON logging landed twelve releases ago and reached neither the website nor docs/, while added.html went on saying "Three settings the device did not have". The Settings screen carries Stealth Mode, SD Logging, JSON format and Boot Lock; the page described the first, second and fourth. The count was in nine places: a heading, a table of contents entry, and the nav on all seven pages. The section describing it is written from the firmware rather than the old changelog entry: which three features honour it, and which two keep their own format because WiGLE's upload accepts nothing but its CSV and Wireshark reads PCAP. check_settings_documented.py, because nothing could have noticed. The one check that reads the site reads the feature menu tables, and its docstring says Settings is outside what it covers: the screen is drawn from its own switch tables. The row labels are read out of those tables now and each has to appear somewhere in the site's pages. It found a second one on its first run. "Log to SD" is the master switch, and the page described it as "a master switch" without ever naming it, so somebody reading the screen and the page together had nothing to match. It is named now. The reverse is still not checked, for the reason check_site_menu.py gives about its own direction: a page may say more than the screen does. 52 checks pass. 0.4.30 - 2026-10-03 ------------------- * The About page's byline and the boot screen's byline are assembled at run time rather than concatenated at compile time. No behaviour change: all four display sites read what they read before, confirmed on a board and in the serial banner. * The website's firmware page carries the WiFi submenu screenshot, which had been rendered at every release and copied at none of them. make_release copies a render only where a destination file already exists and reports the rest, and that line had printed at four consecutive cuts without being acted on. 51 checks pass. 0.4.29 - 2026-10-03 ------------------- All four of these came off a board. * 15 KB OF CLASSIC BT RAM WAS HELD unless you opened a Bluetooth feature first, and Wardrive could not start because of it. Reported from a board: "Startup failed. Could not start scan worker." That message has one source, xTaskCreatePinnedToCore failing to make the 10240 byte stack for wardfg, which fails when no contiguous block that size exists. The block was there, reserved. This firmware is NimBLE only and never initialises Classic BT, so the controller's RAM is dead weight, and esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT) hands it back. That call lived in exactly one place: ensureBleStackReady(), in bluetooth.cpp. So the memory came back only if you happened to open a Bluetooth feature. Boot and go straight to Wardrive and it was still held, and a 10 KB task had to find room around it. Which features worked depended on the order you visited them in, which is not a property anybody can reason about from the screen. Released once in setup() now, before anything allocates, so the fragmentation never happens rather than being undone later. The call in ensureBleStackReady() stays: it is idempotent, the second one returns ESP_ERR_INVALID_STATE, and that function already tolerated exactly that. 14,968 bytes, from the boot log rather than from arithmetic: [boot] classic BT RAM released, heap 146104 -> 161072 Both comments said "about 30 KB" before, an estimate nobody had checked. They carry the measured figure now. The failure message also said nothing actionable. It reports the largest free block alongside the total free heap, because a task stack needs one contiguous block and the total can look comfortable while no single block fits. * WARDRIVE SAID WAIT FOREVER with no GPS module fitted. The GPS card showed a WAIT pill, "Sats 0 HDOP -1.0" and "No fix yet", and kept showing it. That is exactly what it shows for a module that is working and has not acquired, so the screen could not be read. GPS is still out of submenuHardwareNote() and that stands: there is no cheap probe, because the module answers by emitting NMEA when it is ready and a brief look that found none would report "no GPS" for one still waking up. This is the other case. An app that has had the port open for seconds is listening rather than taking a snapshot, and silence over time is evidence. What makes the test sound: a module that is present emits NMEA from power-up whether or not it has a fix, including GSV with nothing in view. One that is absent, unpowered, miswired or at the wrong baud emits nothing. So the test is bytes on the UART, stamped before any parsing, not sentences parsed and not a fix. A module talking rubbish at the wrong baud is still a module, and reporting it missing would send somebody to check wiring that is fine. Four states. Opening, under 5 s since the port opened, says WAIT. Silent, 5 s and not one byte, says NONE and names GPIO 1. Stale, bytes that arrived and stopped for 8 s, says LOST, which is a different fault with a different cause. Live says WAIT until there is a fix, as before. 5 s because a GPS emits its first sentence within about a second of power, long before it can fix. The state goes into WardMainSnap and the gpsDirty test, because the card repaints only when that snapshot changes and a state that cannot trigger a repaint is a warning nobody sees. The Satellites screen had the same blind spot. Its staleGsv is guarded by (lastGsvMs != 0), so the one state it could not report was a module that never spoke: no GSV ever, nothing on screen, identical to one still acquiring. It shows "no GPS" or "GPS lost" now and keeps "no GSV" for a module talking but quiet on GSV specifically. * THE THREE READINGS AT THE TOP OF THE GPS SCREEN WERE 1.23 MM. gps.cpp draws at font 1 size 1 in eight functions, which is 8 px on a 165 ppi panel. Most of that is correct and stays: the sky plot's satellite labels sit at the satellites' own coordinates, the legend strip and footer hints are captions, and in the SNR table the bar is the information with a number beside it, the argument shared.h already makes for the drone detector. The metric cards are the exception. UTC, DOP and FIX are three readouts with no graphical form and they are what the screen is for. The value in each is font 2; the label above it stays 8 px, because a caption is a caption. Sized from the font tables rather than by eye. The card has 93 px of text area and the widest value any caller can pass is "H99.9 P99.9" at 80 px. Font 4 was tried first and does not fit, at 94 to 109 px, so font 2 is what the card can hold rather than what looked right. kCardH grows 29 to 32 and ends at 78 against kBodyTop 82, inside slack that already existed. DOP is one line. Two 16 px lines plus a label want 40 px in a card that cannot pass 36 without displacing the body. drawMetricCardGx loses the value2 parameter with the path that used it. That needed a bound: "H999.9 P999.9" is 96 px against 93. A DOP over about 20 already means the solution is unusable and the figure past that says nothing, so dopForDisplay() clamps at 99.9. check_text_margins cannot measure these, because the value arrives as a parameter and the x is x + 4 off another, so it skips for an x that does not resolve; the bound is held in the source instead. The fix badge is font 2 too. It is the most important word on the screen and at most six characters: "FIX 3D" is 42 px at x=272 on a 320 px panel. * CHECK_TEXT_MARGINS.PY MEASURED FONT 1 and assumed everything else was font 1 too. setTextFont's argument was resolved with int() behind a digit regex, so setTextFont(PUEO_BODY_FONT) gave None and the caller then assumed font 1 and measured at a flat 6 px per character. PUEO_BODY_FONT is 2: proportional, 3 to 10 px, averaging nearer 8. Nineteen call sites were measured about a quarter narrow, in the direction that passes a line which overruns. The font numbers come from shared.h now, so renumbering the constant cannot leave the check agreeing with an old value. Nothing overran, and the first attempt at proving otherwise was wrong in a useful way: it charged the widest glyph for all twelve characters of Hunt's age column, which is the error tft_fonts.py's own docstring warns against. Measured properly that field is 30 px at "999s" and fits at 310 of 320 with eleven digits. The other half was 40 sites skipped for "an explicit font that is not 1". The unit is pixels per font now, which is the real change: a character count cannot be compared across fonts, and param_width was picking the widest caller by character count, which in a proportional font is a different question. A format is charged its literal runs measured plus each conversion at the widest character that conversion can produce. Not the font's widest glyph, 25 px in font 4 and enough to fail every line on the device. Not a digit either: font 4's uppercase hex reaches 18 px against a digit's 14, so charging a digit for %X under-measures a MAC by 48 px. Removing the font gate did not measure those 40, it moved them to "size set neither here nor by any caller", because those functions select a font and never touch the size. Measured before assuming: of the sites where both resolve, font 2 is size 1 at all 29, while font 1 appears at 1, 2 and 3. So a proportional font with no size set is measured at 1, the library default, and font 1 is still skipped, because PUEO_BODY_SIZE is 2 and multiplies font 1 and never font 2. 48 measured to 54, the rest landing in honest skips. Verified by lengthening a font 2 string in Hunt's picker and watching it fail at 345 px on a 320 px panel, then pass again restored. Two things found alongside. check_text_fits.py still carried a 240 px 2.8 inch panel entry that check_text_margins dropped on 2026-09-26, so the two were measuring different hardware and one of them hardware that cannot be built; dropping it took its count from 8 to 4, because it had been measuring the same four strings twice. And its first line claimed centred text generally while showLine has exactly one caller, SignalGauge.cpp. Reading that line and concluding centred text was covered is how the About page came to be drawn in the smallest font on the device with nothing measuring it, so the scope is stated now and points at check_centre_fits.py. 51 checks pass. 0.4.28 - 2026-10-03 ------------------- Documentation and tooling. The firmware is 0.4.27's firmware: nothing in ESP32-DIV/ or PueoBeacon/ was touched, and the image differs by 70 bytes of 1,867,776. Five of those are content, each the last digit of the version string, in the About page's byline, the About details title, the two WiGLE fields and the wardrive User-Agent. The other 65 are two digests: the app descriptor's SHA-256 of the ELF at app0+0xB0, and the SHA-256 and checksum appended to the image. Counted, not reasoned about. * THE UI IS TFT_eSPI CALLED DIRECTLY, and that was written down nowhere. docs/pueo/ui.md. No LVGL, no widget library, no retained object tree, no invalidation and no event dispatch: 260 fillRect, 159 drawString, 151 drawFastHLine, 118 drawBitmap, 34 drawCentreString. The decision appeared in no doc, no changelog entry and no comment above the drawing code, so the constraints it imposes lived only in the shape of the code and in the checks that compensate for them. Two numbers carry it. One 320x480 framebuffer at 16 bpp is 307,200 bytes against 203,252 bytes of free DRAM, so a full framebuffer does not fit, let alone the two a toolkit wants for compositing; LVGL would run with partial-render buffers, redrawing regions on demand, which is what the code already does by hand. And the app is 57% of a single 3.00 MB slot, with the headroom already spent on radios. Neither forbids a toolkit. Together they mean one would run in its least comfortable mode while costing the flash the features use. The rest of the doc is what the absence costs, item by item, and every item is a bug that shipped. No clipping, so three checks measure text split by how it is drawn rather than by preference. No font metrics at layout time, so tft_fonts.py parses the width tables. A 165 ppi panel where one pixel is 0.154 mm, which is why PUEO_BODY_FONT and PUEO_BODY_SIZE are both 2 and not the same 2. One shared mutable tft whose font, size and datum persist across screens, which is the Hunt picker bug stated as a class. No layout engine, so the grid is twelve constants. And coordinates rather than events, which is both why rows are the tap targets and how five menus kept a 2.8 inch hit box on a 320 px panel. It also records that render_screens.py models the screen rather than executing it, so it can agree with itself while disagreeing with the panel, and that the About page is drawn by neither it nor check_render_sync.py. Every figure was recomputed rather than carried across: 576.9 px diagonal, 164.8 ppi, 0.154 mm per pixel, 96 x 92 px tiles at 14.8 x 14.2 mm, 7 mm as 45 px, 30 px rows as 4.6 mm. GRID_TILE_W is shown as the expression it is rather than the 96 it evaluates to, since the gaps are the input and the tile width falls out of them. * CHECK_PROSE.PY COULD NOT SEE THE SITE'S OWN README, for four releases. It globs docs/pueo/*.md, three files at this repo's root, and the site tree's *.html. The site README was none of those: it was in this repo until the site became its own, and no glob followed it out. Ten dashes sat in it while the check reported green on everything it could reach, and nothing said what it could not reach. site_dir() already resolves PUEO_PUBLISH_DIR from .publish.local, which points at the published site/ subdirectory, so the repo root is its parent. Same skip behaviour as the pages: no .publish.local means the site is not on this machine, which is the case inside pueo--src.zip. Both halves were verified by running it, an injected dash failing at pueo-site/README.md and the check still passing with .publish.local moved aside. Adding that file put two README.md in the corpus and the reports printed basenames, which pointed at neither in particular and wasted the line number that is the only reason to print anything. There is a label() helper now, used at all three reporting sites rather than patched at the one that happened to be in view. * Also, in the site repo, which is not published to GitHub: three nav entries wore the name of something else in the same nav. "Surveillance" was a section on the overview page while the Surveillance page sat under What's added with the identical label, so clicking the word that said Surveillance gave the summary. "Overview" was a section of firmware.html while Overview is the front page. And the Hardware dropdown opened with an entry called Hardware. Every link was correct, which is why nothing caught them; the labels were the fault. They read "What is in range", "What it is" and "The board" now, each taken from the section's own heading. tools/check-nav.py holds the nav, because nothing did. Seven pages carry their own copy by hand with no template, so the copies can drift and the only way to notice was to open two pages and compare dropdowns. It holds them identical once the two per-page attributes are removed, resolves every href against the tree including anchor ids, requires one label to mean one destination anywhere in the nav, and requires aria-current on the entry for the page being read and nowhere else. The collision rule was first written per dropdown and could not see the one that prompted it, since those two entries are in different dropdowns and neither is a top-level page. Widening it to one label, one destination is what surfaced the other two. 51 checks pass. 0.4.27 - 2026-10-03 ------------------- * THE UPDATE GUARDS ASKED THE WRONG QUESTION, and an update that got as far as writing would have erased the firmware it was running from. Reported from a board on 0.4.26: Web OTA offered a network list and SD Update asked for a card. Neither of the guards added in 0.4.26 fired, on a build with one app partition, which is the only kind this is. Both tested esp_ota_get_next_update_partition(NULL) == nullptr. That call does not return null here. It returns the next OTA partition after the running one, and on a single-slot table that is the running one, so the test was false and both guards waved the update through. Not cosmetic. Updater.cpp takes what that call returns and erases it, with no check of its own that it differs from the partition currently executing: _partition = esp_ota_get_next_update_partition(NULL); if (!_partition) { _error = UPDATE_ERROR_NO_PARTITION; return false; } So the write would have gone into the running app and the board would have needed a USB reflash. Both guards existed to prevent exactly that and neither could. otaHasSpareSlot() asks what they always meant: whether there is a partition that exists and is not the one running. * THE ABOUT PAGE WAS THE SMALLEST TEXT ON THE DEVICE, and nothing in the tree measured it. Page 0 draws its tagline and byline in font 4, a real 26 px face rather than font 2 doubled, because a scaled bitmap font gets blockier and no clearer. They were font 1 at size 1, which is 8 px, 1.23 mm on a 165 ppi panel, under a 200 px logo, and they are the only text on the page. Page 1 had the same 8 px for its whole body, and that is the page carrying the board, the author, the URL and the upstream credit. It goes to font 2, and since that doubles the line height the vertical run is respaced with it: the rule from 78 to 82, the rows stepping 22 rather than 20, and the three credit lines 18 rather than 14. check_text_fits.py could not have caught either. It matches the name showLine and opens only .cpp files, so all 35 drawCentreString call sites were unmeasured and the About page, being in a .ino, was never read. check_text_margins.py skips 40 sites for "an explicit font that is not 1", because its arithmetic is 6 px per character. So check_centre_fits.py measures centred strings in whatever font the call names, and tft_fonts.py carries the glyph tables, parsed out of Libraries/TFT_eSPI-master.zip rather than the Arduino library directory, since the zip ships in the release archive and a check that only runs in its own tree cannot verify a release built from published source. The tables are the point. A 24 character line is 144 px in font 1 and anywhere from 24 to 600 px in font 4, so a character count says nothing about whether a line fits. The two About lines turn out to be the widest centred strings in the firmware, at 243 px and 223 px of 320. 22 of 34 call sites measure; the 12 skips are String expressions, a .c_str() and one unresolved centre x, each counted and printed on every run. Two things the parser has to handle. Font16.c carries row 96..103 twice under #ifdef TFT_ESPI_GRAVE_IS_DEGREE, which the build does not define, so it takes the #else row: keeping both would shift the table eight entries and mismeasure every character above 0x60. And each row ends in a "// char 32 - 39" comment whose numbers are not widths. This check reads the panel width from shared.h rather than the PANELS list the older checks carry, because that list still has a 240 px entry for a panel that was dropped and that shared.h can no longer build. 51 checks pass. 0.4.26 - 2026-10-03 ------------------- * THE SPOOFERS WERE CONNECTABLE, and a phone was answering. Reported from a board: a pairing request from ESP32-DIV arriving minutes after Sour Apple had been closed. NimBLE defaults conn_mode to BLE_GAP_CONN_MODE_UND, undirected connectable, whenever the peripheral role is compiled in, which this firmware does because the Rubber Ducky is a BLE HID device. It also puts the device name in the advertisement. So BleSpoofer, SourApple and AirTagSpoofer each advertised as a connectable device named after the board wearing somebody else's payload, and a phone answered the only part of that it understood. That is also why it outlived the feature. A central connects, the connection ends, and the host resumes advertising, so SourApple::exit()'s stop() was being undone rather than failing. None of those three has anything to connect to; they are broadcasts. Non-connectable removes the prompt, the connection and the restart together. Two things guard it now. check_adv_nonconn.py holds every advertising start in a broadcast-only namespace to setting the mode first, and asserts the Rubber Ducky is NOT non-connectable, since being connected to is the whole point of a keyboard. And the four menu painters call bleQuietDown() before drawing: a menu on screen means no feature is running, and nothing that is not running may transmit. That one is a net rather than a fix, and it is what proved the leak was real. The BLE name is Pueo rather than ESP32-DIV. Only the BLE identity: the hidden scratch AP in wifi.cpp and the test-string tables keep the old name, the first because renaming it changes attack behaviour rather than branding, the others because they are things to send. * THE SUBMENUS ARE A GRID, AND THERE ARE NO PAGES. They were lists of 30 px rows, which is 4.6 mm on a 165 ppi panel against the 7 mm a fingertip needs, and the rows are the tap targets: the d-pad path wants a PCF8574 expander that is not fitted, so touch is all there is. They also paged at six entries while leaving 45% of the panel black. Three columns by four, 96x92 tiles, which is 14.8 x 14.2 mm. Twelve slots holds the largest menu, so Next Page is gone rather than restyled: WiFi's twelve features and Bluetooth's eleven are each one screen. Labels are the strings the menu tables already carry, wrapped greedily into up to three lines and measured in the panel's own font rather than counted, because font 2 is proportional. Three lines because every label in the firmware fits in two except "Probe Request Flood", which needs 89 px in a 96 px tile as a pair, and TFT_eSPI does not clip: on one line it ran across the screen through its neighbours. The page variables stay. Which feature a tap launches is decided by about ninety tests of the form (wifi_submenu_page == 0 && current_submenu_index == 3), and rewriting those into one index space is how the wrong feature gets launched quietly. The grid is a view over both pages instead: tile 7 sets page 1, index 1, and every one of those tests reads what it always read. check_grid_capacity.py fails if a menu outgrows the grid. WiFi uses twelve of twelve, and drawMenuGrid() stops at GRID_SLOTS, so a thirteenth feature would simply not be drawn with nothing to say so. * THE RIGHT 100 PX OF EVERY LIST ROW WAS DEAD TO A TAP. Five menus tested x <= 220 on a panel 320 across. 220 is a 2.8" number: that panel is 240 wide, where 10..220 covered it. The tile grids had the same bug and it was caught; the lists kept it, for the reason the comment there gives, which is that a hit box reads like a coordinate and behaves like a dimension. check_touch_targets.py reads the panel from shared.h and fails on a hit box that runs past it or stops more than 40 px short. It also prints, without failing, every tap target under 7 mm, because a row pitch is a design decision and a check that failed on one would be demanding a redesign. * THE PANEL CANNOT DRAW AN EM DASH, and Hunt was drawing two sizes. Font 1 covers 0x20 to 0x7E, so an em dash arrives as three UTF-8 bytes and three wrong glyphs. 36 strings had one, twenty of them in rfid.cpp's Card Clone flow, where the status line is the entire interface. Hunt's picker set size 1 at the top and restored PUEO_BODY_SIZE after each row's right-hand column. Those are not the same 2: the font is TFT_eSPI's font 2, already 16 px, and the size is a multiplier for font 1. Every row after the first came out 32 px tall in a 40 px row whose lines sit 20 apart, so each label wore the address below it. check_font_size.py tracks which font was last selected before each use of the constant. * UPDATE FIRMWARE CANNOT WORK, AND ONLY HALF OF IT SAID SO. An OTA write copies into a spare app partition and this build has none. huge_app gives one 3.00 MB slot where min_spiffs gives two of 1.88, a trade made at 0.4.2 because the sketch was at 89% of the smaller one, and it is 57% of this one. SD Update checked for the spare slot and explained. Web OTA did not: it asked you to choose a network and type a password, and only then reached Update.begin(), which fails with a number. That is the SD path's own stated reason for checking, and the web path never did. Both share one message now and both check before asking for anything. The message named pueo--35-merged.bin. The -35 suffix marked the 3.5" image until 0.4.13, when the 2.8" was dropped and the plain name became the only one, so the single instruction on that screen pointed at a file that is not published. build-guide.md said it too, three hundred lines above its own explanation of the change. The feature stays. It cannot update anything, but the screen it shows is the answer to how you do update one, and a menu entry that is gone answers nothing. * Also, all reported off a board: the BLE gate read the free heap and refused below 40 KB before releasing the 30 KB of Classic BT controller RAM it was about to release, so a board at 30 KB was turned away by the function one statement from helping it. Skimmer Detect said press Start while already scanning, because redrawList() ran before startScan() and nothing repainted it. The jammer button said Toggle, which names the mechanism rather than what it will do. Missing hardware now uses the notification panel the Rubber Ducky uses, with the pins to check and, for the PN532, the DIP switches, since one left in I2C mode is the commonest reason one is fitted and silent. And a submenu says in its footer when the module it needs is not there, probed once and remembered. GPS has no such note on purpose. There is no cheap probe: the module answers by emitting NMEA when it is ready, and a brief look that found none would report "no GPS" for one still waking up. 47 checks pass. 0.4.25 - 2026-10-03 ------------------- * THE FIRMWARE IS UNCHANGED. No file under ESP32-DIV/ or PueoBeacon/ moved since 0.4.24, so the image differs from it only in the version string it prints. Flashing this over 0.4.24 gains nothing. It is cut because the documentation, the screenshots and the release tooling moved, and those ship in the archive and on the site. * A FLOCK ALPR RAISED A ROW, so the camera signatures are no longer untested against the hardware they name. Three documents said they had never been tested against a real camera. One has matched now. Nothing was captured or logged off it, so the claim is kept to its size: 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 still proves only the matching path, by transmitting a known OUI, which has always been a different claim from the signature being right. It does not touch the fingerprint claims, and the wording now says why. An IE fingerprint table needs packets captured off a camera, and a row appearing on a screen is not a capture. There are still no values to ship. * A LICENCE SENTENCE THAT ARGUED THE OPPOSITE OF THE POSITION. ie-fingerprinting.md refused FlipDeFlock's fingerprint tables because "it is GPL-3.0-or-later and Pueo is MIT". Pueo has been GPL-3.0-or-later since the relicence, and the website already says the move was made partly so those tables could be used. The table stays empty for the reason that actually applies: they are somebody else's captures rather than this project's. * THE SCREENSHOT RENDERER HAD THE BEACON'S SIGNALS WRITTEN OUT BY HAND. render_screens.py parses the firmware's bitmaps, fonts and coordinates out of the source precisely so a screenshot cannot drift from what the panel draws. Its beacon table was a list typed into the file, under a comment saying it was Emit::name() and Emit::detectedBy(). It was eight rows when the enum had fourteen, and it still said Spotter and Drones after those became Surveillance and Drone Detector, so the published screenshot showed a beacon that had not existed for three releases. The same failure 0.4.24 was full of, in the one file whose entire purpose is not to have it. Both tables are read from Emit.cpp now, in the enum's order from Emit.h, with the fall-through cases resolved so a label reaches every case stacked above its return. The counts are keyed by signal name rather than by position: as a list of eight it raised an IndexError at fourteen, and before that it had been quietly rendering the first eight of however many there were. The counts came off a board rather than being invented. The four WiFi paths run to about 362 and the ten BLE signals to about 12, because the BLE ones share ten rotation slots of three seconds. The numbers that were there spread evenly between the two, which is a device that cannot exist. Verified against a photograph of the panel: fourteen rows, same order, same labels, same counts, chevron on the same row. * MAKE_RELEASE REGENERATES THE SCREENSHOTS IT PUBLISHES. They are rendered from the firmware's own source, which makes them an artifact of the release exactly as the binaries are, and the version is on the boot screen and in the status bar, so most of them move whenever it does. Nothing regenerated them, and 0.4.24 published a site showing 0.4.23 on nine screens. Render into a temporary directory, replace what differs, verify each copy with cmp, say how many moved. Only files already on the site are replaced; a render with no counterpart there is named and left alone. A failed render stops the publish rather than warning, because the alternative is shipping a release whose site shows the previous one. No check script for it. One would compare the site against a fresh render, and the release sequence bumps the version before make_release publishes, so it would fail on every release at the point where the site is correctly still showing the old one. * SEVENTEEN TYRE-SENSOR SIGNATURES, AND NOTHING SAID WHICH KIND. The table carries Schrader, Goodyear, Pacific, Huf, FOBO, TireCheck, the SIG's own tyre service and four aftermarket valve-cap rows. TPMS appeared nowhere in the documentation or on the website, so somebody meeting "TPMS sensor" on the beacon's screen had no way to know these are the Bluetooth sensors rather than the 315 and 433 MHz ones a car reads itself. Factory TPMS is a valve-stem transmitter talking to a receiver in the car. The CC1101 tunes across both of its bands, so the hardware could read it, and no feature does. Saying so matters more than it looks: somebody who owns this device and wants that will otherwise go looking for a screen that is not there. The source had it right in a comment all along, which is the one place a reader will not look. * Also: two documents named the menu entry Spotter, which it stopped being at 0.4.0. The source is still Spotter and that is correct, but build-guide.md was listing what works on a board, which is a list of menu entries, and ble-sniffer.md put Surveillance in the Bluetooth menu, where it has not been since 0.4.11. And "it was never support in any sense you could rely on" wanted supported. 43 checks pass. 0.4.24 - 2026-10-02 ------------------- * THE PANEL CANNOT DRAW AN EM DASH, and was being asked to 36 times. TFT_eSPI's font 1 is a 96-glyph table covering 0x20 to 0x7E. A character outside it is UTF-8 in the source, so it reaches drawString() as two or three bytes, each indexed into that table separately, and what appears on the panel is two or three wrong glyphs in the middle of a sentence. injDrawStatusValue(0, "none - run Scan", UI_DIM_TEXT); rfid.cpp had twenty of them. Card Clone walks somebody through four steps and every one of those steps is a status line reading "Step 1/4 - Present SOURCE tag", which is the whole interface for that feature. Nothing has reported it because no PN532 is soldered to a board yet, so none of those strings has been drawn on hardware. A label and its value takes the colon the dash was standing in for. Ranges take a hyphen, an ellipsis becomes three dots, an arrow becomes -> and the non-breaking hyphen in Wi-Fi becomes a hyphen. Every replacement is the same length or shorter, and each dash was drawing three glyphs, so none of them can widen a line that already fitted. check_ascii_strings.py strips comments before it looks, because the comment banners are full of box-drawing characters that nothing draws and forty false positives would bury the seven real ones. It separates strings it can see reaching the panel from strings it can see reaching the serial console, and fails on both those and on any whose destination it cannot work out: a check that passes because it could not tell is not a check. * THE BEACON NAMED TWO FEATURES THE DETECTOR DOES NOT HAVE. Its whole job is to say which screen should hear each decoy. It said Spotter for ten of the fourteen signals and Drones for the other two. The menu stopped being called Spotter at 0.4.0 and has never been called Drones: the entries are Surveillance and Drone Detector. Somebody reading the beacon went looking in the detector for a feature that is not there. check_beacon.py now takes every string detectedBy() can return, splits the one row that names two features, and insists each half is an entry in one of the sketch's menu tables. It also checks the label fits, with the panel width read from shared.h rather than written down again. * FIVE DOCUMENTS HAD STOPPED BEING TRUE, and now something reads them. README.md said Surveillance matches "108 signatures across nine kinds" and named four matching primitives. There are 266, and three primitives arrived in 0.4.21 that the sentence never learned about: a whole MAC address, the bytes past a company ID, and the bytes past a service UUID. PUEO.md called the 3.5" the board "this has run on" in its third paragraph and then closed its changes list with "None of this is tested on hardware yet". Both cannot be right. Its Status block listed compiler flags, symbol-level checks and host tests, which is everything except whether the firmware runs. Its size was 1,690,265 bytes at 85% of a partition the build stopped using at 0.4.2; it is 1,805,632 at 57% of the 3.00 MB slot, measured off the published image rather than remembered. spi-bus.md said the touch prediction was "the first thing to test when a CYD exists". One exists. enclosure-logo.md said check_logo_scale.py runs 14 checks and it runs 15. eapol-capture.md said check_eapol.py runs 120,301 and it runs 124,315. Both drifted the same way: an assertion was added, the script went on passing, and nothing was reading the sentence that described it. The user guide said "266 signatures across 9 kinds" above a table of eight rows, using the word kinds for both, so a reader who counted got a different number from the one just read. There are nine tables and eight questions, because a name matched from its first character is one question asked separately of a WiFi network and a BLE device. Four checks for that class of failure. check_doc_counts.py finds every case count a doc quotes for a named check script and runs that script to compare; it attributes a count to the nearest script named before it at any distance, and fails on one it cannot attribute at all, because the first version used a fixed window and silently dropped two of eight. check_sig_counts.py reads every doc in the repository rather than the user guide alone, which is where README.md had been wrong for three releases. check_site_menu.py holds the website's menu listing to the sketch's menu tables, that block being introduced as every entry in the menus while missing File Transfer. And check_ascii_strings.py, above. * 510 DASHES DOING A COMMA'S JOB. The house rule is commas and full stops. Every dash in the documentation predated the rule, and they are not all one thing: a pair around an aside is not a dash joining two clauses, and neither is the one between a term and its gloss in a bullet. So the replacement is per instance. A pair around a list or a clause takes brackets, because commas there read as more items. A pair around a phrase takes commas. A subordinator or conjunction after it takes a comma. A clause that can stand alone takes a full stop. A term in a bullet, a label and its value, and a dash introducing a list take the colon the dash stood in for. Four were left for a person, because a rule general enough to catch them broke the band table: "31 - 62% - had been seen" is thirty-one of fifty, which is 62%, and digits either side of a dash is how a range looks. The changelog keeps its 129. It ships inside every published pueo--src.zip, those archives are verified byte for byte, and rewriting an old entry now would put this tree at odds with every copy already downloaded. check_prose.py holds three rules: no dash doing a comma's job, nothing about the conversation that produced the work, and none of the handful of phrases that are always deletable. What it must not flag matters as much as what it must: a range with spaces around its dash, a dash in a heading, and a dash inside a code span all pass, because a check that cries wolf on correct text is one that gets turned off. * Also: the website's section navigation carries every section of every page rather than a flat list of eight, read out of each page's own jump nav so the two cannot drift apart. The overview listed three of the six features this fork added and said thirteen check scripts where there are forty-three. 43 checks pass. 0.4.23 - 2026-10-02 ------------------- * A FILTER ON SURVEILLANCE, by kind and by confidence. 0.4.22 solved the noise problem by curating: handheld action cameras left out, the car population graded Weak. That is a decision made once, at compile time, for everybody, by somebody who is not in the room. Whether a car park full of Weak vehicle rows is noise or is the answer depends on where you are standing, and the person standing there is the one who knows. The left nav slot used to be null, which draws the dots icon meaning the button does nothing. It opens the filter now: nine kinds to toggle, and a confidence floor of everything, Likely and up, or Strong only. Display only, and per session. Both are load-bearing, both are asserted, and the reason is the same: a filtered list and a quiet street look identical. Display only, because a filter that stops recording edits the evidence. The capture on the card would depend on what the list happened to be showing while it ran, which is not readable six months later. record(), captureNote() and the SD writer do not know the filter exists, and check_spotter_filter.py fails if any of them learns. Per session, because the way a remembered filter fails is silent. Somebody sets Strong-only in a car park, walks somewhere that matters, reads an empty screen and concludes there is nothing there. It resets every time the screen opens. And the header carries the difference: "hits 13" becomes "hits 4/13" whenever the filter is not showing everything, and the second number is the one that has not changed. An empty list now also says which empty it is, "nothing matched yet" against "everything here is filtered out". * TWO FAULTS IN THE FIRST VERSION, both reported from the board and both with one cause: the filter screen was written as a full repaint rather than in this file's idiom. It flashed. Every pass it cleared its whole area and drew ten rows into the hole, at the redraw rate. uiShowLine exists to avoid exactly that: everything else here compares the line it is about to draw against the one already there and does nothing when they match. The selection marker and the [x] are inside the string, so a text comparison sees every change that matters. And leaving it left itself on the list. The filter draws more rows than a short list does, so backing out repainted the list over the top and left the filter's lower rows underneath. forgetDrawn() was being called, which fixes what will be drawn and not what is already on the glass. The cache is borrowed rather than new. Thirteen lines of its own cost 572 bytes and overflowed .dram0.bss by 456; DRAM is the scarce thing in this image and has been since File Transfer's WebServer had to go on the heap. The list's cache is 48 slots for the 13 this wants, the two screens are mutually exclusive, and the transition wipes it. Net cost of the whole feature is 24 bytes. * DECOYS FOR THE VEHICLE BLOCK, which arrived in 0.4.22 with 66 rows and nothing to trigger any of them. The beacon emitted one vehicle signal, "QT 12345678", which is the KARR BT module and predates all of this. Three, chosen to cover the three ways a vehicle row can match rather than three vendors: a company ID alone, manufacturer data past the company ID, and one of the Weak car rows. The TPMS one earns its place. Its company ID is Nordic's 0x0001, which is in half the BLE hardware ever made, so the wheel-position byte is the entire signature -- which makes it the decoy that proves mfgMatch reads from md[2] and not md[0]. A wrong offset produces no match at all, and a sensor that is not detected looks exactly like a sensor that is not there. All three verified on two boards. * THE NAV BAR DISAPPEARED AFTER A WHILE, in Surveillance and in Fast Pair. Reported from the board, and older than any of this. redraw(true) starts with fillScreen, which takes the nav bar with it, and it never put the bar back. Every caller was responsible for remembering. setup() did -- its comment explains the ordering in detail -- and the callers that forgot are the ones that run rarely: Spotter's dwell alert, which fires the first time a device has been present long enough to be worth mentioning, and backing out of Fast Pair's confirm or result view. Those are exactly the paths nobody exercises while writing the code. The repaint lives inside redraw() now, so a caller cannot forget. That is the only version of this that stays true as callers are added. check_nav_labels.py gains a fifth rule, which is the inverse of its fourth. Rule 4 catches a repaint that is wiped afterwards; this catches a wipe with no repaint at all. It had four rules, each written after that exact failure had happened, and this is the fifth. * AND TWO CHECKS CAUGHT THEMSELVES BEING WRONG, which is worth recording because both are the failure the check existed to prevent, reproduced inside the thing preventing it. check_spotter_filter.py passed with the per-session reset commented out, because every assertion in it is a substring search and a commented-out line still contains its own substring. check_menu_dispatch.py learned that about a commented-out call. Comments are stripped before searching now. And check_beacon.py's new vehicle assertions hardcoded the company ID, so they asserted only that the detector carries that signature -- which stays true when the beacon is changed to emit something else. The decoy and the signature come apart and the check says nothing. It reads the company out of the emitter now. check_nav_labels.py was right the first time, and about something subtler than it looked: entering the filter painted the nav bar and fell through to the rest of the loop, which ends in redraw() on the dwell path, whose first act is fillScreen. The bar would have been wiped on any pass where a dwell alert fired. 0.4.22 - 2026-10-02 ------------------- * SIXTY VENDORS, AND EVERY OUI CHECKED AGAINST THE IEEE REGISTRY. 0.4.21 added the three primitives. This is what they were for: the surveillance, law-enforcement, camera and vehicle entries from the Fieldwatch comparison, 60 fleets and some 400 of their rules collapsed to about 150 rows. NAME_CONTAINS "Dahua" and NAME_GLOB "Dahua*" are one assertion to nameContains, and fourteen Cradlepoint model numbers are one vendor. The vendor is the signal: a Cradlepoint IBR is a ruggedised cellular router sold to police, EMS, utilities and commercial fleets and to nobody else, so hearing one says a vehicle of that kind is parked nearby. The model number says which modem generation it has, which says nothing about who is driving. What arrives: roadside travel-time readers (BlipTrack, BlueTOAD, Iteris Vantage, TrafficCast), which sit on poles counting the addresses of passing devices and are as squarely in scope as anything in this file; intersection and pole cameras (Miovision, Pigvision, ShotSpotter, LVT trailers); commercial CCTV (Dahua, Uniview, UniFi Protect); body-worn and in-car video (Digital Ally, FirstVu, VuLink, EVO-HD); the routers that ride with them (Cradlepoint, Sierra AirLink, Utility Inc); Flock-family pole batteries; fixed home cameras; fleet telematics (Samsara, Motive, PeopleNet); and tyre sensors. * THE OUI CLAIMS WERE CHECKED, NOT TRUSTED. The blocks came from Fieldwatch's catalog and were carried on its word, recorded in check_spotter_oui.py's VENDOR_OWN as provenance rather than verification. They are checked against the registry now. 53 Strong blocks against the IEEE registry, 52 agreed outright. The one that did not was a company rename rather than a wrong block: D4:11:D6 is registered to ShotSpotter, Inc., who are now SoundThinking. Relabelled to what the registry says. 28:80:A2 is the same shape, Novatel Wireless having become Inseego. check_oui_registry.py does that comparison on demand. "Fieldwatch says 00:0E:A5 is BLIP Systems" and "00:0E:A5 is BLIP Systems" are different statements, and only the second is worth putting in front of somebody using this to decide whether they are being followed. It knows two things that would otherwise be noise. A locally administered address is not in the registry by definition, and several rows here exist precisely because a tool ships a made-up default. And a Weak label names what hardware is probably in rather than who made it, so --all reports those for reading while only Strong rows decide the exit code; six of the FS battery blocks are Silicon Laboratories, which is why they are Weak. The 3.8 MB registry is cached rather than committed. * TYRE SENSORS ARE WORTH MORE THAN THEY LOOK. A TPMS broadcasts a stable identifier for one wheel, continuously, and the owner did not choose it and cannot switch it off. Following a car by its tyres is a known technique, and this is the first release where the table can say so. * TWO CURATION CALLS, STATED RATHER THAN BURIED, because transcribing somebody else's catalog without making them is how a detector starts crying wolf. Handheld action cameras are out. Fieldwatch files GoPro, Insta360 and DJI Osmo under CAMERA. They are cameras and they are not surveillance: they point where somebody is holding them, they are everywhere, and a screen that says SURVEILLANCE because a tourist walked past is a screen people stop reading. Fixed home cameras stay, because an Arlo watches one place continuously, and they are graded low. The car population is in at Weak throughout and kept as one contiguous block. BMW, Toyota, phone-as-key, factory hotspots: not surveillance, just what a car park is made of. Weak reads as corroboration rather than a finding, and contiguous means deleting the lot is one edit if it turns out to drown the rows that matter in traffic. That is the part of this release nobody has driven with yet. * PWNAGOTCHI IS MATCHED ON ITS WHOLE ADDRESS. de:ad:be is not a vendor block: it is the first half of somebody spelling out deadbeef with a MAC. This file already said so in a comment apologising for the row, which is why it was graded Likely. No vendor owns de:ad:be, or cafeba, or beefbe; the registry confirms it. What collides is not a vendor but other people's jokes: de:ad:be:ef:ca:fe on a dev board reads as a Pwnagotchi on three bytes and does not on six. MacSig holds a whole address for the things that have one hardcoded rather than assigned, and Pwnagotchi is Strong there and means it. The prefix row stays at Weak, renamed "deadbeef MAC", which is what is left once the identification moves out. WiFi only, and the check asserts the invariant that rests on: every row is locally administered, which is what makes a BLE pass pointless, since the BLE cross-match only fires on public addresses. It also asserts six bytes a row, because a three-byte row compiles -- mac[6] zero-fills, and the signature then matches any address ending in three zero bytes. * The gap this work opened with was 92 vendors. It is 27. 0.4.21 - 2026-10-02 ------------------- * SURVEILLANCE DID NOT FLAG AN AIRTAG. It had Tile, Samsung SmartTag and Eddystone, a comment mentioning Find My Device, and no Find My rule at all, so the most common tracker there is did not appear on the screen whose job is passive detection of tracking hardware. AirTag Sniffer and Hunt both see it. The one that exists to say "there is tracking gear here" stayed quiet. It does now, and on a bench test it immediately found a real one in the room at -54 dBm alongside the decoy, which is the part that matters: the rule works against hardware nobody set up for it. * THE GAP WAS THREE PRIMITIVES, NOT NINETY SIGNATURES. Found by comparing the five signature tables against Fieldwatch's catalog (github.com/OffGridPete/Fieldwatch, MIT, so incorporation runs that way and not the other). 92 of its in-scope entries had no label here, and only 8 of those were expressible with what this file had. The rest were gated on how the tables can match rather than on what they contain. MfgSig reads the bytes past the company ID. kBleSigs could say "company 0x004C" and that is every Apple device in range; the Find My type byte 0x12 is what makes it a tracker. Likely rather than Strong, because an iPhone relays Find My for other people's accessories: it says something on the network is here, not that there is an AirTag here. Type 0x12 is the separated-from-owner payload, so a tag beside its owner stays quiet, which is correct for this screen. SvcDataSig reads the bytes past a service UUID, which is what separates a Find My Device tag from a shop's beacon. Both advertise 0xFEAA and the frame type says which, so Google's 0x40 and 0x41 are Strong while everything else on that service stays the Weak Eddystone guess it always was. The Eddystone row did not need removing: record() keeps the higher confidence, and the bench test confirmed the Strong label wins on an advertisement that fires both. It also brings DULT, service 0xFCB2, the IETF cross-vendor advertisement for a tracker separated from its owner. One rule, every vendor that implements it, including the ones that do not exist yet. The highest value row in the file. NameInSig matches a needle anywhere. NameSig matches a prefix and that stays the default, because a prefix plus an exact length is what makes "DR " usable. It cannot see a vendor in the middle of a name, which is the shape the pentest kit takes: a Pineapple's management SSID has user text in front of it. A separate table rather than a flag, because adding a field means rewriting every row, and because a substring is a different risk -- it has no anchor, so the table carrying that risk should carry the rule about it too. Needles are four characters or more or they declare a minimum name length. * THE BEACON GREW DECOYS FOR ALL OF THEM, because a decoder can be correct while the scan never hands it a byte, and the symptom is an empty list, which is also what an empty room looks like. Find Hub on 0xFEAA with a 0x40 frame, DULT on 0xFCB2, and an SSID with the needle in the middle rather than at the front, since a prefix would be caught by the old table and leave the substring rule untested. The pentest decoy's MAC is deliberately an OUI that matches nothing. A real Pineapple often has an ALFA radio and 00:C0:CA is in the table as a Weak guess, which would be the more realistic decoy and a useless test: the hit would be corroborated by two signatures with no way to tell from the screen whether the rule under test fired at all. check_beacon.py asserts that in a direction it did not have before, that an OUI matches nothing, because picking an unused OUI is a decision that rots silently the first time somebody adds a rule covering it. It also closes the loop on Apple Find My, emitted from the start and never checked against the detector, because for most of this file's life there was no rule to check it against. 41 checks to 55. * ALL FOUR VERIFIED ON HARDWARE, two boards side by side: the Find My decoy and a real device, Google Find My Device winning over Eddystone on the same advertisement, DULT, and the Pineapple SSID on a frame whose OUI matches nothing, so the substring rule is the only thing that could have labelled it. * check_spotter_prims.py guards the off-by-two, in both directions. NimBLE leaves the company ID on the front of manufacturer data and strips the UUID from service data, so one matcher reads from +2 and the other from 0. Getting either wrong does not crash and does not report nothing: it reports a confident, wrongly-labelled hit on a screen somebody is reading to decide whether they are being followed. 0.4.20 - 2026-10-01 ------------------- * THE BUILD GUIDE'S FLASH COMMAND NAMED A FILE THAT DOES NOT EXIST, and then named a second one that does not exist either. It said pueo-0.3.4-merged.bin, five releases after 0.3.4, and followed that with "on a 3.5" board flash pueo-0.3.4-35-merged.bin instead" -- which is backwards as well as stale. The plain filename meant the 2.8" board until 0.4.13 and means the 3.5" now; there is no -35 image and no 2.8" image at all. Step 2 of that guide is "flash the stock firmware and boot the bare CYD", and the reason it is step 2 is that nothing has been soldered yet and a known-good starting point is worth having. Somebody there has no way to tell a wrong filename from a dead board, which makes it the worst place in the docs for a 404 followed by advice to try another 404. check_doc_versions.py asserts that a download filename in the docs names the release being shipped. Filenames and not every version number, because most version numbers in these docs are history and belong there: a check that had to exempt "supported up to 0.4.13" and "under a tile called More until 0.4.11" would be mostly exemptions, and a check that is mostly exemptions asserts nothing. A filename is never history. Nobody writes pueo-0.3.4-merged.bin to describe what happened in 0.3.4; it is there to be copied into a terminal, which makes the current version the only correct value. Six mutations across all five filename shapes, all caught. * THE FLASH COMMAND CARRIES A BAUD RATE NOW, in the build guide and on the download page. esptool defaults to 115200 and this board does 921600, which is about 19 seconds against two and a half minutes for the same write. tools/build.sh never needed a -b because arduino-cli takes the ESP32 core's first UploadSpeed option, so the hand-typed command was the one place a reader landed on the slow default without knowing there was a choice. Both copies say why, and say the two things worth knowing: that the rate changes the upload and not the result, because every region esptool writes is SHA-256 verified and a rate the cable cannot carry fails at the handshake rather than corrupting anything quietly; and that 115200 is what to try before suspecting the board, while above 921600 is where the CH340 on these panels stops being reliable for the sake of a few seconds. * ONE PANEL, SAID ONCE. The firmware's README opened with "Two panels, one image each" and contradicted itself two paragraphs later, where it says the 2.8" was supported up to 0.4.13 and is not any more. The site's README was worse: it described the firmware as running on an ESP32-2432S028R, the panel that was dropped on 2026-09-26, which is the first sentence anybody reads about the project. Every page in the published tree had been corrected; the READMEs had not, because nothing reads a README. check-readme-board.py in the site repo compares the board its README names against the one index.html's own lede names. That direction has no exemption list, which is why it is written that way round rather than as "nothing may mention the 2.8 inch": the pages mention it deliberately, because a panel that shipped images nobody had booted is worth saying out loud. * Nothing in ESP32-DIV/ or Libraries/. Two commits, both documentation and tooling, and the images agree: the only differences from 0.4.19 are the version-string characters and the two hashes that move whenever anything does. 0.4.19 - 2026-09-30 ------------------- * FILE TRANSFER. System > File Transfer raises a WPA2 access point, serves the SD card read only over HTTP, and shows the network name, an eight digit password and a URL on screen. Join it from a phone, open the page, tap a file. Exit takes the access point down. All of that is verified on the board, including a .jsonl capture pulled off intact. The ask was Bluetooth, which does not survive contact with the stack. There is no standard BLE profile for sending a file; what phones actually speak is OBEX over Classic Bluetooth, which needs Bluedroid, and this firmware is built on NimBLE because Bluedroid will not fit beside the rest of it. A custom GATT service would transfer files to an app nobody has written. Every phone already has an HTTP client. Read only on purpose: SD File Manager is four lines away in the same menu and does the writing, with the panel in your hand rather than a password. The password is generated when the screen opens, shown, and stored nowhere. A SoftAP beacons, so this refuses under Stealth Mode like any other transmitter, which makes twenty. The WebServer is on the heap. As a third file-scope instance it took .dram0.bss 128 bytes past the segment and the link failed; DRAM is what is scarce in this image, and it now exists for as long as the screen does. check_file_server.py asserts the three sentences the screen makes -- nothing writes, the AP carries the generated password, exit takes it down -- plus the hole underneath them, that every client path goes through pathOk before SD.open. Ten mutations, all caught, including WiFi.softAP(s_ssid): one token, compiles, works better, and serves the captures to the street. * EVERYTHING ON THE CARD LIVES IN /pueo. It was nine things in the root: /logs, /captures, /config, /esb, /subghz, /captive_portal, /ducky, /ssids.txt and a /wd_wigle_upload.csv. On a card that also holds somebody's photographs those are nine entries scattered among their files with nothing saying which program they belong to. The card is theirs. Four paths are reads rather than writes and are handled differently, because moving them silently would break cards that already work. ssids.txt, ducky/ and firmware.bin read /pueo first and the old root second. settings.json is the same with worse consequences -- reading only the new location would reset every setting on the first boot after this, stealth off, logging off, backlight back to default, with nothing on screen saying why -- so it reads either and writes the new one, and the first save migrates it. Captures already on a card are left alone. Moving somebody's files is not this change's business. That shipped broken for one commit and is worth recording. SD.mkdir does one level, so mkdir("/pueo/config") fails when /pueo is not there, and under PUEO_DIR every directory is nested by definition: no /pueo folder appeared and settings would not save. There were four copies of ensureDir -- in SettingsStore, subghz, and twice in wifi.cpp -- all one level short in the same way, which is why the fix had to be made in four places and was made in none. One sdEnsureDir walks the path now. * THREE LOGGERS WERE WRITING TO THE CARD ROOT and always had: Surveillance's captures, and the wardriver's in both its foreground and background paths. They go to /pueo/logs with the rest. check_sd_paths.py did not see them, and the reason is the lesson. Its literal pattern had no % in its character class, so "/spotter_%lu.jsonl" matched nothing at all -- a check whose entire job is finding files written to the root, blind to every filename built by snprintf, which is the ordinary way to name a log. It was written in the same sitting, declared green, and was green over three existing bugs. * THE nRF24 FEATURES WERE READING A PULL-UP AND LOGGING IT. With no module fitted, MISO floats high and every register reads 0xFF. That is a confident failure rather than a neutral one: STATUS reads 0xFF, bit 6 is RX_DR, so "a packet is waiting" is permanently true. The FIFO read clocks out 32 bytes of 0xFF, esbTrimPayloadLen only trims trailing zeros so it reports a full payload, and the ESB sniffer wrote it to the card. One row per millisecond, which is what the SD write path costs rather than what a radio delivers, every row byte-for-byte identical, nothing on screen saying the radio was missing. Found by reading a downloaded .jsonl, not by anything on the device. None of the six nRF24 features checked. Scanner presented the same floating reads as channel occupancy and MouseJack Scan presented them as devices; the three transmitters keyed into nothing and reported success. The three that read are the ones that matter, because fabricated data does not announce itself -- it goes in a file, gets downloaded, and is read as a measurement. The probe already existed and five of the six never called it. Nrf24Raw::begin() writes 0x4C into RF_CH and reads it back, which is the one answer a floating line cannot give. nrfReady() is subghz.cpp's cc1101Ready() with a different chip in it, in the same order: stealth first, because that is a question about intent, then the hardware, because that is a question about the board. * A CHECK FOR LEFT-ALIGNED TEXT THAT RUNS OFF THE RIGHT EDGE, and the four places it was already happening. check_text_fits.py measured the centred lines; nothing measured tft.drawString at a fixed x, which is the quieter failure. Centred text loses both ends and looks obviously wrong. Left aligned text loses only the tail, so "http://192.168" is a perfectly plausible thing for a screen to say. That was File Transfer's own URL, laid out as though the panel were 480 wide when it is 320. The other three had shipped: AP Tracker's picker never showed the channel, Hunt's never showed the age, and AP Tracker's empty-list message lost the end of both its lines. All in features somebody had used. Getting the check to see any of it took four passes and each was the same mistake. Skipping calls whose own function set no text size measured 14 of 188 and reported green over the bug it was written for, because drawRow() draws and draw() sets the size. Requiring x to be a literal skipped exactly the four lines just moved to a PUEO_SCREEN_W - 104 column. Requiring the text to be a literal skipped the three rows that go through a row helper, which is most of a screen. And one widening introduced a false positive within the minute, then matched nothing once narrowed back to correctness, so it is gone. What it still cannot see is a %s of a runtime string, which the docstring says, along with the skip counts on every run. * JSON LOGGING, as an option rather than a replacement. Settings > Logging gains a JSON format switch beside the per-app toggles. Surveillance, the ESB sniffer and the jamming detector honour it; the wardriver keeps WiGLE's CSV because WiGLE's upload accepts nothing else, and the packet monitor keeps PCAP because that is what Wireshark reads. Neither format is this project's to choose. NDJSON, one object per line, because a capture is append-only and a truncated file should lose one row rather than being invalid. The format is read once when a file opens, so a setting changed mid-capture cannot produce a file that is half one thing. SSIDs are escaped properly, which the CSV never did -- it substituted dots. check_log_fields.py asserts the two spellings of a row agree, since a field added to a header and not to an object is two files disagreeing about what a row is, and both are string literals as far as the compiler is concerned. * THE SPOTTER SHOWS ITS DROPPED-FRAME COUNT, which nothing ever did. The REC tag turns orange and carries the count when the capture ring overflows, so a capture with holes in it says so while it is running rather than after. * BUTTON WAITS THAT CAN GIVE UP. while (isButtonPressed(b)) {} was in 55 places with no yield in it: a button that does not read as released takes the device with it, with no repaint, no input and nothing feeding the watchdog, indistinguishable from a crash. A stuck button is rare, which is why it survived; the path that makes it worth fixing is the touch controller, which isButtonPressed falls through to and which has lost the shared SPI bus once already. 59 call sites converted. * AND THE DOCS NOW HAVE TO AGREE WITH THE CODE about what Stealth Mode refuses. The user guide prints that list as a box and counts it in words, the README counts it again, and nothing joined the three. The box said nineteen through the whole of AP Tracker, which happened to stay right only because AP Tracker does not transmit. 0.4.18 - 2026-09-30 ------------------- * A CHECK THAT THE SOLDER DIAGRAM MATCHES ITS TABLE. solder-pads.svg is a picture of the castellation table in build-guide.md, and a picture of a table is a copy of it. This one drifted on its first attempt and the wrong version shipped in 0.4.16. Nothing would have caught it; a photograph did, a release later. check_solder_pads.py reads both files and compares every numbered pad: the same nineteen numbers, the same signal name on each, the same classification. The drawing encodes classification as colour, so colour is what gets read. It also asserts the shape the corner implies, fifteen numbered down the right edge and four along the bottom, with fifteen pad rectangles a side and eight across the bottom. That last assertion is the one the broken version would have failed. Broken four ways before it was committed: a pad renamed, a hazard drawn as ordinary, a bottom pad rectangle removed, and pad 17 put back on the right edge, which is the 0.4.16 bug exactly. All four reported. * DATASHEET LINKS FOR THE PARTS, ten of them, every URL checked. The parts this design uses first, then the two amplifier modules that were costed and turned down. Six are named as deliberately unlinked rather than quietly omitted: ST7796, XPT2046, FM5324GA, ATGM336H, TP4056 and MT3608. Their datasheets circulate as PDFs passed between vendors rather than living on a maker's site, and the ATGM336H's arrived here as a file rather than a URL. Those are disproportionately the parts whose figures carry [verify], and the two facts have the same cause. One near miss worth keeping. curl gets 404 from nxp.com for URLs that are plainly valid, including one the vendor's own search results quote, so it blocks automated requests rather than having lost the file. The PN532 datasheet was nearly recorded as dead on that evidence; a real browser pulls it down without complaint. A link checker that trusts a status code would delete working links, which is a reason not to write one here. * THE SOLDER DIAGRAM'S BOTTOM PADS CARRY THEIR NUMBERS. Reported as unclear, and it was: eight identical pads with a list off to one side, nothing tying a number to a pad or saying which way the count ran. Each of the four now has its number beneath it and an arrow reads "15 is the corner. 16 to 19 run this way". Two checks earned their keep on that change. check_svg_labels refused the first attempt, where the new caption ran into the signal names by 4 px, and check_solder_pads had to follow the names when they moved because it keys on where they sit. Mutation-tested again afterwards, all four still caught. * Colour now means one thing in that drawing. Pad 16 was being drawn with a dim rectangle and a bright hazard-coloured label while not being a hazard, which is what let the check read classification straight off the colour. 0.4.17 - 2026-09-30 ------------------- * THE SOLDER DIAGRAM SHIPPED WRONG IN 0.4.16 AND IS FIXED. It drew nineteen castellations in a straight line down the module's right edge, which is how build-guide.md's table reads. The module is the usual WROOM-32: fifteen down each long edge and eight across the bottom. The count reaches 15 at the bottom-right corner, turns it, and 16 to 19 run leftwards along the bottom. The six targets were in the right places, all at 15 or below, so anyone following it to solder would have soldered correctly. Three of the five hazards were not, and SD1, SD0 and CLK are exactly the pads a drawing most needs to place right, because bridging them stops the board booting. The wrap is confirmed by the nineteen names mapping onto WROOM-32 pins 38 down to 24 and then 23 down to 20, in order. Nineteen names matching a standard pinout across a corner is not a coincidence. build-guide.md now says where the count turns instead of heading the whole list "the right-hand edge". * CAUGHT FROM A PHOTOGRAPH ALREADY IN THE REPOSITORY. The enclosure page has carried a picture of the board screwed into the printed lid since the case was printed, module and card slot and ribbon and USB-C all facing up. Nobody had looked at it for the pad layout. Two more things fell out of the same picture once somebody did. * P3'S PINOUT IS CONFIRMED, silkscreen legible beside the connector: `GND`, `IO35`, `IO22`, `IO21`, which is what the pin map has said all along. That was the last open [verify] on the connectors, and it is closed by reading the board rather than a datasheet. * P1'S FULL ORDER IS VISIBLE TOO, printed above the connector as `+5V TX RX GND`, left to right. Pin 1 and pin 4 were already confirmed by silkscreen and by continuity; this is the middle pair, from the same photograph. * HOW TO TELL WHETHER YOUR BOARD IS THIS ONE. The tells existed already, framed as reasons Sunton's back drawing is a revision behind. Turned round into the question someone holding a board actually asks: `ESP32-035` on the silkscreen, two USB sockets rather than one, an FFC socket for the panel ribbon, `BAT1`, and `SW1` beside it. The two-socket one is quickest. A board with a single micro-USB is the earlier revision and nothing measured here is promised to hold on it. 0.4.16 - 2026-09-29 ------------------- * NO FIRMWARE CHANGED, again. Five commits and not one of them touched ESP32-DIV/ or Libraries/. This is documentation, plus one drawing that did not exist. * A DIAGRAM OF THE SIX SOLDERED PADS, `docs/pueo/solder-pads.svg`, linked from the build guide. Those six joints land on the ESP32 module's own castellations, and the guide described them as a table of pad numbers, which is a thing to hold in your head while counting pads on a module 18 mm wide. Five targets are on the right-hand edge and one is pad 10 on the left. The five pads that end the board are marked too: IO5, which is the card slot's chip select and sits directly between two of the targets, IO0 the boot strap, and the three flash pins at the bottom. The left edge is drawn numbered but unnamed, because pad 10 is the only one on that side the guide records. Filling in the other eighteen from a stock pinout is exactly the move that has cost this project four pins already. Orange means solder here, matching wiring.svg rather than contradicting it. The first version of the drawing used green for that, two figures above a legend where green means the opposite. * P1'S PATH IS 2.33 Ω, MEASURED. Two points on one USB source with nothing unplugged between them: 4.582 V open circuit, and 4.534 V across 220 Ω at 20.6 mA. A millivolt of meter resolution on each puts it between 2.23 and 2.43. At the 256 mA the carrier's converter draws, the pin sits at 3.99 V with 0.15 W in the path, so the tap is sound and the [verify] open since the power tree was rebuilt is closed. It also resolves a reading that looked impossible. 4.75 V at 87 mA against 4.534 V at 21 mA is voltage rising with current, which no passive source does. At 2.33 Ω it falls out, because that day's supply sat at 4.95 V and this one at 4.58. `Voc` belongs to the charger and `R` belongs to the board, and two sessions three days apart agree on the half that is this design's problem. * P1'S PINOUT IS CONFIRMED AT BOTH POWER PINS, by two methods and neither of them a document: pin 1 is 5V off the silkscreen, pin 4 is GND by continuity to a known ground. Recorded because the alternative was expensive. A second-hand answer consulted the same day gave the order as pin 1 TX, and the carrier's cable puts `+5V_IN` on pin 1, so following it would have driven 5 V into GPIO 1. Corrected, that same source explained itself by inventing a flipped board revision, which is worth writing down because a phantom variant is the kind of thing that gets believed later. * A CONCLUSION IN hardware.md OUTLIVED ITS PREMISE. The section on P1 ended with "so the carrier cannot take 5 V from here" while the carrier had been taking 5 V from there for days. It was correct when written, against a design where the base ran from a cell, and a rail alive only while tethered to USB cannot feed a battery device. The device then became USB-powered for unrelated reasons, which turned the objection into the operating condition. The heading overstated it as well: "you cannot draw from it" is not what 87 mA at 4.75 V says. * The S7V8F3 has four pins, not the three the netlist connects: SHDN, VIN, GND, VOUT. Three nets is right electrically, because Pololu give SHDN as tie-to-VIN or leave-disconnected for permanently enabled, but the footprint still wants the pad. Its height is 3.0 mm, published rather than estimated, which makes it the shortest part in the case and the only height in the module table that comes from the maker. * The hardware page carried a [verify] whose answer was the paragraph directly beneath it, asking whether P1's 5V was an output while the text below reported it measured as an input. Its premise was the charger that never went in the case. 0.4.15 - 2026-09-28 ------------------- * NO FIRMWARE CHANGED. Twenty-eight commits and not one of them touched ESP32-DIV/ or Libraries/, and the images agree: this merged image differs from 0.4.14's in 70 bytes out of 1,849,696, in seven places. Five are single bytes, a "4" becoming a "5" where the version string sits. The other two are the ELF SHA-256 in the app descriptor at 0x0100B0 and esptool's tail hash, which move whenever anything at all does. So the status bar is the only place a difference shows. This release is the documentation and the tooling, cut because the documentation is where this project keeps its hardware findings, and those moved a long way. * THE BOARD RUNS FROM A CELL ON BAT1, AND SW1 STARTS IT. A bench supply on BAT1 at 3.8 V and again at 4.25 V did nothing and drew 0.000 A, which reads like a dead path. It is not. The FM5324GA is a charger and a power-bank boost in one, its output stays disabled until something enables it, and the button beside the connector, silkscreened SW1, is what enables it. Press it and the board comes up with no USB involved. For a day this looked like the finding that sank an internal-battery build, on the grounds that a handheld needing a USB cable plugged in and pulled out before it will turn on is not a handheld. A button press is a different proposition: that is how every power bank behaves and nobody minds. The condition is that an enclosure has to reach SW1, which the current model does not, because until now there was no reason to. * THE FIRST MEASURED CURRENT, in a load budget that was estimates end to end: 448 mA at 4.00 V, 1.787 W, running from BAT1 with the beacon spammer transmitting. Converted to the USB socket, where the board's own boost is not in the path, that is about 310 mA at 5 V against the 500 mA the budget carried, so its largest line was high by about half. It also explains an earlier drop-out better than the guess that followed it. The same 1.787 W at 3.7 V is 483 mA, roughly 3% under a 500 mA supply limit, and the whole load transfers in one step when USB leaves. The supply was not set wrongly by a wide margin. It was set wrongly by a hair, and the step did the rest. * P1'S 5V IS AN INPUT, AND THE CONCLUSION PUBLISHED BEFORE IT WAS WRONG. It was called a boost output here on the strength of an asymmetric continuity reading. Under a 54.4 ohm load it holds 4.75 V with USB connected and collapses to 0 V on battery alone, which is not a rail but a high-impedance node flattered by a 10 Mohm meter. Sunton's own label said so all along: P1 is the "4P 1.25 Power supply base", and a supply base is where power goes in. The failure is recorded beside the fact, because the theory was never checked against its own prediction, and a body diode at 92 mA would have predicted 3.1 V rather than zero. * THE CARRIER NETLIST DESCRIBED A BOARD NOBODY IS BUILDING: an MT3608, a TP4056, a cell, a 14-way J1 and the PN532 on a 5 V rail, none of which survive in the current design. It had also been refusing to run since the pin corrections, its own guard catching CC1101_CS at 21 against an assumed 27. The guard worked. Nothing ran it. Rebuilt against the enclosure's own module list and the three-cable harness, and two real defects fell out of the rewrite rather than the renaming. R1 had a leg on ground as well as its two series pins, which would have made the GPS series resistor a divider. The PN532's and GPS's decoupling capacitors each had one leg on ground and the other on nothing at all. * TWO CHECKS THAT DID NOT EXIST. gen_netlist.py grew --check, so the suite can run the thing that drifted for want of being run. check_svg_labels.py is new: it reads the SVG out of a page or a standalone file and asks whether every label fits the box it is anchored in and clears its neighbours. Four labels were wrong that way at once and every one was found by eye. * THE DIGEST TABLE HAD NOT BEEN REGENERATED SINCE 0.4.9. Five releases were missing from it, 0.4.10 through 0.4.14, and one of them is 0.4.13, which is pinned precisely so that its digest outlives the file. The table says it holds every release including pruned ones; for five releases it did not. It does again. * The pin map had GPIO 16 and 17's LED colours swapped, contradicting a measurement three screens below it in the same file. check_pinmap.py had them right all along. * Second-hand pin maps describe lcdwiki's boards rather than Sunton's. E32R28T and E32R35T are lcdwiki part numbers, and four wrong pins in this tree came from advice scoped to them. The lesson is in hardware.md: a note that names the board it applies to is doing its job, and the board it names is the thing to check first. * This file's own header named the ESP32-2432S028R, which is the 2.8" board this project dropped in 0.4.14. 0.4.14 - 2026-09-26 ------------------- * THE FLASH IMAGE FILENAME HAS CHANGED MEANING. Since 0.1.0, pueo--merged.bin has been the 2.8" ESP32-2432S028R image and the 3.5" ESP32-3248S035R carried a -35 suffix. From this release the plain name IS the 3.5", and there is no 2.8" image at all. If you have been reaching for the unsuffixed file out of habit and you are on a 2.8" board, it will not work, and it will not tell you so: the panel is chosen at compile time, there is no runtime detection, and the symptom is a dark screen rather than an error. Repointing a published filename is normally exactly the wrong thing to do. A digest somebody kept starts failing for a reason they cannot reconstruct. It is survivable here because every published digest stays in this file, no earlier release is rewritten, and the alternative was carrying a -35 suffix that no longer distinguishes anything. * 0.4.13 IS THE LAST RELEASE WITH A 2.8" IMAGE, and it is pinned. It will not be pruned when newer releases supersede it, the way 0.2.1 is not pruned: each is the last release of a state this project has left, and the digest in this file is the only remaining evidence of what that state produced. 0.2.1 is a licence boundary, the last release containing RF24 (GPL-2.0-only). 0.4.13 is a hardware boundary. * The 2.8" ESP32-2432S028R is dropped. It was built every release and never booted by anyone here, so what shipped for it was an image that compiled rather than one that worked, which is not support in any sense worth claiming. The cost of keeping it was not the build. It was that every pin, every dimension and every table in the documentation had two answers, and nearly every documentation error found in the last week traced back to that: CC1101_CS colliding with the backlight on GPIO 21, NRF24 CSN being 4 or 25 depending on which board was meant, the serial header called P1 on one and P5 on the other, CN1 credited with a GPIO 27 it does not carry. One panel deletes that class of mistake instead of fixing instances of it. The image is unchanged by the removal. Built before and after and compared byte for byte: 1,784,160 bytes both times, and exactly 64 bytes differ, in two 32-byte runs -- the ELF SHA-256 in the app descriptor at 0xB0 and esptool's image hash at the tail, both of which hash the image and so cannot match when anything about the ELF moves. Every executable byte is identical. The enclosure lost its PANEL option too. Both parts re-rendered and compared by facet set rather than by bytes, because OpenSCAD is not deterministic and renders the same file to different bytes at the same size: lid 4636 facets, base 2944, identical sets. The 2.8" STLs stay published and can no longer be regenerated; the base is no loss, since base-28 and base-35 are the same solid. * The Fast Pair probe transmits and nothing was stopping it. It writes eighty bytes to a characteristic, and Stealth Mode did not refuse it: the nineteen gated features are all menu entries with a setup() to gate, and the probe is a keypress inside a scanner that is otherwise passive, so it fell between them. Stealth was device-wide everywhere except the one feature whose own header opens by stating what it transmits. The gate is inside FastPairProbe::run() rather than at the button, so it sits on the function that does the transmitting and a second caller cannot forget it. It returns Outcome::Refused instead of drawing the refusal screen, because that screen exits the whole feature and the scan around it is passive and has no reason to stop. The confirm screen says so before you confirm. * BLE Scanner reads a chosen device's Device Information Service. Info in the detail view connects to the one device on screen, reads GATT 0x180A, and shows manufacturer, model, serial, and the firmware, hardware and software revision strings. An advertisement says what a device is. It never says which build it is running, and which build is the question underneath whether something has been patched. One device, chosen by the operator; nothing scans and nothing sweeps. Reads only: no writes, no pairing, no authentication, nothing outside 0x180A. Refused under Stealth Mode, checked inside the read rather than at the caller, because a GATT connection transmits. "Connected but has no 0x180A" is reported separately from "would not connect", because one says it talked and had nothing to say and the other says it will not talk. * BLE Scanner's detail view decodes the appearance field to a category and subcategory instead of a number, and names service UUIDs where the SIG assigned them. * Surveillance: 108 signatures over nine kinds, up from 99 over eight. 128-bit service UUIDs, which the 16-bit field cannot hold. A vendor-generated 128-bit service is much better evidence than a 16-bit one, because the 16-bit space is allocated by the SIG and shared. Two of the new kinds are not surveillance gear and are there because knowing they are nearby is worth something on its own. Meshtastic nodes advertise their own 128-bit service and get a kind of their own rather than being filed under something they are not. Pwnagotchi beacons from a fixed de:ad:be:ef:de:ad with its stats in the payload, graded likely rather than strong: that address is a convention rather than an allocation, so anything can wear it. Flipper Zero by its advertised service 0x3080-0x3083, read out of Flipper's own serial profile rather than a community detector -- those are one service on differently coloured hardware, not four services. Hak5 LAA defaults, the O.MG cable's default SSID, and Alfa's registered block 00:C0:CA, which is the radio in a Pineapple. Two OUI attributions were checked against the IEEE registry and rejected: 00:13:EF is Kingjon and A4:2B:B0 is TP-LINK, not Atheros or Qualcomm. * Beacon Spammer reads its network list from /ssids.txt on the card, and falls back to the list built into the firmware, which is identical in every copy. The screen says which list it loaded. Up to 64 names of 32 characters; # starts a comment. * The sub-GHz and BLE jammer channel tables dropped two entries that were below the band. Channels 0 and 1 sit under Bluetooth's 2402 MHz start. * wifi.cpp carried two literal NUL bytes where a \0 escape was meant, which is why git had been treating the file as binary and refusing to diff it. * tools/build.sh reports linker errors rather than only the fact that there was one. Its failure grep matched compiler diagnostics and "collect2: error: ld returned", so a section overflow printed nothing useful. * check_stealth.py grew a table for gates that live inside a function and report a status rather than drawing the refusal screen, which is the shape both DeviceInfo::read and FastPairProbe::run use. It checks the order too: a gate after the connection is open is not a gate. * docs/pueo/user-guide.md is new: operating the device, assuming one that is assembled and working. * Two documentation errors that were not simplifications. build-guide.md said the RGB LED's red channel is GPIO 22 on this board, so the LED would flicker with sub-GHz traffic rather than go dark; that is lcdwiki's E32R35T, and on this board, measured 2026-09-23, GPIO 4 is red, 16 blue, 17 green and 22 nothing. The website said GPIO 34 is a battery divider; it is a CdS light sensor, corrected in the firmware docs and not on the site until now. 0.4.13 - 2026-09-25 ------------------- Another one with no firmware source in it. The image differs from 0.4.12 by its version string and nothing else. * The lid logo has been hanging off the lid. LOGO_Y centred a 30 mm owl in the chin of the old 170 mm lid; the case has been 142 for a while, that lid ends at y = -71, and the owl ran to -80.5. Nine and a half millimetres of it was past the bottom edge and 69% of the deboss survived. check_logo_scale.py had been failing on exactly this and nobody had chased it, because LOGO is off by default and no printed part ever showed it. * Both numbers follow the lid now. The band is bounded by where the outer face still has its full 2.5 mm, which is the rebate edge rather than the window edge: above the rebate the skin thins to SCREEN_LIP, and a 0.6 cut into 1.0 leaves 0.4. panel band LOGO_H LOGO_Y thinnest stroke 3.5" -68.00 .. -42.68 25 -55.3 0.42 mm 2.8" -68.00 .. -48.25 19 -58.1 0.32 mm * The 2.8" cannot really carry this artwork: 0.32 mm is under one extrusion on a 0.4 nozzle. It renders anyway rather than being dropped without a word, and the model echoes a warning when you ask for it. * check_logo_scale.py runs both panels instead of the default, and tests clipping by name rather than leaving the next person to infer it from a volume that came up short. * Published STLs are unaffected. They render with LOGO false, and all four parts compare identical by sorted facet sets before and after. Not in the archive but worth recording: the README claimed the 3.5" brings its SPI bus out on a connector. It does not, neither panel does, and six of the ten signals are soldered to the ESP-WROOM-32's own castellations either way. Its hardware table also gave the 2.8"'s chip selects while calling the 3.5" the reference board. 0.4.12 - 2026-09-25 ------------------- A documentation release. No firmware source changed between 0.4.11 and this, and the only difference in the image is the version string it reports. It exists because a published archive is the only copy of the docs most people will read, and 0.4.11's carries text that has since been removed. The alternative was editing 0.4.11 in place, which would have broken every digest on the download page and the reproducible-build claim with them. Cutting a new one is the honest way to change what is published. * Third-party project references removed from the docs. Each was carrying part of an argument rather than sitting there as a citation, so each passage was rewritten rather than cut: the EAPOL rationale now rests on ESP32-DIV having no EAPOL anywhere in its tree, and the sub-GHz PA section on this board's own pin budget. * The changelog entry for the PA decision said GPIO 4 was "the NRF24's CSN here". That stopped being true for the reference board when CSN moved to 25 on the 3.5". It is the RGB LED's red channel. * BAT1's polarity is recorded as measured rather than read: the pin marked BAT- is the one that beeps against CN1's ground. The FM5324GA has no reverse protection on its cell input, so the cost of guessing is the charger. * The enclosure model gained a USB opening where the socket actually is, through the lid's end wall rather than the base's long side, and the flag that used to be EXT_POWER is EXT_CHARGER. The model is not shipped in this archive and never has been; this is here because the build guide and the PCB notes describe it. 0.4.11 - 2026-09-25 ------------------- The main menu loses More, and four features come out from behind it. * More held two peripheral radios and two passive detectors, which is not one category, under a label that says nothing. Surveillance and the drone detector are what this fork is for and they were two taps in, while Beacon Spammer was one. WiFi Bluetooth WiFi Bluetooth NRF24 SubGHz to NRF24 SubGHz More Tools Detect RFID/NFC Settings About GPS System * The two slots come from Settings and About, now rows in System with the old Tools menu. Both were whole tiles of an eight tile grid, at the same visual weight as the radios, and nobody opens either mid-session. * 2.4GHz is NRF24. It opens the Nordic module, and naming a tile after a band that the two tiles above it also use sent people to the wrong screen looking for the BLE jammer. * The Detect handler goes from 304 lines to 142. It carried a grid mode and a list mode, three copies of a back path and two copies of every feature launch, all so one tile could hold two menus inside it. Things that were transmitting, or listening, when they should not have been. * The SubGHz Jammer did not stop when you left it. jammingRunning was only ever cleared by the stop button, so backing out left the CC1101 in TX and, in continuous mode, TX_PIN high, with nothing on screen saying so. * The deauther's scratch AP is hidden and open now. It brought up a visible WPA2 network named ESP32-DIV accepting four clients, with a passphrase hardcoded in the source, so its key was published to anyone who read the repository. startListening() in the same file already did the right thing and these two sites predated it. * The BLE Spoofer and the SubGHz Jammer seeded random() from analogRead(0). An unconnected ADC pin is not entropy: it can sit at a stable value, and then the same sequence of fake device names comes out on every boot, which is a fingerprint in a feature whose point is not having one. Both seed from esp_random() now. Opening a sub-GHz feature with no radio fitted locked the board up. * cc1101Present() drove CS low and called a LOW reading on MISO present. A floating input satisfies that about as often as not, and what it handed you to was ELECHOUSE_CC1101::Init(), whose first act is an unbounded while(digitalRead(MISO_PIN)). A guard with a timeout was protecting a driver without one. * It reads PARTNUM and VERSION over SPI now and checks the answer is one a CC1101 would give, twice, because a floating line can look like a version once. Covers all seven call sites. NMEA sentences are checked before anything parses them. * stripChecksum() truncated at the '*' and threw the two hex digits away. A corrupted RMC is still a well formed RMC, with a plausible latitude and timestamp, and the wardriver writes it to the card as a real fix. * A sentence with no checksum is rejected rather than accepted, because truncation is the corruption most likely here and a sentence cut before its '*' would otherwise pass with its remaining fields intact and wrong. New: * AP Tracker, on the WiFi menu. Pick an access point, park on its channel and walk it down by signal. Beacons arrive about ten times a second; a scan sweep samples once every 1.7 s at best and transmits to do it. * SignalGauge, the needle extracted out of TrackerHunt so Hunt and AP Tracker share one gauge rather than two copies of 261 lines. * docs/pueo/wiring.svg. Ten signals were a pin map in one file, an ordering in a second and a connector inventory in a third, and nowhere a picture. Also: * The satellite scanner asked heap_caps for its panel instead of probing down blindly from the full height in steps of 18, and its floor drops from 200 rows to 96. On the 3.5" that floor was 64,000 bytes of contiguous heap, which after the radio stacks are up often is not there. * A feature that is unavailable on this board can be dismissed again. The notification was drawn and never hidden, so it sat over a live submenu and taps went to rows that could no longer be seen. * WiFi and Bluetooth pages rebalance from 8 + 3 to 6 + 6 and 6 + 5. That shipped two bugs of its own, both fixed here: a branch renumbered without its body still pinned the old index, so ARP Scanner exited into Hidden SSID Revealer and WPS fell into Captive Portal. check_menu_dispatch.py now checks that a branch agrees with itself. 0.4.10 - 2026-09-23 ------------------- GPIO 4 is the RGB LED's red channel. It was never an amplifier's enable. * Measured, rather than read off a datasheet for a board nobody here owns. Each candidate driven LOW in turn -- the LED is common anode, so a pin sinks its own channel and writing HIGH does nothing -- and watched: GPIO 4 red an LED channel, not an amp enable GPIO 16 blue the tree said green GPIO 17 green the tree said blue GPIO 22 nothing not an LED pin on this board at all All four entries for the 3.5" were lcdwiki's E32R35T, which puts RGB red on 22 and an audio amplifier's enable on 4. The reference board is Sunton's and agrees with its own 2.8" instead. * AMP_ENABLE_PIN is gone. What it did on this hardware was drive the red LED low for the length of every beep -- harmless, and also the tell: an LED blinking in time with the buzzer, on a pin the tree believed was an amplifier enable. BUZZER_PIN stays on 26. Consequence worth stating: the 3.5" image is still pin-compatible with lcdwiki's 4.0" E32R40T, except that its amplifier would now never be enabled. That assert was correct for a board this is not built for and was blinking an LED on the one it is. * Which colour sits on 16 and which on 17 changes nothing, since both are given up whole. The other two matter. GPIO 22 is ordinary free GPIO here, so CC1101 GDO0 sitting on it is uncontroversial rather than a spent LED. And GPIO 4 was never the hazard board_pueo.h describes at length: NRF24 CSN was moved to 25 on the 3.5" to avoid keying an amplifier that is not there. It stays on 25 -- free, and the map is published -- but the reason in the commit was not the reason in the hardware. * check_pinmap.py's 3.5" table was wrong on all four pins and is now what the board did. GPIO 26, 34 and 36 are carried over as [verify]: they are assumed to follow Sunton's 2.8" too, which is now a demonstrated pattern rather than a guess, and still an assumption. * Do not solder to the microSD slot. The build guide sent people to the card slot's own pins for SCK, MOSI and MISO and called those three joints the whole difficulty of the build, because the slot still has to work afterwards. Those lines reach the slot through the ESP32, so the module's own castellated pads are the same three nets with none of that risk -- 1.27 mm pitch with the board pad clear of the module body, against eight spring-contact terminations hard up against a grounded shell. Five of the six joints land on one edge of it. With the pad map comes the way to lose a board with it. IO5 sits directly between IO18 and IO17 and is the SD chip select: bridge it and you have broken the card slot from the one direction this change exists to avoid. SD1, SD0 and CLK at the bottom of the same edge are the module's internal flash. IO0 is the boot strap. So the guide says to identify by continuity against the slot pin rather than by counting castellations -- the slot is how you find the pad without soldering to it. * The build guide now says how to make the joint, not only which pad. Flux first; tin the wire off the board and trim the tinned end short; use the factory fillet already on the pad rather than feeding solder off the roll, which is how the bridging blob happens; a small chisel tip rather than the finest in the drawer, because no heat into the joint means dwelling and dwell time is what lifts pads; tape the wire down first; strain relief within 5 mm, because the joint that survives the bench is the one that fails in a bag. 0.4.9 - 2026-09-23 ------------------ The Brightness setting has never worked on the 3.5" panel. It does now. * BACKLIGHT_PIN was GPIO 21 on both panels, and on the 3.5" the backlight is GPIO 27. The sketch has been attaching its PWM channel to a pad with nothing on the end of it for the whole life of that port. Everything around it was already right and said so. User_Setup cyd.h has branched TFT_BL per panel -- 21 and 27 -- since the port existed, and TFT_eSPI drives that pin HIGH at begin(), which is why the screen lit and nobody noticed. board_pueo.h argued in a comment that 27 was the backlight on this panel, and moved CC1101_CS onto 21 on exactly that basis. It never said it in code, so shared.h kept the BOARD_CYD default. Which left CC1101_CS and BACKLIGHT_PIN both on GPIO 21 in every 3.5" build. That half was silent too: nothing is soldered to CC1101 yet, so the chip 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 rather than a pin-map one. * check_pinmap.py printed "no collisions" throughout. It reads TFT_BL out of User_Setup -- that is how it knows which pin the display needs, and hardware.md has a paragraph claiming this makes exactly this mistake fail the build. It never compared that pin against the one the sketch drives. It does now, per panel. * The 3.5" wiring table in build-guide.md described a different board. It promised four JST breakouts -- SPI, I2C, UART, IO35/IO39 -- with the whole VSPI bus and both chip selects on connectors, for three solder joints against the 2.8"'s six. That is lcdwiki's E32R35T. The board this is built on is Sunton's ESP32-3248S035R, silkscreened ESP32-035, and its breakout is P3 (GND IO35 IO22 IO21) and CN1 (GND IO22 IO21 3.3V) -- the same two headers Sunton's 2.8" has, and no SPI breakout at all. Six joints, not three, on either panel: the VSPI bus off the microSD slot's own pins, NRF24's chip select, and two RGB LED pads. What the 3.5" does give you is all three CC1101 lines on P3 at once, which is the pay-off from putting CC1101_CS on 21 there. The panel identification was never wrong -- ESP32-035 is the short form of the part board_pueo.h has named since the port. Only the connectors were, and the guide's own [verify] note recorded that its 3.5" column came off a render rather than a board. It also disagreed with hardware.md, which had GDO0 on P3 and was right. Two files in one directory contradicting each other about where a wire goes, with nothing to notice, because prose is not checked the way the pin map is. * One 2.8" entry is flagged rather than corrected. That column has CC1101 CS reaching CN1, and the CN1 on the board in hand carries GND IO22 IO21 3.3V with no IO27 on it. If the panels share that pinout the 2.8" build needs a seventh joint. Nobody has had one under a meter. 0.4.8 - 2026-09-22 ------------------ The screen Fast Pair is for, and the two checks that found it. * Fast Pair's device list drew three lines to a row at y, y + 11 and y + 21, each clearing an 11 px band, inside a row that is 54 px on the 3.5". Those are font-1 numbers under a font-2 glyph: the lines landed on top of each other and every redraw left the bottom half of the old text behind. It is the main screen of the feature, and it shipped that way in 0.4.7. 0.4.7 moved the list screens to font 2 and fixed three screens that the move broke -- Fast Pair's confirmation page, its result page, and Hunt's gauge readout. This was the fourth, and the reason it was missed is that all four were found by looking rather than by checking. The file's own comment said "three lines to a row, same shape as Surveillance" while it carried only the first of the three numbers that shape is made of. It has kLine2 and kLine3 now, the same values Spotter.cpp uses. The 2.8" third line stays at 21 rather than 2 * 11: 22 plus a 10 px band does not fit a 30 px row. * Hunt's picker had it too, in the state you see before anything is found. "listening..." and the list of tracker families were 14 px apart under a 16 px font, so they touched. * Hunt's gauge was reading "7 dBm best -45 143 se". TFT_eSPI does not clip and does not wrap: a line wider than the panel is drawn anyway, off both edges, and what survives is the middle. That line is 31 characters and 0.4.7 doubled its glyphs, which made it 372 px on a 320 px screen. The LOST screen had it worse -- "changed address -- Exit and re-pick" is 34 characters, 408 px. Both are rewrapped onto lines that fit. It took making the renderer honest to see either of them: the firmware was perfectly happy, and the screenshot on this site was drawn at the old size and looked right. * tools/check_text_pitch.py. Inside a function that sets PUEO_BODY_FONT, a vertical measurement cannot be a literal -- no literal is right for both a 16 px font and an 8 px one. It flags y += N, a trailing + N in a uiShowLine or drawString y argument, and a clearing band under 16 px. An absolute position is left alone, and so is whitespace under 10 px, which is below the smaller panel's own line pitch. Ten flagged on the first run, nine of them not known about. Two things it has to do besides find them. Commas and parentheses inside a string are not punctuation, and counting them as such put a fragment of a caption where a y argument belonged and skipped the line entirely -- that hid one of the ten. And every place the font is set has to land inside a function the scanner recognised, or a whole screen has left its scope and it reports green over nothing. * tools/check_text_fits.py. Font 1 advances 6 px per character whatever the glyph, so a centred line's width is 6 * size * length and the only work is knowing the length -- half of these are snprintf'd, so the format is what exists in the source and the widest thing it can produce is what has to fit. The assumed widths for %d and the rest are printed with any failure, because a check whose assumptions are invisible is one nobody can argue with on the day it is wrong. It is deliberately not bounding %d by the type. A plain int is eleven characters and nothing on these screens is ever eleven characters; the grid-label check already learned once that measuring with the wrong assumption sends somebody off to shorten text that fits. * check_render_sync.py now covers the list screens, which its own commit message said it did not. Two drifts, immediately. The renderer drew the third line of a row at twice the line pitch, which is right on the 3.5" and a pixel low on the 2.8". And the Hunt gauge's distance readout follows PUEO_BODY_SIZE on the board and was hard-coded to size 1 in the renderer -- so the screenshot on this site showed that line at half the height the device draws it. It also had a hole: #define NAME was anchored to end of line, so the three body constants that carry an explanatory comment were invisible to it. A check cannot compare what it cannot see, and it said nothing while not looking. 21 comparisons to 46. Some of the new ones are not comparisons at all but things the numbers have to be on their own -- a band at least as tall as the glyph it clears, a pitch at least as tall as the band, a third line whose text ends inside its row. 0.4.7 - 2026-09-21 ------------------ Text you can read on the 3.5" panel, and two checks that would have caught the ways this went wrong. * The list screens use font 2 on the 3.5". That panel is the denser of the two, not the looser: 577 px across a 3.5 inch diagonal is 165 ppi against the 2.8"'s 143, so the same glyph in the same pixels comes out 13% SMALLER on the bigger screen. Font 1 at size 1 was a 1.23 mm cap height there -- about three and a half point. What the extra pixels had bought was rows rather than legibility. Surveillance showed thirteen on the 3.5" against eight on the 2.8", all of it text you cannot read at arm's length. It shows eight now, at 2.47 mm, which is what the other panel shows anyway. Surveillance, Hunt and Fast Pair follow PUEO_BODY_FONT, PUEO_BODY_LINE, PUEO_BODY_GAP and PUEO_BODY_SIZE in shared.h -- the same panel-conditional shape PUEO_TILE_ICON and PUEO_STATUS_TALL already had. The drone detector deliberately does not: four lines per aircraft at this size takes it from ten rows to five against a six-aircraft table, and there the count is the information. * That change broke three screens on its way in, and all three were the same mistake: a bigger font with the old line pitch left under it. Fast Pair's confirmation page, its result page and Hunt's gauge readout all drew 16 px glyphs at an 11 px advance, or left one line at size 1 beside a picker at font 2. They were found one at a time, by three different means -- reading the file, reading further, and somebody looking at the board. The last one is the reason the fix is now every hard-coded advance in FastPairScan.cpp rather than the lines that were reported. * tools/check_render_sync.py. render_screens.py replays the firmware's drawing calls at the firmware's own coordinates, which is what makes the screenshots on the website evidence rather than illustration -- and it cannot include the firmware's headers, so it carries a copy. Nothing said when the copy drifted. 21 numbers compared across both panels; ten mutants, half of them mutating the renderer rather than the firmware, because drift has two directions. * check_menu_tables.py measures grid labels against their tiles. TFT_eSPI does not clip, so a label wider than its tile draws over its neighbour, and nothing was checking. "Drone Detector" is 95 px in the 2.8" panel's 100 px tile: one character of headroom on a panel nobody has booted. Measured against TFT_eSPI's own font table rather than an average character width, which mattered immediately -- at 8 px/char the same two labels measured 112 and 96 px and both looked like overflows. Both fit. An estimate would have had somebody shortening labels that were fine. * make_release.sh --publish-only. Every release until now was built twice, once to verify and once with --force to publish, and because the script is inside its own archive the second zip has a different digest from the one that was verified. The verification then described a file nobody would download, and had to be redone by hand against the published one. * Two back rows were naming the wrong destination. RFID and GPS both read "Back to Main Menu" and both return to More, one level up. The wording looked inconsistent; the destination was wrong. * The skimmer detector's mode line shouts two of its eight modes. WIFI and ZIGBEE were cased as their enum names beside Bluetooth and Video. The WiGLE exporter's "WIFI" is left alone -- that one is the file format. 0.4.6 - 2026-09-21 ------------------ Two corrections. One to the enclosure, and one to a line in 0.4.5's own notes that was already wrong when it was written. * The 0.4.5 entry says the SD slot in the 3.5" lid has not been printed. It was printed within the hour of that cut, would not take a card, and is fixed. The entry is left standing rather than quietly edited: a published release's notes are a record of what was believed at the cut, and revising them afterwards is how a changelog stops being evidence of anything. This is the correction, in the place corrections go -- the same way 0.4.3 carried the correction to 0.4.0. * The lid screw boss that was standing in front of the slot. The middle of the three lid screws on that side sat at y = 5, which is nowhere near the board -- it is outboard of the PCB entirely -- but squarely across the 15 mm of gap a card crosses between the wall and the socket mouth. 8 mm of clear span for an 11 mm card. It took two goes. The first moved it DOWN, to sit just below the slot, on the argument that a screw beside the opening stiffens the edge a 16 mm hole weakens and that the spans come out evener -- 77.5 and 71.5 against 103 and 46. Both true; neither survived the part. Down is pinched between the slot and the battery pocket, so it only fits with the radius cut, and cutting the radius left the cylinder exactly tangent to the wall rather than overlapping it, which is not a 2-manifold, so it needed moving outboard as well. Three adjustments holding each other up. And it still was not clean: the slot is cut with a prism longer than the wall is thick, so it bit a 3 x 0.9 x 4 mm notch out of the corner of the boss it had just been necessary to narrow. Up needs none of it. Full radius, original x, 2.9 mm clear of the slot and 5.4 mm clear of the card, nothing tangent, no notch. The uneven spans are the cost and the right one to pay: 103 mm between screws is a stiffness argument about a lid with 2.5 mm walls, and what the alternative bought was three interacting fudges around a part that has to come off a printer. A base printed before this needs reprinting. The bosses are shared geometry, so the base and the 2.8" lid moved with the 3.5" one, and the middle pair of holes will not line up. All four STLs are regenerated. * Both full-screen alerts place mark-plus-text as one block rather than centring the 200 px mark and letting four lines of text fall off the bottom of it. Two thirds of the leftover height above, one third below, so the block sits low and deliberate -- 133 px and 67 on the 3.5", against 110 and 90. Derived rather than nudged, because the obvious fixed offset does not survive the other panel: the block is 280 px tall, the 3.5" has 200 px of slack to place it in and the 2.8" has 40. A constant tuned on one runs off the bottom of the other, silently, on the panel nobody has booted. tools/render_screens.py replays these coordinates and carries the same arithmetic, each side naming the other. Two copies of one number, which is the shape of most of this repository's worst bugs -- the renderer exists precisely so it does not include the firmware's headers, and a check comparing the two is worth having the next time this moves. ## No functional change Nothing in the radio paths, the parsers or the settings moved in this release. The firmware differs from 0.4.5 only in where two alert screens draw their contents. 0.4.5 - 2026-09-21 ------------------ The drone detector gets a mark of its own and an alert when something turns up, and everything added over the last three releases has now been raised on a board rather than argued for. * The drone detector opened on Hunt's owl, and had since 0.3.9. A borrowed mark on a feature that is not Hunt: nothing would ever fail on it, and it told anyone looking at the screen the wrong thing about what they had opened. It has its own now -- an owl holding position with its sight-lines down on a quadcopter, which is what the feature does. Remote ID is a broadcast the aircraft is already making, so the owl is not hunting it. The rotors are open rings rather than filled discs. A filled circle at 200 px and one bit per pixel is a blob, and four blobs are not a quadcopter. * An alert when an aircraft appears, built from the same parts as Surveillance's dwell alert: full-screen mark, two rising notes, then a fade rather than a cut, because drawBitmap paints only the set bits and redrawing the mark in a dimmer colour walks it out without touching anything else. Shown once and gone -- a banner that stayed would cover the list it is announcing. Edge-triggered on the table going from empty to occupied and re-armed when it empties, rather than once per aircraft. A busy sky would strobe, and the thing worth interrupting somebody for is that there is anything up there at all. Fired after the queue drain rather than inside ingest(), because a pack often carries Basic ID and Location in the same transmission and alerting mid-drain would report "ID not broadcast yet" for an aircraft whose ID was in the very next message. Height and speed, never distance. Remote ID says where the aircraft is, not how far away it is, and this device has no position of its own to subtract. The last line is gated the way the dwell alert gates its Hunt suggestion: the System message carries the operator's own position, so that is claimed when it has been received and denied when it has not, rather than left as a gap that reads as "no". * tools/render_screens.py draws the mark and both alert states, and reads the beacon's BeaconArt.h beside icon.h rather than carrying a copy of either mark. ## Raised on hardware, not on paper Everything added since 0.4.2 has now run on a board: the dwell alarm, with its Hunt suggestion gated correctly Stealth Mode -- every transmitter refused and named itself, every listener carried on the settings round trip -- "none saved yet" becoming "loaded from SD" across a power cycle, on a path that had never once completed since the fork began per-feature SD logging, writing to a card the drone alert, against the beacon transmitting Remote ID The stealth result is a check of the gates rather than of the radios: it says the firmware declines to call its transmit paths, not that anything watched the antenna go quiet. The gates are enumerated by a host check, so the list cannot shrink without failing it. ## Still not The SD slot in the 3.5" lid has not been printed. Both numbers placing it come off QDtech's outline drawing and the opening was checked in the STL rather than by eye, but nobody has held the part. The 2.8" image has still never been booted -- and it is now the panel that several of this release's predecessors were designed around without being seen on: the scrolling settings list, the six screens that were drawing to its height on the taller panel, and the visible-row count derived from it. 0.4.4 - 2026-09-21 ------------------ The bench beacon is a download now, and one number on the enclosure stopped being a guess. * pueo-0.4.4-beacon-35-merged.bin. Every release until this one built the detector only, so the beacon shipped as source and building it meant the 1 GB toolchain -- which is the same reason the detector's merged image is offered at all. It carries the role in its name rather than only a panel, because it is the one artefact here that transmits and "beacon" has to be in the filename of anything somebody might flash by reaching for the wrong line. 3.5" only: that is the panel the second board is, and an unbooted transmitter is a worse thing to publish than an unbooted receiver, which fails by finding nothing. None of the restraint in it depends on reading anything first. Every identifier says PUEO-TEST, every address carries DEAD in its device half, transmit power sits at the floor, the splash counts down five seconds under THIS BOARD TRANSMITS in red, and it stops itself after fifteen minutes. The deploy's prune learned the new filename in the same change. A pattern it does not know is an artefact that accumulates on the server forever, unlinked and still reachable by direct URL -- and of all the files here, a stale transmitter is the wrong one to leave lying about. Its check pins the pattern count on purpose, so adding one fails the check and sends you to redo the arithmetic by hand; it did. * The SD slot in the 3.5" lid is dimensioned rather than estimated. 0.4.3 placed it off a photograph. QDtech's own outline sheet dimensions the back, in the chain style the rest of that file already trusts: 17.87 + 14.54 + 25.48 = 57.89 socket to the bottom edge of the PCB 101.50 - 57.89 = 43.61 from the top edge 101.50/2 - 43.61 = 7.14 above the board centre against the 6.7 that came off the photo. Both land inside the opening, which is what cutting it oversize buys, but one of them is sourced. The side is the part that inverts, and worth stating for that reason: the socket is on the right of a Back view, and a back view mirrors, so it is the left edge looking at the screen. The I2C, SPI and SPEAKER headers sit on the other side in both the drawing and a photograph, which is the check that the two views agree about handedness rather than one of them being flipped. Verified in the STL rather than by eye: the opening lands 16 x 4 on the -X wall, centred on the socket and on the PCB's back face. The published lid is regenerated. * The beacon's two screens are drawn by tools/render_screens.py, which reads BeaconArt.h beside icon.h rather than carrying a copy of the mark -- the rule Emit.cpp already follows for the signatures. * tools/deploy-pueo.sh says when --prune suppressed the push. It always has: pruning is never part of a push, so that a prune cannot ride along with a routine deploy. But `--live --prune` then ends with "done" having transferred nothing, and the digest verify passes, because it checks files that are on the server and cannot see the ones that never left. 0.4.3 - 2026-09-21 ------------------ Settings that survive a reboot, which they have not since the fork, and two switches that change what the device does with its radios and its card. * settingsLoad() was never called on this board. setup() had it inside `#if BOARD_HAS_ESP32S3`; a CYD takes the #else, which applied touch defaults and printed "settings defaults (v1, SD deferred)". Nothing else in the tree called it. Deferred meant never: brightness, theme, accent and auto scan were written by Save and read back by nobody, on every boot since 0.1.0. It was deferred for a real reason -- a boot-time SD.begin() right after tft.init() was rebooting the device -- and the cause of that, gpio_reset_pin() on the shared SPI pins, is compiled out on this board now. The load sits where the S3 branch has always had it, with board touch defaults applied first, so a card that will not mount leaves the device exactly where it used to be. The boot line says which of four things happened: loaded, none saved yet, unreadable, or no card. It said "loaded from SD" for a card with no settings.json on it, which is the reassuring half of a message that had read nothing. * Stealth Mode. Receive only. Nineteen features whose job is to transmit refuse to start, name themselves and say where the switch is; everything that listens keeps listening. The part worth reading is what counts as listening. A WiFi scan is ACTIVE unless its third argument says otherwise, and all eight calls in this tree left it false -- so "scan for networks" was putting a probe request carrying this device's MAC on every channel, the wardriver and the background scanner included. A BLE scan is active by default too, and four calls asked for it; an active scan answers every advertiser it hears. Those are made passive under stealth rather than blocked. The wardriver's WiGLE upload, which joins an access point, is refused on its own while the log keeps being written. RFID is one gate for the whole menu: a PN532 reads a card by energising a 13.56 MHz field and waiting for an answer, so "read" transmits exactly as much as "clone". It is not a hardware interlock. The stacks stay up because the receivers need them, and an initialised stack is one call from transmitting. The claim is worth the list in tools/check_stealth.py, whose last rule is the one that keeps it honest: every esp_wifi_80211_tx() in the tree has to sit in a namespace whose setup is gated. * Log to SD, per feature. Settings > SD Logging holds a master and one switch each for Surveillance, the Jam Detector, the Packet Monitor, the ESB Sniffer and the Wardriver. A feature logs when both say yes, and each says so in its own language when it cannot -- REC reports "log off" rather than "no SD" at a card sitting in the slot. It does not cover files asked for by name: a saved .sub, a DuckyScript, settings.json itself. Those are the point of pressing the button rather than a side effect of running the app. * A boot password, in Settings > Boot Lock. Asked for before the menu. Salted SHA-256, stretched 20000 rounds, in NVS rather than on the card -- the card comes out, and the card was not being read at boot anyway. It stops someone who picks the device up, and nothing else. The firmware reflashes in twenty seconds and esptool reads the flash out without asking this code anything; a CYD has no secure boot and no flash encryption. BootLock.h says so in the same words. * The dwell alarm, which 0.4.2's duration column implied and did not do. The first row to pass ten minutes brings up a full-screen mark, the device and how long, and two rising notes. Shown once and faded, not held: this is a passive monitoring screen and a banner that stayed would cover the list. The Hunt suggestion is gated to BLE trackers, because Hunt lists nothing else and offering it for a plate reader is sending somebody to an empty picker. "May be travelling with you" is the strongest claim the hardware supports: Pueo has no position of its own and cannot tell a tracker moving with you from a beacon you have been sitting beside. The 3.5" can make the noise -- GPIO 26 into the onboard amplifier, GPIO 4 to enable it, active low. Two things keep it quiet and neither is a fault: the 2.8" has no pin free for it, and the 3.5" ships with no speaker fitted. Upstream's BUZZER_PIN guard was `#ifdef`, which is true of the -1 "no buzzer" sentinel, so every board has been calling ledcAttachPin(-1, 7). * The BLE scan was starving the WiFi one. Surveillance and Drones both listen on two radios and there is one radio; a BLE scan whose window equals its interval asks for it continuously. Measured with the bench beacon, which counts what it sends: 495 ALPR probe requests transmitted, first one heard after about five minutes. Now immediate. * Six more screens were drawing to a 320 px height on a 480 px panel. Two Bluetooth log views showed 26 lines where 40 fit; wifi.cpp's content bottom fell back to 320, clipping every screen without a touch nav bar. The sweep that fixed SCREEN_WIDTH had missed SCREEN_HEIGHT on adjacent lines, because its checks named one and not the other. * The Drones list came back blank on re-entry. setup() cleared the craft table and the screen and never told the line cache, so every line matched what the cache believed was already drawn. forgetDrawn() was written for it and never called. * The settings screen scrolls, so the row count is no longer capped by the shorter panel, and the NeoPixel switch is gone -- it saved, reloaded and controlled nothing, on any board, because there is no NeoPixel driver in the tree. * The lid has a slot for the SD card. Fitting one meant four screws and lifting the screen out. Its position came off the outline drawing and a photograph rather than calipers, so the opening is cut oversize and the two numbers that place it are one line each -- see the header of docs/pueo/pueo-enclosure.scad. The published STL is unchanged until they are measured. * tools/build.sh upload compiles first. It uploaded whatever was last in the build directory, which twice in one session was a build from before the edit being tested: four verified hashes and a board running the old firmware. * Three check scripts were missing from the source archive -- written, relied on, and left out of every release from 0.3.x to 0.4.2, so those archives ship a tree that cannot run its own checks. The cut now fails when a tools/check_*.py is not in the include list. * Correction to 0.4.0. That entry says uiShowLine() in utils.h "is now the only copy" of the cached-line painter. It was not: Surveillance had its own, and Hunt still has a centred variant of it. Surveillance was converted in this release; Hunt's remains, documented as a variant rather than a duplicate. Two copies, not four, and not one. 0.4.2 - 2026-09-21 ------------------ A partition table, which is 1.12 MB the sketch did not have. * huge_app instead of min_spiffs. min_spiffs splits the flash into two 1.88 MB app slots so an over-the-air update can be written into whichever one is not running. The sketch was at 89% of one of them, which is the point at which every new feature turns into an argument about space. huge_app gives a single 3.00 MB slot, and the same sketch sits at 55%: free space goes from about 213 KB to 1.33 MB. The spare slot was buying exactly one thing -- Tools > Update Firmware, which copies a .bin off the SD card into it. That came from upstream, is not mentioned anywhere in this changelog because nobody here has ever run it, and duplicates what a USB cable does in twenty seconds. It is not removed. Removing the menu entry means renumbering the three below it and their self-pinning assignments, which is the exact shape of bug that nearly shipped in 0.4.0, for about 15 KB against a 1.12 MB gain. It checks for a second slot and says why it cannot run, which is better than Update.begin() failing with a number. * Nothing else moves, and that was checked rather than assumed. NVS keeps its offset in both tables, so settings survive; the merged image rewrites the partition table at 0x8000, so flashing it is the whole migration; and the image was flashed to a board, which booted, printed its banner and loaded its settings. The merged binary was then read back at 0x8000 to confirm it carries a 3.00 MB app0. If you are updating from 0.4.1 or earlier, flash the merged image as usual. There is nothing extra to do. 0.4.1 - 2026-09-21 ------------------ Every detector in this firmware has now been shown to receive something. That sentence could not be written before 0.4.0, and writing it turned up two more faults -- one here, one in the bench beacon. * Hunt's picker never repainted when a tracker arrived. record() updates the target list and did not set the dirty flag; enterPicker() sets it once, at entry, while the list is empty, and after that only a button press does. So the picker drew "no trackers" and stayed that way however many were in range, while the gauge worked perfectly -- its branch sets the flag every pass for the live needle. Two things hid it. Scrolling revealed the list, because a button press sets the flag, so it read as "nothing found" rather than "not repainting". And it only bites when a tracker appears *after* the screen is opened; anything already in range at entry shows up in the first draw. It took a transmitter that advertises on a schedule to produce that ordering reliably. Shipped in 0.4.0. A display fault rather than a wrong answer, but the screen exists to be read. * The beacon gave every BLE decoy its own address. It had been sending all five from the ESP32's one MAC, and Spotter keys a row on the MAC and keeps the strongest signature that row has matched. The Meta glasses pair is Strong and the KARR vehicle name is Likely, so the vehicle match was folded into a row labelled Meta Ray-Ban and never got a line of its own. The detector was right: one device is not credibly both, keeping the stronger signature is correct, and it marked the row corroborated to say so. The bench was the thing lying, by pretending five pieces of hardware were one. Each decoy is now a random static address, C0:50:55:DE:AD:xx. Worth recording that the corroboration marker was the tell, and nobody read it. It appeared on exactly the row that had absorbed the missing match, which is what it is for. * What is now verified against a real transmitter rather than against synthetic frames: Surveillance on all three of its matching paths -- WiFi OUI, BLE company/service pair, BLE name -- plus Drones on both transports, Hunt's picker and gauge, and Fast Pair on both payload shapes, which is the distinction that file exists to get right. That is the whole detection surface. None of it was in doubt on paper and two thirds of it had never moved a byte off a radio. 0.4.0 - 2026-09-21 ------------------ A second firmware that transmits what the first one listens for, and the four bugs that finding it made visible. * PueoBeacon. PUEO_ROLE=beacon builds a separate image for a second board. It emits Remote ID over Wi-Fi Beacon and BLE, a plate-reader probe, a body-camera beacon, smart glasses, a vehicle module, a Find My tracker and Fast Pair in both payload shapes -- which is every detector this project has. It exists because none of those detectors had ever been proved to receive anything. They are checked against synthetic frames and, where one exists, a reference implementation, and none of that says a radio path works: a decoder can be correct while the scan never hands it a byte, and the symptom is an empty list, which is also what an empty room looks like. Drones shipped in 0.3.9 with no way to tell those apart. A separate image, not a menu entry. Surveillance and Drones both say they transmit nothing, on screen and on the website, and that is a claim about the binary as much as the feature. Emit.cpp includes the detector's headers rather than restating the constants, because a decoy built from a second copy tests the copy: the tables drift, the bench goes green, and the thing it was meant to prove is what stopped being true. check_beacon.py, 38 checks, cross-references every decoy against the signature tables it is supposed to trip. Everything it sends is built to be recognised rather than to be convincing -- PUEO-TEST identifiers, DEAD in the device half of every address, transmit power at the floor, a five-second countdown before it starts and a ten-minute auto-stop. These signals do not stay on a bench because that is where the bench is: Remote ID has public receiver apps, and a fake plate reader lands in somebody else's anti-surveillance tool the same way. The vendor OUIs themselves are real, because an OUI matcher cannot be tested without the OUI it matches. * A missing CC1101 no longer freezes the device. ELECHOUSE_CC1101's reset is `while(digitalRead(MISO_PIN));` -- it waits for the chip to say it is ready and waits forever, and there are eight such loops in that library. With no module wired MISO floats high and the first one never returns, on the main task, so the whole device stops rather than the feature. Five entry points reached it with nothing in front of them: Replay Attack, Saved Profiles, Sub-GHz Jammer, Sub-GHz Brute and the Jamming Detector. All five froze a board with no radio, which is every board until the carrier exists. All five now probe with a deadline first and say the radio did not answer. * Four screens stopped flickering, and there is now one painter instead of four copies of it. Hunt, Surveillance, Fast Pair and Drones each cleared their list area and redrew every row several times a second. That is invisible on an empty list -- which is how all four were written and tested -- and a black flash behind every row the moment anything is found. It was the same bug four times because it was the same code four times; uiShowLine() in utils.h is now the only copy. * Spotter is Surveillance, and it and Drone Detector move out of Bluetooth into More. Neither is a Bluetooth feature: both listen on Wi-Fi and BLE, and Drones was filed there only because Spotter already was. Spotter was also the one feature name that did not say what you get, and the list is plate readers, body cameras, smart glasses and vehicle modules. The namespace stays Spotter. It is an internal identifier and renaming it would touch every file for no reader's benefit. * Two more 2.8" coordinates found stranded on the 3.5". The corroboration marker sat at x=224, mid-screen instead of at the right edge. The More grid's touch targets were 100 by 60 against tiles that are 145 by 92, so two thirds of every tile in that grid ignored a tap. Both survived the panel sweep by looking like arguments rather than like dimensions, which is worth remembering the next time that sweep is trusted. 0.3.9 - 2026-09-20 ------------------ Drone detection: Broadcast Remote ID, on the two transports this board can actually receive. * Bluetooth > page 2 > Drones. Aircraft required to carry Remote ID announce who and where they are in the clear, with no pairing and no key, to anybody in range. This listens. It transmits nothing, associates with nothing and connects to nothing, which puts it beside Spotter rather than beside the jammers. Wi-Fi Beacon arrives through a promiscuous handler walking the information elements for the ASD-STAN vendor element -- the sibling of the walk Spotter already had. BLE 4 legacy arrives through a passive scan reading service data under 0xFFFA, the same shape FastPairScan reads under 0xFE2C. Neither receiver is new; only the decode is. * What it cannot hear is on the screen, not in a footnote. BLE 5 Long Range is the transport a lot of newer aircraft prefer and a classic ESP32 has no receiver for it; Wi-Fi NAN is a fourth transport and is not implemented. So an empty list means "nothing on Wi-Fi Beacon or BLE 4 legacy", which is a narrower claim than "no drones", and the header says so rather than leaving it to be inferred from a blank screen. * DroneId.{h,cpp} knows nothing about radios or screens. Every constant and every scale factor in it was taken from the opendroneid-core-c reference implementation rather than from memory: the transport constants from libopendroneid/wifi.c and transmitter-linux's bluetooth.c, the message layouts and decode arithmetic from libopendroneid/opendroneid.{h,c}. That mattered more than usual here. The scale factors fail quietly. Latitude is an int32 of ten-millionths, altitude is a half-metre step with a 1000 m offset, and horizontal speed is either quarter-metres or three-quarter-metres depending on one bit in a different byte. Get any of them wrong and the screen shows a plausible number for a real aircraft -- 4000 m instead of 1000, 60 m/s instead of 20 -- with nothing about the output to say it is wrong. * tools/check_droneid.py, 51 checks. It transcribes the arithmetic independently and runs it against vectors with known answers, then feeds the framing things that lie about their own length: a message pack claiming nine 25-byte messages inside a frame holding one, an element declaring more than the frame contains, a 200-byte element size. Then 120,000 random frames, half of them behind a valid header so the decoder is entered rather than rejected at the first byte -- the mistake check_nav_labels made, where a rule that never ran reported as passing. Every read goes through a buffer that refuses to look outside the frame. * Absent fields are not reported as zero. The standard has sentinels for "not being sent" and they are honoured: speed 0xFF, direction 255, timestamp 0xFFFF. A position of exactly 0,0 is treated as unset rather than as a fix, because 0,0 is in the Gulf of Guinea and putting an aircraft there is worse than showing no position at all. * The promiscuous callback does no decoding. It copies the candidate element into a small queue and returns; the arithmetic runs in loop(). Spotter's capture path is built the same way for the same reason. * docs/pueo/enclosure-logo.md loses the test-tile section, which described an object withdrawn in 0.3.8, and its "nothing here has been printed" note, which stopped being true when the lid was printed and the owl came out legible at 30 mm. 0.3.8 - 2026-09-20 ------------------ The enclosure sources stop travelling with the firmware. * pueo--src.zip no longer contains any .scad. The decision to stop publishing the OpenSCAD was taken a while ago and was applied to the website only; docs/pueo ships wholesale, so all three files -- the enclosure and the two fit-test tiles -- had been riding along in every archive since. 0.3.7's still has them. This one does not. The STLs remain what is offered, on the website, which is where they always were. The sources stay in the repository where they are edited. make_release.sh removes them after the copy and then asserts that none survived, rather than trusting the removal: docs/pueo is included as a directory, so a .scad added to it later would otherwise reinstate the thing this was meant to stop. * pueo-logo-fit-test.stl is withdrawn. The tile printed the owl at lid scale with a ladder of grooves beside it, to find out whether a printer held the detail before committing to a full lid. It is gone from the download page and from the docs that pointed at it. What it was teaching is kept, because it is about the lid rather than about the tile: the lid is a tray, so it prints face-down, which puts the logo against the bed, and bed-side debossing closes up below roughly one extrusion width. That paragraph stays on the enclosure page. * Three statements that were true and stopped being true are corrected rather than left. The enclosure page said the OpenSCAD "ships inside pueo--src.zip with the rest of the sources"; its own meta description offered "the parametric OpenSCAD source"; and build-guide.md told the reader to print a .scad the archive would no longer carry. A page that describes a previous release's contents is worse than one that says nothing. 0.3.7 - 2026-09-20 ------------------ The fork's own artwork, on the screens that were showing someone else's or nothing at all. * Hunt and Spotter open on their own marks: a diving owl over four signal arcs, and a perched owl with sight-lines out to the surveillance hardware Spotter recognises. 200x200, one bit, held for 800 ms with the feature's name under it. 200 is where the detail survives, and that was measured rather than guessed: both images were converted at 240, 200, 160 and 120 and looked at. At 160 the radio mast on the Spotter mark collapses into a smear and the Hunt mark's arcs begin to merge. At 200 both hold. The source art is already 99% pure black and white, which is why it converts at all -- midtone is what one bit cannot keep. Neither carries a frame. The Hunt source had one and it was cropped out: a baked-in border is the first thing to go thin as the bitmap shrinks, and the UI can draw a crisp one with drawRect at any size if it ever wants one. Both are --invert, because the artwork is black on white and drawBitmap paints the set bits; without it the owl would be the clear bits and the screen would look dead. 800 ms, not the three seconds upstream's features spend on the skull. This is a signature, not a loading bar: nothing is being waited on while it shows. The skull stays where it is on upstream's features. * The boot splash spends its time the other way round. It ran upstream's skull for 2.0 s and then Pueo's own logo for 0.5 s -- four times as long on the inherited mark as on this project's. Neither number had been chosen; the skull was at its upstream timing and the logo got whatever displayLogo() happened to be called with. One pass of the skull is still a nod to where this came from, so it is 1.0 s now, and the owl holds for 1.5 s -- the longer half, which is the right way round for a fork with its own name on the box. Total splash is unchanged at 2.5 s; what changed is which mark gets it. Both are PUEO_BOOT_SKULL_REPEATS and PUEO_BOOT_LOGO_MS in Branding.h rather than literals at the call site, because they moved three times while this release was being put together. * art/pueo_hunt_src.png and art/pueo_spotter_src.jpg are in the repository and not in the source archive, the same as the owl and for the same reason: 1 MB of source that only buys the ability to regenerate what is already sitting in icon.h. 0.3.6 - 2026-09-20 ------------------ A bug 0.3.5 shipped, and the menu tiles growing icons to match their size. * The Bluetooth page, and every other paged submenu, had the top of its first row painted over. 0.3.5 gave displayPagedSubmenu the tall status bar on the grounds that it was a tile grid. It is not: it is a list, its rows are at 30 + i * 30, and a 34 px bar covers the top four pixels of the first one. The comment introducing the change called it a tile grid, which is the whole of the mistake. tools/check_status_bar.py did not catch it, and the reason is worth keeping. It swept the literal y arguments of drawing calls, and this screen draws with drawBitmap(10, yPos, ...) -- the origin is a literal in an assignment, not an argument, so there was nothing at the call site to see. It now also resolves y-ish assignments, including through named constants, and it reads the 3.5" branch of the panel #if when it does, because taking the other branch resolves Y_START to 30 and condemns the layout it exists to bless. Re-running the old check against the shipped tree passes; the new one names all three yPos origins. * Menu tile icons are 32 px on the 3.5" instead of 16. The tiles grew to 145x92 when that panel arrived and the icons did not, so they had been sitting at the size they were drawn for a 240x320 screen. The icon and label block re-centres around them: 32 + 6 + 16 = 54 in a 92 px tile. The art is unchanged -- drawBitmapScaled() doubles the bits of the same 1-bit 16x16 icons into a scratch bitmap and makes one drawBitmap call, rather than 143 icons being redrawn or a fillRect being issued per lit pixel. Doubled bits look doubled, and that is the trade: every edge is two pixels and the diagonals step. Redrawn 32x32 art would be sharper. The 2.8" keeps 16. Its tile is 100x60, where a 32 px icon and a 16 px label leave four pixels of tile. * The "More" tile is one icon rather than three. It previewed the section with a LED, a satellite and an ellipsis crammed side by side, which read as clutter at 16 px and would have been 108 px wide at 32. It is now bitmap_icon_dots and an ordinary row of the menu table, which deletes drawMainMenuOtherTripleIcons() and the three places that branched on the tile's index. This one applies to both panels, so unlike the status bar the 2.8" image does move here -- deliberately, and unverified on a board like everything else about that panel. 0.3.5 - 2026-09-20 ------------------ The status bar got taller on the screens that had room for it. * The menu grids now draw a 34 px status bar instead of 20, and the name and version in the middle of it are font 2 rather than 6x8 -- the same height as the icons either side, so the bar reads as one row of content instead of small text floating in a stripe. Nothing moved to make space. The first tile is at Y_START = 44 and it is still at 44; the 24 px between the bar and the tiles was bar-coloured background, and this spends 14 of it. * Only the menu grids. Every feature screen puts a toolbar immediately under the bar -- 74 drawing calls have a literal y between 20 and 48, eleven of them at exactly 20 -- so those screens cannot give up a pixel and keep the short bar. The height is therefore a property of the screen rather than of the build: shared state set by whichever function paints the screen, because the bar is also repainted asynchronously by statusBarTask, which has no idea what is on screen and would otherwise repaint a tall bar at the short height and leave an orphan stripe. All four menu painters already ended by forcing a bar redraw, which is where they now declare their height, and every main-menu selection is routed through displaySubmenu(), so features inherit the short bar without having to know any of this exists. drawAboutPage declares its own, being launched straight off a tile. * The 2.8" panel is unchanged, and that is checked rather than asserted. Its Y_START is 30, so it has no room to give and PUEO_STATUS_TALL is defined equal to PUEO_STATUS_SHORT there; every branch is written so the tall path folds away when the two are equal, and the centring reduces to the constants it replaced -- (20 - 8) / 2 is the 6 that was y + 2. Rebuilt, its image differs from the previous one in exactly 65 bytes: the 32-byte ELF hash in esp_app_desc, the one-byte image checksum that hash forces to change, and the 32-byte SHA-256 esptool appends. Splice those three fields back and the image is the previous one, byte for byte. None of this release's code is in it. * The screen mounts take M2.5 x 5, not M3. Driven into a printed lid they bite and hold without splitting the post; the M3 the file assumed came from the board's own 3.20 hole pattern, which is an M3 clearance and is the board's figure, not ours. It is simply loose around an M2.5 shank, which does not matter -- the post's shoulder locates the board and the screw only clamps it. No geometry changed: the 2.50 pilot and the 4.0 blind depth were already right, so the published STLs are unaffected. * The RGB LED hole lands on the LED. Those two coordinates were eyeballed off a photograph and were the only figures in the enclosure file with no source behind them; a printed lid confirms them. Still the softest numbers in it, and still worth measuring if yours misses. * tools/check_status_bar.py, 19 checks. That both heights exist and that the 2.8" has only one; that the centring is an identity at the short height; that every screen-painting function declares a height, which is the failure this is really for -- a new menu screen added later would otherwise inherit a tall bar over its own toolbar; that nothing on a tall-bar screen draws into the band the bar now occupies; and that the tile row clears it. Each rule was mutation-tested: five deliberate breakages, five failures, none silent. 0.3.4 - 2026-09-20 ------------------ The first release built after any of this ran on a board, and the first to ship two flash images. * It boots, draws and responds to touch. Nothing is wired to it yet, so every radio claim in here is still untested and stays marked that way. * Two merged images, because the board that arrived was not the board this targets. pueo-0.3.4-merged.bin is the 2.8" ESP32-2432S028R, the same thing that name has meant since 0.1.0. pueo-0.3.4-35-merged.bin is the 3.5" ESP32-3248S035R: ST7796 instead of ILI9341, 320x480 instead of 240x320, backlight on GPIO 27 instead of 21, CC1101 chip select moved to 21 to get off it, and an XPT2046 hanging off the display's own SPI bus rather than the separate T_CLK/T_DIN/T_OUT trio. Flashing the wrong one is a dark screen, not an error. There is no runtime detection and this release does not pretend there is; the panel is chosen at compile time by PUEO_PANEL_35, which tools/build.sh now exposes as PUEO_PANEL=28 or PUEO_PANEL=35, with a separate build directory each so arduino-cli cannot reuse the other panel's objects. The first 2.8" image cut this way was wrong twice over, and neither fault would have announced itself: Libraries/User_Setup cyd.h still said ST7796 at 320x480 with the backlight on 27. PUEO_PANEL_35 reached the sketch's own macros and stopped there, so the "2.8" build was an ILI9341 panel driven with the 3.5"'s driver, dimensions and backlight pin. It branches on PUEO_PANEL_35 now -- the define arrives as a -D, so the library's configuration header sees the same value the sketch does -- and the six settings that differ sit in one block at the top rather than scattered through the vendor sections, which is how five of six came to be edited at bring-up. TOUCH_SHARES_TFT_SPI was 1 for both boards, under a comment explaining why the 2.8" needs 0. That build would have driven touch through the display's bus, where the 2.8"'s controller is not. It follows the panel now. tools/check_pinmap.py reads User_Setup through the same small preprocessor it uses on the sketch headers, and checks both panels every run. Scanning the file line by line was fine while it described one board; against a branch it took whichever TFT_BL came first, which reported CC1101's chip select as colliding on the 3.5" and clear on the 2.8" when the truth is the other way round. * Three touch fixes that were wrong on every board, not just this one. The calibrator advanced the instant readTouchRawXY returned true, with no wait for release, so one press satisfied all four targets in milliseconds. The first real run returned (3658,3701) (3635,3705) (3636,3705) (3551,354) - three of them the same press. It now waits for release and takes the median of a nine-sample burst after a settle delay. Its endpoint arithmetic used min(xs[0],xs[3]) and max(xs[1],xs[2]), which assumes raw counts rise with the screen coordinate. On a panel where they fall, that picks one reading from each edge pair instead of the correct end. It now averages each pair and extrapolates from the 20 px target inset out to the screen edges, which has no opinion about direction. mapTouchToScreen chose its Y inversion on defined(BOARD_CYD), which asks the wrong question - the two CYDs disagree about Y. It keys on TOUCH_INVERT_Y now. * utils.cpp named ILI9341_VSCRSADD and ILI9341_VSCRDEF for terminal scrolling and would not compile against another driver. 0x33 and 0x37 are MIPI DCS commands, identical across ILI9341, ST7789 and ST7796, so they are named locally and the code no longer cares which panel it is on. * Spotter goes from 54 signatures to 99, across eight kinds instead of five. Every OUI was checked against the IEEE registry - MA-L, MA-M and MA-S - and every service UUID against the Bluetooth SIG's assigned numbers, before it went in the table. Plate readers gain seven vendor blocks: Motorola Solutions and Genetec, both Likely rather than Strong, because both sell a great deal besides plate readers and an OUI cannot tell you which product is in range. Fixed cameras and doorbells arrive as a new Camera kind, 31 rows: Ring (13 blocks), Hikvision, Wyze, Axis, Verkada and Avigilon. These are the cameras that are already on the street, as opposed to the ones mounted on a pole by a contract. A Pentest kind covers a Flipper Zero's own IEEE block, Strong, and two Hak5 locally-administered addresses, Weak - an LAA is chosen by the device, so it is a hint and never a finding. A Tracker kind matches four BLE service UUIDs: Tile's 0xFEED and 0xFEEC are Strong, Samsung's 0xFD5A is Likely because SmartTag is one of many things behind it, and Eddystone's 0xFEAA is Weak because anyone can broadcast one. Amazon's own blocks are in at Weak, deliberately: they own Ring, and also Echo, and also Kindle. A prefix published by another project as "modern Axon" was rejected outright - E4:05:40 is in none of the three IEEE registries. 00:25:DF is Axon Enterprise's only registered block and remains the only Axon row. * Every feature screen now fills the panel. The menu grid was made panel-aware at bring-up; nothing else was, so on a 3.5" board each app drew into the top-left 240x320 of a 320x480 display and left a third of the screen black, with the status icons bunched at 60% of the width and the packet monitor's waterfall centred on a line two thirds of the way across. The dimensions were not in one place to fix. They were in 221: 49 declarations (`#define SCREENHEIGHT 320`, `constexpr int SCREEN_WIDTH = 240`), 31 status-bar icon rows laid out right-to-left from x=240, and 141 coordinates sitting in the argument lists of tft.* calls. Same shape as the bus map and the pin map before it -- one fact, restated everywhere, corrected in some places and not others. All of it reads PUEO_SCREEN_W / PUEO_SCREEN_H from shared.h now. The 2.8" values are unchanged and that build's flash and RAM figures did not move, so nothing here touched that board. Three one-shots under tools/ record how, and each refuses to run twice: screen_dims_from_panel.py, icon_rows_from_width.py and screen_coords_from_panel.py. The last rewrites by argument position rather than by value -- a 240 in a width slot is the panel, a 240 somewhere else is a number -- and prints every change it makes. It ran in two passes because its first file list named the four big UI files and missed Spotter and the Fast Pair scanner, which is the same mistake in miniature. Roughly 48 three-digit literals are deliberately left: right-aligned labels and mid-screen cursors where 240 is not the only reading. They are offset, not clipped. tools/check_screen_dims.py holds the line: the dimensions have one home, both panels reach it, and User_Setup agrees with the sketch about the driver, the size, the backlight pin and the inversion flag. 26 checks, mutation-tested against six ways of breaking it. Network list rows were a second copy of the same problem: eight screens cutting the SSID at 11 characters and padding the field to 15, which is one measurement of a 240 px row written out sixteen times. One helper now derives both from the panel, which is 29 characters of SSID on the 3.5" instead of 11 and an ellipsis. * Hunt -- a new Bluetooth feature, for finding a tracker that is already known to be there. AirTagSniffer asks whether there is one in range and TrackerFollow asks whether one has been beside you for twenty minutes; this is for after the answer is yes. A picker lists what has been heard in the last minute -- Find My by Apple's 0x12 Continuity type, and Tile, SmartTag and Eddystone by service UUID, read out of SpotterSignatures.h rather than copied into a second table. Locking one opens a gauge whose needle swings left to right with smoothed RSSI, with a marker at the best reading of the hunt. The needle is signal strength and the screen never prints a distance. A tracker under a car seat reads weaker than one twice as far away in open air, and turning round with the board in your hand moves the needle more than walking a metre does. What survives all that is the direction of change, which is why there is a peak marker and not a number in metres. The lock is on a BLE address, and Find My devices rotate theirs. A rotation ends the lock and the device returns to the list as a new row; nothing here can follow it across the change, because that is what the rotation is for. Passive scan, continuous window. Never connects, never asks a device anything. * check_pinmap.py stopped carrying its own copy of the CYD's wiring and reads "Libraries/User_Setup cyd.h" instead. The hardcoded table had no entry for GPIO 27, so it could not see CC1101's chip select land on the 3.5" panel's backlight - it exited 0 on a build whose screen would have gone dark. The rewritten one exits 1. * The enclosure lid cuts for either panel, off a PANEL parameter, and docs/pueo/build-guide.md is new: wiring a CYD and four modules by hand, in the order that makes a fault easy to find. Ten signals, four of which reach a header. * Spotter, Fast Pair and Hunt were unreachable by touch. A submenu selection is dispatched twice -- once from the hardware buttons, once from the touch nav bar -- as two hand-maintained if/else chains over the same indices, and nothing made them agree. The button chain had all four of Bluetooth page 2; the touch chain stopped after Skimmer Detect, so the other three drew a tile, took the tap, highlighted and did nothing. Not panel-specific. True of Spotter since it landed and of Fast Pair since before that; it went unnoticed because everything anyone reached by touch was on page 1. tools/check_menu_dispatch.py holds both chains to the declared entry count now, and catches an orphaned handler left behind by a removed entry as well. * ...and once they opened, there was no way out of them. Exit is BTN_SELECT, which is the centre slot of the touch nav bar, and every other feature labels it "Exit". Those same three passed "" for the centre and put "Back" on the left, where nothing reads it: an exit with no label on it, next to a labelled button that did nothing. It survived because they were the three that could not be opened by touch in the first place. * ...and then the labels were right and the bar was still blank. setTouchNavLabels() only stores them; redrawTouchButtonBar() paints them. Spotter and Fast Pair called it and then called redraw(true), whose first act is fillScreen -- the bar drawn and erased inside one setup, which from the outside cannot be told apart from never drawing it. The repaint moves after redraw(true) in both. tools/check_nav_labels.py covers all four ways this has broken: the centre slot is never blank, no slot promises a way out the feature does not route there, a file that sets labels repaints somewhere, and nothing clears the screen between the repaint and the end of its function. 147 checks. * The BLE Sniffer rebooted the board, and the taller panel is why. Guru Meditation, StoreProhibited, EXCVADDR 0x20, inside NimBLEScan::start with `this` null. addLine() shifts its log: const int visibleLines = snifferVisibleLines(); for (int i = visibleLines - 1; i > 0; i--) displayLines[i] = displayLines[i - 1]; displayLines holds 16. snifferVisibleLines() answered with whatever the screen could hold, which on 320x480 is 31 -- fifteen assignments past the end of the array, into the members that follow it. One of those is pBLEScan, and the next scan called start() through a null pointer. bleMaxLinesInZone() takes the caller's capacity now and clamps to it, and the argument is required, so the next log zone cannot repeat this by forgetting. Overrun by panel, the firmware's own arithmetic: 2.8" 3.5" sniffer 18 vs 16 (+2) 31 vs 16 (+15) jammer 19 vs 26 ok 32 vs 26 (+6) prokill 19 vs 26 ok 32 vs 26 (+6) The sniffer was already two past the end at 240x320, where they landed in padding and nothing happened; the taller panel moved the landing site onto a pointer rather than introducing the fault. The jammer and prokill logs are the other way round -- correct before, broken by the panel, not yet triggered. * Hunt's gauge leads with the direction. A needle says where you are standing, not what to do about it, and what to do is the whole technique: move, watch which way it goes, keep the direction that raises it. WARMER / COLDER / HOLD in large type, then a band in words (FAR through ARM'S LENGTH), a strength bar with the peak marked, and the numbers last. The trend is a 1.2 s baseline with a 2 dB deadband, both reasoned rather than measured. Still no distance anywhere. * NRF24 CSN moves off GPIO 4 on the 3.5" panel. The two boards do not agree about that pin: on the 2.8" it is the RGB LED's red channel, which this pin map already spends, and on the 3.5" -- lcdwiki's E32R35T, 55.50 x 101.50 x 5.80 mm, which is the board that arrived -- it is the audio amplifier's enable. Driving it as a chip select keys the amp at SPI rates: it clicks, and it draws current off a rail already carrying the display. CSN follows the panel now, 4 on the 2.8" and 25 on the 3.5". 25 is free there for the same reason it is not free on the 2.8": that panel puts touch on its own bus at 25/32/39, while the 3.5" hangs its XPT2046 off the display SPI behind TOUCH_CS. The pin that collides on one board is the spare on the other. The 2.8" is untouched on purpose -- a published pin map is a thing people have already soldered to. The 3.5" moves RGB red to GPIO 22, where CC1101's GDO0 lands. That one stays: spending the LED is the trade this map makes either way, and the red channel flickering with sub-GHz traffic is not a cost. check_pinmap.py carried one board map and applied it to both panels, which is why GPIO 4 read as a spent LED on a board where it is an amplifier. It knows both now, including that GPIO 34 is the battery divider rather than an LDR there and 36 is the touch IRQ, and its repurposable set is per panel -- the 2.8" gives up an LED, a buzzer and a light sensor; the 3.5" gives up an LED and nothing else. * The published 0.3.4 images were built against a stale TFT_eSPI configuration, and the first two cuts of this release had it. TFT_eSPI reads User_Setup.h from inside the installed library; build.sh setup copies the repo's copy there and is a one-time step nobody re-runs. So the panel split went into Libraries/User_Setup cyd.h and every build kept reading the old unconditional ST7796 -- which means 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 that claimed to fix it. Caught by rebuilding the archive at a different path and comparing: the 3.5" image reproduced and the 2.8" one did not, and the sizes differed, so it was not a path leak. Every compile re-syncs User_Setup.h now, and check_screen_dims.py compares the installed copy against the repo's -- every other check it runs reads the source, which is why all of them passed while the compiler was reading something else. * Every feature this fork adds has a render now. tools/render_screens.py gained Hunt's picker and gauge and the Fast Pair list, which needed primitives the menus never call -- pixels, lines, filled triangles and circles, and font 1 at setTextSize(2) and (3). The scaled text is drawn cell by cell rather than upscaled: TFT_eSPI scales the 5x7 cell and not the string, so the inter-character gap scales with it. Writing the fixtures found two things, which is the argument for a renderer that replays the firmware's own drawing rather than a mockup agreeing with whatever it was drawn from. Target::label was 18 bytes and the longest Kind::Tracker label is "Samsung (SmartTag?)" at 19, so that row reached the screen as "Samsung (SmartTag" -- the truncation turning a hedge into a claim, which is the opposite of what the question mark is for. 24 now. And the Fast Pair fixture first read "PAIRING Pixel Buds Pro". kModels in FastPair.cpp is a sentinel and nothing else, on the grounds that a name needs a source rather than a spam list, so modelName() returns nullptr for everything and the device can only print the raw hex. A render showing a product name would have been inventing a capability. * The 3.5" lid shows only the screen. It cut the board's whole outline, so the PCB dropped through and nothing covered its edges. Now an aperture at the touch panel's active area (49.96 x 77.24), a rebate behind it for the module (55.90 x 85.36), a 1 mm lip the module bears against, and four M3 posts on the board's own 47.90 x 94.50 hole pattern. All off the E32R35T outline drawing. The post height is not a choice: the module bears on the lip, so the PCB sits 4.20 behind it and the posts come out at 2.70. The pilot is 2.50 for a self-tapping M3, not BOSS_HOLE's 3.00, which is sized for the base's machine screws and would strip on the first drive. The window was also the board's exact outline before that -- 55.5 x 101.5 under a comment claiming 0.5 of clearance that was not in the arithmetic. An FDM part printed to a board's exact size does not accept the board. [verify] The RGB LED hole is the one figure here not from the drawing. * The build guide had half its wiring table wrong for the 3.5" board. lcdwiki's render of the E32R35T shows the silkscreen, and it brings the whole VSPI bus out on a four-pin JST: SPI with IO23(MOSI) IO19(MISO) IO18(SCK) IO21(CS). The guide said the SPI bus "is not brought out anywhere on a CYD" and had those three lines coming off the microSD slot's own pins, which it called the whole difficulty of the build. True on the 2.8". Not on the 3.5", where the I2C header also carries IO25 -- where NRF24's chip select now goes -- with 3.3 V and ground beside it. Seven of the ten signals are on connectors there. What is left to solder is the three RGB LED pads, which this pin map spends anyway, so three joints rather than six. The table is per panel now. [verify] it against your own silkscreen: it is read off a product render rather than a board in front of a meter. * "Cheap yellow display" names boards from at least two vendors, and they do not agree about GPIO 4. This tree targets Sunton's ESP32-2432S028R, silkscreened `ESP32 2432S028`, where GPIO 4 is the RGB LED's red channel and spending it is the trade this pin map makes. lcdwiki's 2.8" is the E32R28T: a different board, 50.00 x 86.00 mm, RGB red on GPIO 22 and an audio amplifier's enable on GPIO 4 -- the same layout as their 3.5" and 4.0" parts. The 2.8" image would key that amplifier at chip-select rates, which is the fault the 3.5" build already moves CSN to avoid. Documented rather than handled. Nothing here has been built for the lcdwiki 2.8", and a board profile is the answer if one ever is. * The 2.8"'s serial connector is P5, not P1. The guide had called it P1 since it was written; someone read it off a board. The same board shows no SPI breakout anywhere, which turns the claim the 2.8" wiring rests on -- that SCK, MOSI and MISO come off the microSD slot's own pins -- from an assumption into an observation. * The lcdwiki 4.0" E32R40T should run the 3.5" image unchanged. Every pin lcdwiki publishes for it matches the E32R35T -- display SPI 14/13/12, CS 15, DC 2, touch on that bus behind CS 33 with IRQ 36, backlight 27, SD 5/18/23/19, RGB 22/16/17, amp 4 and 26, battery 34 -- at the same 320x480. The only difference is the controller's suffix, ST7796S against the 3.5"'s ST7796U, and TFT_eSPI drives both with one ST7796_DRIVER. So PUEO_PANEL_35 is right about everything it selects and wrong only about the diagonal in its name. Recorded rather than acted on: nothing here has been on a 4.0" board, and two datasheets agreeing is a reason to expect it to work rather than a report that it did. The enclosure does not carry over. 60.88 x 111.11 x 5.65 against the 3.5"'s 55.50 x 101.50 x 5.80, and there is no outline drawing here for it, so a lid would be guesswork. * Hunt's readout flickered, and its bar could not shrink. The whole readout was cleared and redrawn every 60 ms whether or not anything had changed, which on SPI is a visible blink -- worst on HOLD and LOST, because those are the states where nothing is changing at all. The needle was already guarded against this; the text was not. Each line compares against what is on the glass now and only redraws when it differs, clearing its own band rather than the whole block. Chasing it found the worse one. The strength bar painted the new fill and left whatever was beyond it alone, so a bar that had once been long never got shorter: walking away from a tracker left it pinned at the closest approach, which is the one direction a hunt most needs to see. It paints the remainder black now, and the bar and its peak marker redraw together because the marker sits on top of the fill. * About was still upstream's page. The obfuscated strings in shared.h decode to ESP32-DIV, CiferTech, cifertech@gmail.com, github.com/ cifertech and CiferTech.net -- so the About screen of this fork named the fork nowhere and gave a stranger's email as the contact. Reasonable while it was their sketch; it was never rebranded. Page one is Pueo's now: the owl, the tagline, the author and the version. Page two carries the board, the author, pueo.magikh0e.pl, and then the credit back to the project this came from -- ESP32-DIV, CiferTech, their repository, and the version it forked at. Their email is not on it. ESP32-DIV is MIT and the licence's requirement is the notice in LICENSE, which is untouched; the About credit is voluntary and stays because the code came from somewhere. An address is contact information rather than attribution, and on a fork's About screen it points support at someone who did not ship it. Pueo's strings stay plain text, which is what Branding.h already said they would: the obfuscation is a single-byte XOR that defeats `strings` and nothing else, and the name is already in plain text on the splash, in the serial banner, on the site and throughout the repo. * The logo could never have drawn. ESP32-DIV.ino includes icon.h and not Branding.h, so PUEO_LOGO_BITMAP was undefined there and the #ifdef guarding it silently took the other branch. It is also why using PUEO_LOGO_H outside that guard failed to compile, which is the version of this that announced itself. * About is two pages, and has the owl on it. It had no artwork at all -- the logo is a fixed 200x200 bitmap, drawBitmap does not scale, and on a 240x320 panel it does not share a page with four rows of credits. Two pages fits it on both panels rather than picking one to go without. Page one is the mark and the by-line, page two is board, contact and where the source is. Tap or SELECT moves on, again leaves, with a wait for the release in between so one press does not walk both and exit. Its rule was 216 px wide and its back hint sat at y=300 -- 240x320 figures that survived the whole panel sweep because ESP32-DIV.ino was not in the file list that sweep worked from. It is in check_screen_dims.py's list now. * The status bar carries the build. "Pueo ", centred in the gap between the battery block and the icon cluster, computed rather than placed at a fixed x because the cluster moves with the panel, and dropped rather than overlapped if that gap is ever too small. The status bar is the one piece of chrome every screen draws, so it is the only place a build can say what it is without each feature having to remember to. Worth the six pixels: flashing a stale image looks exactly like a fix not working. * Known, and not fixed here: updateSdCardStatus() reports "SD Card detected" with an empty slot. SD_CD is GPIO 38, input-only with no internal pull-up, and floating. 0.3.3 - 2026-09-20 ------------------ Spotter learns to name KARR modules, and the wardriver learns not to upload them. * UC San Diego's "BLE Theft Auto" (USENIX Security 2026) wardrove San Diego's highways and found KARR second only to Tesla among BLE devices on the road - 608 of 4,314 unique devices - because dealerships fit one to every car on the lot. It is a dealer-installed immobiliser and remote control whose modules share one hard-coded key across every unit. Two models, both advertising a prefix plus exactly eight characters: QT is BLE-only, DR adds cellular. {"QT ", 11, Kind::Vehicle, Conf::Likely, "KARR BT module"}, {"DR ", 11, Kind::Vehicle, Conf::Likely, "KARR Cell module"}, Likely, not Strong, and a module rather than a finding. The shared-key vulnerability was patched by the vendor on 2026-07-20, but the fix is applied by hand and nothing in the advertisement says whether it has been. Presence is what a passive scan can support. No OUI rows. The paper reports KARR allocating MACs sequentially inside a small set of blocks - 221 outliers in 1,141,798 sampled QT addresses - so the addresses are public and corroboration would work, but it plots the blocks without printing them. * NameSig gains exactLen. It matched on prefix alone, case-insensitively, which is fine for "Spectacles" and useless for "DR " - that claims every device whose name starts that way, and a match is what puts a row on the screen. prefixMatch becomes nameMatch so something reads the field. The nine existing rows carry an explicit 0 rather than relying on zero-fill: in a signature table, "no constraint" should be visible, not inferred. * The WiGLE upload withholds Kind::Vehicle rows. The same paper describes the attack beginning with a name-pattern query against a public wardriving database - 1,141,798 QT addresses were pulled from WiGLE that way - so uploading fresh sightings with coordinates would be adding to that index. Filtered at the conversion, not at the log. The ward CSV on the card keeps every device the radio heard; only what leaves for a public database is trimmed, and the conversion reports how many rows it dropped. Withholding from WiGLE and lying to the operator about what their own radio heard are different things. Spotter never fed the WiGLE export in the first place - it writes its own /spotter_.csv with no coordinates in it, and gps.cpp had no reference to Spotter before this. The exposure was the wardriver logging every BLE device it hears by name, MAC and position, which it does for a KARR module the same as for a doorbell. * check_spotter_oui.py: 151 checks to 197. A short prefix must declare a length or be listed with the reason it does not need one; an exemption fails once the row it was written for is gone; isVehicleName has to exist, select on Kind::Vehicle, and be called from gps.cpp. Two of those checks were wrong when first written and were fixed rather than kept. A bare length threshold failed on {"Flock", 0}, which is a false positive - a distinctive word is not a two-letter prefix - so an exemption list replaced the threshold instead of the threshold being tuned until it passed. And the gps.cpp check read `"Spotter::isVehicleName" in gps`, which the comment beside the call satisfied on its own; it now requires the name followed by an open paren. * The Spotter screen render gains a KARR row. It is also the first orange row in it: the grading has three levels and the render showed two. 0.3.2 - 2026-09-20 ------------------ Documentation, and where releases get published. No code change since 0.3.1 - the image differs only in the version string. Corrected, all three found by checking every backticked name in docs/pueo against the source rather than by reading: * pcb-design.md sourced the SPI clocks from CC1101_SPI_HZ and "the RF24 constructor". Neither exists. The identifier is nowhere in the tree and RF24 itself went in 0.2.2 over the GPL-2.0-only clash. Both clocks come from the kProfiles table in SpiBus.cpp, which is where it points now. The figures were right: 4 MHz and 10 MHz are what that table sets. * spi-bus.md named the same dead identifier. * hardware.md said bluetooth.cpp "instantiates radio1/radio2/radio3". It calls Nrf24Raw's free functions; the objects went with RF24. The point that sentence supports is unaffected and was re-verified: CE_PIN_2/_3 and CSN_PIN_2/_3 really are aliased onto _1 in board_pueo.h. * pcb-design.md opened its SPI section with "Four devices on one bus". hardware.md already says five, "not the four you would count from the wiring", because the touch controller is on VSPI too and on a CYD it is the only input device. Five, three of them through the cable. Carrier board: * An Ebyte E07-433M20S gives the CC1101 about +20 dBm against the stock +12, and it drops in beside one on a board this size. Recorded as a decision rather than adopted: TX_EN and RX_EN want GPIO 4, which is the RGB LED's red channel, and GPIO 0, a strapping pin. Nothing is spare after the RGB LED gave up three pins and UART0 a fourth. The RF rail was not sized for that peak, and transmit testing happens in a cage where 8 dB buys nothing. Releases: * make_release.sh reads the publish directory from .publish.local beside the repo when PUEO_PUBLISH_DIR is not set. The variable exists because this script ships inside the archive and a path written into it would be published to everyone who downloads the source; that reasoning is unchanged. What it left behind was a variable to retype on every cut, which is how a release eventually lands in the wrong directory. Parsed, not sourced - sourcing a file to get one string is how a stray command in it gets run. Gitignored, and absent from the archive's include list. 0.3.1 - 2026-09-19 ------------------ Spotter hears Axon hardware on Bluetooth as well as WiFi. * The OUI table was matched against WiFi source addresses only. The BLE side matched on company ID, service UUID and name prefix and never looked at the address, so a unit advertising over BLE and not WiFi went past unnamed. github.com/PoliceDetector/PoliceDetector is a 60-line script that does nothing but that one match - Axon's block, 00:25:DF, against BLE addresses - and it was the thing this had missed. The signature itself was already here and already graded Strong. * Strong entries only, and only on a public address. Strong is the two blocks IEEE assigned to the vendors themselves, Flock Safety and Axon. The other 34 are contract manufacturers and module vendors - 23 Liteon, 2 Espressif, Qualcomm Atheros - and on WiFi they are graded Weak and read as corroboration for an SSID or a beacon. On BLE a scan hears every advertiser in the room rather than only the ones probing for a network, so Espressif's block would report a shelf of dev boards as surveillance hardware, and the board running the scan is one. The public-address gate is not caution for its own sake. A random address's top two bits encode the address type, not a vendor, and 00:25:DF has top two bits 00 - the shape of a non-resolvable private address. The strongest entry in the table is also the one a randomised address could wear by chance. One in 16.7 million per rotation, and the gate costs a comparison. * tools/check_spotter_oui.py, 151 checks. Conf::Strong now does two jobs: it grades the finding and it decides what is allowed on BLE, and nothing in C++ connects the second to the first. Promoting a Liteon block while thinking only about how a WiFi hit is labelled would put it on the BLE path silently. The check fails on that, on either gate being removed from Spotter.cpp, and on a new Conf level added above Strong - each verified by making the change and watching it fail. * Not copied from the same script: its threshold counts sightings rather than devices and the counter does not reset, so one camera seen five times in 35 seconds reads as a raid. That is the failure the tracker-follow maturity gate exists to avoid. Verified in the built image rather than the source: the BLE callback now makes two getAddress calls, one memcmp and three record calls, against one, none and two before, and the entry filter is visible as a branch on the conf byte. -28 bytes of flash, which is layout noise rather than a claim that code was removed; RAM unchanged. 0.3.0 - 2026-09-19 ------------------ Housekeeping. No code change since 0.2.9 - the image differs from it only in the version string, so there is nothing to gain by reflashing if you are already on 0.2.9. Downloads: * make_release.sh prunes the publish directory when it cuts a new one: the release just cut, the one before it, and 0.2.1. Without it the site accumulated images nobody will flash and a download list that grew every release. 0.2.1 is held by name because of the licence rather than the code. It is the last release containing RF24, which is GPL-2.0-only and could not share a binary with arduinoFFT or NimBLE; 0.2.2 ended that, so 0.2.1 is the last of a distinct licence state. Only ever three exact filenames per version, never a glob over the directory, because it runs against a website tree. Ordering is sort -V, so 0.2.10 sorts after 0.2.9 rather than before it - checked against a scratch directory holding both, since a lexical sort would have silently deleted the newest release at the first double digit. dist/ is untouched. Every pruned release's digests stay in this file, which is published beside the downloads and ships inside every archive. 0.2.9 - 2026-09-19 ------------------ A detector for trackers that rotate their identity to avoid being detected. Bluetooth: * New: AirTag Sniffer reports a rotating tail. It keyed its rows on the BLE address, which is fine against a real AirTag and useless against a clone - the published ESP32 one cycles a couple of thousand public keys, so every rotation made a fresh row, the hit count reset, and after 32 identities the list started evicting. The harder something worked to avoid detection, the less it stood out. * ESP32-DIV/TrackerFollow.{h,cpp} asks a different question: has something been beside me, at the same distance, wearing a succession of names. Sightings of the long form of Continuity 0x12 go into RSSI lanes; a lane held without a long gap while collecting persistent identities fast enough is the finding. No BLE, no display, no clock of its own, so it is tested on a host. * Two gates, both added because a scripted scenario failed rather than because they sounded sensible: A shopping centre read as a tail. A lane is not "one device holding station", it is "anything at roughly this distance", and in a crowd every lane stays occupied by a parade of strangers. Fixed by counting only identities seen at least three times - a follower's keys persist, a passer-by is seen once. Sitting still crept into an alert. Half an hour beside your own AirTag reaches two identities and forty-five minutes reaches three, which was the Weak threshold. Fixed with a rate gate: a qualifying lane averages a new persistent identity at least every four minutes. Apple rotates at about 0.07 a minute, the clone at 2.0. * Measured margins, not estimates: desk at 45 min 0.07/min and silent; concourse nothing matured at all; clone following 2.00/min and reported within two minutes. There is a check that three hours at a desk is as quiet as thirty. 315 checks in tools/check_tracker_follow.py. * On screen: a tail line between the header and the columns, always shown, because "no tail" is the answer the feature exists to give. Rows in the implicated lane get a red bar on the RIGHT edge - the left edge is the selection bar. Churn is on the line and above 20 new identities a minute the verdict draws orange rather than red, because a crowd degrades every signal here at once. Costs the list one row, 10 down to 9. Addresses are hashed before the detector sees them. * docs/pueo/tracker-follow.md records what it cannot do, including the weakness named by arXiv:2602.07656 (AirCatch): RSSI is a function of transmit power and transmit power is the adversary's to choose. A clone that randomises its output level alongside its keys never holds a lane and this reports nothing. Their answer - fingerprinting the transmitter by carrier frequency offset - is better and is not implementable here: a NimBLE advertisement report carries no phase, no I/Q and no frequency offset, which is why their work ships a separate micro-SDR. Corrected: * docs/pueo/spoofers.md said AirTag Spoofer sends offline finding. It does not. buildProximityPacket() emits Continuity 0x07 proximity pairing with prefix 0x05 - the setup popup for an unpaired tag. Offline finding is 0x12, which nothing in this firmware transmits; the only code that touches it is AirTagSniffer, which receives it. My error in the 0.2.4 write-up. Wardriving: * WiGLE uploads identify as Pueo. The CSV header said model=ESP32-DIV, brand=ESP32-DIV and the User-Agent said ESP32-DIV-Wardrive/1, so every upload from this build was filed against the wrong firmware. appRelease and release now come from PUEO_VERSION rather than a hardcoded 1.0. Verified against the built image rather than the source. +1364 bytes of flash, +1168 of RAM. 0.2.8 - 2026-09-19 ------------------ Address rotation fails loudly instead of silently. * bleRotateAddressNow() restarted advertising when the address change failed, but never checked whether the restart itself succeeded. If a board dislikes the stop/set/start the symptom would have been a spoofer that mysteriously stops transmitting, once a second, with nothing said on screen and nothing in the log. * Each step is now checked and named: "stop refused", "set addr refused", "restart refused". Three consecutive failures latch rotation off and put one line on screen saying which step it was. * Advertising is restarted whatever happened in between. Silently going dark is worse than a stale address, and a stale address is what every release before 0.2.7 did anyway. * Entering a spoofer resets the latch, so a transient refusal does not disable rotation for the rest of the session. +760 bytes. 0.2.7 - 2026-09-19 ------------------ The BLE spoofers stop transmitting from one fixed address. Bluetooth: * BLE Spoofer, Sour Apple and AirTag Spoofer now rotate their BLE address once a second. All three built a random address and then dropped it on the floor - nothing called esp_ble_gap_set_rand_addr, setOwnAddrType or ble_hs_id_set_rnd - so every packet went out from the ESP32's one fixed address. A stream of a dozen different Apple products sharing an address is not a disguise, and it made the transmitting device attributable for as long as it ran. This was documented rather than fixed in 0.2.4, because making the spoofers harder to attribute is an increase in what they do rather than a correction to what they claim. That is the owner's call and it has now been made. * Non-resolvable private, not static random. ble_hs_id_gen_rnd(1, ...) asks the stack for an NRPA, which is the address type the specification defines for exactly this; a static random address is meant to hold still for a power cycle, so rotating one is out of spec even though the controller allows it. Letting the stack generate it also sidesteps the top-two-bits rule, which the old dead code got right once (0xC0) and wrong twice - 0xF0, and on the wrong end of the array for NimBLE, which takes the address little-endian. * Once a second rather than once a burst, because the controller refuses HCI_LE_Set_Random_Address while advertising is enabled, so a rotation is stop, set, start. AirTagSpoofer carries a note that repeated stop/start was resetting the board, so rotating every 40 ms burst is the exact pattern to avoid. kBleAddrRotateMs is the dial if the board turns out to tolerate more. On a failure advertising restarts on the old address rather than staying dark. * AirTag Spoofer's address display reads the stack instead of a kept copy. NimBLEDevice::getAddress() prefers the PUBLIC address and only falls back to random when there is no public one, which on an ESP32 never happens, so it would have gone back to confidently showing the one address the radio is not transmitting from. ble_hs_id_copy_addr(BLE_ADDR_RANDOM, ...) gets the real one. +504 bytes. The four NimBLE symbols are in the linked image, checked with nm rather than assumed - including ble_hs_hci_util_set_random_addr, which is what actually issues the HCI command. 0.2.6 - 2026-09-19 ------------------ A crash fix, the enclosure carrying the logo, and a way to see the screens without a board. Fixed: * Opening the second Bluetooth page would have faulted the device. Adding Fast Pair in 0.2.5 raised BT_PAGE1_FEATURES from 2 to 3 but left bluetooth_page1_icons with two initialisers, so the third was null and displayPagedSubmenu() handed it straight to drawBitmap. It compiled clean: an array with fewer initialisers than its size is legal C++ and neither -Wall nor -Wextra warns. * tools/check_menu_tables.py now walks every label and icon table in the sketch and asserts each has as many initialisers as its declared bound, and that a menu's labels and icons share a size constant. 28 tables, 12 pairs. Run against the bug first to confirm it fails on it. UI: * A selected menu tile is FILLED with the accent, with its icon and label dropped to the background colour. It used to move a 1 px border and the label from white to accent - and the icon did not change at all, since every icon table is already UI_ICON and SELECTED_ICON_COLOR was defined as UI_ICON again. On a 100x60 tile that was the whole cue. Both grids, the main menu and "More". Flash is byte-identical: a constant swap. * SELECTED_ICON_COLOR is deleted, having no users left. It would not have tripped -Wunused; it was a #define. * "by: magikh0e" on the boot screen, under the artwork. The logo height ceiling in Branding.h moved with it: four lines under the logo is a 62 px block and caps the logo at 217 px, where three lines and 46 px capped it at 249. At the shipped 200 px there are now 8 px of slack rather than 24. Enclosure: * The owl is debossed into the chin of the lid, 30 mm at 0.6 mm deep. LOGO = false turns it off and produces an STL identical to the pre-logo one, triangle for triangle. * Debossed rather than embossed because the artwork is line art: the thinnest 5% of strokes are 8 px of a 474 px height, so at 30 mm they land at 0.51 mm - one extrusion wide on a 0.4 nozzle. A raised rib that thin shears off; the same feature cut in is a channel that cannot. * tools/trace_logo.py turns the PNG into outlines with marching squares and Douglas-Peucker, since OpenSCAD cannot usefully extrude a bitmap and there is no potrace here. It rasterises what it wrote and refuses to write when more than 3% disagrees with the source; at the shipped tolerance it is 0.02%. * docs/pueo/logo-fit-test.scad is a 76 x 56 x 3 tile to print first. The lid is a tray, so it must print face-down or its ceiling is an 80 x 165 bridge - which puts the logo against the bed. The tile carries the same owl on both faces to show which side does it better, plus a ladder of eight grooves 0.3 to 1.6 mm wide at the shipped depth. * tools/check_logo_scale.py asserts the artwork's height, width, centring and area at three sizes, and the lid's removed volume. It exists because the first version rendered the owl at 7.9 mm instead of 30 and 11 mm off centre, and two renders were inspected without noticing. OpenSCAD converts SVG px to mm at a fixed 96 dpi; resize() measures the box that turned up instead of assuming one. Documented: * docs/pueo/jammers.md, to the same standard as the Fast Pair probe. The two radios are not the same story. BLE Jammer and Proto Kill set RF_CONT_WAVE with the power bits at maximum, so they are an unmodulated carrier - certain. SubGHz Jammer sets 2-FSK at +12 dBm and calls SetTx(), and nothing is ever fed to it: no FIFO payload, and TX_PIN is only ever driven LOW. What radiates is NOT established, and no constant-wave bit is set, so nothing in the code states the intent. * Also recorded there: none of the three can report whether they worked, and none ever will, because knowing you had suppressed a link means receiving it. Tooling: * tools/render_screens.py draws the boot screen, main menu and Bluetooth submenu to PNG from the firmware's own bitmaps and TFT_eSPI's own font data, at the coordinates the source gives. Not a mockup - the text is the panel's 5x7 GLCD font and its 16 px proportional font. Version and byline come from Branding.h so the images cannot go stale. * art/ is out of the source archive: 7389 KB -> 6166 KB. It was a 1.3 MB photograph whose only job is regenerating a bitmap already committed in icon.h. The merged image's digest does not move, because the firmware never depended on it. 0.2.5 - 2026-09-19 ------------------ Google's side of the BLE world, which this device had never looked at, plus a Flipper .sub reader and two more advertisement templates. Bluetooth: * New feature: Fast Pair, on page 1 of the Bluetooth menu. Two halves that are deliberately not the same act. Listening is passive - a BLE scan with active scanning off, reading the 0xFE2C service data every Fast Pair device broadcasts unasked. Probing transmits. It opens a GATT connection to ONE device the operator selected and writes 80 bytes to the key-based pairing characteristic, to test for CVE-2025-36911. It sits behind a confirm screen that names the target address and states what will be sent, it never runs on its own, and it never touches more than the selected row. Point it at your own devices or ones you have permission to test. * ESP32-DIV/FastPair.{h,cpp} parses the advertisement. Fast Pair lives in BLE service data, not manufacturer data, which is why the three Apple-shaped parsers already here were structurally incapable of seeing one. A payload is a 24-bit Model ID only when it is exactly three bytes. Every other length is a not-discoverable frame - flags, account key filter, optional salt and battery - with no Model ID anywhere in it. Reading its first three bytes as one produces a confident, plausible, meaningless number, and since most earbuds already belong to somebody that is the common case rather than the edge case. * Nothing correlates devices across address rotation, because Fast Pair deliberately offers nothing to correlate on. A Model ID names a model, not a unit, so two strangers with the same earbuds share one. An account key filter is a salted bloom filter whose salt rotates precisely so a passive listener cannot follow the device. Two tests assert those non-properties, so adding correlation later means deleting a test that says why not. * The model-name table is empty on purpose. Google's mapping needs an API key, and the lists circulating for "fast pair spam" tools are IDs chosen to provoke a popup, with names that are often wrong. An unknown ID prints as six hex digits, which is true. * BLE Spoofer gains Microsoft Swift Pair and Flipper Zero, device types 22 and 23. Both built from primary sources. Swift Pair sends sub scenario 0x00, pairing over LE only. The other two values the spec defines describe a dual-mode peripheral and 0x01 also requires a BR/EDR address in the same advertisement; this has none, so sending either would advertise a capability that is not there. Several spam tools send 0x03, which the spec's table does not define at all. The display name is Pueo-XXXX rather than a real product's, so the operator can tell their own notification from a stranger's. Flipper uses service UUID 0x3080 with the hardware colour in the low bits - from Flipper's own serial profile, not from captures - so 0x3081-0x3083 are one service on differently coloured hardware. * Found, not changed: devices_uuid is handed to addServiceUUID() immediately before setAdvertisementData(), which replaces the whole payload, so it never reaches the air. It also carries 9000 where the Bluetooth base UUID has 8000. Removing it should change nothing, but that is analysis rather than a measurement. Sub-GHz: * ESP32-DIV/SubFile.{h,cpp} reads Flipper .sub captures. Pueo's own export is a binary blob with a magic number that nothing else can read; .sub is what the rest of the sub-GHz world trades in. RAW files are refused by name, three ways - Filetype, Protocol: RAW, and RAW_Data. They share the key file's header and carry microsecond timings where the key would be, and a SubGhzProfile has nowhere to put timings, so half-parsing one yields a profile that looks valid and transmits nonsense. Only Princeton claims an rc-switch protocol number, because Princeton is PT2262/EV1527 and rc-switch protocol 1 is the same thing. CAME, NICE FLO, Holtek and the rest parse and return protocol 0 with the name kept. Their timings do not match an rc-switch number and a guess would transmit something subtly wrong while presenting as correct. * Nothing calls the .sub reader yet. Wiring it in is a UI change - saveProfile() reads globals rather than taking a profile, and there are only five EEPROM slots, so an import has to ask which to replace. Checked: * tools/check_fastpair.py, 80,111 checks. Every fuzz input asserts (frame == ModelId) == (len == 3), which is the whole rule in one line. * tools/check_fastpair_probe.py, 50,293 checks, most of them on the provider address at bytes 2..7. That field is the test: a probe broken so subtly that every device reports "no response" is indistinguishable from a room full of correctly-behaving devices. * tools/check_sub_parse.py, 60,891 checks, including every single-byte mutation of a valid file. * tools/check_ble_adv.py, 1,095 checks. Walks each advertisement the way a receiver does and insists the walk lands exactly on the packet's end - a wrong AD length byte still transmits, still looks right in the log, and is silently dropped by everything that hears it. It also walks the three older fixed templates, which had never been checked; all are well formed. The Google one decodes as a discoverable Fast Pair frame advertising model ID 00B727, one fixed model, never varied. Fixed while writing the above: * mbedtls here is 2.28, so the 3.x MBEDTLS_PRIVATE field accessor was wrong. * deriveKey() called mbedtls_ecp_point_init instead of _free in its cleanup, leaking a point per probe. Still true: none of this has run on hardware. The Fast Pair probe's crypto path in particular has never derived a key against a real provider. Treat the first run on real hardware as the first test of it. 0.2.4 - 2026-09-19 ------------------ Mostly reading what features already claimed, and fixing what turned out not to be true. WiFi: * The promiscuous filter is global and sticky, and nothing set it back. Two features asked for management frames only, so Packet Monitor - which wants data frames, because 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. * Packet Monitor recognises WPA handshakes in the frames it was already recording. ESP32-DIV/Eapol.{h,cpp} finds the 802.1X payload by computing the header length from the frame control field rather than testing a fixed offset, so it still works on a frame carrying a fourth address or an HT Control field; the four messages are told apart by three bits of the Key Information field, plus the Key Type bit so a group rekey is not mistaken for M3. The header shows how many networks have M2 and M3. * Forcing a handshake by deauthenticating a client is deliberately NOT wired up. The decision logic exists, disarms on capture and after six bursts, and nothing calls it - Packet Monitor runs in WIFI_MODE_NULL and cannot transmit anyway. See docs/pueo/eapol-capture.md. Bluetooth: * AirTag Sniffer can tell a separated tag from somebody's phone. Apple uses type 0x12 for both the long form that carries the rotating public key, which a device broadcasts once separated from its owner, and a short online status ping that every participating iPhone and Mac emits. They differ in the length byte, which the parser had and never read. Battery and the maintained flag are decoded from the status byte, which was being stored and never shown. Scanning is passive now: an AirTag advertises non-connectable, so the scan requests bought nothing. * BLE Scanner drew manufacturer and service data as text, with String((char*)raw.c_str()), which stops at the first zero byte. Apple's company identifier is 4C 00, so every Apple device displayed as the single letter L. Both are hex now, capped to the row width. Tx Power is guarded by haveTXPower() like the fields below it, rather than printing whatever was left in the field. * Skimmer Detect's signatures carry a Radio column. It scans BLE only, so eleven of its twenty-eight names can never match, including seven of the eight rated 5 - HC-05 and HC-06 among them. Kept and marked rather than removed. The table's order is load-bearing and now says so. * Sniffer's two alerts are annotated rather than changed. Jamming Suspected fires on any device seen more than twenty times, a counter that is never reset, so about two seconds of a normal beacon. MAC Spoofing Suspected cannot detect a MAC change at all: the row is found by matching that exact address. See docs/pueo/ble-sniffer.md. Sub-GHz: * One frequency list instead of two identical copies in different namespaces, which nothing kept in step. Tooling: * tools/check_eapol.py and tools/check_airtag_parse.py, modelling the parsers against synthetic frames behind a buffer that refuses any read outside them. 0.2.3 - 2026-09-19 ------------------ Tooling and documentation only. The compiled image is identical to 0.2.2 apart from the version string. * CHANGELOG.txt, this file, now ships inside the archive and sits beside the downloads, so deciding whether to flash an image does not mean unzipping the source first. * tools/make_release.sh takes PUEO_PUBLISH_DIR: a directory to copy the finished artifacts and the changelog into, verified with sha256sum -c afterwards. An environment variable rather than a path in the file, because the file ships inside the archive. * The same script now refuses to re-cut a version already in dist/ unless --force is given. Re-running it on a released version silently produced a different archive - the script is itself inside the archive - while the digest published beside it stayed as it was. * How to verify an archive, and where, is written down in that script: extract to a short path and delete it afterwards. A deep path fails on Windows' 259-character limit, and a junction from a short path to a deep one defeats -ffile-prefix-map and quietly breaks reproducibility. 0.2.2 - 2026-09-19 ------------------ Licensing: * Relicensed to GPL-3.0-or-later. Upstream's MIT notice is kept verbatim in LICENSE.MIT and those portions remain available under MIT. * RF24 removed. It is GPL-2.0-only and could not lawfully share a binary with arduinoFFT (GPL-3.0-or-later) or NimBLE-Arduino (Apache-2.0) - a conflict inherited from upstream's dependency set and carried since 0.1.0. It was the only GPLv2-only library in the tree, so the merged image is distributable again. * ESP32-DIV/Nrf24Raw.{h,cpp} replaces it: the nRF24L01+ register interface, scoped to power, channel and an unmodulated carrier. MouseJack, the ESB paths and the skimmer detector never used RF24 - they already drove the registers directly - so only the jammers had to change. 3,476 bytes smaller. * docs/pueo/licensing.md records the dependency table, the grants it is read from, and why "version 2" and "version 2 or later" was the whole question. Spotter: * Fingerprints the probe-request information-element set, so a camera that randomises its MAC can still be followed. FNV-1a over the elements a device emits; ids always, contents only where they belong to the device rather than to that probe, which leaves the SSID and the channel out deliberately. * One device is one row when it changes address. A new address folds into an existing row on a fingerprint match, but only when both addresses are locally administered: two cameras of one model share an element set, and merging on the fingerprint alone would report one where there are two. * Rows show whether the address is made up, how many times it has changed, and how long the device has been in range. * Capture to SD on the Log button, off until switched on. One row per device, and every device rather than only the matched ones. * No fingerprint signature table ships. The values need captures off real hardware. See docs/pueo/ie-fingerprinting.md. Fixes: * The jammers hop on purpose now. They configured three RF24 objects over three channel groups, which reads as twelve channels across three radios and was neither: the board profile maps all three chip selects onto one module, and the setup loop called startConstCarrier once per channel with each call replacing the last. The hopping that did work was in the feature loops, picking a random channel every iteration and writing it three times. Now round-robin on a 4 ms dwell, written once. * onPacket no longer discards wildcard probe requests. A zero-length SSID ended processing of the frame, which threw away the full element set those frames carry. Tooling: * tools/fuzz_ie_walk.py, tools/check_spotter_merge.py and tools/check_spotter_capture.py model the parts that cannot be tried on hardware that does not exist: 65,543 frames through the element walk with no out-of-bounds read, sixteen merge rules, sixteen capture rules. 0.2.1 - 2026-09-19 ------------------ * SpotterSignatures.h: Axon body cameras under a new Bodycam kind (00:25:DF is Axon Enterprise's own IEEE block, so Strong), and the union of the flock-you and flock-finder community OUI collections - 33 prefixes, plus the Flock SSID variants, the Penguin battery pack's BLE name and XUNTONG company ID, and the Raven GATT UUIDs. * Every prefix was resolved against the IEEE MA-L registry first, which is why nearly all land on Weak: 23 belong to Liteon and 2 to Espressif, blocks that appear in a great deal of unrelated consumer hardware. Each carries the assignee the registry names rather than the word "Flock". * The build stopped writing the build machine's home directory into the firmware. NimBLE's assert macros bake __FILE__ in, so the absolute path of every asserting source file was in the published 0.1.0 and 0.2.0 images - seventeen strings of it. -ffile-prefix-map rewrites them. Side effect: the merged image no longer depends on where it was built, so a rebuild at any path matches the published digest. * docs/pueo/nrf24-fit-test.scad: a test print for the one enclosure pocket with no nominal slack. 0.2.0 - 2026-09-18 ------------------ Design work rather than firmware. The compiled image is unchanged from 0.1.0 apart from the version string. * docs/pueo/pcb-design.md grew into the carrier-board design: netlist, power tree, load budget, placement, module dimensions. * docs/pueo/pueo-enclosure.scad brought into the repo. * tools/gen_netlist.py generates the netlist, placement and BOM, and checks every signal GPIO against board_pueo.h so the two cannot drift. 0.1.0 - 2026-09-18 ------------------ First release of the fork. * board_pueo.h: a board profile resolving the pin conflicts in the stock CYD map, as an overlay rather than a fourth board branch. * SpiBus.{h,cpp}: one owner for the SPI bus the display, SD card and all three radios share, and the fix for touch losing it to the radios. The resource that conflicts is the GPIO matrix rather than the clock - MISO is an input and exactly one GPIO can drive it. * Spotter: passive detection of ALPR cameras and smart glasses. Receive only; it never transmits, associates or deauthenticates. * gps.cpp: gpsPortOpen/gpsPortClose bracket every UART open and close and hand GPIO 1 between the console and the GPS. * wifi.cpp: removed an out-of-bounds write in both deauth frame builders. * A brightness step that wrapped: stepping up from 248-254 dropped the backlight to almost off. * -w and -zmuldefs removed from the build, and the underlying issues fixed rather than silenced. The one load-bearing duplicate symbol is handled by weakening the SDK symbol instead. * IR support dropped: 140 KB that could never run on a board with no IR hardware. * tools/check_pinmap.py resolves the pin macros the way the compiler will and fails the build on a collision. Digests, every release ====================================================================== SHA-256 of every artefact this project has published, including releases that have since been pruned from the download page. The page keeps the newest two plus 0.2.1 and 0.4.13; this keeps all of them, because a digest is the one thing about an old release that is still worth having after the file itself is gone. Generated by tools/changelog_digests.py from dist/*.sha256. 0.4.33 6c778435ed8f66a7076efd546dbbec164f4ff72d678b9b6a8d822ce628c17b74 pueo-0.4.33-beacon-35-merged.bin c4e9674526624449f627829a362572398646156fc969dd297ab74cb46c051dfb pueo-0.4.33-merged.bin 6586de46a5128a87570048122713012972c77504a50ac0f6db067fcb6dfcef11 pueo-0.4.33-src.zip 0.4.32 339f7083664140de8598f4f827917b27ddadd94a6d5789ca76b23f183f1545d5 pueo-0.4.32-beacon-35-merged.bin 871ac59be62bb34e9e9f0fb777f68e1dc1ee316ef330e6888cd6cafdf3da9d9d pueo-0.4.32-merged.bin 1ead58897fd2975cfc523ba15a00704db4975205ebf7c516a16f505ad7ba7f4a pueo-0.4.32-src.zip 0.4.31 a50de4291e02c9a9798822d2acd6894184d67c5c897fe9e43a55303a12114ad2 pueo-0.4.31-beacon-35-merged.bin 9ac414048aed9a98c232f5d4e2fd750e9b8c0ed46452566682b7e65852a83a25 pueo-0.4.31-merged.bin d061c8412c0728c7b78c506484e60c1555800bf1ac24e4d4c5e4829f141de8f1 pueo-0.4.31-src.zip 0.4.30 b8b36cf6e16daae87dd4513f58ecedd8434684c3c5beaca62a4146cb8455dec5 pueo-0.4.30-beacon-35-merged.bin 50bbb3b24cab3c203c136ae47613910a7c7c2f45fb2e2877c83bb355ce0b0d38 pueo-0.4.30-merged.bin 572fc38e558a323a0ad761938025cca6b0c185d54505df0d055371b2b258d660 pueo-0.4.30-src.zip 0.4.29 81c9a9f465d8dd9323249b1a81615a725f275290351a85cd7a2962d62489fac9 pueo-0.4.29-beacon-35-merged.bin d699eac20d7e9b6bd071b929da06c160ea68466fba70d24755e682b04dade1d0 pueo-0.4.29-merged.bin 61ebe5fb387e6f502a81dda44ce4dd480b2f8c891b371b0a088f066914ce32c8 pueo-0.4.29-src.zip 0.4.28 de9199d9e83da60e1690b74476329a2323818ddf986c6b9909c237879c6ff8ed pueo-0.4.28-beacon-35-merged.bin 98c293cf029a69c994c5f865f190762a513ee7d6e5c28700e31a66c46092006c pueo-0.4.28-merged.bin c96500ec008f2c2334b5b0025cb8e82e11a399388920da98eae7ac879fb7ff3f pueo-0.4.28-src.zip 0.4.27 d733a451b02b651a6eb8ae1c089d7b6facd7dc5227cc3af67b6ba73b7967032e pueo-0.4.27-beacon-35-merged.bin 4d21874d25376de8560dc62030687d2249b20aece273cbc12b10d1ec5d847040 pueo-0.4.27-merged.bin dbb07e71740b81ae944bde46cc08d25227368ef2a79fd6ae4bffc49d99b7ca87 pueo-0.4.27-src.zip 0.4.26 1f7dcd99e29cb409a864fbf15f13b0d7fecdcb33281f8ecc8e4cfcef1a748771 pueo-0.4.26-beacon-35-merged.bin 2e8750633437c2697ff91e92cf9043a2938149aaa739aeb2eccfd636d8abe36c pueo-0.4.26-merged.bin 2faa8d84a2637ed3873d9a6ef74bad6ff50009b9547506411d249a6724eb0b17 pueo-0.4.26-src.zip 0.4.25 dae3eb5f13b1cf0d274f9a1ccb25ce290e08754c63b56a0d20734c73cfb06573 pueo-0.4.25-beacon-35-merged.bin c2fcb64ec675f0a3929aa49a0dbe745c69cbbe62c4d2859914734cf5089e639f pueo-0.4.25-merged.bin 2819def361ad4738b948ee1d9668b5081de3fb3d721e4001b388bb25bd82d93d pueo-0.4.25-src.zip 0.4.24 84a7695f67fdb7e1739d7b3a487b9c476df1ab7685e4176c3b81833392a97c6f pueo-0.4.24-beacon-35-merged.bin 467cb649506239cb77ec4d955e54413774b78fe332856115553cef6f0bcbbaac pueo-0.4.24-merged.bin 1d38ab4485265e396aa6ea527139b5b6f1e198749ee6e0b4dd3e84cb53376c05 pueo-0.4.24-src.zip 0.4.23 1bc9e3e8b5376fc78e9cfca7fa6ad696bf4f5f50b9372b3015e1883dc4fc4b64 pueo-0.4.23-beacon-35-merged.bin 4124ff0d509975eb0c98cb5a0cdfcb3f4c9d142fc764b82981001b9753c8ecdc pueo-0.4.23-merged.bin e51056e7e72eebaec7d98bc2426d9be50e85b7862150a502bee4028556b9883a pueo-0.4.23-src.zip 0.4.22 04730d45cfee65d7b95670537c476ef1e68bc837fb473e2e733d5633a4fcc99f pueo-0.4.22-beacon-35-merged.bin 486a8fbe17708c7176aee3a7f85df8e7c5f3100c0e6594a58399ace6a41cfc9f pueo-0.4.22-merged.bin 3a948bb24938aa0756f2544c879c699bab4b691b96a6b7d7e7a35ec33deb5785 pueo-0.4.22-src.zip 0.4.21 f121fe7b0d6e369765b4c81425d6fef35ca9cc7e26dc4d840a578ed4b12f713f pueo-0.4.21-beacon-35-merged.bin a5fac79860429ec8b81c01643e5d539ce952a2fc4fba3ccf0a0e8a9686930370 pueo-0.4.21-merged.bin 32f423eeaa75bbe8e68dce26eb7f2588563625551de0eae3a290e14272095422 pueo-0.4.21-src.zip 0.4.20 97150ebdd0268ef88bf46ebb6607a953320cbb8d145c1413a0e9de93b5b7436b pueo-0.4.20-beacon-35-merged.bin 2a2e290436293285cf541bdd6fa69894dbe8c8ca08c9ee625ec18d0bc4819817 pueo-0.4.20-merged.bin 7c6aed9ff2491a261fbd11aed9bb2e5e4ef6b817edb8a5ea1e01d5b31692d6a9 pueo-0.4.20-src.zip 0.4.19 f08ee592f3cfdc3c4424180603e7ee3b4cec31556104f19431d8eba261181c01 pueo-0.4.19-beacon-35-merged.bin 79934a1959c51edda5418f6bddde0895ab86ac717507726df4af3b19fe2afa36 pueo-0.4.19-merged.bin 46b625aca505b6bac35fa121cbf55a47d4cdf45ee7f6a4eb4ab80a15675e5c48 pueo-0.4.19-src.zip 0.4.18 90a5fa94bfe13d9a3effdd4d422eb61d617d6a2d9e44a572ab421060ab3f1585 pueo-0.4.18-beacon-35-merged.bin b510882bb64e24757f3f7936b01279664570f0290dd784f8732aa107b7f42f54 pueo-0.4.18-merged.bin 54e0c4577c37127cb297dfe2840813d85b6788656b0efaa4cff89f2428cd8d77 pueo-0.4.18-src.zip 0.4.17 b824de798e90bcb717d9ebdffe41ceecba3df9af729d7b6dbec7114583c5ac2c pueo-0.4.17-beacon-35-merged.bin d0d7eaa5b7a5f406711cb06b427f6650c5da5cee60303a9d5216827ab74215be pueo-0.4.17-merged.bin d1f11352c072d26b64f463184d8737b8820d8acaffc9825460b75392032f5946 pueo-0.4.17-src.zip 0.4.16 6ec6a8993b6ad77a3fd1c7a7d39fdbd1ca0312b2723baf948811ced66a592347 pueo-0.4.16-beacon-35-merged.bin 0fd6aefd0ec71f704a5756558825e2cd2e0769b436d6c23ce28c04e255d777a4 pueo-0.4.16-merged.bin 4f50978bc050d76e7f1b51bf76e65fc12defbf75c1ca3d16a0e8bd6f59b1eba6 pueo-0.4.16-src.zip 0.4.15 c3507607dd8d76b31faceb074e877b144b615fb7ffd7d07ded2e126e55a3cc91 pueo-0.4.15-beacon-35-merged.bin 3f134db81efacd33fe195d921e6d85996ec0b107ec369944cb2117508f013659 pueo-0.4.15-merged.bin 6076b3a19a06b332b49c9578322379c85e08edbf01ca8de833cb926b245070de pueo-0.4.15-src.zip 0.4.14 b01bef5c7f506cae914663998d48a39c29a11c0238c48372321cc7e7ca41e6ec pueo-0.4.14-beacon-35-merged.bin 70df219c541ac6617d8aeca3996dd828693233eabddb5aa39206362a833f4c44 pueo-0.4.14-merged.bin bc9962cbd3048c575e9312f54e4d1ff807d598f9a4b833926e67db46c0062511 pueo-0.4.14-src.zip 0.4.13 a3ae32e760355c25d7d8487ac5098109f9db1975ac2daba3fd008218a046d0d9 pueo-0.4.13-35-merged.bin 8cbb2aa3e94cc77afcabd86a12d8dd738bd541ceace918b68a806d1213c61e45 pueo-0.4.13-beacon-35-merged.bin d575db2d58b569cc1ca02e12426b046649b9532f8f2be09e6ede8f4d72e42f0c pueo-0.4.13-merged.bin 5ca23355d8ff207c94352e2515467a07ec3ebe92cc05a762153f88fdc6d4060b pueo-0.4.13-src.zip 0.4.12 1f630bbcfcbabf76b52992683978a0860ed369511ff63efc58a710c45b181812 pueo-0.4.12-35-merged.bin 001250f4f03ac8a8d271a27b80bd5e9956d1514000df12bb571c099a4e2041d8 pueo-0.4.12-beacon-35-merged.bin b293eb60370a562c81e17ec1583364af73b470aa5b4cfc17fe6d431d4e920376 pueo-0.4.12-merged.bin b625b40070e0934ebc3a13eab79bec376d66adc95e4529c19aab996212a89be0 pueo-0.4.12-src.zip 0.4.11 d6b117850500619f60094baaa4b3bfb29b144381abeb9434c1e90a1724bcbecd pueo-0.4.11-35-merged.bin 34be23abde887984bd7045dc1147eb936b5191e193176dc5cc69a802f6d10af8 pueo-0.4.11-beacon-35-merged.bin ce33561f69cd313202054e83c8f8bd4c676fb0f3fedde5a57490c8026e4c40e7 pueo-0.4.11-merged.bin f308c58212d5a00f037a106364d3049671ff0c4dc0a5decdf357ae0779307711 pueo-0.4.11-src.zip 0.4.10 3854a8950c6988380191bd4ba1aff9ff5a6950b334d65b78d463867a60235631 pueo-0.4.10-35-merged.bin 6a6803974af575114d10de2c5429976b304c46d004da64136211b0338ef5f644 pueo-0.4.10-beacon-35-merged.bin cb20cdc9b9b26f3a4bd55b6b854043752c347b6a3b974963b2ac6bd188500a1a pueo-0.4.10-merged.bin a8f01f00c0537e6f4536c895fb1cdfd748d0a595b002c0659a10144d7b7b46c2 pueo-0.4.10-src.zip 0.4.9 a83935d1215733816d37b5f539b0c385807c90155e992c390fb104c5a07eb6ec pueo-0.4.9-35-merged.bin 0194d94d4a85fc86e2f579c733bd059a834c6affe6202e9b743584866f50f2e4 pueo-0.4.9-beacon-35-merged.bin 288e58f5a72af5db41073fc4e822763ef572ce7a33d2f27256adb9ddb3c061a6 pueo-0.4.9-merged.bin 93ab083a249a97908d52c45b8094ccac5c6181d946c504e05265c7b8123c6dc0 pueo-0.4.9-src.zip 0.4.8 ef803d30542c7b0783d87ca0bf19089ff6024766191b2727fed41f698add18ed pueo-0.4.8-35-merged.bin 5b35fc05d756a114327b18da8fae70245fdcaff1370cc147859ad196271a8b0e pueo-0.4.8-beacon-35-merged.bin 8cf5d3798a8fcaaeb58c9c4457c1265ef4a4b737bc6b25560758e604e2326d83 pueo-0.4.8-merged.bin 8f557fa93176bb47b36df528692f2987ab77e3a13ff2ae1a2869491559ad3140 pueo-0.4.8-src.zip 0.4.7 bfd92393e9d66bef35c974e18f8821b037671dac48e1447970a0933ff233bc40 pueo-0.4.7-35-merged.bin 0a7fd13708841f996cbe9bc8bd502e0d23915a8a73c101ea648c49de48b7073b pueo-0.4.7-beacon-35-merged.bin d0ba634b3333eba84c47e5237e455246bee9738f6dcb80c19494d6ded4ce39fc pueo-0.4.7-merged.bin c6259470c9c81bf93f4e46e3916bb1479393a2e4c04ae1a982042f5ad2d99ad7 pueo-0.4.7-src.zip 0.4.6 8684376e963ad62a7c7a78c4a24c84c58d7c7d9b1b4c2bfa028e5ac05c15ca65 pueo-0.4.6-35-merged.bin d9ecdee36cd1e7339bf2fd2fa2a3571f9640599ccadd3b16c61c5b920d3ea3cf pueo-0.4.6-beacon-35-merged.bin 54e5f5bff11d81684919e26747a90f67b55f233a378bf531a32505b5a4686f76 pueo-0.4.6-merged.bin 2160d0ffcc1a5d4d46ae5bd6841def8c4b796db90d51b9e30c73b14aff10c5d5 pueo-0.4.6-src.zip 0.4.5 d5ba47a7ee0fe0b1943ddd9be02c9f6f0ef6dc15cecfa26c15eac13810b3895f pueo-0.4.5-35-merged.bin 91d1add05d59d96ec7e0bbaf74d233c43d05c343b32074aa88f0542fed7006a1 pueo-0.4.5-beacon-35-merged.bin 59fd7926b6e83944bdabc5f77934bb39d480ac56bf3d9f4d5af198d1d69fb67f pueo-0.4.5-merged.bin de45949b6faddf6bdf9a30e7093cc743fe356da0945e22fc9859f1fccc8cecb3 pueo-0.4.5-src.zip 0.4.4 5d53754b8a070bb951d1c71474edb8940e75410a0e4e3309968a71cc942e6c85 pueo-0.4.4-35-merged.bin 4b3fcb198f47ef25f8498c94341cd30ea36b2ae329136457d635b1a4c42181bb pueo-0.4.4-beacon-35-merged.bin e5008eadd05ade3a0067fdfbc98bb55d430a295ab6389df7b66e5caf761872dd pueo-0.4.4-merged.bin 6949ab46cdb09f877f4f0273dff886f67e4807d1280c5a49782818f0c06c974d pueo-0.4.4-src.zip 0.4.3 62a336412617b64dab1b292b719fb1257e2e23298fac39bf7a6d29d89fd42997 pueo-0.4.3-35-merged.bin 9dbc0f7273cde2462ff708f94872a1dd882810ddee0c62c0ba1f95c03709e508 pueo-0.4.3-merged.bin 37acdad21509a08ec5557397614d45ffc4063447eee88e7418ad382ef27aee78 pueo-0.4.3-src.zip 0.4.2 0844295000e737fd36b46cac7639752715cd3ae8afe290942dc4c47ff2f14889 pueo-0.4.2-35-merged.bin 8fbff999233b14253d8ae45bd55f50de8ec7eca6f0a596df999af6533bfd7cbd pueo-0.4.2-merged.bin f17f486b402ac1295e7f44e64245a167bc95e1c59e961a77198e82aa298a34d4 pueo-0.4.2-src.zip 0.4.1 8edc75975843a0f06599c44bb726c3f9c51365e60013b41cca77ae16fc66a224 pueo-0.4.1-35-merged.bin bd587cafbb5dee4f402a48fd3ab3f75b0cefda971b2a38cc3c692df96cbc5f5c pueo-0.4.1-merged.bin e9a2306316e2be8b62eb2a1760a8ed9284dc54ae05fda50cd5b97f0775a19651 pueo-0.4.1-src.zip 0.4.0 c13156b3bf8b447a9f79d3d5a70188f0aee3db18aab04a636f4a0ec153a1560b pueo-0.4.0-35-merged.bin ae0e74406ae1660305c897e8171b8cb0d5288213eeaf6f671dfaa97ebb659f57 pueo-0.4.0-merged.bin ca21b9944cf1d89ca55f83435bd9869a8c4c3a1447e2cb269beb32fe778a823a pueo-0.4.0-src.zip 0.3.9 ebfb93dad85a23c206ee4608097622e017875b1f8e37dadda86e3017459481b9 pueo-0.3.9-35-merged.bin af6d0d8bbea2759b5f973a5971d72069102740592ebf621544f610ab7fa38257 pueo-0.3.9-merged.bin 6778d06326d5e9b92593ffa9a8d51146f1e539d9f5e0bfa7504b791cde63089e pueo-0.3.9-src.zip 0.3.8 94d2b203486fa85f0ed62cab322f07b0ab9e90e1c84906c6caf73af55974c979 pueo-0.3.8-35-merged.bin 5a18824729f98bc3909b2b3ee227d2f3ab7c6bf6eb4cf94348d69402b9b3fa72 pueo-0.3.8-merged.bin 8b39482dc902635c5c29e626973e1cf0fa515d9c31fd207cbb4259b394c4dc6c pueo-0.3.8-src.zip 0.3.7 5baa6f4ebbaca45b22f5424aea18271cdda4583a57ce369ec330718c55370ae6 pueo-0.3.7-35-merged.bin eb086ec5c26ccb5dd6504573fa09b3e6ca875fa846f850ebf607ac9b35352491 pueo-0.3.7-merged.bin aa175f87e178d740dc217cf6a86a6f331eb99a7f64a76bcfcfee1462b1a66e1a pueo-0.3.7-src.zip 0.3.6 c25e20a7c262f4298cf4a8173286c894e526438136c7bb36d52c340fac5d8760 pueo-0.3.6-35-merged.bin 6648f02db9fbf33cbfa85b9da25a73603cbeebe2b998f0c90051ea713cf33fbb pueo-0.3.6-merged.bin a715888f7c5b39d8ad23748f76b010f58f5740cf9bc0d3d5cdd91d8477f3c8ce pueo-0.3.6-src.zip 0.3.5 156b10ea0bb7030aabab24fed5d6541a05e017ca8a457f7441032af659d2848f pueo-0.3.5-35-merged.bin 3319778c0d9f42a22b9445eced3573abaae13188e568b420d574bfdc4f5cb59d pueo-0.3.5-merged.bin 72b7a3b1c060087ed274a1c3285b3d2abdac1f130fa73e7a3e0a752dcecabb1b pueo-0.3.5-src.zip 0.3.4 2716fb5299108283ddc903734ff23f76bdabb8f9d4898063273c7a06b9986b71 pueo-0.3.4-35-merged.bin 4f9877a05779174f5ff5cb66410ccae5401e3813ea0fec1830ccebdaaac8d94c pueo-0.3.4-merged.bin 7e3f4a3f314995b850aad65685090a75a45ca32a6190e20d0732e51c8fc3c0f9 pueo-0.3.4-src.zip 0.3.3 443de7e517b048e2f1e819bc4cdc6eba2c4a5ce06ff91ed383e75fcac6f4b7ef pueo-0.3.3-merged.bin 26b8a2429520a5f5a9569f9191cf86eda70cb13a41d58034092ed9fcdf89ba2e pueo-0.3.3-src.zip 0.3.2 6bc0fc44cc8f886f70b1acf24eb3f9d2eb6091e222895f56913bc28b335c7c30 pueo-0.3.2-merged.bin 1668b9248f2b5789f374ed831be6377f08107ccfb54ec303340de2af82da29ad pueo-0.3.2-src.zip 0.3.1 23df7a65f578686329af8caade4527f6bbf40d373617d1c32a55d144c2316e4d pueo-0.3.1-merged.bin 7a51af80a0a02dad228acefd41c137633eba07e83343a8e1c9f51f130739f0ba pueo-0.3.1-src.zip 0.3.0 1d19fbfbb7386ec872e0f523f1c9edb9a56e1b45e6be5bf61560427e2cc3e622 pueo-0.3.0-merged.bin 2f6efd2b4c213eac4a485ca0e89bdaf410e9a5df778783ae06887ca5ebb0c277 pueo-0.3.0-src.zip 0.2.9 31797b87ed347aa1a643ae89079de19ae7e441c613206272c4611ee1cffe535b pueo-0.2.9-merged.bin f8d67498913668b1982e786a63859c9b3d722a459e80d8a54d22c077812386c2 pueo-0.2.9-src.zip 0.2.8 ed176c9c94f177afb06043157eee05de5a6f695184a434be1b6ae7ee9c5459fc pueo-0.2.8-merged.bin a9c8359826704e79bcfe4fea131980a31060a1bda66b601335d5ea6c1cc4f2c2 pueo-0.2.8-src.zip 0.2.7 f8a5c0cc870c79819125b1796b5997332a02a27972f3d72ead86816863c9d252 pueo-0.2.7-merged.bin 1ee9789f7da5dabf96f865515fc33e35c6bf70fbb635b26828ac0858de394a19 pueo-0.2.7-src.zip 0.2.6 f0a9d490a16c0d56135bc3da9f9b7b9e853eec2a48fd242a7ef2613a95b62281 pueo-0.2.6-merged.bin e032f386e7b665f1118f7e7d73bd9fcc8074f850e5aa408124a1a2c79735604f pueo-0.2.6-src.zip 0.2.5 5be95db78a628f7aa429ab0eee680311ff1ea7249c5dc06e4ab8c7bb627df3dd pueo-0.2.5-merged.bin 30570e3f7ea832cc36a3596f970799e0e14efb43151c948e2f00fa95ca0677d2 pueo-0.2.5-src.zip 0.2.4 56298b693c996beb9a8f6e2296b14a114a7100a5d31636228f0d9ebecaadcd04 pueo-0.2.4-merged.bin c7404c4b18594b716e1b041ebdcab9677129d199bc6bd96d05b98dd40756286c pueo-0.2.4-src.zip 0.2.3 0018ec75d17ad84845a31eae373f4215a2d05df16b9648391399632cca350842 pueo-0.2.3-merged.bin 7e15e0c16fed967506e56d2182fd66f65a1671c94eb80a5fa7efcd93cd3ac830 pueo-0.2.3-src.zip 0.2.2 631007016b78cc6b42d6f5e11c2895f6048f0f6007eeb635327ca8dcb6f3259e pueo-0.2.2-merged.bin 292dc4ccff4a47025251b5b16cafb5122d152f87a585acc79ddf0beed4e9f8c8 pueo-0.2.2-src.zip 0.2.1 76ecc8d9117ae22291187337d452929a1bdb7ca9bda8728e259c30614d401f03 pueo-0.2.1-merged.bin 1b1087c37b2d1d833cb5a86cea42dfbba2c0365e5431b9cffe8d76559f796d0c pueo-0.2.1-src.zip 0.2.0 c537b389af607cc455c8fee38c31eadd722c6bd5d60597203f172f6c13a9d56f pueo-0.2.0-merged.bin 17cc36eae7cf3f7eecc657e280fae377123dce55856218f9964388b5c1c4aadf pueo-0.2.0-src.zip 0.1.0 65abeff4e68cc1a785cee6239599816bb054e3e1b917f32643b741a4919633a8 pueo-0.1.0-merged.bin 2e57f35dda71462df6d15b7d5e1706ba41856cdd4f864a2a57a00794090dd1f4 pueo-0.1.0-src.zip