Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/ISSUE_TEMPLATE/guide_stuck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Stuck in the guide
description: A step of the guide at patternflow.work/guide didn't get you through.
title: "[guide] "
labels: ["area:docs", "type:bug"]
body:
- type: markdown
attributes:
value: |
Thanks — a report like this shows exactly where the guide needs to be better. If you came from a "Stuck here?" link, the step is already filled in below.

Need an answer right now rather than a better guide? The [hardware-help channel on Discord](https://discord.com/channels/1497757947827327067/1499907910707187833) is quicker. Writing in Korean is fine too — 한국어로 써도 돼요.
- type: input
id: where
attributes:
label: Where in the guide
description: The chapter and step. Filled in for you if you came from the guide.
placeholder: "01 Flash, step 3 — Hold BOOT, tap RST, let go."
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What you did, and what you saw instead of what the guide said you'd see.
placeholder: "After flashing, the Wi-Fi box never appeared. Pressing RST didn't bring it up either."
validations:
required: true
- type: textarea
id: tried
attributes:
label: What did you try?
description: Anything you tried to get past it, and whether it helped.
- type: dropdown
id: board
attributes:
label: Board
description: The version is printed on the board's silkscreen.
options:
- v3.9
- v3.0
- v2.x
- not sure
- type: input
id: browser
attributes:
label: Browser and computer
description: Filled in for you if you came from the guide. Change it if you did the step on another device.
placeholder: "Chrome 140 on Windows"
- type: textarea
id: photos
attributes:
label: Photos or a screen recording
description: A photo of the panel, or of the screen you were stuck on, often says more than a paragraph. Drag them in here.
2 changes: 2 additions & 0 deletions BUILD_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ The ESP32-S3 module is flashed **separately, outside the PCB**. Already seated i

No installation required — desktop **Chrome or Edge** only (Web Serial; Firefox/Safari won't work).

> 🎬 **See it before you do it.** [patternflow.work/guide](https://patternflow.work/guide) walks through this section — the port, BOOT and RST, the Wi-Fi step, the Basics pack — on a Patternflow you can turn, and every step has a link for telling us where you got stuck.

> 🔌 **Use the LEFT USB-C port** — the one on your left when the two ports face you. On the ESP32-S3 DevKit that's the board's **native USB** port (labeled `USB`); the browser flasher (Web Serial + Improv) talks to it directly. The right-hand port goes through a separate USB-to-UART bridge chip and is the one Arduino IDE uses (§8.2) — the flasher may still write the firmware through it, but the Wi-Fi step never appears there, because the firmware only listens for it on the left port.

1. Visit **[patternflow.work](https://patternflow.work)** on a desktop browser.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to Patternflow will be documented in this file, newest first

### Firmware

- **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](https://github.com/engmung/Patternflow/pull/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](https://github.com/engmung/Patternflow/pull/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](https://github.com/engmung/Patternflow/pull/445), [#448](https://github.com/engmung/Patternflow/pull/448), [#449](https://github.com/engmung/Patternflow/pull/449)).
Expand All @@ -20,6 +22,8 @@ All notable changes to Patternflow will be documented in this file, newest first

### Web

- **A guide you can play along with, at [/guide](https://patternflow.work/guide)** (work in progress, marked WIP). The first page is the first hour, 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. The second, [/guide/make](https://patternflow.work/guide/make), is 05 Community and 06 Pattern Lab: the community's real screens, and a board that plays your own Pattern Lab draft (read, never written) while the real Lab opens in a window beside it. English and Korean. Every step has a "Stuck here?" link to a GitHub issue that already says where (`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](https://github.com/engmung/Patternflow/issues/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.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ What a knob does to a running pattern is the pattern's own decision, so the same

The device functions themselves are fixed. Encoder 1 is brightness. Encoder 4 opens the pattern list, where you turn to browse the names and long-press again to load one, choosing from whatever is installed at the time. Encoder 2 puts the board's IP address on the panel, and typing that into a browser opens the web console, which carries every feature the device has and works from a phone.

[patternflow.work/guide](https://patternflow.work/guide) has all of this on a Patternflow you can turn, press and hold, from flashing the ESP32 to the console.

## On the device

A new board boots into **Origin**, concentric sine waves sampled by an emergent grid, and long-pressing encoder 4 opens the list of whatever else is installed. Patterns live on the filesystem rather than inside the program. The firmware compiles in Origin alone, as the failsafe a board can always boot into, and everything else installs as **`.pfm` modules over Wi-Fi**, up to 128 of them, no reflash. Adding a pattern never costs a firmware update, and a firmware update never touches your patterns, your Wi-Fi, or your storage.
Expand Down
32 changes: 19 additions & 13 deletions firmware/patternflow/console/patterns.html
Original file line number Diff line number Diff line change
Expand Up @@ -777,31 +777,34 @@ <h2>Installed</h2>
// on — writes "\" on Windows, and a pack made that way arrives with its
// whole path stuck to the filename: the junk filter stops matching, the
// duplicate check stops matching, and the queue shows "a\b\wave.pfm".
// What goes onto the device, by file name — one rule for every way in: a
// drop, a zip, a link.
// catalog.txt rides along: it is the pack's running order (deck order).
// .pfs is a Director show table — installed to /show alongside the pack.
// performance.json is that show's editable source (Director / site), not
// a module sidecar: the device has no use for it, so it stays behind.
function installable(n){
if(/^performance\.json$/i.test(n)||/\.perf\.json$/i.test(n))return false;
return /\.(pfm|json|pfs)$/i.test(n)||/^catalog\.txt$/i.test(n)
}

function expandFiles(fileList){
var list=Array.prototype.slice.call(fileList||[]);
// catalog.txt rides along: it is the pack's running order (deck order).
// .pfs is a Director show table — installed to /show alongside the pack.
// performance.json is that show's editable source (Director / site), not
// a module sidecar: the device has no use for it, so it stays behind.
var keep=function(n){
if(/^performance\.json$/i.test(n)||/\.perf\.json$/i.test(n))return false;
return /\.(pfm|json|pfs)$/i.test(n)||/^catalog\.txt$/i.test(n)
};
if(!list.some(function(f){return /\.zip$/i.test(f.name)}))
return Promise.resolve(list.filter(function(f){return keep(f.name)}));
return Promise.resolve(list.filter(function(f){return installable(f.name)}));

say('unpacking zip…');
return loadFflate().then(function(){
var out=[],seen={},total=0;
var push=function(f){if(!seen[f.name]){seen[f.name]=1;out.push(f)}};
var chain=Promise.resolve();
list.forEach(function(f){
if(!/\.zip$/i.test(f.name)){if(keep(f.name))push(f);return}
if(!/\.zip$/i.test(f.name)){if(installable(f.name))push(f);return}
chain=chain.then(function(){return f.arrayBuffer()}).then(function(buf){
var unzipped=fflate.unzipSync(new Uint8Array(buf));
Object.keys(unzipped).forEach(function(name){
var base=name.split(/[\\/]/).pop();
if(!base||base.charAt(0)==='.'||!keep(base))return;
if(!base||base.charAt(0)==='.'||!installable(base))return;
total+=unzipped[name].length;
if(total>ZIP_MAX_BYTES)
throw new Error('zip holds more than '+(ZIP_MAX_BYTES>>20)+' MB of patterns');
Expand Down Expand Up @@ -884,8 +887,11 @@ <h2>Installed</h2>
fetch(src+sep+'list=1').then(function(r){
if(!r.ok)throw 0;return r.json();
}).then(function(d){
var names=(d.files||[]).filter(function(n){return /\.(pfm|json)$/.test(n)});
if(!names.length)throw 0;
// The same rule as a dropped zip. It used to be .pfm/.json only, which
// left a deck's catalog.txt behind: a deck sent from the community's
// dock arrived in alphabetical order instead of the order it was built in.
var names=(d.files||[]).filter(installable);
if(!names.some(function(n){return /\.pfm$/i.test(n)}))throw 0;
holdBatch(true);
items=names.map(function(n){return {f:null,name:n,st:'get',pct:0,tries:0}});
retryBtn.style.display='none';
Expand Down
57 changes: 42 additions & 15 deletions firmware/patternflow/patternflow.ino
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,35 @@ void drawCenteredText(const char* text, int y, uint16_t color, int textSize = 1)
// One fillRect + one text draw per label — cheap enough not to slow the frame
// (a per-glyph outline tripled the per-frame pixel writes and tore the
// double-buffered panel).
// A line of 1x text is 6 px a character, so a portrait line (64 px) holds ten.
// An eleven-character string does not clip: Adafruit GFX wraps its last letter
// onto the next line by itself, over whatever is drawn there ("TURN = SHOW"
// left a stray "W" in front of "K3 = EXIT"; the default host name
// "patternflow" did the same on the hotspot and UPDATE screens). Text that can
// be longer than a line comes through here instead: it is broken into lines
// of at most ten, before a '-' or '.' when one is in reach, else before "flow"
// in the product's own name, else hard at ten. Returns the y below the last line.
int drawCenteredFit(const String& text, int y, uint16_t color, int lineH = 10) {
const unsigned maxChars = dma_display->width() / 6;
String rest = text;
while (rest.length() > maxChars) {
int cut = -1;
for (int i = maxChars; i >= 2; i--) {
if (rest[i] == '-' || rest[i] == '.') { cut = i; break; }
}
if (cut < 0) {
int f = rest.indexOf("flow");
if (f >= 2 && f <= (int)maxChars) cut = f;
}
if (cut < 0) cut = maxChars;
drawCenteredText(rest.substring(0, cut).c_str(), y, color, 1);
y += lineH;
rest = rest.substring(cut);
}
drawCenteredText(rest.c_str(), y, color, 1);
return y + lineH;
}

void drawCenteredTextScrim(const char* text, int y, uint16_t color, int textSize = 1) {
int16_t x1, y1;
uint16_t w, h;
Expand Down Expand Up @@ -787,14 +816,10 @@ void drawNetworkInfo() {
bool wifiUp = PatternflowWifi::isConnected();
if (!wifiUp && PatternflowHotspot::up) {
// No station link, but the panel is a network of its own: the name to
// join, split at the dash so it fits the portrait width. The password
// is the documented default unless the owner changed it on /wifi.
// join, broken to fit the portrait width ("pattern" / "flow-a1b2"). The
// password is the documented default unless the owner changed it on /wifi.
drawCenteredText("HOTSPOT", 50, pfGreenC(), 1);
String name = PatternflowHotspot::name();
int dash = name.indexOf('-');
if (dash < 0) dash = name.length();
drawCenteredText(name.substring(0, dash).c_str(), 62, pfWhiteC(), 1);
drawCenteredText(name.substring(dash).c_str(), 72, pfWhiteC(), 1);
drawCenteredFit(PatternflowHotspot::name(), 62, pfWhiteC());
} else {
drawCenteredText(PatternflowWifi::statusText(), 50, wifiUp ? pfGreenC() : pfBlueC(), 1);
String ip = PatternflowWifi::ipString();
Expand Down Expand Up @@ -875,7 +900,7 @@ void drawPausedScreen() {

if (consolePaused) {
drawCenteredText("PAUSED", 26, pfWhiteC(), 1);
drawCenteredText("web console", 42, pfDimC(), 1);
drawCenteredText("console", 42, pfDimC(), 1);
drawCenteredText("is open", 52, pfDimC(), 1);
dma_display->drawFastHLine(4, h - 28, w - 8, pfRuleC());
drawCenteredText("RESUMES", h - 23, pfDimC(), 1);
Expand Down Expand Up @@ -944,17 +969,19 @@ void drawUpdateScreen(int uploadPct) {
}
if (wifiUp) {
drawCenteredText("DROP .BIN:", 36, pfDimC(), 1);
drawCenteredText(PF_OTA_HOSTNAME, 48, pfWhiteC(), 1);
drawCenteredText(".local", 58, pfWhiteC(), 1);
drawCenteredText("/update", 68, pfWhiteC(), 1);
// "pattern" / "flow.local" / "/update" for the default name; a longer
// one takes more lines and the address below moves down with it.
int y = drawCenteredFit(String(PF_OTA_HOSTNAME) + ".local", 48, pfWhiteC());
drawCenteredText("/update", y, pfWhiteC(), 1);
y += 14;
// Raw IP as the mDNS fallback, split like the NETWORK screen.
String ip = PatternflowWifi::ipString();
if (ip.length() <= 10) {
drawCenteredText(ip.c_str(), 82, pfGrayC(), 1);
drawCenteredText(ip.c_str(), y, pfGrayC(), 1);
} else {
int cut = ip.indexOf('.', ip.indexOf('.') + 1) + 1;
drawCenteredText(ip.substring(0, cut).c_str(), 82, pfGrayC(), 1);
drawCenteredText(ip.substring(cut).c_str(), 92, pfGrayC(), 1);
drawCenteredText(ip.substring(0, cut).c_str(), y, pfGrayC(), 1);
drawCenteredText(ip.substring(cut).c_str(), y + 10, pfGrayC(), 1);
}
} else {
drawCenteredText(PatternflowWifi::statusText(), 52, pfBlueC(), 1);
Expand Down Expand Up @@ -992,7 +1019,7 @@ void drawKnobMap() {
dma_display->setCursor(x + 6, y);
dma_display->print(title);
}
drawCenteredText("TURN = SHOW", (h / 2) + 2, pfDimC(), 1);
drawCenteredText("TURN=SHOW", (h / 2) + 2, pfDimC(), 1);
drawCenteredText("K3 = EXIT", (h / 2) + 12, pfDimC(), 1);

// Front-view corners: K1 top-right, K2 top-left, K3 bottom-right,
Expand Down
Loading
Loading