Skip to content

feat(bengle): sync the wake schedule and sleep timeout to the machine - #467

Open
ChampionDesigns wants to merge 25 commits into
decentespresso:mainfrom
ChampionDesigns:feat/bengle-wake-schedule
Open

feat(bengle): sync the wake schedule and sleep timeout to the machine#467
ChampionDesigns wants to merge 25 commits into
decentespresso:mainfrom
ChampionDesigns:feat/bengle-wake-schedule

Conversation

@ChampionDesigns

@ChampionDesigns ChampionDesigns commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Stacked PR — B-9 of 10. Builds on #466 (feat/bengle-profile-v2-upload). Until that merges this PR's diff includes its commits; please review/merge in order B-1 → B-10.

A Bengle can run its own wake schedule with no tablet in the room - the firmware holds a weekly table of wake windows and a wall clock, and wakes the machine on its own. Nothing in the app had ever written to them. On a bench machine all three registers read zero: no clock, no entries, schedule disabled. So the "wake at 6:30 on weekdays" feature that the app appears to offer was, in fact, a 30-second timer inside the app, running off the tablet's clock, and it only worked if the tablet was awake, connected, and running the app. Leave the tablet at your desk and your machine is cold when you get to it. This PR pushes the wall clock, the wake windows and the sleep timeout into the machine's own registers, so the firmware runs the schedule itself. It also refuses, categorically, to write a sleep timeout of zero - because that register is the machine's thermal safety net, not a user preference.

Summary

  • Problem: The Bengle firmware owns four registers for autonomous scheduling - InactivitySleepTimeout, SetLocalTimeOfWeek, ScheduleEntry and ScheduleControl (register-table rows 54 to 57). The app wrote none of them, so the machine's own scheduler never ran. The app-side wake schedule was a PresenceController timer that required a live tablet.
  • Why it matters: The whole point of a machine-side scheduler is that it works when the tablet is not there. This is also the only path by which a machine can heat up before you walk into the kitchen.
  • What changed: A new BengleScheduleSync controller watches the settings and the connected machine and pushes the local time-of-week, the wake-window table and the sleep timeout into the machine. It re-pushes on every connect, because the clock and the table are RAM-only in firmware and do not survive a power cycle - the app is the only durable store for them. A new pure module, wake_schedule_windows.dart, derives concrete windows from the app's WakeSchedule list in the firmware's own semantics, and sleep_timeout_safety.dart decides what may be written into the sleep-timeout register.
  • What did NOT change (scope boundary): No REST endpoint, no WebSocket topic, no settings-UI change. This PR reads settings that already exist and pushes them to a machine that already had the registers. It is Bengle-gated: a plain DE1 connecting causes not one byte to go on the wire, and PresenceController's app-side wake and sleep behaviour is untouched for it.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening (the sleep-timeout floor; see below)
  • Chore / infra
  • Plugin (DYE2 or bundled skin)

Scope (select all touched areas)

  • BLE transport / device comms
  • REST API / handlers
  • WebSocket API
  • Machine state / shot logic (machine sleep and wake)
  • Scale / weight / flow
  • Profiles / beans / grinders / workflows
  • WebUI skins
  • Plugins / JS runtime
  • UI / Flutter widgets
  • Storage / Drift database
  • CI / build / infra
  • Docs / specs

Linked Issues

  • Closes #
  • Related # (Bengle stack B-1 through B-10)

Root Cause (if bug fix)

N/A - this is a new capability, not a bug fix. The nearest thing to a root cause is that the firmware's scheduling registers were published and never consumed.

Regression Test Plan (if bug fix or refactor)

N/A as a regression plan - but the new code is covered, and here is what by:

  • test/controllers/bengle_schedule_sync_test.dart (741 lines): the push on connect; the re-push after a power cycle (detected by the firmware clock reading back as 0, the "rebooted, never synced" sentinel); the sleep-timeout floor, including the 0, the negative, and the master-toggle-off cases; and a machine swapped mid-write, where the drain re-targets the new machine rather than finishing the write against the old one and pushing nothing to the machine that is actually there.
  • test/models/device/bengle_wake_schedule_test.dart (176 lines): the register writes themselves.
  • test/models/wake_schedule_windows_test.dart (41 cases): window derivation, midnight-crossing splits, the 32-entry cap and merge, and two DST spring-forward regression cases.
  • test/settings/sleep_timeout_safety_test.dart (79 lines).
  • test/unit/models/device/impl/bengle/mmr_contract_test.dart: rows 54 to 57 registered with the firmware contract checker.

Merge-order note - please read this before merging

This branch ships two modules that are also introduced by two of the independent PRs going to main. Each side adds the file from nothing, because each has to stand alone:

File (+ its test) Also introduced by Resolution when the second one merges
lib/src/models/wake_schedule_windows.dart fix/presence-keep-awake-window (I-E) Take either - the copies are byte-identical.
lib/src/settings/sleep_timeout_safety.dart fix/settings-bound-sleep-timeout (I-F) Hand-written UNION. Do not pick a side.

Git does not de-duplicate these for you. Measured with git merge-tree --write-tree against both branches, all four paths come back as CONFLICT (add/add). Whichever PR merges second will land on them.

wake_schedule_windows.dart + its test - take either, they are byte-identical

This branch's copy of both the lib file and its test is byte-identical to I-E's (verified: md5sum matches on both files against fix/presence-keep-awake-window). So the add/add conflict is trivial - take either side. There is nothing to choose between them.

This was not always true, and the history is worth one sentence so it stays fixed. An earlier cut of this branch carried an older windowEnd() that computed a window's end by adding an absolute Duration, which on a spring-forward day lands an hour off the wall clock (a 05:30-07:00 window reports 08:00), and it was missing the two DST regression tests that pin the corrected form. On this branch alone that was dormant - nothing here calls windowEnd(), since bengle_schedule_sync.dart uses only expandWindows, packWakeWindow and localSecondsOfWeek. But it would not have been dormant in the merged tree: it would have landed straight in the keepAwakeUntil that I-E ships. This branch's copy has since been rebuilt from I-E's, so both the corrected windowEnd() and its two DST tests are now present here (the test count is 41, up from 39). If a future edit makes the two copies drift apart again, stop and re-sync them - they are meant to be identical.

Verified, not asserted: the two files hash-match across the branches, this branch is flutter analyze-clean, and the full suite passes at 2295 (the two DST cases are the +2 over the previous 2293).

sleep_timeout_safety.dart + its test - union, and mind the duplicate

The two copies are complementary halves of one file, split by symbol. Taking either side alone drops symbols the other PR's code imports, and the build breaks.

