Skip to content

feat(bengle): fill in the real EndOfShotWeight and ScaleTare registers - #462

Open
ChampionDesigns wants to merge 16 commits into
decentespresso:mainfrom
ChampionDesigns:feat/bengle-stop-weight-tare
Open

feat(bengle): fill in the real EndOfShotWeight and ScaleTare registers#462
ChampionDesigns wants to merge 16 commits into
decentespresso:mainfrom
ChampionDesigns:feat/bengle-stop-weight-tare

Conversation

@ChampionDesigns

@ChampionDesigns ChampionDesigns commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Stacked PR — B-4 of 10. Builds on #461 (feat/bengle-0xa013-scale). Until that merges this PR's diff includes its commits; please review/merge in order B-1 → B-10.

The story

Almost all of the stop-at-weight surface is already upstream: the BengleInterface methods, the
mixin's cache and stream, MockBengle, the ShotSequencer bypass that defers the final stop to the
machine, BengleSawBridge, the machineHasAutonomousSAW flag on the shot state, and the
stopAtWeight capability string. What is missing is the register. BengleScaleMmr.stopAtWeightTarget
was declared with address 0x00000000 and a guessed scale, and the setter checked for that address
and quietly returned. So the app cached your target weight, showed it back to you, told the shot
sequencer the machine would handle the stop, and never told the machine what the target was. Tare had
the same shape: tareIntegratedScale() was a logged no-op waiting for a firmware slot that in fact
already existed. This PR fills in both registers with the firmware's truth and turns two stubs into
two writes.

Summary

  • Problem: stopAtWeightTarget was stubbed at address 0x00000000 with a guessed x10 decigram
    scale and a 500 g clamp, so setStopAtWeightTarget never reached the wire. tareIntegratedScale()
    was a logged no-op.
  • Why it matters: the app already tells the shot sequencer to stand down and let the machine stop
    the shot (machineHasAutonomousSAW), so a target that never arrives at the firmware means nobody
    is stopping on weight. The value was cached and streamed back to the UI, which made it look like it
    had been set.
  • What changed: stopAtWeightTarget is now the firmware's EndOfShotWeight register
    (0x00803864, RWD), scaled x100, so centigrams on the wire, 0 disables, maximum 10000 g. A new
    scaleTare register (0x0080388C, RWT) is a write-trigger: the value is ignored, we send 1 to
    match de1plus, and the firmware performs an immediate tare. getStopAtWeightTarget now reads
    the register back and hydrates the stream cache.
  • What did NOT change (scope boundary): no new REST endpoint and no spec path change. The generic
    PUT /api/v1/scale/tare already reaches the new trigger through the existing
    ScaleController -> BengleVirtualScale.tare() -> tareIntegratedScale() chain. The weight
    pipeline is untouched: the firmware serves CurrW - LastTARE, so 0xA013 weight arrives already
    net of the new zero and nothing downstream needs to know a tare happened.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore / infra
  • Plugin (DYE2 or bundled skin)

Arguably a bug fix, since the shipped surface silently did nothing. I have called it a feature because
the surface it completes was never released as working.

Scope (select all touched areas)

  • BLE transport / device comms
  • REST API / handlers (capability string and spec description only; no new path)
  • WebSocket API
  • Machine state / shot logic
  • 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 # N/A - no tracking issue
  • Related # N/A

Root Cause (if bug fix)

N/A - this completes a deliberately stubbed surface rather than fixing a regression. The stub was
honest about itself (the address was 0x00000000 and the code path logged that it was awaiting
firmware); what it lacked was any test that would fail once the firmware slot existed and the app
still was not using it.

Regression Test Plan (if bug fix or refactor)

  • Coverage level that should have caught this:
    • Unit test
    • Integration test (mock transport edge)
    • End-to-end test (simulate=1 + curl/websocat)
    • Existing coverage already sufficient
  • Target test or file: test/models/device/bengle_saw_test.dart (rewritten from a stub-pinning
    group to byte-exact wire assertions), test/unit/models/device/impl/de1/unified_de1/ integrated_scale_capability_test.dart (the tare case), test/controllers/ de1_state_manager_shot_state_test.dart, test/services/webserver/de1handler_cup_warmer_test.dart.
  • Scenario the test should lock in: the wire bytes, not the cache. A setStopAtWeightTarget(2.3)
    must put 230 (centigrams) at address 0x00803864, not 229 and not the cached double; 0.0
    must disable; out-of-range values must clamp at 0 and 10000 g; the read-back must hydrate the
    stream. A tare must emit exactly one MMR write (length 4, address 0x80388C, payload 1,
    little-endian). The state-manager tests lock machineHasAutonomousSAW == true on every Bengle shot
    state frame including the idle re-seed, and false on a plain DE1. The handler test locks
    stopAtWeight into /machine/capabilities for a Bengle and out of it for a DE1.

Documentation Obligations (required)

  • API spec updated: assets/api/rest_v1.yml - the capabilities path description now lists the
    four live capability identifiers and states the stopAtWeight / targetYield semantics. The
    schema already carried them.
  • API docs updated: doc/Api.md - unchanged by design. PUT /api/v1/scale/tare is the same
    endpoint with the same contract; it simply now does something on a Bengle.
  • Plugin docs updated
  • Skin docs updated
  • Profile docs updated
  • Device docs updated
  • Other: the bengle-integrated-scale end-to-end scenario is refreshed to the autonomous-SAW
    reality (the workflow's target yield goes to the SAW register, the app defers the final stop,
    and the shot's stopReason is machineEnded).

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 - two MMR registers that were never written are now written. Both
    are Bengle-only addresses reached only through BengleInterface, so a plain DE1 cannot be sent
    either write. The tare is a write-trigger with an ignored value.
  • File system access changed? No
  • Plugin sandbox boundary changed? No

User-Visible Changes

Stop-at-weight actually stops the shot at the weight you asked for, because the machine now knows the
target. Tare actually tares. stopAtWeight appears in /api/v1/machine/capabilities on a Bengle
(and did not before, since the capability string was already listed but the feature behind it was
inert). The advertised SAW range widens from 0..500 g to 0..10000 g, matching the firmware.

Verification

Local gates (run before pushing)

  • flutter analyze - clean (No issues found!)
  • flutter test - 2113 tests pass at this branch head (B-3 was 2104; +9 from this branch's
    own tests)
  • (cd packages/dye2-plugin && npm run build) - plugin builds

Manual verification (if applicable)

  • OS / platform tested: Linux (analyzer, full test suite).
  • Simulated devices? (simulate=1): No
  • Real hardware? (DE1/Bengle/scale): No, not by me on this branch.
  • What you personally verified and how: the local gate set, and every register field in this diff
    read back against the firmware register table and the bengle_hw_v1.yml contract file added in
    B-1. Both new registers are registered with the contract checker, so a firmware-side change to
    either address, length or multiplier now fails CI here.
  • Edge cases checked (by test): the rounding boundary (2.3 * 100 is 229.999... in IEEE-754, so
    toInt() would write 229; the shared writeMmrScaled rounds, which is B-1's fix and is what
    makes this write correct); 0.0 as a disable rather than a target; clamping at both ends.
  • What you did not verify: I did not pull a shot on hardware with this branch and watch the
    machine stop at a target weight.
    That is the obvious end-to-end test and it has not been run
    against this slice. The byte-level assertions say the right bytes go to the right address with the
    right scaling, which is a strong claim about the wire and a weaker one about the outcome. I also
    did not verify the read-back path against firmware that has a persisted non-zero target.

Evidence

  • Test output (2113 pass; bengle_saw_test is now byte-exact rather than stub-pinning)
  • Log snippets
  • Screenshot / recording (UI changes)
  • curl / websocat output (API changes)

Compatibility & Migration

  • Backward compatible? Yes
  • Config / env changes needed? No
  • Database migration needed? No

MockBengle's clamp moves from 500 g to 10000 g to match. A client that was setting a SAW target and
seeing it echoed back will now find the machine honouring it, which is the point.

Deliberate choices worth your review

  1. The firmware does not clamp its Bengle registers, so the app is the only guard.
    the firmware write path divides by the multiplier and stores. A client-side clamp to 0..10000 g plus the raw
    maximum on the enum is all that stands between a bad value and the register. That is a real
    asymmetry with the DE1 registers and worth knowing about.
  2. The registers live on BengleScaleMmr, not BengleMmr. The rule is that a capability owns
    the registers it writes. The mixin is part of the unified_de1 library, and importing the Bengle
    subclass's bengle_mmr.dart into it would invert the import layering. This is a deliberate
    divergence from the original design sketch, and it is why the tare register is declared next to
    the scale code rather than with the other Bengle registers.
  3. A tare is confirmed by watching the weight, never by the 0xA013 Flags bit. Bit 0 of the
    Flags byte is a LastTARE value proxy at best, and older firmware hardcodes it to 0. Reads of
    ScaleTare return 0 by design; it is a trigger, not a value.
  4. getStopAtWeightTarget now hits the wire, but production still uses write-precedence.
    BengleSawBridge's connect-time re-apply remains the source of truth. The read-back exists so the
    cache can be hydrated from the wire and so the value can be checked, not so that the firmware
    becomes the authority mid-session.

Risks & Mitigations

  • Risk: a client that previously sent a SAW target above 500 g and had it silently clamped will
    now have it honoured by the machine.
    • Mitigation: the old clamp was a guess against a stubbed register that never reached the wire,
      so nothing was ever honoured. The new bound is the firmware's own maximum. This is not really a
      behaviour change for anyone; it is the feature starting to work.
  • Risk: the scaled write rounds, and somebody later "fixes" it back to truncation.
    • Mitigation: B-1 test-pins both directions of the scaled-write split (setSteamFlow(2.3) must
      land 229, a capability write of 2.3 at x100 must land 230), and bengle_saw_test asserts
      the 230 case again at the SAW call site.

ChampionDesigns and others added 16 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>
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