Repository navigation
Prepare OpenGlucose 0.4.0: GS1 and Anytime 5P integration - #139
Open
shroominic wants to merge 240 commits into
Open
shroominic wants to merge 240 commits into
shroominic wants to merge 240 commits into
Conversation
Snapshot the local libre-protocol-capture worktree so contest cloud agents can branch from a shared remote: cgm_driver_registry, cgm_libre2, cgm_libre2_glucose, cgm_yuwell_anytime, debug protocol-capture harness, and Anytime 5P passive-capture runbook. No raw captures or health data.
Do not await the in-flight connect before the promotion gate releases, and type ControlledSession.disconnectError as Exception? for only_throw_errors.
…ence Adds a private, debug-only macOS BLE path for the Anytime 5P so the existing cgm_yuwell_anytime driver (previously Android-debug-only) can attempt a live session from this Mac, and records the first physical 5P observation. New (openhealth): - YuwellMacosKeychainSessionStore: Keychain-backed sibling to the Android-only YuwellSecureSessionStore, implementing the same YuwellCredentialStore/YuwellWriteIntentStore contracts (HMAC-derived Keychain aliases, opaque random tokens, fail-closed write-intent journal with the same state-machine guards as the Android bridge). - YuwellMacosInMemoryKeyValueStore: explicit, documented fallback for a build with no code-signing Team ID, where macOS Keychain Services fails every item with errSecMissingEntitlement (-34018) independent of the entitlements plist. Same guards, not process-durable. Real fix is signing with a real Team ID; out of scope here. - buildYuwellMacosDebugDriver(): mirrors the reviewed Android _buildCaptureRegistry composition (engineering-provisional V1150 output policy) with the store swapped in. - runYuwellMacosDebugAttempt(): bounded scan -> connect -> observe -> disconnect; always logs `BLE GRAB @Claude` before touching the radio and `BLE RELEASE @Claude` after, including on error/timeout. - yuwell_macos_debug_main.dart: debug-only Flutter entry point, gated on OG_MACOS_YUWELL_DEBUG + debug mode + macOS; does not weaken the Android OG_PROTOCOL_TRACE gate or buildPlatformDriver. Bug found + worked around (not fixed upstream, out of scope here): on this macOS build, letting FlutterBluePlusTransport's scan `timeout` elapse (or cancelling the scan subscription) drives a native stopScan call that wedges the merged UI/platform thread — nothing Dart-side runs again after, including independent Dart timers, since the whole isolate's event loop is what's stuck. The bounded scan here never cancels; it just stops waiting after `scanTimeout` and leaves the subscription dangling for the remaining life of this single-attempt, externally-torn-down process. Real-hardware result: a session against Dom's physical Anytime 5P matched the documented CT5 GATT topology exactly (service 00001000-1212-efde-1523-785feabcd123, notify 00001001-..., write 00001002-...), subscribed cleanly, and completed a real version-query round trip. The driver read the version and correctly failed closed at YuwellSessionFailureKind.unsupportedFirmware: this unit's firmware branch is not V1150, the only branch currently admitted for a live glucose value. No state-changing write was attempted. This promotes discovery/topology/version-handshake to target-confirmed while leaving the V1150-only gate and the unimplemented glucose algorithm untouched per docs/testing/yuwell-anytime-5p-protocol.md's validation plan. Docs updated to record the observation: docs/testing/yuwell-anytime-5p-protocol.md, packages/cgm_yuwell_anytime/doc/evidence-boundary.md, packages/cgm_yuwell_anytime/README.md. No raw capture, sensor identifier, device address, or health payload is recorded anywhere in this commit. 16 new tests (store, driver-factory wiring, session orchestration); existing cgm_yuwell_anytime (105) and relevant openhealth (116) suites still pass; flutter analyze clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sTransport Root-causes and fixes the scan-teardown wedge M3 (164b170) worked around instead of fixing. Read the locked flutter_blue_plus 2.2.1 source (flutter_blue_plus-2.2.1/lib/src/flutter_blue_plus.dart): - startScan(timeout: ...) arms its own internal Timer(timeout, stopScan) (line 352) that calls the *public* stopScan(), which takes a process-global mutex (_MutexFactory.getMutexForKey("scan")) before it ever touches the platform channel. - FlutterBluePlusTransport.scan() independently called that same public stopScan() from its own teardown, forwarded the same `timeout` into startScan, and was triggered around the same wall-clock moment by whatever bounds the caller's own wait. Two independent callers, one mutex: if the plugin's own timer wins the race and its native stop invocation never returns (exactly what M3 observed on this Mac), the loser blocks on that mutex forever, and since the mutex is never released, every later scan/connect call in the process wedges behind it too. That fully accounts for "the whole isolate['s BLE path] is what's stuck" from the M3 notes. Fix: FlutterBluePlusTransport.scan() no longer forwards `timeout` to the plugin. It owns one local bound Timer instead, and every stop trigger (that timer, a consumer cancelling its subscription, an externally observed isScanning drop, a startup error) now funnels through the same already-idempotent teardown. That guard is extracted as SingleFlightTeardown so "at most one call to the plugin's stopScan() per attempt" is independently unit-tested, not just asserted by inspection. This is a real fix in the shared transport, not a debug-tool-only patch: every production driver (cgm_aidex, cgm_libre2, cgm_yuwell_anytime) calls scan(timeout: ...) the same way via `await for` and was exposed to the same race. No openhealth test touches FlutterBluePlusTransport directly (all use fake BleTransport implementations), so this change is fully isolated to and covered by the cgm_ble_flutter suite. 4 new tests (SingleFlightTeardown: concurrent callers, a later caller observing the first outcome instead of re-running, error propagation, the `started` getter). Full package suite 27/27, `flutter analyze` clean, `dart format` clean. Not yet hardware-confirmed: this fix is grounded in the locked plugin's own source, not in a repeated live macOS session. Still wants one live attempt to confirm the wedge is actually gone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hs9hXcMCFfmG1ddee7HL6Q
…ly query for non-V1150 units
Extends the non-V1150 fail-closed path in YuwellAnytimeSession with
self-capturing evidence, without moving the GLU_MG admission gate at
`driver.dart`'s exact-V1150 check. No state-changing write, activation,
or glucose computation is reachable for non-V1150 firmware before or
after this commit.
1. YuwellSessionException gains optional `firmware` and `bound` fields.
`firmware` is the parsed version string (e.g. "V2003") when a fresh
version query actually ran; `bound` is the sensor's own answer to one
additional read-only query described below. Both flow into the
published failure snapshot as `cgm.yuwell.firmware` and
`cgm.yuwell.binding-state` metadata. This makes the evidence-boundary
promotion gate's "record the exact... firmware..." automatic on the
next attempt instead of relying on a human to note it down, and
pre-answers the open "exact firmware string" question from
CLAUDE_STATUS.md M4a.
2. A non-V1150 session now sends YuwellCt5Commands.readBindingStatus()
(0x11) once, right before it fails closed on firmware, and reports
the result. This query is not new protocol surface: it is the exact
query _beginFreshActivation already sends first, unconditionally,
before any state-changing write, for V1150 sessions today — this
change runs that same query one branch earlier, nothing more.
Two other opcodes were considered and rejected after reading the
actual call graph, not just opcode names:
- querySensorCode (0x3f, calibration code) is only ever sent inside
_completeActivationFromAuthenticated, after _setDateWithJournal and
_setCommunicationIdentity — two state-changing writes — have
already run, and its response is decrypted with the cipher
deriveCipherFromSetIdResponse derives from the set-ID write. It is
cryptographically dependent on that write, not just conventionally
ordered after it, so there is no meaningful way to send it cold.
- readHistory/readHistoryVariant (0x37/0x47) carry their own doc
comment marking them target-unverified, and every existing call
site sends them only post-authentication. No precedent anywhere
for sending either cold.
A failure on the binding-status evidence read never masks the primary
unsupportedFirmware diagnostic — it is swallowed and reported as
unknown (bound: null), covered by an explicit test.
3 new tests: firmware+bound captured on a synthetic non-V1150 version
response; the evidence read's own failure does not mask the primary
diagnosis; existing coverage otherwise unchanged. cgm_yuwell_anytime:
107/107, `dart analyze --fatal-infos` clean, `dart format` clean.
Targeted openhealth suites exercising this wiring (macOS debug
driver/session, cgm_driver_registry): 18/18, unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hs9hXcMCFfmG1ddee7HL6Q
M3 found that on this Mac, stopping the shared BLE transport's scan (a plain consumer `.cancel()` included) could wedge the merged UI/platform thread forever, so the macOS Yuwell debug session deliberately never cancelled its scan subscription. M5 root-caused and fixed that race in FlutterBluePlusTransport via SingleFlightTeardown, but that fix is source-grounded, not yet hardware-confirmed. runYuwellMacosDebugAttempt now cancels the scan subscription for real, bounded by a new scanCancelBound (default 5s, configurable for tests): a clean cancel stops the radio scan instead of leaking it for the rest of the process and self-logs as live confirmation evidence for the M5 fix; a cancel that does not resolve in time still degrades to the old, hardware-proven-safe walk-away behavior instead of hanging. 2 new tests cover both branches, including one that would hang the suite if the bounded fallback regressed. cgm_yuwell_anytime (107/107), cgm_ble_flutter (30/30), full openhealth suite (492/492): all green. flutter analyze and dart format clean on both files. Radio-free change; no BLE touched to make it. Confirms nothing live by itself — the next GRAB against real hardware (already the planned next step per M6) now also exercises this cancel path end to end. Commit signing unavailable in this sandboxed shell (1Password cannot be unlocked non-interactively) — reproduced a third time, same error as M6. Committing unsigned per Dom's standing authorization for this environment limitation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaSE8RW2ZXxkRvBozVCxuL
Both were failing since 164b170, before this PR existed — confirmed via CI history on this branch, not introduced by any commit landed here. Secret Scan (gitleaks): flagged `ct5.macos.selftest.v1` in yuwell_macos_debug_main.dart as generic-api-key. It is a FlutterSecureStorage/Keychain *lookup key name* (like a dictionary key), not a credential — _selfTestKeychain() writes/reads/deletes it purely to confirm the Keychain is reachable. Added .gitleaksignore with the exact reviewed fingerprint (commit:file:rule:line, gitleaks' own documented mechanism for one already-checked finding) plus an inline comment at the call site. Ruleset and every other detection path are untouched. Unit Tests: 13 (of 33) cgm_libre2_glucose tests failing in private_bench_test.dart on CI's ubuntu-latest runner, because tool/analyze_private_bench.dart is deliberately macOS-only (Platform.isMacOS guard: it depends on Darwin's stat64 field layout via raw FFI). Off macOS every call returns unsupportedPlatform, which fails most assertions outright and makes a few others (the ones already expecting a nonzero/66 exit, for unrelated reasons) pass for the wrong reason instead of actually exercising that behavior. Wrapped the suite in one group(..., skip: macosOnlySkip) gated on Platform.isMacOS, mirroring the tool's own platform boundary instead of fighting it. No assertion, tool code, or product code touched. All 33 tests still run and pass unchanged on macOS (verified locally) — CI is the first real signal for the skip path itself, since this Mac can't take the non-macOS branch. Verification: cgm_libre2_glucose 33/33 locally, dart analyze/format clean on all three changed files, full make check green. gitleaks itself only runs in GitHub's Secret Scan job, not locally — CI on this push is the real check for that half. Radio-free; no BLE touched. Does not touch the V1150-exact-match admission gate, packages/cgm_yuwell_anytime, or any glucose computation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaSE8RW2ZXxkRvBozVCxuL
… private notes into the committed evidence doc Two OFFICIAL-APP-STATIC findings from this session, moved from CLAUDE_STATUS.md (private, out of this repo) into the actual evidence boundary doc so they're part of the reviewable record, not a private note: 1. OTA firmware-update path cannot reach V1150. OTAViewModel's own ladder is V1200 -> V1300 -> V1400 (confirmed via the .gbl assets' embedded version strings, cross-validated against each other); V1150 is not a node in it. V1300 also has no sibling to TransmitterRepository.FIRMWARE_VERSION_V1150, so it wouldn't be trusted even if reached. The mechanism's own gating functions reference only CT3/CT4, with zero CT5 mentions, so it may not even target this transmitter. New "OTA firmware-update path" subsection + confidence-table row. 2. History (0x37/0x47) and query-code (0x3F) responses are session-cipher-dependent (deriveCipherFromSetIdResponse, keyed off the 0x30 set-ID write), not just unprecedented to send cold. Clarifies why only 0x11 (binding-state check) is actually cold-readable: it's the one plaintext response in that table, not merely a simply-encoded request. New paragraph in "Frame integrity and command map" + confidence-table row. Neither finding changes any rule under "OpenGlucose therefore must not" in "Native algorithm contract and clean-room gap" — non-V1150 remains fail-closed, for the same reasons already documented there. This is documentation of already-established facts; no product/test code touched. Docs-only diff — full make check (native build matrix) not rerun; nothing here can affect Dart analysis, formatting, or test behavior. Radio-free; no BLE touched. Codex has Dom's go-ahead for an activation GRAB per the operator's note — nothing in this commit needed the radio and this session is staying offline regardless. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaSE8RW2ZXxkRvBozVCxuL
…osed evidence read The non-V1150 firmware gate (driver.dart) sends one best-effort binding-status read for evidence before failing closed with unsupportedFirmware. Existing tests covered that read returning "unbound" and covered the read itself failing; the "already bound" branch (bound: true) was untested. Added that case to yuwell_session_test.dart: synthetic firmware digits only (not a captured value from any real unit), asserts the session still fails closed with unsupportedFirmware, records bound: true in both the exception and session metadata, and sends exactly the same two reads (0x01 version, 0x11 binding-status) with no state-changing write. No product code changed; no new evidence or algorithm claim. Verified `dart test` (package: 108 passed), `dart analyze` (no issues), and `dart format --set-exit-if-changed` (clean). Radio-free; no BLE touched. Commit signing unavailable in this sandboxed shell (`error: 1Password: failed to fill whole buffer`, same failure CLAUDE_STATUS.md records for M6/M7) — committed unsigned per Dom's standing authorization for this environment limitation, consistent with a474dcc/ddd3c9d/2ab04df/196a8d9. Only gpgsign was skipped; lefthook's format/analyze hooks ran normally. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsEURNaFxxLJJD5bz6LAu2
…branch Adds one synthetic test for a branch of the non-V1150 firmware gate that had no coverage: _resume's own bare unsupportedFirmware guard for saved credentials with transmitterComputed == false. This is distinct from the three existing non-V1150 tests, which all go through _initialize's fresh version query and its one best-effort binding-status evidence read. A resumed session with saved credentials skips that version query entirely, so _resume's guard fires fi 8000 rst, before check-id, before any evidence read, before the activation gate — with zero writes and a firmware/bound-free exception, since nothing was queried this attempt. cgm_yuwell_anytime: 109/109. Repo-wide dart format --set-exit-if-changed and analyze clean. Full workspace test suite (491 unit + 1 integration) green; native builds/signing gates not re-run for a test-only diff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjBX45BjdGuHsNDswG3xWb
…'s own version gate Structure-only findings from the reference decompile, no decode invented: - ist.com.sdk.ProtocolTools.verify(byte[]) (-> ProtocolToolsHolder .verifyHolder) is the exact call site behind the existing 27-byte advertisement claim, returning type/category/name/isBound. CT5InitViewModel's scan callback uses isBound alone to route between its own recovery flow and a fresh connect; either way it still requires a full GATT connect and the version handshake, so this is a UI hint in the reference app, not a substitute for OpenGlucose's own authenticated 0x11 binding-status check. - CT5InitViewModel's data-receive handler filters notifications to opcodes 0x05/0x01 at this stage, and its version handler enforces a hard-coded version+date allowlist before ever calling setDate, closing the connection otherwise. This is a broader gate than the transmitter-computed trust check already documented (TransmitterRepository.FIRMWARE_VERSION_V1150 stays the only trusted version) — the reference app treats 'firmware new enough to init' and 'firmware whose transmitter value it trusts' as two different questions. Recorded as version-gate evidence only; does not license widening OpenGlucose's own unsupportedFirmware gate, since the native algorithm the wider range would still need remains unimplemented. No vendor code, binary, or captured evidence added; docs-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjBX45BjdGuHsNDswG3xWb
Went one level deeper than the previous verify() note: read ProtocolToolsHolder.verifyHolder itself (structure only, no decode). - It is a standard, generic BLE advertising-data (AD structure) walker, not a CT5-specific envelope. Verify.type is the raw BLE Flags byte (AD type 0x01); Verify.name/category come from Local/Shortened Name AD types (0x08/0x09) with a quirky length-keyed split; Verify.category + isBound come from Manufacturer Specific Data (0xFF), reading exactly 3 bytes + a 1-byte bound flag. - For the 27-byte manufacturer-data form, it reads only that first 4-byte prefix and explicitly discards the remaining 22 bytes. Those 22 bytes are where the six-record/checksum advertising content the doc already describes would live — confirmed this method is not what reads them. Left ProtocolToolsHolder_CT5$BroadData unopened as a pointer for that separate, still out-of-scope thread. - Noted the reference parser's own length-desync fragility on unrecognized AD types as a thing to not imitate, not a wire-format fact. Docs-only; no vendor code, binary, or decode logic added. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjBX45BjdGuHsNDswG3xWb
…t branch Locks in _recoverUnresolved's bare credentials-null guard as a regression test: with no saved credentials and any unresolved intent other than setDate, the session must fail closed with zero radio traffic, before _initialize's fresh-admission and _resume's firmware-based guards would even run. This is the branch that keeps the else-branch's default of _firmware = 'V1150' (when credentials is null) inert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSHZyvw9hHdZREoSDddERt
…pp's own six-record advertisement decoder Opened the ProtocolToolsHolder_CT5$BroadData thread left as an unread pointer in the previous verifyHolder note (structure only, no decode invented; cross-checked jadx against smali since a neighboring CGMService method carries a "decompiled incorrectly" warning): - com.yuwell.cgm.utils.ProtocolToolsHolder_CT5 is a separate class from ist.com.sdk.ProtocolTools/ProtocolToolsHolder, with its own result type (Verify_CT5). Its verify(byte[]) has exactly one call site in the inspected DEX -- CGMService's field j0 -- and CT5InitViewModel's scan callback never reaches it; the two decoders never call each other. - A private checksum helper gates verify(): for the same type-0xFF, 27-byte AD structure verifyHolder discards past its first 4 bytes, this helper sums the next 21 bytes and checks the low byte against a trailing checksum byte. - verify() itself is the method that reads what verifyHolder discards: category, bound flag, a nibble-packed count/type selector, a little-endian index, then 18 bytes run through the same ConvertTools.encode(bytes, kCipher) transform the GATT session path uses, decoded as up to `count` 3-byte big-endian records -- type 1 (current/temperature) or type 2 (trend/errorCode/glucoseValue). Nothing in the method caps count against the payload length; an over-long count throws and is caught by the same shape-error path. - Its sole caller, CGMService's lambda$algorithmGlucose$10, hands every type-2 record straight to onBroadcastNewGlucoseRead() as a CurrentGlucose with no native-algorithm call in that method -- the exact source grounding for the doc's existing "separate advertisement-display path accepts a transmitter value without the native algorithm" claim. jadx flags that method's surrounding gap-continuity logic as unreliably decompiled, so this finding stops at what the smali and the plain accessor calls agree on. Docs-only; no vendor code, binary, or decode logic added. Does not change the fail-closed admission gate, the native-algorithm hard unknown, or any publication requirement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ASY8kedYNDfsP84fbJoRD
…ucose$10 via smali CGMService's lambda$algorithmGlucose$10 has a jadx "Removed duplicated region for block" warning that decompiles to an empty if-body around the index/gap-continuity check. Tracing the raw smali resolves it: the branch only gates whether execution joins the shared record-processing loop every entry path already uses; the real replay guard is the loop's own per-record glucoseId comparison. Confirms, source-level, that this advertisement path never reads a firmware-version field at all -- the V1150-only gate this document establishes elsewhere belongs to a different code path. This sharpens, and does not relax, the existing reason the advertisement path stays out of scope for OpenGlucose's own admission gate. Verification: dart test in packages/cgm_yuwell_anytime -- 110/110 passing (doc-only change, no driver code touched). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZ5jVRQLThvDBxpcZhkeKj
…lifecycle class CT5InitViewModel's scan/version-gate logic is already recorded in this document, but CT5Init — the guide Activity that hosts it — was not: it is a separate class with its own state, not an alias or a thin wrapper. Tracing it (jadx, cross-checked against the smali constant table for TransmitterState) adds a session-lifecycle layer this document did not have: BSN-keyed recovery invalidation on onCreate, permission-gated entry to the ViewModel's backend-config call, an Activity-lifecycle scan backstop on onDestroy, confirmation that startInit(Date) is exactly startBleScan(Date) (SCAN_MODE_LOW_LATENCY, hardware batching disabled), and the TransmitterState-keyed dispatch in onTransmitterStateReceived — including the 30-second resume window shared by DISCONNECTED/CHECK_FAIL and the dedicated UI path (state 23 CHECK_TRANSMITTER_VERSION_FAIL, WearVersionTipDialog) for the version-gate failure this document already established at the ViewModel level under "Application-side version gate". This corroborates, and does not revise, the existing version-gate finding: it is an independent code path confirming the same conclusion from the Activity's own state-code naming and log string. Nothing here touches live, history, or advertisement record content, and nothing here changes any HARD UNKNOWN. Verification: doc-only change; no Dart source or test file touched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
_verifyTopology is the first fail-closed gate in _initialize(): it runs immediately after discoverServices() and before notification subscription or any write, and throws YuwellSessionFailureKind.topology when none of the discovered GATT services match the CT5 primary service UUID. No existing test exercised it — the shared _ScriptedConnection fixture always returned a matching topology unconditionally, so there was no way to reach this branch without a fixture change. Adds an optional serviceOverride to _ScriptedConnection/_Fixture (defaults to the existing hardcoded topology for every other test, so nothing else changes) and one new test that supplies an empty service list. Confirms the failure surfaces before any write, the connection is disconnected during cleanup, and the failure code is recorded in session metadata — the same shape the sibling unsupportedFirmware/responseTimeout tests already check. Synthetic topology only, per the evidence-boundary doc's "unit tests use synthetic values only" rule; no captured device response involved. Verification: dart test in packages/cgm_yuwell_anytime — 111/111 passing; dart analyze and dart format clean on the changed file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
…, and dialog terminality Follow-up to 9966161, which covered CT5Init's onCreate/onDestroy/startInit and its TransmitterState dispatch but left three things under-specified. Traced deeper (jadx, all source-grounded) and filled them in: - Where the resume window's reference Date actually comes from: a four-part prerequisite gate in onPermissionRequestSuccess (runtime permissions, an Android-12-plus BLE-specific recheck, GPS, Bluetooth adapter) that only then clears the version-gate latch and schedules startInit(new Date()). A resumed retry inside the 30s window reuses that same Date rather than minting a new one -- it is a fixed budget from the first attempt, not a sliding one renewed by each retry. - INIT_SUCCESS does not finish the guide directly: it calls the ViewModel's finishGuide(), whose async completion later drives the Activity's own getGuideFinish() observer to broadcast TransmitterState.FINISH_GUIDE through the app's MessageSender and only then close the Activity. Two steps through two components, not one. - CHECK_TRANSMITTER_VERSION_FAIL's WearVersionTipDialog closes the Activity regardless of which option it reports -- this path has no retry inside CT5Init, which the previous revision left implicit. - getScanOverTime() and getBound()'s true case both reach already-cited recovery helpers, but not the same ones: getScanOverTime() matches onRequestFailed exactly, getBound()-true shares only one of the two -- an already-bound sensor is a distinct recovery path, not an identical one. getBound()-true is the Activity-side reaction to the same advertisement isBound flag under "Discovery and GATT topology"; that section's conclusion (isBound is a UI-routing hint, not an authenticated check) is unchanged. None of this revises the prior commit's conclusions -- it sharpens them, same as afaaba6 did for the advertisement-decoder finding. Nothing here touches live, history, or advertisement record content. Verification: doc-only change; no Dart source or test file touched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
connect(DiscoveredSensor) checks driverId/deviceId/storageKey before touching the transport at all and throws YuwellSessionFailureKind .invalidSensor on a mismatch -- the same synchronous, pre-transport shape as the topology check 4147213 covered, but for a different guard clause, and it had no test either. Added a minimal transport that throws if scan()/connect() are ever called, so the test proves the guard fires before any transport use rather than merely running against an unconfigured one. Builds the driver directly instead of through the shared _Fixture, since this check needs neither a scripted connection nor credential/journal state -- only a DiscoveredSensor whose driverId does not match this driver's. Verification: dart test in packages/cgm_yuwell_anytime -- 112/112 passing; dart analyze and dart format clean on the whole package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
…otocolToolsHolder_CT5.a([B)Z Read ProtocolToolsHolder_CT5.java directly this session instead of relying on the existing prose about it, then cross-checked its private a([B)Z checksum-gate helper against smali line by line. Two things the existing account did not have: - jadx's Java renders a([B)Z's non-0xFF branches so that AD type 3 (Service UUIDs) and type 9 (Complete Local Name) both appear to fall through to one more, redundant skip whenever the element's length is exactly 13. The smali does not agree: type 9 jumps straight back to the loop top and never reaches that check. Only type 3 genuinely falls through into it, so only type 3 at length 13 is actually double-skipped -- a real over-read in the reference app's own scanner, not a decompiler illusion, and not the same finding jadx's rendering implied. Type 8 (Shortened Local Name) reaches that same check directly with no skip of its own: correct at length 13, zero bytes advanced otherwise. - The smali catch handler for a([B)Z's one try region falls straight through to the method's final `return false`, not back into the loop: an exception anywhere in one pass aborts the whole scan immediately. jadx's Java (catch-block-with-no-return, inside a while loop) reads as log-and-continue; it is not. Also confirmed by direct read, not just cross-referenced: verify()'s type-1/type-2 record math matches the existing account exactly, and each record's nIndex is the 27-byte structure's starting index plus its position in the batch (not previously stated). A type selector outside 1/2, or a zero count, leaves Verify_CT5 non-null with zero records -- not a parse failure, indistinguishable from an empty batch. This is the same class of decompiler-fidelity issue afaaba6 already resolved once for CGMService.lambda$algorithmGlucose$10, this time in a different method of a different class -- worth having on record as a pattern for this codebase, not a one-off. None of it touches the type-0xFF/27-byte checksum branch or changes any existing conclusion: still zero native-algorithm involvement, still out of scope for OpenGlucose's own admission gate. Verification: doc-only change; no Dart source or test file touched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
…sed branch fetchCalibrations()/submitCalibration() are the code-level enforcement of the evidence-boundary doc's "does not implement ... calibration" line: both reject unconditionally with YuwellSessionFailureKind .unsupportedCapability, no connect/initialize state involved, no branch that could ever succeed. Untested until now, same as the two prior branches 4147213/e7ae2a4 covered. One test, both methods: proves the admission gate this doc describes in prose is actually enforced in code, not just documented policy. The submitCalibration argument is an arbitrary placeholder (0), never read -- the method rejects before touching it -- not a synthetic-but-plausible value. Verification: dart test in packages/cgm_yuwell_anytime -- 113/113 passing; dart analyze and dart format clean on the whole package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
…nt-vs-GATT cipher question The advertisement-decoder section has carried an open note since it was written: kCipher there comes from Transmitter.sureClose, "which this static read does not yet prove is the same session value driver.dart derives for the GATT path." Traced every read/write site of that field (grep across the full decompiled tree, not just the one class already covered) instead of leaving it as a named loose end. CGMCallbackHandlerCT5's device-prepared callback resolves it: on a known transmitter (a local ObjectBox record found), it reuses that record's sureClose directly as AuthInfo's GATT-session cipher, then sends 0x31 -- no fresh 0x30 round-trip. TransmitterRepository.saveTransmitterFirstInit writes sureClose exactly once, during first-time pairing, in the same call that persists the communication ID, K, and R. So for an already-bound transmitter, the advertisement decoder and the connected session read the identical stored value -- resolving the open note. This also closes a second loose end this document had left as a bare citation: enterRecoveryMode() (named under "Discovery and GATT topology" as what a bound scan match enters, never traced). It is one boolean flag on the same callback handler, and it is the difference between two already-known-transmitter paths: no local record AND not recovering goes to the documented first-use branch (getVersion()); no local record AND recovering reads a third, previously-undocumented cipher source instead -- PreferenceSource.getCT5InitCipher(), a SharedPreferences value tied to an interrupted CT5Init flow, distinct from any Transmitter record. Noted, not over-claimed: the recovering flag has other read sites in this same handler this pass did not trace. None of this touches live/history/advertisement record content or changes any HARD UNKNOWN -- it is session/credential provenance, same register as the rest of "Reference session branches." Verification: doc-only change; no Dart source or test file touched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
credentialStore.read() is the first call _initialize() makes, before _transport.connect() -- so a read failure there must never reach the transport. Untested, same as the three prior branches this branch's siblings (4147213, e7ae2a4, af24af7) covered. Added a settable readError on the shared _MemoryCredentialStore fake (defaults to null/no-op, every other test unaffected) so a test can make read() throw without a real storage backend. The assertions deliberately diverge from the topology/unsupportedFirmware siblings: this path never reaches _transport.connect(), so _connection stays null and cleanup's _connection?.disconnect() is a no-op -- the test asserts connection.disconnected is FALSE, not true, and says why inline so the next reader doesn't "fix" it to match the other tests. Verification: dart test in packages/cgm_yuwell_anytime -- 114/114 passing; dart analyze and dart format clean on the whole package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
Closes authenticationRejected, calibrationCode, historyIncomplete, and malformedResponse -- the untested branches named this round. Each is a distinct, independently verified scenario: - authenticationRejected: checkIdAccepted: false (an existing fixture knob) makes the saved-session 0x31 check-ID response report rejection. Confirmed the write sequence is exactly [0x31, 0x11] -- the check itself, then one best-effort binding-status diagnostic read, nothing that could rebind or reconfigure. - calibrationCode: needed a new calibrationCodeOverride fixture knob -- the 0x3f response was hardcoded to one working code. First attempt (a garbage 'INVALID' string) actually hit the earlier frame-decrypt validation instead (malformedResponse), not the clean-room decoder's own rejection; fixed by keeping the same 21-character length as the working fixture (so frame/checksum validation still passes) and only corrupting the K/R digit positions that layout defines. Comment left in place explaining why the length has to match. - historyIncomplete: not a malformed-frame case -- driver.dart's own explicit `if (!completed) throw historyIncomplete` exists for a well-formed history stream that never reports done before the session's per-attempt batch budget runs out. Needed a new maxHistoryBatches parameter on _Fixture.connect() (was hardcoded to 8000) to make that budget small enough to exhaust deliberately with a historyRecordCount far larger than one batch can consume. - malformedResponse: covered via the live-notification-parse path specifically (not the sibling opcode-mismatch path that maps to the same kind) -- _activeCredentials locks the session to the alternate live opcode (0x45), and a too-short payload on it reaches YuwellLiveFrame.parse's own try/catch. notification (_runNotification's generic catch-all) is not included: traced _handleNotification's complete body and every plausible throw site inside it already funnels into a more specific kind (connection, malformedResponse) or is a guarded no-op (empty/unrecognized frames just log). Reaching the generic catch through the public synthetic-fixture surface -- rather than by monkey-patching a private method to force an unrelated exception -- was not achievable this round. Documenting that here as a negative finding rather than leaving it silently untested or forcing a contrived test against internals. Verification: dart test in packages/cgm_yuwell_anytime -- 118/118 passing; dart analyze and dart format clean on the whole package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
…t enum grep Ran dart test --coverage + format_coverage against driver.dart instead of continuing to grep for untested YuwellSessionFailureKind values -- that approach is blind to any gap that doesn't throw one of those, and scan() is exactly that: no exception path, pure discovery-time filtering, and it had zero coverage. scan() drops every advertisement mapScanResult can't classify as the Anytime family, then for the ones it keeps: allowDuplicates: true passes every result through; allowDuplicates: false drops only a repeat at the *same* RSSI for a given candidate, re-surfacing it the moment RSSI actually changes. Covers both modes against one scripted three-result sequence (two Anytime results, same RSSI; a third at a different RSSI; one unrelated device) plus the unrelated device's exclusion. Coverage recheck (same tool, not re-grepped by hand) also confirms line 1453 -- notification's generic catch, documented in 0d65e12 as apparently unreachable through the public surface -- is in fact the only YuwellSessionFailureKind-related line still uncovered; that finding stands. The remaining uncovered lines in driver.dart are lower-value by inspection: a debug status-code-to-string table (cosmetic, no logic), diagnosticCode/toString (never read by any assertion), refreshDiagnostics (no failure path, only display fields), and MTU-negotiation's own catch-and-continue fallback. Noted here rather than chased individually this round -- not a gap list this document is otherwise tracking. Verification: dart test in packages/cgm_yuwell_anytime -- 119/119 passing; dart analyze and dart format clean on the whole package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
Re-ran the coverage tooling 7991e5a used, but per-file across the whole package instead of just driver.dart -- confirms driver.dart's remaining gap is still only the one line already documented there (notification's generic catch), and surfaces frames.dart as the next real one: no dedicated test file at all before this, only indirect exercise through yuwell_session_test.dart's full-session flows. - selfCheckAccepted: exact-length response validator for opcode 0x05. Not currently called anywhere in driver.dart -- noted inline as such, not claimed as closing a live gap -- but it is public clean-room API with its own shape contract, previously untested on its own terms. - YuwellHistoryFrame.parse's layout inference (_inferLayout, reached only when expectedLayout is omitted): rejects a payload length divisible by both known record sizes (ambiguous, neither is a single candidate) and correctly infers compact11 for a length only compact11 divides. Both cases use an all-0xFC first slot -- the documented end sen 10000 tinel -- so neither needs real per-record content: the ambiguous case throws before any record is read, and the valid case terminates on the first slot without reaching the per-record parser. Verification: dart test in packages/cgm_yuwell_anytime -- 122/122 passing; dart analyze and dart format clean on the whole package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
Same coverage pass as 658725a, second file: transmitter_glucose_validation .dart's two factories (YuwellV1150GlucoseObservation, YuwellV1150ValidationRequirements) had every RangeError branch untested -- only the already-valid happy-path construction the existing report-logic tests build was exercised. This is the fail-closed input boundary for the evidence report the doc's "V1150 transmitter-value gates" promotion process reads: every RangeError here is a value that must never silently become part of that decision. Covers every documented range on both factories, and -- not just the rejections -- the boundary value on each side (last accepted value, not just first rejected one), since an off-by-one in the wrong direction would be exactly the kind of bug this evidence gate exists to not have. Verification: dart test in packages/cgm_yuwell_anytime -- 124/124 passing; dart analyze and dart format clean on the whole package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
… store Followed up on last turn's suggestion: same coverage technique, this time against the openhealth-side macOS debug integration (yuwell_macos_secure_session_store.dart backs the same YuwellCredentialStore /YuwellWriteIntentStore contracts driver.dart's own admission gates depend on, just at the Keychain-persistence layer instead of the BLE-session layer). cgm_driver_registry.dart also has uncovered lines, but they are generic multi-vendor scan-zone concurrency edge cases, not Yuwell- or admission-specific -- left alone this round rather than forced. The existing fake only supported failNextWrite; _readAlias and _deleteAlias's own fail-closed catch blocks had never fired. Extended it with failNextRead/failNextDelete (same shape, holding an Exception to throw instead of a bool, so the injected error's type is controllable) and added: - A generic read/delete failure normalizes to the fail-closed StateError. - A read failure that is already a YuwellProtocolFormatException is rethrown as-is, not masked -- confirms the asymmetry with write/delete (which do not special-case that type, since it can only originate from decoding previously-stored data) is real, not an oversight. - The device-secret bootstrap read has its own bare catch-all (on Object, no type preserved) -- a separate, stricter path from the per-alias one above, exercised with its own test rather than assumed identical. - A corrupted stored record (invalid JSON, and valid JSON that is not a Map) fails closed as YuwellProtocolFormatException, not an uncaught decode crash. - A fresh store instance sharing the same backing data can still read what an earlier instance wrote -- proves the device secret persists across what a real launch would be, rather than regenerating and silently orphaning every previously-stored alias. Verification: flutter test on all four yuwell-scoped openhealth test files -- 35/35 passing; flutter analyze and dart format clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
… checksum.dart Final pass of the sweep started in 658725a/377279e/10428f2: session_security .dart's remaining 3 uncovered lines are a toString() override, correctly left alone (cosmetic, same as every other toString() this sweep has deliberately skipped). commands.dart and checksum.dart had one real branch each: - selfCheck(): the request-encoder counterpart to selfCheckAccepted (658725a). Same note applies -- not currently called anywhere in driver.dart, still worth its own encoding-contract test. - initialize()'s two branches were both exercised together only through session-level integration elsewhere; this adds the encoder's own contract directly, including a fixed-request case confirming initializationIndex cannot leak into the non-transmitter-computed wire form, and the existing range check's boundaries. - requireValidSum8Frame's "too short" (<2 bytes) rejection had no test of its own; only the sibling invalid-checksum case did. With this, every file in packages/cgm_yuwell_anytime/lib has either full coverage or only cosmetic (toString()/unused interface getters) lines left, plus the one documented exception (notification's generic catch-all in driver.dart, 0d65e12/7991e5a). This coverage-driven sweep is done for this package. Verification: dart test in packages/cgm_yuwell_anytime -- 126/126 passing; dart analyze and dart format clean on the whole package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbbtbQJ6d7GG13kJzPeS4i
This adds synthetic BLE topology coverage only. It does not establish firmware or glucose acceptance.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Current status — 2026-09-23
Published aggregate source head:
37ec296onrelease/v0.4.0. It contains the exact reviewed N5P source commit0bec58cc82dfc809303920ba3b36881fc246a029, the existing GS1 and Anytime integration history, the independently reviewed Android Health activity compatibility fixa7a5d81, and the separate Android location-manifest lint fix37ec296. Source publication does not publish a mobile or release artifact.The N5P change is one internal, unexported selector-11 temperature-state research primitive. It reproduces only the independently reviewed reachable clamp/reset/binary32 recurrence. It is not wired into the driver and does not admit another firmware branch, decode glucose, consume the packed transmitter field, or emit a
CgmReading.The Android Health compatibility commit changes only
MainActivityfromFlutterActivitytoFlutterFragmentActivity, as required by the pinnedhealth13.3.1 plugin'sComponentActivitypermission-launcher contract. The follow-up manifest commit addsACCESS_COARSE_LOCATIONalongside the existing unrestrictedACCESS_FINE_LOCATION; it changes no runtime or UI flow.Android verification for this exact published sequence:
.debugAPK build from defaultlib/main.dart: GREEN;MainActivityancestry:FlutterFragmentActivity -> FragmentActivity -> ComponentActivity;HealthPlugincast andregisterForActivityResultcall: verified in APK DEX;ACCESS_FINE_LOCATIONandACCESS_COARSE_LOCATION;:app:lintDebug --rerun-tasks: GREEN, 0 errors and 19 existing warnings, 383/383 tasks executed;Preserved ordinary debug artifacts:
d313550154d2ef5234df0e2adfa86a39a0ade8ecec1af0bd39b34dc3d484b41e;36be268f5fb6483c677d70bcd595b1410cc7e04040a5dd28f922c8c5bc7ee11f.These host/artifact results are not physical runtime acceptance and are not release acceptance.
Fresh aggregate checks for the N5P integration:
cgm_yuwell_anytimepackage: 201/201 passed;Release boundary
This remains an integration candidate, not release-ready. GS1 and Anytime physical identity, normalized live/history decoding, lifecycle/reconnect/restart and screen-off behavior, normal-app physical/UI handoff evidence, source-bound production artifacts, signing, distribution, and explicit release approval remain open. The N5P temperature primitive does not reduce those gates or establish physical firmware compatibility. Android Health runtime verification also remains open until the phone is reachable.
Existing sensor feature PRs and issues remain separate: GS1 #208/#209 and Anytime #118/#106. No main merge, tag, or release is requested by this update.