feat: add us debt screen #2

Open
PMK wants to merge 26 commits from PMK/btclock_v4:feat/usdebt_screen into main
First-time contributor
No description provided.
feat: add us debt screen
Some checks failed
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
d6de710d33
Merge branch 'main' into feat/usdebt_screen
Some checks failed
Host tests / host_tests (pull_request) Successful in 34s
Host tests / coverage (pull_request) Successful in 35s
Lint / format (pull_request) Failing after 36s
Lint / tidy (pull_request) Successful in 1m57s
Host tests / sanitize (pull_request) Successful in 3m58s
345d1fc084
fix: add includes
Some checks failed
Host tests / host_tests (pull_request) Successful in 34s
Host tests / coverage (pull_request) Successful in 33s
Lint / format (pull_request) Failing after 35s
Lint / tidy (pull_request) Successful in 1m58s
Host tests / sanitize (pull_request) Successful in 4m4s
2442275596
fix(us_debt): toggle big number
Some checks failed
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
478a6ddedf
PMK changed title from WIP: feat: add us debt screen to feat: add us debt screen 2026-06-06 15:18:16 +00:00
Merge branch 'main' into feat/usdebt_screen
Some checks failed
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
16367ece6d
fix(us_debt): add precise big debt number
Some checks failed
Host tests / host_tests (pull_request) Successful in 2m3s
Host tests / sanitize (pull_request) Successful in 4m27s
Host tests / coverage (pull_request) Successful in 2m50s
Lint / format (pull_request) Failing after 1m14s
Lint / tidy (pull_request) Successful in 4m5s
8e4bd0553d
Merge branch 'main' into feat/usdebt_screen
Some checks failed
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
0c194c65eb
chore: format code
All checks were successful
Host tests / host_tests (pull_request) Successful in 2m12s
Host tests / sanitize (pull_request) Successful in 4m51s
Host tests / coverage (pull_request) Successful in 2m56s
Lint / format (pull_request) Successful in 1m16s
Lint / tidy (pull_request) Successful in 3m40s
189001a0dc
Owner

PR Reviewer Guide 🔍

(Review updated until commit d5333a0a79)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵
🧪 PR contains tests
🔒 No security concerns identified
 Recommended focus areas for review

Floating-point precision

In the "precise mode" fallback path (when us_debt_exact is nullopt but us_debt_t is set), the code computes *us_debt_t * 1e12 and casts to uint64_t. Since us_debt_t (e.g. 39.2) is not exactly representable in IEEE 754 double, the multiplication may produce a value like 39199999999999.998… which truncates to 39199999999999 instead of the expected 39200000000000. In normal operation this fallback never triggers because us_debt_t and us_debt_exact are always populated together in PollOnce(), but if they ever diverge (e.g., partial snapshot merge edge case), the displayed precise number could be wrong by one in the last digit.

const double raw =
    has_debt ? ((us_debt_exact && *us_debt_exact > 0.0) ? *us_debt_exact
                                                        : *us_debt_t * 1e12)
             : 0.0;
const uint64_t debt = has_debt ? static_cast<uint64_t>(raw) : 0;
## PR Reviewer Guide 🔍 #### (Review updated until commit https://git.btclock.dev/btclock/btclock_v4/commit/d5333a0a7932ab92e175deb2ddc4d5e61c7ab4a0) Here are some key observations to aid the review process: <table> <tr><td>⏱️&nbsp;<strong>Estimated effort to review</strong>: 3 🔵🔵🔵⚪⚪</td></tr> <tr><td>🧪&nbsp;<strong>PR contains tests</strong></td></tr> <tr><td>🔒&nbsp;<strong>No security concerns identified</strong></td></tr> <tr><td>⚡&nbsp;<strong>Recommended focus areas for review</strong><br><br> <details><summary><a href='https://git.btclock.dev/btclock/btclock_v4/src/branch/feat/usdebt_screen/main/screens/us_debt.cpp#L96-L100'><strong>Floating-point precision</strong></a> In the "precise mode" fallback path (when `us_debt_exact` is nullopt but `us_debt_t` is set), the code computes `*us_debt_t * 1e12` and casts to `uint64_t`. Since `us_debt_t` (e.g. 39.2) is not exactly representable in IEEE 754 double, the multiplication may produce a value like 39199999999999.998… which truncates to 39199999999999 instead of the expected 39200000000000. In normal operation this fallback never triggers because `us_debt_t` and `us_debt_exact` are always populated together in `PollOnce()`, but if they ever diverge (e.g., partial snapshot merge edge case), the displayed precise number could be wrong by one in the last digit. </summary> ```c++ const double raw = has_debt ? ((us_debt_exact && *us_debt_exact > 0.0) ? *us_debt_exact : *us_debt_t * 1e12) : 0.0; const uint64_t debt = has_debt ? static_cast<uint64_t>(raw) : 0; ``` </details> </td></tr> </table>
Owner

PR Code Suggestions

No code suggestions found for the PR.

## PR Code Suggestions ✨ No code suggestions found for the PR.
Owner

PR Code Suggestions

No code suggestions found for the PR.

## PR Code Suggestions ✨ No code suggestions found for the PR.
Owner

Persistent review updated to latest commit d5333a0a79

**[Persistent review](https://git.btclock.dev/btclock/btclock_v4/pulls/2#issuecomment-1751)** updated to latest commit https://git.btclock.dev/btclock/btclock_v4/commit/d5333a0a7932ab92e175deb2ddc4d5e61c7ab4a0
Owner

🤖 Code Review — US Debt screen (feat/usdebt_screen)

Manual review by Claude (read the full head content of the new us_debt component + the integration points, not just the diff). Overall this is a clean, well-structured addition — solid task lifecycle, good RAII, and a sensible compact/precise display split. A few things worth addressing before merge, ordered by severity.


🟠 Medium

1. Dead branch in FormatUsDebtStringmain/screens/panel_texts.cpp#L946-L949

} else if (value_p < 10.0) {
  std::snprintf(buf, sizeof(buf), "$%.1fP", value_p);
} else {
  std::snprintf(buf, sizeof(buf), "$%.1fP", value_p);   // identical to the branch above
}

The value_p < 10.0 branch and the final else produce the exact same format string, so the < 10.0 test is dead. Given the < 1.0 branch uses "$%.2fP", the intent was almost certainly "$%.2fP" here too (extra precision for single-digit quadrillions). Either restore the intended %.2f or drop the redundant branch.

2. Triplicated "dollars + 3-digit grouping" logic. The derivation

const double raw = (us_debt_exact && *us_debt_exact > 0.0) ? *us_debt_exact
                                                           : *us_debt_t * 1e12;

plus the uint64 → 3-char groups formatting is duplicated across three places that must stay in lockstep:

A format tweak or bug-fix now needs editing in three spots — high divergence risk. Suggest two small shared helpers, e.g. UsDebtDollars(const DataSnapshot&) -> uint64_t and GroupDigits(uint64_t) -> std::vector<std::string>, reused by source/screen/manager.


🟡 Low

3. NVS read on every ShouldRender callscreen_manager.cpp#L700-L707

btclock::Prefs detect_prefs(btclock::prefs::kSettingsNs);
const bool big = btclock::settings::ReadBool(detect_prefs, btclock::prefs::kUsDebtBigChar);

This opens an NVS handle and reads from flash each time ShouldRender runs, whereas the other screen cases just compare cached values. The us_debt_big_char flag is already read once into RenderPrefs (rp.us_debt_big_char). Reuse the cached value here to avoid per-tick flash I/O and to guarantee ShouldRender and Render agree within the same cycle.

4. Float fallback precisionus_debt.cpp#L96-L100 (and the two duplicates)

The *us_debt_t * 1e12 fallback is doubly lossy: us_debt_t is already rounded to 0.1T (±~50 billion), and the double → uint64 cast can truncate (e.g. 39.2 * 1e12 → 39199999999999). It's effectively unreachable today because PollOnce() always sets us_debt_exact alongside us_debt_t, so this is defensive-only — but if the two ever diverge (e.g. a partial snapshot merge), the precise display would be wrong. When using us_debt_exact the path is exact (integer part < 2^53), so the fix is just to make the fallback's lossiness explicit or drop it.

5. Task stack size — please verify. us_debt_source.cpp#L127-L129 creates the worker with a 4 KB stack while it runs esp_http_client_perform + cJSON parsing. If TLS terminates on this task (rather than in the proxy transport), 4 KB is tight and a handshake could overflow it. Worth a quick uxTaskGetStackHighWaterMark() check under a real fetch.


