Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/cyberpunkunicorn-os.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: "cyberpunkunicorn-os: build and release"

# Path-filtered: touching another project must not run this.
# A tag cu5k-v<version> (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.
18 changes: 16 additions & 2 deletions cyberpunkunicorn-os/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<OS_VERSION>`
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
Expand Down Expand Up @@ -46,7 +50,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 "<ssid>"` with no password,
`wifi forget`, password no longer trimmed; join timeout 10 s → 20 s with a reason
Expand Down Expand Up @@ -90,6 +95,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.
Expand All @@ -101,6 +112,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.
- `hardware/` — **Library** + hardware registry (`hardware.h`). Apps with a hardware
Expand Down
18 changes: 16 additions & 2 deletions cyberpunkunicorn-os/apps/frequency/BACKEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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/<token>`: member profile, or a "claim this card" flow?
163 changes: 149 additions & 14 deletions cyberpunkunicorn-os/apps/frequency/backend.cpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
#include "backend.h"

#include "../remote/remote.h"
#include "store.h"
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <freertos/semphr.h>
#include <mbedtls/md.h>

namespace freq {

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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"];
Expand All @@ -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<JsonObject>(), sc);
remote::emit("frequency.scan_relay", evt.as<JsonObjectConst>());

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;
}

Expand Down
Loading
Loading