Skip to content

feat: Pimoroni Galactic Unicorn (Pico W / Pico 2 W) support and tall panels - #69

Closed
flavio-fernandes wants to merge 25 commits into
Blueforcer:mainfrom
flavio-fernandes:feat/galactic-unicorn
Closed

flavio-fernandes wants to merge 25 commits into
Blueforcer:mainfrom
flavio-fernandes:feat/galactic-unicorn

Conversation

@flavio-fernandes

Copy link
Copy Markdown

What this PR does

It adds support for the Pimoroni Galactic Unicorn, the 53×11 LED panel driven by a
Pico W (RP2040) or a Pico 2 W (RP2350). The Unicorn runs the same core as the ESP32
builds: the same HTTP and MQTT API, web UI, Home Assistant discovery and Berry scripting.
Along the way, every board also gains tall panels (8 to 16 rows).

ESP32 behaviour stays the same. The one exception is a small fix in commit 2 that the ESP32
shares: a refused upload no longer deletes the file that was already there.

The Unicorn has come up a few times on the AWTRIX Discord:

How to review it

This is a big PR, so it is arranged as milestones that can be reviewed one at a time. Each
of the 23 commits is one logical change and passes the full CI on its own. The first two
milestones (commits 1 to 7) do not depend on the Pico at all and could land separately.

Milestone Commits What it adds
1. Groundwork 1–3 A local CI container; the upload fix above; a simulator fix so it answers an oversized body with 413, like the device
2. Tall panels 4–7 Panel heights from 8 to 16 rows; eight-row content centred on taller panels; effects that fill the panel; BrickBreaker, Snake and PingPong as real mini-games
3. Pico builds 8–10 Galactic Unicorn builds running the core engine; build features and fixed GPIO; UF2 files in CI and releases; settings stored in LittleFS
4. The board 11–15 The panel driver (PIO and DMA); buttons and light sensor; Wi-Fi, provisioning and mDNS; NTP, sleep and UDP; the HTTP API and web UI
5. Robustness and integrations 16–20 The hardware watchdog; the buttonCallback webhook; API smoke and burst scripts; MQTT and Home Assistant discovery; melodies through the Unicorn's speaker
6. Polish 21–23 The web UI hides what a board lacks and shows fixed pins read-only; a Galactic Unicorn guide; Berry scripting on both Picos

About 8,100 of the changed lines are WebUiAsset.h, which is regenerated from
webui/index.html in the two commits that change the web UI (4 and 21).

How it was tested

  • Every commit passes the host tests, the three ESP32 builds, both Pico builds, the web UI
    tests, the docs checks and mkdocs build --strict, all through tools/container/ci-local.sh.
  • In the simulator, tools/pico/smoke.sh passes 96/0 and tools/pico/burst.sh 80/80.
  • On a real Galactic Unicorn with a Pico W:
    • API: smoke.sh passes 113/0 and burst.sh 80/80.
    • Hardware: the display, buttons, light sensor, melodies on the speaker, mirror and rotate (checked with a camera), and the mini-games at 40–42 fps.
    • Network: Wi-Fi on a mesh and on a single access point, provisioning, and MQTT with Home Assistant.
    • Scripting: a Berry script, a build without scripting, and the A+C rescue at power-on.
    • Robustness: watchdog recovery, and slow or stalled clients that no longer reset the board, including uploads, downloads, trickled requests, request headers split across packets, a header over 2 KB (answered 431), and a download the client stops reading (closed after 5 s).
    • Settings: backups with nested icon folders, a backup made on an ESP32 (the Unicorn keeps its own pins and panel), pin and panel edits that the board refuses, a timed sleep, and every web UI page as the board serves it.
  • The Pico 2 W build is checked in CI, the host tests and the simulator, but not yet on
    hardware. I have a Galactic Unicorn with a Pico 2 W on the way and will pick up
    #7 when it arrives. I don't think
    that should hold up this PR: the work left there is small next to what is here, and this PR
    is big enough already.

Known issues, tracked separately

These came up while building and reviewing this PR. They are tracked in
https://github.com/flavio-fernandes/awtrix-ng/issues rather than fixed here.

