Skip to content

Dev: v3.10.5 - #468

Merged
engmung merged 3 commits into
mainfrom
dev
Oct 1, 2026
Merged

engmung merged 3 commits into
mainfrom
dev

Conversation

@engmung

@engmung engmung commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Promotes dev to main for v3.10.5.

Firmware

  • A panel that crashed says where. resetReason in /api/status said "panic" or "task_wdt" and nothing else - not where, and not which of forty installed patterns was on. The SDK had been writing a core dump to its own flash partition on every panic all along, and nothing read it. At boot the firmware now reads that dump's summary - the task, the cause, the faulting address, the PC and up to sixteen return addresses, and the hash of the image that crashed - and it keeps a breadcrumb through the reset: the pattern's slug, which of its calls was running (loading, constructors, setup, update, draw, or none of them) and where the module's code was executing from. Status gains a crash object with both. With the two together an address inside a module comes out as an offset ("+0x4e") that the .pfm's own symbol table resolves, whether the code ran from PSRAM or from internal RAM. DELETE /api/crash clears it, and the same record is printed on serial at boot ([CRASH] ...). It reports and does nothing else: no pattern is skipped, no reboot is forced, and what the panel does after a crash is what it did before. The breadcrumb covers the boot that follows a panic or a watchdog and is gone with the power; the dump stays in flash, so a board that panicked on an older firmware shows that dump, marked fromThisReset: false, until it is cleared. Checked on a board: a module stored through a null pointer in draw() while running from PSRAM, and after the reboot status named the pattern, the phase, pc +0x4e and the caller at +0xb6 - the offsets the module's own symbols give for those two functions - with the dump read in 62 ms; a dump left by an earlier firmware appeared as fromThisReset: false. It costs 296 bytes of internal RAM. docs/rest-api.md ("The crash record") says how to read one and decode it, and firmware/toolchain/check_crash.py, a new host check, boots the logic through every reset reason against every state of the breadcrumb and the dump; CI runs it.
  • Every pattern is 2.45 ms a frame faster. Copying a finished frame to the panel's bit-planes was the largest fixed cost in the loop: 5.96 ms of every frame, nearly half of Origin's. The kernel was one long loop with more live values than the CPU has registers and a plane count it only learned at run time; it is now three short passes with the 8-bit case written in, 390 instructions a column pair where there were 635, emitting exactly the same words (check_blit.py compares 70 million of them). Measured on two boards, alternating images: presentUs 5,960 -> 3,496 µs on the default and Audio editions alike, so Origin goes from 12.1 to 9.6 ms a frame and a 30 ms pattern to 27.6. With white balance or gamma tuned away from the defaults it is 3.8 ms. It also takes 1 KB less internal RAM than the loop it replaces. /api/status gains loopStackMin.
  • A pattern's code no longer competes with the network for memory. A loaded module's code used to need one contiguous block of internal executable RAM - the same RAM the console, lwIP and every feature run on - because "the S3 cannot execute from PSRAM". That is true only of the address the heap hands back: on the S3 the instruction and data buses share one MMU table, so the same PSRAM page can be fetched at that address + 0x06000000. The loader now places code in PSRAM, writes and relocates it through the heap's pointer, and runs it through the alias (src/core_module_memory.h, execAddress() in src/core_module_loader.h). On the Audio edition a module with 23 KB or 32 KB of code was refused and now loads, and internal heap with a module resident is about 27.8 KB whatever the size of its code, where a 10 KB one used to leave 21-23 KB. On a second panel a community pattern with 8 KB of code was being refused one load in four for want of 528 bytes; it now loads every time and leaves 29 KB free instead of 22. Measured on two boards, same boot, alternating: catalogue and community modules within -0.1..+1.2% of their old frame time; modules built to be the worst case (10-32 KB of code, all of it executed every frame) 2.6-5.3% slower; 400 switches between modules of different sizes with no reset and no refusal. The code block is given whole cache lines to itself, the load reads it back through the instruction bus before calling it, and a unit on which that ever fails goes back to internal RAM until reboot (moduleMemory.codePolicy). load.code in /api/status says where the code is. Module files and the ABI are unchanged.
  • A request that stops half-way can no longer reboot the panel. A multipart POST that ended after its first boundary - to any address, not only an upload - left the web server's form parser reading empty lines for ever with nothing to wait for, and the Core-0 watchdog reset the board five seconds later. The parser now knows a line that never ended from a blank one and gives the form up, tells an upload handler whose file had already arrived that it was aborted, and leaves none of the form's fields behind to answer for the next request (src/webserver/VENDORED.md, Fix 4). Reproduced on a panel before, gone after.
  • Three more requests that could reboot it, found on a PC first. curl -X POST to /api/patterns or /update with no form in it called the upload handler with no upload to read and panicked the board; a multipart request naming a 9,000-character boundary overflowed the network task's stack; and a raw PUT /update?size=N never saw its own size. All three are fixed in the vendored server (VENDORED.md, Fix 5), and all three came out of firmware/toolchain/check_parser.py, a new host check that replays cut-off, stalled and malformed requests - 13,953 of them - through the real parser and fails if it ever spins or holds; CI runs it.
  • A quote in a pattern's name no longer breaks the pattern list. /api/patterns wrote names into its reply unescaped, so one " or \ in a title made the whole reply unparseable and the /patterns page could not list, select or delete anything - including the pattern responsible. Names are escaped now, a sidecar name is read as the JSON string it is - an escaped quote no longer ends it, and \uXXXX becomes the character, so "Dynamic Moiré" is that before the pattern has ever been loaded and not only after - and a name too long for its slot is cut between characters rather than through one. The hotspot password got the same escaping in /api/hotspot.
  • A module's global constructors can use the host. They ran before the module had been handed its host API, so a pattern with a namespace-scope initialiser that allocated, logged or asked for a random number (float* trail = PFMem::allocFloats(n);) crashed the panel every time it was picked, having built and uploaded cleanly. Constructors now run after the entry point and before setup().
  • Lanes keep working past day 25. The five seconds a knob keeps to itself after a hand touches it was timed with a signed compare meant to survive the 49-day millisecond wrap; what it did was move the wrap to 24.9 days, after which a lane on any knob not touched since was held off for the following 24.9. A panel left alone would have stopped answering its microphone (Audio) or its weather (Performance) on day 25. Found by reading, not by waiting: the hold is a flag that ends once now.
  • A pattern that never returns no longer takes the console with it. A module's draw() runs on the render loop, and the render loop is on no watchdog: one while (a > PI) a -= 2 * PI on a value that has reached infinity - which float32 does where the browser's doubles did not - and the panel holds its last frame for good. The first console request that needed the loop then waited for it without limit, and the /patterns page opens with one; the server answers one connection at a time, so /api/status, /update and Reboot, which need nothing from the loop, went silent behind it, and a frozen panel could be neither diagnosed nor restarted without pulling the plug. A request now gives the loop up once it has not reached its frame boundary for 20 s (PF_LOOP_STALL_MS - measured from the loop's own stamp, not from how long the request has waited, so an upload waiting out a slow setup() is not mistaken for it) and answers 503 render loop is not answering; the request is taken back with one atomic exchange, so the loop can never run it on a stack frame that has already returned. /api/status carries loopAgeMs, loopStalled and loopSyncGaveUp, read without the loop's help, and every handler in the core and in the features answers the refusal instead of using what its body never set. The Status page says "not answering for 47s" in red, where it would have gone on showing the frame rate from before the stop under "awake". Nothing restarts the panel by itself: this reports and survives, and the Reboot button does the rest. Twenty seconds is clear of one feature lookup with the internet down and not of two in a row, so on Performance with a dead uplink a healthy panel can read "not answering" for some seconds and then carry on - which is why it does not say "hung". The console has to have come up once for any of this: its routes are registered by the loop on the first network link. Found by reading - no stock pattern hangs - then reproduced on a panel with a module that hangs on purpose: before, the pattern list got no reply and after it neither did status, until the board was reset over USB; after, the list answered 503 in 18 s, sixty more requests were refused at once with the heap unchanged, the Status, Update and home pages still loaded, and Reboot brought the panel back. Held by a host test that races the two sides against each other; firmware/toolchain/tests/modules/_hang_probe is a module that hangs on purpose, for the bench (src/core_loop_sync.h).
  • No more stray letters on the panel's own screens. A portrait line holds ten characters, and Adafruit GFX wraps an eleventh onto the next line by itself: the KNOB MAP's "TURN = SHOW" put a lone "W" in front of "K3 = EXIT", the install screen's "web console" did the same, and the default host name "patternflow" lost its "w" on the hotspot and UPDATE screens. The labels are shorter now ("TURN=SHOW", "console"), and names that can be longer than a line are broken into lines of ten by drawCenteredFit — "pattern" / "flow-a1b2", "pattern" / "flow.local" — keeping every line where it was.
  • A deck sent from the community's dock keeps its order. The console's one-click install from a link (/patterns?src=) took only .pfm and .json from a build's file list, so the catalog.txt every deck build carries was left behind and the deck landed in alphabetical order. It now takes exactly what a dropped zip does (catalog.txt and .pfs too) - one rule, installable(), for every way in.
  • A firmware upload survives a Wi-Fi hitch. The /update connection now waits up to two minutes for a stalled upload instead of five seconds, and the page gives up after five minutes with a message instead of waiting forever. Checked on a panel with a 12 s stall halfway through a flash. PUT /update takes a raw image, for curl -T. From Simone Majocchi (#450).
  • Two 64×64 modules chained make a 128×64 panel. PANEL_GEOMETRY in config.h picks the stock 128×64, one 64×64, or two 64×64 daisy-chained (the firmware64x2 PlatformIO env). The driver is told the module and the chain, and everything else sees the canvas; the stock build is unchanged. From Simone Majocchi (#446).
  • Performance v0.4.0 carries the clock and a Weather face, and its show catalogue holds 100 sequences instead of 48. An MQTT subscriber following a fast sequence stream now drains the broker and applies the latest value per knob each frame instead of falling one message per frame behind (a jump of more than 16 clicks in one frame is capped), and a publisher no longer replays its own retained snapshot when it reconnects. From Simone Majocchi (#445, #448, #449).
  • The panel is a network of its own. With no known Wi-Fi in reach it raises a hotspot, patternflow-a1b2 (its alias), WPA2, password patternflow until changed, and the whole console is at http://192.168.4.1/ on it - patterns, knobs, the Wi-Fi page to add the next place's network, updates. Modes on /wifi: auto (default: up fifteen seconds after the last link, down once a network is joined and nobody is on it), always, off; GET/POST /api/hotspot, and a hotspot object in status. The NETWORK screen shows the name while the hotspot is what there is. What the bench decided (src/core_hotspot.h says why, line by line): the channel comes from a scan - the least loaded of 1/6/11 - never a fixed one; alone, the radio runs AP-only, and the station comes back only for a rare probe with nobody connected or when credentials arrive; a DNS responder on the hotspot resolves every name to the panel and the phone's internet probe fails within a second instead of timing out for twenty; the hotspot is 20 MHz. Pretending to be the internet was tried and put a Samsung into its limited-connectivity state, which drops the network.
  • Console services start on any link. Every core page, and the audio, microphone, clock and MQTT pages, used to wait for WL_CONNECTED before registering - on the hotspot a phone got an address and port 80 never opened. PatternflowWifi::linkUp() is the condition now, the station or the hotspot, and the hotspot raises the same link edge the station does.
  • Full transmit power. The 13 dBm cap from 2026-08 is retired (PF_WIFI_TX_POWER is the radio's 19.5 dBm again). Measured on the hotspot: a phone next to the panel took 4-9 s per 10 KB page at 13 dBm and under a second at full power - the phone's own transmitter had hidden the asymmetry, and a router's antenna had hidden it on the home network. Owner's decision.
  • Switching console tabs no longer downloads the page again. Page addresses carry the firmware's build (/patterns?v=2a535f30), and a page asked for under the running build is cached for good - a second visit to a tab costs no request at all (measured on a panel: /patterns 15.5 KB in 335 ms the first time, nothing and 16 ms after). A new build is a new address, so an update is never answered from an old copy; an open console notices the new build in /api/status and moves to it by itself, unless you are in the middle of typing or an upload, when it offers a reload instead. Pages without v revalidate every time (ETag + 304), an old v gets a 302 to the current one, and the shared header script is addressed by its own checksum (/pf-console.js?h=…, stamped in by console_pages.py). Only names that can only be the panel - an IP, a bare name, .local - are ever given a cache lifetime: the hotspot answers every DNS name, and a phone must not keep the console as someone else's site. On a home network the other tabs are fetched in the background once the page is idle, so even a first visit is instant; not on the hotspot, where the link is the scarce thing. /api/status gains build, viaHotspot and busy, and escapes the network's and pattern's names (an SSID with a quote in it made the reply invalid JSON).
  • One header for the whole console, and one way to talk to the panel. The shared header now lives in a shadow root, so no page's CSS moves it - it had a different height, width and position on every tab, which is most of why switching felt jumpy. On a phone it is two fixed rows with the tabs in one sideways-scrolling strip (Knobs is a tab now), and it says when the panel is not answering, restarting, or has been updated. Pages reach the device through window.PF: polls wait for the previous reply, back off on a slow link, pause in a hidden tab and stop the moment you tap another tab, so the next page is first in the one-connection server's queue; the page's own requests go before the header's status request; uploads hold everything else back and ask before you leave. A small fallback is stamped into every page, so a page still works if the header script fails to arrive. console_serve.py --slow makes the desk as slow as the hotspot (round trips, a 5.7 KB TCP window, one request at a time) to design against.
  • Setting the panel up from its own hotspot. /wifi puts Add a network first when you are on the hotspot or nothing is saved, lists the networks the panel's channel scan saw so you can tap one instead of typing it, stops a case-only typo with an inline "did you mean" before it is saved, and reports what became of the network: joined, with the address to open, or why not - wrong password, not found, refused, no answer - on the page and on the panel's LEDs. On the hotspot with no station link, a network sent from the page is tried at once. The console's pages were gone over for phones on the way: sliders that let the page scroll, inputs that do not zoom iOS, accept="*/*" for the Android file picker, the hotspot shown as what it is instead of "offline", a status page that copies its diagnostics, an update page that confirms the new version after the reboot.
  • always no longer keeps the panel off its network after a boot. Raising the hotspot starts with a channel scan, and the scan cuts the station's first connection attempt short; with the hotspot up the station is retried only every five minutes, so a panel set to always with good credentials sat off its network for five minutes after every boot (the console looked dead from the LAN). The hotspot now gives the station one attempt the moment it is up - on the bench it joined 1.4 s later - and goes AP-only only if that fails. The console also stopped calling a slow link dead: one reply over the timeout (now 12 s at least, not 5) no longer turns the header's state to offline, two failures in a row do - with the hotspot and the station both up, pages took 4-5 s and the console read "offline" after the first one.
  • The console's server waits one second, not five, for a connection that sends nothing. A browser opens connections it never uses; each idle one held for 5 s put the page's real requests behind it, which on the hotspot was the console appearing only after the browser gave up on its spares.

Web

  • A guide you can play along with, in three parts, at /guide (work in progress, marked WIP). /guide is where you pick one by where your Patternflow is, each with its chapters, in order. Build, at /guide/build, is for soldering one from bare parts: 01 Gather (the parts list is bom_v3.9.csv itself), 02 Order & print, 03 Solder, 04 Into the case, 05 Wire & power, 06 Firmware (which hands over to Play's 01 Flash) and 07 Check & close, each step played on the real v3.9 board and case in 3D, in the order the build happens; the board's known issues stay in BUILD_GUIDE.md §10, linked. Play, at /guide/play, is the first hour with a built one, 01 Flash, 02 Knobs, 03 Patterns, 04 Console, on the real v3.9 hardware in 3D, running a port of the firmware's input logic and screens, with the real flasher dialogs and a live copy of the device console. Make, at /guide/make, is 01 Community and 02 Pattern Lab, done by hand: on a big enough screen your own Pattern Lab, a practice community (placeholder patterns, connected to nothing) and a practice AI with set answers sit beside the steps, and a pointer shows each move and waits for yours; phones get the real screens. Sound, MIDI & OSC, MQTT, Clock, Performance and Editions join Make as they are written. Each guide numbers its own chapters from 01. Links into the first version, when Play was /guide itself (/guide#flash, /guide#knobs-3), land on the same step under /guide/play. English and Korean. Every step has a "Stuck here?" link to a GitHub issue that already says which guide, chapter and step (guide_stuck.yml).
  • The community's Performances note says .pfs, which is what the Director saves, instead of "Save-JSON".
  • A moderator can take a pattern or deck off the wall without deleting it. Somebody else's public pattern or deck now offers moderators Make private, with an optional reason: the softer removal, for things like the same pattern posted a fifth time. Nothing about the work changes and its author keeps it — they can still open it, edit it, take it into the lab — it just stops being shown to anyone else. On a pattern the reason is posted underneath as the moderator's comment, where the author can answer it; either way the author is told in their alerts. While the take-down stands the author cannot make it public again (a take-down its subject can undo in one click is a request); Restore to public puts it back exactly as it was. A moderator still cannot publish what an author made private themselves. A deck slot left empty by a take-down reads "made private by a moderator", not "by its author". Moderators can now comment on private patterns and keep their alerts about them, so the thread under a take-down works both ways. Migration 0025 adds hidden_at to patterns and decks; check:hidemod drives it all through the real routes.
  • One press of Publish is one post. A double-click on Publish to the wall, or a mouse switch that bounces, could put the same pattern on the wall twice in the same second (#455). The button's disabled state was React state, a render too late for the second click, and after a successful publish it came back enabled while the page changed. The publish, thread, reply, deck, header, performance and report forms now refuse a second press in the same tick (useSubmitLatch), and a published pattern keeps its button down until the page changes; a thread whose files failed to attach offers Open the thread instead of posting it again. Behind them the server answers an identical submission from the same account within a minute with the post it already made, looked up and inserted in one synchronous transaction so two requests arriving together cannot both get through. check:publish fires two identical publishes into the route at the same instant; a version that awaits between the lookup and the insert fails it.

🤖 Generated with Claude Code

engmung and others added 3 commits October 2, 2026 07:57
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner has run the image on a panel since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core v3.10.5, Audio v0.6.5 staged by shelf.sh from a sketch copy without patternflow_secrets.h; the previous folders retired to their tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
pattern-flow_origin Building Building Preview Oct 1, 2026 11:15pm UTC

@engmung
engmung merged commit 7f5aa64 into main Oct 1, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant