Skip to content

fix(scale): separate original Decent Scale and HDS behavior - #867

Merged
tadelv merged 23 commits into
mainfrom
fix/839-decent-scale-profile-negotiation
Sep 15, 2026
Merged

tadelv merged 23 commits into
mainfrom
fix/839-decent-scale-profile-negotiation

Conversation

@tadelv

@tadelv tadelv commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

What changed, and why?

  • Negotiate a conservative Decent Scale profile from protocol evidence so original scales do not receive HDS-only commands.
  • Remove heartbeat and periodic maintenance writes; retain passive notification watchdog recovery.
  • Gate HDS SoftSleep/power-off by capabilities and preserve original-scale firmware command reliability rules.
  • Treat base display-off (0A 00), HDS SoftSleep (0A 04), power-off and BLE disconnect as independent. ScalePowerMode.displayOff no longer disconnects a healthy original/unknown/pre-modern-HDS scale; it sends the shared display-off command and keeps weighing. This corrects the policy exposed by field report [Bug Report] **Scale** #874.
  • Add profile, lifecycle, disconnect, timeout, stale-evidence, command reliability and controller lifecycle coverage plus BLE/device-management documentation.

Linked Issue

Fixes #839. Follows up field report #874.

Verification

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

  • dart format --output=none --set-exit-if-changed lib test — 0 changed
  • flutter analyze — no issues
  • Targeted Decent Scale suites + de1_state_manager_wake_scan_test — 66 tests passed
  • flutter test — 4,233 passed, 1 skipped, 10 failed; all 10 failures are the localhost webserver/webui port-3000 tests and are caused by a running Decaid instance holding port 3000 (reproduced at HEAD with the change stashed)
  • Mutation/behaviour checks confirm the 0x22 capability split, fw>=3 SoftSleep gate, failed-SoftSleep -> display-off fallback, failed display-off retaining the link, and no reconnect churn on sleep/wake
  • HDS SoftSleep exit ordering: 0A 04 00 is sent before LED/status on both the same-connection and reconnect wake paths, and a failed or superseded exit stays logically asleep and retries on the next wake. Four ordering regressions cover failed entry, failed exit retry, obligation surviving a later sleep, and reconnect-before-confirmation.
  • HDS SoftSleep exit resilience: a single wakeDisplay() retries the exit once (200ms apart) while the display generation is current; on exhaustion it sends no LED/status, does not confirm or disconnect, keeps the exit obligation, and fails the wake instead of silently completing. A later same-connection wake is not misread as a reconnect, and a write completing after a superseding sleep cannot re-enter confirmation. Six lifecycle regressions cover within-wake retry, exhaustion, supersession during retry and during a pending write, no false reconnect, and obligation survival.
  • [Bug Report] **Scale** #874 field finding: a real original full-height scale (FW 1.1) was identified correctly and streamed ~30 minutes, then the old policy logged Decent scale: disconnecting for sleep (SoftSleep unavailable) and dropped the link. After this revision the same path sends 0A 00 and keeps the connection.
  • Real HDS hardware pass (2026-09-15, Android tablet, BLE id 50:78:7D:1F:AE:E1, model hds, firmware 3.1.14-custom):
    • identified as halfDecentScale with softSleep from a valid 0x22 response and a decoded firmware 3.1.14;
    • Sleep used the HDS SoftSleep path and retained the BLE connection - log Decent scale: entering HDS SoftSleep, no disconnecting for sleep, no reconnect;
    • the connection served 4,802 notifications over 484s across the sleep/wake window;
    • Wake restored the display on the same physical connection (a status response followed the SoftSleep line, no new on connect);
    • explicit Disconnect powered the scale off correctly (sending power off, powerOff=true).
  • Residual gap: the shared 0A 00 display-off path for unknown/original/pre-modern-HDS scales (the [Bug Report] **Scale** #874 correction) was not exercised on hardware because no such scale was present; it is covered by unit tests. Confirm on the [Bug Report] **Scale** #874 original-scale device before closing.

Impact

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

  • ScalePowerMode.displayOff keeps the BLE connection for unknown/original/pre-modern-HDS Decent Scales and sends the shared 0A 00 display-off command. There is no intentional disconnect merely because SoftSleep is unavailable.
  • Proven HDS SoftSleep is unchanged; a failed SoftSleep write falls back to 0A 00 instead of disconnecting. A failed display-off write keeps the healthy link; only the transport watchdog tears down a dead one.
  • Wake restores the same connection (0A 01, or 0A 04 00 then 0A 01 after SoftSleep); capabilities are not renegotiated merely because the display was toggled.
  • DecentScale no longer implements DisconnectToSleepScale; De1StateManager no longer marks it sleeping in displayOff mode.
  • No database migration or REST/WebSocket API/spec change.
  • Original-scale firmware marker mapping is derived from public pydecentscale behavior and still needs hardware confirmation.
  • Remaining Android GATT/connectivity work is tracked in Android BLE recovery: DE1 and scale links time out together and reconnect attempts hit GATT 133 #871, not here.

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.

@ODevStudio ODevStudio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the thorough work on this — the capability-based split between original Decent Scale and HDS looks like the right direction, and the lifecycle/reliability coverage is useful.

I went through the protocol and sleep/wake paths and found a couple of things I’d address before merging, plus two smaller protocol details. I’ve left concrete suggestions inline so the intended changes are easy to apply.

Main points:

  • validate protocol integrity before using 0x22/weight frames as capability evidence;
  • fall back to a clean disconnect when HDS SoftSleep itself fails or times out;
  • preserve the Decent tare command counter while still duplicating the exact same command for affected v1.0 firmware;
  • make timestampMillis actually contain milliseconds.

Overall I think the architecture is solid; these look like fairly contained fixes around the new negotiation/reliability paths. I’d also still keep the planned original-scale/HDS hardware sleep-wake validation before considering #839 fully closed.

Comment thread lib/src/models/device/impl/decent_scale/profile.dart
Comment thread lib/src/models/device/impl/decent_scale/scale.dart
Comment thread lib/src/models/device/impl/decent_scale/scale.dart Outdated
Comment thread lib/src/models/device/impl/decent_scale/scale.dart Outdated
tadelv added a commit that referenced this pull request Sep 15, 2026
Addresses the request-changes review of #867.

A valid 0x22 voltage response proves HDS and extended commands, but not
SoftSleep: v2.5.8 introduced 0x22 while SoftSleep only arrived in 2.6.3.
SoftSleep is now a separate capability granted only when HDS identity is
accompanied by a decoded firmware version with major >= 3. HDS before
3.0.1 reports no version and falls back conservatively to
disconnect-on-sleep, as do original and unknown scales.

A failed SoftSleep write no longer leaves Decaid logically asleep while
the scale stays awake: _sendOledOff() reports whether the write sequence
succeeded and sleepDisplay() disconnects whenever it did not, even if
Android still reports the native link as connected. The regression test
covers the write failing while native state remains connected.

Also fixes the firmware decoder to accept the packed minor/patch low
nibbles (OpenScale 3.1.14 arrives as 0x03 0x1E) and scales the weight
frame decisecond timestamp to milliseconds.

Evidence: flutter analyze clean; 5 decent-scale test files 60 passed;
dart format 0 changed. Full suite 4232 passed, 10 pre-existing failures
in webserver-port/webui-token tests caused by a running app holding port
3000, reproduced at HEAD.

Refs #839
Identity is evidence about the connected scale; capabilities are the
behaviour contract that gates which commands are legal. A scale that
cannot be identified stays conservative (7-byte weight frames, no
SoftSleep, no power off, command buffer treated as unreliable).

Evidence: a 0x0A status response or timestamped weight frame identifies
an original Decent Scale; only a valid 0x22 response promotes to HDS.

Refs #839
The DecentScale BLE path sent HDS SoftSleep (0x0A 0x04) unconditionally,
wrote a LED/status command every other maintenance cycle, and tare sent
a heartbeat-enabling 0x01 byte. On original Decent Scales that traffic
precedes the Android GATT 133 disconnect reported in #839.

- remove the heartbeat subsystem entirely; heartbeat-control bytes are 0x00
- maintenance is read-only: no periodic LED/status writes
- after the data channel is confirmed, probe the profile unawaited:
  canonical LED ON/status (0x00 heartbeat) then the HDS 0x22 voltage query
- promote to HDS capabilities only on a valid 0x22 response; otherwise
  stay conservative (no SoftSleep, no power off)
- sleep disconnect/reconnect for unknown and original scales; SoftSleep
  stays available for positively detected HDS
- power-off is withheld unless the profile proves support
- nonessential writes tolerate transient failures while the link is alive;
  tare/timer still fail loudly
- duplicate tare/timer writes only for profiles with the unreliable
  command buffer (7-byte weight frames), 50ms apart

Evidence: flutter analyze clean; flutter test 4215 passed.
Refs #839
Lifecycle suite: unknown/original scales disconnect for sleep and never
receive SoftSleep or power off; confirmed HDS keeps SoftSleep and writes
the exit frame; capabilities do not leak across connections; late
evidence after sleep is ignored; no heartbeat anywhere; maintenance is
read-only.

Reliability suite: original seven-byte firmware gets the 50ms duplicate
tare/timer writes; v1.2 and HDS profiles do not; a transient voltage-probe
failure keeps a healthy link connected; a genuinely disconnected
transport still fails loudly; notification starvation recovers then
disconnects without power off.

Each suite was mutation-checked against the pre-fix behaviour.

Refs #839
Addresses the independent review of the #839 change.

- decode the original firmware marker table (0xFE=1.0, 0x02=1.1,
  0x03=1.2, matching the public pydecentscale client) and expose
  originalFirmwareVersion; only v1.0 needs duplicate commands, only
  v1.2 supports power off
- promote capabilities as evidence arrives instead of only when the
  unawaited negotiation finishes, so a v1.1+/HDS scale stops duplicating
  as soon as its status response lands
- replace the tautological evidence generation with a real profile
  attempt token, bound to the subscription callback, incremented on every
  connect, wake iteration and sleep; a stale callback or a negotiation
  that resumes after sleep can no longer promote the current connection
- guard onConnect teardown and connected publication with the attempt so
  a superseded initialization cannot tear down or publish over a newer one
- bind the transport listener by subscription identity rather than
  attempt, so a drop during sleep is still handled
- bound the 0x22 probe write, and disconnect when an HDS SoftSleep write
  fails with the link gone
- keep promotion monotonic: a voltage frame recorded during a failed or
  hanging probe can no longer be demoted by the probe result
- log the initial conservative profile and the decoded status evidence

Evidence: flutter analyze clean; flutter test 4238 passed. Mutation checks
confirmed the marker table, the failed-SoftSleep disconnect and the
evidence guards are each detected by the new tests.
Refs #839
Second review pass found that a superseded onConnect could resume after
an await, cancel the newer attempt's transport listener, and cancel the
live maintenance timer before the only attempt guard. Maintenance death
would silently disable the stale-notification recovery this issue is
about.

- add a connection-attempt token, distinct from the profile-evidence
  token, and re-check it after every await in onConnect and in the catch
  teardown
- bind the transport listener by subscription identity so a drop during
  sleep is still handled
- expose debugCompletedNegotiations so the hanging-probe test proves the
  probe actually completed instead of merely not crashing
- make the hanging power-off test establish an HDS profile first, so the
  hanging write is actually reached
- keep only evidence-recording guards that a mutation can demonstrate

Evidence: flutter analyze clean; flutter test 4238 passed. Mutation
checks confirmed the outer probe timeout, the paired evidence guards and
the failed-SoftSleep disconnect are each detected.
Refs #839
Recheck the connection attempt after channel confirmation and native state reads before mutating watchdogs or publishing connected. During failed initialization, retain listener identity and recheck ownership after cancellation so a superseded attempt cannot tear down the replacement connection.

Refs #839
Addresses the request-changes review of #867.

A valid 0x22 voltage response proves HDS and extended commands, but not
SoftSleep: v2.5.8 introduced 0x22 while SoftSleep only arrived in 2.6.3.
SoftSleep is now a separate capability granted only when HDS identity is
accompanied by a decoded firmware version with major >= 3. HDS before
3.0.1 reports no version and falls back conservatively to
disconnect-on-sleep, as do original and unknown scales.

A failed SoftSleep write no longer leaves Decaid logically asleep while
the scale stays awake: _sendOledOff() reports whether the write sequence
succeeded and sleepDisplay() disconnects whenever it did not, even if
Android still reports the native link as connected. The regression test
covers the write failing while native state remains connected.

Also fixes the firmware decoder to accept the packed minor/patch low
nibbles (OpenScale 3.1.14 arrives as 0x03 0x1E) and scales the weight
frame decisecond timestamp to milliseconds.

Evidence: flutter analyze clean; 5 decent-scale test files 60 passed;
dart format 0 changed. Full suite 4232 passed, 10 pre-existing failures
in webserver-port/webui-token tests caused by a running app holding port
3000, reproduced at HEAD.

Refs #839
@tadelv
tadelv force-pushed the fix/839-decent-scale-profile-negotiation branch from f7d3bc5 to 662f0cf Compare September 15, 2026 08:34
Field report #874 showed a real original full-height Decent Scale (FW 1.1)
identified correctly and streaming for ~30 minutes, then intentionally
disconnected the moment the DE1 slept because SoftSleep was unavailable.
That reconnect churn is the Android GATT 133 pressure #839 set out to
reduce.

Base display-off (0A 00), HDS SoftSleep (0A 04), power-off and BLE
disconnect are separate. ScalePowerMode.displayOff now sends the shared
0A 00 command and keeps the connection for unknown, original and
pre-modern HDS scales; proven HDS SoftSleep is attempted first and falls
back to 0A 00 on failure. A failed display-off write is logged and the
healthy link retained; only the transport watchdog tears down a dead one.

- split the mixed _sendOledOff into _sendDisplayOff, _enterSoftSleep and
  _exitSoftSleep
- replace the _isSleeping flag with an explicit awake/displayOff/softSleep
  mode; display-off keeps the FFF4 subscription, profile evidence and
  maintenance alive
- wake restores the same connection (0A 01, or 0A 04 00 + 0A 01 after
  SoftSleep); reconnect-during-sleep still renegotiates on the new link
- drop DisconnectToSleepScale from DecentScale so De1StateManager no longer
  marks it sleeping in displayOff mode
- update lifecycle/disconnect/controller tests and the design, BLE and
  device-management docs

Evidence: flutter analyze clean; targeted scale + controller suites 65
passed; full suite 4233 passed with 10 pre-existing webserver/webui
failures caused by a running app holding port 3000.

Refs #839
Refs #874
…connect

A GATT timeout on the 0A 04 01 entry write does not prove the scale never
entered SoftSleep, and OpenScale preserves SoftSleep across a BLE
disconnect. The previous revision tracked the exit only in the sleep mode,
so a failed entry (or a reconnect during SoftSleep) could wake with LED ON
while the scale was still asleep, and a failed 0A 04 00 exit lost the
obligation entirely.

- track _softSleepExitRequired separately from the fallback display mode;
  set it whenever a capable HDS attempts SoftSleep, clear it only after a
  successful exit for the current generation
- send 0A 04 00 before LED/status in both the same-connection and
  reconnected wake paths
- on a failed or superseded exit, stay logically asleep and retry on the
  next wake instead of confirming the channel or sending LED ON
- keep the obligation across a superseding sleep and clear it on explicit
  disconnect
- add four ordering regressions (failed entry still exits, failed exit
  retried, obligation survives a later sleep, reconnect exits before
  confirmation)

Evidence: flutter analyze clean; targeted scale + controller suites 70
passed; full suite 4237 passed with the pre-existing port-3000 webserver/
webui failures plus one data_sync timeout that passes in isolation.

Refs #839
Refs #874
De1StateManager calls wakeDisplay() once per DE1 wake cycle, so a single
transient GATT failure on the 0A 04 00 SoftSleep exit previously stranded
the display until the machine slept and woke again.

- retry the exit once (bounded, 200ms apart) inside a single wakeDisplay
  while the display generation is still current
- clear the exit obligation only after a current successful exit
- on exhaustion keep the obligation, stay logically in SoftSleep, send no
  LED/status and do not confirm the channel, and fail the wake instead of
  silently completing (without disconnecting)
- restore _sleepConnectionAttempt when failing so a later same-connection
  wake is not misread as a reconnect (which would re-subscribe and can
  disconnect on a confirmation timeout)
- return success only for a current, awake exit so a write completing after
  a superseding sleep cannot re-enter confirmation
- regressions: within-wake retry ordering, exhaustion (throws, no LED, no
  disconnect, obligation survives), supersession during retry and during a
  pending write, and no false reconnect after exhaustion

Evidence: flutter analyze clean; targeted scale + controller suites 74
passed; full suite 4241 passed with the pre-existing port-3000 webserver/
webui failures plus one timing flake that passes in isolation.

Refs #839
Refs #874
The bounded SoftSleep-exit retries added ~1.2s of real delay to the
decent_scale_lifecycle suite, pushing its active time past the 20s gate.
The SoftSleep tests use a responding fake, so their _connectAndSettle does
not need the 900ms profile-probe wait; pass a 100ms settle there.

Active time: 20.5s -> 14.1s. 74 targeted tests pass; full suite 4242
passed with only the pre-existing port-3000 webserver/webui failures.

Refs #839
Refs #874

@ODevStudio ODevStudio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review on the latest head: this is substantially stronger. The timestampMillis issue is fixed, the sleep/display-off split now matches the #874 field evidence, the SoftSleep exit obligation/retry handling is much more explicit, and CI is green. I’ve resolved my two old SoftSleep/disconnect threads because that recommendation has been superseded by the new display-off policy.

I do see one lifecycle regression that I think should be fixed before merge: a newer wake request can be lost while an older wakeDisplay() operation is still in flight. I left the exact sequence inline. This is particularly worth fixing because the earlier regression test for “latest wake runs after a superseded wake probe” was removed in this revision.

Two earlier protocol comments are also still open: capability evidence is still accepted without validating the checksummed 0x22 / timestamped frame path, and tare still sends a constant counter byte instead of incrementing once per logical tare. I’d keep those threads open until they’re either addressed or explicitly justified with protocol/hardware evidence.

The hardware validation and the added lifecycle coverage are good progress; after the wake-coalescing race is covered again, I’d be comfortable doing another pass.

Comment thread lib/src/models/device/impl/decent_scale/scale.dart Outdated

@ODevStudio ODevStudio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed after the follow-up fixes. The three outstanding items are addressed: capability-widening evidence is checksum-gated without tightening legacy 7-byte traffic, tare sequencing now advances per logical tare while v1.0 retries remain byte-identical, and the wake lifecycle preserves the latest intent across an in-flight wake -> sleep -> wake supersession. Regression coverage was added for each path. PR checks are green (format, analyze, full Flutter tests, and Linux build smoke).

@tadelv
tadelv merged commit 4d52244 into main Sep 15, 2026
5 checks passed
tadelv added a commit that referenced this pull request Sep 15, 2026
Addresses the request-changes review of #867.

A valid 0x22 voltage response proves HDS and extended commands, but not
SoftSleep: v2.5.8 introduced 0x22 while SoftSleep only arrived in 2.6.3.
SoftSleep is now a separate capability granted only when HDS identity is
accompanied by a decoded firmware version with major >= 3. HDS before
3.0.1 reports no version and falls back conservatively to
disconnect-on-sleep, as do original and unknown scales.

A failed SoftSleep write no longer leaves Decaid logically asleep while
the scale stays awake: _sendOledOff() reports whether the write sequence
succeeded and sleepDisplay() disconnects whenever it did not, even if
Android still reports the native link as connected. The regression test
covers the write failing while native state remains connected.

Also fixes the firmware decoder to accept the packed minor/patch low
nibbles (OpenScale 3.1.14 arrives as 0x03 0x1E) and scales the weight
frame decisecond timestamp to milliseconds.

Evidence: flutter analyze clean; 5 decent-scale test files 60 passed;
dart format 0 changed. Full suite 4232 passed, 10 pre-existing failures
in webserver-port/webui-token tests caused by a running app holding port
3000, reproduced at HEAD.

Refs #839
@tadelv
tadelv deleted the fix/839-decent-scale-profile-negotiation branch September 15, 2026 15:06
ODevStudio added a commit that referenced this pull request Sep 15, 2026
Follow up merged PR #867 against current OpenScale behavior. Reassert display-off after sleeping reconnects, retain only the prior sleep lifecycle capability evidence needed to finish that sleep safely, and require a post-wake status frame before confirming HDS SoftSleep exit.

Add focused regressions for reconnect darkness/power-off and applied wake confirmation.
pponce added a commit to pponce/decaid that referenced this pull request Sep 16, 2026
* fix(firmware): poll erase/verify completion instead of only awaiting the notify

Some firmwares never emit the post-erase / post-verify notification, so the
verify flow has to actively re-read the firmware-map register rather than wait
for it to be pushed. Adds readFwMapRequestFresh plus the poll itself.

Combines the port's two commits (87819b2 + 3435af2): the first references a
timeout constant the second defines, so it has never compiled on its own in
either line and the split was not meaningful.

Re-anchor adaptations:

- readFwMapRequestFresh drops one replayed value ONLY when the subject holds
  one. Upstream's _fwMapRequestSubject is no longer seeded, so the port's
  unconditional skip(1) swallowed the very frame the poll had just provoked and
  every first poll timed out -- caught by the serial test.
- The fake transport keeps upstream's _intResponseQueues rather than
  reinstating the port's _intResponseSeq; upstream renamed and reimplemented
  that helper, so re-adding it would have shadowed working code with a
  duplicate.
- test/helpers/fake_serial_transport.dart comes across from the port: it has no
  upstream equivalent and the serial test needs it.
- bengle_estimator_pipeline_test.dart and machine_snapshot_telemetry_test.dart
  are NOT carried over. Both exercise the MachineSnapshot estimator fold, which
  the sensor-based design replaces; bengle_puck_estimator_sensor_test.dart
  covers the same ground against the new shape. The decoder and serial-dispatch
  tests carry over unchanged -- neither referenced MachineSnapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 293d77c)

