From 7069f75563104ee87247ab3e17a36ca5357d33eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:12:27 +0000 Subject: [PATCH 1/3] cyberpunkunicorn-os: boot hello, setup over the cable, scans through the cable Firmware half of plan phases 0, 3 and 5: - @evt hello at the end of boot; system.caps lists every command; system.time sets the clock from the computer; system.status and the hello carry the git commit build.sh stamps (FW_COMMIT). - frequency.config / provision / unprovision set the board up as a Space's scanner over USB. The secret never comes back, and patch 0014 no longer echoes rpc arguments to the serial log. - With no Wi-Fi and a computer on the cable, a scan is signed on the board (HMAC-SHA256 keyed by sha256(secret), the hash the server stores) and forwarded by the computer (frequency.scan_relay / frequency.relay_reply), so the secret never leaves the board. - rpc frequency.scan now really queues when offline; sensors.read no longer ends its session twice. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AycPrTRVXqSDZhb2NNfyG6 --- cyberpunkunicorn-os/CLAUDE.md | 12 +- cyberpunkunicorn-os/apps/frequency/BACKEND.md | 18 +- .../apps/frequency/backend.cpp | 163 ++++++++++++++++-- cyberpunkunicorn-os/apps/frequency/backend.h | 23 +++ .../apps/frequency/provision.cpp | 102 +++++++++++ cyberpunkunicorn-os/apps/remote/PROTOCOL.md | 107 +++++++++++- .../apps/remote/app_handlers.cpp | 12 +- cyberpunkunicorn-os/apps/remote/remote.cpp | 64 +++++++ cyberpunkunicorn-os/apps/remote/remote.h | 16 +- cyberpunkunicorn-os/build.sh | 9 +- .../patches/0014-remote-hooks.patch | 12 +- 11 files changed, 509 insertions(+), 29 deletions(-) create mode 100644 cyberpunkunicorn-os/apps/frequency/provision.cpp diff --git a/cyberpunkunicorn-os/CLAUDE.md b/cyberpunkunicorn-os/CLAUDE.md index 1152462..56fdfc4 100644 --- a/cyberpunkunicorn-os/CLAUDE.md +++ b/cyberpunkunicorn-os/CLAUDE.md @@ -46,7 +46,8 @@ User-tunable values go in `boot.conf` → `-D` macros via `PLATFORMIO_BUILD_FLAG before SimpleCLI (which strips quotes, so JSON can't be a normal argument); `cyberAppsBoot()` at the end of setup; while the BLE API is on, the CLI task still reads USB and answers on the source transport; `rpc` lines skip the `# ` prompt - and the back-to-menu; `isBLEAPIEnabled()`; BLE advertised name `CU5K` + and the back-to-menu; `isBLEAPIEnabled()`; BLE advertised name `CU5K`; the `COMMAND:` echo + shows only `rpc` for rpc lines (they can carry a Wi-Fi password or the scanner secret) - `0015` Wi-Fi joins: open networks count as saved (`hasWifiCredential`; upstream treated an empty password as "not saved"), `wifi add ""` with no password, `wifi forget`, password no longer trimmed; join timeout 10 s → 20 s with a reason @@ -83,6 +84,12 @@ source published if builds are distributed. Member scan / issue card call frequency-web's device API (reader mode off by default; contract in `apps/frequency/BACKEND.md`; 404 = `not_enabled`). NVS namespace `frequency`. CLI: `frequency status|read|tag|tags|log|scan|issue|queue|config`. + Remote setup over the cable: `frequency.config|provision|unprovision` (`provision.cpp`). + With no Wi-Fi and a computer sending `rpc` over USB in the last 30 s (and its clock + set via `system.time`), a scan is signed on the board (HMAC-SHA256 keyed by + sha256(secret)) and sent through the computer as `@evt frequency.scan_relay`; the + answer comes back as `frequency.relay_reply`. `postScan()` waits for it, so it must + run on the UI task, never in a remote handler. Uses Bruce's `PN532` (`read()`, `write_ndef()` with `rawNdefRecord`, since `buildNdefMessage` caps URIs at 99 chars). Not yet tested with real tags. - `hooks.h` — what patch 0014 calls: raw-line hook and boot hook. @@ -90,6 +97,9 @@ source published if builds are distributed. USB serial and BLE, contract in [`apps/remote/PROTOCOL.md`](apps/remote/PROTOCOL.md). Apps add a command with one line, `REMOTE_COMMAND("name", handler)` (`remote/remote.h`). Menu app: live status, recent commands, Bluetooth now / at boot (NVS `remote/bleboot`). + Boot ends with `@evt hello {proto, os, fw, commit, board}`; `system.caps` lists every + registered command; `system.time {epoch}` sets the clock from the computer. + `build.sh` stamps the git commit (`FW_COMMIT`, `-dirty` if apps/patches changed). CLI: `remote [ble on|off] [boot on|off]`. Android must open the CH340 with DTR/RTS off. BLE replies are split to MTU−3 per notification. Not yet tested on hardware. - `net/` — no menu. Console Wi-Fi setup: `wifi.status|scan|join|forget` remote diff --git a/cyberpunkunicorn-os/apps/frequency/BACKEND.md b/cyberpunkunicorn-os/apps/frequency/BACKEND.md index 5e1bc3d..1129beb 100644 --- a/cyberpunkunicorn-os/apps/frequency/BACKEND.md +++ b/cyberpunkunicorn-os/apps/frequency/BACKEND.md @@ -151,6 +151,20 @@ Errors use the same `ok: false` / `reason` shape. | `devid` | empty | device id | | `devsecret` | empty | device secret | +Set them over the cable with `frequency.provision` (PROTOCOL.md "Setup over the +cable"), which is how the website's Check-in & scanners panel does it. + +## Scans through the cable (no Wi-Fi) + +With no Wi-Fi and the website's device page open on the computer it is plugged +into, a scan goes through that computer instead of the queue. The board signs it +with HMAC-SHA256 keyed by sha256(secret), the hash the server already stores, and +the computer posts it with `X-Frequency-Device-Id` + `X-Frequency-Signature` +instead of `X-Frequency-Device`. Exact bytes, signature and replay rules: +PROTOCOL.md "Scans through the cable". frequency-web has to accept that header +pair on `POST /api/v1/devices/scan`; until it does, the server answers 401 +`device_unauthorized` and the board shows "NOT PAIRED". + TLS: the device currently accepts any certificate (`setInsecure`). Pin the CA once a production device API exists. @@ -160,6 +174,6 @@ once a production device API exists. check-in path (venue tags)? 2. Device auth model: per-device secret (above) vs. a Space-scoped API key. 3. Card token ↔ UID binding: on first scan (above), or at issue time? -4. Should a late (queued) scan count for the day it happened (`scanned_at`) or - the day it arrived? +4. ~~Should a late (queued) scan count for the day it happened?~~ Decided + (Daniel, 2026-10-20): yes, when `scanned_at` is signed and within a window. 5. Card page at `/c/`: member profile, or a "claim this card" flow? diff --git a/cyberpunkunicorn-os/apps/frequency/backend.cpp b/cyberpunkunicorn-os/apps/frequency/backend.cpp index 34dae3f..cd91c5d 100644 --- a/cyberpunkunicorn-os/apps/frequency/backend.cpp +++ b/cyberpunkunicorn-os/apps/frequency/backend.cpp @@ -1,10 +1,13 @@ #include "backend.h" +#include "../remote/remote.h" #include "store.h" #include #include #include #include +#include +#include namespace freq { @@ -44,6 +47,8 @@ struct Response { JsonDocument body; }; +ApiStatus interpret(Response &res, String &reason); + // One JSON POST to {base}{path} with the device header. ApiStatus post(const String &path, const JsonDocument &req, Response &res, String &reason) { Settings &s = settings(); @@ -82,6 +87,11 @@ ApiStatus post(const String &path, const JsonDocument &req, Response &res, Strin reason = HTTPClient::errorToString(res.code); return API_OFFLINE; } + return interpret(res, reason); +} + +// What the server's answer means for the device. Shared by Wi-Fi and the cable. +ApiStatus interpret(Response &res, String &reason) { if (res.code == 404) { reason = "not_enabled"; return API_NOT_SET_UP; @@ -102,20 +112,7 @@ ApiStatus post(const String &path, const JsonDocument &req, Response &res, Strin return API_OK; } -} // namespace - -ScanReply postScan(const String &token, const String &uid, bool queued, uint32_t scannedAt) { - ScanReply out; - JsonDocument req; - req["card_token"] = token; - req["uid"] = uid; - if (queued) { // proposed extension: an offline scan being replayed - req["queued"] = true; - if (scannedAt) req["scanned_at"] = scannedAt; - } - Response res; - out.status = post("/api/v1/devices/scan", req, res, out.reason); - if (out.status != API_OK) return out; +void readScan(Response &res, ScanReply &out) { out.name = res.body["member"]["display_name"] | "Member"; out.zaps = res.body["zapsAwarded"] | 0; JsonVariant loyalty = res.body["loyalty"]; @@ -126,6 +123,144 @@ ScanReply postScan(const String &token, const String &uid, bool queued, uint32_t out.earned = loyalty["earned"] | false; out.rewardCode = loyalty["reward_code"] | ""; } +} + +// ---- scans through the cable (PROTOCOL.md "Scans through the cable") ------------- + +struct Relay { + SemaphoreHandle_t done = xSemaphoreCreateBinary(); + portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED; + uint32_t nonce = 0; // the scan waiting for its answer, 0 = none + int code = 0; + String body; // the server's JSON reply, as the computer passed it on +}; +Relay &relay() { + static Relay r; + return r; +} + +String hex(const uint8_t *b, size_t n) { + static const char *H = "0123456789abcdef"; + String s; + s.reserve(n * 2); + for (size_t i = 0; i < n; i++) { + s += H[b[i] >> 4]; + s += H[b[i] & 15]; + } + return s; +} + +} // namespace + +SignedScan signScan(const String &token, const String &uid, uint32_t scannedAt) { + SignedScan out; + Settings &s = settings(); + out.deviceId = s.deviceId; + out.nonce = esp_random() | 1; + JsonDocument req; + req["card_token"] = token; + req["uid"] = uid; + req["scanned_at"] = scannedAt; + char nonce[9]; + snprintf(nonce, sizeof(nonce), "%08lx", (unsigned long)out.nonce); + req["nonce"] = nonce; + serializeJson(req, out.body); + + // Key = sha256(secret): the server keeps exactly that hash, so it can check + // the signature without ever holding the secret itself. + const mbedtls_md_info_t *sha = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + uint8_t key[32], mac[32]; + mbedtls_md(sha, (const uint8_t *)s.deviceSecret.c_str(), s.deviceSecret.length(), key); + String msg = "scan:v1:" + s.deviceId + ":" + out.body; + mbedtls_md_hmac(sha, key, sizeof(key), (const uint8_t *)msg.c_str(), msg.length(), mac); + memset(key, 0, sizeof(key)); + out.signature = "v1=" + hex(mac, sizeof(mac)); + return out; +} + +bool cableAvailable() { + return WiFi.status() != WL_CONNECTED && remote::usbHostActive() && remote::trustedEpoch() != 0 && + settings().readerReady(); +} + +void putRelay(JsonObject o, const SignedScan &s) { + char nonce[9]; + snprintf(nonce, sizeof(nonce), "%08lx", (unsigned long)s.nonce); + o["nonce"] = nonce; + o["device_id"] = s.deviceId; + o["body"] = s.body; + o["signature"] = s.signature; +} + +bool relayAnswer(uint32_t nonce, int httpCode, const String &body) { + Relay &r = relay(); + bool match = false; + portENTER_CRITICAL(&r.mux); + if (nonce && r.nonce == nonce) { + r.nonce = 0; + match = true; + } + portEXIT_CRITICAL(&r.mux); + if (!match) return false; + r.code = httpCode; + r.body = body; + xSemaphoreGive(r.done); + return true; +} + +namespace { + +// Hands a signed scan to the computer on the cable and waits for the server's +// answer (frequency.relay_reply). Must not run on the CLI task: that task is the +// one that would deliver the answer. +ApiStatus relayScan(const String &token, const String &uid, Response &res, String &reason) { + SignedScan sc = signScan(token, uid, remote::trustedEpoch()); + Relay &r = relay(); + xSemaphoreTake(r.done, 0); // drop a stale answer + portENTER_CRITICAL(&r.mux); + r.nonce = sc.nonce; + portEXIT_CRITICAL(&r.mux); + + JsonDocument evt; + putRelay(evt.to(), sc); + remote::emit("frequency.scan_relay", evt.as()); + + bool answered = xSemaphoreTake(r.done, pdMS_TO_TICKS(RELAY_TIMEOUT_MS)) == pdTRUE; + portENTER_CRITICAL(&r.mux); + r.nonce = 0; + portEXIT_CRITICAL(&r.mux); + if (!answered) { + reason = "the computer didn't answer"; + return API_OFFLINE; + } + res.code = r.code; + if (r.code > 0) deserializeJson(res.body, r.body); + if (res.code <= 0) { + reason = "the computer is offline too"; + return API_OFFLINE; + } + return interpret(res, reason); +} + +} // namespace + +ScanReply postScan(const String &token, const String &uid, bool queued, uint32_t scannedAt) { + ScanReply out; + Response res; + if (!queued && cableAvailable()) { + out.status = relayScan(token, uid, res, out.reason); + out.viaCable = true; + } else { + JsonDocument req; + req["card_token"] = token; + req["uid"] = uid; + if (queued) { // an offline scan being replayed + req["queued"] = true; + if (scannedAt) req["scanned_at"] = scannedAt; + } + out.status = post("/api/v1/devices/scan", req, res, out.reason); + } + if (out.status == API_OK) readScan(res, out); return out; } diff --git a/cyberpunkunicorn-os/apps/frequency/backend.h b/cyberpunkunicorn-os/apps/frequency/backend.h index c6dbb89..cfa5d8b 100644 --- a/cyberpunkunicorn-os/apps/frequency/backend.h +++ b/cyberpunkunicorn-os/apps/frequency/backend.h @@ -6,6 +6,7 @@ #pragma once #include +#include namespace freq { @@ -34,9 +35,13 @@ struct ScanReply { int stamps = 0, required = 0; bool earned = false; String rewardCode; + bool viaCable = false; // went through the computer on the USB cable, not Wi-Fi }; // POST {base}/api/v1/devices/scan. `queued` marks a replay from the offline // queue; `scannedAt` (epoch s) is sent with it when the clock was set. +// With no Wi-Fi but a computer on the USB cable (cableAvailable()), a fresh scan +// goes through that computer instead. Call this from the UI task, never from a +// remote command handler: the CLI task delivers the computer's answer. ScanReply postScan(const String &token, const String &uid, bool queued = false, uint32_t scannedAt = 0); struct IssueReply { @@ -47,6 +52,24 @@ struct IssueReply { // POST {base}/api/v1/devices/cards IssueReply issueCard(const String &memberHandle); +// ---- scans through the cable (Phase 5; PROTOCOL.md "Scans through the cable") ---- +// The board signs the scan with its secret; the computer forwards the body and +// signature to the server unchanged, so the secret never leaves the board. +constexpr uint32_t RELAY_TIMEOUT_MS = 12000; +struct SignedScan { + String deviceId; + String body; // the exact JSON to POST; the signature covers these bytes + String signature; // "v1=" + uint32_t nonce = 0; +}; +SignedScan signScan(const String &token, const String &uid, uint32_t scannedAt); +void putRelay(JsonObject o, const SignedScan &s); // {nonce, device_id, body, signature} +// No Wi-Fi, a computer talking over USB, its clock set (system.time), reader ready. +bool cableAvailable(); +// frequency.relay_reply: the server's answer for the scan waiting on `nonce`. +// False if no scan is waiting on it (late, or already answered). +bool relayAnswer(uint32_t nonce, int httpCode, const String &body); + // Replays the offline queue. Returns scans the server answered (accepted or // rejected); stops at the first one that is still offline. int retryQueue(String &err); diff --git a/cyberpunkunicorn-os/apps/frequency/provision.cpp b/cyberpunkunicorn-os/apps/frequency/provision.cpp new file mode 100644 index 0000000..2515db7 --- /dev/null +++ b/cyberpunkunicorn-os/apps/frequency/provision.cpp @@ -0,0 +1,102 @@ +// Remote commands that set the board up as a Space's scanner over the cable +// (plan Phase 3), and the answer half of scans through the cable (Phase 5). +// Contract: apps/remote/PROTOCOL.md "Setup over the cable". These run on the CLI +// task: no drawing. +#include "../remote/remote.h" +#include "backend.h" +#include "store.h" + +namespace { + +bool isUuid(const String &s) { + if (s.length() != 36) return false; + for (int i = 0; i < 36; i++) { + bool dash = i == 8 || i == 13 || i == 18 || i == 23; + if (dash ? s[i] != '-' : !isxdigit((unsigned char)s[i])) return false; + } + return true; +} + +// frequency-web secrets are 32 random bytes in base64url (43 characters). +bool isSecret(const String &s) { + if (s.length() < 32 || s.length() > 128) return false; + for (char c : s) + if (!isalnum((unsigned char)c) && c != '-' && c != '_') return false; + return true; +} + +// What the board is set up as. The secret itself never comes back. +void putConfig(JsonObject o) { + freq::Settings &s = freq::settings(); + o["reader"] = s.readerMode; + o["base"] = s.baseUrl; + o["device_id"] = s.deviceId; + o["secret_set"] = s.deviceSecret.length() > 0; + o["ready"] = s.readerReady(); + o["queued"] = (int)freq::queuedScans().size(); + o["clock_set"] = remote::trustedEpoch() != 0; + o["cable"] = freq::cableAvailable(); +} + +void cmdConfig(remote::Request &r) { putConfig(r.data); } + +// frequency.provision {device_id, secret, base?, reader?: true} +void cmdProvision(remote::Request &r) { + String id = r.args["device_id"] | ""; + String secret = r.args["secret"] | ""; + String base = r.args["base"] | ""; + id.trim(); + id.toLowerCase(); + if (!isUuid(id)) return r.fail("bad_args", "device_id must be the scanner's uuid"); + if (!isSecret(secret)) return r.fail("bad_args", "secret must be the scanner's base64url secret"); + if (base.length() && !base.startsWith("https://") && !base.startsWith("http://")) + return r.fail("bad_args", "base must start with https://"); + + freq::Settings &s = freq::settings(); + bool changedDevice = s.deviceId.length() && s.deviceId != id; + s.deviceId = id; + s.deviceSecret = secret; + if (base.length()) { + while (base.endsWith("/")) base.remove(base.length() - 1); + s.baseUrl = base; + } + s.readerMode = r.args["reader"] | true; + s.save(); + // Scans queued under another scanner's key would only be refused. + if (changedDevice) freq::removeQueued(freq::queuedScans()); + putConfig(r.data); +} + +// frequency.unprovision {}: forget the scanner's key and the queue (the website +// revoked it). Venue tags still write: they need no key. +void cmdUnprovision(remote::Request &r) { + freq::Settings &s = freq::settings(); + s.deviceId = ""; + s.deviceSecret = ""; + s.readerMode = false; + s.save(); + freq::removeQueued(freq::queuedScans()); + putConfig(r.data); +} + +// frequency.relay_reply {nonce, status, body}: the server's answer to a +// frequency.scan_relay event. status is the HTTP status, or 0 when the computer +// couldn't reach the server either (the board then queues the scan). +void cmdRelayReply(remote::Request &r) { + String nonceHex = r.args["nonce"] | ""; + int status = r.args["status"] | -1; + if (nonceHex.length() != 8 || status < 0 || status > 599) + return r.fail("bad_args", "nonce (8 hex) and status (0-599) are required"); + uint32_t nonce = strtoul(nonceHex.c_str(), nullptr, 16); + String body; + serializeJson(r.args["body"], body); + if (!freq::relayAnswer(nonce, status, body)) return r.fail("not_waiting", "no scan is waiting on that nonce"); + r.data["nonce"] = nonceHex; +} + +} // namespace + +REMOTE_COMMAND("frequency.config", cmdConfig); +REMOTE_COMMAND("frequency.provision", cmdProvision); +REMOTE_COMMAND("frequency.unprovision", cmdUnprovision); +REMOTE_COMMAND("frequency.relay_reply", cmdRelayReply); diff --git a/cyberpunkunicorn-os/apps/remote/PROTOCOL.md b/cyberpunkunicorn-os/apps/remote/PROTOCOL.md index cd23938..25007d0 100644 --- a/cyberpunkunicorn-os/apps/remote/PROTOCOL.md +++ b/cyberpunkunicorn-os/apps/remote/PROTOCOL.md @@ -129,8 +129,8 @@ is merged. Each app then registers its handler in one line (§5). ### `system.status` * ```json -{"os":"CyberUnicorn 5000","version":"0.1.0","bruce_version":"1.16.1", - "heap_free":81234,"heap_min":60123,"uptime_s":372, +{"os":"CyberUnicorn 5000","version":"0.1.0","commit":"a1b2c3d","bruce_version":"1.16.1", + "proto":1,"heap_free":81234,"heap_min":60123,"uptime_s":372, "wifi":{"connected":false,"ssid":"","ip":"","ap_ip":"192.168.4.1"}, "ble":{"enabled":true,"connected":1,"at_boot":false}, "hat":{"live":["pn532"],"probed_s_ago":12}} @@ -142,8 +142,24 @@ is merged. Each app then registers its handler in one line (§5). `hat` CLI), any of `cc1101`, `nrf24`, `pn532`, `ir`, `ook433`. It's `[]` with `probed_s_ago: null` if nothing has probed since boot. Status never drives the shared pins itself. +- `commit` is the git commit the build came from (`build.sh`), with `-dirty` when it + was built from uncommitted changes. `proto` is the protocol version. - Extras beyond the brief: `heap_min`, `wifi.ap_ip`, `ble.connected`, `ble.at_boot`, `hat.probed_s_ago`. +### `system.caps` * + +`{}` → `{"proto":1,"version":"0.1.0","commit":"a1b2c3d","commands":["system.status", ...]}`. +Every command this build answers. Grey out the rest instead of waiting for +`unknown_command`. + +### `system.time` * + +`{"epoch":1792526400}` → `{"epoch":1792526400}`. Sets the board's clock from the +computer's (epoch seconds, 2026 or later). The board has no battery clock and Bruce +boots with a made-up date, so **send this right after the hello**: a scan through +the cable carries no usable time until it has been set. `{}` reports the time the +board trusts (`0` = never set this boot). + ### `apps.list` * ```json @@ -235,6 +251,87 @@ network or a phone hotspot. Serial equivalents: `wifi add "" [""]`, `wifi forget ""` (patch 0015), `netcheck`. +### Frequency tags (`apps/remote/app_handlers.cpp`) + +Each waits for a tap (`timeout_ms`, default 10000, max 30000) and emits +`frequency.waiting_for_tag` first. A card is +`{uid, type, ntag, locked, capacity, uri, kind}`, `kind` being `venue check-in`, +`event door`, `member card`, `other` or `empty`. + +| cmd | args | data | +|---|---|---| +| `frequency.read_tag` | `{timeout_ms?}` | the card | +| `frequency.write_tag` | `{url, label?, timeout_ms?}` | the card + `written` (host + path, no signature). Fails `bad_url`, `write_failed`. | +| `frequency.scan` | `{timeout_ms?}` | `{card, member:{display_name}, zapsAwarded, loyalty?:{stamps, required, earned, reward_code?}}`, or `{card, relay}` through the cable (below). Fails `not_member_card`, `reader_mode_off`, `backend_not_set_up`, `offline` (queued) or `rejected` (`message` = the server's reason code). | + +All three fail `no_reader` (PN532 not answering: DIP 3), `busy`, `timeout` or `card_error`. + +### Setup over the cable (`apps/frequency/provision.cpp`) + +Makes a board a Space's counter scanner without typing anything on it (plan +Phase 3). The website registers the scanner (the owner action in Check-in & +scanners), sends the id and secret straight to the board, then puts it on Wi-Fi +with `wifi.scan` + `wifi.join`. + +| cmd | args | data | +|---|---|---| +| `frequency.config` | `{}` | `{reader, base, device_id, secret_set, ready, queued, clock_set, cable}`. The secret is never sent back. `ready` = reader on with an id and secret. `cable` = a scan right now would go through this computer (no Wi-Fi, clock set, ready). | +| `frequency.provision` | `{device_id, secret, base?, reader?: true}` | same as `frequency.config`. `device_id` is the scanner's uuid, `secret` its 43-character base64url secret, `base` the site origin (default `https://frequencylocal.com`). Provisioning a different `device_id` drops the old one's offline queue. `bad_args` says which field is wrong. | +| `frequency.unprovision` | `{}` | same as `frequency.config`. Forgets the id and secret, turns reader mode off and drops the offline queue. Use it when the owner revokes the scanner with the board plugged in. | + +The board stores the secret in NVS (namespace `frequency`, key `devsecret`). +Without flash encryption anyone holding the board can read it; that is the +threat model frequency-web's LOYALTY §7 accepts (a stolen secret checks in only +that Space's cards, once per member per day, until revoked). + +**The serial log never shows an `rpc` line's arguments.** Bruce echoes every line +it reads as `COMMAND: `; patch 0014 prints just `COMMAND: rpc` for these, +because `frequency.provision` and `wifi.join` carry secrets. + +### Scans through the cable (`apps/frequency/backend.cpp`) + +A board with no Wi-Fi, plugged into a computer whose device page is open, checks +members in through that computer (plan Phase 5). **The secret never leaves the +board:** the board signs the scan, and the computer forwards the exact bytes. + +The board uses the cable when all of these hold: no Wi-Fi, the computer sent an +`rpc` line over USB in the last **30 s** (send `system.status` every ~10 s while +the page is open), `system.time` has been set this boot, and the scanner is +provisioned. Otherwise it queues the scan as before. + +1. A member taps a card on the Frequency home screen. The board emits + `@evt {"event":"frequency.scan_relay","data":{"nonce":"9f86d081","device_id":"","body":"","signature":"v1="}}`. + `body` is a JSON **string**, for example + `{"card_token":"Q2hh…","uid":"04A1B2C3D4E5F6","scanned_at":1792526400,"nonce":"9f86d081"}`. +2. The computer POSTs `body` byte for byte to `{base}/api/v1/devices/scan` with + `Content-Type: application/json`, `X-Frequency-Device-Id: ` and + `X-Frequency-Signature: `. No `X-Frequency-Device` header: the + computer doesn't have the secret. +3. It sends the answer back: `rpc {"id":7,"cmd":"frequency.relay_reply","args":{"nonce":"9f86d081","status":200,"body":{...the server's JSON...}}}`. + `status` is the HTTP status, or `0` if the computer couldn't reach the server + (the board then queues the scan). Keep the line under 512 bytes: the scan reply + is small. A reply after the board stopped waiting (12 s) fails `not_waiting`. +4. The board shows the result exactly as for a Wi-Fi scan. + +`rpc frequency.scan` over the cable with no Wi-Fi works the same way, but in the +reply: its data is `{card, relay:{nonce, device_id, body, signature}}` and the +computer posts it and keeps the answer (the board can't wait for a second command +while it is still answering the first). + +**Signature** (what the server checks): + +``` +key = sha256(secret) (the 32 raw bytes; the server stores this hash as hex) +message = "scan:v1:" + device_id + ":" + body (body = the exact bytes posted) +signature = "v1=" + hex(HMAC-SHA256(key, message)) +``` + +The server then rejects a `scanned_at` more than a few minutes from its own clock, +and a `nonce` it has already seen from that device within the window, so a +captured request can't be replayed. Keying on the stored hash means a database +leak could forge cable scans for that Space, which is the same blast radius as a +leaked device secret; revoke the scanner either way. + ### Planned commands (not in this build) Handlers are wired in by the coordinator when each app branch merges. Args and data @@ -247,9 +344,6 @@ as agreed for v1: | `sound.tone` | `{hz, ms}` | `{hz, ms}` (returns once the tone has started) | | `light.read` | `{}` | `{raw, percent}` (CYD LDR on GPIO 34) | | `sensors.read` | `{}` | `{sensors:[{name, kind, value, unit}]}`, e.g. `{"name":"AHT20","kind":"temperature","value":21.4,"unit":"C"}` | -| `frequency.read_tag` | `{}` | `{uid, url?, label?, type}` (waits up to ~5 s for a tag; `busy` if none) | -| `frequency.write_tag` | `{url, label?}` | `{uid, written:true}` | -| `frequency.scan` | `{}` | the frequency-web scan result: `{ok, reason?, member, zapsAwarded, loyalty}` | Exact field names for these get fixed by their app branches. Each branch must update this table when it registers its handler. @@ -261,6 +355,9 @@ this table when it registers its handler. | `lightshow.state` | same as `lightshow.state` | show starts, stops, or changes scene/level (buttons or remote) | | `lightshow.error` | `{message}` | the show refused to start | | `wifi.joining` | `{}` | `wifi.join` started (it can take up to 30 s) | +| `hello` | `{proto, os, fw, commit, board}`, e.g. `{"proto":1,"os":"CyberUnicorn 5000","fw":"0.1.0","commit":"a1b2c3d","board":"cyd-2432s028r"}` | once, at the end of boot (~18 s after the port opened, since opening it resets the board). Then send `system.time`. | +| `frequency.waiting_for_tag` | `{}` | a `frequency.*` command is waiting for a tap | +| `frequency.scan_relay` | `{nonce, device_id, body, signature}` | a card was tapped with no Wi-Fi and this computer on the cable: forward it (Scans through the cable) | Events go to USB always, and to BLE when a phone is connected. diff --git a/cyberpunkunicorn-os/apps/remote/app_handlers.cpp b/cyberpunkunicorn-os/apps/remote/app_handlers.cpp index 2b7ad43..00e9f50 100644 --- a/cyberpunkunicorn-os/apps/remote/app_handlers.cpp +++ b/cyberpunkunicorn-os/apps/remote/app_handlers.cpp @@ -144,12 +144,22 @@ void cmdScan(remote::Request &r) { putCard(r.data["card"].to(), card); freq::UrlCheck u = freq::checkUrl(card.uri); if (u.kind != freq::URL_MEMBER) return r.fail("not_member_card", "that tag isn't a Frequency member card"); + // No Wi-Fi, but the caller is the computer on the cable: hand it the signed + // scan to forward. This handler can't wait for frequency.relay_reply (it runs + // on the task that would deliver it), so the computer posts it and keeps the answer. + if (freq::cableAvailable()) { + freq::SignedScan sc = freq::signScan(u.token, card.uid, remote::trustedEpoch()); + freq::putRelay(r.data["relay"].to(), sc); + return; + } freq::ScanReply s = freq::postScan(u.token, card.uid); switch (s.status) { case freq::API_OK: break; case freq::API_DISABLED: return r.fail("reader_mode_off", "turn on reader mode and set the device id/secret"); case freq::API_NOT_SET_UP: return r.fail("backend_not_set_up", freq::reasonWords("not_enabled").hint); - case freq::API_OFFLINE: return r.fail("offline", "queued; will retry when online"); + case freq::API_OFFLINE: + if (!freq::queueScan({freq::nowEpoch(), card.uid, u.token})) return r.fail("offline", "queue full"); + return r.fail("offline", "queued; will retry when online"); default: return r.fail("rejected", s.reason); } r.data["member"]["display_name"] = s.name; diff --git a/cyberpunkunicorn-os/apps/remote/remote.cpp b/cyberpunkunicorn-os/apps/remote/remote.cpp index b6041f8..9a370fb 100644 --- a/cyberpunkunicorn-os/apps/remote/remote.cpp +++ b/cyberpunkunicorn-os/apps/remote/remote.cpp @@ -11,8 +11,14 @@ #include #include #include +#include #include +// The git commit this build came from (build.sh), so the website can tell builds apart. +#ifndef FW_COMMIT +#define FW_COMMIT "unknown" +#endif + namespace remote { namespace { @@ -96,6 +102,12 @@ int logHead = 0, logCount = 0; uint32_t total = 0; portMUX_TYPE logMux = portMUX_INITIALIZER_UNLOCKED; +// When a computer last sent an rpc line over USB (0 = never this boot), and the +// clock it set with system.time (0 = never). +volatile uint32_t usbRpcMs = 0; +volatile uint32_t hostEpochAt = 0; // epoch seconds the host gave us +volatile uint32_t hostEpochMs = 0; // millis() when it did + void logCommand(const String &cmd, bool ok, SerialDevice *dev) { LogEntry e; e.ms = millis(); @@ -159,7 +171,9 @@ class CaptureDevice : public SerialDevice { void cmdSystemStatus(Request &r) { r.data["os"] = OS_NAME; r.data["version"] = OS_VERSION; + r.data["commit"] = FW_COMMIT; r.data["bruce_version"] = BRUCE_VERSION; + r.data["proto"] = PROTO_VERSION; r.data["heap_free"] = ESP.getFreeHeap(); r.data["heap_min"] = ESP.getMinFreeHeap(); r.data["uptime_s"] = millis() / 1000; @@ -261,7 +275,37 @@ void cmdHatProbe(Request &r) { r.data["advice"] = hp.advice(); } +// Every command this build answers, so the website can grey out the rest. +void cmdSystemCaps(Request &r) { + r.data["proto"] = PROTO_VERSION; + r.data["version"] = OS_VERSION; + r.data["commit"] = FW_COMMIT; + JsonArray cmds = r.data["commands"].to(); + for (const Entry &e : registry()) cmds.add(e.name); +} + +// system.time {epoch}: the computer's clock, in epoch seconds. The board has no +// battery clock and Bruce boots with a made-up date, so a signed scan carries a +// time only after this (or after it has been set some other way). +void cmdSystemTime(Request &r) { + if (!r.args["epoch"].is()) { + r.data["epoch"] = trustedEpoch(); + return; // no epoch given: just report what the board believes + } + uint32_t epoch = r.args["epoch"].as(); + if (epoch < 1767225600UL) return r.fail("bad_args", "epoch is seconds since 1970, 2026 or later"); + rtc.setTime(epoch); + struct timeval tv = {.tv_sec = (time_t)epoch}; + settimeofday(&tv, nullptr); + clock_set = true; + hostEpochAt = epoch; + hostEpochMs = millis(); + r.data["epoch"] = epoch; +} + REMOTE_COMMAND("system.status", cmdSystemStatus); +REMOTE_COMMAND("system.caps", cmdSystemCaps); +REMOTE_COMMAND("system.time", cmdSystemTime); REMOTE_COMMAND("apps.list", cmdAppsList); REMOTE_COMMAND("screen.nav", cmdScreenNav); REMOTE_COMMAND("cli.run", cmdCliRun); @@ -328,6 +372,7 @@ bool handleLine(const String &raw) { out["ok"] = true; r.data = out["data"].to(); + if (dev == &USBserial) usbRpcMs = millis() | 1; // | 1: never 0, which means "never" Handler fn = cmd.isEmpty() ? nullptr : find(cmd); if (fn) fn(r); else if (planned(cmd)) r.fail("not_available", cmd + " isn't in this build yet"); @@ -394,6 +439,25 @@ void setBleAtBoot(bool on) { void boot() { if (bleAtBoot()) bleSetEnabled(true); + // Tells a computer that just opened the port (which resets this board) that + // the board is up and which protocol it speaks. + JsonDocument hello; + hello["proto"] = PROTO_VERSION; + hello["os"] = OS_NAME; + hello["fw"] = OS_VERSION; + hello["commit"] = FW_COMMIT; + hello["board"] = "cyd-2432s028r"; + emit("hello", hello.as()); +} + +bool usbHostActive() { + uint32_t at = usbRpcMs; + return at && millis() - at < USB_HOST_TIMEOUT_MS; +} + +uint32_t trustedEpoch() { + if (!hostEpochAt) return 0; + return hostEpochAt + (millis() - hostEpochMs) / 1000; } int recent(LogEntry *out, int maxCount) { diff --git a/cyberpunkunicorn-os/apps/remote/remote.h b/cyberpunkunicorn-os/apps/remote/remote.h index de7f682..4ef3973 100644 --- a/cyberpunkunicorn-os/apps/remote/remote.h +++ b/cyberpunkunicorn-os/apps/remote/remote.h @@ -26,6 +26,9 @@ namespace remote { +// Protocol version, sent in the boot hello, system.status and system.caps. +constexpr int PROTO_VERSION = 1; + struct Request { int id = 0; String cmd; @@ -63,7 +66,18 @@ bool bleSetEnabled(bool on); // false if refused (e.g. not enough free heap) int bleConnected(); // connected phones bool bleAtBoot(); void setBleAtBoot(bool on); -void boot(); // called once at the end of setup() +void boot(); // called once at the end of setup(); emits @evt hello + +// A computer is talking to the board over USB: it sent an rpc line in the last +// USB_HOST_TIMEOUT_MS. The website's device page sends system.status every ~10 s +// while it is open, so this goes false soon after the page closes. +constexpr uint32_t USB_HOST_TIMEOUT_MS = 30000; +bool usbHostActive(); + +// Epoch seconds from the clock a computer set with system.time, or 0 if none +// has this boot. Bruce's own clock starts at a made-up date, so it can't be used +// to sign anything. +uint32_t trustedEpoch(); struct LogEntry { uint32_t ms; diff --git a/cyberpunkunicorn-os/build.sh b/cyberpunkunicorn-os/build.sh index 970e5fc..dfadc1f 100755 --- a/cyberpunkunicorn-os/build.sh +++ b/cyberpunkunicorn-os/build.sh @@ -71,10 +71,17 @@ cp -R apps .bruce/src/cyber_apps # name/version go in OS_NAME/OS_VERSION (patch 0005). sed -i '' "s/-DBRUCE_VERSION='\"dev\"'/-DBRUCE_VERSION='\"$BRUCE_TAG\"'/" .bruce/platformio.ini +# The commit this build came from, reported by system.status and the boot hello. +# "-dirty" when apps/, patches/ or this script have uncommitted changes. +FW_COMMIT=$(git rev-parse --short=7 HEAD 2>/dev/null || echo unknown) +if [[ "$FW_COMMIT" != unknown ]] && ! git diff --quiet HEAD -- apps patches build.sh boot.conf 2>/dev/null; then + FW_COMMIT+="-dirty" +fi + # Bruce's patch.py shells out to a bare `pio`; without this it half-patches # libnet80211.a (renames it to .old) and the link fails. export PATH="$PWD/.venv/bin:$PATH" -export PLATFORMIO_BUILD_FLAGS="-DOS_NAME='\"$OS_NAME\"' -DOS_VERSION='\"$OS_VERSION\"' -DBOOT_TITLE='\"$BOOT_TITLE\"' -DBOOT_TAGLINE='\"$BOOT_TAGLINE\"'$extra_flags" +export PLATFORMIO_BUILD_FLAGS="-DOS_NAME='\"$OS_NAME\"' -DOS_VERSION='\"$OS_VERSION\"' -DFW_COMMIT='\"$FW_COMMIT\"' -DBOOT_TITLE='\"$BOOT_TITLE\"' -DBOOT_TAGLINE='\"$BOOT_TAGLINE\"'$extra_flags" # One build at a time per machine: Bruce's patch.py edits libnet80211.a in the # shared ~/.platformio cache, so parallel builds (e.g. several worktrees) corrupt it. LOCK="$HOME/.platformio/.cyberunicorn-build.lock" # next to the shared cache it protects diff --git a/cyberpunkunicorn-os/patches/0014-remote-hooks.patch b/cyberpunkunicorn-os/patches/0014-remote-hooks.patch index e0901fb..c8c9436 100644 --- a/cyberpunkunicorn-os/patches/0014-remote-hooks.patch +++ b/cyberpunkunicorn-os/patches/0014-remote-hooks.patch @@ -39,10 +39,10 @@ index 89104db..ab9d6d1 100644 private: SimpleCLI _cli; diff --git a/src/core/serialcmds.cpp b/src/core/serialcmds.cpp -index edcbcd8..87e06d4 100644 +index edcbcd8..c6b495a 100644 --- a/src/core/serialcmds.cpp +++ b/src/core/serialcmds.cpp -@@ -44,19 +44,34 @@ void handleSerialCommands(SerialCli &serialCli) { +@@ -44,19 +44,37 @@ void handleSerialCommands(SerialCli &serialCli) { Serial.printf("[CLI] Result: %s\n", result ? "TRUE" : "FALSE"); } } @@ -58,8 +58,12 @@ index edcbcd8..87e06d4 100644 + serialDevice = src; - String cmd_str = serialDevice->readStringUntil('\n'); +- Serial.println("COMMAND: " + cmd_str); + String cmd_str = src->readStringUntil('\n'); - Serial.println("COMMAND: " + cmd_str); ++ // CyberUnicorn: an `rpc` line can carry a Wi-Fi password or the scanner secret ++ // (frequency.provision), so the echo shows only that it was one. ++ if (cmd_str.startsWith("rpc ")) Serial.println("COMMAND: rpc"); ++ else Serial.println("COMMAND: " + cmd_str); serialCli.parse(cmd_str); - serialDevice->print("# "); // prompt + String cmd_trimmed = cmd_str; @@ -109,7 +113,7 @@ index 062f413..5b287dc 100644 bool appStoreInstalled(); diff --git a/src/main.cpp b/src/main.cpp -index 873bd4e..558bfc8 100644 +index 99c0f2a..a358e77 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -174,6 +174,7 @@ volatile int tftHeight = VECTOR_DISPLAY_DEFAULT_WIDTH; From 656c1e552728ee201d913a35db59821fda6df687 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:14:42 +0000 Subject: [PATCH 2/3] cyberpunkunicorn-os: CI build, release manifest, portable build.sh Plan Phase 6, firmware side: build.sh writes build/manifest.json (version, commit, app.bin size and sha256, offset 0x10000) for the website's updater. A path-filtered workflow builds every change on Linux, gates app.bin on the app partition size, and a cu5k-v tag publishes a release with app.bin, full.bin and the manifest. build.sh's in-place sed now works on GNU sed as well as macOS. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AycPrTRVXqSDZhb2NNfyG6 --- .github/workflows/cyberpunkunicorn-os.yml | 90 +++++++++++++++++++++++ cyberpunkunicorn-os/CLAUDE.md | 6 +- cyberpunkunicorn-os/build.sh | 26 ++++++- 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/cyberpunkunicorn-os.yml diff --git a/.github/workflows/cyberpunkunicorn-os.yml b/.github/workflows/cyberpunkunicorn-os.yml new file mode 100644 index 0000000..201ae49 --- /dev/null +++ b/.github/workflows/cyberpunkunicorn-os.yml @@ -0,0 +1,90 @@ +name: "cyberpunkunicorn-os: build and release" + +# Path-filtered: touching another project must not run this. +# A tag cu5k-v (matching OS_VERSION in build.sh) publishes a GitHub +# release with app.bin + manifest.json, which the website's updater reads. +on: + push: + paths: ["cyberpunkunicorn-os/**", ".github/workflows/cyberpunkunicorn-os.yml"] + tags: ["cu5k-v*"] + pull_request: + paths: ["cyberpunkunicorn-os/**", ".github/workflows/cyberpunkunicorn-os.yml"] + workflow_dispatch: + +concurrency: + group: cu5k-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 60 + defaults: + run: + working-directory: cyberpunkunicorn-os + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # PlatformIO's toolchains and Bruce's library deps: ~1 GB, slow to fetch. + - uses: actions/cache@v4 + with: + path: | + ~/.platformio + cyberpunkunicorn-os/.venv + key: cu5k-pio-${{ hashFiles('cyberpunkunicorn-os/build.sh') }} + + - name: Build + run: ./build.sh + + # The app partition is 0x10000-0x3cffff. A bigger app.bin won't boot. + - name: Check flash budget + run: | + size=$(stat -c %s build/app.bin) + limit=$((0x3C0000)) + echo "app.bin: $size bytes, $((limit - size)) free of $limit" + echo "### app.bin: $size bytes, $(( (limit - size) / 1024 )) KB free" >> "$GITHUB_STEP_SUMMARY" + test "$size" -le "$limit" + + - uses: actions/upload-artifact@v4 + with: + name: cu5k-${{ github.sha }} + path: | + cyberpunkunicorn-os/build/app.bin + cyberpunkunicorn-os/build/full.bin + cyberpunkunicorn-os/build/manifest.json + if-no-files-found: error + + release: + if: startsWith(github.ref, 'refs/tags/cu5k-v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: cu5k-${{ github.sha }} + path: dist + + - name: Tag must match the build's version + run: | + version=$(python3 -c 'import json; print(json.load(open("dist/manifest.json"))["version"])') + test "${GITHUB_REF_NAME}" = "cu5k-v${version}" || { + echo "Tag ${GITHUB_REF_NAME} but build.sh says OS_VERSION=${version}"; exit 1; } + + # Bruce is AGPL-3.0: the release links its own source (this tag) and keeps + # the "Based on Bruce" credit, which the firmware shows in Device Info. + - uses: softprops/action-gh-release@v2 + with: + files: | + dist/app.bin + dist/full.bin + dist/manifest.json + body: | + CyberUnicorn 5000 ${{ github.ref_name }}. Based on Bruce (AGPL-3.0); source is this tag. + Update from the website, or `./flash.sh` with `app.bin` (keeps settings). + `full.bin` is the whole flash image for a first install only. diff --git a/cyberpunkunicorn-os/CLAUDE.md b/cyberpunkunicorn-os/CLAUDE.md index 56fdfc4..b4cca69 100644 --- a/cyberpunkunicorn-os/CLAUDE.md +++ b/cyberpunkunicorn-os/CLAUDE.md @@ -11,7 +11,11 @@ build (copied, not shared — that project stays as the known-good fallback). - PlatformIO **6.2.0**, esptool **5.4.0** — in `.venv/`, created by `build.sh` - OS identity: `OS_NAME` / `OS_VERSION` at the top of `build.sh` ("CyberUnicorn 5000", 0.1.0). The display font has no ™ glyph: screens draw a superscript "TM"; serial prints a real ™. -- Build: `./build.sh` → `build/app.bin`, `build/full.bin` +- Build: `./build.sh` → `build/app.bin`, `build/full.bin`, `build/manifest.json` (version, commit, + app.bin size + sha256, offset 0x10000: what the website's updater reads) +- CI: `.github/workflows/cyberpunkunicorn-os.yml` builds every push/PR touching this folder + and fails if app.bin outgrows the 0x3C0000 app partition. Tag `cu5k-v` + to publish a GitHub release with app.bin, full.bin and manifest.json - Flash: `./flash.sh` (app only, keeps on-device settings) or `./flash.sh --full` - Remote: `./remote.sh` mirrors the screen to http://localhost:8765 over serial - Test: none automated. A clean build is the minimum bar; confirm on the device diff --git a/cyberpunkunicorn-os/build.sh b/cyberpunkunicorn-os/build.sh index dfadc1f..043305e 100755 --- a/cyberpunkunicorn-os/build.sh +++ b/cyberpunkunicorn-os/build.sh @@ -69,7 +69,9 @@ cp -R apps .bruce/src/cyber_apps # Report the real Bruce version instead of upstream's local "dev"; our own # name/version go in OS_NAME/OS_VERSION (patch 0005). -sed -i '' "s/-DBRUCE_VERSION='\"dev\"'/-DBRUCE_VERSION='\"$BRUCE_TAG\"'/" .bruce/platformio.ini +# (-i.bak + rm: the one in-place form both BSD sed on macOS and GNU sed on Linux CI accept.) +sed -i.bak "s/-DBRUCE_VERSION='\"dev\"'/-DBRUCE_VERSION='\"$BRUCE_TAG\"'/" .bruce/platformio.ini +rm -f .bruce/platformio.ini.bak # The commit this build came from, reported by system.status and the boot hello. # "-dirty" when apps/, patches/ or this script have uncommitted changes. @@ -97,4 +99,24 @@ trap 'rm -rf "$LOCK"' EXIT mkdir -p build cp ".bruce/.pio/build/$PIO_ENV/firmware.bin" build/app.bin cp ".bruce/Bruce-$PIO_ENV.bin" build/full.bin -ls -l build/*.bin + +# What the website's updater reads (plan Phase 6): which build this is and how to +# check it arrived whole. The updater flashes app.bin at 0x10000 only, like +# ./flash.sh without --full, so NVS (scanner key, Wi-Fi) survives. +python3 - "$OS_VERSION" "$FW_COMMIT" "$BRUCE_TAG" <<'PY' +import hashlib, json, os, sys +version, commit, bruce = sys.argv[1:4] +app = open("build/app.bin", "rb").read() +json.dump({ + "board": "cyd-2432s028r", + "os": "CyberUnicorn 5000", + "version": version, + "commit": commit, + "bruce_version": bruce, + "proto": 1, + "app": {"file": "app.bin", "offset": 0x10000, "size": len(app), + "sha256": hashlib.sha256(app).hexdigest()}, + "app_partition_size": 0x3C0000, +}, open("build/manifest.json", "w"), indent=2) +PY +ls -l build/*.bin build/manifest.json From 6609abb7f7a4af89997f80c399c9b10a4e181801 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:01:47 +0000 Subject: [PATCH 3/3] cyberpunkunicorn-os: build.sh lock works on a machine with no ~/.platformio The lock's mkdir failed without its parent, so a fresh runner waited forever. CI hit its 60-minute timeout on that. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AycPrTRVXqSDZhb2NNfyG6 --- cyberpunkunicorn-os/build.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/cyberpunkunicorn-os/build.sh b/cyberpunkunicorn-os/build.sh index 3f35989..6cbe8a1 100755 --- a/cyberpunkunicorn-os/build.sh +++ b/cyberpunkunicorn-os/build.sh @@ -104,6 +104,7 @@ export PLATFORMIO_BUILD_FLAGS="-DOS_NAME='\"$OS_NAME\"' -DOS_VERSION='\"$OS_VERS # One build at a time per machine: Bruce's patch.py edits libnet80211.a in the # shared ~/.platformio cache, so parallel builds (e.g. several worktrees) corrupt it. LOCK="$HOME/.platformio/.cyberunicorn-build.lock" # next to the shared cache it protects +mkdir -p "$(dirname "$LOCK")" # a fresh machine (or CI runner) has no ~/.platformio yet until mkdir "$LOCK" 2>/dev/null; do echo "Another CyberUnicorn build holds $LOCK (pid $(cat "$LOCK/pid" 2>/dev/null)); waiting..." >&2 if [[ -f "$LOCK/pid" ]] && ! kill -0 "$(cat "$LOCK/pid")" 2>/dev/null; then rm -rf "$LOCK"; continue; fi