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..acf8bd163 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,15 @@ 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; + // 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, @@ -269,12 +282,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 +298,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("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); @@ -208,6 +234,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 +243,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 {