Already on main and unrelated to this PR:

  • #2 A web UI test (hub-script-updates) is occasionally flaky.
  • #3 A host test (test_asyncqueue) is occasionally flaky under load.
  • #4 A failed Wi-Fi join at boot reports timeout instead of the real reason.

Follow-ups for the Galactic Unicorn that don't block it:

  • #5 An upload over 2 KB sent deliberately slowly (under about 180 bytes per second) can still trip the watchdog. This is documented in limits.md.
  • #6 Scripts on the Pico cannot make HTTP or Modbus requests or draw icons yet. This is documented too.
  • #7 Run the Pico 2 W build on real hardware (see above).
  • #8 Two hardware checks still owed: the button webhook, and a power cut in the middle of a write.
  • #9 Some Pico boot messages go only to USB serial, not to the web UI log.

Checklist

  • New behaviour has a host test in test/, or it genuinely cannot run off-device
  • src/core/ stayed portable (no Arduino, FastLED or board headers)
  • Documentation in docs/ updated for any new field, endpoint or setting
  • Regenerated any checked-in generated file this change invalidates
    (see the table in CONTRIBUTING.md)

Thank you for taking the time to look at this!

🤖 Generated with Claude Code and Codex

flavio-fernandes and others added 23 commits September 30, 2026 09:59
Runs the same gates as CI, locally, with no toolchain on the host. The
image has Python 3.12, Node 22, PlatformIO, CMake and Ninja; the runner
takes any mix of steps (native, webui, docs, mkdocs, build ENV..., all)
from the repository root and prints PASS or FAIL for each:

    podman build -t awtrix-build tools/container
    podman run --rm -v "$PWD":/w:Z -v pio-home:/root/.platformio \
        awtrix-build tools/container/ci-local.sh all

A failed step does not stop the later ones; the exit code reports any
failure. Like the Docs workflow, the mkdocs step also fails on a link to
a missing anchor, which mkdocs --strict reports only at INFO level.
CONTRIBUTING.md points to it next to the list of CI gates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The device truncated the target at the first chunk and removed it when
the content check failed, so a refused replacement deleted a good icon.
The simulator checks before it writes. Upload beside the target and
rename it over only once the whole file has passed. LittleFS rename
replaces the target in one step, so the old file is never deleted
first and stays readable until the new one takes its place. Until then
both copies are on flash, and a power cut can leave the partial one as
<name>.part; http.md says both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The device refuses an ordinary request body over 8 KB
(HttpApiServer::kMaxBodyBytes) with 413 payloadTooLarge; the simulator
accepted any size. Apply the same cap. Multipart uploads and script
source are streamed on the device, so they stay exempt here too; the
script routes come from api::isRawBodyWrite(), the predicate the device
uses, so installs and compare-and-swap updates are both covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds panelHeight (8-16, default 8) next to panelWidth and panels: config
rules, MatrixLayout, DeviceConfig, backups, the API docs and the web
UI's panel form. As with the total width, a height change applies after
a reboot; the firmware and the simulator only take a layout change live
while the canvas size is unchanged.

The height is stored under the NVS key "pheight". "ph" is a key of older
firmware that DeviceConfig::save() deletes, so it stays on the legacy
list and is never read.

The simulator sizes its canvas from the saved configuration at startup.
tools/test_panel_geometry.py checks that end to end, and the device test
reads the expected frame-buffer height from /api/v1/system. WebUiAsset.h
is regenerated from webui/index.html.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Text, 8x8 icons, the built-in apps, the boot and AP screens and
notification text are all laid out for exactly eight rows. On a taller
panel they now render into an eight-row band that starts at row floor((H
- 8) / 2). render::contentBand() is an allocation-free, clipped view, so
every font keeps its baseline and clipping. Backgrounds, effects,
overlays, transitions and charts use the whole canvas, and progress bars
sit on the last row. Draw commands and explicitly positioned icons keep
absolute coordinates. The Eye effect scales to the full height.

