Skip to content

feat(profiles): lever and power step primitives, with a capability refusal gate - #804

Open
ChampionDesigns wants to merge 11 commits into
decentespresso:mainfrom
ChampionDesigns:ben/profile-modes-r2
Open

feat(profiles): lever and power step primitives, with a capability refusal gate#804
ChampionDesigns wants to merge 11 commits into
decentespresso:mainfrom
ChampionDesigns:ben/profile-modes-r2

Conversation

@ChampionDesigns

@ChampionDesigns ChampionDesigns commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

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 mandatory
    pressure limiter. fromJson throws FormatException (mapped to a REST 400) when the limiter is
    absent or zero, rather than silently running an unbounded power step.
  • 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.
  • Power exit conditions — hydraulic power (W = 0.1 * pressure * flow) becomes a third
    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. onConnect reads the read-only ProfileModeCaps bitmask
(Bengle MMR reg 62: bit0 Power, bit1 Lever, bit2 HOLD) and surfaces it at /api/v1/machine/info as
extra.profileModeCaps. 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 entirely.

_assertProfileModeSupported throws ProfileModeUnsupportedException before any BLE write, so an
unrunnable profile is a documented 400 rather than the opaque 500 the withDe1 catch-all would
otherwise 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 always
emits 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') therefore
throws and surfaces a visible REST 400, instead of silently degrading to a pressure or flow exit. The
encoder emits the independent FrameFlags bit comparePower (0x80) and deliberately does not set
doCompare (0x02) or dcCompF, so apps predating the feature skip the compare rather than
misinterpreting 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

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.dart
was 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.onConnect hydrates the LED palette, FakeBleTransport had no answer queued, and each of
the three tests waited out the fail-closed read timeout. Adding
queuePaletteHydrationResponses() — which the existing Bengle tests already call for this reason —
takes connectedBengle from 12,646 ms to 36 ms. No assertion changed. Re-checked with CI's own
gate: --max-active-ms 20000 exits 0, and the full suite is 3978 passed / 1 skipped / 0 failed.

  • flutter analyze — clean.
  • flutter testfull suite 3978 passed / 1 skipped, run against current main on 5 Sep 2026.
  • dart format — clean on every changed file.
  • Host tests cover the encode path and the refusal path for every mode, including the lever-step
    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.
  • No hardware run is required for the parse, encode and refusal paths.
  • Verified against real firmware. The ProfileModeCaps read and the encode path run against
    the machine in the Decaid-Canary build, which is what Ben uses day to day.

Impact

  • API: assets/api/rest_v1.yml gains the power and lever pump modes, the hold transition,
    the power exit comparand, and extra.profileModeCaps on /api/v1/machine/info. doc/Api.md
    documents all of them.
  • Compatibility: additive. A stored profile using the new types round-trips on a plain DE1. Only
    arming one on a machine without the capability changes behaviour, and that is a new 400.
  • User-visible: a profile that a machine cannot run is now refused with a clear error instead of
    being uploaded and behaving unpredictably.
  • Security: none.

Update — rebased onto main 28 Aug 2026

The lane was rebased from 94873593 onto 82f941d7. Two files conflicted and both resolutions are
unions, not choices:

  • unified_de1.dart — upstream renamed the field _refillKit to _refillKitDetected
    (4b6bc11c, "separate refill-kit detection from override"). This lane keeps upstream's name and
    adds its own _readProfileModeCaps() read and the profileModeCaps entry in extra.
  • assets/api/rest_v1.yml — upstream replaced the free-text extra description with a
    structured schema carrying additionalProperties: true and typed refillKit and voltage
    properties. This lane no longer edits that description. It adds profileModeCaps as a third
    typed property instead, which documents the same bitmask in upstream's own shape.

dart format and flutter analyze are clean on the rebased lane.

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
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>
@ChampionDesigns

Copy link
Copy Markdown
Collaborator Author

The Gate slow test suites failure is fixed. de1handler_profile_modes_test.dart was taking
38.9 s against the 20 s limit, but 42 s of wall time against 4 s of CPU — idle waiting, not work.
Bengle.onConnect hydrates the LED palette and FakeBleTransport had no answer queued, so each
of the three tests waited out the fail-closed read timeout: 12.6 s each.

queuePaletteHydrationResponses() — which the existing Bengle tests already call for this
reason — takes connectedBengle from 12,646 ms to 36 ms. No assertion changed. Verified with
CI's own gate: --max-active-ms 20000 exits 0, and the full suite is 3978 passed / 1 skipped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant