Skip to content

fix(ble): stop treating a missing GATT attribute as a dead link (gh-656) - #657

Merged
tadelv merged 3 commits into
mainfrom
fix/solo-barista-scale-drop
Aug 24, 2026
Merged

fix(ble): stop treating a missing GATT attribute as a dead link (gh-656)#657
tadelv merged 3 commits into
mainfrom
fix/solo-barista-scale-drop

Conversation

@allofmeng

@allofmeng allofmeng commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

What changed, and why?

  • UniversalBleTransport._handleGattError() treated characteristicNotFound and serviceNotFound as gone-device codes: it emitted ConnectionState.disconnected, drained the queue with deviceDisconnected, and threw DeviceNotConnectedException. Both codes are ambiguous — a live peripheral returns them when the attribute is simply not in its GATT database, and a dead link returns them from a stale cache.

  • The Solo Barista (LSJ-001) matches to EurekaScale, which ends onConnect() with an optional battery read of 0x180F/0x2A19. That scale has no battery service, so the read failed and the transport dropped the link microseconds after the scale connected. The impl-level catch (_) around _readBattery() was useless — the transport had already emitted disconnected. From a user log:

    10:43:02.364449 ScaleController - scale connection update: connected
    10:43:02.364759 BLETransport-B560F116... - GATT read(0000180f-.../00002a19-...) failed - device gone: UniversalBleErrorCode.characteristicNotFound
    10:43:02.365020 ScaleController - scale connection update: disconnected
    10:43:02.365094 StatusPublisher - emit error: kind=scaleDisconnected message=Scale disconnected unexpectedly.
    
  • Both codes now log, throw the domain GattAttributeUnavailableException, and hand off to the existing _probeAndDeclareIfDead(), which asks the OS for the real link state and declares the link dead only when the OS agrees. Real-disconnect detection is preserved through that probe.

  • GattAttributeUnavailableException (new, in lib/src/models/errors.dart) extends DeviceNotConnectedException, so no universal_ble type crosses the transport boundary and the lowest-level scale write helpers that already catch DeviceNotConnectedException keep swallowing it. That matters for writes: a stale-GATT characteristicNotFound may still mean a dead link, and the asynchronous OS probe cannot change the exception the caller already received.

  • EurekaScale additionally gates the optional battery read on batteryService.matchesAny(services), using the discoverServices() result it already holds.

  • doc/AI_BLE_NOTES.md "Gone-Device Error Handling" updated with the split and the Solo Barista case.

Linked Issue

Fixes #656

Verification

How did you verify the change? Include relevant tests and any manual or hardware testing.

  • Regression tests in test/services/ble/universal_ble_transport_mtu_test.dart (reusing its fake UniversalBlePlatform, extended with a readErrorCode hook), run over both characteristicNotFound and serviceNotFound:
    • <code> on a live link does not disconnect — fails on the old code, which emitted disconnected synchronously.
    • <code> after the link died reports disconnected — guards the probe path so a real drop is still detected.
    • attribute-missing errors are catchable as not-connected — pins the property the scale write helpers depend on.
  • New test/unit/models/eureka_scale_test.dart covers the Solo Barista path with a fake transport: discovery without 0x180F registers notifications, skips the battery read, and stays connected; discovery with 0x180F still reads the battery level.
  • flutter test test/unit/models/eureka_scale_test.dart test/services/ble/+16: All tests passed!
  • flutter analyzeNo issues found!
  • Full flutter test → 3215 passed, 4 failures, all in test/webui_support/webui_token_injection_test.dart (getWifiIP fallback cases). Confirmed pre-existing on origin/main; unrelated to this PR.
  • No hardware testing — diagnosed from a user-submitted Solo Barista log. The scale is not on hand.

Impact

Note any user-visible behavior, compatibility, migration, API/spec, documentation, or security impact. Write None if there is none.

  • User-visible: BLE scales that lack an optional characteristic no longer disconnect immediately after connecting. Directly fixes the Solo Barista; any other peripheral whose GATT database is missing an attribute the impl probes benefits the same way.
  • Behavior change in the transport: read/write/subscribe now surface GattAttributeUnavailableException for these two codes. It is a DeviceNotConnectedException subtype, so every existing catch site — scale _safeWrite() helpers, controller-level swallows, isBenignFrameworkError(), the telemetry forwarder filter — behaves exactly as before. The link-death outcome for a genuinely dead link is unchanged, just asynchronous via the OS probe.
  • Documentation: doc/AI_BLE_NOTES.md updated.
  • No API/spec, migration, or security impact.

Contributor Responsibility

AI-assisted development is allowed. The submitter remains responsible for the submitted work.

  • I have reviewed and understand all changes in this PR and take responsibility for their correctness, security, behavior, licensing, and provenance, including any AI-assisted or AI-generated work.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RRSCR8Q7HWcHPwH8qvPap6

characteristicNotFound and serviceNotFound were in _goneDeviceCodes, so any
read of an attribute the peripheral does not expose made the transport emit
disconnected and drain the queue. The Solo Barista (LSJ-001) routes to
EurekaScale but has no 0x180F battery service, so the optional battery read
at the end of onConnect dropped the scale microseconds after it connected.

Both codes are ambiguous: a live peripheral returns them for an attribute
that is not in its GATT database, a dead link returns them from a stale
cache. They now log, rethrow the original UniversalBleException, and hand
off to _probeAndDeclareIfDead, which asks the OS for the real link state
before declaring the link dead. EurekaScale also gates the battery read on
the discovered service list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRSCR8Q7HWcHPwH8qvPap6

@tadelv tadelv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracking this down — the root cause and general direction make sense. I’d like two changes before merging:

  1. Keep universal_ble exceptions behind the transport boundary. AGENTS.md / doc/AI_BLE_NOTES.md require library-specific BLE errors to be wrapped in domain types at the transport boundary. The new _attributeMissingCodes path currently rethrows the original UniversalBleException, and the PR explicitly changes read/write/subscribe callers to observe that third-party exception. Please introduce/use a transport-domain exception for “GATT attribute unavailable” (or equivalent) instead. This is particularly important for write paths: a stale-GATT characteristicNotFound can represent a dead link, while scale implementations are intentionally required to catch DeviceNotConnectedException at their lowest-level write helper. The asynchronous link probe cannot change the exception already returned to the caller.

  2. Add regression coverage for the actual Eureka/Solo Barista path. Please add a fake-transport EurekaScale test showing that when discovery returns the Eureka service but not 0x180F, onConnect() registers notifications, does not attempt the battery read, and remains connected. Also please exercise both characteristicNotFound and serviceNotFound in the transport regression tests, since production behavior changes for both codes.

The service-presence guard itself and the probe-before-declaring-dead approach look appropriate; this is mainly about preserving the repository’s transport abstraction and covering the user-visible failure path directly.

Review feedback on #657:

- `_handleGattError()` no longer rethrows the third-party
  `UniversalBleException` for `characteristicNotFound` /
  `serviceNotFound`. It throws `GattAttributeUnavailableException`,
  which extends `DeviceNotConnectedException` so the lowest-level scale
  write helpers keep catching it: for a write, a stale-GATT
  `characteristicNotFound` may still mean a dead link, and the
  asynchronous OS probe cannot change the exception already handed to
  the caller.
- Transport regression tests now run over both `characteristicNotFound`
  and `serviceNotFound`, plus a case asserting the error is catchable as
  `DeviceNotConnectedException`.
- New `test/unit/models/eureka_scale_test.dart` covers the Solo Barista
  path with a fake transport: discovery without 0x180F registers
  notifications, skips the battery read, and stays connected; discovery
  with 0x180F still reads the battery level.
- `doc/AI_BLE_NOTES.md` documents the domain exception and why it
  subclasses `DeviceNotConnectedException`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013888xMCNLWCuQFW1RkgYka
@allofmeng

Copy link
Copy Markdown
Collaborator Author

Both points addressed in 691742d.

1. universal_ble exceptions stay behind the transport boundary.