The graphics, scripting, payload and limits docs describe the geometry.
test_contentband, test_fullcanvas (53x11) and new render-pipeline cases
cover it, and tools/test_tall_notification.py checks a notification in
the simulator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Several effects assumed eight rows and left most of a tall panel dark.
Snake now follows a vertical serpentine path, so its trail crosses the
height quickly even on a wide panel. SwirlIn and SwirlOut use two
rotating elliptical spiral packets sized to the canvas instead of a
small clipped cluster in the middle. BrickBreaker's ball and wall also
cover the height; the next commit turns it into a real game. None of
them allocate per frame.

test_tall_effects checks every height from 8 to 16 at widths 32 and 53,
including determinism and guard sentinels around the frame buffer.
tools/sim/effect_survey.py starts an isolated simulator per panel size,
samples every effect from /api/v1/display/screen and writes PNG strips
and a summary; tools/sim/README-effects.md explains how to run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On every panel size:

- BrickBreaker: 3-LED bricks with 1-LED gaps in 2 rows (3 from 11 rows
  up), one hue per row, one brick knocked out per hit, and 45-degree or
  shallow bounces picked by where the ball meets the paddle.
- Snake: a greedy snake chases a red food pixel, grows by one per meal
  and restarts when it is stuck or 24 long. The palette still colours
  the body.
- PingPong: tracking paddles on both edges and the same two return
  angles. The receiving paddle moves to its aim point, not just under
  the ball, so both angles occur, and corner returns go shallow instead
  of locking at 45 degrees.

All three replay at most 511 ticks from a fixed start, with fixed-size
state and no heap, so seeking and dropped frames stay deterministic.
test_tall_effects pins the brick geometry, one brick per hit, the ball
never overlapping a brick, both slopes, snake growth and food, and
paddles that cover the ball, at widths 32 and 53 and heights 8-16. The
effect reference, the release notes and the survey notes are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pimoroni's Galactic Unicorn is a fixed 53x11 RGB panel driven by a
Raspberry Pi Pico W (RP2040) or Pico 2 W (RP2350). This adds the
galactic_unicorn and galactic_unicorn_2w environments on the
arduino-pico core, with the platform pinned to a tested commit and 512
KiB reserved for LittleFS.

This first step only brings up the portable core on the Pico:
main_rp2040.cpp creates the CoreEngine and the RenderPipeline with the
Time and Date apps and an unset clock, and GalacticUnicornBoard is a
53x11 board (8 rows letterboxed) with no display output yet, registered
in BoardRegistry. The environments list their sources explicitly, so no
ESP transport, persistence, Berry, MP3, TLS or OTA code is linked; the
ESP32 environments leave the rp2040 sources out. test_galactic_board
covers the board's geometry and its missing sensors and sinks.
CONTRIBUTING.md and docs/advanced/building.md list the new environments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A build can now leave out optional subsystems, and says so instead of
failing in odd ways. FeatureSet (portable) and platform::buildFeatures()
(from the AWTRIX_FEATURE_* macros, all on unless a build turns them off)
cover scripting, MP3, radio, outbound TLS and browser OTA.
featurePolicy() answers 503 unavailable for their HTTP routes and MQTT
commands before any body is read, and /api/v1/capabilities reports the
set, so clients never infer features from the chip. The ESP32 builds are
unchanged: everything stays on. The Pico builds turn all five off for
now.

rp2040Profile() describes the Unicorn's fixed wiring. A fixedWiring
profile rejects GPIO changes and still validates its own defaults. Its
I2C pins are -1, like the battery, buzzer and DFPlayer pins: the Qw/ST
connector is on GPIO 4/5, but the Pico builds read no I2C sensor. The
capabilities' gpio object gains "fixed", true for such a board and
false on the ESP32 chips.
Choosing the active SoC profile moves from src/core/SocProfile.h to
src/platform/SocProfile.cpp, and the FastLED data-pin list to
src/platform/MatrixPins.h, so src/core keeps no chip #ifdefs.