Symbol Declared by
kSafetySleepFloorMinutes, kMinMachineSleepTimeoutMinutes, machineSleepTimeoutMinutes this branch (what may be written into the machine's safety register)
kMinSleepTimeoutSetting, kMaxSleepTimeoutSetting, isValidSleepTimeoutSetting, sanitizeSleepTimeoutSetting I-F (bounds on the stored preference)
kMaxSleepTimeoutMinutes both - and that is the trap

Both copies declare const int kMaxSleepTimeoutMinutes = 240; (same value, different doc comment), and both carry their own library; directive with its own library doc block. A naive concatenation of the two sides will not compile - duplicate top-level declaration, duplicate library;. Resolve it like this:

  1. Take this branch's copy in full (its library doc block, kSafetySleepFloorMinutes, kMinMachineSleepTimeoutMinutes, kMaxSleepTimeoutMinutes, machineSleepTimeoutMinutes).
  2. Append only these four symbols from I-F's copy: kMinSleepTimeoutSetting, kMaxSleepTimeoutSetting, isValidSleepTimeoutSetting, sanitizeSleepTimeoutSetting.
  3. Drop I-F's library; directive, its library doc block and its kMaxSleepTimeoutMinutes declaration - all three are duplicates.

The result has exactly one library; and one declaration of each of the eight symbols, and it is the file the two halves were split out of.

The test file resolves the same way, one level down. Both copies are a void main() with non-overlapping group()s - this branch contributes machineSleepTimeoutMinutes: never returns 0; I-F contributes sanitizeSleepTimeoutSetting: bounds untrusted REST/import input and SettingsController.setSleepTimeoutMinutes bounds what it stores. Keep one void main() containing all three groups, and take I-F's import block - it is a superset of this branch's.

Verified, not asserted: this exact union was built and merged (this branch's lib copy in full + I-F's four preference symbols; one void main() with all three test groups). The resulting tree is flutter analyze-clean and the full suite passes (2300/2300), with both PRs' consumers compiling against it.

If the independent PRs are not merged, this branch is self-contained and none of the above applies.

Documentation Obligations (required)

  • API spec updated: assets/api/rest_v1.yml / websocket_v1.yml
  • API docs updated: doc/Api.md
  • Plugin docs updated: doc/Plugins.md
  • Skin docs updated: doc/Skins.md
  • Profile docs updated: doc/Profiles.md
  • Device docs updated: doc/DeviceManagement.md
  • N/A - no docs affected

This branch touches zero files under doc/ and zero API specs, and I want to be explicit about why rather than just ticking N/A. No REST endpoint, WebSocket topic, plugin event, skin behaviour or profile handling changes. The four registers already exist in assets/api/bengle_hw_v1.yml at contract_version: 1; this PR adds app-side registration entries for them, so the contract file is not edited and the version is not bumped.

That said, a reviewer could reasonably argue that "the machine now runs its own wake schedule, and the app clamps the machine's sleep timeout to a 60-minute floor" is a device-behaviour change that belongs in doc/DeviceManagement.md. I did not write it because I was not sure where you would want it. If you want that doc, say so and I will add it.

Security Impact (required)

  • New or changed REST endpoints? No
  • New or changed WebSocket topics? No
  • New or changed network calls? No
  • BLE/USB surface changed? Yes - four Bengle-only MMR registers (rows 54 to 57), all registered with the contract checker. A non-Bengle DE1 gets nothing on the wire.
  • File system access changed? No
  • Plugin sandbox boundary changed? No

The safety-relevant part. InactivitySleepTimeout is the timer that turns a Bengle's heaters off when nobody is there - a dead tablet battery, a crashed app, a blackout. Two firmware facts make writing 0 into it unacceptable rather than merely unwise:

  1. The firmware treats a value less than or equal to zero as "never sleep". The timer simply never runs.
  2. The write sticks. The register is PERM_RWD - disk-backed in firmware NVM and restored at every boot. A 0 written once survives every power cycle until something writes a non-zero value back. And the machine boots hot.

So an app that wrote 0 would leave a machine less safe than one that had never met the app at all, since the firmware's own default is 60 minutes. That is a regression against doing nothing. machineSleepTimeoutMinutes() therefore collapses everything that would disable the net - the master toggle off, a "Disabled" (0) in the dropdown, a rogue negative arriving from REST or an imported settings blob - to a 60-minute floor, which is the firmware's own default, and clamps everything else into the 1-to-240 range the firmware accepts. The function is guaranteed to return a value in 1 to 240, never 0, whatever the inputs.

This costs the user nothing they can perceive. The firmware ignores this timer entirely while a tablet is connected, so it only ever acts once the tablet is already gone - precisely the case it exists for.

User-Visible Changes

A Bengle now honours its wake schedule with the tablet switched off, closed, or in another room. Previously the schedule only worked while the app was running and connected.

The one thing a user could notice as a change rather than an addition: the "Disabled" option for the sleep timeout no longer disables the machine's own sleep. It still disables the app's idle-sleep timer, which is what the setting is for, but the machine will still put itself to sleep after 60 minutes of nobody touching it once the tablet is gone. A user who deliberately wanted an always-hot machine with no tablet attached cannot get that from this app. That is intentional and I would defend it, but it is a real behaviour change and it should not be buried.

Verification

Local gates (run before pushing)

  • flutter analyze - clean (No issues found!)
  • flutter test - 2295 tests pass on this branch at 35f630eb (B-8 was 2213, so this branch adds 82).
  • (cd packages/dye2-plugin && npm run build) - plugin builds

Manual verification (if applicable)

  • OS / platform tested: Linux (analyzer and tests).
  • Simulated devices? (simulate=1): No. MockBengle accepts the schedule writes but has no clock and no scheduler, so a simulated run would only re-confirm what the unit tests already assert.
  • Real hardware? (DE1/Bengle/scale): No for this branch's code. The premise was established on hardware: all three RAM-only registers were read on a bench Bengle and all three came back zero, which is what "nothing was writing them" means in practice.
  • What you personally verified and how: the writes and the sequencing, through the tests. The firmware semantics (the day field is an index and not a bitmask, startMin inclusive and endMin exclusive, startMin >= endMin silently dropped, the 32-entry table, the write-then-echo read behaviour, the boot-time zeroing) come from reading the firmware source at the pinned commit.
  • Edge cases checked: a table with a midnight-crossing window (split app-side, because the firmware drops it otherwise); more than 32 windows (merged and capped app-side, because the firmware silently drops the 33rd); a machine that reboots mid-session (the 15-minute tick sees the clock read back 0 and re-pushes); a machine swapped mid-write; every path by which the sleep timeout could become 0.
  • What you did not verify: no machine has actually woken itself up from a schedule pushed by this code. That is the entire feature and it is unproven end to end. The write protocol, the packing and the register semantics are all inferred from the firmware source and asserted in unit tests, but a Bengle sitting on a bench overnight, waking at a scheduled time with the tablet powered off, is the test that matters and it has not been run. I also could not verify clock drift, because no register exposes the running firmware clock - the four registers are write-driven and a read echoes the last value written, not live device state. The 15-minute clock resync is a mitigation for a drift I cannot measure.

Evidence

  • Test output (2295 pass)
  • Log snippets
  • Screenshot / recording (UI changes)
  • curl / websocat output (API changes)

Compatibility & Migration

  • Backward compatible? Yes for every API surface - nothing public changed. See "User-Visible Changes" for the one behavioural change (the sleep-timeout floor).
  • Config / env changes needed? No
  • Database migration needed? No

A Bengle whose InactivitySleepTimeout was left at some other value by a previous app or by hand will be overwritten on the next connect, to the user's setting, or to 60 if their setting would disable it.

Deliberate choices worth your review

  • The 60-minute floor is a safety decision that overrides a user setting. I want it looked at squarely rather than skimmed. The argument for it is in the Security Impact section above; the short version is that a thermal safety net must not be a UI preference, and 60 is not a number I chose - it is the firmware's own default, so the app can never make a machine less safe than a factory-fresh one. The argument against it is that a user asked for something and did not get it. I think the asymmetry settles it: the failure mode of the floor is a machine that goes to sleep when someone wanted it hot; the failure mode of honouring the 0 is a machine that stays hot forever, permanently, because the write persists.
  • The schedule table is re-pushed only when it actually changed, or when the machine demonstrably lost it. A re-push clears the table first (ScheduleControl 0, then the entries, then ScheduleControl 1), and clearing it drops the firmware's "was in an awake window" edge - so a gratuitous re-push in the middle of a window would re-wake a machine the user had just manually put to sleep. Hence the diff, and hence the 15-minute clock tick deliberately never touches the table.
  • A wake-only schedule (no keepAwakeFor) becomes a 30-minute window. The firmware cannot express "wake, then hand straight back to the idle timer" - it rejects a window whose start is not before its end, so a window must have real length. 30 minutes matches the app's default sleep timeout. A one-minute window is semantically purer, but it is a fragile target against any firmware-clock error, and a missed window means the machine never wakes at all. This is a guess at what the user meant, and it is the kind of guess worth a second opinion.
  • Two register bounds are declared narrower than the contract file allows, on purpose. SetLocalTimeOfWeek is capped at 604799, not 604800, because the firmware rejects a value equal to the seconds-in-a-week and would silently leave the clock invalid. ScheduleControl is capped at 1, not 255, because the firmware reads only bit 0 on a non-zero write - so a stray 2 would disable the schedule without clearing it. Declaring max: 1 makes that structurally impossible, since writeMmrInt clamps to the declared range.
  • The controller is constructed in main.dart and never referenced again (there is an // ignore: unused_local_variable). It self-wires by subscribing to the controller and the settings. That is the pattern the other bridges use, but the lint suppression is ugly and I would take a suggestion.

Risks & Mitigations

  • Risk: A future edit lets this branch's wake_schedule_windows.dart drift from I-E's again, reintroducing the DST bug through a bad conflict resolution.
    • Mitigation: Already handled at the source - this branch's copy of the lib file and its test have been rebuilt from I-E's and are now byte-identical (hash-verified), so the add/add conflict is trivial and the two DST regression tests are present on both sides. The remaining risk is only that someone later edits one copy and not the other; the merge-order note says to stop and re-sync if the two ever differ.
  • Risk: The firmware clock drifts, or a DST transition passes, and a wake window fires an hour late.
    • Mitigation: The clock is re-written every 15 minutes while a Bengle is connected. This bounds DST error and crystal drift, and sits well inside the smallest useful window. It cannot help a machine that is disconnected from the tablet for a long time - which is exactly the case this feature exists for. The size of that drift is unmeasured, because no register exposes the running clock.
  • Risk: A push fails mid-sequence and the machine is left with a cleared, disabled table.
    • Mitigation: Every write path is wrapped; a failure logs and retries with capped backoff (3s, 10s, 30s, then 30s repeating), and the desired state is only stamped as pushed after the write lands, so a failure during the connect flow cannot crash it and self-heals on the next trigger at the latest. The machine falls back to its InactivitySleepTimeout, which this PR guarantees is never 0.

ChampionDesigns and others added 25 commits July 15, 2026 23:16
BLE discovery picks the machine class from the advertised name before a
connection exists, but the authoritative Bengle identity is the v13Model
MMR (0x0080000C, model >= 128 => Bengle), readable only after connect.
A Bengle advertising a DE1-style name therefore landed as a plain
UnifiedDe1 with every Bengle feature dark, and a DE1 mis-advertising
"Bengle" would be driven with the wrong protocol.

- UnifiedDe1 gains an `isBengle` flag set from the (already-read)
  v13Model in onConnect, plus the three seams re-resolution needs:
  `dataTransport` (rebuild over the same live transport),
  `adoptIdentityFrom` (carry connect-time identity so the re-resolved
  instance's onConnect short-circuits the MMR re-reads instead of
  hanging on an empty response queue), and `detachTransport` /
  `UnifiedDe1Transport.detach()` (release the discarded interim's
  wrapper WITHOUT disposing the shared transport the replacement owns —
  else a lingering serial readStream listener double-parses every line).
- New pure resolver `resolveMachineForModel` (de1_resolver.dart):
  same instance when name-picked class matches the model; otherwise a
  fresh Bengle/UnifiedDe1 over the same transport. Mirrors the serial
  path, which already class-dispatches on v13Model >= 128.
- De1Controller.connectToDe1 calls it after onConnect, finishes
  connecting the resolved machine, and tears the interim down. The
  idempotency guard now keys on deviceId, not object identity (post-swap
  _de1 is a different object for the same physical machine). A demoted
  Bengle interim additionally has EVERY capability its onConnect
  initialised disposed (integrated scale + LED strip today) — its
  Bengle.onDisconnect never runs, so anything less leaks the capability
  subjects. This disposal is deliberately exhaustive; the reference
  implementation missed one capability and the controller-level test
  now locks the full set.

DE1 behavior is unchanged: model 1..7 leaves isBengle false and the
resolver returns the same instance untouched.

Tests: bengle_detection_test (flag semantics, boundary 128, name-vs-
model authority), de1_resolver_test (promote/demote/no-swap/identity
carry/detach safety), de1_controller_resolve_test (controller-level
promote + demotion disposal + deviceId guard; disposal test fails when
any capability dispose is removed).
Doc gate: doc/DeviceManagement.md "Bengle: name is a hint, v13Model is
authoritative" section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The public @Protected writeMmrScaled (the path every Bengle capability
scaled write rides) integerized with toInt(), which truncates: IEEE-754
makes 2.3 * 100 == 229.999…, so a 2.30 g stop-at-weight target landed
on the wire as 229 — a whole centigram low. de1plus rounds this write
class, so round() restores byte parity.

The base-DE1 private _writeMMRScaled (flush/hot-water/steam/heater/cal
flow setters) deliberately KEEPS toInt(): de1plus truncates exactly
those (e.g. set_flush_flow_rate `int(10*rate)`), and rounding them
would change bytes on shipped DE1 hardware. Both behaviors are now
test-pinned so neither can be "unified" away — setSteamFlow(2.3) must
land 229 while a capability write of 2.3 at x100 must land 230.

Also fixes the latent MMRItem.steamStartSecs declaration: it carried
the default 1.0 scales while firmware MMR.def has mult = 100 (seconds
x100 on the wire). Nothing reads or writes it today, so no byte-level
behavior changes, but the first wired setter would have written 100x
low; the bengle_hw_v1.yml contract checker (added in this PR) fails on
exactly this class of drift, and this declaration is what makes it run
green.

Tests: protected_surface_test — "writeMmrScaled rounds, not truncates"
(230) and "_writeMMRScaled truncates like de1plus" (229), locking both
directions of the split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The post-connect large-ATT-MTU request was Android-only. The Bengle's
0xA013 shot-sample notification is 28 bytes — above the 23-byte ATT
default payload — so on iOS/macOS/Windows the stream would truncate
unless the OS happened to negotiate a larger MTU on its own. Request
517 on every platform except Linux:

- Linux stays skipped: BlueZ manages the MTU itself and universal_ble
  does not expose requestMtu there.
- The 200 ms post-connect settle stays Android-scoped (it works around
  an Android service-discovery race on tablet SoCs; other platforms
  don't need the delay).
- Failure remains non-fatal (log-and-continue): the DE1/Bengle BLE
  module self-negotiates up to 247 on connect regardless, so the client
  request is belt-and-suspenders — a rejection must never abort the
  connect.

Benign for a plain DE1: a larger MTU only reduces GATT round-trips.

Adds a `@visibleForTesting isLinuxOverride` seam (dart:io Platform is
not fakeable in unit tests) so the platform gate is testable.

Tests: universal_ble_transport_mtu_test — 517 requested on non-Linux,
Linux skipped, failed negotiation non-fatal (fake UniversalBlePlatform,
same shim pattern as universal_ble_transport_recovery_test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Bengle MMR register layout is hand-declared twice — the firmware
MMR.def X-macro table (C, compiled into the chip) and the app's Dart
enums. Two hand-maintained copies in two languages drift silently, and
a silent drift means the app writes the wrong register. This is not
hypothetical: steamStartSecs shipped with default 1.0 scales against a
firmware mult of 100 (fixed in the previous commit), and nothing could
have caught it.

- assets/api/bengle_hw_v1.yml: machine-readable contract, one row per
  MMR register (address/length/perms/mult/kind/range/semantics), plus
  the 0xA013 BengleShotSample packet layout and the ASCII serial-verb
  contract as human sections. Distilled from firmware MMR.def at
  ben/tablet-packet-wiring 0381e7ab58eb5b5ee36c14b0bef123ea3cfe4f2e
  (build-90 — the hardware-validated pin); contract_version 1.
  Normalization rules (raw-wire-unit bounds, the inert v13Model
  mult=1000 column, ENTRY-perms authority) are binding and documented
  in the header.
- test/unit/models/device/impl/bengle/mmr_contract_test.dart: a Dart
  test riding the normal `flutter test` CI job. Asserts every
  app-declared register against the contract: address/length/scale
  exactly, range as app-subset-of-contract; perms not asserted in v1
  (the app enums carry none). On this branch it registers the 30
  shared-DE1 MMRItem rows; each later Bengle capability branch appends
  its own enum's rows per the extension protocol in the file header.
- doc/bengle/HW-CONTRACT.md: the coordination protocol — change flow
  (MMR.def change -> regenerate contract -> bump contract_version ->
  update enums -> checker enforces; both PRs cite the version), the
  back-pointer text for firmware MMR.def, the proposed
  contract/feature-version MMR gate, known firmware-side TODOs the app
  degrades gracefully around, and the current drift snapshot.

The contract home is reaprime (beside rest_v1.yml/websocket_v1.yml)
because the consumer and the CI live here; the layout authority stays
firmware MMR.def — the chip decides.

Tests: mmr_contract_test (35 checks green: parse + version pin +
30 register rows + informational coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app has accepted and persisted the `bengle` simulated-device type
since MockBengle landed (SimulatedDevicesTypes { machine, scale,
sensor, bengle }; POST /api/v1/settings validates entries through that
enum), but both simulatedDevices schemas in rest_v1.yml still listed
only [machine, scale, sensor] — a client following the spec could not
discover the value, and an agent following the spec would flag a valid
request as invalid. The spec is authoritative; this brings it back in
line with the shipped handler.

The device `type` enum at the top of the file is deliberately
untouched: a simulated Bengle presents as type `machine` in device
listings.

Tests: none (spec-only correction; the accepting handler behavior is
pre-existing and already exercised by settings handler tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CONTRIBUTING requires formatting your own changes (the CI format step is
advisory only because the pre-existing codebase predates the Dart 3.7+
tall style). Of the seven format-dirty files this branch touches, the six
pre-existing ones were already dirty at upstream/main — reformatting them
here would be exactly the untouched-file churn CONTRIBUTING forbids — but
this test is net-new on the branch, so it alone owes a clean format.
Whitespace-only; no assertion or behavior changes (file re-run green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndroid probe

The Android USB pre-filter dropped any port whose productName wasn't
'DE1', 'Half Decent Scale', or something containing 'Serial' — before
the class shortcuts or the v13Model probe ever ran. That made the
existing Bengle shortcut dead code, and a real Bengle undetectable over
USB on Android: current firmware enumerates with the pico-sdk DEFAULT
descriptors (VID:PID 0x2E8A:0x000A, product string "TinyUSB Device" —
captured from hardware 2026-07-10), which pass neither check.

Fix, in two additive halves ORed at the gate:
- `serialProbeAllowsProductName` (utils.dart): the old name semantics
  plus 'Bengle' and null names (Android often reports null before
  permission is granted). Exact, case-sensitive matches on purpose —
  the descriptor strings are fixed, and loosening them widens the
  3-second probe's reach onto unrelated devices.
- `bengleProbeCandidateIds` (usb_ids.dart): 0x2E8A:0x000A qualifies a
  port for the identification PROBE only. `bengleUsbIds` stays EMPTY —
  the pair is every default pico-sdk CDC device, so direct
  instantiation would claim random hobby boards as espresso machines;
  the v13Model read stays the authority. (0x2E8A:0x000C is the Pi
  debug probe and must not match.)

The gate is extracted as a @VisibleForTesting static
(`shouldProbeUsbDevice`) so the OR-combination — the actual fix — is
unit-tested, not just the predicates. Every previously admitted name
still passes; plain-DE1 behavior is unchanged. Auto-permission for the
Bengle VID:PID was already upstream in device_filter.xml (verified,
not re-added).

Tests: serial_probe_name_gate_test (name-gate + probe-candidate
predicates + OR call-site groups, 13 tests).
Doc gate: doc/DeviceManagement.md — Android name-gate paragraph +
VID:PID probe-candidate wording in the serial detection list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three USB-serial correctness fixes in the shared transport. All are
serial-only code paths (`transportType == TransportType.serial`); the
BLE path is byte-for-byte unchanged.

- FIX-17.2 — length-exact <F> frames. The firmware serial parser
  consumes exactly getLengthForCID('F') = sizeof(T_WriteToMMR) = 20
  bytes per <F> frame; BLE tolerates a short final DFU chunk, serial
  drops the whole frame and desyncs to the next '<'. Zero-pad short
  writeToMMR frames (the DFU uploader's final image chunk is the only
  short-frame producer). The Len byte carries the true payload length,
  so the padding is inert. Other endpoints are never padded — their
  structs are shorter by design.

- FIX-17.4 — serial reads. The ASCII serial view has no read verb.
  Reads now come in three shapes: continuously-subscribed endpoints
  serve the latest received frame; versions/temperatures/calibration
  are one-shot <+X> → [X] → <-X> round trips over plain broadcast
  controllers (NOT BehaviorSubjects — a read must resolve with the
  fresh frame its own <+X> provoked, never a cached one), bounded by a
  2 s timeout; endpoints the firmware can never emit throw a
  descriptive UnsupportedError instead of UnimplementedError, so the
  raw WS API surfaces a clean error instead of crashing the read. The
  listener is armed BEFORE the <+X> write, the armed future is
  .ignore()d so a throwing request write can't leak an unhandled async
  timeout, and the <-X> is sent in a finally so a failed read never
  leaves a subscription eating downlink budget.

- FIX-17.5 — keepalive. BLE and USB share one serial view in the
  firmware, arbitrated by a last-writer-wins Source flag: any stray
  BLE-module byte silently steals the notify stream from a passively-
  listening USB client. A 5 s <+N> keepalive actively re-asserts the
  USB source, and — because the firmware treats add-notify as a
  force-update — doubles as a resync for the checksum-less framing.
  Fire-and-forget with catchError: a failing write means the port is
  dying, which the read-side onError/onDone already handles.
  Cancelled on disconnect(), dispose(), and detach().

serialKeepaliveInterval/serialSingleReadTimeout are injectable ctor
test seams (fakeAsync stalls on the root-zone _nullFuture that
broadcast-subscription cancels return, so the timer tests run on real
shortened time). Composes with upstream's no-op-reconnect teardown
(075efbb): that path is BLE-gated and untouched.

Tests: FakeSerialTransport helper (inbound-capable),
serial_parity_test — pad/round-trip/timeout/UnsupportedError/keepalive
groups plus parser edge cases (chunk-split reassembly, leading junk,
4096-overflow dump + resync), the unhandled-async-timeout guard, and
the requestedState-aliases-stateInfo pin.
Doc gate: doc/DeviceManagement.md "USB/serial transport behaviour
(DE1 family)" block (reads / length-exact frames / throughput / link
arbitration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
USB/serial discovery runs fine with the Bluetooth adapter off (the
device scan runs every discovery service in parallel and records
per-service failures), but TWO separate gates in the scan flow buried
the results behind a full-screen Bluetooth error, so a wired-only
setup could never reach its machine picker (bench-reproduced — fixing
only one gate leaves the picker hidden behind "Connection error:
Bluetooth is turned off."):

- the guardian's adapter-error view took precedence over everything;
- the connection manager's STICKY adapterOff ConnectionError claimed
  the idle-phase error view.

Both are now demoted by `busyWithoutBle` — anything in flight that
works without Bluetooth: an active machine/scale connect, a pending
picker, found machines, or machines streaming in via
DeviceController.deviceStream (`_discoveredMachines`, which fills
before the ConnectionManager publishes foundMachines — using only the
latter re-opens a window where the error flashes over live discovery).
Only error kind `adapterOff` is demoted: a genuine
machineConnectFailed while machines are listed still shows the error
view. The adapter view also gains a line telling the user USB keeps
working. `ready` still navigates away regardless.

The preferred machine stays stored per TRANSPORT id
(`connectMachine` saves `machine.deviceId`; serial ids are the
`usb-<vid>-<pid>-<serial>` stable id, not a BLE MAC) — deliberately
un-aliased, so the first wired session ends at the picker and picking
the USB machine once makes later launches auto-connect over the wire.

Tests: scan_flow_ble_off_test (guardian demotion, sticky-error
demotion, connect-in-flight, error copy);
connection_manager_wired_preferred_test locks the per-transport-id
preference flow (first wired session → picker; pick → usb stable id
stored; next launch → auto-connect, no picker).
Doc gate: doc/DeviceManagement.md "Bluetooth-off operation" paragraph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bengleUsbIds is deliberately empty — 0x2E8A:0x000A is every default
pico-sdk CDC device, so putting it in the direct-instantiation table
would claim random hobby boards as espresso machines. The pair may only
qualify a port for the v13Model probe (bengleProbeCandidateIds). That
emptiness was documented but untested: someone "completing" the table
later would silently change detection semantics with every existing
test staying green. Pin it, and pin that the default usbDeviceTable
never matches the pair.

Tests: usb_ids_test — bengleUsbIds-stays-empty + no-direct-match cases.
Doc gate: none (test-only; behavior already documented in
doc/DeviceManagement.md and usb_ids.dart).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a Bengle (v13Model >= 128) the firmware streams a 28-byte BIG-endian
high-resolution shot sample on an additive characteristic 0xA013 (serial
char 'S') alongside the stock 19-byte 0xA00D sample, both at 15 Hz. It is
a reorganised superset — field order, widths and scaling all differ (e.g.
Weight at offset 20 is U16P5, /32 NOT /100) — so it gets its own pure
decoder rather than reusing the 0xA00D fixed-point parser. The layout is
byte-locked against the contract file (assets/api/bengle_hw_v1.yml,
packet_0xA013) and the de1plus reference decoder.

Why sole source: the frame carries integrated-scale weight (already net
of tare — firmware subtracts LastTARE), gravimetric flow (GFlow) and milk
temp that 0xA00D lacks; consuming both streams would double-sample every
chart. UnifiedDe1 therefore builds two lazy snapshot pipelines and picks
at ACCESS time (currentSnapshot => _isBengle ? _bengleSnapshot :
_de1Snapshot) — picking in a field initialiser would latch the wrong
pipeline for listeners attaching before onConnect completes, and on a
plain DE1 the Bengle pipeline is never built so the 0xA013 subject is
never touched.

Transport asymmetry (deliberate):
- BLE: the CCCD subscribe is gated on the CONFIRMED identity and fired
  from onConnect (first-connect detection block AND the reconnect path —
  reconnect short-circuits before the detection block). Blind-enabling a
  characteristic a plain DE1 lacks throws and permanently stalls the BLE
  command queue (de1plus de1_comms.tcl:777-785). 0xA00D deliberately
  STAYS subscribed on BLE (headroom exists; parse-and-dropped, keeps the
  raw-WS [M] visibility).
- Serial: <+S> is unconditional at connect (no CCCD stall hazard; a DE1
  never emits [S]) because identity isn't known yet and [M] is how the
  serial probe recognises a DE1-family device. Once the identity IS
  confirmed, subscribeBengleShotSample sends <-M> instead (FIX-17.5):
  the firmware serial downlink tops out at ~1920 B/s (16 bytes per
  120 Hz tick, half-duplex) and dual 15 Hz [M]+[S] streams overrun it —
  hw-confirmed 2026-07-09 as truncated/odd-length frames and weight
  flicker.

Truncated (<28 byte) frames are dropped at BOTH layers — the transport
guard protects rxdart internals from a RangeError (seen as fatal on the
0xA00D analogue), the decoder's null return keeps the pure function
total (FIX-11 tail; MTU 517 request landed with the foundation branch).

MachineSnapshot gains additive weight/weightFlow/milkTemperature fields
(default 0.0, fromJson tolerates absent keys so pre-FIX payloads still
decode); steamTemperature stays an int — the fractional 0xA013 value is
round()ed to match the whole-degree 0xA00D field.

Tests: bengle_shot_sample_test (golden frame byte-exact, /32 weight
divergence, big-endian, <28 drop, trailing-bytes, non-zero MilkTemp at
offset 25), bengle_shotsample_pipeline_test (sole-source with 0xA00D
parse-and-dropped, full snapshot field mapping incl. steamTemp rounding,
truncated-frame drop, plain-DE1 must-NOT-subscribe negative),
bengle_shotsample_serial_test (<+S> at connect, [S] routing, truncated
[S] drop, <-S> at disconnect), serial_parity_test FIX-17.5 group (<+M>
still at connect, <-M> from subscribeBengleShotSample),
machine_snapshot_test (fromJson defaults/round-trip/copyWith).
Doc gate: rest_v1.yml + websocket_v1.yml MachineSnapshot schemas gain the
three fields; doc/Api.md /ws/v1/machine/snapshot row updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The integrated scale is NOT a separate BLE characteristic: hardware
bring-up proved weight rides the 0xA013 BengleShotSample stream, already
net of tare in firmware (it subtracts LastTARE before serialising — the
same expression its own stop-at-weight logic uses). So:

- IntegratedScaleCapability.initIntegratedScale now listens to the
  transport's guarded bengleShotSample stream and re-emits each valid
  frame as a ScaleSnapshot (batteryLevel 100 — mains-powered sentinel
  that keeps the field non-nullable across the seven scale impls).
  GFlow and milk temp deliberately do NOT ride ScaleSnapshot (no flow
  field; adding one ripples through every scale impl) — they travel on
  MachineSnapshot.weightFlow/milkTemperature from FIX-03. The Flags byte
  is ignored: bit0 is a LastTARE value proxy at best (older firmware
  hardcodes 0), so tare is confirmed by watching the weight.
- The BengleScaleEndpoint null-UUID enum (weight/control) is DROPPED
  along with its placeholder parser/encoder and its two pinning tests:
  it modelled the separate-characteristic design FIX-04 disproved, and
  keeping dead scaffolding upstream invites someone to wire it. A
  comment preserves the "weight rides 0xA013" finding.
- tareIntegratedScale becomes a plain logged no-op (and is test-locked
  to stay OFF the wire): the real ScaleTare MMR write-trigger belongs to
  the stop-at-weight/tare branch (FIX-06). Bridged weights stay correct
  meanwhile because the firmware nets out its own tare state.
- ConnectionManager's post-scan machine policy now runs the scale phase
  against _disconnectSupervisor.latestMachine instead of the stale
  name-picked instance: connectToDe1 may re-resolve the machine class
  from v13Model (FIX-02), and only the re-resolved Bengle instance
  attaches the BengleVirtualScale. The two sibling call sites already
  did this; this aligns the third.

Tests: integrated_scale_capability_test — FIX-04 bridge (golden frame ->
36.5 g, battery sentinel), dispose closes subject, tare no-op stays off
the wire, reconnect lifecycle leak-free; the two BengleScaleEndpoint
null-wire pinning tests are removed with the enum. The demotion-path
capability disposal is already locked controller-level by
de1_controller_resolve_test (foundation branch).
Doc gate: no REST/WS surface change — /api/v1/scale/* and
/ws/v1/scale/snapshot serve the virtual scale unchanged (design D5), and
the MachineSnapshot schema deltas shipped with FIX-03.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 0xA013 branch changed serial connection behaviour — <+S> is now part
of the continuous-subscription set and subscribeBengleShotSample sends
<-M> once the Bengle identity is confirmed — but the matching
doc/DeviceManagement.md delta did not ride the code commit (the serial
branch deliberately shipped its transport section with no 0xA013
references, leaving these two sentences to this branch). Completing the
doc gate here: the Reads bullet lists the 0xA013 frame among the
continuously-subscribed set, and the Throughput bullet documents the
FIX-17.5 policy (serial-only <-M>; BLE keeps 0xA00D subscribed,
parse-and-dropped) with the hw-confirmed overrun rationale.

Doc-only commit; noted as a doc-gate split from e4b314cb in the PR
draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…timate it

The Bengle computes gravimetric flow on-device, on the load cell it owns, and
ships it in every 15 Hz 0xA013 frame as GFlow. That value already reaches
MachineSnapshot.weightFlow. It did not reach the *scale* surface: ScaleSnapshot
had no flow field, so ScaleController ran its flow estimator over the Bengle's
weight and derived a second, competing flow number -- re-deriving a quantity the
firmware had already computed, from the very signal it computed it from.

The app's estimate is strictly worse than the firmware's. Measured against a
15 Hz pour whose weight climbs at exactly 2.00 g/s, with the firmware reporting
GFlow = 2.00 from the first frame:

  sample (@15 Hz)  |  firmware GFlow  |  app estimate
  1  (~67 ms)      |      2.0000      |     0.0082
  5  (~333 ms)     |      2.0000      |     0.7913
  15 (1.0 s)       |      2.0000      |     1.9382
  59 (3.9 s)       |      2.0000      |     2.0007

The estimator reads ~0 g/s at shot onset and needs about a second to converge on
a number the firmware has correct immediately. The shot path consumes the
estimate, not the firmware's: step-weight exits project on it, the
stopping-yield refinement uses it for cup-removal and settle detection, and it
is what ws/v1/scale/snapshot and the shot record report -- so the two snapshot
surfaces could disagree by 2 g/s at the moment a shot starts.

Add an optional ScaleSnapshot.flow, populate it from GFlow in the 0xA013 bridge,
and have ScaleController pass a device-provided flow through untouched, bypassing
the estimator entirely. Sourcing both surfaces from the same frame is what keeps
them from disagreeing.

Scope: additive and opt-in. flow defaults to null, so every BLE scale keeps the
estimator it has always had -- a scale that reports weight only has no flow of
its own, which is exactly what the estimator is for. The post-tare
flow-suppression window is still honoured on the device-flow path, so the
specced no-spike-after-tare guarantee holds.

The tests assert the pass-through with the Kalman flag ON as well as OFF, and
assert that toggling the flag does not change what a Bengle reports. That is a
regression lock: the estimator choice must stay inert on a device that answers
the question in hardware, whichever estimator becomes the default.
The SAW surface (BengleInterface methods, mixin cache/stream, MockBengle,
the ShotSequencer final-yield bypass, BengleSawBridge, the shotState
machineHasAutonomousSAW flag, and the 'stopAtWeight' capability string)
is already upstream — but the register slot was stubbed (0x00000000,
guessed x10 deci-grams, 500 g clamp), so setStopAtWeightTarget never
reached the wire and the FW never learned the target.

Fill in the firmware truth: EndOfShotWeight (0x00803864, RWD), x100 —
centigrams on the wire, 0 = disable, max 10000 g. The write rides the
shared writeMmrScaled helper, which ROUNDS the scaled value (2.3 g ->
230, not 229 — IEEE-754 2.3*100 == 229.999…), matching de1plus
int(round(weight*100)). The firmware never clamps its Bengle registers
(process_W divides by mult only), so the client-side 0..10000 g clamp
plus the raw max on the enum are the sole guard. getStopAtWeightTarget
now reads the register back (raw x 0.01) and hydrates the stream cache;
production keeps write-precedence (BengleSawBridge's connect-time
re-apply stays the source of truth). BengleScaleMmr.stopAtWeightTarget
is registered in the MMR contract checker per its extension protocol.

Tests: bengle_saw_test rewritten from the stub-pinning group to
byte-exact wire assertions (address/scale/rounding/clamp/disable/
read-back/stream); MockBengle clamp aligned to 10000 g; new handler
test locks 'stopAtWeight' in /machine/capabilities (Bengle yes, plain
DE1 no); new state-manager tests lock machineHasAutonomousSAW == true
on every Bengle shotState frame incl. the idle re-seed (and == false
on a plain DE1).
Doc gate: rest_v1.yml capabilities path description lists the four live
identifiers + the stopAtWeight/targetYield semantics (the schema already
carried them); bengle-integrated-scale e2e scenario refreshed to the
autonomous-SAW reality (workflow targetYield -> SAW MMR, app defers the
final stop, stopReason machineEnded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tareIntegratedScale() was a logged no-op awaiting the firmware slot.
Wire it to ScaleTare (0x0080388C, PERM_RWT): a write-trigger whose value
is ignored — we send 1 to match de1plus — that runs an immediate
doLCTare() in firmware. Subsequent 0xA013 Weight arrives already net of
the new zero (firmware serves CurrW - LastTARE), so nothing else in the
weight pipeline changes. The register lives in BengleScaleMmr (owned by
the capability), NOT BengleMmr: the mixin is part of the unified_de1
library, and importing the Bengle-subclass bengle_mmr.dart into it would
invert the import layering (an audited, deliberate divergence from the
original design sketch). Reads of ScaleTare return 0; a tare is
confirmed by watching the weight drop toward 0, never the 0xA013 Flags
bit (a LastTARE value proxy at best; older firmware hardcodes it to 0).

The generic PUT /api/v1/scale/tare surface is deliberately unchanged:
it reaches this trigger through the existing ScaleController ->
BengleVirtualScale.tare() -> tareIntegratedScale() chain, so no new
endpoint and no spec delta are needed. BengleScaleMmr.scaleTare is
registered in the MMR contract checker per its extension protocol.

Tests: integrated_scale_capability_test tare case flipped from the
"stays off the wire" stub pin to the byte-exact FIX-06 frame (exactly
one MMR write: len 4, addr 0x80388C, payload 1 LE).
Doc gate: /api/v1/scale/tare spec + Api.md rows unchanged by design;
bengle-integrated-scale e2e scenario notes the real-hardware tare path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Bengle firmware calibrates its integrated scale with a non-blocking
two-point procedure over MMR (ScaleCalCmd/State/Weight 0x00803880/84/88):
precision-zero the empty platform, then latch the SAME known mass on the
LEFT (cmd 4) and RIGHT (cmd 5) halves; a 2x2 solve recovers both per-cell
sensitivities so summed mass is position-independent, then persists. The
app had no way to drive it, so per-unit weight accuracy (and therefore
stop-at-weight) could not be trusted.

- ScaleCalibrationCapability mixin on UnifiedDe1: bounded polling (500 ms
  interval / 30 s deadline vs de1plus's untimed 1 Hz loop), single-flight
  guard, cancellable via a monotonic run token (firmware abort returns to
  Idle, which is NON-terminal - the token unwinds the poll immediately
  and cmd=0 stops the firmware), dispose-safe across a
  disconnect/reconnect (each poll binds its progress subject locally;
  init never resets the token, so a stale poll still sees the bump).
- Completion keys off the packed word's SubState (done=2/error=3), never
  the Step byte: field single-point firmware numbers Complete=4/Error=5
  (colliding with two-point taring/complete), so Step-keyed logic would
  both miss a real completion and mistake an error for success. SubState
  is set atomically with Step in both firmware generations; zero keeps
  working on field firmware.
- A terminal state word is only believed once it is known to belong to
  THIS run. The firmware latches the previous run's terminal word in
  ScaleCalState until it picks up a new command, so a poll racing the
  trigger reads a stale done/error: measured on silicon, a second cal POST
  returned success in 0.316 s while the fresh zero was still running out to
  15.7 s. Benign for a zero, dangerous for the left/right latches - the
  user could lift the reference mass mid-average. _runCalStep therefore
  snapshots the state word before triggering, and accepts a terminal only
  once the state has been observed to leave terminal, or when the terminal
  word differs bitwise from the snapshot (the fresh-word case, for a run
  that legitimately re-terminals inside one poll interval). A run whose
  state never observably changes fails safe on the deadline rather than
  succeeding instantly on the stale word, and stale words are kept off the
  progress stream so a wizard cannot flash "done" right after the trigger.
- The reference weight is read-back-confirmed (0.1 g = one wire LSB at
  x10) before the latch is triggered - a dropped write would calibrate
  to the wrong mass. Firmware reads back whole grams (truncates before
  scaling), so only whole-gram masses round-trip; documented in the spec
  and the hw contract.
- Firmware cmd 3 (tare) is deliberately excluded - reaprime tares via
  the dedicated ScaleTare register (FIX-06). cmd 2 is the removed
  single-cell auto-detect and must not be resurrected.
- REST: POST /api/v1/machine/scale/calibrate (zero|left|right|abort;
  200-with-success:false for failed runs - outcome is data, transport is
  HTTP; 202 abort; 400 incl. a non-object-body guard; 404 on plain DE1)
  plus the 'scaleCalibration' capability string.
- Demotion teardown in De1Controller now disposes this third capability
  (the previous shape would leak the cal subjects on a demoted interim)
  and the controller-level resolve test locks it.
- BengleCalMmr registered in the bengle_hw_v1.yml contract checker.

Tests: scale_calibration_capability_test (incl. the single-point
SubState-terminal byte anchors 0x04020000/0x05030000, the order-free
left-latch-accepts-ok case, and the stale-terminal race group),
de1handler_scale_calibrate_test (12, incl. no-machine 500), MockBengle cal
group, resolve-test demotion lock, contract-checker rows.
Doc gate: rest_v1.yml (calibrate path + 2 schemas + capabilities
enum/example/descriptions), doc/Api.md rows, new e2e scenario
bengle-scale-calibration.md + refreshed capabilities array in
bengle-integrated-scale.md. No websocket_v1.yml / DeviceManagement.md
delta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The LedStripCapability on main was a stub (BengleLedEndpoint null wires):
setLedStrip/commit/reset cached but never touched the machine. The firmware
wire spec has since shipped — six PERM_RWD registers holding packed
0x00RRGGBB int32 (LE on the wire): palettes FrontLEDAwake 0x00803898 /
RearLEDAwake 0x0080389C / FrontLEDSleep 0x008038A0 / RearLEDSleep 0x008038A4
(FW auto-applies on sleep/wake, and immediately when written while already
in that state) and live colours FrontLEDColor 0x00803890 / RearLEDColor
0x00803894.

- BengleLedMmr replaces the stub. setLedStrip writes the four palette
  registers byte-exactly (16-bit app channels map down by high byte);
  resetLedStrip reads them back (8→16 byte-replication, lossless for 8-bit
  sources). No switch register exists — FW mirrors the switch from the
  front strip, so frontSwitch stays JSON-only (ignored on write, mirrors
  front on read).
- Palette writes already persist (PERM_RWD); there is no NVM-commit
  register. commitLedStrip() re-asserts the cache (kept for API symmetry —
  Streamline's Save calls it, the REST contract promises 202); reset is a
  4-register read-back. rest_v1.yml / doc/Api.md / interface doc comments
  reworded from the old NVM-latch model; commit/reset requestBody is now
  optional (body ignored).
- New previewLedColor/clearLedPreview + POST /api/v1/machine/ledStrip/
  preview and /preview/clear: show a colour now, regardless of awake/sleep,
  without touching the stored palette or the cache; clear restores the
  cached awake pair. Both routes gated `is! BengleInterface → 404`,
  defensive body parsing (non-map → 400, malformed colours → black).
- The cache is hydrated from the machine on connect. GET
  /api/v1/machine/ledStrip serves the in-memory cache, so without hydration
  a fresh connect serves an all-off palette while the firmware is holding
  real stored colours — after an app restart the Lighting page would show
  both awake and asleep as off. initLedStrip() therefore reads the four
  stored palette registers and seeds the cache, so the first GET serves the
  machine's real colours.
  Eager-on-connect rather than lazy-on-first-GET: it puts no latency on the
  Lighting page's first paint (on firmware without the LED registers, a lazy
  read would pay the 4 s x 3 read-timeout ladder there), and connect already
  performs failure-tolerant MMR warm-ups plus six identity reads, so four
  more amortise where reads already happen. It also means clearLedPreview
  restores the machine's real awake palette rather than black after a fresh
  connect.
  Hydration is read-only and failure-tolerant: it reuses the reset path's
  _readLedStrip() (the four palette registers only — the live/preview pair
  0x00803890/94 is never touched, so it cannot disturb a preview or flash
  the strips), and a failed read logs a warning, leaves the cache all-off,
  and never fails the connect. PUTs overwrite the cache exactly as before.
- LED writes use _mmrWriteRaw/_packMMRInt on purpose: raw packed int32,
  app min/max null, FW clamps to 0x00FFFFFF — writeMmrScaled's rounding
  semantics don't apply to colour bits.
- BengleLedMmr registered in the MMR contract checker against
  bengle_hw_v1.yml rows 43-48. The LED block moved wholesale in the FW
  "additive renumber" (d9e1801e) — pre-renumber addresses write the wrong
  registers; the checker is what catches that drift class.

This wiring was hardware-validated on a live Bengle against firmware
build 90 over both BLE and USB serial; the byte-exact capability tests
lock the verified frames.

The Streamline skin needs no change: renderLedSettings() already fetches
via getLedStrip() on first Lighting page entry and paints from the
response, so it shows the stored palette.

Tests: led_strip_capability_test (byte-exact palette/preview frames, 16↔8
mapping, cache semantics, lifecycle, connect-time hydration byte-exact for
all four palettes, a wire-level read-only negative — no write frame to any
LED register, zero traffic of any kind to the live/preview pair, exactly one
read per palette register — and a failed-read fallback via a transport that
rejects LED reads), de1handler_led_strip_test (preview REST incl. 400/404
gating, GET-after-hydration over a real Bengle on the fake transport),
mock_bengle_led_test, mmr_contract_test (+6 LED rows).
FakeBleTransport.queueOnConnectResponses() now queues the four palette
registers (default 0) so every existing Bengle connect test hydrates.
Doc gate: rest_v1.yml preview paths + PERM_RWD rewording; doc/Api.md rows;
bengle-led-strip scenario refreshed (sb-dev style, preview steps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BengleMmr.matSetPoint encoded the setpoint ×10 (deci-°C) but firmware
MatSetPoint (MMR.def row 36) uses mult=1 (whole °C), so every set was
10× too hot and every read 10× too cold. Set read/writeScale=1.0 and
cap max at 80 °C (the real ceiling; firmware does not clamp Bengle
macro-path writes, so reaprime is the sole guard). Matches de1plus
set_cupwarmer_temperature. Also drops the wrong "raw IEEE-754 float32"
comment — the wire value is a scaled int32.

Registers MatSetPoint with the bengle_hw_v1.yml contract checker in the
same commit, so this scaling can never silently regress to either of
the two earlier wrong encodings.

Tests: bengle_cup_warmer_test wire-byte regression (70 °C encodes as
LE 70, not 700; read of raw 50 decodes 50.0; over-range clamps to 80
on the wire); mmr_contract_test MatSetPoint row.
Doc gate: none — REST surface unchanged (rest_v1.yml already documents
the 0–80 °C API range).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Setting MatSetPoint alone does nothing — the warmer's real enable is
the separate CupWarmerMode register (0x008038AC, MMR.def row 50, 0/1).
setCupWarmerTemperature now writes both: target > 0 enables, 0.0
disables. CupWarmerMode is deliberately PERM_RW (not RWD) in firmware
so the machine can never boot with the mat silently heating; it resets
to 0 every boot, so Bengle.onConnect re-asserts the remembered target +
mode on every (re)connect. The re-assert is conditional on a positive
cached target: an app instance that never enabled the warmer writes
nothing, so it cannot stomp state set by another client sharing the
machine.

Replaces the dead BengleMmr.scaleTare stub (0x00000000) with a pointer
comment — the real ScaleTare landed as BengleScaleMmr.scaleTare in the
FIX-06 branch and the leftover stub was unreferenced.

Tests: bengle_cup_warmer_test — dual write, disable-at-0, reconnect
re-assert, disabled-no-repush, plus a negative case locking that a
plain-DE1 connect never touches either register. mmr_contract_test
registers CupWarmerMode.
Doc gate: rest_v1.yml cupWarmer PUT + CupWarmerState describe the
enable semantics and the reboot/re-assert behaviour; doc/Api.md row
updated. API shape unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BengleSteamMmr.stopAtTemperatureTarget was a 0x00000000 stub that cached
locally and log-onced. The firmware slot is real: TargetMilkTemp
(0x008038A8, MMR.def row 49, RWD, ×10 decicelsius, 0 = disable, FW max
850 = 85.0 °C). Wire it: set clamps 0..85 °C then writes the scaled LE
int32; get reads, unscales, and echoes onto the BehaviorSubject so
replay subscribers see post-read truth. The write path is deliberately
NOT gated on probe presence — firmware stops autonomously the moment a
probe is physically attached, so the target must always be current.

NB the asymmetric milk scaling: this target is ×10 on the wire while
the live 0xA013 MilkTemp reading is ÷100; and the clamp is 85, not the
80 the rest of the thermal surface uses. The live reading stays
graceful-degradation (probeAttached false, probeTemperature silent) —
current FW serialises MilkTemp as 0.

SteamSequencer.useFwAutonomousStop drops its stub-address 4th term: a
Bengle with a probe attached and a positive target now defers to the
FW-autonomous stop (double-stopping races the state machine).
MockBengle's clamp aligns 80 → 85 to match the real device, and stale
"awaiting FW" comments across the interface, bridge, workflow, and
main.dart are refreshed — code was ahead of its prose.

Tests: bengle_steam_stop_test rewritten from stub-pin to wire-byte
assertions (register pin, 65 °C → [0x8A,0x02] at 0x008038A8, disable-0,
0..85 clamp, stream echo, read-unscale + read-echo, probeAttached
false); steam_sequencer_test predicate flips to true when all terms
hold; mock clamp test 85; mmr_contract_test registers TargetMilkTemp
(desc 'StopAtTemperatureTarget' is the contract row's app_alias).
Doc gate: rest_v1.yml SteamSettings.stopAtTemperature 0..85 +
real-register wording (was "scaffolding only"); doc/Api.md Steams
preamble updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read the firmware's MatCurrentTemp register (0x008038CC, MMR.def row
58, read-only, ×10 deci-°C, max 1600) and surface it on the cup-warmer
REST resource: GET /api/v1/machine/cupWarmer gains
"currentTemperature": number|null. The read is defensive on both axes —
raw 0 means "no valid reading" (NTC open/short) and older field
firmware lacks the register entirely (read failure) — both map to null
so clients render a placeholder instead of fake data, and they must
also tolerate the key being absent (older app versions). PUT is
unchanged; no new endpoint, no WS topic.

NOTE: this MatCurrentTemp readout is a deliberate, intentional
addition. Earlier Bengle app work described this readout but never
actually consumed the register — no code read MatCurrentTemp. The
register facts come from the pinned contract firmware
(ben/tablet-packet-wiring @ 0381e7ab, build 90).

MockBengle defaults the reading to null (matching field firmware) with
a setMatCurrentTemperature test hook, so the handler tests exercise
both the placeholder and live paths.

Tests: bengle_cup_warmer_test — ×10 unscale (raw 425 → 42.5), raw 0 →
null, transport-failure → null; de1handler_cup_warmer_test — key always
present on Bengle, null default, live value after the hook;
mmr_contract_test registers MatCurrentTemp. _TestBengle fakes gain the
new interface stub.
Doc gate: rest_v1.yml CupWarmerState.currentTemperature (nullable,
readOnly) + GET description; doc/Api.md row; e2e scenario refreshed in
the same commit (stale POST/202 → actual PUT/200, drops --connect-scale
per the Bengle integrated-scale rule, adds the placeholder check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FIX-12 is document-only: no code change to the presence machinery was
needed, all of it is upstream (PresenceController fulfils the
UserPresent obligation when the user enables presence; firmware's 120 s
absence timeout emits substate 0x13, already decoded safely). What was
never locked by a test is the obligation's trigger: UnifiedDe1.onConnect
UNCONDITIONALLY advertises AppFeatureFlags=1 (0x00803858, bit0 =
UserNotPresent feature) on every machine. Removing that call would
silently change the machine-side feature set, so pin it — on a plain
DE1 and on a Bengle.

The audit's capability-derived-flags alternative is recorded for
HW-CONTRACT.md / the Phase-0 issue, not implemented here.

Tests: unified_de1_presence_flags_test (new, 2 cases).
Doc gate: none — no wire or API change; the presence REST surface is
untouched upstream code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
reaprime serialises shot descriptors in the DE1 v1 wire format, but Bengle
firmware is v2-only: it memsets and drops any header whose HeaderV != 2
(FW commit cae67565), and decodes flow/pressure fields as U8D1 (byte x 0.1),
not v1 U8P4 (x 16). So today no profile uploads to a Bengle and no shot
runs; a v1-scaled flow also over-commands by 60% and wraps above 15.9 ml/s.
v1 and v2 frames are byte-width-identical, so HeaderV is the only guard —
app and firmware deploy as a matched pair (HW-CONTRACT.md 5.6).

Gate the encoder on the runtime isBengle flag (v13Model >= 128), branched
inside unified_de1.profile.dart — the _writeHeader/frame encoders are
private static extension methods a subclass can't override:

- HeaderV = 2 on Bengle, 1 on DE1.
- SetVal, TriggerVal, header MinimumPressure/MaximumFlow and the ext-frame
  MaxFlowOrPressure/MaxFoPRange encode as U8D1, round(clamp(v,0,25.5) x 10).
- Raise the max-flow ceiling 8 -> 20 ml/s on Bengle; flow-priority targets
  clamp to it before encoding (reaprime is headless, so the encoder is the
  enforcement point, mirroring de1plus max_flowrate). Replaces the
  hard-coded 12*16 header byte.

The DE1 path stays byte-for-byte identical, mod-256 wrap included.

Tests: byte-exact upload test drives a real UnifiedDe1 over
FakeBleTransport and asserts captured header/frame bytes for both machines
(6 ml/s -> 0x3C, 20 -> 0xC8 without wrap, 30 -> clamp 20, pressure 9 ->
0x5A; DE1 still emits v1, incl. the deliberate 20 ml/s wrap witness);
convert_float_to_U8D1 boundary unit tests (saturate 0/255, round-half-up);
new 500 ms DFU-prelude spacing test (wall-clock lower bound — a rxdart
subject's `first` never completes under fakeAsync, and Dart timers never
fire early, so the bound is deterministic).
Doc gate: doc/Profiles.md gains the device-upload wire-format section;
HW-CONTRACT.md 5.6 gains the profile endpoint/byte-table pointer. No REST
endpoint or WS topic changed — rest_v1.yml/websocket_v1.yml untouched.

flutter analyze clean; flutter test green (2179 tests); dye2 builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Bengle can run its own wake schedule with no tablet connected, but its
clock and wake table are RAM-only: they read zero on a fresh machine (no
clock, no entries, schedule disabled) and they do not survive a power cycle.
Nothing was writing them, so the machine's autonomous scheduler never ran.
The app is the only durable store for this, so the app owns pushing it.

BengleScheduleSync watches the settings and the connected machine and pushes
the local time-of-week, the wake windows and the sleep timeout into the
Bengle's registers (rows 54-57), re-pushing on every connect because a power
cycle clears them. Bengle-gated: a plain DE1 gets nothing on the wire, and
PresenceController's app-side wake/sleep keeps working for it unchanged.

The sleep timeout is a thermal safety net, not a UI preference. The firmware
sleeps the machine after InactivitySleepTimeout minutes of no interaction,
which is the only thing that turns the heaters off when the tablet is gone --
a dead battery, a crashed app, a blackout. Two facts make writing 0
unacceptable: the firmware treats <= 0 as "never sleep" (the timer simply
never runs), and the write STICKS -- the register is disk-backed and restored
at every boot, and the machine boots hot. An app that wrote 0 would leave the
machine less safe than one that never met the app at all, since the firmware's
own default is 60 min. So machineSleepTimeoutMinutes() collapses anything that
would disable the net (the master toggle off, a "Disabled" 0, a rogue negative
from REST or an imported settings blob) to a 60-minute floor -- the firmware's
own default -- and clamps everything else into the 1..240 the firmware accepts.
This costs the user nothing: the firmware ignores the timer entirely while a
tablet is connected, so it only ever acts once the tablet is already gone,
which is precisely the case it exists for.

A machine swapped mid-write is handed off cleanly: the drain now re-targets
the new machine instead of finishing the write against the old one and pushing
nothing to the machine that is actually there.

Carried modules: this branch ships lib/src/models/wake_schedule_windows.dart
and the machine-floor half of lib/src/settings/sleep_timeout_safety.dart
itself, so the stack is self-contained. Both are pure, device-independent
helpers that the DE1-wide presence/settings fixes also introduce; if those land
first, the files are identical and merge away.

Contract: rows 54-57 already exist in the contract file at contract_version 1.
This adds registration entries only -- no contract-file edit, no version bump.

Tests: bengle_schedule_sync_test (push-on-connect, re-push after a power
cycle, the sleep-timeout floor incl. the 0/negative/toggle-off cases, and the
mid-write machine handoff), bengle_wake_schedule_test, wake_schedule_windows
(window derivation incl. midnight-crossing and DST), contract-checker rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant