feat(profiles): lever and power step primitives, with a capability refusal gate - #804
Open
ChampionDesigns wants to merge 11 commits into
Open
feat(profiles): lever and power step primitives, with a capability refusal gate#804ChampionDesigns wants to merge 11 commits into
ChampionDesigns wants to merge 11 commits into
Conversation
Add three additive profile primitives the DE1's stock JSON format does not
carry, all parsed and round-tripped on every machine so stored profiles stay
machine-independent:
- ProfileStepPower (`pump: "power"`): constant hydraulic power (watts) with a
MANDATORY pressure limiter — fromJson throws FormatException (mapped to a
400) when the limiter is absent or zero.
- ProfileStepLever (`pump: "lever"`): a spring-lever step reusing `pressure`
as the starting pressure P0, plus `leverSpring` (k_V) and `leverGive` (R_s),
with an optional flow-cap limiter.
- TransitionType.hold: a HOLD step carries no authored target; the firmware
latches the value achieved at the exit of the previous step and holds it
flat. Encoded as a NEW enum value rather than an additive boolean key so an
old client's `byName('hold')` throws (a visible 400) instead of silently
dropping the marker and re-uploading a plain JUMP.
Only execution/arming is machine-gated (a later commit); the model itself is
tolerant and lossless everywhere. Documents the new discriminator values and
fields in the REST ProfileStep schema.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 27554498260796c0aca5d787d791713ed1023bec)
(cherry picked from commit e28dea9e0b324dc1036475208e6da9a4f155e0ea)
Refuse to upload a profile the connected machine cannot run, before any BLE write, and surface it cleanly instead of the opaque 500 the withDe1 catch-all would otherwise give. - onConnect reads the read-only ProfileModeCaps bitmask (Bengle MMR reg 62: bit0 Power, bit1 Lever, bit2 HOLD) and surfaces it via /api/v1/machine/info `extra.profileModeCaps`. The read is fail-closed: any timeout, short buffer, or word with bits outside 0x7 yields 0, and a bounded outer timeout keeps a machine without the register from stalling the connect flow. The 0x7 mask must track the highest capability bit — a 0x3 mask would zero a legitimate 0x7 and hide the modes. - _assertProfileModeSupported throws ProfileModeUnsupportedException when a profile uses a Power/Lever step or a HOLD transition on a non-Bengle or a machine missing the matching bit, and rejects HOLD as the first step (nothing to latch) on every machine. Power and Lever are described as pump modes and HOLD as a transition in the message. - POST /api/v1/machine/profile parses outside withDe1 (malformed body / a power step without its mandatory limiter -> 400) and maps the typed refusal to a 400; other StateErrors stay 500. - WorkflowDeviceSync catches the typed refusal and PARKS: no retry timer, and it will not re-attempt the same refused profile. A workflow-profile change or a reconnect clears the park and re-drives (a firmware update + reconnect is the only way it can start succeeding). Documents `profileModeCaps` in the MachineInfo schema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit d4c675761f70172fb9d655616bed06b53290a603) (cherry picked from commit b65d5e38990d741f86ddad2fb46697ac86d0ca2a)
Extend the profile encoder so Power/Lever/HOLD steps carry their mode and shaper parameters in the per-step extended frame; the base frame stays stock and CtrlF/interpolate are unchanged, so the mode is invisible to firmware that does not implement it. - Power/Lever ALWAYS emit an ext frame (the limiter-null skip does not apply): data[3] = mode (1 Power, 2 Lever), data[4] = ModeMaxP (Power: pressure cap; Lever: P0 == base SetVal), data[5]/[6] = leverSpring/leverGive (U8D1), data[1]/[2] = the stock limiter (Power 0/0; Lever flow-cap or 0/0). - HOLD ALWAYS emits an ext frame with its own Mode byte (3=pressure, 4=flow, 5=power) — even a limiter-null step the legacy branch would skip. The base SetVal is pinned to 0 and interpolate is never set, so on firmware without the HOLD modes the ext `Mode>=3` legacy fallback runs the base frame as a benign vent/pause rather than a stale target. HOLD-power keeps its mandatory pressure cap in data[4]. A lever step with a stray `hold` marker encodes as a plain lever, never HOLD. - All params are U8D1 (Bengle-native ×10). Encoding any of these modes for a non-Bengle throws at the wire boundary as belt-and-suspenders behind the arm-time refusal gate. Golden byte vectors pin the base + ext frames for every mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 429cee5148d8cb43856c18142bf9fce6575277f8) (cherry picked from commit 6ffbf4ed1abb921ef6e99c40fcfe5a891552767a)
Adds "Lever Classic demo" to the bundled default profiles so the new lever pump-mode step ships with a ready-to-run example. The profile is a flow fill (12 ml/s, exit on first pressure over 3 bar) into a short bloom pause (1.5 bar), then a classic lever step (P0 9.0 bar, spring 0.9, give 1.5, 60 s), target weight 36 g. Loaded via the normal manifest-driven bundled-profile path and visible by default. Stored profiles are machine-independent, so it parses and round-trips on any machine; only arming a lever step is capability-gated, so a machine that can't run it refuses cleanly at upload time. Extends the bundled-profiles parse test to cover the demo: its pump:"lever" step parses from the values-as-strings bundle and the profile round-trips losslessly through the model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit c38087ef734ee21ce9649bf5f16fe21b8f6ca5b7) (cherry picked from commit dac3c329effed948da4ff5721b2afe7668cf20a0)
Add hydraulic power (W = 0.1 * pressure * flow) as a third cross-variable
exit comparand alongside pressure and flow. A profile step may now exit
early when measured power crosses a threshold, over or under.
- Model: ExitType gains `power` as an enum VALUE (not an additive key), so
an old client's byName('power') throws -> a visible REST 400 rather than
a silent degrade to a pressure/flow exit. fromJson/toJson stay generic,
so a power exit round-trips losslessly on any machine (DE1 included).
- Encoder: a power exit emits the independent FrameFlags bit comparePower
(0x80), reuses DC_GT for over/under, and writes TriggerVal (data[5]) as
U8D1 watts. It deliberately does NOT set doCompare (0x02) or dcCompF, so
firmware/apps that predate the feature skip the pressure/flow compare
block entirely and run the frame as a benign base frame. Pressure/flow
exits are unchanged.
- Caps: ProfileModeCaps bit3 (0x8) = power-exit support. The fail-closed
read mask widens 0x7 -> 0xF so a machine advertising 0x9/0xB/0xF is not
zeroed; register doc comments and rest_v1.yml caps prose updated.
- Arm refusal: _assertProfileModeSupported gains an orthogonal power-exit
predicate; a power exit on a non-Bengle or a Bengle without bit3 throws
ProfileModeUnsupportedException -> REST 400, message naming the power
exit condition (not a "pump mode"). WorkflowDeviceSync park inherits.
Pressure/flow cross-exits stay ungated.
- Consumers: step_exit_arbiter and the simulated DE1 gain a power case
(currentPower = 0.1 * P * F) so their exhaustive switches compile and
evaluate power exits correctly.
- Docs: rest_v1.yml exit `type` enum widened to [pressure, flow, power]
with a capable-machine note (same commit as the code).
- Tests: model round-trip for power exits; encoder golden vectors
GV-P1 (02 C4 5A B8 9E 2D 04 00) and GV-F1 (03 C1 14 B4 94 14 04 00)
byte-exact; doCompare-clear property; U8D1 watts (10.0 W -> 0x64);
refusal both ways (caps 0xF arms, caps 0x7 -> 400, DE1 -> 400) with a
P/F-exit regression guard; caps mask widening; power-exit park.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 04196e8eacc0a791b9725ead86bafe2c921a9da0)
(cherry picked from commit 3f1c79517f1a9b9e1b38c451ca626180c1107809)
The cross-variable POWER exit encoder is pump-agnostic — convertProfileFlags derives the comparePower flag (0x80) from step.exit regardless of pump mode, and the base frame carries the same U8D1 watts TriggerVal a pressure/flow step does. These tests pin that a LEVER step behaves identically, per Ben's decision to offer the power exit on lever steps too. - Encode: a lever step with the same P0/temp/time/exit as the pressure golden vector GV-P1 produces a byte-identical base frame (00 C4 5A B8 9E 2D 04 00) — the only difference from a pressure step is the ext Mode=2 frame, which the exit leaves untouched. Flag byte pins comparePower + dcGT set with doCompare/dcCompF/ctrlF clear (over); the under case pins 0xC0 + watts TriggerVal 0x14. - Refusal: a lever step's pump mode IS supported at caps 0x7 (Lever = bit1) but its power exit is not (bit3 absent), so the arm-time refusal names the power EXIT condition, never the (supported) lever pump mode — the two gates are orthogonal. At caps 0xF the lever power exit arms. Test-only; no lib changes (the encoder was already mode-agnostic). flutter analyze clean; the power-exit suite is green (24 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit b668ecfbd5b7e9a7eeedcb8f919d917910b09106) (cherry picked from commit a35e8eef89535a453a10d31f9d72977973ff2e19)
Re-anchor fallout, each caught by an existing port test: - The profile handler calls setProfile directly rather than through runDeviceWrite: a capability refusal is deterministic, so replacement-retry buys nothing, and runDeviceWrite's identity check (`identical(device, connectedDe1OrNull)`) plus catch-and-retry turned the documented 400 refusal into a 500. - The handler test's fixed controller must override connectedDe1OrNull as well as connectedDe1; runDeviceWrite identity-checks the machine against it before and after the write. - dart format normalises the power-exit test, now that `dart format lib test` is a required verification step upstream. The third reconcile item from the 11 Aug pass -- the two profile-encoder locals shadowing the isBengle getter -- is resolved inside the replayed commits themselves rather than here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 2a71ebf5fae40d3912488e730dac6859a832ca14)
The rest_v1.yml schema carries them already. Api.md is the entry point a skin author reads first, and it said nothing about the new pump modes, the HOLD transition, the power exit comparand, or the profileModeCaps gate that refuses an unsupported upload with a 400.
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, lib/src/services/webserver/de1handler.dart, replayed by git rerere and checked: it keeps UPSTREAM'S bounded body read and THIS LANE'S placement of the parse outside withDe1, so a malformed profile is a clean 400 rather than the opaque 500 the catch-all gives. Both, not either. rerere also silently applied a `show` list trimmed for a DIFFERENT lane to test/profiles/default_profiles_bundled_test.dart, which broke the build: this lane's file uses ProfileStepFlow, ProfileStepLever and TransitionType in its body. ProfileStepLever and ProfileStepPower are this lane's own additions to profile.dart -- upstream has neither -- so the lane is self-consistent and only the import needed restoring. Verified: flutter analyze clean, test/profiles + test/services/webserver 213 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI runs `dart format` on the stable channel — Dart 3.13.3 — while the SDK on this machine is 3.12.2 from June. The two disagree about wrapping, so a locally clean lane failed CI. Two of the eight files this touches were made unformatted BY this lane and are mine to fix: de1_controller_test.dart and firmware_mmr_exclusion_test.dart. The rest were ALREADY unformatted on upstream/main under 3.13.3 — shot_sequencer_test.dart, remembered_devices_controller_test.dart and the others. CI only checks them because this lane touches them, so the reformat is unavoidable here rather than gratuitous. If you would rather that debt were paid in its own PR, say so and it can move. No behaviour change. Full suite 3978 passed / 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sses CI's "Gate slow test suites" step failed this PR. It fails any test file over 20 s of active time, and this file was taking 38.9 s - the slowest in the whole suite, three times the next one. The cause was a missing queued response, not slow work: real time 42 s against 4 s of CPU. Bengle.onConnect hydrates the LED palette, and FakeBleTransport had no answer queued for that read, so every one of the three tests waited out the fail-closed read timeout. 12.6 s each, spent idle. queuePaletteHydrationResponses() is what the existing Bengle tests already call for exactly this reason. With it, connectedBengle goes from 12,646 ms to 36 ms and the file drops off the slow list entirely. No behaviour changes and no assertion changes. The three tests pass as before. Verified with CI's own gate: flutter test --machine into tool/ci/summarize_flutter_tests.py --max-active-ms 20000 now exits 0, and the full suite is 3978 passed / 1 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator
Author
|
The
|
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
Adds three additive profile step primitives that the DE1's stock JSON format does not carry, plus a
capability gate so a machine that cannot run them refuses the upload cleanly instead of failing
opaquely.
Base:
main. Independent of the other port PRs.ProfileStepPower(pump: "power") — constant hydraulic power in watts with a mandatorypressure limiter.
fromJsonthrowsFormatException(mapped to a REST 400) when the limiter isabsent or zero, rather than silently running an unbounded power step.
ProfileStepLever(pump: "lever") — a spring-lever step reusingpressureas the startingpressure P0, plus
leverSpring(k_V) andleverGive(R_s), with an optional flow-cap limiter.TransitionType.hold— a HOLD step carries no authored target; the firmware latches the valueachieved at the exit of the previous step and holds it.
cross-variable exit comparand alongside pressure and flow.
All three parse and round-trip on every machine, DE1 included, so stored profiles stay
machine-independent.
Design notes worth reviewing
The capability gate is fail-closed.
onConnectreads the read-onlyProfileModeCapsbitmask(Bengle MMR reg 62: bit0 Power, bit1 Lever, bit2 HOLD) and surfaces it at
/api/v1/machine/infoasextra.profileModeCaps. Any timeout, short buffer, or word with bits outside0x7yields 0, and abounded outer timeout keeps a machine without the register from stalling the connect flow. The
0x7mask must track the highest capability bit — a
0x3mask would zero a legitimate0x7and hide themodes entirely.
_assertProfileModeSupportedthrowsProfileModeUnsupportedExceptionbefore any BLE write, so anunrunnable profile is a documented 400 rather than the opaque 500 the
withDe1catch-all wouldotherwise produce.
Encoding keeps the base frame stock. Mode and shaper parameters live in the per-step extended
frame; CtrlF and interpolate are unchanged, so the mode is invisible to firmware that does not
implement it. Power/Lever always emit an ext frame (the limiter-null skip does not apply):
data[3]= mode,data[4]= ModeMaxP,data[5]/[6]= leverSpring/leverGive as U8D1. HOLD alwaysemits its own Mode byte (3 pressure, 4 flow, 5 power) with base SetVal pinned to 0 and interpolate
never set, so firmware without the feature holds a harmless zero target rather than misreading the
frame.
Power exit is an enum value, not an additive key. An old client's
byName('power')thereforethrows and surfaces a visible REST 400, instead of silently degrading to a pressure or flow exit. The
encoder emits the independent
FrameFlagsbitcomparePower(0x80) and deliberately does not setdoCompare(0x02) ordcCompF, so apps predating the feature skip the compare rather thanmisinterpreting 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
Update, 6 Sep 2026 — CI's slow-suite gate. The first CI run failed on
Gate slow test suites, not on format, analyze or the tests.de1handler_profile_modes_test.dartwas taking 38.9 s of active time against a 20 s limit — the slowest file in the suite. The cause
was a missing queued response rather than slow work: 42 s of wall time against 4 s of CPU.
Bengle.onConnecthydrates the LED palette,FakeBleTransporthad no answer queued, and each ofthe three tests waited out the fail-closed read timeout. Adding
queuePaletteHydrationResponses()— which the existing Bengle tests already call for this reason —takes
connectedBenglefrom 12,646 ms to 36 ms. No assertion changed. Re-checked with CI's owngate:
--max-active-ms 20000exits 0, and the full suite is 3978 passed / 1 skipped / 0 failed.flutter analyze— clean.flutter test— full suite 3978 passed / 1 skipped, run against currentmainon 5 Sep 2026.dart format— clean on every changed file.power exit:
profile_modes_test.dart,unified_de1_profile_modes_test.dart,unified_de1_hold_profile_test.dart,unified_de1_power_exit_test.dart,de1handler_profile_modes_test.dart,profile_handler_pump_modes_test.dart.ProfileModeCapsread and the encode path run againstthe machine in the Decaid-Canary build, which is what Ben uses day to day.
Impact
assets/api/rest_v1.ymlgains thepowerandleverpump modes, theholdtransition,the
powerexit comparand, andextra.profileModeCapson/api/v1/machine/info.doc/Api.mddocuments all of them.
arming one on a machine without the capability changes behaviour, and that is a new
400.being uploaded and behaving unpredictably.
Update — rebased onto
main28 Aug 2026The lane was rebased from
94873593onto82f941d7. Two files conflicted and both resolutions areunions, not choices:
unified_de1.dart— upstream renamed the field_refillKitto_refillKitDetected(
4b6bc11c, "separate refill-kit detection from override"). This lane keeps upstream's name andadds its own
_readProfileModeCaps()read and theprofileModeCapsentry inextra.assets/api/rest_v1.yml— upstream replaced the free-textextradescription with astructured schema carrying
additionalProperties: trueand typedrefillKitandvoltageproperties. This lane no longer edits that description. It adds
profileModeCapsas a thirdtyped property instead, which documents the same bitmask in upstream's own shape.
dart formatandflutter analyzeare clean on the rebased lane.Contributor Responsibility
AI-assisted development is allowed. The submitter remains responsible for the submitted work.