From a12e88efd09b2c16d761115496123cd0b4e193da Mon Sep 17 00:00:00 2001 From: uvtqt Date: Fri, 13 Nov 2026 10:26:40 +0100 Subject: [PATCH 1/2] Reports: name the band and its firmware in the plaintext summary Most "no data" tickets turn on which band and firmware the reporter has (a WHOOP 5.0 / MG on 50.43.x withholds all data, #673), and that was only inside the encrypted bundle. The app now reads the firmware it already records on every connect and sends bandGeneration and bandFirmware in the server meta (and the firmware in device.json). The report worker keeps both when well-formed, drops them otherwise without refusing the report, and shows them in the GitHub issue. --- lib/data/db.dart | 13 ++++++++++++ lib/support/issue_report.dart | 17 ++++++++++----- server/report-worker/src/index.ts | 14 ++++++++++++ server/report-worker/test/worker.test.ts | 27 ++++++++++++++++++++++++ test/issue_report_test.dart | 15 +++++++++++++ test/local_counters_test.dart | 4 ++++ 6 files changed, 85 insertions(+), 5 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 69c2eca0e..29c76576b 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9998,6 +9998,19 @@ class LocalDb { return {'fw': fw, 'last_seen': now, 'history': kept}; }); + /// The firmware [noteBandFirmware] last recorded, or null when no band has + /// reported one on this install. Best-effort; never throws. + static Future bandFirmware() async { + try { + final raw = (await computeFreshness(kBandFirmwareKey))?['payload_json']; + final m = raw is String ? jsonDecode(raw) : null; + final fw = m is Map ? m['fw'] : null; + return fw is String && fw.isNotEmpty ? fw : null; + } catch (_) { + return null; + } + } + /// `compute_freshness` key marking the raw-free next-night nap cleanup /// (compute/nap_overlap_cleanup.dart) as applied. Cleared by an import. /// v2: the v1 pass skipped every day the readiness backfill had restamped diff --git a/lib/support/issue_report.dart b/lib/support/issue_report.dart index 219a64931..fe3b8cfcd 100644 --- a/lib/support/issue_report.dart +++ b/lib/support/issue_report.dart @@ -178,7 +178,9 @@ Future writeReportZip( } /// The plaintext `meta` field the Worker shows in the tracker. Nothing in it -/// is health data. +/// is health data. The band's generation and firmware ride here so a ticket +/// can be triaged without decrypting it: most "no data" reports turn on them +/// (a WHOOP 5.0 / MG on 50.43.x withholds all data, edge#673). Map reportServerMeta( ReportDraft d, Map meta) => { @@ -191,6 +193,8 @@ Map reportServerMeta( 'platform': meta['platform'], 'osVersion': meta['osVersion'], 'deviceModel': meta['deviceModel'], + 'bandGeneration': ?meta['bandGeneration'], + 'bandFirmware': ?meta['bandFirmware'], 'hasDatabase': d.includeDatabase && !d.fullDatabase, 'hasScreenshot': d.screenshotPath != null, if (d.includeDatabase && !d.fullDatabase) 'rawDays': ?meta['rawDays'], @@ -256,6 +260,9 @@ Future<({File zip, Map meta})> collectReport( final s = await t.staticDevice(); final now = DateTime.now(); final build = s['app_build']; + final d = app.device; + final bandGeneration = app.paired?.generation ?? d.generation; + final bandFirmware = await LocalDb.bandFirmware(); final meta = { 'appVersion': '${s['app_version'] ?? '?'}${build == null ? '' : '+$build'}', 'algoVersion': kAlgoVersion, @@ -269,12 +276,14 @@ Future<({File zip, Map meta})> collectReport( 'tzOffsetMinutes': now.timeZoneOffset.inMinutes, 'installId': app.deviceId, 'createdAt': _iso(now), + 'bandGeneration': bandGeneration, + 'bandFirmware': bandFirmware, }; - final d = app.device; final device = { 'band': { 'paired': app.isPaired, - 'generation': app.paired?.generation ?? d.generation, + 'generation': bandGeneration, + 'firmware': bandFirmware, 'connection': d.connection, 'batteryPct': d.batteryPct, 'charging': d.charging, @@ -283,8 +292,6 @@ Future<({File zip, Map meta})> collectReport( 'lastSyncedRecord': app.lastSynced?.tsEpoch, 'bandDataOldest': d.dataRangeOldest, 'bandDataNewest': d.dataRangeNewest, - // Firmware is not held in app state; the HELLO lines in logs/sync.log - // carry it. }, 'sensors': [ for (final r in app.sensors) diff --git a/server/report-worker/src/index.ts b/server/report-worker/src/index.ts index ff5f5d467..8f918ff00 100644 --- a/server/report-worker/src/index.ts +++ b/server/report-worker/src/index.ts @@ -51,8 +51,15 @@ export interface Meta { hasDatabase: boolean; hasScreenshot: boolean; /** Days of second-by-second data in the attached slice (1..14); absent when there is no database. */ rawDays?: number; + /** The paired band's generation (`gen4`, `gen5`) and firmware (`50.43.2.0`), when the app knows them. + * Optional and best-effort: a malformed value is dropped, never a reason to refuse the report. */ + bandGeneration?: string; + bandFirmware?: string; } +const BAND_GENERATION = /^[a-z0-9]{1,16}$/; +const BAND_FIRMWARE = /^\d{1,4}(\.\d{1,6}){1,4}$/; + /** Returns a normalised Meta or null. Unknown fields are dropped. */ export function validateMeta(raw: unknown): Meta | null { if (!raw || typeof raw !== "object") return null; @@ -74,10 +81,14 @@ export function validateMeta(raw: unknown): Meta | null { if (typeof m.hasDatabase !== "boolean" || typeof m.hasScreenshot !== "boolean") return null; const rawDays = m.rawDays; if (rawDays !== undefined && !(Number.isInteger(rawDays) && (rawDays as number) >= 1 && (rawDays as number) <= 14)) return null; + const bandGeneration = typeof m.bandGeneration === "string" && BAND_GENERATION.test(m.bandGeneration) ? m.bandGeneration : undefined; + const bandFirmware = typeof m.bandFirmware === "string" && BAND_FIRMWARE.test(m.bandFirmware) ? m.bandFirmware : undefined; return { v: 1, installId: installId.toLowerCase(), category, title, description, appVersion, platform: m.platform, osVersion, deviceModel, hasDatabase: m.hasDatabase, hasScreenshot: m.hasScreenshot, ...(rawDays !== undefined && { rawDays: rawDays as number }), + ...(bandGeneration !== undefined && { bandGeneration }), + ...(bandFirmware !== undefined && { bandFirmware }), }; } @@ -90,6 +101,9 @@ async function createIssue(env: Env, id: string, m: Meta): Promise { expect(JSON.parse(row.meta).rawDays).toBe(7); }); + it("keeps the band's generation and firmware in the stored meta", async () => { + const j = (await (await post(meta({ bandGeneration: "gen5", bandFirmware: "50.43.2.0" }))).json()) as any; + const row = await e.DB.prepare("SELECT meta FROM reports WHERE id=?").bind(j.id).first(); + expect(JSON.parse(row.meta)).toMatchObject({ bandGeneration: "gen5", bandFirmware: "50.43.2.0" }); + }); + + it("drops a malformed band field but still accepts the report", async () => { + const r = await post(meta({ bandGeneration: "Gen 5!", bandFirmware: "50.43.2.0\n**x**" })); + expect(r.status).toBe(201); + const row = await e.DB.prepare("SELECT meta FROM reports WHERE id=?").bind(((await r.json()) as any).id).first(); + const stored = JSON.parse(row.meta); + expect(stored.bandGeneration).toBeUndefined(); + expect(stored.bandFirmware).toBeUndefined(); + }); + it("requires Content-Length and refuses oversized ones before reading the body", async () => { const h = { "content-type": "application/octet-stream", "x-report-meta": metaHeader(meta()) }; expect((await req(h, streamOf([blobOf()]))).status).toBe(411); @@ -208,6 +223,8 @@ describe("POST /v1/reports", () => { const body = JSON.parse(init.body as string); expect(body.title).toBe(`[${id}] sync: Sync stuck`); expect(body.labels).toContain("sync"); + // No band fields in this report: no Band line, rather than "unknown, unknown". + expect(body.body).not.toContain("**Band:**"); const row = await e.DB.prepare("SELECT github_issue_url u FROM reports WHERE id=?").bind(id).first(); expect(row.u).toContain("/issues/1"); @@ -215,6 +232,16 @@ describe("POST /v1/reports", () => { expect((await post(meta())).status).toBe(201); spy.mockRestore(); }); + + it("names the band and firmware in the GitHub issue", async () => { + e.GITHUB_TOKEN = "t"; e.GITHUB_REPO = "OpenStrap/reports"; + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ html_url: "https://github.com/OpenStrap/reports/issues/2" }), { status: 201 })); + await post(meta({ bandGeneration: "gen5", bandFirmware: "50.43.2.0" })); + const body = JSON.parse((spy.mock.calls[0] as [string, RequestInit])[1].body as string); + expect(body.body).toContain("**Band:** gen5, firmware 50.43.2.0"); + spy.mockRestore(); + }); }); describe("DELETE /v1/reports/:id", () => { diff --git a/test/issue_report_test.dart b/test/issue_report_test.dart index 41af38fd0..4de3602b1 100644 --- a/test/issue_report_test.dart +++ b/test/issue_report_test.dart @@ -125,6 +125,21 @@ void main() { {'rawDays': 7}).containsKey('rawDays'), isFalse); }); + test('server meta names the band and its firmware, and only when known', + () { + const d = ReportDraft( + category: ReportCategory.sync, title: 't', description: 'd'); + final m = reportServerMeta( + d, {'bandGeneration': 'gen5', 'bandFirmware': '50.43.2.0'}); + expect(m['bandGeneration'], 'gen5'); + expect(m['bandFirmware'], '50.43.2.0'); + // No band, or one that never reported firmware: the keys are absent, not + // null, so the tracker never shows an empty "firmware" line. + final none = reportServerMeta(d, {}); + expect(none.containsKey('bandGeneration'), isFalse); + expect(none.containsKey('bandFirmware'), isFalse); + }); + test('contact rides only in the encrypted user.txt', () async { final a = ZipDecoder().decodeBytes(await _zip()); expect(utf8.decode(a.findFile('user.txt')!.content as List), diff --git a/test/local_counters_test.dart b/test/local_counters_test.dart index c88ad5fc7..c98bca209 100644 --- a/test/local_counters_test.dart +++ b/test/local_counters_test.dart @@ -25,6 +25,8 @@ void main() { tearDownAll(() async => LocalDb.close()); test('firmware: latest plus one history entry per change', () async { + // Nothing recorded yet reads as unknown, not as an empty version. + expect(await LocalDb.bandFirmware(), isNull); await LocalDb.noteBandFirmware('41.16.6.0', nowSec: 100); await LocalDb.noteBandFirmware('41.16.6.0', nowSec: 200); await LocalDb.noteBandFirmware('41.17.1.0', nowSec: 300); @@ -33,6 +35,8 @@ void main() { expect(m['last_seen'], 300); expect([for (final e in m['history'] as List) e['fw']], ['41.16.6.0', '41.17.1.0']); + // What a support report reads: the latest version only. + expect(await LocalDb.bandFirmware(), '41.17.1.0'); }); test('usage counters count', () async { From e634e3b082ce75c4cc9e514e63f77bcd21cf7cd0 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Fri, 13 Nov 2026 11:56:58 +0100 Subject: [PATCH 2/2] Reports: only attach the firmware for a paired WHOOP 5.0 / MG The recorded firmware is per install and only gen5 HELLOs write it, so after a swap to a WHOOP 4.0 or with no band paired it named the wrong band. Also pins that a band field ending in a line break is dropped (JavaScript's $ does not match before a trailing newline). --- lib/support/issue_report.dart | 8 +++++++- server/report-worker/test/worker.test.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/support/issue_report.dart b/lib/support/issue_report.dart index fe3b8cfcd..acf8bd163 100644 --- a/lib/support/issue_report.dart +++ b/lib/support/issue_report.dart @@ -262,7 +262,13 @@ Future<({File zip, Map meta})> collectReport( final build = s['app_build']; final d = app.device; final bandGeneration = app.paired?.generation ?? d.generation; - final bandFirmware = await LocalDb.bandFirmware(); + // Only gen5 HELLOs record a firmware, and the record is per install, not + // per band: after a swap to a WHOOP 4.0, or with no band paired, it would + // name a band that is no longer the one reporting. + // ponytail: two gen5 bands in turn still share one record; keying it by + // serial is the upgrade if that ever misleads a ticket. + final bandFirmware = + bandGeneration == 'gen5' ? await LocalDb.bandFirmware() : null; final meta = { 'appVersion': '${s['app_version'] ?? '?'}${build == null ? '' : '+$build'}', 'algoVersion': kAlgoVersion, diff --git a/server/report-worker/test/worker.test.ts b/server/report-worker/test/worker.test.ts index 29ec61428..81bd3c1b3 100644 --- a/server/report-worker/test/worker.test.ts +++ b/server/report-worker/test/worker.test.ts @@ -116,6 +116,17 @@ describe("POST /v1/reports", () => { expect(stored.bandFirmware).toBeUndefined(); }); + it("drops a band field that only ends in a line break", async () => { + // JavaScript's `$` (no m flag) matches only at the very end of the input, + // not before a trailing newline, so this must not reach the issue body. + const r = await post(meta({ bandGeneration: "gen5\n", bandFirmware: "50.43.2.0\n" })); + expect(r.status).toBe(201); + const row = await e.DB.prepare("SELECT meta FROM reports WHERE id=?").bind(((await r.json()) as any).id).first(); + const stored = JSON.parse(row.meta); + expect(stored.bandGeneration).toBeUndefined(); + expect(stored.bandFirmware).toBeUndefined(); + }); + it("requires Content-Length and refuses oversized ones before reading the body", async () => { const h = { "content-type": "application/octet-stream", "x-report-meta": metaHeader(meta()) }; expect((await req(h, streamOf([blobOf()]))).status).toBe(411);