Skip to content

Navigation Menu

Sign in
Sign up

feat(sensor): the Bengle fused puck-hydraulic observer as a first-class Sensor - #803

Open
ChampionDesigns wants to merge 11 commits into
decentespresso:main from
ChampionDesigns:ben/puck-estimator-r2
Open

feat(sensor): the Bengle fused puck-hydraulic observer as a first-class Sensor #803
ChampionDesigns wants to merge 11 commits into
decentespresso:main from
ChampionDesigns:ben/puck-estimator-r2

Conversation

@ChampionDesigns

@ChampionDesigns ChampionDesigns commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Surfaces the Bengle firmware's fused puck-hydraulic observer, decoded from the 0xA014
characteristic, 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 target
another branch of that fork, so this one is opened against main and its diff includes the three
derived 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.

  • 0xA014 decoder (bengle_est_sample.dart) — big-endian, 16-byte base frame with per-field wire
    sentinels mapping to null. Frames under 16 bytes are dropped rather than throwing a RangeError.
    The optional 5-byte Rev-2 R1-collapse detector tail decodes only when len >= 21 && rev >= 2, so
    16-byte, truncated and older-rev frames stay backward-compatible with the tail fields null.
  • Rev-3 measured hydraulic power — offset 21, u16 / 1000 watts, 0xFFFF = not yet observed.
    Exposed as the hydraulicPowerMeasured channel.
  • The estimator as a Sensor, not a MachineSnapshot field.
  • BLE subscribe, gated on the machine actually offering the characteristic.
  • Shot-history persistence via a generic sensors map on ShotSnapshot.

Design notes worth reviewing

Why a Sensor and not MachineSnapshot. The port originally folded a subset of this frame into
MachineSnapshot as fusedR1 / fusedR2 / fusedC / fusedConf / vAbs / estLag / estFlags
plus four R1-collapse fields. PR #601's review (633f6f68) ruled against that shape: "MachineSnapshot
stays pure machine telemetry". This PR follows that ruling — 0xA013 weight already went to
ScaleSnapshot via IntegratedScaleCapability and milk temperature to its own sensor, so the
estimator belongs in the same place.

Measured is not 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 reported
group flow. The two agree in steady state and diverge during compliance transients, which is
exactly when the value is worth having. MachineSnapshot.hydraulicPowerDerived keeps 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: puckResistance was the derived value while the firmware's actual
puck-resistance estimate is r2. This PR names the derived set as derived
(puckResistanceDerived, loadImpedanceDerived, hydraulicPowerDerived) and pairs each with its
measured 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.
BLETransport gains discoverCharacteristics() defaulting to an empty list, so a transport that
cannot enumerate reports none and callers skip the subscribe — fail-closed, and every existing
implementor including the test fakes keeps working unchanged. subscribeEstimator() normalises casing
on 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 false return is normal, not an error: the
sensor simply never registers.

Shot history had to follow. Moving the estimator to /sensors fixed the abstraction but broke
persistence — ShotSnapshot records MachineSnapshot plus the scale, and the estimator was only
persisted because it rode on MachineSnapshot. Without this, new shots would record none of it and
the expanded R/Z charts would quietly fall back to the derived channel forever: silently worse rather
than visibly broken. ShotSnapshot.sensors is 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 third-party sensors should record too.

Note on test/helpers/fake_serial_transport.dart

This 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/main was merged into this lane rather than rebased onto it, so the three-dot diff
GitHub shows is still exactly the lane's own work while the merge base moves forward. The merge
resolutions were checked individually — git rerere is enabled in the working repo and replayed
two of them wrongly elsewhere in this round, so none was taken on trust.

Linked Issue

N/A

Verification

  • flutter analyze — clean.
  • flutter testfull suite 3937 passed / 1 skipped, run against current main on 5 Sep 2026.
  • dart format — clean on every changed file.
  • Decoder tests cover the 16-byte base frame, every wire sentinel, the truncated frame, the Rev-2 tail
    gate and the Rev-3 power field: bengle_est_sample_test.dart.
  • bengle_est_sample_serial_test.dart covers the serial [T] dispatch: <+T> on connect, routing,
    the truncated-frame drop, and <-T> on disconnect.
  • Sensor and subscribe tests: bengle_puck_estimator_sensor_test.dart,
    bengle_estimator_ble_subscribe_test.dart (including the fail-closed empty-enumeration path).
  • Persistence: shot_snapshot_sensors_test.dart.
  • Verified against real firmware. The serial [T] path and the BLE subscribe both run against
    the 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

  • API: assets/api/rest_v1.yml and assets/api/websocket_v1.yml gain the sensor and rename the
    three derived MachineSnapshot channels to *Derived. doc/Api.md documents the sensor, its
    channels, and which derived channel each measured channel replaces.
  • Compatibility — BREAKING for one client shape. A client already reading puckResistance,
    loadImpedance or hydraulicPower must move to the *Derived names. Those keys ship in the
    prerequisite PR and have not been in a release, so no released client depends on them.
  • User-visible: a Bengle exposes a new sensor at /api/v1/sensors/:id and
    /ws/v1/sensors/:id/snapshot. Shots recorded from now on carry per-sensor channels.
  • Migration: none. ShotSnapshot.sensors is additive and absent on older records.
  • Security: none.

Contributor Responsibility

AI-assisted development is allowed. The submitter remains responsible for the submitted work.

  • I have reviewed and understand all changes in this PR and take responsibility for their correctness, security, behavior, licensing, and provenance, including any AI-assisted or AI-generated work.

ChampionDesigns and others added 11 commits August 28, 2026 10:47
... 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

1 participant

AltStyle によって変換されたページ (->オリジナル) /