FFFF fix(cbio): give every closed failure its own code and its own sentence by shroominic · Pull Request #186 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content
Merged
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
32 changes: 32 additions & 0 deletions openhealth/lib/src/session_presentation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,9 @@ String? primaryErrorTextForSnapshot(CgmSessionSnapshot snapshot) {
? userMessageForLibreConnectionLoss(snapshot.lastError)
: userMessageForLibreConnectionFailure(snapshot.lastError);
}
if (isCbioSnapshot(snapshot)) {
return userMessageForCbioFailure(snapshot.lastError);
}
final bleFailure = BleFailure.fromMetadata(snapshot.metadata);
return bleFailure == null
? snapshot.lastError
Expand All @@ -535,6 +538,35 @@ bool isLibreGen1Snapshot(CgmSessionSnapshot snapshot) =>
bool isCbioSnapshot(CgmSessionSnapshot snapshot) =>
snapshot.sensor.driverId == 'cbio';

/// The failure-card sentence for a closed CBio session code.
///
/// The session publishes machine codes. They are support codes, not copy: the
/// failure card is the one place a user reads them, and `cbio.auth.rejected` is
/// not a sentence. Each code keeps its own failure, because "the sensor refused
/// this build's credential" and "the sensor is out of range" have different
/// next steps.
String userMessageForCbioFailure(String? code) => switch (code) {
CbioSessionFailure.authMaterial =>
'OpenGlucose could not read the link credential for this sensor. '
'Choose another sensor or update the app.',
CbioSessionFailure.authRejected =>
'The sensor refused the link credential this build uses. '
'Choose another sensor or update the app.',
CbioSessionFailure.authTimeout =>
'The sensor did not answer the link setup. Keep it close and try again.',
CbioSessionFailure.topology =>
'This sensor does not present the link OpenGlucose supports yet. '
'Choose another sensor.',
CbioSessionFailure.write =>
'The link refused a command from this phone. Keep the sensor close and '
'try again.',
CbioSessionFailure.disconnected =>
'The sensor disconnected. Keep it close and try again.',
CbioSessionFailure.connect =>
'Could not reach the sensor. Keep it close and try again.',
_ => 'OpenGlucose could not connect to this sensor.',
};

/// The provisional marker every CBio surface shows.
///
/// The GS1 raw field is divided by ten by two independent clients of the
Expand Down
26 changes: 26 additions & 0 deletions openhealth/test/cbio_app_surface_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,32 @@ void main() {
expect(stageLabelForSnapshot(snapshot), 'Live');
});

test('a closed CBio failure reaches the user as a sentence', () {
for (final code in <String>[
CbioSessionFailure.connect,
CbioSessionFailure.topology,
CbioSessionFailure.authMaterial,
CbioSessionFailure.authTimeout,
CbioSessionFailure.authRejected,
CbioSessionFailure.write,
CbioSessionFailure.disconnected,
]) {
final snapshot = _snapshot(
stage: CgmSyncStage.error,
statusText: 'Connection failed',
history: const <CgmReading>[],
historySync: const CgmHistorySyncState(),
).copyWith(lastError: code);
final message = primaryErrorTextForSnapshot(snapshot);
expect(message, isNotNull, reason: '$code must reach the failure card');
expect(
message,
isNot(contains('cbio.')),
reason: '$code is a support code, not a sentence',
);
}
});

