diff --git a/.gitignore b/.gitignore index 76103d4..0ce62ac 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ bin/ node_modules/ *.log host.json +auth.json +accounts/ .env .env.* !.env.example diff --git a/README.md b/README.md index a6f7de2..2784688 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,10 @@ Open: - Full data: `http://127.0.0.1:8787/api/usage` - ESP32 payload: `http://127.0.0.1:8787/api/device` +When multiple accounts are registered, the dashboard shows the selected account +identity (full email by default or explicit `-Label`), profile ID, and `n/N` position. Use its previous/next buttons to change the +displayed account; refreshes preserve the selection by stable profile ID. + Once verified, stop it with `Ctrl+C`, then listen on the LAN: ```powershell @@ -294,6 +298,51 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start-host.ps1 -Bind 0.0.0.0 If Windows Firewall prompts, allow access only on **Private networks**. Do not expose port 8787 to the public internet or an untrusted network. +### 👥 Add isolated accounts + +The host can poll up to four explicitly registered accounts. Register each account in +its own `CODEX_HOME` instead of copying the active global Codex login or sharing one +`auth.json` between account workers: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\add-account.ps1 ` + -Name personal -Label "PERSONAL" -PlanLabel "PRO 20X" +``` + +`-Name` is the required 1–32-character stable ASCII registry slug and must not be +an email address. +Optional `-Label` is a 1–32-character printable ASCII privacy-safe alias that overrides +the display identity. If it is omitted, the host uses the complete email returned by +`account/read`, for example `user@example.com`. Optional `-PlanLabel` +overrides only that account's plan label. By default the profile is stored under +`%LOCALAPPDATA%\CodexUsageMeter\accounts\\`, with metadata in `profile.json` +and credentials in its isolated `codex-home\`. + +The registration flow runs the official `codex login` scoped to that new account +home. Complete it with the intended account; the script does **not** copy the global +`CODEX_HOME`, the ChatGPT desktop application's authentication files, or a different +account's credentials. Repeat the command with another unique `-Name` to add an +account. Use a short alias such as `PERSONAL`, `WORK`, or `TEAM` when you do not want +the default full email displayed on the device. Authentication tokens, cookies, and +auth-file contents are never used as labels or sent to the device. When the account registry contains +no explicit profile directory, the host preserves backward compatibility by reading +the normal default `CODEX_HOME` as one account. A present but invalid explicit profile +fails closed and is reported in diagnostics instead of silently using global credentials. + +The service watches the account registry and isolated account homes. A completed +registration or a later login change is loaded without merging credentials into the +service's own state. The daemon keeps one app-server process per account, polls the +accounts serially under one global request schedule, and applies failure backoff per +account. Initial or changed profiles are warmed serially; in steady state, each global +scheduling tick polls at most one due account in round-robin order. Adding accounts +therefore keeps the steady-state total poll rate at at most one account per tick; +instead, each account refreshes less often (roughly every N ticks for N accounts). +Only startup, registration, or credential changes add a bounded serial warm-up. +OpenAI has not published a +multi-account polling cadence that is guaranteed to avoid rate limits or abuse +safeguards; use only accounts you are authorized to access and choose a conservative +interval. + ### 📡 Choose Wi-Fi, BLE, or both The host script supports four modes: @@ -378,7 +427,9 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start-host.ps1 ` Labels are limited to 20 ASCII characters, must begin and end with a letter or digit, may contain letters, digits, spaces, `+`, `_`, and `-` in between, and are normalized to uppercase before transmission. Automatic 5x/20x detection should be added only if -app-server eventually returns a dependable entitlement field. +app-server eventually returns a dependable entitlement field. In multi-account mode, +the `-PlanLabel` passed to `add-account.ps1` belongs only to that account and takes +precedence over the host-wide fallback. ### 🔐 Host identity and transport security @@ -515,6 +566,19 @@ A lightning symbol means charging, `USB` means external power is available witho battery, and an unavailable reading—or no valid sample for more than six sampling periods—displays `--%` rather than misreporting a communication error as 0%. +Both pages show the account identity below the page title: the complete email from +`account/read` by default, or the explicit privacy-safe `-Label` override. +With multiple accounts, the label includes its position such as `WORK | 2/3`. Short-press +GPIO18/the onboard Key3 to select the next account; long-press it to select the previous +account. With only one account, the position is omitted and both actions are ignored. + +Do not use the onboard Key2 as the previous-account button. On this reference board it +mechanically pulls both GPIO0 and `CHIP_PU` low, so pressing it resets the ESP32-S3 and +may enter the download path. The optional +`CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0` input is disabled by default and is only +for a separate external button wired directly between GPIO0 and ground; firmware does +not initialize GPIO0 as a runtime button unless that option is enabled. + After 60 seconds without touch or detected movement, brightness drops from 65% to 30% by default. The QMI8658 uses all three acceleration axes, an adaptive gravity baseline, and an 80 mg default threshold. Moving a dimmed device restores active brightness; the very @@ -745,22 +809,48 @@ verification looks like this (the data payload version remains 1): "plan": "pro", "planLabel": "PRO 20X", "preferred": { - "id": "codex", - "name": null, "primary": { "used": 31, "remaining": 69, "windowMins": 10080, - "resetsAt": 1785813196, "resetsIn": 529996 - }, - "secondary": null + } }, - "extras": [] + "extras": [], + "accounts": [ + { + "id": "personal", + "label": "user@example.com", + "status": "ok", + "capturedAt": 1785283200, + "planLabel": "PRO 20X", + "preferred": { + "primary": { + "remaining": 69, + "windowMins": 10080, + "resetsIn": 529996 + } + }, + "extras": [] + }, + { + "id": "work", + "label": "WORK", + "status": "ok", + "capturedAt": 1785283140, + "planLabel": "PLUS", + "preferred": null, + "extras": [] + } + ] } ``` -The host calculates `resetsIn`, so the ESP32 can show a reset countdown without NTP. +The top-level quota fields mirror the first healthy account with renderable cached +usage for compatibility; current firmware reads the complete `accounts` array, which +is capped at four. Top-level `capturedAt` is the newest safe poll/account generation so +updates to any account and registry removals are transported monotonically. The host +calculates `resetsIn`, so the ESP32 can show a reset countdown without NTP. By default, the ESP32 reads the LAN cache every five seconds while the Windows host requests a new quota from its local Codex runtime every 60 seconds. The two cadences do not conflict. The UI does not repaint a host countdown every second, avoiding needless @@ -779,19 +869,21 @@ documented in [`docs/transport.md`](docs/transport.md). ## ⏱️ Polling cadence and rate-limit risk -Three separate cadences are involved, and only the host app-server poll touches the -local Codex runtime. The host reads immediately at startup. After success, the next read -uses the configured interval—60 seconds by default, allowed range 60 seconds to 24 -hours—plus 0–5 seconds of random jitter. Under the default configuration, consecutive -failures back off for 2 / 4 / 8 / 15 minutes and remain capped at 15 minutes. The -ESP32's five-second Wi-Fi pull and the BLE helper's two-second check only read this -local cache; neither adds an OpenAI request. +Three separate cadences are involved, and only the host app-server scheduler touches +the local Codex runtimes. Initial, new, and changed profiles are read serially. In +steady state, the configured 60-second default interval is one global tick and each +tick polls at most one due account in round-robin order. Consecutive failures back off +per account for 2 / 4 / 8 / 15 minutes by default, so one failed login does not create +parallel retries or block healthy accounts. The ESP32's five-second Wi-Fi pull and the +BLE helper's two-second check only read the local list cache; neither adds an OpenAI +request. `account/rateLimits/read` is a documented local Codex app-server RPC, not a standalone public REST quota API for ChatGPT users. The current app-server implementation fetches primary usage and detailed reset-credit information in parallel, so one local RPC should not be assumed to equal exactly one backend HTTP request. OpenAI has not -published a safe periodic-polling cadence or a guarantee against rate limiting. The +published a safe periodic or multi-account polling cadence, nor a guarantee against +rate limiting or other abuse safeguards. The OpenAI Pro usage guidance also notes that abusive automated or programmatic extraction may trigger abuse safeguards; this project's personal quota display has no official exception or safety guarantee. @@ -803,8 +895,9 @@ backs off after every failed read. Clawdmeter's [PR #29 discussion](https://github.com/HermannBjorgvin/Clawdmeter/pull/29#issuecomment-4529772728) contains a public rate-limit report for another API-query approach, later traced to [five-second retries amplifying the problem](https://github.com/HermannBjorgvin/Clawdmeter/pull/29#issuecomment-4529796805). -No public account-ban evidence was found. That does not reveal the threshold of the -OpenAI RPC, and neither project provides an official guarantee that a cadence is safe. +No public account-ban evidence was found. That does not reveal a safe threshold for +the OpenAI RPC or multiple accounts, and neither project provides an official safety +guarantee. Firmware separates the UI data model from the Wi-Fi/HTTP and BLE GATT implementations. A future USB CDC transport only needs to supply the same snapshot/link callbacks; it diff --git a/README.zh-CN.md b/README.zh-CN.md index d58c1e0..3f99e54 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -266,6 +266,10 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start-host.ps1 - 完整数据:`http://127.0.0.1:8787/api/usage` - ESP32 数据:`http://127.0.0.1:8787/api/device` +注册多个账号后,仪表盘会显示当前账号身份(默认完整邮箱或显式 `-Label`)、profile ID 和 `n/N` +位置;可用“上一个/下一个”按钮切换。页面刷新时会按稳定 profile ID 保持 +选择,只有账号被删除时才回退到列表首项。 + 确认正常后按 `Ctrl+C` 停止,再改为局域网监听: ```powershell @@ -275,6 +279,42 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start-host.ps1 -Bind 0.0.0.0 Windows 防火墙首次提示时,只允许“专用网络”。不要在公网或不受信任网络 暴露 8787 端口。 +### 👥 添加隔离账号 + +主机最多可轮询 4 个显式注册的账号。每个账号必须使用独立 +`CODEX_HOME`,不会复制当前全局 Codex 登录态,也不会让多个账号 worker +共用同一份 `auth.json`: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\add-account.ps1 ` + -Name personal -Label "PERSONAL" -PlanLabel "PRO 20X" +``` + +`-Name` 是必填的 1–32 字符稳定 ASCII 本地注册 slug,不能是邮箱。可选 `-Label` 是 1–32 字符的 +可打印 ASCII 安全别名(与设备内置字体一致),用于覆盖显示身份;不填时, +主机默认使用 `account/read` 返回的完整邮箱,例如 `user@example.com`。可选 +`-PlanLabel` 只覆盖该账号的套餐标签。profile 默认保存在 +`%LOCALAPPDATA%\CodexUsageMeter\accounts\\`,其中 `profile.json` 只放元数据, +凭据位于隔离的 `codex-home\` 中。 + +注册流程会在该新账号目录下运行官方 `codex login`;请用目标账号完成登录。 +脚本**不会**复制全局 `CODEX_HOME`、ChatGPT 桌面应用的认证文件或其他账号的 +凭据。使用新的 `-Name` 重复执行即可添加账号。如果不希望设备默认显示完整邮箱, +可使用 `PERSONAL`、`WORK`、`TEAM` 等安全短别名覆盖。认证令牌、Cookie 和 auth 文件内容 +绝不会作为标签或发送到设备。账号注册目录中 +完全没有显式 profile 目录时,主机为向后兼容仍会把普通默认 `CODEX_HOME` 作为单账号读取; +如果显式 profile 已存在但无效,则会安全失败并在诊断中报告,不会静默使用全局凭据。 + +服务会监视账号注册表与各自隔离的账号目录。完成新注册,或之后在某个 +账号目录里切换登录时,服务会加载变化,但不会把凭据合并到服务自身状态。 +daemon 为每个账号保留一个 app-server,所有账号在同一个全局请求节拍下串行 +轮询,并按账号分别退避。首次出现或发生变化的 profile 会串行预热;稳态下每个 +全局调度 tick 最多按 round-robin 轮询一个到期账号。因此添加账号不会产生 4 个同时 +请求,稳态总轮询速率仍不超过每个 tick 一个账号;代价是 N 个账号时,每个账号约 +每 N 个 tick 才刷新一次。只有启动、注册或凭据变化会增加一次有上限的串行预热。 +OpenAI 并未公布可保证不触发限流或风控的多账号 +轮询频率;请仅添加自己有权使用的账号,并选择保守的轮询间隔。 + ### 📡 选择 Wi-Fi、BLE 或双链路 主机脚本支持四种模式: @@ -353,7 +393,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start-host.ps1 ` 标签最多 20 个 ASCII 字符,开头和结尾必须是字母或数字,中间允许字母、 数字、空格、`+`、`_`、`-`;发送前统一转为大写。未来只有在 app-server -明确返回可靠 entitlement 字段时,才应增加自动识别。 +明确返回可靠 entitlement 字段时,才应增加自动识别。多账号模式下, +传给 `add-account.ps1` 的 `-PlanLabel` 只属于对应账号,并优先于主机级默认值。 ### 🔐 主机身份与传输安全 @@ -474,6 +515,17 @@ powershell -ExecutionPolicy Bypass -File .\scripts\configure-firmware.ps1 ` 没有接电池但 USB 供电正常时显示 `USB`;读数不可用,或超过六个采样周期 没有有效样本时显示 `--%`,不会把通信错误误报为 0%。 +两个页面都会在标题下显示账号身份:默认为 `account/read` 返回的完整邮箱, +或显式设置的安全 `-Label` 覆盖值。有多个账号时, +还会显示 `WORK | 2/3` 这样的当前位置。短按 GPIO18/板载 Key3 切换到下一个 +账号,长按则切换到上一个。只有一个账号时不显示位置,两种按键动作都会 +忽略。 + +不要把板载 Key2 当作“上一个账号”键。该键在这块参考板上会机械联动同时拉低 +GPIO0 和 `CHIP_PU`,因此按下会复位 ESP32-S3,也可能进入下载流程。可选的 +`CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0` 默认关闭,仅供独立外接按键直接在 +GPIO0 与地之间拉低使用;未开启该选项时,固件绝不会把 GPIO0 初始化为运行时按键。 + 默认 60 秒无触摸且未检测到运动后,亮度从 65% 降到 30%。QMI8658 同时使用 三轴加速度、自适应重力基线和默认 80 mg 阈值。移动已降亮度的设备会恢复 正常亮度,紧接着的第一次触摸就能直接切页;若设备一直静止、是触摸本身 @@ -687,22 +739,47 @@ ESP32 使用 mDNS 找到主机后,以 nonce/HMAC 认证读取 "plan": "pro", "planLabel": "PRO 20X", "preferred": { - "id": "codex", - "name": null, "primary": { "used": 31, "remaining": 69, "windowMins": 10080, - "resetsAt": 1785813196, "resetsIn": 529996 - }, - "secondary": null + } }, - "extras": [] + "extras": [], + "accounts": [ + { + "id": "personal", + "label": "user@example.com", + "status": "ok", + "capturedAt": 1785283200, + "planLabel": "PRO 20X", + "preferred": { + "primary": { + "remaining": 69, + "windowMins": 10080, + "resetsIn": 529996 + } + }, + "extras": [] + }, + { + "id": "work", + "label": "WORK", + "status": "ok", + "capturedAt": 1785283140, + "planLabel": "PLUS", + "preferred": null, + "extras": [] + } + ] } ``` -主机计算 `resetsIn`,因此 ESP32 不需要 NTP 也能显示重置倒计时。 +顶层额度字段为兼容性保留,优先镜像第一个健康且有可显示缓存用量的账号;当前固件 +会读取最多 4 个的完整 `accounts` 数组。顶层 `capturedAt` 使用最新的安全轮询/账号代次, +因此任意账号更新和注册表删除都能单调推进传输。主机计算 `resetsIn`,因此 ESP32 +不需要 NTP 也能显示重置倒计时。 ESP32 默认每 5 秒读取一次局域网缓存,而 Windows 主机默认每 60 秒向本地 Codex 运行时获取一次新额度;两者互不冲突。界面不显示逐秒主机倒计时, 避免无意义的每秒 AMOLED 重绘。 @@ -719,16 +796,17 @@ ACK。广播本身不携带额度。BLE v1 的 UUID、分片和认证约定见 ## ⏱️ 轮询频率与限流风险 -这里有三层互不相同的频率:只有主机的 app-server poll 会触及本机 Codex -运行时。主机启动时立即读取一次;成功后下一次读取为配置间隔(默认 60 秒、 -允许 60 秒至 24 小时)再加 0–5 秒随机抖动。默认配置下,连续失败会按 -2 / 4 / 8 / 15 分钟退避;达到 15 分钟后不再继续放大。ESP32 的 5 秒 Wi-Fi -pull 与 BLE helper 的 2 秒检查都只读取这份本地缓存,不会追加 OpenAI 请求。 +这里有三层互不相同的频率:只有主机 app-server scheduler 会触及本机 Codex 运行时。 +首次、新增或发生变化的 profile 会串行读取。稳态下,默认 60 秒的配置间隔是一个全局 +tick,每个 tick 最多按 round-robin 轮询一个到期账号。默认下,某个账号连续失败会 +独立按 2 / 4 / 8 / 15 分钟退避,所以一个失效登录不会产生并行重试,也不会阻塞健康 +账号。ESP32 的 5 秒 Wi-Fi pull 与 BLE helper 的 2 秒检查都只读本地列表缓存, +不会追加 OpenAI 请求。 `account/rateLimits/read` 是 OpenAI 文档化的本机 app-server RPC,不是面向 ChatGPT 用户公开的独立 REST 配额接口。当前 app-server 实现会并行获取主要 usage 和详细 reset-credit 信息,因此一次本机 RPC 也不应简单理解为恰好一次 -后端 HTTP 请求;OpenAI 没有公布周期轮询的安全频率或免限流保证。OpenAI +后端 HTTP 请求;OpenAI 没有公布周期或多账号轮询的安全频率,也没有免限流或其他风控保证。OpenAI 的 Pro 使用说明还提醒,滥用性地自动或程序化提取数据可能 触发防滥用保护;本项目的个人额度展示并没有获得官方例外或安全承诺。 Clawdmeter 也以 60 秒为名义周期,但审计到的 Windows 实现会 @@ -737,8 +815,8 @@ Clawdmeter 也以 60 秒为名义周期,但审计到的 Windows 实现会 Clawdmeter 的 [PR #29 讨论](https://github.com/HermannBjorgvin/Clawdmeter/pull/29#issuecomment-4529772728) 对另一种 API 查询方案有公开限流记录,随后定位到失败后的 [5 秒重试会放大问题](https://github.com/HermannBjorgvin/Clawdmeter/pull/29#issuecomment-4529796805); -但没有公开封号证据。这也不能直接推导 OpenAI RPC 的阈值,两者都不 -构成官方安全频率保证。 +但没有公开封号证据。这也不能得出 OpenAI RPC 或多账号的安全阈值,两者都不 +构成官方安全保证。 固件把界面使用的数据模型与 Wi‑Fi/HTTP、BLE GATT 实现分开。后续 USB CDC 只需提供同一组 snapshot/link 回调,不需要重写 UI。设计取舍见 diff --git a/docs/transport.md b/docs/transport.md index fc3a210..bbeb4e3 100644 --- a/docs/transport.md +++ b/docs/transport.md @@ -2,24 +2,26 @@ **English** | [简体中文](transport.zh-CN.md) -The host reads quota data only from the local Codex app-server and stores a -minimized snapshot in a local cache. Wi-Fi and BLE deliver the same snapshot -generation to the device; neither transport queries OpenAI independently: +The host reads quota data only from local Codex app-server processes and stores +a minimized account-list snapshot in a local cache. Up to four isolated account +profiles feed that list. Wi-Fi and BLE deliver the same complete generation to +the device; neither transport queries OpenAI independently: ```text -Codex app-server ── 60 s default poll ── Windows local cache - │ - ┌─────────────────┴─────────────────┐ - │ │ - Wi-Fi HTTP pull BLE GATT push - mDNS + HMAC v2 Bleak central/client - │ │ - └──────── transport manager ────────┘ - │ - usage_snapshot_t - │ - ▼ - UI +isolated CODEX_HOME ─┐ +isolated CODEX_HOME ─┤── serial global scheduler ── Windows account-list cache +isolated CODEX_HOME ─┤ │ +isolated CODEX_HOME ─┘ ┌─────────────┴─────────────┐ + │ │ + Wi-Fi HTTP pull BLE GATT push + mDNS + HMAC v2 Bleak central/client + │ │ + └──── transport manager ────┘ + │ + usage_snapshot_t + │ + ▼ + UI ``` The firmware transport manager accepts only complete, authenticated snapshots @@ -29,26 +31,26 @@ the UI does not switch needlessly between Wi-Fi and BLE. The overall connection remains `LIVE` while either source is live. If both sources fail, the device retains the last snapshot and displays `STALE`. -The host reads once immediately after startup. Following a successful read, the -next one is scheduled after the configured interval—60 seconds by default, with -an allowed range from 60 seconds to 24 hours—plus 0–5 seconds of random jitter. -With the default configuration, consecutive failures wait 2, 4, 8, then 15 -minutes; the delay does not grow beyond 15 minutes. With a custom interval, -backoff still grows by multiples and is capped at the larger of the configured -interval and 15 minutes. Failure backoff has no additional jitter and does not -parse `Retry-After`; every app-server read failure follows the same policy. The -ESP32 polls the host cache over Wi-Fi every 5 seconds by default, while the BLE -helper checks the loopback cache every 2 seconds. Neither local read increases -the number of OpenAI requests. +`-IntervalMs` controls one global scheduling tick—60 seconds by default, with an +allowed range from 60 seconds to 24 hours—plus 0–5 seconds of random jitter. +Initial, newly added, or changed profiles are warmed serially. In steady state, +one tick polls at most one due account in round-robin order, never every account +in parallel. A successful account becomes eligible again after at least the +configured interval. Consecutive failures for that account wait 2, 4, 8, then +15 minutes under the default configuration, independently of healthy accounts. +The ESP32 polls the host cache over Wi-Fi every 5 seconds by default, while the +BLE helper checks the loopback cache every 2 seconds. Neither local read +increases the number of OpenAI requests. `account/rateLimits/read` is a documented local Codex app-server RPC, not a standalone public REST quota endpoint for ChatGPT users. The current official app-server implementation fetches primary usage and detailed reset-credit data in parallel, so one local RPC may correspond to more than one backend GET. -OpenAI publishes neither a safe cadence for periodic polling nor a guarantee -against rate limiting; this project therefore cannot promise that a `429` will -never occur. A hard minimum interval, single-flight execution, and failure -backoff limit request amplification. OpenAI's Pro usage guidance also warns that +OpenAI publishes neither a safe cadence for periodic or multi-account polling +nor a guarantee against rate limiting or abuse safeguards; this project therefore +cannot promise that a `429`, automated restriction, or other control will never +occur. A hard minimum interval, serial execution, and per-account failure backoff +limit request amplification. OpenAI's Pro usage guidance also warns that abusive automated or programmatic data extraction may trigger abuse controls; this personal quota display has no official exception. The 60-second default, 60-second hard floor, success jitter, single-flight behavior, and backoff are an @@ -65,13 +67,70 @@ neither behavior. A records public rate limiting with another API query approach and later identifies [5-second retries as an amplifier](https://github.com/HermannBjorgvin/Clawdmeter/pull/29#issuecomment-4529796805). No public report of an account ban caused by polling was found, but that absence -does not establish the threshold for the OpenAI RPC. +does not establish a safe threshold for this RPC or for multiple accounts. This document describes the BLE v1 byte protocol and runtime behavior in the current code. Discovery, mutual proof of key possession, fragmentation, parsing, and acknowledgement have been verified end to end on Windows with a Waveshare ESP32-S3-Touch-AMOLED-2.16. +## 👥 Isolated account registry + +Create each explicit profile through the registration script: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\add-account.ps1 ` + -Name work -Label "WORK" -PlanLabel "PRO 20X" +``` + +`-Name` is required, must be a 1–32-character non-email ASCII slug, and becomes +the stable account ID. `-Label` is optional, accepts 1–32 printable ASCII +characters supported by the device font, and is a privacy-safe display override. +Without it, the identity sent to the device is the complete email returned by +`account/read`, for example `user@example.com`. `-PlanLabel` is an optional +per-account plan override. The default layout is: + +```text +%LOCALAPPDATA%\CodexUsageMeter\accounts\\ +├── profile.json display label and optional plan label only +└── codex-home\ isolated CODEX_HOME and file credential store +``` + +The script builds a temporary profile, forces file-backed credentials in that +isolated home, and runs the official `codex login`. It publishes the profile only +after login succeeds. It never copies the default/global `auth.json`, the ChatGPT +desktop application's authentication state, or another profile's credentials. +The registration script rejects a fifth profile. At runtime, only the first four +valid profiles are accepted; additional valid directories are reported and +ignored. + +On every global scheduling tick, the host reconciles the registry. It reads +`profile.json` but only stats `auth.json` and `config.toml`; credential contents +are not parsed, copied, or logged. A new profile joins the request list +automatically. A metadata, credential, or configuration fingerprint change +restarts only that profile's app-server and schedules a fresh serial read. This +means changes inside a registered isolated `CODEX_HOME` are detected, while a +switch in the unrelated global login is never silently copied into the explicit +list. Only an empty account registry uses the legacy default `CODEX_HOME` as one +account. A present but invalid explicit profile fails closed and is reported +instead of silently falling back to global credentials. + +An explicit privacy-safe label takes precedence. Without one, the host stores and +sends the complete email returned by `account/read`, such as `user@example.com`. +Authentication tokens, cookies, and auth-file contents are never normalized as +display identity or sent to the device. The stable slug, display identity, status, +captured time, plan label, preferred limit, and extras are carried in each +`accounts[]` entry. A failed account may remain `stale` with its last good usage +while healthy accounts continue to refresh. + +Firmware shows that account identity below both page titles. Multiple accounts add a +position such as `WORK | 2/3`; one account shows only the label and disables list +switching. GPIO18/the onboard Key3 selects next on a short press and previous on +a long press of at least 700 ms. The onboard Key2 cannot serve as a GPIO0 input +because it also pulls `CHIP_PU` low and resets the chip. The default-disabled +`CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0` option is only for a separate +external button wired directly from GPIO0 to ground. + ## 🔀 Transport modes The Windows host selects a path with `start-host.ps1 -Transport `. The @@ -355,7 +414,9 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start-host.ps1 ` `-PlanLabel` takes precedence, and the autostart script accepts the same option. The override is uppercased, limited to 20 ASCII characters, must begin and end with a letter or digit, and is sent to the device in the `/api/device` -`planLabel` field. +`planLabel` field. For an explicit profile, `add-account.ps1 -PlanLabel` is +stored in safe profile metadata and takes precedence over this host-wide +fallback only for that account. ## 🗺️ Roadmap priorities diff --git a/docs/transport.zh-CN.md b/docs/transport.zh-CN.md index 763e1c0..ea5eb8e 100644 --- a/docs/transport.zh-CN.md +++ b/docs/transport.zh-CN.md @@ -2,23 +2,25 @@ [English](transport.md) | **简体中文** -主机始终只从本机 Codex app-server 读取额度,并形成一份精简缓存。Wi-Fi -和 BLE 只是把同一代缓存送到设备,不会各自查询 OpenAI: +主机始终只从本机 Codex app-server 进程读取额度,并形成精简的账号列表缓存。 +最多 4 个隔离账号 profile 可以进入该列表。Wi-Fi 和 BLE 只是把同一代完整列表 +送到设备,不会各自查询 OpenAI: ```text -Codex app-server ── 60 s default poll ── Windows local cache - │ - ┌─────────────────┴─────────────────┐ - │ │ - Wi-Fi HTTP pull BLE GATT push - mDNS + HMAC v2 Bleak central/client - │ │ - └──────── transport manager ────────┘ - │ - usage_snapshot_t - │ - ▼ - UI +isolated CODEX_HOME ─┐ +isolated CODEX_HOME ─┤── serial global scheduler ── Windows account-list cache +isolated CODEX_HOME ─┤ │ +isolated CODEX_HOME ─┘ ┌─────────────┴─────────────┐ + │ │ + Wi-Fi HTTP pull BLE GATT push + mDNS + HMAC v2 Bleak central/client + │ │ + └──── transport manager ────┘ + │ + usage_snapshot_t + │ + ▼ + UI ``` 固件的 transport manager 只接受完整、校验通过的快照,并按主机生成的 @@ -26,20 +28,19 @@ Codex app-server ── 60 s default poll ── Windows local cache Wi-Fi/BLE 之间无意义跳动。任一来源仍为 `LIVE` 时总链路保持在线;全部 中断后保留最后快照并显示 `STALE`。 -主机启动时立即读取一次;成功后下一次读取为配置间隔(默认 60 秒、允许 -60 秒至 24 小时)再加 0–5 秒随机抖动。默认配置下,连续失败后依次等待 -2 / 4 / 8 / 15 分钟,达到 15 分钟后不再继续放大。自定义间隔时仍按倍数 -退避,上限是配置间隔与 15 分钟中的较大值。失败退避本身不附加抖动,也不 -解析 `Retry-After`,而是对所有 app-server 读取失败采用同一策略。ESP32 -Wi-Fi 默认每 5 秒读取主机缓存,BLE helper 每 2 秒检查 loopback 缓存; -后两者都不会增加 OpenAI 请求。 +`-IntervalMs` 控制一个全局调度 tick:默认 60 秒,允许 60 秒至 24 小时, +再加 0–5 秒随机抖动。首次、新添加或发生变化的 profile 会串行预热。稳态下每个 +tick 最多按 round-robin 轮询一个到期账号,不会并行请求所有账号。成功的账号 +至少经过配置间隔后才会再次到期。默认配置下,某个账号连续失败后依次等待 +2 / 4 / 8 / 15 分钟,与健康账号互不影响。ESP32 Wi-Fi 默认每 5 秒读取主机缓存, +BLE helper 每 2 秒检查 loopback 缓存;后两者都不会增加 OpenAI 请求。 `account/rateLimits/read` 是 OpenAI 文档化的本机 app-server RPC,不是公开 给 ChatGPT 用户直接调用的独立 REST 配额接口。当前官方 app-server 实现会 并行获取主要 usage 和详细 reset-credit 信息,所以一次本机 RPC 也可能对应 -多于一次后端 GET。OpenAI 未公布适用于周期轮询的安全频率或免限流保证, -因此项目不能承诺“绝不会出现 429”;最小间隔、单飞和失败 -退避用来限制请求放大。OpenAI 的 Pro 使用说明还提醒,滥用性地自动或 +多于一次后端 GET。OpenAI 未公布适用于周期或多账号轮询的安全频率,也没有免限流/免风控保证, +因此项目不能承诺“绝不会出现 429”,也不能保证不会出现自动限制或其他管控。 +最小间隔、串行请求和按账号退避用来限制请求放大。OpenAI 的 Pro 使用说明还提醒,滥用性地自动或 程序化提取数据可能触发防滥用保护;本项目的个人额度展示没有获得官方 例外。本项目按当前需求采用 60 秒默认值,同时以 60 秒硬下限、成功抖动、 单飞和失败退避限制请求放大;这只是工程取舍,不代表官方认可的安全频率。 @@ -52,13 +53,59 @@ main 还通过 `/v1/messages` 的 `max_tokens:1` 模型请求读取响应头。 [PR #29 讨论](https://github.com/HermannBjorgvin/Clawdmeter/pull/29#issuecomment-4529772728) 对另一种 API 查询方案有公开限流记录,随后定位到失败后的 [5 秒重试会放大问题](https://github.com/HermannBjorgvin/Clawdmeter/pull/29#issuecomment-4529796805); -但未查到因轮询而封号的公开报告。该记录也不能直接推导 OpenAI RPC -的阈值。 +但未查到因轮询而封号的公开报告。该结果也不能确立这个 RPC 或多账号的 +安全阈值。 本文描述当前代码中的 BLE v1 字节协议和运行方式;该组合已在 Windows 与 Waveshare ESP32-S3-Touch-AMOLED-2.16 上完成发现、双向持钥证明、分片、 解析和 ACK 的真机端到端验证。 +## 👥 隔离账号注册表 + +每个显式 profile 都应通过注册脚本创建: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\add-account.ps1 ` + -Name work -Label "WORK" -PlanLabel "PRO 20X" +``` + +`-Name` 必填,必须是 1–32 字符的非邮箱 ASCII slug,并作为稳定账号 ID。`-Label` 可选,接受 +1–32 个设备内置字体支持的可打印 ASCII 字符,是用于覆盖显示身份的安全别名。 +不填时,发送到设备的身份默认为 `account/read` 返回的完整邮箱,例如 `user@example.com`。 +`-PlanLabel` 是该账号的 +可选套餐覆盖值。默认目录为: + +```text +%LOCALAPPDATA%\CodexUsageMeter\accounts\\ +├── profile.json 仅包含显示标签和可选套餐标签 +└── codex-home\ 隔离 CODEX_HOME 与文件凭据存储 +``` + +脚本先创建临时 profile,在隔离目录里强制使用文件凭据,并运行官方 +`codex login`;只有登录成功后才发布 profile。它不会复制默认/全局 `auth.json`、 +ChatGPT 桌面应用的认证状态或其他 profile 的凭据。注册脚本会拒绝第 5 个 +profile;运行时也只接受前 4 个有效 profile,其余有效目录会被报告并忽略。 + +每个全局调度 tick,主机都会重新协调注册表。它会读取 `profile.json`,但对 +`auth.json` 和 `config.toml` 只读文件元数据;不会解析、复制或记录凭据内容。 +新 profile 会自动加入请求列表。元数据、凭据或配置指纹变化时,只会重建该 profile +的 app-server,并安排一次新的串行读取。因此,已注册隔离 `CODEX_HOME` 内的变化可以 +自动检测;不相关的全局登录切换绝不会被静默复制到显式列表。没有任何有效显式 +profile 目录(账号注册表为空)时,保留旧的默认 `CODEX_HOME` 作为单账号读取; +如果显式 profile 已存在但无效,则安全失败并报告,不会静默回退到全局凭据。 + +显式安全标签优先。如果不填,主机会保存并发送 `account/read` 返回的完整邮箱, +例如 `user@example.com`。认证令牌、Cookie 和 auth 文件内容绝不会被标准化为显示身份, +也不会发送到设备。每个 `accounts[]` 条目会携带稳定 slug、显示身份、状态、采集时间、 +套餐标签、主限额与 extras。失败账号可以保留 +最后一份正常用量并标记为 `stale`,其他健康账号继续刷新。 + +固件会在两个页面标题下显示该账号身份。多账号会增加 `WORK | 2/3` 这样的位置; +单账号只显示标签,并关闭列表切换。GPIO18/板载 Key3 短按选下一个,长按至少 +700 ms 选上一个。板载 Key2 也会拉低 `CHIP_PU` 并复位芯片,不能当作 GPIO0 输入键。 +默认关闭的 `CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0` 仅供独立外接按键直接在 GPIO0 +与地之间拉低使用。 + ## 🔀 传输模式 Windows 主机通过 `start-host.ps1 -Transport ` 选择路径;ESP32 在 @@ -298,7 +345,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start-host.ps1 ` `-PlanLabel` 优先;自动启动脚本同样接受该参数。覆盖值最多 20 个 ASCII 字符,开头和结尾必须是字母或数字,并会转为大写,通过 `/api/device` 的 -`planLabel` 字段送到设备。 +`planLabel` 字段送到设备。对于显式 profile,`add-account.ps1 -PlanLabel` 会存入安全的 +profile 元数据,并只对该账号优先于主机级默认值。 ## 🗺️ 后续优先级 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5a15650..565ff63 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -76,19 +76,70 @@ There are separate timers by design: | Activity | Default | Contacts the Codex runtime? | | --- | ---: | --- | -| Host quota read after success | 60 seconds plus 0–5 seconds of jitter | Yes | +| Host global scheduling tick; at most one due account | 60 seconds plus 0–5 seconds of jitter | Yes | | ESP32 Wi-Fi read of `/api/device` | 5 seconds | No; reads the host cache | | BLE helper check of `/api/device` | 2 seconds | No; reads the host cache | | AXP2101 battery sample | 5 seconds | No; board-local I²C | -The Wi-Fi interval is configurable from 2–60 seconds, but it does not alter the host -quota-read interval. BLE pushes only a changed snapshot generation. A countdown or a +With N healthy accounts, each account is normally refreshed about every N host ticks; +startup and newly changed profiles are warmed serially. The Wi-Fi interval is +configurable from 2–60 seconds, but it does not alter the host quota-read interval. +BLE pushes only a changed snapshot generation. A countdown or a fast local check therefore does not create another OpenAI/Codex request. The supported host interval is 60 seconds through 24 hours. Avoid using process restarts as a manual refresh button because each new host start performs an immediate read. +## 👥 Account registration and device selection + +Register each additional account through its own isolated login: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\add-account.ps1 ` + -Name work -Label "WORK" -PlanLabel "PRO 20X" +``` + +Do not repair a missing account by copying `%USERPROFILE%\.codex\auth.json` or +another profile's authentication file. Each profile has a separate `CODEX_HOME`, and +the meter reads only file metadata to detect a credential/configuration change; it +does not parse, copy, or log the credential. The explicit account list is capped at +four. Extra valid profile directories are ignored rather than partially transmitted. + +If an account is missing or stale, inspect the loopback-only responses: + +```powershell +curl.exe http://127.0.0.1:8787/api/usage +curl.exe http://127.0.0.1:8787/api/device +``` + +Check the expected stable ID and display label under `accounts`, then inspect that +account's `status`, `lastError`, `lastPollAt`, `nextPollAt`, and failure count. An +invalid or changed login restarts only that account's app-server and keeps its last +good snapshot as stale while the other accounts continue. Repeated failures follow +that account's normal backoff; do not repeatedly restart the whole daemon to bypass +it. An invalid profile directory or a fifth profile is reported in the host +diagnostics and is not sent to the device. + +If all registered accounts are awaiting authentication or have no quota yet, the new +firmware still receives their IDs, labels, and states and can display the list without +reusing another account's numbers. Legacy single-account firmware keeps its last good +snapshot because it cannot interpret a no-data account list. + +The device shows the complete email returned by `account/read` by default. An explicit +privacy-safe `-Label` replaces that email on the display. Authentication tokens, +cookies, and auth-file contents are never displayed or sent. With two or more accounts, +short-press GPIO18/the onboard Key3 for the next +account and long-press it for the previous account. The selection wraps at both ends +and is preserved by stable account ID when a new list arrives. With one account, both +actions are intentionally ignored. + +The onboard Key2 is not a usable GPIO0 selector: it pulls GPIO0 and `CHIP_PU` low +together, resets the ESP32-S3, and may enter the download path. Leave +`CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0` disabled unless a separate external +button is wired directly from GPIO0 to ground. The firmware does not initialize GPIO0 +as a runtime input by default. + ## 🧵 Duplicate host instance or occupied port The host uses a Windows named-pipe lock for its state directory. A second copy reports @@ -364,11 +415,13 @@ Never publish these files or raw responses: - a setup-screen screenshot that shows the temporary AP key They may contain the HMAC pairing token, Wi-Fi credentials, or other device setup -values. `/api/usage` omits the email address but still reveals plan and quota data; -treat it as private. `/api/transports` may reveal a BLE address, and logs may contain +values. `/api/usage` and `/api/device` may contain the complete account email when no +explicit `-Label` overrides it, as well as plan and quota data; treat both as private. +Neither endpoint should contain authentication tokens, cookies, or auth-file contents. +`/api/transports` may reveal a BLE address, and logs may contain local usernames, paths, IP addresses, SSIDs, or error context. For a public issue, the `/healthz` response is the safest starting point. Before adding -other output, replace usernames, local paths, host IDs, pairing tokens, Wi-Fi names and -passwords, IP addresses, BLE addresses, quota values, and setup keys with clear +other output, replace account emails, usernames, local paths, host IDs, pairing tokens, +Wi-Fi names and passwords, IP addresses, BLE addresses, quota values, and setup keys with clear placeholders. Keep timestamps and state names when they are relevant to ordering. diff --git a/docs/troubleshooting.zh-CN.md b/docs/troubleshooting.zh-CN.md index 2e224fa..b593162 100644 --- a/docs/troubleshooting.zh-CN.md +++ b/docs/troubleshooting.zh-CN.md @@ -72,17 +72,60 @@ Get-Content "$env:LOCALAPPDATA\CodexUsageMeter\host.log" -Tail 100 | 行为 | 默认间隔 | 是否访问 Codex 运行时 | | --- | ---: | --- | -| 主机成功后的额度读取 | 60 秒,再增加 0–5 秒随机抖动 | 是 | +| 主机全局调度 tick;最多读取一个到期账号 | 60 秒,再增加 0–5 秒随机抖动 | 是 | | ESP32 读取 `/api/device` 的 Wi‑Fi 缓存 | 5 秒 | 否,只读主机缓存 | | BLE helper 检查 `/api/device` | 2 秒 | 否,只读主机缓存 | | AXP2101 电量采样 | 5 秒 | 否,仅访问板载 I²C | -Wi‑Fi 间隔可在 2–60 秒内配置,但不会改变主机的额度读取频率。BLE 只推送快照 +N 个健康账号时,每个账号通常约每 N 个主机 tick 刷新一次;启动和新变化的 profile +会串行预热。Wi‑Fi 间隔可在 2–60 秒内配置,但不会改变主机的额度读取频率。BLE 只推送快照 代次发生变化的数据,因此倒计时或快速本地检查不会产生额外 OpenAI/Codex 请求。 主机允许的查询间隔为 60 秒到 24 小时。不要把进程重启当成“立即刷新”按钮,因为 每次新启动都会立刻读取一次。 +## 👥 账号注册与设备切换 + +每个附加账号都应通过独立登录注册: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\add-account.ps1 ` + -Name work -Label "WORK" -PlanLabel "PRO 20X" +``` + +账号缺失时,不要通过复制 `%USERPROFILE%\.codex\auth.json` 或其他 profile 的认证 +文件来“修复”。每个 profile 都有独立 `CODEX_HOME`;meter 只读取文件元数据来检测 +凭据/配置变化,不会解析、复制或记录凭据。显式账号列表最多 4 个;超出的有效 +profile 目录会被忽略,不会只发送半截数据。 + +如果账号缺失或已过期,检查仅限 loopback 的响应: + +```powershell +curl.exe http://127.0.0.1:8787/api/usage +curl.exe http://127.0.0.1:8787/api/device +``` + +先在 `accounts` 中确认预期的稳定 ID 与显示标签,再检查该账号的 `status`、 +`lastError`、`lastPollAt`、`nextPollAt` 和失败次数。登录失效或变化时,只会 +重建对应账号的 app-server,并把它最后一份正常快照保留为 stale;其他账号继续 +工作。连续失败会按该账号自身的正常退避等待,不要反复重启整个 daemon 绕过退避。 +无效 profile 目录或第 5 个 profile 会出现在主机诊断中,不会发送到设备。 + +如果所有注册账号都在等待认证或暂时没有额度,新固件仍会收到各自的 ID、标签与状态, +可以显示账号列表而不会套用其他账号的数字。旧版单账号固件无法理解无数据账号列表, +因此会继续保留最后一份正常快照。 + +设备默认显示 `account/read` 返回的完整邮箱;显式设置安全 `-Label` 时,屏幕用该别名 +覆盖邮箱。认证令牌、Cookie 和 auth 文件内容绝不会显示或发送。当有两个或以上 +账号时,短按 GPIO18/板载 Key3 切换到下一个,长按则切换到上一个。列表两端 +都会循环;收到新列表后,固件通过稳定账号 ID 保留选中项。只有一个账号时,两种 +动作都会被刻意忽略。 + +板载 Key2 不能作为 GPIO0 选择键:它会同时拉低 GPIO0 与 `CHIP_PU`,导致 +ESP32-S3 复位,也可能进入下载流程。除非使用一个独立外接按键直接在 GPIO0 与地之间 +拉低,否则应保持 `CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0` 关闭。固件默认不会把 GPIO0 +初始化为运行时输入。 + ## 🧵 重复实例或端口被占用 主机通过 Windows named pipe 锁定当前状态目录。第二个实例会在产生额度读取前报告 @@ -330,11 +373,12 @@ monitor 首先应出现 `AXP2101 detected; sampling every 5 seconds`,状态变 - `firmware/sdkconfig`、`firmware/sdkconfig.local` 或 `firmware/sdkconfig.old` - 显示临时设置热点密码的设备截图 -这些内容可能包含 HMAC pairing token、Wi‑Fi 凭据或其他设备配置。 -`/api/usage` 不包含邮箱原文,但会泄露套餐和额度,应按隐私数据处理; -`/api/transports` 可能包含 BLE 地址,日志也可能带有本机用户名、路径、IP、SSID 或错误 +这些内容可能包含 HMAC pairing token、Wi-Fi 凭据或其他设备配置。 +没有显式 `-Label` 覆盖时,`/api/usage` 和 `/api/device` 可能包含完整账号邮箱, +并会泄露套餐与额度,两者都应按隐私数据处理。这两个接口不应包含认证令牌、Cookie +或 auth 文件内容。`/api/transports` 可能包含 BLE 地址,日志也可能带有本机用户名、路径、IP、SSID 或错误 上下文。 -公开 issue 首选只附 `/healthz`。确实需要更多信息时,先把用户名、本地路径、host ID、 +公开 issue 首选只附 `/healthz`。确实需要更多信息时,先把账号邮箱、用户名、本地路径、host ID、 pairing token、Wi‑Fi 名称和密码、IP、BLE 地址、额度数值及设置热点密码替换成明确占位符; 与问题时序相关的时间戳和状态名可以保留。 diff --git a/firmware/host_tests/CMakeLists.txt b/firmware/host_tests/CMakeLists.txt index 8486c16..276cfdd 100644 --- a/firmware/host_tests/CMakeLists.txt +++ b/firmware/host_tests/CMakeLists.txt @@ -91,3 +91,19 @@ endif() add_test(NAME activity_policy COMMAND activity_policy_test) add_test(NAME motion_policy COMMAND motion_policy_test) + +add_executable( + account_selection_test + account_selection_test.c + ../main/meter_account_selection.c +) +target_include_directories(account_selection_test PRIVATE ../main stubs) +target_compile_features(account_selection_test PRIVATE c_std_11) + +if(MSVC) + target_compile_options(account_selection_test PRIVATE /W4 /WX) +else() + target_compile_options(account_selection_test PRIVATE -Wall -Wextra -Werror) +endif() + +add_test(NAME account_selection COMMAND account_selection_test) diff --git a/firmware/host_tests/account_selection_test.c b/firmware/host_tests/account_selection_test.c new file mode 100644 index 0000000..3232650 --- /dev/null +++ b/firmware/host_tests/account_selection_test.c @@ -0,0 +1,162 @@ +#include +#include +#include + +#include "meter_account_selection.h" + +static void set_account(usage_snapshot_t *snapshot, uint8_t index, + const char *id, const char *label, int remaining, + int64_t captured_at) +{ + usage_account_t *account = &snapshot->accounts[index]; + memset(account, 0, sizeof(*account)); + account->valid = true; + account->link_state = METER_LINK_LIVE; + account->used = 100 - remaining; + account->remaining = remaining; + account->window_mins = 10080; + account->resets_in = 3600; + account->extra_remaining = -1; + account->extra_resets_in = -1; + account->captured_at = captured_at; + snprintf(account->id, sizeof(account->id), "%s", id); + snprintf(account->label, sizeof(account->label), "%s", label); + snprintf(account->plan, sizeof(account->plan), "PRO"); + snprintf(account->extra_name, sizeof(account->extra_name), "MODEL LIMIT"); +} + +static usage_snapshot_t two_accounts(void) +{ + usage_snapshot_t snapshot = {0}; + snapshot.account_count = 2; + snapshot.source = METER_SOURCE_BLE; + snapshot.next_poll_in = 60; + set_account(&snapshot, 0, "primary", "personal.owner@example.com", 72, 100); + set_account(&snapshot, 1, "work", "work.account@example.org", 41, 101); + return snapshot; +} + +static void test_selects_and_wraps(void) +{ + meter_account_selection_t selection; + meter_account_selection_init(&selection); + usage_snapshot_t snapshot = two_accounts(); + + meter_account_selection_reconcile(&selection, &snapshot); + assert(snapshot.selected_account == 0); + assert(snapshot.remaining == 72); + assert(strcmp(selection.selected_id, "primary") == 0); + + assert(meter_account_selection_step(&selection, &snapshot, 1)); + assert(snapshot.selected_account == 1); + assert(snapshot.remaining == 41); + assert(strcmp(selection.selected_id, "work") == 0); + + assert(meter_account_selection_step(&selection, &snapshot, 1)); + assert(snapshot.selected_account == 0); + assert(meter_account_selection_step(&selection, &snapshot, -1)); + assert(snapshot.selected_account == 1); +} + +static void test_stable_id_survives_refresh_and_reorder(void) +{ + meter_account_selection_t selection; + meter_account_selection_init(&selection); + usage_snapshot_t snapshot = two_accounts(); + meter_account_selection_reconcile(&selection, &snapshot); + assert(meter_account_selection_step(&selection, &snapshot, 1)); + + usage_snapshot_t refreshed = {0}; + refreshed.account_count = 2; + refreshed.source = METER_SOURCE_WIFI; + set_account(&refreshed, 0, "work", "work.account@example.org", 38, 200); + set_account(&refreshed, 1, "primary", "personal.owner@example.com", 70, 201); + meter_account_selection_reconcile(&selection, &refreshed); + + assert(refreshed.selected_account == 0); + assert(refreshed.remaining == 38); + assert(refreshed.source == METER_SOURCE_WIFI); + assert(strcmp(selection.selected_id, "work") == 0); +} + +static void test_missing_selection_falls_back_and_single_account_ignores_buttons(void) +{ + meter_account_selection_t selection; + meter_account_selection_init(&selection); + usage_snapshot_t snapshot = two_accounts(); + meter_account_selection_reconcile(&selection, &snapshot); + assert(meter_account_selection_step(&selection, &snapshot, 1)); + + usage_snapshot_t single = {0}; + single.account_count = 1; + set_account(&single, 0, "primary", "personal.owner@example.com", 67, 300); + meter_account_selection_reconcile(&selection, &single); + assert(single.selected_account == 0); + assert(strcmp(selection.selected_id, "primary") == 0); + assert(!meter_account_selection_step(&selection, &single, 1)); + assert(!meter_account_selection_step(&selection, &single, -1)); + assert(single.remaining == 67); +} + +static void test_legacy_payload_is_unchanged(void) +{ + meter_account_selection_t selection; + meter_account_selection_init(&selection); + usage_snapshot_t legacy = { + .valid = true, + .link_state = METER_LINK_LIVE, + .remaining = 55, + .account_count = 0, + }; + meter_account_selection_reconcile(&selection, &legacy); + assert(legacy.valid); + assert(legacy.remaining == 55); + assert(!meter_account_selection_step(&selection, &legacy, 1)); +} + +static void test_transport_failure_cannot_be_hidden_by_account_switch(void) +{ + meter_account_selection_t selection; + meter_account_selection_init(&selection); + usage_snapshot_t snapshot = two_accounts(); + meter_account_selection_reconcile(&selection, &snapshot); + + meter_account_selection_apply_transport_link(&snapshot, METER_LINK_OFFLINE); + assert(snapshot.link_state == METER_LINK_OFFLINE); + assert(meter_account_selection_step(&selection, &snapshot, 1)); + meter_account_selection_apply_transport_link(&snapshot, METER_LINK_OFFLINE); + assert(snapshot.link_state == METER_LINK_OFFLINE); + + snapshot.accounts[1].link_state = METER_LINK_AUTH_REQUIRED; + meter_account_selection_apply_transport_link(&snapshot, METER_LINK_LIVE); + assert(snapshot.link_state == METER_LINK_AUTH_REQUIRED); +} + +static void test_selected_account_keeps_its_own_freshness_age(void) +{ + meter_account_selection_t selection; + meter_account_selection_init(&selection); + usage_snapshot_t snapshot = two_accounts(); + snapshot.captured_at = 101; + snapshot.freshness_tick = 200000; + + meter_account_selection_reconcile(&selection, &snapshot); + + assert(snapshot.captured_at == 100); + assert(snapshot.freshness_tick == 199000); + assert(meter_account_selection_step(&selection, &snapshot, 1)); + assert(snapshot.captured_at == 101); + assert(snapshot.freshness_tick == 200000); +} + +int main(void) +{ + test_selects_and_wraps(); + test_stable_id_survives_refresh_and_reorder(); + test_missing_selection_falls_back_and_single_account_ignores_buttons(); + test_legacy_payload_is_unchanged(); + test_transport_failure_cannot_be_hidden_by_account_switch(); + test_selected_account_keeps_its_own_freshness_age(); + puts("account selection tests passed"); + return 0; +} diff --git a/firmware/host_tests/stubs/freertos/FreeRTOS.h b/firmware/host_tests/stubs/freertos/FreeRTOS.h index 4abcf3a..5c80754 100644 --- a/firmware/host_tests/stubs/freertos/FreeRTOS.h +++ b/firmware/host_tests/stubs/freertos/FreeRTOS.h @@ -3,3 +3,5 @@ #include typedef uint32_t TickType_t; + +#define configTICK_RATE_HZ 1000 diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index adce786..d6b8771 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,9 +1,11 @@ idf_component_register( SRCS "main.c" + "meter_account_selection.c" "meter_activity_policy.c" "meter_battery.c" "meter_battery_policy.c" + "meter_buttons.c" "meter_config.c" "meter_config_requirements.c" "meter_display.c" diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild index a1c1bbd..5c5d48b 100644 --- a/firmware/main/Kconfig.projbuild +++ b/firmware/main/Kconfig.projbuild @@ -115,6 +115,29 @@ config METER_HOST_RETRY_LIMIT endmenu +menu "Account selection" + +config METER_ACCOUNT_BUTTON_GPIO18 + bool "Use onboard GPIO18 Key3 for account selection" + default y + help + Uses the board's normal user key as an active-low account selector. + A short press selects the next account and a press held for at least + 700 ms selects the previous account. Both gestures are ignored when + the host provides only one account. + +config METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0 + bool "Enable an independent external GPIO0 previous-account button" + default n + help + Advanced hardware option only. It expects a separate active-low + external button wired directly from GPIO0 to ground. The onboard Key2 + is NOT suitable: its mechanically linked second pole also pulls + CHIP_PU low, resetting the ESP32-S3 and potentially selecting the ROM + download mode. Leave this disabled for the supported reference board. + +endmenu + menu "Display and appearance" config METER_AUTO_ROTATE diff --git a/firmware/main/main.c b/firmware/main/main.c index bca50d1..51bd174 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -10,7 +10,9 @@ #include "freertos/semphr.h" #include "freertos/task.h" #include "lvgl.h" +#include "meter_account_selection.h" #include "meter_battery.h" +#include "meter_buttons.h" #include "meter_display.h" #include "meter_orientation.h" #include "meter_ui.h" @@ -27,6 +29,8 @@ static const char *TAG = "usage_meter"; static SemaphoreHandle_t s_snapshot_mutex; +static meter_account_selection_t s_account_selection; +static meter_link_state_t s_transport_link_state = METER_LINK_CONNECTING; static usage_snapshot_t s_snapshot = { .valid = false, .link_state = METER_LINK_CONNECTING, @@ -56,7 +60,11 @@ static void on_transport_snapshot(const usage_snapshot_t *snapshot, void *contex { (void)context; xSemaphoreTake(s_snapshot_mutex, portMAX_DELAY); + s_transport_link_state = snapshot->link_state; s_snapshot = *snapshot; + meter_account_selection_reconcile(&s_account_selection, &s_snapshot); + meter_account_selection_apply_transport_link( + &s_snapshot, s_transport_link_state); xSemaphoreGive(s_snapshot_mutex); } @@ -64,7 +72,9 @@ static void on_transport_link(meter_link_state_t state, void *context) { (void)context; xSemaphoreTake(s_snapshot_mutex, portMAX_DELAY); - s_snapshot.link_state = state; + s_transport_link_state = state; + meter_account_selection_apply_transport_link( + &s_snapshot, s_transport_link_state); xSemaphoreGive(s_snapshot_mutex); } @@ -72,14 +82,44 @@ static void on_transport_setup(const char *ssid, const char *password, void *con { (void)context; xSemaphoreTake(s_snapshot_mutex, portMAX_DELAY); + s_transport_link_state = METER_LINK_SETUP; s_snapshot.valid = false; s_snapshot.link_state = METER_LINK_SETUP; + s_snapshot.account_count = 0; + s_snapshot.selected_account = 0; snprintf(s_snapshot.setup_ssid, sizeof(s_snapshot.setup_ssid), "%s", ssid ? ssid : ""); snprintf(s_snapshot.setup_psk, sizeof(s_snapshot.setup_psk), "%s", password ? password : ""); xSemaphoreGive(s_snapshot_mutex); } +#if CONFIG_METER_ACCOUNT_BUTTON_GPIO18 || CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0 +static void on_account_button(meter_account_button_action_t action, void *context) +{ + (void)context; + unsigned selected = 0; + unsigned count = 0; + + xSemaphoreTake(s_snapshot_mutex, portMAX_DELAY); + bool changed = meter_account_selection_step( + &s_account_selection, &s_snapshot, + action == METER_ACCOUNT_BUTTON_PREVIOUS ? -1 : 1); + if (changed) { + meter_account_selection_apply_transport_link( + &s_snapshot, s_transport_link_state); + selected = (unsigned)s_snapshot.selected_account + 1U; + count = s_snapshot.account_count; + } + xSemaphoreGive(s_snapshot_mutex); + + if (changed) { + /* The display already presents the requested identity. Keep email + * addresses and aliases out of persistent serial logs. */ + ESP_LOGI(TAG, "Selected account %u/%u", selected, count); + } +} +#endif + static void on_battery_snapshot( const meter_battery_snapshot_t *snapshot, void *context) @@ -170,6 +210,7 @@ void app_main(void) s_snapshot_mutex = xSemaphoreCreateMutex(); ESP_ERROR_CHECK(s_snapshot_mutex ? ESP_OK : ESP_ERR_NO_MEM); + meter_account_selection_init(&s_account_selection); vTaskDelay(pdMS_TO_TICKS(DISPLAY_POWER_SETTLE_MS)); lv_display_t *display = meter_display_start(); @@ -220,6 +261,14 @@ void app_main(void) esp_err_to_name(battery_result)); } +#if CONFIG_METER_ACCOUNT_BUTTON_GPIO18 || CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0 + esp_err_t buttons_result = meter_buttons_start(on_account_button, NULL); + if (buttons_result != ESP_OK) { + ESP_LOGW(TAG, "Account buttons unavailable: %s", + esp_err_to_name(buttons_result)); + } +#endif + const meter_transport_callbacks_t callbacks = { .on_snapshot = on_transport_snapshot, .on_link = on_transport_link, diff --git a/firmware/main/meter_account_selection.c b/firmware/main/meter_account_selection.c new file mode 100644 index 0000000..4f24209 --- /dev/null +++ b/firmware/main/meter_account_selection.c @@ -0,0 +1,121 @@ +#include "meter_account_selection.h" + +#include +#include +#include + +static void apply_account(const meter_account_selection_t *selection, + usage_snapshot_t *snapshot, uint8_t index) +{ + if (!selection || !snapshot || snapshot->account_count == 0 || + index >= snapshot->account_count || index >= METER_MAX_ACCOUNTS) { + return; + } + + const usage_account_t *account = &snapshot->accounts[index]; + snapshot->selected_account = index; + snapshot->valid = account->valid; + snapshot->link_state = account->link_state; + snapshot->used = account->used; + snapshot->remaining = account->remaining; + snapshot->window_mins = account->window_mins; + snapshot->resets_in = account->resets_in; + snapshot->extra_remaining = account->extra_remaining; + snapshot->extra_resets_in = account->extra_resets_in; + snapshot->captured_at = account->captured_at; + snapshot->freshness_tick = selection->generation_freshness_tick; + if (account->captured_at > 0 && + selection->generation_at > account->captured_at) { + uint64_t age_seconds = + (uint64_t)(selection->generation_at - account->captured_at); + uint64_t age_ticks = age_seconds * (uint64_t)configTICK_RATE_HZ; + if (age_ticks > UINT32_MAX) { + age_ticks = UINT32_MAX; + } + /* Tick subtraction intentionally keeps FreeRTOS wraparound semantics. */ + snapshot->freshness_tick -= (TickType_t)age_ticks; + } + snprintf(snapshot->plan, sizeof(snapshot->plan), "%s", account->plan); + snprintf(snapshot->extra_name, sizeof(snapshot->extra_name), "%s", + account->extra_name); +} + +void meter_account_selection_init(meter_account_selection_t *selection) +{ + if (selection) { + memset(selection, 0, sizeof(*selection)); + } +} + +void meter_account_selection_reconcile( + meter_account_selection_t *selection, + usage_snapshot_t *snapshot) +{ + if (!selection || !snapshot || snapshot->account_count == 0) { + return; + } + if (snapshot->account_count > METER_MAX_ACCOUNTS) { + snapshot->account_count = METER_MAX_ACCOUNTS; + } + selection->generation_at = snapshot->captured_at; + selection->generation_freshness_tick = snapshot->freshness_tick; + + uint8_t selected = 0; + if (selection->selected_id[0] != '\0') { + for (uint8_t index = 0; index < snapshot->account_count; ++index) { + if (strcmp(selection->selected_id, snapshot->accounts[index].id) == 0) { + selected = index; + break; + } + } + } + + snprintf(selection->selected_id, sizeof(selection->selected_id), "%s", + snapshot->accounts[selected].id); + apply_account(selection, snapshot, selected); +} + +bool meter_account_selection_step( + meter_account_selection_t *selection, + usage_snapshot_t *snapshot, + int direction) +{ + if (!selection || !snapshot || snapshot->account_count <= 1 || direction == 0) { + return false; + } + if (snapshot->account_count > METER_MAX_ACCOUNTS) { + snapshot->account_count = METER_MAX_ACCOUNTS; + } + + int count = snapshot->account_count; + int selected = snapshot->selected_account < snapshot->account_count + ? snapshot->selected_account + : 0; + selected = direction > 0 ? (selected + 1) % count + : (selected + count - 1) % count; + snprintf(selection->selected_id, sizeof(selection->selected_id), "%s", + snapshot->accounts[selected].id); + apply_account(selection, snapshot, (uint8_t)selected); + return true; +} + +void meter_account_selection_apply_transport_link( + usage_snapshot_t *snapshot, + meter_link_state_t transport_link) +{ + if (!snapshot) { + return; + } + + /* A broken transport must never look LIVE merely because the selected + * account's last cached entry was healthy. Once transport is live again, + * expose the selected account's own stale/auth/online state. */ + if (transport_link == METER_LINK_LIVE && snapshot->account_count > 0 && + snapshot->account_count <= METER_MAX_ACCOUNTS && + snapshot->selected_account < snapshot->account_count) { + snapshot->link_state = + snapshot->accounts[snapshot->selected_account].link_state; + return; + } + snapshot->link_state = transport_link; +} diff --git a/firmware/main/meter_account_selection.h b/firmware/main/meter_account_selection.h new file mode 100644 index 0000000..4135eb9 --- /dev/null +++ b/firmware/main/meter_account_selection.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include "usage_model.h" + +typedef struct { + char selected_id[METER_ACCOUNT_ID_SIZE]; + int64_t generation_at; + TickType_t generation_freshness_tick; +} meter_account_selection_t; + +void meter_account_selection_init(meter_account_selection_t *selection); + +/** Preserve the stable selected ID across a newly received account list. */ +void meter_account_selection_reconcile( + meter_account_selection_t *selection, + usage_snapshot_t *snapshot); + +/** Move by direction (-1 previous, +1 next), wrapping at the list edges. */ +bool meter_account_selection_step( + meter_account_selection_t *selection, + usage_snapshot_t *snapshot, + int direction); + +/** Combine the physical transport state with the selected account state. */ +void meter_account_selection_apply_transport_link( + usage_snapshot_t *snapshot, + meter_link_state_t transport_link); diff --git a/firmware/main/meter_buttons.c b/firmware/main/meter_buttons.c new file mode 100644 index 0000000..d9ac29c --- /dev/null +++ b/firmware/main/meter_buttons.c @@ -0,0 +1,156 @@ +#include "meter_buttons.h" + +#include +#include + +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sdkconfig.h" + +#define ACCOUNT_NEXT_GPIO GPIO_NUM_18 +#define ACCOUNT_PREVIOUS_GPIO GPIO_NUM_0 +#define BUTTON_POLL_MS 20 +#define BUTTON_DEBOUNCE_MS 40 +#define BUTTON_LONG_PRESS_MS 700 +#define BUTTON_TASK_STACK 1536 +#define BUTTON_TASK_PRIORITY 4 + +typedef struct { + gpio_num_t gpio; + int sampled_level; + int stable_level; + uint32_t sampled_since_ms; + uint32_t pressed_since_ms; + bool pressed; +} button_state_t; + +static const char *TAG = "meter_buttons"; +static meter_account_button_callback_t s_callback; +static void *s_context; +static TaskHandle_t s_task; + +static uint32_t monotonic_ms(void) +{ + return (uint32_t)(esp_timer_get_time() / 1000ULL); +} + +static bool update_button(button_state_t *button, uint32_t now_ms, + uint32_t *held_ms) +{ + int level = gpio_get_level(button->gpio); + if (level != button->sampled_level) { + button->sampled_level = level; + button->sampled_since_ms = now_ms; + return false; + } + if (level == button->stable_level || + now_ms - button->sampled_since_ms < BUTTON_DEBOUNCE_MS) { + return false; + } + + button->stable_level = level; + if (level == 0) { + button->pressed = true; + button->pressed_since_ms = now_ms; + return false; + } + if (!button->pressed) { + return false; + } + + button->pressed = false; + *held_ms = now_ms - button->pressed_since_ms; + return true; +} + +static void buttons_task(void *argument) +{ + (void)argument; + uint32_t now = monotonic_ms(); +#if CONFIG_METER_ACCOUNT_BUTTON_GPIO18 + button_state_t next = { + .gpio = ACCOUNT_NEXT_GPIO, + .sampled_level = gpio_get_level(ACCOUNT_NEXT_GPIO), + .stable_level = gpio_get_level(ACCOUNT_NEXT_GPIO), + .sampled_since_ms = now, + }; +#endif +#if CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0 + button_state_t previous = { + .gpio = ACCOUNT_PREVIOUS_GPIO, + .sampled_level = gpio_get_level(ACCOUNT_PREVIOUS_GPIO), + .stable_level = gpio_get_level(ACCOUNT_PREVIOUS_GPIO), + .sampled_since_ms = now, + }; +#endif + + for (;;) { + now = monotonic_ms(); + uint32_t held_ms = 0; +#if CONFIG_METER_ACCOUNT_BUTTON_GPIO18 + if (update_button(&next, now, &held_ms)) { + meter_account_button_action_t action = + held_ms >= BUTTON_LONG_PRESS_MS ? METER_ACCOUNT_BUTTON_PREVIOUS + : METER_ACCOUNT_BUTTON_NEXT; + s_callback(action, s_context); + } +#endif +#if CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0 + if (update_button(&previous, now, &held_ms)) { + s_callback(METER_ACCOUNT_BUTTON_PREVIOUS, s_context); + } +#endif + vTaskDelay(pdMS_TO_TICKS(BUTTON_POLL_MS)); + } +} + +esp_err_t meter_buttons_start( + meter_account_button_callback_t callback, + void *context) +{ + if (!callback) { + return ESP_ERR_INVALID_ARG; + } + if (s_task) { + return ESP_ERR_INVALID_STATE; + } + + uint64_t pins = 0; +#if CONFIG_METER_ACCOUNT_BUTTON_GPIO18 + pins |= 1ULL << ACCOUNT_NEXT_GPIO; +#endif +#if CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0 + pins |= 1ULL << ACCOUNT_PREVIOUS_GPIO; + ESP_LOGW(TAG, "%s", "External GPIO0 previous button enabled; onboard Key2 also resets CHIP_PU"); +#endif + if (pins == 0) { + return ESP_ERR_NOT_SUPPORTED; + } + const gpio_config_t config = { + .pin_bit_mask = pins, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t result = gpio_config(&config); + if (result != ESP_OK) { + return result; + } + + s_callback = callback; + s_context = context; + if (xTaskCreate(buttons_task, "account_buttons", BUTTON_TASK_STACK, NULL, + BUTTON_TASK_PRIORITY, &s_task) != pdPASS) { + s_callback = NULL; + s_context = NULL; + return ESP_ERR_NO_MEM; + } +#if CONFIG_METER_ACCOUNT_BUTTON_GPIO18 + ESP_LOGI(TAG, "%s", "GPIO18 account selector ready: short=next, long=previous"); +#endif + return ESP_OK; +} diff --git a/firmware/main/meter_buttons.h b/firmware/main/meter_buttons.h new file mode 100644 index 0000000..0dc6485 --- /dev/null +++ b/firmware/main/meter_buttons.h @@ -0,0 +1,16 @@ +#pragma once + +#include "esp_err.h" + +typedef enum { + METER_ACCOUNT_BUTTON_PREVIOUS = -1, + METER_ACCOUNT_BUTTON_NEXT = 1, +} meter_account_button_action_t; + +typedef void (*meter_account_button_callback_t)( + meter_account_button_action_t action, + void *context); + +esp_err_t meter_buttons_start( + meter_account_button_callback_t callback, + void *context); diff --git a/firmware/main/meter_ui.c b/firmware/main/meter_ui.c index d225e6b..c664d86 100644 --- a/firmware/main/meter_ui.c +++ b/firmware/main/meter_ui.c @@ -80,6 +80,7 @@ typedef struct { lv_obj_t *header_status; lv_obj_t *plan; + lv_obj_t *overview_account; lv_obj_t *ring; lv_obj_t *remaining; lv_obj_t *remaining_caption; @@ -90,6 +91,7 @@ typedef struct { lv_obj_t *overview_page_dot; lv_obj_t *detail_window; + lv_obj_t *details_account; lv_obj_t *detail_remaining; lv_obj_t *detail_used; lv_obj_t *detail_reset; @@ -139,6 +141,30 @@ static void set_label_text_fmt(lv_obj_t *label, const char *format, ...) set_label_text(label, text); } +static void render_account_identity(const usage_snapshot_t *snapshot) +{ + const char *label = ""; + unsigned selected = 0; + unsigned count = 0; + if (snapshot->account_count > 0 && + snapshot->account_count <= METER_MAX_ACCOUNTS && + snapshot->selected_account < snapshot->account_count) { + selected = (unsigned)snapshot->selected_account + 1U; + count = snapshot->account_count; + label = snapshot->accounts[snapshot->selected_account].label; + } + + if (count > 1) { + set_label_text_fmt(s_ui.overview_account, "%s | %u/%u", label, selected, + count); + set_label_text_fmt(s_ui.details_account, "%s | %u/%u", label, selected, + count); + } else { + set_label_text(s_ui.overview_account, label); + set_label_text(s_ui.details_account, label); + } +} + static void format_duration(int seconds, char *target, size_t size) { if (seconds < 0) { @@ -548,6 +574,38 @@ static void apply_extra_level(quota_level_t level) s_applied_extra_level = (int)level; } +static void clear_quota_presentation(void) +{ + s_primary_level = QUOTA_LEVEL_UNKNOWN; + s_extra_level = QUOTA_LEVEL_UNKNOWN; + apply_primary_level(s_primary_level); + apply_extra_level(s_extra_level); + + if (lv_arc_get_value(s_ui.ring) != 0) { + lv_arc_set_value(s_ui.ring, 0); + } + if (lv_bar_get_value(s_ui.detail_bar) != 0) { + lv_bar_set_value(s_ui.detail_bar, 0, LV_ANIM_OFF); + } + if (lv_bar_get_value(s_ui.extra_bar) != 0) { + lv_bar_set_value(s_ui.extra_bar, 0, LV_ANIM_OFF); + } + + set_label_text(s_ui.remaining, "--%"); + set_label_text(s_ui.detail_remaining, "--%"); + set_label_text(s_ui.detail_used, "-- used"); + set_label_text(s_ui.health, "WAITING FOR DATA"); + set_label_text(s_ui.window, "CODEX LIMIT"); + set_label_text(s_ui.detail_window, "CODEX LIMIT"); + set_label_text(s_ui.reset, "Waiting for usage data"); + set_label_text(s_ui.detail_reset, "Resets in --"); + set_label_text(s_ui.plan, ""); + set_label_text(s_ui.extra_name, "MODEL LIMIT"); + set_label_text(s_ui.extra_remaining, "--%"); + set_label_text(s_ui.extra_used, "-- used"); + set_label_text(s_ui.extra_reset, "Waiting for usage data"); +} + static void apply_link_state(meter_link_state_t state) { if (s_applied_link_state == (int)state) { @@ -742,8 +800,17 @@ static void create_overview_page(lv_obj_t *screen) lv_obj_set_pos(title, 28, 23); s_ui.plan = make_label(s_ui.overview_page, "", &lv_font_montserrat_12, - lv_color_hex(COLOR_SECONDARY_TEXT)); + lv_color_hex(COLOR_SECONDARY_TEXT)); lv_obj_set_pos(s_ui.plan, 110, 32); + lv_obj_set_width(s_ui.plan, 126); + lv_label_set_long_mode(s_ui.plan, LV_LABEL_LONG_DOT); + + s_ui.overview_account = + make_label(s_ui.overview_page, "", &lv_font_montserrat_12, + lv_color_hex(COLOR_SECONDARY_TEXT)); + lv_obj_set_pos(s_ui.overview_account, 28, 54); + lv_obj_set_width(s_ui.overview_account, 424); + lv_label_set_long_mode(s_ui.overview_account, LV_LABEL_LONG_SCROLL_CIRCULAR); s_ui.ring = lv_arc_create(s_ui.overview_page); lv_obj_set_pos(s_ui.ring, 108, 72); @@ -809,9 +876,16 @@ static void create_details_page(lv_obj_t *screen) lv_obj_add_event_cb(s_ui.details_page, page_event_cb, LV_EVENT_LONG_PRESSED, NULL); lv_obj_t *title = make_label(s_ui.details_page, "Usage details", &lv_font_montserrat_24, - lv_color_hex(COLOR_PRIMARY_TEXT)); + lv_color_hex(COLOR_PRIMARY_TEXT)); lv_obj_set_pos(title, 28, 23); + s_ui.details_account = + make_label(s_ui.details_page, "", &lv_font_montserrat_12, + lv_color_hex(COLOR_SECONDARY_TEXT)); + lv_obj_set_pos(s_ui.details_account, 28, 54); + lv_obj_set_width(s_ui.details_account, 424); + lv_label_set_long_mode(s_ui.details_account, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_t *main_card = make_card(s_ui.details_page, 24, 70, 432, 132); s_ui.detail_window = make_label(main_card, "CODEX LIMIT", &lv_font_montserrat_14, lv_color_hex(COLOR_SECONDARY_TEXT)); @@ -927,6 +1001,7 @@ void meter_ui_render( return; } + render_account_identity(snapshot); apply_link_state(snapshot->link_state); const char *source = transport_source_name(snapshot->source); @@ -949,6 +1024,7 @@ void meter_ui_render( } if (!snapshot->valid) { + clear_quota_presentation(); if (snapshot->link_state == METER_LINK_SETUP && snapshot->setup_ssid[0] != '\0') { set_label_text(s_ui.transport, "Wi-Fi setup"); set_label_text_fmt(s_ui.sync, "Join %s / SETUP", snapshot->setup_ssid); diff --git a/firmware/main/transport_ble_gatt.c b/firmware/main/transport_ble_gatt.c index 27d5ca8..613d7ac 100644 --- a/firmware/main/transport_ble_gatt.c +++ b/firmware/main/transport_ble_gatt.c @@ -6,6 +6,7 @@ #include #include +#include "esp_heap_caps.h" #include "esp_log.h" #include "esp_mac.h" #include "esp_random.h" @@ -98,6 +99,17 @@ static bool s_has_snapshot; static uint8_t s_own_address_type; static char s_device_name[20]; +static void log_heap_state(const char *stage) +{ + ESP_LOGI(TAG, "%s: internal free=%u largest=%u, PSRAM free=%u", stage, + (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL | + MALLOC_CAP_8BIT), + (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | + MALLOC_CAP_8BIT), + (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM | + MALLOC_CAP_8BIT)); +} + static int gatt_access(uint16_t connection_handle, uint16_t attribute_handle, struct ble_gatt_access_ctxt *context, void *argument); static int gap_event(struct ble_gap_event *event, void *argument); @@ -740,6 +752,7 @@ static void host_sync(void) static void host_task(void *argument) { (void)argument; + ESP_LOGI(TAG, "NimBLE host task started"); nimble_port_run(); nimble_port_freertos_deinit(); } @@ -771,8 +784,11 @@ esp_err_t meter_ble_transport_start(const meter_transport_callbacks_t *callbacks } s_callbacks = *callbacks; + log_heap_state("BLE start"); s_connection_mutex = xSemaphoreCreateMutex(); if (!s_connection_mutex) { + ESP_LOGE(TAG, "Connection mutex allocation failed"); + log_heap_state("BLE mutex failure"); return ESP_ERR_NO_MEM; } @@ -786,16 +802,23 @@ esp_err_t meter_ble_transport_start(const meter_transport_callbacks_t *callbacks snprintf(s_device_name, sizeof(s_device_name), "Codex Meter-%02X%02X", mac[4], mac[5]); rotate_challenge(); + log_heap_state("Before NimBLE init"); result = nimble_port_init(); if (result != ESP_OK) { + ESP_LOGE(TAG, "NimBLE init failed: %s", esp_err_to_name(result)); + log_heap_state("NimBLE init failure"); vSemaphoreDelete(s_connection_mutex); s_connection_mutex = NULL; return result; } + log_heap_state("After NimBLE init"); ble_hs_cfg.reset_cb = host_reset; ble_hs_cfg.sync_cb = host_sync; result = register_services(); if (result != ESP_OK) { + ESP_LOGE(TAG, "GATT service registration failed: %s", + esp_err_to_name(result)); + log_heap_state("GATT registration failure"); (void)nimble_port_deinit(); vSemaphoreDelete(s_connection_mutex); s_connection_mutex = NULL; @@ -804,12 +827,16 @@ esp_err_t meter_ble_transport_start(const meter_transport_callbacks_t *callbacks s_message_queue = xQueueCreate(1, sizeof(ble_message_t)); if (!s_message_queue) { + ESP_LOGE(TAG, "BLE message queue allocation failed"); + log_heap_state("BLE queue failure"); (void)nimble_port_deinit(); vSemaphoreDelete(s_connection_mutex); s_connection_mutex = NULL; return ESP_ERR_NO_MEM; } if (xTaskCreate(message_task, "ble_usage", 8192, NULL, 5, NULL) != pdPASS) { + ESP_LOGE(TAG, "BLE message task allocation failed"); + log_heap_state("BLE task failure"); vQueueDelete(s_message_queue); s_message_queue = NULL; (void)nimble_port_deinit(); @@ -818,6 +845,8 @@ esp_err_t meter_ble_transport_start(const meter_transport_callbacks_t *callbacks return ESP_ERR_NO_MEM; } + log_heap_state("Before NimBLE host task"); nimble_port_freertos_init(host_task); + log_heap_state("BLE transport ready"); return ESP_OK; } diff --git a/firmware/main/transport_manager.c b/firmware/main/transport_manager.c index 48660ef..354a659 100644 --- a/firmware/main/transport_manager.c +++ b/firmware/main/transport_manager.c @@ -4,6 +4,7 @@ #include #include +#include "esp_heap_caps.h" #include "esp_log.h" #include "freertos/queue.h" #include "freertos/semphr.h" @@ -15,11 +16,12 @@ /* * These objects are created before NimBLE reserves controller memory. The - * callbacks only copy small snapshots into the app model, so a short queue - * and stack preserve ordering without starving BLE's internal-RAM pool. + * callbacks copy bounded snapshots into the app model. Multi-account snapshots + * are roughly 1 KiB, so keep both the queue storage and the dispatch receive + * buffer in PSRAM instead of permanently inflating the task's internal stack. */ #define MANAGER_EVENT_QUEUE_LENGTH 4 -#define MANAGER_EVENT_TASK_STACK 2560 +#define MANAGER_EVENT_TASK_STACK 3072 #define MANAGER_EVENT_TASK_PRIORITY 6 static const char *TAG = "meter_transport"; @@ -50,6 +52,9 @@ static const source_context_t s_wifi_context = {.source = METER_SOURCE_WIFI}; static const source_context_t s_ble_context = {.source = METER_SOURCE_BLE}; static SemaphoreHandle_t s_lock; static QueueHandle_t s_event_queue; +static StaticQueue_t s_event_queue_control; +static uint8_t *s_event_queue_storage; +static manager_event_t *s_dispatch_event; static meter_transport_callbacks_t s_callbacks; static meter_transport_policy_t s_policy = { .source_state = { @@ -69,22 +74,23 @@ static char s_setup_password[sizeof(((usage_snapshot_t *)0)->setup_psk)]; static void dispatch_task(void *argument) { (void)argument; - manager_event_t event; for (;;) { - if (xQueueReceive(s_event_queue, &event, portMAX_DELAY) != pdTRUE) { + if (xQueueReceive(s_event_queue, s_dispatch_event, portMAX_DELAY) != pdTRUE) { continue; } - switch (event.type) { + switch (s_dispatch_event->type) { case MANAGER_EVENT_SNAPSHOT: - s_callbacks.on_snapshot(&event.data.snapshot, s_callbacks.context); + s_callbacks.on_snapshot(&s_dispatch_event->data.snapshot, + s_callbacks.context); break; case MANAGER_EVENT_LINK: - s_callbacks.on_link(event.data.link, s_callbacks.context); + s_callbacks.on_link(s_dispatch_event->data.link, s_callbacks.context); break; case MANAGER_EVENT_SETUP: if (s_callbacks.on_setup) { - s_callbacks.on_setup(event.data.setup.ssid, event.data.setup.password, + s_callbacks.on_setup(s_dispatch_event->data.setup.ssid, + s_dispatch_event->data.setup.password, s_callbacks.context); } break; @@ -299,8 +305,40 @@ esp_err_t meter_transport_start(const meter_transport_callbacks_t *callbacks) if (!s_lock) { return ESP_ERR_NO_MEM; } - s_event_queue = xQueueCreate(MANAGER_EVENT_QUEUE_LENGTH, sizeof(manager_event_t)); + const size_t event_queue_bytes = + MANAGER_EVENT_QUEUE_LENGTH * sizeof(manager_event_t); + /* Multi-account snapshots are intentionally copied through the queue. Put + * that bounded data storage in byte-addressable PSRAM so display startup + * does not leave NimBLE without a large enough internal-RAM block. Queue + * control and the dispatch task remain in internal RAM. */ + s_event_queue_storage = heap_caps_malloc( + event_queue_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!s_event_queue_storage) { + ESP_LOGE(TAG, "Unable to allocate %u-byte event queue in PSRAM", + (unsigned)event_queue_bytes); + vSemaphoreDelete(s_lock); + s_lock = NULL; + return ESP_ERR_NO_MEM; + } + s_dispatch_event = heap_caps_malloc(sizeof(*s_dispatch_event), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!s_dispatch_event) { + ESP_LOGE(TAG, "Unable to allocate %u-byte dispatch buffer in PSRAM", + (unsigned)sizeof(*s_dispatch_event)); + heap_caps_free(s_event_queue_storage); + s_event_queue_storage = NULL; + vSemaphoreDelete(s_lock); + s_lock = NULL; + return ESP_ERR_NO_MEM; + } + s_event_queue = xQueueCreateStatic( + MANAGER_EVENT_QUEUE_LENGTH, sizeof(manager_event_t), + s_event_queue_storage, &s_event_queue_control); if (!s_event_queue) { + heap_caps_free(s_dispatch_event); + s_dispatch_event = NULL; + heap_caps_free(s_event_queue_storage); + s_event_queue_storage = NULL; vSemaphoreDelete(s_lock); s_lock = NULL; return ESP_ERR_NO_MEM; @@ -310,6 +348,10 @@ esp_err_t meter_transport_start(const meter_transport_callbacks_t *callbacks) MANAGER_EVENT_TASK_PRIORITY, NULL) != pdPASS) { vQueueDelete(s_event_queue); s_event_queue = NULL; + heap_caps_free(s_dispatch_event); + s_dispatch_event = NULL; + heap_caps_free(s_event_queue_storage); + s_event_queue_storage = NULL; vSemaphoreDelete(s_lock); s_lock = NULL; return ESP_ERR_NO_MEM; diff --git a/firmware/main/usage_model.h b/firmware/main/usage_model.h index 4dc895e..5c94540 100644 --- a/firmware/main/usage_model.h +++ b/firmware/main/usage_model.h @@ -5,6 +5,10 @@ #include "freertos/FreeRTOS.h" +#define METER_MAX_ACCOUNTS 4 +#define METER_ACCOUNT_ID_SIZE 33 +#define METER_ACCOUNT_LABEL_SIZE 65 + typedef enum { METER_LINK_SETUP = 0, METER_LINK_CONNECTING, @@ -21,6 +25,22 @@ typedef enum { METER_SOURCE_BLE, } meter_transport_source_t; +typedef struct { + bool valid; + meter_link_state_t link_state; + int used; + int remaining; + int window_mins; + int resets_in; + int extra_remaining; + int extra_resets_in; + int64_t captured_at; + char id[METER_ACCOUNT_ID_SIZE]; + char label[METER_ACCOUNT_LABEL_SIZE]; + char plan[32]; + char extra_name[40]; +} usage_account_t; + typedef struct { bool valid; meter_link_state_t link_state; @@ -39,4 +59,7 @@ typedef struct { char extra_name[40]; char setup_ssid[33]; char setup_psk[16]; + uint8_t account_count; + uint8_t selected_account; + usage_account_t accounts[METER_MAX_ACCOUNTS]; } usage_snapshot_t; diff --git a/firmware/main/usage_payload.c b/firmware/main/usage_payload.c index 1e4d526..a719e77 100644 --- a/firmware/main/usage_payload.c +++ b/firmware/main/usage_payload.c @@ -34,6 +34,191 @@ static void json_string_copy(const cJSON *object, const char *name, char *target snprintf(target, size, "%s", value ? value : ""); } +static meter_link_state_t link_state_from_status(const cJSON *object, bool valid) +{ + const cJSON *status = cJSON_GetObjectItemCaseSensitive(object, "status"); + const char *value = + cJSON_IsString(status) && status->valuestring ? status->valuestring : ""; + if (valid && (strcmp(value, "ok") == 0 || + strcmp(value, "refreshing") == 0)) { + return METER_LINK_LIVE; + } + if (strcmp(value, "auth_required") == 0 || + strcmp(value, "authentication_required") == 0 || + strcmp(value, "pairing") == 0) { + return METER_LINK_AUTH_REQUIRED; + } + if (!valid && (strcmp(value, "starting") == 0 || + strcmp(value, "refreshing") == 0)) { + return METER_LINK_CONNECTING; + } + if (!valid && (strcmp(value, "offline") == 0 || + strcmp(value, "error") == 0)) { + return METER_LINK_OFFLINE; + } + return METER_LINK_STALE; +} + +static void account_plan_copy(const cJSON *object, char *target, size_t size, + const char *fallback) +{ + const cJSON *plan_label = cJSON_GetObjectItemCaseSensitive(object, "planLabel"); + if (cJSON_IsString(plan_label) && plan_label->valuestring && + plan_label->valuestring[0]) { + snprintf(target, size, "%s", plan_label->valuestring); + return; + } + + const cJSON *metadata = cJSON_GetObjectItemCaseSensitive(object, "metadata"); + plan_label = cJSON_IsObject(metadata) + ? cJSON_GetObjectItemCaseSensitive(metadata, "planLabel") + : NULL; + if (cJSON_IsString(plan_label) && plan_label->valuestring && + plan_label->valuestring[0]) { + snprintf(target, size, "%s", plan_label->valuestring); + return; + } + + const cJSON *plan = cJSON_GetObjectItemCaseSensitive(object, "plan"); + if (cJSON_IsString(plan) && plan->valuestring && plan->valuestring[0]) { + snprintf(target, size, "%s", plan->valuestring); + return; + } + snprintf(target, size, "%s", fallback ? fallback : ""); +} + +static void parse_extra(const cJSON *object, usage_account_t *account) +{ + account->extra_remaining = -1; + account->extra_resets_in = -1; + snprintf(account->extra_name, sizeof(account->extra_name), "ADDITIONAL LIMIT"); + + const cJSON *extras = cJSON_GetObjectItemCaseSensitive(object, "extras"); + const cJSON *extra = cJSON_IsArray(extras) ? cJSON_GetArrayItem(extras, 0) : NULL; + if (!cJSON_IsObject(extra)) { + return; + } + + json_string_copy(extra, "name", account->extra_name, + sizeof(account->extra_name), "EXTRA"); + const cJSON *primary = cJSON_GetObjectItemCaseSensitive(extra, "primary"); + if (!cJSON_IsObject(primary)) { + return; + } + + const cJSON *remaining = cJSON_GetObjectItemCaseSensitive(primary, "remaining"); + const cJSON *used = cJSON_GetObjectItemCaseSensitive(primary, "used"); + if (cJSON_IsNumber(remaining)) { + account->extra_remaining = clamp_percent(remaining->valueint); + account->extra_resets_in = json_int(primary, "resetsIn", -1); + } else if (cJSON_IsNumber(used)) { + account->extra_remaining = 100 - clamp_percent(used->valueint); + account->extra_resets_in = json_int(primary, "resetsIn", -1); + } +} + +static bool parse_account_usage(const cJSON *object, const char *fallback_plan, + usage_account_t *account) +{ + memset(account, 0, sizeof(*account)); + account->resets_in = -1; + account->extra_remaining = -1; + account->extra_resets_in = -1; + account_plan_copy(object, account->plan, sizeof(account->plan), fallback_plan); + parse_extra(object, account); + + const cJSON *captured_at = cJSON_GetObjectItemCaseSensitive(object, "capturedAt"); + account->captured_at = + cJSON_IsNumber(captured_at) ? (int64_t)captured_at->valuedouble : 0; + + const cJSON *preferred = cJSON_GetObjectItemCaseSensitive(object, "preferred"); + const cJSON *primary = cJSON_IsObject(preferred) + ? cJSON_GetObjectItemCaseSensitive(preferred, "primary") + : NULL; + const cJSON *used = cJSON_IsObject(primary) + ? cJSON_GetObjectItemCaseSensitive(primary, "used") + : NULL; + const cJSON *remaining = cJSON_IsObject(primary) + ? cJSON_GetObjectItemCaseSensitive(primary, "remaining") + : NULL; + const bool has_used = cJSON_IsNumber(used); + const bool has_remaining = cJSON_IsNumber(remaining); + account->valid = has_used || has_remaining; + account->link_state = link_state_from_status(object, account->valid); + if (!account->valid) { + return false; + } + + account->used = has_used ? clamp_percent(used->valueint) + : 100 - clamp_percent(remaining->valueint); + account->remaining = has_remaining ? clamp_percent(remaining->valueint) + : 100 - account->used; + account->window_mins = json_int(primary, "windowMins", 0); + account->resets_in = json_int(primary, "resetsIn", -1); + return true; +} + +static void copy_account_usage(const usage_account_t *account, usage_snapshot_t *result) +{ + result->valid = account->valid; + result->link_state = account->link_state; + result->used = account->used; + result->remaining = account->remaining; + result->window_mins = account->window_mins; + result->resets_in = account->resets_in; + result->extra_remaining = account->extra_remaining; + result->extra_resets_in = account->extra_resets_in; + result->captured_at = account->captured_at; + snprintf(result->plan, sizeof(result->plan), "%s", account->plan); + snprintf(result->extra_name, sizeof(result->extra_name), "%s", + account->extra_name); +} + +static bool parse_accounts(const cJSON *root, const char *fallback_plan, + usage_snapshot_t *result) +{ + const cJSON *accounts = cJSON_GetObjectItemCaseSensitive(root, "accounts"); + if (!accounts) { + return true; + } + if (!cJSON_IsArray(accounts)) { + return false; + } + + int count = cJSON_GetArraySize(accounts); + if (count < 0 || count > METER_MAX_ACCOUNTS) { + return false; + } + for (int index = 0; index < count; ++index) { + const cJSON *item = cJSON_GetArrayItem(accounts, index); + const cJSON *id = cJSON_IsObject(item) + ? cJSON_GetObjectItemCaseSensitive(item, "id") + : NULL; + const cJSON *label = cJSON_IsObject(item) + ? cJSON_GetObjectItemCaseSensitive(item, "label") + : NULL; + if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0] || + strlen(id->valuestring) >= METER_ACCOUNT_ID_SIZE || + !cJSON_IsString(label) || !label->valuestring || !label->valuestring[0] || + strlen(label->valuestring) >= METER_ACCOUNT_LABEL_SIZE) { + return false; + } + for (int previous = 0; previous < index; ++previous) { + if (strcmp(result->accounts[previous].id, id->valuestring) == 0) { + return false; + } + } + + usage_account_t *account = &result->accounts[index]; + (void)parse_account_usage(item, fallback_plan, account); + snprintf(account->id, sizeof(account->id), "%s", id->valuestring); + snprintf(account->label, sizeof(account->label), "%s", label->valuestring); + } + result->account_count = (uint8_t)count; + result->selected_account = 0; + return true; +} + bool meter_usage_payload_parse(const char *json, usage_snapshot_t *result) { if (!json || !result) { @@ -48,73 +233,22 @@ bool meter_usage_payload_parse(const char *json, usage_snapshot_t *result) } const cJSON *version = cJSON_GetObjectItemCaseSensitive(root, "v"); - if (!cJSON_IsNumber(version) || version->valueint != 1) { - goto cleanup; - } - - const cJSON *preferred = cJSON_GetObjectItemCaseSensitive(root, "preferred"); - const cJSON *primary = - cJSON_IsObject(preferred) ? cJSON_GetObjectItemCaseSensitive(preferred, "primary") : NULL; - if (!cJSON_IsObject(primary)) { - goto cleanup; - } - - const cJSON *used_item = cJSON_GetObjectItemCaseSensitive(primary, "used"); - const cJSON *remaining_item = cJSON_GetObjectItemCaseSensitive(primary, "remaining"); - bool has_used = cJSON_IsNumber(used_item); - bool has_remaining = cJSON_IsNumber(remaining_item); - if (!has_used && !has_remaining) { + if (!cJSON_IsObject(root) || !cJSON_IsNumber(version) || version->valueint != 1) { goto cleanup; } memset(result, 0, sizeof(*result)); - result->valid = true; - const cJSON *status = cJSON_GetObjectItemCaseSensitive(root, "status"); - result->link_state = - cJSON_IsString(status) && status->valuestring && - strcmp(status->valuestring, "ok") == 0 - ? METER_LINK_LIVE - : METER_LINK_STALE; - result->used = - has_used ? clamp_percent(used_item->valueint) - : 100 - clamp_percent(remaining_item->valueint); - result->remaining = - has_remaining ? clamp_percent(remaining_item->valueint) : 100 - result->used; - result->window_mins = json_int(primary, "windowMins", 0); - result->resets_in = json_int(primary, "resetsIn", -1); + usage_account_t top_level; + bool top_level_valid = parse_account_usage(root, "", &top_level); + copy_account_usage(&top_level, result); result->next_poll_in = json_int(root, "nextPollIn", HOST_REFRESH_FALLBACK_SECONDS); - result->extra_remaining = -1; - result->extra_resets_in = -1; - const cJSON *captured_at = cJSON_GetObjectItemCaseSensitive(root, "capturedAt"); - result->captured_at = cJSON_IsNumber(captured_at) ? (int64_t)captured_at->valuedouble : 0; result->received_tick = xTaskGetTickCount(); result->freshness_tick = result->received_tick; - - const cJSON *plan_label = cJSON_GetObjectItemCaseSensitive(root, "planLabel"); - if (cJSON_IsString(plan_label) && plan_label->valuestring && plan_label->valuestring[0]) { - snprintf(result->plan, sizeof(result->plan), "%s", plan_label->valuestring); - } else { - json_string_copy(root, "plan", result->plan, sizeof(result->plan), ""); + if (!parse_accounts(root, top_level.plan, result)) { + goto cleanup; } - snprintf(result->extra_name, sizeof(result->extra_name), "ADDITIONAL LIMIT"); - - const cJSON *extras = cJSON_GetObjectItemCaseSensitive(root, "extras"); - const cJSON *extra = cJSON_IsArray(extras) ? cJSON_GetArrayItem(extras, 0) : NULL; - if (cJSON_IsObject(extra)) { - json_string_copy(extra, "name", result->extra_name, sizeof(result->extra_name), "EXTRA"); - const cJSON *extra_primary = cJSON_GetObjectItemCaseSensitive(extra, "primary"); - if (cJSON_IsObject(extra_primary)) { - const cJSON *extra_remaining = - cJSON_GetObjectItemCaseSensitive(extra_primary, "remaining"); - const cJSON *extra_used = cJSON_GetObjectItemCaseSensitive(extra_primary, "used"); - if (cJSON_IsNumber(extra_remaining)) { - result->extra_remaining = clamp_percent(extra_remaining->valueint); - result->extra_resets_in = json_int(extra_primary, "resetsIn", -1); - } else if (cJSON_IsNumber(extra_used)) { - result->extra_remaining = 100 - clamp_percent(extra_used->valueint); - result->extra_resets_in = json_int(extra_primary, "resetsIn", -1); - } - } + if (!top_level_valid && result->account_count == 0) { + goto cleanup; } success = true; diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index a0e713e..092f311 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -61,6 +61,12 @@ CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=256 CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2 # CONFIG_BT_NIMBLE_50_FEATURE_SUPPORT is not set # CONFIG_BT_NIMBLE_DTM_MODE_TEST is not set +# The display and multi-account snapshots leave less contiguous internal DRAM +# at transport startup. The ESP32-S3 board has octal PSRAM, and ESP-IDF's +# NimBLE port supports placing host-stack dynamic allocations there while the +# controller keeps using the capabilities it requires internally. +# CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_INTERNAL is not set +CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_EXTERNAL=y CONFIG_BT_NIMBLE_MEM_OPTIMIZATION=y CONFIG_BT_NIMBLE_STATIC_TO_DYNAMIC=y CONFIG_BT_CTRL_BLE_MAX_ACT=2 diff --git a/public/app.js b/public/app.js index e769646..8c11157 100644 --- a/public/app.js +++ b/public/app.js @@ -1,24 +1,66 @@ -const elements = { - planTitle: document.getElementById("plan-title"), - status: document.getElementById("status"), - remaining: document.getElementById("remaining"), - window: document.getElementById("window"), - used: document.getElementById("used"), - reset: document.getElementById("reset"), - captured: document.getElementById("captured"), - bar: document.getElementById("bar"), - ring: document.getElementById("ring"), - extraName: document.getElementById("extra-name"), - extraRemaining: document.getElementById("extra-remaining"), - extraReset: document.getElementById("extra-reset"), - nextPoll: document.getElementById("next-poll"), - hostId: document.getElementById("host-id"), - pairingToken: document.getElementById("pairing-token"), - copyToken: document.getElementById("copy-token"), -}; - -let latest = null; -let pairingToken = null; +const LEGACY_ACCOUNT_ID = "legacy"; + +function nonEmptyText(value, fallback) { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} + +export function accountsFromPayload(payload) { + if (Array.isArray(payload?.accounts)) { + const usedIds = new Set(); + return payload.accounts.map((account, index) => { + let id = nonEmptyText(account?.id, `account-${index + 1}`); + if (usedIds.has(id)) id = `${id}-${index + 1}`; + usedIds.add(id); + return { + id, + // The top-level label already applies an explicit profile alias when + // configured. Fall back to the validated account/read email for + // additive payloads produced by older host revisions. + label: nonEmptyText( + account?.label, + nonEmptyText(account?.usage?.account?.email, id), + ), + status: nonEmptyText(account?.status, "error"), + usage: account?.usage ?? null, + lastError: nonEmptyText(account?.lastError, null), + }; + }); + } + + const usage = payload?.usage ?? null; + const id = nonEmptyText(usage?.account?.id, LEGACY_ACCOUNT_ID); + return [{ + id, + label: nonEmptyText( + usage?.account?.label, + nonEmptyText( + usage?.account?.email, + id === LEGACY_ACCOUNT_ID ? "当前账号" : id, + ), + ), + status: nonEmptyText(payload?.service?.status, usage ? "ok" : "error"), + usage, + lastError: nonEmptyText(payload?.service?.lastError, null), + }]; +} + +export function reconcileSelectedAccountId(accounts, selectedId) { + if (accounts.some((account) => account.id === selectedId)) return selectedId; + return accounts[0]?.id ?? null; +} + +export function adjacentAccountId(accounts, selectedId, direction) { + const current = reconcileSelectedAccountId(accounts, selectedId); + if (!current || accounts.length <= 1) return current; + const index = accounts.findIndex((account) => account.id === current); + const offset = direction < 0 ? -1 : 1; + return accounts[(index + offset + accounts.length) % accounts.length].id; +} + +export function accountPosition(accounts, selectedId) { + const index = accounts.findIndex((account) => account.id === selectedId); + return index < 0 ? null : { index: index + 1, total: accounts.length }; +} function timeText(epochSeconds) { if (!epochSeconds) return "--"; @@ -38,95 +80,236 @@ function ageText(isoText) { return `${Math.floor(seconds / 60)} 分钟前同步`; } -function render(payload) { - latest = payload; - const { service, usage } = payload; - const primary = usage?.preferred?.primary; - const extra = Object.values(usage?.buckets ?? {}).find( - (bucket) => bucket.limitId !== usage?.preferred?.limitId, - ); - const plan = usage?.account?.planType; - - elements.planTitle.textContent = plan - ? `${String(plan).toUpperCase()} · Codex 额度` - : "Codex 额度"; - elements.status.textContent = - service.status === "ok" ? "实时" : service.status === "stale" ? "缓存" : "异常"; - elements.status.className = `status ${service.status === "ok" ? "ok" : "error"}`; - - const remaining = primary?.remainingPercent ?? 0; - const used = primary?.usedPercent ?? 0; - elements.remaining.textContent = Math.round(remaining); - elements.used.textContent = `已使用 ${Math.round(used)}%`; - elements.window.textContent = primary?.windowDurationMins - ? `${primary.windowDurationMins / 1440} 天窗口` - : "额度窗口"; - elements.reset.textContent = `重置于 ${timeText(primary?.resetsAt)}`; - elements.ring.style.setProperty("--used", `${Math.min(100, used) * 3.6}deg`); - elements.bar.style.width = `${Math.min(100, used)}%`; - - elements.captured.textContent = ageText(usage?.capturedAt); - elements.extraName.textContent = extra?.limitName ?? extra?.limitId ?? "附加额度"; - elements.extraRemaining.textContent = extra?.primary - ? `${Math.round(extra.primary.remainingPercent)}%` - : "--%"; - elements.extraReset.textContent = extra?.primary?.resetsAt - ? `重置于 ${timeText(extra.primary.resetsAt)}` - : "暂无独立窗口"; +function statusPresentation(status) { + switch (status) { + case "ok": + return { text: "实时", className: "status ok" }; + case "refreshing": + return { text: "更新中", className: "status pending" }; + case "stale": + return { text: "缓存", className: "status stale" }; + case "starting": + return { text: "等待中", className: "status pending" }; + default: + return { text: "异常", className: "status error" }; + } } -async function refresh() { - try { - const response = await fetch("/api/usage", { cache: "no-store" }); - const payload = await response.json(); - render(payload); - } catch { - elements.status.textContent = "离线"; - elements.status.className = "status error"; +export function initializeDashboard() { + const elements = { + planTitle: document.getElementById("plan-title"), + status: document.getElementById("status"), + accountPrev: document.getElementById("account-prev"), + accountNext: document.getElementById("account-next"), + accountIdentity: document.getElementById("account-identity"), + accountLabel: document.getElementById("account-label"), + accountId: document.getElementById("account-id"), + accountPosition: document.getElementById("account-position"), + accountMessage: document.getElementById("account-message"), + remaining: document.getElementById("remaining"), + window: document.getElementById("window"), + used: document.getElementById("used"), + reset: document.getElementById("reset"), + captured: document.getElementById("captured"), + bar: document.getElementById("bar"), + ring: document.getElementById("ring"), + extraName: document.getElementById("extra-name"), + extraRemaining: document.getElementById("extra-remaining"), + extraReset: document.getElementById("extra-reset"), + nextPoll: document.getElementById("next-poll"), + hostId: document.getElementById("host-id"), + pairingToken: document.getElementById("pairing-token"), + copyToken: document.getElementById("copy-token"), + }; + + let latest = null; + let accounts = []; + let selectedAccountId = null; + let pairingToken = null; + + function setMessage(text) { + elements.accountMessage.textContent = text ?? ""; + elements.accountMessage.hidden = !text; + } + + function clearUsage() { + elements.planTitle.textContent = "Codex 额度"; + elements.remaining.textContent = "--"; + elements.used.textContent = "已使用 --%"; + elements.window.textContent = "额度窗口"; + elements.reset.textContent = "重置时间 --"; + elements.ring.style.setProperty("--used", "0deg"); + elements.bar.style.width = "0%"; + elements.captured.textContent = "尚未同步"; + elements.extraName.textContent = "附加额度"; + elements.extraRemaining.textContent = "--%"; + elements.extraReset.textContent = "等待数据"; } -} -function updateClock() { - if (latest) { - elements.captured.textContent = ageText(latest.usage?.capturedAt); - const next = Date.parse(latest.service?.nextPollAt); + function renderSelectedAccount() { + selectedAccountId = reconcileSelectedAccountId(accounts, selectedAccountId); + const selected = accounts.find((account) => account.id === selectedAccountId) ?? null; + const position = accountPosition(accounts, selectedAccountId); + const multiple = accounts.length > 1; + + elements.accountPrev.hidden = !multiple; + elements.accountNext.hidden = !multiple; + elements.accountPrev.disabled = !multiple; + elements.accountNext.disabled = !multiple; + elements.accountPosition.hidden = !multiple; + elements.accountIdentity.style.gridColumn = multiple ? "2" : "1 / -1"; + elements.accountPosition.textContent = multiple && position + ? `${position.index}/${position.total}` + : ""; + elements.accountLabel.textContent = selected?.label ?? "未找到账号"; + elements.accountId.textContent = selected?.id ?? "--"; + + const presentation = statusPresentation(selected?.status ?? "error"); + elements.status.textContent = presentation.text; + elements.status.className = presentation.className; + clearUsage(); + + const usage = selected?.usage; + if (!selected) { + setMessage("没有可显示的账号,请先注册账号或检查主机服务。"); + return; + } + if (!usage) { + setMessage(selected.lastError + ? `账号读取失败:${selected.lastError}` + : "该账号尚无可显示的额度数据。"); + return; + } + + const primary = usage.preferred?.primary; + const extra = Object.values(usage.buckets ?? {}).find( + (bucket) => bucket.limitId !== usage.preferred?.limitId, + ); + const plan = usage.account?.planType; + elements.planTitle.textContent = plan + ? `${String(plan).toUpperCase()} · Codex 额度` + : "Codex 额度"; + elements.captured.textContent = ageText(usage.capturedAt); + + if (primary) { + const remaining = Number(primary.remainingPercent); + const used = Number(primary.usedPercent); + const safeRemaining = Number.isFinite(remaining) ? Math.max(0, remaining) : null; + const safeUsed = Number.isFinite(used) ? Math.min(100, Math.max(0, used)) : null; + elements.remaining.textContent = safeRemaining == null ? "--" : Math.round(safeRemaining); + elements.used.textContent = safeUsed == null + ? "已使用 --%" + : `已使用 ${Math.round(safeUsed)}%`; + elements.window.textContent = primary.windowDurationMins + ? `${primary.windowDurationMins / 1440} 天窗口` + : "额度窗口"; + elements.reset.textContent = `重置于 ${timeText(primary.resetsAt)}`; + elements.ring.style.setProperty("--used", `${(safeUsed ?? 0) * 3.6}deg`); + elements.bar.style.width = `${safeUsed ?? 0}%`; + } + + elements.extraName.textContent = extra?.limitName ?? extra?.limitId ?? "附加额度"; + elements.extraRemaining.textContent = extra?.primary + ? `${Math.round(extra.primary.remainingPercent)}%` + : "--%"; + elements.extraReset.textContent = extra?.primary?.resetsAt + ? `重置于 ${timeText(extra.primary.resetsAt)}` + : "暂无独立窗口"; + + if (selected.status === "stale") { + setMessage(selected.lastError + ? `当前显示缓存数据:${selected.lastError}` + : "当前显示该账号的缓存数据。"); + } else if (!primary) { + setMessage("该账号没有可显示的主要额度窗口。"); + } else { + setMessage(null); + } + } + + function render(payload) { + latest = payload; + accounts = accountsFromPayload(payload); + selectedAccountId = reconcileSelectedAccountId(accounts, selectedAccountId); + renderSelectedAccount(); + } + + async function refresh() { + try { + const response = await fetch("/api/usage", { cache: "no-store" }); + const payload = await response.json(); + if (!response.ok && !Array.isArray(payload.accounts) && !payload.usage) { + throw new Error(`HTTP ${response.status}`); + } + render(payload); + } catch { + latest = null; + const selected = accounts.find((account) => account.id === selectedAccountId); + accounts = selected + ? [{ ...selected, status: "error", usage: null, lastError: "主机服务离线" }] + : []; + renderSelectedAccount(); + elements.status.textContent = "离线"; + elements.status.className = "status error"; + } + } + + function updateClock() { + const selected = accounts.find((account) => account.id === selectedAccountId); + elements.captured.textContent = ageText(selected?.usage?.capturedAt); + const next = Date.parse(latest?.service?.nextPollAt); + elements.nextPoll.textContent = "每分钟自动刷新"; if (Number.isFinite(next)) { const seconds = Math.max(0, Math.ceil((next - Date.now()) / 1000)); elements.nextPoll.textContent = `${seconds} 秒后刷新`; } } -} -async function refreshHostIdentity() { - try { - const response = await fetch("/api/host", { cache: "no-store" }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const identity = await response.json(); - pairingToken = identity.pairingToken; - elements.hostId.textContent = identity.hostId; - elements.pairingToken.textContent = identity.pairingToken; - elements.copyToken.disabled = false; - } catch { - elements.hostId.textContent = "不可用"; - elements.pairingToken.textContent = "不可用"; - elements.copyToken.disabled = true; + async function refreshHostIdentity() { + try { + const response = await fetch("/api/host", { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const identity = await response.json(); + pairingToken = identity.pairingToken; + elements.hostId.textContent = identity.hostId; + elements.pairingToken.textContent = identity.pairingToken; + elements.copyToken.disabled = false; + } catch { + elements.hostId.textContent = "不可用"; + elements.pairingToken.textContent = "不可用"; + elements.copyToken.disabled = true; + } } -} -elements.copyToken.addEventListener("click", async () => { - if (!pairingToken) return; - try { - await navigator.clipboard.writeText(pairingToken); - elements.copyToken.textContent = "已复制"; - setTimeout(() => { - elements.copyToken.textContent = "复制令牌"; - }, 1_500); - } catch { - elements.copyToken.textContent = "复制失败"; - } -}); + elements.accountPrev.addEventListener("click", () => { + selectedAccountId = adjacentAccountId(accounts, selectedAccountId, -1); + renderSelectedAccount(); + }); + elements.accountNext.addEventListener("click", () => { + selectedAccountId = adjacentAccountId(accounts, selectedAccountId, 1); + renderSelectedAccount(); + }); + elements.copyToken.addEventListener("click", async () => { + if (!pairingToken) return; + try { + await navigator.clipboard.writeText(pairingToken); + elements.copyToken.textContent = "已复制"; + setTimeout(() => { + elements.copyToken.textContent = "复制令牌"; + }, 1_500); + } catch { + elements.copyToken.textContent = "复制失败"; + } + }); + + void refresh(); + void refreshHostIdentity(); + setInterval(refresh, 5_000); + setInterval(updateClock, 1_000); -await refresh(); -await refreshHostIdentity(); -setInterval(refresh, 5_000); -setInterval(updateClock, 1_000); + return { refresh, render }; +} + +if (typeof document !== "undefined") { + initializeDashboard(); +} diff --git a/public/index.html b/public/index.html index 9b69497..8360457 100644 --- a/public/index.html +++ b/public/index.html @@ -16,6 +16,19 @@