CI builds both UF2s and uploads them as uf2-galactic_unicorn and
uf2-galactic_unicorn_2w, and ci-local.sh all builds them too. A tagged
release also waits for them and publishes firmware-galactic-unicorn.uf2
and firmware-galactic-unicorn-2w.uf2 next to the ESP32 images.
CONTRIBUTING.md and building.md list the new CI gate. The HTTP reference
and OpenAPI document the 503 for a feature a build leaves out, and
gpio.fixed.
test_features covers the policy, the capabilities and the fixed pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
arduino-pico has no NVS. KvPreferences implements the subset of the
ESP32 Preferences API that DeviceConfig and NvsSettings use, over one
LittleFS file per namespace. A namespace is committed at end() by
writing a temporary file and renaming it, so a power cut leaves either
the old or the new file. platform/Preferences.h picks it on RP2040 and
the ESP32 Preferences elsewhere. Numbers wider than four bytes are
rejected, not truncated.

The backup-restore sink moves out of HttpApiServer into
persistence/LittleFsRestoreSink, shared by both platforms. It stages
each asset and renames it only when complete, so an aborted restore
leaves no partial files. It accepts the paths the shared backup policy
does, and creates every folder a nested entry such as
/ICONS/sub/smile.gif needs. On the Pico, IconOriginsStore checks icons
through the LittleFS File API, since arduino-pico has no VFS; the
ESP32 keeps its VFS check. Scripts and radio stations
get no-op stores while those features are compiled out. A requested
reboot saves pending settings first, except after a factory reset.

The configuration that applies live is applied at boot too: debugMode
(verbose logging) and tempDecimals. Custom palettes in /PALETTES are
found by name: the ESP32's LittleFS palette loader moves into
persistence/PaletteFiles, which both boards install once the filesystem
is mounted, and it now opens the directory with an explicit "r" because
arduino-pico's open() has no default mode.

docs/advanced/pico-persistence.md describes the layout.
test_kv_preferences covers the store, including corruption and failed
replacement. test_pico_persistence runs the production DeviceConfig,
NvsSettings, LittleFS adapter and restore sink against a simulated
filesystem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ports the display half of Pimoroni pico v1.23.0 (MIT; see
LICENSES/MIT-Pimoroni.txt and THIRD-PARTY-NOTICES.md). One PIO state
machine and two chained DMA channels refresh fourteen binary-weighted
bit planes continuously, with no CPU refresh interrupt. show() packs the
inactive of two 9,240-byte buffers and swaps them at a frame boundary.
The PIO program prefers PIO1, leaving PIO0 instruction memory for the
CYW43 radio.

Colour grading follows NG's order: saturation, gamma, brightness,
correction, tint. It goes through a 14-bit LUT, so precision is not lost
to an 8-bit intermediate, and it applies NG's gamma once in place of
Pimoroni's fixed 2.2 curve. panelHeight defaults to 11; 8 is letterboxed
on physical rows 1-8. The wiring is fixed, but mirror and rotate are
drawing transforms, so the board applies them while packing the frame,
from boot on. Without that, rotate would swap the buttons while the
picture stayed upright.

Portable defaults live in GalacticUnicornDefaults.h, so the host tests
need no hardware headers. test_galactic_display covers bit-plane
packing, mirror and rotate, and the colour LUT.
docs/advanced/galactic-unicorn-display.md documents the refresh and
colour design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A, B and C are left, select and right in the shared PeripheryService,
with the same rotate, swapButtons and blockNavigation rules, double
press and button events as an Ulanzi. The brightness, volume and sleep
keys dispatch SetSettings and SetDisplay (GalacticUnicornControls), so
they behave exactly like the API. All keys are active low and debounced
for 35 ms. The light sensor on ADC2 feeds the shared auto-brightness
curve.

The list of built-in apps, effects and overlays moves from main.cpp into
core/BuiltinCatalog.h, so both platforms register the same five apps,
nineteen effects and six overlays. tools/check_builtin_registration.py,
run by CI's docs job and ci-local.sh docs, keeps it that way. The ESP32
button webhook moves into system/PeripheryHttp.h, so the shared
periphery has no network dependency.

