From a0e56b40e22ac3c574a672b0690327d526a6a86f Mon Sep 17 00:00:00 2001 From: ewowi Date: Fri, 11 Sep 2026 07:42:30 +0200 Subject: [PATCH 1/3] Add MoonCloud: opt-in usage stats and a public message board Devices can now tell the project what hardware and configuration they run on, and post to a shared message board, both strictly opt-in and off by default. Nothing leaves a device until someone switches it on. The data lands in a Cloudflare Worker backed by D1 at stats.moonmodules.org, and the same numbers render back into the UI as pie charts. Performance: esp32 flash +13 KB (2,025 KB, 81% of slot); desktop +22 KB. Desktop tick 127 us (-20), 7,874 FPS (+1,072). esp32 tick 8,354 us. The esp32-eth line reads +277 KB, which is a baseline recorded from a differently configured build rather than MoonCloud growth: esp32-eth is the smaller image (1,133 KB text vs 1,496 KB for esp32-16mb) because WiFi is compiled out, and the two classic targets cannot differ by 270 KB from one shared source change. Core: - MoonCloudModule owns the compiled-in address and the POST seam; MoonStatsModule and MoonTalkModule hang off it as children, each with its own consent. - Installation id is SHA-256(salt, MAC) truncated to 32 hex chars, one scheme on every target: eFuse MAC on ESP32, a stored random locally-administered address on desktop. The raw MAC never leaves the device. - Vendored sha256.{h,cpp} (~150 lines, FIPS 180-4, pinned against published vectors) rather than a library, because ESP-IDF ships mbedtls while desktop would link something else. - MoonTalk holds a typed message for 2 s before sending: a Text control POSTs per keystroke, which otherwise published "hel", "hell", "hello" as three messages. - Both tick1s hooks warn under -Wfunction-effects. The warning names a real property (the send blocks) rather than a mistake: it is the 1 Hz housekeeping tick, and the guards above it return first, so a reported device never reaches the blocking part again. Light domain: - platform::httpsPost is the one seam: libcurl on desktop (optional via MM_HAVE_CURL), esp_http_client with the cert bundle on ESP32. UI: - Ten pie charts for the stats, a message board, and a one-line consent explanation. Every pie counts everything; the Build pie shows the dev/release split. - The reboot button is hidden on desktop builds, keyed on deviceModel rather than chipModel, which now reports arm64/x64. Scripts/MoonDeck: - run_mooncloud.py and purge_mooncloud.py for the local Worker and for emptying the tables. Tests: - unit_sha256, unit_InstallationId, unit_MoonStatsReport, unit_MoonStatsModule, unit_MoonTalkModule, plus mooncloud-report and ui-mooncloud on the JS side. 1,930 cases pass (+26), 148 JS tests pass. Docs/CI: - privacy-policy.md rewritten from 142 lines to 79, stating a rule rather than an inventory of one feature. - mooncloud/ carries README, DEPLOY, CHANGES (what changed and how to reverse each part), schema.sql and wrangler.toml. - Backlogged: a MoonCloud card cannot tell "unreachable" from "nothing there". A DNS failure and an empty board render identically, found on the bench when a stale negative DNS entry made the log look empty while messages were arriving. Known, not fixed here: both D1 tables still hold bench test data, and the ESP32 freshness check flags both classic images (esp32-eth was built one minute after the CMakeLists change and does contain sha256; esp32-16mb predates it). Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 18 + docs/backlog/backlog-core.md | 19 + .../plans/Plan-20260910 - MoonCloud.md | 177 +++++++ ...0910 - projectMM writes British English.md | 129 +++++ docs/metrics/repo-health.json | 157 +++--- docs/metrics/repo-health.md | 74 +-- docs/moonmodules/core/system.md | 41 ++ docs/privacy-policy.md | 83 ++- esp32/main/CMakeLists.txt | 1 + mooncloud/.gitignore | 5 + mooncloud/CHANGES.md | 76 +++ mooncloud/DEPLOY.md | 89 ++++ mooncloud/README.md | 109 ++++ mooncloud/schema.sql | 79 +++ mooncloud/seed.sql | 14 + mooncloud/worker.js | 493 ++++++++++++++++++ mooncloud/wrangler.toml | 28 + moondeck/run/purge_mooncloud.py | 141 +++++ moondeck/run/run_mooncloud.py | 65 +++ src/core/MoonCloudModule.h | 133 +++++ src/core/MoonStatsModule.h | 359 +++++++++++++ src/core/MoonTalkModule.h | 162 ++++++ src/core/SystemModule.h | 6 + src/core/sha256.cpp | 116 +++++ src/core/sha256.h | 42 ++ src/main.cpp | 34 ++ src/platform/desktop/platform_desktop.cpp | 109 +++- src/platform/esp32/platform_esp32.cpp | 7 + src/platform/esp32/platform_esp32_ota.cpp | 26 +- src/platform/platform.h | 25 + src/ui/app.js | 316 ++++++++++- src/ui/style.css | 36 ++ test/CMakeLists.txt | 5 + test/js/mooncloud-report.test.mjs | 112 ++++ test/js/ui-mooncloud.test.mjs | 78 +++ .../scenario_MoonModule_control_change.json | 30 +- .../light/scenario_Audio_mutation.json | 34 +- test/scenarios/light/scenario_Aurora_fps.json | 62 +-- .../light/scenario_Driver_mutation.json | 40 +- .../light/scenario_Effects_composition.json | 6 +- .../light/scenario_Fields_polar_lut.json | 66 +-- .../light/scenario_Fluid_solver.json | 94 ++-- .../light/scenario_GridBlacks_blackpixel.json | 12 +- .../light/scenario_GridLayout_resize.json | 28 +- .../light/scenario_Layer_base_pipeline.json | 4 +- .../light/scenario_Layer_memory_1to1.json | 6 +- .../light/scenario_Layouts_mutation.json | 26 +- .../scenario_MoonLiveEffect_livescript.json | 54 +- .../light/scenario_MoonLive_pipeline.json | 30 +- .../scenario_MultiplyModifier_memory_lut.json | 6 +- .../scenario_MultiplyModifier_pipeline.json | 8 +- .../light/scenario_Trails_ladder.json | 50 +- .../light/scenario_modifier_chain.json | 16 +- .../light/scenario_modifier_swap.json | 24 +- test/scenarios/light/scenario_perf_full.json | 158 +++--- test/scenarios/light/scenario_perf_light.json | 28 +- .../light/scenario_peripheral_grid_sweep.json | 120 ++--- .../light/scenario_peripheral_switch.json | 38 +- test/unit/core/unit_InstallationId.cpp | 103 ++++ test/unit/core/unit_MoonStatsModule.cpp | 229 ++++++++ test/unit/core/unit_MoonStatsReport.cpp | 126 +++++ test/unit/core/unit_MoonTalkModule.cpp | 36 ++ test/unit/core/unit_sha256.cpp | 55 ++ 63 files changed, 4220 insertions(+), 633 deletions(-) create mode 100644 docs/history/plans/Plan-20260910 - MoonCloud.md create mode 100644 docs/history/plans/Plan-20260910 - projectMM writes British English.md create mode 100644 mooncloud/.gitignore create mode 100644 mooncloud/CHANGES.md create mode 100644 mooncloud/DEPLOY.md create mode 100644 mooncloud/README.md create mode 100644 mooncloud/schema.sql create mode 100644 mooncloud/seed.sql create mode 100644 mooncloud/worker.js create mode 100644 mooncloud/wrangler.toml create mode 100755 moondeck/run/purge_mooncloud.py create mode 100755 moondeck/run/run_mooncloud.py create mode 100644 src/core/MoonCloudModule.h create mode 100644 src/core/MoonStatsModule.h create mode 100644 src/core/MoonTalkModule.h create mode 100644 src/core/sha256.cpp create mode 100644 src/core/sha256.h create mode 100644 test/js/mooncloud-report.test.mjs create mode 100644 test/js/ui-mooncloud.test.mjs create mode 100644 test/unit/core/unit_InstallationId.cpp create mode 100644 test/unit/core/unit_MoonStatsModule.cpp create mode 100644 test/unit/core/unit_MoonStatsReport.cpp create mode 100644 test/unit/core/unit_MoonTalkModule.cpp create mode 100644 test/unit/core/unit_sha256.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 160c40d5..62cb0cdd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -128,6 +128,7 @@ add_library(mm_core STATIC src/core/FilesystemModule.cpp src/core/FileManagerModule.cpp src/core/MqttModule.cpp + src/core/sha256.cpp src/core/Scheduler.cpp src/core/moonlive/MoonLive.cpp src/core/moonlive/MoonLiveCompiler.cpp @@ -175,6 +176,23 @@ target_link_libraries(mm_platform PUBLIC $<$:ws2_32> $<$ r.ok ? r.json() : null) + .then(d => draw(d?.messages)) + .catch(() => draw(null)); +``` + +A DNS failure, a dead network, a 500, and a genuinely empty message list all reach `draw(null)` and render the same nothing. `renderMoonCloudStats` fails even more quietly: its `.catch` drops the section entirely, so an unreachable server and a server with no reports both render a card with no statistics on it. The Worker's own dev page (`mooncloud/worker.js`) does the third variant, printing `"No data yet."` on any failure, which states as fact something it has no evidence for. + +Found on the bench: with `kMoonCloudUrl` freshly switched to `stats.moonmodules.org`, a stale negative DNS entry on the local network made the name unresolvable to the browser while the firmware's own POSTs kept succeeding. Messages were arriving on the server and the log stayed empty, with nothing on screen to say why. The device looked broken and was not. + +The fix is to distinguish the cases the promise already separates: a rejected fetch is unreachable, `!r.ok` is a server error, and a parsed empty array is genuinely empty. Only the last one may claim there is no data. A card that cannot reach MoonCloud should say so, because the user's next action differs completely: check the network, versus wait for someone to post. + +Worth pairing with the request-storm item in the same area: `renderMoonTalk` fetches uncached on every `createCard`, so a failing endpoint is also retried far more often than it needs to be. diff --git a/docs/history/plans/Plan-20260910 - MoonCloud.md b/docs/history/plans/Plan-20260910 - MoonCloud.md new file mode 100644 index 00000000..5ec8478d --- /dev/null +++ b/docs/history/plans/Plan-20260910 - MoonCloud.md @@ -0,0 +1,177 @@ +# Plan: MoonCloud + +## MoonCloud + +**MoonCloud** is the family name for everything projectMM does with a server we run. One Worker, one database, one deploy, and one container module in the UI holding a child per member. + +Each member is a separate feature with its own consent, because agreeing to share a chip model says nothing about wanting to publish messages. Each gets its own prompt, its own consent control, and its own paragraph in the privacy policy. + +| Member | What it is | Status | +|---|---|---| +| **Stats** | one opt-in report per install or upgrade, and the aggregates back | built | +| **Talk** | a public message board between devices, Meshtastic-style | built | +| **Sync** | device to device: a joint show across houses | designed, see below | + +**The installation id is the one thing they share.** It identifies an installation across every member, so a device reads as the same one to Stats and to Talk, and any future pairing has an identity to work with. + +### Stats + +We want to know what projectMM runs on, so effort goes where the users are. Today that is guesswork from Discord and issue reports, which over-represent whoever is loudest and miss everyone quietly running a board that works. + +One report per install or upgrade, sent only after the user says yes, describing hardware and configuration: chip, flash, PSRAM, SDK, board, and which modules are enabled. The aggregates come back to the card that asked for consent, so contributing earns the answer. + +### Talk + +A public message board between devices, in the shape Meshtastic's channel chat has. A message is sent because somebody typed one. + +Two consents: `consent` allows posting, and `shareName` (off by default) allows the device name to ride along. A device name is the one field here that identifies a person rather than a machine, so sharing it is its own decision. Without it a message shows the first 8 characters of the installation id, which groups one device's messages while leaving the sender unnamed. + +Reading the board needs no consent and sends nothing: it is public, and refusing to publish still leaves you able to read. + +### Sync + +A joint show across houses: several installations running one synchronized animation together. + +**The same deployment.** Cloudflare Workers terminates WebSockets natively, and a Durable Object is a single addressable instance with in-memory state: one per group, which is exactly what a group is. So `mooncloud/worker.js` grows a `/sync/` route beside the others and one deploy still carries everything. + +**A different transport, which is why it is its own member.** Stats is a single POST per firmware install; a shared show needs persistent connections, presence, groups and time alignment. + +Two things shape the design. **Durable Objects need a paid Cloudflare plan** (Stats and Talk run on Workers plus D1, both free), so Sync is the member that starts costing money. And **the hard part is device-side**: round-trip latency to the edge is tens of milliseconds and varies per device, so the shape that works is to sync clocks, agree a start time in the future, then run the animation locally from each device's own clock. + +**Talk is the proof of the plumbing**: identity, consent, and a shared stream that devices both write to and read from, on the infrastructure Sync would use. + +## The installation id + +Every member identifies an installation the same way: **`SHA-256(salt || platform::getMacAddress())`, truncated to 16 bytes, 32 hex characters. One scheme on every target.** + +Per target, the seed is whatever that platform means by "this installation": the eFuse MAC on ESP32, and on desktop and in Docker a random locally-administered address generated once and persisted to `/.config/identity`, following systemd's machine-id pattern. The hashing code takes six bytes from the platform layer, which is why it sits in `src/core/` rather than behind the platform seam: a new target inherits a correct id by implementing `getMacAddress` alone. + +**The id is stable across a reflash, a factory reset and an image upgrade.** That is what makes an upgrade distinguishable from a new install, and it is the property the whole feature rests on. An identifier that changed on reflash would report every bench board as a stream of fresh installs. + +**The salt is MoonCloud-specific and differs from every other salt in the project.** The same MAC produces the MQTT topic prefix and the Home Assistant `unique_id`, both visible on the user's own network, so a distinct salt keeps a report from being tied to a device somebody can observe locally. Measured on the bench: MAC `2A:DA:B7:C6:A0:94` gives id `c26086e8...`, where an unsalted hash gives `26a61cc4...`; a test asserts the report carries neither the address nor the unsalted form. + +**It is pseudonymous, and the privacy policy says so.** Two reports carrying one id came from one installation: that is the point. An ESP32 id is reversible in principle by anyone determined, since the salt is in open source and a MAC is short; a desktop id is seeded from `std::random_device` and is not. An install that predates the identity file keeps the historic address, so upgrading leaves a device's name and topics alone, at the cost of those installs sharing one id. + +**On a read-only container filesystem the address is valid for that run and retried next start.** An install holding config keeps the historic address every start; a fresh install generates a new one each start and so reports as a new installation each time. Rare, invisible from the server, recorded so an anomaly in the desktop numbers has an explanation. + +**Where the design came from.** A random per-install id was the first draft, and it wrecks the install-versus-upgrade split: a reflash yields a new id, so every reflashed board reports as a fresh install. A hardware-consistent id is the answer, and the scheme above is it. Reporting into an existing usage server was the other route considered, and running our own turned out to fit better, since the fields worth collecting diverge. + +## Steps: Stats + +The first member, and the one that proves the plumbing every later one uses. + +### 1. The report builder, as a pure function (DONE) + +`src/core/MoonStatsReport.h`: one function from the live module tree to a JSON string, with no network, consent or persistence involved. It reads what is already in memory: `chip`, `flash`, `psramType`, `sdk` and `deviceModel` from SystemModule, `version` from FirmwareUpdateModule, and the enabled module names from the tree. + +**Fields are named one at a time and copied by name**, which is the design rather than an implementation detail: `deviceName`, `mac`, `ssid` and `password` are live controls sitting beside `chip` and `flash`, so a builder that emitted what it found would leak on its first run. + +**Its test is the privacy policy made executable.** `unit_MoonStatsReport.cpp` builds a tree stuffed with a device name, a MAC, an SSID, a password and a user's free text, then asserts the report carries none of them, checking values and keys alike. **Control-checked by sabotage**: made the builder emit `deviceName` and watched the test fail on both. + +### 2. The installation id (DONE) + +`src/core/sha256.{h,cpp}` plus `src/core/InstallationId.{h,cpp}`, implementing the scheme above. + +**SHA-256 is vendored, about 150 lines.** ESP-IDF ships mbedtls and the desktop would link something else, so a library means two implementations of one function that must agree byte for byte, where a divergence produces ids that silently differ between platforms. One file is the smaller thing to own, the algorithm is frozen, and `unit_sha256.cpp` pins it against the published FIPS 180-4 vectors (verified independently against Python's `hashlib` before being trusted), including the 55/56/64-byte cases where padding bugs hide. + +The id is gated on consent: `Never` yields an empty string, so a user who declined has none. + +### 3. Consent, and the one-time trigger (DONE) + +`src/core/MoonStatsModule.h`: `consent` (Not answered / Yes / Not now / Never) and `reportedVersion`, both persisted like any other control. `reportDue()` is true when consent is Yes and the running version differs from the recorded one, so a reboot sends nothing and an upgrade sends exactly one report. Not-now is a deferral, so a busy user is asked again after the next upgrade. + +Install and upgrade are told apart by `reportedVersion` alone: empty means this install has never reported. **Control-checked by sabotage**: removing the consent gate fails the test. + +`MoonCloudModule` is the container, with Stats and Talk as children, and both are marked `markWiredByCode()` so the tree comes from the code rather than from whatever the config file last recorded. + +**Still to build here**: the first-boot prompt, and `setApMode()`. That last one is how NetworkModule tells Stats the device is serving its own access point, so the prompt waits until the device is on a real network and the user is past provisioning. It is defined and unused today. + +### 4. The send (DONE) + +One POST, fire and forget, off the render thread on `tick1s`. A failure is silent and not retried: a lost report costs one row in an aggregate, where a retry queue is persistence, scheduling and a failure mode for something nobody is waiting for. `markReported()` runs on hand-off, so an unreachable server leaves the device quiet rather than re-sending every boot. + +**HTTPS through one seam, `platform::httpsPost`, using the TLS each OS already ships.** libcurl on desktop, present on macOS and every Linux distribution and found with `find_package(CURL)`; `esp_http_client` with `esp_crt_bundle_attach` on ESP32, the same pair the OTA path uses, so a device adds call-site code rather than a TLS stack. Nothing is vendored and no certificate store is ours to maintain. + +`platform::httpRequest` stays what it is: a LAN socket for the Philips Hue v1 API, plain HTTP, host given as a dotted-quad IP. `httpsPost` is the one that resolves names and verifies certificates. + +**libcurl is optional.** Without it the build succeeds, `MM_HAVE_CURL` stays undefined and `httpsPost` returns false, so a build never fails over an opt-in statistic. CMake prints which way it went. + +Verified against live endpoints: a valid certificate sends, and `expired.badssl.com` is refused, so the verification is real. `VERIFYPEER` and `VERIFYHOST` are set explicitly rather than left to curl's defaults, so a later edit has to say out loud that it is turning them off. + +### 5. The server, API only (DONE) + +Cloudflare Workers plus D1, EU jurisdiction pinned. Four endpoints: `POST /api/report`, `GET /api/stats`, `POST /api/talk`, `GET /api/talk`. + +**Workers rather than a box we own, for one specific reason**: `request.cf.country` resolves the country at the edge, so the promise that the IP address is never stored is structural. On our own server that line would be one config change away from being false. + +Its source is public, in this repository, for the same reason the firmware is. + +Built as `mooncloud/`: `worker.js`, `schema.sql`, `wrangler.toml`, `seed.sql` for local sample rows. `uv run moondeck/run/run_mooncloud.py --seed` runs it under workerd, the same runtime Cloudflare uses, so the local server is the deployed server with a local database. + +Verified end to end: a report carrying `deviceName`, `mac`, `ssid` and `password` stored only the allowlisted fields, and three re-posts of one id and version left the installation count unchanged. Eight contract tests in `test/js/mooncloud-report.test.mjs`, control-checked by adding `deviceName` to the allowlist and watching them fail. + +### 6. The aggregates on the device's own card (DONE) + +The Stats card fetches `GET /api/stats` and renders what everyone reported as pie charts: version, chip, board and country. + +**The card is the UI, which is why the server is an API.** There is one place to build and keep in sync with the schema, contributing earns something visible in return, and a user sees the shape of the data their own report joined. `/api/stats` is readable by anyone, so it carries aggregates that are safe to show the world, which counts of chips and versions are. + +Fetched when the card is opened, and a failure leaves the card without the section: the aggregate is a reward rather than something the device needs. + +`server` and `serverPort` are controls on the card, so one setting drives both the report and the fetch. Port 443 or none means the public server over HTTPS; anything else is a local or self-hosted MoonCloud over plain HTTP. + +## Next: filter the charts by clicking a slice + +Sketched 2026-09-10; nothing started. Every pie counts every report, and the `Build` pie shows the released-against-development split rather than the other charts pre-applying it. The natural next step is to make a slice a FILTER: click `development` and every other chart re-counts within it, click `NL` and the rest describe the Netherlands. + +That is why no chart filters by default. A default that quietly removed development installs was the first shape, and it made the headline number smaller with nothing saying why, while hiding a developer's own bench boards from the card running on one of them. Counting everything and showing the split is both honest and the thing cross-filtering builds on: a filter has to be something the reader chose, or it is just a hidden default with extra steps. + +The server already takes `?dev=0`, so the shape exists for one dimension. Generalising it means a filter parameter per dimension and one query builder rather than a special case per chart. + +## Next: share a MoonLive script over MoonTalk + +Sketched 2026-09-10 while Talk was built. **The unique feature nobody else has**: projectMM ships a scripting language whose programs are about a kilobyte of text, and a message board between devices. Together they mean a script someone wrote on their wall is one tap from running on yours. + +**What makes it plausible.** The scripts are tiny: the shipped `.mle` files run 780 to 1907 bytes and the whole library of twenty-odd is 36 KB. A script is self-contained by design, so the text is the artifact. And the device already compiles and runs arbitrary script text safely, which is the hard half and is done. + +**The design work is that a script is five to seven times a message.** Talk caps a message at 280 characters, deliberately: a chat message is a sentence, and one caller must not fill the table. Three options: + +- **A second endpoint** (`/api/script`) with its own size cap and table, and a message that references it by id. The chat table stays a chat table, a script is fetched when someone wants it, and a board read stays small. Most work, cleanest shape, and the recommendation. +- **An attachment column on the message**, capped separately. One table and one post, at the cost of every board read carrying script text unless the query is careful. +- **Share a URL.** A script already lives in the user's File Manager and a device could serve it over the LAN. Free, and limited to one network, which is the case that makes the feature interesting. + +**A shared script is code from a stranger**, in a way chat text is not. Three questions the design answers rather than discovers: + +1. **Loading is explicit and reversible.** A script arrives as something to look at, and taking it leaves what the user already has intact. `/moonlive` (user) shadowing `/.moonlive` (factory) is the existing trap. +2. **The device is the sandbox.** MoonLive reaches no filesystem and no network, so the blast radius of a hostile script is the LEDs and the render budget. Worth confirming rather than assuming: a script that never yields is a watchdog reset, which is a denial of service on someone's wall. +3. **Attribution and removal.** Any sender id can be claimed, and a message stays once posted. Fine for chat, weaker for code. + +Depends on Talk shipping and on the server being deployed. + +## Scope + +- **One report per install or upgrade.** The trigger is a version change, so a device that keeps running stays quiet. +- **Hardware and configuration only.** "Which effects are most used" is a genuinely interesting question and a different promise: it would need its own consent and its own policy paragraph. +- **A failed send is forgotten.** See step 4. + +## Verification + +1. `build_desktop.py --tests` and `test_desktop.py`: the forbidden-fields test is the one that matters. +2. The consent prompt on a desktop run: Never is remembered across a restart, and a declined install opens no connection (verifiable with a local listener that should never be reached). +3. On the bench, ONE board (PO's call): the prompt appears after an upgrade, Yes sends one report, and a second boot stays quiet. +4. End to end: a report from a real device appears in `GET /api/stats`, and its row holds a country and no address. + +## Risks + +- **The totals are trust-based.** Any sender id can be claimed, so the numbers rest on people having no reason to fabricate them. Accepted: the alternative is authenticating users to defend a statistic. Rate limiting at the edge blunts casual abuse, and the privacy policy says this out loud. +- **A prompt that annoys is worse than no data.** It appears once per install or upgrade, and Never is permanent. If it ever fires more often than that, it is a bug. +- **`std::random_device` is deterministic on some libstdc++ targets**, which would make two fresh containers share an id. Container-only, and detectable in the data as an implausibly popular id. +- **The server is a standing commitment.** It needs an owner, a domain and an account, and that rather than the code is what kept this in the backlog. + +## The server is owned + +**ewowi owns the Cloudflare account** (settled 2026-09-10). That was the last open question and the one that kept this feature in the backlog: the code was never the blocker, a person willing to hold the account was. Stats and Talk run on Workers plus D1, both free tier; Sync would need Durable Objects, which are paid, so it is also the point where MoonCloud starts costing money. + +What ownership means in practice: the login that deploys, the address that gets the alerts, and the person who notices when it breaks. [mooncloud/DEPLOY.md](../../../mooncloud/DEPLOY.md) is the sequence, and three of its steps need that account rather than any automation: `wrangler login`, the DNS record for `stats.moonmodules.org`, and the decision to flip the firmware default. + +**The firmware default is the last step and the one with no recovery.** `MoonCloudModule.h` ships pointing at `127.0.0.1:8787`, a local server for development. Changed before the domain answers, every device in the wild sends one report into nothing and never retries, because a failed report is deliberately not queued. diff --git a/docs/history/plans/Plan-20260910 - projectMM writes British English.md b/docs/history/plans/Plan-20260910 - projectMM writes British English.md new file mode 100644 index 00000000..00dacca1 --- /dev/null +++ b/docs/history/plans/Plan-20260910 - projectMM writes British English.md @@ -0,0 +1,129 @@ +# Plan: projectMM writes British English + +## Context + +The project is American-spelled by an explicit rule ([coding-standards.md:18](docs/coding-standards.md), [CLAUDE.md:101](CLAUDE.md)), enforced by `check_prose.py` at the commit gate and by `hook_prose.py` on every write. The PO wants British: `colour`, `behaviour`, `initialise`, `centre`, `grey`, `catalogue`, `analyse`. + +The deciding argument is user impact. The project has no official launch, so ADR-0013's "no migration code, documented break" applies at its cheapest: nothing outside our control has adopted our names yet. What *is* outside our control keeps its American spelling, and the PO accepts that discrepancy: **CSS/HTML properties, WLED/Home-Assistant wire keys, vendor symbols, SPDX headers.** + +Measured scope: ~2,900 American occurrences across **445 files**. Two PO decisions widen it to the maximum: internal C++ identifiers are renamed **including the FastLED-mirroring ones** (`colorFromPalette` becomes `colourFromPalette`), and the **MoonLive script builtins are renamed** with the 18 shipped scripts updated and a MIGRATING entry. + +## What the exploration settled + +- **The switch is one dict.** `check_prose.py:50` holds `SPELLING = {"behaviour": "behavior", ...}` as substring stems. `hook_prose.py` shells out to it rather than copying it, so inverting that one dict flips both the write-time hook and the commit gate. +- **The hook blocks the migration itself.** `hook_prose.py` is a live `PostToolUse` hook (`.claude/settings.json:9`) that **refuses any write adding a British spelling**. Until it is inverted, every edit in this migration is rejected. This is step 1, not cleanup. +- **The persisted-key risk is theoretical, not actual.** **15 control registrations** across 12 effects contain American spellings (`colorMode` twice, `panCenter`, `oneColor`, `color_chaos`, …) and controls are addressed by name over `POST /api/control` and persisted into `.config/*.json`. But **no config under `build/fs/.config/` currently holds one**, and `deviceModels.json` sets none of them. Nothing on the bench breaks. +- **Those 13 names live only in C++**, not duplicated in `src/ui` or scripts, so each is a single-site rename. +- **The external contract is tiny and self-contained**: 214 CSS/HTML hits (all in `src/ui` + `mooninstaller`), 4 WLED/HA wire keys, 9 vendor symbols, 251 SPDX headers. +- **`meter` is out of scope.** All 111 uses are the *device* sense (an audio meter's ballistic); British keeps `meter` there. Only `metre` (length) would change, and the repo has none. +- **`docs/` is smaller than it looks.** 140 of 178 matching files are already `EXEMPT` in `check_prose.py` (history, backlog, generated moxygen/tests pages, friend-repos), leaving ~38 real files. +- **CodeRabbit is skipped for this change** (PO), so the ~100-file branch ceiling does not apply. The split below follows *risk*, not review limits. + +## Hard exclusions: never rewrite these + +A blind find-and-replace breaks the build and has done so before ([memory: a spelling sweep once renamed an API inside `draw.h`](CLAUDE.md#principles)). Every step is a reviewed rename, never a blanket `sed`. + +| Never touch | Why | Count | +|---|---|---| +| `std::initializer_list`, `initializer_list` | C++ standard library | 5 | +| `CRGB`, `CHSV`, `esp_*_color_*`, `lcd_color_*` | vendor symbols | 9 | +| CSS `color:`, `bgcolor`, `type="color"`, `backgroundColor` | the language defines these | 214 | +| `"col"`, `"color"`, `"colors"`, `"cols"` in WLED/HA payloads | interop contract with software we do not control | 4 | +| `SPDX-License-Identifier` | legal identifier | 251 | +| `src/platform/desktop/vendor/`, `src/ui/vendor/` | upstream code, already EXEMPT | whole trees | +| `docs/history/`, `docs/backlog/`, `docs/friend-repos/` | quoted records; rewriting falsifies them | already EXEMPT | +| `analysis`, `analyses` | already correct in both dialects | 219 | +| `meter` (device sense) | British keeps it | 111 | + +The files that speak a foreign protocol (`MqttModule`, `HttpServerModule`, `DevicesModule`, `HueDriver`) keep American **for the wire keys only**; their own comments and internal identifiers go British like everywhere else. + +## Steps + +Six branches, each independently committable and verifiable. Order matters: the enforcement must flip first, and the build-breaking renames come before the prose sweep so a bisect stays meaningful. + +### 1. Flip the enforcement (small, blocking) + +`moondeck/check/check_prose.py`: invert `SPELLING` to American→British, keeping the substring-stem style (`"initializ": "initialis"` covers *-ize/-ization/-izer*). Add the words the current dict lacks but the repo uses: `catalog`→`catalogue`, `gray`→`grey`, `neighbor`→`neighbour`, `synchroniz`→`synchronis`, `defense`→`defence`. + +Three traps the inverted dict must handle, each needing an exclusion in the checker rather than a naive stem: +- `initializer_list` (C++ stdlib) +- `analysis`/`analyses` (already correct; only `analyze`→`analyse`) +- `license` as a *verb* and in SPDX headers stays; only the noun becomes `licence` + +Update the rule statements: [docs/coding-standards.md:18](docs/coding-standards.md) (rewrite the paragraph and its rationale, which currently argues the opposite) and [CLAUDE.md:101](CLAUDE.md). Both must name the external-contract exception explicitly, or the next reader will "fix" a CSS property. + +**Verify:** `uv run moondeck/check/check_prose.py` now flags American spellings in added lines; write a file containing `colour` and confirm the hook permits it. + +### 2. MoonLive script API + the 18 shipped scripts (user-visible) + +Rename the builtins in `src/core/moonlive/MoonLiveBuiltins_common.h` and `src/light/moonlive/MoonLiveBuiltins_light.h`: `setPaletteColor`→`setPaletteColour`, `setPaletteColorZ`, `setPalEntryHSV` (unchanged: HSV is an initialism), `paletteR/G/B` (unchanged). `setRGB` stays: RGB is an initialism, not a spelling. + +Update the **12 call sites across 9 files** under `moonlive/effects/` (aurora, balls, comet-trail, fractal, metal, noise, octopus, stadbeest-eyes, stadbeest-legs). `setPaletteColorZ` is used only by `aurora.mle` (2 of those 12). + +**MIGRATING entry**, following the format of the `floor` entry at [docs/MIGRATING.md:27](docs/MIGRATING.md): state the action (rename the call in any script you wrote), and name the shadowing hazard, which is the real trap: `/moonlive` (user) shadows `/.moonlive` (factory), so a stale user copy of a shipped script keeps calling the old name and fails with "unknown function" at the call site, while the factory copy is fixed. + +**Verify:** `./build/macos/test/mm_tests -tc="*every script in moonlive*"` (the test that compiles every shipped script), then the Xtensa codegen test that compiles all 33 at the device budget. On the bench, load a renamed script on a board and see it run. + +### 3. C++ identifiers (build-breaking, largest) + +~139 files under `src/`. Rename in dependency order so the tree builds at each stage: `src/platform` → `src/core` → `src/light` → `src/ui`. + +Includes the FastLED-mirroring names per the PO's decision: `colorFromPalette`→`colourFromPalette` (86 uses), and the **15 control registrations**: `colorMode` (twice: `SolidEffect.h:48` and `AudioSpectrumEffect.h:34`), `colorByAge`, `colorByColumn`, `colorByHeight`, `colorBars`, `colorSpeed`, `oneColor`, `randomColors`, `color_chaos`, `backgroundColorR/G/B`, `panCenter`, `tiltCenter`. + +**One module TYPE name changes, and it is the widest-reaching single rename**: `ColorTrailsEffect`→`ColourTrailsEffect`. A type name is a persisted key, a REST string (`POST /api/modules {"type":"ColorTrailsEffect"}`), and a catalog identifier, so it also renames files: `src/light/effects/ColorTrailsEffect.h`, `test/unit/light/unit_ColorTrailsEffect.cpp`, its entry in `test/CMakeLists.txt`, `test/scenario_runner.cpp`, `moondeck/docs/screenshot_modules.py`, and the generated `docs/moonmodules/light/moxygen/ColorTrailsEffect.md`. It is the only American-spelled type name, and no local config or scenario references it, so nothing on the bench breaks; it still earns a line in the MIGRATING entry because a saved config elsewhere would lose that effect. + +Each rename is verified against the exclusion table above before applying. Where a renamed identifier sits beside a vendor one, a one-line comment records why they differ (Principle 2: a bespoke choice carries its reason). + +**Verify:** `uv run moondeck/build/build_desktop.py --tests` (zero warnings), `uv run moondeck/test/test_desktop.py`, then all three ESP32 variants build. The scenario suite must produce **identical observations**: a rename changes no behavior, so any moved number is a real bug. + +### 4. Tests and their names (~84 files) + +Test *descriptions* are functional documentation and go British. Test *code* follows step 3's identifiers. Check `test/scenarios/*.json` for any American spelling inside recorded observation blocks (data, not prose: leave it unless it is a name we own). + +**Verify:** the full suite passes with the same case count; `docs/tests/*.md` regenerated via `generate_test_docs.py` (they are generated, so the source test names are the fix). + +### 5. Docs (~38 non-exempt files, plus the catalog pages) + +`docs/*.md` outside the exempt trees, plus `README.md` and `docs/index.md`. `check_taglines.py` must still pass (the three front pages agree). + +Generated pages (`docs/moonmodules/*/moxygen/`, `docs/tests/`) are **not** edited by hand; they are regenerated from the sources changed in steps 3 and 4. + +**The catalog pages need hand editing and the checker will not tell you.** `docs/moonmodules/light/effects.md` is hand-written prose carrying 345 backticked control names, including every renamed one (`colorMode`, `colorByAge`, `panCenter`, …), but it sits under `docs/moonmodules/`, which `check_prose.py:37` exempts as "partly generated". So it is skipped silently while holding stale names. Same for the other catalog pages (`modifiers.md`, `layouts.md`, `drivers.md`). Grep them explicitly for the renamed identifiers rather than trusting the gate; `check_specs.py` catches a missing module block but not a stale control name inside one. + +**Verify:** `uv run moondeck/check/check_specs.py` (no drift), `uv run moondeck/check/check_taglines.py`, `uv run moondeck/check/check_prose.py`. + +### 6. Tooling and the last sweep (~29 files) + +`moondeck/` (22 files) and `mooninstaller/` (7). In `mooninstaller`, the CSS and the installer's own HTML attributes stay American; its prose does not. + +Finally, register `check_prose.py` in the pre-commit gate table. Its docstring currently says it is deliberately *unregistered* because pre-existing violations would fail every commit until a sweep lands. **This migration is that sweep**, so the last step of the last branch is to register it and delete that caveat. + +## Verification (end to end) + +1. `uv run moondeck/build/build_desktop.py --tests`: zero warnings, zero errors. +2. `uv run moondeck/test/test_desktop.py`: same case count as before the migration. +3. `uv run moondeck/scenario/run_scenario.py`: **observations unchanged**; a rename that moves a tick or heap number is a bug, not noise. +4. Three ESP32 variants build (`esp32-16mb`, `esp32s3-n16r8`, `esp32p4rev1-eth`). +5. `uv run moondeck/check/check_prose.py`, `check_specs.py`, `check_taglines.py`, `check_devices.py` all pass. +6. `uv run moondeck/build/build_desktop.py --gcc --tests`: GCC catches what clang misses, and this migration touches ~445 files of identifiers ([memory: run --gcc before pushing anything with new/renamed symbols](CLAUDE.md)). +7. **On the bench** (PO's call, one flash): a board runs, the UI renders its cards, and a renamed MoonLive script compiles and animates. Renamed control names are the thing to look at: a control whose name changed loses its persisted value and falls back to its default. + +## Risks + +- **A blanket `sed` breaks the build.** `std::initializer_list` and the vendor symbols are in the same files as the words being changed. Every step is a reviewed rename with the exclusion table in hand. +- **Silent wire-contract drift.** The current rule warns that "a wire key that drifts between dialects breaks a cross-device contract without a compile error." The 4 WLED/HA keys are the whole exposure; they are listed above and stay American. +- **The grep hazard becomes permanent.** After this, a search for `color` misses `colour` and vice versa, forever, because the external-contract words never convert. This is the cost the PO has accepted; the mitigation is that the American remnants are confined to CSS, four wire keys, and vendor symbols. +- **A stale user script fails silently** at the call site, not at load. Named in the MIGRATING entry. +- **`docs/history/` keeps American spelling** by design (rewriting a record falsifies it), so the repo permanently contains both dialects in prose. Already true of the em-dash rule. + +## Open questions (raised after approval, not yet decided) + +The collision map ran after the plan was approved and turned up three things the plan above does not account for. They are the PO's to settle, and the plan is not executable until they are. + +1. **`src/ui/migrate.js` exists and this plan ignores it.** It carries `FILE_RENAMES`, `TYPE_RENAMES` and `CONTROL_RENAMES`, scoped by `onTypes`, and migrates config JSON. So the 15 control registrations and `ColorTrailsEffect`→`ColourTrailsEffect` could be carried for existing configs at near-zero cost. The Context above says ADR-0013 means "no migration code", which is the general rule; a mechanism nonetheless exists. Use it, or take the pure documented break? + +2. **`setPaletteColor` may not be safely renameable.** MoonLive scripts are fetched from GitHub at a pinned tag (`src/ui/app.js:5900`), and `migrate.js` never touches script text, only config JSON. A user script on a device in the wild calling `setPaletteColor` therefore fails at the call site with "unknown function" and nothing can repair it. `/moonlive` (user) shadowing `/.moonlive` (factory) means even a stale copy of a shipped script keeps failing after the factory copy is fixed. The PO chose "rename, update the scripts, MIGRATING entry" knowing there is no launch yet, so this may simply be an accepted cost; it is recorded here rather than reversed. + +3. **Two CI gates are stricter than step 3 and step 5 assume.** `check_specs.py:126-129` compares registered control names against the hand-written catalog by exact substring, so a rename that lands in the code before the doc fails the gate immediately rather than at the end; `check_registertype_docpaths()` (`check_specs.py:335-377`) validates that the `light/effects.md#colortrails` anchor resolves, so the type rename must move the anchor and the heading in the same commit. Separately, `generate_test_docs.py --check` makes any test rename a CI failure until `docs/tests/*.md` is regenerated, which step 4 should do rather than discover. + +**The rule is already not holding.** The repo contains British spellings today, in test names that propagated into the generated docs: "centre" in `test/unit/light/unit_BlockModifier.cpp:30`, `unit_MirrorModifier.cpp:53` and `unit_CircleModifier.cpp:29`, and "greyscale" in `unit_StarFieldEffect.cpp:15`, all of which reach `docs/tests/unit-tests.md`. They pass because `check_prose.py` reads added lines only and is not in the commit gate. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index a73e6f4a..11d2ce98 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,15 +1,15 @@ { - "commit": "b2a5bce6", + "commit": "38f66d7b", "flash": { "esp32s3-n16r8": 2103024, - "desktop": 1910456, - "esp32": 2060272, + "desktop": 1933496, + "esp32": 2073632, "esp32p4rev1-eth": 1997440, "esp32p4rev1-eth-wifi": 2284640, "esp32s3-n8r8": 2087168, "esp32s31": 2348592, "esp32-16mb": 2060368, - "esp32-eth": 1397456, + "esp32-eth": 1681040, "esp32-wrover": 1843760, "qemu": 1383648, "esp32p4rev3-eth": 1643760, @@ -19,31 +19,32 @@ "measured": { "esp32p4rev1-eth": "2026-09-09", "esp32s31": "2026-09-06", - "esp32": "2026-09-09", + "esp32": "2026-09-11", "esp32-pico": "2026-09-09", "esp32s3-n16r8": "2026-09-09", - "desktop": "2026-09-09", + "desktop": "2026-09-11", "esp32s3-n8r8": "2026-09-08", "esp32s3-zero": "2026-09-08", "esp32-16mb": "2026-09-09", - "esp32p4rev1-eth-wifi": "2026-09-08" + "esp32p4rev1-eth-wifi": "2026-09-08", + "esp32-eth": "2026-09-11" }, "perf": { "desktop": { - "tick_us": 147, - "fps": 6802, + "tick_us": 127, + "fps": 7874, "scenario_p50": { "Layer_base_pipeline": { "p50": 69, "p95": 139, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "Layer_memory_1to1": { "p50": 5, - "p95": 11, + "p95": 7, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" } } }, @@ -54,10 +55,10 @@ "scenario_matrix": { "MoonModule_control_change": { "desktop-macos": { - "p50": 125, + "p50": 124, "p95": 246, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "esp32-eth-wifi": { "p50": 89895, @@ -170,10 +171,10 @@ }, "Audio_mutation": { "desktop-macos": { - "p50": 23, + "p50": 24, "p95": 61, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 40, @@ -196,18 +197,18 @@ }, "Aurora_fps": { "desktop-macos": { - "p50": 1556, + "p50": 1564, "p95": 2013, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" } }, "Driver_mutation": { "desktop-macos": { "p50": 20, - "p95": 29, + "p95": 27, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 42, @@ -231,9 +232,9 @@ "Effects_composition": { "desktop-macos": { "p50": 147, - "p95": 248, + "p95": 169, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 549, @@ -244,18 +245,18 @@ }, "Fields_polar_lut": { "desktop-macos": { - "p50": 1261, - "p95": 1686, + "p50": 1269, + "p95": 1892, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" } }, "Fluid_solver": { "desktop-macos": { "p50": 226, "p95": 265, - "n": 30, - "last": "2026-09-09" + "n": 32, + "last": "2026-09-11" } }, "GridBlacks_blackpixel": { @@ -263,7 +264,7 @@ "p50": 2, "p95": 3, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "esp32s3-n16r8": { "p50": 267, @@ -286,10 +287,10 @@ }, "GridLayout_resize": { "desktop-macos": { - "p50": 122, - "p95": 189, + "p50": 121, + "p95": 143, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "esp32-eth-wifi": { "p50": 82231, @@ -333,7 +334,7 @@ "p50": 69, "p95": 139, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 118, @@ -345,9 +346,9 @@ "Layer_memory_1to1": { "desktop-macos": { "p50": 5, - "p95": 11, + "p95": 7, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 1, @@ -359,9 +360,9 @@ "Layouts_mutation": { "desktop-macos": { "p50": 94, - "p95": 169, + "p95": 156, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 111, @@ -412,8 +413,8 @@ "desktop-macos": { "p50": 6, "p95": 8, - "n": 26, - "last": "2026-09-09" + "n": 28, + "last": "2026-09-11" }, "esp32s3-n16r8": { "p50": 8255, @@ -461,9 +462,9 @@ }, "desktop-macos": { "p50": 5, - "p95": 11, + "p95": 7, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 1, @@ -477,7 +478,7 @@ "p50": 3, "p95": 4, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 3, @@ -489,9 +490,9 @@ "MultiplyModifier_pipeline": { "desktop-macos": { "p50": 121, - "p95": 172, + "p95": 146, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 225, @@ -504,8 +505,8 @@ "desktop-macos": { "p50": 364, "p95": 442, - "n": 31, - "last": "2026-09-09" + "n": 32, + "last": "2026-09-11" } }, "modifier_chain": { @@ -513,7 +514,7 @@ "p50": 43, "p95": 48, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 69, @@ -530,10 +531,10 @@ }, "modifier_swap": { "desktop-macos": { - "p50": 22, - "p95": 31, + "p50": 21, + "p95": 25, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "esp32-eth": { "p50": 1010, @@ -568,10 +569,10 @@ }, "perf_full": { "desktop-macos": { - "p50": 265, - "p95": 402, + "p50": 264, + "p95": 301, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "esp32s3-n16r8": { "p50": 16915, @@ -601,9 +602,9 @@ "perf_light": { "desktop-macos": { "p50": 16, - "p95": 24, + "p95": 20, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "esp32s3-n16r8": { "p50": 2485, @@ -644,10 +645,10 @@ "last": "2026-07-25" }, "desktop-macos": { - "p50": 272, + "p50": 264, "p95": 813, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "desktop-windows": { "p50": 649, @@ -671,9 +672,9 @@ }, "desktop-macos": { "p50": 4, - "p95": 6, + "p95": 5, "n": 32, - "last": "2026-09-09" + "last": "2026-09-11" }, "esp32p4rev1-eth": { "p50": 217, @@ -697,54 +698,54 @@ } }, "loc": { - "core": 26218, + "core": 27044, "light": 35665, - "platform": 18705, - "ui": 10659, - "test": 57853, - "moondeck": 22906 + "platform": 18948, + "ui": 10971, + "test": 58444, + "moondeck": 23112 }, "comments": { "core": { - "lines": 10544, - "ratio": 0.434 + "lines": 10868, + "ratio": 0.435 }, "light": { "lines": 13554, "ratio": 0.417 }, "platform": { - "lines": 6608, + "lines": 6705, "ratio": 0.387 }, "ui": { - "lines": 3190, + "lines": 3279, "ratio": 0.316 }, "test": { - "lines": 10859, - "ratio": 0.215 + "lines": 10999, + "ratio": 0.216 }, "moondeck": { - "lines": 3723, - "ratio": 0.186 + "lines": 3737, + "ratio": 0.185 } }, "tests": { - "cases": 2004, + "cases": 2030, "scenarios": 27 }, "docs": { - "md_files": 222, - "md_lines": 37624, - "plans_files": 117, - "backlog_lines": 7155, + "md_files": 224, + "md_lines": 38096, + "plans_files": 119, + "backlog_lines": 7224, "lessons_lines": 705, "claude_md_lines": 259 }, "complexity": { - "functions": 3514, - "over_threshold": 251, + "functions": 3548, + "over_threshold": 254, "worst_ccn": 128 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 6d0ecf66..2c15306a 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `b2a5bce6`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `38f66d7b`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,19 +8,19 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop | 1,866 KB (+0 KB) ⚠ | - | - | yes | -| esp32 | 2,012 KB | 2,496 KB | 81% | yes | -| esp32-16mb | 2,012 KB (+0 KB) ⚠ | 4,096 KB | 49% | yes | -| esp32-eth | 1,365 KB | - | - | carried (age?) | -| esp32-pico | 2,058 KB | 3,072 KB | 67% | yes | +| desktop | 1,888 KB (+22 KB) ⚠ | - | - | yes | +| esp32 | 2,025 KB (+13 KB) ⚠ | 2,496 KB | 81% | yes | +| esp32-16mb | 2,012 KB | 4,096 KB | 49% | carried 2d | +| esp32-eth | 1,642 KB (+277 KB) ⚠ | 2,496 KB | 66% | yes | +| esp32-pico | 2,058 KB | 3,072 KB | 67% | carried 2d | | esp32-wrover | 1,801 KB | - | - | carried (age?) | -| esp32p4rev1-eth | 1,951 KB (+0 KB) ⚠ | 4,096 KB | 48% | yes | -| esp32p4rev1-eth-wifi | 2,231 KB | 4,096 KB | 54% | carried 1d | +| esp32p4rev1-eth | 1,951 KB | 4,096 KB | 48% | carried 2d | +| esp32p4rev1-eth-wifi | 2,231 KB | 4,096 KB | 54% | carried 3d | | esp32p4rev3-eth | 1,605 KB | - | - | carried (age?) | -| esp32s3-n16r8 | 2,054 KB (+0 KB) ⚠ | 4,096 KB | 50% | yes | -| esp32s3-n8r8 | 2,038 KB | 3,072 KB | 66% | carried 1d | -| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | carried 1d | -| esp32s31 | 2,294 KB | 4,096 KB | 56% | carried 3d | +| esp32s3-n16r8 | 2,054 KB | 4,096 KB | 50% | carried 2d | +| esp32s3-n8r8 | 2,038 KB | 3,072 KB | 66% | carried 3d | +| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | carried 3d | +| esp32s31 | 2,294 KB | 4,096 KB | 56% | carried 5d | | qemu | 1,351 KB | - | - | carried (age?) | `Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table. @@ -29,39 +29,39 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Tick | FPS | |---|---:|---:| -| desktop | 147 µs (+7 µs) ⚠ | 6,802 (−340) ⚠ | +| desktop | 127 µs (−20 µs) ✓ | 7,874 (+1,072) ✓ | | esp32 | 8,354 µs | 119 | ### Scenario tick by target (p50 of each sample window) | Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | unknown | |---|---|---|---|---|---|---|---|---|---| -| Audio_mutation | 23 | 40 ? | 13,152 | 47 ? | - | - | - | - | - | -| Aurora_fps | 1,556 (+22) ⚠ | - | - | - | - | - | - | - | - | +| Audio_mutation | 24 (+1) ⚠ | 40 ? | 13,152 | 47 ? | - | - | - | - | - | +| Aurora_fps | 1,564 (+8) ⚠ | - | - | - | - | - | - | - | - | | Driver_mutation | 20 | 42 ? | 12,812 | 39 ? | - | - | - | - | - | | Effects_composition | 147 | 549 ? | - | - | - | - | - | - | - | -| Fields_polar_lut | 1,261 (+21) ⚠ | - | - | - | - | - | - | - | - | -| Fluid_solver | 226 (+2) ⚠ | - | - | - | - | - | - | - | - | +| Fields_polar_lut | 1,269 (+8) ⚠ | - | - | - | - | - | - | - | - | +| Fluid_solver | 226 | - | - | - | - | - | - | - | - | | GridBlacks_blackpixel | 2 | 8 ? | 269 ? | 267 ? | - | - | - | - | - | -| GridLayout_resize | 122 | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - | +| GridLayout_resize | 121 (−1) ✓ | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - | | Layer_base_pipeline | 69 | 118 ? | - | - | - | - | - | - | - | | Layer_memory_1to1 | 5 | 1 ? | - | - | - | - | - | - | - | | Layouts_mutation | 94 | 111 ? | 13,692 | 45 ? | - | - | 27 ? | - | - | | MoonLiveEffect_controls | 11 ? | - | 12,901 | 4,624 ? | - | - | - | - | - | | MoonLiveEffect_livescript | 6 | - | 13,433 ? | 8,255 ? | 11,336 ? | - | - | - | - | | MoonLive_pipeline | 5 | 1 ? | 9,604 ? | 3,278 ? | - | 11,398 ? | - | - | 4,393 ? | -| MoonModule_control_change | 125 | 262 ? | 212 ? | 166 ? | 165 ? | - | 111,731 ? | 89,895 ? | - | +| MoonModule_control_change | 124 (−1) ✓ | 262 ? | 212 ? | 166 ? | 165 ? | - | 111,731 ? | 89,895 ? | - | | MqttModule_haDiscovery_toggle | 3 ? | - | 36 ? | 36 ? | - | - | - | - | - | | MultiplyModifier_memory_lut | 3 | 3 ? | - | - | - | - | - | - | - | | MultiplyModifier_pipeline | 121 | 225 ? | - | - | - | - | - | - | - | | NetworkModule_eth_reconfigure | - | - | 1,169 ? | 97,843 ? | - | - | - | - | - | | NetworkModule_mdns_toggle | 13 ? | - | 36 ? | 36 ? | 21 ? | - | 109,767 ? | 93,963 ? | - | -| Trails_ladder | 364 (+2) ⚠ | - | - | - | - | - | - | - | - | -| modifier_chain | 43 (−1) ✓ | 69 ? | 13,337 | - | - | - | - | - | - | -| modifier_swap | 22 | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - | -| perf_full | 265 | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - | +| Trails_ladder | 364 | - | - | - | - | - | - | - | - | +| modifier_chain | 43 | 69 ? | 13,337 | - | - | - | - | - | - | +| modifier_swap | 21 (−1) ✓ | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - | +| perf_full | 264 (−1) ✓ | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - | | perf_light | 16 | 35 ? | 2,183 | 2,485 ? | 2,038 ? | - | - | - | - | -| peripheral_grid_sweep | 272 | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - | +| peripheral_grid_sweep | 264 (−8) ✓ | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - | | peripheral_switch | 4 | 9 ? | 437 | 46 ? | 217 ? | - | - | - | - | Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first impression rather than a percentile; several are months old and were captured during a network reconfigure, so they read as whole milliseconds. `-` means that target has never run that scenario. @@ -73,7 +73,7 @@ Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first | Scenario | p50 | p95 | n | |---|---:|---:|---:| | Layer_base_pipeline | 69 µs | 139 µs | 32 | -| Layer_memory_1to1 | 5 µs | 11 µs | 32 | +| Layer_memory_1to1 | 5 µs | 7 µs | 32 | These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers. @@ -81,36 +81,36 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 26,218 (+19) ⚠ | 10,544 | 43.4 % | +| core | 27,044 (+826) ⚠ | 10,868 | 43.5 % (+0.1 %) ⚠ | | light | 35,665 | 13,554 | 41.7 % | -| platform | 18,705 | 6,608 | 38.7 % | -| ui | 10,659 | 3,190 | 31.6 % | -| test | 57,853 (+32) ⚠ | 10,859 | 21.5 % | -| moondeck | 22,906 | 3,723 | 18.6 % | +| platform | 18,948 (+243) ⚠ | 6,705 | 38.7 % | +| ui | 10,971 (+312) ⚠ | 3,279 | 31.6 % | +| test | 58,444 (+591) ⚠ | 10,999 | 21.6 % (+0.1 %) ⚠ | +| moondeck | 23,112 (+206) ⚠ | 3,737 | 18.5 % (−0.1 %) ✓ | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,004 (+2) ✓ | +| unit cases | 2,030 (+26) ✓ | | scenarios | 27 | ## Complexity | Metric | Value | |---|---:| -| functions | 3,514 | -| over threshold | 251 (+1) ⚠ | +| functions | 3,548 (+34) ✓ | +| over threshold | 254 (+3) ⚠ | | worst CCN | 128 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 222 | -| markdown lines | 37,624 (+16) ⚠ | -| plan files | 117 | -| backlog lines | 7,155 (+16) ⚠ | +| markdown files | 224 (+2) ⚠ | +| markdown lines | 38,096 (+472) ⚠ | +| plan files | 119 (+2) ⚠ | +| backlog lines | 7,224 (+69) ⚠ | | lessons lines | 705 | | CLAUDE.md lines | 259 | diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md index d15f53e5..4b10be9f 100644 --- a/docs/moonmodules/core/system.md +++ b/docs/moonmodules/core/system.md @@ -114,6 +114,47 @@ Detail: [technical](moxygen/FirmwareUpdateModule.md) [Tests](../../tests/unit-tests.md#firmwareupdatemodule) + + +### MoonCloud + +The container for everything projectMM does with a server MoonModules runs. It holds no settings of its own: each thing MoonCloud does is a child with its own consent, because a user who wants one has not thereby agreed to the other. + +- **Stats**, below: one opt-in report per install or upgrade, and the totals back. +- **Talk**, below: a public message board between devices. +- **Sync** (planned): device to device over the internet, a joint show across houses. Not built on Stats; they share this container and the installation id, nothing else. + + + +#### Stats + +One opt-in report about this install, sent once when the firmware is installed or upgraded, so development effort goes where the users are. Off until you answer yes. Everything it sends, and the reasoning behind the identifier, is in the [privacy policy](../../privacy-policy.md). + +- `consent`: **Not answered** / **Yes** / **Not now** / **Never**. Nothing is sent, and no identifier is computed, until this reads Yes. **Never** is remembered forever; **Not now** defers, so the question returns after the next upgrade rather than never. +- read-only: `version` (what is running) and `reportedVersion` (what last produced a report). They differ exactly when a report is due, which is what makes one upgrade send one report and a reboot send nothing. + +The report carries hardware and configuration: chip, flash, PSRAM, SDK, device model, and which modules are enabled. It carries no device name, no addresses, no credentials and no text you typed, and a unit test asserts those cannot appear in it. + +The card also shows the totals everyone else reported, which is why there is no separate public dashboard: contributing earns the answer back where you already are. Reading them sends nothing about you and works whether or not you consented. + +**MoonCloud** is the family name for anything projectMM does with a server we run. Stats and Talk are its members today; device-to-device sync over the internet is planned as a third, with its own consent. + +Detail: [technical](moxygen/MoonStatsModule.md) + + + +#### Talk + +A public message board between projectMM devices, in the shape Meshtastic's channel chat has. Off until you turn it on, and a message is sent because you typed one: nothing posts on its own. + +- `consent`: **Not answered** / **Yes** / **Never**. Nothing is published until this reads Yes. +- `shareName`: whether your device name rides along, **off by default and a separate decision**. A device name is the one field here that identifies a person rather than a machine, and "Ewoud's bedroom" says a great deal more than "MM-A094". Without it your messages show the first 8 characters of your installation id, which groups them together without naming you. +- `message`: what to say. Cleared after sending. + +**Everything sent is public and permanent**: there is no private message, no recipient and no delete. Reading the board sends nothing about your device and needs no consent. + +**There is no authentication**, so a sender id can be fabricated by anyone posting by hand. That is acceptable for a board where nothing is gated on identity, and it is said here rather than left to be discovered. + ### File Manager diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index 47a8db33..42487d06 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -1,80 +1,79 @@ # Privacy policy -**Last updated: 2026-09-01** +**Last updated: 2026-09-10** -This policy covers the projectMM software (the firmware, the desktop application and the web interface they serve), the [web installer](https://moonmodules.org/projectMM/install/), and this documentation site. +Covers the projectMM software (firmware, desktop application and the web interface they serve), the [web installer](https://moonmodules.org/projectMM/install/), and this documentation site. -## The short version +## The rule -**projectMM collects nothing.** It has no accounts, no analytics, no telemetry and no usage reporting. It does not phone home. Your configuration lives on your own device or computer and is never sent anywhere by the software. +**projectMM sends nothing to us unless you switch it on.** Every feature that transmits anything is opt-in, off by default, and asks in plain words before its first transmission. Declining is one click, is remembered, and sends nothing at all: not even a record that you declined. -The rest of this page is the detail behind that sentence, because "we collect nothing" is worth being able to check rather than being asked to take on trust. +Everything else stays on the machine you run it on: your layouts, effects, drivers, pin assignments, device name, and any credentials you entered. `%LOCALAPPDATA%\projectMM` on Windows, `~/Library/Application Support/projectMM` on macOS, `$XDG_DATA_HOME/projectMM` on Linux, the device's own flash on a board. Nothing there is uploaded, synchronized or backed up by us. -## What projectMM stores, and where +## What we never collect -Everything projectMM keeps is **on the machine you run it on**: +Whatever you switch on, projectMM does not transmit **your name, email or postal address, your Wi-Fi or MQTT credentials, your IP or MAC address, or the contents of files you made**. -- **On an ESP32 or similar device**: your configuration in the device's own flash storage. -- **On a desktop**: a folder belonging to your user account. `%LOCALAPPDATA%\projectMM` on Windows, `~/Library/Application Support/projectMM` on macOS, `$XDG_DATA_HOME/projectMM` (or `~/.local/share/projectMM`) on Linux. +## What you can switch on -That configuration is what you would expect it to contain: your layouts, effects, drivers, pin assignments, device name, and any network credentials you entered so the device can reconnect. **Credentials are stored so the device can use them and are never transmitted to us**, because there is no "us" for them to be transmitted to. +Each of these is a separate choice with its own setting. Turning one on says nothing about the others. -Nothing in that folder is uploaded, synchronized or backed up by projectMM. You can read it, edit it, copy it and delete it. +### MoonCloud Stats -## What reaches the internet, and when +One report when the firmware is installed or upgraded, describing hardware and configuration: chip family, flash and PSRAM size, SDK version, board model, which modules are enabled, whether this is an install or an upgrade, the version running and the one it replaced, and whether the firmware was built locally rather than released. Plus an installation id (below) and a country derived from the connection. -Four things, all of them either something you asked for or something your browser does: +The report carries no device name, no addresses, no credentials and no text you typed. A unit test asserts those cannot appear in it. -**Checking for a newer release.** The web interface asks GitHub's public release API whether a newer version exists, so it can show the update badge. **Your browser makes this request, not your device and not the desktop application.** GitHub sees it as an ordinary anonymous API call from your browser, subject to [GitHub's privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement). +### MoonCloud Talk -**Downloading a MoonLive script.** When you pick a factory script, your browser fetches it from `raw.githubusercontent.com`. Again the browser, not the device: projectMM serves your browser the name of the script and the browser downloads it. +A public message board between devices. Messages you send are readable by anyone, permanently, and cannot be withdrawn. Sharing your device name alongside them is a **separate setting, off by default**; without it your messages show the first 8 characters of your installation id. -**Updating firmware over the air.** If you start a firmware update, the device downloads the new firmware from GitHub. This only happens when you ask for it. +Reading the board transmits nothing about your device. -**Using the web installer.** The installer page is served from GitHub Pages and talks to GitHub's API to list releases. Flashing itself happens over USB, directly between your browser and the board, and the firmware never passes through a server of ours. +## The installation id -In every case the request goes to GitHub, not to MoonModules. We operate no server that receives data from projectMM, and we keep no logs of your use of it, because there is nothing to log. +The one part of a MoonCloud transmission that is about you rather than your hardware. -## What we do not collect +It is a SHA-256 hash of an internal address together with a fixed salt. On a device that address is the chip's MAC; on a desktop or in Docker it is a random value generated on first run and stored with your configuration, derived from nothing about your machine. The address itself is never sent, and the salt is unique to MoonCloud, so the id cannot be matched against the device name or MQTT topics that same address produces on your own network. -To be explicit, projectMM does not collect or transmit: your name, email address, postal address or location; your IP or MAC address; your Wi-Fi credentials or MQTT passwords; which effects you use or how you use them; crash reports; identifiers of any kind; or anything that would let one installation be told from another. +**We call it pseudonymous rather than anonymous.** It is the same each time, so reports carrying it came from one installation. That is deliberate: it is what distinguishes an upgrade from a new install. It also means the id is personal data under the GDPR, and **your consent is the lawful basis**: nothing is generated, stored or sent until you answer yes. -There is no opt-out because there is nothing to opt out of. +What follows from it being stable: -## Your browser's local storage - -The web interface stores a few conveniences in your browser's own local storage, for example the cached result of the update check and the release you last selected in the installer. This never leaves your browser, is readable and clearable by you, and holds nothing personal. +- On a device it survives a factory reset and a reflash. Our salt is published in our source code and a MAC address is a short number, so someone determined could work backwards from an id to a MAC. We do not do this and store nothing that would help, but we will not claim it is impossible. +- On a desktop or in Docker it cannot be worked backwards to anything. Deleting your configuration folder gives you a new one. +- It is kept indefinitely, alongside the aggregates. +- **A transmission already sent cannot practically be withdrawn**, because finding it would mean you supplying your id. To ask anyway, use the contact details below. -## Things you connect projectMM to +## The IP address -projectMM speaks to other systems on your network when you configure it to: an MQTT broker, Home Assistant, Art-Net or E1.31 consoles, and similar. Those connections carry lighting data and go where you point them, usually to hardware on your own network. +Any server receiving a request sees the address it came from. Ours derives a country from it at the network edge and **never writes the address to storage**. What is stored is a country name beside a chip family and a version number. -**When you connect projectMM to another service, that service's own privacy policy governs whatever it does with the data.** This is worth knowing if you point it at a cloud-hosted broker or a smart-home platform, because that is a decision to send data somewhere, and it is yours rather than ours. +## What reaches the internet without you switching anything on -## Documentation and downloads +Four things, each either something you asked for or something your browser does, and none of them reach a server of ours: -This documentation site and the release downloads are hosted by **GitHub** (GitHub Pages and GitHub Releases). Like any web host, GitHub receives the requests your browser makes, including your IP address, and processes them under [its own privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement). We add no analytics, no cookies of our own and no tracking to these pages. +- **The update check**: your browser asks GitHub's public release API whether a newer version exists. +- **Downloading a MoonLive script**: your browser fetches it from `raw.githubusercontent.com`. +- **A firmware update**: your device downloads it from GitHub, when you start one. +- **The web installer**: served by GitHub Pages; flashing happens over USB, directly between your browser and the board. -## If usage statistics are ever added +These go to GitHub under [its privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement). This documentation site and the release downloads are hosted by GitHub too, which receives the requests your browser makes, including your IP address. We add no analytics, no cookies of our own and no tracking. -We may one day want to know which boards and configurations are actually in use, so that development effort goes where the users are. If that is ever added, we commit in advance that it will: +## Your browser's local storage -- be **opt-in**, with a clear prompt and a working decline, -- be a **one-time report** rather than continuous reporting, -- carry **no identifying information in the report itself**: no device name, no network addresses, no credentials, no free-text fields you typed, and nothing that would let one installation be recognized again, -- **not retain the IP address** the report arrives from, and -- be documented on this page, in detail, before it ships. +The web interface stores a few conveniences in your browser: a cached update-check result, the release you last selected. This never leaves your browser and holds nothing personal. -The fourth point deserves saying out loud rather than leaving implied. Any server that receives an HTTP request necessarily sees the IP address it came from, and a country breakdown, which is the sort of thing such a dashboard usually shows, can only be derived from that address. So "we do not collect your IP" would be too strong a claim for any such service to make honestly. What we can promise is the narrower and truthful version: the address is used to answer the request, and to derive a country if we show one, and is not stored. +## Systems you connect projectMM to -**Nothing of the kind exists today.** This section is a commitment about how it would be done, not a description of something running. This page is updated when the software changes, not in advance of it. +An MQTT broker, Home Assistant, Art-Net or E1.31 consoles: projectMM speaks to these when you configure it to, and those connections go where you point them. **Whatever you connect it to is governed by that service's own privacy policy**, which is worth knowing if you point it at a cloud-hosted broker. ## Changes to this policy -Changes are made in the open: this page lives in the [project repository](https://github.com/MoonModules/projectMM/blob/main/docs/privacy-policy.md), so every revision is a commit you can read, with its date and its reason. +**A new feature that transmits anything is documented here before it ships, and asks for its own consent.** We may later offer to collect things this policy does not describe today, such as which effects are used or automatic crash reports; if we do, each will be a separate opt-in choice, described here first, and switched off until you turn it on. -## Contact +Revisions are made in the open: this page lives in the [project repository](https://github.com/MoonModules/projectMM/blob/main/docs/privacy-policy.md), so every change is a commit you can read. -Questions about this policy, or about anything on this page you would like to verify, are welcome as an [issue on the repository](https://github.com/MoonModules/projectMM/issues) or on the [Discord](https://discord.gg/TC8NSUSCdV). +## Contact -projectMM is free and open-source software. If you want to check any statement here rather than trust it, the source is public and the network calls described above are the only ones in it. +Questions, or anything here you would like to verify: an [issue on the repository](https://github.com/MoonModules/projectMM/issues) or the [Discord](https://discord.gg/TC8NSUSCdV). projectMM is free and open-source software, and the network calls described above are the only ones in it. diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 621865c2..ca593e05 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -10,6 +10,7 @@ idf_component_register( "../../src/core/FileManagerModule.cpp" "../../src/core/MqttModule.cpp" "../../src/core/Scheduler.cpp" + "../../src/core/sha256.cpp" "../../src/core/moonlive/MoonLive.cpp" "../../src/core/moonlive/MoonLiveCompiler.cpp" "../../src/core/moonlive/MoonLiveSpill.cpp" diff --git a/mooncloud/.gitignore b/mooncloud/.gitignore new file mode 100644 index 00000000..0f57be27 --- /dev/null +++ b/mooncloud/.gitignore @@ -0,0 +1,5 @@ +# wrangler's local runtime state: the on-disk D1 database, the bundler's temp output and its cache. +# Created by `wrangler dev` (moondeck/run/run_mooncloud.py) and rebuilt from scratch on demand, so +# it is machine-local scratch rather than anything to share. Several megabytes, and it holds the +# LOCAL test rows, which are not data anyone else wants. +.wrangler/ diff --git a/mooncloud/CHANGES.md b/mooncloud/CHANGES.md new file mode 100644 index 00000000..14e1b06d --- /dev/null +++ b/mooncloud/CHANGES.md @@ -0,0 +1,76 @@ +# What MoonCloud changed, and how to undo it + +Written 2026-09-10, while MoonCloud was built. Everything here is either a file in this repository or a piece of state that lives OUTSIDE it, and the second kind is the reason this page exists: `git revert` removes the code and leaves the account, the database and the nameservers exactly as they are. + +## Outside the repository + +**A Cloudflare account**, owned by moonmodules@icloud.com, on the Free plan with no payment method. Free Workers stop at their limits rather than billing, so the account cannot run up a cost while it stays on Free. To undo: delete the Worker and the database in the dashboard, or the whole account. + +**A D1 database** named `mooncloud-stats`, id `7966e8d7-1fdd-4231-b28d-9dfc08ea94b1`, region WEUR (Western Europe). Holds three tables and, at the time of writing, one real report from a desktop. To undo: `npx wrangler d1 delete mooncloud-stats`. + +**A deployed Worker** at `https://mooncloud-stats.moonmodules.workers.dev`. To undo: `npx wrangler delete mooncloud-stats`. + +**A `workers.dev` subdomain**, `moonmodules`, claimed on first deploy. It belongs to the account and cannot be released, only left unused. + +**The nameservers for moonmodules.org**, changed at Esmero from `ns3`/`ns4.esmero.nl` to `imani`/`pablo.ns.cloudflare.com`. **This is the only change that can take the docs site offline**, and the only one that touches something the project already had. To undo: put the old nameservers back at Esmero. Esmero keeps its zone file, so the revert is a form submission and a wait, not a rebuild. + +The zone as it was before, which is what must survive: + +``` +moonmodules.org A 185.199.108.153 +moonmodules.org A 185.199.109.153 +moonmodules.org A 185.199.110.153 +moonmodules.org A 185.199.111.153 +moonmodules.org AAAA 2606:50c0:8000::153 +moonmodules.org AAAA 2606:50c0:8001::153 +moonmodules.org AAAA 2606:50c0:8002::153 +moonmodules.org AAAA 2606:50c0:8003::153 +www.moonmodules.org CNAME moonmodules.github.io +_discord TXT "dh=e25f5f5ee85784a5717c19e33db7a9ccd6eea13b" +``` + +All ten point at GitHub Pages or verify Discord, and all are set to DNS only rather than proxied: GitHub Pages serves its own certificate, and proxying is a known source of redirect loops with it. + +## In the repository + +**New: the server** (`mooncloud/`). `worker.js` carries four endpoints and a dev page, `schema.sql` three tables, `wrangler.toml` the deploy config including the database id above, `seed.sql` sample rows for local runs, plus `README.md` and `DEPLOY.md`. + +**New: three modules** (`src/core/MoonCloudModule.h`, `MoonStatsModule.h`, `MoonTalkModule.h`). MoonCloud is the container and owns the server address, the send, and the installation id; Stats and Talk are children with their own consent. + +**New: SHA-256** (`src/core/sha256.{h,cpp}`), vendored rather than linked, because ESP-IDF ships mbedtls and the desktop would link something else, and one function with two implementations is one function that drifts. + +**New: tooling.** `moondeck/run/run_mooncloud.py` runs the server locally under the same runtime Cloudflare uses; `moondeck/run/purge_mooncloud.py` deletes rows by date range or by development build, authenticated by the Cloudflare login rather than by a public endpoint. + +**Changed: `platform::httpsPost`** (`platform.h`, `platform_desktop.cpp`, `platform_esp32_ota.cpp`). One outbound HTTPS call using the TLS each OS already ships: libcurl on desktop, `esp_http_client` with the OTA path's certificate bundle on ESP32. libcurl is OPTIONAL: without it the build succeeds and reporting is disabled, because a build must never fail over an opt-in statistic. + +**Changed: the desktop reports its real architecture.** `chipModel()` returned the literal "desktop"; it now returns `arm64` / `x64`, and `hostPlatform()` reports `macos-arm64` / `linux-x64` / `windows-x64` / `docker` using the vocabulary the release packaging already uses. + +**Fixed: `sha256.cpp` was missing from the ESP32 build** (`esp32/main/CMakeLists.txt`), so MoonCloud had never compiled for a device. Found by building for the Olimex, which failed to link. + +**Changed: the privacy policy**, rewritten from 142 lines to 79 and made future-proof: it states a RULE (nothing is sent unless you switch it on) rather than an inventory of one feature, and names usage data and crash reports as things that may later be offered as their own opt-ins. + +## The address is `stats.moonmodules.org` + +`MoonCloudModule.h` (`kHost`) and `app.js` (`kMoonCloudUrl`) both compile in **`stats.moonmodules.org`** on port 443, switched together once the zone moved to Cloudflare and the Worker got its Custom Domain. + +The `workers.dev` address still answers the same Worker, so nothing that shipped pointing at it is stranded. Keeping it alive takes an explicit `workers_dev = true` in `wrangler.toml`: declaring any route disables it by default, and the first deploy of the Custom Domain did exactly that, returning 404 on the address the firmware had compiled in until the line was added. + +To undo: set both constants back to `mooncloud-stats.moonmodules.workers.dev` and rebuild. Both addresses serve the same Worker and the same D1 database, so the switch is reversible without touching data. + +## Before merging to main + +Two things, neither of which a test can catch. + +**1. Point at the real domain. DONE.** `kHost` and `kMoonCloudUrl` both read `stats.moonmodules.org`, changed together: a device reporting to one address while the card reads another shows a user their own report missing. Verified answering over HTTPS with a valid certificate. + +**2. Empty both databases.** Everything in them is test data from the evening MoonCloud was built: probe reports, a board reflashed repeatedly, messages typed to watch the board update. Shipping with it means the first real user sees a version distribution describing one developer's bench. + +```sh +uv run moondeck/run/purge_mooncloud.py --from 2026-01-01 --to 2026-12-31 --remote +``` + +That asks for the row count before touching the deployed database. The local one under `.wrangler/` is scratch and can be left alone, or cleared the same way without `--remote`. + +## Measured cost on a device + ++12,832 bytes of flash (+0.77%) and about 800 bytes of static RAM, measured on a classic ESP32 by building the same tree with and without MoonCloud on one toolchain. The TLS stack was already linked for OTA, so `httpsPost` adds call-site code rather than a library. diff --git a/mooncloud/DEPLOY.md b/mooncloud/DEPLOY.md new file mode 100644 index 00000000..52483f97 --- /dev/null +++ b/mooncloud/DEPLOY.md @@ -0,0 +1,89 @@ +# Deploying MoonCloud + +Three of these steps only the account owner can do, and they are the reason this is not automated: the account is a standing commitment rather than a build artifact. + +## Before anything + +**Decide who owns the Cloudflare account.** It needs a person, not a project: someone whose login can deploy, whose email gets the alerts, and who notices when it breaks. If that person leaves, the server does too. This is the question that kept the feature in the backlog, and it is not a technical one. + +Stats and Talk run on **Workers plus D1, both free tier**. Sync would need Durable Objects, which are paid. + +## 1. Log in + +```sh +npx wrangler login +``` + +Opens a browser against the owner's account. Nothing below works until this succeeds. + +## 2. Create the database + +```sh +cd mooncloud +npx wrangler d1 create mooncloud-stats +``` + +It prints a `database_id`. Put it in `wrangler.toml`, replacing `REPLACE_WITH_D1_DATABASE_ID`, and commit that: the id is not a secret, and a config that cannot deploy without a local edit is a config that drifts. + +## 3. Create the tables + +```sh +npx wrangler d1 execute mooncloud-stats --file=schema.sql --remote +``` + +`--remote` is the deployed database; without it you get the local one under `.wrangler/`. + +**On a database that already exists**, `CREATE TABLE IF NOT EXISTS` skips it, so a new column needs saying out loud: + +```sh +npx wrangler d1 execute mooncloud-stats --remote \ + --command="ALTER TABLE reports ADD COLUMN dev INTEGER NOT NULL DEFAULT 0" +``` + +## 4. Deploy + +```sh +npx wrangler deploy +``` + +Answers on `mooncloud-stats..workers.dev` immediately. Check it: + +```sh +curl https://mooncloud-stats..workers.dev/api/stats +``` + +## 5. Point a domain at it + +`stats.moonmodules.org` needs the zone on Cloudflare (its nameservers pointed there), then a Custom Domain route in `wrangler.toml`: + +```toml +[[routes]] +pattern = "stats.moonmodules.org" +custom_domain = true +``` + +Then deploy again. Cloudflare creates the DNS record and issues the certificate itself, so there is no record to add by hand. + +**Declaring any route disables the `workers.dev` address by default**, and that address is the one older firmware compiled in. Keep it alive with an explicit line, or every device still pointing at it gets a 404: + +```toml +workers_dev = true +``` + +**Until this is done the firmware default below cannot be set**, because a `workers.dev` name is not a promise: it moves with the account. + +## 6. Point the firmware at it + +The address is compiled in, not a setting: `kHost` in `MoonCloudModule.h` and `kMoonCloudUrl` in `src/ui/app.js`. They must change TOGETHER, because a device reporting to one address while the card reads another shows a user their own report missing. + +```cpp +static constexpr const char* kHost = "stats.moonmodules.org"; +static constexpr uint16_t kPort = 443; // 443 selects HTTPS +``` + +**This is the last step, not the first.** Ship it before the domain answers and every device in the wild sends one report into nothing, and never retries: a failed report is not queued. + +## Afterwards + +- `uv run moondeck/run/purge_mooncloud.py --from ... --to ... --remote` removes rows by date, authenticated by this login rather than by a public endpoint. +- The privacy policy names the server: check it still describes what runs. diff --git a/mooncloud/README.md b/mooncloud/README.md new file mode 100644 index 00000000..242422ce --- /dev/null +++ b/mooncloud/README.md @@ -0,0 +1,109 @@ +# MoonCloud + +The server behind the MoonCloud card, and the only one projectMM talks to. **Stats** takes a report and hands back the aggregates; **Talk** is a public message board between devices. One Worker, one database, one deploy. Published here for the same reason the firmware is, so that "the deployed code is the published code" is checkable rather than taken on trust. + +What it stores, and what it deliberately does not, is in [privacy-policy.md](../docs/privacy-policy.md). The design and the reasoning behind the installation id are in [the MoonCloud plan](../docs/history/plans/Plan-20260910%20-%20MoonCloud.md). + +## The whole thing + +| File | | +|---|---| +| `worker.js` | both endpoints | +| `schema.sql` | the three tables | +| `wrangler.toml` | deployment config | +| `seed.sql` | sample rows for a local run, never deployed | + +## Endpoints + +**`POST /api/report`** takes one JSON report. Fields outside the allowlist are dropped rather than stored. A report with no installation id is accepted and discarded, since without one it cannot be counted as an installation. Returns `{"ok": true}`. + +**`GET /api/stats`** returns the aggregates the device card renders: installation and report totals, plus counts by version, chip, device model and country. Readable by anyone, so it carries only aggregates and never a row. + +**`POST /api/talk`** posts one message to the public board: a sender (the installation id), the text, and a device name ONLY when that device's own consent said to share it. Bounded at 280 characters. + +**`GET /api/talk`** returns the newest 50 messages, `?since=` for what a caller has not seen. The full sender id is never published: a message carries the device name if one was shared, else the first 8 characters of the id, which groups one device's messages without naming anyone. + +## Running it locally + +```sh +uv run moondeck/run/run_mooncloud.py --seed # --seed only the first time +``` + +`wrangler dev` runs `worker.js` in **workerd, the same runtime Cloudflare uses**, against a local D1 (SQLite under `.wrangler/`). So this is not a stand-in that approximates the server: it is the server, with a local database and a localhost address, and what you verify here is what deploys. + +Point a device at it by changing `kMoonCloudUrl` in `src/ui/app.js` and `kHost` in `src/core/MoonCloudModule.h`, which is a rebuild: the address is compiled in rather than configurable, so that one MoonCloud cannot be mistyped into another. + +## Pointing a device somewhere else + +`server` and `serverPort` are controls on the **MoonCloud** card, so any device can be aimed at any MoonCloud without a rebuild. The port picks the transport: **443 or empty means HTTPS**, anything else means plain HTTP. + +Three cases this exists for, and only one of them is development: + +**Self-hosting.** The server is published here precisely so someone can run their own. Set `server` to their host and `serverPort` to 443, and that device reports to them instead. Nothing about MoonCloud assumes the address is ours. + +**A local server while changing `worker.js`.** Set `server` to the machine's LAN address and `serverPort` to `8787`: + +```sh +uv run moondeck/run/run_mooncloud.py --seed +``` + +That runs the Worker under workerd, the same runtime Cloudflare uses, against a local D1 under `.wrangler/`. **A DEVICE must be given the machine's LAN address, not `127.0.0.1`**, which on a board means the board itself. `httpRequest` also does no name resolution on the plain-HTTP path, so it has to be a dotted-quad IP rather than a hostname. + +The local server binds `0.0.0.0` for exactly this reason: wrangler's default is localhost, which answers from the machine and refuses every board on the network. + +**Moving to a new address.** The shipped default is a compile-time value in `MoonCloudModule.h`, and the controls are how a device already in the field follows a move without a firmware update. + +## Deploying + +```sh +npx wrangler d1 create mooncloud-stats # then put the id in wrangler.toml +npx wrangler d1 execute mooncloud-stats --file=schema.sql --remote +npx wrangler deploy +``` + +## Changing the schema + +Nothing here is fixed. All four were tested against a real D1, and `wrangler d1 execute` is the tool for each: add `--remote` for the deployed database, leave it off for the local one. + +```sh +npx wrangler d1 execute mooncloud-stats --remote --command="ALTER TABLE reports ADD COLUMN cpu TEXT NOT NULL DEFAULT ''" +npx wrangler d1 execute mooncloud-stats --remote --command="ALTER TABLE reports DROP COLUMN psram" +npx wrangler d1 execute mooncloud-stats --remote --command="ALTER TABLE reports RENAME COLUMN sdk TO sdkVersion" +npx wrangler d1 execute mooncloud-stats --remote --command="UPDATE reports SET chip='ESP32-S3' WHERE chip='esp32s3'" +``` + +Deleting rows has its own script, because it is the one operation that cannot be undone by waiting: `moondeck/run/purge_mooncloud.py`, which asks for the row count before touching the deployed database. + +**Adding a field needs three edits, and the order matters.** The column first, then `ALLOWED` in `worker.js` so the field is no longer dropped on arrival, then the report builder in `MoonStatsModule.h`. Reversed, devices send a field the server discards. + +**`schema.sql` uses `CREATE TABLE IF NOT EXISTS`**, so re-running it on an existing database changes nothing. A new column has to be said out loud with `ALTER TABLE`, and the file updated to match so a fresh deployment gets it too. + +**An old device keeps sending the old shape.** A dropped column means its value is discarded, a renamed one means it arrives under a name nothing reads. Neither breaks the device: an unknown field is ignored, and a missing one is simply absent from the row. That is why the allowlist drops rather than rejects. + +**What cannot be changed retroactively is a field that was never collected.** A column added in six months is empty for every row before it, and nothing can fill it in. That is the reasoning behind the `events` table and the `dev` flag: both exist now because history cannot be reconstructed later. + +## Moving to another host + +Nothing here is Cloudflare-specific except the deployment itself. **The data is plain SQLite** and comes out in one command: + +```sh +npx wrangler d1 export mooncloud-stats --remote --output=mooncloud-backup.sql +``` + +That file is `CREATE TABLE` plus `INSERT` statements, which any SQLite, Postgres or MySQL will take with minor dialect edits. Worth running before any schema change, and worth running periodically regardless. + +**What would have to be rewritten** is `worker.js`, and it is about 300 lines of routing, an allowlist and four aggregate queries. The Workers-specific parts are three: + +- `env.DB.prepare(...).bind(...).all()`, which is D1's client. Any SQL library replaces it. +- `request.cf.country`, the edge-derived country. **This is the one thing genuinely lost**: elsewhere the country comes from a GeoIP lookup on the IP address, which means handling the address, which the privacy policy promises not to store. A different host means either dropping the country or rewriting that promise. +- The `fetch(request, env)` entry point, which is a shape every serverless runtime has an equivalent of. + +**The device side needs no change at all.** `server` and `serverPort` are controls, so a device already in the field follows a move without a firmware update, and the default in `MoonCloudModule.h` is a one-line change for new ones. + +**What would break if the address moves without warning**: a report is sent once and never retried, so any device that reports while the old address is dead loses that report. Keep both answering until the fleet has moved. + +## The two things that are not obvious + +**The country never comes from an address we store.** Cloudflare resolves it at the edge and hands it over as `request.cf.country`, so no code here ever sees an IP. That is what makes the privacy policy's promise structural rather than a discipline someone has to maintain in a log config. + +**The primary key is (installationId, version).** A device re-reporting the same upgrade overwrites its row, so a row count is a count of installations rather than of retries. It is also why the totals answer "how many installations are on 4.0.0" rather than only "how many upgrade events happened". diff --git a/mooncloud/schema.sql b/mooncloud/schema.sql new file mode 100644 index 00000000..73072d91 --- /dev/null +++ b/mooncloud/schema.sql @@ -0,0 +1,79 @@ +-- MoonCloud Stats: the whole database. +-- +-- One table. There is no address column and no precise timestamp, because neither is collected: +-- the country arrives already derived at the edge, and the date is stored to the DAY, since a +-- timestamp to the second plus a country is a fingerprint. + +CREATE TABLE IF NOT EXISTS reports ( + installationId TEXT NOT NULL, + event TEXT NOT NULL DEFAULT 'install', + version TEXT NOT NULL DEFAULT '', + previousVersion TEXT NOT NULL DEFAULT '', + chip TEXT NOT NULL DEFAULT '', + flash TEXT NOT NULL DEFAULT '', + psram TEXT NOT NULL DEFAULT '', + sdk TEXT NOT NULL DEFAULT '', + deviceModel TEXT NOT NULL DEFAULT '', + modules TEXT NOT NULL DEFAULT '', + country TEXT NOT NULL DEFAULT '??', + receivedAt TEXT NOT NULL, + -- 1 when the firmware was built locally rather than published by CI. Every figure counts every + -- report, and the `Build` breakdown is what shows the split, so a reader sees the distinction + -- rather than a total that quietly left one side out. `?dev=0` narrows to released installs for + -- a caller that wants only those. Recorded from the first report because a flag added later + -- cannot classify rows already stored. + dev INTEGER NOT NULL DEFAULT 0, + + -- One row per installation per version. A device that re-reports the same upgrade overwrites its + -- row rather than adding one, so a count of rows is a count of installations rather than of + -- retries. + PRIMARY KEY (installationId, version) +); + +CREATE INDEX IF NOT EXISTS idx_reports_version ON reports(version); +CREATE INDEX IF NOT EXISTS idx_reports_chip ON reports(chip); + +-- MoonTalk: a public message board between devices, in the shape Meshtastic's channel chat has. +-- +-- Every message is readable by everyone, so nothing here is private and the table holds only what a +-- sender chose to publish. There is no auth and no per-user table: `sender` is the same installation +-- id Stats uses, which makes messages from one device groupable without naming anyone. +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender TEXT NOT NULL, -- installation id, or its first 8 chars as a display handle + name TEXT NOT NULL DEFAULT '', -- device name, ONLY when the sender consented to share it + text TEXT NOT NULL, + country TEXT NOT NULL DEFAULT '??', + sentAt TEXT NOT NULL -- ISO 8601 to the SECOND: a chat needs ordering, where a + -- report only needed a day +); + +-- Newest first is the only query the board makes. +CREATE INDEX IF NOT EXISTS idx_messages_id ON messages(id DESC); + +-- Every report as it arrived, append-only, for trends over time. +-- +-- SEPARATE from `reports` because the two answer different questions and cannot share a shape. +-- `reports` is CURRENT STATE: one row per installation per version, overwritten when a device +-- re-reports, so it answers "what is running now". This table is HISTORY: one row per report ever +-- received, never updated, so it answers "what happened in March". +-- +-- It exists now rather than when someone wants a chart, because history cannot be reconstructed +-- afterwards: `reports` overwrites `receivedAt` on every conflict, so the moment a device +-- re-reports, when it first reported is gone. A table added in six months starts empty. +-- +-- No installation id is stored here. A dated row per installation is a movement profile, and +-- nothing a trend asks needs one: "how many upgrades in March" counts rows. +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event TEXT NOT NULL DEFAULT 'install', + version TEXT NOT NULL DEFAULT '', + previousVersion TEXT NOT NULL DEFAULT '', + chip TEXT NOT NULL DEFAULT '', + deviceModel TEXT NOT NULL DEFAULT '', + country TEXT NOT NULL DEFAULT '??', + day TEXT NOT NULL, -- YYYY-MM-DD, the same resolution the trend charts need + dev INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_events_day ON events(day); diff --git a/mooncloud/seed.sql b/mooncloud/seed.sql new file mode 100644 index 00000000..e8a31f25 --- /dev/null +++ b/mooncloud/seed.sql @@ -0,0 +1,14 @@ +-- Sample rows, so a local `GET /api/stats` returns something to look at. +-- +-- Local only: `run_mooncloud.py --seed` applies this with `wrangler d1 execute --local`, which +-- writes to the on-disk SQLite under .wrangler/ and never touches a deployed database. The ids are +-- obviously fake so a real row is never confused for one of these. + +INSERT OR REPLACE INTO reports + (installationId, event, version, previousVersion, chip, flash, psram, sdk, deviceModel, modules, country, receivedAt) +VALUES + ('00000000000000000000000000000001', 'install', '4.0.0', '', 'ESP32-S3', '16MB', '8MB', 'v5.5', 'esp32s3-n16r8', 'System,Network,Layouts', 'NL', '2026-09-01'), + ('00000000000000000000000000000002', 'upgrade', '4.0.0', '3.9.0', 'ESP32-S3', '16MB', '8MB', 'v5.5', 'esp32s3-n16r8', 'System,Network', 'DE', '2026-09-02'), + ('00000000000000000000000000000003', 'install', '4.0.0', '', 'ESP32', '4MB', '', 'v5.5', 'esp32-wrover', 'System', 'US', '2026-09-03'), + ('00000000000000000000000000000004', 'upgrade', '3.9.0', '3.8.0', 'ESP32-P4', '16MB', '32MB','v5.5', 'esp32p4rev1-eth','System,Network,Drivers', 'NL', '2026-08-20'), + ('00000000000000000000000000000005', 'install', '4.0.0', '', 'desktop', '', '', '', 'docker', 'System,Network', 'FR', '2026-09-05'); diff --git a/mooncloud/worker.js b/mooncloud/worker.js new file mode 100644 index 00000000..dc1c02a0 --- /dev/null +++ b/mooncloud/worker.js @@ -0,0 +1,493 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// MoonCloud Stats: the whole server. +// +// Two endpoints and one table. POST /api/report takes one report from a device that consented; +// GET /api/stats hands back the aggregates, which is what the device's own card renders. There is +// no dashboard here on purpose: the card is the UI, so there is no second front end to keep in +// sync with this schema. +// +// Cloudflare Workers rather than a box we own, for one specific reason: `request.cf.country` +// resolves the country at the EDGE, so the privacy policy's promise that the IP address is never +// stored is structural. Nothing here ever sees an address to write down, where on our own server +// that promise would be one nginx config change away from being false. +// +// The source is public for the same reason the firmware is: "the deployed code is the published +// code" is checkable rather than asked for on trust. + +// Every field a report may carry. Anything else a client sends is DROPPED rather than stored: +// the device is trusted to be honest, not to be current, and a future firmware inventing a field +// must not silently start filling a column nobody agreed to. +const ALLOWED = [ + "installationId", + "event", + "version", + "previousVersion", + "chip", + "flash", + "psram", + "sdk", + "deviceModel", + "modules", + "dev", +]; + +// What a value may look like. A report is untrusted input from an open endpoint, so the point is +// bounding what reaches storage rather than deciding what is true. +const MAX_STRING = 64; +const MAX_MODULES = 64; + +function clean(report, country) { + const row = {}; + for (const key of ALLOWED) { + const value = report[key]; + if (value === undefined || value === null) continue; + + if (key === "modules") { + if (!Array.isArray(value)) continue; + row.modules = value + .filter((m) => typeof m === "string" && m.length <= MAX_STRING) + .slice(0, MAX_MODULES) + .join(","); + continue; + } + // `dev` is the one boolean in the report: whether this firmware came from a release or a + // local build. Stored as 0/1 because the column is an INTEGER and SQLite has no bool. + if (key === "dev") { + row.dev = value === true || value === "true" ? 1 : 0; + continue; + } + if (typeof value !== "string") continue; + if (value.length > MAX_STRING) continue; + row[key] = value; + } + + // Derived here, never sent by the device and never stored alongside an address. + row.country = country || "??"; + row.receivedAt = new Date().toISOString().slice(0, 10); // the DAY, not the moment: a timestamp + // precise to the second is a fingerprint + // when combined with a country. + return row; +} + +async function handleReport(request, env) { + let report; + try { + report = await request.json(); + } catch { + return json({ error: "not JSON" }, 400); + } + if (typeof report !== "object" || report === null) { + return json({ error: "not an object" }, 400); + } + // An id is what makes a row countable as an installation rather than an event. A report without + // one is accepted and discarded: rejecting it would tell a caller what we require, and the + // device never sends one without consent anyway. + // Hex, not merely 32 characters: an id is a truncated SHA-256, and anything else did not come + // from a device. Accepted-and-discarded rather than rejected, so a caller learns nothing about + // what is required. + if (typeof report.installationId !== "string" || !/^[0-9a-f]{32}$/.test(report.installationId)) { + return json({ ok: true }, 202); + } + // `event` reaches every user's card as a pie legend, so it is one of two words or nothing. + if (report.event !== undefined && report.event !== "install" && report.event !== "upgrade") { + return json({ ok: true }, 202); + } + + const row = clean(report, request.cf?.country); + + // One row per installation per version: re-reporting the same upgrade overwrites rather than + // double-counting, which is what makes the totals a count of installations. + await env.DB.prepare( + `INSERT INTO reports + (installationId, event, version, previousVersion, chip, flash, psram, sdk, deviceModel, modules, country, receivedAt, dev) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(installationId, version) DO UPDATE SET + event = excluded.event, + previousVersion = excluded.previousVersion, + chip = excluded.chip, + flash = excluded.flash, + psram = excluded.psram, + sdk = excluded.sdk, + deviceModel = excluded.deviceModel, + modules = excluded.modules, + country = excluded.country, + receivedAt = excluded.receivedAt, + dev = excluded.dev` + ) + .bind( + row.installationId, + row.event ?? "install", + row.version ?? "", + row.previousVersion ?? "", + row.chip ?? "", + row.flash ?? "", + row.psram ?? "", + row.sdk ?? "", + row.deviceModel ?? "", + row.modules ?? "", + row.country, + row.receivedAt, + row.dev ?? 0 + ) + .run(); + + // The same report appended to the history table, which is never updated: `reports` above holds + // current state and overwrites on conflict, so without this the day a device FIRST reported is + // lost the moment it reports again, and a trend added later would start from empty. Carries no + // installation id: a dated row per installation is a movement profile, and counting events needs + // no identity. + await env.DB.prepare( + `INSERT INTO events (event, version, previousVersion, chip, deviceModel, country, day, dev) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind(row.event ?? "install", row.version ?? "", row.previousVersion ?? "", + row.chip ?? "", row.deviceModel ?? "", row.country, row.receivedAt, row.dev ?? 0) + .run(); + + return json({ ok: true }); +} + +// The aggregates, and ONLY aggregates: this endpoint is readable by anyone, so it may never carry +// a row. Counts of chips and versions are safe to show the world; an installation id is not. +async function handleStats(env, url) { + // EVERY figure counts every report. Nothing is filtered out here, and the `Build` breakdown below + // is what shows the released-against-development split, so a reader sees the distinction rather + // than a total that quietly left one side out. + // + // The earlier shape excluded development installs from every other figure by default. It made the + // headline number smaller with nothing saying why, and it hid a developer's own bench boards from + // the card running ON one of them, which reads as a report that never arrived. + // + // `?dev=0` still narrows to released installs for a caller that wants only those. + const releasedOnly = url?.searchParams.get("dev") === "0"; + const devFilter = releasedOnly ? " AND dev = 0" : ""; + + const counts = async (column) => { + const { results } = await env.DB.prepare( + `SELECT ${column} AS name, COUNT(DISTINCT installationId) AS count + FROM reports WHERE ${column} != ''${devFilter} GROUP BY ${column} ORDER BY count DESC LIMIT 40` + ).all(); + return results ?? []; + }; + + // `modules` is stored as one comma-separated string per report, so counting it needs the rows + // rather than a GROUP BY: a module is present on an installation, and an installation appears + // once. Counted over DISTINCT installations for the same reason every other figure is, so a + // device that reported twice does not count twice. + const moduleCounts = async () => { + const { results } = await env.DB.prepare( + `SELECT DISTINCT installationId, modules FROM reports WHERE modules != ''${devFilter}` + ).all(); + const seen = new Map(); // module -> Set of installation ids + for (const row of results ?? []) { + for (const name of String(row.modules).split(",")) { + const m = name.trim(); + if (!m) continue; + if (!seen.has(m)) seen.set(m, new Set()); + seen.get(m).add(row.installationId); + } + } + return [...seen.entries()] + .map(([name, ids]) => ({ name, count: ids.size })) + .sort((a, b) => b.count - a.count) + .slice(0, 40); + }; + + // Install versus upgrade. The one figure counted over REPORTS rather than installations: it + // describes events, and an installation that upgraded three times is three upgrade events. + const eventCounts = async () => { + const { results } = await env.DB.prepare( + `SELECT event AS name, COUNT(*) AS count FROM reports WHERE event != ''${devFilter} + GROUP BY event ORDER BY count DESC` + ).all(); + return results ?? []; + }; + + // Released against development, and the ONE figure that ignores the dev filter: a pie whose job + // is to show the split cannot be drawn from a query that already removed one side of it. + const buildCounts = async () => { + const { results } = await env.DB.prepare( + `SELECT CASE dev WHEN 1 THEN 'development' ELSE 'released' END AS name, + COUNT(DISTINCT installationId) AS count + FROM reports GROUP BY dev ORDER BY count DESC` + ).all(); + return results ?? []; + }; + + const { results: totals } = await env.DB.prepare( + `SELECT COUNT(DISTINCT installationId) AS installations, COUNT(*) AS reports + FROM reports WHERE 1=1${devFilter}` + ).all(); + + return json({ + installations: totals?.[0]?.installations ?? 0, + reports: totals?.[0]?.reports ?? 0, + // Says which population these figures describe, so a dashboard never has to guess. + includesDevelopmentInstalls: !releasedOnly, + versions: await counts("version"), + chips: await counts("chip"), + deviceModels: await counts("deviceModel"), + countries: await counts("country"), + // Already in every report and previously unused: aggregating them costs the device nothing. + flash: await counts("flash"), + psram: await counts("psram"), + sdk: await counts("sdk"), + previousVersions: await counts("previousVersion"), + events: await eventCounts(), + modules: await moduleCounts(), + builds: await buildCounts(), + updated: new Date().toISOString(), + }); +} + +// MoonTalk: a public message board between devices. +// +// Everything here is READABLE BY ANYONE, which is the whole point and also the constraint: there is +// no private message, no recipient and no per-user state, so the only thing to get right is not +// storing more than a sender published. `name` arrives only when that device's own consent said so; +// absent, a message shows a short id prefix instead. +// +// There is NO AUTHENTICATION, so a sender id can be fabricated by anyone who wants to. That is +// acceptable for a hobby board where nothing is gated on identity, and it is stated in the privacy +// policy rather than left to be discovered. +const MAX_MESSAGE = 280; // a message, not a document: bounded so one caller cannot fill the table +const MAX_NAME = 32; +const PAGE_SIZE = 50; + +async function handleTalkPost(request, env) { + let msg; + try { + msg = await request.json(); + } catch { + return json({ error: "not JSON" }, 400); + } + if (typeof msg !== "object" || msg === null) return json({ error: "not an object" }, 400); + + const sender = typeof msg.sender === "string" ? msg.sender.slice(0, 32) : ""; + const text = typeof msg.text === "string" ? msg.text.trim() : ""; + // The name is OPTIONAL by design: a device shares it only when its own consent control says so, + // so an empty one is the normal case rather than an error. + const name = typeof msg.name === "string" ? msg.name.trim().slice(0, MAX_NAME) : ""; + + if (!sender || sender.length !== 32) return json({ error: "sender required" }, 400); + if (!text) return json({ error: "empty message" }, 400); + if (text.length > MAX_MESSAGE) return json({ error: "message too long" }, 400); + + await env.DB.prepare( + `INSERT INTO messages (sender, name, text, country, sentAt) VALUES (?, ?, ?, ?, ?)` + ) + .bind(sender, name, text.slice(0, MAX_MESSAGE), request.cf?.country || "??", + new Date().toISOString()) + .run(); + + return json({ ok: true }); +} + +async function handleTalkGet(request, env) { + const url = new URL(request.url); + // `since` lets a device poll for what it has not seen instead of re-reading the board. + const since = Number(url.searchParams.get("since") || 0) || 0; + + const { results } = await env.DB.prepare( + `SELECT id, sender, name, text, country, sentAt FROM messages + WHERE id > ? ORDER BY id DESC LIMIT ?` + ).bind(since, PAGE_SIZE).all(); + + // The full sender id is NOT published: a caller gets the first 8 characters, which is enough to + // group one device's messages together and not enough to match against a Stats row. + const messages = (results ?? []).map((m) => ({ + id: m.id, + from: m.name || m.sender.slice(0, 8), + // The id prefix travels alongside a shared name rather than instead of it, so a named sender + // is still identifiable as one device: two people can pick the same device name, and without + // this their messages would be indistinguishable. + senderId: m.sender.slice(0, 8), + named: Boolean(m.name), + text: m.text, + country: m.country, + sentAt: m.sentAt, + })); + + return json({ messages }); +} + +// A minimal page at the root, so opening the server in a browser shows what it holds rather than a +// 404. DELIBERATELY SMALL: the device card is the real UI (that is why the API exists at all), and +// this is the view for whoever runs or checks the server. Inline HTML with no build step and no +// dependency, kept in one string so the whole server stays four files. +const PAGE = ` + + +MoonCloud Stats + +

MoonCloud Stats

+
Loading...
+
+
+ Opt-in, one report per install or upgrade. No addresses are stored and the country is resolved at + the edge. Privacy policy · + Source · + Raw JSON +
+`; + +function json(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + // The device fetches this from its own web UI, which is served from a different origin + // (the device itself), so the browser needs to be told this is allowed. Read-only and + // aggregate-only, so there is nothing here to protect with a narrower origin. + "access-control-allow-origin": "*", + "cache-control": "public, max-age=300", + }, + }); +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (request.method === "POST" && url.pathname === "/api/report") { + return handleReport(request, env); + } + if (request.method === "GET" && url.pathname === "/api/stats") { + return handleStats(env, url); + } + if (request.method === "POST" && url.pathname === "/api/talk") { + return handleTalkPost(request, env); + } + if (request.method === "GET" && url.pathname === "/api/talk") { + return handleTalkGet(request, env); + } + if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + return new Response(PAGE, { + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=300" }, + }); + } + if (request.method === "OPTIONS") { + return new Response(null, { + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "content-type", + }, + }); + } + return json({ error: "not found" }, 404); + }, +}; diff --git a/mooncloud/wrangler.toml b/mooncloud/wrangler.toml new file mode 100644 index 00000000..843bb12e --- /dev/null +++ b/mooncloud/wrangler.toml @@ -0,0 +1,28 @@ +name = "mooncloud-stats" +main = "worker.js" +compatibility_date = "2026-09-01" + +# Keep the workers.dev address alive: declaring any route disables it by default, and firmware built +# before the Custom Domain existed compiled it in. Both addresses serve the same Worker. +workers_dev = true + +# EU only. The rows hold no address and no precise time, so this is belt and braces rather than the +# thing that makes the feature lawful, but the data of European users staying in Europe is worth +# the one line it costs. +[placement] +mode = "smart" + +[[d1_databases]] +binding = "DB" +database_name = "mooncloud-stats" +database_id = "7966e8d7-1fdd-4231-b28d-9dfc08ea94b1" + +# No [limits] block: an explicit cpu_ms is a PAID-plan feature and the deploy is rejected without +# one. The free plan already caps CPU per invocation and STOPS at its request ceiling rather than +# billing, which is the protection that actually matters here. + +# The firmware hardcodes this address, so a move means a new release. Cloudflare owns the DNS record +# and the certificate for a Custom Domain; the workers.dev address keeps working alongside it. +[[routes]] +pattern = "stats.moonmodules.org" +custom_domain = true diff --git a/moondeck/run/purge_mooncloud.py b/moondeck/run/purge_mooncloud.py new file mode 100755 index 00000000..5ab371e7 --- /dev/null +++ b/moondeck/run/purge_mooncloud.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Delete MoonCloud rows in a date range, local or deployed. + +Test runs leave rows behind: a bench board reporting under a dev build, a dozen probe messages, a +day of one board reflashed twenty times. This removes them by DAY, which is the resolution the +tables store. + +WHY A SCRIPT AND NOT AN ENDPOINT. Every route in `mooncloud/worker.js` is unauthenticated, which is +right for reports and a public board and would be wrong for a delete: a public DELETE with a date +range lets anyone erase the whole dataset with one call. `wrangler d1 execute` is already +authenticated by the Cloudflare login and already works both ways, so the safe version is the one +that needs no new code on the server at all. + + uv run moondeck/run/purge_mooncloud.py --from 2026-09-01 --to 2026-09-10 + uv run moondeck/run/purge_mooncloud.py --from 2026-09-10 --to 2026-09-10 --remote + uv run moondeck/run/purge_mooncloud.py --dev-only # just development-build rows + +Shows what it would remove and asks before removing it. + +`--yes` skips the question for the LOCAL database only. The deployed one always asks, and asks for +the row count rather than a keystroke: those rows came from real devices, each sent once, and a +report is never retried, so a delete there is not recoverable by waiting. +""" + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +MOONCLOUD = ROOT / "mooncloud" + +DAY = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def run_sql(sql: str, remote: bool) -> str: + """One `wrangler d1 execute`, returning its output.""" + cmd = ["npx", "wrangler", "d1", "execute", "mooncloud-stats", + "--remote" if remote else "--local", f"--command={sql}"] + r = subprocess.run(cmd, cwd=MOONCLOUD, capture_output=True, text=True) + if r.returncode != 0: + print(r.stderr, file=sys.stderr) + raise SystemExit(r.returncode) + return r.stdout + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--from", dest="start", help="first day to delete, YYYY-MM-DD") + ap.add_argument("--to", dest="end", help="last day to delete, YYYY-MM-DD (inclusive)") + ap.add_argument("--dev-only", action="store_true", + help="only rows from locally-built firmware, whatever the date") + ap.add_argument("--remote", action="store_true", + help="the DEPLOYED database (default: the local one under .wrangler/)") + ap.add_argument("--yes", action="store_true", + help="skip the question. LOCAL ONLY: the deployed database always asks, " + "because the rows there came from other people's devices and cannot " + "be sent again") + args = ap.parse_args() + + if shutil.which("npx") is None: + print("npx not found: install Node.js to reach the database.", file=sys.stderr) + return 1 + + for day in (args.start, args.end): + if day and not DAY.match(day): + print(f"not a date: {day} (want YYYY-MM-DD)", file=sys.stderr) + return 1 + if not args.dev_only and not (args.start and args.end): + print("give --from and --to, or --dev-only", file=sys.stderr) + return 1 + + # `reports` dates its rows `receivedAt`, `events` calls the same thing `day`, and `messages` + # carries a full timestamp whose first ten characters are the date. + if args.dev_only: + where = {"reports": "dev = 1", "events": "dev = 1", "messages": None} + what = "development-build rows" + else: + where = { + "reports": f"receivedAt BETWEEN '{args.start}' AND '{args.end}'", + "events": f"day BETWEEN '{args.start}' AND '{args.end}'", + "messages": f"substr(sentAt, 1, 10) BETWEEN '{args.start}' AND '{args.end}'", + } + what = f"rows from {args.start} to {args.end} inclusive" + + where = {t: w for t, w in where.items() if w} + + print(f"About to delete {what} from the " + f"{'DEPLOYED' if args.remote else 'local'} database:\n") + total = 0 + for table, cond in where.items(): + out = run_sql(f"SELECT COUNT(*) AS n FROM {table} WHERE {cond}", args.remote) + m = re.search(r'"n":\s*(\d+)', out) + n = int(m.group(1)) if m else 0 + total += n + print(f" {table:10} {n:>6} rows") + + if total == 0: + print("\nNothing to delete.") + return 0 + + # A remote delete ALWAYS asks, and asks for the number rather than a keystroke. The local + # database is scratch that a re-run rebuilds; the deployed one holds reports that real devices + # sent once and will never send again, because a report is not retried. `--yes` is for a script + # against local data, and letting it through here would make the destructive case the easy one. + if args.remote: + print() + print("This is the DEPLOYED database. These rows came from real devices and cannot be") + print("sent again: a report is one-time and is never retried.") + # EOFError, not a crash: run without a terminal (a script, a pipe) there is nobody to ask, + # and the safe reading of "nobody answered" is to leave the rows alone. + try: + answer = input(f"Type {total} to delete, anything else to stop: ").strip() + except EOFError: + print("\nNot a terminal, so nothing was asked and nothing was deleted.") + return 1 + if answer != str(total): + print("Left alone.") + return 0 + elif not args.yes: + print() + try: + answer = input(f"Delete {total} rows? [y/N] ").strip().lower() + except EOFError: + print("\nNot a terminal, so nothing was asked and nothing was deleted.") + return 1 + if answer not in ("y", "yes"): + print("Left alone.") + return 0 + + for table, cond in where.items(): + run_sql(f"DELETE FROM {table} WHERE {cond}", args.remote) + print(f"\nDeleted {total} rows.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/run/run_mooncloud.py b/moondeck/run/run_mooncloud.py new file mode 100755 index 00000000..d5d03d73 --- /dev/null +++ b/moondeck/run/run_mooncloud.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Run the MoonCloud Stats server locally, on the same code that ships to Cloudflare. + +`wrangler dev` executes `mooncloud/worker.js` in workerd, the SAME runtime Cloudflare runs in +production, against a local D1 (SQLite on disk under .wrangler/). So this is not a stand-in that +approximates the server: it is the server, with a local database and a localhost address. What you +verify here is what deploys. + +The one thing that differs is `request.cf.country`, which the edge fills in and a local run leaves +undefined. The worker already handles that (an unknown country is stored as "??"), so the local +path exercises the same branch a report from an unrecognized network would take. + +Deploying afterwards is `npx wrangler deploy` from mooncloud/, and nothing about the code changes. +""" + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +MOONCLOUD = ROOT / "mooncloud" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--port", type=int, default=8787, + help="port to serve on (default 8787, wrangler's own default)") + ap.add_argument("--seed", action="store_true", + help="apply schema.sql and insert a few sample reports first, so " + "GET /api/stats has something to return") + args = ap.parse_args() + + if shutil.which("npx") is None: + print("npx not found: install Node.js (https://nodejs.org) to run the local server.", + file=sys.stderr) + return 1 + + if args.seed: + print("Applying schema and sample rows to the local D1...") + # --local keeps this on the on-disk SQLite under .wrangler/, never the deployed database. + for sql in ("schema.sql", "seed.sql"): + r = subprocess.run( + ["npx", "wrangler", "d1", "execute", "mooncloud-stats", "--local", f"--file={sql}"], + cwd=MOONCLOUD) + if r.returncode != 0: + print(f"failed applying {sql}", file=sys.stderr) + return r.returncode + + print(f"MoonCloud Stats on http://localhost:{args.port} (and on this machine's LAN address, for devices)") + print(f" POST http://localhost:{args.port}/api/report") + print(f" GET http://localhost:{args.port}/api/stats") + print("Ctrl-C to stop.") + # 0.0.0.0, not wrangler's default localhost: a DEVICE reporting to this server is the whole + # point of running it here, and a localhost-only bind refuses every board on the LAN while + # answering fine from this machine, which looks like a firmware bug rather than a bind address. + return subprocess.run( + ["npx", "wrangler", "dev", "--local", "--ip", "0.0.0.0", "--port", str(args.port)], + cwd=MOONCLOUD).returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/core/MoonCloudModule.h b/src/core/MoonCloudModule.h new file mode 100644 index 00000000..a0f982eb --- /dev/null +++ b/src/core/MoonCloudModule.h @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + + +/// @file MoonCloudModule.h +/// MoonCloud: the container for everything projectMM does with a server we run. +/// +/// It holds no controls of its own and does no work. It exists so that each thing MoonCloud does +/// is a CHILD with its own consent, rather than one module whose meaning widens: +/// +/// - **Stats** (`MoonStatsModule`): one report per install or upgrade, and the aggregates back. +/// - **Sync** (planned): device to device over the internet, a joint show across houses. +/// +/// **One module per promise.** A user who wants a joint lightshow has not agreed to usage +/// reporting, and a user who shares their chip model has not agreed to accept connections from +/// other people's devices. Separate children keep those separate questions, separately answered +/// and separately revocable, which a single "MoonCloud: on/off" switch could not express. +/// +/// Sync is NOT an extension of Stats and will not be built on it: Stats is one fire-and-forget POST +/// per firmware install, where Sync needs persistent connections, time alignment, presence and +/// pairing. They share this container and the installation id, and nothing else. See +/// [the MoonCloud plan](../../docs/history/plans/Plan-20260910%20-%20MoonCloud.md). + +#include +#include +#include + +#include "core/MoonModule.h" +#include "core/sha256.h" +#include "platform/platform.h" + +namespace mm { + +class MoonCloudModule : public MoonModule { +public: + /// A container with nothing to disable: turning MoonCloud "off" would be ambiguous when its + /// children carry the real consent, so each child owns its own answer and this one always runs. + bool respectsEnabled() const MM_NONBLOCKING override { return false; } + + /// How long any member waits for the server. One bounded call, off the render path. + static constexpr uint32_t kTimeoutMs = 4000; + + /// POST `body` to `path`. The one outbound call in MoonCloud, so every member sends through + /// here rather than repeating the client API. + /// + /// HTTPS only, because the address is a constant and that constant is public. An earlier shape + /// also carried a plain-HTTP branch for a local server, reached by editing a control; with the + /// address compiled in that branch could never run, and an unreachable transport is a second + /// way to send that nobody tests. + bool post(const char* path, const char* body) const { + char url[192]; + std::snprintf(url, sizeof(url), "https://%s%s", kHost, path); + return platform::httpsPost(url, body, kTimeoutMs); + } + + void defineControls() override { + controls_.clear(); + // On the CONTAINER, so one setting serves every member. A child registering its own would + // put the same field on two cards and let them drift apart. + // Chain FIRST so children register before any control of this module's own, per the + // override-and-chain convention (docs/coding-standards.md). Without this a child of a + // container shows an empty card, which is exactly what happened when MoonStats was + // briefly parented to Firmware, whose override does not chain. + MoonModule::defineControls(); + } + +private: + // THE address, compiled in rather than configurable. There is one MoonCloud, and a device that + // could be pointed elsewhere is a device that can be pointed at nothing: a typo means reports + // vanish silently, since a failed report is never retried. Moving the server is a release. + static constexpr const char* kHost = "stats.moonmodules.org"; + static constexpr uint16_t kPort = 443; +}; + + +// --------------------------------------------------------------------------------------------- +// The installation id: MoonCloud's shared identity, used by every member. +// +// The MoonStats installation id: 32 hex characters identifying this install, and nothing else. +// +// `SHA-256(salt || platform::getMacAddress())`, truncated to 16 bytes, exactly as +// [the MoonCloud plan](../../docs/history/plans/Plan-20260910 - MoonCloud.md) specifies. ONE +// scheme on every target, because the platform layer already answers "what is this installation" +// per platform: an eFuse MAC on ESP32, and since PR #98 a stored random address on desktop and in +// Docker. This code does not care which it got. +// +// **It lives in core, not behind the platform seam.** There is nothing platform-specific left once +// the seed is in hand: hashing six bytes is the same arithmetic everywhere, and putting it here +// means a new target inherits a correct id by implementing `getMacAddress` alone. +// +// **The salt is MoonStats-specific and deliberately differs from every other salt in the project.** +// The same MAC produces the MQTT topic prefix and the Home Assistant `unique_id` +// ([ADR-0010](../../docs/adr/0010-integration-identity-stable-hardware-id.md)), both visible on the +// user's own network. Hashing with a distinct salt is what stops a report being tied to a device +// somebody can observe locally. +// +// What this is NOT: anonymous. It is stable, so two reports carrying it came from one install, +// which is the entire point and also why [privacy-policy.md](../../docs/privacy-policy.md) calls it +// pseudonymous and says an ESP32 id is reversible in principle under a published salt. + +/// Characters written by `installationId`, excluding the terminator. +inline constexpr size_t kInstallationIdChars = 32; + +/// Write the installation id into `out`, which must hold `kInstallationIdChars + 1` characters. +/// +/// Computed on every call rather than cached: it is wanted once per install, and a cache would be +/// state that has to be invalidated when `fsSetRoot` moves the identity (which tests do between +/// cases). +// `inline` because this lives in a header: one definition shared by every translation unit that +// includes it, rather than one per unit. +// +// The salt is MoonCloud-wide and changing it re-identifies every installation in the world exactly +// once, so it is a fixed constant. It differs from every other salt in the project, which is what +// stops a report being correlated with the MQTT identity built from the same MAC. +inline constexpr char kMoonCloudSalt[] = "projectMM/MoonStats/v1"; + +inline void installationId(char* out) { + if (!out) return; + + uint8_t mac[6] = {}; + platform::getMacAddress(mac); + + // salt || mac, hashed as one buffer. Concatenation rather than HMAC: the salt is public in this + // source either way, so it is a domain separator rather than a key, and HMAC would imply a + // secret that does not exist. + uint8_t buf[sizeof(kMoonCloudSalt) - 1 + sizeof(mac)]; + std::memcpy(buf, kMoonCloudSalt, sizeof(kMoonCloudSalt) - 1); + std::memcpy(buf + sizeof(kMoonCloudSalt) - 1, mac, sizeof(mac)); + + sha256Hex(buf, sizeof(buf), out, kInstallationIdChars / 2); +} + +} // namespace mm diff --git a/src/core/MoonStatsModule.h b/src/core/MoonStatsModule.h new file mode 100644 index 00000000..f367e964 --- /dev/null +++ b/src/core/MoonStatsModule.h @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +/// @file MoonStatsModule.h +/// MoonStats: consent, and the once-per-install trigger. +/// +/// Owns the two things that decide whether a report is ever built: what the user answered, and +/// whether the firmware version changed since the last time this ran. It does NOT build the report +/// (that is [MoonStatsReport.h](MoonStatsReport.h), a pure function) and it does not send it. +/// +/// **Consent is the gate, and declining is silent.** `consent` is Unanswered until the user picks. +/// Never is remembered forever, and a device that never gets a Yes opens no connection and computes +/// no identifier: there is no "declined" record, because sending one would be a report. +/// +/// **The trigger is a version comparison, not a timer.** `reportedVersion` persists the version that +/// last produced a report. When the running version differs, one report is due; afterwards the two +/// match and nothing is due until the next upgrade. So a reboot sends nothing, and an upgrade sends +/// exactly one report. Whether that report says `install` or `upgrade` follows from the same field: +/// an empty `reportedVersion` on a device that has never reported is a fresh install, a different +/// one is an upgrade. No identifier is involved in telling those apart. +/// +/// Suppressed in AP mode, where there is no route to the internet and the user is usually +/// mid-provisioning: prompting there asks a question about data before the device can even reach +/// the network. +/// +/// See [privacy-policy.md](../../docs/privacy-policy.md) for what is promised, and +/// [the MoonCloud plan](../../docs/history/plans/Plan-20260910 - MoonCloud.md) for the id. + +#include +#include +#include + +#include "core/MoonModule.h" +#include "core/MoonCloudModule.h" +#include "core/Control.h" +#include "core/JsonSink.h" +#include "core/Scheduler.h" +#include "core/build_info.h" +#include "platform/platform.h" + +namespace mm { + +// ----------------------------------------------------------------------------------------------- +// The report itself: a pure function over the live module tree. +// +// MoonStats: the one report projectMM sends, and only after the user says yes. +// +// A PURE FUNCTION over the live module tree. It opens no socket, reads no consent, persists +// nothing, and can be called from a test with a tree built by hand. Everything it reports is +// already in memory as a control or a module name, so this is a serializer over known facts +// rather than new instrumentation. +// +// **The allowlist is the design.** Fields are named one at a time and copied by name. A builder +// that walked the tree and emitted what it found would leak on its first run: `deviceName` and +// `mac` are live SystemModule controls sitting beside `chip` and `flash`, and the network SSID and +// credentials are controls too. So there is no "emit everything except" path here, and +// `unit_MoonStatsReport.cpp` asserts the forbidden names cannot appear whatever the tree holds. +// See [privacy-policy.md](../../docs/privacy-policy.md), which is the promise this implements, and +// [the MoonCloud plan](../../docs/history/plans/Plan-20260910 - MoonCloud.md) for the id. + +/// What the report says happened. The device knows which without an identifier: the trigger is a +/// stored version file changing, so a report carrying a previous version is an upgrade and one +/// without is a fresh install. +enum class MoonStatsEvent : uint8_t { Install, Upgrade }; + +/// Serialize the report into `sink`. +/// +/// @param sink where the JSON object lands. +/// @param root the module tree to read (Scheduler's modules in production, a hand-built +/// tree in a test). +/// @param moduleCount how many modules `root` holds. +/// @param event install or upgrade. +/// @param installationId 32 hex characters from `platform::installationId()`, or nullptr to omit +/// it (which is what a test asserting the forbidden fields passes). +/// @param version the version now running. +/// @param previousVersion the version it replaced, or nullptr on a fresh install. + +/// Read one control's value out of `mod` BY NAME, as a string, or return false when the module +/// does not carry it. Named lookup rather than a cached descriptor: the report is built once per +/// install, so a linear walk of ~20 controls costs nothing, and a control that gets renamed makes +/// the field disappear rather than emitting whatever moved into its index. +inline bool readControl(const MoonModule* mod, const char* name, JsonSink& out) { + if (!mod) return false; + auto& ctrls = mod->controls(); + for (uint8_t i = 0; i < ctrls.count(); i++) { + auto& c = ctrls[i]; + if (c.name && std::strcmp(c.name, name) == 0) { + writeControlValue(out, c); + return true; + } + } + return false; +} + +/// Find a module by name anywhere in the tree, depth first. +inline const MoonModule* findModule(MoonModule* const* root, uint8_t count, const char* name) { + for (uint8_t i = 0; i < count; i++) { + const MoonModule* m = root[i]; + if (!m) continue; + if (m->name() && std::strcmp(m->name(), name) == 0) return m; + for (uint8_t c = 0; c < m->childCount(); c++) { + if (const MoonModule* ch = m->child(c)) { + if (ch->name() && std::strcmp(ch->name(), name) == 0) return ch; + } + } + } + return nullptr; +} + +/// Copy one named control into the report under the same key, or omit the key entirely when the +/// control is absent. Omission rather than a null: an absent field costs no bytes and the server +/// counts what it sees, where a null would need a rule about what it means. +inline void field(JsonSink& sink, const MoonModule* mod, const char* control, const char* key, + bool& first) { + if (!mod) return; + JsonSink value; + if (!readControl(mod, control, value)) return; + if (!first) sink.append(","); + first = false; + sink.append("\""); + sink.append(key); + sink.append("\":"); + sink.append(value.data()); +} + + +inline void buildMoonStatsReport(JsonSink& sink, + MoonModule* const* root, uint8_t moduleCount, + MoonStatsEvent event, + const char* installationId, + const char* version, + const char* previousVersion) { + const MoonModule* system = findModule(root, moduleCount, "System"); + + sink.append("{"); + bool first = true; + + // The installation id, when the caller has one. Omitted entirely by the test that asserts the + // forbidden fields, and by any caller that has not obtained consent. + if (installationId && *installationId) { + sink.append("\"installationId\":"); + sink.writeJsonString(installationId); + first = false; + } + + if (!first) sink.append(","); + sink.append("\"event\":"); + sink.writeJsonString(event == MoonStatsEvent::Upgrade ? "upgrade" : "install"); + first = false; + + if (version && *version) { + sink.append(",\"version\":"); + sink.writeJsonString(version); + } + + // Whether this firmware came from a release or somebody's laptop. `kRelease` is set by CI on a + // published build and EMPTY on a local one, so this needs no new plumbing and no question to the + // user: it describes the binary, not the person. + // + // Sent from the FIRST report rather than added when the noise becomes annoying, because a flag + // introduced later cannot classify rows already stored: every report before it exists is + // permanently unlabeled, and nothing afterwards can tell a developer's bench board from a + // user's shelf. One boolean now buys a dashboard that can say "excluding development installs" + // for its whole history. + sink.append(",\"dev\":"); + sink.writeBool(kRelease[0] == 0); + // Present only on an upgrade, and it is what tells the server this was one. + if (previousVersion && *previousVersion) { + sink.append(",\"previousVersion\":"); + sink.writeJsonString(previousVersion); + } + + // Hardware, straight off the System module. Every one of these is a fact about the board, not + // about the person holding it. `deviceName` and `mac` sit in the same control list and are + // deliberately NOT here. + field(sink, system, "chip", "chip", first); + field(sink, system, "flash", "flash", first); + field(sink, system, "psramType", "psram", first); + field(sink, system, "sdk", "sdk", first); + field(sink, system, "deviceModel", "deviceModel", first); + + // Which modules are enabled, by name. The names are ours (a fixed vocabulary from the + // catalog), not anything a user typed. + sink.append(",\"modules\":["); + bool firstModule = true; + for (uint8_t i = 0; i < moduleCount; i++) { + const MoonModule* m = root[i]; + if (!m || !m->enabled() || !m->name()) continue; + if (!firstModule) sink.append(","); + firstModule = false; + sink.writeJsonString(m->name()); + } + sink.append("]"); + + sink.append("}"); + sink.flush(); +} + + +class MoonStatsModule : public MoonModule { +public: + /// What the user answered. Persisted, so Never survives a reboot and an upgrade. + /// + /// Unanswered is the default and the only state that shows a prompt. NotNow exists so a user + /// who is busy is asked again after the NEXT upgrade rather than never: it is a deferral, where + /// Never is an answer. + enum Consent : uint8_t { Unanswered = 0, Yes = 1, NotNow = 2, Never = 3 }; + + void setup() override { + std::snprintf(runningVersion_, sizeof(runningVersion_), "%s", kVersion); + MoonModule::setup(); + } + + void defineControls() override { + controls_.clear(); + // The user's answer. A select rather than a bool: "not now" and "never" are different + // answers, and collapsing them would either nag someone who declined or silence someone + // who only deferred. + static const char* kConsentOptions[] = {"Not answered", "Yes", "Not now", "Never"}; + controls_.addSelect("consent", consent_, kConsentOptions, 4); + + // The version that last produced a report. Empty means none ever has, which is what makes + // the first report an `install`. Read-only in the UI: it is bookkeeping, and a user editing + // it would either re-send or silence a genuine upgrade. + controls_.addReadOnly("reportedVersion", reportedVersion_, sizeof(reportedVersion_)); + controls_.addReadOnly("version", runningVersion_, sizeof(runningVersion_)); + + } + + /// True when the UI should ask. Only ever true before the user answers, and never while the + /// device is its own access point. + bool shouldPrompt() const { + if (consent_ != Unanswered) return false; + // Not while the device is its own access point: there is no route to the internet there, + // and the user is part-way through setting the device up. The question waits for a real + // network rather than interrupting provisioning. + if (inApMode()) return false; + return true; + } + + /// True when a report is due: the user said yes, and the running version differs from the one + /// that last reported. + bool reportDue() const { + if (consent_ != Yes) return false; + return std::strcmp(reportedVersion_, runningVersion_) != 0; + } + + /// Which kind of report is due. An empty `reportedVersion` means this install has never + /// reported, so it is a fresh install; anything else means the firmware moved under it. + MoonStatsEvent dueEvent() const { + return reportedVersion_[0] == 0 ? MoonStatsEvent::Install : MoonStatsEvent::Upgrade; + } + + /// The version being replaced, for an upgrade report, or nullptr on a fresh install. + const char* previousVersion() const { + return reportedVersion_[0] == 0 ? nullptr : reportedVersion_; + } + + /// Record that a report was sent for the running version, so nothing further is due until the + /// next upgrade. Called after a send is HANDED OFF rather than after it succeeds: a failure is + /// not retried (a lost report costs one row in an aggregate), and marking only on success would + /// make an unreachable server re-send on every boot. + void markReported() { + std::snprintf(reportedVersion_, sizeof(reportedVersion_), "%s", runningVersion_); + markDirty(); + } + + /// Record the user's answer. + void setConsent(Consent answer) { + consent_ = static_cast(answer); + markDirty(); + } + + Consent consent() const { return static_cast(consent_); } + + /// Pretend the firmware is a different version, so a test can exercise a real UPGRADE: the + /// running version changing under an install that already reported. Nothing else can produce + /// that state, since `setup()` reads a compile-time constant and a test cannot recompile. + void setRunningVersionForTest(const char* v) { + std::snprintf(runningVersion_, sizeof(runningVersion_), "%s", v ? v : ""); + } + + /// This installation's id, or an empty string when the user has not consented. + /// + /// Gated on consent rather than merely unused without it: the policy says nothing is generated + /// until you say yes, so a caller cannot obtain one to log or display either. + void installationId(char* out) const { + if (!out) return; + if (consent_ != Yes) { out[0] = 0; return; } + mm::installationId(out); + } + + /// One bounded HTTP call per second at most, and only when a report is actually due, which is + /// once per install or upgrade in the life of a device. The same shape HueDriver uses for the + /// bridge poll: `tick1s` is not the render path, and a call that costs a second here costs + /// nothing a user can see. + // The send is a bounded blocking call, which is what `tick1s` is for: it is the 1 Hz + // housekeeping tick, not the per-frame render path, and it is where HueDriver polls its bridge + // and the OTA path fetches. `-Wfunction-effects` still warns, because the base declares the + // hook MM_NONBLOCKING for the per-frame case and an override cannot narrow that: the warning + // names a real property (this call blocks) rather than a mistake. + // + // What keeps it acceptable is frequency: at most one call, once per firmware install, for the + // life of the device. The guards below run first and cost nothing, so a device that has already + // reported never reaches the blocking part again. + void tick1s() MM_NONBLOCKING override { + MoonModule::tick1s(); + if (!reportDue()) return; + if (!platform::networkReady()) return; // nothing to do yet; try again next second + // Serving our own AP means no route out, so a send would fail and mark itself reported. + if (inApMode()) return; + sendReport(); + } + + /// Whether the device is currently serving its own access point, asked of the platform rather + /// than pushed in by NetworkModule. A setter would have meant one module reaching into another + /// to keep a copy of a fact the platform already answers, and a stale copy is a prompt that + /// appears at the wrong moment. + bool inApMode() const { return platform::wifiApConnected(); } + +private: + /// Build the report and POST it once. Marked reported on HAND-OFF rather than on success: a + /// failure costs one row in an aggregate, where re-sending on every boot until a server answers + /// would turn one report into a heartbeat, which is the one thing this feature promises never + /// to be. + void sendReport() { + char id[kInstallationIdChars + 1] = {}; + installationId(id); + if (!id[0]) return; // no consent, no id, no report + + // Scheduler exposes module(i) rather than the array, so the tree is gathered here. 32 is + // its own capacity, so this cannot truncate a tree the scheduler accepted. + MoonModule* tree[32] = {}; + auto* sched = Scheduler::instance(); + if (!sched) return; + uint8_t count = 0; + for (uint8_t i = 0; i < sched->moduleCount() && count < 32; i++) { + if (MoonModule* m = sched->module(i)) tree[count++] = m; + } + + JsonSink body; + buildMoonStatsReport(body, tree, count, + dueEvent(), id, runningVersion_, previousVersion()); + + // The response is discarded: the server answers {"ok":true} and there is nothing to do + // with it. A small buffer still has to exist because httpRequest reads into one. + // Sent through the container, which owns the address and the scheme choice. + if (auto* cloud = static_cast(parent())) { + (void)cloud->post("/api/report", body.data()); + } + markReported(); + } + + uint8_t consent_ = Unanswered; + char reportedVersion_[32] = {}; + char runningVersion_[32] = {}; +}; + +} // namespace mm diff --git a/src/core/MoonTalkModule.h b/src/core/MoonTalkModule.h new file mode 100644 index 00000000..89c017f3 --- /dev/null +++ b/src/core/MoonTalkModule.h @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +/// @file MoonTalkModule.h +/// MoonTalk: a public message board between projectMM devices, in the shape Meshtastic's channel +/// chat has. +/// +/// A second MoonCloud child, with its OWN consent: agreeing to share a chip model says nothing +/// about wanting to publish messages, so the two are separate questions and neither implies the +/// other ([MoonCloudModule.h](MoonCloudModule.h)). +/// +/// **Everything sent here is public and permanent.** There is no private message, no recipient and +/// no delete: a message goes on a board anyone can read, and that is what the consent is asking +/// about. Nothing is sent until it says yes, and the module never posts on its own: a message +/// exists because somebody typed it. +/// +/// **Two consents, not one.** `consent` allows posting at all. `shareName` is separate and OFF by +/// default, because a device name is a real identifier: "MM-A094" says nothing, "Ewoud's bedroom" +/// says a great deal, and the second is what people actually type. Without it a message is +/// attributed to the first 8 characters of the installation id, which groups one device's messages +/// without naming anyone. +/// +/// **Reading needs no consent at all.** The board is public, so fetching it sends nothing about +/// this device, exactly like the MoonCloud Stats aggregates. +/// +/// **There is no authentication**, so a sender id can be fabricated by anyone posting by hand. +/// Acceptable for a hobby board where nothing is gated on identity, and stated in the privacy +/// policy rather than left to be discovered. + +#include +#include +#include + +#include "core/MoonCloudModule.h" +#include "core/Scheduler.h" +#include "core/JsonSink.h" +#include "core/MoonModule.h" +#include "platform/platform.h" + +namespace mm { + +class MoonTalkModule : public MoonModule { +public: + enum Consent : uint8_t { Unanswered = 0, Yes = 1, Never = 2 }; + + void defineControls() override { + controls_.clear(); + + static const char* kConsentOptions[] = {"Not answered", "Yes", "Never"}; + controls_.addSelect("consent", consent_, kConsentOptions, 3); + + // Separate from `consent` and default OFF. A device name is the one field here that + // identifies a person rather than a machine, so sharing it is its own decision. + controls_.addControl("shareName", shareName_); + + // What to say. Cleared after a send, so the box is empty for the next message rather than + // inviting an accidental repost. + controls_.addText("message", message_, sizeof(message_)); + + } + + /// Post whatever is in `message` when the user commits it. Driven by a control write rather + /// than a tick: a message board that posts on a timer would be a bot. + void onControlChanged(const char* name) override { + if (!name || std::strcmp(name, "message") != 0) return; + if (consent_ != Yes) { message_[0] = 0; return; } // never send without consent + if (message_[0] == 0) return; // cleared, nothing to do + + // A TEXT CONTROL REPORTS EVERY KEYSTROKE, debounced. That suits a setting, where the last + // value wins and the ones before it are harmless, and it does not suit a message: typing + // "hello" published "hel" and then "hell" as messages of their own. + // + // So a message is held until it stops growing. Each write restarts a short wait; the send + // happens in tick1s once nothing has arrived for a moment, which is the same thing a person + // means by having finished typing. Held HERE rather than in the browser because it is a + // rule about what a message is, and a device posting from a script or over the API deserves + // it too. + pendingMs_ = kSettleMs; + } + + // Same shape as MoonCloud Stats: the post is a bounded blocking call on the 1 Hz housekeeping + // tick rather than the per-frame path, and `-Wfunction-effects` warns because the base hook is + // declared MM_NONBLOCKING for that per-frame case. The frequency is what makes it fine: a + // message is sent because a person typed one, and the settle check below returns immediately on + // every tick where nobody did. + void tick1s() MM_NONBLOCKING override { + MoonModule::tick1s(); + if (pendingMs_ == 0) return; + pendingMs_ = pendingMs_ > 1000 ? pendingMs_ - 1000 : 0; + if (pendingMs_ != 0) return; + + if (consent_ == Yes && message_[0]) send(); + message_[0] = 0; // sent, or refused: the box empties either way + markDirty(); + } + + /// The device name, read from SystemModule at send time rather than pushed in by whoever knows + /// it. A setter was the first shape and it was never called, so every message went out unnamed + /// while `shareName` said otherwise: a copy that nothing updates is worse than no copy. + /// + /// Read at SEND time, not cached, so renaming the device renames the sender on the next message + /// rather than at the next reboot. + const char* deviceName() const { + auto* sched = Scheduler::instance(); + if (!sched) return ""; + for (uint8_t i = 0; i < sched->moduleCount(); i++) { + const MoonModule* m = sched->module(i); + if (!m || !m->name() || std::strcmp(m->name(), "System") != 0) continue; + auto& ctrls = m->controls(); + for (uint8_t c = 0; c < ctrls.count(); c++) { + const ControlDescriptor& d = ctrls[c]; + if (d.ptr && d.name && std::strcmp(d.name, "deviceName") == 0 && + (d.type == ControlType::Text || d.type == ControlType::ReadOnly)) { + return static_cast(d.ptr); + } + } + } + return ""; + } + + Consent consent() const { return static_cast(consent_); } + bool sharesName() const { return shareName_ && consent_ == Yes; } + +private: + /// Build and post one message. Same server and scheme rule as MoonCloud Stats: port 443 or + /// none means the public server over HTTPS, anything else a local one over plain HTTP. + void send() { + char id[kInstallationIdChars + 1] = {}; + installationId(id); + if (!id[0]) return; + + JsonSink body; + body.append("{\"sender\":"); + body.writeJsonString(id); + // The name rides ONLY when both consents allow it. Omitted rather than sent empty, so the + // server stores nothing rather than a blank it would have to interpret. + const char* name = deviceName(); + if (shareName_ && name && name[0]) { + body.append(",\"name\":"); + body.writeJsonString(name); + } + body.append(",\"text\":"); + body.writeJsonString(message_); + body.append("}"); + body.flush(); + + // Sent through the container, which owns the address and the scheme choice. + if (auto* cloud = static_cast(parent())) { + (void)cloud->post("/api/talk", body.data()); + } + } + + /// How long a message must stop changing before it counts as finished. + static constexpr uint16_t kSettleMs = 2000; + uint16_t pendingMs_ = 0; + + uint8_t consent_ = Unanswered; + bool shareName_ = false; + char message_[192] = {}; +}; + +} // namespace mm diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h index 36f573c4..8e0091e8 100644 --- a/src/core/SystemModule.h +++ b/src/core/SystemModule.h @@ -169,6 +169,12 @@ class SystemModule : public MoonModule { // checks it in the backend, wherever the write comes from. Display-only in // the UI (pushed, never user-typed); bound as Text — not ReadOnly — because Text is // auto-persisted and the readonly flag is only a UI-render hint. + // Seeded from the platform when it can answer for itself and nothing was persisted: a + // desktop knows its own OS and whether it is a container, where a board does not and waits + // for tooling. Only when EMPTY, so an injected catalog name is never overwritten. + if (deviceModel_[0] == 0) { + std::snprintf(deviceModel_, sizeof(deviceModel_), "%s", platform::hostPlatform()); + } controls_.addText("deviceModel", deviceModel_, sizeof(deviceModel_), validateDeviceModel); // firmware: the build variant this image is (`esp32s3-zero`), written from kFirmwareName diff --git a/src/core/sha256.cpp b/src/core/sha256.cpp new file mode 100644 index 00000000..d8899f91 --- /dev/null +++ b/src/core/sha256.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "sha256.h" + +#include + +namespace mm { + +namespace { + +// The first 32 bits of the fractional parts of the cube roots of the first 64 primes (FIPS 180-4 +// §4.2.2). A constant of the algorithm, not a choice. +constexpr uint32_t kK[64] = { + 0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u, 0x3956c25bu, 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u, + 0xd807aa98u, 0x12835b01u, 0x243185beu, 0x550c7dc3u, 0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, 0xc19bf174u, + 0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu, 0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau, + 0x983e5152u, 0xa831c66du, 0xb00327c8u, 0xbf597fc7u, 0xc6e00bf3u, 0xd5a79147u, 0x06ca6351u, 0x14292967u, + 0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, 0x53380d13u, 0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u, + 0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u, 0xd192e819u, 0xd6990624u, 0xf40e3585u, 0x106aa070u, + 0x19a4c116u, 0x1e376c08u, 0x2748774cu, 0x34b0bcb5u, 0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, 0x682e6ff3u, + 0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u, 0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u, +}; + +inline uint32_t rotr(uint32_t x, int n) { return (x >> n) | (x << (32 - n)); } + +/// One 64-byte block into the running state (FIPS 180-4 §6.2.2). +void compress(uint32_t h[8], const uint8_t block[64]) { + uint32_t w[64]; + // Big-endian by construction rather than by cast: the digest must be identical on the + // little-endian hosts here and any big-endian target, so the bytes are assembled explicitly. + for (int i = 0; i < 16; i++) { + w[i] = (static_cast(block[i * 4]) << 24) | + (static_cast(block[i * 4 + 1]) << 16) | + (static_cast(block[i * 4 + 2]) << 8) | + (static_cast(block[i * 4 + 3])); + } + for (int i = 16; i < 64; i++) { + const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); + const uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + + uint32_t a = h[0], b = h[1], c = h[2], d = h[3]; + uint32_t e = h[4], f = h[5], g = h[6], hh = h[7]; + + for (int i = 0; i < 64; i++) { + const uint32_t s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const uint32_t ch = (e & f) ^ (~e & g); + const uint32_t t1 = hh + s1 + ch + kK[i] + w[i]; + const uint32_t s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const uint32_t maj = (a & b) ^ (a & c) ^ (b & c); + const uint32_t t2 = s0 + maj; + hh = g; g = f; f = e; e = d + t1; + d = c; c = b; b = a; a = t1 + t2; + } + + h[0] += a; h[1] += b; h[2] += c; h[3] += d; + h[4] += e; h[5] += f; h[6] += g; h[7] += hh; +} + +} // namespace + +void sha256(const void* data, size_t len, uint8_t out[kSha256DigestSize]) { + // The first 32 bits of the fractional parts of the square roots of the first 8 primes + // (FIPS 180-4 §5.3.3). + uint32_t h[8] = {0x6a09e667u, 0xbb67ae85u, 0x3c6ef372u, 0xa54ff53au, + 0x510e527fu, 0x9b05688cu, 0x1f83d9abu, 0x5be0cd19u}; + + const uint8_t* p = static_cast(data); + size_t remaining = len; + + while (remaining >= 64) { + compress(h, p); + p += 64; + remaining -= 64; + } + + // Padding: the 0x80 terminator, zeroes, then the length in BITS as a big-endian 64-bit value. + // Two blocks when the tail plus the terminator leaves no room for that length (FIPS 180-4 §5.1.1). + uint8_t tail[128] = {}; + std::memcpy(tail, p, remaining); + tail[remaining] = 0x80; + const size_t tailLen = (remaining >= 56) ? 128 : 64; + + const uint64_t bits = static_cast(len) * 8; + for (int i = 0; i < 8; i++) { + tail[tailLen - 1 - static_cast(i)] = static_cast((bits >> (i * 8)) & 0xFF); + } + + compress(h, tail); + if (tailLen == 128) compress(h, tail + 64); + + for (int i = 0; i < 8; i++) { + out[i * 4] = static_cast((h[i] >> 24) & 0xFF); + out[i * 4 + 1] = static_cast((h[i] >> 16) & 0xFF); + out[i * 4 + 2] = static_cast((h[i] >> 8) & 0xFF); + out[i * 4 + 3] = static_cast(h[i] & 0xFF); + } +} + +void sha256Hex(const void* data, size_t len, char* out, size_t outBytes) { + if (!out) return; + if (outBytes > kSha256DigestSize) outBytes = kSha256DigestSize; + + uint8_t digest[kSha256DigestSize]; + sha256(data, len, digest); + + static const char kHex[] = "0123456789abcdef"; + for (size_t i = 0; i < outBytes; i++) { + out[i * 2] = kHex[(digest[i] >> 4) & 0x0F]; + out[i * 2 + 1] = kHex[digest[i] & 0x0F]; + } + out[outBytes * 2] = 0; +} + +} // namespace mm diff --git a/src/core/sha256.h b/src/core/sha256.h new file mode 100644 index 00000000..9b5c4c91 --- /dev/null +++ b/src/core/sha256.h @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +/// @file sha256.h +/// SHA-256 (FIPS 180-4), vendored. +/// +/// **Why a copy rather than a library.** This is the only cryptographic primitive projectMM needs, +/// and it needs it on all five targets. OpenSSL does not exist on ESP32 (ESP-IDF ships mbedtls), so +/// linking it would mean an mbedtls path for devices and an OpenSSL path for the desktop: two +/// implementations of one function that must agree byte for byte, where a divergence produces +/// identifiers that silently do not match between platforms. One vendored file is the smaller +/// thing to own, and the algorithm is frozen: FIPS 180-4 has not changed since 2015 and the test +/// vectors are published, so `unit_sha256.cpp` pins this against them. +/// +/// The one use today is the MoonStats installation id +/// ([the MoonCloud plan](../../docs/history/plans/Plan-20260910 - MoonCloud.md)). It is NOT a +/// general-purpose crypto layer: no HMAC, no streaming over a socket, no constant-time comparison, +/// because nothing here needs them and an unused primitive is a maintenance cost with no user. + +#include +#include + +namespace mm { + +/// Digest length in bytes. +inline constexpr size_t kSha256DigestSize = 32; + +/// Hash `len` bytes at `data` into `out`, which must hold 32 bytes. +/// +/// One shot rather than init/update/final: every caller here hashes a short buffer that is already +/// in memory, and the streaming form would be three functions nobody calls with more than one +/// update. +void sha256(const void* data, size_t len, uint8_t out[kSha256DigestSize]); + +/// Hash `len` bytes and write the first `outBytes` of the digest as lowercase hex into `out`. +/// +/// `out` must hold `outBytes * 2 + 1` characters. Truncating a SHA-256 is the standard way to get a +/// shorter identifier (NIST SP 800-107 §5.1); 16 bytes leaves 128 bits, far past any collision +/// concern for a population of LED controllers. +void sha256Hex(const void* data, size_t len, char* out, size_t outBytes = 16); + +} // namespace mm diff --git a/src/main.cpp b/src/main.cpp index 018c14fd..3ffe6a50 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -159,6 +159,9 @@ #include "core/MoonLiveService.h" #include "core/FileManagerModule.h" #include "core/FirmwareUpdateModule.h" +#include "core/MoonCloudModule.h" +#include "core/MoonStatsModule.h" +#include "core/MoonTalkModule.h" #include "core/ImprovProvisioningModule.h" #include "core/MqttModule.h" #include "core/DevicesModule.h" @@ -335,6 +338,9 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("MoonLiveService", "core/services.md#moonliveservice"); mm::ModuleFactory::registerType("FileManagerModule", "core/system.md#file-manager"); mm::ModuleFactory::registerType("FirmwareUpdateModule", "core/system.md#firmware-update"); + mm::ModuleFactory::registerType("MoonCloudModule", "core/system.md#mooncloud"); + mm::ModuleFactory::registerType("MoonStatsModule", "core/system.md#mooncloud-stats"); + mm::ModuleFactory::registerType("MoonTalkModule", "core/system.md#mooncloud-talk"); mm::ModuleFactory::registerType("ImprovProvisioningModule", "core/system.md#improv-provisioning"); mm::ModuleFactory::registerType("MqttModule", "core/system.md#mqtt"); mm::ModuleFactory::registerType("DevicesModule", "core/system.md#devices"); @@ -446,6 +452,27 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { mm::ModuleFactory::create("FirmwareUpdateModule")); firmwareUpdateModule->setName("Firmware"); + // MoonCloud: the container for everything that talks to a server we run. Stats is its first + // child and Sync will be its second, each with its own consent, because a user who wants a + // joint lightshow has not thereby agreed to usage reporting. + // + // NOT a child of Firmware, which was the first shape tried: FirmwareUpdateModule overrides + // defineControls() without chaining to children, so anything parented there shows an empty + // card. MoonCloudModule chains, which is what makes Stats' controls appear. + auto* moonCloudModule = static_cast( + mm::ModuleFactory::create("MoonCloudModule")); + moonCloudModule->setName("MoonCloud"); + + auto* moonStatsModule = static_cast( + mm::ModuleFactory::create("MoonStatsModule")); + moonStatsModule->setName("Stats"); + + // MoonTalk: the public message board. A SECOND MoonCloud child with its own consent, + // because publishing a message and sharing a chip model are different decisions. + auto* moonTalkModule = static_cast( + mm::ModuleFactory::create("MoonTalkModule")); + moonTalkModule->setName("Talk"); + // Network (platform stubs return false on desktop — module is a no-op) auto* networkModule = static_cast(mm::ModuleFactory::create("NetworkModule")); networkModule->setScheduler(&scheduler); @@ -584,6 +611,13 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { scheduler.addModule(systemModule); scheduler.addModule(fileManagerModule); scheduler.addModule(firmwareUpdateModule); + // markWiredByCode: both are boot wiring, not user-added, so the persisted tree must not decide + // whether they exist. Without it a config written before a child was added drops that child on + // load, which is exactly what happened when Talk was introduced beside Stats: the file listed + // one child, so the tree came back with one. + if (moonStatsModule) { moonStatsModule->markWiredByCode(); moonCloudModule->addChild(moonStatsModule); } + if (moonTalkModule) { moonTalkModule->markWiredByCode(); moonCloudModule->addChild(moonTalkModule); } + scheduler.addModule(moonCloudModule); if (improvModule) networkModule->addChild(improvModule); if (mqttModule) networkModule->addChild(mqttModule); // Devices: discovers other devices on the LAN. Child of Network (discovery diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index cbb43222..1fc5605e 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -11,6 +11,9 @@ #include #include // the stored identity is generated once, see getMacAddress #include +#ifdef MM_HAVE_CURL +#include // https POST: the OS's own TLS, nothing vendored +#endif #include #include #ifndef _WIN32 @@ -514,7 +517,111 @@ const char* macString() { } const char* chipModel() { - return "desktop"; + // The real instruction set, not the word "desktop". On a device this control names the silicon + // (ESP32-S3, ESP32-P4), so a host that answers "desktop" is naming its category instead, and + // the MoonCloud chip breakdown could not tell an Apple Silicon Mac from an x86 mini PC. The + // architecture is what carries the same meaning here: it is what a build targets and what a + // performance number belongs to. + // + // Compile-time, because the answer cannot change at run time: a binary is built for one ISA. + // (Rosetta reports the EMULATED one, which is the truthful answer for the code that is running.) +#if defined(__aarch64__) || defined(_M_ARM64) + return "arm64"; +#elif defined(__x86_64__) || defined(_M_X64) + return "x64"; +#elif defined(__arm__) || defined(_M_ARM) + return "arm32"; +#elif defined(__i386__) || defined(_M_IX86) + return "x86"; +#else + return "desktop"; // an architecture nobody has built for yet; better vague than wrong +#endif +} + +bool httpsPost(const char* url, const char* body, uint32_t timeoutMs) { +#ifdef MM_HAVE_CURL + if (!url || !*url) return false; + + // curl_global_init is NOT thread-safe and must run once before any easy handle. Doing it in a + // function-local static makes the first call initialize it and every later call skip, which is + // thread-safe since C++11 and needs no explicit teardown: the process exiting is the teardown. + static const bool inited = (curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK); + if (!inited) return false; + + CURL* curl = curl_easy_init(); + if (!curl) return false; + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : ""); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast(timeoutMs)); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast(timeoutMs)); + // VERIFYPEER and VERIFYHOST are curl's defaults; set explicitly so a future edit cannot turn + // them off without saying so out loud. Without them TLS proves nothing about who answered. + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + // No redirect following: the one endpoint is ours and answers directly. Following one would + // let a server move a POST body somewhere the caller never named. + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); // no SIGALRM in a threaded process + + struct curl_slist* headers = curl_slist_append(nullptr, "Content-Type: application/json"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + // Discard the body rather than buffer it: nothing reads it, and a write callback that returns + // the full size is how curl is told the data was consumed. + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, + +[](char*, size_t size, size_t nmemb, void*) -> size_t { return size * nmemb; }); + + const CURLcode rc = curl_easy_perform(curl); + long status = 0; + if (rc == CURLE_OK) curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return rc == CURLE_OK && status >= 200 && status < 300; +#else + // Built without libcurl: the caller degrades visibly rather than pretending it sent something. + (void)url; (void)body; (void)timeoutMs; + return false; +#endif +} + +const char* hostPlatform() { + // The same names the release packaging uses (package_desktop.py: macos-arm64, windows-x64, + // linux-x64), so a MoonCloud board breakdown lines up with the downloads it came from rather + // than inventing a second vocabulary for the same thing. + // + // A container reports "docker" rather than its host OS: what matters about a container is that + // it IS one (no display, a mounted volume, an identity that dies without it), and its Linux + // underneath is already visible in the chip field. +#if defined(__linux__) + // /.dockerenv is what Docker itself creates in every container it builds. Checked ONCE, since + // a process cannot move in or out of a container while it runs. + static const bool inContainer = std::filesystem::exists("/.dockerenv"); + if (inContainer) return "docker"; + #if defined(__aarch64__) + // A Raspberry Pi and every other arm64 SBC lands here, and the board breakdown exists to find + // out how many there are: answering "linux-x64" would report every one of them as a PC. + return "linux-arm64"; + #else + return "linux-x64"; + #endif +#elif defined(__APPLE__) + #if defined(__aarch64__) + return "macos-arm64"; + #else + return "macos-x64"; + #endif +#elif defined(_WIN32) + #if defined(_M_ARM64) + return "windows-arm64"; + #else + return "windows-x64"; + #endif +#else + return ""; +#endif } uint8_t currentCore() { return 0; } diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index cc3ec993..e8242fd0 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -300,6 +300,13 @@ const char* macString() { return buf; } +const char* hostPlatform() { + // Empty on a device: a board cannot self-identify, so `deviceModel` is injected by tooling from + // the catalog (MoonDeck, or the web installer over serial). Answering something here would + // overwrite a real board name with a guess. + return ""; +} + const char* chipModel() { esp_chip_info_t info; esp_chip_info(&info); diff --git a/src/platform/esp32/platform_esp32_ota.cpp b/src/platform/esp32/platform_esp32_ota.cpp index f134e425..3c808cd9 100644 --- a/src/platform/esp32/platform_esp32_ota.cpp +++ b/src/platform/esp32/platform_esp32_ota.cpp @@ -747,4 +747,28 @@ bool moonbaseStageInstallUrl(const char* url) { return ok; } -} // namespace mm::platform + +bool httpsPost(const char* url, const char* body, uint32_t timeoutMs) { + if (!url || !*url) return false; + + esp_http_client_config_t cfg = {}; + cfg.url = url; + cfg.method = HTTP_METHOD_POST; + cfg.timeout_ms = static_cast(timeoutMs); + // The SAME trust-anchor bundle the OTA path uses: IDF's built-in root certificates, so this + // verifies a public server without us shipping or maintaining a CA store. + cfg.crt_bundle_attach = esp_crt_bundle_attach; + + esp_http_client_handle_t client = esp_http_client_init(&cfg); + if (!client) return false; + + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_post_field(client, body ? body : "", body ? std::strlen(body) : 0); + + const esp_err_t err = esp_http_client_perform(client); + const int status = (err == ESP_OK) ? esp_http_client_get_status_code(client) : 0; + esp_http_client_cleanup(client); + return err == ESP_OK && status >= 200 && status < 300; +} + +} // namespace mm::platform \ No newline at end of file diff --git a/src/platform/platform.h b/src/platform/platform.h index 4eb93dbe..c993de9f 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -283,6 +283,13 @@ void getMacAddress(uint8_t mac[6]); // return static strings; a ReadOnly control binds straight to these, storing nothing per-module.) const char* macString(); const char* chipModel(); + +// The hardware this installation runs on, when the platform can answer it for itself. Empty on a +// DEVICE, where the board cannot self-identify and tooling injects `deviceModel` from the catalog. +// A desktop CAN answer: the OS is known at compile time and a container announces itself, so this +// returns the same vocabulary the release packaging uses ("macos-arm64", "linux-x64", "docker"), +// which keeps a MoonCloud board breakdown comparable with the downloads it came from. +const char* hostPlatform(); const char* sdkVersion(); // CPU frequency + core count as one short static string ("240 MHz, 2 cores"), read from the RUNNING @@ -845,6 +852,24 @@ void moonbaseClearStagedUrl(); int httpRequest(const char* method, const char* host, uint16_t port, const char* path, const char* reqBody, uint32_t timeoutMs, char* body, size_t bodyLen); +// One HTTPS POST to a public server, bounded by `timeoutMs`. Returns the HTTP status, or 0 on +// DNS/connect/TLS/timeout failure. `url` is a full https:// URL: unlike httpRequest above this +// resolves names, so a hostname works. +// +// SEPARATE from httpRequest rather than a flag on it, because they are different jobs with +// different implementations. httpRequest is a hand-rolled socket for the LAN (Philips Hue), where +// cleartext is allowed and a dotted-quad IP is the input. This one crosses the public internet, so +// it needs TLS, certificate verification and DNS, none of which we should be writing ourselves. +// +// Each platform uses the TLS ITS OS ALREADY SHIPS, so nothing is vendored: `esp_http_client` with +// `esp_crt_bundle` on ESP32 (the same pair the OTA path uses), and libcurl on desktop, which is +// present on macOS and every Linux distribution and carries its own certificate handling. +// +// Response body is discarded: the one caller (MoonCloud Stats) has nothing to do with it, and +// buffering a reply from an untrusted server is a risk with no benefit. Blocking, so callers run +// it off the render path. +bool httpsPost(const char* url, const char* body, uint32_t timeoutMs); + // Improv WiFi provisioning over UART0. // ESP32 only; desktop stub returns false. Spawns a FreeRTOS task that installs // a UART driver on UART_NUM_0 (the same channel ESP-IDF logging writes to; diff --git a/src/ui/app.js b/src/ui/app.js index 557a7072..1244d4f0 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -518,8 +518,9 @@ async function sendControl(moduleName, controlName, value) { // it writes the stale `state` value straight back into the field the user just changed (the value // "reverses"; a manual refresh shows the correct value because it refetches). The client knows what // it sent, so update `state` now; any later echo just confirms it. - if (state && Array.isArray(state.modules)) { - const mod = allModules().find(m => m.name === moduleName); + const mod = (state && Array.isArray(state.modules)) + ? allModules().find(m => m.name === moduleName) : null; + if (mod) { const ctrl = mod && Array.isArray(mod.controls) && mod.controls.find(c => c.name === controlName); if (ctrl) ctrl.value = value; // Siblings on the same target move with it. The device already does this (followTargets runs @@ -553,6 +554,23 @@ async function sendControl(moduleName, controlName, value) { // switched. The routine WS push cannot carry it: renderCards is suppressed while the user // is interacting, and operating this select is exactly that. else if (moduleName === "Firmware" && controlName === "image") refetchState(); + // MoonCloud: saying yes sends a report, and typing a message posts it, so in both cases the + // thing the card DISPLAYS changed on the server as a result of the write. Refetched after + // the POST for the same reason as `image` above: the new data only exists once the device + // has acted on it. The cached aggregates are dropped first, since a fetch that returned the + // cache would show the state from before this device contributed to it. + // A MoonCloud write changes what the server holds, and the card shows the server. One + // rule for every member rather than a hook per control: a report goes out when consent is + // granted, a message when one settles, and a later member will have its own trigger. + // + // Re-checked a few times rather than once after a guessed delay, because the device acts on + // its own tick and how long that takes is not the browser's to know: a single timeout is a + // guess that is wrong as soon as a member's timing changes, which is exactly what happened + // when messages gained a settle window. + else if (["MoonStatsModule", "MoonTalkModule"].includes(mod?.type)) { + moonCloudStatsCache = null; + for (const delay of [800, 2000, 3500]) setTimeout(refetchState, delay); + } } catch (e) { console.warn(`[control] POST ${moduleName}.${controlName} failed (error=${e && e.message ? e.message : e})`); } @@ -1931,6 +1949,19 @@ function createCard(mod, depth) { renderFileManager(mod, controlsHost); } + // MoonCloud shows what everyone else reported, which is why the server has no dashboard of its + // own: contributing earns the answer back on the card that asked for consent. Reading the + // totals sends nothing about this device, so it runs whatever `consent` says. + if (mod.type === "MoonStatsModule") { + renderMoonCloudStats(controlsHost, mod); + } + + // The board itself, under the controls that post to it: a message box with no way to read the + // replies is half a chat. + if (mod.type === "MoonTalkModule") { + renderMoonTalk(controlsHost, mod); + } + // FirmwareUpdate card hosts the shared install picker. Mount once per // card-build. The picker reads SystemModule.firmware (already in // /api/state) to filter to OTA-compatible releases. On install, the @@ -5605,8 +5636,17 @@ function updateStatusBar() { // which is not useful from the UI and can be mistaken for a crash. const chipCtrl = ctrls.find(c => c.name === "chip"); const rebootBtn = document.getElementById("reboot-btn"); - if (rebootBtn && chipCtrl) { - rebootBtn.hidden = chipCtrl.value === "desktop"; + if (rebootBtn) { + // Hidden on a desktop, where `reboot()` EXITS THE PROCESS and nothing restarts it. Keyed on + // `deviceModel`, which the desktop platform seeds with its own name (macos-arm64, + // linux-x64, windows-x64, docker) and a board leaves for tooling to fill from the catalog. + // + // NOT on `chip`: that used to read "desktop" and now reads the real architecture (arm64, + // x64), so the old test silently stopped matching and put a process-killing button on every + // desktop card. + const modelCtrl = ctrls.find(c => c.name === "deviceModel"); + const model = String(modelCtrl?.value ?? ""); + rebootBtn.hidden = /^(macos|linux|windows)-|^docker$/.test(model); } // bootReason → crashed-state styling on reboot button @@ -5937,6 +5977,274 @@ async function fmFetchDir(absPath, hidden) { // shape: an expanded folder's children are loaded from /api/dir), plus a toolbar (show // hidden, new folder, delete on the selected node). Filesystem ops go through the module's controls // (path/new folder/delete); browsing is pure UI over /api/dir, so the module stays minimal. +// MoonCloud Stats: what everyone else reported, rendered on the card that asked for consent. +// +// THE REASON THE SERVER HAS NO DASHBOARD. A second web UI would be a second thing to build, host +// and keep in sync with the schema, and it would put the answer somewhere other than where the +// question was asked. Here, contributing earns the result back on the device you already own. +// +// Reads only: this sends no id, no report and no configuration, so it runs whatever `consent` +// says, including for someone who declined. A failure leaves the section out rather than showing +// an error, because the aggregate is a reward and not something the device needs to work. +// The MoonCloud server is an API and nothing else: the device asks it for the aggregates and +// draws them here. That is the whole reason it has no dashboard of its own, and why contributing +// shows its result on the card that asked for consent rather than on a website somewhere. +// +// Reads only. No id, no report and no configuration leave the browser here, so this runs whatever +// `consent` says, including for someone who answered Never. A failure leaves the section out +// rather than showing an error: the aggregate is a reward, not something the device needs. +// Where the aggregates come from: the SAME `server` / `serverPort` controls the firmware posts +// its report to, read off the module rather than hardcoded here. One setting drives both +// directions, so pointing a device at a local or self-hosted MoonCloud moves the fetch with it +// instead of leaving the card looking at somewhere else. +// THE MoonCloud address, the same constant the firmware compiles in. Not read from a control: +// there is one MoonCloud, and moving it is a release rather than a setting somebody can mistype. +const kMoonCloudUrl = "https://stats.moonmodules.org"; + +let moonCloudStatsCache = null; + +const kMoonCloudColors = ["#4a9eff", "#ff8c42", "#3ecf8e", "#e8618c", "#a78bfa", + "#f6c445", "#4dd0e1", "#9aa5b1"]; + +// An SVG arc from one angle to another. The whole-circle case is separate because an arc whose +// start and end coincide draws NOTHING, so a single-slice pie would come out blank. +function moonCloudArc(cx, cy, r, a0, a1) { + if (a1 - a0 >= Math.PI * 2 - 1e-9) { + return `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx - 0.001} ${cy - r} Z`; + } + const x0 = cx + r * Math.sin(a0), y0 = cy - r * Math.cos(a0); + const x1 = cx + r * Math.sin(a1), y1 = cy - r * Math.cos(a1); + return `M ${cx} ${cy} L ${x0} ${y0} ` + + `A ${r} ${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x1} ${y1} Z`; +} + +// Past the 7th slice everything becomes one "other" wedge: a pie with twenty slivers reads as +// noise, and the exact tail is a question for the raw API rather than a card. +function moonCloudTopSlices(rows, keep = 7) { + if (!rows || rows.length <= keep) return rows || []; + const head = rows.slice(0, keep); + const tail = rows.slice(keep).reduce((sum, r) => sum + r.count, 0); + return tail ? head.concat([{ name: "other", count: tail }]) : head; +} + +function moonCloudPie(rows) { + const NS = "http://www.w3.org/2000/svg"; + const total = rows.reduce((sum, r) => sum + r.count, 0) || 1; + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("viewBox", "0 0 200 200"); + svg.setAttribute("class", "mooncloud-pie"); + + let angle = 0; + rows.forEach((r, i) => { + const next = angle + (r.count / total) * Math.PI * 2; + const path = document.createElementNS(NS, "path"); + path.setAttribute("d", moonCloudArc(100, 100, 92, angle, next)); + path.setAttribute("fill", kMoonCloudColors[i % kMoonCloudColors.length]); + path.setAttribute("class", "mooncloud-slice"); + const title = document.createElementNS(NS, "title"); + title.textContent = `${r.name}: ${r.count} (${Math.round((r.count / total) * 100)}%)`; + path.appendChild(title); + svg.appendChild(path); + angle = next; + }); + return svg; +} + +function moonCloudLegend(rows) { + const box = document.createElement("div"); + box.className = "mooncloud-legend"; + rows.forEach((r, i) => { + const line = document.createElement("div"); + const swatch = document.createElement("span"); + swatch.className = "mooncloud-swatch"; + swatch.style.background = kMoonCloudColors[i % kMoonCloudColors.length]; + const name = document.createElement("span"); + name.className = "mooncloud-legend-name"; + name.textContent = r.name || "unknown"; + const count = document.createElement("span"); + count.className = "mooncloud-legend-count"; + count.textContent = String(r.count); + line.append(swatch, name, count); + box.appendChild(line); + }); + return box; +} + +// What the consent control is asking, in one line above it. +// +// NOT a prompt with its own Yes/Not now/Never buttons: the `consent` dropdown already offers +// exactly those, so a button row beside it is the same choice rendered twice and costs a block of +// the card to say nothing new. What the dropdown cannot carry is WHY, so that is what stays. +// +// Shown only while the question is unanswered, so a card whose owner has decided is not still +// explaining itself. +function renderMoonCloudConsent(host, mod) { + const consent = (mod?.controls || []).find(c => c.name === "consent"); + if (!consent || Number(consent.value) !== 0) return; // 0 = Not answered + + const box = document.createElement("div"); + box.className = "mooncloud-consent"; + box.textContent = "Share what hardware you run, so development goes where the users are? " + + "One report per install or upgrade: chip, board, and which modules are on. " + + "No device name, no addresses, no credentials. "; + + const link = document.createElement("a"); + link.href = "https://moonmodules.org/projectMM/privacy-policy.html"; + link.target = "_blank"; + link.rel = "noopener"; + link.textContent = "Details"; + box.appendChild(link); + + host.appendChild(box); +} + +function renderMoonCloudStats(host, mod) { + renderMoonCloudConsent(host, mod); + const section = document.createElement("div"); + section.className = "mooncloud-stats"; + host.appendChild(section); + + const draw = (stats) => { + section.textContent = ""; + if (!stats || !stats.installations) return; + + const title = document.createElement("div"); + title.className = "mooncloud-stats-title"; + title.textContent = + `What everyone else is running: ${stats.installations} installations`; + + section.appendChild(title); + + const grid = document.createElement("div"); + grid.className = "mooncloud-charts"; + + // The four the server aggregates. Country is included even though a device never reports + // it (the edge derives it from the connection): withholding it here while serving it to + // anyone who calls /api/stats would be a distinction without a difference, and a + // country-level count is the same coarse figure every comparable dashboard shows. + for (const [label, rows] of [["Version", stats.versions], + ["Chip", stats.chips], + ["Board", stats.deviceModels], + ["Flash", stats.flash], + ["PSRAM", stats.psram], + ["SDK", stats.sdk], + ["Install or upgrade", stats.events], + ["Upgraded from", stats.previousVersions], + ["Modules", stats.modules], + ["Build", stats.builds], + ["Country", stats.countries]]) { + const shown = moonCloudTopSlices(rows); + if (!shown.length) continue; + const chart = document.createElement("div"); + chart.className = "mooncloud-chart"; + const heading = document.createElement("div"); + heading.className = "mooncloud-chart-title"; + heading.textContent = label; + chart.append(heading, moonCloudPie(shown), moonCloudLegend(shown)); + grid.appendChild(chart); + } + section.appendChild(grid); + }; + + // Cached for the session: these numbers move on the scale of days, and a card rebuild happens + // on every unrelated state push. + if (moonCloudStatsCache) { draw(moonCloudStatsCache); return; } + + fetch(kMoonCloudUrl + "/api/stats", { cache: "no-store" }) + .then(r => r.ok ? r.json() : null) + .then(stats => { moonCloudStatsCache = stats; draw(stats); }) + .catch(() => { /* no section: the card is complete without it */ }); +} + +// MoonTalk: the public board, read straight onto the card that posts to it. +// +// Reading sends NOTHING about this device: no id, no name, no consent involved. That is why this +// renders whatever `consent` says, including for someone who answered Never: the board is public, +// and refusing to publish is not a reason to be unable to read. +// +// Polled on card build rather than streamed. A chat wants to feel live, but a device UI rebuilds +// this card on every unrelated state push, so a socket per card would be a connection per render. +// The refresh button is the honest version until MoonCloud Sync brings a real transport. +function renderMoonTalk(host, mod) { + const section = document.createElement("div"); + section.className = "moontalk"; + host.appendChild(section); + + const header = document.createElement("div"); + header.className = "moontalk-header"; + const title = document.createElement("span"); + title.textContent = "Messages"; + const refresh = document.createElement("button"); + refresh.className = "moontalk-refresh"; + refresh.textContent = "\u21bb"; + refresh.title = "Refresh"; + header.append(title, refresh); + + const list = document.createElement("div"); + list.className = "moontalk-list"; + section.append(header, list); + + const draw = (messages) => { + list.textContent = ""; + if (!messages || !messages.length) { + const empty = document.createElement("div"); + empty.className = "moontalk-empty"; + empty.textContent = "No messages yet."; + list.appendChild(empty); + return; + } + // Oldest at the top, like every chat: the API returns newest first for paging. + for (const m of messages.slice().reverse()) { + const row = document.createElement("div"); + row.className = "moontalk-message"; + + const who = document.createElement("span"); + // `named` says whether the sender consented to share a device name. Without it the + // server sends the first 8 characters of the installation id, shown in a monospace + // face so it reads as an identifier rather than as somebody's name. + who.className = m.named ? "moontalk-from" : "moontalk-from moontalk-from-id"; + who.textContent = m.from; + // A named sender keeps its id in brackets: a device name is whatever someone typed, so + // two devices can share one, and the id is what tells them apart. + if (m.named && m.senderId) { + const id = document.createElement("span"); + id.className = "moontalk-from-id"; + id.textContent = `(${m.senderId})`; + who.appendChild(document.createTextNode(" ")); + who.appendChild(id); + } + + const text = document.createElement("span"); + text.className = "moontalk-text"; + text.textContent = m.text; // textContent, never innerHTML: this is a stranger's text + + const when = document.createElement("span"); + when.className = "moontalk-when"; + when.textContent = new Date(m.sentAt).toLocaleTimeString([], { + hour: "2-digit", minute: "2-digit", + }); + when.title = new Date(m.sentAt).toLocaleString(); + + row.append(who, text, when); + list.appendChild(row); + } + }; + + // Incremental: ask only for what this browser has not seen, and keep what it has. A card is + // rebuilt on every unrelated state push, so re-fetching the whole board each time costs 6 KB a + // rebuild on a device serving its own UI, where an empty answer costs 15 bytes. + // + // The cache is per session and per board, so switching servers starts clean. + const load = () => { + fetch(kMoonCloudUrl + "/api/talk", { cache: "no-store" }) + .then(r => r.ok ? r.json() : null) + .then(d => draw(d?.messages)) + .catch(() => draw(null)); + }; + refresh.addEventListener("click", load); + load(); +} + function renderFileManager(mod, host) { const ctrl = (n) => (mod.controls || []).find(c => c.name === n); const st = fmState(mod); diff --git a/src/ui/style.css b/src/ui/style.css index 22052d16..caaec935 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -2146,3 +2146,39 @@ body.assign-mode .control-encoder input { cursor: pointer; } /* One row per bank, whatever the width: without this the strips are inline-flex siblings that wrap as soon as eight of them do not fit. */ .card:has(.control-display-strip) .surface-break { break-after: always; } + +/* MoonCloud Stats: the aggregates, drawn as pie charts on the card that asked for consent. Plain + SVG arcs rather than a charting library: three pies do not earn a dependency, and the device + serves its own UI with no build step to tree-shake one. */ +.mooncloud-stats { margin-top: 12px; } +.mooncloud-stats-title { font-size: 0.85em; opacity: 0.75; margin-bottom: 10px; } +.mooncloud-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 16px; } +.mooncloud-chart { text-align: center; } +.mooncloud-chart-title { font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.55; margin-bottom: 6px; } +.mooncloud-pie { width: 100%; max-width: 8rem; height: auto; } +.mooncloud-slice { stroke: var(--card-bg, #1b1b1b); stroke-width: 1.5; } +.mooncloud-legend { display: inline-block; text-align: left; margin-top: 6px; font-size: 0.75em; } +.mooncloud-legend div { display: flex; align-items: center; gap: 5px; line-height: 1.6; } +.mooncloud-swatch { width: 0.6rem; height: 0.6rem; border-radius: 2px; flex: 0 0 auto; } +.mooncloud-legend-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mooncloud-legend-count { margin-left: auto; opacity: 0.6; padding-left: 8px; font-variant-numeric: tabular-nums; } + +/* MoonTalk: the public board on the card that posts to it. Rows rather than bubbles: this is a + device panel, not a phone, and a sender plus a line of text is the whole content. */ +.moontalk { margin-top: 12px; } +.moontalk-header { display: flex; align-items: center; font-size: 0.85em; opacity: 0.75; margin-bottom: 6px; } +.moontalk-refresh { margin-left: auto; background: none; border: none; color: inherit; cursor: pointer; font-size: 1.1em; opacity: 0.6; padding: 0 4px; } +.moontalk-refresh:hover { opacity: 1; } +.moontalk-list { max-height: 15rem; overflow-y: auto; } +.moontalk-message { display: flex; align-items: baseline; gap: 6px; font-size: 0.8em; line-height: 1.8; } +.moontalk-from { flex: 0 0 auto; font-weight: 600; opacity: 0.9; } +/* An unnamed sender is an id, so it reads as one rather than as somebody's name. */ +.moontalk-from-id { font-family: ui-monospace, monospace; font-weight: 400; opacity: 0.55; } +.moontalk-text { flex: 1 1 auto; overflow-wrap: anywhere; } +.moontalk-when { flex: 0 0 auto; opacity: 0.45; font-variant-numeric: tabular-nums; } +.moontalk-empty { opacity: 0.5; font-size: 0.8em; } + +/* What the MoonCloud consent control is asking, one line above it. No buttons: the `consent` + dropdown already offers the same choices, and rendering them twice costs card space to say + nothing new. */ +.mooncloud-consent { margin: 6px 0 10px; font-size: 0.8em; line-height: 1.6; opacity: 0.8; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 5841a6ba..ed54b48d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -74,6 +74,11 @@ add_executable(mm_tests unit/core/unit_platform_worker.cpp unit/core/unit_IpList.cpp unit/core/unit_Scheduler_unique_names.cpp + unit/core/unit_MoonStatsModule.cpp + unit/core/unit_MoonTalkModule.cpp + unit/core/unit_InstallationId.cpp + unit/core/unit_sha256.cpp + unit/core/unit_MoonStatsReport.cpp unit/core/unit_SystemModule.cpp unit/core/unit_Services.cpp diff --git a/test/js/mooncloud-report.test.mjs b/test/js/mooncloud-report.test.mjs new file mode 100644 index 00000000..83964bfc --- /dev/null +++ b/test/js/mooncloud-report.test.mjs @@ -0,0 +1,112 @@ +// MoonCloud Stats server contract: what reaches storage, and what cannot. +// +// The report endpoint is open to anyone, so its input is untrusted twice over: a field the +// firmware never sends can still arrive, and a field it does send can be any length. These tests +// pin the two rules that keep the privacy policy true on the server side as well as the device +// side: only allowlisted fields are stored, and the country is derived at the edge rather than +// from anything the caller supplies. +// +// Run: `node --test test/js`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const source = readFileSync(new URL("../../mooncloud/worker.js", import.meta.url), "utf8"); + +// The worker is an ES module written for the Workers runtime, so it is exercised here by pulling +// the pure helpers out of it rather than by booting a fake runtime: `clean` is where every +// storage decision is made, and it is the thing worth pinning. +const cleanSource = source.slice( + source.indexOf("function clean("), + source.indexOf("async function handleReport") +); +const ALLOWED = JSON.parse( + source.slice(source.indexOf("const ALLOWED = ["), source.indexOf("];") + 1) + .replace("const ALLOWED = ", "") + .replace(/,(\s*)\]/, "]") + .replace(/\/\/.*$/gm, "") +); +const clean = new Function( + "ALLOWED", "MAX_STRING", "MAX_MODULES", + `${cleanSource}; return clean;` +)(ALLOWED, 64, 64); + +test("a report cannot smuggle a field the firmware never sends", () => { + const row = clean( + { + installationId: "c26086e8da9b6fb2d4b81e4ca71f97e5", + chip: "ESP32-S3", + // None of these are in the allowlist. A device would never send them; someone posting by + // hand might, and the server must not create columns for them. + deviceName: "ewoud-livingroom", + mac: "2A:DA:B7:C6:A0:94", + ssid: "Travelrouter", + password: "hunter2", + latitude: "52.37", + }, + "NL" + ); + + assert.equal(row.deviceName, undefined); + assert.equal(row.mac, undefined); + assert.equal(row.ssid, undefined); + assert.equal(row.password, undefined); + assert.equal(row.latitude, undefined); + assert.equal(row.chip, "ESP32-S3"); +}); + +test("the country comes from the edge, never from the report", () => { + // A caller claiming a different country must not be believed: the value is the one Cloudflare + // resolved, and the whole reason it is trustworthy is that no address was stored to derive it. + const row = clean({ installationId: "x".repeat(32), country: "AQ" }, "NL"); + assert.equal(row.country, "NL"); +}); + +test("an unknown country is recorded rather than left empty", () => { + const row = clean({ installationId: "x".repeat(32) }, undefined); + assert.equal(row.country, "??"); +}); + +test("the received date is a day, not a moment", () => { + // A timestamp precise to the second, combined with a country, is a fingerprint. A date is not. + const row = clean({ installationId: "x".repeat(32) }, "NL"); + assert.match(row.receivedAt, /^\d{4}-\d{2}-\d{2}$/); +}); + +test("oversized values are dropped rather than truncated into storage", () => { + const row = clean( + { installationId: "x".repeat(32), chip: "E".repeat(500) }, + "NL" + ); + assert.equal(row.chip, undefined); +}); + +test("the module list is bounded and flattened", () => { + const row = clean( + { + installationId: "x".repeat(32), + modules: ["System", "Network", ...Array(200).fill("Filler")], + }, + "NL" + ); + assert.equal(row.modules.split(",").length, 64); + assert.ok(row.modules.startsWith("System,Network")); +}); + +test("a non-string sneaking into the module list is dropped", () => { + const row = clean( + { installationId: "x".repeat(32), modules: ["System", 42, null, "Network"] }, + "NL" + ); + assert.equal(row.modules, "System,Network"); +}); + +test("the stats endpoint selects only aggregates, never a row", () => { + // The endpoint is world-readable, so a query that returned installationId would publish exactly + // the identifier the privacy policy promises to hold rather than show. + const stats = source.slice(source.indexOf("async function handleStats")); + assert.ok(stats.includes("COUNT(DISTINCT installationId)")); + assert.ok(!/SELECT\s+installationId/i.test(stats)); + assert.ok(!/SELECT\s+\*/i.test(stats)); +}); diff --git a/test/js/ui-mooncloud.test.mjs b/test/js/ui-mooncloud.test.mjs new file mode 100644 index 00000000..43e2479d --- /dev/null +++ b/test/js/ui-mooncloud.test.mjs @@ -0,0 +1,78 @@ +// MoonCloud card contracts, pinned in the source the way ui-visibility does. +// +// Every MoonCloud bug found by hand tonight lived in app.js, not in the firmware: the C++ tests +// were right that the device posted the message and cleared its control, while the browser showed +// stale text and a stale board. That is the class these pin. +// +// Run: `node --test test/js`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const app = readFileSync(join(ROOT, "src", "ui", "app.js"), "utf8"); + +test("MoonTalk adds no special case to the shared control path", () => { + // MoonTalk is a proof of the plumbing a later member reuses, so it earns its place by adding + // NOTHING to the UI. An earlier shape carried twelve special cases here: two module-scoped + // globals, a control renderer branch keyed on the literal control name, an incremental message + // cache and a reload callback. Each was a workaround for the one before it, and each broke the + // next thing. A second member has to be able to follow the same path. + for (const leak of ["moonTalkCache", "moonTalkReload", 'ctrl.name === "message"']) { + assert.ok(!app.includes(leak), + `${leak} is a MoonTalk special case in the shared UI`); + } +}); + +test("one refresh rule serves every MoonCloud member", () => { + // Consent granted sends a report, a settled message posts one, and a later member will have its + // own trigger: all three change what the server holds, and the card shows the server. A hook per + // control was two copies of one idea with two different guessed delays, and the message one was + // already wrong once messages gained a settle window. + const i = app.indexOf('"MoonStatsModule", "MoonTalkModule"'); + assert.ok(i > 0, "no shared MoonCloud refresh rule in sendControl"); + const rule = app.slice(i, i + 400); + assert.ok(rule.includes("refetchState"), "the rule must refresh the card"); + assert.ok(!/\.value\s*=\s*""/.test(rule), "it must not reach into the DOM"); + + // Re-checked rather than timed once: how long the device takes to act is its own business, and + // a single delay is a guess that goes stale the moment a member's timing changes. + assert.match(rule, /for \(const delay of \[/, + "the refresh must be retried rather than fired once after a guessed delay"); + + // And the per-control hooks are gone. + assert.ok(!app.includes('controlName === "message"'), + "no message-specific refresh hook"); + assert.ok(!app.includes('moduleName === "Stats" && controlName === "consent"'), + "no consent-specific refresh hook"); +}); + +test("both MoonCloud fetches use one compiled-in address", () => { + // There is ONE MoonCloud, so its address is a constant rather than a control. A field on the + // card invited editing a value whose only correct setting is the default, and a typo there + // means reports vanish silently, because a failed report is never retried. Moving the server + // is a release. + assert.ok(app.includes("const kMoonCloudUrl ="), "the address must be a single constant"); + for (const path of ["/api/stats", "/api/talk"]) { + assert.ok(app.includes(`kMoonCloudUrl + "${path}"`), + `${path} must be built from that constant`); + } + // And nothing reads it from state any more. + assert.ok(!app.includes("moonCloudBase("), "the per-container resolver is gone"); + assert.ok(!app.includes("moonTalkUrl("), "the per-child resolver is gone"); +}); + +test("consent is asked once, above the control, with no duplicate buttons", () => { + // The consent dropdown already offers Yes / Not now / Never, so a button row beside it is the + // same choice rendered twice and costs a block of the card to say nothing new. + const i = app.indexOf("function renderMoonCloudConsent("); + assert.ok(i > 0, "no consent renderer"); + const fn = app.slice(i, i + 1600); + assert.ok(fn.includes('Number(consent.value) !== 0'), + "the explanation shows only while the question is unanswered"); + assert.ok(!fn.includes("mooncloud-consent-buttons"), + "no second set of consent buttons beside the dropdown"); +}); diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json index f8c8d51a..a47237a3 100644 --- a/test/scenarios/core/scenario_MoonModule_control_change.json +++ b/test/scenarios/core/scenario_MoonModule_control_change.json @@ -117,14 +117,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 125, + "p50": 124, "p95": 246, "min": 117, "max": 302, "n": 32, - "samples": [179, 191, 127, 202, 119, 121, 118, 120, 199, 193, 117, 144, 302, 129, 118, 246, 120, 133, 122, 121, 122, 126, 124, 118, 118, 120, 123, 196, 125, 129, 130, 144] + "samples": [127, 202, 119, 121, 118, 120, 199, 193, 117, 144, 302, 129, 118, 246, 120, 133, 122, 121, 122, 126, 124, 118, 118, 120, 123, 196, 125, 129, 130, 144, 160, 120] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth-wifi": { "tick_us": { @@ -299,14 +299,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 125, - "p95": 182, + "p50": 124, + "p95": 146, "min": 117, "max": 260, "n": 32, - "samples": [182, 135, 128, 132, 119, 120, 118, 118, 131, 134, 117, 133, 135, 146, 121, 260, 123, 118, 120, 124, 123, 137, 125, 118, 124, 120, 123, 132, 126, 130, 131, 145] + "samples": [128, 132, 119, 120, 118, 118, 131, 134, 117, 133, 135, 146, 121, 260, 123, 118, 120, 124, 123, 137, 125, 118, 124, 120, 123, 132, 126, 130, 131, 145, 123, 120] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth-wifi": { "tick_us": { @@ -482,13 +482,13 @@ "desktop-macos": { "tick_us": { "p50": 122, - "p95": 152, + "p95": 136, "min": 116, - "max": 180, + "max": 152, "n": 32, - "samples": [180, 126, 128, 118, 121, 121, 118, 116, 116, 122, 117, 122, 129, 122, 121, 152, 122, 120, 120, 126, 123, 128, 123, 120, 126, 120, 124, 117, 136, 130, 130, 125] + "samples": [128, 118, 121, 121, 118, 116, 116, 122, 117, 122, 129, 122, 121, 152, 122, 120, 120, 126, 123, 128, 123, 120, 126, 120, 124, 117, 136, 130, 130, 125, 118, 118] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth-wifi": { "tick_us": { @@ -671,14 +671,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 122, - "p95": 182, + "p50": 121, + "p95": 151, "min": 117, "max": 201, "n": 32, - "samples": [182, 127, 128, 120, 119, 120, 118, 118, 120, 123, 117, 122, 141, 124, 121, 151, 122, 119, 121, 123, 121, 129, 120, 129, 126, 118, 120, 120, 201, 129, 129, 125] + "samples": [128, 120, 119, 120, 118, 118, 120, 123, 117, 122, 141, 124, 121, 151, 122, 119, 121, 123, 121, 129, 120, 129, 126, 118, 120, 120, 201, 129, 129, 125, 121, 126] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth-wifi": { "tick_us": { diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json index d6e813cf..5ce1b217 100644 --- a/test/scenarios/light/scenario_Audio_mutation.json +++ b/test/scenarios/light/scenario_Audio_mutation.json @@ -109,9 +109,9 @@ "min": 16, "max": 26, "n": 32, - "samples": [20, 16, 20, 20, 17, 17, 16, 16, 17, 17, 17, 19, 26, 16, 17, 25, 19, 20, 20, 17, 20, 22, 17, 17, 17, 20, 20, 16, 21, 19, 19, 17] + "samples": [20, 20, 17, 17, 16, 16, 17, 17, 17, 19, 26, 16, 17, 25, 19, 20, 20, 17, 20, 22, 17, 17, 17, 20, 20, 16, 21, 19, 19, 17, 16, 17] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -206,9 +206,9 @@ "min": 17, "max": 216, "n": 32, - "samples": [22, 17, 17, 19, 28, 27, 22, 17, 19, 20, 37, 22, 28, 24, 137, 27, 17, 20, 19, 39, 20, 31, 22, 216, 36, 21, 18, 18, 43, 20, 41, 23] + "samples": [17, 19, 28, 27, 22, 17, 19, 20, 37, 22, 28, 24, 137, 27, 17, 20, 19, 39, 20, 31, 22, 216, 36, 21, 18, 18, 43, 20, 41, 23, 18, 67] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -316,13 +316,13 @@ "desktop-macos": { "tick_us": { "p50": 21, - "p95": 56, - "min": 17, + "p95": 65, + "min": 18, "max": 108, "n": 32, - "samples": [20, 17, 18, 20, 19, 25, 18, 18, 19, 19, 21, 26, 28, 30, 40, 32, 26, 18, 20, 36, 21, 25, 21, 108, 24, 20, 19, 18, 37, 20, 56, 23] + "samples": [18, 20, 19, 25, 18, 18, 19, 19, 21, 26, 28, 30, 40, 32, 26, 18, 20, 36, 21, 25, 21, 108, 24, 20, 19, 18, 37, 20, 56, 23, 21, 65] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -412,14 +412,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 23, + "p50": 24, "p95": 61, "min": 19, "max": 62, "n": 32, - "samples": [23, 20, 21, 20, 21, 31, 20, 20, 21, 19, 22, 24, 28, 31, 44, 29, 29, 21, 24, 30, 24, 30, 22, 61, 25, 23, 22, 19, 27, 22, 62, 25] + "samples": [21, 20, 21, 31, 20, 20, 21, 19, 22, 24, 28, 31, 44, 29, 29, 21, 24, 30, 24, 30, 22, 61, 25, 23, 22, 19, 27, 22, 62, 25, 21, 47] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -512,9 +512,9 @@ "min": 19, "max": 74, "n": 32, - "samples": [21, 21, 21, 20, 22, 27, 20, 21, 19, 19, 21, 30, 28, 22, 29, 36, 19, 21, 23, 25, 21, 21, 19, 74, 22, 23, 27, 19, 25, 24, 37, 25] + "samples": [21, 20, 22, 27, 20, 21, 19, 19, 21, 30, 28, 22, 29, 36, 19, 21, 23, 25, 21, 21, 19, 74, 22, 23, 27, 19, 25, 24, 37, 25, 20, 28] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -602,14 +602,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 20, - "p95": 30, + "p50": 19, + "p95": 33, "min": 16, "max": 57, "n": 32, - "samples": [26, 20, 19, 19, 17, 17, 17, 16, 17, 17, 21, 17, 25, 17, 20, 24, 19, 17, 20, 22, 21, 19, 17, 57, 21, 20, 20, 17, 24, 20, 30, 22] + "samples": [19, 19, 17, 17, 17, 16, 17, 17, 21, 17, 25, 17, 20, 24, 19, 17, 20, 22, 21, 19, 17, 57, 21, 20, 20, 17, 24, 20, 30, 22, 17, 33] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Aurora_fps.json b/test/scenarios/light/scenario_Aurora_fps.json index 6110cc6f..99b245e3 100644 --- a/test/scenarios/light/scenario_Aurora_fps.json +++ b/test/scenarios/light/scenario_Aurora_fps.json @@ -84,14 +84,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 871, + "p50": 879, "p95": 1751, - "min": 803, + "min": 827, "max": 2054, "n": 32, - "samples": [803, 855, 835, 843, 890, 827, 841, 871, 845, 856, 1391, 863, 858, 885, 1527, 1317, 853, 859, 861, 1047, 962, 1024, 870, 1427, 907, 879, 833, 937, 1314, 1009, 2054, 1751] + "samples": [835, 843, 890, 827, 841, 871, 845, 856, 1391, 863, 858, 885, 1527, 1317, 853, 859, 861, 1047, 962, 1024, 870, 1427, 907, 879, 833, 937, 1314, 1009, 2054, 1751, 859, 1266] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -114,14 +114,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 322, + "p50": 334, "p95": 607, - "min": 311, + "min": 312, "max": 1465, "n": 32, - "samples": [311, 320, 316, 318, 317, 317, 321, 334, 312, 322, 355, 335, 322, 495, 395, 449, 317, 318, 317, 431, 362, 359, 340, 522, 317, 318, 315, 361, 607, 374, 1465, 489] + "samples": [316, 318, 317, 317, 321, 334, 312, 322, 355, 335, 322, 495, 395, 449, 317, 318, 317, 431, 362, 359, 340, 522, 317, 318, 315, 361, 607, 374, 1465, 489, 316, 442] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -144,14 +144,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 192, + "p50": 193, "p95": 406, - "min": 183, + "min": 185, "max": 429, "n": 32, - "samples": [183, 189, 187, 192, 186, 185, 188, 196, 187, 187, 254, 187, 188, 222, 232, 245, 186, 188, 185, 201, 205, 212, 203, 406, 185, 185, 193, 212, 429, 221, 331, 275] + "samples": [187, 192, 186, 185, 188, 196, 187, 187, 254, 187, 188, 222, 232, 245, 186, 188, 185, 201, 205, 212, 203, 406, 185, 185, 193, 212, 429, 221, 331, 275, 186, 285] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -174,14 +174,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 554, + "p50": 560, "p95": 1037, - "min": 532, + "min": 540, "max": 1419, "n": 32, - "samples": [532, 554, 540, 544, 551, 551, 554, 581, 543, 554, 560, 550, 553, 567, 702, 720, 550, 545, 552, 583, 570, 599, 637, 1419, 542, 542, 578, 620, 1011, 631, 1037, 772] + "samples": [540, 544, 551, 551, 554, 581, 543, 554, 560, 550, 553, 567, 702, 720, 550, 545, 552, 583, 570, 599, 637, 1419, 542, 542, 578, 620, 1011, 631, 1037, 772, 541, 642] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -206,12 +206,12 @@ "tick_us": { "p50": 964, "p95": 1647, - "min": 919, + "min": 933, "max": 2085, "n": 32, - "samples": [919, 974, 933, 936, 938, 945, 964, 997, 937, 956, 1236, 950, 953, 952, 1158, 1152, 946, 1029, 970, 1026, 955, 1046, 996, 1647, 938, 936, 934, 1055, 1373, 1070, 2085, 1253] + "samples": [933, 936, 938, 945, 964, 997, 937, 956, 1236, 950, 953, 952, 1158, 1152, 946, 1029, 970, 1026, 955, 1046, 996, 1647, 938, 936, 934, 1055, 1373, 1070, 2085, 1253, 934, 1063] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -234,14 +234,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1490, + "p50": 1493, "p95": 2116, - "min": 1423, + "min": 1430, "max": 2727, "n": 32, - "samples": [1423, 1465, 1430, 1562, 1455, 1458, 1483, 2116, 1446, 1497, 1493, 1472, 1467, 1490, 1853, 1666, 1473, 1474, 1871, 1512, 1472, 1672, 1547, 1784, 1445, 1443, 1441, 1616, 1865, 1631, 2727, 1797] + "samples": [1430, 1562, 1455, 1458, 1483, 2116, 1446, 1497, 1493, 1472, 1467, 1490, 1853, 1666, 1473, 1474, 1871, 1512, 1472, 1672, 1547, 1784, 1445, 1443, 1441, 1616, 1865, 1631, 2727, 1797, 1431, 1894] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -264,14 +264,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1556, + "p50": 1564, "p95": 2013, "min": 1488, "max": 4090, "n": 32, - "samples": [1511, 1506, 1491, 1494, 1498, 1506, 1574, 1920, 1494, 1564, 1543, 1622, 1526, 1556, 1761, 1695, 1534, 1514, 1828, 1601, 1518, 1642, 4090, 1584, 1505, 1488, 1490, 1685, 1792, 1669, 2013, 1785] + "samples": [1491, 1494, 1498, 1506, 1574, 1920, 1494, 1564, 1543, 1622, 1526, 1556, 1761, 1695, 1534, 1514, 1828, 1601, 1518, 1642, 4090, 1584, 1505, 1488, 1490, 1685, 1792, 1669, 2013, 1785, 1525, 1655] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -294,14 +294,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1131, - "p95": 1351, - "min": 1079, + "p50": 1135, + "p95": 1443, + "min": 1087, "max": 2118, "n": 32, - "samples": [1079, 1108, 1092, 1096, 1104, 1118, 1165, 1211, 1103, 1135, 1128, 1137, 1120, 1131, 1303, 1210, 1117, 1118, 1105, 1167, 1161, 1167, 2118, 1144, 1111, 1097, 1110, 1257, 1325, 1214, 1351, 1260] + "samples": [1092, 1096, 1104, 1118, 1165, 1211, 1103, 1135, 1128, 1137, 1120, 1131, 1303, 1210, 1117, 1118, 1105, 1167, 1161, 1167, 2118, 1144, 1111, 1097, 1110, 1257, 1325, 1214, 1351, 1260, 1087, 1443] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } } diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json index 6d465c49..5df4ab07 100644 --- a/test/scenarios/light/scenario_Driver_mutation.json +++ b/test/scenarios/light/scenario_Driver_mutation.json @@ -77,13 +77,13 @@ "desktop-macos": { "tick_us": { "p50": 18, - "p95": 32, + "p95": 21, "min": 16, - "max": 43, + "max": 32, "n": 32, - "samples": [43, 17, 21, 17, 20, 20, 17, 19, 20, 19, 18, 21, 16, 19, 19, 17, 19, 18, 20, 18, 17, 17, 32, 17, 16, 17, 17, 18, 18, 17, 18, 18] + "samples": [21, 17, 20, 20, 17, 19, 20, 19, 18, 21, 16, 19, 19, 17, 19, 18, 20, 18, 17, 17, 32, 17, 16, 17, 17, 18, 18, 17, 18, 18, 20, 21] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -174,13 +174,13 @@ "desktop-macos": { "tick_us": { "p50": 20, - "p95": 29, + "p95": 27, "min": 16, - "max": 45, + "max": 29, "n": 32, - "samples": [45, 17, 22, 17, 20, 20, 17, 27, 20, 20, 20, 23, 16, 20, 20, 18, 20, 20, 20, 23, 17, 17, 29, 17, 17, 17, 20, 18, 18, 17, 18, 18] + "samples": [22, 17, 20, 20, 17, 27, 20, 20, 20, 23, 16, 20, 20, 18, 20, 20, 20, 23, 17, 17, 29, 17, 17, 17, 20, 18, 18, 17, 18, 18, 20, 21] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -271,13 +271,13 @@ "desktop-macos": { "tick_us": { "p50": 20, - "p95": 37, + "p95": 24, "min": 16, - "max": 46, + "max": 37, "n": 32, - "samples": [46, 21, 18, 20, 20, 20, 20, 24, 20, 20, 21, 17, 16, 20, 19, 17, 20, 20, 20, 21, 18, 17, 37, 17, 20, 19, 20, 18, 18, 17, 18, 18] + "samples": [18, 20, 20, 20, 20, 24, 20, 20, 21, 17, 16, 20, 19, 17, 20, 20, 20, 21, 18, 17, 37, 17, 20, 19, 20, 18, 18, 17, 18, 18, 20, 20] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -366,13 +366,13 @@ "desktop-macos": { "tick_us": { "p50": 20, - "p95": 33, + "p95": 24, "min": 17, - "max": 47, + "max": 33, "n": 32, - "samples": [47, 20, 24, 20, 20, 20, 17, 19, 20, 20, 17, 19, 20, 20, 19, 21, 19, 20, 20, 20, 20, 17, 33, 17, 20, 20, 20, 18, 18, 17, 19, 18] + "samples": [24, 20, 20, 20, 17, 19, 20, 20, 17, 19, 20, 20, 19, 21, 19, 20, 20, 20, 20, 17, 33, 17, 20, 20, 20, 18, 18, 17, 19, 18, 20, 20] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -461,13 +461,13 @@ "desktop-macos": { "tick_us": { "p50": 20, - "p95": 36, + "p95": 23, "min": 17, - "max": 52, + "max": 36, "n": 32, - "samples": [52, 21, 21, 20, 20, 20, 17, 23, 20, 20, 19, 20, 20, 20, 19, 17, 19, 19, 19, 21, 20, 17, 36, 17, 20, 20, 20, 18, 18, 17, 19, 18] + "samples": [21, 20, 20, 20, 17, 23, 20, 20, 19, 20, 20, 20, 19, 17, 19, 19, 19, 21, 20, 17, 36, 17, 20, 20, 20, 18, 18, 17, 19, 18, 20, 19] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_composition.json b/test/scenarios/light/scenario_Effects_composition.json index d8a8eed2..476634e5 100644 --- a/test/scenarios/light/scenario_Effects_composition.json +++ b/test/scenarios/light/scenario_Effects_composition.json @@ -107,13 +107,13 @@ "desktop-macos": { "tick_us": { "p50": 147, - "p95": 248, + "p95": 169, "min": 142, "max": 309, "n": 32, - "samples": [248, 248, 146, 142, 146, 152, 145, 146, 148, 144, 145, 147, 148, 164, 169, 144, 147, 144, 147, 146, 143, 152, 309, 152, 143, 144, 147, 153, 155, 147, 161, 153] + "samples": [146, 142, 146, 152, 145, 146, 148, 144, 145, 147, 148, 164, 169, 144, 147, 144, 147, 146, 143, 152, 309, 152, 143, 144, 147, 153, 155, 147, 161, 153, 143, 151] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Fields_polar_lut.json b/test/scenarios/light/scenario_Fields_polar_lut.json index 3c0d4620..8494575b 100644 --- a/test/scenarios/light/scenario_Fields_polar_lut.json +++ b/test/scenarios/light/scenario_Fields_polar_lut.json @@ -87,12 +87,12 @@ "tick_us": { "p50": 297, "p95": 421, - "min": 278, + "min": 274, "max": 808, "n": 32, - "samples": [387, 283, 278, 281, 289, 325, 292, 302, 291, 286, 293, 285, 287, 294, 337, 332, 293, 290, 300, 301, 421, 298, 808, 301, 288, 284, 297, 314, 323, 305, 336, 335] + "samples": [278, 281, 289, 325, 292, 302, 291, 286, 293, 285, 287, 294, 337, 332, 293, 290, 300, 301, 421, 298, 808, 301, 288, 284, 297, 314, 323, 305, 336, 335, 274, 297] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -115,14 +115,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 290, + "p50": 291, "p95": 340, - "min": 275, + "min": 274, "max": 373, "n": 32, - "samples": [275, 284, 281, 281, 313, 297, 290, 297, 283, 288, 291, 285, 290, 293, 340, 320, 284, 287, 285, 289, 295, 295, 322, 296, 281, 282, 289, 311, 323, 301, 333, 373] + "samples": [281, 281, 313, 297, 290, 297, 283, 288, 291, 285, 290, 293, 340, 320, 284, 287, 285, 289, 295, 295, 322, 296, 281, 282, 289, 311, 323, 301, 333, 373, 274, 292] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -146,13 +146,13 @@ "desktop-macos": { "tick_us": { "p50": 288, - "p95": 334, - "min": 274, + "p95": 337, + "min": 275, "max": 337, "n": 32, - "samples": [274, 282, 281, 282, 288, 289, 289, 303, 284, 286, 289, 284, 309, 284, 337, 317, 285, 283, 284, 289, 296, 287, 302, 287, 282, 280, 288, 311, 322, 301, 321, 334] + "samples": [281, 282, 288, 289, 289, 303, 284, 286, 289, 284, 309, 284, 337, 317, 285, 283, 284, 289, 296, 287, 302, 287, 282, 280, 288, 311, 322, 301, 321, 334, 275, 337] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -175,14 +175,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 286, + "p50": 287, "p95": 339, - "min": 273, + "min": 274, "max": 347, "n": 32, - "samples": [273, 284, 277, 282, 285, 286, 286, 301, 281, 287, 290, 288, 347, 283, 339, 306, 284, 293, 285, 295, 284, 289, 299, 283, 282, 281, 285, 325, 322, 301, 323, 334] + "samples": [277, 282, 285, 286, 286, 301, 281, 287, 290, 288, 347, 283, 339, 306, 284, 293, 285, 295, 284, 289, 299, 283, 282, 281, 285, 325, 322, 301, 323, 334, 274, 334] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -205,14 +205,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 143, - "p95": 166, - "min": 137, - "max": 169, + "p50": 144, + "p95": 169, + "min": 135, + "max": 253, "n": 32, - "samples": [137, 142, 140, 138, 144, 141, 144, 147, 141, 144, 143, 143, 155, 141, 166, 153, 141, 140, 140, 146, 144, 147, 146, 141, 141, 138, 140, 169, 160, 149, 160, 164] + "samples": [140, 138, 144, 141, 144, 147, 141, 144, 143, 143, 155, 141, 166, 153, 141, 140, 140, 146, 144, 147, 146, 141, 141, 138, 140, 169, 160, 149, 160, 164, 135, 253] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -236,13 +236,13 @@ "desktop-macos": { "tick_us": { "p50": 433, - "p95": 503, - "min": 410, - "max": 512, + "p95": 512, + "min": 407, + "max": 622, "n": 32, - "samples": [410, 501, 413, 417, 422, 421, 428, 444, 420, 451, 442, 503, 433, 512, 502, 461, 424, 421, 425, 443, 431, 448, 448, 421, 421, 422, 418, 481, 479, 458, 479, 494] + "samples": [413, 417, 422, 421, 428, 444, 420, 451, 442, 503, 433, 512, 502, 461, 424, 421, 425, 443, 431, 448, 448, 421, 421, 422, 418, 481, 479, 458, 479, 494, 407, 622] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -271,14 +271,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1261, - "p95": 1686, + "p50": 1269, + "p95": 1892, "min": 1206, - "max": 1892, + "max": 2429, "n": 32, - "samples": [1206, 1234, 1239, 1206, 1218, 1228, 1239, 1298, 1269, 1240, 1311, 1232, 1236, 1892, 1402, 1341, 1261, 1229, 1250, 1339, 1271, 1686, 1458, 1231, 1320, 1238, 1225, 1355, 1349, 1298, 1354, 1396] + "samples": [1239, 1206, 1218, 1228, 1239, 1298, 1269, 1240, 1311, 1232, 1236, 1892, 1402, 1341, 1261, 1229, 1250, 1339, 1271, 1686, 1458, 1231, 1320, 1238, 1225, 1355, 1349, 1298, 1354, 1396, 1237, 2429] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -306,9 +306,9 @@ "min": 468, "max": 1469, "n": 32, - "samples": [590, 474, 479, 476, 472, 473, 480, 506, 482, 604, 488, 475, 481, 491, 538, 530, 477, 475, 477, 1469, 496, 528, 500, 473, 468, 509, 476, 518, 520, 502, 568, 534] + "samples": [479, 476, 472, 473, 480, 506, 482, 604, 488, 475, 481, 491, 538, 530, 477, 475, 477, 1469, 496, 528, 500, 473, 468, 509, 476, 518, 520, 502, 568, 534, 471, 540] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } } diff --git a/test/scenarios/light/scenario_Fluid_solver.json b/test/scenarios/light/scenario_Fluid_solver.json index d6000447..bbf3982b 100644 --- a/test/scenarios/light/scenario_Fluid_solver.json +++ b/test/scenarios/light/scenario_Fluid_solver.json @@ -80,12 +80,12 @@ "tick_us": { "p50": 30, "p95": 56, - "min": 29, + "min": 28, "max": 73, - "n": 31, - "samples": [30, 30, 30, 31, 30, 31, 29, 31, 73, 30, 29, 29, 30, 33, 32, 29, 29, 29, 56, 30, 33, 31, 30, 30, 29, 30, 32, 32, 30, 33, 33] + "n": 32, + "samples": [30, 30, 31, 30, 31, 29, 31, 73, 30, 29, 29, 30, 33, 32, 29, 29, 29, 56, 30, 33, 31, 30, 30, 29, 30, 32, 32, 30, 33, 33, 28, 33] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -104,10 +104,10 @@ "p95": 37, "min": 19, "max": 44, - "n": 31, - "samples": [20, 20, 20, 19, 19, 19, 20, 19, 37, 19, 19, 19, 20, 21, 21, 19, 19, 19, 44, 19, 21, 21, 19, 20, 19, 19, 21, 21, 20, 22, 22] + "n": 32, + "samples": [20, 20, 19, 19, 19, 20, 19, 37, 19, 19, 19, 20, 21, 21, 19, 19, 19, 44, 19, 21, 21, 19, 20, 19, 19, 21, 21, 20, 22, 22, 19, 24] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -126,10 +126,10 @@ "p95": 101, "min": 66, "max": 115, - "n": 31, - "samples": [69, 69, 68, 70, 68, 68, 66, 68, 101, 68, 67, 67, 70, 76, 75, 67, 67, 67, 115, 70, 76, 70, 69, 70, 67, 69, 73, 73, 71, 76, 76] + "n": 32, + "samples": [69, 68, 70, 68, 68, 66, 68, 101, 68, 67, 67, 70, 76, 75, 67, 67, 67, 115, 70, 76, 70, 69, 70, 67, 69, 73, 73, 71, 76, 76, 66, 73] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -148,10 +148,10 @@ "p95": 37, "min": 29, "max": 71, - "n": 31, - "samples": [30, 31, 30, 30, 29, 29, 29, 29, 37, 29, 29, 29, 30, 33, 33, 29, 29, 29, 71, 29, 33, 30, 30, 31, 29, 30, 32, 32, 31, 33, 33] + "n": 32, + "samples": [31, 30, 30, 29, 29, 29, 29, 37, 29, 29, 29, 30, 33, 33, 29, 29, 29, 71, 29, 33, 30, 30, 31, 29, 30, 32, 32, 31, 33, 33, 29, 31] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -170,10 +170,10 @@ "p95": 74, "min": 62, "max": 95, - "n": 31, - "samples": [64, 65, 65, 66, 66, 64, 64, 63, 74, 66, 63, 62, 66, 71, 70, 63, 62, 62, 95, 67, 72, 66, 64, 65, 63, 66, 69, 70, 67, 72, 71] + "n": 32, + "samples": [65, 65, 66, 66, 64, 64, 63, 74, 66, 63, 62, 66, 71, 70, 63, 62, 62, 95, 67, 72, 66, 64, 65, 63, 66, 69, 70, 67, 72, 71, 62, 69] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -191,10 +191,10 @@ "p95": 176, "min": 127, "max": 208, - "n": 31, - "samples": [133, 134, 138, 136, 134, 131, 130, 132, 133, 134, 129, 128, 131, 145, 145, 131, 127, 129, 176, 208, 154, 139, 130, 136, 129, 136, 144, 143, 138, 149, 146] + "n": 32, + "samples": [134, 138, 136, 134, 131, 130, 132, 133, 134, 129, 128, 131, 145, 145, 131, 127, 129, 176, 208, 154, 139, 130, 136, 129, 136, 144, 143, 138, 149, 146, 127, 164] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } }, "description": "And the height, making it 64x64: four times the cells of the 32x32 the pair started from. Reallocating on each axis separately is the shape a UI resize actually takes." @@ -212,12 +212,12 @@ "tick_us": { "p50": 136, "p95": 161, - "min": 129, + "min": 128, "max": 264, - "n": 31, - "samples": [133, 140, 137, 136, 135, 131, 132, 131, 131, 134, 130, 138, 129, 145, 145, 131, 129, 132, 264, 150, 161, 145, 129, 137, 129, 131, 143, 143, 138, 147, 146] + "n": 32, + "samples": [140, 137, 136, 135, 131, 132, 131, 131, 134, 130, 138, 129, 145, 145, 131, 129, 132, 264, 150, 161, 145, 129, 137, 129, 131, 143, 143, 138, 147, 146, 128, 157] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -233,13 +233,13 @@ "desktop-macos": { "tick_us": { "p50": 135, - "p95": 158, + "p95": 223, "min": 127, "max": 296, - "n": 31, - "samples": [134, 141, 138, 135, 147, 134, 130, 131, 129, 135, 129, 129, 129, 146, 145, 129, 128, 127, 296, 138, 158, 135, 130, 130, 128, 135, 143, 143, 139, 149, 149] + "n": 32, + "samples": [141, 138, 135, 147, 134, 130, 131, 129, 135, 129, 129, 129, 146, 145, 129, 128, 127, 296, 138, 158, 135, 130, 130, 128, 135, 143, 143, 139, 149, 149, 127, 223] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -258,10 +258,10 @@ "p95": 42, "min": 34, "max": 106, - "n": 30, - "samples": [39, 38, 36, 40, 36, 35, 36, 35, 37, 35, 35, 35, 40, 40, 35, 35, 35, 106, 37, 42, 37, 35, 34, 34, 37, 38, 38, 37, 40, 40] + "n": 32, + "samples": [39, 38, 36, 40, 36, 35, 36, 35, 37, 35, 35, 35, 40, 40, 35, 35, 35, 106, 37, 42, 37, 35, 34, 34, 37, 38, 38, 37, 40, 40, 34, 39] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -279,10 +279,10 @@ "p95": 13, "min": 10, "max": 80, - "n": 30, - "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 11, 11, 10, 80, 11, 13, 11, 11, 10, 10, 11, 12, 12, 11, 12, 12] + "n": 32, + "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 11, 11, 10, 80, 11, 13, 11, 11, 10, 10, 11, 12, 12, 11, 12, 12, 10, 11] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -298,12 +298,12 @@ "tick_us": { "p50": 226, "p95": 265, - "min": 215, + "min": 214, "max": 326, - "n": 30, - "samples": [249, 232, 229, 220, 220, 220, 222, 219, 227, 217, 226, 219, 249, 247, 217, 218, 215, 326, 222, 259, 228, 217, 215, 224, 265, 237, 236, 228, 246, 246] + "n": 32, + "samples": [249, 232, 229, 220, 220, 220, 222, 219, 227, 217, 226, 219, 249, 247, 217, 218, 215, 326, 222, 259, 228, 217, 215, 224, 265, 237, 236, 228, 246, 246, 214, 227] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -322,10 +322,10 @@ "p95": 12, "min": 10, "max": 13, - "n": 30, - "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 12, 12, 11, 11, 11, 12, 12, 13, 11, 10, 11, 11, 12, 12, 12, 12, 12, 12] + "n": 32, + "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 12, 12, 11, 11, 11, 12, 12, 13, 11, 10, 11, 11, 12, 12, 12, 12, 12, 12, 11, 11] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -343,11 +343,11 @@ "p50": 9, "p95": 10, "min": 8, - "max": 29, - "n": 31, - "samples": [29, 9, 9, 9, 8, 8, 8, 8, 8, 9, 8, 9, 8, 9, 10, 8, 9, 9, 10, 9, 10, 9, 9, 8, 8, 9, 9, 9, 9, 10, 10] + "max": 10, + "n": 32, + "samples": [9, 9, 9, 8, 8, 8, 8, 8, 9, 8, 9, 8, 9, 10, 8, 9, 9, 10, 9, 10, 9, 9, 8, 8, 9, 9, 9, 9, 10, 10, 8, 9] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -365,10 +365,10 @@ "p95": 8, "min": 6, "max": 8, - "n": 31, - "samples": [7, 7, 7, 7, 6, 7, 6, 7, 8, 8, 7, 7, 6, 8, 7, 6, 7, 7, 8, 7, 8, 7, 6, 6, 7, 7, 7, 7, 7, 8, 7] + "n": 32, + "samples": [7, 7, 7, 6, 7, 6, 7, 8, 8, 7, 7, 6, 8, 7, 6, 7, 7, 8, 7, 8, 7, 6, 6, 7, 7, 7, 7, 7, 8, 7, 7, 7] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } } diff --git a/test/scenarios/light/scenario_GridBlacks_blackpixel.json b/test/scenarios/light/scenario_GridBlacks_blackpixel.json index 77d29b26..01a2344d 100644 --- a/test/scenarios/light/scenario_GridBlacks_blackpixel.json +++ b/test/scenarios/light/scenario_GridBlacks_blackpixel.json @@ -93,11 +93,11 @@ "p50": 1, "p95": 2, "min": 1, - "max": 5, + "max": 2, "n": 32, - "samples": [5, 2, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -196,11 +196,11 @@ "p50": 2, "p95": 3, "min": 2, - "max": 7, + "max": 3, "n": 32, - "samples": [7, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2] + "samples": [3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json index d92d6be3..da9419bb 100644 --- a/test/scenarios/light/scenario_GridLayout_resize.json +++ b/test/scenarios/light/scenario_GridLayout_resize.json @@ -117,14 +117,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 122, - "p95": 189, + "p50": 121, + "p95": 143, "min": 117, - "max": 240, + "max": 189, "n": 32, - "samples": [240, 128, 126, 120, 118, 120, 128, 117, 119, 122, 117, 121, 120, 124, 125, 126, 119, 189, 124, 141, 131, 143, 119, 121, 118, 118, 124, 121, 122, 120, 126, 129] + "samples": [126, 120, 118, 120, 128, 117, 119, 122, 117, 121, 120, 124, 125, 126, 119, 189, 124, 141, 131, 143, 119, 121, 118, 118, 124, 121, 122, 120, 126, 129, 117, 126] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth-wifi": { "tick_us": { @@ -299,14 +299,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 65, - "p95": 118, + "p50": 64, + "p95": 113, "min": 59, "max": 151, "n": 32, - "samples": [118, 68, 68, 65, 66, 64, 61, 59, 64, 65, 59, 65, 65, 66, 62, 63, 64, 60, 64, 67, 151, 69, 59, 66, 66, 113, 65, 61, 60, 61, 63, 67] + "samples": [68, 65, 66, 64, 61, 59, 64, 65, 59, 65, 65, 66, 62, 63, 64, 60, 64, 67, 151, 69, 59, 66, 66, 113, 65, 61, 60, 61, 63, 67, 63, 64] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth-wifi": { "tick_us": { @@ -481,14 +481,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 122, - "p95": 190, + "p50": 121, + "p95": 142, "min": 116, - "max": 240, + "max": 190, "n": 32, - "samples": [240, 126, 127, 120, 123, 121, 123, 116, 120, 122, 119, 121, 120, 122, 126, 127, 120, 120, 142, 190, 142, 142, 118, 123, 120, 121, 119, 121, 122, 121, 126, 135] + "samples": [127, 120, 123, 121, 123, 116, 120, 122, 119, 121, 120, 122, 126, 127, 120, 120, 142, 190, 142, 142, 118, 123, 120, 121, 119, 121, 122, 121, 126, 135, 119, 126] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth-wifi": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layer_base_pipeline.json b/test/scenarios/light/scenario_Layer_base_pipeline.json index 35fe98c2..52e98b80 100644 --- a/test/scenarios/light/scenario_Layer_base_pipeline.json +++ b/test/scenarios/light/scenario_Layer_base_pipeline.json @@ -88,9 +88,9 @@ "min": 64, "max": 241, "n": 32, - "samples": [125, 71, 65, 72, 70, 69, 66, 64, 66, 66, 64, 71, 70, 70, 68, 69, 70, 67, 139, 241, 77, 77, 65, 68, 67, 70, 71, 67, 66, 67, 69, 72] + "samples": [65, 72, 70, 69, 66, 64, 66, 66, 64, 71, 70, 70, 68, 69, 70, 67, 139, 241, 77, 77, 65, 68, 67, 70, 71, 67, 66, 67, 69, 72, 69, 72] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layer_memory_1to1.json b/test/scenarios/light/scenario_Layer_memory_1to1.json index bdda8d35..3d72cc78 100644 --- a/test/scenarios/light/scenario_Layer_memory_1to1.json +++ b/test/scenarios/light/scenario_Layer_memory_1to1.json @@ -81,13 +81,13 @@ "desktop-macos": { "tick_us": { "p50": 5, - "p95": 11, + "p95": 7, "min": 5, "max": 24, "n": 32, - "samples": [11, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 24, 5, 7, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 24, 5, 7, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json index 1b43c617..209bf49c 100644 --- a/test/scenarios/light/scenario_Layouts_mutation.json +++ b/test/scenarios/light/scenario_Layouts_mutation.json @@ -79,13 +79,13 @@ "desktop-macos": { "tick_us": { "p50": 17, - "p95": 32, + "p95": 20, "min": 16, "max": 83, "n": 32, - "samples": [32, 16, 16, 17, 17, 16, 17, 17, 16, 16, 17, 16, 16, 17, 18, 18, 16, 17, 16, 83, 17, 20, 17, 17, 17, 17, 17, 17, 18, 18, 19, 19] + "samples": [16, 17, 17, 16, 17, 17, 16, 16, 17, 16, 16, 17, 18, 18, 16, 17, 16, 83, 17, 20, 17, 17, 17, 17, 17, 17, 18, 18, 19, 19, 16, 17] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -207,13 +207,13 @@ "desktop-macos": { "tick_us": { "p50": 47, - "p95": 86, + "p95": 72, "min": 43, "max": 174, "n": 32, - "samples": [86, 48, 47, 48, 44, 46, 49, 45, 43, 48, 44, 48, 49, 48, 46, 49, 46, 50, 48, 174, 50, 72, 45, 47, 47, 47, 45, 45, 46, 46, 47, 53] + "samples": [47, 48, 44, 46, 49, 45, 43, 48, 44, 48, 49, 48, 46, 49, 46, 50, 48, 174, 50, 72, 45, 47, 47, 47, 45, 45, 46, 46, 47, 53, 46, 53] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -330,13 +330,13 @@ "desktop-macos": { "tick_us": { "p50": 94, - "p95": 169, + "p95": 156, "min": 88, "max": 211, "n": 32, - "samples": [169, 92, 93, 156, 93, 93, 97, 88, 92, 94, 88, 121, 93, 93, 96, 95, 93, 95, 96, 211, 97, 114, 90, 95, 94, 93, 95, 92, 92, 93, 95, 101] + "samples": [93, 156, 93, 93, 97, 88, 92, 94, 88, 121, 93, 93, 96, 95, 93, 95, 96, 211, 97, 114, 90, 95, 94, 93, 95, 92, 92, 93, 95, 101, 93, 94] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -452,13 +452,13 @@ "desktop-macos": { "tick_us": { "p50": 20, - "p95": 29, + "p95": 26, "min": 17, - "max": 31, + "max": 29, "n": 32, - "samples": [31, 20, 20, 17, 20, 20, 21, 17, 20, 20, 17, 20, 20, 18, 18, 18, 20, 20, 20, 29, 22, 26, 17, 20, 20, 20, 20, 17, 17, 17, 18, 18] + "samples": [20, 17, 20, 20, 21, 17, 20, 20, 17, 20, 20, 18, 18, 18, 20, 20, 20, 29, 22, 26, 17, 20, 20, 20, 20, 17, 17, 17, 18, 18, 19, 21] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json index 6d3730f3..0cbb75b3 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json @@ -93,9 +93,9 @@ "min": 5, "max": 15, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 6, 5, 6, 6, 5, 5, 6, 5, 6, 5, 14, 15, 7, 5, 12, 15, 5, 5, 5, 5, 6, 6, 5, 5, 5] + "samples": [5, 5, 5, 5, 5, 6, 5, 6, 6, 5, 5, 6, 5, 6, 5, 14, 15, 7, 5, 12, 15, 5, 5, 5, 5, 6, 6, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -211,9 +211,9 @@ "min": 5, "max": 10, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 10, 5, 5, 5, 6, 5, 5, 5, 9, 7, 6, 6, 10, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 5, 5, 5, 5, 5, 5, 10, 5, 5, 5, 6, 5, 5, 5, 9, 7, 6, 6, 10, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -321,9 +321,9 @@ "min": 5, "max": 15, "n": 32, - "samples": [5, 5, 5, 5, 5, 6, 5, 5, 6, 15, 5, 5, 5, 6, 5, 5, 5, 12, 5, 6, 6, 10, 5, 6, 5, 5, 5, 5, 5, 5, 6, 6] + "samples": [5, 5, 5, 6, 5, 5, 6, 15, 5, 5, 5, 6, 5, 5, 5, 12, 5, 6, 6, 10, 5, 6, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -431,9 +431,9 @@ "min": 4, "max": 10, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 4, 5, 5, 5, 5, 5, 5, 6, 5, 7, 6, 10, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6] + "samples": [5, 5, 5, 5, 5, 5, 5, 6, 4, 5, 5, 5, 5, 5, 5, 6, 5, 7, 6, 10, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -534,9 +534,9 @@ "min": 5, "max": 10, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 5, 5, 5, 5, 5, 5, 5, 10, 5, 7, 6, 9, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6] + "samples": [5, 5, 5, 5, 5, 5, 5, 7, 5, 5, 5, 5, 5, 5, 5, 10, 5, 7, 6, 9, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -637,9 +637,9 @@ "min": 5, "max": 14, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 7, 5, 5, 8, 5, 5, 5, 6, 5, 5, 5, 14, 5, 7, 6, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 5, 5, 5, 7, 5, 5, 8, 5, 5, 5, 6, 5, 5, 5, 14, 5, 7, 6, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -739,10 +739,10 @@ "p95": 8, "min": 5, "max": 10, - "n": 26, - "samples": [5, 5, 5, 8, 5, 5, 7, 5, 5, 5, 5, 10, 6, 7, 6, 7, 6, 5, 5, 5, 5, 5, 5, 5, 5, 6] + "n": 28, + "samples": [5, 5, 5, 8, 5, 5, 7, 5, 5, 5, 5, 10, 6, 7, 6, 7, 6, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -761,10 +761,10 @@ "p95": 9, "min": 5, "max": 12, - "n": 26, - "samples": [5, 5, 5, 6, 5, 5, 6, 6, 5, 5, 5, 6, 12, 6, 6, 9, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6] + "n": 28, + "samples": [5, 5, 5, 6, 5, 5, 6, 6, 5, 5, 5, 6, 12, 6, 6, 9, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 6] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -783,10 +783,10 @@ "p95": 8, "min": 5, "max": 11, - "n": 26, - "samples": [5, 5, 5, 6, 5, 6, 6, 6, 5, 6, 5, 7, 6, 6, 6, 8, 11, 5, 6, 5, 5, 6, 5, 5, 6, 6] + "n": 28, + "samples": [5, 5, 5, 6, 5, 6, 6, 6, 5, 6, 5, 7, 6, 6, 6, 8, 11, 5, 6, 5, 5, 6, 5, 5, 6, 6, 5, 6] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -804,9 +804,9 @@ "min": 5, "max": 10, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 9, 6, 6, 6, 6, 10, 5, 5, 5, 5, 6, 5, 6, 5, 6] + "samples": [5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 9, 6, 6, 6, 6, 10, 5, 5, 5, 5, 6, 5, 6, 5, 6, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -907,9 +907,9 @@ "min": 5, "max": 9, "n": 32, - "samples": [5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 9, 6, 6, 7, 8, 5, 5, 5, 6, 5, 6, 6, 5, 6] + "samples": [5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 9, 6, 6, 7, 8, 5, 5, 5, 6, 5, 6, 6, 5, 6, 5, 6] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -1010,9 +1010,9 @@ "min": 5, "max": 8, "n": 32, - "samples": [5, 6, 5, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 7, 6, 6, 8, 6, 5, 5, 5, 5, 5, 5, 5, 6, 6] + "samples": [5, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 7, 6, 6, 8, 6, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32": { "tick_us": { diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json index 81c7d85e..f936a9c5 100644 --- a/test/scenarios/light/scenario_MoonLive_pipeline.json +++ b/test/scenarios/light/scenario_MoonLive_pipeline.json @@ -375,13 +375,13 @@ "desktop-macos": { "tick_us": { "p50": 5, - "p95": 11, + "p95": 7, "min": 4, "max": 15, "n": 32, - "samples": [11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 6, 7, 6, 15, 5, 4, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 6, 7, 6, 15, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -534,11 +534,11 @@ "p50": 5, "p95": 7, "min": 5, - "max": 9, + "max": 7, "n": 32, - "samples": [9, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 6, 6, 7, 7, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 6, 6, 7, 7, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -683,13 +683,13 @@ "desktop-macos": { "tick_us": { "p50": 5, - "p95": 10, + "p95": 8, "min": 5, "max": 11, "n": 32, - "samples": [10, 5, 6, 5, 6, 5, 5, 6, 5, 5, 5, 6, 5, 6, 5, 5, 5, 6, 8, 11, 6, 7, 7, 5, 5, 5, 5, 5, 5, 5, 6, 6] + "samples": [6, 5, 6, 5, 5, 6, 5, 5, 5, 6, 5, 6, 5, 5, 5, 6, 8, 11, 6, 7, 7, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -985,13 +985,13 @@ "desktop-macos": { "tick_us": { "p50": 5, - "p95": 10, + "p95": 9, "min": 5, "max": 11, "n": 32, - "samples": [10, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 6, 7, 11, 5, 5, 5, 5, 5, 5, 5, 6, 6] + "samples": [5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 6, 7, 11, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 9] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -1129,11 +1129,11 @@ "p50": 5, "p95": 7, "min": 4, - "max": 11, + "max": 7, "n": 32, - "samples": [11, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 6, 5, 7, 7, 5, 4, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 6, 5, 7, 7, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json index 309e8d67..e4dde239 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json +++ b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json @@ -92,11 +92,11 @@ "p50": 3, "p95": 4, "min": 2, - "max": 6, + "max": 4, "n": 32, - "samples": [6, 3, 3, 3, 2, 3, 3, 3, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 4, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3] + "samples": [3, 3, 2, 3, 3, 3, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 4, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json index 150d66f8..a773170a 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json +++ b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json @@ -90,13 +90,13 @@ "desktop-macos": { "tick_us": { "p50": 121, - "p95": 172, + "p95": 146, "min": 117, - "max": 240, + "max": 172, "n": 32, - "samples": [240, 127, 126, 118, 119, 117, 121, 117, 119, 119, 118, 122, 119, 121, 127, 129, 119, 121, 119, 172, 126, 146, 144, 122, 118, 118, 120, 126, 121, 121, 126, 136] + "samples": [126, 118, 119, 117, 121, 117, 119, 119, 118, 122, 119, 121, 127, 129, 119, 121, 119, 172, 126, 146, 144, 122, 118, 118, 120, 126, 121, 121, 126, 136, 118, 123] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Trails_ladder.json b/test/scenarios/light/scenario_Trails_ladder.json index 8f67100a..01526b4d 100644 --- a/test/scenarios/light/scenario_Trails_ladder.json +++ b/test/scenarios/light/scenario_Trails_ladder.json @@ -90,10 +90,10 @@ "p95": 14, "min": 10, "max": 15, - "n": 31, - "samples": [11, 10, 10, 10, 11, 11, 11, 11, 11, 13, 12, 11, 11, 13, 13, 11, 12, 11, 15, 11, 14, 12, 11, 11, 11, 11, 13, 12, 12, 13, 13] + "n": 32, + "samples": [10, 10, 10, 11, 11, 11, 11, 11, 13, 12, 11, 11, 13, 13, 11, 12, 11, 15, 11, 14, 12, 11, 11, 11, 11, 13, 12, 12, 13, 13, 11, 11] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -124,13 +124,13 @@ "desktop-macos": { "tick_us": { "p50": 46, - "p95": 57, + "p95": 56, "min": 40, "max": 60, - "n": 31, - "samples": [57, 40, 42, 41, 45, 45, 45, 45, 45, 47, 46, 46, 49, 50, 53, 45, 44, 46, 49, 60, 56, 46, 47, 46, 45, 45, 51, 49, 49, 51, 53] + "n": 32, + "samples": [40, 42, 41, 45, 45, 45, 45, 45, 47, 46, 46, 49, 50, 53, 45, 44, 46, 49, 60, 56, 46, 47, 46, 45, 45, 51, 49, 49, 51, 53, 45, 45] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -160,14 +160,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 184, + "p50": 183, "p95": 222, "min": 160, "max": 233, - "n": 31, - "samples": [208, 160, 166, 165, 182, 178, 180, 184, 180, 183, 186, 179, 187, 200, 202, 178, 178, 178, 233, 191, 222, 188, 222, 178, 180, 184, 207, 195, 194, 202, 211] + "n": 32, + "samples": [160, 166, 165, 182, 178, 180, 184, 180, 183, 186, 179, 187, 200, 202, 178, 178, 178, 233, 191, 222, 188, 222, 178, 180, 184, 207, 195, 194, 202, 211, 177, 182] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -204,14 +204,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 357, + "p50": 355, "p95": 437, "min": 313, "max": 438, - "n": 31, - "samples": [323, 313, 331, 324, 358, 348, 353, 357, 352, 357, 361, 351, 349, 396, 395, 347, 349, 348, 394, 372, 437, 372, 429, 344, 351, 344, 394, 381, 438, 395, 413] + "n": 32, + "samples": [313, 331, 324, 358, 348, 353, 357, 352, 357, 361, 351, 349, 396, 395, 347, 349, 348, 394, 372, 437, 372, 429, 344, 351, 344, 394, 381, 438, 395, 413, 345, 355] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -238,10 +238,10 @@ "p95": 442, "min": 312, "max": 608, - "n": 31, - "samples": [321, 312, 334, 332, 442, 353, 358, 354, 351, 608, 364, 362, 373, 423, 414, 354, 360, 355, 373, 384, 433, 368, 366, 359, 358, 352, 398, 395, 400, 394, 411] + "n": 32, + "samples": [312, 334, 332, 442, 353, 358, 354, 351, 608, 364, 362, 373, 423, 414, 354, 360, 355, 373, 384, 433, 368, 366, 359, 358, 352, 398, 395, 400, 394, 411, 351, 367] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -268,10 +268,10 @@ "p95": 451, "min": 312, "max": 490, - "n": 31, - "samples": [319, 312, 336, 350, 359, 351, 354, 354, 365, 370, 365, 351, 348, 451, 413, 350, 350, 349, 373, 412, 434, 368, 350, 344, 345, 345, 407, 490, 395, 395, 414] + "n": 32, + "samples": [312, 336, 350, 359, 351, 354, 354, 365, 370, 365, 351, 348, 451, 413, 350, 350, 349, 373, 412, 434, 368, 350, 344, 345, 345, 407, 490, 395, 395, 414, 345, 359] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -310,12 +310,12 @@ "tick_us": { "p50": 183, "p95": 221, - "min": 164, + "min": 166, "max": 250, - "n": 31, - "samples": [164, 169, 169, 166, 183, 178, 180, 182, 189, 186, 185, 180, 183, 250, 210, 177, 177, 181, 203, 210, 221, 187, 179, 176, 175, 178, 203, 211, 202, 201, 210] + "n": 32, + "samples": [169, 169, 166, 183, 178, 180, 182, 189, 186, 185, 180, 183, 250, 210, 177, 177, 181, 203, 210, 221, 187, 179, 176, 175, 178, 203, 211, 202, 201, 210, 176, 187] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } } diff --git a/test/scenarios/light/scenario_modifier_chain.json b/test/scenarios/light/scenario_modifier_chain.json index f42caabc..203ecb4d 100644 --- a/test/scenarios/light/scenario_modifier_chain.json +++ b/test/scenarios/light/scenario_modifier_chain.json @@ -107,9 +107,9 @@ "min": 8, "max": 24, "n": 32, - "samples": [8, 8, 8, 8, 8, 10, 9, 8, 9, 8, 12, 10, 10, 24, 10, 8, 10, 10, 13, 11, 9, 10, 8, 10, 10, 8, 10, 9, 9, 9, 9, 10] + "samples": [8, 8, 8, 10, 9, 8, 9, 8, 12, 10, 10, 24, 10, 8, 10, 10, 13, 11, 9, 10, 8, 10, 10, 8, 10, 9, 9, 9, 9, 10, 10, 8] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -167,9 +167,9 @@ "min": 6, "max": 56, "n": 32, - "samples": [7, 7, 6, 7, 7, 9, 7, 6, 9, 7, 7, 9, 9, 56, 8, 9, 9, 9, 10, 10, 8, 8, 7, 9, 9, 7, 9, 8, 8, 8, 8, 8] + "samples": [6, 7, 7, 9, 7, 6, 9, 7, 7, 9, 9, 56, 8, 9, 9, 9, 10, 10, 8, 8, 7, 9, 9, 7, 9, 8, 8, 8, 8, 8, 8, 7] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -225,9 +225,9 @@ "min": 21, "max": 28, "n": 32, - "samples": [26, 22, 24, 22, 22, 28, 21, 21, 24, 21, 27, 24, 25, 26, 24, 24, 24, 25, 28, 25, 23, 24, 27, 25, 24, 22, 25, 22, 23, 22, 22, 23] + "samples": [24, 22, 22, 28, 21, 21, 24, 21, 27, 24, 25, 26, 24, 24, 24, 25, 28, 25, 23, 24, 27, 25, 24, 22, 25, 22, 23, 22, 22, 23, 24, 24] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -258,9 +258,9 @@ "min": 37, "max": 50, "n": 32, - "samples": [47, 47, 43, 43, 44, 41, 42, 40, 44, 37, 39, 44, 43, 48, 43, 45, 44, 44, 50, 44, 40, 45, 38, 45, 44, 43, 44, 39, 42, 42, 40, 42] + "samples": [43, 43, 44, 41, 42, 40, 44, 37, 39, 44, 43, 48, 43, 45, 44, 44, 50, 44, 40, 45, 38, 45, 44, 43, 44, 39, 42, 42, 40, 42, 44, 46] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json index 4653c917..0d75b7a7 100644 --- a/test/scenarios/light/scenario_modifier_swap.json +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -152,13 +152,13 @@ "desktop-macos": { "tick_us": { "p50": 8, - "p95": 12, + "p95": 10, "min": 8, - "max": 35, + "max": 12, "n": 32, - "samples": [35, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 12, 9, 9, 10, 8, 8, 9, 10, 8, 8, 8, 8, 8, 9, 9, 9, 9, 10] + "samples": [8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 12, 9, 9, 10, 8, 8, 9, 10, 8, 8, 8, 8, 8, 9, 9, 9, 9, 10, 8, 8] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth": { "tick_us": { @@ -295,14 +295,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 22, - "p95": 31, + "p50": 21, + "p95": 25, "min": 20, - "max": 74, + "max": 31, "n": 32, - "samples": [74, 25, 25, 21, 21, 20, 21, 21, 20, 22, 21, 22, 20, 21, 31, 24, 24, 24, 20, 24, 23, 24, 21, 22, 20, 20, 21, 22, 23, 22, 22, 24] + "samples": [25, 21, 21, 20, 21, 21, 20, 22, 21, 22, 20, 21, 31, 24, 24, 24, 20, 24, 23, 24, 21, 22, 20, 20, 21, 22, 23, 22, 22, 24, 20, 21] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth": { "tick_us": { @@ -440,13 +440,13 @@ "desktop-macos": { "tick_us": { "p50": 10, - "p95": 39, + "p95": 12, "min": 8, "max": 106, "n": 32, - "samples": [39, 10, 10, 10, 9, 10, 8, 9, 11, 10, 8, 9, 9, 8, 106, 9, 10, 10, 10, 11, 12, 10, 9, 10, 11, 10, 10, 9, 9, 9, 9, 9] + "samples": [10, 10, 9, 10, 8, 9, 11, 10, 8, 9, 9, 8, 106, 9, 10, 10, 10, 11, 12, 10, 9, 10, 11, 10, 10, 9, 9, 9, 9, 9, 10, 11] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth": { "tick_us": { diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json index 125479ca..e3b7fb0e 100644 --- a/test/scenarios/light/scenario_perf_full.json +++ b/test/scenarios/light/scenario_perf_full.json @@ -86,13 +86,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 5, + "max": 2, "n": 32, - "samples": [5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -206,13 +206,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 5, + "max": 2, "n": 32, - "samples": [5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -326,13 +326,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 5, + "max": 2, "n": 32, - "samples": [5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -569,13 +569,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 5, + "max": 2, "n": 32, - "samples": [5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -689,11 +689,11 @@ "p50": 1, "p95": 2, "min": 1, - "max": 5, + "max": 2, "n": 32, - "samples": [5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -816,13 +816,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 5, + "max": 2, "n": 32, - "samples": [5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -949,13 +949,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 5, + "max": 2, "n": 32, - "samples": [5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -1056,13 +1056,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 6, + "max": 2, "n": 32, - "samples": [6, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32p4rev1-eth": { "tick_us": { @@ -1169,13 +1169,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 5, + "max": 2, "n": 32, - "samples": [5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -1293,13 +1293,13 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 9, + "p95": 6, "min": 4, - "max": 19, + "max": 9, "n": 32, - "samples": [19, 9, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 5, 5, 5, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5] + "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 5, 5, 5, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -1417,13 +1417,13 @@ "desktop-macos": { "tick_us": { "p50": 18, - "p95": 41, + "p95": 23, "min": 17, - "max": 82, + "max": 40, "n": 32, - "samples": [82, 41, 40, 17, 17, 17, 18, 18, 17, 18, 18, 18, 18, 18, 23, 21, 18, 18, 18, 20, 20, 21, 18, 18, 17, 18, 18, 20, 21, 20, 20, 21] + "samples": [40, 17, 17, 17, 18, 18, 17, 18, 18, 18, 18, 18, 23, 21, 18, 18, 18, 20, 20, 21, 18, 18, 17, 18, 18, 20, 21, 20, 20, 21, 18, 18] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -1540,14 +1540,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 74, - "p95": 174, + "p50": 73, + "p95": 113, "min": 70, - "max": 368, + "max": 173, "n": 32, - "samples": [368, 174, 173, 71, 70, 70, 74, 74, 71, 70, 73, 75, 72, 74, 113, 86, 71, 71, 70, 76, 80, 88, 73, 72, 70, 73, 72, 80, 84, 80, 80, 84] + "samples": [173, 71, 70, 70, 74, 74, 71, 70, 73, 75, 72, 74, 113, 86, 71, 71, 70, 76, 80, 88, 73, 72, 70, 73, 72, 80, 84, 80, 80, 84, 70, 72] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -1675,11 +1675,11 @@ "p50": 4, "p95": 5, "min": 4, - "max": 9, + "max": 5, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -1797,13 +1797,13 @@ "desktop-macos": { "tick_us": { "p50": 16, - "p95": 37, + "p95": 19, "min": 15, "max": 92, "n": 32, - "samples": [37, 17, 17, 15, 15, 15, 16, 16, 16, 16, 16, 17, 15, 16, 92, 19, 16, 16, 16, 16, 19, 19, 16, 17, 15, 16, 16, 18, 19, 18, 18, 18] + "samples": [17, 15, 15, 15, 16, 16, 16, 16, 16, 17, 15, 16, 92, 19, 16, 16, 16, 16, 19, 19, 16, 17, 15, 16, 16, 18, 19, 18, 18, 18, 15, 16] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -1920,14 +1920,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 66, - "p95": 165, + "p50": 65, + "p95": 82, "min": 61, "max": 574, "n": 32, - "samples": [165, 69, 69, 62, 62, 61, 66, 65, 64, 63, 64, 64, 63, 67, 574, 74, 64, 62, 63, 82, 72, 74, 68, 69, 62, 63, 65, 71, 74, 70, 71, 74] + "samples": [69, 62, 62, 61, 66, 65, 64, 63, 64, 64, 63, 67, 574, 74, 64, 62, 63, 82, 72, 74, 68, 69, 62, 63, 65, 71, 74, 70, 71, 74, 62, 64] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -2044,14 +2044,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 265, - "p95": 402, + "p50": 264, + "p95": 301, "min": 247, - "max": 589, + "max": 402, "n": 32, - "samples": [589, 278, 279, 252, 249, 247, 256, 265, 251, 255, 259, 267, 257, 264, 402, 298, 250, 259, 252, 285, 294, 301, 265, 277, 252, 257, 264, 281, 298, 285, 284, 298] + "samples": [279, 252, 249, 247, 256, 265, 251, 255, 259, 267, 257, 264, 402, 298, 250, 259, 252, 285, 294, 301, 265, 277, 252, 257, 264, 281, 298, 285, 284, 298, 250, 257] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -2204,13 +2204,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, "max": 2, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32": { "tick_us": { @@ -2328,13 +2328,13 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 7, + "p95": 5, "min": 4, - "max": 9, + "max": 7, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 7, 5, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 7, 5, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32": { "tick_us": { @@ -2452,13 +2452,13 @@ "desktop-macos": { "tick_us": { "p50": 16, - "p95": 27, + "p95": 18, "min": 15, - "max": 35, + "max": 27, "n": 32, - "samples": [35, 18, 17, 16, 15, 15, 16, 16, 15, 16, 16, 16, 16, 16, 27, 18, 15, 15, 15, 17, 18, 18, 16, 15, 15, 16, 16, 17, 18, 18, 18, 18] + "samples": [17, 16, 15, 15, 16, 16, 15, 16, 16, 16, 16, 16, 27, 18, 15, 15, 15, 17, 18, 18, 16, 15, 15, 16, 16, 17, 18, 18, 18, 18, 15, 16] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32": { "tick_us": { @@ -2575,14 +2575,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 66, - "p95": 103, + "p50": 65, + "p95": 79, "min": 62, - "max": 141, + "max": 103, "n": 32, - "samples": [141, 70, 70, 63, 63, 62, 66, 66, 63, 64, 65, 65, 63, 67, 103, 74, 63, 63, 63, 68, 71, 79, 67, 62, 62, 62, 63, 70, 74, 71, 71, 72] + "samples": [70, 63, 63, 62, 66, 66, 63, 64, 65, 65, 63, 67, 103, 74, 63, 63, 63, 68, 71, 79, 67, 62, 62, 62, 63, 70, 74, 71, 71, 72, 62, 64] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32": { "tick_us": { diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json index 2edcd780..d9ce014c 100644 --- a/test/scenarios/light/scenario_perf_light.json +++ b/test/scenarios/light/scenario_perf_light.json @@ -104,11 +104,11 @@ "p50": 1, "p95": 2, "min": 1, - "max": 5, + "max": 2, "n": 32, - "samples": [5, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -450,13 +450,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, "max": 2, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -574,13 +574,13 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 6, + "p95": 5, "min": 4, - "max": 10, + "max": 6, "n": 32, - "samples": [10, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -698,13 +698,13 @@ "desktop-macos": { "tick_us": { "p50": 16, - "p95": 24, + "p95": 20, "min": 15, - "max": 35, + "max": 24, "n": 32, - "samples": [35, 17, 17, 15, 15, 15, 16, 16, 15, 15, 17, 20, 16, 16, 24, 18, 15, 16, 15, 16, 18, 19, 16, 16, 16, 15, 16, 17, 19, 18, 17, 18] + "samples": [17, 15, 15, 15, 16, 16, 15, 15, 17, 20, 16, 16, 24, 18, 15, 16, 15, 16, 18, 19, 16, 16, 16, 15, 16, 17, 19, 18, 17, 18, 15, 16] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json index da3c3571..6467eba1 100644 --- a/test/scenarios/light/scenario_peripheral_grid_sweep.json +++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json @@ -176,11 +176,11 @@ "p50": 4, "p95": 6, "min": 4, - "max": 9, + "max": 6, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 6, 5, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 6, 5, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -305,9 +305,9 @@ "min": 15, "max": 84, "n": 32, - "samples": [35, 17, 17, 15, 15, 15, 18, 17, 16, 16, 16, 84, 17, 16, 37, 19, 16, 16, 16, 16, 17, 20, 16, 16, 15, 15, 15, 17, 18, 18, 17, 18] + "samples": [17, 15, 15, 15, 18, 17, 16, 16, 16, 84, 17, 16, 37, 19, 16, 16, 16, 16, 17, 20, 16, 16, 15, 15, 15, 17, 18, 18, 17, 18, 16, 16] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -432,9 +432,9 @@ "min": 62, "max": 296, "n": 32, - "samples": [144, 69, 69, 63, 63, 62, 151, 65, 63, 63, 66, 296, 66, 64, 103, 74, 65, 63, 63, 69, 72, 78, 66, 72, 64, 62, 66, 71, 73, 71, 71, 71] + "samples": [69, 63, 63, 62, 151, 65, 63, 63, 66, 296, 66, 64, 103, 74, 65, 63, 63, 69, 72, 78, 66, 72, 64, 62, 66, 71, 73, 71, 71, 71, 62, 67] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -554,14 +554,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 272, + "p50": 264, "p95": 813, "min": 247, "max": 872, "n": 32, - "samples": [575, 277, 278, 251, 247, 250, 590, 264, 252, 255, 260, 872, 259, 272, 813, 298, 254, 251, 251, 278, 285, 299, 272, 277, 251, 250, 254, 282, 285, 282, 285, 285] + "samples": [278, 251, 247, 250, 590, 264, 252, 255, 260, 872, 259, 272, 813, 298, 254, 251, 251, 278, 285, 299, 272, 277, 251, 250, 254, 282, 285, 282, 285, 285, 249, 260] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -703,13 +703,13 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 9, + "p95": 7, "min": 4, "max": 16, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 7, 4, 4, 16, 5, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 5, 4, 7, 4, 4, 16, 5, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -830,13 +830,13 @@ "desktop-macos": { "tick_us": { "p50": 16, - "p95": 35, + "p95": 22, "min": 15, "max": 61, "n": 32, - "samples": [35, 17, 17, 15, 16, 16, 17, 16, 15, 15, 16, 22, 16, 16, 61, 18, 15, 16, 15, 18, 18, 18, 16, 16, 15, 15, 15, 17, 18, 17, 18, 18] + "samples": [17, 15, 16, 16, 17, 16, 15, 15, 16, 22, 16, 16, 61, 18, 15, 16, 15, 18, 18, 18, 16, 16, 15, 15, 15, 17, 18, 17, 18, 18, 15, 16] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -956,14 +956,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 68, - "p95": 125, + "p50": 65, + "p95": 83, "min": 61, - "max": 142, + "max": 125, "n": 32, - "samples": [142, 70, 69, 63, 61, 65, 68, 68, 62, 64, 64, 83, 64, 63, 125, 74, 62, 63, 64, 69, 72, 75, 69, 67, 62, 62, 61, 70, 71, 69, 71, 71] + "samples": [69, 63, 61, 65, 68, 68, 62, 64, 64, 83, 64, 63, 125, 74, 62, 63, 64, 69, 72, 75, 69, 67, 62, 62, 61, 70, 71, 69, 71, 71, 62, 64] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -1083,14 +1083,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 272, - "p95": 564, + "p50": 260, + "p95": 316, "min": 248, "max": 643, "n": 32, - "samples": [564, 279, 279, 253, 249, 310, 316, 263, 254, 254, 259, 308, 260, 253, 643, 309, 254, 255, 252, 272, 285, 296, 274, 258, 248, 249, 251, 283, 286, 276, 276, 284] + "samples": [279, 253, 249, 310, 316, 263, 254, 254, 259, 308, 260, 253, 643, 309, 254, 255, 252, 272, 285, 296, 274, 258, 248, 249, 251, 283, 286, 276, 276, 284, 248, 257] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -1232,13 +1232,13 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 7, + "p95": 5, "min": 4, - "max": 9, + "max": 7, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 7, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 7, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -1359,13 +1359,13 @@ "desktop-macos": { "tick_us": { "p50": 16, - "p95": 27, + "p95": 19, "min": 15, - "max": 35, + "max": 27, "n": 32, - "samples": [35, 17, 17, 16, 15, 15, 16, 16, 16, 15, 16, 16, 16, 17, 27, 18, 16, 15, 15, 16, 18, 18, 19, 17, 15, 15, 16, 17, 18, 17, 17, 18] + "samples": [17, 16, 15, 15, 16, 16, 16, 15, 16, 16, 16, 17, 27, 18, 16, 15, 15, 16, 18, 18, 19, 17, 15, 15, 16, 17, 18, 17, 17, 18, 15, 16] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -1485,14 +1485,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 67, - "p95": 140, - "min": 62, + "p50": 66, + "p95": 96, + "min": 61, "max": 588, "n": 32, - "samples": [140, 69, 70, 62, 62, 62, 67, 66, 64, 63, 64, 86, 64, 67, 96, 588, 63, 64, 64, 68, 71, 74, 67, 70, 62, 62, 63, 70, 71, 68, 68, 78] + "samples": [70, 62, 62, 62, 67, 66, 64, 63, 64, 86, 64, 67, 96, 588, 63, 64, 64, 68, 71, 74, 67, 70, 62, 62, 63, 70, 71, 68, 68, 78, 61, 64] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -1612,14 +1612,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 266, - "p95": 384, + "p50": 260, + "p95": 359, "min": 247, - "max": 561, + "max": 384, "n": 32, - "samples": [561, 278, 274, 252, 247, 251, 277, 262, 251, 254, 252, 311, 255, 260, 384, 359, 252, 252, 251, 270, 292, 297, 266, 266, 248, 247, 251, 282, 287, 275, 276, 288] + "samples": [274, 252, 247, 251, 277, 262, 251, 254, 252, 311, 255, 260, 384, 359, 252, 252, 251, 270, 292, 297, 266, 266, 248, 247, 251, 282, 287, 275, 276, 288, 249, 258] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -1761,13 +1761,13 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 6, + "p95": 5, "min": 4, - "max": 10, + "max": 6, "n": 32, - "samples": [10, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -1888,13 +1888,13 @@ "desktop-macos": { "tick_us": { "p50": 16, - "p95": 25, + "p95": 21, "min": 15, - "max": 36, + "max": 25, "n": 32, - "samples": [36, 18, 17, 16, 15, 15, 17, 16, 16, 15, 16, 17, 15, 15, 25, 21, 15, 15, 15, 16, 17, 18, 16, 16, 15, 15, 15, 17, 18, 20, 18, 18] + "samples": [17, 16, 15, 15, 17, 16, 16, 15, 16, 17, 15, 15, 25, 21, 15, 15, 15, 16, 17, 18, 16, 16, 15, 15, 15, 17, 18, 20, 18, 18, 15, 16] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -2015,13 +2015,13 @@ "desktop-macos": { "tick_us": { "p50": 64, - "p95": 117, + "p95": 87, "min": 61, - "max": 144, + "max": 117, "n": 32, - "samples": [144, 69, 68, 62, 62, 61, 64, 65, 63, 64, 63, 69, 64, 62, 117, 87, 64, 63, 63, 68, 71, 74, 65, 62, 62, 62, 62, 69, 71, 80, 71, 71] + "samples": [68, 62, 62, 61, 64, 65, 63, 64, 63, 69, 64, 62, 117, 87, 64, 63, 63, 68, 71, 74, 65, 62, 62, 62, 62, 69, 71, 80, 71, 71, 62, 64] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -2141,14 +2141,14 @@ }, "desktop-macos": { "tick_us": { - "p50": 261, - "p95": 391, + "p50": 256, + "p95": 385, "min": 246, - "max": 606, + "max": 391, "n": 32, - "samples": [606, 279, 281, 251, 248, 246, 258, 271, 249, 255, 253, 276, 251, 253, 385, 363, 252, 251, 250, 299, 287, 289, 265, 261, 248, 249, 250, 275, 287, 391, 279, 284] + "samples": [281, 251, 248, 246, 258, 271, 249, 255, 253, 276, 251, 253, 385, 363, 252, 251, 250, 299, 287, 289, 265, 261, 248, 249, 250, 275, 287, 391, 279, 284, 249, 256] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_peripheral_switch.json b/test/scenarios/light/scenario_peripheral_switch.json index e0355e3a..32fc1321 100644 --- a/test/scenarios/light/scenario_peripheral_switch.json +++ b/test/scenarios/light/scenario_peripheral_switch.json @@ -173,13 +173,13 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 6, + "p95": 5, "min": 4, - "max": 9, + "max": 6, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32p4rev1-eth": { "tick_us": { @@ -294,13 +294,13 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 9, + "p95": 6, "min": 4, "max": 19, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 19, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 19, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32p4rev1-eth": { "tick_us": { @@ -417,11 +417,11 @@ "p50": 4, "p95": 5, "min": 4, - "max": 9, + "max": 5, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32p4rev1-eth": { "tick_us": { @@ -537,11 +537,11 @@ "p50": 4, "p95": 5, "min": 4, - "max": 9, + "max": 5, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32p4rev1-eth": { "tick_us": { @@ -658,11 +658,11 @@ "p50": 4, "p95": 5, "min": 4, - "max": 9, + "max": 5, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32p4rev1-eth": { "tick_us": { @@ -795,11 +795,11 @@ "p50": 4, "p95": 5, "min": 4, - "max": 9, + "max": 5, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32p4rev1-eth": { "tick_us": { diff --git a/test/unit/core/unit_InstallationId.cpp b/test/unit/core/unit_InstallationId.cpp new file mode 100644 index 00000000..2660cdbd --- /dev/null +++ b/test/unit/core/unit_InstallationId.cpp @@ -0,0 +1,103 @@ +// @module InstallationId + +#include "doctest.h" +#include "core/MoonCloudModule.h" +#include "core/sha256.h" + +#include +#include +#include + +#include "platform/platform.h" + +namespace { +std::string id() { + char buf[mm::kInstallationIdChars + 1] = {}; + mm::installationId(buf); + return std::string(buf); +} +} // namespace + +/// The id is 32 lowercase hex characters, which is what the server stores and what the privacy +/// policy describes. +TEST_CASE("the installation id is 32 hex characters") { + const std::string value = id(); + CHECK(value.size() == mm::kInstallationIdChars); + for (char c : value) { + const bool isHex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + CHECK(isHex); + } +} + +/// The same installation reports the same id every time, which is the property the whole feature +/// rests on: without it an upgrade cannot be told from a new install. +TEST_CASE("the installation id is stable across calls") { + CHECK(id() == id()); +} + +/// The id is not the MAC address, in any recognizable form. +/// +/// The privacy policy promises the address itself is never sent. A hash that happened to contain +/// the address as a substring, or that were simply the address in hex, would break that promise +/// while still looking like an opaque identifier. +TEST_CASE("the installation id does not contain the underlying address") { + uint8_t mac[6] = {}; + mm::platform::getMacAddress(mac); + + char macHex[13] = {}; + std::snprintf(macHex, sizeof(macHex), "%02x%02x%02x%02x%02x%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + const std::string value = id(); + CHECK(value.find(macHex) == std::string::npos); + + // Nor any four-byte run of it, which would leak most of the address. + for (int i = 0; i + 4 <= 6; i++) { + char run[9] = {}; + std::snprintf(run, sizeof(run), "%02x%02x%02x%02x", + mac[i], mac[i + 1], mac[i + 2], mac[i + 3]); + CHECK(value.find(run) == std::string::npos); + } +} + +/// A different address gives a different id, so two installations are told apart. +/// +/// Checked through the hash directly rather than by moving the platform's address, which a unit +/// test cannot do: the property under test is that the construction separates its inputs. +TEST_CASE("a different address produces a different installation id") { + // The REAL salt, not a copy of its text: retyping it means a salt change passes this test while + // silently re-identifying every installation in the world. + const size_t saltLen = std::strlen(mm::kMoonCloudSalt); + uint8_t a[64] = {}, b[64] = {}; + std::memcpy(a, mm::kMoonCloudSalt, saltLen); + std::memcpy(b, mm::kMoonCloudSalt, saltLen); + const uint8_t macA[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55}; + const uint8_t macB[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x56}; // one bit apart + std::memcpy(a + saltLen, macA, 6); + std::memcpy(b + saltLen, macB, 6); + + char idA[33] = {}, idB[33] = {}; + mm::sha256Hex(a, saltLen + 6, idA, 16); + mm::sha256Hex(b, saltLen + 6, idB, 16); + CHECK(std::string(idA) != std::string(idB)); +} + +/// The MoonStats id differs from any other identifier derived from the same address, so a report +/// cannot be matched against the MQTT topic or Home Assistant unique_id a device publishes on the +/// user's own network. That separation is the salt's job. +TEST_CASE("the installation id cannot be correlated with the network identity") { + uint8_t mac[6] = {}; + mm::platform::getMacAddress(mac); + + // What ADR-0010's identities expose: the last three bytes, in hex, on the local network. + char networkIdentity[7] = {}; + std::snprintf(networkIdentity, sizeof(networkIdentity), "%02x%02x%02x", mac[3], mac[4], mac[5]); + + // An unsalted hash of the same address, which is what a naive implementation would send. + char unsalted[33] = {}; + mm::sha256Hex(mac, sizeof(mac), unsalted, 16); + + const std::string value = id(); + CHECK(value.find(networkIdentity) == std::string::npos); + CHECK(value != std::string(unsalted)); +} diff --git a/test/unit/core/unit_MoonStatsModule.cpp b/test/unit/core/unit_MoonStatsModule.cpp new file mode 100644 index 00000000..1a40f8f2 --- /dev/null +++ b/test/unit/core/unit_MoonStatsModule.cpp @@ -0,0 +1,229 @@ +// @module MoonStatsModule + +#include "doctest.h" +#include "core/MoonStatsModule.h" + +#include + +/// Nothing is reported until the user says yes, and declining is permanent. +/// +/// The default is Unanswered, which is the only state that shows a prompt. A device whose user +/// never answers, or who answers Never, sends nothing at all: no report, and no record that they +/// declined, since sending one would itself be a report. +TEST_CASE("a report is due only after the user consents") { + mm::MoonStatsModule stats; + stats.setup(); + stats.defineControls(); + + CHECK(stats.consent() == mm::MoonStatsModule::Unanswered); + CHECK_FALSE(stats.reportDue()); + + stats.setConsent(mm::MoonStatsModule::Never); + CHECK_FALSE(stats.reportDue()); + CHECK_FALSE(stats.shouldPrompt()); + + stats.setConsent(mm::MoonStatsModule::NotNow); + CHECK_FALSE(stats.reportDue()); + + stats.setConsent(mm::MoonStatsModule::Yes); + CHECK(stats.reportDue()); +} + +/// The prompt appears once and stops appearing as soon as it is answered, whichever way. +TEST_CASE("the prompt appears only while the question is unanswered") { + mm::MoonStatsModule stats; + stats.setup(); + stats.defineControls(); + + CHECK(stats.shouldPrompt()); + + stats.setConsent(mm::MoonStatsModule::Yes); + CHECK_FALSE(stats.shouldPrompt()); +} + +/// A device serving its own access point asks nothing and sends nothing. +/// +/// Both paths consult the platform rather than a cached flag, so there is no copy to go stale. The +/// desktop stub answers false, so this pins the FALSE branch: with no AP, the question is asked and +/// a report becomes due. The true branch belongs on a board, which is a state the desktop cannot +/// enter, so the honest thing is to say so rather than to write an assertion that holds either way. +/// +/// An earlier version of this case checked only `inApMode() == false` and two consequences that +/// follow from consent alone: it stayed green with the AP check deleted from `shouldPrompt()` +/// entirely, which is a test that cannot fail. +TEST_CASE("with no access point, the question is asked and a report becomes due") { + mm::MoonStatsModule stats; + stats.setup(); + stats.defineControls(); + + REQUIRE_FALSE(stats.inApMode()); // the desktop stub: never its own AP + + // shouldPrompt() is (unanswered AND not in AP mode). With the second false, the first decides, + // so these two together pin that consent still drives it while the AP check is present. + CHECK(stats.shouldPrompt()); + stats.setConsent(mm::MoonStatsModule::Never); + CHECK_FALSE(stats.shouldPrompt()); + + stats.setConsent(mm::MoonStatsModule::Yes); + CHECK(stats.reportDue()); +} + +/// One report per version, not one per boot. +/// +/// This is the whole trigger: after reporting, the recorded version matches the running one and +/// nothing further is due, however many times the device restarts. +TEST_CASE("a restart sends nothing once the running version has been reported") { + mm::MoonStatsModule stats; + stats.setup(); + stats.defineControls(); + stats.setConsent(mm::MoonStatsModule::Yes); + + REQUIRE(stats.reportDue()); + stats.markReported(); + CHECK_FALSE(stats.reportDue()); + + // A reboot re-runs setup(); the persisted reportedVersion_ still matches, so nothing is due. + stats.setup(); + CHECK_FALSE(stats.reportDue()); +} + +/// A first report is an install, and one after a version change is an upgrade, told apart without +/// any identifier: an empty recorded version means this install has never reported. +TEST_CASE("an install and an upgrade are distinguished by the recorded version") { + mm::MoonStatsModule stats; + stats.setup(); + stats.defineControls(); + stats.setConsent(mm::MoonStatsModule::Yes); + + CHECK(stats.dueEvent() == mm::MoonStatsEvent::Install); + CHECK(stats.previousVersion() == nullptr); + + stats.markReported(); + CHECK(stats.dueEvent() == mm::MoonStatsEvent::Upgrade); + CHECK(stats.previousVersion() != nullptr); + CHECK(std::string(stats.previousVersion()).size() > 0); +} + +/// No identifier exists until the user consents, so a device that declined has nothing to leak and +/// nothing to log. +TEST_CASE("no installation id is produced without consent") { + mm::MoonStatsModule stats; + stats.setup(); + stats.defineControls(); + + char id[mm::kInstallationIdChars + 1] = {"unset"}; + stats.installationId(id); + CHECK(std::string(id).empty()); + + stats.setConsent(mm::MoonStatsModule::Never); + stats.installationId(id); + CHECK(std::string(id).empty()); + + stats.setConsent(mm::MoonStatsModule::Yes); + stats.installationId(id); + CHECK(std::string(id).size() == mm::kInstallationIdChars); +} + +/// An upgrade sends exactly one report: the version changes under an install that already +/// reported, one report becomes due, and the next boot is quiet again. +/// +/// This is the sequence a real device lives through and the one nothing else covers: the other +/// cases start from a fresh install, where the version never moves. +TEST_CASE("an upgrade sends one report, and the boot after it sends none") { + mm::MoonStatsModule stats; + stats.setup(); + stats.defineControls(); + stats.setConsent(mm::MoonStatsModule::Yes); + + // First install: one report due, then reported. + REQUIRE(stats.reportDue()); + REQUIRE(stats.dueEvent() == mm::MoonStatsEvent::Install); + stats.markReported(); + REQUIRE_FALSE(stats.reportDue()); + + // The firmware is upgraded. One report is due again, and it is an UPGRADE carrying the + // version it replaced. + stats.setRunningVersionForTest("9.9.9"); + CHECK(stats.reportDue()); + CHECK(stats.dueEvent() == mm::MoonStatsEvent::Upgrade); + CHECK(std::string(stats.previousVersion()) != "9.9.9"); + + stats.markReported(); + CHECK_FALSE(stats.reportDue()); + + // And every boot after it stays quiet, however many times. + for (int i = 0; i < 3; i++) CHECK_FALSE(stats.reportDue()); +} + +/// Not-now defers rather than answers: the question returns after the next upgrade, where Never +/// silences it for good. +/// +/// The difference is the whole reason there are four options rather than three, and neither branch +/// was covered. +TEST_CASE("not-now is asked again after an upgrade, never is not") { + mm::MoonStatsModule deferred; + deferred.setup(); + deferred.defineControls(); + deferred.setConsent(mm::MoonStatsModule::NotNow); + CHECK_FALSE(deferred.shouldPrompt()); // not right now + CHECK_FALSE(deferred.reportDue()); // and nothing is sent + + mm::MoonStatsModule refused; + refused.setup(); + refused.defineControls(); + refused.setConsent(mm::MoonStatsModule::Never); + refused.setRunningVersionForTest("9.9.9"); + CHECK_FALSE(refused.shouldPrompt()); // an upgrade does not reopen the question + CHECK_FALSE(refused.reportDue()); +} + +/// Consent withdrawn after reporting stops the next one. +/// +/// A user who says yes, upgrades, then changes their mind must not have that upgrade reported. +TEST_CASE("withdrawing consent stops a report that would otherwise be due") { + mm::MoonStatsModule stats; + stats.setup(); + stats.defineControls(); + stats.setConsent(mm::MoonStatsModule::Yes); + stats.markReported(); + + stats.setRunningVersionForTest("9.9.9"); + REQUIRE(stats.reportDue()); // the upgrade would be reported + + stats.setConsent(mm::MoonStatsModule::Never); + CHECK_FALSE(stats.reportDue()); // until it is refused + + char id[mm::kInstallationIdChars + 1] = {"unset"}; + stats.installationId(id); + CHECK(std::string(id).empty()); // and no id exists to send +} + +/// Toggling consent off and on again reports nothing new. +/// +/// Yes, then Never, then Yes is a user changing their mind, not a new installation, so the second +/// Yes must not produce a second report. The version bookkeeping is what enforces it: consent +/// gates whether a report may be sent, and the recorded version decides whether one is DUE, so +/// re-granting consent cannot re-arm a report that already went. +/// +/// Without this, one installation could inflate the totals by toggling a dropdown. +TEST_CASE("saying yes, then no, then yes again sends only the first report") { + mm::MoonStatsModule stats; + stats.setup(); + stats.defineControls(); + + stats.setConsent(mm::MoonStatsModule::Yes); + REQUIRE(stats.reportDue()); + stats.markReported(); + REQUIRE_FALSE(stats.reportDue()); + + stats.setConsent(mm::MoonStatsModule::Never); + CHECK_FALSE(stats.reportDue()); + + stats.setConsent(mm::MoonStatsModule::Yes); + CHECK_FALSE(stats.reportDue()); // still nothing: no new install, no new report + + // An actual upgrade still reports, so the rule above suppresses duplicates rather than + // silencing the device. + stats.setRunningVersionForTest("9.9.9"); + CHECK(stats.reportDue()); +} diff --git a/test/unit/core/unit_MoonStatsReport.cpp b/test/unit/core/unit_MoonStatsReport.cpp new file mode 100644 index 00000000..7c89d402 --- /dev/null +++ b/test/unit/core/unit_MoonStatsReport.cpp @@ -0,0 +1,126 @@ +// @module MoonStatsReport + +#include "doctest.h" +#include "core/MoonStatsModule.h" + +#include +#include + +#include "core/SystemModule.h" + +namespace { + +/// A module carrying exactly the controls a real device would expose that MUST NEVER be reported: +/// the identifying ones (device name, MAC), the secret ones (SSID, password), and free text the +/// user typed. Built to look as much like the real System module as possible, because a builder +/// that walked the tree rather than naming its fields would happily emit all of these. +class LeakyModule : public mm::MoonModule { +public: + LeakyModule() { setName("System"); } + + void defineControls() override { + std::strncpy(deviceName_, "ewoud-livingroom", sizeof(deviceName_) - 1); + std::strncpy(mac_, "A4:CF:12:9B:33:07", sizeof(mac_) - 1); + std::strncpy(ssid_, "Travelrouter", sizeof(ssid_) - 1); + std::strncpy(password_, "hunter2-secret", sizeof(password_) - 1); + std::strncpy(note_, "my bedroom wall, do not touch", sizeof(note_) - 1); + std::strncpy(chip_, "ESP32-S3", sizeof(chip_) - 1); + std::strncpy(flash_, "16MB", sizeof(flash_) - 1); + + controls_.addText("deviceName", deviceName_, sizeof(deviceName_)); + controls_.addReadOnly("mac", mac_, sizeof(mac_)); + controls_.addText("ssid", ssid_, sizeof(ssid_)); + controls_.addPassword("password", password_, sizeof(password_)); + controls_.addText("note", note_, sizeof(note_)); + controls_.addReadOnly("chip", chip_, sizeof(chip_)); + controls_.addReadOnly("flash", flash_, sizeof(flash_)); + } + +private: + char deviceName_[32] = {}; + char mac_[24] = {}; + char ssid_[32] = {}; + char password_[32] = {}; + char note_[40] = {}; + char chip_[16] = {}; + char flash_[8] = {}; +}; + +std::string report(mm::MoonStatsEvent event = mm::MoonStatsEvent::Install, + const char* id = nullptr, + const char* version = "4.0.0", + const char* previous = nullptr) { + LeakyModule sys; + sys.defineControls(); + mm::MoonModule* tree[] = {&sys}; + mm::JsonSink sink; + mm::buildMoonStatsReport(sink, tree, 1, event, id, version, previous); + return std::string(sink.data(), sink.size()); +} + +} // namespace + +/// The report never carries anything that identifies the person or their network, however much of +/// it the module tree holds. +/// +/// This is the privacy policy made executable. The policy promises no device name, no network +/// addresses, no credentials and no free text the user typed, and the tree here holds all four +/// sitting beside the hardware fields that ARE reported. A builder that emitted what it found +/// rather than naming each field would fail this the first time it ran. +TEST_CASE("the usage report cannot carry identifying or secret values") { + const std::string json = report(); + + // The VALUES, which is what would actually harm someone. + CHECK(json.find("ewoud-livingroom") == std::string::npos); + CHECK(json.find("A4:CF:12:9B:33:07") == std::string::npos); + CHECK(json.find("Travelrouter") == std::string::npos); + CHECK(json.find("hunter2-secret") == std::string::npos); + CHECK(json.find("my bedroom wall") == std::string::npos); + + // The KEYS, so a later refactor cannot reintroduce the field with an empty value and look + // harmless while the next change fills it in. + CHECK(json.find("deviceName") == std::string::npos); + CHECK(json.find("\"mac\"") == std::string::npos); + CHECK(json.find("ssid") == std::string::npos); + CHECK(json.find("password") == std::string::npos); + CHECK(json.find("note") == std::string::npos); +} + +/// The hardware facts the report exists for do arrive, so the test above is not passing merely +/// because the builder emits nothing. +TEST_CASE("the usage report carries the hardware facts it exists to collect") { + const std::string json = report(); + CHECK(json.find("ESP32-S3") != std::string::npos); + CHECK(json.find("16MB") != std::string::npos); + CHECK(json.find("\"chip\"") != std::string::npos); + CHECK(json.find("\"flash\"") != std::string::npos); +} + +/// An install and an upgrade are told apart by the report itself, with no identifier involved: a +/// previous version present means the firmware changed under an existing install. +TEST_CASE("an upgrade is distinguished from a fresh install by the previous version") { + const std::string fresh = report(mm::MoonStatsEvent::Install, nullptr, "4.0.0", nullptr); + CHECK(fresh.find("\"event\":\"install\"") != std::string::npos); + CHECK(fresh.find("previousVersion") == std::string::npos); + + const std::string upgraded = report(mm::MoonStatsEvent::Upgrade, nullptr, "4.1.0", "4.0.0"); + CHECK(upgraded.find("\"event\":\"upgrade\"") != std::string::npos); + CHECK(upgraded.find("\"previousVersion\":\"4.0.0\"") != std::string::npos); +} + +/// A report built without consent carries no installation id at all, rather than an empty or +/// placeholder one: nothing is generated until the user says yes. +TEST_CASE("no installation id appears until one is supplied") { + CHECK(report().find("installationId") == std::string::npos); + + const std::string withId = report(mm::MoonStatsEvent::Install, + "66b1706d30ff5c0fb1c6fdd7f6fe1151"); + CHECK(withId.find("\"installationId\":\"66b1706d30ff5c0fb1c6fdd7f6fe1151\"") + != std::string::npos); +} + +/// Only enabled modules are named, so the report describes what a device actually runs. +TEST_CASE("the report names the modules that are enabled") { + const std::string json = report(); + CHECK(json.find("\"modules\":[\"System\"]") != std::string::npos); +} diff --git a/test/unit/core/unit_MoonTalkModule.cpp b/test/unit/core/unit_MoonTalkModule.cpp new file mode 100644 index 00000000..7d719688 --- /dev/null +++ b/test/unit/core/unit_MoonTalkModule.cpp @@ -0,0 +1,36 @@ +// @module MoonTalkModule + +#include "doctest.h" +#include "core/MoonTalkModule.h" + +#include + +/// Naming is a SECOND consent on top of posting, and it starts off. +/// +/// A device name identifies a person where an installation id does not, so agreeing to publish +/// messages is not agreeing to be named. `sharesName()` requires BOTH, which is what keeps the two +/// decisions separate. +TEST_CASE("naming is a second consent, off by default") { + mm::MoonTalkModule talk; + talk.defineControls(); + + CHECK(talk.consent() == mm::MoonTalkModule::Unanswered); + CHECK_FALSE(talk.sharesName()); +} + +/// The device name is READ from the module tree, never held as a copy. +/// +/// An earlier shape had a `setDeviceName()` setter that nothing ever called, so the name stayed +/// empty and every message went out anonymous while the `shareName` toggle said otherwise: the UI +/// promised something the wire did not deliver, and only reading the stored rows revealed it. +/// +/// With no System module present the lookup yields an empty string rather than misbehaving, which +/// is the case a probe instance (built by /api/types and thrown away) actually hits. +TEST_CASE("the device name is looked up rather than stored") { + mm::MoonTalkModule talk; + talk.defineControls(); + + const char* name = talk.deviceName(); + REQUIRE(name != nullptr); // never null, whatever the tree holds + CHECK(std::string(name).size() < 32); +} diff --git a/test/unit/core/unit_sha256.cpp b/test/unit/core/unit_sha256.cpp new file mode 100644 index 00000000..7258dfd7 --- /dev/null +++ b/test/unit/core/unit_sha256.cpp @@ -0,0 +1,55 @@ +// @module sha256 + +#include "doctest.h" +#include "core/sha256.h" + +#include + +namespace { +std::string hex(const std::string& in) { + char buf[mm::kSha256DigestSize * 2 + 1]; + mm::sha256Hex(in.data(), in.size(), buf, mm::kSha256DigestSize); + return std::string(buf); +} +} // namespace + +/// The digest matches the published FIPS 180-4 values, so this is SHA-256 rather than a function +/// that merely agrees with itself. +/// +/// These three are the standard vectors: the empty string, the one-block "abc" example from the +/// specification's own appendix, and the two-block example that exercises the padding path where +/// the length does not fit beside the terminator. +TEST_CASE("the digest matches the published SHA-256 test vectors") { + CHECK(hex("") == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + CHECK(hex("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + CHECK(hex("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq") + == "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); +} + +/// A message that lands exactly on a block boundary, and one just over it, both hash correctly. +/// +/// This is where a padding bug hides: 55 bytes leaves room for the length, 56 does not and forces a +/// second block, and 64 is a whole block with the padding entirely in the next one. +TEST_CASE("messages around the block boundary hash correctly") { + CHECK(hex(std::string(55, 'a')) + == "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318"); + CHECK(hex(std::string(56, 'a')) + == "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a"); + CHECK(hex(std::string(64, 'a')) + == "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb"); +} + +/// A longer message spanning several blocks, pinning the loop rather than a single compression. +TEST_CASE("a multi-block message hashes correctly") { + CHECK(hex(std::string(1000, 'a')) + == "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3"); +} + +/// The hex helper truncates to the requested length and always terminates, which is what the +/// installation id relies on for its 32 characters. +TEST_CASE("the hex helper truncates the digest to the requested length") { + char buf[64] = {}; + mm::sha256Hex("abc", 3, buf, 16); + CHECK(std::string(buf).size() == 32); + CHECK(std::string(buf) == "ba7816bf8f01cfea414140de5dae2223"); +} From e7a6b65480d686b690b069bc764c46f46ae25aee Mon Sep 17 00:00:00 2001 From: ewowi Date: Fri, 11 Sep 2026 11:20:41 +0200 Subject: [PATCH 2/3] Add MoonCloud: opt-in usage stats and a public message board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devices can tell the project what hardware and configuration they run on, and post to a shared message board. Both are strictly opt-in and off by default: nothing leaves a device, and nothing is read from the server, until someone ticks the box. The data lands in a Cloudflare Worker backed by D1, and the same numbers render back onto the card that asked for consent. Performance: esp32 flash 2,028 KB (+3 KB, 81% of slot); desktop unchanged at 1,888 KB. Desktop tick 132 us (+5), 7,575 FPS (-299). esp32 tick 8,354 us, with MoonCloud at 4 us per tick once MoonTalk's tick1s override was removed. Core: - MoonCloudModule owns the compiled-in address and the POST seam; MoonStatsModule and MoonTalkModule hang off it as children, each with its own consent. - Installation id is SHA-256(salt, MAC) truncated to 32 hex chars, one scheme on every target: eFuse MAC on ESP32, a stored random locally-administered address on desktop. The raw MAC never leaves the device. - Vendored sha256.{h,cpp} (~150 lines, FIPS 180-4, pinned against published vectors) rather than a library, because ESP-IDF ships mbedtls while desktop would link something else. - MoonTalk publishes on a `send` button. The first shape sent on a control write, which a Text control emits per debounced keystroke: typing "hello" published "hel" and "hell". A settle window then guessed when typing had stopped and cleared half-typed messages when it guessed wrong. The button removes the guess and the tick1s override with it. - Consent is a checkbox on both members. A four-option select (unanswered/yes/not now/never) cost a persisted version and a branch in setup() to express a distinction nobody asked for. - reportedVersion is actually persisted. It used addReadOnly, a type the persistence layer skips, so it came back empty on every boot and a consented device re-sent an `install` every time; the reports row was overwritten by its primary key, which hid it, while events gained a row per boot. markReported() now schedules the save rather than only marking the module dirty. - The report names what a user ADDED, tagged by role, plus memory and light count. Filtering on isWiredByCode() reported every top-level module as `generic:System`, because only children are marked wired. Light domain: - platform::httpsPost is the one seam: libcurl on desktop (optional via MM_HAVE_CURL), esp_http_client with the cert bundle on ESP32. UI: - Charts for version, chip, board, flash, PSRAM, SDK, install/upgrade, country and build, plus drivers, services, layouts, effects and modifiers split by role, and memory and light count as server-bucketed ranges. - Clicking a slice filters every chart, with the active filter named and a Clear button. Every pie counts everything until a reader narrows it: a filter is a choice, never a hidden default. - Without consent nothing is fetched at all, and the card draws its own headings over placeholder discs. Consent is what decides whether a member reads, so switching it rebuilds that card: once when switched on, and not at all when switched off, since nothing on the server changed. - Enter in a text field presses the card's `send` button, where the module has one. - A card that cannot reach the server says so, and a board nobody read says that rather than "No messages yet." Scripts/MoonDeck: - run_mooncloud.py and purge_mooncloud.py for the local Worker and for emptying the tables. Tests: - unit_sha256, unit_InstallationId, unit_MoonStatsReport, unit_MoonStatsModule, unit_MoonTalkModule, plus mooncloud-report and ui-mooncloud on the JS side. 1,937 C++ cases, 155 JS, 170 Python. Docs/CI: - privacy-policy.md rewritten from 142 lines to 79, stating a rule rather than an inventory, and listing the fields the report actually carries. - Three mooncloud/ docs merged into one README (274 lines to 102). - CLAUDE.md and coding-standards.md: "do not remove comments" replaced with "comments are minimal, dense, about WHY; condense, don't delete", which the MoonCloud headers then follow (654 lines to 512). Reviews: - 👾 Two Reviewer passes, 39 findings, all processed. Three were real device bugs that green tests had missed: the unsaved reportedVersion above, a NotNow deferral that never ran, and a deviceName test that compared "" with "". Others: numeric report fields reached the server as strings and stored zeros; the sender id was truncated at storage while validation required 32 chars, rejecting every message with 400; `?module=` and an unprefixed-entry fallback were migration code for rows that cannot exist. Verified on hardware: Olimex ESP32-Gateway (classic ESP32) and a macOS desktop build, both reporting over real TLS and appearing in the charts. The address is the workers.dev one: a Custom Domain was tried and reverted, because Cloudflare issued its certificate from a CA absent from IDF's default root bundle and every ESP32 handshake failed while desktop's system trust store accepted it. Known, not fixed here: the D1 tables hold bench data from building this. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 4 +- docs/backlog/backlog-core.md | 25 +- docs/coding-standards.md | 2 +- .../plans/Plan-20260910 - MoonCloud.md | 36 +- docs/metrics/repo-health.json | 66 +-- docs/metrics/repo-health.md | 56 +-- docs/moonmodules/core/system.md | 4 +- docs/privacy-policy.md | 2 +- mooncloud/CHANGES.md | 76 ---- mooncloud/DEPLOY.md | 89 ---- mooncloud/README.md | 99 +++-- mooncloud/schema.sql | 6 + mooncloud/worker.js | 235 +++++++++-- moondeck/run/purge_mooncloud.py | 12 +- mooninstaller/index.html | 4 + src/core/MoonCloudModule.h | 112 ++--- src/core/MoonStatsModule.h | 301 +++++++------ src/core/MoonTalkModule.h | 122 ++---- src/main.cpp | 4 +- src/ui/app.js | 397 ++++++++++++++---- src/ui/style.css | 30 +- test/js/mooncloud-report.test.mjs | 22 + test/js/ui-mooncloud.test.mjs | 195 +++++++-- .../scenario_MoonModule_control_change.json | 12 +- .../light/scenario_Audio_mutation.json | 16 +- test/scenarios/light/scenario_Aurora_fps.json | 36 +- .../light/scenario_Driver_mutation.json | 12 +- .../light/scenario_Effects_composition.json | 4 +- .../light/scenario_Fields_polar_lut.json | 26 +- .../light/scenario_Fluid_solver.json | 54 +-- .../light/scenario_GridBlacks_blackpixel.json | 10 +- .../light/scenario_GridLayout_resize.json | 6 +- .../light/scenario_Layer_base_pipeline.json | 2 +- .../light/scenario_Layer_memory_1to1.json | 4 +- .../light/scenario_Layouts_mutation.json | 10 +- .../scenario_MoonLiveEffect_livescript.json | 34 +- .../light/scenario_MoonLive_pipeline.json | 10 +- .../scenario_MultiplyModifier_memory_lut.json | 2 +- .../scenario_MultiplyModifier_pipeline.json | 2 +- .../light/scenario_Trails_ladder.json | 28 +- .../light/scenario_modifier_chain.json | 12 +- .../light/scenario_modifier_swap.json | 10 +- test/scenarios/light/scenario_perf_full.json | 80 ++-- test/scenarios/light/scenario_perf_light.json | 10 +- .../light/scenario_peripheral_grid_sweep.json | 58 +-- .../light/scenario_peripheral_switch.json | 12 +- test/unit/core/unit_MoonStatsModule.cpp | 204 ++++++--- test/unit/core/unit_MoonStatsReport.cpp | 77 +++- test/unit/core/unit_MoonTalkModule.cpp | 100 ++++- 49 files changed, 1652 insertions(+), 1078 deletions(-) delete mode 100644 mooncloud/CHANGES.md delete mode 100644 mooncloud/DEPLOY.md diff --git a/CLAUDE.md b/CLAUDE.md index b096b3f4..f9d48f50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,9 @@ product owner triggers it; say in one line what was picked and why. Docs land with the code, not at merge time: the module's spec and catalog card describe what actually shipped ([coding-standards § Documentation model](docs/coding-standards.md#documentation-model)); a breaking change gets its entry in [docs/MIGRATING.md](docs/MIGRATING.md); a shipped backlog item or spec draft is deleted. The merge gate only verifies this happened. -**How the writing looks: American spelling, no em-dashes.** `color`, `serialize`, `behavior`, `analyze`; a comma, colon or full stop where an em-dash wants to go. In comments, docs, commit messages and chat replies alike. Both rules are enforced mechanically by `check_prose.py` (a write-time hook, and again at the commit gate), because they are exactly the kind of habit that stays invisible to its own author. Full rationale: [coding-standards § Writing](docs/coding-standards.md). +**How the writing looks: American spelling, no em-dashes.** `color`, `serialize`, `behavior`, `analyze`; a comma, colon or full stop where an em-dash wants to go. In comments, docs, commit messages and chat replies alike. Both rules are enforced mechanically by `check_prose.py` (a write-time hook, and again at the commit gate), because they are exactly the kind of habit that stays invisible to its own author. + +**And how much of it there is: minimal, dense, straight to the point.** A comment or a doc paragraph says what the code cannot (the reason, the constraint, the failure it prevents) in the fewest words that carry it. Restating the code is noise; so is a paragraph where a clause would do. Nothing is stripped wholesale, and a reason still true is shortened rather than dropped: **condense, don't delete**. No check catches this one, so it is judgment, applied when writing and again when reviewing. Full rationale: [coding-standards § Writing](docs/coding-standards.md). ### Commit diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index dbc07f8e..f9f21f25 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -1792,21 +1792,22 @@ making the compile-time constant win: re-assert `kFirmwareName` after the config only at `defineControls()`, and pin it with a test that loads a config naming a DIFFERENT variant and checks the control still reads the compiled one. -## A MoonCloud card cannot tell "unreachable" from "nothing there" (2026-09-11) +## MoonCloud: share a MoonLive script over MoonTalk (2026-09-10) -Both MoonCloud readers in `src/ui/app.js` swallow every failure into the empty state. `renderMoonTalk`'s loader is the clearest case: +Sketched 2026-09-10 while Talk was built. **The unique feature nobody else has**: projectMM ships a scripting language whose programs are about a kilobyte of text, and a message board between devices. Together they mean a script someone wrote on their wall is one tap from running on yours. -```js -fetch(kMoonCloudUrl + "/api/talk", { cache: "no-store" }) - .then(r => r.ok ? r.json() : null) - .then(d => draw(d?.messages)) - .catch(() => draw(null)); -``` +**What makes it plausible.** The scripts are tiny: the shipped `.mle` files run 780 to 1907 bytes and the whole library of twenty-odd is 36 KB. A script is self-contained by design, so the text is the artifact. And the device already compiles and runs arbitrary script text safely, which is the hard half and is done. + +**The design work is that a script is five to seven times a message.** Talk caps a message at 280 characters, deliberately: a chat message is a sentence, and one caller must not fill the table. Three options: -A DNS failure, a dead network, a 500, and a genuinely empty message list all reach `draw(null)` and render the same nothing. `renderMoonCloudStats` fails even more quietly: its `.catch` drops the section entirely, so an unreachable server and a server with no reports both render a card with no statistics on it. The Worker's own dev page (`mooncloud/worker.js`) does the third variant, printing `"No data yet."` on any failure, which states as fact something it has no evidence for. +- **A second endpoint** (`/api/script`) with its own size cap and table, and a message that references it by id. The chat table stays a chat table, a script is fetched when someone wants it, and a board read stays small. Most work, cleanest shape, and the recommendation. +- **An attachment column on the message**, capped separately. One table and one post, at the cost of every board read carrying script text unless the query is careful. +- **Share a URL.** A script already lives in the user's File Manager and a device could serve it over the LAN. Free, and limited to one network, which is the case that makes the feature interesting. -Found on the bench: with `kMoonCloudUrl` freshly switched to `stats.moonmodules.org`, a stale negative DNS entry on the local network made the name unresolvable to the browser while the firmware's own POSTs kept succeeding. Messages were arriving on the server and the log stayed empty, with nothing on screen to say why. The device looked broken and was not. +**A shared script is code from a stranger**, in a way chat text is not. Three questions the design answers rather than discovers: -The fix is to distinguish the cases the promise already separates: a rejected fetch is unreachable, `!r.ok` is a server error, and a parsed empty array is genuinely empty. Only the last one may claim there is no data. A card that cannot reach MoonCloud should say so, because the user's next action differs completely: check the network, versus wait for someone to post. +1. **Loading is explicit and reversible.** A script arrives as something to look at, and taking it leaves what the user already has intact. `/moonlive` (user) shadowing `/.moonlive` (factory) is the existing trap. +2. **The device is the sandbox.** MoonLive reaches no filesystem and no network, so the blast radius of a hostile script is the LEDs and the render budget. Worth confirming rather than assuming: a script that never yields is a watchdog reset, which is a denial of service on someone's wall. +3. **Attribution and removal.** Any sender id can be claimed, and a message stays once posted. Fine for chat, weaker for code. -Worth pairing with the request-storm item in the same area: `renderMoonTalk` fetches uncached on every `createCard`, so a failing endpoint is also retried far more often than it needs to be. +Depends on Talk shipping and on the server being deployed. diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 1e5aa9bb..82bc6b38 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -18,7 +18,7 @@ Decided once; not re-derived per file. - **American English spelling, everywhere.** Code identifiers, JSON/wire keys, comments, docs, and UI strings all use US spelling: `color` (not the British form), `serialize`, `optimize`, `initialize`, `normalize`, `behavior`, `center`, `gray`, `canceled`, `analyze`. Two reasons: (1) it's the dominant technical-project convention (LLVM, Chromium, the Linux kernel are American throughout), and (2) the graphics/LED ecosystem we interop with is uniformly American (`CRGB`, `color`, `colorFromPalette`, CSS `color`), so a British identifier fights the whole toolchain. The real hazard mixing creates: a grep for one dialect silently misses the other, and a wire key that drifts between dialects breaks a cross-device contract without a compile error. So one dialect, chosen to match the ecosystem. Watch-outs: `analysis` is already US (keep it; only `analyse`→`analyze`); a proper noun keeps its own spelling (`Travelrouter`, a product name). Existing British spellings in older prose get converted **opportunistically when a file is touched**, not in one big sweep (same as the em-dash rule above); a code identifier or wire key in the wrong dialect is the higher-priority fix, since it's a correctness hazard, not just style. - **All Python through `uv run`.** Never bare `python`/`python3`: not in shell commands, not in CMake, not in docs. uv manages the project venv and is the project standard ([moondeck/MoonDeck.md](../moondeck/MoonDeck.md)); bare `python3` isn't on PATH on Windows, and the macOS Python launcher pops a Store prompt. In CMake, resolve `find_program(UV_EXECUTABLE NAMES uv REQUIRED HINTS "$ENV{USERPROFILE}/.local/bin" "$ENV{HOME}/.local/bin")` once and use `${UV_EXECUTABLE} run python …` thereafter; the shared `src/ui/embed_ui.cmake` takes a `PYTHON_CMD` parameter (desktop passes uv; ESP32 passes IDF's Python). The one exception is `esp32/main/CMakeLists.txt`: ESP-IDF builds use IDF's own bundled Python venv via `find_package(Python3)`, since IDF manages that environment itself. - **Consider extending before creating.** When adding a feature, check whether an existing module extends cleanly; a new file is fine if genuinely cleaner, but justify it. -- **Do not remove comments** unless they are outdated or factually wrong. Comments document intent and context; removing them silently loses knowledge. +- **Comments are minimal, dense, and about WHY.** A comment earns its place by saying something the code cannot: the reason for a choice, the constraint behind it, the failure it prevents. Restating what the line already says is noise, and so is a paragraph where a clause would do. Write the shortest version that still carries the reason. **Removing a comment needs the same justification as removing code**: it is outdated, factually wrong, or it only restated the code. Never strip comments wholesale to hit a length target, and never delete a reason you cannot reconstruct: if the *why* is still true, keep it and shorten it. **Condense, don't delete.** - **Reference, don't copy.** Prior art (friend repos, datasheets, our own prototype branches) holds proven approaches: study it, take the ideas, write our own code, never copy or trace the structure. Credits live in the [friend-repo digests](friend-repos/README.md) and per-module prior-art sections. - **Minimal comments in MoonLive scripts, in exactly three places.** A `.mle`/`.mll`/`.mlm`/`.mls`/`.mlp` is a user-facing artifact shown in an editor on the device's own card, not a C++ source file: the reader is looking at the effect, and a comment block longer than the code buries it. So the budget is fixed, and it is one line each: diff --git a/docs/history/plans/Plan-20260910 - MoonCloud.md b/docs/history/plans/Plan-20260910 - MoonCloud.md index 5ec8478d..e9acabc3 100644 --- a/docs/history/plans/Plan-20260910 - MoonCloud.md +++ b/docs/history/plans/Plan-20260910 - MoonCloud.md @@ -116,37 +116,9 @@ The Stats card fetches `GET /api/stats` and renders what everyone reported as pi **The card is the UI, which is why the server is an API.** There is one place to build and keep in sync with the schema, contributing earns something visible in return, and a user sees the shape of the data their own report joined. `/api/stats` is readable by anyone, so it carries aggregates that are safe to show the world, which counts of chips and versions are. -Fetched when the card is opened, and a failure leaves the card without the section: the aggregate is a reward rather than something the device needs. +Fetched when the card is opened. A failure says so on the card ("Cannot reach the MoonCloud server.") rather than rendering as an empty section: an unreachable server and one with nothing in it lead a reader to different actions. -`server` and `serverPort` are controls on the card, so one setting drives both the report and the fetch. Port 443 or none means the public server over HTTPS; anything else is a local or self-hosted MoonCloud over plain HTTP. - -## Next: filter the charts by clicking a slice - -Sketched 2026-09-10; nothing started. Every pie counts every report, and the `Build` pie shows the released-against-development split rather than the other charts pre-applying it. The natural next step is to make a slice a FILTER: click `development` and every other chart re-counts within it, click `NL` and the rest describe the Netherlands. - -That is why no chart filters by default. A default that quietly removed development installs was the first shape, and it made the headline number smaller with nothing saying why, while hiding a developer's own bench boards from the card running on one of them. Counting everything and showing the split is both honest and the thing cross-filtering builds on: a filter has to be something the reader chose, or it is just a hidden default with extra steps. - -The server already takes `?dev=0`, so the shape exists for one dimension. Generalising it means a filter parameter per dimension and one query builder rather than a special case per chart. - -## Next: share a MoonLive script over MoonTalk - -Sketched 2026-09-10 while Talk was built. **The unique feature nobody else has**: projectMM ships a scripting language whose programs are about a kilobyte of text, and a message board between devices. Together they mean a script someone wrote on their wall is one tap from running on yours. - -**What makes it plausible.** The scripts are tiny: the shipped `.mle` files run 780 to 1907 bytes and the whole library of twenty-odd is 36 KB. A script is self-contained by design, so the text is the artifact. And the device already compiles and runs arbitrary script text safely, which is the hard half and is done. - -**The design work is that a script is five to seven times a message.** Talk caps a message at 280 characters, deliberately: a chat message is a sentence, and one caller must not fill the table. Three options: - -- **A second endpoint** (`/api/script`) with its own size cap and table, and a message that references it by id. The chat table stays a chat table, a script is fetched when someone wants it, and a board read stays small. Most work, cleanest shape, and the recommendation. -- **An attachment column on the message**, capped separately. One table and one post, at the cost of every board read carrying script text unless the query is careful. -- **Share a URL.** A script already lives in the user's File Manager and a device could serve it over the LAN. Free, and limited to one network, which is the case that makes the feature interesting. - -**A shared script is code from a stranger**, in a way chat text is not. Three questions the design answers rather than discovers: - -1. **Loading is explicit and reversible.** A script arrives as something to look at, and taking it leaves what the user already has intact. `/moonlive` (user) shadowing `/.moonlive` (factory) is the existing trap. -2. **The device is the sandbox.** MoonLive reaches no filesystem and no network, so the blast radius of a hostile script is the LEDs and the render budget. Worth confirming rather than assuming: a script that never yields is a watchdog reset, which is a denial of service on someone's wall. -3. **Attribution and removal.** Any sender id can be claimed, and a message stays once posted. Fine for chat, weaker for code. - -Depends on Talk shipping and on the server being deployed. +The address is compiled in (`kHost` in `MoonCloudModule.h`, `kMoonCloudUrl` in `src/ui/app.js`), so one constant drives both the report and the fetch. HTTPS only: there is one MoonCloud, and a device that could be pointed elsewhere could be pointed at nothing, where a failed report is never retried. Moving the server is a release. ## Scope @@ -172,6 +144,6 @@ Depends on Talk shipping and on the server being deployed. **ewowi owns the Cloudflare account** (settled 2026-09-10). That was the last open question and the one that kept this feature in the backlog: the code was never the blocker, a person willing to hold the account was. Stats and Talk run on Workers plus D1, both free tier; Sync would need Durable Objects, which are paid, so it is also the point where MoonCloud starts costing money. -What ownership means in practice: the login that deploys, the address that gets the alerts, and the person who notices when it breaks. [mooncloud/DEPLOY.md](../../../mooncloud/DEPLOY.md) is the sequence, and three of its steps need that account rather than any automation: `wrangler login`, the DNS record for `stats.moonmodules.org`, and the decision to flip the firmware default. +What ownership means in practice: the login that deploys, the address that gets the alerts, and the person who notices when it breaks. [mooncloud/README.md](../../../mooncloud/README.md) is the sequence, and three of its steps need that account rather than any automation: `wrangler login`, the Cloudflare account itself, and the decision to flip the firmware default. -**The firmware default is the last step and the one with no recovery.** `MoonCloudModule.h` ships pointing at `127.0.0.1:8787`, a local server for development. Changed before the domain answers, every device in the wild sends one report into nothing and never retries, because a failed report is deliberately not queued. +**The firmware default is the last step and the one with no recovery.** `MoonCloudModule.h` ships pointing at the `workers.dev` address. A Custom Domain was tried and reverted: Cloudflare issued its certificate from Google Trust Services, which is not in IDF's default root bundle, so every ESP32 handshake failed while desktop's system trust store accepted it. The address is compiled in and hidden, so a prettier one buys nothing a device can use. Changed before an address answers, every device in the wild sends one report into nothing and never retries, because a failed report is deliberately not queued. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 11d2ce98..d1e1ca55 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "38f66d7b", + "commit": "a0e56b40", "flash": { "esp32s3-n16r8": 2103024, - "desktop": 1933496, - "esp32": 2073632, + "desktop": 1933048, + "esp32": 2076368, "esp32p4rev1-eth": 1997440, "esp32p4rev1-eth-wifi": 2284640, "esp32s3-n8r8": 2087168, @@ -31,8 +31,8 @@ }, "perf": { "desktop": { - "tick_us": 127, - "fps": 7874, + "tick_us": 132, + "fps": 7575, "scenario_p50": { "Layer_base_pipeline": { "p50": 69, @@ -55,7 +55,7 @@ "scenario_matrix": { "MoonModule_control_change": { "desktop-macos": { - "p50": 124, + "p50": 125, "p95": 246, "n": 32, "last": "2026-09-11" @@ -197,7 +197,7 @@ }, "Aurora_fps": { "desktop-macos": { - "p50": 1564, + "p50": 1573, "p95": 2013, "n": 32, "last": "2026-09-11" @@ -245,7 +245,7 @@ }, "Fields_polar_lut": { "desktop-macos": { - "p50": 1269, + "p50": 1271, "p95": 1892, "n": 32, "last": "2026-09-11" @@ -253,7 +253,7 @@ }, "Fluid_solver": { "desktop-macos": { - "p50": 226, + "p50": 222, "p95": 265, "n": 32, "last": "2026-09-11" @@ -262,7 +262,7 @@ "GridBlacks_blackpixel": { "desktop-macos": { "p50": 2, - "p95": 3, + "p95": 2, "n": 32, "last": "2026-09-11" }, @@ -360,7 +360,7 @@ "Layouts_mutation": { "desktop-macos": { "p50": 94, - "p95": 156, + "p95": 121, "n": 32, "last": "2026-09-11" }, @@ -413,7 +413,7 @@ "desktop-macos": { "p50": 6, "p95": 8, - "n": 28, + "n": 31, "last": "2026-09-11" }, "esp32s3-n16r8": { @@ -511,7 +511,7 @@ }, "modifier_chain": { "desktop-macos": { - "p50": 43, + "p50": 44, "p95": 48, "n": 32, "last": "2026-09-11" @@ -531,8 +531,8 @@ }, "modifier_swap": { "desktop-macos": { - "p50": 21, - "p95": 25, + "p50": 22, + "p95": 24, "n": 32, "last": "2026-09-11" }, @@ -569,7 +569,7 @@ }, "perf_full": { "desktop-macos": { - "p50": 264, + "p50": 259, "p95": 301, "n": 32, "last": "2026-09-11" @@ -645,7 +645,7 @@ "last": "2026-07-25" }, "desktop-macos": { - "p50": 264, + "p50": 260, "p95": 813, "n": 32, "last": "2026-09-11" @@ -698,17 +698,17 @@ } }, "loc": { - "core": 27044, + "core": 26963, "light": 35665, "platform": 18948, - "ui": 10971, - "test": 58444, - "moondeck": 23112 + "ui": 11121, + "test": 58697, + "moondeck": 23122 }, "comments": { "core": { - "lines": 10868, - "ratio": 0.435 + "lines": 10779, + "ratio": 0.432 }, "light": { "lines": 13554, @@ -719,33 +719,33 @@ "ratio": 0.387 }, "ui": { - "lines": 3279, - "ratio": 0.316 + "lines": 3301, + "ratio": 0.314 }, "test": { - "lines": 10999, + "lines": 11058, "ratio": 0.216 }, "moondeck": { - "lines": 3737, + "lines": 3739, "ratio": 0.185 } }, "tests": { - "cases": 2030, + "cases": 2037, "scenarios": 27 }, "docs": { "md_files": 224, - "md_lines": 38096, + "md_lines": 38088, "plans_files": 119, - "backlog_lines": 7224, + "backlog_lines": 7244, "lessons_lines": 705, - "claude_md_lines": 259 + "claude_md_lines": 261 }, "complexity": { - "functions": 3548, - "over_threshold": 254, + "functions": 3552, + "over_threshold": 253, "worst_ccn": 128 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 2c15306a..18c3ac64 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `38f66d7b`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `a0e56b40`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,10 +8,10 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop | 1,888 KB (+22 KB) ⚠ | - | - | yes | -| esp32 | 2,025 KB (+13 KB) ⚠ | 2,496 KB | 81% | yes | +| desktop | 1,888 KB (−0 KB) ✓ | - | - | yes | +| esp32 | 2,028 KB (+3 KB) ⚠ | 2,496 KB | 81% | yes | | esp32-16mb | 2,012 KB | 4,096 KB | 49% | carried 2d | -| esp32-eth | 1,642 KB (+277 KB) ⚠ | 2,496 KB | 66% | yes | +| esp32-eth | 1,642 KB | 2,496 KB | 66% | carried 0d | | esp32-pico | 2,058 KB | 3,072 KB | 67% | carried 2d | | esp32-wrover | 1,801 KB | - | - | carried (age?) | | esp32p4rev1-eth | 1,951 KB | 4,096 KB | 48% | carried 2d | @@ -29,39 +29,39 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Tick | FPS | |---|---:|---:| -| desktop | 127 µs (−20 µs) ✓ | 7,874 (+1,072) ✓ | +| desktop | 132 µs (+5 µs) ⚠ | 7,575 (−299) ⚠ | | esp32 | 8,354 µs | 119 | ### Scenario tick by target (p50 of each sample window) | Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | unknown | |---|---|---|---|---|---|---|---|---|---| -| Audio_mutation | 24 (+1) ⚠ | 40 ? | 13,152 | 47 ? | - | - | - | - | - | -| Aurora_fps | 1,564 (+8) ⚠ | - | - | - | - | - | - | - | - | +| Audio_mutation | 24 | 40 ? | 13,152 | 47 ? | - | - | - | - | - | +| Aurora_fps | 1,573 (+9) ⚠ | - | - | - | - | - | - | - | - | | Driver_mutation | 20 | 42 ? | 12,812 | 39 ? | - | - | - | - | - | | Effects_composition | 147 | 549 ? | - | - | - | - | - | - | - | -| Fields_polar_lut | 1,269 (+8) ⚠ | - | - | - | - | - | - | - | - | -| Fluid_solver | 226 | - | - | - | - | - | - | - | - | +| Fields_polar_lut | 1,271 (+2) ⚠ | - | - | - | - | - | - | - | - | +| Fluid_solver | 222 (−4) ✓ | - | - | - | - | - | - | - | - | | GridBlacks_blackpixel | 2 | 8 ? | 269 ? | 267 ? | - | - | - | - | - | -| GridLayout_resize | 121 (−1) ✓ | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - | +| GridLayout_resize | 121 | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - | | Layer_base_pipeline | 69 | 118 ? | - | - | - | - | - | - | - | | Layer_memory_1to1 | 5 | 1 ? | - | - | - | - | - | - | - | | Layouts_mutation | 94 | 111 ? | 13,692 | 45 ? | - | - | 27 ? | - | - | | MoonLiveEffect_controls | 11 ? | - | 12,901 | 4,624 ? | - | - | - | - | - | | MoonLiveEffect_livescript | 6 | - | 13,433 ? | 8,255 ? | 11,336 ? | - | - | - | - | | MoonLive_pipeline | 5 | 1 ? | 9,604 ? | 3,278 ? | - | 11,398 ? | - | - | 4,393 ? | -| MoonModule_control_change | 124 (−1) ✓ | 262 ? | 212 ? | 166 ? | 165 ? | - | 111,731 ? | 89,895 ? | - | +| MoonModule_control_change | 125 (+1) ⚠ | 262 ? | 212 ? | 166 ? | 165 ? | - | 111,731 ? | 89,895 ? | - | | MqttModule_haDiscovery_toggle | 3 ? | - | 36 ? | 36 ? | - | - | - | - | - | | MultiplyModifier_memory_lut | 3 | 3 ? | - | - | - | - | - | - | - | | MultiplyModifier_pipeline | 121 | 225 ? | - | - | - | - | - | - | - | | NetworkModule_eth_reconfigure | - | - | 1,169 ? | 97,843 ? | - | - | - | - | - | | NetworkModule_mdns_toggle | 13 ? | - | 36 ? | 36 ? | 21 ? | - | 109,767 ? | 93,963 ? | - | | Trails_ladder | 364 | - | - | - | - | - | - | - | - | -| modifier_chain | 43 | 69 ? | 13,337 | - | - | - | - | - | - | -| modifier_swap | 21 (−1) ✓ | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - | -| perf_full | 264 (−1) ✓ | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - | +| modifier_chain | 44 (+1) ⚠ | 69 ? | 13,337 | - | - | - | - | - | - | +| modifier_swap | 22 (+1) ⚠ | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - | +| perf_full | 259 (−5) ✓ | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - | | perf_light | 16 | 35 ? | 2,183 | 2,485 ? | 2,038 ? | - | - | - | - | -| peripheral_grid_sweep | 264 (−8) ✓ | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - | +| peripheral_grid_sweep | 260 (−4) ✓ | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - | | peripheral_switch | 4 | 9 ? | 437 | 46 ? | 217 ? | - | - | - | - | Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first impression rather than a percentile; several are months old and were captured during a network reconfigure, so they read as whole milliseconds. `-` means that target has never run that scenario. @@ -81,36 +81,36 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 27,044 (+826) ⚠ | 10,868 | 43.5 % (+0.1 %) ⚠ | +| core | 26,963 (−81) ✓ | 10,779 | 43.2 % (−0.3 %) ✓ | | light | 35,665 | 13,554 | 41.7 % | -| platform | 18,948 (+243) ⚠ | 6,705 | 38.7 % | -| ui | 10,971 (+312) ⚠ | 3,279 | 31.6 % | -| test | 58,444 (+591) ⚠ | 10,999 | 21.6 % (+0.1 %) ⚠ | -| moondeck | 23,112 (+206) ⚠ | 3,737 | 18.5 % (−0.1 %) ✓ | +| platform | 18,948 | 6,705 | 38.7 % | +| ui | 11,121 (+150) ⚠ | 3,301 | 31.4 % (−0.2 %) ✓ | +| test | 58,697 (+253) ⚠ | 11,058 | 21.6 % | +| moondeck | 23,122 (+10) ⚠ | 3,739 | 18.5 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,030 (+26) ✓ | +| unit cases | 2,037 (+7) ✓ | | scenarios | 27 | ## Complexity | Metric | Value | |---|---:| -| functions | 3,548 (+34) ✓ | -| over threshold | 254 (+3) ⚠ | +| functions | 3,552 (+4) ✓ | +| over threshold | 253 (−1) ✓ | | worst CCN | 128 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 224 (+2) ⚠ | -| markdown lines | 38,096 (+472) ⚠ | -| plan files | 119 (+2) ⚠ | -| backlog lines | 7,224 (+69) ⚠ | +| markdown files | 224 | +| markdown lines | 38,088 (−8) ✓ | +| plan files | 119 | +| backlog lines | 7,244 (+20) ⚠ | | lessons lines | 705 | -| CLAUDE.md lines | 259 | +| CLAUDE.md lines | 261 (+2) ⚠ | diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md index 4b10be9f..dd6ed64d 100644 --- a/docs/moonmodules/core/system.md +++ b/docs/moonmodules/core/system.md @@ -130,12 +130,12 @@ The container for everything projectMM does with a server MoonModules runs. It h One opt-in report about this install, sent once when the firmware is installed or upgraded, so development effort goes where the users are. Off until you answer yes. Everything it sends, and the reasoning behind the identifier, is in the [privacy policy](../../privacy-policy.md). -- `consent`: **Not answered** / **Yes** / **Not now** / **Never**. Nothing is sent, and no identifier is computed, until this reads Yes. **Never** is remembered forever; **Not now** defers, so the question returns after the next upgrade rather than never. +- `consent`: a checkbox, off by default. Nothing is sent, and no identifier is computed, while it is off. - read-only: `version` (what is running) and `reportedVersion` (what last produced a report). They differ exactly when a report is due, which is what makes one upgrade send one report and a reboot send nothing. The report carries hardware and configuration: chip, flash, PSRAM, SDK, device model, and which modules are enabled. It carries no device name, no addresses, no credentials and no text you typed, and a unit test asserts those cannot appear in it. -The card also shows the totals everyone else reported, which is why there is no separate public dashboard: contributing earns the answer back where you already are. Reading them sends nothing about you and works whether or not you consented. +The card also shows the totals everyone else reported: contributing earns the answer back where you already are. The charts are drawn empty until consent is on, so what saying yes gets you is visible before you say it. **MoonCloud** is the family name for anything projectMM does with a server we run. Stats and Talk are its members today; device-to-device sync over the internet is planned as a third, with its own consent. diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index 42487d06..4d6861b5 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -20,7 +20,7 @@ Each of these is a separate choice with its own setting. Turning one on says not ### MoonCloud Stats -One report when the firmware is installed or upgraded, describing hardware and configuration: chip family, flash and PSRAM size, SDK version, board model, which modules are enabled, whether this is an install or an upgrade, the version running and the one it replaced, and whether the firmware was built locally rather than released. Plus an installation id (below) and a country derived from the connection. +One report when the firmware is installed or upgraded, describing hardware and configuration: chip family, flash and PSRAM size, total and free memory, SDK version, board model, how many lights are driven, which drivers, services, layouts and effects you added, whether this is an install or an upgrade, the version running and the one it replaced, and whether the firmware was built locally rather than released. Plus an installation id (below) and a country derived from the connection. The report carries no device name, no addresses, no credentials and no text you typed. A unit test asserts those cannot appear in it. diff --git a/mooncloud/CHANGES.md b/mooncloud/CHANGES.md deleted file mode 100644 index 14e1b06d..00000000 --- a/mooncloud/CHANGES.md +++ /dev/null @@ -1,76 +0,0 @@ -# What MoonCloud changed, and how to undo it - -Written 2026-09-10, while MoonCloud was built. Everything here is either a file in this repository or a piece of state that lives OUTSIDE it, and the second kind is the reason this page exists: `git revert` removes the code and leaves the account, the database and the nameservers exactly as they are. - -## Outside the repository - -**A Cloudflare account**, owned by moonmodules@icloud.com, on the Free plan with no payment method. Free Workers stop at their limits rather than billing, so the account cannot run up a cost while it stays on Free. To undo: delete the Worker and the database in the dashboard, or the whole account. - -**A D1 database** named `mooncloud-stats`, id `7966e8d7-1fdd-4231-b28d-9dfc08ea94b1`, region WEUR (Western Europe). Holds three tables and, at the time of writing, one real report from a desktop. To undo: `npx wrangler d1 delete mooncloud-stats`. - -**A deployed Worker** at `https://mooncloud-stats.moonmodules.workers.dev`. To undo: `npx wrangler delete mooncloud-stats`. - -**A `workers.dev` subdomain**, `moonmodules`, claimed on first deploy. It belongs to the account and cannot be released, only left unused. - -**The nameservers for moonmodules.org**, changed at Esmero from `ns3`/`ns4.esmero.nl` to `imani`/`pablo.ns.cloudflare.com`. **This is the only change that can take the docs site offline**, and the only one that touches something the project already had. To undo: put the old nameservers back at Esmero. Esmero keeps its zone file, so the revert is a form submission and a wait, not a rebuild. - -The zone as it was before, which is what must survive: - -``` -moonmodules.org A 185.199.108.153 -moonmodules.org A 185.199.109.153 -moonmodules.org A 185.199.110.153 -moonmodules.org A 185.199.111.153 -moonmodules.org AAAA 2606:50c0:8000::153 -moonmodules.org AAAA 2606:50c0:8001::153 -moonmodules.org AAAA 2606:50c0:8002::153 -moonmodules.org AAAA 2606:50c0:8003::153 -www.moonmodules.org CNAME moonmodules.github.io -_discord TXT "dh=e25f5f5ee85784a5717c19e33db7a9ccd6eea13b" -``` - -All ten point at GitHub Pages or verify Discord, and all are set to DNS only rather than proxied: GitHub Pages serves its own certificate, and proxying is a known source of redirect loops with it. - -## In the repository - -**New: the server** (`mooncloud/`). `worker.js` carries four endpoints and a dev page, `schema.sql` three tables, `wrangler.toml` the deploy config including the database id above, `seed.sql` sample rows for local runs, plus `README.md` and `DEPLOY.md`. - -**New: three modules** (`src/core/MoonCloudModule.h`, `MoonStatsModule.h`, `MoonTalkModule.h`). MoonCloud is the container and owns the server address, the send, and the installation id; Stats and Talk are children with their own consent. - -**New: SHA-256** (`src/core/sha256.{h,cpp}`), vendored rather than linked, because ESP-IDF ships mbedtls and the desktop would link something else, and one function with two implementations is one function that drifts. - -**New: tooling.** `moondeck/run/run_mooncloud.py` runs the server locally under the same runtime Cloudflare uses; `moondeck/run/purge_mooncloud.py` deletes rows by date range or by development build, authenticated by the Cloudflare login rather than by a public endpoint. - -**Changed: `platform::httpsPost`** (`platform.h`, `platform_desktop.cpp`, `platform_esp32_ota.cpp`). One outbound HTTPS call using the TLS each OS already ships: libcurl on desktop, `esp_http_client` with the OTA path's certificate bundle on ESP32. libcurl is OPTIONAL: without it the build succeeds and reporting is disabled, because a build must never fail over an opt-in statistic. - -**Changed: the desktop reports its real architecture.** `chipModel()` returned the literal "desktop"; it now returns `arm64` / `x64`, and `hostPlatform()` reports `macos-arm64` / `linux-x64` / `windows-x64` / `docker` using the vocabulary the release packaging already uses. - -**Fixed: `sha256.cpp` was missing from the ESP32 build** (`esp32/main/CMakeLists.txt`), so MoonCloud had never compiled for a device. Found by building for the Olimex, which failed to link. - -**Changed: the privacy policy**, rewritten from 142 lines to 79 and made future-proof: it states a RULE (nothing is sent unless you switch it on) rather than an inventory of one feature, and names usage data and crash reports as things that may later be offered as their own opt-ins. - -## The address is `stats.moonmodules.org` - -`MoonCloudModule.h` (`kHost`) and `app.js` (`kMoonCloudUrl`) both compile in **`stats.moonmodules.org`** on port 443, switched together once the zone moved to Cloudflare and the Worker got its Custom Domain. - -The `workers.dev` address still answers the same Worker, so nothing that shipped pointing at it is stranded. Keeping it alive takes an explicit `workers_dev = true` in `wrangler.toml`: declaring any route disables it by default, and the first deploy of the Custom Domain did exactly that, returning 404 on the address the firmware had compiled in until the line was added. - -To undo: set both constants back to `mooncloud-stats.moonmodules.workers.dev` and rebuild. Both addresses serve the same Worker and the same D1 database, so the switch is reversible without touching data. - -## Before merging to main - -Two things, neither of which a test can catch. - -**1. Point at the real domain. DONE.** `kHost` and `kMoonCloudUrl` both read `stats.moonmodules.org`, changed together: a device reporting to one address while the card reads another shows a user their own report missing. Verified answering over HTTPS with a valid certificate. - -**2. Empty both databases.** Everything in them is test data from the evening MoonCloud was built: probe reports, a board reflashed repeatedly, messages typed to watch the board update. Shipping with it means the first real user sees a version distribution describing one developer's bench. - -```sh -uv run moondeck/run/purge_mooncloud.py --from 2026-01-01 --to 2026-12-31 --remote -``` - -That asks for the row count before touching the deployed database. The local one under `.wrangler/` is scratch and can be left alone, or cleared the same way without `--remote`. - -## Measured cost on a device - -+12,832 bytes of flash (+0.77%) and about 800 bytes of static RAM, measured on a classic ESP32 by building the same tree with and without MoonCloud on one toolchain. The TLS stack was already linked for OTA, so `httpsPost` adds call-site code rather than a library. diff --git a/mooncloud/DEPLOY.md b/mooncloud/DEPLOY.md deleted file mode 100644 index 52483f97..00000000 --- a/mooncloud/DEPLOY.md +++ /dev/null @@ -1,89 +0,0 @@ -# Deploying MoonCloud - -Three of these steps only the account owner can do, and they are the reason this is not automated: the account is a standing commitment rather than a build artifact. - -## Before anything - -**Decide who owns the Cloudflare account.** It needs a person, not a project: someone whose login can deploy, whose email gets the alerts, and who notices when it breaks. If that person leaves, the server does too. This is the question that kept the feature in the backlog, and it is not a technical one. - -Stats and Talk run on **Workers plus D1, both free tier**. Sync would need Durable Objects, which are paid. - -## 1. Log in - -```sh -npx wrangler login -``` - -Opens a browser against the owner's account. Nothing below works until this succeeds. - -## 2. Create the database - -```sh -cd mooncloud -npx wrangler d1 create mooncloud-stats -``` - -It prints a `database_id`. Put it in `wrangler.toml`, replacing `REPLACE_WITH_D1_DATABASE_ID`, and commit that: the id is not a secret, and a config that cannot deploy without a local edit is a config that drifts. - -## 3. Create the tables - -```sh -npx wrangler d1 execute mooncloud-stats --file=schema.sql --remote -``` - -`--remote` is the deployed database; without it you get the local one under `.wrangler/`. - -**On a database that already exists**, `CREATE TABLE IF NOT EXISTS` skips it, so a new column needs saying out loud: - -```sh -npx wrangler d1 execute mooncloud-stats --remote \ - --command="ALTER TABLE reports ADD COLUMN dev INTEGER NOT NULL DEFAULT 0" -``` - -## 4. Deploy - -```sh -npx wrangler deploy -``` - -Answers on `mooncloud-stats..workers.dev` immediately. Check it: - -```sh -curl https://mooncloud-stats..workers.dev/api/stats -``` - -## 5. Point a domain at it - -`stats.moonmodules.org` needs the zone on Cloudflare (its nameservers pointed there), then a Custom Domain route in `wrangler.toml`: - -```toml -[[routes]] -pattern = "stats.moonmodules.org" -custom_domain = true -``` - -Then deploy again. Cloudflare creates the DNS record and issues the certificate itself, so there is no record to add by hand. - -**Declaring any route disables the `workers.dev` address by default**, and that address is the one older firmware compiled in. Keep it alive with an explicit line, or every device still pointing at it gets a 404: - -```toml -workers_dev = true -``` - -**Until this is done the firmware default below cannot be set**, because a `workers.dev` name is not a promise: it moves with the account. - -## 6. Point the firmware at it - -The address is compiled in, not a setting: `kHost` in `MoonCloudModule.h` and `kMoonCloudUrl` in `src/ui/app.js`. They must change TOGETHER, because a device reporting to one address while the card reads another shows a user their own report missing. - -```cpp -static constexpr const char* kHost = "stats.moonmodules.org"; -static constexpr uint16_t kPort = 443; // 443 selects HTTPS -``` - -**This is the last step, not the first.** Ship it before the domain answers and every device in the wild sends one report into nothing, and never retries: a failed report is not queued. - -## Afterwards - -- `uv run moondeck/run/purge_mooncloud.py --from ... --to ... --remote` removes rows by date, authenticated by this login rather than by a public endpoint. -- The privacy policy names the server: check it still describes what runs. diff --git a/mooncloud/README.md b/mooncloud/README.md index 242422ce..acf9946d 100644 --- a/mooncloud/README.md +++ b/mooncloud/README.md @@ -1,10 +1,8 @@ # MoonCloud -The server behind the MoonCloud card, and the only one projectMM talks to. **Stats** takes a report and hands back the aggregates; **Talk** is a public message board between devices. One Worker, one database, one deploy. Published here for the same reason the firmware is, so that "the deployed code is the published code" is checkable rather than taken on trust. +The server behind the MoonCloud card, and the only one projectMM talks to. **Stats** takes a report and hands back the aggregates; **Talk** is a public message board between devices. One Worker, one database, one deploy. Published here so that "the deployed code is the published code" is checkable rather than taken on trust. -What it stores, and what it deliberately does not, is in [privacy-policy.md](../docs/privacy-policy.md). The design and the reasoning behind the installation id are in [the MoonCloud plan](../docs/history/plans/Plan-20260910%20-%20MoonCloud.md). - -## The whole thing +What it stores, and what it deliberately does not, is in [privacy-policy.md](../docs/privacy-policy.md). | File | | |---|---| @@ -15,13 +13,17 @@ What it stores, and what it deliberately does not, is in [privacy-policy.md](../ ## Endpoints -**`POST /api/report`** takes one JSON report. Fields outside the allowlist are dropped rather than stored. A report with no installation id is accepted and discarded, since without one it cannot be counted as an installation. Returns `{"ok": true}`. +**`POST /api/report`** takes one JSON report. Fields outside the allowlist are dropped rather than rejected, so an old device sending an old shape still counts. Returns `{"ok": true}`. + +**`GET /api/stats`** returns aggregates only, never a row: totals plus counts by version, chip, device model, country, and by role for what a user added (drivers, services, layouts, effects, modifiers). + +Any dimension narrows the whole answer, which is what makes a chart slice clickable: `?version=`, `?chip=`, `?deviceModel=`, `?flash=`, `?psram=`, `?sdk=`, `?country=`, `?event=`, `?driver=`, `?service=`, `?layout=`, `?effect=`, `?modifier=`, and `?dev=0|1`. Memory and light counts are stored as raw numbers and bucketed into ranges on read, so they filter by bounds instead: `?lightCountMin=1&lightCountMax=64`, likewise `totalHeap` and `freeHeap`. The answer carries `filtered: true` when any of them applied. -**`GET /api/stats`** returns the aggregates the device card renders: installation and report totals, plus counts by version, chip, device model and country. Readable by anyone, so it carries only aggregates and never a row. +Column names come from an allowlist in the Worker, never from the query string, and values are bound. -**`POST /api/talk`** posts one message to the public board: a sender (the installation id), the text, and a device name ONLY when that device's own consent said to share it. Bounded at 280 characters. +**`POST /api/talk`** posts one message, bounded at 280 characters, carrying a device name only when that device's consent said to share it. -**`GET /api/talk`** returns the newest 50 messages, `?since=` for what a caller has not seen. The full sender id is never published: a message carries the device name if one was shared, else the first 8 characters of the id, which groups one device's messages without naming anyone. +**`GET /api/talk`** returns the newest 50, `?since=` for what a caller has not seen. The full sender id is never published: a message shows the shared name, else the first 8 characters of the id. ## Running it locally @@ -29,81 +31,78 @@ What it stores, and what it deliberately does not, is in [privacy-policy.md](../ uv run moondeck/run/run_mooncloud.py --seed # --seed only the first time ``` -`wrangler dev` runs `worker.js` in **workerd, the same runtime Cloudflare uses**, against a local D1 (SQLite under `.wrangler/`). So this is not a stand-in that approximates the server: it is the server, with a local database and a localhost address, and what you verify here is what deploys. - -Point a device at it by changing `kMoonCloudUrl` in `src/ui/app.js` and `kHost` in `src/core/MoonCloudModule.h`, which is a rebuild: the address is compiled in rather than configurable, so that one MoonCloud cannot be mistyped into another. - -## Pointing a device somewhere else +`wrangler dev` runs `worker.js` in workerd, the runtime Cloudflare uses, against a local D1 under `.wrangler/`. What you verify here is what deploys. It binds `0.0.0.0` because wrangler's default answers only the machine itself and refuses every board on the network. -`server` and `serverPort` are controls on the **MoonCloud** card, so any device can be aimed at any MoonCloud without a rebuild. The port picks the transport: **443 or empty means HTTPS**, anything else means plain HTTP. +To point a device at it, change `kHost` in `src/core/MoonCloudModule.h` and `kMoonCloudUrl` in `src/ui/app.js` and rebuild. The address is compiled in rather than configurable, so one MoonCloud cannot be mistyped into another, and the two must change together: a device reporting to one address while the card reads another shows a user their own report missing. A device needs the machine's LAN address as a dotted quad, not `127.0.0.1` (which on a board means the board) and not a hostname (the plain-HTTP path does no name resolution). -Three cases this exists for, and only one of them is development: - -**Self-hosting.** The server is published here precisely so someone can run their own. Set `server` to their host and `serverPort` to 443, and that device reports to them instead. Nothing about MoonCloud assumes the address is ours. +## Deploying -**A local server while changing `worker.js`.** Set `server` to the machine's LAN address and `serverPort` to `8787`: +Needs the owner's Cloudflare login. Stats and Talk run on Workers plus D1, both free tier. ```sh -uv run moondeck/run/run_mooncloud.py --seed +npx wrangler login +npx wrangler d1 create mooncloud-stats # put the id in wrangler.toml +npx wrangler d1 execute mooncloud-stats --file=schema.sql --remote +npx wrangler deploy ``` -That runs the Worker under workerd, the same runtime Cloudflare uses, against a local D1 under `.wrangler/`. **A DEVICE must be given the machine's LAN address, not `127.0.0.1`**, which on a board means the board itself. `httpRequest` also does no name resolution on the plain-HTTP path, so it has to be a dotted-quad IP rather than a hostname. - -The local server binds `0.0.0.0` for exactly this reason: wrangler's default is localhost, which answers from the machine and refuses every board on the network. +**The firmware talks to the `workers.dev` address, and only that one.** A Custom Domain is optional, for people who want to read the aggregates without the app: -**Moving to a new address.** The shipped default is a compile-time value in `MoonCloudModule.h`, and the controls are how a device already in the field follows a move without a firmware update. +```toml +[[routes]] +pattern = "stats.example.org" +custom_domain = true +workers_dev = true # declaring ANY route disables workers.dev, which the firmware compiles in +``` -## Deploying +**Do not point the firmware at a Custom Domain without checking the issuer.** Cloudflare picks the CA, and the one it picked was absent from IDF's default `esp_crt_bundle`: every ESP32 handshake failed with "No matching trusted root certificate found" while desktop's system trust store accepted it, so devices went silent with nothing on screen to say why. The `workers.dev` certificate is one the bundle carries. Enabling `CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL` would cover any issuer, at roughly 30 KB of flash on a board already at 81% of its app slot. -```sh -npx wrangler d1 create mooncloud-stats # then put the id in wrangler.toml -npx wrangler d1 execute mooncloud-stats --file=schema.sql --remote -npx wrangler deploy -``` +Point the firmware at a new address only once that address answers, and only after a real device has completed a TLS handshake with it: a failed report is never retried, so every device reporting into an unreachable name loses that report. ## Changing the schema -Nothing here is fixed. All four were tested against a real D1, and `wrangler d1 execute` is the tool for each: add `--remote` for the deployed database, leave it off for the local one. +`wrangler d1 execute` does all of it, `--remote` for the deployed database: ```sh npx wrangler d1 execute mooncloud-stats --remote --command="ALTER TABLE reports ADD COLUMN cpu TEXT NOT NULL DEFAULT ''" -npx wrangler d1 execute mooncloud-stats --remote --command="ALTER TABLE reports DROP COLUMN psram" -npx wrangler d1 execute mooncloud-stats --remote --command="ALTER TABLE reports RENAME COLUMN sdk TO sdkVersion" -npx wrangler d1 execute mooncloud-stats --remote --command="UPDATE reports SET chip='ESP32-S3' WHERE chip='esp32s3'" ``` -Deleting rows has its own script, because it is the one operation that cannot be undone by waiting: `moondeck/run/purge_mooncloud.py`, which asks for the row count before touching the deployed database. - -**Adding a field needs three edits, and the order matters.** The column first, then `ALLOWED` in `worker.js` so the field is no longer dropped on arrival, then the report builder in `MoonStatsModule.h`. Reversed, devices send a field the server discards. +**Adding a field needs three edits in order**: the column, then `ALLOWED` in `worker.js` so it is no longer dropped on arrival, then the report builder in `MoonStatsModule.h`. Reversed, devices send a field the server discards. -**`schema.sql` uses `CREATE TABLE IF NOT EXISTS`**, so re-running it on an existing database changes nothing. A new column has to be said out loud with `ALTER TABLE`, and the file updated to match so a fresh deployment gets it too. +`schema.sql` uses `CREATE TABLE IF NOT EXISTS`, so re-running it changes nothing; a new column needs `ALTER TABLE` and an edit to the file so a fresh deployment gets it too. -**An old device keeps sending the old shape.** A dropped column means its value is discarded, a renamed one means it arrives under a name nothing reads. Neither breaks the device: an unknown field is ignored, and a missing one is simply absent from the row. That is why the allowlist drops rather than rejects. +**A field that was never collected cannot be filled in later.** That is why the `events` table and the `dev` flag exist now rather than when they are needed. -**What cannot be changed retroactively is a field that was never collected.** A column added in six months is empty for every row before it, and nothing can fill it in. That is the reasoning behind the `events` table and the `dev` flag: both exist now because history cannot be reconstructed later. +Deleting rows has its own script, because it is the one operation waiting cannot undo: `moondeck/run/purge_mooncloud.py` asks for the row count first. ## Moving to another host -Nothing here is Cloudflare-specific except the deployment itself. **The data is plain SQLite** and comes out in one command: +The data is plain SQLite and comes out in one command, worth running before any schema change regardless: ```sh npx wrangler d1 export mooncloud-stats --remote --output=mooncloud-backup.sql ``` -That file is `CREATE TABLE` plus `INSERT` statements, which any SQLite, Postgres or MySQL will take with minor dialect edits. Worth running before any schema change, and worth running periodically regardless. +`worker.js` is the only thing to rewrite, and three parts of it are Workers-specific: `env.DB.prepare(...)` (any SQL library replaces it), the `fetch(request, env)` entry point (every serverless runtime has one), and `request.cf.country`. That last one is genuinely lost: elsewhere the country comes from a GeoIP lookup on the IP address, which means handling an address the privacy policy promises not to store. + +Keep both addresses answering until the fleet has moved, since a report is sent once and never retried. + +## Two things that are not obvious -**What would have to be rewritten** is `worker.js`, and it is about 300 lines of routing, an allowlist and four aggregate queries. The Workers-specific parts are three: +**The country never comes from an address we store.** Cloudflare resolves it at the edge as `request.cf.country`, so no code here sees an IP. The privacy policy's promise is structural rather than a discipline someone maintains in a log config. -- `env.DB.prepare(...).bind(...).all()`, which is D1's client. Any SQL library replaces it. -- `request.cf.country`, the edge-derived country. **This is the one thing genuinely lost**: elsewhere the country comes from a GeoIP lookup on the IP address, which means handling the address, which the privacy policy promises not to store. A different host means either dropping the country or rewriting that promise. -- The `fetch(request, env)` entry point, which is a shape every serverless runtime has an equivalent of. +**The primary key is `(installationId, version)`.** A device re-reporting the same upgrade overwrites its row, so a row count counts installations rather than retries. -**The device side needs no change at all.** `server` and `serverPort` are controls, so a device already in the field follows a move without a firmware update, and the default in `MoonCloudModule.h` is a one-line change for new ones. +## State outside this repository -**What would break if the address moves without warning**: a report is sent once and never retried, so any device that reports while the old address is dead loses that report. Keep both answering until the fleet has moved. +`git revert` removes the code and leaves all of this exactly as it is. -## The two things that are not obvious +- **A Cloudflare account** (moonmodules@icloud.com), Free plan, no payment method: free Workers stop at their limits rather than billing. Undo: delete the Worker and database in the dashboard. +- **A D1 database** `mooncloud-stats`, id `7966e8d7-1fdd-4231-b28d-9dfc08ea94b1`, region WEUR. Undo: `npx wrangler d1 delete mooncloud-stats`. +- **A deployed Worker**. Undo: `npx wrangler delete mooncloud-stats`. +- **A `workers.dev` subdomain**, `moonmodules`, claimed on first deploy and never releasable, only left unused. +- **The nameservers for moonmodules.org**, moved at Esmero to `imani`/`pablo.ns.cloudflare.com`. **The only change that can take the docs site offline.** Undo: put `ns3`/`ns4.esmero.nl` back; Esmero keeps its zone file, so it is a form submission and a wait. The zone is ten records, all DNS-only rather than proxied because GitHub Pages serves its own certificate and proxying causes redirect loops with it: four A and four AAAA at GitHub Pages, `www` CNAME to `moonmodules.github.io`, and a `_discord` TXT. -**The country never comes from an address we store.** Cloudflare resolves it at the edge and hands it over as `request.cf.country`, so no code here ever sees an IP. That is what makes the privacy policy's promise structural rather than a discipline someone has to maintain in a log config. +## Cost on a device -**The primary key is (installationId, version).** A device re-reporting the same upgrade overwrites its row, so a row count is a count of installations rather than of retries. It is also why the totals answer "how many installations are on 4.0.0" rather than only "how many upgrade events happened". ++12,832 bytes of flash (+0.77%) and about 800 bytes of static RAM on a classic ESP32, measured by building the same tree with and without MoonCloud on one toolchain. The TLS stack was already linked for OTA, so `httpsPost` adds call-site code rather than a library. diff --git a/mooncloud/schema.sql b/mooncloud/schema.sql index 73072d91..34d9452d 100644 --- a/mooncloud/schema.sql +++ b/mooncloud/schema.sql @@ -24,6 +24,12 @@ CREATE TABLE IF NOT EXISTS reports ( -- cannot classify rows already stored. dev INTEGER NOT NULL DEFAULT 0, + -- Raw numbers, bucketed into ranges by the server on read: storing a bucket would freeze every + -- row at today's boundaries, and a range that turns out wrong could never be re-cut. + totalHeap INTEGER NOT NULL DEFAULT 0, -- internal + PSRAM capacity + freeHeap INTEGER NOT NULL DEFAULT 0, -- free at report time + lightCount INTEGER NOT NULL DEFAULT 0, -- physical lights driven (Layer::physicalLightCount) + -- One row per installation per version. A device that re-reports the same upgrade overwrites its -- row rather than adding one, so a count of rows is a count of installations rather than of -- retries. diff --git a/mooncloud/worker.js b/mooncloud/worker.js index dc1c02a0..60d42593 100644 --- a/mooncloud/worker.js +++ b/mooncloud/worker.js @@ -3,9 +3,7 @@ // MoonCloud Stats: the whole server. // // Two endpoints and one table. POST /api/report takes one report from a device that consented; -// GET /api/stats hands back the aggregates, which is what the device's own card renders. There is -// no dashboard here on purpose: the card is the UI, so there is no second front end to keep in -// sync with this schema. +// GET /api/stats hands back the aggregates, which is what the device's own card renders. // // Cloudflare Workers rather than a box we own, for one specific reason: `request.cf.country` // resolves the country at the EDGE, so the privacy policy's promise that the IP address is never @@ -29,6 +27,9 @@ const ALLOWED = [ "sdk", "deviceModel", "modules", + "totalHeap", + "freeHeap", + "lightCount", "dev", ]; @@ -36,6 +37,7 @@ const ALLOWED = [ // bounding what reaches storage rather than deciding what is true. const MAX_STRING = 64; const MAX_MODULES = 64; +const MAX_NUMBER = 2147483647; // a device fact, not a size to trust: bounded like every string function clean(report, country) { const row = {}; @@ -57,6 +59,14 @@ function clean(report, country) { row.dev = value === true || value === "true" ? 1 : 0; continue; } + // The numeric fields arrive as JSON NUMBERS, so the string test below would drop them: every + // other allowlisted field is text, and only `modules` and `dev` had branches of their own. + // Bounded and floored at 0, because a report is untrusted input from an open endpoint. + if (key === "totalHeap" || key === "freeHeap" || key === "lightCount") { + const n = typeof value === "number" ? value : Number(value); + row[key] = Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), MAX_NUMBER) : 0; + continue; + } if (typeof value !== "string") continue; if (value.length > MAX_STRING) continue; row[key] = value; @@ -100,8 +110,8 @@ async function handleReport(request, env) { // double-counting, which is what makes the totals a count of installations. await env.DB.prepare( `INSERT INTO reports - (installationId, event, version, previousVersion, chip, flash, psram, sdk, deviceModel, modules, country, receivedAt, dev) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (installationId, event, version, previousVersion, chip, flash, psram, sdk, deviceModel, modules, country, receivedAt, totalHeap, freeHeap, lightCount, dev) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(installationId, version) DO UPDATE SET event = excluded.event, previousVersion = excluded.previousVersion, @@ -113,6 +123,9 @@ async function handleReport(request, env) { modules = excluded.modules, country = excluded.country, receivedAt = excluded.receivedAt, + totalHeap = excluded.totalHeap, + freeHeap = excluded.freeHeap, + lightCount = excluded.lightCount, dev = excluded.dev` ) .bind( @@ -128,6 +141,9 @@ async function handleReport(request, env) { row.modules ?? "", row.country, row.receivedAt, + row.totalHeap ?? 0, + row.freeHeap ?? 0, + row.lightCount ?? 0, row.dev ?? 0 ) .run(); @@ -159,15 +175,57 @@ async function handleStats(env, url) { // headline number smaller with nothing saying why, and it hid a developer's own bench boards from // the card running ON one of them, which reads as a report that never arrived. // - // `?dev=0` still narrows to released installs for a caller that wants only those. + // A caller narrows any dimension by naming it: `?chip=ESP32`, `?country=NL`, `?dev=0`. Every chart + // then re-counts within that population, which is what makes a slice clickable in the UI. + // + // The filter is a CHOICE, never a default: the charts count everything until a reader asks + // otherwise, so a narrowed total is always something they did rather than something hidden. + // + // Column names come from this table, never from the query string, so a parameter cannot reach the + // SQL. Values are bound. + const FILTERABLE = ["version", "previousVersion", "chip", "flash", "psram", "sdk", + "deviceModel", "country", "event"]; + const where = []; + const binds = []; + for (const column of FILTERABLE) { + const value = url?.searchParams.get(column); + if (value === null || value === undefined || value === "") continue; + where.push(`${column} = ?`); + binds.push(value); + } + // `dev` is an integer flag rather than a string column, and `?dev=0` predates the general form. const releasedOnly = url?.searchParams.get("dev") === "0"; - const devFilter = releasedOnly ? " AND dev = 0" : ""; + if (releasedOnly) where.push("dev = 0"); + else if (url?.searchParams.get("dev") === "1") where.push("dev = 1"); + // `modules` is a comma-joined list, so membership is a LIKE against the delimited form rather + // than equality. Bounded by the same allowlist idea: the value is bound, never interpolated. + // One filter per role, matching the charts: `?driver=Preview`, `?effect=Lissajous`. Entries are + // stored as `role:name` in one comma-joined column, so membership is a LIKE against the delimited + // form. `?module=` still works for a report from before the role split, whose entries carry no + // prefix. Role names come from this table, never the query string; values are bound. + for (const role of ["driver", "service", "layout", "effect", "modifier"]) { + const value = url?.searchParams.get(role); + if (!value) continue; + where.push("(',' || modules || ',') LIKE ?"); + binds.push(`%,${role}:${value},%`); + } + // A bucketed chart filters by BOUNDS: its slices name ranges ("64-128 KB"), and the column holds + // the raw number, so there is no value to match on. `?freeHeapMin=65536&freeHeapMax=131072`. + for (const column of ["totalHeap", "freeHeap", "lightCount"]) { + const min = Number(url?.searchParams.get(`${column}Min`)); + const max = Number(url?.searchParams.get(`${column}Max`)); + if (Number.isFinite(min) && min > 0) { where.push(`${column} >= ?`); binds.push(min); } + if (Number.isFinite(max) && max > 0) { where.push(`${column} <= ?`); binds.push(max); } + } + + const filter = where.length ? ` AND ${where.join(" AND ")}` : ""; + const filtered = where.length > 0; const counts = async (column) => { const { results } = await env.DB.prepare( `SELECT ${column} AS name, COUNT(DISTINCT installationId) AS count - FROM reports WHERE ${column} != ''${devFilter} GROUP BY ${column} ORDER BY count DESC LIMIT 40` - ).all(); + FROM reports WHERE ${column} != ''${filter} GROUP BY ${column} ORDER BY count DESC LIMIT 40` + ).bind(...binds).all(); return results ?? []; }; @@ -175,56 +233,129 @@ async function handleStats(env, url) { // rather than a GROUP BY: a module is present on an installation, and an installation appears // once. Counted over DISTINCT installations for the same reason every other figure is, so a // device that reported twice does not count twice. - const moduleCounts = async () => { + // Modules arrive as `role:name` (driver:Preview, effect:Lissajous), so one column yields a chart + // per kind: which drivers and services are actually used is a different question from which + // effects are, and one combined pie buried the first under the second. + // + // Counted over DISTINCT installations, like every other figure: a device that reported twice does + // not count twice. An entry without a role prefix is from a device older than this split and is + // counted under `modules`, so old rows still say something rather than vanishing. + const moduleCountsByRole = async () => { const { results } = await env.DB.prepare( - `SELECT DISTINCT installationId, modules FROM reports WHERE modules != ''${devFilter}` - ).all(); - const seen = new Map(); // module -> Set of installation ids + `SELECT DISTINCT installationId, modules FROM reports WHERE modules != ''${filter}` + ).bind(...binds).all(); + + const byRole = new Map(); // role -> Map(name -> Set of installation ids) for (const row of results ?? []) { - for (const name of String(row.modules).split(",")) { - const m = name.trim(); - if (!m) continue; - if (!seen.has(m)) seen.set(m, new Set()); - seen.get(m).add(row.installationId); + for (const entry of String(row.modules).split(",")) { + const text = entry.trim(); + if (!text) continue; + const cut = text.indexOf(":"); + // Every entry carries its role: a report predating the split cannot reach this, because the + // tables are emptied before ship (ADR-0013: no migration code, documented breaks). + if (cut <= 0) continue; + const role = text.slice(0, cut); + const name = text.slice(cut + 1); + if (!name) continue; + if (!byRole.has(role)) byRole.set(role, new Map()); + const names = byRole.get(role); + if (!names.has(name)) names.set(name, new Set()); + names.get(name).add(row.installationId); } } - return [...seen.entries()] - .map(([name, ids]) => ({ name, count: ids.size })) - .sort((a, b) => b.count - a.count) - .slice(0, 40); + + const out = {}; + for (const [role, names] of byRole) { + out[role] = [...names.entries()] + .map(([name, ids]) => ({ name, count: ids.size })) + .sort((a, b) => b.count - a.count) + .slice(0, 40); + } + return out; }; // Install versus upgrade. The one figure counted over REPORTS rather than installations: it // describes events, and an installation that upgraded three times is three upgrade events. const eventCounts = async () => { const { results } = await env.DB.prepare( - `SELECT event AS name, COUNT(*) AS count FROM reports WHERE event != ''${devFilter} + `SELECT event AS name, COUNT(*) AS count FROM reports WHERE event != ''${filter} GROUP BY event ORDER BY count DESC` - ).all(); + ).bind(...binds).all(); return results ?? []; }; // Released against development, and the ONE figure that ignores the dev filter: a pie whose job // is to show the split cannot be drawn from a query that already removed one side of it. const buildCounts = async () => { + // Every filter EXCEPT dev: a pie whose job is to show the released-against-development split + // cannot be drawn from a query that already removed one side of it. The other dimensions still + // apply, so clicking `NL` narrows this pie to the Netherlands rather than leaving it global. + const noDev = where.filter(c => c !== "dev = 0" && c !== "dev = 1"); + const devless = noDev.length ? ` WHERE ${noDev.join(" AND ")}` : ""; const { results } = await env.DB.prepare( `SELECT CASE dev WHEN 1 THEN 'development' ELSE 'released' END AS name, COUNT(DISTINCT installationId) AS count - FROM reports GROUP BY dev ORDER BY count DESC` - ).all(); + FROM reports${devless} GROUP BY dev ORDER BY count DESC` + ).bind(...binds).all(); return results ?? []; }; + // Bucketed on READ rather than at the device, so a range that turns out wrong can be re-cut + // against rows already stored. Raw values would give one slice per device and say nothing. + const bucketed = async (column, buckets) => { + const { results } = await env.DB.prepare( + `SELECT DISTINCT installationId, ${column} AS v FROM reports WHERE ${column} > 0${filter}` + ).bind(...binds).all(); + const counts = new Map(); + for (const row of results ?? []) { + const label = buckets.find(b => row.v <= b.max)?.name ?? buckets[buckets.length - 1].name; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + // Bucket ORDER, not count order: a range chart reads as a scale, so the slices follow the + // ranges rather than sorting the biggest first. + // `min`/`max` ride along so a clicked slice can filter by bounds. `max` is omitted for the + // open-ended top bucket, where Infinity has no JSON form. + return buckets.map((b, i) => ({ + name: b.name, + count: counts.get(b.name) ?? 0, + min: i === 0 ? 1 : buckets[i - 1].max + 1, + ...(Number.isFinite(b.max) ? { max: b.max } : {}), + })).filter(r => r.count > 0); + }; + + // LED strips cluster at powers of two, so the ranges follow that rather than decades: a 60-light + // strip and a 300-light strip are different installations in a way "10-100" and "100-1000" blur. + const kLightBuckets = [ + { name: "1-64", max: 64 }, { name: "65-256", max: 256 }, + { name: "257-1024", max: 1024 }, { name: "1025-4096", max: 4096 }, + { name: "4097+", max: Infinity }, + ]; + // Decades would put every ESP32 in one bucket. These separate a struggling classic from an S3. + const kFreeBuckets = [ + { name: "<32 KB", max: 32 * 1024 }, { name: "32-64 KB", max: 64 * 1024 }, + { name: "64-128 KB", max: 128 * 1024 }, { name: "128-256 KB", max: 256 * 1024 }, + { name: "256 KB+", max: Infinity }, + ]; + const kTotalBuckets = [ + { name: "<128 KB", max: 128 * 1024 }, { name: "128-256 KB", max: 256 * 1024 }, + { name: "256-512 KB", max: 512 * 1024 }, { name: "512 KB-4 MB", max: 4 * 1024 * 1024 }, + { name: "4 MB+", max: Infinity }, + ]; + + const moduleRoles = await moduleCountsByRole(); + const { results: totals } = await env.DB.prepare( `SELECT COUNT(DISTINCT installationId) AS installations, COUNT(*) AS reports - FROM reports WHERE 1=1${devFilter}` - ).all(); + FROM reports WHERE 1=1${filter}` + ).bind(...binds).all(); return json({ installations: totals?.[0]?.installations ?? 0, reports: totals?.[0]?.reports ?? 0, // Says which population these figures describe, so a dashboard never has to guess. includesDevelopmentInstalls: !releasedOnly, + // What the reader narrowed to, so the card can show it and offer a way back. + filtered, versions: await counts("version"), chips: await counts("chip"), deviceModels: await counts("deviceModel"), @@ -234,8 +365,22 @@ async function handleStats(env, url) { psram: await counts("psram"), sdk: await counts("sdk"), previousVersions: await counts("previousVersion"), + lightCounts: await bucketed("lightCount", kLightBuckets), + freeMemory: await bucketed("freeHeap", kFreeBuckets), + totalMemory: await bucketed("totalHeap", kTotalBuckets), events: await eventCounts(), - modules: await moduleCounts(), + ...(() => { + // Spread as `drivers`, `services`, `layouts`, `effects`, … so a new role needs no server + // change: whatever devices report becomes a chart the UI can render. + const byRole = moduleRoles; + return { + drivers: byRole.driver ?? [], + services: byRole.service ?? [], + layouts: byRole.layout ?? [], + effects: byRole.effect ?? [], + modifiers: byRole.modifier ?? [], + }; + })(), builds: await buildCounts(), updated: new Date().toISOString(), }); @@ -251,6 +396,7 @@ async function handleStats(env, url) { // There is NO AUTHENTICATION, so a sender id can be fabricated by anyone who wants to. That is // acceptable for a hobby board where nothing is gated on identity, and it is stated in the privacy // policy rather than left to be discovered. +const SENDER_CHARS = 8; // how much of the sender id a message publishes; the row keeps all 32 const MAX_MESSAGE = 280; // a message, not a document: bounded so one caller cannot fill the table const MAX_NAME = 32; const PAGE_SIZE = 50; @@ -264,6 +410,9 @@ async function handleTalkPost(request, env) { } if (typeof msg !== "object" || msg === null) return json({ error: "not an object" }, 400); + // The FULL id is stored and validated; only the first SENDER_CHARS are ever published (see + // handleTalkGet). Truncating here instead looked like storing less, and broke every post: the + // length check below rejects anything that is not a whole installation id. const sender = typeof msg.sender === "string" ? msg.sender.slice(0, 32) : ""; const text = typeof msg.text === "string" ? msg.text.trim() : ""; // The name is OPTIONAL by design: a device shares it only when its own consent control says so, @@ -289,20 +438,26 @@ async function handleTalkGet(request, env) { // `since` lets a device poll for what it has not seen instead of re-reading the board. const since = Number(url.searchParams.get("since") || 0) || 0; + // An incremental read walks FORWARD from `since`, so a caller that missed more than one page gets + // the oldest unseen first and can page again. Ordering DESC here would hand back the newest 50 and + // silently drop everything between, which a caller cannot detect. const { results } = await env.DB.prepare( - `SELECT id, sender, name, text, country, sentAt FROM messages - WHERE id > ? ORDER BY id DESC LIMIT ?` + since + ? `SELECT id, sender, name, text, country, sentAt FROM messages + WHERE id > ? ORDER BY id ASC LIMIT ?` + : `SELECT id, sender, name, text, country, sentAt FROM messages + WHERE id > ? ORDER BY id DESC LIMIT ?` ).bind(since, PAGE_SIZE).all(); // The full sender id is NOT published: a caller gets the first 8 characters, which is enough to // group one device's messages together and not enough to match against a Stats row. const messages = (results ?? []).map((m) => ({ id: m.id, - from: m.name || m.sender.slice(0, 8), + from: m.name || m.sender.slice(0, SENDER_CHARS), // The id prefix travels alongside a shared name rather than instead of it, so a named sender // is still identifiable as one device: two people can pick the same device name, and without // this their messages would be indistinguishable. - senderId: m.sender.slice(0, 8), + senderId: m.sender.slice(0, SENDER_CHARS), named: Boolean(m.name), text: m.text, country: m.country, @@ -429,7 +584,13 @@ fetch("/api/stats").then(r => r.json()).then(d => { ["PSRAM", d.psram], ["SDK", d.sdk], ["Install or upgrade", d.events], ["Upgraded from", d.previousVersions], - ["Modules", d.modules], + ["Drivers", d.drivers], + ["Services", d.services], + ["Layouts", d.layouts], + ["Effects", d.effects], + ["Lights", d.lightCounts], + ["Free memory", d.freeMemory], + ["Total memory", d.totalMemory], ["Build", d.builds], ["Country", d.countries]]) { const shown = topSlices(rows); if (!shown.length) continue; @@ -441,7 +602,11 @@ fetch("/api/stats").then(r => r.json()).then(d => { section.append(title, pie(shown), legend(shown)); out.appendChild(section); } -}).catch(() => { document.getElementById("sub").textContent = "No data yet."; }); +}).catch(() => { + // An unreachable server is not an empty one: saying "No data yet." on a failed fetch states as + // fact something this page has no evidence for. + document.getElementById("sub").textContent = "Cannot reach the server."; +}); `; function json(body, status = 200) { diff --git a/moondeck/run/purge_mooncloud.py b/moondeck/run/purge_mooncloud.py index 5ab371e7..95e924a9 100755 --- a/moondeck/run/purge_mooncloud.py +++ b/moondeck/run/purge_mooncloud.py @@ -23,6 +23,7 @@ """ import argparse +import datetime import re import shutil import subprocess @@ -66,9 +67,18 @@ def main() -> int: return 1 for day in (args.start, args.end): - if day and not DAY.match(day): + if not day: + continue + # The shape AND the calendar: `2026-02-31` matches the pattern and is not a date, and this + # script builds a delete predicate out of it. + if not DAY.match(day): print(f"not a date: {day} (want YYYY-MM-DD)", file=sys.stderr) return 1 + try: + datetime.date.fromisoformat(day) + except ValueError: + print(f"not a real date: {day}", file=sys.stderr) + return 1 if not args.dev_only and not (args.start and args.end): print("give --from and --to, or --dev-only", file=sys.stderr) return 1 diff --git a/mooninstaller/index.html b/mooninstaller/index.html index 6bcc53c7..7b9f974d 100644 --- a/mooninstaller/index.html +++ b/mooninstaller/index.html @@ -403,6 +403,10 @@

Installing

+ +
Turn on MoonCloud stats on your device to share and see what everyone else is running.
diff --git a/src/core/MoonCloudModule.h b/src/core/MoonCloudModule.h index a0f982eb..df216f4d 100644 --- a/src/core/MoonCloudModule.h +++ b/src/core/MoonCloudModule.h @@ -5,24 +5,14 @@ /// @file MoonCloudModule.h /// MoonCloud: the container for everything projectMM does with a server we run. /// -/// It holds no controls of its own and does no work. It exists so that each thing MoonCloud does -/// is a CHILD with its own consent, rather than one module whose meaning widens: -/// -/// - **Stats** (`MoonStatsModule`): one report per install or upgrade, and the aggregates back. -/// - **Sync** (planned): device to device over the internet, a joint show across houses. -/// -/// **One module per promise.** A user who wants a joint lightshow has not agreed to usage -/// reporting, and a user who shares their chip model has not agreed to accept connections from -/// other people's devices. Separate children keep those separate questions, separately answered -/// and separately revocable, which a single "MoonCloud: on/off" switch could not express. -/// -/// Sync is NOT an extension of Stats and will not be built on it: Stats is one fire-and-forget POST -/// per firmware install, where Sync needs persistent connections, time alignment, presence and -/// pairing. They share this container and the installation id, and nothing else. See -/// [the MoonCloud plan](../../docs/history/plans/Plan-20260910%20-%20MoonCloud.md). +/// Holds no controls and does no work. Each thing MoonCloud does is a CHILD with its own consent: +/// Stats (one report per install or upgrade), Talk (a public message board), Sync (planned). +/// A user who wants a joint lightshow has not agreed to usage reporting, and one switch could not +/// express that. #include #include +#include #include #include "core/MoonModule.h" @@ -33,20 +23,13 @@ namespace mm { class MoonCloudModule : public MoonModule { public: - /// A container with nothing to disable: turning MoonCloud "off" would be ambiguous when its - /// children carry the real consent, so each child owns its own answer and this one always runs. + /// Always runs: the children carry the real consent, so disabling the container is ambiguous. bool respectsEnabled() const MM_NONBLOCKING override { return false; } - /// How long any member waits for the server. One bounded call, off the render path. static constexpr uint32_t kTimeoutMs = 4000; - /// POST `body` to `path`. The one outbound call in MoonCloud, so every member sends through - /// here rather than repeating the client API. - /// - /// HTTPS only, because the address is a constant and that constant is public. An earlier shape - /// also carried a plain-HTTP branch for a local server, reached by editing a control; with the - /// address compiled in that branch could never run, and an unreachable transport is a second - /// way to send that nobody tests. + /// The one outbound call in MoonCloud. HTTPS only: the address is a compiled-in constant, so a + /// plain-HTTP branch could never run and would be a second transport nobody tests. bool post(const char* path, const char* body) const { char url[192]; std::snprintf(url, sizeof(url), "https://%s%s", kHost, path); @@ -55,74 +38,55 @@ class MoonCloudModule : public MoonModule { void defineControls() override { controls_.clear(); - // On the CONTAINER, so one setting serves every member. A child registering its own would - // put the same field on two cards and let them drift apart. - // Chain FIRST so children register before any control of this module's own, per the - // override-and-chain convention (docs/coding-standards.md). Without this a child of a - // container shows an empty card, which is exactly what happened when MoonStats was - // briefly parented to Firmware, whose override does not chain. + // Chain FIRST so children register before this module's own controls. Without it a child of + // a container shows an empty card. MoonModule::defineControls(); } private: - // THE address, compiled in rather than configurable. There is one MoonCloud, and a device that - // could be pointed elsewhere is a device that can be pointed at nothing: a typo means reports - // vanish silently, since a failed report is never retried. Moving the server is a release. - static constexpr const char* kHost = "stats.moonmodules.org"; - static constexpr uint16_t kPort = 443; + // Compiled in rather than configurable: a device that can be pointed elsewhere can be pointed + // at nothing, and a failed report is never retried. Moving the server is a release. + // + // The workers.dev name rather than a custom domain. Cloudflare picks the CA for a Custom Domain + // certificate, and the one it picked is absent from IDF's default root bundle, so every ESP32 + // handshake failed while desktop's system trust store accepted it. The address is compiled in + // and never shown, so a prettier one buys nothing a device can reach. + static constexpr const char* kHost = "mooncloud-stats.moonmodules.workers.dev"; }; -// --------------------------------------------------------------------------------------------- -// The installation id: MoonCloud's shared identity, used by every member. -// -// The MoonStats installation id: 32 hex characters identifying this install, and nothing else. -// -// `SHA-256(salt || platform::getMacAddress())`, truncated to 16 bytes, exactly as -// [the MoonCloud plan](../../docs/history/plans/Plan-20260910 - MoonCloud.md) specifies. ONE -// scheme on every target, because the platform layer already answers "what is this installation" -// per platform: an eFuse MAC on ESP32, and since PR #98 a stored random address on desktop and in -// Docker. This code does not care which it got. -// -// **It lives in core, not behind the platform seam.** There is nothing platform-specific left once -// the seed is in hand: hashing six bytes is the same arithmetic everywhere, and putting it here -// means a new target inherits a correct id by implementing `getMacAddress` alone. -// -// **The salt is MoonStats-specific and deliberately differs from every other salt in the project.** -// The same MAC produces the MQTT topic prefix and the Home Assistant `unique_id` -// ([ADR-0010](../../docs/adr/0010-integration-identity-stable-hardware-id.md)), both visible on the -// user's own network. Hashing with a distinct salt is what stops a report being tied to a device -// somebody can observe locally. -// -// What this is NOT: anonymous. It is stable, so two reports carrying it came from one install, -// which is the entire point and also why [privacy-policy.md](../../docs/privacy-policy.md) calls it -// pseudonymous and says an ESP32 id is reversible in principle under a published salt. +/// The installation id: `SHA-256(salt || MAC)` truncated to 16 bytes, 32 hex characters. +/// +/// One scheme on every target, because the platform layer already answers "what is this +/// installation": an eFuse MAC on ESP32, a stored random address on desktop and Docker. It lives in +/// core rather than behind the platform seam, so a new target inherits a correct id by implementing +/// `getMacAddress` alone. +/// +/// The salt differs from every other salt in the project on purpose. The same MAC produces the MQTT +/// topic prefix and the Home Assistant `unique_id`, both visible on the user's own network; a +/// distinct salt is what stops a report being tied to a device somebody can observe locally. +/// +/// NOT anonymous: it is stable, so two reports carrying it came from one install. That is the point, +/// and why [privacy-policy.md](../../docs/privacy-policy.md) calls it pseudonymous. /// Characters written by `installationId`, excluding the terminator. inline constexpr size_t kInstallationIdChars = 32; -/// Write the installation id into `out`, which must hold `kInstallationIdChars + 1` characters. -/// -/// Computed on every call rather than cached: it is wanted once per install, and a cache would be -/// state that has to be invalidated when `fsSetRoot` moves the identity (which tests do between -/// cases). -// `inline` because this lives in a header: one definition shared by every translation unit that -// includes it, rather than one per unit. -// -// The salt is MoonCloud-wide and changing it re-identifies every installation in the world exactly -// once, so it is a fixed constant. It differs from every other salt in the project, which is what -// stops a report being correlated with the MQTT identity built from the same MAC. +/// Changing this re-identifies every installation in the world exactly once, so it is fixed. inline constexpr char kMoonCloudSalt[] = "projectMM/MoonStats/v1"; +/// Write the installation id into `out`, which must hold `kInstallationIdChars + 1` characters. +/// +/// Computed per call rather than cached: it is wanted once per install, and a cache would need +/// invalidating when `fsSetRoot` moves the identity (which tests do between cases). inline void installationId(char* out) { if (!out) return; uint8_t mac[6] = {}; platform::getMacAddress(mac); - // salt || mac, hashed as one buffer. Concatenation rather than HMAC: the salt is public in this - // source either way, so it is a domain separator rather than a key, and HMAC would imply a - // secret that does not exist. + // Concatenation rather than HMAC: the salt is public in this source either way, so it is a + // domain separator rather than a key. uint8_t buf[sizeof(kMoonCloudSalt) - 1 + sizeof(mac)]; std::memcpy(buf, kMoonCloudSalt, sizeof(kMoonCloudSalt) - 1); std::memcpy(buf + sizeof(kMoonCloudSalt) - 1, mac, sizeof(mac)); diff --git a/src/core/MoonStatsModule.h b/src/core/MoonStatsModule.h index f367e964..45a593af 100644 --- a/src/core/MoonStatsModule.h +++ b/src/core/MoonStatsModule.h @@ -4,27 +4,20 @@ /// @file MoonStatsModule.h /// MoonStats: consent, and the once-per-install trigger. /// -/// Owns the two things that decide whether a report is ever built: what the user answered, and -/// whether the firmware version changed since the last time this ran. It does NOT build the report -/// (that is [MoonStatsReport.h](MoonStatsReport.h), a pure function) and it does not send it. +/// Owns what decides whether a report is built. It does not build one (that is +/// `buildMoonStatsReport`, a pure function) and does not send it. /// -/// **Consent is the gate, and declining is silent.** `consent` is Unanswered until the user picks. -/// Never is remembered forever, and a device that never gets a Yes opens no connection and computes -/// no identifier: there is no "declined" record, because sending one would be a report. +/// **Consent is the gate, and declining is silent.** A device that never gets a Yes opens no +/// connection and computes no identifier; there is no "declined" record, because sending one would +/// be a report. /// /// **The trigger is a version comparison, not a timer.** `reportedVersion` persists the version that -/// last produced a report. When the running version differs, one report is due; afterwards the two -/// match and nothing is due until the next upgrade. So a reboot sends nothing, and an upgrade sends -/// exactly one report. Whether that report says `install` or `upgrade` follows from the same field: -/// an empty `reportedVersion` on a device that has never reported is a fresh install, a different -/// one is an upgrade. No identifier is involved in telling those apart. +/// last reported, so a reboot sends nothing and an upgrade sends exactly one. An empty value means a +/// fresh install, a different one an upgrade: no identifier is involved in telling those apart. /// -/// Suppressed in AP mode, where there is no route to the internet and the user is usually -/// mid-provisioning: prompting there asks a question about data before the device can even reach -/// the network. +/// Suppressed in AP mode, where there is no route out and the user is mid-provisioning. /// -/// See [privacy-policy.md](../../docs/privacy-policy.md) for what is promised, and -/// [the MoonCloud plan](../../docs/history/plans/Plan-20260910 - MoonCloud.md) for the id. +/// See [privacy-policy.md](../../docs/privacy-policy.md) for what is promised. #include #include @@ -33,52 +26,29 @@ #include "core/MoonModule.h" #include "core/MoonCloudModule.h" #include "core/Control.h" +#include "core/FilesystemModule.h" // noteDirty(): scheduling the save, not just marking it #include "core/JsonSink.h" #include "core/Scheduler.h" #include "core/build_info.h" #include "platform/platform.h" +#include "core/LightSummary.h" // lightCount: the POD the light domain publishes +#include "light/drivers/Drivers.h" // Drivers::latestSummary(): the real light total namespace mm { -// ----------------------------------------------------------------------------------------------- -// The report itself: a pure function over the live module tree. +// The report: a PURE FUNCTION over the live module tree. It opens no socket, reads no consent and +// persists nothing, so a test can call it with a tree built by hand. // -// MoonStats: the one report projectMM sends, and only after the user says yes. -// -// A PURE FUNCTION over the live module tree. It opens no socket, reads no consent, persists -// nothing, and can be called from a test with a tree built by hand. Everything it reports is -// already in memory as a control or a module name, so this is a serializer over known facts -// rather than new instrumentation. -// -// **The allowlist is the design.** Fields are named one at a time and copied by name. A builder -// that walked the tree and emitted what it found would leak on its first run: `deviceName` and -// `mac` are live SystemModule controls sitting beside `chip` and `flash`, and the network SSID and -// credentials are controls too. So there is no "emit everything except" path here, and -// `unit_MoonStatsReport.cpp` asserts the forbidden names cannot appear whatever the tree holds. -// See [privacy-policy.md](../../docs/privacy-policy.md), which is the promise this implements, and -// [the MoonCloud plan](../../docs/history/plans/Plan-20260910 - MoonCloud.md) for the id. - -/// What the report says happened. The device knows which without an identifier: the trigger is a -/// stored version file changing, so a report carrying a previous version is an upgrade and one -/// without is a fresh install. +// **The allowlist is the design.** Fields are named one at a time and copied by name. A builder that +// walked the tree and emitted what it found would leak on its first run: `deviceName`, `mac` and the +// network credentials are live controls sitting beside `chip` and `flash`. `unit_MoonStatsReport.cpp` +// asserts the forbidden names cannot appear whatever the tree holds. + +/// What the report says happened. enum class MoonStatsEvent : uint8_t { Install, Upgrade }; -/// Serialize the report into `sink`. -/// -/// @param sink where the JSON object lands. -/// @param root the module tree to read (Scheduler's modules in production, a hand-built -/// tree in a test). -/// @param moduleCount how many modules `root` holds. -/// @param event install or upgrade. -/// @param installationId 32 hex characters from `platform::installationId()`, or nullptr to omit -/// it (which is what a test asserting the forbidden fields passes). -/// @param version the version now running. -/// @param previousVersion the version it replaced, or nullptr on a fresh install. - -/// Read one control's value out of `mod` BY NAME, as a string, or return false when the module -/// does not carry it. Named lookup rather than a cached descriptor: the report is built once per -/// install, so a linear walk of ~20 controls costs nothing, and a control that gets renamed makes -/// the field disappear rather than emitting whatever moved into its index. +/// Read one control's value out of `mod` by name. Named lookup rather than an index: a renamed +/// control makes the field disappear rather than emitting whatever moved into its slot. inline bool readControl(const MoonModule* mod, const char* name, JsonSink& out) { if (!mod) return false; auto& ctrls = mod->controls(); @@ -107,9 +77,8 @@ inline const MoonModule* findModule(MoonModule* const* root, uint8_t count, cons return nullptr; } -/// Copy one named control into the report under the same key, or omit the key entirely when the -/// control is absent. Omission rather than a null: an absent field costs no bytes and the server -/// counts what it sees, where a null would need a rule about what it means. +/// Copy one named control into the report, or omit the key when the control is absent. Omission +/// rather than a null, which would need a rule about what it means. inline void field(JsonSink& sink, const MoonModule* mod, const char* control, const char* key, bool& first) { if (!mod) return; @@ -124,19 +93,59 @@ inline void field(JsonSink& sink, const MoonModule* mod, const char* control, co } +/// Append every user-added enabled module, depth first, each as `role:name`. +/// +/// The role prefix is what lets the server aggregate drivers, layouts, effects and services +/// separately: one list in one column, four charts out of it. `ModuleRole` already exists on every +/// module, so nothing new is invented and nothing a user typed is sent: both halves come from the +/// catalog's fixed vocabulary. +/// +/// Wired-by-code modules are the boot tree every device shares, so they are skipped: counting them +/// made every slice read "N of N devices", which says only that they all booted. Their CHILDREN are +/// still walked, because a user's effect hangs under the Effects module main.cpp wired. The two +/// filters agree in practice: a wired container is `generic`, and what hangs under it carries a +/// real role. +inline void reportModules(JsonSink& sink, const MoonModule* const* mods, uint8_t count, + bool& first) { + for (uint8_t i = 0; i < count; i++) { + const MoonModule* m = mods[i]; + if (!m || !m->enabled()) continue; + // ROLE decides, not `isWiredByCode()`. Only children are marked wired (main.cpp marks Tasks, + // Pins, Stats, Talk, Preview, ...); the twelve top-level modules are added with + // `scheduler.addModule()` and carry no marker, so a wired-by-code test reported every one of + // them as `generic:System`, `generic:Network` and so on. + // + // `Generic` means structural container, which is exactly what should not be counted, and + // `Layer` is structural too (it holds effects rather than being one a user picks). What + // remains is what somebody chose: drivers, services, layouts, effects, modifiers. + const ModuleRole role = m->role(); + if (m->name() && role != ModuleRole::Generic && role != ModuleRole::Layer) { + char entry[80]; + std::snprintf(entry, sizeof(entry), "%s:%s", roleName(role), m->name()); + if (!first) sink.append(","); + first = false; + sink.writeJsonString(entry); + } + for (uint8_t c = 0; c < m->childCount(); c++) { + const MoonModule* child = m->child(c); + reportModules(sink, &child, 1, first); + } + } +} + inline void buildMoonStatsReport(JsonSink& sink, MoonModule* const* root, uint8_t moduleCount, MoonStatsEvent event, const char* installationId, const char* version, - const char* previousVersion) { + const char* previousVersion, + uint32_t lightCount = 0) { const MoonModule* system = findModule(root, moduleCount, "System"); sink.append("{"); bool first = true; - // The installation id, when the caller has one. Omitted entirely by the test that asserts the - // forbidden fields, and by any caller that has not obtained consent. + // Omitted by any caller without consent, and by the test asserting the forbidden fields. if (installationId && *installationId) { sink.append("\"installationId\":"); sink.writeJsonString(installationId); @@ -153,15 +162,9 @@ inline void buildMoonStatsReport(JsonSink& sink, sink.writeJsonString(version); } - // Whether this firmware came from a release or somebody's laptop. `kRelease` is set by CI on a - // published build and EMPTY on a local one, so this needs no new plumbing and no question to the - // user: it describes the binary, not the person. - // - // Sent from the FIRST report rather than added when the noise becomes annoying, because a flag - // introduced later cannot classify rows already stored: every report before it exists is - // permanently unlabeled, and nothing afterwards can tell a developer's bench board from a - // user's shelf. One boolean now buys a dashboard that can say "excluding development installs" - // for its whole history. + // Release or somebody's laptop: `kRelease` is set by CI and empty on a local build, so it + // describes the binary, not the person. Sent from the FIRST report because a flag added later + // cannot classify rows already stored. sink.append(",\"dev\":"); sink.writeBool(kRelease[0] == 0); // Present only on an upgrade, and it is what tells the server this was one. @@ -170,26 +173,35 @@ inline void buildMoonStatsReport(JsonSink& sink, sink.writeJsonString(previousVersion); } - // Hardware, straight off the System module. Every one of these is a fact about the board, not - // about the person holding it. `deviceName` and `mac` sit in the same control list and are - // deliberately NOT here. + // Memory and light count as RAW numbers, bucketed into ranges by the server. Bucketing here + // would freeze every stored row at today's boundaries: a range that turns out wrong could never + // be re-cut, which is the same trap as a field that was never collected. + sink.appendf(",\"totalHeap\":%u,\"freeHeap\":%u,\"lightCount\":%u", + static_cast(platform::totalHeap()), + static_cast(platform::freeHeap()), + static_cast(lightCount)); + + // Facts about the board, not the person. `deviceName` and `mac` sit in the same control list + // and are deliberately NOT here. field(sink, system, "chip", "chip", first); field(sink, system, "flash", "flash", first); field(sink, system, "psramType", "psram", first); field(sink, system, "sdk", "sdk", first); field(sink, system, "deviceModel", "deviceModel", first); - // Which modules are enabled, by name. The names are ours (a fixed vocabulary from the - // catalog), not anything a user typed. + // What the user CHOSE to run, by name: a fixed vocabulary from the catalog, never anything a + // user typed. + // + // Modules wired by main.cpp are skipped. Every device has them, so counting them said only + // "these two devices both booted": every slice read 2 of 2, and the interesting modules were + // buried in an `other` bucket. What varies between installations is what someone ADDED, and + // `isWiredByCode()` is exactly that line, drawn where the knowledge lives. + // + // Children are walked, because a user's additions hang off a parent (an effect under Effects, a + // driver under Drivers) while the boot wiring is what sits at the top. sink.append(",\"modules\":["); bool firstModule = true; - for (uint8_t i = 0; i < moduleCount; i++) { - const MoonModule* m = root[i]; - if (!m || !m->enabled() || !m->name()) continue; - if (!firstModule) sink.append(","); - firstModule = false; - sink.writeJsonString(m->name()); - } + reportModules(sink, root, moduleCount, firstModule); sink.append("]"); sink.append("}"); @@ -199,13 +211,6 @@ inline void buildMoonStatsReport(JsonSink& sink, class MoonStatsModule : public MoonModule { public: - /// What the user answered. Persisted, so Never survives a reboot and an upgrade. - /// - /// Unanswered is the default and the only state that shows a prompt. NotNow exists so a user - /// who is busy is asked again after the NEXT upgrade rather than never: it is a deferral, where - /// Never is an answer. - enum Consent : uint8_t { Unanswered = 0, Yes = 1, NotNow = 2, Never = 3 }; - void setup() override { std::snprintf(runningVersion_, sizeof(runningVersion_), "%s", kVersion); MoonModule::setup(); @@ -213,40 +218,38 @@ class MoonStatsModule : public MoonModule { void defineControls() override { controls_.clear(); - // The user's answer. A select rather than a bool: "not now" and "never" are different - // answers, and collapsing them would either nag someone who declined or silence someone - // who only deferred. - static const char* kConsentOptions[] = {"Not answered", "Yes", "Not now", "Never"}; - controls_.addSelect("consent", consent_, kConsentOptions, 4); - - // The version that last produced a report. Empty means none ever has, which is what makes - // the first report an `install`. Read-only in the UI: it is bookkeeping, and a user editing - // it would either re-send or silence a genuine upgrade. - controls_.addReadOnly("reportedVersion", reportedVersion_, sizeof(reportedVersion_)); + // A select rather than a bool: collapsing "not now" and "never" would either nag someone + // who declined or silence someone who only deferred. + // A checkbox, not a four-option select: "not now" and "never" both mean nothing is sent, + // and telling them apart cost a persisted version and a branch in setup() to express a + // distinction nobody asked for. + controls_.addControl("consent", consent_); + + // addText + the readonly FLAG, not addReadOnly: ControlType::ReadOnly is excluded from + // persistence (Control.cpp, isPersistable) and refused on load, so these two came back empty + // on every boot. reportedVersion empty means a report is due, so a consented device sent a + // fresh `install` on EVERY reboot: the reports row was overwritten by its primary key, which + // hid it, while the events table gained a row per boot and the install pie counted reboots. + // + // The flag keeps them display-only in the UI, which is the actual intent: bookkeeping a user + // would break by editing. + controls_.addText("reportedVersion", reportedVersion_, sizeof(reportedVersion_)); + controls_.setReadOnly(controls_.count() - 1, true); + + // The running version is derived at setup() from a compile-time constant, so it genuinely + // has nothing to persist. controls_.addReadOnly("version", runningVersion_, sizeof(runningVersion_)); } - /// True when the UI should ask. Only ever true before the user answers, and never while the - /// device is its own access point. - bool shouldPrompt() const { - if (consent_ != Unanswered) return false; - // Not while the device is its own access point: there is no route to the internet there, - // and the user is part-way through setting the device up. The question waits for a real - // network rather than interrupting provisioning. - if (inApMode()) return false; - return true; - } - /// True when a report is due: the user said yes, and the running version differs from the one /// that last reported. bool reportDue() const { - if (consent_ != Yes) return false; + if (!consent_) return false; return std::strcmp(reportedVersion_, runningVersion_) != 0; } - /// Which kind of report is due. An empty `reportedVersion` means this install has never - /// reported, so it is a fresh install; anything else means the firmware moved under it. + /// Which kind of report is due. MoonStatsEvent dueEvent() const { return reportedVersion_[0] == 0 ? MoonStatsEvent::Install : MoonStatsEvent::Upgrade; } @@ -256,53 +259,45 @@ class MoonStatsModule : public MoonModule { return reportedVersion_[0] == 0 ? nullptr : reportedVersion_; } - /// Record that a report was sent for the running version, so nothing further is due until the - /// next upgrade. Called after a send is HANDED OFF rather than after it succeeds: a failure is - /// not retried (a lost report costs one row in an aggregate), and marking only on success would - /// make an unreachable server re-send on every boot. + /// Record that a report was sent. Called on HAND-OFF rather than on success: a lost report + /// costs one row, where marking only on success would re-send on every boot. void markReported() { std::snprintf(reportedVersion_, sizeof(reportedVersion_), "%s", runningVersion_); + // markDirty() alone marks the MODULE dirty; it does not schedule a save. FilesystemModule's + // tick1s returns before flushing unless noteDirty() has set its pending flag, and nothing on + // the report path calls it: the pair is what a control write does (HttpServerModule). + // + // Without it an INSTALL usually still saved, riding along with the 2 s debounce the consent + // write left pending, while an UPGRADE boot (consent already on, no control written) left the + // new version in RAM only, so the next reboot reported the same upgrade again. That is the + // case this feature exists for. markDirty(); + FilesystemModule::noteDirty(); } - /// Record the user's answer. - void setConsent(Consent answer) { - consent_ = static_cast(answer); - markDirty(); - } + /// Record the user's answer, the same way a control write does. + void setConsent(bool yes) { consent_ = yes; markDirty(); } - Consent consent() const { return static_cast(consent_); } + bool consent() const { return consent_; } - /// Pretend the firmware is a different version, so a test can exercise a real UPGRADE: the - /// running version changing under an install that already reported. Nothing else can produce - /// that state, since `setup()` reads a compile-time constant and a test cannot recompile. + /// Fake the running version so a test can exercise a real upgrade; `setup()` reads a + /// compile-time constant, which a test cannot change. void setRunningVersionForTest(const char* v) { std::snprintf(runningVersion_, sizeof(runningVersion_), "%s", v ? v : ""); } - /// This installation's id, or an empty string when the user has not consented. - /// - /// Gated on consent rather than merely unused without it: the policy says nothing is generated - /// until you say yes, so a caller cannot obtain one to log or display either. + /// This installation's id, or empty without consent. Gated rather than merely unused, so no + /// caller can obtain one to log or display. void installationId(char* out) const { if (!out) return; - if (consent_ != Yes) { out[0] = 0; return; } + if (!consent_) { out[0] = 0; return; } mm::installationId(out); } - /// One bounded HTTP call per second at most, and only when a report is actually due, which is - /// once per install or upgrade in the life of a device. The same shape HueDriver uses for the - /// bridge poll: `tick1s` is not the render path, and a call that costs a second here costs - /// nothing a user can see. - // The send is a bounded blocking call, which is what `tick1s` is for: it is the 1 Hz - // housekeeping tick, not the per-frame render path, and it is where HueDriver polls its bridge - // and the OTA path fetches. `-Wfunction-effects` still warns, because the base declares the - // hook MM_NONBLOCKING for the per-frame case and an override cannot narrow that: the warning - // names a real property (this call blocks) rather than a mistake. - // - // What keeps it acceptable is frequency: at most one call, once per firmware install, for the - // life of the device. The guards below run first and cost nothing, so a device that has already - // reported never reaches the blocking part again. + /// A bounded blocking send on the 1 Hz housekeeping tick, the same shape HueDriver uses for its + /// bridge poll. `-Wfunction-effects` warns because the base declares the hook MM_NONBLOCKING for + /// the per-frame case; the warning names a real property rather than a mistake. At most one call + /// per firmware install, and the guards below return first once a device has reported. void tick1s() MM_NONBLOCKING override { MoonModule::tick1s(); if (!reportDue()) return; @@ -312,24 +307,20 @@ class MoonStatsModule : public MoonModule { sendReport(); } - /// Whether the device is currently serving its own access point, asked of the platform rather - /// than pushed in by NetworkModule. A setter would have meant one module reaching into another - /// to keep a copy of a fact the platform already answers, and a stale copy is a prompt that + /// Asked of the platform rather than pushed in by NetworkModule: a stale copy is a prompt that /// appears at the wrong moment. bool inApMode() const { return platform::wifiApConnected(); } private: - /// Build the report and POST it once. Marked reported on HAND-OFF rather than on success: a - /// failure costs one row in an aggregate, where re-sending on every boot until a server answers - /// would turn one report into a heartbeat, which is the one thing this feature promises never - /// to be. + /// Build the report and POST it once. Marked reported on hand-off: re-sending until a server + /// answers would turn one report into a heartbeat. void sendReport() { char id[kInstallationIdChars + 1] = {}; installationId(id); if (!id[0]) return; // no consent, no id, no report - // Scheduler exposes module(i) rather than the array, so the tree is gathered here. 32 is - // its own capacity, so this cannot truncate a tree the scheduler accepted. + // Scheduler exposes module(i) rather than the array. 32 is its own capacity, so this + // cannot truncate a tree it accepted. MoonModule* tree[32] = {}; auto* sched = Scheduler::instance(); if (!sched) return; @@ -339,19 +330,19 @@ class MoonStatsModule : public MoonModule { } JsonSink body; + const LightSummary* lights = Drivers::latestSummary(); buildMoonStatsReport(body, tree, count, - dueEvent(), id, runningVersion_, previousVersion()); + dueEvent(), id, runningVersion_, previousVersion(), + lights ? lights->lightCount : 0); - // The response is discarded: the server answers {"ok":true} and there is nothing to do - // with it. A small buffer still has to exist because httpRequest reads into one. - // Sent through the container, which owns the address and the scheme choice. + // Sent through the container, which owns the address. The response is discarded. if (auto* cloud = static_cast(parent())) { (void)cloud->post("/api/report", body.data()); } markReported(); } - uint8_t consent_ = Unanswered; + bool consent_ = false; char reportedVersion_[32] = {}; char runningVersion_[32] = {}; }; diff --git a/src/core/MoonTalkModule.h b/src/core/MoonTalkModule.h index 89c017f3..57a2f384 100644 --- a/src/core/MoonTalkModule.h +++ b/src/core/MoonTalkModule.h @@ -2,30 +2,21 @@ #pragma once /// @file MoonTalkModule.h -/// MoonTalk: a public message board between projectMM devices, in the shape Meshtastic's channel -/// chat has. +/// MoonTalk: a public message board between projectMM devices. /// -/// A second MoonCloud child, with its OWN consent: agreeing to share a chip model says nothing -/// about wanting to publish messages, so the two are separate questions and neither implies the -/// other ([MoonCloudModule.h](MoonCloudModule.h)). +/// A MoonCloud child with its OWN consent: agreeing to share a chip model says nothing about wanting +/// to publish messages. /// -/// **Everything sent here is public and permanent.** There is no private message, no recipient and -/// no delete: a message goes on a board anyone can read, and that is what the consent is asking -/// about. Nothing is sent until it says yes, and the module never posts on its own: a message -/// exists because somebody typed it. +/// **Everything sent here is public and permanent.** No private message, no recipient, no delete, +/// and the module never posts on its own: a message exists because somebody typed it. /// -/// **Two consents, not one.** `consent` allows posting at all. `shareName` is separate and OFF by -/// default, because a device name is a real identifier: "MM-A094" says nothing, "Ewoud's bedroom" -/// says a great deal, and the second is what people actually type. Without it a message is -/// attributed to the first 8 characters of the installation id, which groups one device's messages -/// without naming anyone. +/// **Two consents.** `consent` allows posting at all; `shareName` is separate and OFF by default, because +/// "MM-A094" says nothing while "Ewoud's bedroom" says a great deal. Without it a message is +/// attributed to the first 8 characters of the installation id. /// -/// **Reading needs no consent at all.** The board is public, so fetching it sends nothing about -/// this device, exactly like the MoonCloud Stats aggregates. -/// -/// **There is no authentication**, so a sender id can be fabricated by anyone posting by hand. -/// Acceptable for a hobby board where nothing is gated on identity, and stated in the privacy -/// policy rather than left to be discovered. +/// Reading needs no consent and sends nothing. There is no authentication, so a sender id can be +/// fabricated by anyone posting by hand: acceptable for a board where nothing is gated on identity, +/// and stated in the privacy policy rather than left to be discovered. #include #include @@ -41,65 +32,39 @@ namespace mm { class MoonTalkModule : public MoonModule { public: - enum Consent : uint8_t { Unanswered = 0, Yes = 1, Never = 2 }; - void defineControls() override { controls_.clear(); - static const char* kConsentOptions[] = {"Not answered", "Yes", "Never"}; - controls_.addSelect("consent", consent_, kConsentOptions, 3); + // A checkbox: publishing is on or off, and a third state said nothing extra. + controls_.addControl("consent", consent_); - // Separate from `consent` and default OFF. A device name is the one field here that - // identifies a person rather than a machine, so sharing it is its own decision. + // Default OFF: a device name is the one field here that identifies a person. controls_.addControl("shareName", shareName_); - // What to say. Cleared after a send, so the box is empty for the next message rather than - // inviting an accidental repost. controls_.addText("message", message_, sizeof(message_)); - + // Below the text it sends, and the ONLY thing that publishes. + controls_.addButton("send"); } - /// Post whatever is in `message` when the user commits it. Driven by a control write rather - /// than a tick: a message board that posts on a timer would be a bot. + /// A message is published when the user presses `send`, and never before. + /// + /// The first shape sent on a `message` write, which a Text control emits on every debounced + /// KEYSTROKE: typing "hello" published "hel" and "hell" as messages of their own. A settle + /// window then guessed when typing had stopped, and guessing wrong cleared a half-typed + /// message. A button removes the guess entirely: a keystroke is just a keystroke. void onControlChanged(const char* name) override { - if (!name || std::strcmp(name, "message") != 0) return; - if (consent_ != Yes) { message_[0] = 0; return; } // never send without consent - if (message_[0] == 0) return; // cleared, nothing to do - - // A TEXT CONTROL REPORTS EVERY KEYSTROKE, debounced. That suits a setting, where the last - // value wins and the ones before it are harmless, and it does not suit a message: typing - // "hello" published "hel" and then "hell" as messages of their own. - // - // So a message is held until it stops growing. Each write restarts a short wait; the send - // happens in tick1s once nothing has arrived for a moment, which is the same thing a person - // means by having finished typing. Held HERE rather than in the browser because it is a - // rule about what a message is, and a device posting from a script or over the API deserves - // it too. - pendingMs_ = kSettleMs; - } + if (!name || std::strcmp(name, "send") != 0) return; + if (!consent_) return; // never publish without consent + if (message_[0] == 0) return; // nothing typed - // Same shape as MoonCloud Stats: the post is a bounded blocking call on the 1 Hz housekeeping - // tick rather than the per-frame path, and `-Wfunction-effects` warns because the base hook is - // declared MM_NONBLOCKING for that per-frame case. The frequency is what makes it fine: a - // message is sent because a person typed one, and the settle check below returns immediately on - // every tick where nobody did. - void tick1s() MM_NONBLOCKING override { - MoonModule::tick1s(); - if (pendingMs_ == 0) return; - pendingMs_ = pendingMs_ > 1000 ? pendingMs_ - 1000 : 0; - if (pendingMs_ != 0) return; - - if (consent_ == Yes && message_[0]) send(); - message_[0] = 0; // sent, or refused: the box empties either way + send(); + message_[0] = 0; // the box empties once the message is on its way markDirty(); } - /// The device name, read from SystemModule at send time rather than pushed in by whoever knows - /// it. A setter was the first shape and it was never called, so every message went out unnamed - /// while `shareName` said otherwise: a copy that nothing updates is worse than no copy. - /// - /// Read at SEND time, not cached, so renaming the device renames the sender on the next message - /// rather than at the next reboot. + /// Read from SystemModule at SEND time, not cached: a setter was never called, so every message + /// went out unnamed while `shareName` said otherwise. Renaming the device now takes effect on + /// the next message rather than the next reboot. const char* deviceName() const { auto* sched = Scheduler::instance(); if (!sched) return ""; @@ -118,12 +83,20 @@ class MoonTalkModule : public MoonModule { return ""; } - Consent consent() const { return static_cast(consent_); } - bool sharesName() const { return shareName_ && consent_ == Yes; } + bool consent() const { return consent_; } + bool sharesName() const { return shareName_ && consent_; } + const char* message() const { return message_; } + + /// Set the two consents directly, so a test can walk the matrix without a UI write. + void setConsentForTest(bool yes) { consent_ = yes; } + void setShareNameForTest(bool share) { shareName_ = share; } + + /// Put text in the box the way a control write does, so a test can press send without a UI. + void setMessageForTest(const char* text) { + std::snprintf(message_, sizeof(message_), "%s", text ? text : ""); + } private: - /// Build and post one message. Same server and scheme rule as MoonCloud Stats: port 443 or - /// none means the public server over HTTPS, anything else a local one over plain HTTP. void send() { char id[kInstallationIdChars + 1] = {}; installationId(id); @@ -132,8 +105,7 @@ class MoonTalkModule : public MoonModule { JsonSink body; body.append("{\"sender\":"); body.writeJsonString(id); - // The name rides ONLY when both consents allow it. Omitted rather than sent empty, so the - // server stores nothing rather than a blank it would have to interpret. + // Only when both consents allow it, and omitted rather than sent empty. const char* name = deviceName(); if (shareName_ && name && name[0]) { body.append(",\"name\":"); @@ -144,17 +116,13 @@ class MoonTalkModule : public MoonModule { body.append("}"); body.flush(); - // Sent through the container, which owns the address and the scheme choice. + // Sent through the container, which owns the address. if (auto* cloud = static_cast(parent())) { (void)cloud->post("/api/talk", body.data()); } } - /// How long a message must stop changing before it counts as finished. - static constexpr uint16_t kSettleMs = 2000; - uint16_t pendingMs_ = 0; - - uint8_t consent_ = Unanswered; + bool consent_ = false; bool shareName_ = false; char message_[192] = {}; }; diff --git a/src/main.cpp b/src/main.cpp index 3ffe6a50..b242babc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -615,8 +615,8 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { // whether they exist. Without it a config written before a child was added drops that child on // load, which is exactly what happened when Talk was introduced beside Stats: the file listed // one child, so the tree came back with one. - if (moonStatsModule) { moonStatsModule->markWiredByCode(); moonCloudModule->addChild(moonStatsModule); } - if (moonTalkModule) { moonTalkModule->markWiredByCode(); moonCloudModule->addChild(moonTalkModule); } + moonStatsModule->markWiredByCode(); moonCloudModule->addChild(moonStatsModule); + moonTalkModule->markWiredByCode(); moonCloudModule->addChild(moonTalkModule); scheduler.addModule(moonCloudModule); if (improvModule) networkModule->addChild(improvModule); if (mqttModule) networkModule->addChild(mqttModule); diff --git a/src/ui/app.js b/src/ui/app.js index 1244d4f0..725a3854 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -554,22 +554,44 @@ async function sendControl(moduleName, controlName, value) { // switched. The routine WS push cannot carry it: renderCards is suppressed while the user // is interacting, and operating this select is exactly that. else if (moduleName === "Firmware" && controlName === "image") refetchState(); - // MoonCloud: saying yes sends a report, and typing a message posts it, so in both cases the - // thing the card DISPLAYS changed on the server as a result of the write. Refetched after - // the POST for the same reason as `image` above: the new data only exists once the device - // has acted on it. The cached aggregates are dropped first, since a fetch that returned the - // cache would show the state from before this device contributed to it. - // A MoonCloud write changes what the server holds, and the card shows the server. One - // rule for every member rather than a hook per control: a report goes out when consent is - // granted, a message when one settles, and a later member will have its own trigger. + // MoonCloud: a write that changes what the SERVER holds, and the card shows the server. // - // Re-checked a few times rather than once after a guessed delay, because the device acts on - // its own tick and how long that takes is not the browser's to know: a single timeout is a - // guess that is wrong as soon as a member's timing changes, which is exactly what happened - // when messages gained a settle window. - else if (["MoonStatsModule", "MoonTalkModule"].includes(mod?.type)) { - moonCloudStatsCache = null; - for (const delay of [800, 2000, 3500]) setTimeout(refetchState, delay); + // Only those writes: pressing `send` publishes a message, and answering `consent` with Yes + // sends a report. Typing in `message` or toggling `shareName` changes nothing on the server, + // so refreshing after them re-fetched the board to show exactly what it already showed. + // + // A message is sent INSIDE the write (MoonTalk::onControlChanged calls send()), so a 200 + // means the board already has it and one refetch is right. + // + // A report is not: MoonStats sends from tick1s once networkReady(), up to a second or more + // after the consent write returns. So the consent branch retries, and the message branch + // does not. An earlier shape retried both three times, which re-fetched the board twice for + // nothing; a later one refetched both once, and a user who said Yes did not see their own + // install until they reloaded. + // Three writes change what a MoonCloud card shows: pressing `send` publishes a message, and + // either member's `consent` decides whether that member reads at all. Typing in `message` or + // toggling `shareName` changes only the device, and re-reading after those showed exactly + // what was already on screen. + else if (mod?.type === "MoonTalkModule" ? (controlName === "send" || controlName === "consent") + : mod?.type === "MoonStatsModule" ? controlName === "consent" + : false) { + // Switching consent OFF changes nothing on the server (a report already sent stays + // sent), so the card rebuilds from what it has and nothing is re-read. The rebuild is + // what hides the data. + const turnedOn = Boolean(value); + if (!turnedOn) { moonCloudGeneration++; refetchState(); return; } + + // Switching ON: ONE read, once. A Stats report leaves from tick1s rather than during + // this write, so reading immediately would show the totals without this device's own + // install, the one row the reader is looking for. A Talk message is sent inside the + // write, so its read needs no delay. An earlier shape read three times to cover the + // uncertainty and rebuilt the card three times with it. + moonCloudGeneration++; + setTimeout(() => { + moonCloudStatsCache.clear(); + moonTalkCache = null; + refetchState(); + }, mod?.type === "MoonStatsModule" && controlName === "consent" ? 2000 : 0); } } catch (e) { console.warn(`[control] POST ${moduleName}.${controlName} failed (error=${e && e.message ? e.message : e})`); @@ -1949,9 +1971,8 @@ function createCard(mod, depth) { renderFileManager(mod, controlsHost); } - // MoonCloud shows what everyone else reported, which is why the server has no dashboard of its - // own: contributing earns the answer back on the card that asked for consent. Reading the - // totals sends nothing about this device, so it runs whatever `consent` says. + // Contributing earns the answer back on the card that asked for consent, so the charts render + // here rather than anywhere else. They are drawn empty until consent is on. if (mod.type === "MoonStatsModule") { renderMoonCloudStats(controlsHost, mod); } @@ -1968,6 +1989,20 @@ function createCard(mod, depth) { // device fetches the binary via /api/firmware/url: no browser CORS in // the data path. See docs/architecture.md § Firmware vs board. if (mod.type === "FirmwareUpdateModule") { + // A nudge where someone has just thought about versions, which is the moment the aggregate + // is worth something to them. Shown only while Stats consent is off, so it disappears the + // moment it is acted on. Both halves are named: reading it should not be how someone + // discovers that consenting also shares. + const statsConsent = allModules() + .find(m => m.type === "MoonStatsModule")?.controls + ?.find(c => c.name === "consent")?.value; + if (statsConsent === false) { + const nudge = document.createElement("div"); + nudge.className = "mooncloud-nudge"; + nudge.textContent = "Turn on MoonCloud stats to share and see what everyone else is running."; + host.appendChild(nudge); + } + // TWO IMAGES, ONE PANEL. A device installs the app it runs and MoonBase the recovery // image, described by the same four controls and installed the same three ways (a // release, a URL, a file). The device's `image` control says which, and everything here @@ -2801,6 +2836,26 @@ function createControl(moduleName, moduleType, ctrl) { dragTs[key] = Date.now(); debounceSend(key, 500, () => sendControl(moduleName, ctrl.name, input.value)); }); + // Enter presses the card's `send` button, where the module has one. Generic rather + // than a MoonTalk rule: any module pairing a text field with a send button gets it, + // and a module without one is unaffected. + // + // The write is FLUSHED first and awaited. Typing is debounced 500 ms, so Enter + // straight after the last keystroke would otherwise press send while the device + // still held the previous text, publishing the message a keystroke short. + input.addEventListener("keydown", async (e) => { + if (e.key !== "Enter") return; + // allModules() walks children: Talk is a MoonCloud child, so a flat lookup on + // state.modules never finds it. + const mod = allModules().find(m => m.name === moduleName); + const send = (mod?.controls || []) + .find(c => c.type === "button" && c.name === "send"); + if (!send) return; + e.preventDefault(); + clearTimeout(dragTimers[key]); + await sendControl(moduleName, ctrl.name, input.value); + await sendControl(moduleName, "send", 1); + }); } row.appendChild(input); break; @@ -4187,7 +4242,11 @@ function appendResetButton(row, moduleName, ctrl, def, applyVisually) { // A SURFACE control has no default worth restoring: its value belongs to whatever it drives, so // "reset" would drive that target to zero, which is a change rather than a reset. The row is // also 26px wide, and the button was taking space from the thing being operated. - if (ctrl.fader || ctrl.encoder || ctrl.switchRow) return; + // + // A CONSENT control is the same shape for a different reason: off is where it starts, so the + // button's only possible action is to withdraw consent, which is a decision rather than a + // reset. The checkbox already expresses both answers. + if (ctrl.fader || ctrl.encoder || ctrl.switchRow || ctrl.name === "consent") return; const btn = document.createElement("button"); btn.className = "reset-btn"; btn.type = "button"; @@ -5979,29 +6038,27 @@ async function fmFetchDir(absPath, hidden) { // (path/new folder/delete); browsing is pure UI over /api/dir, so the module stays minimal. // MoonCloud Stats: what everyone else reported, rendered on the card that asked for consent. // -// THE REASON THE SERVER HAS NO DASHBOARD. A second web UI would be a second thing to build, host -// and keep in sync with the schema, and it would put the answer somewhere other than where the -// question was asked. Here, contributing earns the result back on the device you already own. -// -// Reads only: this sends no id, no report and no configuration, so it runs whatever `consent` -// says, including for someone who declined. A failure leaves the section out rather than showing -// an error, because the aggregate is a reward and not something the device needs to work. -// The MoonCloud server is an API and nothing else: the device asks it for the aggregates and -// draws them here. That is the whole reason it has no dashboard of its own, and why contributing -// shows its result on the card that asked for consent rather than on a website somewhere. -// -// Reads only. No id, no report and no configuration leave the browser here, so this runs whatever -// `consent` says, including for someone who answered Never. A failure leaves the section out -// rather than showing an error: the aggregate is a reward, not something the device needs. +// Reads only: no id, no report and no configuration leave the browser here. The charts are drawn +// EMPTY until this member's consent is on, so what saying yes gets you is visible before you say +// it, and a server that cannot be reached says so rather than rendering as an empty one. // Where the aggregates come from: the SAME `server` / `serverPort` controls the firmware posts // its report to, read off the module rather than hardcoded here. One setting drives both // directions, so pointing a device at a local or self-hosted MoonCloud moves the fetch with it // instead of leaving the card looking at somewhere else. // THE MoonCloud address, the same constant the firmware compiles in. Not read from a control: // there is one MoonCloud, and moving it is a release rather than a setting somebody can mistype. -const kMoonCloudUrl = "https://stats.moonmodules.org"; +// Whether this member's own consent is on. The data a MoonCloud card shows is what contributing +// earns back, so an unconsented card renders the same charts with nothing in them. +function consented(mod) { + return Boolean((mod?.controls || []).find(c => c.name === "consent")?.value); +} + +const kMoonCloudUrl = "https://mooncloud-stats.moonmodules.workers.dev"; -let moonCloudStatsCache = null; +let moonCloudStatsCache = new Map(); // filter query -> stats, so switching back is free +let moonCloudFilter = {}; // dimension -> value the reader chose +let moonCloudGeneration = 0; // bumped on a control write; older answers are dropped +let moonTalkCache = null; const kMoonCloudColors = ["#4a9eff", "#ff8c42", "#3ecf8e", "#e8618c", "#a78bfa", "#f6c445", "#4dd0e1", "#9aa5b1"]; @@ -6027,30 +6084,45 @@ function moonCloudTopSlices(rows, keep = 7) { return tail ? head.concat([{ name: "other", count: tail }]) : head; } -function moonCloudPie(rows) { +function moonCloudPie(rows, onPick) { const NS = "http://www.w3.org/2000/svg"; const total = rows.reduce((sum, r) => sum + r.count, 0) || 1; const svg = document.createElementNS(NS, "svg"); svg.setAttribute("viewBox", "0 0 200 200"); svg.setAttribute("class", "mooncloud-pie"); + // No rows: one filled circle, so a placeholder reads as a pie waiting for data rather than as + // the blank sliver a zero-angle slice produces. + if (!rows.length) { + const disc = document.createElementNS(NS, "circle"); + disc.setAttribute("cx", "100"); + disc.setAttribute("cy", "100"); + disc.setAttribute("r", "92"); + disc.setAttribute("class", "mooncloud-pie-placeholder"); + svg.appendChild(disc); + return svg; + } + let angle = 0; rows.forEach((r, i) => { const next = angle + (r.count / total) * Math.PI * 2; const path = document.createElementNS(NS, "path"); path.setAttribute("d", moonCloudArc(100, 100, 92, angle, next)); path.setAttribute("fill", kMoonCloudColors[i % kMoonCloudColors.length]); - path.setAttribute("class", "mooncloud-slice"); + const pickable = onPick && r.name && r.name !== "other"; + path.setAttribute("class", pickable ? "mooncloud-slice mooncloud-pickable" : "mooncloud-slice"); const title = document.createElementNS(NS, "title"); - title.textContent = `${r.name}: ${r.count} (${Math.round((r.count / total) * 100)}%)`; + title.textContent = `${r.name}: ${r.count} (${Math.round((r.count / total) * 100)}%)` + + (pickable ? ", click to filter" : ""); path.appendChild(title); + if (pickable) path.addEventListener("click", () => onPick(r.name)); svg.appendChild(path); angle = next; }); return svg; } -function moonCloudLegend(rows) { +function moonCloudLegend(rows, onPick) { const box = document.createElement("div"); box.className = "mooncloud-legend"; rows.forEach((r, i) => { @@ -6065,22 +6137,26 @@ function moonCloudLegend(rows) { count.className = "mooncloud-legend-count"; count.textContent = String(r.count); line.append(swatch, name, count); + // `other` is a bucket of everything past the top slices, so it names no value to filter by. + if (onPick && r.name && r.name !== "other") { + line.className = "mooncloud-pickable"; + line.addEventListener("click", () => onPick(r.name)); + } box.appendChild(line); }); return box; } -// What the consent control is asking, in one line above it. +// What the consent checkbox is asking, in one line above it. // -// NOT a prompt with its own Yes/Not now/Never buttons: the `consent` dropdown already offers -// exactly those, so a button row beside it is the same choice rendered twice and costs a block of -// the card to say nothing new. What the dropdown cannot carry is WHY, so that is what stays. +// NOT a prompt with its own buttons: the checkbox already IS the choice, so a button row beside it +// renders the same decision twice. What a checkbox cannot carry is WHY, so that is what stays. // -// Shown only while the question is unanswered, so a card whose owner has decided is not still -// explaining itself. +// Shown only while consent is off, so a card whose owner has said yes is not still explaining +// itself. function renderMoonCloudConsent(host, mod) { const consent = (mod?.controls || []).find(c => c.name === "consent"); - if (!consent || Number(consent.value) !== 0) return; // 0 = Not answered + if (!consent || consent.value) return; const box = document.createElement("div"); box.className = "mooncloud-consent"; @@ -6104,16 +6180,68 @@ function renderMoonCloudStats(host, mod) { section.className = "mooncloud-stats"; host.appendChild(section); - const draw = (stats) => { + // As in MoonTalk: `reached` separates "the server said there is nothing" from "the server never + // answered". Only the first may render as no statistics; the second says so. + const draw = (stats, reached = true) => { section.textContent = ""; - if (!stats || !stats.installations) return; - + if (!reached) { + const failed = document.createElement("div"); + failed.className = "mooncloud-stats-title mooncloud-unreachable"; + failed.textContent = "Cannot reach the MoonCloud server."; + section.appendChild(failed); + return; + } + const head = document.createElement("div"); + head.className = "mooncloud-stats-head"; const title = document.createElement("div"); title.className = "mooncloud-stats-title"; - title.textContent = - `What everyone else is running: ${stats.installations} installations`; + title.textContent = stats + ? `What everyone else is running: ${stats.installations} installations` + : "What everyone else is running"; + head.appendChild(title); + // Fetched once per card build, so this is how to ask again without reloading. Only with + // consent: without it there is nothing to refresh, because nothing is fetched. + if (consented(mod)) { + const refresh = document.createElement("button"); + refresh.className = "moontalk-refresh"; + refresh.textContent = "\u21bb"; + refresh.title = "Refresh"; + refresh.addEventListener("click", () => { moonCloudStatsCache.clear(); load(); }); + head.appendChild(refresh); + } - section.appendChild(title); + section.appendChild(head); + + // A filter is always something the reader chose, so it is stated plainly with a way back. + const active = stats ? Object.entries(moonCloudFilter) : []; + if (active.length) { + const bar = document.createElement("div"); + bar.className = "mooncloud-filter"; + const what = document.createElement("span"); + // A bounds pair reads as one range, not two numbers: `lightCountMin` + `lightCountMax` + // came from a single slice and a reader thinks of it that way. + const seen = new Set(); + const parts = []; + for (const [k, v] of active) { + const base = k.replace(/(Min|Max)$/, ""); + if (base !== k) { + if (seen.has(base)) continue; + seen.add(base); + // The slice's own label, not the bounds: the reader clicked "64-128 KB" and + // "65537 to 131072" is the same fact in a form nobody chose. + parts.push(moonCloudFilter[`${base}Label`] ?? moonCloudFilter[`${base}Min`]); + continue; + } + parts.push(k === "dev" ? (v === "1" ? "development" : "released") : v); + } + what.textContent = "Filtered to " + parts.join(", "); + const clear = document.createElement("button"); + clear.className = "mooncloud-filter-clear"; + clear.textContent = "Clear"; + clear.addEventListener("click", () => { moonCloudFilter = {}; load(); }); + bar.append(what, clear); + section.appendChild(bar); + } const grid = document.createElement("div"); grid.className = "mooncloud-charts"; @@ -6122,38 +6250,90 @@ function renderMoonCloudStats(host, mod) { // it (the edge derives it from the connection): withholding it here while serving it to // anyone who calls /api/stats would be a distinction without a difference, and a // country-level count is the same coarse figure every comparable dashboard shows. - for (const [label, rows] of [["Version", stats.versions], - ["Chip", stats.chips], - ["Board", stats.deviceModels], - ["Flash", stats.flash], - ["PSRAM", stats.psram], - ["SDK", stats.sdk], - ["Install or upgrade", stats.events], - ["Upgraded from", stats.previousVersions], - ["Modules", stats.modules], - ["Build", stats.builds], - ["Country", stats.countries]]) { - const shown = moonCloudTopSlices(rows); - if (!shown.length) continue; + // Each chart names the query parameter its slices filter by, so clicking one re-counts + // every other chart within it. `Build` maps to `dev`, whose values are 0 and 1 rather than + // the labels shown. + // The list names a KEY, not a value: reading `stats.versions` here dereferenced a null + // answer while the array was built, which threw before any per-chart guard could run and + // took the whole card down with it. + for (const [label, key, param] of [["Version", "versions", "version"], + ["Chip", "chips", "chip"], + ["Board", "deviceModels", "deviceModel"], + ["Flash", "flash", "flash"], + ["PSRAM", "psram", "psram"], + ["SDK", "sdk", "sdk"], + ["Install or upgrade", "events", "event"], + ["Upgraded from", "previousVersions", "previousVersion"], + ["Drivers", "drivers", "driver"], + ["Services", "services", "service"], + ["Layouts", "layouts", "layout"], + ["Effects", "effects", "effect"], + ["Modifiers", "modifiers", "modifier"], + ["Lights", "lightCounts", "lightCount"], + ["Free memory", "freeMemory", "freeHeap"], + ["Total memory", "totalMemory", "totalHeap"], + ["Build", "builds", "dev"], + ["Country", "countries", "country"]]) { + // No stats: the heading over an empty circle, so the card shows what consent unlocks + // without naming a single value somebody else reported. + const shown = stats ? moonCloudTopSlices(stats[key]) : []; + if (stats && !shown.length) continue; const chart = document.createElement("div"); chart.className = "mooncloud-chart"; const heading = document.createElement("div"); heading.className = "mooncloud-chart-title"; heading.textContent = label; - chart.append(heading, moonCloudPie(shown), moonCloudLegend(shown)); + // A bucketed slice carries `min`/`max` rather than a value the column could equal, so + // it filters by BOUNDS: the label is a range, the column holds the raw number. + const bounded = shown.some(r => r.min !== undefined); + const pick = (name) => { + if (bounded) { + const row = shown.find(r => r.name === name); + if (!row) return; + delete moonCloudFilter[`${param}Min`]; + delete moonCloudFilter[`${param}Max`]; + delete moonCloudFilter[`${param}Label`]; + if (row.min !== undefined) moonCloudFilter[`${param}Min`] = String(row.min); + if (row.max !== undefined) moonCloudFilter[`${param}Max`] = String(row.max); + moonCloudFilter[`${param}Label`] = name; // what the reader actually clicked + } else { + moonCloudFilter[param] = param === "dev" + ? (name === "development" ? "1" : "0") + : name; + } + load(); + }; + chart.append(heading, moonCloudPie(shown, pick), moonCloudLegend(shown, pick)); grid.appendChild(chart); } section.appendChild(grid); }; - // Cached for the session: these numbers move on the scale of days, and a card rebuild happens - // on every unrelated state push. - if (moonCloudStatsCache) { draw(moonCloudStatsCache); return; } - - fetch(kMoonCloudUrl + "/api/stats", { cache: "no-store" }) - .then(r => r.ok ? r.json() : null) - .then(stats => { moonCloudStatsCache = stats; draw(stats); }) - .catch(() => { /* no section: the card is complete without it */ }); + // Cached per filter for the session: these numbers move on the scale of days, a card rebuild + // happens on every unrelated state push, and clicking back to a filter already read is free. + const load = () => { + // Without consent NOTHING is fetched: the headings are a constant in this file, so the card + // draws its own shape. A request would carry no identifier and still be a call to our server + // from a device whose owner said no. + if (!consented(mod)) { draw(null, true); return; } + // `*Label` is what the filter bar shows, not something the server knows: the bounds are the + // filter, and sending a label would be an unknown parameter. + const sent = Object.fromEntries( + Object.entries(moonCloudFilter).filter(([k]) => !k.endsWith("Label"))); + const query = new URLSearchParams(sent).toString(); + if (moonCloudStatsCache.has(query)) { draw(moonCloudStatsCache.get(query), true); return; } + const generation = moonCloudGeneration; + fetch(kMoonCloudUrl + "/api/stats" + (query ? `?${query}` : ""), { cache: "no-store" }) + .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))) + .then(stats => { + if (generation !== moonCloudGeneration) return; // a newer write superseded this + moonCloudStatsCache.set(query, stats); + draw(stats, true); + }) + // Not cached: a failure is a fact about right now, so the next card build retries. + .catch(() => draw(null, false)); + }; + load(); } // MoonTalk: the public board, read straight onto the card that posts to it. @@ -6174,22 +6354,31 @@ function renderMoonTalk(host, mod) { header.className = "moontalk-header"; const title = document.createElement("span"); title.textContent = "Messages"; - const refresh = document.createElement("button"); - refresh.className = "moontalk-refresh"; - refresh.textContent = "\u21bb"; - refresh.title = "Refresh"; - header.append(title, refresh); + header.appendChild(title); const list = document.createElement("div"); list.className = "moontalk-list"; section.append(header, list); - const draw = (messages) => { + // `reached` says whether the server answered at all. An unreachable server and an empty board + // are different facts and lead to different actions (check the network, versus wait for + // someone to post), so they must not render as the same nothing. + const draw = (messages, reached = true, asked = true) => { list.textContent = ""; + if (!reached) { + const failed = document.createElement("div"); + failed.className = "moontalk-empty moontalk-unreachable"; + failed.textContent = "Cannot reach the MoonCloud server."; + list.appendChild(failed); + return; + } if (!messages || !messages.length) { const empty = document.createElement("div"); empty.className = "moontalk-empty"; - empty.textContent = "No messages yet."; + // "No messages yet." is a claim about the board. Without consent it was never read, so + // the card says what is true instead: nothing was asked. + empty.textContent = asked ? "No messages yet." + : "Turn on consent to read the board."; list.appendChild(empty); return; } @@ -6228,21 +6417,45 @@ function renderMoonTalk(host, mod) { row.append(who, text, when); list.appendChild(row); } + // Oldest first means the newest is at the BOTTOM of a scrolling box, so a refresh that did + // not scroll left the message someone just sent out of sight. + list.scrollTop = list.scrollHeight; }; - // Incremental: ask only for what this browser has not seen, and keep what it has. A card is - // rebuilt on every unrelated state push, so re-fetching the whole board each time costs 6 KB a - // rebuild on a device serving its own UI, where an empty answer costs 15 bytes. - // - // The cache is per session and per board, so switching servers starts clean. - const load = () => { + // A card is rebuilt on every unrelated state push, so a fetch per build is a request storm + // against a device serving its own UI. Cached for the session like the stats are, and the + // refresh button forces a re-read: `force` is what tells the two apart. + const load = (force) => { + // Same rule as Stats: without consent the board is not fetched at all. The cache is CLEARED + // rather than left holding the empty draw, because a cached `[]` from an unconsented card is + // not an answer: consenting rebuilt the card, the cache hit below returned that empty array, + // and the board stayed blank until a manual refresh. + if (!consented(mod)) { moonTalkCache = null; draw([], true, false); return; } + if (!force && moonTalkCache) { draw(moonTalkCache, true); return; } + const generation = moonCloudGeneration; fetch(kMoonCloudUrl + "/api/talk", { cache: "no-store" }) - .then(r => r.ok ? r.json() : null) - .then(d => draw(d?.messages)) - .catch(() => draw(null)); + .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))) + .then(d => { + if (generation !== moonCloudGeneration) return; // a newer write superseded this + moonTalkCache = d?.messages ?? []; + draw(moonTalkCache, true); + }) + // Not cached: a failure is a fact about right now, so the next build retries. + .catch(() => draw(null, false)); }; - refresh.addEventListener("click", load); - load(); + // Created AFTER `load`, which is a const arrow: a listener attached above its declaration + // throws on click rather than hoisting. Only with consent, since without it nothing is fetched + // and a button that does nothing is worse than none. + if (consented(mod)) { + const refresh = document.createElement("button"); + refresh.className = "moontalk-refresh"; + refresh.textContent = "\u21bb"; + refresh.title = "Refresh"; + refresh.addEventListener("click", () => load(true)); + header.appendChild(refresh); + } + + load(false); } function renderFileManager(mod, host) { diff --git a/src/ui/style.css b/src/ui/style.css index caaec935..5dd55734 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -2151,17 +2151,35 @@ body.assign-mode .control-encoder input { cursor: pointer; } SVG arcs rather than a charting library: three pies do not earn a dependency, and the device serves its own UI with no build step to tree-shake one. */ .mooncloud-stats { margin-top: 12px; } +.mooncloud-stats-head { display: flex; align-items: center; margin-bottom: 10px; } +.mooncloud-stats-head .mooncloud-stats-title { margin-bottom: 0; } +.mooncloud-stats-head .moontalk-refresh { margin-left: auto; } .mooncloud-stats-title { font-size: 0.85em; opacity: 0.75; margin-bottom: 10px; } .mooncloud-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 16px; } -.mooncloud-chart { text-align: center; } +/* Title, pie and legend are ONE centered column, so they cannot drift apart: centering each of them + separately let a long legend entry (a board name) center on a different width than the pie. */ +.mooncloud-chart { display: flex; flex-direction: column; align-items: center; text-align: center; min-width: 0; } .mooncloud-chart-title { font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.55; margin-bottom: 6px; } -.mooncloud-pie { width: 100%; max-width: 8rem; height: auto; } +.mooncloud-pie { width: 8rem; max-width: 100%; height: auto; } .mooncloud-slice { stroke: var(--card-bg, #1b1b1b); stroke-width: 1.5; } -.mooncloud-legend { display: inline-block; text-align: left; margin-top: 6px; font-size: 0.75em; } +/* A chart with no data yet: a muted disc, not a filled slice. */ +.mooncloud-pie-placeholder { fill: currentColor; opacity: 0.10; } +/* Matches the pie's width rather than shrink-wrapping its own content, so the counts line up under + the pie instead of at the right edge of the longest name. */ +.mooncloud-legend { width: 8rem; max-width: 100%; text-align: left; margin-top: 6px; font-size: 0.75em; } .mooncloud-legend div { display: flex; align-items: center; gap: 5px; line-height: 1.6; } .mooncloud-swatch { width: 0.6rem; height: 0.6rem; border-radius: 2px; flex: 0 0 auto; } -.mooncloud-legend-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* min-width:0 is what lets a flex item actually shrink: without it the name keeps its full + intrinsic width, overflows the legend, and drags the auto-margined count along with it. */ +.mooncloud-legend-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .mooncloud-legend-count { margin-left: auto; opacity: 0.6; padding-left: 8px; font-variant-numeric: tabular-nums; } +/* A slice or legend row that names a value can be clicked to narrow every chart to it. */ +.mooncloud-pickable { cursor: pointer; } +.mooncloud-slice.mooncloud-pickable:hover { opacity: 0.75; } +.mooncloud-legend div.mooncloud-pickable:hover .mooncloud-legend-name { text-decoration: underline; } +.mooncloud-filter { display: flex; align-items: center; gap: 8px; font-size: 0.8em; margin-bottom: 10px; } +.mooncloud-filter-clear { background: none; border: 1px solid currentColor; border-radius: 3px; color: inherit; cursor: pointer; font: inherit; opacity: 0.7; padding: 1px 7px; } +.mooncloud-filter-clear:hover { opacity: 1; } /* MoonTalk: the public board on the card that posts to it. Rows rather than bubbles: this is a device panel, not a phone, and a sender plus a line of text is the whole content. */ @@ -2177,8 +2195,12 @@ body.assign-mode .control-encoder input { cursor: pointer; } .moontalk-text { flex: 1 1 auto; overflow-wrap: anywhere; } .moontalk-when { flex: 0 0 auto; opacity: 0.45; font-variant-numeric: tabular-nums; } .moontalk-empty { opacity: 0.5; font-size: 0.8em; } +/* An unreachable server is a different fact from an empty board, so it does not read as absence. */ +.moontalk-unreachable, .mooncloud-unreachable { color: var(--fg-muted); opacity: 0.9; } /* What the MoonCloud consent control is asking, one line above it. No buttons: the `consent` dropdown already offers the same choices, and rendering them twice costs card space to say nothing new. */ .mooncloud-consent { margin: 6px 0 10px; font-size: 0.8em; line-height: 1.6; opacity: 0.8; } +/* One line on the Firmware card, only while stats consent is off. */ +.mooncloud-nudge { margin: 8px 0 0; font-size: 0.8em; line-height: 1.6; opacity: 0.7; } diff --git a/test/js/mooncloud-report.test.mjs b/test/js/mooncloud-report.test.mjs index 83964bfc..06ea46d7 100644 --- a/test/js/mooncloud-report.test.mjs +++ b/test/js/mooncloud-report.test.mjs @@ -110,3 +110,25 @@ test("the stats endpoint selects only aggregates, never a row", () => { assert.ok(!/SELECT\s+installationId/i.test(stats)); assert.ok(!/SELECT\s+\*/i.test(stats)); }); + +// MoonTalk post validation: the shape a device actually sends must be ACCEPTED. +// +// Written after a change truncated the sender to 8 characters at storage while the validator still +// required 32, so every message a device sent was rejected with 400 and the board silently stopped +// receiving anything. Nothing caught it: the device cleared its box, the UI showed no error, and no +// test covered the post path at all. These pin both halves of that contract. +test("a message from a device is accepted, and a malformed sender is not", () => { + // The validator's length check and what the handler stores must agree. A slice narrower than + // the check means nothing can ever pass it. + const stored = source.match(/const sender = typeof msg\.sender === "string" \? msg\.sender\.slice\(0, (\d+)\)/); + assert.ok(stored, "the talk handler must derive `sender` from the request"); + const check = source.match(/sender\.length !== (\d+)\)/); + assert.ok(check, "the talk handler must validate the sender length"); + assert.equal(stored[1], check[1], + "the stored sender and the validated length must match, or every post is rejected"); + + // And the published form is the SHORT one, which is the property the truncation was meant to + // protect: stored in full, published at 8. + assert.match(source, /m\.sender\.slice\(0, SENDER_CHARS\)/, + "a published message must carry only the id prefix"); +}); diff --git a/test/js/ui-mooncloud.test.mjs b/test/js/ui-mooncloud.test.mjs index 43e2479d..b6d84f52 100644 --- a/test/js/ui-mooncloud.test.mjs +++ b/test/js/ui-mooncloud.test.mjs @@ -21,33 +21,54 @@ test("MoonTalk adds no special case to the shared control path", () => { // globals, a control renderer branch keyed on the literal control name, an incremental message // cache and a reload callback. Each was a workaround for the one before it, and each broke the // next thing. A second member has to be able to follow the same path. - for (const leak of ["moonTalkCache", "moonTalkReload", 'ctrl.name === "message"']) { + for (const leak of ["moonTalkReload", 'ctrl.name === "message"']) { assert.ok(!app.includes(leak), `${leak} is a MoonTalk special case in the shared UI`); } + + // A session cache is NOT a special case: Talk and Stats each keep one, both cleared by the same + // shared rule. It was on this list while Talk had a bespoke incremental cache with its own + // reload callback; what made that a leak was the callback and the control-name branch, not the + // caching. Both members now cache the same way. + assert.ok(app.includes("let moonTalkCache") && app.includes("let moonCloudStatsCache"), + "both MoonCloud members cache per session, or neither should"); + // Stats caches PER FILTER, so clicking a slice refetches and clicking back is free. A single + // slot would have served the previous filter's numbers under the new heading. + assert.match(app, /moonCloudStatsCache\.has\(query\)/, + "the stats cache must be keyed on the active filter"); }); -test("one refresh rule serves every MoonCloud member", () => { - // Consent granted sends a report, a settled message posts one, and a later member will have its - // own trigger: all three change what the server holds, and the card shows the server. A hook per - // control was two copies of one idea with two different guessed delays, and the message one was - // already wrong once messages gained a settle window. - const i = app.indexOf('"MoonStatsModule", "MoonTalkModule"'); - assert.ok(i > 0, "no shared MoonCloud refresh rule in sendControl"); - const rule = app.slice(i, i + 400); +test("the card re-reads only on a write that changed the server, once", () => { + // Pressing `send` publishes a message and answering `consent` sends a report. Typing in + // `message` or toggling `shareName` changes only the device, and re-reading after those showed + // exactly what was already on screen. + const i = app.indexOf('mod?.type === "MoonTalkModule" ? (controlName === "send"'); + assert.ok(i > 0, "no MoonCloud refresh rule keyed on the write that changes the server"); + + // BOTH members rebuild on their own consent: the rebuild is what shows or hides the data, and + // without it a Talk consent change did nothing until the page was reloaded. + const trigger = app.slice(i, i + 260); + assert.match(trigger, /controlName === "send" \|\| controlName === "consent"/, + "Talk must rebuild on its own consent, not only on send"); + assert.match(trigger, /"MoonStatsModule" \? controlName === "consent"/, + "Stats rebuilds on its consent"); + const rule = app.slice(i, app.indexOf("\n }", i) + 1); assert.ok(rule.includes("refetchState"), "the rule must refresh the card"); assert.ok(!/\.value\s*=\s*""/.test(rule), "it must not reach into the DOM"); + assert.ok(!app.includes('["MoonStatsModule", "MoonTalkModule"].includes(mod?.type)'), + "matching the whole module type re-reads on every keystroke in `message`"); - // Re-checked rather than timed once: how long the device takes to act is its own business, and - // a single delay is a guess that goes stale the moment a member's timing changes. - assert.match(rule, /for \(const delay of \[/, - "the refresh must be retried rather than fired once after a guessed delay"); + // Switching consent OFF changes nothing on the server, so the card redraws from what it has. + assert.match(rule, /if \(!turnedOn\) \{ moonCloudGeneration\+\+; refetchState\(\); return; \}/, + "switching off must not clear the cache or re-read"); - // And the per-control hooks are gone. - assert.ok(!app.includes('controlName === "message"'), - "no message-specific refresh hook"); - assert.ok(!app.includes('moduleName === "Stats" && controlName === "consent"'), - "no consent-specific refresh hook"); + // Switching ON reads ONCE, after a delay: the report leaves from tick1s rather than during the + // write, so an immediate read shows the totals without this device's own install. An earlier + // shape read three times and rebuilt the card three times with it. + assert.ok(!/for \(const delay of \[/.test(rule), + "one read, not a retry loop: each one rebuilds the card"); + assert.equal((rule.match(/refetchState\(\)/g) || []).length, 2, + "exactly two call sites: the off path and the single delayed on path"); }); test("both MoonCloud fetches use one compiled-in address", () => { @@ -66,13 +87,139 @@ test("both MoonCloud fetches use one compiled-in address", () => { }); test("consent is asked once, above the control, with no duplicate buttons", () => { - // The consent dropdown already offers Yes / Not now / Never, so a button row beside it is the - // same choice rendered twice and costs a block of the card to say nothing new. + // The checkbox IS the choice, so a button row beside it renders the same decision twice and + // costs a block of the card to say nothing new. const i = app.indexOf("function renderMoonCloudConsent("); assert.ok(i > 0, "no consent renderer"); - const fn = app.slice(i, i + 1600); - assert.ok(fn.includes('Number(consent.value) !== 0'), - "the explanation shows only while the question is unanswered"); + const fn = app.slice(i, app.indexOf("\n}", i) + 2); + assert.match(fn, /if \(!consent \|\| consent\.value\) return;/, + "the explanation shows only while consent is off"); assert.ok(!fn.includes("mooncloud-consent-buttons"), - "no second set of consent buttons beside the dropdown"); + "no second set of consent buttons beside the checkbox"); +}); + +test("a card that cannot reach MoonCloud says so", () => { + // Found on the bench: a stale negative DNS entry made the host unresolvable to the browser + // while the firmware's own POSTs kept succeeding. Messages were arriving on the server and the + // board rendered empty, with nothing on screen to say why. The device looked broken and was not. + // + // A rejected fetch, a non-ok status and a genuinely empty answer are three different facts. Only + // the last one may render as absence, because the reader's next action differs: check the + // network, versus wait for someone to post. + // Both readers must reject a non-ok response rather than folding it into a null body: `r.ok ? + // r.json() : null` was the shape that made a 500 indistinguishable from an empty board. + const rejects = app.match(/r\.ok \? r\.json\(\) : Promise\.reject/g) || []; + assert.equal(rejects.length, 2, + "both /api/stats and /api/talk must treat a non-ok status as a failure"); + + // And neither may swallow the failure silently. + assert.ok(!app.includes("/* no section: the card is complete without it */"), + "an unreachable stats server must render a message, not drop the section"); + for (const draw of ["draw(null, false)"]) { + assert.ok(app.includes(draw), `${draw} must report the failure to the renderer`); + } + assert.ok(app.includes('"Cannot reach the MoonCloud server."'), + "the failure must be stated in words, not left as an empty card"); +}); + +test("a clicked slice filters every chart, and says so", () => { + // The plan's reasoning: a filter has to be something the reader chose, or it is a hidden default + // with extra steps. So every chart counts everything until a slice is clicked, and when one is, + // the card states what it narrowed to and offers the way back. + assert.match(app, /class="mooncloud-filter"|mooncloud-filter"/, + "an active filter must be stated on the card"); + assert.ok(app.includes("Clear"), "an active filter must offer a way back"); + + // `other` is a bucket of everything past the top slices, so it names no value to filter by. + const guards = app.match(/r\.name !== "other"/g) || []; + assert.equal(guards.length, 2, "neither the pie nor the legend may make `other` clickable"); + + // Build maps to `dev`, whose stored values are 0 and 1 rather than the labels shown. + assert.match(app, /name === "development" \? "1" : "0"/, + "the Build pie must translate its labels to the dev flag"); +}); + +test("Enter in a text field presses the card's send button", () => { + // Generic rather than a MoonTalk rule: any module pairing a text field with a `send` button gets + // it, and a module without one is untouched. That is what keeps the shared control path free of + // per-module branches. + const i = app.indexOf('input.addEventListener("keydown"'); + assert.ok(i > 0, "a text control must handle Enter"); + // Bounded by the handler's own closing brace, not a character count: a fixed window stops + // covering the assertions below the moment a comment line is added. + const handler = app.slice(i, app.indexOf("\n });", i) + 1); + + assert.ok(handler.includes('e.key !== "Enter"'), "it must act only on Enter"); + assert.ok(handler.includes('c.name === "send"'), + "it must look for a send button rather than naming a module"); + + // The write is flushed and awaited before the press. Typing is debounced 500 ms, so pressing + // send first would publish the text as it stood one keystroke ago. + assert.ok(handler.includes("clearTimeout(dragTimers[key])"), + "the pending debounced write must be cancelled"); + assert.match(handler, /await sendControl\(moduleName, ctrl\.name, input\.value\);\s*\n\s*await sendControl\(moduleName, "send", 1\)/, + "the text must be written and awaited BEFORE send is pressed"); + + // Talk is a MoonCloud child, so the lookup has to walk the tree. + assert.ok(handler.includes("allModules()"), + "a nested module's controls are not reachable from state.modules directly"); +}); + + +test("consent has no reset-to-default button", () => { + // Off is where consent starts, so the button's only possible action is to withdraw it: a + // decision, not a reset. It joins the existing surface-control opt-out rather than getting a + // mechanism of its own, and the checkbox already expresses both answers. + const i = app.indexOf("function appendResetButton("); + assert.ok(i > 0, "no reset-button renderer"); + const fn = app.slice(i, app.indexOf("\n}", i) + 2); + assert.match(fn, /ctrl\.name === "consent"/, + "consent must opt out of the reset button"); + assert.ok(fn.includes("ctrl.fader || ctrl.encoder || ctrl.switchRow"), + "it opts out through the existing guard, not a second one"); +}); + + +test("without consent nothing is fetched, and the card still shows its shape", () => { + // Reading carries no identifier, but it is still a request to our server from a device whose + // owner said no. The chart headings are a constant in this file, so the card can draw its own + // shape without asking anyone. + assert.ok(!app.includes("function zeroed(stats)"), + "zeroing a fetched answer means it was fetched: the request is what consent gates"); + + for (const fn of ["renderMoonCloudStats", "renderMoonTalk"]) { + const i = app.indexOf("const load = ", app.indexOf("function " + fn + "(")); + const load = app.slice(i, app.indexOf("\n };", i)); + assert.match(load, /if \(!consented\(mod\)\)/, + `${fn} must not fetch without consent`); + } + + // A zero-row pie draws no slices at all, which rendered as a bare sliver: the placeholder is a + // shape of its own. + assert.ok(app.includes("mooncloud-pie-placeholder"), + "an empty chart needs a placeholder, not an empty SVG"); +}); + + + +test("without consent the board says it was not read, not that it is empty", () => { + // Three states, three different facts: never asked, asked and empty, asked and unreachable. + // Collapsing the first two says the board is empty when nobody looked, which is the same + // mistake as rendering an unreachable server as an empty one. + const i = app.indexOf("function renderMoonTalk("); + const fn = app.slice(i, app.indexOf("\nfunction ", i + 1)); + + assert.match(fn, /const draw = \(messages, reached = true, asked = true\)/, + "draw must be able to tell `not asked` from `empty`"); + assert.match(fn, /if \(!consented\(mod\)\) \{[^}]*draw\(\[\], true, false\); return; \}/, + "the unconsented path must say it did not ask"); + assert.ok(fn.includes("Turn on consent to read the board."), + "and say so in words a reader can act on"); + assert.ok(fn.includes("No messages yet."), + "an actually-empty board still says so"); + + // The unconsented draw must not be CACHED: a cached `[]` survived the rebuild that consenting + // triggers, the cache hit returned it, and the board stayed blank until a manual refresh. + assert.match(fn, /if \(!consented\(mod\)\) \{ moonTalkCache = null;/, + "an empty draw from an unconsented card is not an answer to remember"); }); diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json index a47237a3..cbb6210a 100644 --- a/test/scenarios/core/scenario_MoonModule_control_change.json +++ b/test/scenarios/core/scenario_MoonModule_control_change.json @@ -117,12 +117,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 124, + "p50": 125, "p95": 246, "min": 117, "max": 302, "n": 32, - "samples": [127, 202, 119, 121, 118, 120, 199, 193, 117, 144, 302, 129, 118, 246, 120, 133, 122, 121, 122, 126, 124, 118, 118, 120, 123, 196, 125, 129, 130, 144, 160, 120] + "samples": [121, 118, 120, 199, 193, 117, 144, 302, 129, 118, 246, 120, 133, 122, 121, 122, 126, 124, 118, 118, 120, 123, 196, 125, 129, 130, 144, 160, 120, 161, 163, 194] }, "last_updated": "2026-09-11" }, @@ -304,7 +304,7 @@ "min": 117, "max": 260, "n": 32, - "samples": [128, 132, 119, 120, 118, 118, 131, 134, 117, 133, 135, 146, 121, 260, 123, 118, 120, 124, 123, 137, 125, 118, 124, 120, 123, 132, 126, 130, 131, 145, 123, 120] + "samples": [120, 118, 118, 131, 134, 117, 133, 135, 146, 121, 260, 123, 118, 120, 124, 123, 137, 125, 118, 124, 120, 123, 132, 126, 130, 131, 145, 123, 120, 125, 135, 131] }, "last_updated": "2026-09-11" }, @@ -481,12 +481,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 122, + "p50": 121, "p95": 136, "min": 116, "max": 152, "n": 32, - "samples": [128, 118, 121, 121, 118, 116, 116, 122, 117, 122, 129, 122, 121, 152, 122, 120, 120, 126, 123, 128, 123, 120, 126, 120, 124, 117, 136, 130, 130, 125, 118, 118] + "samples": [121, 118, 116, 116, 122, 117, 122, 129, 122, 121, 152, 122, 120, 120, 126, 123, 128, 123, 120, 126, 120, 124, 117, 136, 130, 130, 125, 118, 118, 120, 116, 118] }, "last_updated": "2026-09-11" }, @@ -676,7 +676,7 @@ "min": 117, "max": 201, "n": 32, - "samples": [128, 120, 119, 120, 118, 118, 120, 123, 117, 122, 141, 124, 121, 151, 122, 119, 121, 123, 121, 129, 120, 129, 126, 118, 120, 120, 201, 129, 129, 125, 121, 126] + "samples": [120, 118, 118, 120, 123, 117, 122, 141, 124, 121, 151, 122, 119, 121, 123, 121, 129, 120, 129, 126, 118, 120, 120, 201, 129, 129, 125, 121, 126, 122, 117, 119] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json index 5ce1b217..b2623c8a 100644 --- a/test/scenarios/light/scenario_Audio_mutation.json +++ b/test/scenarios/light/scenario_Audio_mutation.json @@ -109,7 +109,7 @@ "min": 16, "max": 26, "n": 32, - "samples": [20, 20, 17, 17, 16, 16, 17, 17, 17, 19, 26, 16, 17, 25, 19, 20, 20, 17, 20, 22, 17, 17, 17, 20, 20, 16, 21, 19, 19, 17, 16, 17] + "samples": [17, 16, 16, 17, 17, 17, 19, 26, 16, 17, 25, 19, 20, 20, 17, 20, 22, 17, 17, 17, 20, 20, 16, 21, 19, 19, 17, 16, 17, 16, 16, 16] }, "last_updated": "2026-09-11" }, @@ -206,7 +206,7 @@ "min": 17, "max": 216, "n": 32, - "samples": [17, 19, 28, 27, 22, 17, 19, 20, 37, 22, 28, 24, 137, 27, 17, 20, 19, 39, 20, 31, 22, 216, 36, 21, 18, 18, 43, 20, 41, 23, 18, 67] + "samples": [27, 22, 17, 19, 20, 37, 22, 28, 24, 137, 27, 17, 20, 19, 39, 20, 31, 22, 216, 36, 21, 18, 18, 43, 20, 41, 23, 18, 67, 37, 18, 17] }, "last_updated": "2026-09-11" }, @@ -320,7 +320,7 @@ "min": 18, "max": 108, "n": 32, - "samples": [18, 20, 19, 25, 18, 18, 19, 19, 21, 26, 28, 30, 40, 32, 26, 18, 20, 36, 21, 25, 21, 108, 24, 20, 19, 18, 37, 20, 56, 23, 21, 65] + "samples": [25, 18, 18, 19, 19, 21, 26, 28, 30, 40, 32, 26, 18, 20, 36, 21, 25, 21, 108, 24, 20, 19, 18, 37, 20, 56, 23, 21, 65, 29, 20, 18] }, "last_updated": "2026-09-11" }, @@ -417,7 +417,7 @@ "min": 19, "max": 62, "n": 32, - "samples": [21, 20, 21, 31, 20, 20, 21, 19, 22, 24, 28, 31, 44, 29, 29, 21, 24, 30, 24, 30, 22, 61, 25, 23, 22, 19, 27, 22, 62, 25, 21, 47] + "samples": [31, 20, 20, 21, 19, 22, 24, 28, 31, 44, 29, 29, 21, 24, 30, 24, 30, 22, 61, 25, 23, 22, 19, 27, 22, 62, 25, 21, 47, 19, 22, 19] }, "last_updated": "2026-09-11" }, @@ -509,10 +509,10 @@ "tick_us": { "p50": 22, "p95": 37, - "min": 19, + "min": 18, "max": 74, "n": 32, - "samples": [21, 20, 22, 27, 20, 21, 19, 19, 21, 30, 28, 22, 29, 36, 19, 21, 23, 25, 21, 21, 19, 74, 22, 23, 27, 19, 25, 24, 37, 25, 20, 28] + "samples": [27, 20, 21, 19, 19, 21, 30, 28, 22, 29, 36, 19, 21, 23, 25, 21, 21, 19, 74, 22, 23, 27, 19, 25, 24, 37, 25, 20, 28, 21, 20, 18] }, "last_updated": "2026-09-11" }, @@ -602,12 +602,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 19, + "p50": 20, "p95": 33, "min": 16, "max": 57, "n": 32, - "samples": [19, 19, 17, 17, 17, 16, 17, 17, 21, 17, 25, 17, 20, 24, 19, 17, 20, 22, 21, 19, 17, 57, 21, 20, 20, 17, 24, 20, 30, 22, 17, 33] + "samples": [17, 17, 16, 17, 17, 21, 17, 25, 17, 20, 24, 19, 17, 20, 22, 21, 19, 17, 57, 21, 20, 20, 17, 24, 20, 30, 22, 17, 33, 17, 17, 20] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_Aurora_fps.json b/test/scenarios/light/scenario_Aurora_fps.json index 99b245e3..fa70cdfb 100644 --- a/test/scenarios/light/scenario_Aurora_fps.json +++ b/test/scenarios/light/scenario_Aurora_fps.json @@ -84,12 +84,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 879, + "p50": 873, "p95": 1751, - "min": 827, + "min": 821, "max": 2054, "n": 32, - "samples": [835, 843, 890, 827, 841, 871, 845, 856, 1391, 863, 858, 885, 1527, 1317, 853, 859, 861, 1047, 962, 1024, 870, 1427, 907, 879, 833, 937, 1314, 1009, 2054, 1751, 859, 1266] + "samples": [827, 841, 871, 845, 856, 1391, 863, 858, 885, 1527, 1317, 853, 859, 861, 1047, 962, 1024, 870, 1427, 907, 879, 833, 937, 1314, 1009, 2054, 1751, 859, 1266, 840, 873, 821] }, "last_updated": "2026-09-11" } @@ -116,10 +116,10 @@ "tick_us": { "p50": 334, "p95": 607, - "min": 312, + "min": 308, "max": 1465, "n": 32, - "samples": [316, 318, 317, 317, 321, 334, 312, 322, 355, 335, 322, 495, 395, 449, 317, 318, 317, 431, 362, 359, 340, 522, 317, 318, 315, 361, 607, 374, 1465, 489, 316, 442] + "samples": [317, 321, 334, 312, 322, 355, 335, 322, 495, 395, 449, 317, 318, 317, 431, 362, 359, 340, 522, 317, 318, 315, 361, 607, 374, 1465, 489, 316, 442, 314, 312, 308] }, "last_updated": "2026-09-11" } @@ -146,10 +146,10 @@ "tick_us": { "p50": 193, "p95": 406, - "min": 185, + "min": 181, "max": 429, "n": 32, - "samples": [187, 192, 186, 185, 188, 196, 187, 187, 254, 187, 188, 222, 232, 245, 186, 188, 185, 201, 205, 212, 203, 406, 185, 185, 193, 212, 429, 221, 331, 275, 186, 285] + "samples": [185, 188, 196, 187, 187, 254, 187, 188, 222, 232, 245, 186, 188, 185, 201, 205, 212, 203, 406, 185, 185, 193, 212, 429, 221, 331, 275, 186, 285, 185, 181, 181] }, "last_updated": "2026-09-11" } @@ -176,10 +176,10 @@ "tick_us": { "p50": 560, "p95": 1037, - "min": 540, + "min": 532, "max": 1419, "n": 32, - "samples": [540, 544, 551, 551, 554, 581, 543, 554, 560, 550, 553, 567, 702, 720, 550, 545, 552, 583, 570, 599, 637, 1419, 542, 542, 578, 620, 1011, 631, 1037, 772, 541, 642] + "samples": [551, 554, 581, 543, 554, 560, 550, 553, 567, 702, 720, 550, 545, 552, 583, 570, 599, 637, 1419, 542, 542, 578, 620, 1011, 631, 1037, 772, 541, 642, 538, 532, 535] }, "last_updated": "2026-09-11" } @@ -204,12 +204,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 964, + "p50": 970, "p95": 1647, - "min": 933, + "min": 915, "max": 2085, "n": 32, - "samples": [933, 936, 938, 945, 964, 997, 937, 956, 1236, 950, 953, 952, 1158, 1152, 946, 1029, 970, 1026, 955, 1046, 996, 1647, 938, 936, 934, 1055, 1373, 1070, 2085, 1253, 934, 1063] + "samples": [945, 964, 997, 937, 956, 1236, 950, 953, 952, 1158, 1152, 946, 1029, 970, 1026, 955, 1046, 996, 1647, 938, 936, 934, 1055, 1373, 1070, 2085, 1253, 934, 1063, 1007, 918, 915] }, "last_updated": "2026-09-11" } @@ -236,10 +236,10 @@ "tick_us": { "p50": 1493, "p95": 2116, - "min": 1430, + "min": 1410, "max": 2727, "n": 32, - "samples": [1430, 1562, 1455, 1458, 1483, 2116, 1446, 1497, 1493, 1472, 1467, 1490, 1853, 1666, 1473, 1474, 1871, 1512, 1472, 1672, 1547, 1784, 1445, 1443, 1441, 1616, 1865, 1631, 2727, 1797, 1431, 1894] + "samples": [1458, 1483, 2116, 1446, 1497, 1493, 1472, 1467, 1490, 1853, 1666, 1473, 1474, 1871, 1512, 1472, 1672, 1547, 1784, 1445, 1443, 1441, 1616, 1865, 1631, 2727, 1797, 1431, 1894, 1531, 1417, 1410] }, "last_updated": "2026-09-11" } @@ -264,12 +264,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1564, + "p50": 1573, "p95": 2013, "min": 1488, "max": 4090, "n": 32, - "samples": [1491, 1494, 1498, 1506, 1574, 1920, 1494, 1564, 1543, 1622, 1526, 1556, 1761, 1695, 1534, 1514, 1828, 1601, 1518, 1642, 4090, 1584, 1505, 1488, 1490, 1685, 1792, 1669, 2013, 1785, 1525, 1655] + "samples": [1506, 1574, 1920, 1494, 1564, 1543, 1622, 1526, 1556, 1761, 1695, 1534, 1514, 1828, 1601, 1518, 1642, 4090, 1584, 1505, 1488, 1490, 1685, 1792, 1669, 2013, 1785, 1525, 1655, 1573, 1496, 1498] }, "last_updated": "2026-09-11" } @@ -296,10 +296,10 @@ "tick_us": { "p50": 1135, "p95": 1443, - "min": 1087, + "min": 1078, "max": 2118, "n": 32, - "samples": [1092, 1096, 1104, 1118, 1165, 1211, 1103, 1135, 1128, 1137, 1120, 1131, 1303, 1210, 1117, 1118, 1105, 1167, 1161, 1167, 2118, 1144, 1111, 1097, 1110, 1257, 1325, 1214, 1351, 1260, 1087, 1443] + "samples": [1118, 1165, 1211, 1103, 1135, 1128, 1137, 1120, 1131, 1303, 1210, 1117, 1118, 1105, 1167, 1161, 1167, 2118, 1144, 1111, 1097, 1110, 1257, 1325, 1214, 1351, 1260, 1087, 1443, 1096, 1078, 1088] }, "last_updated": "2026-09-11" } diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json index 5df4ab07..2dbb395a 100644 --- a/test/scenarios/light/scenario_Driver_mutation.json +++ b/test/scenarios/light/scenario_Driver_mutation.json @@ -81,7 +81,7 @@ "min": 16, "max": 32, "n": 32, - "samples": [21, 17, 20, 20, 17, 19, 20, 19, 18, 21, 16, 19, 19, 17, 19, 18, 20, 18, 17, 17, 32, 17, 16, 17, 17, 18, 18, 17, 18, 18, 20, 21] + "samples": [20, 17, 19, 20, 19, 18, 21, 16, 19, 19, 17, 19, 18, 20, 18, 17, 17, 32, 17, 16, 17, 17, 18, 18, 17, 18, 18, 20, 21, 19, 20, 20] }, "last_updated": "2026-09-11" }, @@ -178,7 +178,7 @@ "min": 16, "max": 29, "n": 32, - "samples": [22, 17, 20, 20, 17, 27, 20, 20, 20, 23, 16, 20, 20, 18, 20, 20, 20, 23, 17, 17, 29, 17, 17, 17, 20, 18, 18, 17, 18, 18, 20, 21] + "samples": [20, 17, 27, 20, 20, 20, 23, 16, 20, 20, 18, 20, 20, 20, 23, 17, 17, 29, 17, 17, 17, 20, 18, 18, 17, 18, 18, 20, 21, 20, 20, 20] }, "last_updated": "2026-09-11" }, @@ -275,7 +275,7 @@ "min": 16, "max": 37, "n": 32, - "samples": [18, 20, 20, 20, 20, 24, 20, 20, 21, 17, 16, 20, 19, 17, 20, 20, 20, 21, 18, 17, 37, 17, 20, 19, 20, 18, 18, 17, 18, 18, 20, 20] + "samples": [20, 20, 24, 20, 20, 21, 17, 16, 20, 19, 17, 20, 20, 20, 21, 18, 17, 37, 17, 20, 19, 20, 18, 18, 17, 18, 18, 20, 20, 20, 19, 20] }, "last_updated": "2026-09-11" }, @@ -366,11 +366,11 @@ "desktop-macos": { "tick_us": { "p50": 20, - "p95": 24, + "p95": 21, "min": 17, "max": 33, "n": 32, - "samples": [24, 20, 20, 20, 17, 19, 20, 20, 17, 19, 20, 20, 19, 21, 19, 20, 20, 20, 20, 17, 33, 17, 20, 20, 20, 18, 18, 17, 19, 18, 20, 20] + "samples": [20, 17, 19, 20, 20, 17, 19, 20, 20, 19, 21, 19, 20, 20, 20, 20, 17, 33, 17, 20, 20, 20, 18, 18, 17, 19, 18, 20, 20, 20, 20, 20] }, "last_updated": "2026-09-11" }, @@ -465,7 +465,7 @@ "min": 17, "max": 36, "n": 32, - "samples": [21, 20, 20, 20, 17, 23, 20, 20, 19, 20, 20, 20, 19, 17, 19, 19, 19, 21, 20, 17, 36, 17, 20, 20, 20, 18, 18, 17, 19, 18, 20, 19] + "samples": [20, 17, 23, 20, 20, 19, 20, 20, 20, 19, 17, 19, 19, 19, 21, 20, 17, 36, 17, 20, 20, 20, 18, 18, 17, 19, 18, 20, 19, 20, 20, 20] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_Effects_composition.json b/test/scenarios/light/scenario_Effects_composition.json index 476634e5..ce1d0c68 100644 --- a/test/scenarios/light/scenario_Effects_composition.json +++ b/test/scenarios/light/scenario_Effects_composition.json @@ -108,10 +108,10 @@ "tick_us": { "p50": 147, "p95": 169, - "min": 142, + "min": 143, "max": 309, "n": 32, - "samples": [146, 142, 146, 152, 145, 146, 148, 144, 145, 147, 148, 164, 169, 144, 147, 144, 147, 146, 143, 152, 309, 152, 143, 144, 147, 153, 155, 147, 161, 153, 143, 151] + "samples": [152, 145, 146, 148, 144, 145, 147, 148, 164, 169, 144, 147, 144, 147, 146, 143, 152, 309, 152, 143, 144, 147, 153, 155, 147, 161, 153, 143, 151, 145, 145, 143] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_Fields_polar_lut.json b/test/scenarios/light/scenario_Fields_polar_lut.json index 8494575b..6624e946 100644 --- a/test/scenarios/light/scenario_Fields_polar_lut.json +++ b/test/scenarios/light/scenario_Fields_polar_lut.json @@ -90,7 +90,7 @@ "min": 274, "max": 808, "n": 32, - "samples": [278, 281, 289, 325, 292, 302, 291, 286, 293, 285, 287, 294, 337, 332, 293, 290, 300, 301, 421, 298, 808, 301, 288, 284, 297, 314, 323, 305, 336, 335, 274, 297] + "samples": [325, 292, 302, 291, 286, 293, 285, 287, 294, 337, 332, 293, 290, 300, 301, 421, 298, 808, 301, 288, 284, 297, 314, 323, 305, 336, 335, 274, 297, 281, 279, 278] }, "last_updated": "2026-09-11" } @@ -115,12 +115,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 291, + "p50": 290, "p95": 340, "min": 274, "max": 373, "n": 32, - "samples": [281, 281, 313, 297, 290, 297, 283, 288, 291, 285, 290, 293, 340, 320, 284, 287, 285, 289, 295, 295, 322, 296, 281, 282, 289, 311, 323, 301, 333, 373, 274, 292] + "samples": [297, 290, 297, 283, 288, 291, 285, 290, 293, 340, 320, 284, 287, 285, 289, 295, 295, 322, 296, 281, 282, 289, 311, 323, 301, 333, 373, 274, 292, 279, 276, 276] }, "last_updated": "2026-09-11" } @@ -150,7 +150,7 @@ "min": 275, "max": 337, "n": 32, - "samples": [281, 282, 288, 289, 289, 303, 284, 286, 289, 284, 309, 284, 337, 317, 285, 283, 284, 289, 296, 287, 302, 287, 282, 280, 288, 311, 322, 301, 321, 334, 275, 337] + "samples": [289, 289, 303, 284, 286, 289, 284, 309, 284, 337, 317, 285, 283, 284, 289, 296, 287, 302, 287, 282, 280, 288, 311, 322, 301, 321, 334, 275, 337, 278, 277, 281] }, "last_updated": "2026-09-11" } @@ -180,7 +180,7 @@ "min": 274, "max": 347, "n": 32, - "samples": [277, 282, 285, 286, 286, 301, 281, 287, 290, 288, 347, 283, 339, 306, 284, 293, 285, 295, 284, 289, 299, 283, 282, 281, 285, 325, 322, 301, 323, 334, 274, 334] + "samples": [286, 286, 301, 281, 287, 290, 288, 347, 283, 339, 306, 284, 293, 285, 295, 284, 289, 299, 283, 282, 281, 285, 325, 322, 301, 323, 334, 274, 334, 281, 275, 278] }, "last_updated": "2026-09-11" } @@ -205,12 +205,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 144, + "p50": 143, "p95": 169, "min": 135, "max": 253, "n": 32, - "samples": [140, 138, 144, 141, 144, 147, 141, 144, 143, 143, 155, 141, 166, 153, 141, 140, 140, 146, 144, 147, 146, 141, 141, 138, 140, 169, 160, 149, 160, 164, 135, 253] + "samples": [141, 144, 147, 141, 144, 143, 143, 155, 141, 166, 153, 141, 140, 140, 146, 144, 147, 146, 141, 141, 138, 140, 169, 160, 149, 160, 164, 135, 253, 140, 139, 136] }, "last_updated": "2026-09-11" } @@ -240,7 +240,7 @@ "min": 407, "max": 622, "n": 32, - "samples": [413, 417, 422, 421, 428, 444, 420, 451, 442, 503, 433, 512, 502, 461, 424, 421, 425, 443, 431, 448, 448, 421, 421, 422, 418, 481, 479, 458, 479, 494, 407, 622] + "samples": [421, 428, 444, 420, 451, 442, 503, 433, 512, 502, 461, 424, 421, 425, 443, 431, 448, 448, 421, 421, 422, 418, 481, 479, 458, 479, 494, 407, 622, 421, 411, 414] }, "last_updated": "2026-09-11" } @@ -271,12 +271,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1269, + "p50": 1271, "p95": 1892, - "min": 1206, + "min": 1219, "max": 2429, "n": 32, - "samples": [1239, 1206, 1218, 1228, 1239, 1298, 1269, 1240, 1311, 1232, 1236, 1892, 1402, 1341, 1261, 1229, 1250, 1339, 1271, 1686, 1458, 1231, 1320, 1238, 1225, 1355, 1349, 1298, 1354, 1396, 1237, 2429] + "samples": [1228, 1239, 1298, 1269, 1240, 1311, 1232, 1236, 1892, 1402, 1341, 1261, 1229, 1250, 1339, 1271, 1686, 1458, 1231, 1320, 1238, 1225, 1355, 1349, 1298, 1354, 1396, 1237, 2429, 1286, 1229, 1219] }, "last_updated": "2026-09-11" } @@ -303,10 +303,10 @@ "tick_us": { "p50": 488, "p95": 604, - "min": 468, + "min": 461, "max": 1469, "n": 32, - "samples": [479, 476, 472, 473, 480, 506, 482, 604, 488, 475, 481, 491, 538, 530, 477, 475, 477, 1469, 496, 528, 500, 473, 468, 509, 476, 518, 520, 502, 568, 534, 471, 540] + "samples": [473, 480, 506, 482, 604, 488, 475, 481, 491, 538, 530, 477, 475, 477, 1469, 496, 528, 500, 473, 468, 509, 476, 518, 520, 502, 568, 534, 471, 540, 479, 464, 461] }, "last_updated": "2026-09-11" } diff --git a/test/scenarios/light/scenario_Fluid_solver.json b/test/scenarios/light/scenario_Fluid_solver.json index bbf3982b..3a05da28 100644 --- a/test/scenarios/light/scenario_Fluid_solver.json +++ b/test/scenarios/light/scenario_Fluid_solver.json @@ -83,7 +83,7 @@ "min": 28, "max": 73, "n": 32, - "samples": [30, 30, 31, 30, 31, 29, 31, 73, 30, 29, 29, 30, 33, 32, 29, 29, 29, 56, 30, 33, 31, 30, 30, 29, 30, 32, 32, 30, 33, 33, 28, 33] + "samples": [30, 31, 29, 31, 73, 30, 29, 29, 30, 33, 32, 29, 29, 29, 56, 30, 33, 31, 30, 30, 29, 30, 32, 32, 30, 33, 33, 28, 33, 29, 28, 28] }, "last_updated": "2026-09-11" } @@ -100,12 +100,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 20, + "p50": 19, "p95": 37, - "min": 19, + "min": 18, "max": 44, "n": 32, - "samples": [20, 20, 19, 19, 19, 20, 19, 37, 19, 19, 19, 20, 21, 21, 19, 19, 19, 44, 19, 21, 21, 19, 20, 19, 19, 21, 21, 20, 22, 22, 19, 24] + "samples": [19, 19, 20, 19, 37, 19, 19, 19, 20, 21, 21, 19, 19, 19, 44, 19, 21, 21, 19, 20, 19, 19, 21, 21, 20, 22, 22, 19, 24, 19, 18, 19] }, "last_updated": "2026-09-11" } @@ -127,7 +127,7 @@ "min": 66, "max": 115, "n": 32, - "samples": [69, 68, 70, 68, 68, 66, 68, 101, 68, 67, 67, 70, 76, 75, 67, 67, 67, 115, 70, 76, 70, 69, 70, 67, 69, 73, 73, 71, 76, 76, 66, 73] + "samples": [68, 68, 66, 68, 101, 68, 67, 67, 70, 76, 75, 67, 67, 67, 115, 70, 76, 70, 69, 70, 67, 69, 73, 73, 71, 76, 76, 66, 73, 66, 67, 66] }, "last_updated": "2026-09-11" } @@ -144,12 +144,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 30, + "p50": 29, "p95": 37, - "min": 29, + "min": 28, "max": 71, "n": 32, - "samples": [31, 30, 30, 29, 29, 29, 29, 37, 29, 29, 29, 30, 33, 33, 29, 29, 29, 71, 29, 33, 30, 30, 31, 29, 30, 32, 32, 31, 33, 33, 29, 31] + "samples": [29, 29, 29, 29, 37, 29, 29, 29, 30, 33, 33, 29, 29, 29, 71, 29, 33, 30, 30, 31, 29, 30, 32, 32, 31, 33, 33, 29, 31, 28, 29, 28] }, "last_updated": "2026-09-11" } @@ -168,10 +168,10 @@ "tick_us": { "p50": 66, "p95": 74, - "min": 62, + "min": 61, "max": 95, "n": 32, - "samples": [65, 65, 66, 66, 64, 64, 63, 74, 66, 63, 62, 66, 71, 70, 63, 62, 62, 95, 67, 72, 66, 64, 65, 63, 66, 69, 70, 67, 72, 71, 62, 69] + "samples": [66, 64, 64, 63, 74, 66, 63, 62, 66, 71, 70, 63, 62, 62, 95, 67, 72, 66, 64, 65, 63, 66, 69, 70, 67, 72, 71, 62, 69, 63, 61, 61] }, "last_updated": "2026-09-11" } @@ -187,12 +187,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 134, + "p50": 133, "p95": 176, - "min": 127, + "min": 126, "max": 208, "n": 32, - "samples": [134, 138, 136, 134, 131, 130, 132, 133, 134, 129, 128, 131, 145, 145, 131, 127, 129, 176, 208, 154, 139, 130, 136, 129, 136, 144, 143, 138, 149, 146, 127, 164] + "samples": [134, 131, 130, 132, 133, 134, 129, 128, 131, 145, 145, 131, 127, 129, 176, 208, 154, 139, 130, 136, 129, 136, 144, 143, 138, 149, 146, 127, 164, 128, 126, 127] }, "last_updated": "2026-09-11" } @@ -210,12 +210,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 136, + "p50": 132, "p95": 161, - "min": 128, + "min": 127, "max": 264, "n": 32, - "samples": [140, 137, 136, 135, 131, 132, 131, 131, 134, 130, 138, 129, 145, 145, 131, 129, 132, 264, 150, 161, 145, 129, 137, 129, 131, 143, 143, 138, 147, 146, 128, 157] + "samples": [135, 131, 132, 131, 131, 134, 130, 138, 129, 145, 145, 131, 129, 132, 264, 150, 161, 145, 129, 137, 129, 131, 143, 143, 138, 147, 146, 128, 157, 128, 129, 127] }, "last_updated": "2026-09-11" } @@ -232,12 +232,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 135, + "p50": 131, "p95": 223, - "min": 127, + "min": 125, "max": 296, "n": 32, - "samples": [141, 138, 135, 147, 134, 130, 131, 129, 135, 129, 129, 129, 146, 145, 129, 128, 127, 296, 138, 158, 135, 130, 130, 128, 135, 143, 143, 139, 149, 149, 127, 223] + "samples": [147, 134, 130, 131, 129, 135, 129, 129, 129, 146, 145, 129, 128, 127, 296, 138, 158, 135, 130, 130, 128, 135, 143, 143, 139, 149, 149, 127, 223, 130, 125, 128] }, "last_updated": "2026-09-11" } @@ -254,12 +254,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 37, + "p50": 36, "p95": 42, "min": 34, "max": 106, "n": 32, - "samples": [39, 38, 36, 40, 36, 35, 36, 35, 37, 35, 35, 35, 40, 40, 35, 35, 35, 106, 37, 42, 37, 35, 34, 34, 37, 38, 38, 37, 40, 40, 34, 39] + "samples": [40, 36, 35, 36, 35, 37, 35, 35, 35, 40, 40, 35, 35, 35, 106, 37, 42, 37, 35, 34, 34, 37, 38, 38, 37, 40, 40, 34, 39, 35, 35, 35] }, "last_updated": "2026-09-11" } @@ -280,7 +280,7 @@ "min": 10, "max": 80, "n": 32, - "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 11, 11, 10, 80, 11, 13, 11, 11, 10, 10, 11, 12, 12, 11, 12, 12, 10, 11] + "samples": [11, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 11, 11, 10, 80, 11, 13, 11, 11, 10, 10, 11, 12, 12, 11, 12, 12, 10, 11, 11, 10, 10] }, "last_updated": "2026-09-11" } @@ -296,12 +296,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 226, + "p50": 222, "p95": 265, "min": 214, "max": 326, "n": 32, - "samples": [249, 232, 229, 220, 220, 220, 222, 219, 227, 217, 226, 219, 249, 247, 217, 218, 215, 326, 222, 259, 228, 217, 215, 224, 265, 237, 236, 228, 246, 246, 214, 227] + "samples": [220, 220, 220, 222, 219, 227, 217, 226, 219, 249, 247, 217, 218, 215, 326, 222, 259, 228, 217, 215, 224, 265, 237, 236, 228, 246, 246, 214, 227, 216, 214, 218] }, "last_updated": "2026-09-11" } @@ -323,7 +323,7 @@ "min": 10, "max": 13, "n": 32, - "samples": [12, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 12, 12, 11, 11, 11, 12, 12, 13, 11, 10, 11, 11, 12, 12, 12, 12, 12, 12, 11, 11] + "samples": [11, 11, 11, 11, 11, 11, 11, 11, 10, 12, 12, 11, 11, 11, 12, 12, 13, 11, 10, 11, 11, 12, 12, 12, 12, 12, 12, 11, 11, 11, 11, 11] }, "last_updated": "2026-09-11" } @@ -345,7 +345,7 @@ "min": 8, "max": 10, "n": 32, - "samples": [9, 9, 9, 8, 8, 8, 8, 8, 9, 8, 9, 8, 9, 10, 8, 9, 9, 10, 9, 10, 9, 9, 8, 8, 9, 9, 9, 9, 10, 10, 8, 9] + "samples": [8, 8, 8, 8, 8, 9, 8, 9, 8, 9, 10, 8, 9, 9, 10, 9, 10, 9, 9, 8, 8, 9, 9, 9, 9, 10, 10, 8, 9, 9, 9, 8] }, "last_updated": "2026-09-11" } @@ -366,7 +366,7 @@ "min": 6, "max": 8, "n": 32, - "samples": [7, 7, 7, 6, 7, 6, 7, 8, 8, 7, 7, 6, 8, 7, 6, 7, 7, 8, 7, 8, 7, 6, 6, 7, 7, 7, 7, 7, 8, 7, 7, 7] + "samples": [6, 7, 6, 7, 8, 8, 7, 7, 6, 8, 7, 6, 7, 7, 8, 7, 8, 7, 6, 6, 7, 7, 7, 7, 7, 8, 7, 7, 7, 7, 7, 7] }, "last_updated": "2026-09-11" } diff --git a/test/scenarios/light/scenario_GridBlacks_blackpixel.json b/test/scenarios/light/scenario_GridBlacks_blackpixel.json index 01a2344d..76817ca3 100644 --- a/test/scenarios/light/scenario_GridBlacks_blackpixel.json +++ b/test/scenarios/light/scenario_GridBlacks_blackpixel.json @@ -91,11 +91,11 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 2, + "max": 1, "n": 32, - "samples": [2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -194,11 +194,11 @@ "desktop-macos": { "tick_us": { "p50": 2, - "p95": 3, + "p95": 2, "min": 2, "max": 3, "n": 32, - "samples": [3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2] + "samples": [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json index da9419bb..7d64b5cc 100644 --- a/test/scenarios/light/scenario_GridLayout_resize.json +++ b/test/scenarios/light/scenario_GridLayout_resize.json @@ -122,7 +122,7 @@ "min": 117, "max": 189, "n": 32, - "samples": [126, 120, 118, 120, 128, 117, 119, 122, 117, 121, 120, 124, 125, 126, 119, 189, 124, 141, 131, 143, 119, 121, 118, 118, 124, 121, 122, 120, 126, 129, 117, 126] + "samples": [120, 128, 117, 119, 122, 117, 121, 120, 124, 125, 126, 119, 189, 124, 141, 131, 143, 119, 121, 118, 118, 124, 121, 122, 120, 126, 129, 117, 126, 127, 117, 120] }, "last_updated": "2026-09-11" }, @@ -304,7 +304,7 @@ "min": 59, "max": 151, "n": 32, - "samples": [68, 65, 66, 64, 61, 59, 64, 65, 59, 65, 65, 66, 62, 63, 64, 60, 64, 67, 151, 69, 59, 66, 66, 113, 65, 61, 60, 61, 63, 67, 63, 64] + "samples": [64, 61, 59, 64, 65, 59, 65, 65, 66, 62, 63, 64, 60, 64, 67, 151, 69, 59, 66, 66, 113, 65, 61, 60, 61, 63, 67, 63, 64, 70, 64, 68] }, "last_updated": "2026-09-11" }, @@ -486,7 +486,7 @@ "min": 116, "max": 190, "n": 32, - "samples": [127, 120, 123, 121, 123, 116, 120, 122, 119, 121, 120, 122, 126, 127, 120, 120, 142, 190, 142, 142, 118, 123, 120, 121, 119, 121, 122, 121, 126, 135, 119, 126] + "samples": [121, 123, 116, 120, 122, 119, 121, 120, 122, 126, 127, 120, 120, 142, 190, 142, 142, 118, 123, 120, 121, 119, 121, 122, 121, 126, 135, 119, 126, 126, 118, 120] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_Layer_base_pipeline.json b/test/scenarios/light/scenario_Layer_base_pipeline.json index 52e98b80..0e2917bc 100644 --- a/test/scenarios/light/scenario_Layer_base_pipeline.json +++ b/test/scenarios/light/scenario_Layer_base_pipeline.json @@ -88,7 +88,7 @@ "min": 64, "max": 241, "n": 32, - "samples": [65, 72, 70, 69, 66, 64, 66, 66, 64, 71, 70, 70, 68, 69, 70, 67, 139, 241, 77, 77, 65, 68, 67, 70, 71, 67, 66, 67, 69, 72, 69, 72] + "samples": [69, 66, 64, 66, 66, 64, 71, 70, 70, 68, 69, 70, 67, 139, 241, 77, 77, 65, 68, 67, 70, 71, 67, 66, 67, 69, 72, 69, 72, 66, 66, 70] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_Layer_memory_1to1.json b/test/scenarios/light/scenario_Layer_memory_1to1.json index 3d72cc78..ab69c82c 100644 --- a/test/scenarios/light/scenario_Layer_memory_1to1.json +++ b/test/scenarios/light/scenario_Layer_memory_1to1.json @@ -82,10 +82,10 @@ "tick_us": { "p50": 5, "p95": 7, - "min": 5, + "min": 4, "max": 24, "n": 32, - "samples": [5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 24, 5, 7, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7] + "samples": [5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 24, 5, 7, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 5, 4, 5] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json index 209bf49c..2838a8a3 100644 --- a/test/scenarios/light/scenario_Layouts_mutation.json +++ b/test/scenarios/light/scenario_Layouts_mutation.json @@ -83,7 +83,7 @@ "min": 16, "max": 83, "n": 32, - "samples": [16, 17, 17, 16, 17, 17, 16, 16, 17, 16, 16, 17, 18, 18, 16, 17, 16, 83, 17, 20, 17, 17, 17, 17, 17, 17, 18, 18, 19, 19, 16, 17] + "samples": [16, 17, 17, 16, 16, 17, 16, 16, 17, 18, 18, 16, 17, 16, 83, 17, 20, 17, 17, 17, 17, 17, 17, 18, 18, 19, 19, 16, 17, 16, 16, 17] }, "last_updated": "2026-09-11" }, @@ -211,7 +211,7 @@ "min": 43, "max": 174, "n": 32, - "samples": [47, 48, 44, 46, 49, 45, 43, 48, 44, 48, 49, 48, 46, 49, 46, 50, 48, 174, 50, 72, 45, 47, 47, 47, 45, 45, 46, 46, 47, 53, 46, 53] + "samples": [46, 49, 45, 43, 48, 44, 48, 49, 48, 46, 49, 46, 50, 48, 174, 50, 72, 45, 47, 47, 47, 45, 45, 46, 46, 47, 53, 46, 53, 46, 50, 50] }, "last_updated": "2026-09-11" }, @@ -330,11 +330,11 @@ "desktop-macos": { "tick_us": { "p50": 94, - "p95": 156, + "p95": 121, "min": 88, "max": 211, "n": 32, - "samples": [93, 156, 93, 93, 97, 88, 92, 94, 88, 121, 93, 93, 96, 95, 93, 95, 96, 211, 97, 114, 90, 95, 94, 93, 95, 92, 92, 93, 95, 101, 93, 94] + "samples": [93, 97, 88, 92, 94, 88, 121, 93, 93, 96, 95, 93, 95, 96, 211, 97, 114, 90, 95, 94, 93, 95, 92, 92, 93, 95, 101, 93, 94, 94, 93, 94] }, "last_updated": "2026-09-11" }, @@ -456,7 +456,7 @@ "min": 17, "max": 29, "n": 32, - "samples": [20, 17, 20, 20, 21, 17, 20, 20, 17, 20, 20, 18, 18, 18, 20, 20, 20, 29, 22, 26, 17, 20, 20, 20, 20, 17, 17, 17, 18, 18, 19, 21] + "samples": [20, 21, 17, 20, 20, 17, 20, 20, 18, 18, 18, 20, 20, 20, 29, 22, 26, 17, 20, 20, 20, 20, 17, 17, 17, 18, 18, 19, 21, 20, 19, 20] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json index 0cbb75b3..ec10aae8 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json @@ -93,7 +93,7 @@ "min": 5, "max": 15, "n": 32, - "samples": [5, 5, 5, 5, 5, 6, 5, 6, 6, 5, 5, 6, 5, 6, 5, 14, 15, 7, 5, 12, 15, 5, 5, 5, 5, 6, 6, 5, 5, 5, 5, 5] + "samples": [5, 5, 6, 5, 6, 6, 5, 5, 6, 5, 6, 5, 14, 15, 7, 5, 12, 15, 5, 5, 5, 5, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5] }, "last_updated": "2026-09-11" }, @@ -211,7 +211,7 @@ "min": 5, "max": 10, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 10, 5, 5, 5, 6, 5, 5, 5, 9, 7, 6, 6, 10, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 5, 5, 5, 10, 5, 5, 5, 6, 5, 5, 5, 9, 7, 6, 6, 10, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, "last_updated": "2026-09-11" }, @@ -318,10 +318,10 @@ "tick_us": { "p50": 5, "p95": 12, - "min": 5, + "min": 4, "max": 15, "n": 32, - "samples": [5, 5, 5, 6, 5, 5, 6, 15, 5, 5, 5, 6, 5, 5, 5, 12, 5, 6, 6, 10, 5, 6, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5] + "samples": [6, 5, 5, 6, 15, 5, 5, 5, 6, 5, 5, 5, 12, 5, 6, 6, 10, 5, 6, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 4, 5] }, "last_updated": "2026-09-11" }, @@ -431,7 +431,7 @@ "min": 4, "max": 10, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 6, 4, 5, 5, 5, 5, 5, 5, 6, 5, 7, 6, 10, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5] + "samples": [5, 5, 5, 5, 6, 4, 5, 5, 5, 5, 5, 5, 6, 5, 7, 6, 10, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 4, 5] }, "last_updated": "2026-09-11" }, @@ -531,10 +531,10 @@ "tick_us": { "p50": 5, "p95": 9, - "min": 5, + "min": 4, "max": 10, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 7, 5, 5, 5, 5, 5, 5, 5, 10, 5, 7, 6, 9, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5] + "samples": [5, 5, 5, 5, 7, 5, 5, 5, 5, 5, 5, 5, 10, 5, 7, 6, 9, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 4, 5] }, "last_updated": "2026-09-11" }, @@ -637,7 +637,7 @@ "min": 5, "max": 14, "n": 32, - "samples": [5, 5, 5, 5, 7, 5, 5, 8, 5, 5, 5, 6, 5, 5, 5, 14, 5, 7, 6, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 7, 5, 5, 8, 5, 5, 5, 6, 5, 5, 5, 14, 5, 7, 6, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, "last_updated": "2026-09-11" }, @@ -739,8 +739,8 @@ "p95": 8, "min": 5, "max": 10, - "n": 28, - "samples": [5, 5, 5, 8, 5, 5, 7, 5, 5, 5, 5, 10, 6, 7, 6, 7, 6, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5] + "n": 31, + "samples": [5, 5, 5, 8, 5, 5, 7, 5, 5, 5, 5, 10, 6, 7, 6, 7, 6, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5] }, "last_updated": "2026-09-11" } @@ -761,8 +761,8 @@ "p95": 9, "min": 5, "max": 12, - "n": 28, - "samples": [5, 5, 5, 6, 5, 5, 6, 6, 5, 5, 5, 6, 12, 6, 6, 9, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 6] + "n": 31, + "samples": [5, 5, 5, 6, 5, 5, 6, 6, 5, 5, 5, 6, 12, 6, 6, 9, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 6, 5, 5, 5] }, "last_updated": "2026-09-11" } @@ -783,8 +783,8 @@ "p95": 8, "min": 5, "max": 11, - "n": 28, - "samples": [5, 5, 5, 6, 5, 6, 6, 6, 5, 6, 5, 7, 6, 6, 6, 8, 11, 5, 6, 5, 5, 6, 5, 5, 6, 6, 5, 6] + "n": 31, + "samples": [5, 5, 5, 6, 5, 6, 6, 6, 5, 6, 5, 7, 6, 6, 6, 8, 11, 5, 6, 5, 5, 6, 5, 5, 6, 6, 5, 6, 5, 5, 5] }, "last_updated": "2026-09-11" } @@ -804,7 +804,7 @@ "min": 5, "max": 10, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 9, 6, 6, 6, 6, 10, 5, 5, 5, 5, 6, 5, 6, 5, 6, 5, 5] + "samples": [5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 9, 6, 6, 6, 6, 10, 5, 5, 5, 5, 6, 5, 6, 5, 6, 5, 5, 5, 5, 5] }, "last_updated": "2026-09-11" }, @@ -907,7 +907,7 @@ "min": 5, "max": 9, "n": 32, - "samples": [5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 9, 6, 6, 7, 8, 5, 5, 5, 6, 5, 6, 6, 5, 6, 5, 6] + "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 9, 6, 6, 7, 8, 5, 5, 5, 6, 5, 6, 6, 5, 6, 5, 6, 5, 5, 5] }, "last_updated": "2026-09-11" }, @@ -1010,7 +1010,7 @@ "min": 5, "max": 8, "n": 32, - "samples": [5, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 7, 6, 6, 8, 6, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5] + "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 7, 6, 6, 8, 6, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 6, 5] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json index f936a9c5..0692d0d4 100644 --- a/test/scenarios/light/scenario_MoonLive_pipeline.json +++ b/test/scenarios/light/scenario_MoonLive_pipeline.json @@ -379,7 +379,7 @@ "min": 4, "max": 15, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 6, 7, 6, 15, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 6, 7, 6, 15, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, "last_updated": "2026-09-11" }, @@ -536,7 +536,7 @@ "min": 5, "max": 7, "n": 32, - "samples": [5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 6, 6, 7, 7, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 5, 6, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 6, 6, 7, 7, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, "last_updated": "2026-09-11" }, @@ -687,7 +687,7 @@ "min": 5, "max": 11, "n": 32, - "samples": [6, 5, 6, 5, 5, 6, 5, 5, 5, 6, 5, 6, 5, 5, 5, 6, 8, 11, 6, 7, 7, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5] + "samples": [5, 5, 6, 5, 5, 5, 6, 5, 6, 5, 5, 5, 6, 8, 11, 6, 7, 7, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 5, 5] }, "last_updated": "2026-09-11" }, @@ -989,7 +989,7 @@ "min": 5, "max": 11, "n": 32, - "samples": [5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 6, 7, 11, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 9] + "samples": [5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 6, 7, 11, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 9, 5, 5, 5] }, "last_updated": "2026-09-11" }, @@ -1131,7 +1131,7 @@ "min": 4, "max": 7, "n": 32, - "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 6, 5, 7, 7, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5] + "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 6, 5, 7, 7, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json index e4dde239..ba166d60 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json +++ b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json @@ -94,7 +94,7 @@ "min": 2, "max": 4, "n": 32, - "samples": [3, 3, 2, 3, 3, 3, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 4, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4] + "samples": [3, 3, 3, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 4, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 3, 3, 3] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json index a773170a..f6351c5c 100644 --- a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json +++ b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json @@ -94,7 +94,7 @@ "min": 117, "max": 172, "n": 32, - "samples": [126, 118, 119, 117, 121, 117, 119, 119, 118, 122, 119, 121, 127, 129, 119, 121, 119, 172, 126, 146, 144, 122, 118, 118, 120, 126, 121, 121, 126, 136, 118, 123] + "samples": [117, 121, 117, 119, 119, 118, 122, 119, 121, 127, 129, 119, 121, 119, 172, 126, 146, 144, 122, 118, 118, 120, 126, 121, 121, 126, 136, 118, 123, 117, 118, 117] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_Trails_ladder.json b/test/scenarios/light/scenario_Trails_ladder.json index 01526b4d..3ac996d8 100644 --- a/test/scenarios/light/scenario_Trails_ladder.json +++ b/test/scenarios/light/scenario_Trails_ladder.json @@ -88,10 +88,10 @@ "tick_us": { "p50": 11, "p95": 14, - "min": 10, + "min": 11, "max": 15, "n": 32, - "samples": [10, 10, 10, 11, 11, 11, 11, 11, 13, 12, 11, 11, 13, 13, 11, 12, 11, 15, 11, 14, 12, 11, 11, 11, 11, 13, 12, 12, 13, 13, 11, 11] + "samples": [11, 11, 11, 11, 11, 13, 12, 11, 11, 13, 13, 11, 12, 11, 15, 11, 14, 12, 11, 11, 11, 11, 13, 12, 12, 13, 13, 11, 11, 11, 11, 11] }, "last_updated": "2026-09-11" } @@ -125,10 +125,10 @@ "tick_us": { "p50": 46, "p95": 56, - "min": 40, + "min": 44, "max": 60, "n": 32, - "samples": [40, 42, 41, 45, 45, 45, 45, 45, 47, 46, 46, 49, 50, 53, 45, 44, 46, 49, 60, 56, 46, 47, 46, 45, 45, 51, 49, 49, 51, 53, 45, 45] + "samples": [45, 45, 45, 45, 45, 47, 46, 46, 49, 50, 53, 45, 44, 46, 49, 60, 56, 46, 47, 46, 45, 45, 51, 49, 49, 51, 53, 45, 45, 45, 44, 44] }, "last_updated": "2026-09-11" } @@ -162,10 +162,10 @@ "tick_us": { "p50": 183, "p95": 222, - "min": 160, + "min": 175, "max": 233, "n": 32, - "samples": [160, 166, 165, 182, 178, 180, 184, 180, 183, 186, 179, 187, 200, 202, 178, 178, 178, 233, 191, 222, 188, 222, 178, 180, 184, 207, 195, 194, 202, 211, 177, 182] + "samples": [182, 178, 180, 184, 180, 183, 186, 179, 187, 200, 202, 178, 178, 178, 233, 191, 222, 188, 222, 178, 180, 184, 207, 195, 194, 202, 211, 177, 182, 180, 177, 175] }, "last_updated": "2026-09-11" } @@ -206,10 +206,10 @@ "tick_us": { "p50": 355, "p95": 437, - "min": 313, + "min": 344, "max": 438, "n": 32, - "samples": [313, 331, 324, 358, 348, 353, 357, 352, 357, 361, 351, 349, 396, 395, 347, 349, 348, 394, 372, 437, 372, 429, 344, 351, 344, 394, 381, 438, 395, 413, 345, 355] + "samples": [358, 348, 353, 357, 352, 357, 361, 351, 349, 396, 395, 347, 349, 348, 394, 372, 437, 372, 429, 344, 351, 344, 394, 381, 438, 395, 413, 345, 355, 348, 345, 346] }, "last_updated": "2026-09-11" } @@ -236,10 +236,10 @@ "tick_us": { "p50": 364, "p95": 442, - "min": 312, + "min": 351, "max": 608, "n": 32, - "samples": [312, 334, 332, 442, 353, 358, 354, 351, 608, 364, 362, 373, 423, 414, 354, 360, 355, 373, 384, 433, 368, 366, 359, 358, 352, 398, 395, 400, 394, 411, 351, 367] + "samples": [442, 353, 358, 354, 351, 608, 364, 362, 373, 423, 414, 354, 360, 355, 373, 384, 433, 368, 366, 359, 358, 352, 398, 395, 400, 394, 411, 351, 367, 359, 358, 352] }, "last_updated": "2026-09-11" } @@ -266,10 +266,10 @@ "tick_us": { "p50": 354, "p95": 451, - "min": 312, + "min": 344, "max": 490, "n": 32, - "samples": [312, 336, 350, 359, 351, 354, 354, 365, 370, 365, 351, 348, 451, 413, 350, 350, 349, 373, 412, 434, 368, 350, 344, 345, 345, 407, 490, 395, 395, 414, 345, 359] + "samples": [359, 351, 354, 354, 365, 370, 365, 351, 348, 451, 413, 350, 350, 349, 373, 412, 434, 368, 350, 344, 345, 345, 407, 490, 395, 395, 414, 345, 359, 347, 352, 347] }, "last_updated": "2026-09-11" } @@ -310,10 +310,10 @@ "tick_us": { "p50": 183, "p95": 221, - "min": 166, + "min": 175, "max": 250, "n": 32, - "samples": [169, 169, 166, 183, 178, 180, 182, 189, 186, 185, 180, 183, 250, 210, 177, 177, 181, 203, 210, 221, 187, 179, 176, 175, 178, 203, 211, 202, 201, 210, 176, 187] + "samples": [183, 178, 180, 182, 189, 186, 185, 180, 183, 250, 210, 177, 177, 181, 203, 210, 221, 187, 179, 176, 175, 178, 203, 211, 202, 201, 210, 176, 187, 177, 178, 177] }, "last_updated": "2026-09-11" } diff --git a/test/scenarios/light/scenario_modifier_chain.json b/test/scenarios/light/scenario_modifier_chain.json index 203ecb4d..d1968990 100644 --- a/test/scenarios/light/scenario_modifier_chain.json +++ b/test/scenarios/light/scenario_modifier_chain.json @@ -102,12 +102,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 9, + "p50": 10, "p95": 13, "min": 8, "max": 24, "n": 32, - "samples": [8, 8, 8, 10, 9, 8, 9, 8, 12, 10, 10, 24, 10, 8, 10, 10, 13, 11, 9, 10, 8, 10, 10, 8, 10, 9, 9, 9, 9, 10, 10, 8] + "samples": [10, 9, 8, 9, 8, 12, 10, 10, 24, 10, 8, 10, 10, 13, 11, 9, 10, 8, 10, 10, 8, 10, 9, 9, 9, 9, 10, 10, 8, 10, 10, 8] }, "last_updated": "2026-09-11" }, @@ -167,7 +167,7 @@ "min": 6, "max": 56, "n": 32, - "samples": [6, 7, 7, 9, 7, 6, 9, 7, 7, 9, 9, 56, 8, 9, 9, 9, 10, 10, 8, 8, 7, 9, 9, 7, 9, 8, 8, 8, 8, 8, 8, 7] + "samples": [9, 7, 6, 9, 7, 7, 9, 9, 56, 8, 9, 9, 9, 10, 10, 8, 8, 7, 9, 9, 7, 9, 8, 8, 8, 8, 8, 8, 7, 9, 8, 9] }, "last_updated": "2026-09-11" }, @@ -225,7 +225,7 @@ "min": 21, "max": 28, "n": 32, - "samples": [24, 22, 22, 28, 21, 21, 24, 21, 27, 24, 25, 26, 24, 24, 24, 25, 28, 25, 23, 24, 27, 25, 24, 22, 25, 22, 23, 22, 22, 23, 24, 24] + "samples": [28, 21, 21, 24, 21, 27, 24, 25, 26, 24, 24, 24, 25, 28, 25, 23, 24, 27, 25, 24, 22, 25, 22, 23, 22, 22, 23, 24, 24, 25, 22, 24] }, "last_updated": "2026-09-11" }, @@ -253,12 +253,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 43, + "p50": 44, "p95": 48, "min": 37, "max": 50, "n": 32, - "samples": [43, 43, 44, 41, 42, 40, 44, 37, 39, 44, 43, 48, 43, 45, 44, 44, 50, 44, 40, 45, 38, 45, 44, 43, 44, 39, 42, 42, 40, 42, 44, 46] + "samples": [41, 42, 40, 44, 37, 39, 44, 43, 48, 43, 45, 44, 44, 50, 44, 40, 45, 38, 45, 44, 43, 44, 39, 42, 42, 40, 42, 44, 46, 44, 45, 44] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json index 0d75b7a7..38e88fb0 100644 --- a/test/scenarios/light/scenario_modifier_swap.json +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -156,7 +156,7 @@ "min": 8, "max": 12, "n": 32, - "samples": [8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 12, 9, 9, 10, 8, 8, 9, 10, 8, 8, 8, 8, 8, 9, 9, 9, 9, 10, 8, 8] + "samples": [8, 8, 8, 8, 8, 8, 8, 8, 8, 12, 9, 9, 10, 8, 8, 9, 10, 8, 8, 8, 8, 8, 9, 9, 9, 9, 10, 8, 8, 8, 9, 8] }, "last_updated": "2026-09-11" }, @@ -295,12 +295,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 21, - "p95": 25, + "p50": 22, + "p95": 24, "min": 20, "max": 31, "n": 32, - "samples": [25, 21, 21, 20, 21, 21, 20, 22, 21, 22, 20, 21, 31, 24, 24, 24, 20, 24, 23, 24, 21, 22, 20, 20, 21, 22, 23, 22, 22, 24, 20, 21] + "samples": [20, 21, 21, 20, 22, 21, 22, 20, 21, 31, 24, 24, 24, 20, 24, 23, 24, 21, 22, 20, 20, 21, 22, 23, 22, 22, 24, 20, 21, 23, 23, 23] }, "last_updated": "2026-09-11" }, @@ -444,7 +444,7 @@ "min": 8, "max": 106, "n": 32, - "samples": [10, 10, 9, 10, 8, 9, 11, 10, 8, 9, 9, 8, 106, 9, 10, 10, 10, 11, 12, 10, 9, 10, 11, 10, 10, 9, 9, 9, 9, 9, 10, 11] + "samples": [10, 8, 9, 11, 10, 8, 9, 9, 8, 106, 9, 10, 10, 10, 11, 12, 10, 9, 10, 11, 10, 10, 9, 9, 9, 9, 9, 10, 11, 10, 10, 10] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json index e3b7fb0e..4cc8df21 100644 --- a/test/scenarios/light/scenario_perf_full.json +++ b/test/scenarios/light/scenario_perf_full.json @@ -88,9 +88,9 @@ "p50": 1, "p95": 1, "min": 1, - "max": 2, + "max": 1, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -208,9 +208,9 @@ "p50": 1, "p95": 1, "min": 1, - "max": 2, + "max": 1, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -328,9 +328,9 @@ "p50": 1, "p95": 1, "min": 1, - "max": 2, + "max": 1, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -571,9 +571,9 @@ "p50": 1, "p95": 1, "min": 1, - "max": 2, + "max": 1, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -687,11 +687,11 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, "max": 2, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -818,9 +818,9 @@ "p50": 1, "p95": 1, "min": 1, - "max": 2, + "max": 1, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -951,9 +951,9 @@ "p50": 1, "p95": 1, "min": 1, - "max": 2, + "max": 1, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -1058,9 +1058,9 @@ "p50": 1, "p95": 1, "min": 1, - "max": 2, + "max": 1, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -1171,9 +1171,9 @@ "p50": 1, "p95": 1, "min": 1, - "max": 2, + "max": 1, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -1293,11 +1293,11 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 6, + "p95": 5, "min": 4, - "max": 9, + "max": 6, "n": 32, - "samples": [9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 5, 5, 5, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 5, 5, 5, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -1417,11 +1417,11 @@ "desktop-macos": { "tick_us": { "p50": 18, - "p95": 23, + "p95": 21, "min": 17, - "max": 40, + "max": 23, "n": 32, - "samples": [40, 17, 17, 17, 18, 18, 17, 18, 18, 18, 18, 18, 23, 21, 18, 18, 18, 20, 20, 21, 18, 18, 17, 18, 18, 20, 21, 20, 20, 21, 18, 18] + "samples": [17, 18, 18, 17, 18, 18, 18, 18, 18, 23, 21, 18, 18, 18, 20, 20, 21, 18, 18, 17, 18, 18, 20, 21, 20, 20, 21, 18, 18, 18, 17, 17] }, "last_updated": "2026-09-11" }, @@ -1541,11 +1541,11 @@ "desktop-macos": { "tick_us": { "p50": 73, - "p95": 113, + "p95": 88, "min": 70, - "max": 173, + "max": 113, "n": 32, - "samples": [173, 71, 70, 70, 74, 74, 71, 70, 73, 75, 72, 74, 113, 86, 71, 71, 70, 76, 80, 88, 73, 72, 70, 73, 72, 80, 84, 80, 80, 84, 70, 72] + "samples": [70, 74, 74, 71, 70, 73, 75, 72, 74, 113, 86, 71, 71, 70, 76, 80, 88, 73, 72, 70, 73, 72, 80, 84, 80, 80, 84, 70, 72, 70, 71, 70] }, "last_updated": "2026-09-11" }, @@ -1677,7 +1677,7 @@ "min": 4, "max": 5, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -1801,7 +1801,7 @@ "min": 15, "max": 92, "n": 32, - "samples": [17, 15, 15, 15, 16, 16, 16, 16, 16, 17, 15, 16, 92, 19, 16, 16, 16, 16, 19, 19, 16, 17, 15, 16, 16, 18, 19, 18, 18, 18, 15, 16] + "samples": [15, 16, 16, 16, 16, 16, 17, 15, 16, 92, 19, 16, 16, 16, 16, 19, 19, 16, 17, 15, 16, 16, 18, 19, 18, 18, 18, 15, 16, 15, 16, 15] }, "last_updated": "2026-09-11" }, @@ -1920,12 +1920,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 65, + "p50": 64, "p95": 82, "min": 61, "max": 574, "n": 32, - "samples": [69, 62, 62, 61, 66, 65, 64, 63, 64, 64, 63, 67, 574, 74, 64, 62, 63, 82, 72, 74, 68, 69, 62, 63, 65, 71, 74, 70, 71, 74, 62, 64] + "samples": [61, 66, 65, 64, 63, 64, 64, 63, 67, 574, 74, 64, 62, 63, 82, 72, 74, 68, 69, 62, 63, 65, 71, 74, 70, 71, 74, 62, 64, 62, 63, 61] }, "last_updated": "2026-09-11" }, @@ -2044,12 +2044,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 264, + "p50": 259, "p95": 301, - "min": 247, + "min": 243, "max": 402, "n": 32, - "samples": [279, 252, 249, 247, 256, 265, 251, 255, 259, 267, 257, 264, 402, 298, 250, 259, 252, 285, 294, 301, 265, 277, 252, 257, 264, 281, 298, 285, 284, 298, 250, 257] + "samples": [247, 256, 265, 251, 255, 259, 267, 257, 264, 402, 298, 250, 259, 252, 285, 294, 301, 265, 277, 252, 257, 264, 281, 298, 285, 284, 298, 250, 257, 248, 250, 243] }, "last_updated": "2026-09-11" }, @@ -2208,7 +2208,7 @@ "min": 1, "max": 2, "n": 32, - "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -2332,7 +2332,7 @@ "min": 4, "max": 7, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 7, 5, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 7, 5, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -2456,7 +2456,7 @@ "min": 15, "max": 27, "n": 32, - "samples": [17, 16, 15, 15, 16, 16, 15, 16, 16, 16, 16, 16, 27, 18, 15, 15, 15, 17, 18, 18, 16, 15, 15, 16, 16, 17, 18, 18, 18, 18, 15, 16] + "samples": [15, 16, 16, 15, 16, 16, 16, 16, 16, 27, 18, 15, 15, 15, 17, 18, 18, 16, 15, 15, 16, 16, 17, 18, 18, 18, 18, 15, 16, 15, 15, 15] }, "last_updated": "2026-09-11" }, @@ -2575,12 +2575,12 @@ }, "desktop-macos": { "tick_us": { - "p50": 65, + "p50": 64, "p95": 79, - "min": 62, + "min": 61, "max": 103, "n": 32, - "samples": [70, 63, 63, 62, 66, 66, 63, 64, 65, 65, 63, 67, 103, 74, 63, 63, 63, 68, 71, 79, 67, 62, 62, 62, 63, 70, 74, 71, 71, 72, 62, 64] + "samples": [62, 66, 66, 63, 64, 65, 65, 63, 67, 103, 74, 63, 63, 63, 68, 71, 79, 67, 62, 62, 62, 63, 70, 74, 71, 71, 72, 62, 64, 62, 61, 61] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json index d9ce014c..26e1f56c 100644 --- a/test/scenarios/light/scenario_perf_light.json +++ b/test/scenarios/light/scenario_perf_light.json @@ -102,11 +102,11 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, "max": 2, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -454,7 +454,7 @@ "min": 1, "max": 2, "n": 32, - "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-09-11" }, @@ -578,7 +578,7 @@ "min": 4, "max": 6, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -702,7 +702,7 @@ "min": 15, "max": 24, "n": 32, - "samples": [17, 15, 15, 15, 16, 16, 15, 15, 17, 20, 16, 16, 24, 18, 15, 16, 15, 16, 18, 19, 16, 16, 16, 15, 16, 17, 19, 18, 17, 18, 15, 16] + "samples": [15, 16, 16, 15, 15, 17, 20, 16, 16, 24, 18, 15, 16, 15, 16, 18, 19, 16, 16, 16, 15, 16, 17, 19, 18, 17, 18, 15, 16, 16, 15, 15] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json index 6467eba1..96ad1ce0 100644 --- a/test/scenarios/light/scenario_peripheral_grid_sweep.json +++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json @@ -178,7 +178,7 @@ "min": 4, "max": 6, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 6, 5, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 6, 4, 4, 6, 5, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -305,7 +305,7 @@ "min": 15, "max": 84, "n": 32, - "samples": [17, 15, 15, 15, 18, 17, 16, 16, 16, 84, 17, 16, 37, 19, 16, 16, 16, 16, 17, 20, 16, 16, 15, 15, 15, 17, 18, 18, 17, 18, 16, 16] + "samples": [15, 18, 17, 16, 16, 16, 84, 17, 16, 37, 19, 16, 16, 16, 16, 17, 20, 16, 16, 15, 15, 15, 17, 18, 18, 17, 18, 16, 16, 16, 16, 16] }, "last_updated": "2026-09-11" }, @@ -429,10 +429,10 @@ "tick_us": { "p50": 66, "p95": 151, - "min": 62, + "min": 61, "max": 296, "n": 32, - "samples": [69, 63, 63, 62, 151, 65, 63, 63, 66, 296, 66, 64, 103, 74, 65, 63, 63, 69, 72, 78, 66, 72, 64, 62, 66, 71, 73, 71, 71, 71, 62, 67] + "samples": [62, 151, 65, 63, 63, 66, 296, 66, 64, 103, 74, 65, 63, 63, 69, 72, 78, 66, 72, 64, 62, 66, 71, 73, 71, 71, 71, 62, 67, 63, 61, 61] }, "last_updated": "2026-09-11" }, @@ -554,12 +554,12 @@ }, "desktop-macos": { "tick_us": { - "p50": 264, + "p50": 260, "p95": 813, - "min": 247, + "min": 244, "max": 872, "n": 32, - "samples": [278, 251, 247, 250, 590, 264, 252, 255, 260, 872, 259, 272, 813, 298, 254, 251, 251, 278, 285, 299, 272, 277, 251, 250, 254, 282, 285, 282, 285, 285, 249, 260] + "samples": [250, 590, 264, 252, 255, 260, 872, 259, 272, 813, 298, 254, 251, 251, 278, 285, 299, 272, 277, 251, 250, 254, 282, 285, 282, 285, 285, 249, 260, 250, 245, 244] }, "last_updated": "2026-09-11" }, @@ -707,7 +707,7 @@ "min": 4, "max": 16, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 5, 4, 7, 4, 4, 16, 5, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 5, 4, 7, 4, 4, 16, 5, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -834,7 +834,7 @@ "min": 15, "max": 61, "n": 32, - "samples": [17, 15, 16, 16, 17, 16, 15, 15, 16, 22, 16, 16, 61, 18, 15, 16, 15, 18, 18, 18, 16, 16, 15, 15, 15, 17, 18, 17, 18, 18, 15, 16] + "samples": [16, 17, 16, 15, 15, 16, 22, 16, 16, 61, 18, 15, 16, 15, 18, 18, 18, 16, 16, 15, 15, 15, 17, 18, 17, 18, 18, 15, 16, 15, 15, 15] }, "last_updated": "2026-09-11" }, @@ -956,12 +956,12 @@ }, "desktop-macos": { "tick_us": { - "p50": 65, + "p50": 64, "p95": 83, - "min": 61, + "min": 60, "max": 125, "n": 32, - "samples": [69, 63, 61, 65, 68, 68, 62, 64, 64, 83, 64, 63, 125, 74, 62, 63, 64, 69, 72, 75, 69, 67, 62, 62, 61, 70, 71, 69, 71, 71, 62, 64] + "samples": [65, 68, 68, 62, 64, 64, 83, 64, 63, 125, 74, 62, 63, 64, 69, 72, 75, 69, 67, 62, 62, 61, 70, 71, 69, 71, 71, 62, 64, 63, 61, 60] }, "last_updated": "2026-09-11" }, @@ -1083,12 +1083,12 @@ }, "desktop-macos": { "tick_us": { - "p50": 260, + "p50": 259, "p95": 316, - "min": 248, + "min": 243, "max": 643, "n": 32, - "samples": [279, 253, 249, 310, 316, 263, 254, 254, 259, 308, 260, 253, 643, 309, 254, 255, 252, 272, 285, 296, 274, 258, 248, 249, 251, 283, 286, 276, 276, 284, 248, 257] + "samples": [310, 316, 263, 254, 254, 259, 308, 260, 253, 643, 309, 254, 255, 252, 272, 285, 296, 274, 258, 248, 249, 251, 283, 286, 276, 276, 284, 248, 257, 248, 246, 243] }, "last_updated": "2026-09-11" }, @@ -1236,7 +1236,7 @@ "min": 4, "max": 7, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 7, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 7, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -1363,7 +1363,7 @@ "min": 15, "max": 27, "n": 32, - "samples": [17, 16, 15, 15, 16, 16, 16, 15, 16, 16, 16, 17, 27, 18, 16, 15, 15, 16, 18, 18, 19, 17, 15, 15, 16, 17, 18, 17, 17, 18, 15, 16] + "samples": [15, 16, 16, 16, 15, 16, 16, 16, 17, 27, 18, 16, 15, 15, 16, 18, 18, 19, 17, 15, 15, 16, 17, 18, 17, 17, 18, 15, 16, 15, 15, 15] }, "last_updated": "2026-09-11" }, @@ -1485,12 +1485,12 @@ }, "desktop-macos": { "tick_us": { - "p50": 66, + "p50": 64, "p95": 96, "min": 61, "max": 588, "n": 32, - "samples": [70, 62, 62, 62, 67, 66, 64, 63, 64, 86, 64, 67, 96, 588, 63, 64, 64, 68, 71, 74, 67, 70, 62, 62, 63, 70, 71, 68, 68, 78, 61, 64] + "samples": [62, 67, 66, 64, 63, 64, 86, 64, 67, 96, 588, 63, 64, 64, 68, 71, 74, 67, 70, 62, 62, 63, 70, 71, 68, 68, 78, 61, 64, 61, 61, 61] }, "last_updated": "2026-09-11" }, @@ -1612,12 +1612,12 @@ }, "desktop-macos": { "tick_us": { - "p50": 260, + "p50": 258, "p95": 359, - "min": 247, + "min": 243, "max": 384, "n": 32, - "samples": [274, 252, 247, 251, 277, 262, 251, 254, 252, 311, 255, 260, 384, 359, 252, 252, 251, 270, 292, 297, 266, 266, 248, 247, 251, 282, 287, 275, 276, 288, 249, 258] + "samples": [251, 277, 262, 251, 254, 252, 311, 255, 260, 384, 359, 252, 252, 251, 270, 292, 297, 266, 266, 248, 247, 251, 282, 287, 275, 276, 288, 249, 258, 243, 246, 243] }, "last_updated": "2026-09-11" }, @@ -1765,7 +1765,7 @@ "min": 4, "max": 6, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -1892,7 +1892,7 @@ "min": 15, "max": 25, "n": 32, - "samples": [17, 16, 15, 15, 17, 16, 16, 15, 16, 17, 15, 15, 25, 21, 15, 15, 15, 16, 17, 18, 16, 16, 15, 15, 15, 17, 18, 20, 18, 18, 15, 16] + "samples": [15, 17, 16, 16, 15, 16, 17, 15, 15, 25, 21, 15, 15, 15, 16, 17, 18, 16, 16, 15, 15, 15, 17, 18, 20, 18, 18, 15, 16, 15, 15, 15] }, "last_updated": "2026-09-11" }, @@ -2016,10 +2016,10 @@ "tick_us": { "p50": 64, "p95": 87, - "min": 61, + "min": 60, "max": 117, "n": 32, - "samples": [68, 62, 62, 61, 64, 65, 63, 64, 63, 69, 64, 62, 117, 87, 64, 63, 63, 68, 71, 74, 65, 62, 62, 62, 62, 69, 71, 80, 71, 71, 62, 64] + "samples": [61, 64, 65, 63, 64, 63, 69, 64, 62, 117, 87, 64, 63, 63, 68, 71, 74, 65, 62, 62, 62, 62, 69, 71, 80, 71, 71, 62, 64, 60, 60, 61] }, "last_updated": "2026-09-11" }, @@ -2141,12 +2141,12 @@ }, "desktop-macos": { "tick_us": { - "p50": 256, + "p50": 255, "p95": 385, - "min": 246, + "min": 245, "max": 391, "n": 32, - "samples": [281, 251, 248, 246, 258, 271, 249, 255, 253, 276, 251, 253, 385, 363, 252, 251, 250, 299, 287, 289, 265, 261, 248, 249, 250, 275, 287, 391, 279, 284, 249, 256] + "samples": [246, 258, 271, 249, 255, 253, 276, 251, 253, 385, 363, 252, 251, 250, 299, 287, 289, 265, 261, 248, 249, 250, 275, 287, 391, 279, 284, 249, 256, 245, 245, 247] }, "last_updated": "2026-09-11" }, diff --git a/test/scenarios/light/scenario_peripheral_switch.json b/test/scenarios/light/scenario_peripheral_switch.json index 32fc1321..d6add21b 100644 --- a/test/scenarios/light/scenario_peripheral_switch.json +++ b/test/scenarios/light/scenario_peripheral_switch.json @@ -177,7 +177,7 @@ "min": 4, "max": 6, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -298,7 +298,7 @@ "min": 4, "max": 19, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 19, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 19, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -419,7 +419,7 @@ "min": 4, "max": 5, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -539,7 +539,7 @@ "min": 4, "max": 5, "n": 32, - "samples": [4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 5, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -660,7 +660,7 @@ "min": 4, "max": 5, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, @@ -797,7 +797,7 @@ "min": 4, "max": 5, "n": 32, - "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] }, "last_updated": "2026-09-11" }, diff --git a/test/unit/core/unit_MoonStatsModule.cpp b/test/unit/core/unit_MoonStatsModule.cpp index 1a40f8f2..27683b90 100644 --- a/test/unit/core/unit_MoonStatsModule.cpp +++ b/test/unit/core/unit_MoonStatsModule.cpp @@ -1,8 +1,14 @@ // @module MoonStatsModule #include "doctest.h" +#include "core/FilesystemModule.h" #include "core/MoonStatsModule.h" +#include "core/Scheduler.h" +#include +#include +#include +#include #include /// Nothing is reported until the user says yes, and declining is permanent. @@ -15,56 +21,48 @@ TEST_CASE("a report is due only after the user consents") { stats.setup(); stats.defineControls(); - CHECK(stats.consent() == mm::MoonStatsModule::Unanswered); + CHECK_FALSE(stats.consent()); CHECK_FALSE(stats.reportDue()); - stats.setConsent(mm::MoonStatsModule::Never); + stats.setConsent(false); CHECK_FALSE(stats.reportDue()); - CHECK_FALSE(stats.shouldPrompt()); - stats.setConsent(mm::MoonStatsModule::NotNow); + stats.setConsent(false); CHECK_FALSE(stats.reportDue()); - stats.setConsent(mm::MoonStatsModule::Yes); + stats.setConsent(true); CHECK(stats.reportDue()); } -/// The prompt appears once and stops appearing as soon as it is answered, whichever way. +/// The prompt appears once and stops as soon as the question is answered, whichever way. +/// +/// The UI asks on `consent == Unanswered` and nothing else (app.js), so that state IS the prompt: +/// an earlier helper duplicated that rule in C++ where nothing called it. TEST_CASE("the prompt appears only while the question is unanswered") { mm::MoonStatsModule stats; stats.setup(); stats.defineControls(); - CHECK(stats.shouldPrompt()); + CHECK_FALSE(stats.consent()); - stats.setConsent(mm::MoonStatsModule::Yes); - CHECK_FALSE(stats.shouldPrompt()); + stats.setConsent(true); + CHECK(stats.consent()); } -/// A device serving its own access point asks nothing and sends nothing. -/// -/// Both paths consult the platform rather than a cached flag, so there is no copy to go stale. The -/// desktop stub answers false, so this pins the FALSE branch: with no AP, the question is asked and -/// a report becomes due. The true branch belongs on a board, which is a state the desktop cannot -/// enter, so the honest thing is to say so rather than to write an assertion that holds either way. +/// A device serving its own access point sends nothing. /// -/// An earlier version of this case checked only `inApMode() == false` and two consequences that -/// follow from consent alone: it stayed green with the AP check deleted from `shouldPrompt()` -/// entirely, which is a test that cannot fail. -TEST_CASE("with no access point, the question is asked and a report becomes due") { +/// `inApMode()` consults the platform rather than a cached flag, so there is no copy to go stale. +/// The desktop stub answers false, so this pins the FALSE branch: with no AP, a consented device +/// has a report due. The true branch belongs on a board, a state the desktop cannot enter, so the +/// honest thing is to say so rather than write an assertion that holds either way. +TEST_CASE("with no access point, a consented device has a report due") { mm::MoonStatsModule stats; stats.setup(); stats.defineControls(); REQUIRE_FALSE(stats.inApMode()); // the desktop stub: never its own AP - // shouldPrompt() is (unanswered AND not in AP mode). With the second false, the first decides, - // so these two together pin that consent still drives it while the AP check is present. - CHECK(stats.shouldPrompt()); - stats.setConsent(mm::MoonStatsModule::Never); - CHECK_FALSE(stats.shouldPrompt()); - - stats.setConsent(mm::MoonStatsModule::Yes); + stats.setConsent(true); CHECK(stats.reportDue()); } @@ -76,24 +74,49 @@ TEST_CASE("a restart sends nothing once the running version has been reported") mm::MoonStatsModule stats; stats.setup(); stats.defineControls(); - stats.setConsent(mm::MoonStatsModule::Yes); + stats.setConsent(true); REQUIRE(stats.reportDue()); stats.markReported(); CHECK_FALSE(stats.reportDue()); - // A reboot re-runs setup(); the persisted reportedVersion_ still matches, so nothing is due. - stats.setup(); - CHECK_FALSE(stats.reportDue()); + // A reboot is a NEW instance reading persisted state. The value travels through the CONTROL, + // which is the mechanism that was broken: `addReadOnly` registers a type the persistence layer + // skips, so reportedVersion came back empty on every boot and a consented device re-reported + // forever. Writing it the way a config load does is what proves the fix. + const mm::ControlList& ctrls = stats.controls(); + uint8_t idx = ctrls.count(); + for (uint8_t i = 0; i < ctrls.count(); i++) + if (ctrls[i].name && std::strcmp(ctrls[i].name, "reportedVersion") == 0) idx = i; + REQUIRE(idx < ctrls.count()); + // THE assertion: a control the persistence layer skips saves nothing, and `addReadOnly` + // registered exactly such a type. Without this the case below passes on a value that never + // travelled. + REQUIRE(mm::isPersistable(ctrls[idx])); + + // Restore it the way a config load does: parse the value out of a JSON object by key, with the + // tolerant policy persistence uses. + char saved[128]; + std::snprintf(saved, sizeof(saved), "{\"reportedVersion\":\"%s\"}", + static_cast(ctrls[idx].ptr)); + + mm::MoonStatsModule rebooted; + rebooted.defineControls(); + REQUIRE(mm::applyControlValue(rebooted.controls()[idx], saved, "reportedVersion", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + rebooted.setup(); + rebooted.setConsent(true); + CHECK_FALSE(rebooted.reportDue()); } + /// A first report is an install, and one after a version change is an upgrade, told apart without /// any identifier: an empty recorded version means this install has never reported. TEST_CASE("an install and an upgrade are distinguished by the recorded version") { mm::MoonStatsModule stats; stats.setup(); stats.defineControls(); - stats.setConsent(mm::MoonStatsModule::Yes); + stats.setConsent(true); CHECK(stats.dueEvent() == mm::MoonStatsEvent::Install); CHECK(stats.previousVersion() == nullptr); @@ -115,11 +138,11 @@ TEST_CASE("no installation id is produced without consent") { stats.installationId(id); CHECK(std::string(id).empty()); - stats.setConsent(mm::MoonStatsModule::Never); + stats.setConsent(false); stats.installationId(id); CHECK(std::string(id).empty()); - stats.setConsent(mm::MoonStatsModule::Yes); + stats.setConsent(true); stats.installationId(id); CHECK(std::string(id).size() == mm::kInstallationIdChars); } @@ -133,7 +156,7 @@ TEST_CASE("an upgrade sends one report, and the boot after it sends none") { mm::MoonStatsModule stats; stats.setup(); stats.defineControls(); - stats.setConsent(mm::MoonStatsModule::Yes); + stats.setConsent(true); // First install: one report due, then reported. REQUIRE(stats.reportDue()); @@ -155,26 +178,27 @@ TEST_CASE("an upgrade sends one report, and the boot after it sends none") { for (int i = 0; i < 3; i++) CHECK_FALSE(stats.reportDue()); } -/// Not-now defers rather than answers: the question returns after the next upgrade, where Never -/// silences it for good. +/// Declining stays declined, including across an upgrade. /// -/// The difference is the whole reason there are four options rather than three, and neither branch -/// was covered. -TEST_CASE("not-now is asked again after an upgrade, never is not") { - mm::MoonStatsModule deferred; - deferred.setup(); - deferred.defineControls(); - deferred.setConsent(mm::MoonStatsModule::NotNow); - CHECK_FALSE(deferred.shouldPrompt()); // not right now - CHECK_FALSE(deferred.reportDue()); // and nothing is sent - - mm::MoonStatsModule refused; - refused.setup(); - refused.defineControls(); - refused.setConsent(mm::MoonStatsModule::Never); - refused.setRunningVersionForTest("9.9.9"); - CHECK_FALSE(refused.shouldPrompt()); // an upgrade does not reopen the question - CHECK_FALSE(refused.reportDue()); +/// The four-option consent (unanswered / yes / not now / never) collapsed to a checkbox: both ways +/// of saying no meant nothing is sent, and telling them apart cost a persisted version and a branch +/// in setup(). What remains is the contract that matters: off sends nothing, and a firmware change +/// does not quietly turn it back on. +TEST_CASE("declining survives a reboot and an upgrade") { + mm::MoonStatsModule declined; + declined.setup(); + declined.defineControls(); + declined.setConsent(false); + CHECK_FALSE(declined.reportDue()); + + declined.setup(); // a reboot + CHECK_FALSE(declined.consent()); + CHECK_FALSE(declined.reportDue()); + + declined.setRunningVersionForTest("9.9.9"); // and an upgrade under it + declined.setup(); + CHECK_FALSE(declined.consent()); + CHECK_FALSE(declined.reportDue()); } /// Consent withdrawn after reporting stops the next one. @@ -184,13 +208,13 @@ TEST_CASE("withdrawing consent stops a report that would otherwise be due") { mm::MoonStatsModule stats; stats.setup(); stats.defineControls(); - stats.setConsent(mm::MoonStatsModule::Yes); + stats.setConsent(true); stats.markReported(); stats.setRunningVersionForTest("9.9.9"); REQUIRE(stats.reportDue()); // the upgrade would be reported - stats.setConsent(mm::MoonStatsModule::Never); + stats.setConsent(false); CHECK_FALSE(stats.reportDue()); // until it is refused char id[mm::kInstallationIdChars + 1] = {"unset"}; @@ -211,15 +235,15 @@ TEST_CASE("saying yes, then no, then yes again sends only the first report") { stats.setup(); stats.defineControls(); - stats.setConsent(mm::MoonStatsModule::Yes); + stats.setConsent(true); REQUIRE(stats.reportDue()); stats.markReported(); REQUIRE_FALSE(stats.reportDue()); - stats.setConsent(mm::MoonStatsModule::Never); + stats.setConsent(false); CHECK_FALSE(stats.reportDue()); - stats.setConsent(mm::MoonStatsModule::Yes); + stats.setConsent(true); CHECK_FALSE(stats.reportDue()); // still nothing: no new install, no new report // An actual upgrade still reports, so the rule above suppresses duplicates rather than @@ -227,3 +251,67 @@ TEST_CASE("saying yes, then no, then yes again sends only the first report") { stats.setRunningVersionForTest("9.9.9"); CHECK(stats.reportDue()); } + + +/// Reporting SCHEDULES the save, it does not merely mark the module dirty. +/// +/// `markDirty()` flags the module; only `FilesystemModule::noteDirty()` sets the pending flag that +/// `FilesystemModule::tick1s` needs before it flushes. Nothing on the report path called it, and the +/// gap hid behind ordinary use: on a first INSTALL the consent write leaves a 2 s debounce pending, +/// so `reportedVersion` rode along with that flush and looked saved. On an UPGRADE boot consent is +/// already on and no control is written, so the new version stayed in RAM and the next reboot +/// reported the same upgrade again, which is the one case the feature exists for. +/// +/// Pinned through the FILE, because the flag is private and the file is what a reboot reads. +TEST_CASE("a report schedules the save that records it") { + char tmpRoot[256]; + std::snprintf(tmpRoot, sizeof(tmpRoot), "/tmp/mm_stats_save_%u", + static_cast(mm::platform::millis())); + std::filesystem::remove_all(tmpRoot); + mm::platform::fsSetRoot(tmpRoot); + + { + // Scheduler::release() deletes its tree, so the modules are heap-allocated. + mm::Scheduler scheduler; + auto* fs = new mm::FilesystemModule(); + auto* stats = new mm::MoonStatsModule(); + fs->setTypeName("FilesystemModule"); + stats->setTypeName("MoonStatsModule"); + stats->setName("Stats"); + fs->setScheduler(&scheduler); + scheduler.addModule(fs); + scheduler.addModule(stats); + scheduler.setup(); + + stats->setConsent(true); + REQUIRE(stats->reportDue()); + + // What sendReport() does once the POST is handed off. No control is written here, which is + // exactly the upgrade-boot shape. + stats->markReported(); + CHECK_FALSE(stats->reportDue()); + + // Past the debounce, so the pending save lands. Without noteDirty() the flag is never set + // and this tick returns before flushing. + mm::platform::setTestNowMs(mm::platform::millis() + 5000); + fs->tick1s(); + mm::platform::setTestNowMs(0); + + char path[512]; + std::snprintf(path, sizeof(path), "%s/.config/MoonStatsModule.json", tmpRoot); + REQUIRE(std::filesystem::exists(path)); + + std::ifstream in(path); + const std::string saved((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + // The VERSION, not merely the key: an empty value is what the unsaved shape looked like. + char expected[64]; + std::snprintf(expected, sizeof(expected), "\"reportedVersion\":\"%s\"", mm::kVersion); + CHECK(saved.find(expected) != std::string::npos); + + scheduler.release(); + } + + mm::platform::fsSetRoot(""); + std::filesystem::remove_all(tmpRoot); +} diff --git a/test/unit/core/unit_MoonStatsReport.cpp b/test/unit/core/unit_MoonStatsReport.cpp index 7c89d402..b9b235ea 100644 --- a/test/unit/core/unit_MoonStatsReport.cpp +++ b/test/unit/core/unit_MoonStatsReport.cpp @@ -2,6 +2,7 @@ #include "doctest.h" #include "core/MoonStatsModule.h" +#include "core/AudioService.h" #include #include @@ -119,8 +120,76 @@ TEST_CASE("no installation id appears until one is supplied") { != std::string::npos); } -/// Only enabled modules are named, so the report describes what a device actually runs. -TEST_CASE("the report names the modules that are enabled") { - const std::string json = report(); - CHECK(json.find("\"modules\":[\"System\"]") != std::string::npos); +/// Memory and light count ride the report as RAW numbers, for the server to bucket into ranges. +/// +/// Nothing pinned them, and the worker's own `clean()` drops anything that is not a string unless a +/// field has a branch of its own: exactly the regression that stored three zeros for every device. +TEST_CASE("the report carries memory and light count as numbers") { + mm::SystemModule system; + system.setName("System"); + + mm::MoonModule* tree[] = {&system}; + mm::JsonSink sink; + mm::buildMoonStatsReport(sink, tree, 1, mm::MoonStatsEvent::Install, + nullptr, "1.0.0", nullptr, 256); + const std::string json = sink.data(); + + CHECK(json.find("\"lightCount\":256") != std::string::npos); + // Unquoted: a JSON number, not a string, which is what the server's numeric branch accepts. + CHECK(json.find("\"lightCount\":\"") == std::string::npos); + CHECK(json.find("\"totalHeap\":") != std::string::npos); + CHECK(json.find("\"freeHeap\":") != std::string::npos); +} + +/// The report names what the user ADDED, not the boot tree every device shares. +/// +/// Counting main.cpp's wired modules made every slice read "2 of 2 devices", which says only that +/// both booted. What varies between installations is what someone chose to run, so a wired module +/// is skipped while its children are still walked: a user's effect hangs under a wired parent. +TEST_CASE("the report names modules by ROLE, not by how they were wired") { + // A plain container. NOT wired by code, so only the ROLE rule excludes it: under the older + // isWiredByCode() test this one would have been reported. + mm::MoonModule container; + container.setName("Container"); + + // Wired by code AND a real role: the mirror case, reported under the role rule and dropped + // under the old one. Together these two fail if the filter ever switches back. + mm::AudioService added; + added.setName("SomeService"); + added.markWiredByCode(); + + mm::AudioService off; + off.setName("Disabled"); + off.setEnabled(false); + + mm::MoonModule* tree[] = {&container, &added, &off}; + mm::JsonSink sink; + mm::buildMoonStatsReport(sink, tree, 3, mm::MoonStatsEvent::Install, + nullptr, "1.0.0", nullptr); + const std::string json = sink.data(); + + CHECK(json.find("service:SomeService") != std::string::npos); // a real role: reported + CHECK(json.find("Container") == std::string::npos); // generic: a structural container + CHECK(json.find("Disabled") == std::string::npos); // switched off +} + +/// A user's module hangs UNDER a wired parent, so skipping the parent must not skip the child. +TEST_CASE("a module added under a wired parent is still reported") { + mm::SystemModule parent; + parent.setName("Effects"); + parent.markWiredByCode(); + + mm::AudioService child; + child.setName("Lissajous"); + parent.addChild(&child); + + mm::MoonModule* tree[] = {&parent}; + mm::JsonSink sink; + mm::buildMoonStatsReport(sink, tree, 1, mm::MoonStatsEvent::Install, + nullptr, "1.0.0", nullptr); + const std::string json = sink.data(); + + CHECK(json.find("service:Lissajous") != std::string::npos); + CHECK(json.find("Effects") == std::string::npos); } + diff --git a/test/unit/core/unit_MoonTalkModule.cpp b/test/unit/core/unit_MoonTalkModule.cpp index 7d719688..78be339b 100644 --- a/test/unit/core/unit_MoonTalkModule.cpp +++ b/test/unit/core/unit_MoonTalkModule.cpp @@ -2,6 +2,8 @@ #include "doctest.h" #include "core/MoonTalkModule.h" +#include "core/Scheduler.h" +#include "core/SystemModule.h" #include @@ -14,8 +16,20 @@ TEST_CASE("naming is a second consent, off by default") { mm::MoonTalkModule talk; talk.defineControls(); - CHECK(talk.consent() == mm::MoonTalkModule::Unanswered); + CHECK_FALSE(talk.consent()); CHECK_FALSE(talk.sharesName()); + + // The whole matrix, because `sharesName()` is an AND and either half alone must not publish a + // name: consenting to post is not consenting to be named, and vice versa. + talk.setShareNameForTest(true); + CHECK_FALSE(talk.sharesName()); // named, but not allowed to post + + talk.setShareNameForTest(false); + talk.setConsentForTest(true); + CHECK_FALSE(talk.sharesName()); // allowed to post, but anonymously + + talk.setShareNameForTest(true); + CHECK(talk.sharesName()); // both, and only then } /// The device name is READ from the module tree, never held as a copy. @@ -30,7 +44,89 @@ TEST_CASE("the device name is looked up rather than stored") { mm::MoonTalkModule talk; talk.defineControls(); + // With no System module in the tree: empty, not a crash. This is the case a probe instance + // (built by /api/types and thrown away) actually hits. const char* name = talk.deviceName(); REQUIRE(name != nullptr); // never null, whatever the tree holds - CHECK(std::string(name).size() < 32); + CHECK(std::string(name).empty()); +} + +/// And with a System module present it returns THAT name, which is the half the previous case could +/// not see: a lookup that always returned "" would have passed it. +TEST_CASE("the device name comes from the System module") { + // Heap-allocated and owned by the scheduler, which deletes its tree on release(): a stack module + // handed to addModule() is deleted as stack memory, which segfaults. + mm::Scheduler scheduler; + auto* system = new mm::SystemModule(); + system->setTypeName("SystemModule"); + system->setName("System"); + system->setScheduler(&scheduler); + scheduler.addModule(system); + // setup() publishes `instance_` (which deviceName() walks) AND runs each module's setup(), the + // MAC fallback that fills deviceName_. Without it the lookup finds no tree and returns "". + scheduler.setup(); + + const std::string expected = system->deviceName(); + REQUIRE_FALSE(expected.empty()); // or the comparison below proves nothing + + mm::MoonTalkModule talk; + talk.defineControls(); + + const char* fromTree = talk.deviceName(); + REQUIRE(fromTree != nullptr); + CHECK(std::string(fromTree) == expected); + + scheduler.release(); +} + +/// Pressing `send` is the only thing that publishes, and it empties the box. +/// +/// A Text control reports every debounced KEYSTROKE, so an earlier shape published "hel" and +/// "hell" while someone typed "hello". A settle window then guessed when typing had stopped and +/// cleared half-typed messages when it guessed wrong. The button removes the guess: typing changes +/// nothing, and the message survives until the user says so. +TEST_CASE("a message is published by the send button, not by typing") { + mm::MoonTalkModule talk; + talk.defineControls(); + talk.setConsentForTest(true); + talk.setMessageForTest("hello"); + + // Typing does NOT publish and does NOT clear: the box still holds what was typed. + talk.onControlChanged("message"); + CHECK(std::string(talk.message()) == "hello"); + + // Nor does any other control. + talk.onControlChanged("shareName"); + CHECK(std::string(talk.message()) == "hello"); + + // Send empties the box. There is no MoonCloud parent here, so the post is a no-op and this + // pins the TRIGGER rather than the transport. + talk.onControlChanged("send"); + CHECK(std::string(talk.message()).empty()); +} + +/// Consent still gates publishing, and a refused send leaves the text alone rather than discarding +/// it: withholding consent is not a reason to lose what somebody wrote. +TEST_CASE("send without consent publishes nothing and keeps the text") { + mm::MoonTalkModule talk; + talk.defineControls(); + talk.setMessageForTest("hello"); + + REQUIRE_FALSE(talk.consent()); + talk.onControlChanged("send"); + CHECK(std::string(talk.message()) == "hello"); + + talk.setConsentForTest(false); + talk.onControlChanged("send"); + CHECK(std::string(talk.message()) == "hello"); +} + +/// An empty box sends nothing, so a stray press cannot publish a blank message. +TEST_CASE("send with an empty message does nothing") { + mm::MoonTalkModule talk; + talk.defineControls(); + talk.setConsentForTest(true); + + talk.onControlChanged("send"); + CHECK(std::string(talk.message()).empty()); } From 19c3d0ba76a2535bbfe5901d38b49c28138a8f63 Mon Sep 17 00:00:00 2001 From: ewowi Date: Fri, 11 Sep 2026 12:28:03 +0200 Subject: [PATCH 3/3] Add a MoonCloud user page and work the CodeRabbit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MoonCloud now has a page written for the person deciding whether to switch it on, rather than only a catalog entry and a privacy policy: what Stats and Talk exchange, and five concrete reasons the project collects usage numbers at all. The rest is the CodeRabbit round on the MoonCloud branch, including two tests that could not have failed. Performance: desktop 1,904 KB (+16 KB, the MM_RELEASE curl guard pulls libcurl into the measured build); esp32 2,029 KB. Desktop headline scenario p50s unchanged at 69 us and 5 us; the 184 us/5,434 FPS figure is a live re-run on a loaded host, not a contract number. 2,038 test cases. Core: - buildMoonStatsReport takes totalHeap and freeHeap as parameters instead of calling platform:: itself, so the report builder stays testable and platform-free. - MoonTalk's send() returns whether anything was published, and the message is kept in the box when it was not: a failed post no longer loses what you typed. UI: - Pie slices and legend rows are keyboard-operable (tabindex, role, Enter/Space), matching the collapsible headers. A filter only a mouse can reach is a control only some people have. - Pending control writes are flushed before a button click, so a value typed and immediately sent is the value that goes. - The chart filter bar skips "*Label" keys, which were rendering as their own filter entries. Tests: - "declining survives a reboot and an upgrade" now does a real persistence round trip. It called setup() and labelled it a reboot, but setup() reloads nothing, so consent staying false was guaranteed by the object never being destroyed: the case could not fail. Control-checked by persisting true instead, which fails 4 of its 8 assertions. - The report test asserts the totalHeap VALUE, not merely that the key is present. Docs/CI: - docs/mooncloud.md, linked from the nav under Getting started. It links to the catalog and the privacy policy rather than restating them, so the facts keep one home. - Why we collect stats: to build what is used, to know what can be removed, to test on hardware people own, to size defaults for real installations, and to tell an upgrade from a fresh install. With the explicit caveat that rare-and-excellent survives. - Fixed a docs build failure that predates this commit: the MoonCloud plan linked to mooncloud/README.md, which sits outside docs/ and can never resolve relatively. Now the repo-blob URL, the convention MIGRATING.md already uses. MkDocs --strict went from aborting on 1 warning to clean. - CMakeLists.txt: honour -> honor, three instances. check_prose.py only reads .md, so source comments escape it. Reviews: - 🐇 12 CodeRabbit findings, 10 fixed, 2 skipped with reason. Skipped: restoring the isWiredByCode() module filter (it reported every top-level module as generic:System, which is why it was replaced by the role filter), and naming a MoonCloud member in the privacy policy (the policy deliberately names none, so it needs no edit when a member is added; line 67 already discloses country for both row types). Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- CMakeLists.txt | 10 +- .../plans/Plan-20260910 - MoonCloud.md | 12 +-- docs/metrics/repo-health.json | 52 +++++----- docs/metrics/repo-health.md | 52 +++++----- docs/mooncloud.md | 41 ++++++++ docs/moonmodules/core/system.md | 14 +-- docs/privacy-policy.md | 54 +++++----- mkdocs.yml | 1 + mooncloud/README.md | 9 +- mooncloud/seed.sql | 16 +-- mooncloud/worker.js | 10 +- mooncloud/wrangler.toml | 6 +- src/core/MoonCloudModule.h | 7 +- src/core/MoonStatsModule.h | 58 ++++++----- src/core/MoonTalkModule.h | 62 +++++++----- src/core/sha256.cpp | 4 +- src/platform/desktop/platform_desktop.cpp | 8 ++ src/platform/esp32/platform_esp32_ota.cpp | 6 +- src/platform/platform.h | 5 + src/ui/app.js | 99 +++++++++---------- src/ui/style.css | 7 +- test/js/mooncloud-report.test.mjs | 32 +++++- test/js/ui-mooncloud.test.mjs | 25 ++--- .../scenario_MoonModule_control_change.json | 12 +-- .../light/scenario_Audio_mutation.json | 12 +-- test/scenarios/light/scenario_Aurora_fps.json | 20 ++-- .../light/scenario_Driver_mutation.json | 12 +-- .../light/scenario_Effects_composition.json | 2 +- .../light/scenario_Fields_polar_lut.json | 24 ++--- .../light/scenario_Fluid_solver.json | 36 +++---- .../light/scenario_GridBlacks_blackpixel.json | 2 +- .../light/scenario_GridLayout_resize.json | 6 +- .../light/scenario_Layer_base_pipeline.json | 2 +- .../light/scenario_Layer_memory_1to1.json | 2 +- .../light/scenario_Layouts_mutation.json | 8 +- .../scenario_MoonLiveEffect_livescript.json | 32 +++--- .../light/scenario_MoonLive_pipeline.json | 10 +- .../scenario_MultiplyModifier_memory_lut.json | 2 +- .../scenario_MultiplyModifier_pipeline.json | 2 +- .../light/scenario_Trails_ladder.json | 20 ++-- .../light/scenario_modifier_chain.json | 10 +- .../light/scenario_modifier_swap.json | 6 +- test/scenarios/light/scenario_perf_full.json | 24 ++--- test/scenarios/light/scenario_perf_light.json | 8 +- .../light/scenario_peripheral_grid_sweep.json | 34 +++---- .../light/scenario_peripheral_switch.json | 12 +-- test/unit/core/unit_MoonStatsModule.cpp | 69 ++++++++----- test/unit/core/unit_MoonStatsReport.cpp | 8 +- test/unit/core/unit_MoonTalkModule.cpp | 21 +++- 50 files changed, 578 insertions(+), 410 deletions(-) create mode 100644 docs/mooncloud.md diff --git a/CLAUDE.md b/CLAUDE.md index f9d48f50..e3414c1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Docs land with the code, not at merge time: the module's spec and catalog card d **How the writing looks: American spelling, no em-dashes.** `color`, `serialize`, `behavior`, `analyze`; a comma, colon or full stop where an em-dash wants to go. In comments, docs, commit messages and chat replies alike. Both rules are enforced mechanically by `check_prose.py` (a write-time hook, and again at the commit gate), because they are exactly the kind of habit that stays invisible to its own author. -**And how much of it there is: minimal, dense, straight to the point.** A comment or a doc paragraph says what the code cannot (the reason, the constraint, the failure it prevents) in the fewest words that carry it. Restating the code is noise; so is a paragraph where a clause would do. Nothing is stripped wholesale, and a reason still true is shortened rather than dropped: **condense, don't delete**. No check catches this one, so it is judgment, applied when writing and again when reviewing. Full rationale: [coding-standards § Writing](docs/coding-standards.md). +**And how much of it there is: minimal, dense, straight to the point.** A comment or a doc paragraph says what the code cannot (the reason, the constraint, the failure it prevents) in the fewest words that carry it. Restating the code is noise; so is a paragraph where a clause would do. Nothing is stripped wholesale, and a reason still true is shortened rather than dropped: **condense, don't delete**. No check catches this one, so it is judgment, applied when writing and again when reviewing. Full rationale: [coding-standards § Conventions](docs/coding-standards.md#conventions). ### Commit diff --git a/CMakeLists.txt b/CMakeLists.txt index 62cb0cdd..665c43c8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -189,6 +189,14 @@ if(CURL_FOUND) message(STATUS "libcurl ${CURL_VERSION_STRING}: MoonCloud reporting enabled") target_compile_definitions(mm_platform PRIVATE MM_HAVE_CURL) target_link_libraries(mm_platform PUBLIC CURL::libcurl) +elseif(DEFINED ENV{MM_RELEASE}) + # A RELEASE build is the one case where absent is not acceptable: the binary would ask for + # consent and then be structurally unable to honor it, and nobody would find out. Optional + # stays optional for a contributor's machine; a published artifact must be able to report. + message(FATAL_ERROR + "libcurl not found, and MM_RELEASE is set. A released build with reporting compiled out " + "asks for consent it can never honor. Install libcurl (Windows: vcpkg install curl) or " + "unset MM_RELEASE for a local build.") else() message(STATUS "libcurl not found: MoonCloud reporting disabled (install libcurl to enable)") endif() @@ -265,7 +273,7 @@ if(WIN32) add_custom_command( OUTPUT "${MM_ICON}" # `uv run