Looks good / verified

  • Lifecycle is correct. Stop() sets stop_, joins on the done_ semaphore (≤12 s) and only then clears hub_, so there's no use-after-free / data race on hub_ from PollOnce(). FetchContext frees its buffer via RAII. Nicely done.
  • Response guard. 8 KB cap with a truncated flag prevents unbounded growth; body[size] = '\0' stays within the kMaxResponseBytes + 1 allocation.
  • static_assert(kAgnosticSlots == 11, …) bumped in lockstep with the slot map — good guard against the documented slot regression.
  • Not a bug: the heap_caps_malloc_prefer(kMaxResponseBytes + 1, 2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, MALLOC_CAP_8BIT) call is correct — the 2 is the count of capability arguments (num), not a caps bitmask. It properly prefers SPIRAM and falls back to internal 8-bit RAM. (Flagging explicitly because an automated pass read this as a "wrong caps" bug — it's a false positive.)

🧪 Tests

test_host/test_us_debt_panel_texts.cpp covers the panel-text formatting — good. Note the duplicated render/manager grouping logic (point 2) isn't exercised by those tests; folding it into a shared helper would also make it directly testable.

## 🤖 Code Review — US Debt screen (`feat/usdebt_screen`) Manual review by **Claude** (read the full head content of the new `us_debt` component + the integration points, not just the diff). Overall this is a clean, well-structured addition — solid task lifecycle, good RAII, and a sensible compact/precise display split. A few things worth addressing before merge, ordered by severity. --- ### 🟠 Medium **1. Dead branch in `FormatUsDebtString`** — [`main/screens/panel_texts.cpp#L946-L949`](https://git.btclock.dev/btclock/btclock_v4/src/commit/189001a0dca9b0dbade4558ae28f0df04ff1e4a1/main/screens/panel_texts.cpp#L946-L949) ```cpp } else if (value_p < 10.0) { std::snprintf(buf, sizeof(buf), "$%.1fP", value_p); } else { std::snprintf(buf, sizeof(buf), "$%.1fP", value_p); // identical to the branch above } ``` The `value_p < 10.0` branch and the final `else` produce the **exact same** format string, so the `< 10.0` test is dead. Given the `< 1.0` branch uses `"$%.2fP"`, the intent was almost certainly `"$%.2fP"` here too (extra precision for single-digit quadrillions). Either restore the intended `%.2f` or drop the redundant branch. **2. Triplicated "dollars + 3-digit grouping" logic.** The derivation ```cpp const double raw = (us_debt_exact && *us_debt_exact > 0.0) ? *us_debt_exact : *us_debt_t * 1e12; ``` plus the `uint64 → 3-char groups` formatting is duplicated across **three** places that must stay in lockstep: - [`panel_texts.cpp BuildUsDebt`](https://git.btclock.dev/btclock/btclock_v4/src/commit/189001a0dca9b0dbade4558ae28f0df04ff1e4a1/main/screens/panel_texts.cpp#L858) - [`main/screens/us_debt.cpp RenderUsDebtScreen` + `DebtGroups`](https://git.btclock.dev/btclock/btclock_v4/src/commit/189001a0dca9b0dbade4558ae28f0df04ff1e4a1/main/screens/us_debt.cpp#L20) - [`screen_manager.cpp ShouldRender` (L708) and `Render` (L1019)](https://git.btclock.dev/btclock/btclock_v4/src/commit/189001a0dca9b0dbade4558ae28f0df04ff1e4a1/main/app/screen_manager.cpp#L700-L715) A format tweak or bug-fix now needs editing in three spots — high divergence risk. Suggest two small shared helpers, e.g. `UsDebtDollars(const DataSnapshot&) -> uint64_t` and `GroupDigits(uint64_t) -> std::vector<std::string>`, reused by source/screen/manager. --- ### 🟡 Low **3. NVS read on every `ShouldRender` call** — [`screen_manager.cpp#L700-L707`](https://git.btclock.dev/btclock/btclock_v4/src/commit/189001a0dca9b0dbade4558ae28f0df04ff1e4a1/main/app/screen_manager.cpp#L700-L707) ```cpp btclock::Prefs detect_prefs(btclock::prefs::kSettingsNs); const bool big = btclock::settings::ReadBool(detect_prefs, btclock::prefs::kUsDebtBigChar); ``` This opens an NVS handle and reads from flash *each time* `ShouldRender` runs, whereas the other screen cases just compare cached values. The `us_debt_big_char` flag is already read once into `RenderPrefs` (`rp.us_debt_big_char`). Reuse the cached value here to avoid per-tick flash I/O and to guarantee `ShouldRender` and `Render` agree within the same cycle. **4. Float fallback precision** — [`us_debt.cpp#L96-L100`](https://git.btclock.dev/btclock/btclock_v4/src/commit/189001a0dca9b0dbade4558ae28f0df04ff1e4a1/main/screens/us_debt.cpp#L96-L100) (and the two duplicates) The `*us_debt_t * 1e12` fallback is doubly lossy: `us_debt_t` is already rounded to 0.1T (±~50 billion), and the `double → uint64` cast can truncate (e.g. `39.2 * 1e12 → 39199999999999`). It's effectively unreachable today because `PollOnce()` always sets `us_debt_exact` alongside `us_debt_t`, so this is defensive-only — but if the two ever diverge (e.g. a partial snapshot merge), the precise display would be wrong. When using `us_debt_exact` the path is exact (integer part < 2^53), so the fix is just to make the fallback's lossiness explicit or drop it. **5. Task stack size — please verify.** [`us_debt_source.cpp#L127-L129`](https://git.btclock.dev/btclock/btclock_v4/src/commit/189001a0dca9b0dbade4558ae28f0df04ff1e4a1/components/us_debt/src/us_debt_source.cpp#L127-L129) creates the worker with a **4 KB** stack while it runs `esp_http_client_perform` + cJSON parsing. If TLS terminates on this task (rather than in the proxy transport), 4 KB is tight and a handshake could overflow it. Worth a quick `uxTaskGetStackHighWaterMark()` check under a real fetch. --- ### ✅ Looks good / verified - **Lifecycle is correct.** `Stop()` sets `stop_`, joins on the `done_` semaphore (≤12 s) and only then clears `hub_`, so there's no use-after-free / data race on `hub_` from `PollOnce()`. `FetchContext` frees its buffer via RAII. Nicely done. - **Response guard.** 8 KB cap with a `truncated` flag prevents unbounded growth; `body[size] = '\0'` stays within the `kMaxResponseBytes + 1` allocation. - **`static_assert(kAgnosticSlots == 11, …)`** bumped in lockstep with the slot map — good guard against the documented slot regression. - **Not a bug:** the `heap_caps_malloc_prefer(kMaxResponseBytes + 1, 2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, MALLOC_CAP_8BIT)` call is correct — the `2` is the **count** of capability arguments (`num`), not a caps bitmask. It properly prefers SPIRAM and falls back to internal 8-bit RAM. (Flagging explicitly because an automated pass read this as a "wrong caps" bug — it's a false positive.) ### 🧪 Tests `test_host/test_us_debt_panel_texts.cpp` covers the panel-text formatting — good. Note the duplicated render/manager grouping logic (point 2) isn't exercised by those tests; folding it into a shared helper would also make it directly testable.
fix: last_rendered_us_debt_big_char_ cached in Render(), reused in ShouldRender()
Some checks failed
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
dc7ac9dccd
fix: revert staging ws url
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
6c0b28256f
docs: fix revert-issues
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
023c44e8c2
docs: whitespace
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
5c8415d517
docs: more revert-issues fixed
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
4b451d03fb
PMK left a comment

Please check my comments.

Please check my comments.
docs/HANDBOOK.md Outdated
Author
First-time contributor

@djuri Can you verify the dataSource if this is done correctly?

@djuri Can you verify the dataSource if this is done correctly?
docs/SETTINGS.md Outdated
@ -168,3 +170,3 @@
| `mempoolInstance` | string | `"mempool.space"` | Base URL of the mempool.space-compatible instance. | Reboot required. |
| `mempoolSecure` | bool | `true` | Use HTTPS/WSS when talking to `mempoolInstance`. | Reboot required. |
| `ceEndpoint` | string | `"ws-staging.btclock.dev"` | Custom-endpoint host when `dataSource=1`. | Reboot required. |
| `ceEndpoint` | string | `"ws.btclock.dev"` | Custom-endpoint host when `dataSource=2/3`. | Reboot required. |
Author
First-time contributor

@djuri Can you verify the dataSource if this is done correctly?

@djuri Can you verify the dataSource if this is done correctly?
@ -19,6 +19,8 @@
// slot 12 + 3k : kMarketCap api_id 30 for currencies[k]
// slot last : kBlockFeeRate api_id 6
//
// app_id 100 - 128 are for the debt screens (kDebtSlotBase)
Author
First-time contributor

@djuri As there are 28 new screens, I have added only this line.

@djuri As there are 28 new screens, I have added only this line.
@ -73,3 +72,4 @@
// agnostic block (after NwcBalance) so the screen's introduction
// didn't shift bitaxe / NWC slot indices. New screens added later
// should follow the same pattern.
"base slot") {
Author
First-time contributor

@djuri Please test the "didn't shift bitaxe / NWC slot indices" part with this fix.

@djuri Please test the "didn't shift bitaxe / NWC slot indices" part with this fix.
Merge branch 'main' into feat/usdebt_screen
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
61df9dc439
Owner

Review — US Debt screen over the v2 WS metric channel

Verdict: Request changes. This is a well-structured feature — the rate-frame-ticked-locally model is the right design, the slot/catalog integration is clean, and host-test coverage is solid. The wire protocol aligns 100% with the new Go metric feed. But there are two must-fixes before merge, and the debt socket's connection target is wired wrong — confirmed both by static analysis and empirically on hardware (Rev B).

Empirical verification (Rev B @ staging, dataSource=3ws-staging.btclock.dev)

I built this branch, flashed a Rev B, pointed it at staging, and read the live panel texts via GET /api/status (data[] = the current screen's panels), jumping straight to the debt screen with POST /api/show/screen {"s":100}:

Build data[] on screen 100 Result
A — branch as-is ['US/DEBT','','','','','',''] label only, all value cells blank
B — 2-line fix (below) ['US/DEBT','$','3','9.','2','6','T'] $39.26T, fully rendered from staging

In both runs the control price screen showed ['BTC/USD','$','6','2','9','3','7'] (≈ $62,937) — so the staging core feed was alive the whole time; only debt was silent in A. That isolates the failure precisely to the debt-metric routing.


🔴 Blockers

1. The debt-metric socket can never reach the server that serves the feed

main/sources/sources.cpp

The metric feed exists only on ws-staging.btclock.dev today (GET https://ws.btclock.dev/api/v2/metrics404). But the debt subscription is hardwired to production via two paths:

  • shares_debt_socket = (data_source == 0) (line ~199): with a custom endpoint (dataSource=3), the core socket that is connected to staging is handed an empty metric_keys list and never subscribes to debt:US.
  • The dedicated debt socket (line ~215, data_source != 0) is built with BuildBtclockSourceUri(0, "", false) = wss://ws.btclock.dev/api/v2/ws — hardcoded production, ignoring ceEndpoint.

So there is no settings/runtime path to point the debt socket at the configured custom endpoint. This is broader than the staging period: even after GA, a user running dataSource=3 against a self-hosted btclock endpoint that serves metrics would still not get debt. Hardware-verified fix (2 lines):

// line ~199 — 0/2/3 already hold a btclock-v2 core socket; ride it for debt:
const bool shares_debt_socket = data_source != 1;
// line ~215 — only mempool+kraken (ds=1) needs a separate debt socket:
if (data_source == 1) {

Optional refinement: have that data_source == 1 socket honor ceEndpoint instead of hardcoding production.

2. Host tests fail on CI — ceEndpoint default mismatch

test_host/test_settings_api.cpp asserts the ceEndpoint GET default is "ws.btclock.dev", but components/settings/include/settings/schema.hpp still declares kCeEndpoint's default as "ws-staging.btclock.dev" (and docs/SETTINGS.md agrees with the schema). GET /api/settings emits the schema default, so the assertion fails deterministically → btclock_host_tests exits non-zero → CI red. Looks like a rebase leftover (test updated, schema/doc not). Pick one source of truth.


🟢 Wire-contract alignment — fully verified

A live probe against wss://ws-staging.btclock.dev/api/v2/ws replaying the firmware's exact subscribe frame, plus feeding the real server bytes through the vendored ArduinoJson:

  • subscribe frame = 45 bytes (≪ the 96-byte buffer) → server acks Subscribed to debt:US
  • rate-frame base/rate/ref/dp encode as float64 / float64 / int64 / intis<double>() / is<double>() / is<int64_t>() / is<uint8_t>() all accept it.

Because the Go encoder statically types base/rate as float64, msgpack always emits 0xcb even for whole values, so the is<double>() guards are safe against integer-encoding drift by construction. No legitimate frame is ever dropped. The protocol side is correct; only the endpoint wiring was wrong.


🟠 Medium

  • BTC mode is wrong for small countriesmain/screens/panel_texts.cpp (FormatCompactDebt). The divisor table floors at 1e6 ('M'), so any debt-in-BTC below 1M renders as a misleading ₿0.0XM (e.g. Malta ≈ ₿0.11M), and the non-big-char path rounds *value to uint64 so a sub-1-BTC value collapses to 0. Add a sub-million tier and a host test for a small-country BTC value.
  • debtCurrency toggle bounces the WebSocketcomponents/webserver/control_server.cpp. It's in the on_screens_changed trigger, which posts kRebuildScreensSetSubscriptions → unconditional Stop()+Start() on the live socket. But debtCurrency only changes rendering; the subscription set is invariant. Route it through the live re-render / MarkDirty path instead of a TLS reconnect.
  • Always-on second WSS socket for every data_source != 0 user, even with zero debt screens enabled. Largely resolved by the Blocker-1 fix.
  • Rev A partition budgetmain/CMakeLists.txt links debt.cpp + the 29-entry catalog + ~170 lines of formatting unconditionally into the 4 MB image. Rev A sits at ~19 KiB free, so please measure the Rev A build before merge. Feature parity across all variants is the goal — if it regresses past the headroom, reclaim space by trimming the footprint (e.g. a leaner catalog representation, folding the duplicated formatting), not by excluding Rev A from the build.
  • Semantic merge conflict — of the conflicting files, only main/sources/sources.cpp truly conflicts on a 3-way merge. main moved currency-fetch into a deferred RefreshUpstreamCurrencies(); a naive resolution drops the USD/EUR price subscription (which debt-in-BTC depends on) after the first connect. Rebase manually and add a test that USD/EUR survive the post-connect prune.
  • DataSnapshot::Merge debt loop has no testcomponents/data_core/hub.cpp.

Low / nit (selection)

Per-tick ReadRenderPrefs() (~18 NVS reads) in the debt ShouldRender branch; the debt screen repaints every minSecPriceUpds while displayed (the counter ticks continuously — EPD wear); BuildDebtPanelTexts is built 3× per paint; dp is decoded but never consumed; dead starts_with("screen") branch in IsScreenVisibilityKey; ShouldRender/Render debt-key diverge only on the first frame (V8, N=8); untested edges in CurrentDebtValue (now<ref), the FormatCompactDebt unit-carry, and the 8-panel layout. Note: the "replace the US-only setting" comment is inaccurate — the prefs are purely additive, no migration involved.


Suggested order before merge

  1. Endpoint fix (2 lines, hardware-verified) — optionally honor ceEndpoint for the data_source == 1 socket too.
  2. Resolve the ceEndpoint default mismatch; get host tests green.
  3. Manual rebase onto main (semantic sources.cpp conflict) + a test that the debt USD/EUR subscription survives the prune.
  4. Measure the Rev A partition.
  5. BTC-mode sub-million tier; decouple debtCurrency from the WS bounce.

Reviewed by reading the full PR-branch files (not just the diff), an adversarially-verified multi-dimension pass, a live wire probe, and an on-device build/flash confirmation on Rev B.

# Review — US Debt screen over the v2 WS `metric` channel **Verdict: Request changes.** This is a well-structured feature — the rate-frame-ticked-locally model is the right design, the slot/catalog integration is clean, and host-test coverage is solid. The **wire protocol aligns 100% with the new Go `metric` feed**. But there are two must-fixes before merge, and the debt socket's connection target is wired wrong — confirmed both by static analysis and **empirically on hardware (Rev B)**. ## Empirical verification (Rev B @ staging, `dataSource=3` → `ws-staging.btclock.dev`) I built this branch, flashed a Rev B, pointed it at staging, and read the live panel texts via `GET /api/status` (`data[]` = the current screen's panels), jumping straight to the debt screen with `POST /api/show/screen {"s":100}`: | Build | `data[]` on screen 100 | Result | |---|---|---| | **A — branch as-is** | `['US/DEBT','','','','','','']` | label only, **all value cells blank** | | **B — 2-line fix (below)** | `['US/DEBT','$','3','9.','2','6','T']` | **$39.26T**, fully rendered from staging | In both runs the control price screen showed `['BTC/USD','$','6','2','9','3','7']` (≈ $62,937) — so the staging **core feed was alive the whole time**; only debt was silent in A. That isolates the failure precisely to the debt-metric routing. --- ## 🔴 Blockers ### 1. The debt-metric socket can never reach the server that serves the feed `main/sources/sources.cpp` The `metric` feed exists **only** on `ws-staging.btclock.dev` today (`GET https://ws.btclock.dev/api/v2/metrics` → `404`). But the debt subscription is hardwired to production via two paths: - `shares_debt_socket = (data_source == 0)` (line ~199): with a custom endpoint (`dataSource=3`), the core socket that *is* connected to staging is handed an **empty** `metric_keys` list and never subscribes to `debt:US`. - The dedicated debt socket (line ~215, `data_source != 0`) is built with `BuildBtclockSourceUri(0, "", false)` = `wss://ws.btclock.dev/api/v2/ws` — hardcoded production, **ignoring `ceEndpoint`**. So there is no settings/runtime path to point the debt socket at the configured custom endpoint. This is broader than the staging period: even after GA, a user running `dataSource=3` against a self-hosted btclock endpoint that serves metrics would still not get debt. **Hardware-verified fix (2 lines):** ```cpp // line ~199 — 0/2/3 already hold a btclock-v2 core socket; ride it for debt: const bool shares_debt_socket = data_source != 1; // line ~215 — only mempool+kraken (ds=1) needs a separate debt socket: if (data_source == 1) { ``` Optional refinement: have that `data_source == 1` socket honor `ceEndpoint` instead of hardcoding production. ### 2. Host tests fail on CI — `ceEndpoint` default mismatch `test_host/test_settings_api.cpp` asserts the `ceEndpoint` GET default is `"ws.btclock.dev"`, but `components/settings/include/settings/schema.hpp` still declares `kCeEndpoint`'s default as `"ws-staging.btclock.dev"` (and `docs/SETTINGS.md` agrees with the schema). `GET /api/settings` emits the schema default, so the assertion fails deterministically → `btclock_host_tests` exits non-zero → CI red. Looks like a rebase leftover (test updated, schema/doc not). Pick one source of truth. --- ## 🟢 Wire-contract alignment — fully verified A live probe against `wss://ws-staging.btclock.dev/api/v2/ws` replaying the firmware's exact subscribe frame, plus feeding the real server bytes through the vendored ArduinoJson: - subscribe frame = 45 bytes (≪ the 96-byte buffer) → server acks `Subscribed to debt:US` - rate-frame `base`/`rate`/`ref`/`dp` encode as **float64 / float64 / int64 / int** → `is<double>()` / `is<double>()` / `is<int64_t>()` / `is<uint8_t>()` all accept it. Because the Go encoder statically types `base`/`rate` as `float64`, msgpack always emits `0xcb` even for whole values, so the `is<double>()` guards are safe against integer-encoding drift by construction. No legitimate frame is ever dropped. The protocol side is correct; only the endpoint wiring was wrong. --- ## 🟠 Medium - **BTC mode is wrong for small countries** — `main/screens/panel_texts.cpp` (`FormatCompactDebt`). The divisor table floors at `1e6` ('M'), so any debt-in-BTC below 1M renders as a misleading `₿0.0XM` (e.g. Malta ≈ `₿0.11M`), and the non-big-char path rounds `*value` to `uint64` so a sub-1-BTC value collapses to `0`. Add a sub-million tier and a host test for a small-country BTC value. - **`debtCurrency` toggle bounces the WebSocket** — `components/webserver/control_server.cpp`. It's in the `on_screens_changed` trigger, which posts `kRebuildScreens` → `SetSubscriptions` → unconditional `Stop()`+`Start()` on the live socket. But `debtCurrency` only changes rendering; the subscription set is invariant. Route it through the live re-render / `MarkDirty` path instead of a TLS reconnect. - **Always-on second WSS socket** for every `data_source != 0` user, even with zero debt screens enabled. Largely resolved by the Blocker-1 fix. - **Rev A partition budget** — `main/CMakeLists.txt` links `debt.cpp` + the 29-entry catalog + ~170 lines of formatting unconditionally into the 4 MB image. Rev A sits at ~19 KiB free, so please measure the Rev A build before merge. Feature parity across all variants is the goal — if it regresses past the headroom, reclaim space by trimming the footprint (e.g. a leaner catalog representation, folding the duplicated formatting), **not** by excluding Rev A from the build. - **Semantic merge conflict** — of the conflicting files, only `main/sources/sources.cpp` truly conflicts on a 3-way merge. `main` moved currency-fetch into a deferred `RefreshUpstreamCurrencies()`; a naive resolution drops the USD/EUR price subscription (which debt-in-BTC depends on) after the first connect. Rebase manually and add a test that USD/EUR survive the post-connect prune. - **`DataSnapshot::Merge` debt loop has no test** — `components/data_core/hub.cpp`. ## ⚪ Low / nit (selection) Per-tick `ReadRenderPrefs()` (~18 NVS reads) in the debt `ShouldRender` branch; the debt screen repaints every `minSecPriceUpd`s while displayed (the counter ticks continuously — EPD wear); `BuildDebtPanelTexts` is built 3× per paint; `dp` is decoded but never consumed; dead `starts_with("screen")` branch in `IsScreenVisibilityKey`; `ShouldRender`/`Render` debt-key diverge only on the first frame (V8, N=8); untested edges in `CurrentDebtValue` (now<ref), the `FormatCompactDebt` unit-carry, and the 8-panel layout. Note: the "replace the US-only setting" comment is inaccurate — the prefs are purely additive, no migration involved. --- ## Suggested order before merge 1. Endpoint fix (2 lines, hardware-verified) — optionally honor `ceEndpoint` for the `data_source == 1` socket too. 2. Resolve the `ceEndpoint` default mismatch; get host tests green. 3. Manual rebase onto `main` (semantic `sources.cpp` conflict) + a test that the debt USD/EUR subscription survives the prune. 4. Measure the Rev A partition. 5. BTC-mode sub-million tier; decouple `debtCurrency` from the WS bounce. *Reviewed by reading the full PR-branch files (not just the diff), an adversarially-verified multi-dimension pass, a live wire probe, and an on-device build/flash confirmation on Rev B.*
fix: various review feedback
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
9e3c4b96e7
Merge branch 'main' into feat/usdebt_screen
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
0438a0cb73
Owner

Re-review — after 9e3c4b9 + 0438a0c (firmware) and webui#179

Verdict: Request changes. Functionally correct and hardware-verified, but the debt catalogue must be data-driven before this merges (Required #1), and one latent regression remains.

Addressed since the last review (9e3c4b9)

Finding (was) Fix
🔴 Debt socket hardwired to production shares_debt_socket = data_source != 1; dedicated socket only for data_source == 1. Verified on Rev B — with dataSource=3→staging the US Debt screen renders live data.
🔴 CI red: ceEndpoint default mismatch test reverted to ws-staging.btclock.dev, matches schema/doc.
🟠 debtCurrency PATCH bounced the WS diff-guard added in SetSubscriptions.
🟠 BTC-mode wrong for small countries 'K' tier added to FormatCompactDebt.
🟠 DataSnapshot::Merge debt loop untested new tests in test_data_hub.cpp.
nits (dp unused, dead starts_with("screen"), whitespace) all cleaned up.

🔴 Required before merge

1. Drive the metric catalogue from /api/v2/metrics/info — don't hardcode it. The Go server is the source of truth and now publishes the full per-metric catalogue:

{"key":"debt:US","label":"United States","region":"US","currency":"USD","kind":"debt","dp":0}

(live on staging, 29 entries). The hardcoded kDebtCountries table duplicates that catalogue (key/label/region/currency/dp) in the firmware, which means it silently drifts the moment the server adds, renames, re-denominates, or drops a metric: wrong label or currency, a missing country, or a screen that subscribes to a key the server no longer serves — each only fixable with a coordinated firmware release. The firmware already avoids exactly this class of drift for fiat by fetching /api/v2/currencies; debt metrics should follow the same proven path (FetchAvailableCurrencies in main/sources/sources.cpp, deferred + non-blocking). Maintaining two hand-synced copies of the catalogue is the kind of liability that shouldn't ship.

Two things to handle in the firmware:

  • Stable api_id per key, persisted in NVS (first-seen → next free id, not list position — the server list is alphabetical, so a positional id shifts every persisted s<id>Visible/screenOrder entry when a country is inserted). This is the one thing the endpoint can't give you, and it's what keeps persisted screen order/visibility stable across catalogue growth.
  • Runtime-derived slot count instead of kBaseAgnosticSlots + kDebtCountries.size(). This touches slot_count()/KindForSlot/ApiIdForSlot/SlotForApiId and the static_assert drift-guards, so it wants focused tests (the slot-shift regression those asserts protect against).

Re-add the dp field while you're there and honor it in FormatCompactDebt. Degrade gracefully when /api/v2/metrics/info is unreachable (empty catalogue → no metric screens, same as today's default-off). Availability is no extra risk: the info endpoint ships on the same feat/metric-debt-channel branch as the feed itself. Bonus: this removes the 29 string-literal rows from every image, which also resolves the Rev A partition concern below.

2. Fix the debt USD/EUR price drop in RefreshUpstreamCurrencies. The main merge (0438a0c) brought in the deferred currency refresh but didn't extend it for debt. main/sources/sources.cpp:288 re-subscribes with the pruned user currency list and does not call AddDebtPriceCurrencies — unlike the live actCurrencies PATCH path (main/app/control_command_drain.cpp:211), which does. The debt:US metric sub survives (SetCurrencies keeps metric_keys_), but the USD/EUR price sub does not, so debtCurrency=1 (BTC mode) loses its conversion price after the first STA connect whenever a currency is pruned and the user has no USD/EUR in actCurrencies. The comment there even claims it's the "same path as the live actCurrencies PATCH hook" — it isn't. Fix: re-apply AddDebtPriceCurrencies to the pruned set (for data_source != 1) before re-subscribing, plus a host test that USD/EUR survive a prune with a debt screen enabled.

🟡 Nit

  • The new comments in sources.cpp mislabel data_source==1 as "Nostr" — it's mempool+Kraken; 2 is Nostr.
  • Rev A partition: I verified Rev B (51% app free), not Rev A. Required #1 removes the hardcoded catalogue and with it this concern; if #1 lands, no separate measurement is needed.

End-to-end hardware verification (Rev B)

Flashed 9e3c4b9 + webui#179 to a Rev B against ws-staging:

  • US Debt renders live, locally-ticked: $39.31T (compact) and $ 39 314 436 … (groups, debtBigChar=false).
  • The matching WebUI toggles ("Use big characters for national debt", "Debt amount currency" USD/EUR↔BTC) are served and functional.
  • Core feed (price/blocks) unaffected. Protocol alignment with the v2 metric channel is correct and empirically verified.

💡 Suggestion (optional)

Tag metric screens in /api/settings so the WebUI can group them. With 29 debt screens, the flat screens[] list ({id,name,enabled,order}) crowds the Status column and the screen picker. Adding a discriminator per entry — e.g. "kind":"debt" (or a generic "group":"metric"), sourced from the screen catalogue's existing short-name — lets the WebUI render metric screens in their own collapsible "National debt" section, separate from the standard screens. Small, additive change in BuildGetResponse + static/openapi.{json,yml} + the WebUI Screen type; backward-compatible.

Bottom line: make the catalogue data-driven from /api/v2/metrics/info (Required #1) and fix the RefreshUpstreamCurrencies price drop (Required #2), and this is good to merge. The feature is already functionally correct and hardware-verified; these keep it maintainable.

## Re-review — after `9e3c4b9` + `0438a0c` (firmware) and webui#179 **Verdict: Request changes.** Functionally correct and hardware-verified, but the debt catalogue must be data-driven before this merges (Required #1), and one latent regression remains. ### ✅ Addressed since the last review (`9e3c4b9`) | Finding (was) | Fix | |---|---| | 🔴 Debt socket hardwired to production | `shares_debt_socket = data_source != 1`; dedicated socket only for `data_source == 1`. **Verified on Rev B** — with `dataSource=3`→staging the US Debt screen renders live data. | | 🔴 CI red: `ceEndpoint` default mismatch | test reverted to `ws-staging.btclock.dev`, matches schema/doc. | | 🟠 `debtCurrency` PATCH bounced the WS | diff-guard added in `SetSubscriptions`. | | 🟠 BTC-mode wrong for small countries | `'K'` tier added to `FormatCompactDebt`. | | 🟠 `DataSnapshot::Merge` debt loop untested | new tests in `test_data_hub.cpp`. | | nits (`dp` unused, dead `starts_with("screen")`, whitespace) | all cleaned up. | ### 🔴 Required before merge **1. Drive the metric catalogue from `/api/v2/metrics/info` — don't hardcode it.** The Go server is the source of truth and now publishes the full per-metric catalogue: ```json {"key":"debt:US","label":"United States","region":"US","currency":"USD","kind":"debt","dp":0} ``` (live on staging, 29 entries). The hardcoded `kDebtCountries` table duplicates that catalogue (key/label/region/currency/dp) in the firmware, which means it silently drifts the moment the server adds, renames, re-denominates, or drops a metric: wrong label or currency, a missing country, or a screen that subscribes to a key the server no longer serves — each only fixable with a coordinated firmware release. The firmware already avoids exactly this class of drift for fiat by fetching `/api/v2/currencies`; debt metrics should follow the same proven path (`FetchAvailableCurrencies` in `main/sources/sources.cpp`, deferred + non-blocking). Maintaining two hand-synced copies of the catalogue is the kind of liability that shouldn't ship. Two things to handle in the firmware: - **Stable `api_id` per key, persisted in NVS** (first-seen → next free id, *not* list position — the server list is alphabetical, so a positional id shifts every persisted `s<id>Visible`/`screenOrder` entry when a country is inserted). This is the one thing the endpoint can't give you, and it's what keeps persisted screen order/visibility stable across catalogue growth. - **Runtime-derived slot count** instead of `kBaseAgnosticSlots + kDebtCountries.size()`. This touches `slot_count()`/`KindForSlot`/`ApiIdForSlot`/`SlotForApiId` and the `static_assert` drift-guards, so it wants focused tests (the slot-shift regression those asserts protect against). Re-add the `dp` field while you're there and honor it in `FormatCompactDebt`. Degrade gracefully when `/api/v2/metrics/info` is unreachable (empty catalogue → no metric screens, same as today's default-off). Availability is no extra risk: the info endpoint ships on the same `feat/metric-debt-channel` branch as the feed itself. Bonus: this removes the 29 string-literal rows from every image, which also resolves the Rev A partition concern below. **2. Fix the debt USD/EUR price drop in `RefreshUpstreamCurrencies`.** The `main` merge (`0438a0c`) brought in the deferred currency refresh but didn't extend it for debt. `main/sources/sources.cpp:288` re-subscribes with the pruned **user** currency list and does *not* call `AddDebtPriceCurrencies` — unlike the live `actCurrencies` PATCH path (`main/app/control_command_drain.cpp:211`), which does. The `debt:US` metric sub survives (`SetCurrencies` keeps `metric_keys_`), but the USD/EUR price sub does not, so `debtCurrency=1` (BTC mode) loses its conversion price after the first STA connect whenever a currency is pruned and the user has no USD/EUR in `actCurrencies`. The comment there even claims it's the "same path as the live actCurrencies PATCH hook" — it isn't. Fix: re-apply `AddDebtPriceCurrencies` to the pruned set (for `data_source != 1`) before re-subscribing, plus a host test that USD/EUR survive a prune with a debt screen enabled. ### 🟡 Nit - The new comments in `sources.cpp` mislabel `data_source==1` as "Nostr" — it's mempool+Kraken; `2` is Nostr. - Rev A partition: I verified Rev B (51% app free), not Rev A. Required #1 removes the hardcoded catalogue and with it this concern; if #1 lands, no separate measurement is needed. ### ✅ End-to-end hardware verification (Rev B) Flashed `9e3c4b9` + webui#179 to a Rev B against `ws-staging`: - US Debt renders live, locally-ticked: `$39.31T` (compact) and `$ 39 314 436 …` (groups, `debtBigChar=false`). - The matching WebUI toggles ("Use big characters for national debt", "Debt amount currency" USD/EUR↔BTC) are served and functional. - Core feed (price/blocks) unaffected. Protocol alignment with the v2 `metric` channel is correct and empirically verified. ### 💡 Suggestion (optional) **Tag metric screens in `/api/settings` so the WebUI can group them.** With 29 debt screens, the flat `screens[]` list (`{id,name,enabled,order}`) crowds the Status column and the screen picker. Adding a discriminator per entry — e.g. `"kind":"debt"` (or a generic `"group":"metric"`), sourced from the screen catalogue's existing short-name — lets the WebUI render metric screens in their own collapsible "National debt" section, separate from the standard screens. Small, additive change in `BuildGetResponse` + `static/openapi.{json,yml}` + the WebUI `Screen` type; backward-compatible. **Bottom line:** make the catalogue data-driven from `/api/v2/metrics/info` (Required #1) and fix the `RefreshUpstreamCurrencies` price drop (Required #2), and this is good to merge. The feature is already functionally correct and hardware-verified; these keep it maintainable.
refactor(debt): fetch debt-country catalogue from /api/v2/metrics/info at runtime
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
39cd32f1ea
The supported debt countries were a hardcoded constexpr kDebtCountries
array. Make the relay the source of truth: fetch the catalogue from
/api/v2/metrics/info on the first STA connect, with the compile-time
list demoted to an offline fallback

- data_core: kDebtCountries (constexpr std::array) -> a runtime
  std::vector<DebtCountry> singleton (DebtCatalog/SetDebtCatalog),
  seeded from the kDebtCatalogFallback snapshot. DebtCountry now owns
  std::string so a fetched entry outlives the HTTP buffer. Helpers
  (DebtCountryForApiId/Metric, DebtIndexForApiId, DebtCount) read it.
- slot_map: keep kAgnosticSlots as a constexpr pinned to the fallback
  size (host-test slot contract + KindForSlot switch coverage); live
  slot math (SlotCount/ApiIdForSlot/SlotForApiId/TransposeSlot) and the
  ScreenManager use a runtime AgnosticSlots() so a resized catalogue
  still maps api_id <-> slot. The relay's metric `id` is the stable
  api_id, so persisted screenOrder / screen<id>Visible survive.
- catalogs: kScreenKinds (constexpr) -> ScreenKindsCatalog() built at
  runtime from the live debt rows + constexpr kBaseScreenKinds.
- btclock_data: new BuildMetricsInfoUri + ParseDebtCatalogJson (kind
  filter, id-sort, "<region> Debt" label synthesis, validation) and
  FetchDebtCatalog (esp_http_client), mirroring the currencies fetch.
- sources: RefreshDebtCatalog runs alongside RefreshUpstreamCurrencies;
  on a changed catalogue it swaps it in, re-anchors the current slot by
  api_id (ScreenManager::ApplyDebtCatalog), rebuilds the rotation and
  refreshes debt WS subscriptions. No-op when the relay matches the
  fallback or the fetch fails.

EU27 row is now relay-authoritative: code EU -> EU27, label
"EU Debt" -> "EU27 Debt" (panel header EU/DEBT -> EU27/DEBT).

Host tests cover the URL/parse/swap surface (test_btclock_debt_catalog);
existing slot-map / settings-catalog tests stay green against the
fallback. rev-a firmware builds clean.
refactor(debt): drive the metric catalogue from the relay
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
91cb876cc0
The firmware no longer ships a compile-time debt catalogue.
Owner

Two findings from hands-on testing of 91cb876 on a Rev B

🟠 Regression: debt screens are missing from GET /api/settings (so the WebUI can't list/enable them)

On the runtime-catalogue build the debt screens render (POST /api/show/screen {"s":100}US/DEBT $ 39 377 485 510 772) but are absent from the screens[] array in GET /api/settings (11 entries, no id ≥ 100). webui#179 sources the screen list solely from /api/settings (ScreenButtons.svelte: settings.screens.filter((s) => s.enabled); no /api/v2/metrics/info fetch), and only adds the two global debtBigChar / debtCurrency toggles — so with the debt rows missing there is no way to list or enable/disable an individual debt screen from the UI. The two PRs are inconsistent: the WebUI expects debt rows in /api/settings; the firmware doesn't deliver them at runtime.

Root cause is ordering. ScreenKindsCatalog() does include DebtCatalog(), and init_control_api builds ctx.screens from it — but that runs at boot, before RefreshDebtCatalog() (first STA connect) fetches the catalogue. The post-fetch swap rebuilds the rotation but not ctx.screens, so the settings list stays a boot-time snapshot with the catalogue still empty. On 9e3c4b9 (compile-time catalogue) the rows were present — test_settings_catalogs asserted expected.at(100) == "US Debt" and kEnumCount == 43 — so this is a regression introduced by the runtime refactor.

The host test masks it: test_settings_catalogs on 91cb876 seeds DebtCatalog() (via debt_catalog_seed) before building the settings response, so CI is green — but on-device the order is reversed (ctx.screens built before the catalogue loads), which is exactly the path that fails.

Fix: rebuild ctx.screens after RefreshDebtCatalog() installs the catalogue (or build the settings screen list lazily at GET time from the live ScreenKindsCatalog()), and add a host test that follows the real boot order — empty catalogue → build context → install catalogue → assert /api/settings now contains the debt rows.

(This supersedes my earlier "suggestion #2 — tag metric screens in /api/settings": they first need to be present at all.)

🔎 Crash investigation (Rev B coredump): heap corruption, not the debt feature or the fonts

A Rev B running the debt build captured a coredump: abort() inside stb_truetype's rasterizer (STBTT_assert(z->direction) in stbtt__rasterize_sorted_edges) while rendering '8' at pixel_height 196 on the Moscow Time screen (fontName=notoSans, glyph box 72×107, scale 0.143906).

I reproduced that exact rasterization on the host with the repo's own stb_truetype.h (v1.26, asserts on): the byte-identical input (NotoSans '8' ph 196 → box 72×107, scale 0.143906) renders cleanly, and an exhaustive sweep (both NotoSans cuts × all ASCII × ph 120–240) fires no assert. So the glyph data is benign — the rasterizer was the victim of heap corruption, not the cause. The corruptor is not in that backtrace and isn't the debt code (the crash is on Moscow Time).

I reflashed the Rev B with CONFIG_HEAP_POISONING_COMPREHENSIVE=y and tried to provoke a recurrence (~7 min: WS-bounce via actCurrencies churn ≈115 reconnects, render churn across all screens incl. debt + Moscow Time, API/zap/identify/heap-trace flood). No recurrence — heap stayed healthy, no leak. The corruptor is rare/elusive; a poisoning build + watcher are left running so the next occurrence aborts at the offending block (address + caller PC) rather than as a downstream font assert.

This isn't a blocker for this PR (it predates and is unrelated to the debt code), but flagging it here since it surfaced on this build and the simulation conclusively rules out the fonts.

## Two findings from hands-on testing of `91cb876` on a Rev B ### 🟠 Regression: debt screens are missing from `GET /api/settings` (so the WebUI can't list/enable them) On the runtime-catalogue build the debt screens **render** (`POST /api/show/screen {"s":100}` → `US/DEBT $ 39 377 485 510 772`) but are **absent from the `screens[]` array** in `GET /api/settings` (11 entries, no id ≥ 100). webui#179 sources the screen list solely from `/api/settings` (`ScreenButtons.svelte`: `settings.screens.filter((s) => s.enabled)`; no `/api/v2/metrics/info` fetch), and only adds the two *global* `debtBigChar` / `debtCurrency` toggles — so with the debt rows missing there is no way to list or enable/disable an individual debt screen from the UI. The two PRs are inconsistent: the WebUI expects debt rows in `/api/settings`; the firmware doesn't deliver them at runtime. Root cause is ordering. `ScreenKindsCatalog()` does include `DebtCatalog()`, and `init_control_api` builds `ctx.screens` from it — but that runs **at boot**, before `RefreshDebtCatalog()` (first STA connect) fetches the catalogue. The post-fetch swap rebuilds the rotation but not `ctx.screens`, so the settings list stays a boot-time snapshot with the catalogue still empty. On `9e3c4b9` (compile-time catalogue) the rows were present — `test_settings_catalogs` asserted `expected.at(100) == "US Debt"` and `kEnumCount == 43` — so this is a regression introduced by the runtime refactor. The host test masks it: `test_settings_catalogs` on `91cb876` **seeds** `DebtCatalog()` (via `debt_catalog_seed`) *before* building the settings response, so CI is green — but on-device the order is reversed (`ctx.screens` built before the catalogue loads), which is exactly the path that fails. **Fix:** rebuild `ctx.screens` after `RefreshDebtCatalog()` installs the catalogue (or build the settings screen list lazily at GET time from the live `ScreenKindsCatalog()`), and add a host test that follows the real boot order — empty catalogue → build context → install catalogue → assert `/api/settings` now contains the debt rows. *(This supersedes my earlier "suggestion #2 — tag metric screens in `/api/settings`": they first need to be present at all.)* ### 🔎 Crash investigation (Rev B coredump): heap corruption, **not** the debt feature or the fonts A Rev B running the debt build captured a coredump: `abort()` inside stb_truetype's rasterizer (`STBTT_assert(z->direction)` in `stbtt__rasterize_sorted_edges`) while rendering `'8'` at pixel_height 196 on the Moscow Time screen (`fontName=notoSans`, glyph box 72×107, scale 0.143906). I reproduced that exact rasterization on the host with the repo's own `stb_truetype.h` (v1.26, asserts on): the byte-identical input (NotoSans `'8'` ph 196 → box 72×107, scale 0.143906) renders **cleanly**, and an exhaustive sweep (both NotoSans cuts × all ASCII × ph 120–240) fires no assert. So the glyph data is benign — the rasterizer was the **victim of heap corruption**, not the cause. The corruptor is not in that backtrace and isn't the debt code (the crash is on Moscow Time). I reflashed the Rev B with `CONFIG_HEAP_POISONING_COMPREHENSIVE=y` and tried to provoke a recurrence (~7 min: WS-bounce via actCurrencies churn ≈115 reconnects, render churn across all screens incl. debt + Moscow Time, API/zap/identify/heap-trace flood). No recurrence — heap stayed healthy, no leak. The corruptor is rare/elusive; a poisoning build + watcher are left running so the next occurrence aborts at the offending block (address + caller PC) rather than as a downstream font assert. This isn't a blocker for this PR (it predates and is unrelated to the debt code), but flagging it here since it surfaced on this build and the simulation conclusively rules out the fonts.
Merge branch 'main' into feat/usdebt_screen
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
e30071bd22
Owner

🟠 Full-device hang on hardware — main event loop deadlocks in esp_websocket_client_stop() (pre-existing WS-teardown bug, surfaced while testing this branch)

Correction to my original wording: I first framed this as "introduced via the debt subscription refresh." After checking the history that is not accurate — the deadlock-prone path is pre-existing in main and is not introduced by this PR. I'm keeping the report here because I hit it while testing the debt build and it's worth fixing, but the attribution below is corrected.

Reproduced on a Rev B running this branch (91cb876, comprehensive heap poisoning on). The device "hung": e-paper frozen, POST /api/show/screen accepted (HTTP 200) but never applied, data and heap completely static — yet /api/status kept answering and espUptime kept climbing. So it isn't a crash: the RTOS and the httpd task are alive while the application event loop is dead.

Root cause (confirmed via JTAG thread apply all bt)

The main task (which runs RunEventLoop) is parked forever here:

#0  xEventGroupWaitBits (xTicksToWait = portMAX_DELAY)   event_groups.c:439
#1  stop_wait_task                                       esp_websocket_client.c:543
#2  esp_websocket_client_stop                            esp_websocket_client.c:1456
#3  SafeShutdownWsClient                                 net_util/ws_client_lifecycle.hpp:37
#5  BtclockDataSource::Stop                              btclock_data.cpp:260
#6  BtclockDataSource::SetSubscriptions                  btclock_data.cpp:209
#8  DrainControlCommands (Kind::kRebuildScreens)         control_command_drain.cpp:213
#9  RunEventLoop                                         event_loop.cpp:130
#10 app_main                                             main.cpp:165

A kRebuildScreens command (a settings / screen-order / currency change) calls SetSubscriptions(...), which performs a synchronous Stop()SafeShutdownWsClient()esp_websocket_client_stop() with an unbounded portMAX_DELAY wait for STOPPED_BIT. Inspecting the client handle being stopped (0x3c21ff34) at the halt point, the struct is fully zeroed (task_handle=0, status_bits=0, state=WEBSOCKET_STATE_UNKNOW, run=false) while the waiter is blocked on a now-stale event-group handle it captured before the zeroing. So STOPPED_BIT is never delivered, the wait never returns, and the entire UI/render/command loop is wedged.

This is pre-existing, not a debt regression

At the merge-base (26d64f9), the same kRebuildScreens handler already called ctx.btclock_ws->SetCurrencies(new_currencies), and SetCurrenciesStop()SafeShutdownWsClient() → blocking esp_websocket_client_stop() already existed verbatim (so did the SetBlockFeeDec bounce). This PR's only change to that handler is renaming SetCurrencies(new_currencies)SetSubscriptions(ws_currencies, metric_keys) (identical blocking semantics) and adding a second SetSubscriptions call for a separate debt socket. So the deadlock is reachable on main today via any kRebuildScreens / SetBlockFeeDec; the debt change only slightly widens exposure (two back-to-back blocking teardowns when a dedicated debt socket is configured). The teardown path also has a documented race history — see the comment in ws_client_lifecycle.hpp itself, bd btclock_v4-28n, and the 2026-05-23 UAF coredump.

Why it's invisible and unrecoverable

  • No panic, no coredump. The Task WDT only watches the idle tasks (CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0/1=y); a blocked task yields the CPU, the idle task keeps feeding the watchdog, so nothing trips.
  • POST /api/restart does not recover itHandleRestart enqueues kRestart on the very command queue the hung loop is supposed to drain (control_server.cpp:1257), so the reboot request just sits behind the deadlock. Only a JTAG/USB reset brought the device back.

Suggested direction (separate from the debt feature)

  • Don't block the main event loop on WS teardown — offload Stop+Start to a worker task, or make the subscription change asynchronous.
  • Bound the stop wait (drop the portMAX_DELAY) and treat a stop timeout as a recoverable error rather than an infinite park.
  • Fix the underlying lifecycle race so esp_websocket_client_stop() cannot be invoked on a stale/destroyed handle.

Probably best tracked as its own issue against main rather than gating this PR. Happy to share the full thread apply all bt dump.

## 🟠 Full-device hang on hardware — main event loop deadlocks in `esp_websocket_client_stop()` (pre-existing WS-teardown bug, surfaced while testing this branch) > **Correction to my original wording:** I first framed this as "introduced via the debt subscription refresh." After checking the history that is **not accurate** — the deadlock-prone path is pre-existing in `main` and is **not introduced by this PR**. I'm keeping the report here because I hit it while testing the debt build and it's worth fixing, but the attribution below is corrected. Reproduced on a Rev B running this branch (`91cb876`, comprehensive heap poisoning on). The device "hung": e-paper frozen, `POST /api/show/screen` accepted (HTTP 200) but never applied, `data` and heap completely static — **yet `/api/status` kept answering and `espUptime` kept climbing.** So it isn't a crash: the RTOS and the httpd task are alive while the application event loop is dead. ### Root cause (confirmed via JTAG `thread apply all bt`) The `main` task (which runs `RunEventLoop`) is parked forever here: ``` #0 xEventGroupWaitBits (xTicksToWait = portMAX_DELAY) event_groups.c:439 #1 stop_wait_task esp_websocket_client.c:543 #2 esp_websocket_client_stop esp_websocket_client.c:1456 #3 SafeShutdownWsClient net_util/ws_client_lifecycle.hpp:37 #5 BtclockDataSource::Stop btclock_data.cpp:260 #6 BtclockDataSource::SetSubscriptions btclock_data.cpp:209 #8 DrainControlCommands (Kind::kRebuildScreens) control_command_drain.cpp:213 #9 RunEventLoop event_loop.cpp:130 #10 app_main main.cpp:165 ``` A `kRebuildScreens` command (a settings / screen-order / currency change) calls `SetSubscriptions(...)`, which performs a synchronous `Stop()` → `SafeShutdownWsClient()` → **`esp_websocket_client_stop()` with an unbounded `portMAX_DELAY` wait** for `STOPPED_BIT`. Inspecting the client handle being stopped (`0x3c21ff34`) at the halt point, the struct is fully zeroed (`task_handle=0`, `status_bits=0`, `state=WEBSOCKET_STATE_UNKNOW`, `run=false`) while the waiter is blocked on a now-stale event-group handle it captured before the zeroing. So `STOPPED_BIT` is never delivered, the wait never returns, and the entire UI/render/command loop is wedged. ### This is pre-existing, not a debt regression At the merge-base (`26d64f9`), the same `kRebuildScreens` handler already called `ctx.btclock_ws->SetCurrencies(new_currencies)`, and `SetCurrencies` → `Stop()` → `SafeShutdownWsClient()` → blocking `esp_websocket_client_stop()` already existed verbatim (so did the `SetBlockFeeDec` bounce). This PR's only change to that handler is renaming `SetCurrencies(new_currencies)` → `SetSubscriptions(ws_currencies, metric_keys)` (identical blocking semantics) and adding a second `SetSubscriptions` call for a separate debt socket. So the deadlock is reachable on `main` today via any `kRebuildScreens` / `SetBlockFeeDec`; the debt change only slightly widens exposure (two back-to-back blocking teardowns when a dedicated debt socket is configured). The teardown path also has a documented race history — see the comment in `ws_client_lifecycle.hpp` itself, `bd btclock_v4-28n`, and the 2026-05-23 UAF coredump. ### Why it's invisible and unrecoverable - **No panic, no coredump.** The Task WDT only watches the idle tasks (`CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0/1=y`); a *blocked* task yields the CPU, the idle task keeps feeding the watchdog, so nothing trips. - **`POST /api/restart` does not recover it** — `HandleRestart` enqueues `kRestart` on the very command queue the hung loop is supposed to drain (`control_server.cpp:1257`), so the reboot request just sits behind the deadlock. Only a JTAG/USB reset brought the device back. ### Suggested direction (separate from the debt feature) - Don't block the main event loop on WS teardown — offload Stop+Start to a worker task, or make the subscription change asynchronous. - Bound the stop wait (drop the `portMAX_DELAY`) and treat a stop timeout as a recoverable error rather than an infinite park. - Fix the underlying lifecycle race so `esp_websocket_client_stop()` cannot be invoked on a stale/destroyed handle. Probably best tracked as its own issue against `main` rather than gating this PR. Happy to share the full `thread apply all bt` dump.
Owner

Correction / retraction of attribution above.

My earlier comment in this thread (now edited) framed the WS-teardown hang as something this PR introduces "via the debt subscription refresh." That attribution is wrong, and I want it stated explicitly in the thread rather than only as a silent edit.

After checking the history: at the merge-base (26d64f9) the same kRebuildScreens handler already called btclock_ws->SetCurrencies(new_currencies), and SetCurrenciesStop()SafeShutdownWsClient() → blocking esp_websocket_client_stop() (portMAX_DELAY) already existed verbatim — as did the SetBlockFeeDec bounce. This PR only renames that call to SetSubscriptions(currencies, metric_keys) (identical blocking semantics) and adds a second teardown for a dedicated debt socket.

So the deadlock is pre-existing in main and reachable today via any kRebuildScreens / SetBlockFeeDec; the debt change does not cause it (it only slightly widens exposure). I'm filing it as a separate issue against main so it does not gate this PR. Apologies for the initial misattribution.

**Correction / retraction of attribution above.** My earlier comment in this thread (now edited) framed the WS-teardown hang as something this PR introduces "via the debt subscription refresh." That attribution is wrong, and I want it stated explicitly in the thread rather than only as a silent edit. After checking the history: at the merge-base (`26d64f9`) the same `kRebuildScreens` handler already called `btclock_ws->SetCurrencies(new_currencies)`, and `SetCurrencies` → `Stop()` → `SafeShutdownWsClient()` → blocking `esp_websocket_client_stop()` (`portMAX_DELAY`) already existed verbatim — as did the `SetBlockFeeDec` bounce. This PR only renames that call to `SetSubscriptions(currencies, metric_keys)` (identical blocking semantics) and adds a second teardown for a dedicated debt socket. So the deadlock is **pre-existing in `main`** and reachable today via any `kRebuildScreens` / `SetBlockFeeDec`; the debt change does not cause it (it only slightly widens exposure). I'm filing it as a separate issue against `main` so it does **not** gate this PR. Apologies for the initial misattribution.
fix(debt): refresh settings screen catalog after runtime load
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
3069f58b42
Merge branch 'main' into feat/usdebt_screen
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
3e5818a8db
Owner

Review — 3069f58 "fix(debt): refresh settings screen catalog after runtime load"

Verdict: correct, thread-safe, tested, and hardware-verified. This resolves the /api/settings regression I flagged in [#issuecomment-1810] (debt screens rendered but were absent from GET /api/settings, so the WebUI couldn't list or toggle them).

What the fix does

cfg_.screens_catalog is now refreshable instead of a boot-time snapshot. BuildDeviceContext takes the catalogue as a parameter; GET/PATCH /api/settings read a fresh ScreensCatalogSnapshot(). RefreshDebtCatalog() pushes the updated catalogue (with the debt screens) via the new SetScreensCatalog() after the runtime fetch completes — so the debt screens appear in /api/settings once the catalogue loads.

Strengths

  • Thread-safe. The new settings_catalog_mu_ guards the cross-task access — the httpd worker (GET/PATCH) vs. the refresh on the network task. Every cfg_.screens_catalog access now goes through the mutex, and BuildDeviceContext reads the snapshot rather than cfg_ directly. This also closes the catalogue-swap-on-network-task nit from the earlier review.
  • The test follows the real boot order. "settings screens refresh after runtime debt catalogue loads" starts with an empty debt catalogue → asserts screen 100 is absent → loads the catalogue + rebuilds → asserts 100 is present ("US Debt"). No more pre-seeding mask, so this test would have caught the original regression. Full host suite green (1159 cases / 21132 assertions).

Hardware verification (Rev B, OTA, dataSource=3ws-staging)

GET /api/settings now lists all 29 debt screens (id 100–128: US Debt … SK Debt) with id/name/enabled. US Debt and NL Debt render live: US/DEBT $ 39 378 540 133 003 and NL/DEBT € 583 297 260 870. This closes the firmware↔WebUI inconsistency — webui#179 can now list and toggle them.

⚠️ Rev A partition: the debt feature now fits, but with ~0 headroom

I built and USB-flashed this branch to a Rev A (4 MB). It boots and runs, and the debt screens are present in /api/settings. But it only just fits:

btclock_v4.bin  0x1af200 bytes
app partition   0x1b0000 bytes  ->  0xe00 (3584 bytes, 0%) free

For reference, main (no debt) leaves ~17.5 KB free on Rev A; the debt feature costs ~14 KB there. 3.5 KB of headroom means any further growth overflows Rev A. Since feature-parity across variants is a hard requirement, this is worth addressing now by trimming the feature's own footprint (e.g. the runtime catalogue is already leaner than the old hardcoded table; the display-label strings + per-country ScreenEntry copies are the next candidates) rather than letting Rev A drift toward an overflow.

Re-raising: tag the metric screens (was Suggestion #2 in [#issuecomment-1794])

I marked that suggestion superseded in [#issuecomment-1810] only because the screens weren't present at all yet. Now that they are, it's live again — the flat screens[] list puts 29 debt rows into the Status column and the screen picker. A small, additive discriminator per entry (e.g. "kind":"debt" / "group":"metric", sourced from the catalogue) in BuildGetResponse + static/openapi.{json,yml} + the WebUI Screen type would let the WebUI render them in their own collapsible "National debt" section. Backward-compatible; optional but it keeps the screen list usable at 29+ entries.

Minor (non-blocking)

  • RefreshDebtCatalog()'s AP-mode early-return path swaps the catalogue but returns before SetScreensCatalog(), so in pure-AP/provisioning mode /api/settings wouldn't list the debt screens. Almost certainly fine (you don't configure debt screens from the captive portal), just flagging.

Nice fix — the regression is gone and it's verified on both Rev B and Rev A.

## Review — `3069f58` "fix(debt): refresh settings screen catalog after runtime load" **Verdict: ✅ correct, thread-safe, tested, and hardware-verified.** This resolves the `/api/settings` regression I flagged in [#issuecomment-1810] (debt screens rendered but were absent from `GET /api/settings`, so the WebUI couldn't list or toggle them). ### What the fix does `cfg_.screens_catalog` is now *refreshable* instead of a boot-time snapshot. `BuildDeviceContext` takes the catalogue as a parameter; `GET`/`PATCH /api/settings` read a fresh `ScreensCatalogSnapshot()`. `RefreshDebtCatalog()` pushes the updated catalogue (with the debt screens) via the new `SetScreensCatalog()` after the runtime fetch completes — so the debt screens appear in `/api/settings` once the catalogue loads. ### Strengths - **Thread-safe.** The new `settings_catalog_mu_` guards the cross-task access — the httpd worker (GET/PATCH) vs. the refresh on the network task. Every `cfg_.screens_catalog` access now goes through the mutex, and `BuildDeviceContext` reads the snapshot rather than `cfg_` directly. This also closes the catalogue-swap-on-network-task nit from the earlier review. - **The test follows the real boot order.** `"settings screens refresh after runtime debt catalogue loads"` starts with an **empty** debt catalogue → asserts screen `100` is **absent** → loads the catalogue + rebuilds → asserts `100` is **present** ("US Debt"). No more pre-seeding mask, so this test would have caught the original regression. Full host suite green (1159 cases / 21132 assertions). ### Hardware verification (Rev B, OTA, `dataSource=3` → `ws-staging`) `GET /api/settings` now lists all **29 debt screens** (id 100–128: `US Debt … SK Debt`) with `id`/`name`/`enabled`. `US Debt` and `NL Debt` render live: `US/DEBT $ 39 378 540 133 003` and `NL/DEBT € 583 297 260 870`. This closes the firmware↔WebUI inconsistency — webui#179 can now list and toggle them. ### ⚠️ Rev A partition: the debt feature now fits, but with ~0 headroom I built **and USB-flashed** this branch to a **Rev A** (4 MB). It boots and runs, and the debt screens are present in `/api/settings`. But it only just fits: ``` btclock_v4.bin 0x1af200 bytes app partition 0x1b0000 bytes -> 0xe00 (3584 bytes, 0%) free ``` For reference, `main` (no debt) leaves ~17.5 KB free on Rev A; the debt feature costs ~14 KB there. **3.5 KB of headroom means any further growth overflows Rev A.** Since feature-parity across variants is a hard requirement, this is worth addressing now by trimming the feature's own footprint (e.g. the runtime catalogue is already leaner than the old hardcoded table; the display-label strings + per-country `ScreenEntry` copies are the next candidates) rather than letting Rev A drift toward an overflow. ### Re-raising: tag the metric screens (was Suggestion #2 in [#issuecomment-1794]) I marked that suggestion *superseded* in [#issuecomment-1810] only because the screens weren't present at all yet. **Now that they are, it's live again** — the flat `screens[]` list puts 29 debt rows into the Status column and the screen picker. A small, additive discriminator per entry (e.g. `"kind":"debt"` / `"group":"metric"`, sourced from the catalogue) in `BuildGetResponse` + `static/openapi.{json,yml}` + the WebUI `Screen` type would let the WebUI render them in their own collapsible "National debt" section. Backward-compatible; optional but it keeps the screen list usable at 29+ entries. ### Minor (non-blocking) - `RefreshDebtCatalog()`'s AP-mode early-return path swaps the catalogue but returns **before** `SetScreensCatalog()`, so in pure-AP/provisioning mode `/api/settings` wouldn't list the debt screens. Almost certainly fine (you don't configure debt screens from the captive portal), just flagging. Nice fix — the regression is gone and it's verified on both Rev B and Rev A.
Owner

🟠 Debt screen alignment: empty panel between the currency symbol and the value (any debt with < 5 digit groups)

Observed on hardware (Rev B + Rev A, ws-staging). The grouped (non-debtBigChar) debt screen leaves a blank panel between the currency sign and the number whenever the value has fewer than 5 three-digit groups:

US Debt  -> ['US/DEBT', '$', ' 39', '378', '540', '133', '003']   # 14 digits = 5 groups, fills all slots, OK
NL Debt  -> ['NL/DEBT', '€', '',    '583', '297', '260', '870']   # 12 digits = 4 groups -> gap after '€'

So US/EU27 (both ~14-digit, 5-group) look fine, but every smaller-debt country (NL and most of the EU set) renders € ␢ 583 297 … with a hole between the symbol and the first group.

Root cause

BuildDebtPanelTexts, the grouped path (main/screens/panel_texts.cpp):

out.push_back(symbol);
if (groups.size() < group_slots) {
  out.resize(out.size() + group_slots - groups.size());   // <-- pads AFTER the symbol
}
out.insert(out.end(), groups.begin() + start, groups.end());

The padding for the missing groups is inserted after the symbol, which right-aligns the digits but strands the symbol on the far left. The debtBigChar path right above it already does the right thing — it builds [symbol, amount…, unit] and pads at the front (cells.insert(cells.begin(), …)), keeping the symbol attached to the value.

Suggested fix — mirror the big-char path (pad before the symbol)

auto groups = DebtDigitGroups(whole);
const std::size_t group_slots = tail - 1;
if (groups.size() > group_slots)
  groups.erase(groups.begin(), groups.begin() +
               static_cast<std::ptrdiff_t>(groups.size() - group_slots));
std::vector<std::string> cells;
cells.push_back(symbol);
cells.insert(cells.end(), groups.begin(), groups.end());
if (cells.size() < tail)                       // right-align symbol+value together
  cells.insert(cells.begin(), tail - cells.size(), std::string());
out.insert(out.end(), cells.begin(), cells.end());
out.resize(n_panels);

NL then renders ['NL/DEBT', '', '€', '583', '297', '260', '870'] adjacent to the value, the blank panel moved to the front (where it reads as right-alignment, not a gap). US is unchanged (already fills every slot).

Test gap

test_debt_panel_texts.cpp covers US debt and EU27 — both ~14-digit / 5-group, so neither exercises the < group_slots branch that has the bug. Worth adding a sub-5-group EUR case (e.g. an NL-sized value) asserting cells[1].empty() and cells[2] == "€" so the alignment is pinned.

(Affects only the grouped layout; debtBigChar=true is already correct.)

## 🟠 Debt screen alignment: empty panel between the currency symbol and the value (any debt with < 5 digit groups) Observed on hardware (Rev B + Rev A, `ws-staging`). The grouped (non-`debtBigChar`) debt screen leaves a blank panel between the currency sign and the number whenever the value has fewer than 5 three-digit groups: ``` US Debt -> ['US/DEBT', '$', ' 39', '378', '540', '133', '003'] # 14 digits = 5 groups, fills all slots, OK NL Debt -> ['NL/DEBT', '€', '', '583', '297', '260', '870'] # 12 digits = 4 groups -> gap after '€' ``` So `US`/`EU27` (both ~14-digit, 5-group) look fine, but every smaller-debt country (NL and most of the EU set) renders `€ ␢ 583 297 …` with a hole between the symbol and the first group. ### Root cause `BuildDebtPanelTexts`, the grouped path (`main/screens/panel_texts.cpp`): ```cpp out.push_back(symbol); if (groups.size() < group_slots) { out.resize(out.size() + group_slots - groups.size()); // <-- pads AFTER the symbol } out.insert(out.end(), groups.begin() + start, groups.end()); ``` The padding for the missing groups is inserted *after* the symbol, which right-aligns the digits but strands the symbol on the far left. The `debtBigChar` path right above it already does the right thing — it builds `[symbol, amount…, unit]` and pads at the **front** (`cells.insert(cells.begin(), …)`), keeping the symbol attached to the value. ### Suggested fix — mirror the big-char path (pad before the symbol) ```cpp auto groups = DebtDigitGroups(whole); const std::size_t group_slots = tail - 1; if (groups.size() > group_slots) groups.erase(groups.begin(), groups.begin() + static_cast<std::ptrdiff_t>(groups.size() - group_slots)); std::vector<std::string> cells; cells.push_back(symbol); cells.insert(cells.end(), groups.begin(), groups.end()); if (cells.size() < tail) // right-align symbol+value together cells.insert(cells.begin(), tail - cells.size(), std::string()); out.insert(out.end(), cells.begin(), cells.end()); out.resize(n_panels); ``` NL then renders `['NL/DEBT', '', '€', '583', '297', '260', '870']` — `€` adjacent to the value, the blank panel moved to the front (where it reads as right-alignment, not a gap). US is unchanged (already fills every slot). ### Test gap `test_debt_panel_texts.cpp` covers `US debt` and `EU27` — both ~14-digit / 5-group, so neither exercises the `< group_slots` branch that has the bug. Worth adding a sub-5-group EUR case (e.g. an NL-sized value) asserting `cells[1].empty()` and `cells[2] == "€"` so the alignment is pinned. (Affects only the grouped layout; `debtBigChar=true` is already correct.)
feat(debt): right alignment incl. symmbol size, fix screen kind for debt screens, fix screen update and preview in debt screen
Some checks failed
Docs site / build (pull_request) Has been cancelled
Host tests / host_tests (pull_request) Has been cancelled
Host tests / sanitize (pull_request) Has been cancelled
Host tests / coverage (pull_request) Has been cancelled
Lint / format (pull_request) Has been cancelled
Lint / tidy (pull_request) Has been cancelled
d464ba480d
Merge branch 'main' into feat/usdebt_screen
Some checks are pending
Docs site / build (pull_request) Blocked by required conditions
Host tests / host_tests (pull_request) Blocked by required conditions
Host tests / sanitize (pull_request) Blocked by required conditions
Host tests / coverage (pull_request) Blocked by required conditions
Lint / format (pull_request) Blocked by required conditions
Lint / tidy (pull_request) Blocked by required conditions
56c08a1283
Some checks are pending
Docs site / build (pull_request) Blocked by required conditions
Host tests / host_tests (pull_request) Blocked by required conditions
Host tests / sanitize (pull_request) Blocked by required conditions
Host tests / coverage (pull_request) Blocked by required conditions
Lint / format (pull_request) Blocked by required conditions
Lint / tidy (pull_request) Blocked by required conditions
This pull request has changes conflicting with the target branch.
  • main/app/boot/init_screen_manager.cpp
  • main/app/screen_manager.cpp
Some workflows are waiting to be reviewed.
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u feat/usdebt_screen:PMK-feat/usdebt_screen
git switch PMK-feat/usdebt_screen

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff PMK-feat/usdebt_screen
git switch PMK-feat/usdebt_screen
git rebase main
git switch main
git merge --ff-only PMK-feat/usdebt_screen
git switch PMK-feat/usdebt_screen
git rebase main
git switch main
git merge --no-ff PMK-feat/usdebt_screen
git switch main
git merge --squash PMK-feat/usdebt_screen
git switch main
git merge --ff-only PMK-feat/usdebt_screen
git switch main
git merge PMK-feat/usdebt_screen
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
btclock/btclock_v4!2
No description provided.