Commands assume the repo root. pio is PlatformIO Core (pip install platformio).
pio run # build
pio run -t upload # flash over USB (hold BOOT + tap RST if the port is missing)
pio device monitor # serial log at 115200-
Boot – the monitor shows
MWC x.y.z boot, the I2C map (es8311=0x18 pca9557=0x.. rtc=0x51) andHUB75 ok 64x32 @ NNN Hz. The panel plays the test pattern for 10 s on the very first boot. -
WiFi – without credentials the AP
MatrixWeatherClock-XXXXappears; connect, open http://4.3.2.1/, enter SSID and password on the WiFi tab and save. The panel showsSETUP WIFI/4.3.2.1until it is online, then the time.ping matrixweatherclock.localanswers. -
Config –
curl -s http://matrixweatherclock.local/api/config | jq .display.brightness, thencurl -X POST http://matrixweatherclock.local/api/config -H 'Content-Type: application/json' -d '{"display":{"brightness":30}}'dims the panel at once and survives a power cycle.-d '{"location":{"lat":999}}'returns HTTP 400. -
Weather –
curl -s http://matrixweatherclock.local/api/weather | jq .cur.tempmatchescurl -s 'http://api.open-meteo.com/v1/forecast?latitude=LAT&longitude=LON¤t=temperature_2m&temperature_unit=fahrenheit'. Bottom pages rotate; the forecast screen appears every third cycle. Unplug the router: the last data stays and/api/statusnet.wx_errshows the failure. -
Alerts – set the NWS contact on the Location tab, then
curl -X POST http://matrixweatherclock.local/api/test/alert -H 'Content-Type: application/json' -d '{"event":"Tornado Warning","severity":"Extreme","headline":"Test until 5 PM","minutes":3}'shows a red scrolling banner with a flashing frame; the chime plays (outside quiet hours);GET /api/alertslists it; it disappears after 3 minutes. Injecting the same test twice does not flash again. For a live alert runtools/find_nws_test_point.shand point the clock at the printed coordinates. -
Audio –
curl -X POST http://matrixweatherclock.local/api/test/chime -H 'Content-Type: application/json' -d '{"force":true}'plays; with quiet hours covering "now" a test alert stays silent and/api/logshowschime suppressed: quiet hours. Speech – with WiFi up,curl -X POST http://matrixweatherclock.local/api/voice/download, then watchcurl -s http://matrixweatherclock.local/api/status | jq .speechgodownloading(withprogress) →verifying→installedand/api/logprintvoice: pack v1 installed (134 clips, en_US-ljspeech-medium). Thencurl -X POST http://matrixweatherclock.local/api/test/say -H 'Content-Type: application/json' -d '{"text":"Tornado Warning"}'speaks (a phrase that is not in the pack returns 404), a test alert plays its chime followed by the event name, a 5-second timer (POST /api/timer {"seconds":5}) beeps and says "Timer finished" once and then only beeps, andPOST '/api/demo?on=1&sound=1'announces every scenario. Power-cycle:speech.installedstays true. Radar – about 20 s after WiFi/api/logshowsradar: 11 frames, echoes N%;curl -s http://matrixweatherclock.local/api/radar | jq .lists eleven ages from 50 down to 0 minutes;curl -X POST http://matrixweatherclock.local/api/show -d screen=radarplays the loop on the panel with the blinking home cross; the Status tab's Radar card plays it enlarged. With no rain within 100 km the frames are black apart from the marker, which is correct. Indoor sensor – with a BME280/BME680 wired to SDA 1 / SCL 2 the boot log showsindoor: BME280 at 0x76,curl -s http://matrixweatherclock.local/api/status | jq .indoorhasvalid: truewithin 10 s, theindoorpage appears in the rotation, and after 10 minutes the arrows start (breathe on the sensor: humidity rises, arrow up). The Status tab's Indoor card charts/api/indoor/history. With a BME680 the log saysgas sensor on; after five minutes/api/status | jq .indoor.air_scoreshows a score and theairpage appears; breathing on the sensor or opening a marker pen drops the score and, after two minutes below the poor threshold, triggers the chime, "Air quality poor" and a push. Thebaropage scrolls the Zambretti text once 30 minutes of pressure history exist. Remote – with a receiver on GPIO 44 the boot log showsremote: IR receiver on GPIO 44; pressing any key raisesreceivedincurl -s http://matrixweatherclock.local/api/remoteand logsremote: code 0x... not mapped; after mapping it on the Remote tab the key runs its action.curl -X POST 'http://matrixweatherclock.local/api/action?name=show_radar'does the same from a script, and http://matrixweatherclock.local/remote works on a phone. -
RTC / buttons / OTA – power-cycle with WiFi unavailable: the time is right immediately and
/api/statustime.sourceisrtc. K1 short = next page, K1 long = test pattern, K2 short = acknowledge alerts, K2 long = test chime, K3 short = refresh data, K3 long = reboot.curl -F 'firmware=@.pio/build/seengreat_hub75_s3/firmware.bin' http://matrixweatherclock.local/updatereboots into the new version with settings intact. -
0.14.0 additions – verified on the device at 192.168.4.56 on 2026-09-27, except where noted. Results: config flags, new pages, weather values, water temperature, webhook, stopwatch and sleep timer all pass. The voice pack download wedged the clock once and is the open item; see the end of this section. Config flag width – save each of the fifteen existing sections from the web UI and confirm the log line
config: applying changes 0x0.....names the expected bit and the change actually takes effect; the panel section must still answerreboot_required. Then save the two new sections (webhook,events) and confirm the same. This is the check that theuint16_ttouint32_twidening did not break the existing bits. New pages – adduv, sky, rain, water, world, eventtodisplay.pages. Each must either draw or hide itself:curl -s http://matrixweatherclock.local/api/weather | jq .curshould carryuv,cloud,visibilityandprecip, and.daily[0]should carryrain_sumanduv_max. Check the ultraviolet page's band colour against the number, and that visibility reads in miles with imperial units (Open-Meteo returns feet on that unit set, kilometres otherwise, which is the conversion most likely to be wrong). Water temperature – with a station that reports one,/api/status | jq .tide.water_tempappears within a refresh and thewaterpage shows it. A station without a thermometer must leave the field absent and the page hidden, not show a wrong number. World clock – set up to four zones on the Display tab, confirm the full screen shows them all and theworldpage shows the first, and that the main clock and the tide times are still right afterwards. Since 0.16.0 the zone conversion no longer borrows the process timezone (seesrc/time/posix_tz.h), so that last check should be dull -- but it is the one that would catch the regression if anything ever swaps TZ per lookup again. Countdowns – set a one-off and a yearly date, confirm theeventpage counts down, saysTOMORROWthe day before andTODAYon the day, and that a yearly date rolls to next year once it has passed. Hourly chime – set the clock a minute before the hour withhourly_chimeandhourly_strikeon: the hour should strike the right number of times about a second apart, then speak the time ifhourly_speakis on. Inside quiet hours nothing should sound. While an alarm rings or is snoozed, nothing should sound. Spoken time –curl -X POST 'http://matrixweatherclock.local/api/test/say-time?force=1'should say the time as one utterance with no gaps between the words. It needs voice pack v8; on an older pack it must answer 409 rather than speak a partial phrase. Check both 12- and 24-hour settings, and minutes 0, 5, 15, 30 and 47. Stopwatch –POST /api/stopwatch {"action":"start"}, confirm it counts up on the panel, pause and confirm it holds, reset and confirm it leaves the display. Sleep timer –POST /api/sleep {"minutes":1}, confirm the panel fades to black oversleep_fade_secand stays dark, then{"minutes":0}restores the scheduled brightness. One-off alarm – set an alarm one minute out withonceon, let it ring, then confirm/api/configshows it disabled and that it stays disabled after a reboot. Webhook – point it at a request bin orntfy.sh, press Send a test, and confirm the body carries the two configured field names pluseventanddevice. Inject a test alert and confirm it fires for alerts too. Point it at a dead host and confirm/api/status.webhook.errorscounts up without disturbing anything else. Two networks and static address – save a second SSID, take the first network down, and confirm the log showswifi: retrying STA on <second>and the clock associates. Then set a static address, save, and confirm the clock comes back on that address with working DNS. Getting this wrong takes the device off the network, so have the serial console or the setup access point as a way back. Memory – with radar on and the web UI open,curl -s http://matrixweatherclock.local/api/status | jq '.sys'must keepheap_largestabove 18432 andheap_freeabove 47104, which is what the TLS guard needs. Static internal RAM grew from 67,560 to 70,696 bytes in this release, so confirm HTTPS still works: watch a tide fetch and an update check succeed rather than logginglow memory for TLS.Results, 2026-09-27, firmware 0.14.0 on the device.
- Flag widening: all seventeen sections apply. The log shows
applying changes 0x030000forwebhookandevents, which is the proof that the two bits above 16 survive; under the olduint16_tthey were zero. The panel section still answersreboot_required. Note the OTA upload needscurl -H "Expect:"; without it the async server leaves the updater engaged and the next attempt fails withbegin failed, needing a reboot. - Memory: steady state is 101 KB free internal heap and a 58 KB largest block, against the TLS guard's 46 KB and 18 KB. Right after an OTA the largest block dips to 34 KB, still comfortably above the guard.
- Weather:
uv6.9,cloud_cover0%,visibility25.8 miles,rain_sumanduv_maxall present. Visibility was cross-checked against the raw feed: 136,154 ft / 5280 = 25.8 miles, so the imperial unit really is feet. - Pages: all six draw correctly.
uvshowed "UV 7 / HIGH" in the orange 6-8 band,sky"CLOUD 0% / VIS 20 MI",rain"RAIN 0.00" / CHANCE 0%",event"CHRISTMAS / 89 DAYS" (correct for 27 Sep to 25 Dec),world7:01 PM UTC beside a local 3:01 PM Eastern, which also confirms the timezone swap leaves the main clock alone. - Water temperature: station 8727012 does not offer the product, and the page correctly showed "NO WATER TEMP" rather than a wrong number. Pointing at 8726724 (Clearwater Beach) gave 82.0 F, matching NOAA exactly.
- Webhook: both the test and an injected alert arrived at a listener with the configured field names, the
eventanddevicefields, and the customX-Clock-Tokenheader. - Stopwatch and sleep timer: counted 7.0 s on the panel matching 7,076 ms from the API; sleep armed, reported 120 s remaining and cancelled. Bad inputs rejected with 400, unconfigured webhook and pack-less spoken time with 409.
- Open: the voice pack. Fetching pack v8 (4.4 MB) from a local HTTP server reached 35% and the clock then
went off the network and did not return until it was power-cycled. It recorded no crash (reset reason
power-on, black box clean), so it hung rather than panicking; a watchdog would have shown. The old pack
survived intact and
speech.statestayedinstalledwith no error, so the swap logic is safe. Spoken time andhourly_speaktherefore remain unverified. Before retrying, note the pack grew from 3.98 to 4.4 MB, so old plus new now occupy 8.4 MB of the 9.9 MB filesystem during the swap; that is within the free-space check but tighter than before. Retry with nothing else polling the device, and watch the serial console.
- Flag widening: all seventeen sections apply. The log shows
-
0.15.0 additions – verified on the device at 192.168.4.56 on 2026-09-27. Page picker – the Display tab lists all eighteen pages with a tick box, a drag handle and up/down arrows;
GET /api/config.pages_availablefeeds it so it can never drift from thePageIdenum. Checked in a browser: eighteen rows render, ticking a page adds it in the position it sits in, the arrows and drag both reorder, position numbers renumber over the ticked rows only, the first up and last down arrows disable, and Save posts the list in the displayed order. A page ticked while it sits below the ticked block lands last, and one dragged to the top before ticking stays first. Pressure – no longer on the indoor page, which now shows temperature, humidity and dew point (81 F at 43 % gave a dew point of 57 F, correct). The barometer page prefers the sensor: with the BME688 connected it read 29.93 inHg with ansmarker, matching the sensor's 1013.5 hPa sea-level figure. Withindoor.enabledturned off it fell back to 29.90 inHg with awmarker, matching the 1012.6 hPa Open-Meteo reports. Both sources agree to about a tenth of a hPa, which is the cross-check worth repeating after any change here. The reported tendency needs history:press_span_minis 0 until about 20 minutes of fetches have accumulated, and the page says LEARNING TREND until 30 minutes, so the Zambretti text on a sensorless clock appears only after roughly half an hour. Worth re-checking a few hours after a reboot thatd_press_3hbecomes non-zero. -
0.16.0 additions – verified on the device at 192.168.4.77 on 2026-09-29. Zone table – grew from ten US zones to 78 worldwide.
tools/check_timezones.pyis the standing check and must keep passing: it parsessrc/time/tz_table.cpp, reads the system tzfiles, and compares both the C library's POSIX parser and the firmware's own evaluator against the real IANA data at every instant from today through six years out, plus every real transition to the minute. It caught two rules of mine that were genuinely wrong (British Columbia and Alberta both went to permanent offsets during 2026, soAmerica/VancouverisMST7andAmerica/EdmontonisCST6while Toronto and Winnipeg still switch). Its horizon starts at today on purpose: a single POSIX rule cannot also describe a zone's past, so verifying from today forward is the honest test. Current result: 78 zones, 844,464 instants, 0 disagreements, plus 17 edge cases under UBSan and ASan. World clock screen – four rows, label left and time right-aligned, with a+1/-1marker when that zone is on another date. Verified by rebuilding the expected frame from the IANA data with the same TomThumb font and comparingGET /api/framepixel for pixel: exact matches for Singapore, Kyiv, UTC and Iran (IRST-3:30, a half-hour offset), and for Hawaii showing7:38P-1while home was the next day. Right-aligned text ends at x=62, not 63 --drawTextRight(s, W-1, ..)draws atW-1-textWidth, the same one-pixel margindrawForecastuses. The heap leak, which is the thing to remember.timesvc::zoneNowused to borrow the process timezone: setenv, tzset, localtime_r, then put it back. On this newlib that leaks about 50 bytes per call. It was survivable when only the old world page called it, but refreshing four rows twice a second meant 8 calls a second, and the device lost 24 KB a minute and panicked inrender:clockwith 600 bytes of heap left, every four minutes. It was diagnosed by clearing the zones, which made the heap perfectly flat, and fixed by evaluating the POSIX rule arithmetically insrc/time/posix_tz.cpp-- no allocation, no global state, no lock. After the fix, four zones configured: heap flat at ~96 KB free withheap_largestpinned at 51 KB over several minutes. If anything ever calls setenv/tzset per lookup again, this is what it will look like.GET /api/timezones– the same lesson in miniature. Built as anAsyncJsonResponseit cost ~40 KB of heap for 5.3 KB of text, because ArduinoJson copies every string, and it did not hand it straight back: one call tookheap_largestfrom 56 KB to 31 KB. It is now generated a chunk at a time straight out of flash. Ten consecutive calls: identical 5,334-byte payloads,heap_largestunchanged at 34,804. Legacy migration – a config carrying only the pre-0.16.0time.tz2_*keys puts them inworld.zones[0]and reportsapplied:["world"]; confirmed on the device with the rows cleared first. On a schedule – watched for 22 minutes without prompting it: the four full screens took turns in clean round-robin order, radar then world then forecast then hourly, one each about five minutes apart, and the world clock stayed up for itsshow_sec. Worth knowing before testing this: withpage_sec6 andforecast_every_n_cycles3 an opportunity only comes round every few minutes, and there are four candidates, so a five-minute look is not long enough to conclude anything. On demand –POST /api/show?screen=world,POST /api/action?name=show_worldand the phone remote button all work. The world branch sits above the!wx.validgate inrequestFullScreenon purpose: a world clock needs no weather. It also refreshes the cache itself when asked while empty, so the first request right after saving zones is not refused. Change flags – every section posted its own current values straight back and each was listed inapplied, which is the check that the wideneduint32_tflag word and the newCHG_WORLDbit did not shift anything.tm_isdst–posix_tz::zone_tmsets it (New York September 1, January 0, Sydney the other way round, Arizona always 0). Nothing reads it today, but returning 0 all year would quietly break whatever does first. -
0.17.0 additions – verified on the device at 192.168.4.77 on 2026-10-01. The structural fix first. A theme used to carry only three of the six colours, and
temp,hiandlowere read straight from config at ten sites, so on Christmas the clock turned red and the date green while the forecast strip and the high/low page stayed on the everyday orange and blue.themes::Themenow carries all six andrenderer.cpphascolTemp()/colHi()/colLo()beside the existing three; every drawing site goes through an accessor. If you add a colour, add an accessor -- readingg_cfg.display.colorsdirectly is how half the panel stops following a theme. Four sites also hardcoded a copy of a config default, so the setting had no effect there: the sunrise and sunset lines, the rainfall line and the sea temperature. They read their fields now. The date page's number line used the text colour, so "Date" only painted the weekday; the whole page takes the date colour now. Palettes – eight, applied byPOST /api/config {"display":{"color_preset":"ocean"}}, which is write-only and copies the six colours intodisplay.colors. Verified by parsingsrc/display/themes.cppfor the expected values and comparing against both/api/configand the actual pixels in/api/frame: all eight matched exactly, the clock digits included. Also checked that a colour tweaked by hand after applying a palette survives a later save, which is the property that makes "the pickers are the truth" true. Holiday control –display.holidays_enabledis a bitmask by table position, sothemes::HOLIDAYSis append-only; inserting or reordering would silently remap which holidays somebody had switched off. All nine were forced in turn and the forecast strip was confirmed themed, which is the thing that did not work before. The default is0xFFFFFFFF, which means "every holiday, including ones a later firmware adds".syncHolidayBitsin the web UI therefore rewrites only the bits it drew a tick box for and carries the rest of the stored mask through untouched (v0.17.1 -- the first version wrote a mask built from zero, so one Save from an older page would have switched off every holiday added after it). Regression check: extractsyncHolidayBitsfrom the page the device is serving and run it under node against a stub DOM -- nine ticked against a stored0x3FFmust still post0x3FF, not0x1FF. Forcing a holiday that is unticked still works, by design. An unknown id inforce_themeorcolor_presetis rejected with HTTP 400, and the renderer falls back to automatic if one ever goes stale. A trap worth knowing when testing this. A pinned theme outranksdisplay.colors, so ifforce_themeis set, applying a palette writes the config and changes nothing on the panel. My first verification run failed eight palette checks for exactly this reason -- the clock was pinned to New Year's Eve, and every "wrong" colour decoded to that theme's gold. Clearforce_themebefore testing colours. The Colours card now says so on screen when a theme is pinned. The other first-run failures were the test grabbing/api/frameafter the forecast screen had already timed out; assert the screen is actually up (sys.screen) before reading pixels.