Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions assets/api/rest_v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5628,7 +5628,7 @@ components:
description: >
Machine details read from MMR once at connect. The object stays
open — a firmware or model may contribute keys not listed here —
but these two are always present for a DE1.
but the first two are always present for a DE1.
additionalProperties: true
properties:
refillKit:
Expand All @@ -5649,6 +5649,15 @@ components:
voltage:
type: integer
description: Heater supply voltage in volts, as reported by the machine.
profileModeCaps:
type: integer
description: >
Per-frame profile-step capability bitmask: 0x1 = Power,
0x2 = Lever, 0x4 = HOLD, 0x8 = cross-variable power exit.
0 or absent means the machine cannot run those profile steps,
and arming one — or a power exit — is refused with a 400.
Only firmware that implements the new pump modes defines the
register; stock firmware and every DE1 report 0.
example:
refillKit: true
voltage: 220
Expand Down Expand Up @@ -6020,6 +6029,16 @@ components:
A single step in a brewing profile. Discriminated by the `pump` field:
- `pump: "pressure"` → pressure-controlled step (has `pressure` field)
- `pump: "flow"` → flow-controlled step (has `flow` field)
- `pump: "power"` → constant-hydraulic-power step (has `power` field and
a MANDATORY pressure `limiter`; Bengle only)
- `pump: "lever"` → spring-lever step (uses `pressure` as the starting
pressure P₀, plus `leverSpring` and `leverGive`; Bengle only)

The `power` and `lever` pump modes and the `hold` transition require a
machine that advertises the matching capability. Uploading a profile
that uses one to a machine that does not (a DE1, or firmware without the
capability) is refused with a `400` before any write; the profile still
stores and round-trips unchanged on any machine.
required:
- name
- pump
Expand All @@ -6034,12 +6053,18 @@ components:
description: Step name/identifier
pump:
type: string
enum: [pressure, flow]
description: Step type discriminator. Determines whether `pressure` or `flow` field is used.
enum: [pressure, flow, power, lever]
description: Step type discriminator. Selects which target field applies (`pressure`, `flow`, `power`, or the lever `pressure`/`leverSpring`/`leverGive` trio).
transition:
type: string
enum: [fast, smooth]
description: How the machine transitions to this step's target
enum: [fast, smooth, hold]
description: >-
How the machine transitions to this step's target. `fast` = jump,
`smooth` = ramp. `hold` (capable machines only) has NO authored
target: the firmware latches the value achieved at the exit of the
previous step (for this step's own control variable) and holds it
flat. Arming is refused with a `400` on a machine that does not
advertise the capability, and HOLD may not be the first step.
exit:
$ref: '#/components/schemas/StepExitCondition'
volume:
Expand Down Expand Up @@ -6071,6 +6096,18 @@ components:
type: number
format: double
description: Target flow rate in ml/s (present when pump=flow)
power:
type: number
format: double
description: Target hydraulic power in watts (present when pump=power; requires a pressure `limiter`)
leverSpring:
type: number
format: double
description: Lever spring rate k_V in bar per 10 ml delivered (present when pump=lever)
leverGive:
type: number
format: double
description: Lever give R_s in bar per ml/s (present when pump=lever)
limiter:
$ref: '#/components/schemas/StepLimiter'

Expand All @@ -6084,8 +6121,14 @@ components:
properties:
type:
type: string
enum: [pressure, flow]
description: Which measurement to evaluate
enum: [pressure, flow, power]
description: >-
Which measurement to evaluate. `power` is hydraulic watts
(0.1 * pressure * flow) and requires a capable machine
(ProfileModeCaps bit3 = 0x8); arming a power exit on a machine
without it is refused with a 400. The exit variable is normally the
step's non-own variable (e.g. a pressure step exits on flow or
power).
condition:
type: string
enum: [over, under]
Expand Down
56 changes: 56 additions & 0 deletions assets/defaultProfiles/lever_classic_demo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"title": "Lever Classic demo",
"author": "reaprime",
"notes": "Demonstration of the lever pump mode. A quick flow fill primes the puck and exits on first pressure, a short bloom pause lets it saturate, then a classic lever step starts at 9 bar and declines under its spring (0.9) and give (1.5) as volume is delivered — like pulling a manual lever machine. Requires a machine that supports lever profile steps; a stock machine will refuse it.",
"beverage_type": "espresso",
"steps": [
{
"name": "fast fill",
"pump": "flow",
"flow": "12.0",
"sensor": "coffee",
"transition": "fast",
"temperature": "92.0",
"seconds": "20",
"volume": "0",
"weight": "0.0",
"exit": {
"type": "pressure",
"value": "3.0",
"condition": "over"
}
},
{
"name": "bloom",
"pump": "pressure",
"pressure": "1.5",
"sensor": "coffee",
"transition": "fast",
"temperature": "92.0",
"seconds": "15",
"volume": "0",
"weight": "0.0"
},
{
"name": "lever",
"pump": "lever",
"pressure": "9.0",
"leverSpring": "0.9",
"leverGive": "1.5",
"sensor": "coffee",
"transition": "fast",
"temperature": "92.0",
"seconds": "60",
"volume": "0",
"weight": "0.0"
}
],
"tank_temperature": "90",
"target_weight": "36",
"target_volume": "0",
"target_volume_count_start": "0",
"type": "advanced",
"lang": "en",
"hidden": "0",
"version": "2"
}
1 change: 1 addition & 0 deletions assets/defaultProfiles/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"filter3.json",
"icbinf.json",
"kalita_20.json",
"lever_classic_demo.json",
"manual_flow.json",
"manual_pressure.json",
"psph.json",
Expand Down
15 changes: 15 additions & 0 deletions doc/Api.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,21 @@ Profile updates use tri-state patch semantics: omitting `metadata` preserves it,
`metadata: null` clears it, and an object replaces it. The profile itself is
non-nullable; `profile: null` returns `400`.

