feat(bengle): LED strip palettes + live preview over MMR - #464
Open
ChampionDesigns wants to merge 18 commits into
Open
feat(bengle): LED strip palettes + live preview over MMR#464ChampionDesigns wants to merge 18 commits into
ChampionDesigns wants to merge 18 commits into
Conversation
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>
This was referenced Jul 16, 2026
ChampionDesigns
force-pushed
the
feat/bengle-led-strip
branch
from
July 16, 2026 07:20
b78cdc8 to
89d421f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A Bengle has front and rear light strips, and the app has had a full REST surface for them since the capability landed: read the configuration, write all three zones in both awake and sleeping modes, commit, reset. None of it did anything. The capability was a scaffold written before the firmware wire spec was published, so every endpoint address was
null, every write was swallowed, and the code logged one line per session and returned success. A user who set the strips to a warm white got a200 OKand a machine that stayed exactly the colour it already was, and aGETafterwards cheerfully echoed back the colour they had asked for, because the value never left the app's own cache. This PR wires the capability to the six real firmware registers, hydrates the cache from the machine on connect so the app stops inventing state, and adds a live-preview endpoint so a colour picker can show a colour on the strip without storing it.Summary
LedStripCapabilitywas a stub.BengleLedEndpointreturnednullfor everyuuidandrepresentation;setLedStrip,commitLedStripandresetLedStripall no-opped with a once-per-session info log. The REST endpoints returned success and wrote nothing to the machine. The in-memory cache was seeded all-off and never corrected, soGET /api/v1/machine/ledStripreported black on a machine that was glowing.200 OK, a cache that agreed with you, and a machine that ignored you.FrontLEDAwake0x00803898,FrontLEDSleep0x008038A0,RearLEDAwake0x0080389C,RearLEDSleep0x008038A4) and two live-colour slots (FrontLEDColor0x00803890,RearLEDColor0x00803894).initLedStripnow hydrates the cache from the four palette registers on connect. Two new endpoints,POST /api/v1/machine/ledStrip/previewandPOST /api/v1/machine/ledStrip/preview/clear, drive the live slots so a UI can preview the sleep colour while the machine is awake.LedStripStateJSON shape is unchanged - still three zones by two modes.GET,PUT,/commitand/resetkeep their paths and their status codes. No skin ships in this PR; the Streamline LED UI is a separate repo and a separate review.Change Type (select all)
Scope (select all touched areas)
Linked Issues
Root Cause (if bug fix)
BengleLedEndpoint.uuidand.representationwere hard-codednullwith a// TBD with FWcomment, and the mixin treated a null wire as "capability not yet wired" and no-opped by design. That was the right call at the time. What made it a bug rather than a placeholder is that the REST layer never learned about it: the handler returned202/200exactly as it would for a real write, and the cache absorbed the value so a read-back confirmed the lie.Regression Test Plan (if bug fix or refactor)
simulate=1+ curl/websocat)test/unit/models/device/impl/de1/unified_de1/led_strip_capability_test.dart(+392 lines), plustest/unit/models/device/impl/bengle/mmr_contract_test.dart(the four palette registers and the two live registers are now registered with the firmware contract checker).BengleoverFakeBleTransportand assert the bytes on the wire, not the cache: asetLedStripwrites four MMR frames to the four palette addresses in0x00RRGGBBform; apreviewLedColorwrites the two live addresses and does not touch the palette or the cache;clearLedPreviewwrites the cached awake palette back to the live addresses;initLedStripreads the four palette registers into the cache; and a read that throws leaves the cache seeded all-off rather than failing the connect. The contract test additionally fails CI if any of these six addresses, lengths or scales drift fromassets/api/bengle_hw_v1.yml.Documentation Obligations (required)
assets/api/rest_v1.yml-GETgains the connect-time-hydration contract;PUTdescribes the persist-on-write semantics and the ignoredfrontSwitchzone;/commitand/resetare re-described (see below); the two/previewendpoints are new.doc/Api.mddoc/Plugins.mddoc/Skins.mddoc/Profiles.mddoc/DeviceManagement.mdThe e2e scenario
docs/e2e/decent-app/scenarios/bengle-led-strip.mdis refreshed in the same commit.Security Impact (required)
Yes-POST /api/v1/machine/ledStrip/previewandPOST /api/v1/machine/ledStrip/preview/clear. Both are Bengle-only (404 on any other machine) and both sit on the same unauthenticated local web server as the existing LED endpoints, which already accept arbitrary colours. The new surface is a colour, on a light. It grants no reach a caller did not already have throughPUT /api/v1/machine/ledStrip.NoNoYes- six new MMR reads/writes, all Bengle-only. They are registered with the contract checker, which fails CI if an address drifts from the firmware contract file.NoNoUser-Visible Changes
The LED strip endpoints now actually change the colour of the lights.
GET /api/v1/machine/ledStripreturns the colours stored on the machine after a connect, rather than all-off, so a fresh app install shows the user their real palette. Two new endpoints allow a live preview.POST /commitandPOST /resetkeep working and keep their status codes, but their meaning has changed - see the deliberate choices below. Their request bodies are nowrequired: false, which is a relaxation, not a break.Verification
Local gates (run before pushing)
flutter analyze- clean (No issues found!)flutter test- 2170 tests pass on this branch at89d421f1(B-5 was 2150, so this branch adds 20).(cd packages/dye2-plugin && npm run build)- plugin buildsManual verification (if applicable)
simulate=1):Yes- againstMockBengle,GET /api/v1/machine/ledStripreturns three zones by two modes in 16-bit RGB;POST /ledStrip/previewreturns202;POST /ledStrip/preview/clearreturns202.No.FakeBleTransportcaptures the MMR frames and the assertions are byte-exact against the six addresses.assets/api/bengle_hw_v1.yml, but no one has yet confirmed on hardware that writingFrontLEDAwaketurns the front strip that colour.MockBengle.previewLedColoris deliberately a no-op, so the simulated run proves the REST plumbing and nothing about the machine. This should be smoke-tested on a real Bengle before merge.Evidence
simulate=1)Compatibility & Migration
Yes, with one semantic caveat. Every existing path, method and response shape is unchanged, and the two endpoints whose request body wasrequired: truenow accept an empty body. But/commitand/resetno longer mean what their names say (below). A client that callsPUTthen/commitkeeps working; the/commitis now redundant rather than necessary.NoNoDeliberate choices worth your review
/commitand/resetare now vestigial, and I kept them anyway. The four palette registers arePERM_RWDin firmware - they persist on every write. There is no separate commit register, so the live/persist split the API was designed around does not exist on the wire. Rather than break the API I madecommitLedStrip()re-assert the cached palette (a harmless idempotent re-write) andresetLedStrip()re-read the registers into the cache. That means/resetis a cache re-hydrate, not a rollback - it returns whatever was last written, so it cannot undo aPUT. The spec now says so in as many words. The honest alternative is to deprecate both endpoints; I did not want to make that call unilaterally on someone else's public API.frontSwitchzone has no register and is silently ignored on write. The physical switch light mirrors the front strip in firmware; there is no independent control. I kept the zone inLedStripStatefor API symmetry and made reads mirror the front strip's values into it. The alternative - reject a request that setsfrontSwitchto something different - is arguably more honest, but it would break the existing three-zone request shape. This is documented inrest_v1.ymlbut a client cannot detect it programmatically.Color16is 16-bit per channel and the firmware is 8. Writes take the high byte of each channel; reads byte-replicate back (0xABbecomes0xABAB). Round-trips are lossless for anything that originated as 8-bit colour, which is every colour picker I know of, but a genuinely 16-bit source loses its low byte silently.front/backin a preview request previews black. That isColor16.fromJson's existing semantics, not a decision I made here, but it meansPOST /ledStrip/previewwith{}turns both strips off. I documented it rather than special-casing it, because the alternative is to diverge from howColor16is parsed everywhere else in the API. If you would rather have a400there, say so and I will add it.Risks & Mitigations
warningand the spec documents the exact sequence. It is bounded: anyPUTorPOST /resetrepairs the cache. I chose a tolerant hydration (a failed read never fails the connect) over a strict one, because a Bengle that cannot connect because its LEDs would not answer is a much worse failure than a strip that goes dark.assets/api/bengle_hw_v1.yml, so they cannot silently drift. It does not, however, prove they were right to begin with - see "what you did not verify" above. A hardware smoke test is the mitigation, and it has not been done.