test('history progress wording reports the fetched count', () {
expect(
historySyncProgressText(
Expand Down
67 changes: 54 additions & 13 deletions packages/cgm_cbio/lib/src/cbio_glucose_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,28 @@ abstract final class CbioSessionPhase {
static const String failed = 'failed';
}

/// Why a masked frame did, or did not, reach the radio.
///
/// These are not interchangeable. A refused write is terminal, an exhausted
/// read budget is not, and a blocked frame never left the app - so the session
/// can fail closed on the one and carry on with the others.
enum CbioFrameWrite {
/// The command characteristic accepted the frame.
sent,

/// The transport threw or timed out: the sensor never received the frame.
failed,

/// The session is closing or the link is gone; there is nothing to send to.
unavailable,

/// The frame was not one of the permitted commands, so it stayed in the app.
blocked,

/// The per-session read budget is spent.
budgetExhausted,
}

/// Closed failure codes. They carry no payload and no credential.
abstract final class CbioSessionFailure {
static const String connect = 'cbio.connect.failed';
Expand Down Expand Up @@ -352,11 +374,14 @@ final class CbioGlucoseSession implements CgmSession {
// The vendor application issues one packed read first. This firmware
// answers it with frozen zeros, so its content is ignored and the raw
// path below carries every reading.
await _sendMasked(
final query = await _sendMasked(
buildMaskedCbioGlucoseQuery(0, key: _streamKey),
label: 'glucose-0a',
isRead: true,
);
if (!_requireWritten(query)) {
return;
}
_beginHistory(_historyStartIndex);
}

Expand Down Expand Up @@ -392,7 +417,7 @@ final class CbioGlucoseSession implements CgmSession {
label: 'auth',
isRead: false,
);
if (!wrote) {
if (!_requireWritten(wrote)) {
_authReply = null;
return false;
}
Expand Down Expand Up @@ -461,7 +486,7 @@ final class CbioGlucoseSession implements CgmSession {
label: 'clock',
isRead: false,
);
if (!wrote) {
if (!_requireWritten(wrote)) {
return false;
}
_clockWritten = true;
Expand Down Expand Up @@ -554,9 +579,11 @@ final class CbioGlucoseSession implements CgmSession {
label: label,
isRead: true,
);
if (!wrote) {
if (wrote != CbioFrameWrite.sent) {
_liveWindow = null;
_publishBudgetState();
if (wrote == CbioFrameWrite.budgetExhausted) {
_publishBudgetState();
}
return;
}
_liveResponseTimer?.cancel();
Expand Down Expand Up @@ -683,35 +710,35 @@ final class CbioGlucoseSession implements CgmSession {
}
}

Future<bool> _sendMasked(
Future<CbioFrameWrite> _sendMasked(
List<int> masked, {
required String label,
required bool isRead,
}) async {
if (_closing) {
return false;
return CbioFrameWrite.unavailable;
}
final connection = _connection;
final command = _command;
if (connection == null || command == null) {
return false;
return CbioFrameWrite.unavailable;
}
// Reachable only before the session resolved its material, and then no
// frame is written.
final CbioCredentials? resolved = _resolved;
if (resolved == null) {
return false;
return CbioFrameWrite.unavailable;
}
final plaintext = unmaskCbioFrame(masked, key: resolved.streamKey);
if (!_isAllowedCommand(plaintext)) {
// Defence in depth: an unrecognised command never reaches the radio.
_log(CgmLogLevel.error, 'cbio.write.blocked:$label');
return false;
return CbioFrameWrite.blocked;
}
if (isRead && _readsUsed >= timing.maxReadsPerSession) {
_budgetExhausted = true;
_log(CgmLogLevel.warning, 'cbio.read.budget');
return false;
return CbioFrameWrite.budgetExhausted;
}
if (isRead) {
_readsUsed += 1;
Expand All @@ -721,11 +748,25 @@ final class CbioGlucoseSession implements CgmSession {
.write(command, masked, withoutResponse: false)
.timeout(timing.writeTimeout);
_log(CgmLogLevel.info, 'cbio.write.$label');
return true;
return CbioFrameWrite.sent;
} on Object {
_log(CgmLogLevel.error, 'cbio.write.failed:$label');
return false;
return CbioFrameWrite.failed;
}
}

/// Ends setup when a frame the sensor must receive never reached the radio.
///
/// Returning quietly here is what left a refused write parked in the
/// connecting stage with no terminal state and no code to report.
bool _requireWritten(CbioFrameWrite outcome) {
if (outcome == CbioFrameWrite.sent) {
return true;
}
if (outcome == CbioFrameWrite.failed) {
_fail(CbioSessionFailure.write);
}
return false;
}

static bool _isAllowedCommand(List<int> plaintext) {
Expand Down
67 changes: 67 additions & 0 deletions packages/cgm_cbio/test/cbio_glucose_session_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,73 @@ void main() {
await session.disconnect();
});

test(
'a write the radio refuses fails the setup with its own code',
() async {
final connection = _FakeConnection();
final transport = _FakeTransport(connection);
await _defaultResponder(connection);
connection. async {
throw StateError('radio refused the frame');
};

final session = CbioGlucoseSession(
sensor: _sensor,
transport: transport,
credentials: _syntheticSource,
timing: _fastTiming,
);
await session.initialize();
await _pumpUntil(
() => session.currentSnapshot.stage == CgmSyncStage.error,
);

expect(
session.currentSnapshot.lastError,
'cbio.write.failed',
reason: 'a frame the sensor never received is its own failure',
);
expect(session.currentSnapshot.stage, CgmSyncStage.error);
expect(
session.currentSnapshot.metadata[cbioPhaseMetadataKey],
CbioSessionPhase.failed,
);
await session.disconnect();
},
);

test('an unusable credential is never reported as a refused one', () async {
final connection = _FakeConnection(serial: <int>[]);
final transport = _FakeTransport(connection);
await _defaultResponder(connection);

final session = CbioGlucoseSession(
sensor: DiscoveredSensor(
driverId: 'cbio',
deviceId: 'not-an-address',
displayName: 'GS1 sensor',
storageKey: 'cbio:test',
rssi: -55,
capabilities: CbioGlucoseSession.capabilities,
),
transport: transport,
credentials: _syntheticSource,
timing: _fastTiming,
);
await session.initialize();
await _pumpUntil(
() => session.currentSnapshot.stage == CgmSyncStage.error,
);

expect(session.currentSnapshot.lastError, 'cbio.auth.material');
expect(
session.currentSnapshot.lastError,
isNot('cbio.auth.rejected'),
reason: 'the link resolves its own credential before asking the sensor',
);
await session.disconnect();
});

test('never logs the link credential', () async {
final connection = _FakeConnection();
final transport = _FakeTransport(connection);
Expand Down
Loading
0