* docs(api): say the verify step polls rather than waits for a notify

The paragraph read as if done depended on a pushed notification. It does not
any more: the flow re-reads the firmware-map register, because some firmwares
never emit that notification.

* Keep upstream's own formatting for the untouched expect()

CI runs `dart format` on the stable channel; the SDK on this machine is
Flutter 3.44.2 / Dart 3.12.2 from June, and the two disagree about how to wrap
this call. Reformatting it here was churn, not a change — the lane's only real
edit to this file is the shadowing note above.

Restores upstream's spelling so the diff carries the fix and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(ble): move the firmware poll rationale out of the code

AGENTS.md keeps explanatory comments out of new code and puts rationale in
the matching doc/AI_*_NOTES.md. The poll fallback carried its reasoning
inline instead, in the production files, both fake transports and the
regression tests.

doc/AI_BLE_NOTES.md gains "Firmware Update: Erase/Verify Poll Fallback",
which records why the poll exists, why each transport needs a fresh read,
why the serial path must not send the matching <-I>, why the BLE path must
not use the public read(), and what each timeout bounds. The code keeps
only the short notes a reader needs at the line itself.

doc/Api.md said the app polls the register rather than waiting for a
notification. The implementation races notify against the poll, so the
sentence now describes that, in externally observable terms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ble): the plugin fixture inherits the transport's call counters