_handleGattError() no longer rethrows UniversalBleException for characteristicNotFound / serviceNotFound. It throws a new domain type in lib/src/models/errors.dart:

class GattAttributeUnavailableException extends DeviceNotConnectedException {
  final String operation;
  final String path;

  const GattAttributeUnavailableException({
    required this.operation,
    required this.path,
  }) : super(DeviceKind.unknown);
}

It extends DeviceNotConnectedException on purpose, for exactly the write-path reason you raised: a stale-GATT characteristicNotFound can represent a dead link, the OS probe is asynchronous and cannot retroactively change the exception the caller already holds, and scale implementations are required to catch DeviceNotConnectedException at their lowest-level write helper. Subclassing means every existing on DeviceNotConnectedException catch — _safeWrite() in each scale impl, the controller-level swallows, isBenignFrameworkError(), and the telemetry forwarder filter — keeps behaving as before with no call-site churn, while the specific type stays available for callers that want to distinguish "attribute is not in this peripheral's GATT database" from "link is gone".

No third-party BLE type crosses the transport boundary any more. The earlier PR-body claim that read/write/subscribe callers would observe UniversalBleException no longer applies.

2. Regression coverage.

  • New test/unit/models/eureka_scale_test.dart, fake-transport EurekaScale:
    • discovery returns fff0 but not 0x180F -> onConnect() subscribes to fff0/fff1, performs no read, and ends in ConnectionState.connected.
    • discovery returns both -> the 0x180F/0x2A19 battery read still happens, so the guard did not silently kill battery reporting on a real Eureka Precisa.
  • test/services/ble/universal_ble_transport_mtu_test.dart regression tests now loop over both characteristicNotFound and serviceNotFound, each in the live-link case (no disconnected emitted) and the dead-link case (probe finds the OS disconnected, disconnected emitted). Added one more asserting the thrown error is catchable as DeviceNotConnectedException, which is the property the scale write helpers depend on.

doc/AI_BLE_NOTES.md now documents the domain exception and why it subclasses DeviceNotConnectedException.

Verification

  • flutter test test/unit/models/eureka_scale_test.dart test/services/ble/ -> +16: All tests passed!
  • flutter analyze -> No issues found!
  • Full flutter test -> 3215 passed, same 4 pre-existing test/webui_support/webui_token_injection_test.dart failures (getWifiIP fallback cases) that reproduce on origin/main.
  • Still no hardware; the Solo Barista is not on hand.

The previous commits reformatted eight test files untouched by this fix.
That formatting came from an older local Dart formatter and disagrees with
the stable Flutter the CI format gate runs, so `dart format
--set-exit-if-changed` over the PR diff failed. Restore those files to
their base state; the fix's own files are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015J7EV8fcsjBNH15axyAiwk
@tadelv
tadelv merged commit 3371b48 into main Aug 24, 2026
5 checks passed
tadelv pushed a commit that referenced this pull request Aug 24, 2026
Review feedback on #657:

- `_handleGattError()` no longer rethrows the third-party
  `UniversalBleException` for `characteristicNotFound` /
  `serviceNotFound`. It throws `GattAttributeUnavailableException`,
  which extends `DeviceNotConnectedException` so the lowest-level scale
  write helpers keep catching it: for a write, a stale-GATT
  `characteristicNotFound` may still mean a dead link, and the
  asynchronous OS probe cannot change the exception already handed to
  the caller.
- Transport regression tests now run over both `characteristicNotFound`
  and `serviceNotFound`, plus a case asserting the error is catchable as
  `DeviceNotConnectedException`.
- New `test/unit/models/eureka_scale_test.dart` covers the Solo Barista
  path with a fake transport: discovery without 0x180F registers
  notifications, skips the battery read, and stays connected; discovery
  with 0x180F still reads the battery level.
- `doc/AI_BLE_NOTES.md` documents the domain exception and why it
  subclasses `DeviceNotConnectedException`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013888xMCNLWCuQFW1RkgYka
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug Report] **solo barista scale shown as connected but no weight values in app**

2 participants