-
Notifications
You must be signed in to change notification settings - Fork 26
feat(sensor): the Bengle fused puck-hydraulic observer as a first-class Sensor - #803
Open
ChampionDesigns wants to merge 11 commits into
Open
feat(sensor): the Bengle fused puck-hydraulic observer as a first-class Sensor #803ChampionDesigns wants to merge 11 commits into
ChampionDesigns wants to merge 11 commits into
Conversation
... channels Add three compute-on-read channels to MachineSnapshot, derived from the existing pressure and flow fields: puckResistance R = P / F2 bar·s2/mL2 loadImpedance Z = P / F bar·s/mL hydraulicPower W = 0.1·P·F W They are pure functions of pressure and flow, so any DE1-class machine gets them with no extra hardware or firmware. - Getters return null (and toJson omits the key entirely, rather than emitting null) unless flow >= 0.3 mL/s and pressure >= 0.3 bar. Below that the ratios are numerically meaningless, and the omit-not-null contract lets consumers treat key presence as the validity signal and keeps older clients unaffected. - A finite guard is mandatory before serialization: jsonEncode throws on NaN/Infinity and toJson is streamed on the live machine-snapshot websocket, so an unguarded divide would kill the socket. - fromJson never reads the keys; toJson recomputes them from the raw fields, so already-stored history shots gain the channels on read with zero migration. Document the keys in both API specs (same commit per AGENTS.md): the same toJson serves GET /machine/state and history re-serialization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 48afe562c9da916b7faf79589afa4705a43c8c9c) (cherry picked from commit f97d4d54e6bfa1d20f3681844ea4679a480f633d)
Unit tests for MachineSnapshot.puckResistance / loadImpedance / hydraulicPower: value correctness, the >= 0.3 flow/pressure gate on both sides, key omission (not null) below the gate, the >= boundary at exactly 0.3, that zero flow keeps NaN/Infinity out of the payload so jsonEncode does not throw, and that a stored-then-restored snapshot recomputes the channels from the raw fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 32323d2ce7322a5b6c840f3d288b7a26d6c99e15) (cherry picked from commit 4da994f5b0852dfaa0d6761f913d67bad53a5ee3)
The spec carries the three channels, but Api.md is where a skin author looks first and it did not mention them. The omit-when-gated rule matters most: the key disappears rather than going null, so a client that reads absent as zero plots a lie.
...ver serial
Add the Bengle 0xA014 fused puck-estimator characteristic (serial char 'T')
as a serial/CDC-only stream, alongside the existing 0xA013 shot sample. The
estimator is a pure observer; nothing the machine does reads it.
- Endpoint.estimator('A014','T').
- bengle_est_sample.dart: a big-endian decoder for the 16-byte base frame
(per-field wire sentinels -> null; frames under 16 bytes dropped rather than
throwing a RangeError) plus the optional 5-byte Rev-2 R1-collapse detector
tail, decoded only when len >= 21 AND rev >= 2 so 16-byte, truncated, or
older-rev frames stay backward-compatible with the tail fields null.
- Transport intake, serial-only: a seeded zero-length _estimatorSubject (so
"no frame yet" decodes to all-null instead of a run of zeros), the <+T>
subscribe in _serialConnect, the [T] dispatch + length-guarded handler, the
read() case, and <-T> plus subject-close on disconnect / dispose / detach.
Never subscribed over BLE: a blind CCCD write on an unregistered
characteristic stalls the command queue.
- notificationsFor() exposes the estimator stream for runtime subscription.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b42775821fed60a44b8c146174978f303210fd95)
(cherry picked from commit 1faba30940d1c3293d71489789c57d4d7e4dbbc2)
The Bengle 0xA014 frame carries the firmware's fused puck-hydraulic observer. The port originally folded a subset of it into MachineSnapshot as fusedR1 / fusedR2 / fusedC / fusedConf / vAbs / estLag / estFlags plus four R1-collapse detector fields. Upstream has ruled against that shape. Reviewing this port's own PR decentespresso#601, 633f6f6 ("route A013 into existing machine/scale/sensor abstractions") revised it in place: "MachineSnapshot stays pure machine telemetry (weight/weightFlow/ milkTemperature removed; steamTemperature retained)". 0xA013 weight went to ScaleSnapshot via IntegratedScaleCapability and milk temperature to BengleMilkProbe, a Sensor. rest_v1.yml states the rule outright: "milk-probe scaffolding lives in /sensors, not here." Observer output is not machine telemetry -- the machine never reads it back -- so it follows the BengleMilkProbe precedent instead: - PuckEstimatorCapability decodes the transport's raw 0xA014 stream, dropping undecodable frames including the zero-length seed. That seed is why "no estimator yet" cannot be confused with a run of real zeros. - BengleInterface.puckEstimator is the semantic stream, mirroring probeTemperature. - BenglePuckEstimator implements Sensor with id <machine>-puckestimator, declaring a DataChannel per observer output. A field at its wire sentinel is OMITTED rather than zeroed: a zero would read as a real measurement. - BenglePuckEstimatorBridge registers on the first decoded frame, not on connect. 0xA014 is serial/CDC only, so a BLE-connected Bengle, an older firmware, or a plain DE1 must never advertise a sensor that would only ever report nothing. MachineSnapshot is untouched, so no machine-snapshot schema change. Consumers read the estimator from the existing /api/v1/sensors surfaces and the live ws/v1/sensors/<id>/snapshot channel. The sensor also exposes lagConf, sigmaQ, lastPauseTau and rev, which the MachineSnapshot fold dropped. Skins reading fused fields off the machine-snapshot websocket must move to the sensor snapshot channel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit c2b4737083a94d8dce41fff9434a8ada48baa02c)
The firmware now reports the hydraulic power it actually delivers into the puck (MainCPU, 0xA014 rev 3, offset 21, W x1000, 0xFFFF = not yet observed). Decode it and expose it as the `puckPower` channel on the puck-estimator sensor. Measured is not the same number as derived, and that is the point. The firmware computes 0.1 * P * Q_puck from the estimator's own state; a client can only derive 0.1 * P * Q_in from the 0xA013 pressure and flow. They agree in steady state and diverge during compliance transients, which is when the value is worth having. `MachineSnapshot.hydraulicPower` keeps the derived form -- it is the only way a plain DE1 gets this channel, and a test asserting the two match would be wrong. Provenance is the endpoint, so no name carries a `Measured`/`Derived` suffix: `/ws/v1/machine/snapshot` is derived, `ws/v1/sensors/<id>/snapshot` is measured. That avoids the r1-vs-loadImpedance shape the plan warned about, where two names for one quantity differ only in origin. Revision gating follows the existing tail: offsets 0-20 stay byte-identical, so a rev-2 frame from older firmware still decodes fully with wPuck null, and a rev-3 frame truncated mid-u16 drops the tail rather than decoding half of it. The sentinel maps to null and the key is OMITTED from the payload -- 0 W is a real, different statement from "not yet observed", and a chart must draw a gap. Named puckPower, not power: mains draw is hundreds of watts (MeasuredACPowerDrain) and an ambiguous name would cause a real bug the first time electrical is exposed. Firmware side is on the Bengle lane B tip ben/diag-telemetry-packet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 2e8499fa69ff3cd77aedb5140d04499febfce175)
...rs it The firmware now registers BengleEstSample as its own BLE characteristic, so the estimator is no longer serial-only. Subscribe it on BLE too -- but only after confirming the machine actually exposes it. The check is a safety gate, not an optimisation. Firmware predating that registration does not have the characteristic, and a CCCD write against a characteristic the peripheral never registered stalls the command queue. That hazard is exactly why this stream was serial-only until now, so subscribing unconditionally would trade one limitation for a much worse failure. - BLETransport gains discoverCharacteristics(), defaulting to an EMPTY list. A transport that cannot enumerate characteristics therefore reports none and callers skip the optional subscribe -- fail-closed, and it leaves every existing implementor (including the test fakes) working unchanged. - UniversalBleTransport caches the characteristic UUIDs already present in the discovery result it was discarding, in both the standard and BlueZ paths, and replaces the cache wholesale each discovery so a reconnect cannot leave a stale characteristic looking present. - subscribeEstimator() normalises casing on BOTH sides: a safety gate must not depend on a transport honouring a lowercase convention. Caught by the test -- the first version compared a lowercased needle against a raw haystack and silently refused to subscribe on a machine that did offer the characteristic. - Bengle.onConnect calls it. A false return is normal, not an error: the puck-estimator sensor simply never registers, exactly as before. Serial is untouched -- _serialConnect already sends the unconditional <+T>, where no stall hazard exists and a machine without the observer just never emits a frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 6d4aa245930ef1908fac84cd01078407aa9e625b)
Three quantities now exist twice, and nothing said they were the same thing. MachineSnapshot carried puckResistance / loadImpedance / hydraulicPower -- derived from pressure and reported group flow (Q_in), available on every machine and every historical shot. The puck-estimator sensor carries r2 / r1 / puckPower -- measured by the firmware from Q_puck, the flow actually through the puck, and more accurate because it shares the exact pair the R1 fit consumes. The old naming was worse than merely unpaired: `puckResistance` was the DERIVED value while the firmware's actual puck-resistance estimate is `r2`, so a client reading the snapshot got the weaker number under the better name with no hint a counterpart existed. They are NOT merged onto the snapshot. Observer output is not machine telemetry, so it stays in /sensors beside BengleMilkProbe -- upstream's rule, and the reason the sensor exists at all. The problem was discoverability, not placement, so this only renames and documents: - The snapshot getters and their toJson keys take a `Derived` suffix, which makes a reader ask "derived as opposed to what?" and the doc comment answers by name. - The sensor's power channel becomes hydraulicPowerMeasured, sharing a stem with hydraulicPowerDerived so the pairing needs no lookup. - r1 and r2 keep the firmware's own names rather than gaining a Measured suffix. R1/R2 is the vocabulary of the firmware, the estimator and the "R1 collapse events" detector, and renaming an n=1 resistance to an "impedance" to force symmetry would misstate the physics. Their derived counterparts are named in the channel table instead. - Both API specs document the pairing and, importantly, that the two go null at DIFFERENT times -- the derived 0.3/0.3 gate versus the firmware's own gate and not-yet-observed sentinel -- so a client switching source mid-shot sees gaps in different places rather than a continuous trace. Safe to rename outright: the derived channels have never been on a bench build (they were not in port/rea-bench-v2), the skins compute their own copy rather than reading these, and the sensor channel is hours old with no consumer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 7b81d33e8585220fd354959b13d6812c5db71b45)
Moving the puck estimator to /sensors fixed the abstraction but broke history. ShotSnapshot persists MachineSnapshot plus the scale, and the estimator used to be persisted only because it rode on MachineSnapshot. Without this, new shots record none of it: the expanded R/Z charts and the R1-collapse event list would quietly fall back to the derived channel forever, which is the worst failure mode -- silently worse rather than visibly broken. ShotSnapshot gains `sensors`: deviceId -> that sensor's own channel map, one entry per attached sensor per sample. Generic rather than estimator-specific, because the milk probe is already a Sensor and any third-party sensor attached during a shot is equally part of that shot's record. No Drift migration: measurements persist as a JSON blob (measurementsJson), so the new key flows through the existing column. The key is OMITTED rather than written empty -- a record holds one snapshot per sample, and an empty object on every one is pure weight. Records written before this change load unchanged. ShotSequencer reconciles its sensor subscriptions PER SNAPSHOT rather than once at shot start. The Bengle puck estimator registers on its first decoded frame, so binding only at the start would miss it on every shot where the observer had not yet spoken. A sensor that drops out mid-shot keeps its last frame rather than being deleted, so samples already taken do not retroactively lose a channel. sensorController is optional the whole way down (ShotSequencer, De1StateManager, App, AppRoot), so every existing construction site and test fake is unaffected and a build without it simply records no sensor channels. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 2b047d02e42e29bf2d96a2aa964ef94e4a4daba3)
Names the sensor, its id and its channels, and states which derived MachineSnapshot channel each measured channel replaces. Also renames the three derived channels in the Api.md text to match this lane's *Derived keys.
Brings the lane current with upstream (5 Sep 2026) so it can be opened as a PR. Merging rather than rebasing keeps the three-dot diff GitHub shows equal to this lane's own work. One conflict, doc/Api.md: each side documented a different feature at the same point. Union -- the lane's "Bengle puck estimator" section and upstream's "Plugin-backed sensors" section are both present. This lane is still STACKED on ben/derived-channels, which merges onto upstream cleanly. Its PR targets that lane until it merges, then retarget to main. Verified: flutter analyze clean, test/controllers 693 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Summary
Surfaces the Bengle firmware's fused puck-hydraulic observer, decoded from the
0xA014characteristic, as a first-class
Sensor— and persists it into shot history.Base:
main, but this PR CONTAINS the derived-channels PR. A PR from a fork cannot targetanother branch of that fork, so this one is opened against
mainand its diff includes the threederived channels as well. It carries a rename that necessarily touches both the derived channels
and the measured ones, which is why the two cannot be separated.
Review the derived-channels PR first. Once that merges, this diff shrinks to the puck estimator
alone with no action needed here.
0xA014decoder (bengle_est_sample.dart) — big-endian, 16-byte base frame with per-field wiresentinels mapping to
null. Frames under 16 bytes are dropped rather than throwing aRangeError.The optional 5-byte Rev-2 R1-collapse detector tail decodes only when
len >= 21 && rev >= 2, so16-byte, truncated and older-rev frames stay backward-compatible with the tail fields null.
u16 / 1000watts,0xFFFF= not yet observed.Exposed as the
hydraulicPowerMeasuredchannel.Sensor, not aMachineSnapshotfield.sensorsmap onShotSnapshot.Design notes worth reviewing
Why a Sensor and not MachineSnapshot. The port originally folded a subset of this frame into
MachineSnapshotasfusedR1/fusedR2/fusedC/fusedConf/vAbs/estLag/estFlagsplus four R1-collapse fields. PR #601's review (
633f6f68) ruled against that shape: "MachineSnapshotstays pure machine telemetry". This PR follows that ruling — 0xA013 weight already went to
ScaleSnapshotviaIntegratedScaleCapabilityand milk temperature to its own sensor, so theestimator belongs in the same place.
Measured is not derived, and that is the point. The firmware computes
0.1 * P * Q_puckfrom theestimator's own state. A client can only derive
0.1 * P * Q_infrom the 0xA013 pressure and reportedgroup flow. The two agree in steady state and diverge during compliance transients, which is
exactly when the value is worth having.
MachineSnapshot.hydraulicPowerDerivedkeeps the derived form— it is the only way a plain DE1 gets this channel. A test asserting the two match would be wrong by
construction.
The rename. Three quantities existed twice with nothing saying they were related, and the old
naming was actively misleading:
puckResistancewas the derived value while the firmware's actualpuck-resistance estimate is
r2. This PR names the derived set as derived(
puckResistanceDerived,loadImpedanceDerived,hydraulicPowerDerived) and pairs each with itsmeasured counterpart (
r2,r1,hydraulicPowerMeasured).BLE subscribe is a safety gate, not an optimisation. Firmware predating the characteristic
registration does not have it, and a CCCD write against a characteristic the peripheral never
registered stalls the command queue. That hazard is why the stream was serial/CDC-only until now.
BLETransportgainsdiscoverCharacteristics()defaulting to an empty list, so a transport thatcannot enumerate reports none and callers skip the subscribe — fail-closed, and every existing
implementor including the test fakes keeps working unchanged.
subscribeEstimator()normalises casingon both sides; the first version compared a lowercased needle against a raw haystack and silently
refused to subscribe on a machine that did offer it. A
falsereturn is normal, not an error: thesensor simply never registers.
Shot history had to follow. Moving the estimator to
/sensorsfixed the abstraction but brokepersistence —
ShotSnapshotrecordsMachineSnapshotplus the scale, and the estimator was onlypersisted because it rode on
MachineSnapshot. Without this, new shots would record none of it andthe expanded R/Z charts would quietly fall back to the derived channel forever: silently worse rather
than visibly broken.
ShotSnapshot.sensorsis deviceId -> that sensor's own channel map, one entryper attached sensor per sample. Generic rather than estimator-specific, because the milk probe is
already a Sensor and third-party sensors should record too.
Note on
test/helpers/fake_serial_transport.dartThis PR and the firmware verify-poll PR both add this helper, byte-identical. Each PR is independent
and each needs a serial fake, so neither can rely on the other landing first. Git merges the two
identical additions without a conflict. If you would rather it lived in one place, say which PR should
own it and I will rebase the other onto it.
Brought current with upstream, 5 Sep 2026
upstream/mainwas merged into this lane rather than rebased onto it, so the three-dot diffGitHub shows is still exactly the lane's own work while the merge base moves forward. The merge
resolutions were checked individually —
git rerereis enabled in the working repo and replayedtwo of them wrongly elsewhere in this round, so none was taken on trust.
Linked Issue
N/A
Verification
flutter analyze— clean.flutter test— full suite 3937 passed / 1 skipped, run against currentmainon 5 Sep 2026.dart format— clean on every changed file.gate and the Rev-3 power field:
bengle_est_sample_test.dart.bengle_est_sample_serial_test.dartcovers the serial[T]dispatch:<+T>on connect, routing,the truncated-frame drop, and
<-T>on disconnect.bengle_puck_estimator_sensor_test.dart,bengle_estimator_ble_subscribe_test.dart(including the fail-closed empty-enumeration path).shot_snapshot_sensors_test.dart.[T]path and the BLE subscribe both run againstthe machine in the Decaid-Canary build. A machine that does not register the characteristic is
handled by the absent-channel rule above, not by an error.
Impact
assets/api/rest_v1.ymlandassets/api/websocket_v1.ymlgain the sensor and rename thethree derived
MachineSnapshotchannels to*Derived.doc/Api.mddocuments the sensor, itschannels, and which derived channel each measured channel replaces.
puckResistance,loadImpedanceorhydraulicPowermust move to the*Derivednames. Those keys ship in theprerequisite PR and have not been in a release, so no released client depends on them.
/api/v1/sensors/:idand/ws/v1/sensors/:id/snapshot. Shots recorded from now on carry per-sensor channels.ShotSnapshot.sensorsis additive and absent on older records.Contributor Responsibility
AI-assisted development is allowed. The submitter remains responsible for the submitted work.