The merge put two declarations of the same names in one class chain: this lane
added connectCalls and disconnectCalls to FakeBleTransport, and main's
PluginBleFixtureTransport, which extends it, declares its own. Neither side is
wrong alone; together they shadow, and analyze refuses it with overridden_fields
and annotate_overrides.

The fixture drops its copies and uses the inherited ones. Its connect() and
disconnectConfirmed() never called super, so what they increment is the only
change and every existing assertion reads the same number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(ble): correct firmware verify terminal states

* feat(scale): show connected Skale firmware revision

* refactor(scale): simplify firmware reporting

Remove duplicate firmware state and subscription bookkeeping identified during the ponytail review. The firmware revision still publishes only validated values while disconnect cleanup and UI behavior remain unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(settings): expose device management

The top-right Settings page did not link to the existing Devices page, leaving firmware and scale controls hidden behind the launcher navigation. Add a direct Devices row so connected-device metadata and settings are reachable from the expected entry point.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Document the Settings device-management route

Device Management is now reachable from the regular Settings page as well as the dashboard. Record both entry points so the user-facing navigation documentation matches the new discoverable route.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(api): separate connected scale info from inventory

Expose generic connected-scale metadata through /api/v1/scale/info while keeping device inventory limited to discovery and connection state. Remove metadata refresh subscriptions from the inventory WebSocket and align REST and WebSocket contracts and API documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(api): complete scale info boundary

