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 "arduino<BS>uild-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 + <literal>" 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-<hash>\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-<version>-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-<version>-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-<version>-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-<version>-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 <value> 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-<version>-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-<version>-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 <version>", 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_<ms>.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