test_galactic_inputs covers debounce, key steps and navigation parity;
test_builtin_catalog covers the catalog and the canonical overlay names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pico W runs the shared NetworkService: stored credentials, the boot
timeout, reconnect checks, the weak-signal roam policy, the provisioning
AP with captive DNS, and mDNS (<hostname>.local, _http._tcp and
_awtrixng._tcp with the same TXT records as the ESP32). Holding B for
one second at boot forces the AP without erasing credentials.
platform/rp2040/WifiCompat.h absorbs arduino-pico's differences: AP+STA
is restored after a join, and the static IP arguments come in a
different order.

arduino-pico brings the CYW43 radio up before setup(). RadioStartup
defers that with a linker --wrap until the display has claimed its PIO
and DMA, then logs which PIO state machines the radio took and checks
that the panel refresh still advances.

Several fixes came from running on real routers and mesh networks:

- Power save is turned off after every join. The CYW43 default (PM2)
  dropped the link seconds after joining; the ESP32 already uses
  WIFI_PS_NONE.
- Firmware roaming is turned off (roam_off=1) before every join. The
  radio roamed between mesh nodes on its own, and the roams back timed
  out in the key exchange. Roaming stays with roamIfWeak, as on the
  ESP32.
- A join is only restarted once the previous one has had its full
  connect timeout. arduino-pico's begin() does not block after
  WiFi.mode(), and the 5 s check kept aborting joins.
- The boot join is retried up to three times before falling back to the
  AP. After an unclean reset the AP can still hold the old session and
  deauthenticate the first join mid key exchange.
- CYW43 has one radio, so with the AP up the station can only join on
  the AP's channel. The Pico drops the AP for one station-only join
  window every 60 s while no phone is attached, and reboots once it
  joins.
- lastError reads badCredentials only when two joins in a row fail
  authentication (core/net/WifiLink.h AuthFailureGate). CYW43 reports a
  deauth in the middle of the handshake as BADAUTH, like a wrong
  password.
- Every CYW43 async event (type, status, reason, AP, join state) is
  logged at debug level through a --wrap of
  cyw43_cb_process_async_event, and link-up logs the AP and RSSI, so the
  device log says why a link dropped.

On a Pico W: 40 minutes on a mesh with no drops, then 60 minutes on a
single AP, reachable 60 times out of 60 with no rejoins. test_pico_wifi
and test_linkstatus cover the host-testable parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Time: the configured POSIX tz and ntpServer drive SNTP, which restarts
  when either changes or the link comes back. Pages use the same
  DevicePageClock as the ESP32.
- Reset reason: a platform ResetReason maps the RP2040's cause to the
  shared names (poweron, watchdog, software, external, brownout,
  unknown).
- Sleep: the Pico has no deep sleep. POST /api/v1/device/sleep blanks
  the panel, enables radio power saving and waits for the duration or a
  new press of the Sleep key, then restarts. The docs describe it as
  emulated.
- UDP: UdpSocket gains a WiFiUDP binding, so the shared discovery
  (4210/4211) and Art-Net (6454) services run unchanged.
  galactic_unicorn_udp_measure, a non-release environment without them,
  shows they cost 140 bytes of static RAM and about 2 KB of flash.
- Effect noise is seeded from the SDK's hardware entropy at boot.

DeviceServices uses the shared Preferences adapter. test_pico_system and
test_pico_udp cover the time config, reset mapping, UDP binding and the
Art-Net pixel mapping across the 53x11 canvas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HttpApiServer now also builds on arduino-pico's WebServer: its
HTTPServer& request hooks (including canRaw), bodies streamed into the
fixed arena, LittleFS's File API for the directory listings (the ESP32
keeps its VFS readdir, and melodies are read through readAsset on
both), and the asynchronous Wi-Fi scan lifecycle. The script-listing part of the state
JSON moves into core/script/ScriptInfo, so /api/v1/apps and
/api/v1/device work with no script host. /update answers 503 on the Pico
with instructions to flash a UF2 over USB; the simulator's 501 is
documented too.

