diff --git a/CLAUDE.md b/CLAUDE.md index b096b3f4..e3414c1d 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 § Conventions](docs/coding-standards.md#conventions). ### Commit diff --git a/CMakeLists.txt b/CMakeLists.txt index 160c40d5..665c43c8 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,31 @@ target_link_libraries(mm_platform PUBLIC $<$:ws2_32> $<$` rather than the usual `uv run python `; + +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..cf937044 --- /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 + +# Runs the Worker near its backend rather than near the caller, which for a request that always +# touches D1 is the shorter path. NOT a jurisdiction control: the data staying in Europe comes from +# the database's region (WEUR), not from this. +[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..95e924a9 --- /dev/null +++ b/moondeck/run/purge_mooncloud.py @@ -0,0 +1,151 @@ +#!/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 datetime +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 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 + + # `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/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 new file mode 100644 index 00000000..fae6b0ca --- /dev/null +++ b/src/core/MoonCloudModule.h @@ -0,0 +1,92 @@ +// 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. +/// +/// 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" +#include "core/sha256.h" +#include "platform/platform.h" + +namespace mm { + +class MoonCloudModule : public MoonModule { +public: + /// Always runs: the children carry the real consent, so disabling the container is ambiguous. + bool respectsEnabled() const MM_NONBLOCKING override { return false; } + + static constexpr uint32_t kTimeoutMs = 4000; + + /// 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); + return platform::httpsPost(url, body, kTimeoutMs); + } + + + +private: + // 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: `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; + +/// 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); + + // 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)); + + 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..5cd937eb --- /dev/null +++ b/src/core/MoonStatsModule.h @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +/// @file MoonStatsModule.h +/// MoonStats: consent, and the once-per-install trigger. +/// +/// 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.** 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 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 out and the user is mid-provisioning. +/// +/// See [privacy-policy.md](../../docs/privacy-policy.md) for what is promised. + +#include +#include +#include + +#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: 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. +// +// **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 }; + +/// 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(); + 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 among the roots and their direct children, which is as deep as the one +/// caller needs (the top-level `System`). +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, 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; + 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()); +} + + +/// 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. +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, + uint32_t lightCount = 0, + uint32_t totalHeap = 0, uint32_t freeHeap = 0) { + const MoonModule* system = findModule(root, moduleCount, "System"); + + sink.append("{"); + bool first = true; + + // Omitted by any caller without consent, and by the test asserting the forbidden fields. + 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); + } + + // 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. + if (previousVersion && *previousVersion) { + sink.append(",\"previousVersion\":"); + sink.writeJsonString(previousVersion); + } + + // 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. + // Passed in, not read here: the builder is a pure function over its arguments everywhere else, + // and reading the platform mid-serialize would make these two fields untestable. + sink.appendf(",\"totalHeap\":%u,\"freeHeap\":%u,\"lightCount\":%u", + static_cast(totalHeap), + static_cast(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); + + // What the user CHOSE to run, by name and role: see reportModules for which modules count and + // why. A fixed vocabulary from the catalog, never anything a user typed. + sink.append(",\"modules\":["); + bool firstModule = true; + reportModules(sink, root, moduleCount, firstModule); + sink.append("]"); + + sink.append("}"); + sink.flush(); +} + + +class MoonStatsModule : public MoonModule { +public: + void setup() override { + std::snprintf(runningVersion_, sizeof(runningVersion_), "%s", kVersion); + refreshStatus(); + MoonModule::setup(); + } + + /// What this setting exchanges, on the module's own status slot rather than a control of its + /// own: the standard place for a line of explanation. The privacy policy points here rather than + /// carrying a list that dates the moment a field changes, and the charts are the detail, so the + /// line names them instead of repeating them. + void refreshStatus() { + if (consent_) clearStatus(); + else setStatus("Off. Switch on to share what hardware you run, once per install or upgrade: " + "the empty charts below are exactly what it contributes to, and what you get " + "back. No device name, no addresses, no credentials."); + } + + void onControlChanged(const char* name) override { + if (name && std::strcmp(name, "consent") == 0) refreshStatus(); + } + + void defineControls() override { + controls_.clear(); + // 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 a report is due: the user said yes, and the running version differs from the one + /// that last reported. + bool reportDue() const { + if (!consent_) return false; + return std::strcmp(reportedVersion_, runningVersion_) != 0; + } + + /// Which kind of report is due. + 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. 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, the same way a control write does. + void setConsent(bool yes) { consent_ = yes; markDirty(); refreshStatus(); } + + bool consent() const { return consent_; } + + /// 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 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_) { out[0] = 0; return; } + mm::installationId(out); + } + + /// 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; + // A build without an HTTPS client can never send, so there is nothing to hand off and + // nothing to mark. Distinct from a failed attempt: retrying costs nothing when no request is + // ever made, where re-sending after network loss would turn one report into a heartbeat. + if (!platform::httpsAvailable()) 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(); + } + + /// 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: 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. 32 is its own capacity, so this + // cannot truncate a tree it 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; + const LightSummary* lights = Drivers::latestSummary(); + buildMoonStatsReport(body, tree, count, + dueEvent(), id, runningVersion_, previousVersion(), + lights ? lights->lightCount : 0, + static_cast(platform::totalHeap()), + static_cast(platform::freeHeap())); + + // 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(); + } + + bool consent_ = false; + 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..6523d103 --- /dev/null +++ b/src/core/MoonTalkModule.h @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +/// @file MoonTalkModule.h +/// MoonTalk: a public message board between projectMM devices. +/// +/// 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.** No private message, no recipient, no delete, +/// and the module never posts on its own: a message exists because somebody typed it. +/// +/// **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 sends no identifier, but the board is only read while consent is on: a device whose +/// owner said no makes no request at all. 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 +#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: + void defineControls() override { + controls_.clear(); + + // A checkbox: publishing is on or off, and a third state said nothing extra. + controls_.addControl("consent", consent_); + + // Default OFF: a device name is the one field here that identifies a person. + controls_.addControl("shareName", shareName_); + + controls_.addText("message", message_, sizeof(message_)); + // Below the text it sends, and the ONLY thing that publishes. + controls_.addButton("send"); + } + + /// 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 setup() override { + refreshStatus(); + MoonModule::setup(); + } + + /// What this setting exchanges, on the status slot: see MoonStatsModule::refreshStatus for why + /// the explanation lives here rather than in a control or only in the policy. + void refreshStatus() { + if (consent_) clearStatus(); + else setStatus("Off. Switch on to post to a public board shared by projectMM devices. " + "What you type is readable by anyone, permanently, and cannot be withdrawn. " + "Each message carries the country it came from and the time it was sent."); + } + + void onControlChanged(const char* name) override { + if (name && std::strcmp(name, "consent") == 0) { refreshStatus(); return; } + if (!name || std::strcmp(name, "send") != 0) return; + if (!consent_) return; // never publish without consent + if (message_[0] == 0) return; // nothing typed + + // Only on a successful hand-off. Clearing regardless threw away what somebody typed when the + // server was unreachable, which is the moment they would most want to try again. + if (send()) { + message_[0] = 0; + markDirty(); + } + } + + /// 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(); + const MoonModule* system = sched ? sched->firstByName("System") : nullptr; + if (!system) return ""; + auto& ctrls = system->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 ""; + } + + 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; refreshStatus(); } + 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. False when nothing was published, so the caller can keep the text. + bool send() { + char id[kInstallationIdChars + 1] = {}; + installationId(id); + if (!id[0]) return false; + + JsonSink body; + body.append("{\"sender\":"); + body.writeJsonString(id); + // Only when both consents allow it, and omitted rather than sent empty. + 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. + auto* cloud = static_cast(parent()); + return cloud && cloud->post("/api/talk", body.data()); + } + + bool consent_ = false; + bool shareName_ = false; + // 281 = the server's MAX_MESSAGE (280) plus the terminator. Sized to the contract rather than + // under it: a 192-byte buffer silently capped a device 89 characters below the documented limit. + char message_[281] = {}; +}; + +} // 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..fce01ae3 --- /dev/null +++ b/src/core/sha256.cpp @@ -0,0 +1,118 @@ +// 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] = {}; + // Guarded: memcpy's src is `nonnull` even for a zero length, so sha256(nullptr, 0) is UB by the + // letter of the standard and UBSan reports it. Hashing nothing is a legal thing to ask for. + if (remaining) 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..b242babc 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. + moonStatsModule->markWiredByCode(); moonCloudModule->addChild(moonStatsModule); + 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..629b9152 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,119 @@ 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 httpsAvailable() MM_NONBLOCKING { +#ifdef MM_HAVE_CURL + return true; +#else + return false; // built without libcurl: httpsPost can never succeed +#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..459eb4a8 100644 --- a/src/platform/esp32/platform_esp32_ota.cpp +++ b/src/platform/esp32/platform_esp32_ota.cpp @@ -747,4 +747,32 @@ bool moonbaseStageInstallUrl(const char* url) { return ok; } + +bool httpsAvailable() MM_NONBLOCKING { + return true; // the TLS stack is linked for OTA, so an ESP32 build can always try +} + +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 diff --git a/src/platform/platform.h b/src/platform/platform.h index 4eb93dbe..5b817d0e 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,29 @@ 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); + +/// Whether this build can make an outbound HTTPS request at all. False only on a desktop build +/// compiled without libcurl, where `httpsPost` always returns false: a structural inability rather +/// than a failed attempt, which a caller must be able to tell apart from network loss. +bool httpsAvailable() MM_NONBLOCKING; + // 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..c4ab2f26 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -518,9 +518,10 @@ 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 ctrl = mod && Array.isArray(mod.controls) && mod.controls.find(c => c.name === controlName); + const mod = (state && Array.isArray(state.modules)) + ? allModules().find(m => m.name === moduleName) : null; + if (mod) { + const ctrl = 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 // on every surface write), but the browser would not see it until the next 1 Hz push, so a @@ -553,6 +554,31 @@ 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(); + // 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})`); } @@ -1931,12 +1957,38 @@ function createCard(mod, depth) { renderFileManager(mod, controlsHost); } + // 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); + } + + // 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 // 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 @@ -2770,6 +2822,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; @@ -2990,7 +3062,13 @@ function createControl(moduleName, moduleType, ctrl) { const btn = document.createElement("button"); btn.className = "action-btn"; btn.textContent = ctrl.label || ctrl.name; - btn.addEventListener("click", () => sendControl(moduleName, ctrl.name, 1)); + // Flush any pending debounced text write first, then act. Typing is debounced 500 ms, + // so a click inside that window sent the button while the device still held the text as + // it stood one keystroke ago: the Enter path already does this, and the two must agree. + btn.addEventListener("click", async () => { + await flushPendingControlWrites(moduleName); + sendControl(moduleName, ctrl.name, 1); + }); row.appendChild(btn); break; } @@ -4151,12 +4229,36 @@ function relativeAge(sec) { return `${Math.floor(sec / 86400)}d ago`; } +/// Send any text control of this module whose debounced write is still pending, and wait for it. +/// +/// Typing is debounced, so anything that ACTS on the typed value (Enter, a send button) has to land +/// the text first or it acts on what the device held a keystroke ago. +async function flushPendingControlWrites(moduleName) { + const mod = allModules().find(m => m.name === moduleName); + for (const ctrl of mod?.controls || []) { + const key = moduleName + ":" + ctrl.name; + if (!dragTimers[key]) continue; + clearTimeout(dragTimers[key]); + delete dragTimers[key]; + // queryByName, not a bare querySelector: a CSS attribute match is case-insensitive, so + // `data-mid="talk"` would resolve to a module named `Talk`. See queryByName for the bench + // case that found it. + const input = queryByName( + `input[data-key="${cssEscape(ctrl.name)}"]`, "data-mid", moduleName); + if (input) await sendControl(moduleName, ctrl.name, input.value); + } +} + function appendResetButton(row, moduleName, ctrl, def, applyVisually) { if (def === undefined || def === null) return; // type not loaded yet or no default // 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"; @@ -5605,8 +5707,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 +6048,415 @@ 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. +// +// 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. +// 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 = 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"]; + +// 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, 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]); + 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)}%)` + + (pickable ? ", click to filter" : ""); + path.appendChild(title); + if (pickable) { + path.addEventListener("click", () => onPick(r.name)); + // Reachable without a mouse, the same way a collapsible card header is: a filter nobody + // can operate from the keyboard is a control only some people have. + path.setAttribute("tabindex", "0"); + path.setAttribute("role", "button"); + path.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPick(r.name); } + }); + } + svg.appendChild(path); + angle = next; + }); + return svg; +} + +function moonCloudLegend(rows, onPick) { + 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); + // `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)); + line.tabIndex = 0; + line.setAttribute("role", "button"); + line.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPick(r.name); } + }); + } + box.appendChild(line); + }); + return box; +} + +function renderMoonCloudStats(host, mod) { + const section = document.createElement("div"); + section.className = "mooncloud-stats"; + host.appendChild(section); + + // 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 (!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 = 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 = "mooncloud-refresh"; + refresh.textContent = "\u21bb"; + refresh.title = "Refresh"; + refresh.addEventListener("click", () => { moonCloudStatsCache.clear(); load(); }); + head.appendChild(refresh); + } + + 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) { + if (k.endsWith("Label")) continue; // display text for a bounds pair, not a filter + 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"; + + // 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. + // 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; + // 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 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. +// +// Reading sends no identifier: no id, no name, nothing about this device. It still happens only +// while consent is on, because a request from a device whose owner said no is still a request to +// our server. +// +// 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"; + header.appendChild(title); + + const list = document.createElement("div"); + list.className = "moontalk-list"; + section.append(header, list); + + // `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"; + // "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; + } + // 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); + } + // 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; + }; + + // 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() : 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)); + }; + // 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 = "mooncloud-refresh"; + refresh.textContent = "\u21bb"; + refresh.title = "Refresh"; + refresh.addEventListener("click", () => load(true)); + header.appendChild(refresh); + } + + load(false); +} + 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..ed6f489b 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -2146,3 +2146,60 @@ 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-head { display: flex; align-items: center; margin-bottom: 10px; } +.mooncloud-stats-head .mooncloud-stats-title { margin-bottom: 0; } +.mooncloud-stats-head .mooncloud-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; } +/* 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: 8rem; max-width: 100%; height: auto; } +.mooncloud-slice { stroke: var(--card-bg, #1b1b1b); stroke-width: 1.5; } +/* 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; } +/* 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. */ +.moontalk { margin-top: 12px; } +.moontalk-header { display: flex; align-items: center; font-size: 0.85em; opacity: 0.75; margin-bottom: 6px; } +.mooncloud-refresh { margin-left: auto; background: none; border: none; color: inherit; cursor: pointer; font-size: 1.1em; opacity: 0.6; padding: 0 4px; } +.mooncloud-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; } +/* 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. */ +/* 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/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..d4896c88 --- /dev/null +++ b/test/js/mooncloud-report.test.mjs @@ -0,0 +1,162 @@ +// 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", "MAX_NUMBER", + `${cleanSource}; return clean;` +)(ALLOWED, 64, 64, 2147483647); + +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)); +}); + +// 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"); +}); + +// The numeric fields arrive as JSON NUMBERS, and every other allowlisted field is a string. The +// generic `typeof value !== "string"` test dropped them silently, so three zeros were stored for +// every device until a branch of their own was added. Nothing pinned it: the harness could not even +// run a numeric case, because it never passed MAX_NUMBER into the extracted clean(). +test("memory and light counts are stored as numbers, not dropped", () => { + const row = clean( + { installationId: "c26086e8da9b6fb2d4b81e4ca71f97e5", + totalHeap: 282152, freeHeap: 84788, lightCount: 256 }, + "NL" + ); + assert.equal(row.totalHeap, 282152); + assert.equal(row.freeHeap, 84788); + assert.equal(row.lightCount, 256); +}); + +test("a numeric field that is not a number stores zero rather than text", () => { + // The endpoint is open, so its input is untrusted: a string, a negative, or a float must not + // reach an INTEGER column as-is. + const row = clean( + { installationId: "c26086e8da9b6fb2d4b81e4ca71f97e5", + totalHeap: "abc", freeHeap: -5, lightCount: 12.7 }, + "NL" + ); + assert.equal(row.totalHeap, 0); + assert.equal(row.freeHeap, 0); + assert.equal(row.lightCount, 12); // floored, not rejected: a count is a count +}); diff --git a/test/js/ui-mooncloud.test.mjs b/test/js/ui-mooncloud.test.mjs new file mode 100644 index 00000000..adfa4126 --- /dev/null +++ b/test/js/ui-mooncloud.test.mjs @@ -0,0 +1,226 @@ +// MoonCloud card contracts, pinned in the source the way ui-visibility does. +// +// Every MoonCloud bug found by hand 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 ["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("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`"); + + // 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"); + + // 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", () => { + // 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("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"); +}); + + +test("the consent explanation is a module status, not a bespoke UI block", () => { + // Every module already has a status slot and the card already renders it, so a hand-rolled + // element beside the checkbox was a second mechanism for a job the first one does. The text + // lives with the module that knows what it exchanges, which is also what the privacy policy + // points at instead of carrying a list. + assert.ok(!app.includes("renderMoonCloudConsent"), + "no bespoke consent renderer: the status row shows it"); + assert.ok(!app.includes("mooncloud-consent"), + "and no bespoke element for it either"); +}); diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json index f8c8d51a..900be80c 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": 126, "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": [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, 186] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth-wifi": { "tick_us": { @@ -300,13 +300,13 @@ "desktop-macos": { "tick_us": { "p50": 125, - "p95": 182, + "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": [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, 129] }, - "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": 152, + "p50": 121, + "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": [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, 117] }, - "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": [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, 119] }, - "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..3fdee535 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": [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, 16] }, - "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": [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, 25] }, - "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, 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, 28] }, - "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": [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, 38] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -509,12 +509,12 @@ "tick_us": { "p50": 22, "p95": 37, - "min": 19, + "min": 18, "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": [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, 26] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -603,13 +603,13 @@ "desktop-macos": { "tick_us": { "p50": 20, - "p95": 30, + "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": [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, 18] }, - "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..62aaaa69 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": 821, "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": [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, 923] }, - "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": 308, "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": [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, 322] }, - "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": 181, "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": [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, 189] }, - "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, "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": [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, 559] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -204,14 +204,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 964, + "p50": 970, "p95": 1647, - "min": 919, + "min": 915, "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": [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, 960] }, - "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": 1410, "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": [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, 1461] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -264,14 +264,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1556, + "p50": 1574, "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": [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, 1899] }, - "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": 1078, "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": [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, 1131] }, - "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..bd13b7af 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": [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, 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": [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, 20] }, - "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": [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, 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": 21, "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": [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, 20] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -460,14 +460,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 20, - "p95": 36, + "p50": 19, + "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": [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, 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..6b2c8280 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, - "min": 142, + "p95": 169, + "min": 143, "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": [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, 145] }, - "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..78a63dea 100644 --- a/test/scenarios/light/scenario_Fields_polar_lut.json +++ b/test/scenarios/light/scenario_Fields_polar_lut.json @@ -85,14 +85,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 297, + "p50": 294, "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": [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, 290] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -117,12 +117,12 @@ "tick_us": { "p50": 290, "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": [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, 290] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -145,14 +145,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 288, - "p95": 334, - "min": 274, + "p50": 287, + "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": [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, 286] }, - "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": [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, 283] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -206,13 +206,13 @@ "desktop-macos": { "tick_us": { "p50": 143, - "p95": 166, - "min": 137, - "max": 169, + "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": [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, 141] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -235,14 +235,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 433, - "p95": 503, - "min": 410, - "max": 512, + "p50": 439, + "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": [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, 439] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -271,14 +271,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1261, - "p95": 1686, - "min": 1206, - "max": 1892, + "p50": 1286, + "p95": 1892, + "min": 1219, + "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, 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, 1531] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -303,12 +303,12 @@ "tick_us": { "p50": 488, "p95": 604, - "min": 468, + "min": 461, "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": [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, 478] }, - "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..1105acc6 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": [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, 29] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -100,14 +100,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 20, + "p50": 19, "p95": 37, - "min": 19, + "min": 18, "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": [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, 19] }, - "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": [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, 66] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -144,14 +144,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 30, + "p50": 29, "p95": 37, - "min": 29, + "min": 28, "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": [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, 29] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -166,14 +166,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 66, + "p50": 65, "p95": 74, - "min": 62, + "min": 61, "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": [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, 64] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -187,14 +187,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 134, + "p50": 132, "p95": 176, - "min": 127, + "min": 126, "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": [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, 129] }, - "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." @@ -210,14 +210,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 136, + "p50": 132, "p95": 161, - "min": 129, + "min": 127, "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": [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, 129] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -232,14 +232,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 135, - "p95": 158, - "min": 127, + "p50": 130, + "p95": 223, + "min": 125, "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": [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, 129] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -254,14 +254,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 37, + "p50": 35, "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": [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, 35] }, - "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": [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, 10] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -296,14 +296,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 226, + "p50": 222, "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": [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, 217] }, - "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": [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, 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": [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, 8] }, - "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, 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, 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..3298b41c 100644 --- a/test/scenarios/light/scenario_GridBlacks_blackpixel.json +++ b/test/scenarios/light/scenario_GridBlacks_blackpixel.json @@ -91,13 +91,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 5, + "max": 1, "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": [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-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -194,13 +194,13 @@ "desktop-macos": { "tick_us": { "p50": 2, - "p95": 3, + "p95": 2, "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": [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, 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..1797a460 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": [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, 121] }, - "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": [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, 65] }, - "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": [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, 120] }, - "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..fa7f3c95 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": [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, 71] }, - "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..edf30f87 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, - "min": 5, + "p95": 7, + "min": 4, "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, 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, 5] }, - "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..17d01311 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": [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, 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": [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, 50] }, - "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": 121, "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": [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, 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": [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, 19] }, - "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..50537179 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, 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, 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, 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, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -318,12 +318,12 @@ "tick_us": { "p50": 5, "p95": 12, - "min": 5, + "min": 4, "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, 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, 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, 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, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -531,12 +531,12 @@ "tick_us": { "p50": 5, "p95": 9, - "min": 5, + "min": 4, "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, 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, 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": [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, 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": 32, + "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, 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": 32, + "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, 5] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -779,14 +779,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 6, + "p50": 5, "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": 32, + "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, 5] }, - "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, 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, 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, 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, 5] }, - "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, 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, 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..56ace2e6 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, 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, 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, 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, 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": [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, 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, 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, 5] }, - "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, 6, 6, 5, 5, 5, 6, 5, 7, 7, 5, 4, 5, 5, 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": { diff --git a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json index 309e8d67..7e23b467 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, 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, 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..3509ced8 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": [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, 120] }, - "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..5bb7701f 100644 --- a/test/scenarios/light/scenario_Trails_ladder.json +++ b/test/scenarios/light/scenario_Trails_ladder.json @@ -88,12 +88,12 @@ "tick_us": { "p50": 11, "p95": 14, - "min": 10, + "min": 11, "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": [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, 12] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -124,13 +124,13 @@ "desktop-macos": { "tick_us": { "p50": 46, - "p95": 57, - "min": 40, + "p95": 56, + "min": 44, "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": [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, 46] }, - "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, + "min": 175, "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": [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, 178] }, - "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, + "min": 344, "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": [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, 356] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -234,14 +234,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 364, - "p95": 442, - "min": 312, + "p50": 362, + "p95": 433, + "min": 351, "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": [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, 359] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -266,12 +266,12 @@ "tick_us": { "p50": 354, "p95": 451, - "min": 312, + "min": 344, "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": [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, 350] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" } } }, @@ -308,14 +308,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 183, + "p50": 182, "p95": 221, - "min": 164, + "min": 175, "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": [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, 177] }, - "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..caac4e99 100644 --- a/test/scenarios/light/scenario_modifier_chain.json +++ b/test/scenarios/light/scenario_modifier_chain.json @@ -102,14 +102,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 9, + "p50": 10, "p95": 13, "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": [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, 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": [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, 7] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -221,13 +221,13 @@ "desktop-macos": { "tick_us": { "p50": 24, - "p95": 28, + "p95": 27, "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": [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, 22] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -253,14 +253,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 43, + "p50": 44, "p95": 48, "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": [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, 38] }, - "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..ce36f450 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, 12, 9, 9, 10, 8, 8, 9, 10, 8, 8, 8, 8, 8, 9, 9, 9, 9, 10, 8, 8, 8, 9, 8, 9] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "esp32-eth": { "tick_us": { @@ -296,13 +296,13 @@ "desktop-macos": { "tick_us": { "p50": 22, - "p95": 31, + "p95": 24, "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": [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, 24] }, - "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": [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, 10] }, - "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..9559bac6 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": 1, "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": [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-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": 1, "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": [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-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": 1, "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": [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-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": 1, "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": [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-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -687,13 +687,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, 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, 2, 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": { @@ -816,13 +816,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, - "max": 5, + "max": 1, "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": [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-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": 1, "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": [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-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": 1, "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": [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-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": 1, "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": [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-09" + "last_updated": "2026-09-11" }, "esp32s3-n16r8": { "tick_us": { @@ -1293,13 +1293,13 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 9, + "p95": 5, "min": 4, - "max": 19, + "max": 6, "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": [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, 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": 21, "min": 17, - "max": 82, + "max": 23, "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": [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, 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": 88, "min": 70, - "max": 368, + "max": 113, "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": [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, 71] }, - "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, 5, 4, 4, 4, 4, 4, 4, 5, 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" }, "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": [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, 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": 64, + "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": [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, 62] }, - "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, - "min": 247, - "max": 589, + "p50": 259, + "p95": 301, + "min": 243, + "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": [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, 252] }, - "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, 2, 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" }, "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, 7, 5, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 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": [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, 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, - "min": 62, - "max": 141, + "p50": 64, + "p95": 79, + "min": 61, + "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": [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, 62] }, - "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..de838a16 100644 --- a/test/scenarios/light/scenario_perf_light.json +++ b/test/scenarios/light/scenario_perf_light.json @@ -102,13 +102,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, 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, 2, 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": { @@ -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, 2, 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": { @@ -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, 6, 4, 4, 4, 4, 4, 4, 5, 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" }, "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": [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, 15] }, - "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..33f0440f 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, 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, 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": [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, 16] }, - "last_updated": "2026-09-09" + "last_updated": "2026-09-11" }, "desktop-windows": { "tick_us": { @@ -429,12 +429,12 @@ "tick_us": { "p50": 66, "p95": 151, - "min": 62, + "min": 61, "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": [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, 62] }, - "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": 260, "p95": 813, - "min": 247, + "min": 244, "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": [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, 251] }, - "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, 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, 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, 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, 15] }, - "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, - "min": 61, - "max": 142, + "p50": 64, + "p95": 83, + "min": 60, + "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": [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, 63] }, - "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, - "min": 248, + "p50": 258, + "p95": 316, + "min": 243, "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": [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, 251] }, - "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, 7, 4, 4, 4, 4, 4, 4, 5, 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": { @@ -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": [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, 15] }, - "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": 64, + "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": [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, 62] }, - "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, - "min": 247, - "max": 561, + "p50": 258, + "p95": 359, + "min": 243, + "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": [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, 251] }, - "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, 6, 5, 4, 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-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, 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, 15] }, - "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, - "min": 61, - "max": 144, + "p95": 87, + "min": 60, + "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": [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, 62] }, - "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, - "min": 246, - "max": 606, + "p50": 255, + "p95": 385, + "min": 245, + "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": [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, 252] }, - "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..05fc60b9 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, 6, 5, 4, 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-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, 6, 19, 4, 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-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, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 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": { @@ -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": [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, 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, 5, 5, 4, 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-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, 5, 5, 4, 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-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..74c88b99 --- /dev/null +++ b/test/unit/core/unit_MoonStatsModule.cpp @@ -0,0 +1,338 @@ +// @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. +/// +/// 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_FALSE(stats.consent()); + CHECK_FALSE(stats.reportDue()); + + stats.setConsent(false); + CHECK_FALSE(stats.reportDue()); + + stats.setConsent(true); + CHECK(stats.reportDue()); +} + + +/// A device serving its own access point sends nothing. +/// +/// `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 + + stats.setConsent(true); + 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(true); + + REQUIRE(stats.reportDue()); + stats.markReported(); + 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(true); + + 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(false); + stats.installationId(id); + CHECK(std::string(id).empty()); + + stats.setConsent(true); + 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(true); + + // 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()); +} + +/// Declining stays declined, including across an upgrade. +/// +/// 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") { + // A REAL round trip through the control: calling setup() again reloads nothing, so a case that + // reused one instance could only have failed if setup() itself flipped the bool. The value has + // to travel the way a config load moves it, or the case proves nothing about a reboot. + mm::MoonStatsModule declined; + declined.setup(); + declined.defineControls(); + declined.setConsent(true); // on first, so `false` is a WITHDRAWAL, not a default + declined.setConsent(false); + CHECK_FALSE(declined.reportDue()); + + const mm::ControlList& ctrls = declined.controls(); + uint8_t idx = ctrls.count(); + for (uint8_t i = 0; i < ctrls.count(); i++) + if (ctrls[i].name && std::strcmp(ctrls[i].name, "consent") == 0) idx = i; + REQUIRE(idx < ctrls.count()); + REQUIRE(mm::isPersistable(ctrls[idx])); + + mm::MoonStatsModule rebooted; + rebooted.defineControls(); + REQUIRE(mm::applyControlValue(rebooted.controls()[idx], "{\"consent\":false}", "consent", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + rebooted.setup(); + CHECK_FALSE(rebooted.consent()); + CHECK_FALSE(rebooted.reportDue()); + + // And an upgrade under it does not reopen the question. + rebooted.setRunningVersionForTest("9.9.9"); + rebooted.setup(); + CHECK_FALSE(rebooted.consent()); + CHECK_FALSE(rebooted.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(true); + stats.markReported(); + + stats.setRunningVersionForTest("9.9.9"); + REQUIRE(stats.reportDue()); // the upgrade would be reported + + stats.setConsent(false); + 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(true); + REQUIRE(stats.reportDue()); + stats.markReported(); + REQUIRE_FALSE(stats.reportDue()); + + stats.setConsent(false); + CHECK_FALSE(stats.reportDue()); + + 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 + // silencing the device. + 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); +} + + +/// The explanation lives on the module's status slot, and follows the answer. +/// +/// The privacy policy states rules and points at the device for the detail, so this line is the +/// disclosure: it must be there while the setting is off, and gone once it is on. An earlier shape +/// hand-rolled an element in the UI, which put the text somewhere the module could not keep true. +TEST_CASE("the consent explanation is a status that follows the answer") { + mm::MoonStatsModule stats; + stats.setup(); + stats.defineControls(); + + REQUIRE_FALSE(stats.consent()); + REQUIRE(stats.status() != nullptr); + CHECK(std::string(stats.status()).find("Switch on") != std::string::npos); + + stats.setConsent(true); + CHECK(stats.status() == nullptr); // answered: nothing left to explain + + stats.setConsent(false); + REQUIRE(stats.status() != nullptr); // and it comes back if they change their mind +} diff --git a/test/unit/core/unit_MoonStatsReport.cpp b/test/unit/core/unit_MoonStatsReport.cpp new file mode 100644 index 00000000..1de45481 --- /dev/null +++ b/test/unit/core/unit_MoonStatsReport.cpp @@ -0,0 +1,197 @@ +// @module MoonStatsReport + +#include "doctest.h" +#include "core/MoonStatsModule.h" +#include "core/AudioService.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); +} + +/// 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, 282152, 84788); + 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); + // The VALUES, unquoted: the server's numeric branch accepts a JSON number and its generic + // string test drops anything else, which is how three zeros were stored for every device. + CHECK(json.find("\"totalHeap\":282152") != std::string::npos); + CHECK(json.find("\"freeHeap\":84788") != 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 new file mode 100644 index 00000000..806d2e02 --- /dev/null +++ b/test/unit/core/unit_MoonTalkModule.cpp @@ -0,0 +1,147 @@ +// @module MoonTalkModule + +#include "doctest.h" +#include "core/MoonTalkModule.h" +#include "core/Scheduler.h" +#include "core/SystemModule.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_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. +/// +/// 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(); + + // 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).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"); + + // Pressing send with no MoonCloud parent publishes nothing, and the text SURVIVES: clearing on + // a failed hand-off threw away what somebody typed at the moment they would most want to retry. + talk.onControlChanged("send"); + CHECK(std::string(talk.message()) == "hello"); +} + +/// 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()); +} + + +/// Talk carries its own explanation, because what it exchanges is nothing like a report. +TEST_CASE("the talk consent explanation is a status that follows the answer") { + mm::MoonTalkModule talk; + talk.setup(); + talk.defineControls(); + + REQUIRE_FALSE(talk.consent()); + REQUIRE(talk.status() != nullptr); + CHECK(std::string(talk.status()).find("public board") != std::string::npos); + + talk.setConsentForTest(true); + CHECK(talk.status() == nullptr); +} 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"); +}