8000 feat(cbio): stream GS1 history and live readings into the app by shroominic · Pull Request #142 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions docs/testing/cbio-gs1-app-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Cbio GS1 app-live evidence

What the installed debug app does with a real GS1 sensor, on a Pixel 6, with the
driver in this branch. Every number below was read off the device screen after
the capture; nothing is inferred from unit tests.

## Screen evidence

Captured from the running app (`/tmp/og-cbio-live-raw-800.png` after
`sips -Z 800`; full-resolution source `/tmp/og-cbio-live-raw.png`):

| Surface | What it rendered |
| --- | --- |
| Hero card | `5.3` with no unit suffix, stage pill `Live` |
| Hero subtitle | `Sensor raw value · index 10067` |
| Stored-history line | `10067 readings stored · sensor minutes 1–10067` |
| Provisional marker | `Provisional reading. Sensor raw / 10, scale unverified.` |
| History card | `10067 readings`, the same provisional marker, `Sensor history complete. 1 records.` |
| History chart | full ~7 day curve, ranges `3h / 12h / 1d / 3d / ALL` |

An earlier capture of the same session (`/tmp/og-cbio-live-800.png`) is the
before-fix screen: it rendered `101 mg/dL`, which claimed a glucose unit the
protocol does not establish.

## Discovery, link, and stream evidence

- The app's own nearby-sensor scan lists the sensor (`1 sensor found`,
`Supported sensor · Bluetooth · good signal`); before the transport fix in the
stacked scan-stream change the same scan hung on "Looking for supported
sensors nearby…" forever.
- `adb logcat` shows the live characteristic delivering continuously while the
screen above is up: `[FBP] onCharacteristicChanged: chr: ff31`, roughly every
500 ms.
- The stored count grows while the screen is open (5680 → 6128 → 10067), and the
history card ends on `Sensor history complete.`, so the backfill reached the
sensor's live edge rather than stopping at the first batch.

## Scale and time provenance

- The value shown is the `0x08` record's raw counter divided by ten. The capture
harness read raw `47..53` from the same field at index `9940..9992`; those are
`4.7..5.3` on this scale, which is the `5.3` the app renders. App and harness
therefore agree on the field and the scale; only the unit label differed.
- The unit stays unverified: no reference measurement exists for this sensor, so
no surface may present the number as mg/dL or mmol/L.
- Reading positions are the protocol's own `index` counter, which advances one
per stored minute. It carries no epoch, so the app does not invent a
wall-clock time from it.

### What the archive adoption changed in the rendered number

The archive used to expose the raw field three ways: `rawCurrent`, a derived
mmol/L accessor (`rawCurrent / 10`), and a derived mg/dL accessor
(`rawCurrent / 10 * 18.0182`). Nothing in this repository establishes that
either unit belongs to the field, so the record now exposes `rawCurrent` and
`rawCurrentScaled` (`rawCurrent / 10`) and no unit-bearing accessor at all.

The session published the mg/dL derivation into `CgmReading.valueMgdl`, so this
adoption changes the number a cbio record carries, and therefore every surface
that reads it:

| Surface | Before | After |
| --- | --- | --- |
| Hero card | `5.3`, no unit | `5.3`, no unit (unchanged: it already rendered `rawValue / 10`) |
| Dashboard chart, `valueMgdl` | `rawCurrent / 10 * 18.0182` (e.g. `95` for raw `53`) | `rawCurrent / 10` (e.g. `5.3` for raw `53`) |

The hero is unchanged; the chart's plotted value is the unit-free scale the hero
already showed, so app and hero agree instead of the chart asserting a unit the
protocol never established. Provisional readings are still excluded from Apple
Health, so no export claims the number as mg/dL either.

## Clock anchor

The counter stays epoch-less; what the app can offer instead is the one
reference it establishes itself. The session writes the sensor clock once per
session (`06 03 LE32(epoch)`), and the surface reads an index as a clock only
when the sensor's own newest record stamp agrees with the app's clock to within
`cbioAnchorTolerance` (3 minutes):

- the anchor is the newest stored position plus that position's own stamp, and
every older position steps back 60 s per index, as the vendor layout reports
the counter (`base + 60 * i`);
- the range the anchor speaks for stops at the first hole or counter jump, so a
position the archive cannot support keeps no timestamp;
- the anchor is published in the snapshot metadata
(`cgm.cbio.clock.anchorIndex`, `…anchorEpochSeconds`,
`…anchorCoveredFrom`, `…referenceEpochSeconds`), and the hero states the
provenance: `Sensor clock set by this app · latest 09:05 (±1 min)`;
- when the clock was never written, or the sensor's stamps disagree with the
app's clock (the +7 h 28 m counter of #146), no anchor is published and the
hero says `Sensor clock unsynced · ordered by sensor index, not by clock`
instead of a placeholder time.

The timestamps therefore inherit the clock this app set on the sensor and its
drift, which the surface states as the ±1 minute of one stored record. The
capture above predates the anchor and shows the unsynced copy; a device
re-capture of the anchored copy is still pending.

## How to reproduce