#### Extended step types (capability-gated)

A profile step may set `pump: "power"` — a constant-hydraulic-power step carrying a `power` field and
a mandatory pressure `limiter` — or `pump: "lever"`, a spring-lever step that reads `pressure` as its
starting pressure P₀ and adds `leverSpring` and `leverGive`. A step may also set
`transition: "hold"`, which carries no authored target: the firmware latches the value the previous
step reached for this step's own control variable and holds it flat. HOLD may not be the first step.
`exit` conditions gain `power` as a third cross-variable comparand alongside pressure and flow.

Every machine stores and round-trips these profiles unchanged, a plain DE1 included. Running one
needs firmware that advertises the matching capability. `GET /api/v1/machine/info` reports the
bitmask as `extra.profileModeCaps`: `0x1` Power, `0x2` Lever, `0x4` HOLD, `0x8` cross-variable power
exit. An absent or zero mask means the machine cannot run those steps, and
`POST /api/v1/machine/profile` refuses the upload with a `400` before it writes anything.

### Workflow

| Method | Path | Description | Handler |
Expand Down
9 changes: 9 additions & 0 deletions lib/src/controllers/step_exit_arbiter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ class StepExitArbiter {

static const double flowProximityFraction = 0.25;

/// Proximity window as fraction of power exit threshold.
/// Power = 0.1*P*F is a product of two noisy signals, so its window mirrors
/// the flow fraction (the wider of the two P/F windows).
static const double powerProximityFraction = 0.25;

static const double pressureProximityMinimum = 0.3;
static const double flowProximityMinimum = 0.2;
static const double powerProximityMinimum = 0.5; // W

final Map<int, _DeferralState> _deferrals = {};

Expand All @@ -34,6 +40,7 @@ class StepExitArbiter {
final sensorValue = switch (exit.type) {
ExitType.pressure => currentPressure,
ExitType.flow => currentFlow,
ExitType.power => 0.1 * currentPressure * currentFlow,
};

final distance = switch (exit.condition) {
Expand Down Expand Up @@ -65,10 +72,12 @@ class StepExitArbiter {
final proximityFraction = switch (exit.type) {
ExitType.pressure => pressureProximityFraction,
ExitType.flow => flowProximityFraction,
ExitType.power => powerProximityFraction,
};
final proximityMinimum = switch (exit.type) {
ExitType.pressure => pressureProximityMinimum,
ExitType.flow => flowProximityMinimum,
ExitType.power => powerProximityMinimum,
};
final proximityThreshold = (exit.value * proximityFraction).clamp(
proximityMinimum,
Expand Down
39 changes: 39 additions & 0 deletions lib/src/controllers/workflow_device_sync.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ class WorkflowDeviceSync {

Profile? _lastPushedProfile;
Profile? _desiredProfile;

/// The profile the connected machine PERMANENTLY refused — a
/// [ProfileModeUnsupportedException] (missing capability, or not a Bengle).
/// Unlike a transient BLE-write failure, retrying can never succeed for the
/// current connection, so the drain parks on it (no retry timer) and skips
/// re-attempting the same profile. Cleared when the workflow profile changes
/// ([_onChange]), the machine reconnects ([_onInitSettled]) or disconnects
/// ([_onDe1Change]) — any may resolve the refusal (a different profile, or a
/// reconnect to firmware that now advertises the capability, which the
/// reconnect push re-drives).
Profile? _refusedProfile;

bool _uploading = false;
Timer? _retryTimer;
int _attempt = 0;
Expand All @@ -60,6 +72,9 @@ class WorkflowDeviceSync {
}
_desiredProfile = next;
_attempt = 0;
// A genuine profile change clears any parked capability refusal so the new
// profile gets a fresh attempt.
_refusedProfile = null;
_cancelRetry();
unawaited(_drain());
}
Expand All @@ -69,6 +84,9 @@ class WorkflowDeviceSync {
_lastPushedProfile = null;
_desiredProfile = _workflow.currentWorkflow.profile;
_attempt = 0;
// A reconnect may be to firmware that now advertises the capability, so
// clear any parked refusal and let the push re-drive.
_refusedProfile = null;
_cancelRetry();
unawaited(_drain());
}
Expand All @@ -84,6 +102,12 @@ class WorkflowDeviceSync {
_desiredProfile = null;
return;
}
// Parked on a permanent capability refusal. Do not re-attempt — only a
// workflow-profile change or a reconnect (both clear _refusedProfile
// and re-drive) can resolve it.
if (profile == _refusedProfile) {
return;
}
try {
await _de1.runDeviceWrite((device) => device.setProfile(profile));
if (generation != _generation) return;
Expand All @@ -93,6 +117,20 @@ class WorkflowDeviceSync {
_errorSurfaced = false;
onUploadErrorCleared?.call();
}
} on ProfileModeUnsupportedException catch (e) {
if (generation != _generation) return;
// The machine's firmware cannot run the pump step types / transitions
// in this profile. The refusal is permanent for the connection (the
// gate throws before any BLE write, so nothing is wedged), so PARK —
// no retry timer — and remember the profile so a spurious re-drive
// doesn't re-attempt it. A workflow-profile change or a reconnect
// clears _refusedProfile and re-drives.
_refusedProfile = profile;
_lastPushedProfile = null;
_log.warning(
'setProfile refused (unsupported pump step types): ${e.message}',
);
return;
} on DeviceNotConnectedException {
_log.fine('DE1 not connected; skipping profile push');
return;
Expand Down Expand Up @@ -156,6 +194,7 @@ class WorkflowDeviceSync {
_cancelRetry();
_desiredProfile = null;
_lastPushedProfile = null;
_refusedProfile = null;
_attempt = 0;
if (_errorSurfaced) {
_errorSurfaced = false;
Expand Down
Loading
Loading