From 17421407f781629c557dc59dbd1e76d27f77465a Mon Sep 17 00:00:00 2001
From: engmung <122682380+engmung@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:26:59 +0900
Subject: [PATCH 1/2] firmware: the panel is a network of its own - a hotspot,
and the console on it.
A panel with no known Wi-Fi in reach raises an access point named after
itself (patternflow-a1b2, 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
and /api/hotspot: auto (the default - up fifteen seconds after the last
link, down once a network is joined and nobody is on it), always, off.
Status carries a hotspot object; the NETWORK screen shows the name while
the hotspot is what there is. Core, not a feature: Wi-Fi is the device's.
The bench ran six rounds before the phone loaded a page in under a
second, and each round is a line in core_hotspot.h's header:
- the channel comes from a scan (least loaded of 1/6/11), never a fixed
one - channel 1 sat under three -33 dBm networks and the SSID flickered;
- every console module waited for WL_CONNECTED before registering, so on
the hotspot a phone got an address and port 80 never opened -
PatternflowWifi::linkUp() is the condition now, for the core pages and
the audio, microphone, clock and MQTT pages, and the hotspot raises the
same link edge the station does;
- a station interface that is merely present - scanning, retrying, idle -
drags the AP's transmit to a crawl (a 13 KB page cut at 5.7 KB after
20 s), so alone the radio runs AP-only and the station comes back for a
rare probe with nobody connected, or when credentials arrive;
- the 13 dBm transmit cap made a phone beside the panel take 4-9 s per
10 KB page and under a second at full power: the cap is retired, the
owner's call, conformance to be handled elsewhere;
- answering the phone's internet probe with a 204 put a Samsung into its
limited-connectivity state and it dropped the network; a DNS responder
on the hotspot answers every name with the panel so the verdict comes
in a second, and the probe gets the console's ordinary 404;
- the web server waits one second, not five, for a connection that sends
nothing - a browser's spare connections were holding the real requests.
Verified on the bench board: home network joined at full power with the
hotspot down in auto; always brings it up beside the station on its
channel and auto drops it; the API's validation; the /wifi section. In
the field scenario (no network) with the user's phone: joins first time,
console pages 0.5-3 s, DNS answered, no drops over the session.
Co-Authored-By: Claude Fable 5.1
---
AGENTS.md | 2 +-
BUILD_GUIDE.md | 1 +
CHANGELOG.md | 7 +
FEATURE_GUIDE.md | 6 +
docs/rest-api.md | 24 +-
firmware/patternflow/console/wifi.html | 47 +-
.../features/audio/core_audio_ws.h | 2 +-
.../features/audio_in/core_audio_in_http.h | 2 +-
.../features/clock/core_clock_http.h | 2 +-
.../features/mqtt/core_mqtt_http.h | 2 +-
firmware/patternflow/net_config.h | 51 +-
firmware/patternflow/patternflow.ino | 30 +-
firmware/patternflow/src/core_display_http.h | 2 +-
firmware/patternflow/src/core_home_http.h | 2 +-
firmware/patternflow/src/core_hotspot.h | 422 ++++++++++++++
firmware/patternflow/src/core_knobs_http.h | 2 +-
firmware/patternflow/src/core_net_task.h | 1 +
firmware/patternflow/src/core_ota.h | 2 +-
firmware/patternflow/src/core_patterns_http.h | 2 +-
firmware/patternflow/src/core_send.h | 4 +
firmware/patternflow/src/core_status_http.h | 3 +-
firmware/patternflow/src/core_web_update.h | 2 +-
firmware/patternflow/src/core_wifi.h | 78 ++-
firmware/patternflow/src/core_wifi_http.h | 2 +-
.../patternflow/src/webserver/WebServer.h | 7 +-
firmware/patternflow/src/wifi_index.h | 537 ++++++++++--------
firmware/toolchain/console_serve.py | 17 +
27 files changed, 993 insertions(+), 266 deletions(-)
create mode 100644 firmware/patternflow/src/core_hotspot.h
diff --git a/AGENTS.md b/AGENTS.md
index 847d5b1f..e85dc47d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,7 +9,7 @@ It is standalone (not Eurorack), powered from a 5 V power bank through a screw t
## Repository map
- `docs/` — contracts and long-form docs: `EDITIONS.md` (the firmware seam — read it before touching firmware), `rest-api.md` / `osc-spec.md` / `midi-spec.md` / `audio-ws-spec.md` (wire contracts integrations are built against), `pfst-v2-spec.md` (show files), `panel-compatibility.md`, `RELEASING.md`, `SERVICES.md` (production server ops), the assembly map (`docs/assembly/`), the manifesto, investigations, and media (build-guide images in `docs/build-guide/`). Root-level guides: `BUILD_GUIDE.md` (the **v3.0 board**), `BUILD_GUIDE_v2.md` (legacy v2.x board), `PATTERN_GUIDE.md` (community → deck → Pattern Lab → device), `FEATURE_GUIDE.md` (writing a firmware feature or edition), `AUDIO_GUIDE.md` (sound into the panel). The live roadmap is patternflow.work/journal and the `/roadmap` page.
-- `firmware/` — PlatformIO/Arduino code for the ESP32-S3. Main sketch folder: `firmware/patternflow/` with `patternflow.ino`, `config.h` (pin mappings, brightness, limits, LED calibration), `net_config.h` (Wi-Fi / Improv / OTA / self-update defaults and the lane scale — core only; a feature's own tunables live in `features//_config.h`, and settings TUNE a feature, they never add one; per-device secrets in gitignored `patternflow_secrets.h`), and `pattern_registry.h` (function-pointer pattern table; compiles in Origin alone — the rest of the curated set ships as the Basics pack). Shared engine code lives in `firmware/patternflow/src/` (`core_display.h` HUB75 driver, `core_encoders.h`, canvas/color/math/noise helpers, Wi-Fi/OTA/web-console modules) — core only: OSC, audio, MQTT, weather, shows, MIDI and the clock all live in `features/`. `firmware/patternflow/abi/` is the frozen host ⇄ module contract. Curated patterns live in `firmware/patternflow/presets/` (`preset_origin.h`, `preset_wave_saw.h`, dated presets), each using its own pattern namespace; user patterns are `.pfm` modules uploaded to the device over Wi-Fi, built from `firmware/modules/` via `firmware/toolchain/build_module.py` (the old `custom1.h`–`custom3.h` root slots are gone). Features attach through `firmware/patternflow/features/` (named `addons/` until 2026-08-30; legacy spellings are shimmed for out-of-tree bundles — see docs/EDITIONS.md) — a directory and a descriptor of function pointers, with the core naming none of them (see its README). `firmware/bundles/` names firmwares built from the same tree (the shelf editions `audio` and `performance`, plus `clock` and `midi`, compositions the tree keeps buildable but does not publish; the default build has no bundle): two files each, saying which features compile in and what the build calls itself; `firmware/bundles/build.sh` builds the default or a named one, `shelf.sh` stages a publishable image. Device console pages are plain HTML in `firmware/patternflow/console/`, spliced into `*_index.h` by `firmware/toolchain/console_pages.py`. Tooling lives in `firmware/toolchain/` (repo-level scripts) and `firmware/patternflow/toolchain/` (PlatformIO `extra_scripts`, which must stay sketch-relative). Also: `firmware/encoder_test/` (standalone encoder diagnostic) and `firmware/CUSTOM_PATTERNS.md` (pattern authoring guide).
+- `firmware/` — PlatformIO/Arduino code for the ESP32-S3. Main sketch folder: `firmware/patternflow/` with `patternflow.ino`, `config.h` (pin mappings, brightness, limits, LED calibration), `net_config.h` (Wi-Fi / Improv / OTA / self-update defaults and the lane scale — core only; a feature's own tunables live in `features//_config.h`, and settings TUNE a feature, they never add one; per-device secrets in gitignored `patternflow_secrets.h`), and `pattern_registry.h` (function-pointer pattern table; compiles in Origin alone — the rest of the curated set ships as the Basics pack). Shared engine code lives in `firmware/patternflow/src/` (`core_display.h` HUB75 driver, `core_encoders.h`, canvas/color/math/noise helpers, Wi-Fi/OTA/web-console modules, the hotspot in `core_hotspot.h`) — core only: OSC, audio, MQTT, weather, shows, MIDI and the clock all live in `features/`. `firmware/patternflow/abi/` is the frozen host ⇄ module contract. Curated patterns live in `firmware/patternflow/presets/` (`preset_origin.h`, `preset_wave_saw.h`, dated presets), each using its own pattern namespace; user patterns are `.pfm` modules uploaded to the device over Wi-Fi, built from `firmware/modules/` via `firmware/toolchain/build_module.py` (the old `custom1.h`–`custom3.h` root slots are gone). Features attach through `firmware/patternflow/features/` (named `addons/` until 2026-08-30; legacy spellings are shimmed for out-of-tree bundles — see docs/EDITIONS.md) — a directory and a descriptor of function pointers, with the core naming none of them (see its README). `firmware/bundles/` names firmwares built from the same tree (the shelf editions `audio` and `performance`, plus `clock` and `midi`, compositions the tree keeps buildable but does not publish; the default build has no bundle): two files each, saying which features compile in and what the build calls itself; `firmware/bundles/build.sh` builds the default or a named one, `shelf.sh` stages a publishable image. Device console pages are plain HTML in `firmware/patternflow/console/`, spliced into `*_index.h` by `firmware/toolchain/console_pages.py`. Tooling lives in `firmware/toolchain/` (repo-level scripts) and `firmware/patternflow/toolchain/` (PlatformIO `extra_scripts`, which must stay sketch-relative). Also: `firmware/encoder_test/` (standalone encoder diagnostic) and `firmware/CUSTOM_PATTERNS.md` (pattern authoring guide).
- `hardware/` — Hardware designs. `case/` (Blender source in `source/`, STLs by printer bed size, `legacy_v2/` for the v2.x board), `pcb/` (KiCad 10.0 source, Gerbers per revision, schematic PDF), `bom/` (`bom_v3.9.csv` — **the BOM source of truth**; the guide's table is derived from it. `bom_v3.0.csv` is kept for the previous board).
- `web/` — Next.js site at patternflow.work: landing page (`/`, `/pattern`, `/build`, `/inside` are tabs of one view), Pattern Lab (`/pattern-lab`), community (`/community/**`, SQLite + Drizzle + Better Auth, only on the Pi deployment), browser flasher (`/flash`), the edition shelf (`/editions`; `/variants` redirects there), the feature catalogue (`/features`, reels per feature), device update handoff (`/update`), journal, roadmap. `lib/pattern/` is the pattern runtime and annotations, `lib/lab/` the Pattern Lab, `lib/community/` the community, `lib/ai/` the Gemini client. Architecture doc: `web/ARCHITECTURE.md`. The JS presets in `web/src/lib/presets/` are the source of truth for firmware preset headers.
- `tools/` — desktop-side helpers: `patternflow-audio-extension` (the browser audio-react extension — also the authoring source the device's `/audio-in` console page is assembled from), `patternflow-audio-android` (phone capture app), `rtpmidi-probe`.
diff --git a/BUILD_GUIDE.md b/BUILD_GUIDE.md
index b5810190..39d6f5c9 100644
--- a/BUILD_GUIDE.md
+++ b/BUILD_GUIDE.md
@@ -266,6 +266,7 @@ No installation required — desktop **Chrome or Edge** only (Web Serial; Firefo
> 📶 **Changing Wi-Fi later.** The network you set during flashing is **saved on the device and reused on every boot** — it stays until you overwrite it. To move Patternflow to a different Wi-Fi, either **re-flash from the browser** (you'll set the new network during Improv provisioning), or in Arduino IDE do a **full erase** (Tools → *Erase All Flash Before Sketch Upload* → *Enabled*) and re-upload. A plain re-upload does **not** clear the stored credentials.
+> 📡 **No Wi-Fi where you are? The panel is one.** About fifteen seconds after it finds no known network, the panel raises its own hotspot: `patternflow-xxxx` (the name on its NETWORK screen - hold K2), password `patternflow`. Join it from a phone or a laptop and open `http://192.168.4.1/` - the whole console, including the Wi-Fi page, so you can add the network for wherever you are next and the panel joins it at once. The phone will say the network has no internet; that is true, and it stays connected. Mode (`auto`, `always`, `off`) and the password are on the console's Wi-Fi page.
*Photos from the v2 guide — the flashing flow is identical on v3.*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a7d5b78..a8e35833 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to Patternflow will be documented in this file, newest first
## [Unreleased]
+### Firmware
+
+- **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.
+- **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.
+
## [3.10.4] - 2026-09-19
### Firmware
diff --git a/FEATURE_GUIDE.md b/FEATURE_GUIDE.md
index 1fbf221f..039ff177 100644
--- a/FEATURE_GUIDE.md
+++ b/FEATURE_GUIDE.md
@@ -103,6 +103,12 @@ section fully before writing code.
with its reason, including `PF_VARIANT` / `PF_VARIANT_VERSION` (the name
and version the panel reports; `shelf.sh` refuses an image whose version
string doesn't match).
+- **A server starts when `PatternflowWifi::linkUp()` says so, not on
+ `WiFi.status() == WL_CONNECTED`.** The link a phone reaches the panel over
+ may be the panel's own hotspot (`src/core_hotspot.h`), on which the station
+ is never connected. Every core page waits on `linkUp()`; a feature that
+ waits on the station instead is simply absent on the hotspot - which is how
+ the audio, clock and MQTT pages went missing there before 2026-09-22.
- **Your HTTP handlers run on the network core, not the frame's.** Since
3.9.1 the console's server is serviced by a task on Core 0 while `loop()`
renders on Core 1. A handler that reads a word of state, or writes a value
diff --git a/docs/rest-api.md b/docs/rest-api.md
index 7ceac8de..edb72a21 100644
--- a/docs/rest-api.md
+++ b/docs/rest-api.md
@@ -70,6 +70,7 @@ The numbers that explain a device when something is off. Requires `PF_STATUS_HTT
| `panel` | Physical matrix, `"x"`. The closest thing to a model number. |
| `host` | mDNS hostname, i.e. `PF_OTA_HOSTNAME`. **Not unique** — every device ships as `"patternflow"`. See [Identifying a device](#identifying-a-device). |
| `network` | Since 1.5, when Wi-Fi is compiled in: `disconnects` counts sampled connected→disconnected edges; `retries` counts explicit retry attempts (including initial join failures); `reconnectMs` is the last sampled downtime, zero before a reconnect. `namesReady` means the core's local mDNS/service/alias and NetBIOS registrations succeeded; probing may still be in progress and it does not prove client reachability. `announcements` counts successful registration passes. Counters reset on boot. |
+| `hotspot` | Since 1.5. The panel's own access point: `mode` (`off`, `auto`, `always`), `up`, `ssid` (the board alias, `patternflow-a1b2`), `ip` (`192.168.4.1` while up, else `""`), `channel`, `clients`, and `dns` (name queries answered on the hotspot since boot). |
| `heapInternal` | Free internal DRAM. The scarce one: HUB75's DMA buffers live there, and below roughly 10 KB the console starts answering with headers and no body while Wi-Fi and OSC carry on looking healthy. Worth watching. |
| `fsError` | Why the pattern storage is not mounted, in words; `""` while it is mounted, and before any mount has been tried. Since 1.5. The core's serial line says `Mounting FFat partition failed! Error: -1` whatever the cause, so the first failure in a boot reads the volume's boot sector and records what it found. `"not formatted"` is a freshly erased board — press Format on `/patterns`. `"format did not stick: …"` means a Format ran and reported success, but the flash did not keep what was written. `"no filesystem (boot sector …)"` shows the sector's first and last bytes. `"boot sector looks valid but does not mount"` means the data is on the chip and the read side refused it. A failed `POST /api/patterns/format` returns the same text as its `error`. |
| `flashId` | The flash chip's JEDEC id as the driver detected it at boot, six hex digits: manufacturer, then device. `"c22018"` is a 16 MB Macronix part. Since 1.5. |
@@ -383,6 +384,27 @@ Requires `PF_WIFI_HTTP_ENABLED` (default on). Up to `PatternflowWifi::MAX_NETWOR
Passwords travel in the clear over LAN HTTP and are never sent back. Same trust model as `/update` and `/patterns`.
+### Hotspot
+
+Since 1.5. The panel is a network of its own when it has none: an access point named after the
+board (`patternflow-a1b2`, the alias the NETWORK screen shows), WPA2, password `patternflow` until
+the owner changes it. The console is at `http://192.168.4.1/` on it, every route in this document
+included, so a phone can add the next place's Wi-Fi from the hotspot. Modes: `off`; `auto` (the
+default - up about fifteen seconds after the panel last had a network, down again once one is
+joined and nobody is on the hotspot); `always` (up beside the station link too, on its channel).
+The channel is the least loaded of 1/6/11 from a scan when the panel is alone.
+
+| Route | |
+|---|---|
+| `GET /api/hotspot` | `{ok:true, hotspot:{mode, up, ssid, ip, channel, clients, dns}, pass}` - the same object status carries, plus the password. |
+| `POST /api/hotspot` (`mode=off|auto|always`, `pass=…`) | Either or both. `400` with an `error` for an unknown mode or a password outside 8-63 characters. A change is saved to NVS and applied at once: a hotspot that was up restarts, which drops whoever was on it. Replies like `GET`. |
+
+On the hotspot every DNS name resolves to the panel, so `patternflow.local` and any typed name land
+on the console; the phone's own "is there internet" probe fails, on purpose, within a second - the
+network says it has no internet, which is true, and the phone stays on it. A panel on its hotspot
+runs the radio AP-only: the station interface comes back for a rare retry when nobody is connected,
+and at once when `POST /api/wifi` brings credentials.
+
## Shows (Sequences)
`.pfs` cue tables live on the pattern volume under `/shows` and play on a wall clock — cues fire by `millis()`, not by frame. Format: PFST v1 (whole-second cues) and v2 (deciseconds + eased cues); see `docs/pfst-v2-spec.md`.
@@ -450,7 +472,7 @@ In short: HTTP is the management and state transport, OSC and MIDI are the low-l
## Version history
-- **1.5** (unreleased) — status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted).
+- **1.5** (unreleased) — the hotspot (`hotspot` in status, `GET`/`POST /api/hotspot`); status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted).
- **1.4** (2026-09-06) — `GET /api/patterns/file` gains `ext=thumb`; `GET /api/display` takes `brightness` and status reports it; status gains `resetReason` and `load.internal`/`load.psram`; console pages are served gzip-compressed (`Content-Encoding: gzip`); the page sender no longer truncates on a slow link; the server no longer trips the Core-0 watchdog on a request that stalls mid-header.
- **1.3** (2026-09-04) — `GET`/`POST /api/clock` (Utility edition) and the `clock` block in status; `caps` gains `"clock"`.
- **1.2** (2026-09-03) — the server is serviced on Core 0 (the one-connection rule stands; the render-pays rule is history); status gains `httpCore`, `netStackMin`, `loopSyncServed`/`loopSyncMaxUs`; `POST /api/params` documents `d1`..`d4` and how a held value reaches a legacy pattern; `GET /api/patterns/select` gains `step`; `GET`/`POST /api/audio` (Audio-React) are documented; `featureNav`'s microphone label is *Audio*.
diff --git a/firmware/patternflow/console/wifi.html b/firmware/patternflow/console/wifi.html
index 781d9830..57f5a4ea 100644
--- a/firmware/patternflow/console/wifi.html
+++ b/firmware/patternflow/console/wifi.html
@@ -111,6 +111,33 @@
Add a network
current connection immediately.
+
+
Hotspot
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
The panel as a network of its own: patternflow-????.
+ Join it from a phone or a laptop and open http://192.168.4.1/ — the
+ same console, anywhere. auto raises it about fifteen seconds after the panel
+ finds no network and drops it once one is joined; a network added above from the
+ hotspot is tried at once. The phone is told the network works, so it stays on it;
+ it has no internet through the panel.