From 65542affec1bc5bf2e8e0d629e85a8f45a021d7a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:56:43 +0000 Subject: [PATCH] cyberpunkunicorn-os: internet relay, device side (Phase 4b) The board joins its private Supabase Realtime channel device: with a 15-minute token from frequency-web's realtime-token route (scanner key), over a small TLS WebSocket + Phoenix client of our own, and runs broadcast rpc lines through the same code as USB, limited to the owner's allowed_cmds (never cli.run or remote.relay). Replies go back as "reply", events as "evt", presence tracks {online, version}. remote::runLine() now holds one lock around every handler, so lines from USB, BLE and the relay never run two at once. Off by default (NVS remote/relay): Remote app "Internet remote", `remote relay on|off`, rpc remote.relay. Never beside Bluetooth; a check-in POST asks it to step aside when RAM is short. system.status reports relay state. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AycPrTRVXqSDZhb2NNfyG6 --- cyberpunkunicorn-os/CLAUDE.md | 6 + .../apps/frequency/backend.cpp | 2 + cyberpunkunicorn-os/apps/remote/PROTOCOL.md | 103 ++-- .../apps/remote/RemoteMenu.cpp | 15 +- cyberpunkunicorn-os/apps/remote/relay.cpp | 467 ++++++++++++++++++ cyberpunkunicorn-os/apps/remote/relay.h | 40 ++ cyberpunkunicorn-os/apps/remote/remote.cpp | 60 ++- cyberpunkunicorn-os/apps/remote/remote.h | 9 + 8 files changed, 629 insertions(+), 73 deletions(-) create mode 100644 cyberpunkunicorn-os/apps/remote/relay.cpp create mode 100644 cyberpunkunicorn-os/apps/remote/relay.h diff --git a/cyberpunkunicorn-os/CLAUDE.md b/cyberpunkunicorn-os/CLAUDE.md index 08edcbc..def97b6 100644 --- a/cyberpunkunicorn-os/CLAUDE.md +++ b/cyberpunkunicorn-os/CLAUDE.md @@ -115,6 +115,12 @@ source published if builds are distributed. 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). + Internet relay (`relay.cpp`, PROTOCOL.md §6): task `cu_relay` mints a Realtime token + from frequency-web with the scanner key, joins `device:` over our own TLS WebSocket + + Phoenix client, and runs broadcast `rpc` lines through `remote::runLine()` (shared + with USB; one handler at a time) against the token's `allowed_cmds`. NVS `remote/relay` + (off by default), `remote relay on|off`, `rpc remote.relay`. Never beside Bluetooth; + only the relay task touches the socket. 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. - `hardware/` — **Library** + hardware registry (`hardware.h`). Apps with a hardware diff --git a/cyberpunkunicorn-os/apps/frequency/backend.cpp b/cyberpunkunicorn-os/apps/frequency/backend.cpp index cd91c5d..77af1fa 100644 --- a/cyberpunkunicorn-os/apps/frequency/backend.cpp +++ b/cyberpunkunicorn-os/apps/frequency/backend.cpp @@ -1,5 +1,6 @@ #include "backend.h" +#include "../remote/relay.h" #include "../remote/remote.h" #include "store.h" #include @@ -64,6 +65,7 @@ ApiStatus post(const String &path, const JsonDocument &req, Response &res, Strin while (base.endsWith("/")) base.remove(base.length() - 1); String url = base + path; + relay::yieldTls(); // two TLS sessions don't fit in RAM beside the UI WiFiClientSecure tls; WiFiClient plain; // http:// is allowed for a dev server on the LAN only tls.setInsecure(); // TODO: pin the CA once a production device API exists diff --git a/cyberpunkunicorn-os/apps/remote/PROTOCOL.md b/cyberpunkunicorn-os/apps/remote/PROTOCOL.md index 68d758c..3bf4bd2 100644 --- a/cyberpunkunicorn-os/apps/remote/PROTOCOL.md +++ b/cyberpunkunicorn-os/apps/remote/PROTOCOL.md @@ -31,6 +31,7 @@ The phone side lives in `frequency-web`. Change both together. | `unknown_command` | no such `cmd` | | `not_available` | a v1 command whose app isn't in this build yet (see §3), or an app hidden from the menu | | `bad_args` | an arg is missing or out of range; `message` says which | +| `not_allowed` | the command isn't allowed over this link (the internet relay, §6) | | `busy` | the device can't do that right now (e.g. the hat pins are in use); `message` says why | ### How `rpc` is wired (implementation note) @@ -441,72 +442,58 @@ screen from a handler: leave a request for the app's own loop (`apps/lightshow/LightShowMenu.cpp` shows the pattern). To send an event, call `remote::emit("name", obj)`. -## 6. Internet relay (website side built, device side not) +## 6. Internet relay (`apps/remote/relay.cpp`) -frequency-web now has `POST /api/v1/devices/realtime-token` (LIVE-1155), behind -`DEVICE_RELAY_ENABLED`. Its allow-list includes `led.mode`, which this firmware -answers `not_available`. The device-side Realtime client below is not built yet. +Both halves are built. frequency-web: `POST /api/v1/devices/realtime-token` +(LIVE-1155, ADR-1870, LOYALTY §8) behind `DEVICE_RELAY_ENABLED`. Device: the +`cu_relay` task below. Not yet run against the live site. Goal: control the device from any network. Neither side needs an open port. Both are clients of **Supabase Realtime** (frequency-web's backend), meeting on a private channel per device. ``` -phone app ──(user session JWT)──► Realtime channel "device:" ◄──(device token)── CyberUnicorn - broadcast "rpc" / "@rpc" / "@evt" +website / phone ──(member session)──► Realtime channel "device:" ◄──(device token)── CyberUnicorn + broadcast "rpc" / "reply" / "evt" ``` ### Device side -1. **Identity.** The device already has `X-Frequency-Device: .` from - the frequency-web device API (session `fw-devices`, branch `live/nfc-device-checkin`: - `POST /api/v1/devices/scan`, `/devices/cards`). The relay reuses it. Nothing new - is stored on the device. -2. **Token.** With Wi-Fi up (station mode) and *Internet remote* switched on (new NVS - key `remote/relay`, default off), the device calls - `POST /api/v1/devices/realtime-token` with that header. The reply is - `{ok, token, expires_at, channel:"device:", realtime_url}`: a Supabase-signed - JWT that lives ~15 min, with `role: authenticated`, `sub: device:` and a - claim limiting it to that one channel. The device re-mints it ~2 min before - expiry, or on a 401. Same flat `{ok, reason}` error replies and `Retry-After` as the - existing device API. -3. **Connection.** A WebSocket (`wss://.supabase.co/realtime/v1/websocket?apikey=&vsn=1.0.0`) - using the ESP32 core's `WiFiClientSecure` plus a small Phoenix-protocol client: - `phx_join` on `realtime:device:` with `config.private=true` and - `access_token=`, a `heartbeat` every 25 s, and `access_token` pushes on - refresh. A minimal client is ~10 KB of flash. TLS costs ~40 KB of heap while - connected, so **BLE and the relay shouldn't run at the same time** on this board. - The Remote app enforces one or the other. -4. **Payloads: the same protocol, untouched.** The phone broadcasts event `rpc` with - payload `{line:"rpc {...}"}`. The device passes `line` to the same - `remote::handleLine()` with a third "relay" transport (a `SerialDevice` that buffers - the reply line) and broadcasts event `reply` with `{line:"@rpc {...}"}`. `@evt` - lines go out as event `evt`. The phone reuses its line parser unchanged. -5. **Safety.** The relay accepts only `rpc` lines (no raw CLI), and `cli.run` is - **disabled over the relay**: it would be remote shell access. Only an explicit - allow-list goes over the internet: `system.status`, `apps.list`, `lightshow.*`, - `led.*`, `sound.tone`, `light.read`, `sensors.read`, `frequency.read_tag`. A device - setting can narrow it further. The screen shows a "remote: internet" badge while - joined. -6. **Presence.** The device `track()`s `{online:true, version}` on the channel, so the - phone can show online/offline without polling. - -### What frequency-web needs to add - -1. **`POST /api/v1/devices/realtime-token`**: authenticates `X-Frequency-Device` like - `/devices/scan`, and returns a short-lived JWT signed with the project's JWT - secret (server-side only) for `sub: device:`, carrying a custom claim - `device_id`. Rate-limited like the other device routes. -2. **Realtime authorization (RLS on `realtime.messages`)** for private channels: - - a device JWT may `select`/`insert` only on topic `device:`; - - a user JWT may do the same only for devices they own or manage: a join from - `realtime.topic()` → `device_id` → the existing devices table → the venue/owner - the user belongs to. -3. **Device ownership / pairing**: a way to link a device to a user or venue if the - devices table doesn't already have one (it's created by the device API branch). - The phone app needs `GET /api/v1/me/devices` to list what it can control. -4. **A device setting row** (`relay_enabled`, `relay_allowed_cmds`) so owners can - switch the relay off server-side. The token endpoint refuses when disabled. -5. **Audit log** (optional): a table of relayed commands (device, user, cmd, ok, at), - written by the phone app or an Edge Function. Payloads stay on Realtime, not in the DB. -6. Feature flag `DEVICE_RELAY_ENABLED`, off by default, like `SCANNER_DEVICES_ENABLED`. +1. **Switch.** *Internet remote* in the Remote app, `remote relay on|off` over serial, + or `rpc remote.relay {"on":true}` over USB (NVS `remote/relay`, default off). + `system.status` gains `relay:{enabled, state, error?}`, `state` being `off`, + `waiting` (no Wi-Fi, not provisioned, Bluetooth on, or low RAM), `connecting`, + `online` or `refused` (the site said no: retried every 5 min, or at once on + off/on). +2. **Identity.** The scanner key from `frequency.provision` (`X-Frequency-Device`). + Nothing new is stored. +3. **Token.** `POST /api/v1/devices/realtime-token`, no body. Reply + `{ok, token, expires_at, channel:"device:", realtime_url, apikey, allowed_cmds}`. + The JWT lives 15 min (`role: anon`, `sub` = the bare uuid, `frequency_kind: device`). + The device re-mints ~2 min before expiry and pushes `access_token` on the channel. + 404 or `relay_disabled` puts the relay in `refused`. +4. **Connection.** Our own small WebSocket client over `WiFiClientSecure` to + `realtime_url?apikey=&vsn=1.0.0`, then `phx_join` on + `realtime:device:` with `config.private=true`, `config.presence.key=` + and `access_token`, a `heartbeat` every 25 s, and a presence `track` of + `{online:true, version}`. TLS costs ~40 KB of heap, so the relay **never runs + beside Bluetooth** and waits below 60 KB free. A Frequency check-in POST asks + it to close for 20 s when RAM is short (`relay::yieldTls()`). Only the relay task + touches the socket; events from other tasks are queued (up to 8). +5. **Payloads: the same protocol.** The website broadcasts event `rpc` with + `{line:"rpc {...}"}`. The device runs it through the same `remote::runLine()` as + USB (logged as transport `net`) and broadcasts `reply` with `{line:"@rpc {...}"}`. + `@evt` lines go out as event `evt` with `{line:"@evt {...}"}`. +6. **Safety.** Only `rpc` lines, never raw CLI. A command runs only if it is in the + token reply's `allowed_cmds` (the owner's list, from `RELAY_COMMANDS` in + frequency-web's `lib/devices/relay.ts`); anything else answers `not_allowed`. + `cli.run` and `remote.relay` are refused whatever the list says. + +### Website side + +Built in frequency-web (LOYALTY §8): the token route, the `realtime.messages` +policies, `GET/PATCH /api/v1/me/devices` (the owner turns `relayEnabled` on and +narrows `allowedCmds`), and the `DEVICE_RELAY_ENABLED` switch. Still to build: a +Realtime transport in `lib/device/` so the device page works by cable or over the +internet, and an owner switch on the web (today only the PATCH route has it). Not +built, optional: the audit table of relayed commands. diff --git a/cyberpunkunicorn-os/apps/remote/RemoteMenu.cpp b/cyberpunkunicorn-os/apps/remote/RemoteMenu.cpp index db4edef..297cd94 100644 --- a/cyberpunkunicorn-os/apps/remote/RemoteMenu.cpp +++ b/cyberpunkunicorn-os/apps/remote/RemoteMenu.cpp @@ -7,6 +7,7 @@ #include "core/scrollableTextArea.h" #include "core/utils.h" #include "remote.h" +#include "relay.h" #include namespace { @@ -38,6 +39,7 @@ void RemoteMenu::optionsMenu() { {("Bluetooth now: " + onOff(remote::bleEnabled())).c_str(), []() { setBle(!remote::bleEnabled()); }}, {("Bluetooth at boot: " + onOff(remote::bleAtBoot())).c_str(), []() { remote::setBleAtBoot(!remote::bleAtBoot()); }}, + {("Internet remote: " + onOff(relay::enabled())).c_str(), []() { relay::setEnabled(!relay::enabled()); }}, {"How to connect", [this]() { showHelp(); }}, }; addOptionToMainMenu(); @@ -79,6 +81,9 @@ void RemoteMenu::showStatus() { tft.drawString("USB serial 115200, always on", x, y, 1); tft.setTextColor(cyberHue(280), bruceConfig.bgColor); tft.drawString("BLE " + ble + (remote::bleAtBoot() ? " [boot]" : ""), x, y + 12, 1); + tft.setTextColor(cyberHue(120), bruceConfig.bgColor); + tft.fillRect(x, y + 36, w, 10, bruceConfig.bgColor); + tft.drawString("NET internet remote " + String(relay::stateText(relay::state())), x, y + 36, 1); tft.setTextColor(cyberHue(40), bruceConfig.bgColor); tft.drawString( "RAM " + String(ESP.getFreeHeap() / 1024) + " KB free, " + String(count) + " cmds", x, y + 24, 1 @@ -87,7 +92,7 @@ void RemoteMenu::showStatus() { if (count != lastCount || ble != lastBle) { lastCount = count; lastBle = ble; - int ly = y + 46; + int ly = y + 58; tft.fillRect(x, ly, w, tftHeight - 22 - ly, bruceConfig.bgColor); cyberRainbowText("RECENT", x, ly, cyberPhase(), bruceConfig.bgColor); remote::LogEntry e[8]; @@ -141,11 +146,17 @@ static uint32_t remoteCallback(cmd *c) { if (!remote::bleSetEnabled(value == "on")) serialDevice->println("Not enough free RAM for Bluetooth"); } else if (what == "boot" && (value == "on" || value == "off")) { remote::setBleAtBoot(value == "on"); + } else if (what == "relay" && (value == "on" || value == "off")) { + relay::setEnabled(value == "on"); } else if (what.length()) { - serialDevice->println("usage: remote [ble on|off] [boot on|off]"); + serialDevice->println("usage: remote [ble on|off] [boot on|off] [relay on|off]"); return false; } serialDevice->println("Bluetooth: " + bleLine() + ", at boot " + onOff(remote::bleAtBoot())); + serialDevice->println( + "Internet remote: " + String(relay::stateText(relay::state())) + + (relay::lastError().length() ? " (" + relay::lastError() + ")" : String("")) + ); serialDevice->println("Free heap: " + String(ESP.getFreeHeap()) + " bytes"); serialDevice->println("Commands: " + String(remote::commandCount())); remote::LogEntry e[8]; diff --git a/cyberpunkunicorn-os/apps/remote/relay.cpp b/cyberpunkunicorn-os/apps/remote/relay.cpp new file mode 100644 index 0000000..0c3b1bf --- /dev/null +++ b/cyberpunkunicorn-os/apps/remote/relay.cpp @@ -0,0 +1,467 @@ +#include "relay.h" + +#include "../frequency/store.h" +#include "remote.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace relay { + +namespace { + +constexpr uint32_t HEARTBEAT_MS = 25000; +constexpr uint32_t MIN_HEAP = 60000; // TLS takes ~40 KB while open; leave the UI room +constexpr uint32_t REMINT_EARLY_S = 120; // re-mint this long before the token expires +constexpr uint32_t YIELD_MS = 20000; // how long yieldTls() keeps the socket closed +constexpr size_t MAX_MESSAGE = 4096; // a Realtime message bigger than this is dropped + +volatile State st = OFF; +volatile bool wantOn = false; +volatile uint32_t yieldUntil = 0; +TaskHandle_t task = nullptr; + +SemaphoreHandle_t lock() { // guards the socket writes, err and allowed + static SemaphoreHandle_t m = xSemaphoreCreateRecursiveMutex(); + return m; +} +struct Guard { + Guard() { xSemaphoreTakeRecursive(lock(), portMAX_DELAY); } + ~Guard() { xSemaphoreGiveRecursive(lock()); } +}; + +String err; +std::vector allowed; // from the token reply: the owner's allow-list +std::vector pendingEvents; // @evt JSON waiting for the relay task to send +volatile bool yieldReq = false; +// Only the relay task touches the socket: TLS can't read and write from two tasks. +WiFiClientSecure *sock = nullptr; +String topic; // "realtime:device:" +uint32_t refNo = 0; + +void setErr(const String &e) { + Guard g; + err = e; +} + +// ---- WebSocket (RFC 6455), client side only --------------------------------------- + +bool wsSend(uint8_t opcode, const String &payload) { + if (!sock || !sock->connected()) return false; + size_t n = payload.length(); + uint8_t head[14]; + size_t h = 0; + head[h++] = 0x80 | opcode; // FIN + if (n < 126) head[h++] = 0x80 | n; + else if (n < 65536) { + head[h++] = 0x80 | 126; + head[h++] = n >> 8; + head[h++] = n & 0xff; + } else return false; + uint32_t mask = esp_random(); + uint8_t m[4] = {uint8_t(mask), uint8_t(mask >> 8), uint8_t(mask >> 16), uint8_t(mask >> 24)}; + memcpy(head + h, m, 4); + h += 4; + if (sock->write(head, h) != h) return false; + // Mask in small chunks: no second copy of a big payload on the heap. + uint8_t buf[128]; + for (size_t i = 0; i < n; i += sizeof(buf)) { + size_t k = min(sizeof(buf), n - i); + for (size_t j = 0; j < k; j++) buf[j] = payload[i + j] ^ m[(i + j) & 3]; + if (sock->write(buf, k) != k) return false; + } + return true; +} + +bool readExact(uint8_t *dst, size_t n, uint32_t timeoutMs) { + uint32_t start = millis(); + size_t got = 0; + while (got < n) { + if (!sock->connected()) return false; + int a = sock->available(); + if (a > 0) { + int r = sock->read(dst + got, min((size_t)a, n - got)); + if (r > 0) got += r; + } else { + if (millis() - start > timeoutMs) return false; + delay(5); + } + } + return true; +} + +// Reads one whole message (joining fragments), answering pings on the way. +// 1 = text in `out`, 0 = nothing waiting, -1 = closed or broken. +int wsRead(String &out) { + if (!sock->connected()) return -1; + if (sock->available() < 2) return 0; + out = ""; + for (;;) { + uint8_t h[2]; + if (!readExact(h, 2, 5000)) return -1; + bool fin = h[0] & 0x80; + uint8_t op = h[0] & 0x0f; + uint64_t len = h[1] & 0x7f; + if (len == 126) { + uint8_t e[2]; + if (!readExact(e, 2, 5000)) return -1; + len = (e[0] << 8) | e[1]; + } else if (len == 127) { + uint8_t e[8]; + if (!readExact(e, 8, 5000)) return -1; + len = 0; + for (int i = 0; i < 8; i++) len = (len << 8) | e[i]; + } + if (h[1] & 0x80) return -1; // servers never mask + bool keep = out.length() + len <= MAX_MESSAGE; + String chunk; + uint8_t buf[128]; + for (uint64_t i = 0; i < len; i += sizeof(buf)) { + size_t k = min((uint64_t)sizeof(buf), len - i); + if (!readExact(buf, k, 5000)) return -1; + if (keep) chunk.concat((const char *)buf, k); + } + if (op == 0x8) return -1; // close + if (op == 0x9) { wsSend(0xA, chunk); continue; } // ping -> pong + if (op == 0xA) { if (!out.length()) return 0; continue; } + if (op == 0x1 || op == 0x0) { + if (!keep) out = "\x01"; // too big: remembered, then dropped below + else if (out != "\x01") out += chunk; + if (!fin) continue; + if (out == "\x01") { out = ""; return 0; } + return 1; + } + // binary or unknown: ignore + if (fin) return 0; + } +} + +bool wsOpen(const String &wssUrl, const String &apikey) { + // wss://host/path + if (!wssUrl.startsWith("wss://")) return false; + String rest = wssUrl.substring(6); + int slash = rest.indexOf('/'); + String host = slash < 0 ? rest : rest.substring(0, slash); + String path = slash < 0 ? String("/") : rest.substring(slash); + path += (path.indexOf('?') < 0 ? "?" : "&") + String("apikey=") + apikey + "&vsn=1.0.0"; + + WiFiClientSecure *c = new (std::nothrow) WiFiClientSecure(); + if (!c) return false; + c->setInsecure(); // TODO with the scanner: pin the CA bundle (plan Phase 3) + c->setTimeout(10); + if (!c->connect(host.c_str(), 443)) { + delete c; + setErr("couldn't reach " + host); + return false; + } + uint8_t raw[16]; + for (int i = 0; i < 16; i += 4) { + uint32_t r = esp_random(); + memcpy(raw + i, &r, 4); + } + unsigned char key[32]; + size_t klen = 0; + mbedtls_base64_encode(key, sizeof(key), &klen, raw, sizeof(raw)); + key[klen] = 0; + String req = "GET " + path + " HTTP/1.1\r\nHost: " + host + + "\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: " + String((char *)key) + + "\r\nSec-WebSocket-Version: 13\r\n\r\n"; + c->print(req); + // Status line, then headers up to the blank line. + String status = c->readStringUntil('\n'); + if (status.indexOf(" 101") < 0) { + setErr("socket refused: " + status.substring(0, 40)); + c->stop(); + delete c; + return false; + } + for (int i = 0; i < 40; i++) { + String line = c->readStringUntil('\n'); + if (line.length() <= 1) break; + } + sock = c; + return true; +} + +void wsClose() { + if (!sock) return; + sock->stop(); + delete sock; + sock = nullptr; +} + +// ---- Phoenix (Supabase Realtime) ---------------------------------------------------- + +bool push(const char *topicName, const char *event, JsonDocument &payload) { + JsonDocument msg; + msg["topic"] = topicName; + msg["event"] = event; + msg["payload"] = payload; + msg["ref"] = String(++refNo); + String s; + serializeJson(msg, s); + return wsSend(0x1, s); +} + +bool broadcast(const char *event, const String &line) { + JsonDocument p; + p["type"] = "broadcast"; + p["event"] = event; + p["payload"]["line"] = line; + return push(topic.c_str(), "broadcast", p); +} + +struct Token { + String token, realtimeUrl, apikey, channel; + uint32_t expiresAt = 0; // epoch s + uint32_t mintedMs = 0; + uint32_t lifeS = 0; // expiresAt - the server's now, roughly: the board's clock may be unset +}; + +// POST /api/v1/devices/realtime-token. False = try again later; sets st = REFUSED +// when the site said no for a reason that won't fix itself. +bool mint(Token &t) { + freq::Settings &s = freq::settings(); + if (s.deviceId.isEmpty() || s.deviceSecret.isEmpty()) { + setErr("not set up as a scanner yet"); + return false; + } + String base = s.baseUrl; + while (base.endsWith("/")) base.remove(base.length() - 1); + WiFiClientSecure tls; + tls.setInsecure(); + HTTPClient http; + http.setTimeout(8000); + if (!http.begin(tls, base + "/api/v1/devices/realtime-token")) { + setErr("bad base URL"); + return false; + } + http.addHeader("Content-Type", "application/json"); + http.addHeader("X-Frequency-Device", s.deviceId + "." + s.deviceSecret); + int code = http.POST("{}"); + JsonDocument body; + if (code > 0) deserializeJson(body, http.getString()); + http.end(); + String reason = body["reason"] | ""; + if (code == 404 || reason == "relay_disabled") { + st = REFUSED; + setErr(code == 404 ? "internet relay is off on the site" : "the owner hasn't switched the relay on"); + return false; + } + if (code != 200 || !(body["ok"] | false)) { + setErr(code < 0 ? HTTPClient::errorToString(code) : "token: " + (reason.length() ? reason : String(code))); + return false; + } + t.token = body["token"] | ""; + t.realtimeUrl = body["realtime_url"] | ""; + t.apikey = body["apikey"] | ""; + t.channel = body["channel"] | ""; + t.expiresAt = body["expires_at"] | 0; + t.mintedMs = millis(); + t.lifeS = 15 * 60; // the token's lifetime (lib/devices/relay.ts); expires_at is informative + { + Guard g; + allowed.clear(); + for (JsonVariant v : body["allowed_cmds"].as()) allowed.push_back(v.as()); + } + return t.token.length() && t.realtimeUrl.length() && t.channel.length(); +} + +bool allowedCmd(const String &cmd) { + if (cmd == "cli.run") return false; // never a remote shell over the internet + Guard g; + for (const String &a : allowed) + if (a == cmd) return true; + return false; +} + +// A broadcast arrived on the channel. Only "rpc" lines are run. +void onMessage(const String &text) { + JsonDocument msg; + if (deserializeJson(msg, text)) return; + String event = msg["event"] | ""; + if (event == "phx_reply") { + String status = msg["payload"]["status"] | ""; + if (status != "ok" && String(msg["topic"] | "") == topic) { + setErr("join refused: " + String(msg["payload"]["response"]["reason"] | status.c_str())); + wsClose(); + } + return; + } + if (event == "phx_error" || event == "phx_close") { + wsClose(); + return; + } + if (event != "broadcast") return; + if (String(msg["payload"]["event"] | "") != "rpc") return; + String line = msg["payload"]["payload"]["line"] | ""; + line.trim(); + if (!remote::isRpcLine(line)) return; // the relay never takes raw CLI lines + broadcast("reply", "@rpc " + remote::runLine(line, "net", allowedCmd)); +} + +bool join(const Token &t) { + topic = "realtime:" + t.channel; + JsonDocument p; + p["config"]["broadcast"]["self"] = false; + p["config"]["broadcast"]["ack"] = false; + p["config"]["presence"]["key"] = freq::settings().deviceId; + p["config"]["private"] = true; + p["access_token"] = t.token; + if (!push(topic.c_str(), "phx_join", p)) return false; + // Presence: the website shows online/offline without polling. + JsonDocument pr; + pr["type"] = "presence"; + pr["event"] = "track"; + pr["payload"]["online"] = true; + pr["payload"]["version"] = OS_VERSION; + return push(topic.c_str(), "presence", pr); +} + +bool ready() { + if (WiFi.status() != WL_CONNECTED) return setErr("no Wi-Fi"), false; + if (remote::bleEnabled()) return setErr("Bluetooth is on (not enough RAM for both)"), false; + if (millis() < yieldUntil) return false; + if (ESP.getFreeHeap() < MIN_HEAP) return setErr("not enough free RAM"), false; + return true; +} + +void loop(void *) { + uint32_t backoff = 5000; + Token tok; + while (wantOn) { + if (st == REFUSED) { // only a restart of the relay (off/on) or a re-provision retries quickly + vTaskDelay(pdMS_TO_TICKS(300000)); + st = WAITING; + continue; + } + if (!ready()) { + st = WAITING; + vTaskDelay(pdMS_TO_TICKS(2000)); + continue; + } + st = CONNECTING; + if (!mint(tok) || !ready() || !wsOpen(tok.realtimeUrl, tok.apikey) || !join(tok)) { + wsClose(); + if (st != REFUSED) st = WAITING; + vTaskDelay(pdMS_TO_TICKS(backoff)); + backoff = min(backoff * 2, 60000); + continue; + } + backoff = 5000; + st = JOINED; + setErr(""); + uint32_t beat = millis(); + while (wantOn && sock && sock->connected() && WiFi.status() == WL_CONNECTED && !remote::bleEnabled() && + millis() >= yieldUntil) { + String text; + int r = wsRead(text); + if (r < 0) break; + if (r > 0) onMessage(text); + if (millis() - beat > HEARTBEAT_MS) { + JsonDocument empty; + empty.to(); + if (!push("phoenix", "heartbeat", empty)) break; + beat = millis(); + } + if ((millis() - tok.mintedMs) / 1000 > tok.lifeS - REMINT_EARLY_S) { + Token fresh; + if (mint(fresh)) { + JsonDocument p; + p["access_token"] = fresh.token; + push(topic.c_str(), "access_token", p); + tok = fresh; + } else if (st == REFUSED) break; + else tok.mintedMs = millis() - (tok.lifeS - REMINT_EARLY_S - 30) * 1000; // retry in 30 s + } + std::vector evts; + { + Guard g; + evts.swap(pendingEvents); + } + for (const String &e : evts) broadcast("evt", "@evt " + e); + if (yieldReq) break; + if (r == 0) vTaskDelay(pdMS_TO_TICKS(20)); + } + if (yieldReq) { + yieldReq = false; + yieldUntil = millis() + YIELD_MS; + } + wsClose(); + if (st == JOINED) st = WAITING; + } + wsClose(); + st = OFF; + task = nullptr; + vTaskDelete(nullptr); +} + +void start() { + if (task) return; + st = WAITING; + // 8 KB like the CLI task: relayed handlers run here. + xTaskCreatePinnedToCore(loop, "cu_relay", 8192, nullptr, 1, &task, 1); +} + +} // namespace + +bool enabled() { + Preferences p; + p.begin("remote", true); + bool on = p.getBool("relay", false); + p.end(); + return on; +} + +void setEnabled(bool on) { + Preferences p; + p.begin("remote", false); + p.putBool("relay", on); + p.end(); + wantOn = on; + if (on) { + if (st == REFUSED) st = WAITING; + start(); + } +} + +State state() { return st; } + +const char *stateText(State s) { + switch (s) { + case OFF: return "off"; + case WAITING: return "waiting"; + case CONNECTING: return "connecting"; + case JOINED: return "online"; + case REFUSED: return "refused"; + } + return "?"; +} + +String lastError() { + Guard g; + return err; +} + +void sendEvent(const String &json) { + if (st != JOINED) return; + Guard g; + if (pendingEvents.size() < 8) pendingEvents.push_back(json); // a burst past 8 is dropped +} + +void yieldTls() { + if (st != JOINED || ESP.getFreeHeap() >= MIN_HEAP) return; + yieldReq = true; // the relay task closes the socket and stays off for YIELD_MS + for (int i = 0; i < 60 && st == JOINED; i++) delay(50); +} + +void boot() { + if (enabled()) setEnabled(true); +} + +} // namespace relay diff --git a/cyberpunkunicorn-os/apps/remote/relay.h b/cyberpunkunicorn-os/apps/remote/relay.h new file mode 100644 index 0000000..11edf39 --- /dev/null +++ b/cyberpunkunicorn-os/apps/remote/relay.h @@ -0,0 +1,40 @@ +// Internet relay (plan Phase 4b): the website controls the board from any network. +// Both sides are clients of Supabase Realtime and meet on the private channel +// "device:". The board mints a 15-minute token from frequency-web +// (POST /api/v1/devices/realtime-token, X-Frequency-Device), joins over a TLS +// WebSocket, and runs `rpc` lines that arrive as broadcast "rpc" through the same +// code as USB. Contract: PROTOCOL.md section 6. +// +// Off by default (NVS remote/relay). Needs Wi-Fi, a provisioned scanner +// (frequency.provision) and the RAM for TLS, so it never runs beside Bluetooth. +#pragma once + +#include + +namespace relay { + +enum State { + OFF, // switched off + WAITING, // on, but no Wi-Fi, no scanner key, Bluetooth on or low RAM + CONNECTING, // minting a token or opening the socket + JOINED, // on the channel: the website can send commands + REFUSED, // the site said no (relay off for this device, or not enabled there) +}; + +bool enabled(); +void setEnabled(bool on); // saved in NVS; starts or stops the background task +State state(); +const char *stateText(State s); +String lastError(); // why the last attempt failed, for the Remote screen + +// Sends an @evt line's JSON ({"event":..., "data":...}) to the website while +// joined. Safe from any task; drops it when not joined. +void sendEvent(const String &json); + +// Closes the socket for a while so a check-in POST has the RAM for its own TLS +// session. Called by the Frequency backend; the relay rejoins on its own. +void yieldTls(); + +void boot(); // from remote::boot(): starts the task when the relay is on + +} // namespace relay diff --git a/cyberpunkunicorn-os/apps/remote/remote.cpp b/cyberpunkunicorn-os/apps/remote/remote.cpp index 7711bf7..7b0ec8e 100644 --- a/cyberpunkunicorn-os/apps/remote/remote.cpp +++ b/cyberpunkunicorn-os/apps/remote/remote.cpp @@ -3,6 +3,7 @@ #include "../hardware/hardware.h" #include "../hat/hat_probe.h" #include "../lightshow/LightShowMenu.h" +#include "relay.h" #include "core/display.h" #include "core/main_menu.h" #include "core/settings.h" @@ -95,6 +96,11 @@ void writeLine(SerialDevice *dev, const String &line) { xSemaphoreGive(writeLock()); } +SemaphoreHandle_t commandLock() { + static SemaphoreHandle_t m = xSemaphoreCreateRecursiveMutex(); + return m; +} + // ---- recent-command log ---------------------------------------------------------- constexpr int LOG_SIZE = 8; @@ -109,10 +115,10 @@ 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) { +void logCommand(const String &cmd, bool ok, const char *transport) { LogEntry e; e.ms = millis(); - strlcpy(e.transport, dev == &USBserial ? "usb" : "ble", sizeof(e.transport)); + strlcpy(e.transport, transport, sizeof(e.transport)); strlcpy(e.cmd, cmd.c_str(), sizeof(e.cmd)); e.ok = ok; portENTER_CRITICAL(&logMux); @@ -192,6 +198,12 @@ void cmdSystemStatus(Request &r) { ble["connected"] = bleConnected(); ble["at_boot"] = bleAtBoot(); + JsonObject net = r.data["relay"].to(); + net["enabled"] = relay::enabled(); + net["state"] = relay::stateText(relay::state()); + String relayErr = relay::lastError(); + if (relayErr.length()) net["error"] = relayErr; + JsonObject hat = r.data["hat"].to(); JsonArray live = hat["live"].to(); HatProbeResult hp; @@ -305,7 +317,17 @@ void cmdSystemTime(Request &r) { r.data["epoch"] = epoch; } +// remote.relay {on?}: the internet relay switch (never allowed over the relay itself). +void cmdRelay(Request &r) { + if (r.args["on"].is()) relay::setEnabled(r.args["on"].as()); + r.data["enabled"] = relay::enabled(); + r.data["state"] = relay::stateText(relay::state()); + String e = relay::lastError(); + if (e.length()) r.data["error"] = e; +} + REMOTE_COMMAND("system.status", cmdSystemStatus); +REMOTE_COMMAND("remote.relay", cmdRelay); REMOTE_COMMAND("system.caps", cmdSystemCaps); REMOTE_COMMAND("system.time", cmdSystemTime); REMOTE_COMMAND("apps.list", cmdAppsList); @@ -342,14 +364,10 @@ void emit(const char *name, JsonObjectConst data) { // Events go to USB always, and to the phone on BLE when that's on. writeLine(&USBserial, line); if (bleSerialActive() && bleConnected() > 0) writeLine(serialDevice, line); + relay::sendEvent(json); // and to the website over the internet relay, when it's joined } -bool handleLine(const String &raw) { - String line = raw; - line.trim(); - if (!line.startsWith("rpc") || (line.length() > 3 && line[3] != ' ')) return false; - - SerialDevice *dev = serialDevice; // the transport this line came in on (patch 0014) +String runLine(const String &line, const char *transport, CommandFilter allow) { JsonDocument in; JsonDocument out; String cmd; @@ -374,10 +392,15 @@ 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"); + if (allow && !cmd.isEmpty() && !allow(cmd)) r.fail("not_allowed", cmd + " isn't allowed over this link"); + else if (fn) { + // One handler at a time, whichever link the line came in on: handlers + // share the hat pins, the reader and the screen-request flags. + xSemaphoreTakeRecursive(commandLock(), portMAX_DELAY); + fn(r); + xSemaphoreGiveRecursive(commandLock()); + } else if (planned(cmd)) r.fail("not_available", cmd + " isn't in this build yet"); else r.fail("unknown_command", cmd.isEmpty() ? String("cmd is required") : cmd); ok = !r.failed(); @@ -391,11 +414,21 @@ bool handleLine(const String &raw) { String json; serializeJson(out, json); // overwrites, never appends (ArduinoJson 7): see emit() - String reply = "\n@rpc " + json + "\n"; // leading \n: see emit() + logCommand(cmd, ok, transport); + return json; +} + +bool handleLine(const String &raw) { + String line = raw; + line.trim(); + if (!isRpcLine(line)) return false; + + SerialDevice *dev = serialDevice; // the transport this line came in on (patch 0014) + if (dev == &USBserial) usbRpcMs = millis() | 1; // | 1: never 0, which means "never" + String reply = "\n@rpc " + runLine(line, dev == &USBserial ? "usb" : "ble", nullptr) + "\n"; // leading \n: see emit() // A command that switched Bluetooth off (cli.run "remote ble off") has no link // left to answer on. if (dev == &USBserial || bleEnabled()) writeLine(dev, reply); - logCommand(cmd, ok, dev); return true; } @@ -441,6 +474,7 @@ void setBleAtBoot(bool on) { void boot() { if (bleAtBoot()) bleSetEnabled(true); + relay::boot(); // waits in the background for Wi-Fi; never beside Bluetooth // Tells a computer that just opened the port (which resets this board) that // the board is up and which protocol it speaks. JsonDocument hello; diff --git a/cyberpunkunicorn-os/apps/remote/remote.h b/cyberpunkunicorn-os/apps/remote/remote.h index 4ef3973..2ccdf5c 100644 --- a/cyberpunkunicorn-os/apps/remote/remote.h +++ b/cyberpunkunicorn-os/apps/remote/remote.h @@ -60,6 +60,15 @@ void emit(const char *name, JsonObjectConst data); // Handles one raw CLI line. Returns false if it isn't an `rpc` line. bool handleLine(const String &line); +inline bool isRpcLine(const String &line) { return line.startsWith("rpc") && (line.length() == 3 || line[3] == ' '); } + +// Runs one trimmed `rpc {...}` line and returns the reply JSON (no "@rpc " prefix). +// `transport` goes in the recent-command log ("usb", "ble", "net"). `allow`, when +// set, refuses any command it returns false for (not_allowed). Handlers never run +// two at a time, whatever link their lines came in on. +using CommandFilter = bool (*)(const String &cmd); +String runLine(const String &line, const char *transport, CommandFilter allow); + // Bluetooth remote (Bruce's BLE API): on/off now, and the persisted boot setting. bool bleEnabled(); bool bleSetEnabled(bool on); // false if refused (e.g. not enough free heap)