Pushed apps and notifications draw their icon and icons: the Pico links
the shared GIF and JPEG decoders and the ESP32's DevicePageIcon, and
platform/rp2040/AssetFileRp2040.cpp reads assets through LittleFS, since
arduino-pico has no VFS. Uploading, deleting or restoring an asset drops
the cached icons and palettes, as on the ESP32. A mirror or rotate
change applies live: the panel size is fixed, so the board only ever
takes the orientation from the layout.

The Unicorn's panel is part of the board, like its pins, so
PUT /api/v1/system refuses a width other than 53, more than one panel,
a height other than 11 or 8, and any change to the wiring keys, with
422 validationFailed on the field; before, they were stored and
reported while the panel drew its own size. The rules come from the
board's SocProfile, so the ESP32 profiles are unaffected. A restored
backup keeps the Unicorn's pins and panel, as it keeps the Wi-Fi, so a
backup from a board wired differently still restores the rest.
The capabilities' gpio object names the panel ("panel": its width and
the heights it can run at). At boot, sysconfig::adoptFixedBoard()
replaces a pin map or panel size stored by another build with the
board's own; otherwise such a device would refuse every later
PUT /api/v1/system. test_galactic_board and test_pico_persistence
cover all three.

Fixes found on a Pico W:

- Link _printf_float. arduino-pico's PlatformIO builder leaves it out
  (its Arduino IDE platform.txt adds it), so every float in the JSON API
  rendered empty ("lightLevel":,) and the web UI fell back to
  provisioning mode.
- arduino-pico's lwIP has five TCP pcbs and gives accepted clients
  TCP_PRIO_MIN while the listener stays at TCP_PRIO_NORMAL. A SYN that
  found the pool full made lwIP abort the oldest live client, so two or
  three of the web UI's eight parallel requests failed with a reset. The
  listener now runs at its clients' priority, so the extra SYN is
  dropped and the client retries. lwIP reports that dropped SYN as
  accept(NULL, ERR_MEM), and WiFiServer would wrap the NULL pcb and
  fault, so it is filtered out first.
- fps is measured in the loop.
- largestFreeBlockBytes is the free block at the top of the heap. newlib
  cannot walk its free list, so the true largest block can only be
  larger; the device reference says so.

test_http_state checks the apps and device JSON without a script host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Arm the RP2040 watchdog at 8 s once the radio is up, and feed it on
every loop() pass, in the boot Wi-Fi join's wait callback and while
sleeping. A factory reset disarms it before formatting LittleFS.

A whole HTTP request and response happen inside one loop() pass, and
arduino-pico's WebServer waits on the client with timeouts that restart
whenever a byte moves, so a slow client could hold the pass past 8 s.
On the Pico:
- every upload, restore and raw script-source callback feeds it,
  including the ones that refuse a feature-disabled route (/update,
  MP3 uploads), which still read the whole body before answering 503;
- a request is parsed only once its header, and a body of up to 2 KB,
  has fully arrived; a client that has not sent it within 5 s is
  dropped. The header is read into a 2 KB buffer that WebServer's
  parser then reads back (HeadClient): WiFiClient::peekBytes() sees
  only the first TCP segment, so a header that spans segments, or
  arrives in two writes, could not be checked where it lies. A longer
  header is answered 431 and closed;
- every response byte goes through RawWebServer's client-write hooks,
  which write only what the send buffer has room for and feed the
  watchdog while waiting; a client that takes nothing for 5 s is given
  up on and its connection closed, so the rest of the response, such as
  a file sent 1 KB at a time, is skipped instead of each piece waiting
  out its own 5 s. Files go out through sendContent(), since
  streamFile() writes past those hooks.
Other boards keep their existing paths. transport/http/RequestHead.h
finds where the header ends however the reads split it, and reads its
Content-Length; test_http_state covers both.

The reset reason is read once, before the watchdog is armed; read
later, it reported an ordinary reboot, such as after flashing, as
"watchdog".