Codex 额度

连接中 + + +
diff --git a/public/styles.css b/public/styles.css index 65d4274..d369bc1 100644 --- a/public/styles.css +++ b/public/styles.css @@ -65,6 +65,89 @@ header { align-items: flex-start; } +.account-switcher { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 38px; + gap: 10px; + align-items: center; + margin-top: 15px; + padding: 10px 12px; + border: 1px solid rgb(255 255 255 / 7%); + border-radius: 16px; + background: rgb(255 255 255 / 3%); +} + +.account-switcher button { + width: 38px; + height: 34px; + padding: 0; + color: #8de8b9; + font: inherit; + font-size: 17px; + cursor: pointer; + border: 1px solid rgb(69 231 146 / 20%); + border-radius: 11px; + background: rgb(26 156 92 / 10%); +} + +.account-switcher button:hover { + background: rgb(26 156 92 / 19%); +} + +.account-switcher button:focus-visible { + outline: 2px solid #66ecaa; + outline-offset: 2px; +} + +.account-switcher button[hidden] { + visibility: hidden; +} + +.account-identity { + min-width: 0; + text-align: center; +} + +.account-identity span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-identity strong { + display: block; + color: #e8fff3; + font-size: 13px; + font-weight: 650; + overflow-wrap: anywhere; +} + +.account-identity span { + margin-top: 3px; + color: #68756f; + font-size: 10px; +} + +.account-identity code { + color: inherit; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; +} + +.account-identity b { + margin-left: 7px; + color: #52dfa0; + font-weight: 650; +} + +.account-message { + margin: 8px 4px -12px; + color: #d99a7b; + font-size: 10px; + line-height: 1.45; + text-align: center; +} + .eyebrow, h1, .hero-copy p, @@ -106,10 +189,20 @@ h1 { border-color: rgb(255 128 94 / 25%); } +.status.stale { + color: #f2c66d; + border-color: rgb(242 198 109 / 25%); + background: rgb(190 132 30 / 10%); +} + +.status.pending { + color: #a7bbb3; +} + .hero { justify-content: flex-start; gap: 26px; - margin: 27px 0 24px; + margin: 20px 0 24px; } .ring { diff --git a/scripts/add-account.ps1 b/scripts/add-account.ps1 new file mode 100644 index 0000000..32a812b --- /dev/null +++ b/scripts/add-account.ps1 @@ -0,0 +1,165 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$')] + [string]$Name, + + [ValidateLength(1, 32)] + [string]$Label, + + [ValidatePattern('^[A-Za-z0-9](?:[A-Za-z0-9 +_-]*[A-Za-z0-9])?$')] + [ValidateLength(1, 20)] + [string]$PlanLabel, + + [string]$AccountsDirectory, + [string]$CodexExecutable, + [switch]$DeviceAuth +) + +$ErrorActionPreference = 'Stop' + +if ($Name.Contains('@')) { + throw 'Profile names must be non-email slugs such as personal or work.' +} +if ($Label -and ($Label.ToCharArray() | Where-Object { [char]::IsControl($_) })) { + throw 'Label must not contain control characters.' +} +if ($Label -and $Label.Contains('@')) { + throw 'Label must be a short alias, not an email address.' +} +if ($Label -and -not $Label.Trim()) { + throw 'Label must not contain only whitespace.' +} +if ($Label -and $Label.Trim() -notmatch '^[\x20-\x7e]+$') { + throw 'Label must use printable ASCII characters supported by the device font.' +} + +if (-not $AccountsDirectory) { + if ($env:METER_ACCOUNTS_DIR) { + $AccountsDirectory = $env:METER_ACCOUNTS_DIR + } + elseif ($env:METER_STATE_DIR) { + $AccountsDirectory = Join-Path $env:METER_STATE_DIR 'accounts' + } + elseif ($env:LOCALAPPDATA) { + $AccountsDirectory = Join-Path $env:LOCALAPPDATA 'CodexUsageMeter\accounts' + } + elseif ($env:XDG_STATE_HOME) { + $AccountsDirectory = Join-Path $env:XDG_STATE_HOME 'codex-usage-meter\accounts' + } + else { + $AccountsDirectory = Join-Path $HOME '.local\state\codex-usage-meter\accounts' + } +} +$AccountsDirectory = [IO.Path]::GetFullPath($AccountsDirectory) +[void](New-Item -ItemType Directory -Path $AccountsDirectory -Force) + +$profilePath = Join-Path $AccountsDirectory $Name +if (Test-Path -LiteralPath $profilePath) { + throw "Account profile already exists: $Name" +} +$registered = @( + Get-ChildItem -LiteralPath $AccountsDirectory -Directory -ErrorAction Stop | + Where-Object { $_.Name -match '^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$' } +) +if ($registered.Count -ge 4) { + throw 'CodexMeter supports at most four registered account profiles.' +} + +if (-not $CodexExecutable) { + $CodexExecutable = $env:CODEX_EXECUTABLE +} +if (-not $CodexExecutable) { + $bundled = Join-Path $PSScriptRoot '..\.runtime\codex.exe' + if (Test-Path -LiteralPath $bundled -PathType Leaf) { + $CodexExecutable = $bundled + } +} +if (-not $CodexExecutable) { + $command = Get-Command codex -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($command) { + $CodexExecutable = $command.Source + } +} +if (-not $CodexExecutable -or -not (Test-Path -LiteralPath $CodexExecutable -PathType Leaf)) { + throw 'Codex executable was not found. Run prepare-windows.ps1 or pass -CodexExecutable.' +} +$CodexExecutable = [IO.Path]::GetFullPath($CodexExecutable) + +$pendingPath = Join-Path $AccountsDirectory ('.pending-' + [guid]::NewGuid().ToString('N')) +$accountsPrefix = $AccountsDirectory.TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar +) + [IO.Path]::DirectorySeparatorChar +$pendingPath = [IO.Path]::GetFullPath($pendingPath) +if (-not $pendingPath.StartsWith($accountsPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Refusing to create a temporary profile outside the accounts directory.' +} +$codexHome = Join-Path $pendingPath 'codex-home' +$hadCodexHome = Test-Path Env:CODEX_HOME +$previousCodexHome = $env:CODEX_HOME +$credentialEnvironment = @{} +try { + [void](New-Item -ItemType Directory -Path $codexHome -Force) + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + $configText = @( + '# Isolated CodexMeter profile. Credentials remain inside this CODEX_HOME.' + 'cli_auth_credentials_store = "file"' + ) -join [Environment]::NewLine + [IO.File]::WriteAllText( + (Join-Path $codexHome 'config.toml'), + $configText + [Environment]::NewLine, + $utf8NoBom + ) + + foreach ($variableName in @('OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN')) { + $environmentPath = 'Env:' + $variableName + if (Test-Path $environmentPath) { + $credentialEnvironment[$variableName] = [Environment]::GetEnvironmentVariable($variableName, 'Process') + Remove-Item $environmentPath + } + } + $env:CODEX_HOME = $codexHome + $loginArguments = @('login') + if ($DeviceAuth) { $loginArguments += '--device-auth' } + & $CodexExecutable @loginArguments + if ($LASTEXITCODE -ne 0) { + throw "codex login failed with exit code $LASTEXITCODE" + } + + $metadata = [ordered]@{ + v = 1 + displayLabel = if ($Label) { $Label.Trim() } else { $null } + planLabel = if ($PlanLabel) { $PlanLabel.Trim().ToUpperInvariant() } else { $null } + } + [IO.File]::WriteAllText( + (Join-Path $pendingPath 'profile.json'), + ($metadata | ConvertTo-Json) + [Environment]::NewLine, + $utf8NoBom + ) + Move-Item -LiteralPath $pendingPath -Destination $profilePath +} +finally { + if ($hadCodexHome) { + $env:CODEX_HOME = $previousCodexHome + } + else { + Remove-Item Env:CODEX_HOME -ErrorAction SilentlyContinue + } + foreach ($variableName in $credentialEnvironment.Keys) { + [Environment]::SetEnvironmentVariable( + $variableName, + $credentialEnvironment[$variableName], + 'Process' + ) + } + if (Test-Path -LiteralPath $pendingPath) { + # pendingPath was normalized and containment-checked above. Never make + # this cleanup target depend on CODEX_HOME or another mutable variable. + Remove-Item -LiteralPath $pendingPath -Recurse -Force + } +} + +Write-Host "Registered CodexMeter account profile '$Name'." +Write-Host "The host service will discover it automatically at its next scheduling tick." diff --git a/scripts/install-autostart.ps1 b/scripts/install-autostart.ps1 index 1efc070..2ad8fe9 100644 --- a/scripts/install-autostart.ps1 +++ b/scripts/install-autostart.ps1 @@ -26,12 +26,22 @@ if ($null -ne $PlanLabel -and $PlanLabel.Length -gt 0) { throw "PlanLabel must be 1-20 ASCII letters, numbers, spaces, +, _, or -." } } -# Prefer Windows PowerShell because its in-box path remains stable across Store updates. -$shell = Get-Command powershell -CommandType Application -ErrorAction SilentlyContinue -if (-not $shell) { - $shell = Get-Command pwsh -CommandType Application -ErrorAction Stop +# Prefer the explicit in-box Windows PowerShell path because it remains stable +# across Store updates. Get-Command can return multiple application matches +# (for example, the packaged pwsh.exe and its WindowsApps alias), so the +# fallback must deliberately select exactly one executable. +$inboxPowerShell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" +if (Test-Path -LiteralPath $inboxPowerShell -PathType Leaf) { + $shellPath = $inboxPowerShell } -$command = "`"$($shell.Source)`" -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$runner`" -IntervalMs $IntervalMs -Transport $Transport" +else { + $shellPath = Get-Command pwsh -CommandType Application -ErrorAction Stop | + Select-Object -First 1 -ExpandProperty Source +} +if (-not $shellPath -or -not (Test-Path -LiteralPath $shellPath -PathType Leaf)) { + throw "Could not resolve a usable PowerShell executable for autostart." +} +$command = "`"$shellPath`" -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$runner`" -IntervalMs $IntervalMs -Transport $Transport" if ($PlanLabel) { $command += " -PlanLabel `"$PlanLabel`"" } diff --git a/scripts/prepare-windows.ps1 b/scripts/prepare-windows.ps1 index 4de27cf..c16a252 100644 --- a/scripts/prepare-windows.ps1 +++ b/scripts/prepare-windows.ps1 @@ -8,6 +8,15 @@ $projectRoot = Split-Path -Parent $PSScriptRoot $runtimeDir = Join-Path $projectRoot ".runtime" $destination = Join-Path $runtimeDir "codex.exe" +# PowerShell 7 prepends its own modules to PSModulePath. A Windows PowerShell +# child launched from that environment can otherwise discover the incompatible +# PowerShell 7 Security module before its in-box module and fail before startup. +$securityModule = Join-Path $PSHOME "Modules\Microsoft.PowerShell.Security\Microsoft.PowerShell.Security.psd1" +if (-not (Test-Path -LiteralPath $securityModule -PathType Leaf)) { + throw "Could not locate the PowerShell Security module used to verify the Codex runtime." +} +Import-Module -Name $securityModule -ErrorAction Stop + function Test-OpenAIRuntime([string]$Path) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false diff --git a/src/account-registry.mjs b/src/account-registry.mjs new file mode 100644 index 0000000..834c3c9 --- /dev/null +++ b/src/account-registry.mjs @@ -0,0 +1,192 @@ +import { mkdir, readFile, readdir, realpath, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { normalizePlanLabelOverride } from "./plan-label.mjs"; + +export const MAX_ACCOUNT_PROFILES = 4; +// The firmware stores the stable ID in a 33-byte buffer (32 ASCII characters +// plus NUL), so reject longer directory names at the host boundary. +const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/; +const DISPLAY_LABEL_MAX_LENGTH = 32; +const DISPLAY_LABEL_PATTERN = /^[\x20-\x7e]+$/; + +function safeDisplayLabel(value, source) { + if (value == null || value === "") return null; + if (typeof value !== "string") { + throw new Error(`displayLabel in ${source} must be a string`); + } + const label = value.trim(); + if ( + label.length === 0 || + label.length > DISPLAY_LABEL_MAX_LENGTH || + !DISPLAY_LABEL_PATTERN.test(label) || + label.includes("@") || + /[\u0000-\u001f\u007f]/.test(label) + ) { + throw new Error( + `displayLabel in ${source} must contain 1-${DISPLAY_LABEL_MAX_LENGTH} printable ASCII non-email characters`, + ); + } + return label; +} + +async function fileFingerprint(filePath) { + try { + const info = await stat(filePath, { bigint: true }); + return `${path.basename(filePath)}:${info.size}:${info.mtimeNs}`; + } catch (error) { + if (error.code === "ENOENT") return `${path.basename(filePath)}:missing`; + throw error; + } +} + +async function runtimeFingerprint(codexHome) { + // Deliberately inspect metadata only. Credentials are never opened, parsed, + // copied, or logged by the meter. + const parts = await Promise.all([ + fileFingerprint(path.join(codexHome, "auth.json")), + fileFingerprint(path.join(codexHome, "config.toml")), + ]); + return parts.join("|"); +} + +async function readProfileMetadata(profileDirectory) { + const filePath = path.join(profileDirectory, "profile.json"); + let text; + try { + text = await readFile(filePath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") { + return { displayLabel: null, planLabel: null, fingerprint: "profile.json:missing" }; + } + throw error; + } + + let value; + try { + value = JSON.parse(text); + } catch { + throw new Error(`Invalid JSON in ${filePath}`); + } + if (!value || value.v !== 1) { + throw new Error(`Invalid profile metadata version in ${filePath}`); + } + return { + displayLabel: safeDisplayLabel(value.displayLabel, filePath), + planLabel: normalizePlanLabelOverride(value.planLabel ?? null), + fingerprint: await fileFingerprint(filePath), + }; +} + +function isInside(parent, child) { + const relative = path.relative(parent, child); + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +export function defaultAccountsDirectory({ stateDirectory, env = process.env }) { + return env.METER_ACCOUNTS_DIR + ? path.resolve(env.METER_ACCOUNTS_DIR) + : path.join(stateDirectory, "accounts"); +} + +export function defaultCodexHome(env = process.env) { + return env.CODEX_HOME + ? path.resolve(env.CODEX_HOME) + : path.join(os.homedir(), ".codex"); +} + +export async function discoverAccountProfiles({ + accountsDirectory, + fallbackCodexHome = defaultCodexHome(), + maxAccounts = MAX_ACCOUNT_PROFILES, +}) { + await mkdir(accountsDirectory, { recursive: true }); + const canonicalRoot = await realpath(accountsDirectory); + const entries = await readdir(canonicalRoot, { withFileTypes: true }); + const profileEntries = entries.filter( + (entry) => entry.isDirectory() || entry.isSymbolicLink(), + ); + const invalidEntryIds = profileEntries + .filter((entry) => + !PROFILE_ID_PATTERN.test(entry.name) && + !entry.name.startsWith(".pending-")) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right, "en")); + const candidates = profileEntries + .filter((entry) => PROFILE_ID_PATTERN.test(entry.name)) + .sort((left, right) => left.name.localeCompare(right.name, "en")); + + const profiles = []; + const invalidProfiles = [...invalidEntryIds]; + for (const entry of candidates) { + const profileDirectory = path.join(canonicalRoot, entry.name); + let canonicalProfile; + try { + canonicalProfile = await realpath(profileDirectory); + } catch { + invalidProfiles.push(entry.name); + continue; + } + if (!isInside(canonicalRoot, canonicalProfile)) { + invalidProfiles.push(entry.name); + continue; + } + const codexHome = path.join(canonicalProfile, "codex-home"); + let canonicalCodexHome; + try { + canonicalCodexHome = await realpath(codexHome); + } catch (error) { + if (error.code === "ENOENT") { + invalidProfiles.push(entry.name); + continue; + } + throw error; + } + if (!isInside(canonicalProfile, canonicalCodexHome)) { + invalidProfiles.push(entry.name); + continue; + } + try { + const metadata = await readProfileMetadata(canonicalProfile); + profiles.push({ + id: entry.name, + codexHome: canonicalCodexHome, + inheritEnvironment: false, + displayLabel: metadata.displayLabel, + planLabel: metadata.planLabel, + fingerprint: `${metadata.fingerprint}|${await runtimeFingerprint(canonicalCodexHome)}`, + }); + } catch { + invalidProfiles.push(entry.name); + } + } + + if ( + profiles.length === 0 && + candidates.length === 0 && + invalidProfiles.length === 0 + ) { + const codexHome = path.resolve(fallbackCodexHome); + return { + explicit: false, + profiles: [{ + id: "default", + codexHome, + inheritEnvironment: true, + displayLabel: null, + planLabel: null, + fingerprint: await runtimeFingerprint(codexHome), + }], + ignoredProfileIds: [], + invalidProfileIds: invalidProfiles, + }; + } + + return { + explicit: true, + profiles: profiles.slice(0, maxAccounts), + ignoredProfileIds: profiles.slice(maxAccounts).map((profile) => profile.id), + invalidProfileIds: invalidProfiles, + }; +} diff --git a/src/app-server-client.mjs b/src/app-server-client.mjs index e77cebc..6843c87 100644 --- a/src/app-server-client.mjs +++ b/src/app-server-client.mjs @@ -4,9 +4,18 @@ import { createInterface } from "node:readline"; const DEFAULT_TIMEOUT_MS = 20_000; export class AppServerClient { - constructor({ command, args = ["app-server", "--listen", "stdio://"] }) { + constructor({ + command, + args = ["app-server", "--listen", "stdio://"], + env = {}, + unsetEnv = [], + spawnImpl = spawn, + }) { this.command = command; this.args = args; + this.env = { ...env }; + this.unsetEnv = [...unsetEnv]; + this.spawnImpl = spawnImpl; this.process = null; this.pending = new Map(); this.nextId = 1; @@ -19,10 +28,15 @@ export class AppServerClient { throw new Error("app-server is already running"); } - const child = spawn(this.command, this.args, { + const childEnv = { ...process.env, ...this.env }; + const unsetNames = new Set(this.unsetEnv.map((name) => name.toLowerCase())); + for (const name of Object.keys(childEnv)) { + if (unsetNames.has(name.toLowerCase())) delete childEnv[name]; + } + const child = this.spawnImpl(this.command, this.args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, - env: process.env, + env: childEnv, }); this.process = child; diff --git a/src/daemon.mjs b/src/daemon.mjs index a0c2221..d3736f8 100644 --- a/src/daemon.mjs +++ b/src/daemon.mjs @@ -4,6 +4,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { AppServerClient } from "./app-server-client.mjs"; +import { + defaultAccountsDirectory, + defaultCodexHome, + discoverAccountProfiles, +} from "./account-registry.mjs"; import { BleHelperSupervisor } from "./ble-helper-supervisor.mjs"; import { toDevicePayload } from "./device-payload.mjs"; import { buildDeviceJsonResponse } from "./device-response.mjs"; @@ -15,6 +20,7 @@ import { MDNS_SERVICE_TYPE, startMdnsAdvertiser, } from "./mdns-advertiser.mjs"; +import { MultiAccountMeter } from "./multi-account-meter.mjs"; import { readUsage } from "./read-usage.mjs"; import { normalizePlanLabelOverride } from "./plan-label.mjs"; import { @@ -84,6 +90,10 @@ validatePollInterval(pollIntervalMs); const state = { status: "starting", snapshot: null, + accounts: [], + explicitProfiles: false, + ignoredProfileIds: [], + invalidProfileIds: [], lastError: null, lastPollAt: null, nextPollAt: null, @@ -121,7 +131,23 @@ function transportDiagnostics() { const stateDirectory = defaultStateDirectory(); const daemonLock = await acquireSingleInstanceLock({ stateDirectory }); const identity = await loadOrCreateHostIdentity({ stateDirectory }); -const client = new AppServerClient({ command: await resolveCodexCommand() }); +const codexCommand = await resolveCodexCommand(); +const accountsDirectory = defaultAccountsDirectory({ stateDirectory }); +const accountMeter = new MultiAccountMeter({ + discoverProfiles: () => discoverAccountProfiles({ + accountsDirectory, + fallbackCodexHome: defaultCodexHome(), + }), + createClient: (profile) => new AppServerClient({ + command: codexCommand, + env: profile.inheritEnvironment ? {} : { CODEX_HOME: profile.codexHome }, + unsetEnv: profile.inheritEnvironment + ? [] + : ["OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN"], + }), + readUsage, + pollIntervalMs, +}); function serviceState() { return { @@ -132,13 +158,24 @@ function serviceState() { consecutiveFailures: state.consecutiveFailures, lastPollDelayMs: state.lastPollDelayMs, lastError: state.lastError, + accountCount: state.accounts.length, + explicitProfiles: state.explicitProfiles, + ignoredProfileIds: state.ignoredProfileIds, + invalidProfileIds: state.invalidProfileIds, transports: transportDiagnostics(), }; } function devicePayload() { - return toDevicePayload(state.snapshot, serviceState(), Date.now(), { + const compatibleAccount = state.accounts.find( + (account) => account.usage === state.snapshot, + ); + return toDevicePayload(state.snapshot, { + ...serviceState(), + status: compatibleAccount?.status ?? state.status, + }, Date.now(), { planLabel: planLabelOverride, + accounts: state.accounts, }); } @@ -146,15 +183,23 @@ async function poll() { if (pollPromise) return pollPromise; pollPromise = (async () => { - let succeeded = false; state.status = state.snapshot ? "refreshing" : "starting"; state.lastPollAt = new Date().toISOString(); try { - state.snapshot = await readUsage(client); - state.status = "ok"; - state.lastError = null; - state.consecutiveFailures = 0; - succeeded = true; + const meterState = await accountMeter.poll(); + state.snapshot = meterState.snapshot; + state.accounts = meterState.accounts; + state.explicitProfiles = meterState.explicitProfiles; + state.ignoredProfileIds = meterState.ignoredProfileIds; + state.invalidProfileIds = meterState.invalidProfileIds; + state.status = meterState.status; + const failedAccount = meterState.accounts.find((account) => account.lastError); + state.lastError = failedAccount + ? `${failedAccount.id}: ${failedAccount.lastError}` + : null; + state.consecutiveFailures = state.status === "error" + ? state.consecutiveFailures + 1 + : 0; } catch (error) { state.status = state.snapshot ? "stale" : "error"; state.lastError = error.message; @@ -162,7 +207,9 @@ async function poll() { } finally { const schedule = nextPollSchedule({ baseIntervalMs: pollIntervalMs, - succeeded, + // Account failures have independent backoff. Keep the global discovery + // and round-robin scheduler running at the configured total cadence. + succeeded: true, consecutiveFailures: state.consecutiveFailures, }); state.lastPollDelayMs = schedule.delayMs; @@ -311,8 +358,25 @@ const server = http.createServer(async (request, response) => { }); return; } - const statusCode = state.snapshot ? 200 : 503; - const payload = devicePayload(); + // A discovered account list remains useful even when every account is + // awaiting authentication or has no renderable quota yet. New firmware can + // show those identities/states; legacy firmware safely rejects no-data v1. + const statusCode = state.snapshot || state.accounts.length > 0 ? 200 : 503; + let payload; + try { + payload = devicePayload(); + } catch (error) { + const value = { v: 1, status: "error", error: error.message }; + if (isTrustedLocalRequest) { + sendJson(response, 503, value); + } else { + sendDeviceJson(response, 503, value, { + nonce, + remote: !isLoopback, + }); + } + return; + } if (isTrustedLocalRequest) { sendJson(response, statusCode, payload); } else { @@ -347,6 +411,7 @@ const server = http.createServer(async (request, response) => { sendJson(response, state.snapshot ? 200 : 503, { service: serviceState(), usage: state.snapshot, + accounts: state.accounts, }); return; } @@ -367,7 +432,12 @@ const server = http.createServer(async (request, response) => { devicePath: "/api/device", auth: "hmac-sha256", mdnsService: MDNS_SERVICE_TYPE, - planLabel: devicePayload().planLabel, + planLabel: toDevicePayload( + state.snapshot, + serviceState(), + Date.now(), + { planLabel: planLabelOverride }, + ).planLabel, }); return; } @@ -395,7 +465,7 @@ async function shutdown() { if (bleSupervisor) await bleSupervisor.stop(); if (mdnsAdvertiser) await mdnsAdvertiser.stop(); await new Promise((resolve) => server.close(resolve)); - await client.stop(); + await accountMeter.stop(); await daemonLock.release(); } @@ -416,7 +486,6 @@ await new Promise((resolve, reject) => { // Acquire the local port before touching the Codex runtime. A duplicate // launcher now fails with EADDRINUSE without performing an extra usage read. -await client.start(); await poll(); wifiTransportState.state = transport.wifi ? "listening" : "disabled"; diff --git a/src/device-payload.mjs b/src/device-payload.mjs index 3918a52..ab08336 100644 --- a/src/device-payload.mjs +++ b/src/device-payload.mjs @@ -1,12 +1,47 @@ import { devicePlanLabel } from "./plan-label.mjs"; +export const MAX_DEVICE_PAYLOAD_BYTES = 2016; +// usage_model.h reserves 65 bytes including the terminating NUL. Keep the +// host boundary in lockstep with that fixed firmware buffer and never truncate +// an account identity silently. +export const MAX_DEVICE_ACCOUNT_LABEL_BYTES = 64; + +function deviceAccountLabel(value, accountId) { + if ( + typeof value !== "string" || + value.length === 0 || + !/^[\x20-\x7e]+$/.test(value) + ) { + throw new TypeError( + `Account label for ${accountId ?? "unknown"} must be printable ASCII`, + ); + } + const encodedBytes = Buffer.byteLength(value, "utf8"); + if (encodedBytes > MAX_DEVICE_ACCOUNT_LABEL_BYTES) { + throw new RangeError( + `Account label for ${accountId ?? "unknown"} is ${encodedBytes} bytes; maximum is ${MAX_DEVICE_ACCOUNT_LABEL_BYTES}`, + ); + } + return value; +} + +function boundedUtf8(value, maximumBytes) { + if (typeof value !== "string" || value.length === 0) return null; + if (Buffer.byteLength(value, "utf8") <= maximumBytes) return value; + let result = ""; + for (const character of value) { + if (Buffer.byteLength(result + character, "utf8") > maximumBytes) break; + result += character; + } + return result || null; +} + function compactWindow(window, nowSeconds) { if (!window) return null; return { used: window.usedPercent, remaining: window.remainingPercent, windowMins: window.windowDurationMins, - resetsAt: window.resetsAt, resetsIn: window.resetsAt == null ? null @@ -14,12 +49,80 @@ function compactWindow(window, nowSeconds) { }; } -function compactBucket(bucket, nowSeconds) { - return { - id: bucket.limitId, - name: bucket.limitName, +function compactBucket(bucket, nowSeconds, includeName = false) { + if (!bucket) return null; + const compact = { primary: compactWindow(bucket.primary, nowSeconds), - secondary: compactWindow(bucket.secondary, nowSeconds), + }; + if (includeName) compact.name = boundedUtf8(bucket.limitName, 39); + return compact; +} + +// The compatibility mirror above must retain the original v1 shape for older +// firmware. Account entries are additive and can use the smaller shape that the +// multi-account firmware actually renders: the preferred primary window and +// the first additional primary window. Keeping secondary/reset-at data here +// would duplicate data that the device never reads and can push four otherwise +// ordinary accounts over the shared BLE stream limit. +function compactAccountWindow(window, nowSeconds) { + if (!window) return null; + return { + remaining: window.remainingPercent, + windowMins: window.windowDurationMins, + resetsIn: + window.resetsAt == null + ? null + : Math.max(0, Math.round(window.resetsAt - nowSeconds)), + }; +} + +function compactAccountBucket(bucket, nowSeconds, includeName = false) { + if (!bucket) return null; + const compact = { + primary: compactAccountWindow(bucket.primary, nowSeconds), + }; + if (includeName) compact.name = boundedUtf8(bucket.limitName, 39); + return compact; +} + +function compactAccountUsage(snapshot, status, nowSeconds, planLabel) { + const preferredId = snapshot?.preferred?.limitId ?? null; + const plan = snapshot?.account?.planType ?? null; + const firstExtra = Object.values(snapshot?.buckets ?? {}) + .find((bucket) => bucket && bucket.limitId !== preferredId); + return { + status: status === "refreshing" && snapshot ? "ok" : status, + capturedAt: snapshot + ? Math.floor(Date.parse(snapshot.capturedAt) / 1000) + : null, + planLabel: devicePlanLabel(plan, planLabel), + preferred: snapshot?.preferred + ? compactAccountBucket(snapshot.preferred, nowSeconds) + : null, + extras: firstExtra + ? [compactAccountBucket(firstExtra, nowSeconds, true)] + : [], + }; +} + +function compactUsage(snapshot, status, nowSeconds, planLabel) { + const preferredId = snapshot?.preferred?.limitId ?? null; + const plan = snapshot?.account?.planType ?? null; + const extras = Object.values(snapshot?.buckets ?? {}) + .filter((bucket) => bucket && bucket.limitId !== preferredId) + .slice(0, 1) + .map((bucket) => compactBucket(bucket, nowSeconds, true)); + return { + status: status === "refreshing" && snapshot ? "ok" : status, + capturedAt: snapshot + ? Math.floor(Date.parse(snapshot.capturedAt) / 1000) + : null, + plan: boundedUtf8(plan, 31), + planLabel: devicePlanLabel(plan, planLabel), + preferred: snapshot?.preferred + ? compactBucket(snapshot.preferred, nowSeconds) + : null, + extras, }; } @@ -27,33 +130,67 @@ export function toDevicePayload( snapshot, service, nowMs = Date.now(), - { planLabel = null } = {}, + { planLabel = null, accounts = null } = {}, ) { const nowSeconds = Math.floor(nowMs / 1000); - const preferredId = snapshot?.preferred?.limitId ?? null; - const plan = snapshot?.account?.planType ?? null; - const status = service.status === "refreshing" && snapshot - ? "ok" - : service.status; - const extras = Object.values(snapshot?.buckets ?? {}) - .filter((bucket) => bucket?.limitId !== preferredId) - .map((bucket) => compactBucket(bucket, nowSeconds)); - - return { + const compatiblePlanLabel = snapshot?.account?.planLabel ?? planLabel; + const compatible = compactUsage( + snapshot, + service.status, + nowSeconds, + compatiblePlanLabel, + ); + const payload = { v: 1, - status, - capturedAt: snapshot ? Math.floor(Date.parse(snapshot.capturedAt) / 1000) : null, + ...compatible, nextPollAt: service.nextPollAt ? Math.floor(Date.parse(service.nextPollAt) / 1000) : null, nextPollIn: service.nextPollAt ? Math.max(0, Math.round((Date.parse(service.nextPollAt) - nowMs) / 1000)) : null, - plan, - planLabel: devicePlanLabel(plan, planLabel), - preferred: snapshot?.preferred - ? compactBucket(snapshot.preferred, nowSeconds) - : null, - extras, }; + + if (Array.isArray(accounts)) { + payload.accounts = accounts.map((account) => { + const usage = account.usage ?? null; + const accountPlanLabel = usage?.account?.planLabel ?? planLabel; + return { + id: account.id, + label: deviceAccountLabel(account.label, account.id), + ...compactAccountUsage( + usage, + account.status, + nowSeconds, + accountPlanLabel, + ), + }; + }); + if (payload.accounts.length > 0) { + const accountTimes = payload.accounts + .map((account) => account.capturedAt) + .filter((value) => Number.isSafeInteger(value)); + const lastPollSeconds = service.lastPollAt + ? Math.floor(Date.parse(service.lastPollAt) / 1000) + : null; + if (Number.isSafeInteger(lastPollSeconds)) accountTimes.push(lastPollSeconds); + if (Number.isSafeInteger(payload.capturedAt)) { + accountTimes.push(payload.capturedAt); + } + if (accountTimes.length > 0) { + // capturedAt is also the transport generation key. Include the host + // registry/poll generation so account deletion and non-mirror updates + // cannot make it move backwards across Wi-Fi/BLE failover. + payload.capturedAt = Math.max(...accountTimes); + } + } + } + + const encodedBytes = Buffer.byteLength(JSON.stringify(payload), "utf8") + 1; + if (encodedBytes > MAX_DEVICE_PAYLOAD_BYTES) { + throw new RangeError( + `Device payload is ${encodedBytes} bytes; maximum is ${MAX_DEVICE_PAYLOAD_BYTES}`, + ); + } + return payload; } diff --git a/src/multi-account-meter.mjs b/src/multi-account-meter.mjs new file mode 100644 index 0000000..cc3601b --- /dev/null +++ b/src/multi-account-meter.mjs @@ -0,0 +1,280 @@ +import { failurePollDelayMs, validatePollInterval } from "./poll-schedule.mjs"; + +function isoTime(value) { + return value == null ? null : new Date(value).toISOString(); +} + +function safeErrorMessage(error) { + const message = error instanceof Error ? error.message : String(error); + return message + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[account]") + .replace(/[\u0000-\u001f\u007f]/g, " ") + .slice(0, 240); +} + +function decorateSnapshot(snapshot, profile) { + if (!snapshot) return null; + const identity = snapshot.account; + const label = profile.displayLabel ?? identity?.label ?? profile.id; + return { + ...snapshot, + account: identity + ? { + ...identity, + id: profile.id, + label, + planLabel: profile.planLabel, + } + : { + id: profile.id, + type: null, + planType: null, + emailPresent: false, + email: null, + label, + planLabel: profile.planLabel, + }, + }; +} + +function hasRenderableUsage(snapshot) { + const primary = snapshot?.preferred?.primary; + return Boolean( + primary && + (Number.isFinite(primary.usedPercent) || + Number.isFinite(primary.remainingPercent)), + ); +} + +function createEntry(profile) { + return { + profile, + client: null, + identity: null, + snapshot: null, + status: "starting", + lastError: null, + lastPollAtMs: null, + lastIdentityAtMs: null, + nextEligibleAtMs: 0, + consecutiveFailures: 0, + needsInitialPoll: true, + }; +} + +export class MultiAccountMeter { + constructor({ + discoverProfiles, + createClient, + readUsage, + pollIntervalMs, + identityRefreshMs = null, + now = Date.now, + }) { + if (typeof discoverProfiles !== "function") { + throw new Error("Multi-account meter requires profile discovery"); + } + if (typeof createClient !== "function" || typeof readUsage !== "function") { + throw new Error("Multi-account meter requires client and usage factories"); + } + validatePollInterval(pollIntervalMs); + const effectiveIdentityRefreshMs = identityRefreshMs ?? pollIntervalMs; + if ( + !Number.isSafeInteger(effectiveIdentityRefreshMs) || + effectiveIdentityRefreshMs < pollIntervalMs + ) { + throw new Error("Identity refresh interval must be an integer at least as large as polling"); + } + this.discoverProfiles = discoverProfiles; + this.createClient = createClient; + this.readUsage = readUsage; + this.pollIntervalMs = pollIntervalMs; + // account/read uses refreshToken:false and is therefore a local identity + // check, not an OAuth refresh. Run it whenever this account is due so a + // default CODEX_HOME/keyring account switch cannot retain the old label. + this.identityRefreshMs = effectiveIdentityRefreshMs; + this.now = now; + this.entries = new Map(); + this.profileOrder = []; + this.roundRobinIndex = 0; + this.discovery = { + explicit: false, + ignoredProfileIds: [], + invalidProfileIds: [], + }; + this.polling = false; + } + + async poll() { + if (this.polling) return this.getState(); + this.polling = true; + try { + await this.#reconcile(); + const initialEntries = this.profileOrder + .map((id) => this.entries.get(id)) + .filter((entry) => entry.needsInitialPoll); + + if (initialEntries.length > 0) { + // Initial registration is bounded to four profiles and intentionally + // serial. Steady-state ticks below issue work for at most one account. + for (const entry of initialEntries) { + await this.#pollEntry(entry); + } + } else { + const selected = this.#nextDueEntry(this.now()); + if (selected) await this.#pollEntry(selected); + } + } finally { + this.polling = false; + } + return this.getState(); + } + + getState() { + const accounts = this.profileOrder.map((id) => { + const entry = this.entries.get(id); + const label = + entry.snapshot?.account?.label ?? + entry.profile.displayLabel ?? + entry.profile.id; + return { + id: entry.profile.id, + label, + status: entry.status, + usage: entry.snapshot, + lastError: entry.lastError, + lastPollAt: isoTime(entry.lastPollAtMs), + nextPollAt: isoTime(entry.nextEligibleAtMs), + consecutiveFailures: entry.consecutiveFailures, + }; + }); + // The v1 compatibility mirror must contain a usable primary window. Keep + // no-data accounts in accounts[] for identity/status display, but never let + // one hide a later healthy account from old firmware. + const compatibleAccount = + accounts.find( + (account) => account.status === "ok" && hasRenderableUsage(account.usage), + ) ?? + accounts.find((account) => hasRenderableUsage(account.usage)); + const snapshot = compatibleAccount?.usage ?? null; + const failures = accounts.filter((account) => account.status !== "ok"); + let status = "starting"; + if (snapshot && failures.length === 0) status = "ok"; + else if (snapshot) status = "stale"; + else if (accounts.length > 0 && accounts.every((account) => account.status === "error")) { + status = "error"; + } + else if ( + accounts.length === 0 && + (this.discovery.invalidProfileIds.length > 0 || + this.discovery.ignoredProfileIds.length > 0) + ) { + status = "error"; + } + if (this.polling && snapshot && status === "ok") status = "refreshing"; + + return { + status, + snapshot, + accounts, + explicitProfiles: this.discovery.explicit, + ignoredProfileIds: [...this.discovery.ignoredProfileIds], + invalidProfileIds: [...this.discovery.invalidProfileIds], + }; + } + + async stop() { + const clients = [...this.entries.values()] + .map((entry) => entry.client) + .filter(Boolean); + this.entries.clear(); + this.profileOrder = []; + await Promise.allSettled(clients.map((client) => client.stop())); + } + + async #reconcile() { + const discovery = await this.discoverProfiles(); + this.discovery = discovery; + const desiredIds = new Set(discovery.profiles.map((profile) => profile.id)); + + for (const [id, entry] of this.entries) { + if (desiredIds.has(id)) continue; + if (entry.client) await entry.client.stop(); + this.entries.delete(id); + } + + for (const profile of discovery.profiles) { + const existing = this.entries.get(profile.id); + if (!existing) { + this.entries.set(profile.id, createEntry(profile)); + continue; + } + if (existing.profile.fingerprint !== profile.fingerprint) { + if (existing.client) await existing.client.stop(); + existing.client = null; + existing.profile = profile; + existing.identity = null; + existing.status = existing.snapshot ? "stale" : "starting"; + existing.lastError = null; + existing.nextEligibleAtMs = 0; + existing.consecutiveFailures = 0; + existing.needsInitialPoll = true; + } else { + existing.profile = profile; + } + } + + this.profileOrder = discovery.profiles.map((profile) => profile.id); + if (this.roundRobinIndex >= this.profileOrder.length) this.roundRobinIndex = 0; + } + + #nextDueEntry(nowMs) { + const count = this.profileOrder.length; + if (count === 0) return null; + for (let offset = 0; offset < count; offset += 1) { + const index = (this.roundRobinIndex + offset) % count; + const entry = this.entries.get(this.profileOrder[index]); + if (entry.nextEligibleAtMs <= nowMs) { + this.roundRobinIndex = (index + 1) % count; + return entry; + } + } + return null; + } + + async #pollEntry(entry) { + const startedAtMs = this.now(); + entry.needsInitialPoll = false; + entry.status = entry.snapshot ? "refreshing" : "starting"; + entry.lastPollAtMs = startedAtMs; + try { + if (!entry.client) { + entry.client = this.createClient(entry.profile); + await entry.client.start(); + } + const refreshIdentity = + !entry.identity || + entry.lastIdentityAtMs == null || + startedAtMs - entry.lastIdentityAtMs >= this.identityRefreshMs; + const snapshot = await this.readUsage(entry.client, { + account: refreshIdentity ? null : entry.identity, + }); + entry.identity = snapshot.account; + if (refreshIdentity) entry.lastIdentityAtMs = startedAtMs; + entry.snapshot = decorateSnapshot(snapshot, entry.profile); + entry.status = "ok"; + entry.lastError = null; + entry.consecutiveFailures = 0; + entry.nextEligibleAtMs = startedAtMs + this.pollIntervalMs; + } catch (error) { + entry.status = entry.snapshot ? "stale" : "error"; + entry.lastError = safeErrorMessage(error); + entry.consecutiveFailures += 1; + entry.nextEligibleAtMs = + startedAtMs + + failurePollDelayMs(this.pollIntervalMs, entry.consecutiveFailures); + if (entry.client) await entry.client.stop().catch(() => {}); + entry.client = null; + } + } +} diff --git a/src/normalize-usage.mjs b/src/normalize-usage.mjs index d82c586..944c6ba 100644 --- a/src/normalize-usage.mjs +++ b/src/normalize-usage.mjs @@ -35,7 +35,42 @@ function normalizeSnapshot(snapshot) { }; } -export function normalizeUsage(accountResult, rateLimitResult) { +export const MAX_ACCOUNT_EMAIL_BYTES = 254; + +export function normalizeAccountEmail(email) { + if (typeof email !== "string") return null; + if (/[\u0000-\u001f\u007f]/.test(email)) return null; + const normalized = email.trim(); + const separator = normalized.indexOf("@"); + if ( + separator <= 0 || + separator !== normalized.lastIndexOf("@") || + separator === normalized.length - 1 || + !/^[\x21-\x7e]+$/.test(normalized) || + Buffer.byteLength(normalized, "utf8") > MAX_ACCOUNT_EMAIL_BYTES + ) { + return null; + } + return normalized; +} + +export function normalizeAccount(accountResult) { + const account = accountResult?.account ?? accountResult; + if (!account) return null; + const email = normalizeAccountEmail(account.email); + return { + type: account.type ?? null, + planType: account.planType ?? null, + emailPresent: Boolean(email ?? account.emailPresent), + // account/read is the only source trusted for the automatic identity. + // Copy the validated email explicitly and ignore every other upstream + // field so credentials and an unexpected raw label cannot cross the API. + email, + label: email, + }; +} + +export function normalizeUsageWithAccount(account, rateLimitResult) { const buckets = {}; for (const [id, snapshot] of Object.entries( rateLimitResult?.rateLimitsByLimitId ?? {}, @@ -51,15 +86,13 @@ export function normalizeUsage(accountResult, rateLimitResult) { return { schemaVersion: 1, capturedAt: new Date().toISOString(), - account: accountResult?.account - ? { - type: accountResult.account.type ?? null, - planType: accountResult.account.planType ?? null, - emailPresent: Boolean(accountResult.account.email), - } - : null, + account, preferred, buckets, resetCredits: rateLimitResult?.rateLimitResetCredits ?? null, }; } + +export function normalizeUsage(accountResult, rateLimitResult) { + return normalizeUsageWithAccount(normalizeAccount(accountResult), rateLimitResult); +} diff --git a/src/read-usage.mjs b/src/read-usage.mjs index dc4c872..b830dc8 100644 --- a/src/read-usage.mjs +++ b/src/read-usage.mjs @@ -1,10 +1,17 @@ -import { normalizeUsage } from "./normalize-usage.mjs"; +import { + normalizeAccount, + normalizeUsageWithAccount, +} from "./normalize-usage.mjs"; -export async function readUsage(client) { - // Routine metering must not proactively refresh OAuth on every poll. The - // app-server can use the currently stored account state for this read. - const account = await client.request("account/read", { refreshToken: false }); +export async function readUsage(client, { account = null } = {}) { + // Identity is read with refreshToken:false. The scheduler normally performs + // this check whenever an account is due so a keyring-backed account switch is + // reflected without forcing a token refresh; callers may still pass an + // already-sanitized identity for narrowly-scoped probes/tests. + const normalizedAccount = account ?? normalizeAccount( + await client.request("account/read", { refreshToken: false }), + ); const rateLimits = await client.request("account/rateLimits/read"); - return normalizeUsage(account, rateLimits); + return normalizeUsageWithAccount(normalizedAccount, rateLimits); } diff --git a/test/account-registry.test.mjs b/test/account-registry.test.mjs new file mode 100644 index 0000000..db8c7e3 --- /dev/null +++ b/test/account-registry.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + discoverAccountProfiles, + MAX_ACCOUNT_PROFILES, +} from "../src/account-registry.mjs"; + +test("discovers at most four isolated profiles without reading credential contents", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "meter-accounts-")); + t.after(() => rm(root, { recursive: true, force: true })); + for (const id of ["zeta", "alpha", "gamma", "beta", "extra"]) { + const profile = path.join(root, id); + const codexHome = path.join(profile, "codex-home"); + await mkdir(codexHome, { recursive: true }); + await writeFile(path.join(codexHome, "auth.json"), "not-json-on-purpose"); + await writeFile( + path.join(profile, "profile.json"), + `${JSON.stringify({ + v: 1, + displayLabel: id === "alpha" ? "Main" : null, + planLabel: id === "alpha" ? "Pro 20x" : null, + })}\n`, + ); + } + + const result = await discoverAccountProfiles({ + accountsDirectory: root, + fallbackCodexHome: path.join(root, "fallback"), + }); + + assert.equal(result.explicit, true); + assert.equal(result.profiles.length, MAX_ACCOUNT_PROFILES); + assert.deepEqual(result.profiles.map((profile) => profile.id), [ + "alpha", + "beta", + "extra", + "gamma", + ]); + assert.deepEqual(result.ignoredProfileIds, ["zeta"]); + assert.equal(result.profiles[0].displayLabel, "Main"); + assert.equal(result.profiles[0].planLabel, "PRO 20X"); +}); + +test("falls back to the existing default CODEX_HOME when no profile is registered", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "meter-fallback-")); + t.after(() => rm(root, { recursive: true, force: true })); + const fallback = path.join(root, "default-codex-home"); + const result = await discoverAccountProfiles({ + accountsDirectory: path.join(root, "accounts"), + fallbackCodexHome: fallback, + }); + + assert.equal(result.explicit, false); + assert.equal(result.profiles.length, 1); + assert.equal(result.profiles[0].id, "default"); + assert.equal(result.profiles[0].codexHome, path.resolve(fallback)); + assert.equal(result.profiles[0].inheritEnvironment, true); +}); + +test("does not fall back to global credentials when an explicit profile is invalid", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "meter-invalid-profile-")); + t.after(() => rm(root, { recursive: true, force: true })); + const profile = path.join(root, "accounts", "work"); + await mkdir(path.join(profile, "codex-home"), { recursive: true }); + await writeFile( + path.join(profile, "profile.json"), + '{"v":1,"displayLabel":"person@example.com"}\n', + ); + + const result = await discoverAccountProfiles({ + accountsDirectory: path.join(root, "accounts"), + fallbackCodexHome: path.join(root, "fallback"), + }); + + assert.equal(result.explicit, true); + assert.deepEqual(result.profiles, []); + assert.deepEqual(result.invalidProfileIds, ["work"]); +}); + +test("ignores profile IDs that cannot fit in the firmware account selector", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "meter-long-profile-")); + t.after(() => rm(root, { recursive: true, force: true })); + const tooLong = "a".repeat(33); + await mkdir(path.join(root, tooLong, "codex-home"), { recursive: true }); + + const result = await discoverAccountProfiles({ + accountsDirectory: root, + fallbackCodexHome: path.join(root, "fallback"), + }); + + assert.equal(result.explicit, true); + assert.deepEqual(result.profiles, []); + assert.deepEqual(result.invalidProfileIds, [tooLong]); +}); + +test("rejects a display label that the firmware font cannot render", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "meter-wide-label-")); + t.after(() => rm(root, { recursive: true, force: true })); + const profile = path.join(root, "work"); + await mkdir(path.join(profile, "codex-home"), { recursive: true }); + await writeFile( + path.join(profile, "profile.json"), + `${JSON.stringify({ v: 1, displayLabel: "账".repeat(22) })}\n`, + ); + + const result = await discoverAccountProfiles({ + accountsDirectory: root, + fallbackCodexHome: path.join(root, "fallback"), + }); + + assert.equal(result.explicit, true); + assert.deepEqual(result.profiles, []); + assert.deepEqual(result.invalidProfileIds, ["work"]); +}); + +test("fails closed for a profile link that escapes the account registry", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "meter-link-root-")); + const outside = await mkdtemp(path.join(os.tmpdir(), "meter-link-outside-")); + t.after(() => Promise.all([ + rm(root, { recursive: true, force: true }), + rm(outside, { recursive: true, force: true }), + ])); + await mkdir(path.join(outside, "codex-home"), { recursive: true }); + try { + await symlink( + outside, + path.join(root, "escape"), + process.platform === "win32" ? "junction" : "dir", + ); + } catch (error) { + if (["EPERM", "EACCES", "ENOTSUP"].includes(error.code)) { + t.skip(`directory links are unavailable: ${error.code}`); + return; + } + throw error; + } + + const result = await discoverAccountProfiles({ + accountsDirectory: root, + fallbackCodexHome: path.join(root, "fallback"), + }); + + assert.equal(result.explicit, true); + assert.deepEqual(result.profiles, []); + assert.deepEqual(result.invalidProfileIds, ["escape"]); +}); diff --git a/test/app-server-client.test.mjs b/test/app-server-client.test.mjs new file mode 100644 index 0000000..560b841 --- /dev/null +++ b/test/app-server-client.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import test from "node:test"; + +import { AppServerClient } from "../src/app-server-client.mjs"; + +test("merges a profile CODEX_HOME and removes conflicting credential variables", async () => { + let observed; + const spawnImpl = (command, args, options) => { + observed = { command, args, options }; + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = new Writable({ + write(chunk, _encoding, done) { + const message = JSON.parse(chunk.toString("utf8")); + setImmediate(() => { + child.stdout.write(`${JSON.stringify({ id: message.id, result: {} })}\n`); + }); + done(); + }, + }); + child.stdin.once("finish", () => setImmediate(() => child.emit("exit", 0, null))); + child.kill = () => child.emit("exit", 0, null); + return child; + }; + const client = new AppServerClient({ + command: "codex", + env: { + CODEX_HOME: "C:\\isolated\\codex-home", + OPENAI_API_KEY: "must-be-removed", + }, + unsetEnv: ["OPENAI_API_KEY", "CODEX_API_KEY"], + spawnImpl, + }); + + await client.start(); + assert.equal(observed.options.env.CODEX_HOME, "C:\\isolated\\codex-home"); + assert.equal("OPENAI_API_KEY" in observed.options.env, false); + assert.equal("CODEX_API_KEY" in observed.options.env, false); + const observedPath = Object.entries(observed.options.env) + .find(([name]) => name.toLowerCase() === "path")?.[1]; + const processPath = Object.entries(process.env) + .find(([name]) => name.toLowerCase() === "path")?.[1]; + assert.equal(observedPath, processPath); + await client.stop(); +}); diff --git a/test/dashboard-accounts.test.mjs b/test/dashboard-accounts.test.mjs new file mode 100644 index 0000000..c668707 --- /dev/null +++ b/test/dashboard-accounts.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + accountPosition, + accountsFromPayload, + adjacentAccountId, + initializeDashboard, + reconcileSelectedAccountId, +} from "../public/app.js"; + +function usage(id, usedPercent) { + return { + capturedAt: "2026-08-04T00:00:00.000Z", + account: { + id, + email: `${id}@example.com`, + label: `${id}@example.com`, + planType: "pro", + }, + preferred: { + limitId: "codex", + primary: { + usedPercent, + remainingPercent: 100 - usedPercent, + windowDurationMins: 300, + resetsAt: 1_800_000_000, + }, + }, + buckets: {}, + }; +} + +test("normalizes additive and legacy account payloads", () => { + const modern = accountsFromPayload({ + accounts: [ + { id: "personal", label: "personal@example.com", status: "ok", usage: usage("personal", 10) }, + { id: "work", label: "WORK", status: "stale", usage: usage("work", 20) }, + ], + }); + assert.deepEqual(modern.map(({ id, label, status }) => ({ id, label, status })), [ + { id: "personal", label: "personal@example.com", status: "ok" }, + { id: "work", label: "WORK", status: "stale" }, + ]); + + const legacyUsage = usage("default", 30); + const legacy = accountsFromPayload({ + service: { status: "ok" }, + usage: legacyUsage, + }); + assert.equal(legacy.length, 1); + assert.equal(legacy[0].id, "default"); + assert.equal(legacy[0].label, "default@example.com"); + assert.equal(legacy[0].usage, legacyUsage); + + const additiveEmailFallback = accountsFromPayload({ + accounts: [{ + id: "fallback", + status: "ok", + usage: usage("full-address", 40), + }], + }); + assert.equal(additiveEmailFallback[0].label, "full-address@example.com"); +}); + +test("keeps selection by stable id, cycles, and falls back after deletion", () => { + const accounts = [{ id: "a" }, { id: "b" }, { id: "c" }]; + assert.equal(reconcileSelectedAccountId(accounts, "b"), "b"); + assert.equal(adjacentAccountId(accounts, "b", 1), "c"); + assert.equal(adjacentAccountId(accounts, "a", -1), "c"); + assert.deepEqual(accountPosition(accounts, "b"), { index: 2, total: 3 }); + assert.equal(reconcileSelectedAccountId([{ id: "a" }], "b"), "a"); + assert.equal(adjacentAccountId([{ id: "a" }], "a", 1), "a"); +}); + +test("dashboard switches selected usage, preserves it on refresh, and clears failed data", (t) => { + class FakeElement { + constructor() { + this.textContent = ""; + this.hidden = false; + this.disabled = false; + this.className = ""; + this.listeners = new Map(); + this.style = { + width: "", + values: new Map(), + setProperty: (name, value) => this.style.values.set(name, value), + }; + } + + addEventListener(name, listener) { + this.listeners.set(name, listener); + } + + click() { + this.listeners.get("click")?.(); + } + } + + const ids = [ + "plan-title", "status", "account-prev", "account-next", "account-identity", "account-label", + "account-id", "account-position", "account-message", "remaining", "window", + "used", "reset", "captured", "bar", "ring", "extra-name", + "extra-remaining", "extra-reset", "next-poll", "host-id", "pairing-token", + "copy-token", + ]; + const elements = new Map(ids.map((id) => [id, new FakeElement()])); + const previousDocument = globalThis.document; + const previousFetch = globalThis.fetch; + const previousSetInterval = globalThis.setInterval; + globalThis.document = { getElementById: (id) => elements.get(id) }; + globalThis.fetch = () => new Promise(() => {}); + globalThis.setInterval = () => 0; + t.after(() => { + globalThis.document = previousDocument; + globalThis.fetch = previousFetch; + globalThis.setInterval = previousSetInterval; + }); + + const dashboard = initializeDashboard(); + dashboard.render({ + service: { status: "ok", nextPollAt: null }, + accounts: [ + { id: "a", label: "a@example.com", status: "ok", usage: usage("a", 10) }, + { id: "b", label: "B", status: "ok", usage: usage("b", 80) }, + ], + }); + assert.equal(elements.get("account-id").textContent, "a"); + assert.equal(elements.get("account-label").textContent, "a@example.com"); + assert.equal(elements.get("remaining").textContent, 90); + assert.equal(elements.get("account-position").textContent, "1/2"); + + elements.get("account-next").click(); + assert.equal(elements.get("account-id").textContent, "b"); + assert.equal(elements.get("remaining").textContent, 20); + + dashboard.render({ + service: { status: "ok", nextPollAt: null }, + accounts: [ + { id: "a", label: "A", status: "ok", usage: usage("a", 15) }, + { id: "b", label: "B", status: "error", usage: null, lastError: "token invalidated" }, + ], + }); + assert.equal(elements.get("account-id").textContent, "b"); + assert.equal(elements.get("remaining").textContent, "--"); + assert.equal(elements.get("used").textContent, "已使用 --%"); + assert.equal(elements.get("extra-remaining").textContent, "--%"); + assert.match(elements.get("account-message").textContent, /账号读取失败/); + + dashboard.render({ + service: { status: "ok", nextPollAt: null }, + accounts: [{ id: "a", label: "A", status: "ok", usage: usage("a", 15) }], + }); + assert.equal(elements.get("account-id").textContent, "a"); + assert.equal(elements.get("remaining").textContent, 85); + assert.equal(elements.get("account-next").hidden, true); + assert.equal(elements.get("account-prev").disabled, true); + assert.equal(elements.get("account-identity").style.gridColumn, "1 / -1"); +}); + +test("dashboard markup exposes accessible account controls", async () => { + const [html, css] = await Promise.all([ + readFile(new URL("../public/index.html", import.meta.url), "utf8"), + readFile(new URL("../public/styles.css", import.meta.url), "utf8"), + ]); + assert.match(html, /id="account-prev"[^>]+aria-label="上一个账号"/); + assert.match(html, /id="account-next"[^>]+aria-label="下一个账号"/); + assert.match(html, /id="account-label"/); + assert.match(html, /id="account-identity"/); + assert.match(html, /id="account-id"/); + assert.match(html, /id="account-position"/); + assert.match(html, /id="account-message"[^>]+role="status"/); + assert.match(css, /\.account-identity strong \{[\s\S]*?overflow-wrap: anywhere;[\s\S]*?\}/); +}); diff --git a/test/device-payload.test.mjs b/test/device-payload.test.mjs index 6ffc4aa..8770c86 100644 --- a/test/device-payload.test.mjs +++ b/test/device-payload.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { toDevicePayload } from "../src/device-payload.mjs"; +import { + MAX_DEVICE_ACCOUNT_LABEL_BYTES, + MAX_DEVICE_PAYLOAD_BYTES, + toDevicePayload, +} from "../src/device-payload.mjs"; test("builds a compact ESP32 payload without account identity", () => { const result = toDevicePayload( @@ -68,3 +72,281 @@ test("keeps an existing snapshot live while its next poll is in flight", () => { assert.equal(result.status, "ok"); }); + +test("keeps a discovered account list deliverable before quota data exists", () => { + const result = toDevicePayload( + null, + { + status: "error", + lastPollAt: "2026-07-29T00:00:00.000Z", + nextPollAt: null, + }, + Date.parse("2026-07-29T00:00:30.000Z"), + { + accounts: [{ + id: "work", + label: "WORK", + status: "error", + usage: null, + }], + }, + ); + + assert.equal(result.capturedAt, 1_785_283_200); + assert.equal(result.preferred, null); + assert.deepEqual(result.accounts.map(({ id, label }) => ({ id, label })), [ + { id: "work", label: "WORK" }, + ]); +}); + +test("adds compact multi-account data without changing the v1 compatibility mirror", () => { + const usage = { + capturedAt: "2026-07-29T00:00:00.000Z", + account: { + id: "personal", + email: "user@example.com", + label: "user@example.com", + type: "chatgpt", + planType: "pro", + planLabel: "PRO 20X", + accessToken: "usage-access-secret", + }, + preferred: { + limitId: "codex", + primary: { + usedPercent: 8, + remainingPercent: 92, + windowDurationMins: 300, + resetsAt: 1_800_000_000, + }, + secondary: null, + }, + buckets: {}, + }; + const result = toDevicePayload( + usage, + { status: "ok", nextPollAt: null }, + Date.parse("2026-07-29T00:00:00.000Z"), + { + planLabel: "PRO", + accounts: [{ + id: "personal", + label: "user@example.com", + status: "ok", + usage, + refreshToken: "record-refresh-secret", + }], + }, + ); + + assert.equal(result.v, 1); + assert.equal(result.planLabel, "PRO 20X"); + assert.equal(result.preferred.primary.remaining, 92); + assert.deepEqual(result.accounts.map(({ id, label, planLabel }) => ({ + id, + label, + planLabel, + })), [{ + id: "personal", + label: "user@example.com", + planLabel: "PRO 20X", + }]); + const encoded = JSON.stringify(result); + assert.equal(encoded.includes("user@example.com"), true); + assert.equal(encoded.includes("usage-access-secret"), false); + assert.equal(encoded.includes("record-refresh-secret"), false); +}); + +test("uses the newest account or registry poll as the transport generation", () => { + const first = { + capturedAt: "2026-07-29T00:00:00.000Z", + account: { planType: "pro" }, + preferred: { + limitId: "codex", + primary: { + usedPercent: 10, + remainingPercent: 90, + windowDurationMins: 300, + resetsAt: 1_800_000_000, + }, + secondary: null, + }, + buckets: {}, + }; + const second = { + ...first, + capturedAt: "2026-07-29T00:01:00.000Z", + }; + const result = toDevicePayload( + first, + { + status: "ok", + lastPollAt: "2026-07-29T00:02:00.000Z", + nextPollAt: null, + }, + Date.parse("2026-07-29T00:02:01.000Z"), + { + accounts: [ + { id: "first", label: "FIRST", status: "ok", usage: first }, + { id: "second", label: "SECOND", status: "ok", usage: second }, + ], + }, + ); + + assert.equal(result.preferred.primary.remaining, 90); + assert.equal(result.capturedAt, 1_785_283_320); + assert.equal(result.accounts[1].capturedAt, 1_785_283_260); +}); + +test("bounds upstream buckets to the fields and first extra rendered by firmware", () => { + const usage = { + capturedAt: "2026-07-29T00:00:00.000Z", + account: { planType: "pro" }, + preferred: null, + buckets: { + missing: null, + ...Object.fromEntries(Array.from({ length: 50 }, (_, index) => [ + `bucket-${index}`, + { + limitId: `bucket-${index}`, + limitName: "x".repeat(100), + primary: null, + secondary: null, + }, + ])), + }, + }; + const payload = toDevicePayload( + usage, + { status: "ok", nextPollAt: null }, + Date.parse("2026-07-29T00:00:00.000Z"), + { + accounts: [{ id: "one", label: "One", status: "ok", usage }], + }, + ); + + assert.equal(payload.extras.length, 1); + assert.equal(payload.accounts[0].extras.length, 1); + assert.equal(Buffer.byteLength(payload.extras[0].name, "utf8"), 39); +}); + +test("enforces the shared BLE JSON byte budget for out-of-contract input", () => { + assert.throws( + () => toDevicePayload( + null, + { status: "starting", nextPollAt: null }, + Date.parse("2026-07-29T00:00:00.000Z"), + { + accounts: Array.from({ length: 20 }, (_, index) => ({ + id: `account-${index}`, + label: "x".repeat(MAX_DEVICE_ACCOUNT_LABEL_BYTES), + status: "starting", + usage: null, + })), + }, + ), + new RegExp(`maximum is ${MAX_DEVICE_PAYLOAD_BYTES}`), + ); +}); + +test("preserves a full device email at the firmware limit and rejects unsafe labels", () => { + const maximumEmail = `${"a".repeat(52)}@example.com`; + assert.equal(Buffer.byteLength(maximumEmail, "utf8"), MAX_DEVICE_ACCOUNT_LABEL_BYTES); + const payload = toDevicePayload( + null, + { status: "starting", nextPollAt: null }, + Date.parse("2026-07-29T00:00:00.000Z"), + { + accounts: [{ + id: "long-email", + label: maximumEmail, + status: "starting", + usage: null, + }], + }, + ); + assert.equal(payload.accounts[0].label, maximumEmail); + + const invalidPayload = (label) => toDevicePayload( + null, + { status: "starting", nextPollAt: null }, + Date.parse("2026-07-29T00:00:00.000Z"), + { + accounts: [{ + id: "invalid-label", + label, + status: "starting", + usage: null, + }], + }, + ); + assert.throws( + () => invalidPayload(`${"a".repeat(53)}@example.com`), + /65 bytes; maximum is 64/, + ); + assert.throws(() => invalidPayload("用户@example.com"), /printable ASCII/); + assert.throws(() => invalidPayload("line@example.com\n"), /printable ASCII/); +}); + +test("keeps four maximum-size registered accounts inside the BLE JSON budget", () => { + const nowMs = Date.parse("2026-07-29T00:00:00.000Z"); + const makeUsage = (index) => { + const preferred = { + limitId: "codex", + limitName: "P".repeat(100), + primary: { + usedPercent: 99, + remainingPercent: 1, + windowDurationMins: 10_080, + resetsAt: 1_800_000_000 + index, + }, + secondary: { + usedPercent: 88, + remainingPercent: 12, + windowDurationMins: 300, + resetsAt: 1_800_000_300 + index, + }, + }; + const extra = { + limitId: `extra-${index}`, + limitName: "E".repeat(100), + primary: { + usedPercent: 77, + remainingPercent: 23, + windowDurationMins: 10_080, + resetsAt: 1_800_000_600 + index, + }, + secondary: { + usedPercent: 66, + remainingPercent: 34, + windowDurationMins: 300, + resetsAt: 1_800_000_900 + index, + }, + }; + return { + capturedAt: "2026-07-29T00:00:00.000Z", + account: { planType: "pro", planLabel: "ABCDEFGHIJKLMNOPQRST" }, + preferred, + buckets: { codex: preferred, [extra.limitId]: extra }, + }; + }; + const accounts = Array.from({ length: 4 }, (_, index) => ({ + id: String(index).padEnd(32, "i"), + label: "L".repeat(MAX_DEVICE_ACCOUNT_LABEL_BYTES), + status: "refreshing", + usage: makeUsage(index), + })); + + const payload = toDevicePayload( + accounts[0].usage, + { status: "ok", nextPollAt: null }, + nowMs, + { accounts }, + ); + const encodedBytes = Buffer.byteLength(JSON.stringify(payload), "utf8") + 1; + + assert.equal(payload.accounts.length, 4); + assert.ok(encodedBytes <= MAX_DEVICE_PAYLOAD_BYTES); + assert.equal(payload.accounts[0].preferred.secondary, undefined); + assert.equal(payload.accounts[0].extras.length, 1); +}); diff --git a/test/firmware-account-selection.test.mjs b/test/firmware-account-selection.test.mjs new file mode 100644 index 0000000..80990a0 --- /dev/null +++ b/test/firmware-account-selection.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const read = (relativePath) => readFile(path.join(root, relativePath), "utf8"); + +test("firmware accepts optional v1 account lists with a fixed four-account bound", async () => { + const [model, parser] = await Promise.all([ + read("firmware/main/usage_model.h"), + read("firmware/main/usage_payload.c"), + ]); + + assert.match(model, /#define METER_MAX_ACCOUNTS 4/); + assert.match(model, /usage_account_t accounts\[METER_MAX_ACCOUNTS\]/); + assert.match(parser, /version->valueint != 1/); + assert.match(parser, /GetObjectItemCaseSensitive\(root, "accounts"\)/); + assert.match(parser, /if \(!accounts\) \{\s*return true;/s); + assert.match(parser, /count > METER_MAX_ACCOUNTS/); + assert.match(parser, /GetObjectItemCaseSensitive\(item, "label"\)/); + assert.doesNotMatch(parser, /GetObjectItemCaseSensitive\(item, "email"\)/); +}); + +test("stable account selection is reconciled by id and ignores a single account", async () => { + const [policy, main] = await Promise.all([ + read("firmware/main/meter_account_selection.c"), + read("firmware/main/main.c"), + ]); + + assert.match(policy, /strcmp\(selection->selected_id, snapshot->accounts\[index\]\.id\)/); + assert.match(policy, /snapshot->account_count <= 1/); + assert.match(policy, /selected \+ count - 1/); + assert.match(main, /meter_account_selection_reconcile\(&s_account_selection, &s_snapshot\)/); + assert.match(main, /meter_account_selection_step/); + assert.match(main, /ESP_LOGI\(TAG, "Selected account %u\/%u", selected, count\)/); + assert.doesNotMatch(main, /Selected account %u\/%u: %s/); +}); + +test("reference-board account buttons keep GPIO0 opt-in and GPIO18 safe by default", async () => { + const [kconfig, buttons] = await Promise.all([ + read("firmware/main/Kconfig.projbuild"), + read("firmware/main/meter_buttons.c"), + ]); + + assert.match( + kconfig, + /config METER_ACCOUNT_BUTTON_GPIO18[\s\S]*?default y/, + ); + assert.match( + kconfig, + /config METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0[\s\S]*?default n/, + ); + assert.match(kconfig, /second pole also pulls\s+CHIP_PU low/); + assert.match(buttons, /ACCOUNT_NEXT_GPIO GPIO_NUM_18/); + assert.match(buttons, /ACCOUNT_PREVIOUS_GPIO GPIO_NUM_0/); + assert.match(buttons, /held_ms >= BUTTON_LONG_PRESS_MS[\s\S]*METER_ACCOUNT_BUTTON_PREVIOUS/); + assert.match(buttons, /#if CONFIG_METER_EXTERNAL_PREVIOUS_BUTTON_GPIO0[\s\S]*pins \|= 1ULL << ACCOUNT_PREVIOUS_GPIO/); +}); + +test("both firmware pages show the full account label and multi-account position", async () => { + const ui = await read("firmware/main/meter_ui.c"); + + assert.match(ui, /overview_account/); + assert.match(ui, /details_account/); + assert.match(ui, /"%s \| %u\/%u"/); + assert.match(ui, /snapshot->accounts\[snapshot->selected_account\]\.label/); + assert.match( + ui, + /lv_label_set_long_mode\(s_ui\.overview_account, LV_LABEL_LONG_SCROLL_CIRCULAR\)/, + ); + assert.match( + ui, + /lv_label_set_long_mode\(s_ui\.details_account, LV_LABEL_LONG_SCROLL_CIRCULAR\)/, + ); +}); diff --git a/test/multi-account-meter.test.mjs b/test/multi-account-meter.test.mjs new file mode 100644 index 0000000..e38f1cd --- /dev/null +++ b/test/multi-account-meter.test.mjs @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MultiAccountMeter } from "../src/multi-account-meter.mjs"; + +function profile(id, fingerprint = "v1") { + return { + id, + codexHome: `/profiles/${id}`, + inheritEnvironment: false, + displayLabel: null, + planLabel: null, + fingerprint, + }; +} + +function snapshot(id, renderable = true) { + return { + schemaVersion: 1, + capturedAt: "2026-08-04T00:00:00.000Z", + account: { + type: "chatgpt", + planType: "pro", + emailPresent: true, + email: `${id}@example.com`, + label: `${id}@example.com`, + }, + preferred: renderable + ? { + limitId: "codex", + limitName: null, + primary: { + usedPercent: 10, + remainingPercent: 90, + windowDurationMins: 10_080, + resetsAt: 1_800_000_000, + }, + secondary: null, + } + : null, + buckets: {}, + resetCredits: null, + }; +} + +test("warms profiles serially then polls one due account per global tick", async () => { + let nowMs = 0; + const calls = []; + const clients = new Map(); + const meter = new MultiAccountMeter({ + discoverProfiles: async () => ({ + explicit: true, + profiles: [profile("alpha"), profile("beta")], + ignoredProfileIds: [], + invalidProfileIds: [], + }), + createClient: (item) => { + const client = { + profileId: item.id, + starts: 0, + stops: 0, + async start() { this.starts += 1; }, + async stop() { this.stops += 1; }, + }; + clients.set(item.id, client); + return client; + }, + readUsage: async (client, options) => { + calls.push({ id: client.profileId, reusedIdentity: Boolean(options.account) }); + return snapshot(client.profileId); + }, + pollIntervalMs: 60_000, + now: () => nowMs, + }); + + let state = await meter.poll(); + assert.deepEqual(calls.map((call) => call.id), ["alpha", "beta"]); + assert.equal(state.status, "ok"); + assert.equal(state.accounts.length, 2); + assert.equal(state.snapshot.account.id, "alpha"); + assert.equal(state.accounts[0].label, "alpha@example.com"); + assert.equal(state.accounts[0].usage.account.email, "alpha@example.com"); + + nowMs = 60_000; + await meter.poll(); + assert.deepEqual(calls.map((call) => call.id), ["alpha", "beta", "alpha"]); + assert.equal(calls.at(-1).reusedIdentity, false); + + nowMs = 120_000; + await meter.poll(); + assert.deepEqual(calls.map((call) => call.id), [ + "alpha", + "beta", + "alpha", + "beta", + ]); + await meter.stop(); + assert.equal(clients.get("alpha").stops, 1); + assert.equal(clients.get("beta").stops, 1); +}); + +test("backs off a failed account independently while other accounts continue", async () => { + let nowMs = 0; + const attempts = new Map(); + const calls = []; + const meter = new MultiAccountMeter({ + discoverProfiles: async () => ({ + explicit: true, + profiles: [profile("alpha"), profile("beta")], + ignoredProfileIds: [], + invalidProfileIds: [], + }), + createClient: (item) => ({ + profileId: item.id, + async start() {}, + async stop() {}, + }), + readUsage: async (client) => { + const attempt = (attempts.get(client.profileId) ?? 0) + 1; + attempts.set(client.profileId, attempt); + calls.push(client.profileId); + if (client.profileId === "alpha" && attempt === 2) { + throw new Error("401 token invalidated"); + } + return snapshot(client.profileId); + }, + pollIntervalMs: 60_000, + now: () => nowMs, + }); + + await meter.poll(); + nowMs = 60_000; + let state = await meter.poll(); + const alpha = state.accounts.find((account) => account.id === "alpha"); + assert.equal(alpha.status, "stale"); + assert.equal(alpha.nextPollAt, "1970-01-01T00:03:00.000Z"); + assert.equal(state.snapshot.account.id, "beta"); + + nowMs = 120_000; + state = await meter.poll(); + assert.equal(calls.at(-1), "beta"); + assert.equal(state.accounts.find((account) => account.id === "beta").status, "ok"); + assert.equal(attempts.get("alpha"), 2); +}); + +test("chooses the first renderable account for the v1 compatibility mirror", async () => { + const meter = new MultiAccountMeter({ + discoverProfiles: async () => ({ + explicit: true, + profiles: [profile("alpha"), profile("beta")], + ignoredProfileIds: [], + invalidProfileIds: [], + }), + createClient: (item) => ({ + profileId: item.id, + async start() {}, + async stop() {}, + }), + readUsage: async (client) => snapshot( + client.profileId, + client.profileId === "beta", + ), + pollIntervalMs: 60_000, + now: () => 0, + }); + + const state = await meter.poll(); + + assert.equal(state.accounts.length, 2); + assert.equal(state.accounts[0].usage.preferred, null); + assert.equal(state.snapshot.account.id, "beta"); +}); + +test("keeps the full email while an explicit alias wins as the display label", async () => { + const aliasedProfile = { + ...profile("work"), + displayLabel: "WORK", + }; + const meter = new MultiAccountMeter({ + discoverProfiles: async () => ({ + explicit: true, + profiles: [aliasedProfile], + ignoredProfileIds: [], + invalidProfileIds: [], + }), + createClient: () => ({ + async start() {}, + async stop() {}, + }), + readUsage: async () => snapshot("work"), + pollIntervalMs: 60_000, + now: () => 0, + }); + + const state = await meter.poll(); + + assert.equal(state.accounts[0].label, "WORK"); + assert.equal(state.accounts[0].usage.account.label, "WORK"); + assert.equal(state.accounts[0].usage.account.email, "work@example.com"); +}); diff --git a/test/normalize-usage.test.mjs b/test/normalize-usage.test.mjs index f094865..1523292 100644 --- a/test/normalize-usage.test.mjs +++ b/test/normalize-usage.test.mjs @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { normalizeUsage } from "../src/normalize-usage.mjs"; +import { + MAX_ACCOUNT_EMAIL_BYTES, + normalizeAccount, + normalizeAccountEmail, + normalizeUsage, +} from "../src/normalize-usage.mjs"; test("normalizes a Pro account with primary and secondary windows", () => { const result = normalizeUsage( @@ -39,8 +44,66 @@ test("normalizes a Pro account with primary and secondary windows", () => { assert.equal(result.account.planType, "pro"); assert.equal(result.account.emailPresent, true); - assert.equal("email" in result.account, false); + assert.equal(result.account.email, "user@example.com"); + assert.equal(result.account.label, "user@example.com"); assert.equal(result.preferred.primary.remainingPercent, 75); assert.equal(result.preferred.secondary.remainingPercent, 60); assert.equal(result.buckets.codex.credits.balance, "12.50"); }); + +test("keeps the full account/read email but does not trust other identity fields", () => { + const result = normalizeUsage( + { + account: { + type: "chatgpt", + planType: "pro", + email: "safe@example.com", + label: "unmasked@example.com", + accessToken: "access-secret", + refreshToken: "refresh-secret", + apiKey: "api-secret", + credentials: { password: "password-secret" }, + }, + }, + { rateLimits: null }, + ); + + assert.equal(result.account.email, "safe@example.com"); + assert.equal(result.account.label, "safe@example.com"); + const encoded = JSON.stringify(result); + for (const secret of [ + "unmasked@example.com", + "access-secret", + "refresh-secret", + "api-secret", + "password-secret", + ]) { + assert.equal(encoded.includes(secret), false); + } +}); + +test("accepts a complete trimmed ASCII email without truncating it", () => { + const email = "User.Name+meter@example.com"; + assert.equal(normalizeAccountEmail(` ${email} `), email); + + const maximumEmail = `${"a".repeat(64)}@${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(61)}`; + assert.equal(Buffer.byteLength(maximumEmail, "utf8"), MAX_ACCOUNT_EMAIL_BYTES); + assert.equal(normalizeAccountEmail(maximumEmail), maximumEmail); + assert.equal(normalizeAccountEmail(`${maximumEmail}x`), null); +}); + +test("rejects unsafe or overlong email identity values", () => { + const overlong = `${"a".repeat(MAX_ACCOUNT_EMAIL_BYTES)}@example.com`; + for (const email of [ + "missing-domain@", + "two@@example.com", + "space in@example.com", + "用户@example.com", + "line@example.com\n", + overlong, + ]) { + const result = normalizeAccount({ account: { email } }); + assert.equal(result.email, null); + assert.equal(result.label, null); + } +}); diff --git a/test/public-release-safety.test.mjs b/test/public-release-safety.test.mjs index de5439e..c5a2d92 100644 --- a/test/public-release-safety.test.mjs +++ b/test/public-release-safety.test.mjs @@ -49,6 +49,8 @@ test("machine-local identity files remain ignored", async () => { const ignore = await read(".gitignore"); assert.match(ignore, /^host\.json$/mu); + assert.match(ignore, /^auth\.json$/mu); + assert.match(ignore, /^accounts\/$/mu); assert.match(ignore, /^\.env$/mu); assert.match(ignore, /^firmware\/sdkconfig$/mu); assert.match(ignore, /^firmware\/build\/$/mu); diff --git a/test/read-usage.test.mjs b/test/read-usage.test.mjs index cb3b7da..8c12a60 100644 --- a/test/read-usage.test.mjs +++ b/test/read-usage.test.mjs @@ -33,3 +33,27 @@ test("routine usage reads never proactively refresh the OAuth token", async () = { method: "account/rateLimits/read", params: undefined }, ]); }); + +test("steady-state reads reuse sanitized identity and request only rate limits", async () => { + const calls = []; + const client = { + async request(method, params) { + calls.push({ method, params }); + return { rateLimits: null }; + }, + }; + const account = { + type: "chatgpt", + planType: "pro", + emailPresent: true, + email: "user@example.com", + label: "user@example.com", + }; + + const result = await readUsage(client, { account }); + + assert.deepEqual(result.account, account); + assert.deepEqual(calls, [ + { method: "account/rateLimits/read", params: undefined }, + ]); +}); diff --git a/ui-simulator/main.c b/ui-simulator/main.c index e29b06a..9836ae9 100644 --- a/ui-simulator/main.c +++ b/ui-simulator/main.c @@ -82,6 +82,16 @@ static simulator_state_t healthy_state(void) snprintf(state.usage.plan, sizeof(state.usage.plan), "Pro"); snprintf(state.usage.extra_name, sizeof(state.usage.extra_name), "GPT-5.3 Codex Spark"); + state.usage.account_count = 2; + state.usage.selected_account = 0; + snprintf(state.usage.accounts[0].id, + sizeof(state.usage.accounts[0].id), "personal"); + snprintf(state.usage.accounts[0].label, + sizeof(state.usage.accounts[0].label), "personal.owner@example.com"); + snprintf(state.usage.accounts[1].id, + sizeof(state.usage.accounts[1].id), "work"); + snprintf(state.usage.accounts[1].label, + sizeof(state.usage.accounts[1].label), "work.account@example.org"); state.battery.valid = true; state.battery.battery_present = true;