diff --git a/assets/api/rest_v1.yml b/assets/api/rest_v1.yml index 9823a951e..ba3a4dfca 100644 --- a/assets/api/rest_v1.yml +++ b/assets/api/rest_v1.yml @@ -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: @@ -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 @@ -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 @@ -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: @@ -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' @@ -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] diff --git a/assets/defaultProfiles/lever_classic_demo.json b/assets/defaultProfiles/lever_classic_demo.json new file mode 100644 index 000000000..6e8607b16 --- /dev/null +++ b/assets/defaultProfiles/lever_classic_demo.json @@ -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" +} diff --git a/assets/defaultProfiles/manifest.json b/assets/defaultProfiles/manifest.json index d6b06a4ce..31c400b3d 100644 --- a/assets/defaultProfiles/manifest.json +++ b/assets/defaultProfiles/manifest.json @@ -57,6 +57,7 @@ "filter3.json", "icbinf.json", "kalita_20.json", + "lever_classic_demo.json", "manual_flow.json", "manual_pressure.json", "psph.json", diff --git a/doc/Api.md b/doc/Api.md index 0a5ed02fb..396c59c9a 100644 --- a/doc/Api.md +++ b/doc/Api.md @@ -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 | diff --git a/lib/src/controllers/step_exit_arbiter.dart b/lib/src/controllers/step_exit_arbiter.dart index a2ac4ed25..8eee885bf 100644 --- a/lib/src/controllers/step_exit_arbiter.dart +++ b/lib/src/controllers/step_exit_arbiter.dart @@ -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 _deferrals = {}; @@ -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) { @@ -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, diff --git a/lib/src/controllers/workflow_device_sync.dart b/lib/src/controllers/workflow_device_sync.dart index acf646fdf..79a4ac8e7 100644 --- a/lib/src/controllers/workflow_device_sync.dart +++ b/lib/src/controllers/workflow_device_sync.dart @@ -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; @@ -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()); } @@ -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()); } @@ -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; @@ -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; @@ -156,6 +194,7 @@ class WorkflowDeviceSync { _cancelRetry(); _desiredProfile = null; _lastPushedProfile = null; + _refusedProfile = null; _attempt = 0; if (_errorSurfaced) { _errorSurfaced = false; diff --git a/lib/src/models/data/profile.dart b/lib/src/models/data/profile.dart index 90e5152af..7ca4928ff 100644 --- a/lib/src/models/data/profile.dart +++ b/lib/src/models/data/profile.dart @@ -135,11 +135,32 @@ BeverageType _parseBeverageType(dynamic value) { return BeverageType.espresso; } -enum TransitionType { fast, smooth } +// A HOLD step carries no user-selected target — at frame entry the firmware +// latches the value the machine actually ACHIEVED at the exit of the previous +// step (for the step's OWN control variable) and holds it flat for the frame. +// HOLD is encoded as a NEW enum value (not an additive boolean key) +// deliberately: an old client's `TransitionType.values.byName('hold')` THROWS +// an ArgumentError -> a VISIBLE 400 at the REST boundary, whereas an unknown +// additive key would be silently DROPPED on the toJson round-trip and +// re-uploaded as a plain JUMP (forbidden — a silent target jump). A client that +// knows `hold` round-trips it losslessly on ANY machine (a stock DE1 included) +// via `.name`/`byName`; only execution/arming and the editor UI are gated by +// the machine's advertised capability. The target field is ignored at +// execution and stored as 0 so the base-frame fallback encodes to a benign +// vent. +enum TransitionType { fast, smooth, hold } enum TemperatureSensor { coffee, water } -enum ExitType { pressure, flow } +// `power` (hydraulic watts, W = 0.1 * pressure * flow) is a NEW enum value, not +// an additive boolean/key, for the same skew reason as TransitionType.hold: an +// old client's `ExitType.values.byName('power')` THROWS an ArgumentError -> a +// VISIBLE 400 at the REST boundary, whereas a dropped additive key would round- +// trip to a silent pressure/flow exit (a wrong early exit — forbidden). A client +// that knows `power` round-trips it losslessly on ANY machine (a stock DE1 +// included) via `.name`/`byName`; only encoding/arming is gated by the machine's +// advertised power-exit capability. +enum ExitType { pressure, flow, power } enum ExitCondition { over, under } @@ -194,6 +215,13 @@ abstract class ProfileStep extends Equatable { return ProfileStepPressure.fromJson(json); } else if (json.containsKey('pump') && json['pump'] == 'flow') { return ProfileStepFlow.fromJson(json); + // Additive per-frame pump modes. Bengle-only at execution time (gated by + // the capability read + arm-time refusal), but parsed and round-tripped + // everywhere so stored profiles stay machine-independent. + } else if (json.containsKey('pump') && json['pump'] == 'power') { + return ProfileStepPower.fromJson(json); + } else if (json.containsKey('pump') && json['pump'] == 'lever') { + return ProfileStepLever.fromJson(json); } else { throw Exception( 'Invalid step type. Must include either "pressure" or "flow".', @@ -380,6 +408,211 @@ class ProfileStepFlow extends ProfileStep { ]; } +/// Constant-hydraulic-power pump step. Mirrors [ProfileStepPressure] exactly, +/// with `power` (hydraulic watts, W = 0.1·P·F) as the target and a MANDATORY +/// pressure `limiter` (the over-pressure cap the firmware shaper enforces). A +/// power step with no/zero limiter is a schema violation: `fromJson` throws a +/// [FormatException] the REST handlers map to a 400. `getTarget()` returns the +/// power target (the base-frame SetVal, which capable firmware reinterprets as +/// watts). +class ProfileStepPower extends ProfileStep { + final double power; + + const ProfileStepPower({ + required super.name, + required super.transition, + super.exit, + required super.volume, + required super.seconds, + super.weight, + required super.temperature, + required super.sensor, + super.limiter, + required this.power, + }); + + @override + double getTarget() => power; + + factory ProfileStepPower.fromJson(Map json) { + // The pressure limiter is the shaper's mandatory over-pressure cap: a + // power step is meaningless (and unsafe to send) without it. + final limiterJson = json['limiter']; + final limiter = limiterJson != null + ? StepLimiter.fromJson(limiterJson) + : null; + if (limiter == null || limiter.value == 0) { + throw const FormatException('power step requires a pressure limiter'); + } + return ProfileStepPower( + name: json['name'], + transition: TransitionType.values.byName(json['transition']), + exit: json['exit'] != null + ? StepExitCondition.fromJson(json['exit']) + : null, + volume: parseDouble(json['volume']), + seconds: parseDouble(json['seconds']), + weight: parseOptionalDouble(json['weight']), + temperature: parseDouble(json['temperature']), + sensor: TemperatureSensor.values.byName(json['sensor']), + limiter: limiter, + power: parseDouble(json['power']), + ); + } + + @override + Map toJson() { + final data = { + 'name': name, + 'pump': 'power', + 'transition': transition.name, + 'exit': exit?.toJson(), + 'volume': volume, + 'seconds': seconds, + 'weight': weight, + 'temperature': temperature, + 'sensor': sensor.name, + 'power': power, + 'limiter': limiter?.toJson(), + }; + return data; + } + + @override + ProfileStep copyWith({double? temperature}) { + return ProfileStepPower( + name: name, + transition: transition, + exit: exit, + volume: volume, + seconds: seconds, + weight: weight, + temperature: temperature ?? this.temperature, + sensor: sensor, + power: power, + limiter: limiter, + ); + } + + @override + List get props => [ + name, + transition, + exit, + volume, + seconds, + weight, + temperature, + sensor, + power, + limiter, + ]; +} + +/// Spring-lever pump step. Mirrors [ProfileStepPressure] exactly, reusing +/// `pressure` for P₀ (the lever's starting pressure). `leverSpring` (k_V: bar +/// per 10 mL delivered) and `leverGive` (R_s: bar per mL/s) shape the pressure +/// decline the firmware computes. The `limiter` is an OPTIONAL flow cap (the +/// stock max-flow machinery). `getTarget()` returns P₀. +class ProfileStepLever extends ProfileStep { + final double pressure; + final double leverSpring; + final double leverGive; + + const ProfileStepLever({ + required super.name, + required super.transition, + super.exit, + required super.volume, + required super.seconds, + super.weight, + required super.temperature, + required super.sensor, + super.limiter, + required this.pressure, + required this.leverSpring, + required this.leverGive, + }); + + @override + double getTarget() => pressure; + + factory ProfileStepLever.fromJson(Map json) { + return ProfileStepLever( + name: json['name'], + transition: TransitionType.values.byName(json['transition']), + exit: json['exit'] != null + ? StepExitCondition.fromJson(json['exit']) + : null, + volume: parseDouble(json['volume']), + seconds: parseDouble(json['seconds']), + weight: parseOptionalDouble(json['weight']), + temperature: parseDouble(json['temperature']), + sensor: TemperatureSensor.values.byName(json['sensor']), + limiter: json['limiter'] != null + ? StepLimiter.fromJson(json['limiter']) + : null, + pressure: parseDouble(json['pressure']), + leverSpring: parseDouble(json['leverSpring']), + leverGive: parseDouble(json['leverGive']), + ); + } + + @override + Map toJson() { + final data = { + 'name': name, + 'pump': 'lever', + 'transition': transition.name, + 'exit': exit?.toJson(), + 'volume': volume, + 'seconds': seconds, + 'weight': weight, + 'temperature': temperature, + 'sensor': sensor.name, + 'pressure': pressure, + 'leverSpring': leverSpring, + 'leverGive': leverGive, + 'limiter': limiter?.toJson(), + }; + return data; + } + + @override + ProfileStep copyWith({double? temperature}) { + return ProfileStepLever( + name: name, + transition: transition, + exit: exit, + volume: volume, + seconds: seconds, + weight: weight, + temperature: temperature ?? this.temperature, + sensor: sensor, + pressure: pressure, + leverSpring: leverSpring, + leverGive: leverGive, + limiter: limiter, + ); + } + + @override + List get props => [ + name, + transition, + exit, + volume, + seconds, + weight, + temperature, + sensor, + pressure, + leverSpring, + leverGive, + limiter, + ]; +} + class StepExitCondition extends Equatable { final ExitType type; final ExitCondition condition; diff --git a/lib/src/models/device/impl/de1/de1.models.dart b/lib/src/models/device/impl/de1/de1.models.dart index 874f74520..e6256d6a9 100644 --- a/lib/src/models/device/impl/de1/de1.models.dart +++ b/lib/src/models/device/impl/de1/de1.models.dart @@ -350,7 +350,21 @@ enum MMRItem implements MmrAddress { allowUSBCharging(0x00803854, 4, MmrValueKind.boolean, "Allow USB charging"), appFeatureFlags(0x00803858, 4, MmrValueKind.int32, "App Feature Flags"), refillKitPresent(0x0080385C, 4, MmrValueKind.int32, "Refill Kit Present"), - userPresent(0x00803860, 4, MmrValueKind.boolean, "Is User Present"); + userPresent(0x00803860, 4, MmrValueKind.boolean, "Is User Present"), + // Per-frame Power/Lever/HOLD/power-exit capability bitmask (Bengle-only, + // read-only): bit0 = Power supported, bit1 = Lever supported, bit2 = per-frame + // HOLD supported, bit3 = cross-variable power exit supported. A Power/Lever + // machine returns 0x3, a HOLD-capable machine 0x7, a power-exit-capable + // machine 0xF. Absent on stock firmware and every DE1 — the onConnect read + // fails-closed to 0 (see `UnifiedDe1._readProfileModeCaps`). Never written by + // the app. + profileModeCaps( + 0x008038DC, + 4, + MmrValueKind.int32, + "ProfileModeCaps bitmask: 0x1 = per-frame Power, 0x2 = per-frame Lever, " + "0x4 = per-frame HOLD, 0x8 = cross-variable power exit", + ); @override final int address; diff --git a/lib/src/models/device/impl/de1/unified_de1/unified_de1.dart b/lib/src/models/device/impl/de1/unified_de1/unified_de1.dart index a281bd9d4..2f0624296 100644 --- a/lib/src/models/device/impl/de1/unified_de1/unified_de1.dart +++ b/lib/src/models/device/impl/de1/unified_de1/unified_de1.dart @@ -271,6 +271,16 @@ class UnifiedDe1 implements De1Interface { @protected int get connectedModelValue => _connectedModelValue!; + /// True when this machine should be driven as a Bengle — by the class picked + /// at discovery OR by the v13Model the firmware reported. Either alone is + /// insufficient: a `Bengle` instance has not read v13Model before onConnect, + /// and a name-picked `UnifiedDe1` that turns out to report v13Model >= 128 + /// still speaks protocol v2 on the wire. Reads false until onConnect has + /// learned the model, which is why the profile gate is fail-closed. + bool get isBengle => + isBengleModelValue(_connectedModelValue ?? 0) || + implementation == DeviceImplementation.bengle; + @override Future onConnect() async { initRawStream(); @@ -298,6 +308,13 @@ class UnifiedDe1 implements De1Interface { _refillKitDetected = _unpackMMRInt( await _mmrRead(MMRItem.refillKitPresent), ); + // Per-frame Power/Lever/HOLD/power-exit capability bitmask. Only firmware + // that implements the new pump modes defines this register; stock firmware + // and every DE1 do not. ANY failure — timeout, short buffer, or a stray word + // with bits outside 0xF — must yield 0, so the read can never hang or fail + // the connect flow and the arm-time refusal gate then fail-closes on any + // new-mode step. + final profileModeCaps = await _readProfileModeCaps(); try { _cachedFlowEstimation = await getFlowEstimation(); } catch (e) { @@ -312,6 +329,9 @@ class UnifiedDe1 implements De1Interface { extra: { 'refillKit': (_refillKitDetected & 0x01) != 0, 'voltage': _voltage, + // Surfaced through /api/v1/machine/info and read by the + // profile-upload refusal gate. + 'profileModeCaps': profileModeCaps, }, ); @@ -323,6 +343,39 @@ class UnifiedDe1 implements De1Interface { await enableUserPresenceFeature(); } + /// Outer bound on the ProfileModeCaps read. Long enough to let one internal + /// MMR read attempt resolve, short enough to bail before the retry budget — + /// so a machine WITHOUT the register (stock firmware, every DE1) settles to + /// caps 0 in ~one attempt instead of stalling the connect flow for the full + /// MMR-read retry budget. + static const _profileModeCapsReadTimeout = Duration(milliseconds: 4500); + + /// Read the ProfileModeCaps bitmask, fail-closed to 0. + /// Every failure mode collapses to 0 (no new modes offered): a missing + /// register (timeout/omit on firmware without the capability), a short + /// buffer, or a stray word with bits outside the defined 0xF mask. The source + /// read's late retry-timeout is swallowed (`catchError`) so it can never + /// surface as an unhandled async error after the outer timeout wins. + /// + /// The mask is 0xF (bit0 Power / bit1 Lever / bit2 HOLD / bit3 power exit) and + /// MUST track the highest defined capability bit: a power-exit-capable machine + /// returns 0xF, and a mask left at 0x7 would treat that legitimate word as + /// garbage and zero it, hiding Power/Lever/HOLD/power-exit entirely. This is + /// why the mask MUST widen to 0xF before any firmware advertises bit3. Bits + /// above 0xF remain undefined and still fail-close to 0. + Future _readProfileModeCaps() async { + try { + final caps = await _mmrRead(MMRItem.profileModeCaps) + .then(_unpackMMRInt) + .catchError((Object _) => 0) + .timeout(_profileModeCapsReadTimeout, onTimeout: () => 0); + if (caps < 0 || (caps & ~0xF) != 0) return 0; + return caps; + } catch (_) { + return 0; + } + } + Future onDisconnect() async {} final StreamController _rawMessageController = @@ -462,7 +515,112 @@ class UnifiedDe1 implements De1Interface { return upload; } + /// Refuse to upload a profile that uses a per-frame Power/Lever step, a HOLD + /// transition, or a cross-variable power exit to a machine that cannot run it + /// — either the device is not a Bengle (the modes reinterpret the base-frame + /// U8D1 SetVal as watts / P0, latch a live measurement for HOLD, or compare + /// hydraulic watts for a power exit — all protocol-v2 semantics) or its + /// firmware did not advertise the matching capability bit (bit0 Power, bit1 + /// Lever, bit2 HOLD, bit3 power exit). A HOLD-capable machine returns 0x7, a + /// power-exit-capable machine 0xF; stock firmware and every DE1 return 0 (see + /// [onConnect]), so this fail-closes. A power exit is an orthogonal per-step + /// property (any step type can carry one), so it gets its own predicate and + /// its own bit — pressure/flow cross-exits stay ungated, since they already + /// run on a stock DE1. Thrown as a [ProfileModeUnsupportedException] (a + /// PERMANENT refusal for the connection) so the REST boundary surfaces a clean + /// 400 instead of a silent mis-command (watts read as bar, a P0 as constant + /// pressure), and so `WorkflowDeviceSync` parks instead of retrying forever. + void _assertProfileModeSupported(Profile profile) { + final hasPower = profile.steps.any((s) => s is ProfileStepPower); + final hasLever = profile.steps.any((s) => s is ProfileStepLever); + // A HOLD step is any pressure/flow/power step with `transition:hold`. A + // LEVER step never takes HOLD (the editor forces JUMP on lever, and the + // encoder writes it as a plain lever), so it is covered by [hasLever]. + final hasHold = profile.steps.any( + (s) => s.transition == TransitionType.hold && s is! ProfileStepLever, + ); + // A power exit is orthogonal to the step type: any step may carry one. + final hasPowerExit = profile.steps.any( + (s) => s.exit?.type == ExitType.power, + ); + if (!hasPower && !hasLever && !hasHold && !hasPowerExit) return; + + // HOLD on the FIRST step is invalid on EVERY machine — there is no previous + // step whose achieved value could be latched. Refuse it up front (before + // the caps/Bengle checks); the editor also disables HOLD on step 1, and the + // firmware falls back to the benign base frame as a last resort. + if (hasHold && + profile.steps.isNotEmpty && + profile.steps.first.transition == TransitionType.hold && + profile.steps.first is! ProfileStepLever) { + throw ProfileModeUnsupportedException( + 'The first step of profile "${profile.title}" uses a HOLD transition, ' + 'but HOLD latches the value achieved at the exit of the PREVIOUS step ' + 'and the first step has no previous step. Remove HOLD from the first ' + 'step (it can only follow another step).', + ); + } + + // "Power", "Power and Lever", "Power, Lever and HOLD". + String andJoin(List xs) => xs.length <= 1 + ? xs.join() + : '${xs.sublist(0, xs.length - 1).join(', ')} and ${xs.last}'; + // Describe each refused capability with the RIGHT noun: Power and Lever are + // pump MODES, HOLD is a TRANSITION, a power exit is an EXIT CONDITION (never + // a "pump mode"). e.g. "Power and Lever pump modes and the HOLD transition + // and the power exit condition". + String describe(List pumpModes, bool hold, bool powerExit) { + final parts = [ + if (pumpModes.isNotEmpty) + '${andJoin(pumpModes)} pump mode${pumpModes.length > 1 ? 's' : ''}', + if (hold) 'HOLD transition', + if (powerExit) 'power exit condition', + ]; + return parts.join(' and the '); + } + + // Refuse a non-Bengle BEFORE any BLE write — these modes are protocol-v2 by + // definition. This also closes a garbage-caps hole: a stock DE1 whose + // out-of-range MMR read of the register happened to answer with a mask that + // passes the check below would otherwise reach the encoder's ext-loop guard + // only AFTER header + base frames were on the wire, stranding a half-written + // profile. The ext-loop isBengle guard stays as belt-and-suspenders. + if (!isBengle) { + final desc = describe( + [if (hasPower) 'Power', if (hasLever) 'Lever'], + hasHold, + hasPowerExit, + ); + final count = + (hasPower ? 1 : 0) + + (hasLever ? 1 : 0) + + (hasHold ? 1 : 0) + + (hasPowerExit ? 1 : 0); + throw ProfileModeUnsupportedException( + 'This machine is not a Bengle; the $desc used by profile ' + '"${profile.title}" require${count == 1 ? 's' : ''} Bengle firmware ' + '(protocol v2).', + ); + } + final caps = (machineInfo.extra['profileModeCaps'] as int?) ?? 0; + final missingModes = []; + if (hasPower && (caps & 0x1) == 0) missingModes.add('Power'); + if (hasLever && (caps & 0x2) == 0) missingModes.add('Lever'); + final missingHold = hasHold && (caps & 0x4) == 0; + final missingPowerExit = hasPowerExit && (caps & 0x8) == 0; + if (missingModes.isEmpty && !missingHold && !missingPowerExit) return; + throw ProfileModeUnsupportedException( + 'This machine does not support the ' + '${describe(missingModes, missingHold, missingPowerExit)} ' + 'used by profile "${profile.title}" — the firmware did not advertise the ' + 'capability (ProfileModeCaps bit missing). Update the machine firmware ' + 'to run these profiles.', + ); + } + Future _uploadProfileLocked(Profile profile) async { + // Fail-closed before any BLE write. + _assertProfileModeSupported(profile); if (_currentProfile == profile) { return; } diff --git a/lib/src/models/device/impl/de1/unified_de1/unified_de1.profile.dart b/lib/src/models/device/impl/de1/unified_de1/unified_de1.profile.dart index f28e4464d..c929809da 100644 --- a/lib/src/models/device/impl/de1/unified_de1/unified_de1.profile.dart +++ b/lib/src/models/device/impl/de1/unified_de1/unified_de1.profile.dart @@ -1,5 +1,12 @@ part of 'unified_de1.dart'; +/// Bengle firmware (BLE protocol v2) supports flow rates up to 20 ml/s, +/// versus the DE1's 8 ml/s. de1plus raises `max_flowrate` to 20 when +/// `use_ble_v2` is negotiated (`de1_de1.tcl:778-782`). reaprime is headless +/// — there is no UI slider to bound a flow input — so the profile encoder +/// is the enforcement point for this ceiling. +const double _bengleMaxFlowMlPerSec = 20.0; + extension UnifiedDe1Profile on UnifiedDe1 { Future _sendProfile(Profile profile) async { await _writeHeader(profile); @@ -8,8 +15,29 @@ extension UnifiedDe1Profile on UnifiedDe1 { await _writeMMRInt(MMRItem.tankTemp, profile.tankTemperature.round()); } + /// Encode a flow (ml/s) or pressure (bar) profile field to its wire byte, + /// honouring the negotiated protocol version. + /// + /// A Bengle decodes these as **U8D1** (`byte × 0.1`, range 0..25.5); a DE1 + /// uses **U8P4** (`byte / 16`, range 0..15.9375). Encoding a v2 value with + /// the v1 scale silently mis-commands the machine (`6 ml/s` written as the + /// v1 `96` reads back as `9.6` on a Bengle). Only the Bengle U8D1 path clamps + /// (to 0..25.5); the DE1 U8P4 path is unchanged from stock reaprime and wraps + /// mod-256 above 15.9375 — harmless in practice, as DE1 flow/pressure stays + /// well within range. Mirrors de1plus `convert_float_to_flow_pressure_byte` + /// (`binary.tcl`). + int _encodeFlowPressure(double value) => isBengle + ? Helper.convert_float_to_U8D1(value) + : (0.5 + value * 16.0).toInt(); + + /// True iff [step] is a HOLD step that carries an ext HOLD Mode byte (3/4/5). + /// Pressure/flow/power steps honour `transition:hold`; a LEVER step never + /// does (the editor forces JUMP on lever), so a `hold` marker on a lever step + /// is treated as a plain lever (Mode 2), NOT HOLD. + bool _isHoldStep(ProfileStep step) => + step.transition == TransitionType.hold && step is! ProfileStepLever; + Future _writeHeader(Profile profile) async { - final isBengle = implementation == DeviceImplementation.bengle; final scale = isBengle ? 10.0 : 16.0; Uint8List data = Uint8List(5); @@ -29,7 +57,6 @@ extension UnifiedDe1Profile on UnifiedDe1 { } Future _writeSteps(Profile profile) async { - final isBengle = implementation == DeviceImplementation.bengle; final scale = isBengle ? 10.0 : 16.0; for (var i = 0; i < profile.steps.length; i++) { @@ -44,7 +71,25 @@ extension UnifiedDe1Profile on UnifiedDe1 { index++; data[index] = Helper.convertProfileFlags(step); index++; - data[index] = (0.5 + step.getTarget() * scale).toInt(); + // SetVal: the target flow (ml/s) or pressure (bar). U8D1 on a + // Bengle, U8P4 on a DE1. A flow-priority target is clamped to the Bengle + // 20 ml/s ceiling first (pressure targets ride the U8D1 0..25.5 clamp). + double setVal = step.getTarget(); + // A HOLD step has NO authored target — the firmware latches the previous + // step's achieved value at frame entry. Its base-frame SetVal is pinned to + // 0 so that on firmware without the HOLD modes, the ext `Mode>=3` legacy + // fallback runs this base frame as a benign vent/pause rather than a stale + // target. LEVER never takes HOLD (editor forces JUMP), so a lever step's + // `hold` transition keeps its P0 SetVal. + if (_isHoldStep(step)) { + setVal = 0.0; + } + if (isBengle && + step is ProfileStepFlow && + setVal > _bengleMaxFlowMlPerSec) { + setVal = _bengleMaxFlowMlPerSec; + } + data[index] = _encodeFlowPressure(setVal); index++; data[index] = (0.5 + step.temperature * 2.0).toInt(); index++; @@ -64,6 +109,120 @@ extension UnifiedDe1Profile on UnifiedDe1 { data[0] = stepIndex; + // A HOLD step ALWAYS emits an ext frame carrying its dedicated HOLD Mode + // byte (3=pressure, 4=flow, 5=power) — even a plain pressure/flow step + // with no limiter, which the legacy limiter-null branch below would + // otherwise skip. The Mode byte is HOLD's own marker (NOT the base + // interpolate bit): firmware without the HOLD modes runs any Mode>=3 + // through its existing legacy fallback (the benign base frame), which is + // HOLD's safe-degrade. These modes assume Bengle protocol v2, so refuse to + // encode one for a DE1 (the arm-time gate blocks this earlier; this is the + // wire-boundary belt-and-suspenders). Checked BEFORE the Power/Lever + // branch so a HOLD-power step emits Mode 5, not Mode 1. + if (_isHoldStep(step)) { + if (!isBengle) { + throw StateError( + 'The HOLD transition requires Bengle firmware (protocol v2); ' + 'refusing to encode step "${step.name}" for a DE1.', + ); + } + if (step is ProfileStepPressure) { + // HOLD-pressure (Mode 3): pass the optional flow limiter through + // data[1]/[2] (else 0/0); MaxFlow stays live over the held pressure. + final limiter = step.limiter; + final hasFlowCap = limiter != null && limiter.value != 0; + data[1] = hasFlowCap ? _encodeFlowPressure(limiter.value) : 0; + data[2] = hasFlowCap ? _encodeFlowPressure(limiter.range) : 0; + data[3] = 3; // Mode = HOLD-pressure + data[4] = 0; + data[5] = 0; + data[6] = 0; + data[7] = 0; + } else if (step is ProfileStepFlow) { + // HOLD-flow (Mode 4): pass the optional pressure limiter through + // data[1]/[2] (else 0/0); MaxPressure stays live over the held flow. + final limiter = step.limiter; + final hasPressureCap = limiter != null && limiter.value != 0; + data[1] = hasPressureCap ? _encodeFlowPressure(limiter.value) : 0; + data[2] = hasPressureCap ? _encodeFlowPressure(limiter.range) : 0; + data[3] = 4; // Mode = HOLD-flow + data[4] = 0; + data[5] = 0; + data[6] = 0; + data[7] = 0; + } else if (step is ProfileStepPower) { + // HOLD-power (Mode 5): mandatory pressure cap in data[4] (identical + // to Power). fromJson guarantees the limiter, but never emit a HOLD + // Mode-5 frame without its cap. + final limiter = step.limiter; + if (limiter == null || limiter.value == 0) { + throw StateError( + 'HOLD power step "${step.name}" requires a pressure limiter', + ); + } + data[1] = 0; + data[2] = 0; + data[3] = 5; // Mode = HOLD-power + data[4] = _encodeFlowPressure(limiter.value); // ModeMaxP cap + data[5] = 0; + data[6] = 0; + data[7] = 0; + } + + await _transport.writeWithResponse(Endpoint.frameWrite, data); + continue; + } + + // Power/Lever steps ALWAYS emit an ext frame carrying the mode + shaper + // params — the limiter-null skip below must NOT apply to them. These modes + // assume Bengle protocol v2 (the base-frame U8D1 SetVal reinterpreted as + // watts / P0), so they cannot exist on a DE1: refuse to encode one (the + // refusal gate blocks this earlier; this is the belt-and-suspenders at the + // wire boundary). + if (step is ProfileStepPower || step is ProfileStepLever) { + if (!isBengle) { + throw StateError( + 'Power/Lever pump modes require Bengle firmware (protocol v2); ' + 'refusing to encode step "${step.name}" for a DE1.', + ); + } + if (step is ProfileStepPower) { + final limiter = step.limiter; + if (limiter == null || limiter.value == 0) { + // fromJson guarantees this, but never emit a Mode-1 frame without + // its mandatory over-pressure cap. + throw StateError( + 'power step "${step.name}" requires a pressure limiter', + ); + } + // data[1]/[2]: no flow cap for Power. + data[1] = 0; + data[2] = 0; + data[3] = 1; // Mode = Power + data[4] = _encodeFlowPressure(limiter.value); // ModeMaxP = cap + data[5] = 0; // LeverSpring n/a + data[6] = 0; // LeverGive n/a + data[7] = 0; + } else if (step is ProfileStepLever) { + // data[1]/[2]: optional flow cap (stock max-flow machinery), else 0/0. + final limiter = step.limiter; + final hasFlowCap = limiter != null && limiter.value != 0; + data[1] = hasFlowCap ? _encodeFlowPressure(limiter.value) : 0; + data[2] = hasFlowCap ? _encodeFlowPressure(limiter.range) : 0; + data[3] = 2; // Mode = Lever + // ModeMaxP = P0 — byte-identical to this step's base-frame SetVal + // (getTarget() == pressure, no flow clamp on a non-flow step). + data[4] = _encodeFlowPressure(step.getTarget()); + // k_V and R_s are always U8D1 (Bengle-native, gated to Bengle above). + data[5] = Helper.convert_float_to_U8D1(step.leverSpring); + data[6] = Helper.convert_float_to_U8D1(step.leverGive); + data[7] = 0; + } + + await _transport.writeWithResponse(Endpoint.frameWrite, data); + continue; + } + if (step.limiter == null || step.limiter?.value == 0) { continue; } @@ -107,6 +266,17 @@ class Helper { } } + /// U8D1: unsigned byte, scale ×0.1 (range 0..25.5, step 0.1) — the Bengle + /// (BLE protocol v2) flow/pressure encoding. Clamps to the byte range so + /// out-of-range values saturate instead of wrapping. Uses round-half-up (to + /// match the `+ 0.5` truncation the v1 path uses). Mirrors de1plus + /// `convert_float_to_U8D1` (`binary.tcl`). + // ignore: non_constant_identifier_names + static int convert_float_to_U8D1(double x) { + final clamped = x < 0.0 ? 0.0 : (x > 25.5 ? 25.5 : x); + return (clamped * 10).round(); + } + // ignore: non_constant_identifier_names static int convert_float_to_F8_1_7(double x) { if (x == 0) { @@ -172,19 +342,37 @@ class Helper { static int interpolate = 0x20; // ignore: constant_identifier_names static int ignoreLimit = 0x40; + // ignore: constant_identifier_names + static int comparePower = + 0x80; // Exit when measured hydraulic power (0.1*P*F) crosses TriggerVal static int convertProfileFlags(ProfileStep step) { int flag = ignoreLimit; if (step is ProfileStepFlow) flag |= ctrlF; if (step.sensor == TemperatureSensor.water) flag |= tMixTemp; + // ONLY `smooth` sets the interpolate/ramp bit. `hold` deliberately leaves + // the base frame a JUMP: HOLD is carried by its own ext Mode byte, and the + // base frame is the benign legacy-fallback frame that runs on firmware + // without the HOLD modes. if (step.transition == TransitionType.smooth) flag |= interpolate; if (step.exit != null) { - flag |= doCompare; - - if (step.exit!.type == ExitType.flow) flag |= dcCompF; - if (step.exit!.condition == ExitCondition.over) flag |= dcGT; + if (step.exit!.type == ExitType.power) { + // A power exit uses the independent comparePower bit and deliberately + // does NOT set doCompare or dcCompF: firmware/apps that predate the + // power exit gate the pressure/flow compare block on doCompare, so with + // doCompare clear they skip that block entirely and run the frame to its + // time/volume limits (a benign base frame) rather than comparing a + // watts TriggerVal against pressure/flow and exiting at the wrong time. + // over/under still ride dcGT; TriggerVal (data[5]) is U8D1 watts. + flag |= comparePower; + if (step.exit!.condition == ExitCondition.over) flag |= dcGT; + } else { + flag |= doCompare; + if (step.exit!.type == ExitType.flow) flag |= dcCompF; + if (step.exit!.condition == ExitCondition.over) flag |= dcGT; + } } return flag; diff --git a/lib/src/models/device/impl/mock_de1/mock_de1.dart b/lib/src/models/device/impl/mock_de1/mock_de1.dart index 59aede5ba..b0e643480 100644 --- a/lib/src/models/device/impl/mock_de1/mock_de1.dart +++ b/lib/src/models/device/impl/mock_de1/mock_de1.dart @@ -502,9 +502,11 @@ class MockDe1 implements De1Interface, SimulatedDevice { bool _stepExitConditionMet(ProfileStep step) { final exit = step.exit; if (exit == null || exit.value <= 0) return false; - final reading = exit.type == ExitType.flow - ? _lastSnapshot.flow - : _lastSnapshot.pressure; + final reading = switch (exit.type) { + ExitType.pressure => _lastSnapshot.pressure, + ExitType.flow => _lastSnapshot.flow, + ExitType.power => 0.1 * _lastSnapshot.pressure * _lastSnapshot.flow, + }; return exit.condition == ExitCondition.over ? reading >= exit.value : reading <= exit.value; diff --git a/lib/src/models/errors.dart b/lib/src/models/errors.dart index 9745e3dd9..fa86568c8 100644 --- a/lib/src/models/errors.dart +++ b/lib/src/models/errors.dart @@ -139,3 +139,23 @@ class FirmwareImageValidationException implements Exception { @override String toString() => 'FirmwareImageValidationException: $reason'; } + +/// Thrown by `UnifiedDe1._assertProfileModeSupported` when a profile using a +/// per-frame Power/Lever pump mode or a HOLD transition is uploaded to a +/// machine that cannot run it — either the connected device is not a Bengle +/// (the modes are protocol-v2 by definition), or its firmware did not advertise +/// the matching ProfileModeCaps bit. +/// +/// This refusal is PERMANENT for a given connection: the capability cannot +/// appear without a firmware update followed by a reconnect, so callers must +/// PARK on it rather than retry (`WorkflowDeviceSync` catches this type +/// specifically and stops retrying). The REST boundary maps it to a clean 400 +/// with [message]; a generic `StateError` from elsewhere stays a 500. +class ProfileModeUnsupportedException implements Exception { + final String message; + + const ProfileModeUnsupportedException(this.message); + + @override + String toString() => 'ProfileModeUnsupportedException: $message'; +} diff --git a/lib/src/services/webserver/de1handler.dart b/lib/src/services/webserver/de1handler.dart index c173524fc..d7bb3eb77 100644 --- a/lib/src/services/webserver/de1handler.dart +++ b/lib/src/services/webserver/de1handler.dart @@ -856,22 +856,52 @@ class De1Handler { } Future _profileHandler(Request request) async { - return withDe1((_) async { - final payload = await readBoundedRequestBodyString( - request, - maxBytes: largeRequestBodyBytes, - timeout: largeRequestBodyTimeout, - ); + // UPSTREAM'S BOUND, THE CANARY'S PLACE. Two changes met here: upstream capped + // and timed the body read, and this fork moved the PARSE outside withDe1 so a + // malformed profile is a clean 400 rather than the opaque 500 the catch-all + // gives. Both are kept — the read is bounded AND it happens before withDe1. + // RequestBodyReadException propagates as it did before, which is what the + // `rethrow` inside withDe1 was already arranging. + final payload = await readBoundedRequestBodyString( + request, + maxBytes: largeRequestBodyBytes, + timeout: largeRequestBodyTimeout, + ); + // A power step without its mandatory pressure limiter throws a FormatException + // here — also a client error. + Profile profile; + try { + final Map json = jsonDecode(payload); + profile = Profile.fromJson(json); + } on FormatException catch (e) { + return jsonBadRequest({'error': 'Invalid profile', 'message': '$e'}); + } on ArgumentError catch (e) { + return jsonBadRequest({'error': 'Invalid profile', 'message': '$e'}); + } - Map json; + return withDe1((de1) async { try { - json = jsonDecode(payload); - } catch (e) { - return jsonBadRequest({'error': 'Invalid JSON body'}); + // Called directly rather than through runDeviceWrite: a capability + // refusal is deterministic, so replacement-retry buys nothing, and + // runDeviceWrite's catch-and-retry would turn the refusal into a 500 + // instead of the 400 contract below. + await de1.setProfile(profile); + return jsonOk(null); + } on ProfileModeUnsupportedException catch (e) { + // The machine cannot run a Power/Lever step or a HOLD transition in this + // profile (arm-time refusal gate). Surface as a clean 400 with the + // refusal message, not the opaque 500 the withDe1 catch-all would give. + // Any OTHER StateError escaping setProfile is a genuine internal fault + // and stays a 500 (the withDe1 catch-all), not a mislabeled client 400. + return jsonBadRequest({ + 'error': 'Unsupported profile', + 'message': e.message, + }); } - Profile profile = Profile.fromJson(json); - await _controller.runDeviceWrite((device) => device.setProfile(profile)); - return jsonOk(null); + // UPSTREAM DROPPED `retryOnReplacement: true` HERE; this fork had already + // dropped runDeviceWrite entirely, for the reason stated above — the retry + // turns a deterministic capability refusal into a 500. Same direction, taken + // further, so upstream's edit is subsumed rather than lost. }); } diff --git a/test/controllers/workflow_device_sync_test.dart b/test/controllers/workflow_device_sync_test.dart index d745e21ac..45a0e1476 100644 --- a/test/controllers/workflow_device_sync_test.dart +++ b/test/controllers/workflow_device_sync_test.dart @@ -231,6 +231,29 @@ class _NotConnectedDe1 extends TestDe1 { } } +/// Refuses (permanently, per the capability gate) any profile whose title +/// matches [refuseTitle]; accepts everything else. Mirrors +/// `UnifiedDe1._assertProfileModeSupported` throwing before any BLE write, so +/// the workflow-sync path must PARK instead of retrying forever. +class _RefusingDe1 extends TestDe1 { + _RefusingDe1({required this.refuseTitle}); + final String refuseTitle; + int totalCalls = 0; + final List setProfileCalls = []; + + @override + Future setProfile(Profile profile) async { + totalCalls++; + setProfileCalls.add(profile); + if (profile.title == refuseTitle) { + throw const ProfileModeUnsupportedException( + 'This machine does not support the Lever pump mode used by this ' + 'profile.', + ); + } + } +} + void main() { late WorkflowController workflow; late DeviceController deviceController; @@ -1179,4 +1202,164 @@ void main() { ); }); }); + + // A capability refusal (missing ProfileModeCaps / not a Bengle) is PERMANENT + // for the connection — the gate throws ProfileModeUnsupportedException before + // any BLE write, so nothing is wedged and retrying can never succeed. The + // sync must PARK on it (no retry timer) and re-attempt only when the workflow + // profile changes or the machine reconnects. + group('capability refusal parks (no retry loop)', () { + late WorkflowController wf; + WorkflowDeviceSync? activeSync; + + setUp(() { + wf = WorkflowController(); + activeSync = null; + }); + + tearDown(() { + activeSync?.dispose(); + }); + + Future connect(TestDe1 testDe1) async { + final dc = DeviceController([MockDeviceDiscoveryService()]); + await dc.initialize(); + final controller = De1Controller(controller: dc); + await controller.connectToDe1(testDe1); + testDe1.emitShotSettings( + De1ShotSettings( + steamSetting: 0, + targetSteamTemp: 150, + targetSteamDuration: 30, + targetHotWaterTemp: 75, + targetHotWaterVolume: 50, + targetHotWaterDuration: 30, + targetShotVolume: 36, + groupTemp: 94.0, + ), + ); + await Future.delayed(const Duration(milliseconds: 150)); + return controller; + } + + test('a refused profile parks: attempted once, no auto-retry, and a ' + 'workflow change re-attempts', () async { + final refusing = _RefusingDe1(refuseTitle: 'Lever demo'); + final controller = await connect(refusing); + final s = WorkflowDeviceSync( + workflowController: wf, + de1Controller: controller, + retryDelays: const [ + Duration(milliseconds: 20), + Duration(milliseconds: 40), + ], + ); + activeSync = s; + // Attaching to an already-connected machine triggers the on-connect push + // (the current workflow profile) once via initSettled. Let it land, then + // reset the counter so this test measures only the refusal cycle. + await Future.delayed(const Duration(milliseconds: 10)); + refusing.totalCalls = 0; + refusing.setProfileCalls.clear(); + // Select the unsupported lever profile — refused once at the gate. + wf.setWorkflow( + wf.currentWorkflow.copyWith(profile: _profile('Lever demo')), + ); + await Future.delayed(const Duration(milliseconds: 10)); + expect(refusing.totalCalls, 1, reason: 'attempted exactly once'); + + // Wait past every backoff delay: a permanent refusal must NOT retry. + await Future.delayed(const Duration(milliseconds: 120)); + expect( + refusing.totalCalls, + 1, + reason: 'a permanent capability refusal must never be retried', + ); + + // A genuine workflow-profile change clears the park and re-attempts. + wf.setWorkflow( + wf.currentWorkflow.copyWith(profile: _profile('Adaptive')), + ); + await Future.delayed(const Duration(milliseconds: 10)); + expect(refusing.setProfileCalls.map((p) => p.title), [ + 'Lever demo', + 'Adaptive', + ], reason: 'a workflow change triggers a fresh attempt'); + }); + + test('re-selecting the same refused profile does not re-attempt', () async { + final refusing = _RefusingDe1(refuseTitle: 'Lever demo'); + final controller = await connect(refusing); + final s = WorkflowDeviceSync( + workflowController: wf, + de1Controller: controller, + retryDelays: const [Duration(milliseconds: 20)], + ); + activeSync = s; + // Let the on-connect push (current workflow profile) land, then reset so + // this test measures only the refusal cycle. + await Future.delayed(const Duration(milliseconds: 10)); + refusing.totalCalls = 0; + refusing.setProfileCalls.clear(); + + wf.setWorkflow( + wf.currentWorkflow.copyWith(profile: _profile('Lever demo')), + ); + await Future.delayed(const Duration(milliseconds: 10)); + expect(refusing.totalCalls, 1); + + // Re-select the SAME (still-refused) profile: no new attempt. + wf.setWorkflow( + wf.currentWorkflow.copyWith(profile: _profile('Lever demo')), + ); + await Future.delayed(const Duration(milliseconds: 60)); + expect( + refusing.totalCalls, + 1, + reason: 'parked on the same refused profile — no re-attempt', + ); + }); + + test('a power-exit refusal parks identically (the park is exception-driven, ' + 'so it inherits for a power exit with no park-side change)', () async { + // The gate throws the same ProfileModeUnsupportedException for a power + // exit as for a lever step, so the park path needs no per-reason branch. + final refusing = _RefusingDe1(refuseTitle: 'Power exit demo'); + final controller = await connect(refusing); + final s = WorkflowDeviceSync( + workflowController: wf, + de1Controller: controller, + retryDelays: const [ + Duration(milliseconds: 20), + Duration(milliseconds: 40), + ], + ); + activeSync = s; + await Future.delayed(const Duration(milliseconds: 10)); + refusing.totalCalls = 0; + refusing.setProfileCalls.clear(); + + wf.setWorkflow( + wf.currentWorkflow.copyWith(profile: _profile('Power exit demo')), + ); + await Future.delayed(const Duration(milliseconds: 10)); + expect(refusing.totalCalls, 1, reason: 'attempted exactly once'); + + await Future.delayed(const Duration(milliseconds: 120)); + expect( + refusing.totalCalls, + 1, + reason: 'a permanent power-exit refusal must never be retried', + ); + + wf.setWorkflow( + wf.currentWorkflow.copyWith(profile: _profile('Adaptive')), + ); + await Future.delayed(const Duration(milliseconds: 10)); + expect(refusing.setProfileCalls.map((p) => p.title), [ + 'Power exit demo', + 'Adaptive', + ], reason: 'a workflow change clears the park and re-attempts'); + }); + }); } diff --git a/test/helpers/fake_ble_transport.dart b/test/helpers/fake_ble_transport.dart index 126e12c4a..e3ebe70b0 100644 --- a/test/helpers/fake_ble_transport.dart +++ b/test/helpers/fake_ble_transport.dart @@ -293,6 +293,11 @@ class FakeBleTransport extends BLETransport { int refillKitPresent = 0, // calFlowEst has a read scale of 0.001, so raw 1000 is 1.0. int calFlowEst = 1000, + // ProfileModeCaps, read on every connect. Default 0 (unsupported) + // mirrors stock firmware / a stock DE1 so existing tests answer the read + // instantly instead of eating the fail-closed read timeout; a test for the + // new pump modes passes 0x3 (Power|Lever) or 0x7 (+HOLD). + int profileModeCaps = 0, }) { queueMmrResponseInt(MMRItem.v13Model, v13Model); queueMmrResponseInt(MMRItem.ghcInfo, ghcInfo); @@ -301,6 +306,7 @@ class FakeBleTransport extends BLETransport { queueMmrResponseInt(MMRItem.heaterV, heaterV); queueMmrResponseInt(MMRItem.refillKitPresent, refillKitPresent); queueMmrResponseInt(MMRItem.calFlowEst, calFlowEst); + queueMmrResponseInt(MMRItem.profileModeCaps, profileModeCaps); } @override diff --git a/test/models/data/profile_modes_test.dart b/test/models/data/profile_modes_test.dart new file mode 100644 index 000000000..6342d85e6 --- /dev/null +++ b/test/models/data/profile_modes_test.dart @@ -0,0 +1,352 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/models/data/profile.dart'; + +/// Model round-trip coverage for the additive Power / Lever pump-mode steps and +/// the HOLD transition. Keys are omitted (not null) for the other mode's +/// fields, a power step without its mandatory pressure limiter is a parse +/// error, and `transition:"hold"` survives a round-trip losslessly on any +/// machine (it must never silently degrade to a JUMP). +void main() { + // A well-formed power-step JSON body (values-as-primitives). + Map powerJson() => { + 'name': 'power pour', + 'pump': 'power', + 'transition': 'smooth', + 'volume': 100, + 'seconds': 25, + 'weight': 0.0, + 'temperature': 93, + 'sensor': 'coffee', + 'power': 2.0, + 'limiter': {'value': 9.0, 'range': 0.6}, + }; + + // A well-formed lever-step JSON body (CLASSIC preset triple). + Map leverJson() => { + 'name': 'lever pour', + 'pump': 'lever', + 'transition': 'smooth', + 'volume': 100, + 'seconds': 40, + 'weight': 0.0, + 'temperature': 92, + 'sensor': 'coffee', + 'pressure': 9.0, + 'leverSpring': 0.9, + 'leverGive': 1.5, + }; + + group('ProfileStepPower', () { + test('dispatches from ProfileStep.fromJson on pump:"power"', () { + final step = ProfileStep.fromJson(powerJson()); + expect(step, isA()); + }); + + test('getTarget() returns the power (watts), not the limiter', () { + final step = ProfileStep.fromJson(powerJson()) as ProfileStepPower; + expect(step.getTarget(), 2.0); + expect(step.power, 2.0); + expect(step.limiter!.value, 9.0); + }); + + test('round-trips through toJson/fromJson', () { + final original = ProfileStep.fromJson(powerJson()); + final restored = ProfileStep.fromJson(original.toJson()); + expect(restored, equals(original)); + }); + + test('toJson carries pump:"power" and the power key, no lever keys', () { + final json = (ProfileStep.fromJson(powerJson())).toJson(); + expect(json['pump'], 'power'); + expect(json['power'], 2.0); + expect(json.containsKey('leverSpring'), isFalse); + expect(json.containsKey('leverGive'), isFalse); + }); + + test('throws FormatException when the limiter is absent', () { + final json = powerJson()..remove('limiter'); + expect(() => ProfileStep.fromJson(json), throwsFormatException); + }); + + test('throws FormatException when the limiter value is zero', () { + final json = powerJson()..['limiter'] = {'value': 0, 'range': 0.6}; + expect(() => ProfileStep.fromJson(json), throwsFormatException); + }); + }); + + group('ProfileStepLever', () { + test('dispatches from ProfileStep.fromJson on pump:"lever"', () { + final step = ProfileStep.fromJson(leverJson()); + expect(step, isA()); + }); + + test('getTarget() returns P0 (the pressure key)', () { + final step = ProfileStep.fromJson(leverJson()) as ProfileStepLever; + expect(step.getTarget(), 9.0); + expect(step.pressure, 9.0); + expect(step.leverSpring, 0.9); + expect(step.leverGive, 1.5); + }); + + test('round-trips through toJson/fromJson without a limiter', () { + final original = ProfileStep.fromJson(leverJson()); + final restored = ProfileStep.fromJson(original.toJson()); + expect(restored, equals(original)); + }); + + test('round-trips with an optional flow-cap limiter', () { + final json = leverJson()..['limiter'] = {'value': 6.0, 'range': 0.6}; + final original = ProfileStep.fromJson(json); + final restored = ProfileStep.fromJson(original.toJson()); + expect(restored, equals(original)); + expect((restored as ProfileStepLever).limiter!.value, 6.0); + }); + + test('toJson carries pump:"lever" and the lever keys, no power key', () { + final json = (ProfileStep.fromJson(leverJson())).toJson(); + expect(json['pump'], 'lever'); + expect(json['pressure'], 9.0); + expect(json['leverSpring'], 0.9); + expect(json['leverGive'], 1.5); + expect(json.containsKey('power'), isFalse); + }); + }); + + group('HOLD transition', () { + // A pressure step holding the previous achieved pressure, with a flow cap + // (the canonical example) — target `pressure` stored as 0. + Map holdPressureJson() => { + 'name': 'hold pressure', + 'pump': 'pressure', + 'transition': 'hold', + 'volume': 0, + 'seconds': 30, + 'temperature': 92, + 'sensor': 'coffee', + 'pressure': 0, + 'limiter': {'value': 6.0, 'range': 0.6}, + }; + + test('parses transition:"hold" into TransitionType.hold', () { + final step = ProfileStep.fromJson(holdPressureJson()); + expect(step, isA()); + expect(step.transition, TransitionType.hold); + }); + + test('round-trips transition:"hold" losslessly (pressure)', () { + final original = ProfileStep.fromJson(holdPressureJson()); + final restored = ProfileStep.fromJson(original.toJson()); + expect(restored, equals(original)); + expect(restored.transition, TransitionType.hold); + }); + + test('toJson re-emits transition:"hold" (not dropped, not JUMP)', () { + final json = ProfileStep.fromJson(holdPressureJson()).toJson(); + // The load-bearing guarantee: a HOLD marker survives round-trip on ANY + // machine and NEVER silently degrades to fast/JUMP. + expect(json['transition'], 'hold'); + }); + + test('round-trips HOLD-flow and HOLD-power', () { + final flowJson = { + 'name': 'hold flow', + 'pump': 'flow', + 'transition': 'hold', + 'volume': 0, + 'seconds': 30, + 'temperature': 92, + 'sensor': 'coffee', + 'flow': 0, + 'limiter': {'value': 9.0, 'range': 0.9}, + }; + final powerJson = { + 'name': 'hold power', + 'pump': 'power', + 'transition': 'hold', + 'volume': 0, + 'seconds': 30, + 'temperature': 92, + 'sensor': 'coffee', + 'power': 0, + 'limiter': {'value': 9.0, 'range': 0.6}, + }; + for (final j in [flowJson, powerJson]) { + final original = ProfileStep.fromJson(j); + final restored = ProfileStep.fromJson(original.toJson()); + expect(restored, equals(original)); + expect(restored.transition, TransitionType.hold); + } + }); + + test('SKEW: an enum lacking a transition name THROWS on byName ' + '(so an old client without "hold" gives a VISIBLE 400, never a ' + 'silent JUMP)', () { + // This is exactly the mechanism by which `transition:"hold"` was chosen + // over an additive boolean key: `byName` on an unknown name throws + // ArgumentError, which the fromJson factory propagates to a clean 400. + // (A dropped additive key would round-trip to a silent JUMP — forbidden.) + expect( + () => TransitionType.values.byName('definitely-not-a-transition'), + throwsA(isA()), + ); + }); + }); + + group('power exit condition', () { + // A pressure step exiting early on hydraulic power (W = 0.1*P*F). + Map pressureStepPowerOverJson() => { + 'name': 'ramp to power', + 'pump': 'pressure', + 'transition': 'fast', + 'volume': 0, + 'seconds': 30, + 'temperature': 92, + 'sensor': 'coffee', + 'pressure': 9.0, + 'exit': {'type': 'power', 'condition': 'over', 'value': 4.5}, + }; + + test('parses exit type:"power" into ExitType.power', () { + final step = ProfileStep.fromJson(pressureStepPowerOverJson()); + expect(step.exit, isNotNull); + expect(step.exit!.type, ExitType.power); + expect(step.exit!.condition, ExitCondition.over); + expect(step.exit!.value, 4.5); + }); + + test('round-trips a pressure-step power-over exit losslessly', () { + final original = ProfileStep.fromJson(pressureStepPowerOverJson()); + final restored = ProfileStep.fromJson(original.toJson()); + expect(restored, equals(original)); + expect(restored.exit!.type, ExitType.power); + }); + + test('round-trips a flow-step power-under exit losslessly', () { + final json = { + 'name': 'flow to power', + 'pump': 'flow', + 'transition': 'fast', + 'volume': 0, + 'seconds': 20, + 'temperature': 90, + 'sensor': 'coffee', + 'flow': 2.0, + 'exit': {'type': 'power', 'condition': 'under', 'value': 2.0}, + }; + final original = ProfileStep.fromJson(json); + final restored = ProfileStep.fromJson(original.toJson()); + expect(restored, equals(original)); + expect(restored.exit!.type, ExitType.power); + expect(restored.exit!.condition, ExitCondition.under); + }); + + test('toJson re-emits type:"power" (not dropped, not degraded to P/F)', () { + final json = ProfileStep.fromJson(pressureStepPowerOverJson()).toJson(); + // The load-bearing guarantee: a power exit survives round-trip on ANY + // machine and NEVER silently becomes a pressure/flow exit. + expect(json['exit']['type'], 'power'); + }); + + test('StepExitCondition round-trips directly', () { + const exit = StepExitCondition( + type: ExitType.power, + condition: ExitCondition.over, + value: 4.5, + ); + expect(StepExitCondition.fromJson(exit.toJson()), equals(exit)); + }); + + test('SKEW: an ExitType lacking "power" THROWS on byName (an old client ' + 'gives a VISIBLE 400, never a silent pressure/flow exit)', () { + // The same mechanism that makes `type:"power"` an enum value rather than + // an additive key: byName on an unknown name throws ArgumentError, which + // fromJson propagates to a clean 400. + expect( + () => ExitType.values.byName('definitely-not-an-exit-type'), + throwsA(isA()), + ); + // A knowing client resolves it fine. + expect(ExitType.values.byName('power'), ExitType.power); + }); + }); + + group('whole-Profile round-trip with novel pump-mode steps', () { + test('a profile mixing flow / power / lever round-trips', () { + final profileJson = { + 'version': '2', + 'title': 'mixed modes', + 'notes': '', + 'author': 'test', + 'beverage_type': 'espresso', + 'steps': [ + { + 'name': 'fill', + 'pump': 'flow', + 'transition': 'fast', + 'volume': 100, + 'seconds': 10, + 'temperature': 92, + 'sensor': 'coffee', + 'flow': 8.0, + }, + powerJson(), + leverJson(), + ], + 'tank_temperature': 90.0, + 'target_weight': 36.0, + 'target_volume_count_start': 0, + }; + + final profile = Profile.fromJson(profileJson); + expect(profile.steps, hasLength(3)); + expect(profile.steps[1], isA()); + expect(profile.steps[2], isA()); + + final restored = Profile.fromJson(profile.toJson()); + expect(restored, equals(profile)); + }); + + test('a profile with a HOLD step round-trips losslessly (the ' + '"viewed/synced on a DE1" case — model tolerance is NOT gated)', () { + final profileJson = { + 'version': '2', + 'title': 'hold pour', + 'notes': '', + 'author': 'test', + 'beverage_type': 'espresso', + 'steps': [ + { + 'name': 'fill', + 'pump': 'flow', + 'transition': 'fast', + 'volume': 100, + 'seconds': 10, + 'temperature': 92, + 'sensor': 'coffee', + 'flow': 8.0, + }, + { + 'name': 'hold pressure', + 'pump': 'pressure', + 'transition': 'hold', + 'volume': 0, + 'seconds': 30, + 'temperature': 92, + 'sensor': 'coffee', + 'pressure': 0, + 'limiter': {'value': 6.0, 'range': 0.6}, + }, + ], + 'tank_temperature': 90.0, + 'target_volume_count_start': 0, + }; + + final profile = Profile.fromJson(profileJson); + expect(profile.steps[1].transition, TransitionType.hold); + final restored = Profile.fromJson(profile.toJson()); + expect(restored, equals(profile), reason: 'no data loss on round-trip'); + // The HOLD marker survives — it is NOT silently rewritten to fast/JUMP. + expect(restored.toJson()['steps'][1]['transition'], 'hold'); + }); + }); +} diff --git a/test/models/device/unified_de1_hold_profile_test.dart b/test/models/device/unified_de1_hold_profile_test.dart new file mode 100644 index 000000000..5f8642b05 --- /dev/null +++ b/test/models/device/unified_de1_hold_profile_test.dart @@ -0,0 +1,322 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/models/data/profile.dart'; +import 'package:reaprime/src/models/device/impl/de1/de1.models.dart'; +import 'package:reaprime/src/models/device/impl/de1/unified_de1/unified_de1.dart'; +import 'package:reaprime/src/models/errors.dart'; + +import '../../helpers/fake_ble_transport.dart'; + +/// Byte-exact ext-frame encoding for the additive HOLD transition, plus the +/// capability refusal gate and the HOLD-as-first-step rejection. +/// +/// A HOLD step carries NO authored target — the firmware latches the value +/// achieved at the exit of the previous step. reaprime therefore: +/// * pins the base-frame SetVal to 0 (benign vent/pause on firmware without +/// the HOLD modes); +/// * emits a dedicated ext frame with the HOLD Mode byte +/// (data[3] = 3 HOLD-pressure / 4 HOLD-flow / 5 HOLD-power); +/// * never sets the base interpolate bit (HOLD rides its own Mode marker). +/// +/// Golden bytes (U8D1 = value×10, base temp 92C=0xB8, 30s F8_1_7=0x9E, +/// vol0 U10P0=0x0400): +/// HOLD-pressure ext: [.., 0x3C, 0x06, 0x03, 0, 0, 0, 0] (flow cap 6.0/0.6) +/// HOLD-flow ext: [.., 0x5A, 0x09, 0x04, 0, 0, 0, 0] (pres cap 9.0/0.9) +/// HOLD-power ext: [.., 0x00, 0x00, 0x05, 0x5A, 0, 0, 0] (cap 9.0) +void main() { + // Step 0: a benign flow fill (no HOLD — HOLD can't be first). Steps 1..3 are + // the three HOLD variants carrying the golden limiters. + const profile = Profile( + version: '2', + title: 'hold encoder profile', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepFlow( + name: 'fill', + transition: TransitionType.fast, + volume: 0, + seconds: 10, + temperature: 92, + sensor: TemperatureSensor.coffee, + flow: 8.0, + ), + // HOLD-pressure: authored pressure ignored (stored 0), flow cap 6.0. + ProfileStepPressure( + name: 'hold pressure', + transition: TransitionType.hold, + volume: 0, + seconds: 30, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 0, + limiter: StepLimiter(value: 6.0, range: 0.6), + ), + // HOLD-flow: authored flow ignored (stored 0), pressure cap 9.0. + ProfileStepFlow( + name: 'hold flow', + transition: TransitionType.hold, + volume: 0, + seconds: 30, + temperature: 92, + sensor: TemperatureSensor.coffee, + flow: 0, + limiter: StepLimiter(value: 9.0, range: 0.9), + ), + // HOLD-power: authored watts ignored (stored 0), mandatory cap 9.0. + ProfileStepPower( + name: 'hold power', + transition: TransitionType.hold, + volume: 0, + seconds: 30, + temperature: 92, + sensor: TemperatureSensor.coffee, + power: 0, + limiter: StepLimiter(value: 9.0, range: 0.6), + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 0, + ); + + Future> uploadFrames(int caps) async { + final transport = FakeBleTransport(); + final de1 = UnifiedDe1(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + transport.queueOnConnectResponses(v13Model: 128, profileModeCaps: caps); + await de1.onConnect(); + await de1.setProfile(profile); + final frames = transport.writes + .where((w) => w.characteristicUUID == Endpoint.frameWrite.uuid) + .toList(); + await transport.dispose(); + return frames; + } + + group('caps read widened to 0x7 (bit2 = HOLD)', () { + test('a HOLD firmware reporting 0x7 is preserved, not zeroed', () async { + final transport = FakeBleTransport(); + final de1 = UnifiedDe1(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + transport.queueOnConnectResponses(v13Model: 128, profileModeCaps: 0x7); + await de1.onConnect(); + // The old 0x3 garbage-guard would have zeroed a legitimate 0x7; the + // widened 0x7 mask keeps it (Power|Lever|HOLD all advertised). + expect(de1.machineInfo.extra['profileModeCaps'], 0x7); + await transport.dispose(); + }); + + test('a word with a bit above 0x7 still fails-closed to 0', () async { + final transport = FakeBleTransport(); + final de1 = UnifiedDe1(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + transport.queueOnConnectResponses(v13Model: 128, profileModeCaps: 0x1F); + await de1.onConnect(); + expect(de1.machineInfo.extra['profileModeCaps'], 0); + await transport.dispose(); + }); + }); + + group('Bengle HOLD ext-frame encoding (caps 0x7)', () { + late List frames; + + setUpAll(() async { + frames = await uploadFrames(0x7); + }); + + test('sequence = 4 base + 3 ext + tail (fill emits no ext)', () { + // Base frames 0..3, ext frames only for the three HOLD steps (33/34/35), + // then the tail. The fill (step 0, no limiter, legacy) emits no ext frame. + expect(frames, hasLength(8)); + expect(frames[0].data[0], 0, reason: 'base frame 0 (fill)'); + expect(frames[1].data[0], 1, reason: 'base frame 1 (hold-pressure)'); + expect(frames[2].data[0], 2, reason: 'base frame 2 (hold-flow)'); + expect(frames[3].data[0], 3, reason: 'base frame 3 (hold-power)'); + expect(frames[4].data[0], 33, reason: 'ext frame step 1'); + expect(frames[5].data[0], 34, reason: 'ext frame step 2'); + expect(frames[6].data[0], 35, reason: 'ext frame step 3'); + expect(frames[7].data[0], 4, reason: 'tail = steps.length'); + }); + + test('HOLD-pressure base: flag 0x40, SetVal 0, no interpolate', () { + final d = frames[1].data; + // ignoreLimit(0x40) only: pressure-priority, no CtrlF, no interpolate. + expect(d[1], 0x40, reason: 'flag: ignoreLimit, no ctrlF, no interpolate'); + expect(d[2], 0x00, reason: 'SetVal pinned to 0 (locked PREVIOUS)'); + expect(d[3], 0xB8, reason: 'temp 92C'); + expect(d[4], 0x9E, reason: '30s'); + expect(d[5], 0x00, reason: 'no exit trigger'); + expect(d[6], 0x04, reason: 'vol 0 -> U10P0 high byte'); + expect(d[7], 0x00); + }); + + test('HOLD-pressure ext: [.., 0x3C, 0x06, mode=3, 0,0,0,0]', () { + final d = frames[4].data; + expect(d[1], 0x3C, reason: 'MaxFlow 6.0'); + expect(d[2], 0x06, reason: 'MaxFlow range 0.6'); + expect(d[3], 0x03, reason: 'Mode = HOLD-pressure'); + expect(d[4], 0x00); + expect(d[5], 0x00); + expect(d[6], 0x00); + expect(d[7], 0x00); + }); + + test('HOLD-flow base: flag 0x41 (ctrlF), SetVal 0', () { + final d = frames[2].data; + // ignoreLimit(0x40) | ctrlF(0x01): flow-priority, no interpolate. + expect(d[1], 0x41, reason: 'flag: ignoreLimit | ctrlF, no interpolate'); + expect(d[2], 0x00, reason: 'SetVal pinned to 0'); + }); + + test('HOLD-flow ext: [.., 0x5A, 0x09, mode=4, 0,0,0,0]', () { + final d = frames[5].data; + expect(d[1], 0x5A, reason: 'MaxPressure 9.0'); + expect(d[2], 0x09, reason: 'MaxPressure range 0.9'); + expect(d[3], 0x04, reason: 'Mode = HOLD-flow'); + expect(d[4], 0x00); + expect(d[5], 0x00); + expect(d[6], 0x00); + expect(d[7], 0x00); + }); + + test('HOLD-power base: flag 0x40 (pressure-prio), SetVal 0', () { + final d = frames[3].data; + expect(d[1], 0x40, reason: 'flag: ignoreLimit, no ctrlF, no interpolate'); + expect(d[2], 0x00, reason: 'SetVal pinned to 0'); + }); + + test('HOLD-power ext: [.., 0,0, mode=5, cap=0x5A, 0,0,0]', () { + final d = frames[6].data; + expect(d[1], 0x00, reason: 'no flow cap for HOLD-power'); + expect(d[2], 0x00); + expect(d[3], 0x05, reason: 'Mode = HOLD-power'); + expect(d[4], 0x5A, reason: 'ModeMaxP = mandatory pressure cap 9.0'); + expect(d[5], 0x00); + expect(d[6], 0x00); + expect(d[7], 0x00); + }); + }); + + group('HOLD arm-time refusal gate', () { + // A minimal, VALID (HOLD-not-first) profile with one HOLD-pressure step. + Profile holdProfile() => const Profile( + version: '2', + title: 'hold only', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepFlow( + name: 'fill', + transition: TransitionType.fast, + volume: 0, + seconds: 5, + temperature: 92, + sensor: TemperatureSensor.coffee, + flow: 8.0, + ), + ProfileStepPressure( + name: 'hold pressure', + transition: TransitionType.hold, + volume: 0, + seconds: 30, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 0, + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 90, + ); + + Future connect({ + required int v13Model, + required int caps, + }) async { + final transport = FakeBleTransport(); + final de1 = UnifiedDe1(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + transport.queueOnConnectResponses( + v13Model: v13Model, + profileModeCaps: caps, + ); + await de1.onConnect(); + addTearDown(transport.dispose); + return de1; + } + + test( + 'caps 0x3 (Power|Lever, no HOLD): a HOLD profile is refused', + () async { + final de1 = await connect(v13Model: 128, caps: 0x3); + await expectLater( + de1.setProfile(holdProfile()), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('HOLD'), contains('does not support')), + ), + ), + ); + }, + ); + + test('caps 0x7 (HOLD present): a HOLD profile proceeds', () async { + final de1 = await connect(v13Model: 128, caps: 0x7); + await de1.setProfile(holdProfile()); // completes, no throw + }); + + test( + 'DE1 (not Bengle): a HOLD profile is refused before any write', + () async { + final de1 = await connect(v13Model: 1, caps: 0x7); + expect(de1.isBengle, isFalse); + await expectLater( + de1.setProfile(holdProfile()), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('HOLD'), contains('not a Bengle')), + ), + ), + ); + }, + ); + + test('HOLD as the FIRST step is refused on EVERY machine', () async { + final de1 = await connect(v13Model: 128, caps: 0x7); + final firstStepHold = const Profile( + version: '2', + title: 'hold first', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepPressure( + name: 'hold pressure', + transition: TransitionType.hold, + volume: 0, + seconds: 30, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 0, + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 90, + ); + await expectLater( + de1.setProfile(firstStepHold), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('first step'), contains('HOLD')), + ), + ), + ); + }); + }); +} diff --git a/test/models/device/unified_de1_power_exit_test.dart b/test/models/device/unified_de1_power_exit_test.dart new file mode 100644 index 000000000..4e4122918 --- /dev/null +++ b/test/models/device/unified_de1_power_exit_test.dart @@ -0,0 +1,521 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/models/data/profile.dart'; +import 'package:reaprime/src/models/device/impl/de1/de1.models.dart'; +import 'package:reaprime/src/models/device/impl/de1/unified_de1/unified_de1.dart'; +import 'package:reaprime/src/models/errors.dart'; + +import '../../helpers/fake_ble_transport.dart'; + +/// Byte-exact base-frame encoding of a cross-variable POWER exit, plus the +/// capability refusal gate and the flag-bit properties. +/// +/// A power exit rides the base frame's existing TriggerVal byte (data[5], U8D1 +/// watts) and the existing DC_GT over/under bit, selected by the independent +/// comparePower flag (0x80). It deliberately does NOT set doCompare (0x02): a +/// firmware/app that predates the power exit gates the pressure/flow compare +/// block on doCompare, so with it clear the frame runs to its time/volume limits +/// (a benign base frame) instead of comparing watts against pressure/flow. +/// +/// Golden vectors (8-byte Endpoint.frameWrite payload data[0..7]): +/// GV-P1 pressure step, power over 4.5 W -> 02 C4 5A B8 9E 2D 04 00 +/// GV-F1 flow step, power under 2.0 W -> 03 C1 14 B4 94 14 04 00 +void main() { + // A pressure-power-over exit at step 2 and a flow-power-under exit at step 3, + // so the encoded frame index bytes are 0x02 / 0x03 (matching the vectors). + const goldenProfile = Profile( + version: '2', + title: 'power-exit golden', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepPressure( + name: 'preinfuse', + transition: TransitionType.fast, + volume: 0, + seconds: 10, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 2.0, + ), + ProfileStepPressure( + name: 'ramp', + transition: TransitionType.fast, + volume: 0, + seconds: 10, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 6.0, + ), + // GV-P1: pressure step 9.0 bar, 92 C, 30 s, vol 0, exit power over 4.5 W. + ProfileStepPressure( + name: 'hold to power', + transition: TransitionType.fast, + volume: 0, + seconds: 30, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 9.0, + exit: StepExitCondition( + type: ExitType.power, + condition: ExitCondition.over, + value: 4.5, + ), + ), + // GV-F1: flow step 2.0 ml/s, 90 C, 20 s, vol 0, exit power under 2.0 W. + ProfileStepFlow( + name: 'flow to power', + transition: TransitionType.fast, + volume: 0, + seconds: 20, + temperature: 90, + sensor: TemperatureSensor.coffee, + flow: 2.0, + exit: StepExitCondition( + type: ExitType.power, + condition: ExitCondition.under, + value: 2.0, + ), + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 0, + ); + + Future> uploadFrames(Profile profile, int caps) async { + final transport = FakeBleTransport(); + final de1 = UnifiedDe1(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + transport.queueOnConnectResponses(v13Model: 128, profileModeCaps: caps); + await de1.onConnect(); + await de1.setProfile(profile); + final frames = transport.writes + .where((w) => w.characteristicUUID == Endpoint.frameWrite.uuid) + .toList(); + await transport.dispose(); + return frames; + } + + // A single-step LEVER profile carrying a cross-variable POWER exit. The exit + // encode is pump-agnostic: convertProfileFlags derives comparePower from + // step.exit regardless of pump mode, and the base frame carries the same U8D1 + // watts TriggerVal a pressure/flow step does. The lever step additionally + // emits its Mode=2 ext frame, which the exit does not disturb. + Profile leverPowerProfile(ExitCondition cond, double watts) => Profile( + version: '2', + title: 'lever power exit', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepLever( + name: 'lever to power', + transition: TransitionType.fast, + volume: 0, + seconds: 30, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 9.0, + leverSpring: 0.9, + leverGive: 1.5, + exit: StepExitCondition( + type: ExitType.power, + condition: cond, + value: watts, + ), + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 0, + ); + + group('golden vectors (power exit, Bengle caps 0xF)', () { + late List frames; + + setUpAll(() async { + // caps 0xF advertises bit3 (power exit), so the profile arms. + frames = await uploadFrames(goldenProfile, 0xF); + }); + + test( + 'GV-P1: pressure step, power over 4.5 W -> 02 C4 5A B8 9E 2D 04 00', + () { + final f = frames.firstWhere((w) => w.data[0] == 0x02); + expect( + f.data, + orderedEquals([0x02, 0xC4, 0x5A, 0xB8, 0x9E, 0x2D, 0x04, 0x00]), + ); + }, + ); + + test('GV-F1: flow step, power under 2.0 W -> 03 C1 14 B4 94 14 04 00', () { + final f = frames.firstWhere((w) => w.data[0] == 0x03); + expect( + f.data, + orderedEquals([0x03, 0xC1, 0x14, 0xB4, 0x94, 0x14, 0x04, 0x00]), + ); + }); + + test( + 'GV-P1 flag byte 0xC4 = ignoreLimit | comparePower | dcGT, NO doCompare', + () { + final flag = frames.firstWhere((w) => w.data[0] == 0x02).data[1]; + expect(flag, 0xC4); + expect(flag & Helper.comparePower, Helper.comparePower); + expect(flag & Helper.dcGT, Helper.dcGT, reason: 'over'); + expect(flag & Helper.doCompare, 0, reason: 'doCompare MUST be clear'); + expect(flag & Helper.dcCompF, 0, reason: 'dcCompF MUST be clear'); + }, + ); + + test( + 'GV-F1 flag byte 0xC1 = ignoreLimit | ctrlF | comparePower (under)', + () { + final flag = frames.firstWhere((w) => w.data[0] == 0x03).data[1]; + expect(flag, 0xC1); + expect(flag & Helper.ctrlF, Helper.ctrlF, reason: 'flow priority'); + expect(flag & Helper.comparePower, Helper.comparePower); + expect(flag & Helper.dcGT, 0, reason: 'under -> dcGT clear'); + expect(flag & Helper.doCompare, 0, reason: 'doCompare MUST be clear'); + }, + ); + }); + + group('lever step power exit (mode-agnostic encoder)', () { + test( + 'lever + power over 4.5 W: base frame is byte-identical to the pressure ' + 'GV-P1 (00 C4 5A B8 9E 2D 04 00) — the exit encode is pump-agnostic', + () async { + final frames = await uploadFrames( + leverPowerProfile(ExitCondition.over, 4.5), + 0xF, + ); + final base = frames.firstWhere((w) => w.data[0] == 0x00); + // Same P0/temp/time/exit as the pressure GV-P1: only the ext Mode byte + // differs, which proves the power-exit encode does not depend on pump mode. + expect( + base.data, + orderedEquals([0x00, 0xC4, 0x5A, 0xB8, 0x9E, 0x2D, 0x04, 0x00]), + ); + }, + ); + + test( + 'flag byte: comparePower + dcGT set, doCompare/dcCompF/ctrlF clear', + () async { + final frames = await uploadFrames( + leverPowerProfile(ExitCondition.over, 4.5), + 0xF, + ); + final flag = frames.firstWhere((w) => w.data[0] == 0x00).data[1]; + expect(flag & Helper.comparePower, Helper.comparePower); + expect(flag & Helper.dcGT, Helper.dcGT, reason: 'over'); + expect(flag & Helper.doCompare, 0, reason: 'doCompare MUST be clear'); + expect(flag & Helper.dcCompF, 0, reason: 'dcCompF MUST be clear'); + expect( + flag & Helper.ctrlF, + 0, + reason: 'a lever step is not flow priority', + ); + }, + ); + + test( + 'power under 2.0 W: flag 0xC0 (no dcGT), TriggerVal 0x14 watts', + () async { + final frames = await uploadFrames( + leverPowerProfile(ExitCondition.under, 2.0), + 0xF, + ); + final base = frames.firstWhere((w) => w.data[0] == 0x00); + expect( + base.data[1], + 0xC0, + reason: 'ignoreLimit | comparePower, dcGT clear (under)', + ); + expect(base.data[5], 0x14, reason: 'TriggerVal = U8D1(2.0 W)'); + }, + ); + + test( + 'the lever step still emits its Mode=2 ext frame alongside the exit', + () async { + final frames = await uploadFrames( + leverPowerProfile(ExitCondition.over, 4.5), + 0xF, + ); + final ext = frames.firstWhere((w) => w.data[0] == 0x20); // 32 + step 0 + expect( + ext.data[3], + 2, + reason: 'Mode = Lever, unchanged by the power exit', + ); + }, + ); + }); + + group('convertProfileFlags: power exit does NOT set doCompare', () { + ProfileStepPressure pressureExit(ExitType type, ExitCondition cond) => + ProfileStepPressure( + name: 's', + transition: TransitionType.fast, + volume: 0, + seconds: 10, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 9.0, + exit: StepExitCondition(type: type, condition: cond, value: 4.5), + ); + + test('power over: comparePower + dcGT, clear doCompare/dcCompF', () { + final flag = Helper.convertProfileFlags( + pressureExit(ExitType.power, ExitCondition.over), + ); + expect(flag & Helper.comparePower, Helper.comparePower); + expect(flag & Helper.dcGT, Helper.dcGT); + expect(flag & Helper.doCompare, 0); + expect(flag & Helper.dcCompF, 0); + }); + + test('power under: comparePower only (no dcGT), clear doCompare', () { + final flag = Helper.convertProfileFlags( + pressureExit(ExitType.power, ExitCondition.under), + ); + expect(flag & Helper.comparePower, Helper.comparePower); + expect(flag & Helper.dcGT, 0); + expect(flag & Helper.doCompare, 0); + }); + + test('flow exit still sets doCompare + dcCompF, NOT comparePower', () { + final flag = Helper.convertProfileFlags( + pressureExit(ExitType.flow, ExitCondition.over), + ); + expect(flag & Helper.doCompare, Helper.doCompare); + expect(flag & Helper.dcCompF, Helper.dcCompF); + expect(flag & Helper.comparePower, 0); + }); + + test('pressure exit still sets doCompare, NOT dcCompF/comparePower', () { + final flag = Helper.convertProfileFlags( + pressureExit(ExitType.pressure, ExitCondition.under), + ); + expect(flag & Helper.doCompare, Helper.doCompare); + expect(flag & Helper.dcCompF, 0); + expect(flag & Helper.comparePower, 0); + }); + }); + + group('TriggerVal is U8D1 watts', () { + test('10.0 W -> 0x64', () { + expect(Helper.convert_float_to_U8D1(10.0), 0x64); + }); + + test('encoded TriggerVal byte for a 10.0 W exit is 0x64', () async { + const p = Profile( + version: '2', + title: 't', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepPressure( + name: 's', + transition: TransitionType.fast, + volume: 0, + seconds: 10, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 9.0, + exit: StepExitCondition( + type: ExitType.power, + condition: ExitCondition.over, + value: 10.0, + ), + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 0, + ); + final frames = await uploadFrames(p, 0xF); + final base0 = frames.firstWhere((w) => w.data[0] == 0); + expect(base0.data[5], 0x64, reason: 'TriggerVal = U8D1(10.0 W)'); + }); + }); + + group('arm-time refusal (power exit)', () { + Profile powerExitProfile() => const Profile( + version: '2', + title: 'power exit only', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepPressure( + name: 's', + transition: TransitionType.fast, + volume: 0, + seconds: 20, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 9.0, + exit: StepExitCondition( + type: ExitType.power, + condition: ExitCondition.over, + value: 4.5, + ), + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 90, + ); + + // A plain pressure/flow cross-exit that already runs on a stock DE1 — it + // must NEVER be gated (regression guard). + Profile flowExitProfile() => const Profile( + version: '2', + title: 'flow exit only', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepPressure( + name: 's', + transition: TransitionType.fast, + volume: 0, + seconds: 20, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 9.0, + exit: StepExitCondition( + type: ExitType.flow, + condition: ExitCondition.under, + value: 1.5, + ), + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 90, + ); + + Future connect({required int model, required int caps}) async { + final transport = FakeBleTransport(); + final de1 = UnifiedDe1(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + transport.queueOnConnectResponses(v13Model: model, profileModeCaps: caps); + await de1.onConnect(); + addTearDown(transport.dispose); + return de1; + } + + test('Bengle caps 0xF (bit3 set): a power exit arms (no throw)', () async { + final de1 = await connect(model: 128, caps: 0xF); + expect(de1.machineInfo.extra['profileModeCaps'], 0xF); + await de1.setProfile(powerExitProfile()); // completes + }); + + test( + 'Bengle caps 0x7 (bit3 absent): a power exit is refused with 400', + () async { + final de1 = await connect(model: 128, caps: 0x7); + expect(de1.machineInfo.extra['profileModeCaps'], 0x7); + await expectLater( + de1.setProfile(powerExitProfile()), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('power exit condition'), + isNot(contains('pump mode')), + ), + ), + ), + ); + }, + ); + + test( + 'non-Bengle DE1: a power exit is refused (names the power exit)', + () async { + final de1 = await connect(model: 1, caps: 0); + expect(de1.isBengle, isFalse); + await expectLater( + de1.setProfile(powerExitProfile()), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('power exit condition'), + ), + ), + ); + }, + ); + + test('regression: a flow cross-exit is NOT gated on a stock DE1', () async { + final de1 = await connect(model: 1, caps: 0); + expect(de1.isBengle, isFalse); + // A pressure/flow cross-exit runs on stock firmware; it must arm without + // any capability refusal. + await de1.setProfile(flowExitProfile()); // completes + }); + + // A LEVER step whose pump mode IS supported at caps 0x7 (Lever = bit1) but + // which ALSO carries a power exit (bit3, absent at 0x7). The power exit is + // an orthogonal gate: the refusal must name the power EXIT condition, never + // the (supported) lever pump mode. + test( + 'Bengle caps 0x7: a lever-step power exit is refused, naming the exit', + () async { + final de1 = await connect(model: 128, caps: 0x7); + expect(de1.machineInfo.extra['profileModeCaps'], 0x7); + await expectLater( + de1.setProfile(leverPowerProfile(ExitCondition.over, 4.5)), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('power exit condition'), + isNot(contains('pump mode')), + ), + ), + ), + ); + }, + ); + + test('Bengle caps 0xF: a lever-step power exit arms (no throw)', () async { + final de1 = await connect(model: 128, caps: 0xF); + expect(de1.machineInfo.extra['profileModeCaps'], 0xF); + await de1.setProfile(leverPowerProfile(ExitCondition.over, 4.5)); // ok + }); + }); + + group('caps mask widening to 0xF', () { + Future connectCaps(int caps) async { + final transport = FakeBleTransport(); + final de1 = UnifiedDe1(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + transport.queueOnConnectResponses(v13Model: 128, profileModeCaps: caps); + await de1.onConnect(); + addTearDown(transport.dispose); + return de1; + } + + test('0x9 (Power + power exit) survives the widened ~0xF mask', () async { + final de1 = await connectCaps(0x9); + expect(de1.machineInfo.extra['profileModeCaps'], 0x9); + }); + + test('0xF survives (all bits) — not zeroed by the mask', () async { + final de1 = await connectCaps(0xF); + expect(de1.machineInfo.extra['profileModeCaps'], 0xF); + }); + + test('a stray bit above 0xF still fail-closes to 0', () async { + final de1 = await connectCaps(0x10); + expect(de1.machineInfo.extra['profileModeCaps'], 0); + }); + }); +} diff --git a/test/models/device/unified_de1_profile_modes_test.dart b/test/models/device/unified_de1_profile_modes_test.dart new file mode 100644 index 000000000..8c9ab2b16 --- /dev/null +++ b/test/models/device/unified_de1_profile_modes_test.dart @@ -0,0 +1,310 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/models/data/profile.dart'; +import 'package:reaprime/src/models/device/impl/de1/de1.models.dart'; +import 'package:reaprime/src/models/device/impl/de1/unified_de1/unified_de1.dart'; +import 'package:reaprime/src/models/errors.dart'; + +import '../../helpers/fake_ble_transport.dart'; + +/// Byte-exact ext-frame encoding for the additive Power / Lever pump modes, +/// plus the capability refusal gate. A real [UnifiedDe1] over +/// [FakeBleTransport], connected as a Bengle (`v13Model = 128`) that advertises +/// the caps mask. +/// +/// New-mode steps are NOT ProfileStepFlow, so `CtrlF` stays 0 (pressure +/// priority) and the base-frame SetVal already encodes W / P₀ via the shared +/// U8D1 path. The ext frame is what carries the mode: +/// data[3] = mode (1 Power, 2 Lever) +/// data[4] = ModeMaxP (Power: pressure cap; Lever: P₀ == base SetVal byte) +/// data[5] = leverSpring (U8D1) data[6] = leverGive (U8D1) data[7] = 0 +/// data[1]/[2] = stock limiter (Power 0/0; Lever flow cap value/range or 0/0) +/// +/// Golden bytes (U8D1 = value×10): +/// power 2.0 W -> base SetVal 0x14 +/// pressure/P₀ 9.0 bar -> 0x5A +/// pressure/P₀ 8.0 bar -> 0x50 +/// flow cap 6.0 ml/s -> 0x3C range 0.6 -> 0x06 +/// leverSpring 0.9 -> 0x09 leverGive 1.5 -> 0x0F 2.5 -> 0x19 +void main() { + // Frame 0: Power 2.0 W with the mandatory 9.0-bar pressure cap. + // Frame 1: Lever P₀ 9.0 (CLASSIC), spring 0.9, give 1.5, WITH a 6.0 ml/s + // flow cap. + // Frame 2: Lever P₀ 8.0, spring 0.6, give 2.5, WITHOUT a flow cap (0/0). + const profile = Profile( + version: '2', + title: 'encoder profile', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepPower( + name: 'power', + transition: TransitionType.fast, + volume: 0, + seconds: 10, + temperature: 92, + sensor: TemperatureSensor.coffee, + power: 2.0, + limiter: StepLimiter(value: 9.0, range: 0.6), + ), + ProfileStepLever( + name: 'lever-capped', + transition: TransitionType.smooth, + volume: 0, + seconds: 30, + temperature: 90, + sensor: TemperatureSensor.coffee, + pressure: 9.0, + leverSpring: 0.9, + leverGive: 1.5, + limiter: StepLimiter(value: 6.0, range: 0.6), + ), + ProfileStepLever( + name: 'lever-uncapped', + transition: TransitionType.fast, + volume: 0, + seconds: 30, + temperature: 90, + sensor: TemperatureSensor.coffee, + pressure: 8.0, + leverSpring: 0.6, + leverGive: 2.5, + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 0, + ); + + Future> uploadFrames() async { + final transport = FakeBleTransport(); + final de1 = UnifiedDe1(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + // Advertise both Power and Lever so the refusal gate lets this proceed. + transport.queueOnConnectResponses(v13Model: 128, profileModeCaps: 0x3); + await de1.onConnect(); + await de1.setProfile(profile); + final frames = transport.writes + .where((w) => w.characteristicUUID == Endpoint.frameWrite.uuid) + .toList(); + await transport.dispose(); + return frames; + } + + group('Bengle ext-frame encoding', () { + late List frames; + + setUpAll(() async { + frames = await uploadFrames(); + }); + + test('sequence = 3 base + 3 ext + tail, in order', () { + expect(frames, hasLength(7)); + expect(frames[0].data[0], 0, reason: 'base frame 0'); + expect(frames[1].data[0], 1, reason: 'base frame 1'); + expect(frames[2].data[0], 2, reason: 'base frame 2'); + expect(frames[3].data[0], 32, reason: 'ext frame step 0'); + expect(frames[4].data[0], 33, reason: 'ext frame step 1'); + expect(frames[5].data[0], 34, reason: 'ext frame step 2'); + expect(frames[6].data[0], 3, reason: 'tail = steps.length'); + }); + + test('base SetVal: power 2.0 W -> 0x14', () { + expect(frames[0].data[2], 0x14); + }); + + test('base flag byte: power step keeps CtrlF=0 (pressure priority)', () { + // ignoreLimit(0x40) only — no CtrlF(0x01), no interpolate (fast). + expect(frames[0].data[1], 0x40); + }); + + test('base SetVal: lever P0 9.0 -> 0x5A, 8.0 -> 0x50', () { + expect(frames[1].data[2], 0x5A); + expect(frames[2].data[2], 0x50); + }); + + test( + 'base flag byte: lever step keeps CtrlF=0 (smooth adds interpolate)', + () { + // ignoreLimit(0x40) | interpolate(0x20) = 0x60, still no CtrlF. + expect(frames[1].data[1], 0x60); + }, + ); + + test('power ext frame: [0,0, mode=1, cap=0x5A, 0,0,0]', () { + final d = frames[3].data; + expect(d[1], 0x00, reason: 'no flow cap for Power'); + expect(d[2], 0x00); + expect(d[3], 0x01, reason: 'Mode = Power'); + expect(d[4], 0x5A, reason: 'ModeMaxP = pressure cap 9.0'); + expect(d[5], 0x00); + expect(d[6], 0x00); + expect(d[7], 0x00); + }); + + test( + 'lever ext frame (capped): [0x3C,0x06, mode=2, 0x5A, 0x09,0x0F, 0]', + () { + final d = frames[4].data; + expect(d[1], 0x3C, reason: 'flow cap 6.0'); + expect(d[2], 0x06, reason: 'flow cap range 0.6'); + expect(d[3], 0x02, reason: 'Mode = Lever'); + expect(d[4], 0x5A, reason: 'ModeMaxP = P0 9.0 (== base SetVal)'); + expect(d[5], 0x09, reason: 'leverSpring 0.9'); + expect(d[6], 0x0F, reason: 'leverGive 1.5'); + expect(d[7], 0x00); + }, + ); + + test('lever ext frame (uncapped): flow cap 0/0, mode=2, P0=0x50', () { + final d = frames[5].data; + expect(d[1], 0x00, reason: 'no flow cap -> 0'); + expect(d[2], 0x00); + expect(d[3], 0x02, reason: 'Mode = Lever'); + expect(d[4], 0x50, reason: 'ModeMaxP = P0 8.0'); + expect(d[5], 0x06, reason: 'leverSpring 0.6'); + expect(d[6], 0x19, reason: 'leverGive 2.5'); + expect(d[7], 0x00); + }); + }); + + group('a new-mode step on a non-Bengle is refused before any write', () { + test('a plain DE1 (not Bengle) with garbage caps is refused at the gate — ' + 'no frames reach the wire', () async { + final transport = FakeBleTransport(); + final de1 = UnifiedDe1(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + // A DE1 with the caps bits force-set (impossible in the field, but it + // exercises the garbage-caps hole): even though the mask passes the caps + // check, isBengle is false, so the gate refuses BEFORE any BLE write — + // connect as model 1 so isBengle stays false. + transport.queueOnConnectResponses(v13Model: 1, profileModeCaps: 0x3); + await de1.onConnect(); + expect(de1.isBengle, isFalse); + await expectLater( + de1.setProfile(profile), + throwsA(isA()), + ); + // The refusal fires before the encoder runs, so no header/base frames + // were written — no half-written profile stranded on the wire. + expect( + transport.writes + .where((w) => w.characteristicUUID == Endpoint.frameWrite.uuid) + .isEmpty, + isTrue, + reason: 'gate must refuse before any frame write (no half-write)', + ); + await transport.dispose(); + }); + }); + + group('capability refusal gate (setProfile)', () { + Profile leverProfile() => const Profile( + version: '2', + title: 'lever only', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepLever( + name: 'lever', + transition: TransitionType.smooth, + volume: 0, + seconds: 30, + temperature: 92, + sensor: TemperatureSensor.coffee, + pressure: 9.0, + leverSpring: 0.9, + leverGive: 1.5, + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 90, + ); + + Profile powerProfile() => const Profile( + version: '2', + title: 'power only', + notes: '', + author: 'test', + beverageType: BeverageType.espresso, + steps: [ + ProfileStepPower( + name: 'power', + transition: TransitionType.smooth, + volume: 0, + seconds: 25, + temperature: 93, + sensor: TemperatureSensor.coffee, + power: 2.0, + limiter: StepLimiter(value: 9.0, range: 0.6), + ), + ], + targetVolumeCountStart: 0, + tankTemperature: 90, + ); + + Future connect(int caps) async { + final transport = FakeBleTransport(); + final de1 = UnifiedDe1(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + transport.queueOnConnectResponses(v13Model: 128, profileModeCaps: caps); + await de1.onConnect(); + addTearDown(transport.dispose); + return de1; + } + + test('caps 0: a lever profile is refused with a typed exception', () async { + final de1 = await connect(0); + expect(de1.machineInfo.extra['profileModeCaps'], 0); + await expectLater( + de1.setProfile(leverProfile()), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('Lever'), contains('does not support')), + ), + ), + ); + }); + + test('caps 0: a power profile is refused with a typed exception', () async { + final de1 = await connect(0); + await expectLater( + de1.setProfile(powerProfile()), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Power'), + ), + ), + ); + }); + + test('caps 0x3: both proceed (no throw)', () async { + final de1 = await connect(0x3); + expect(de1.machineInfo.extra['profileModeCaps'], 0x3); + await de1.setProfile(leverProfile()); // completes + await de1.setProfile(powerProfile()); // completes + }); + + test('caps 0x1 (Power only): power proceeds, lever refused', () async { + final de1 = await connect(0x1); + await de1.setProfile(powerProfile()); // completes + await expectLater( + de1.setProfile(leverProfile()), + throwsA(isA()), + ); + }); + + test('caps 0x2 (Lever only): lever proceeds, power refused', () async { + final de1 = await connect(0x2); + await de1.setProfile(leverProfile()); // completes + await expectLater( + de1.setProfile(powerProfile()), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/profiles/default_profiles_bundled_test.dart b/test/profiles/default_profiles_bundled_test.dart index d71e20ad3..d0b2a8e3e 100644 --- a/test/profiles/default_profiles_bundled_test.dart +++ b/test/profiles/default_profiles_bundled_test.dart @@ -3,7 +3,13 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:reaprime/src/models/data/profile.dart' - show ExitCondition, ExitType, Profile; + show + ExitCondition, + ExitType, + Profile, + ProfileStepFlow, + ProfileStepLever, + TransitionType; import 'package:reaprime/src/models/data/profile_hash.dart'; void main() { @@ -141,4 +147,60 @@ void main() { ]), ); }); + + group('Lever Classic demo (bundled lever pump-mode demo)', () { + Profile leverDemo() { + final p = profiles['lever_classic_demo.json']; + expect( + p, + isNotNull, + reason: 'lever_classic_demo.json must be listed in the manifest', + ); + return p!; + } + + test('is present and parses its pump:"lever" step from the bundle', () { + final p = leverDemo(); + expect(p.title, 'Lever Classic demo'); + // The last step is the lever step — proves the values-as-strings bundle + // parses through ProfileStepLever (leverSpring/leverGive via parseDouble). + final lever = p.steps.last; + expect(lever, isA()); + final l = lever as ProfileStepLever; + expect(l.pressure, 9.0); // P0 + expect(l.leverSpring, 0.9); + expect(l.leverGive, 1.5); + // The demo must START at 9 bar and decline under its spring/give. A + // `smooth` lever step would set the interpolate bit and ramp P0 up across + // the whole frame — the opposite of a lever decline — so the transition + // must be `fast`. + expect(l.transition, TransitionType.fast); + }); + + test('carries the tablet-authored numeric tweaks', () { + final p = leverDemo(); + // fast-fill flow raised to 12 ml/s; per-step volume caps disabled (0). + final fill = p.steps.first as ProfileStepFlow; + expect(fill.flow, 12.0); + for (final s in p.steps) { + expect(s.volume, 0.0, reason: '${s.name} volume cap disabled'); + } + expect(p.targetWeight, 36.0); + }); + + test('round-trips losslessly through the profile model', () { + final p = leverDemo(); + final restored = Profile.fromJson(p.toJson()); + expect(restored, equals(p)); + }); + + test('notes and author name the capability, not the firmware family', () { + final p = leverDemo(); + // Author is the plain app name (no parenthetical firmware qualifier). + expect(p.author, 'reaprime'); + // The requirement is phrased as a capability, not a private firmware name. + expect(p.notes, contains('lever profile steps')); + expect(p.notes, contains('a stock machine will refuse it')); + }); + }); } diff --git a/test/services/webserver/de1handler_profile_modes_test.dart b/test/services/webserver/de1handler_profile_modes_test.dart new file mode 100644 index 000000000..9ef383038 --- /dev/null +++ b/test/services/webserver/de1handler_profile_modes_test.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/controllers/de1_controller.dart'; +import 'package:reaprime/src/controllers/device_controller.dart'; +import 'package:reaprime/src/controllers/workflow_controller.dart'; +import 'package:reaprime/src/settings/settings_controller.dart'; +import 'package:reaprime/src/models/device/de1_interface.dart'; +import 'package:reaprime/src/models/errors.dart'; +import 'package:reaprime/src/models/device/impl/de1/de1.models.dart'; +import 'package:reaprime/src/services/webserver_service.dart'; +import 'package:shelf_plus/shelf_plus.dart'; + +import 'package:reaprime/src/models/device/impl/bengle/bengle.dart'; + +import '../../helpers/fake_ble_transport.dart'; +import '../../helpers/mock_device_discovery_service.dart'; +import '../../helpers/mock_settings_service.dart'; +import '../../helpers/test_scale.dart'; +import '../../helpers/test_scale_controller.dart'; + +/// POST /api/v1/machine/profile is the machine-push choke point for the +/// capability refusal gate. A profile with a Power/Lever step must land as a +/// CLEAN 400 (with the refusal message) when the machine has not advertised the +/// matching caps bit — not the opaque 500 the withDe1 catch-all would otherwise +/// produce — and go through (200) when it has. +class _FixedDe1Controller extends De1Controller { + _FixedDe1Controller({required super.controller, this.device}); + + De1Interface? device; + + @override + De1Interface connectedDe1() { + final d = device; + if (d == null) throw const DeviceNotConnectedException.machine(); + return d; + } + + // runDeviceWrite also identity-checks the machine against this before and + // after the write; without the override it stays null, the write is skipped + // as "machine changed", and every response becomes a 500. + @override + De1Interface? get connectedDe1OrNull => device; +} + +void main() { + late Handler handler; + + Future wireWith(De1Interface? device) async { + final deviceController = DeviceController([MockDeviceDiscoveryService()]); + await deviceController.initialize(); + final controller = _FixedDe1Controller( + controller: deviceController, + device: device, + ); + + final mockSettings = MockSettingsService(); + final settingsController = SettingsController(mockSettings); + await settingsController.loadSettings(); + + final scaleController = TestScaleController(TestScale()); + + final de1Handler = De1Handler( + controller: controller, + settingsController: settingsController, + scaleController: scaleController, + workflowController: WorkflowController(), + ); + final app = Router().plus; + de1Handler.addRoutes(app); + handler = app.call; + } + + /// Connect a real Bengle over the fake transport, advertising [caps]. + Future connectedBengle(int caps) async { + final transport = FakeBleTransport(); + final bengle = Bengle(transport: transport); + transport.queueMmrResponseInt(MMRItem.calFlowEst, 100); + transport.queueOnConnectResponses(v13Model: 128, profileModeCaps: caps); + // Bengle.onConnect also hydrates the LED palette. Without a queued answer + // that read waits out its fail-closed timeout - 12.6 s per test, and this + // file was the slowest in the whole suite because of it. + transport.queuePaletteHydrationResponses(); + await bengle.onConnect(); + addTearDown(transport.dispose); + return bengle; + } + + Future postProfile(Map profile) async => handler( + Request( + 'POST', + Uri.parse('http://localhost/api/v1/machine/profile'), + body: jsonEncode(profile), + headers: {HttpHeaders.contentTypeHeader: 'application/json'}, + ), + ); + + Map leverProfile() => { + 'version': '2', + 'title': 'Lever demo', + 'beverage_type': 'espresso', + 'steps': [ + { + 'name': 'lever', + 'pump': 'lever', + 'transition': 'smooth', + 'volume': 100, + 'seconds': 40, + 'temperature': 92, + 'sensor': 'coffee', + 'pressure': 9.0, + 'leverSpring': 0.9, + 'leverGive': 1.5, + }, + ], + 'tank_temperature': 90.0, + 'target_weight': 36.0, + 'target_volume_count_start': 0, + }; + + Map powerProfileNoLimiter() => { + 'version': '2', + 'title': 'Power (bad)', + 'beverage_type': 'espresso', + 'steps': [ + { + 'name': 'power', + 'pump': 'power', + 'transition': 'smooth', + 'volume': 100, + 'seconds': 25, + 'temperature': 93, + 'sensor': 'coffee', + 'power': 2.0, + }, + ], + 'tank_temperature': 90.0, + 'target_volume_count_start': 0, + }; + + group('POST /api/v1/machine/profile — capability refusal gate', () { + test( + '400 (not 500) with the refusal message when caps are absent', + () async { + await wireWith(await connectedBengle(0)); + + final res = await postProfile(leverProfile()); + + expect(res.statusCode, 400); + final body = + jsonDecode(await res.readAsString()) as Map; + expect(body['error'], 'Unsupported profile'); + expect(body['message'], contains('Lever')); + expect(body['message'], contains('does not support')); + }, + ); + + test( + '200 when the machine advertises the Lever capability (0x3)', + () async { + await wireWith(await connectedBengle(0x3)); + + final res = await postProfile(leverProfile()); + + expect(res.statusCode, 200); + }, + ); + + test( + '400 for a power step missing its mandatory limiter (FormatException)', + () async { + await wireWith(await connectedBengle(0x3)); + + final res = await postProfile(powerProfileNoLimiter()); + + expect(res.statusCode, 400); + final body = + jsonDecode(await res.readAsString()) as Map; + expect(body['error'], 'Invalid profile'); + }, + ); + }); +} diff --git a/test/services/webserver/profile_handler_pump_modes_test.dart b/test/services/webserver/profile_handler_pump_modes_test.dart new file mode 100644 index 000000000..00a8e619d --- /dev/null +++ b/test/services/webserver/profile_handler_pump_modes_test.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/controllers/profile_controller.dart'; +import 'package:reaprime/src/models/data/profile_record.dart'; +import 'package:reaprime/src/services/storage/profile_storage_service.dart'; +import 'package:reaprime/src/services/webserver_service.dart'; +import 'package:shelf_plus/shelf_plus.dart'; + +/// The profile STORAGE routes are machine-independent — they must accept and +/// round-trip pump:"power"/"lever" profiles regardless of any connected +/// machine's capabilities (the refusal gate lives on the machine-push path +/// only). +class _StubStorage implements ProfileStorageService { + final Map _records = {}; + + @override + Future initialize() async {} + @override + Future store(ProfileRecord record) async => + _records[record.id] = record; + @override + Future get(String id) async => _records[id]; + @override + Future> getAll({Visibility? visibility}) async => + _records.values.toList(); + @override + Future update(ProfileRecord record) async => + _records[record.id] = record; + @override + Future replace(String oldId, ProfileRecord replacement) async { + _records[replacement.id] = replacement; + _records.remove(oldId); + } + + @override + Future delete(String id) async => _records.remove(id); + @override + Future exists(String id) async => _records.containsKey(id); + @override + Future> getAllIds() async => _records.keys.toList(); + @override + Future> getByParentId(String parentId) async => const []; + @override + Future storeAll(List records) async { + for (final r in records) { + _records[r.id] = r; + } + } + + @override + Future clear() async => _records.clear(); + @override + Future count({Visibility? visibility}) async => _records.length; +} + +void main() { + late Handler handler; + + Future postProfile(Map body) async { + return await handler( + Request( + 'POST', + Uri.parse('http://localhost/api/v1/profiles'), + body: jsonEncode(body), + ), + ); + } + + setUp(() { + final controller = ProfileController(storage: _StubStorage()); + final profileHandler = ProfileHandler(controller: controller); + final app = Router().plus; + profileHandler.addRoutes(app); + handler = app.call; + }); + + Map leverProfile() => { + 'version': '2', + 'title': 'Lever demo', + 'beverage_type': 'espresso', + 'steps': [ + { + 'name': 'lever', + 'pump': 'lever', + 'transition': 'smooth', + 'volume': 100, + 'seconds': 40, + 'temperature': 92, + 'sensor': 'coffee', + 'pressure': 9.0, + 'leverSpring': 0.9, + 'leverGive': 1.5, + }, + ], + 'tank_temperature': 90.0, + 'target_weight': 36.0, + 'target_volume_count_start': 0, + }; + + Map powerProfile() => { + 'version': '2', + 'title': 'Power demo', + 'beverage_type': 'espresso', + 'steps': [ + { + 'name': 'power', + 'pump': 'power', + 'transition': 'smooth', + 'volume': 100, + 'seconds': 25, + 'temperature': 93, + 'sensor': 'coffee', + 'power': 2.0, + 'limiter': {'value': 9.0, 'range': 0.6}, + }, + ], + 'tank_temperature': 90.0, + 'target_weight': 36.0, + 'target_volume_count_start': 0, + }; + + group('POST /api/v1/profiles (storage) accepts novel pump-mode profiles', () { + test('a lever profile stores (201) and round-trips', () async { + final response = await postProfile({'profile': leverProfile()}); + + expect(response.statusCode, 201); + final record = + jsonDecode(await response.readAsString()) as Map; + final step = + (record['profile']['steps'] as List).first as Map; + expect(step['pump'], 'lever'); + expect(step['pressure'], 9.0); + expect(step['leverSpring'], 0.9); + expect(step['leverGive'], 1.5); + }); + + test('a power profile stores (201) and round-trips its limiter', () async { + final response = await postProfile({'profile': powerProfile()}); + + expect(response.statusCode, 201); + final record = + jsonDecode(await response.readAsString()) as Map; + final step = + (record['profile']['steps'] as List).first as Map; + expect(step['pump'], 'power'); + expect(step['power'], 2.0); + expect(step['limiter']['value'], 9.0); + }); + + test( + 'a power profile WITHOUT a limiter is a 400 (schema violation)', + () async { + final body = powerProfile(); + (body['steps'] as List).first.remove('limiter'); + + final response = await postProfile({'profile': body}); + + expect(response.statusCode, 400); + }, + ); + }); +}