Verified on a Pico W with a build that hangs on purpose: the watchdog
rebooted it after 8 s and /api/v1/device reported resetReason
"watchdog". A slow /update upload, a slow download of the web UI and
of a 30 KB file, and a header or small body trickled a byte every 2 s
all reset the board before these changes. Now each finishes or is
dropped, as do slow MP3 and script uploads, and the board keeps running.
Requests whose header is sent in two writes or runs to 1.9 KB are
answered; a 2.1 KB header gets 431 at once, sent whole or trickled.
A 40 KB file whose reader stops taking it held the loop for as long as
the reader stayed connected (40 s in the test); it is now closed about
5 s after the last byte went out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PeripheryHttp.h is now shared by the ESP32 and the Pico, with 300 ms
connect and response timeouts on both, so an unreachable listener only
stalls the display briefly. The body is built by a portable, host-tested
helper (core/api/ButtonEventJson.h), identical to an ESP32's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/pico/smoke.sh makes about a hundred HTTP API calls against a
device or native_sim and prints PASS or FAIL for each. It reads
boardType and /api/v1/capabilities to expect the documented answer where
targets legitimately differ (503 for a compiled-out feature, 503 for
/update on the Pico and 501 in the simulator), undoes every write, and
never prints configuration.

tools/pico/burst.sh fires the eight requests the web UI makes on load at
once, and exits non-zero on anything but a 200 within 45 s. This is the
load that exposed the lwIP connection resets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pico links the shared MqttService, HaAnnouncer and MqttLink, so
topics, commands, retained state and discovery are the same as on an
ESP32.

- Broker names: arduino-pico resolves hosts, including .local through
  lwIP mDNS, only with blocking calls. HostResolverPico bounds each
  lookup to 1 s, and to 2 s with the bare-label .local fallback
  (BoundedHostLookup).
- arduino-pico applies the client timeout to TCP connect and write too,
  so the MQTT client uses 300 ms there.
- Discovery streams many bounded TCP writes and feeds the watchdog
  between them.
- The Home Assistant device is named "AWTRIX NG" unless a hostname is
  set, the same rule as on the ESP32.

test_picohostlookup covers the resolver's bounds and fallback, and
test_hadiscovery checks that the Unicorn keeps the stock device identity
and button entities. test/integration/mqtt_smoke.py (run_mqtt_smoke.sh
starts mosquitto in podman) drives native_sim through discovery,
retained state, commands, button press and release edges and the last
will. docs/reference/mqtt.md describes the Pico's lookup and timeout
bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Unicorn has an I2S amplifier and speaker instead of a buzzer.
core/sound/ToneSamples renders RTTTL as 24 kHz, 16-bit square waves with
the buzzer's pitch, note and rest lengths and 6 ms gap between notes.
platform/rp2040/UnicornToneSink streams them through arduino-pico's I2S
(GPIO 9/10/11) with non-blocking writes, so audio never blocks the loop
or the watchdog. The amplifier enable (GPIO 22) is held low from boot
and whenever nothing plays or the volume is zero.

The sink is the shared buzzer tone sink: "sound" plays a saved melody,
"soundRtttl" inline notes, and buzzerVolume and the volume keys scale
the output. /api/v1/capabilities reports the buzzer and no MP3, track or
radio. If the PIO or DMA resources are missing, the amplifier stays
muted and the capability is left out.

test_tone_samples covers sample generation, stop and replacement, live
gain, and the real notification router picking a saved melody or inline
RTTTL with tone-only capabilities. Heard on a Pico W: the "alert"
melody, the volume keys and idle mute, with Wi-Fi and the display
running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web UI now reads "scripting" from /api/v1/capabilities, next to the
audio sinks it already read. Without it, the Scripts tab and the Run
scripts setting are hidden and a "Not available on this board" note is
shown. The audio page names a missing MP3 or radio output the same way.
Until the capabilities arrive everything counts as present, so nothing
flickers away on a full build. The navigation is rebuilt once they
arrive, and each page that hides something waits for them before it
draws, so a page opened directly by its address is right too.