```bash
export PATH=/Users/fungus/dev/openhealth/.toolchains/flutter/bin:$PATH
cd openhealth
flutter build apk --debug
adb -s <device> install -r -t build/app/outputs/flutter-apk/app-debug.apk
adb -s <device> shell pm grant com.openglucose.app.debug android.permission.BLUETOOTH_SCAN
adb -s <device> shell pm grant com.openglucose.app.debug android.permission.BLUETOOTH_CONNECT
adb -s <device> shell pm grant com.openglucose.app.debug android.permission.ACCESS_FINE_LOCATION
adb -s <device> shell am start -n com.openglucose.app.debug/com.aidex.aidex_flutter.MainActivity
# dashboard -> Connect a sensor -> Connect, then wait for the history window
adb -s <device> shell screencap -p /sdcard/screen.png && adb -s <device> pull /sdcard/screen.png /tmp/screen.png
sips -Z 800 /tmp/screen.png --out /tmp/screen-800.png
```

The device address is never committed; it is passed at run time when a harness
needs discovery bypassed.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// Masked bytes are logged; the authentication frame's plaintext is not, because
// it carries the link credential.
import 'dart:async';
import 'dart:io' show Platform;

import 'package:cgm_cbio/cgm_cbio.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
Expand All @@ -40,6 +41,23 @@ const int _maxWrites = 9;

const String _targetDeviceId = String.fromEnvironment('CBIO_TARGET_DEVICE_ID');

/// Vendor material for this run.
///
/// Nothing in the repository carries the stream key or the link credential, so
/// the harness reads them from the process environment or from `--dart-define`
/// and aborts before touching the radio when they are absent. Provide them
/// without writing them to a committed file, for example with
/// `--dart-define-from-file` against a git-ignored local file.
final CbioMapCredentialSource _credentials =
CbioMapCredentialSource(<String, String>{
...Platform.environment,
if (cbioStreamKeyHex.isNotEmpty) cbioStreamKeyDefine: cbioStreamKeyHex,
if (cbioAuthMaterialHex.isNotEmpty)
cbioAuthMaterialDefine: cbioAuthMaterialHex,
if (cbioAuthTriggerHex.isNotEmpty)
cbioAuthTriggerDefine: cbioAuthTriggerHex,
});

/// First raw (`08`) index to request. Zero starts a full history replay; a
/// higher value resumes partway so a bounded window can reach the newest
/// stored record instead of re-reading the whole archive.
Expand Down Expand Up @@ -67,6 +85,15 @@ void main() {
}

Future<void> _runSession() async {
if (!_credentials.isConfigured) {
_emit(
'CBIO-A abort=vendor-material-missing '
'missing=${_credentials.missing.join(",")}',
);
return;
}
final vendor = _credentials.read();

final targetId = _targetDeviceId.isNotEmpty
? _targetDeviceId
: await _acquireTargetId();
Expand Down Expand Up @@ -125,7 +152,7 @@ Future<void> _runSession() async {

subscription = notify.onValueReceived.listen((bytes) {
masked.add((elapsed(), List<int>.from(bytes)));
final plaintext = unmaskCbioFrame(bytes);
final plaintext = unmaskCbioFrame(bytes, key: vendor.streamKey);
_emit(
'CBIO-A notify t=${elapsed()} masked=${_hex(bytes)} '
'plaintext=${_hex(plaintext)}',
Expand Down Expand Up @@ -193,7 +220,7 @@ Future<void> _runSession() async {
/// Returns true when the newest unmasked reply is an auth success ACK.
bool authSucceeded() {
for (final (_, bytes) in masked.reversed) {
final plaintext = unmaskCbioFrame(bytes);
final plaintext = unmaskCbioFrame(bytes, key: vendor.streamKey);
if (plaintext.length != 5) continue;
if (plaintext.fold<int>(0, (a, b) => a + b) & 255 != 0) continue;
if (plaintext[1] == 0x01) {
Expand All @@ -215,7 +242,11 @@ Future<void> _runSession() async {
var authenticated = false;
for (final (source, octets) in addressCandidates) {
_emit('CBIO-A auth-attempt source=$source');
final authFrame = buildMaskedCbioAuthentication(octets);
final authFrame = buildMaskedCbioAuthentication(
octets,
key: vendor.streamKey,
material: vendor.authMaterial,
);
await send(authFrame, 'auth-$source');
final deadline = DateTime.now().add(_authWindow);
while (DateTime.now().isBefore(deadline)) {
Expand All @@ -240,14 +271,18 @@ Future<void> _runSession() async {
await send(
buildMaskedCbioClock(
DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000,
key: vendor.streamKey,
),
'clock',
);

final beforeReads = masked.length;
await send(buildMaskedCbioGlucoseQuery(0), 'glucose-0a-index0');
await send(
buildMaskedCbioRawQuery(_rawStartIndex),
buildMaskedCbioGlucoseQuery(0, key: vendor.streamKey),
'glucose-0a-index0',
);
await send(
buildMaskedCbioRawQuery(_rawStartIndex, key: vendor.streamKey),
'raw-08-index$_rawStartIndex',
);

Expand All @@ -261,7 +296,7 @@ Future<void> _runSession() async {
final rawRecords = <CbioRawRecord>[];
final plaintextFrames = <List<int>>[];
for (final (_, bytes) in masked.sublist(beforeReads)) {
final plaintext = unmaskCbioFrame(bytes);
final plaintext = unmaskCbioFrame(bytes, key: vendor.streamKey);
plaintextFrames.add(plaintext);
try {
final batch = parseCbioGlucoseBatch(plaintext);
Expand Down
Loading
Loading
0