fix(scale): separate original Decent Scale and HDS behavior - #867
Conversation
ODevStudio
left a comment
There was a problem hiding this comment.
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
timestampMillisactually 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.
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
f7d3bc5 to
662f0cf
Compare
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
left a comment
There was a problem hiding this comment.
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.
ODevStudio
left a comment
There was a problem hiding this comment.
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).
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
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.
* 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>
Summary
What changed, and why?
0A 00), HDS SoftSleep (0A 04), power-off and BLE disconnect as independent.ScalePowerMode.displayOffno 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.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 changedflutter analyze— no issuesde1_state_manager_wake_scan_test— 66 tests passedflutter 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)0x22capability split, fw>=3 SoftSleep gate, failed-SoftSleep -> display-off fallback, failed display-off retaining the link, and no reconnect churn on sleep/wake0A 04 00is 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.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.Decent scale: disconnecting for sleep (SoftSleep unavailable)and dropped the link. After this revision the same path sends0A 00and keeps the connection.50:78:7D:1F:AE:E1, modelhds, firmware3.1.14-custom):halfDecentScalewithsoftSleepfrom a valid0x22response and a decoded firmware 3.1.14;Decent scale: entering HDS SoftSleep, nodisconnecting for sleep, no reconnect;on connect);sending power off,powerOff=true).0A 00display-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
Noneif there is none.ScalePowerMode.displayOffkeeps the BLE connection for unknown/original/pre-modern-HDS Decent Scales and sends the shared0A 00display-off command. There is no intentional disconnect merely because SoftSleep is unavailable.0A 00instead of disconnecting. A failed display-off write keeps the healthy link; only the transport watchdog tears down a dead one.0A 01, or0A 04 00then0A 01after SoftSleep); capabilities are not renegotiated merely because the display was toggled.DecentScaleno longer implementsDisconnectToSleepScale;De1StateManagerno longer marks it sleeping indisplayOffmode.pydecentscalebehavior and still needs hardware confirmation.Contributor Responsibility
AI-assisted development is allowed. The submitter remains responsible for the submitted work.