Keep scale metadata on the connected-scale endpoint and update affected implementations and tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(api): route scale info through scale command

Use the router's parameterized GET form so the connected-scale metadata endpoint is reachable, and align inventory tests with the boundary.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(api): keep battery metadata scoped to battery PR

* test(api): enforce scale metadata boundary

Keep inventory payloads metadata-free and cover connected scale info responses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(api): keep firmware metadata scope accurate

Do not document battery metadata before the battery API change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Skale metadata boundary and formatting

Observed: PR742 review identified batteryLevel as obsolete ScaleInfo state and a DeviceManagement description that put connected metadata in device inventories.

Change: Remove batteryLevel from ScaleInfo serialization, document GET /api/v1/scale/info as the REST metadata boundary, and apply the Dart formatter output.

Constraint: Preserve opaque firmware handling and the existing ScaleInfo handler and Skale tests; no unrelated API behavior changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Restore single device connection subscriptions

Keep the aggregator inventory in its original (Device, StreamSubscription) shape now that each device has one connection-state listener. Apply the same cleanup across related PR copies so stale, replacement, and disposal paths cancel exactly one subscription.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(webui): isolate entry port allocation

* fix(scale): narrow connected metadata capability

* fix(scale): report nullable Skale battery metadata

* refactor(scale): simplify battery reporting

Remove duplicate battery resets, guards, and subscription bookkeeping identified during the ponytail review. Nullable battery reporting and refresh behavior remain unchanged while the implementation has fewer competing state paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(api): separate connected scale info from inventory

Expose generic connected-scale metadata through /api/v1/scale/info while keeping device inventory limited to discovery and connection state. Keep metadata refreshes out of the inventory WebSocket and align the API contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(api): enforce scale metadata boundary

Keep inventory payloads metadata-free and cover connected scale info responses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Align device metadata docs and formatting

Observed: the device-management text still claimed inventory REST and WebSocket entries carried nested deviceInfo metadata.

Change: Route connected-scale metadata documentation through GET /api/v1/scale/info and apply Dart formatter output.

Constraint: Keep the PR743 batteryLevel support and all implementation behavior unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scale): narrow connected metadata capability

* fix(steam): select temperature source by contract (decentespresso#831)

* refactor(serial): add desktop serial identity resolver

Introduce SerialPortMetadata, desktopSerialLegacyIds and
dedupeSerialCandidates as pure helpers so one canonical usb-* identity is
resolved per enumerated desktop port and macOS cu/tty aliases collapse
before probing.

Part of decentespresso#826

* fix(serial): resolve desktop port identity once per scan

Enumerate each desktop serial port into a SerialPortMetadata candidate,
dedupe within the scan (collapsing macOS cu/tty aliases and repeat
canonical IDs), and inject the resolved usb-* id into the transport so
scan dedup, quick-connect and Device.deviceId agree.

Part of decentespresso#826

* fix(devices): re-key legacy serial alias on connect

RememberedDevicesController.replaceAliasOnConnect atomically drops a
legacy serial-* key, keeps or creates the canonical record, persists once
and rolls the registry back on persistence failure.

Part of decentespresso#826

* fix(devices): migrate legacy serial quick-connect ids

After a successful quick-connect, re-key the remembered legacy serial-*
alias to the canonical usb-* id and move preferredMachineId. Registry
persistence failure keeps the connection and the legacy id.

Part of decentespresso#826

* docs(devices): document canonical desktop serial identity

Part of decentespresso#826

* fix(devices): harden serial alias dedup and id migration

Address review findings:
- collapse macOS cu/tty aliases even when only one node exposes USB
  metadata, keeping the resolved usb-* id
- suppress tty aliases of already-tracked ports on later scans
- roll SettingsController preferred id back when persistence fails, and
  move preferred before the remembered re-key so neither can dangle
- dispose the raw serial port when transport construction fails

Archive the design doc under doc/plans/archive/.

Part of decentespresso#826

* fix(ios): lower deployment target to iOS 16.7

* ci(android): skip obsolete SDK tools package

* feat(scale): add Decent Scale profile and capability model

Identity is evidence about the connected scale; capabilities are the
behaviour contract that gates which commands are legal. A scale that
cannot be identified stays conservative (7-byte weight frames, no
SoftSleep, no power off, command buffer treated as unreliable).

Evidence: a 0x0A status response or timestamped weight frame identifies
an original Decent Scale; only a valid 0x22 response promotes to HDS.

Refs decentespresso#839

* fix(scale): negotiate Decent Scale profile before HDS-only commands

The DecentScale BLE path sent HDS SoftSleep (0x0A 0x04) unconditionally,
wrote a LED/status command every other maintenance cycle, and tare sent
a heartbeat-enabling 0x01 byte. On original Decent Scales that traffic
precedes the Android GATT 133 disconnect reported in decentespresso#839.

- remove the heartbeat subsystem entirely; heartbeat-control bytes are 0x00
- maintenance is read-only: no periodic LED/status writes
- after the data channel is confirmed, probe the profile unawaited:
  canonical LED ON/status (0x00 heartbeat) then the HDS 0x22 voltage query
- promote to HDS capabilities only on a valid 0x22 response; otherwise
  stay conservative (no SoftSleep, no power off)
- sleep disconnect/reconnect for unknown and original scales; SoftSleep
  stays available for positively detected HDS
- power-off is withheld unless the profile proves support
- nonessential writes tolerate transient failures while the link is alive;
  tare/timer still fail loudly
- duplicate tare/timer writes only for profiles with the unreliable
  command buffer (7-byte weight frames), 50ms apart

Evidence: flutter analyze clean; flutter test 4215 passed.
Refs decentespresso#839

* test(scale): cover conservative profile lifecycle and write tolerance

Lifecycle suite: unknown/original scales disconnect for sleep and never
receive SoftSleep or power off; confirmed HDS keeps SoftSleep and writes
the exit frame; capabilities do not leak across connections; late
evidence after sleep is ignored; no heartbeat anywhere; maintenance is
read-only.

Reliability suite: original seven-byte firmware gets the 50ms duplicate
tare/timer writes; v1.2 and HDS profiles do not; a transient voltage-probe
failure keeps a healthy link connected; a genuinely disconnected
transport still fails loudly; notification starvation recovers then
disconnects without power off.

Each suite was mutation-checked against the pre-fix behaviour.

Refs decentespresso#839

* docs(scale): record Decent Scale profile negotiation rationale

Refs decentespresso#839

* fix(scale): close profile-negotiation concurrency and firmware gaps

Addresses the independent review of the decentespresso#839 change.

- decode the original firmware marker table (0xFE=1.0, 0x02=1.1,
  0x03=1.2, matching the public pydecentscale client) and expose
  originalFirmwareVersion; only v1.0 needs duplicate commands, only
  v1.2 supports power off
- promote capabilities as evidence arrives instead of only when the
  unawaited negotiation finishes, so a v1.1+/HDS scale stops duplicating
  as soon as its status response lands
- replace the tautological evidence generation with a real profile
  attempt token, bound to the subscription callback, incremented on every
  connect, wake iteration and sleep; a stale callback or a negotiation
  that resumes after sleep can no longer promote the current connection
- guard onConnect teardown and connected publication with the attempt so
  a superseded initialization cannot tear down or publish over a newer one
- bind the transport listener by subscription identity rather than
  attempt, so a drop during sleep is still handled
- bound the 0x22 probe write, and disconnect when an HDS SoftSleep write
  fails with the link gone
- keep promotion monotonic: a voltage frame recorded during a failed or
  hanging probe can no longer be demoted by the probe result
- log the initial conservative profile and the decoded status evidence

Evidence: flutter analyze clean; flutter test 4238 passed. Mutation checks
confirmed the marker table, the failed-SoftSleep disconnect and the
evidence guards are each detected by the new tests.
Refs decentespresso#839

* docs(scale): note decoded firmware markers and hardware gap

Refs decentespresso#839

* fix(scale): guard connection ownership across overlapping connects

Second review pass found that a superseded onConnect could resume after
an await, cancel the newer attempt's transport listener, and cancel the
live maintenance timer before the only attempt guard. Maintenance death
would silently disable the stale-notification recovery this issue is
about.

- add a connection-attempt token, distinct from the profile-evidence
  token, and re-check it after every await in onConnect and in the catch
  teardown
- bind the transport listener by subscription identity so a drop during
  sleep is still handled
- expose debugCompletedNegotiations so the hanging-probe test proves the
  probe actually completed instead of merely not crashing
- make the hanging power-off test establish an HDS profile first, so the
  hanging write is actually reached
- keep only evidence-recording guards that a mutation can demonstrate

Evidence: flutter analyze clean; flutter test 4238 passed. Mutation
checks confirmed the outer probe timeout, the paired evidence guards and
the failed-SoftSleep disconnect are each detected.
Refs decentespresso#839

* fix(scale): retain ownership through connection teardown

Recheck the connection attempt after channel confirmation and native state reads before mutating watchdogs or publishing connected. During failed initialization, retain listener identity and recheck ownership after cancellation so a superseded attempt cannot tear down the replacement connection.

Refs decentespresso#839

* fix(scale): gate HDS SoftSleep on firmware and harden sleep fallback

Addresses the request-changes review of decentespresso#867.

A valid 0x22 voltage response proves HDS and extended commands, but not
SoftSleep: v2.5.8 introduced 0x22 while SoftSleep only arrived in 2.6.3.
SoftSleep is now a separate capability granted only when HDS identity is
accompanied by a decoded firmware version with major >= 3. HDS before
3.0.1 reports no version and falls back conservatively to
disconnect-on-sleep, as do original and unknown scales.

A failed SoftSleep write no longer leaves Decaid logically asleep while
the scale stays awake: _sendOledOff() reports whether the write sequence
succeeded and sleepDisplay() disconnects whenever it did not, even if
Android still reports the native link as connected. The regression test
covers the write failing while native state remains connected.

Also fixes the firmware decoder to accept the packed minor/patch low
nibbles (OpenScale 3.1.14 arrives as 0x03 0x1E) and scales the weight
frame decisecond timestamp to milliseconds.

Evidence: flutter analyze clean; 5 decent-scale test files 60 passed;
dart format 0 changed. Full suite 4232 passed, 10 pre-existing failures
in webserver-port/webui-token tests caused by a running app holding port
3000, reproduced at HEAD.

Refs decentespresso#839

* fix(scale): keep original Decent Scale connected on display off

Field report decentespresso#874 showed a real original full-height Decent Scale (FW 1.1)
identified correctly and streaming for ~30 minutes, then intentionally
disconnected the moment the DE1 slept because SoftSleep was unavailable.
That reconnect churn is the Android GATT 133 pressure decentespresso#839 set out to
reduce.

Base display-off (0A 00), HDS SoftSleep (0A 04), power-off and BLE
disconnect are separate. ScalePowerMode.displayOff now sends the shared
0A 00 command and keeps the connection for unknown, original and
pre-modern HDS scales; proven HDS SoftSleep is attempted first and falls
back to 0A 00 on failure. A failed display-off write is logged and the
healthy link retained; only the transport watchdog tears down a dead one.

- split the mixed _sendOledOff into _sendDisplayOff, _enterSoftSleep and
  _exitSoftSleep
- replace the _isSleeping flag with an explicit awake/displayOff/softSleep
  mode; display-off keeps the FFF4 subscription, profile evidence and
  maintenance alive
- wake restores the same connection (0A 01, or 0A 04 00 + 0A 01 after
  SoftSleep); reconnect-during-sleep still renegotiates on the new link
- drop DisconnectToSleepScale from DecentScale so De1StateManager no longer
  marks it sleeping in displayOff mode
- update lifecycle/disconnect/controller tests and the design, BLE and
  device-management docs

Evidence: flutter analyze clean; targeted scale + controller suites 65
passed; full suite 4233 passed with 10 pre-existing webserver/webui
failures caused by a running app holding port 3000.

Refs decentespresso#839
Refs decentespresso#874

* test(scale): cover repeated display-off and wake cycles

Refs decentespresso#839
Refs decentespresso#874

* fix(scale): keep the HDS SoftSleep exit obligation across wake and reconnect

A GATT timeout on the 0A 04 01 entry write does not prove the scale never
entered SoftSleep, and OpenScale preserves SoftSleep across a BLE
disconnect. The previous revision tracked the exit only in the sleep mode,
so a failed entry (or a reconnect during SoftSleep) could wake with LED ON
while the scale was still asleep, and a failed 0A 04 00 exit lost the
obligation entirely.

- track _softSleepExitRequired separately from the fallback display mode;
  set it whenever a capable HDS attempts SoftSleep, clear it only after a
  successful exit for the current generation
- send 0A 04 00 before LED/status in both the same-connection and
  reconnected wake paths
- on a failed or superseded exit, stay logically asleep and retry on the
  next wake instead of confirming the channel or sending LED ON
- keep the obligation across a superseding sleep and clear it on explicit
  disconnect
- add four ordering regressions (failed entry still exits, failed exit
  retried, obligation survives a later sleep, reconnect exits before
  confirmation)

Evidence: flutter analyze clean; targeted scale + controller suites 70
passed; full suite 4237 passed with the pre-existing port-3000 webserver/
webui failures plus one data_sync timeout that passes in isolation.

Refs decentespresso#839
Refs decentespresso#874

* fix(scale): retry and surface failed SoftSleep exits within one wake

De1StateManager calls wakeDisplay() once per DE1 wake cycle, so a single
transient GATT failure on the 0A 04 00 SoftSleep exit previously stranded
the display until the machine slept and woke again.

- retry the exit once (bounded, 200ms apart) inside a single wakeDisplay
  while the display generation is still current
- clear the exit obligation only after a current successful exit
- on exhaustion keep the obligation, stay logically in SoftSleep, send no
  LED/status and do not confirm the channel, and fail the wake instead of
  silently completing (without disconnecting)
- restore _sleepConnectionAttempt when failing so a later same-connection
  wake is not misread as a reconnect (which would re-subscribe and can
  disconnect on a confirmation timeout)
- return success only for a current, awake exit so a write completing after
  a superseding sleep cannot re-enter confirmation
- regressions: within-wake retry ordering, exhaustion (throws, no LED, no
  disconnect, obligation survives), supersession during retry and during a
  pending write, and no false reconnect after exhaustion

Evidence: flutter analyze clean; targeted scale + controller suites 74
passed; full suite 4241 passed with the pre-existing port-3000 webserver/
webui failures plus one timing flake that passes in isolation.

Refs decentespresso#839
Refs decentespresso#874

* test(scale): keep the lifecycle suite under the CI active-time gate

The bounded SoftSleep-exit retries added ~1.2s of real delay to the
decent_scale_lifecycle suite, pushing its active time past the 20s gate.
The SoftSleep tests use a responding fake, so their _connectAndSettle does
not need the 900ms profile-probe wait; pass a 100ms settle there.

Active time: 20.5s -> 14.1s. 74 targeted tests pass; full suite 4242
passed with only the pre-existing port-3000 webserver/webui failures.

Refs decentespresso#839
Refs decentespresso#874

* fix(scale): validate capability evidence frames

* fix(scale): preserve latest wake intent and tare sequence

* test(scale): cover latest wake intent and valid voltage evidence

* test(scale): validate tare sequence and checksummed evidence

* test(scale): use checksummed HDS voltage fixtures

* test(scale): reject corrupt capability evidence

* docs(scale): align tare and evidence decisions

* style(scale): format profile integrity test

* style(scale): format latest-wake regression

* fix(scale): harden HDS sleeping reconnect lifecycle

Follow up merged PR decentespresso#867 against current OpenScale behavior. Reassert display-off after sleeping reconnects, retain only the prior sleep lifecycle capability evidence needed to finish that sleep safely, and require a post-wake status frame before confirming HDS SoftSleep exit.

Add focused regressions for reconnect darkness/power-off and applied wake confirmation.

* test(scale): expect display-off reassertion on reconnect

---------

Co-authored-by: ChampionDesigns <ben@championdesigns.com.au>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Vid Tadel <tadelv@gmail.com>
Co-authored-by: Vid Tadel <tadelv@users.noreply.github.com>
Co-authored-by: Maximilian Rink <maximilian.rink@telekom.de>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: ODevStudio <ODevStudio@proton.me>
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] **Scale not connecting**

2 participants