On a board with fixed wiring (gpio.fixed), the System page lists the
board's pins and reserved ranges instead of offering them, keeps only
the panel height (one of gpio.panel's heights) and the orientation, and
leaves out what the board cannot have: the DFPlayer toggle, the battery
and I2C sensor settings and LDR on GND, and, on the Display page, the
sensor colours. With no piezo pin but a speaker, the melody slider is
called Melody volume. The note that the select button cannot wake a
deep sleep is left out, and the sound switch mentions radio only where
there is one. Every page that reads the capabilities now also takes
gpio from them.

Browser firmware install is limited to the three ESP32 images. A board
that reports another updateImage, a Pico's .uf2, gets no Download &
install, which could only fail, and no .bin upload, which /update
refuses. The update row still names a newer release and links its
notes, where the .uf2 is, and the Maintenance section explains the USB
(BOOTSEL) route instead.

webui/test/capabilities.test.js covers the capabilities, including
the audio and system pages opened directly while /device and
/capabilities are still slow to answer (the harness can now open a
route and delay answers), a fixed board against the same board
reported as configurable (the harness can now serve /api/v1/system and
/api/v1/display), and update-check.test.js both Pico images.
WebUiAsset.h is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/advanced/galactic-unicorn.md covers choosing the image, flashing
over BOOTSEL, provisioning, the buttons, which features each Pico build
has, emulated sleep and the tall-panel rule. README, the release notes,
the device, GPIO and limits references and the mkdocs navigation link to
it.

UF2 targets use the framework's linker layout, not an ESP-IDF partition
table. tools/check_partitions.py now also checks every environment's
LittleFS reservation, including inherited ones, and
test/test_partition_reservations.py covers the checker; CI's docs job,
ci-local.sh docs, CONTRIBUTING.md and building.md all include it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scripting is on in both Pico builds. AWTRIX_FEATURE_SCRIPTING=0 still
builds it out, with the capability, routes and web UI following. The
Pico environments compile core/script, ScriptStore and Berry, and
main_rp2040 wires the script host the way main.cpp does, without script
HTTP requests and icons, which need ESP32-only workers: http.* and
Modbus reads, which share that request path, return false and icons are
not drawn. ScriptStore now persists scripts.
scriptingEnabled now defaults to true on the Unicorn as on every board;
the false default dated from before the Pico could run scripts. A build
with AWTRIX_FEATURE_SCRIPTING=0 reports scriptHeapPool "unavailable"
with a zero budget, as documented.

- ScriptHeapRp2040 gives the VM its own budget from the one heap that
  Wi-Fi, HTTP, MQTT and the display share: 48 KB on the Pico W and 96
  KB, the ESP32's, on the Pico 2 W (AWTRIX_RP2040_SCRIPT_HEAP_KB).
- AsyncQueue uses a no-op lock where std::mutex does not exist.
  arm-none-eabi newlib has no gthreads, and every producer there runs on
  the main loop. Other targets keep std::mutex.
- ScriptStore opens the script directory with an explicit "r".
  arduino-pico's FS::open has no default mode; "r" is the ESP32 default.
- Holding A and C (left and right) for three seconds at power-on turns
  scripting off and shows NOSCR, the ESP32's rescue combo for a script
  that takes the device down on every boot.
- Script output goes through the shared log buffer, so print() and debug
  lines reach /api/v1/logs and the web UI as on the ESP32.

Each call into a script is capped at BerryVM::kInstructionLimit and
returns to loop(), which feeds the watchdog. smoke.sh now installs,
reads back, lists and deletes a script where scripting is available. The
guide, limits, persistence and API docs describe scripting on both
builds.

On a Pico W: the interpreter costs about 19 KB of free heap with no
script, a Hello script runs, and smoke.sh and burst.sh pass. The Pico 2
W is verified by build, host tests and the simulator only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Galactic Unicorn with a Pico 2 W (RP2350) now runs on real hardware:
display at 42 fps, mirror/rotate, notifications, light sensor, Wi-Fi,
mDNS, NTP, the HTTP smoke suite, watchdog recovery and settings kept
across UF2 updates. Measured: the interpreter costs about 17 KB of free
heap and a script of about 28 KB of source installs before the 96 KB
budget runs out. Drop "compile-only" from the README, the guide, the
limits page and the release notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Blueforcer
Blueforcer force-pushed the main branch 2 times, most recently from f04b7b0 to e25a3d6 Compare October 6, 2026 17:09
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants