From fd0fdf8f0ae066c89eef2120c0e994c8f6744a9c Mon Sep 17 00:00:00 2001 From: ChampionDesigns Date: Tue, 25 Aug 2026 15:24:11 +1000 Subject: [PATCH 1/4] Add BengleShotSample (0xA013), the Bengle high-resolution shot sample A Bengle exposes an extra BLE characteristic carrying a superset of ShotSample at higher precision, plus a milk probe and integrated-scale fields the stock packet has no room for. 28 bytes, big-endian, matching the firmware's T_BengleShotSample and decodeBengleShotSample in decentespresso/decaid. ShotSample (0xA00D) keeps its stock v1 layout on every machine, a Bengle included, so a stock build talking to a Bengle still works and this app talking to a DE1 still works, with no negotiation on the shared packet. Weight is SIGNED (S16P4). It is net of tare, so an unloaded platform after a tare reads negative; clamping at zero would hide a real reading. from_shotvalue splits into from_shotvalue / from_bengleshotvalue and a shared _apply_shotvalue. The stale-frame guard added by #343 moves into the shared body, so it now protects the 0xA013 path too. The superset-only fields are picked up with [info exists]. On a Bengle the 0xA00D dispatch is gated off: it still streams, but charting both would double-sample with broken intersample timing. Receiving 0xA013 pins the protocol to v2 immediately, closing the window between connect and the model MMR read. The enable is gated on the characteristic being present in ::cinstance. That gate is load-bearing: on a DE1 the enable would throw on the unset instance, and the vital-retry path re-runs a failed vital command every 500 ms WITHOUT advancing the FIFO, stalling the whole BLE queue. gui.tcl normalises sample-to-sample deltas to their 5 Hz-equivalent magnitude using the measured intersample time. The chart math was implicitly tuned for 5 Hz; without this the delta trace and the diff_flow_rate_text readout scale with the notify rate. Falls back to the raw delta when the event carries no intersample time. The milk probe lands here too: ::de1(milk_temperature), a steam_milk_temperature chart vector, and milktemp / milktemp_text. 0 means no probe. set_target_milk_temp is gated and clamped to 0-85 C, matching BengleSteamMmr.targetMilkTemp in decaid. Co-Authored-By: Claude Opus 5 (1M context) --- de1plus/bluetooth.tcl | 26 ++++++++- de1plus/de1_comms.tcl | 45 +++++++++++++++ de1plus/de1_de1.tcl | 127 ++++++++++++++++++++++++++++++++++++++---- de1plus/gui.tcl | 33 +++++++++-- de1plus/machine.tcl | 2 + de1plus/shot.tcl | 4 +- de1plus/utils.tcl | 2 +- de1plus/vars.tcl | 12 ++++ 8 files changed, 233 insertions(+), 18 deletions(-) diff --git a/de1plus/bluetooth.tcl b/de1plus/bluetooth.tcl index 0eebffc2..b535d2a3 100755 --- a/de1plus/bluetooth.tcl +++ b/de1plus/bluetooth.tcl @@ -2781,7 +2781,15 @@ proc de1_ble_handler { event data } { if {$cuuid eq $::de1(cuuid_0D)} { set ::de1(last_ping) [clock seconds] - ::de1::state::update::from_shotvalue $value $event_time + # Additive BLE: on a Bengle the high-resolution superset + # (BengleShotSample, 0xA013) is the sole shot-sample source + # and drives the chart. The stock 0xA00D still streams (so a + # stock DE1 app works), but we must NOT also chart it here or + # the chart would be double-sampled at 2x with broken + # intersample timing. + if {![::de1::packet::use_ble_v2]} { + ::de1::state::update::from_shotvalue $value $event_time + } set do_this 0 if {$do_this == 1} { # this tries to handle bad write situations, but it might have side effects if it is not working correctly. @@ -2793,6 +2801,22 @@ proc de1_ble_handler { event data } { return } } + } elseif {$cuuid eq $::de1(cuuid_13)} { + # Additive BLE: BengleShotSample (0xA013) -- the Bengle + # self-contained high-resolution superset. On a Bengle this + # is the SOLE shot-sample source: it drives the pressure/flow/ + # temp chart AND bridges integrated-scale weight/flow + milk + # into the scale pipeline (chart / SAW / display). All of that + # lives in _apply_shotvalue, shared with the stock v1 path. + set ::de1(last_ping) [clock seconds] + # Receiving 0xA013 is definitive proof this is a Bengle, so + # pin the protocol to v2 now. This gates off the stock 0xA00D + # charting path (use_ble_v2) from the very first sample, + # closing the connect-time window -- before the model MMR read + # lands -- where both 0xA00D and 0xA013 would otherwise chart. + # set_ble_protocol_version short-circuits once already at 2. + ::de1::packet::set_ble_protocol_version 2 + ::de1::state::update::from_bengleshotvalue $value $event_time } elseif {$cuuid eq $::de1(cuuid_05)} { # MMR read ::bt::msg -INFO "MMR read: [::logging::format_mmr $value]" diff --git a/de1plus/de1_comms.tcl b/de1plus/de1_comms.tcl index c4fda87e..86e1fe3a 100644 --- a/de1plus/de1_comms.tcl +++ b/de1plus/de1_comms.tcl @@ -284,6 +284,7 @@ proc de1_connect_handler { handle address name} { set dothis 1 if {$dothis == 1} { de1_enable_temp_notifications + de1_enable_bengleshotsample_notifications ;# Additive: 0xA013 (no-op on a stock DE1) if {[info exists ::de1(first_connection_was_made)] != 1} { # on app startup, wake the machine up @@ -756,6 +757,28 @@ proc de1_enable_temp_notifications {} { userdata_append "enable de1 temp notifications" [list de1_comm enable "ShotSample"] 1 } +# Additive BLE: subscribe to the Bengle high-resolution shot sample +# characteristic (BengleShotSample, 0xA013). Only a Bengle exposes it; a stock +# DE1 does not, so we must NOT enqueue the enable there. The enable is vital, +# and on a stock DE1 de1_ble would throw on the unset ::cinstance(0xA013); the +# vital-retry path then re-runs it every 500ms WITHOUT advancing the FIFO, +# permanently stalling the whole BLE command queue (blocking the version/state +# reads, profile/MMR writes, etc.). Gate on the discovered characteristic +# instance -- discovery populates ::cinstance for present characteristics before +# this connect-time enable runs (the temp/state enables rely on the same). +proc de1_enable_bengleshotsample_notifications {} { + ::comms::msg -NOTICE de1_enable_bengleshotsample_notifications + if {[ifexists ::sinstance($::de1(suuid))] == ""} { + ::comms::msg -DEBUG "DE1 not connected, cannot enable BengleShotSample notifications" + return + } + if {![info exists ::cinstance($::de1(cuuid_13))]} { + ::comms::msg -DEBUG "BengleShotSample (0xA013) not present on this machine (stock DE1); skipping enable" + return + } + userdata_append "enable de1 bengleshotsample notifications" [list de1_comm enable "BengleShotSample"] 1 +} + # status changes proc de1_enable_state_notifications {} { ::comms::msg -NOTICE de1_enable_state_notifications @@ -1266,6 +1289,21 @@ proc set_steam_flow {desired_flow} { mmr_write "set_steam_flow" "803828" "04" [zero_pad [int_to_hex $desired_flow] 2] } +# Milk auto-stop target in C (stored on firmware as C*10). 0 disables. +# Gated and clamped to match the reaprime/decaid contract for this register +# (TargetMilkTemp 0x008038A8, raw 0..850). +proc set_target_milk_temp {temp_c} { + if {[is_bengle_model] != 1} { + return + } + set raw [expr {int(round($temp_c * 10))}] + if {$raw < 0} { set raw 0 } + if {$raw > 850} { set raw 850 } + ::comms::msg -NOTICE set_target_milk_temp "'$temp_c' (raw $raw)" + remove_matching_ble_queue_entries {^MMR set_target_milk_temp} + mmr_write "set_target_milk_temp" "8038A8" "04" [zero_pad [long_to_little_endian_hex $raw] 2] +} + proc send_refill_kit_override {} { if {$::settings(refill_kit_override) == 0} { set_refill_kit_present 0 @@ -1644,6 +1682,13 @@ proc de1_send_steam_hotwater_settings { {temporarily_disable_steam 0} } { # only works on Bengle set_cupwarmer_temperature $::settings(cupwarmer_temp) + + # Milk temp auto-stop: write the target, or 0 to disable. + if {[ifexists ::settings(steam_stop_mode)] eq "temp"} { + set_target_milk_temp $::settings(target_milk_temp) + } else { + set_target_milk_temp 0 + } } diff --git a/de1plus/de1_de1.tcl b/de1plus/de1_de1.tcl index 37dbf12b..9359ba8d 100644 --- a/de1plus/de1_de1.tcl +++ b/de1plus/de1_de1.tcl @@ -520,27 +520,64 @@ namespace eval ::de1::state::update { proc from_shotvalue {packed {update_received 0}} { + # Stock DE1 v1 ShotSample (0xA00D). On a Bengle (model 128) the chart is + # driven by from_bengleshotvalue (the 0xA013 superset) instead, and the + # bluetooth dispatch skips this path -- see use_ble_v2. + if { $update_received == 0 } { set update_received [expr {[clock milliseconds] / 1000.0}] } - # TODO: Consider capturing early (on packet arrival) along with update_received - # in a generic way for all packets + array set ShotSample {} - # Capture for downstreaam consumers of events as well as local use + ::de1::packet::shotsample_parse $packed ShotSample - set this_state [::de1::state::current_state] - set this_substate [::de1::state::current_substate] - set this_flow_phase [::de1::state::flow_phase $this_state $this_substate] + if {[array size ShotSample] == 0} { + # shotsample_parse can return with a blank, if the packet is invalid + msg -WARN "Invalid shot sample received, ignoring" + return + } + + _apply_shotvalue ShotSample $update_received + } + + # Additive BLE: the Bengle self-contained high-resolution superset + # (BengleShotSample, 0xA013) is the SOLE shot-sample source on a Bengle. + # Parse it into a ShotSample-shaped array and run the identical downstream + # pipeline as the stock v1 path; the shared body already handles the + # superset-only fields (GFlow / Weight / MilkTemp) via [info exists]. + proc from_bengleshotvalue {packed {update_received 0}} { + + if { $update_received == 0 } { set update_received [expr {[clock milliseconds] / 1000.0}] } array set ShotSample {} - ::de1::packet::shotsample_parse $packed ShotSample + ::de1::packet::bengleshotsample_parse $packed ShotSample if {[array size ShotSample] == 0} { - # shotsample_parse can return with a blank, if the packet is invalid - msg -WARN "Invalid shot sample received, ignoring" + msg -WARN "Invalid Bengle shot sample received, ignoring" return } + _apply_shotvalue ShotSample $update_received + } + + # Shared post-parse pipeline for both shot-sample sources: drive the live + # chart vars, bridge integrated-scale weight/flow + milk into the scale + # pipeline, integrate dispensed volume, run stop-at-volume, and fire the + # shotvalue callbacks. The parsed sample array is passed by name. + proc _apply_shotvalue {arrName {update_received 0}} { + + upvar 1 $arrName ShotSample + + if { $update_received == 0 } { set update_received [expr {[clock milliseconds] / 1000.0}] } + + # Capture for downstreaam consumers of events as well as local use + + set this_state [::de1::state::current_state] + set this_substate [::de1::state::current_substate] + set this_flow_phase [::de1::state::flow_phase $this_state $this_substate] + + # Stale-sample guard (upstream). Lives in the shared body so it + # protects the Bengle 0xA013 path as well as the stock 0xA00D one. set previous_frame_number $::de1(current_frame_number) if { $this_flow_phase == "during" \ && $ShotSample(FrameNumber) < $previous_frame_number } { @@ -563,6 +600,15 @@ namespace eval ::de1::state::update { set ::de1(current_frame_number) $ShotSample(FrameNumber) set ::de1(steam_heater_temperature) $ShotSample(SteamTemp) + # Bengle ShotSample v2 only: milk temperature probe. + # U16D2 (x0.01 C), 0 = no probe attached. Matches the spec table in + # bengleshotsample_parse below and decodeBengleShotSample in + # decentespresso/decaid, both of which scale by 1/100. + if {[info exists ShotSample(MilkTemp)]} { + set ::de1(milk_temperature) $ShotSample(MilkTemp) + } + + # TODO: Determine if excessive change in SampleTime should invalidate all deltas @@ -666,6 +712,7 @@ namespace eval ::de1::state::update { volume_dispensed \ [expr { $::de1(preinfusion_volume) \ + $::de1(pour_volume) }] \ + intersample_time $intersample_time \ this_state $this_state \ this_substate $this_substate \ ] @@ -674,7 +721,7 @@ namespace eval ::de1::state::update { return - } ;# from_shotvalue + } ;# _apply_shotvalue } ;# ::de1::state::update @@ -811,6 +858,11 @@ namespace eval ::de1::packet { SteamTemp {char {} {} {unsigned} {}} } + # Additive BLE: ShotSample (0xA00D) is ALWAYS the stock DE1 v1 layout -- + # the Bengle high-resolution scale/milk data rides on its own + # characteristic (BengleShotSample, 0xA013; see bengleshotsample_parse), + # never by redefining 0xA00D. So even a Bengle (use_ble_v2 == 1, which + # now only selects the v2 profile encoding) reads ShotSample as v1 here. if {[use_old_ble_spec] == 1} { array set specarr $spec_old ::fields::unpack $t_shotsample $spec_old ShotSample bigeendian @@ -849,6 +901,61 @@ namespace eval ::de1::packet { } ;# shotsample_parse + # Additive BLE: parse the BengleShotSample characteristic (0xA013) -- the + # Bengle self-contained high-resolution shot sample. This SUPERSET carries + # every ShotSample field plus the integrated-scale gravimetric flow (GFlow), + # the milk probe, and a Flags byte, at the v2 precision. Wire layout + # (big-endian, 28 bytes) MUST match the firmware T_BengleShotSample. Layout + # (the v2 precision: U16D2 scalars, S16P4 SIGNED weight) with a trailing Flags byte: + # SampleTime U16 halfcycles since shot start + # GroupPressure U16D2 bar (*0.01) + # SetGroupPressure U16D2 bar (*0.01) + # GroupFlow U16D2 ml/s (*0.01) + # SetGroupFlow U16D2 ml/s (*0.01) + # GFlow U16D2 g/s (*0.01, gravimetric from integrated scale) + # MixTemp U16D2 C (*0.01) + # HeadTemp U16D2 C (*0.01) + # SetMixTemp U16D2 C (*0.01) + # SetHeadTemp U16D2 C (*0.01) + # Weight S16P4 g (*0.0625, SIGNED -- net of tare, may be negative) + # FrameNumber U8 + # SteamTemp U16D2 C (*0.01) + # MilkTemp U16D2 C (*0.01, 0 = no probe) + # Flags U8 bit0 = firmware TAREd + # Populates the SAME array keys as shotsample_parse so _apply_shotvalue + # consumes it unchanged (the superset-only keys GFlow/Weight/MilkTemp are + # picked up there via [info exists]). + proc bengleshotsample_parse {packet target_array_name} { + upvar $target_array_name ShotSample + if {[string length $packet] < 28} { + msg -ERROR "bengleshotsample_parse: short packet [string length $packet] < 28" + return + } + set spec { + SampleTime {Short {} {} {unsigned} {}} + GroupPressure {Short {} {} {unsigned} {$val * 0.01}} + SetGroupPressure {Short {} {} {unsigned} {$val * 0.01}} + GroupFlow {Short {} {} {unsigned} {$val * 0.01}} + SetGroupFlow {Short {} {} {unsigned} {$val * 0.01}} + GFlow {Short {} {} {unsigned} {$val * 0.01}} + MixTemp {Short {} {} {unsigned} {$val * 0.01}} + HeadTemp {Short {} {} {unsigned} {$val * 0.01}} + SetMixTemp {Short {} {} {unsigned} {$val * 0.01}} + SetHeadTemp {Short {} {} {unsigned} {$val * 0.01}} + Weight {Short {} {} {signed} {$val * 0.0625}} + FrameNumber {char {} {} {unsigned} {}} + SteamTemp {Short {} {} {unsigned} {$val * 0.01}} + MilkTemp {Short {} {} {unsigned} {$val * 0.01}} + Flags {char {} {} {unsigned} {}} + } + array set specarr $spec + ::fields::unpack $packet $spec ShotSample bigeendian + foreach {field val} [array get ShotSample] { + set extra [lindex $specarr($field) 4] + if {$extra != ""} { set ShotSample($field) [expr $extra] } + } + } + } ;# ::de1::packet diff --git a/de1plus/gui.tcl b/de1plus/gui.tcl index 350cd7c9..33b1d644 100644 --- a/de1plus/gui.tcl +++ b/de1plus/gui.tcl @@ -3410,10 +3410,31 @@ namespace eval ::gui::update { # TODO: Decide how to make these dimensionally meaningful # Probably should be "per second", which would divide by 4 or about 5 (50/60 Hz) - set ::gui::state::_delta_flow \ - [expr { $this_flow - $::gui::state::_previous_flow }] - set ::gui::state::_delta_pressure \ - [expr { $this_pressure - $::gui::state::_previous_pressure }] + # Normalise sample-to-sample deltas to "5 Hz-equivalent + # magnitude" so that downstream chart math (e.g. the + # (10.0 * $flow_delta) negative-flow chart constant) and + # the diff_flow_rate_text display read identically + # regardless of the firmware's BLE notify rate. + # intersample_time is the actual elapsed time between + # samples (computed in de1_de1.tcl::from_shotvalue from + # ShotSample(SampleTime), the DE1's hardware tick counter). + # 0.2 s is the legacy 5 Hz period the chart code was + # implicitly tuned for. Guards: first sample has + # intersample_time = 0; treat as zero delta. Backwards- + # compat: if event_dict lacks the key (older firmware), + # fall back to raw delta (legacy behaviour). + set _ist [expr {[dict exists $event_dict intersample_time] \ + ? [dict get $event_dict intersample_time] : 0.2}] + if { $_ist > 0 } { + set _scale [expr { 0.2 / $_ist }] + set ::gui::state::_delta_flow \ + [expr { ($this_flow - $::gui::state::_previous_flow) * $_scale }] + set ::gui::state::_delta_pressure \ + [expr { ($this_pressure - $::gui::state::_previous_pressure) * $_scale }] + } else { + set ::gui::state::_delta_flow 0 + set ::gui::state::_delta_pressure 0 + } set ::gui::state::_previous_flow $this_flow set ::gui::state::_previous_pressure $this_pressure @@ -3635,6 +3656,10 @@ namespace eval ::gui::update { } steam_elapsed append [expr {[steam_pour_millitimer $update_received]/1000.0}] + + # Milk temperature probe (Bengle v2 only, 0 = no probe) + set _mt [ifexists ::de1(milk_temperature) 0] + steam_milk_temperature append [round_to_two_digits $_mt] } } diff --git a/de1plus/machine.tcl b/de1plus/machine.tcl index ac502ccc..07312744 100644 --- a/de1plus/machine.tcl +++ b/de1plus/machine.tcl @@ -70,6 +70,7 @@ array set ::de1 { cuuid_10 "0000A010-0000-1000-8000-00805F9B34FB" cuuid_11 "0000A011-0000-1000-8000-00805F9B34FB" cuuid_12 "0000A012-0000-1000-8000-00805F9B34FB" + cuuid_13 "0000A013-0000-1000-8000-00805F9B34FB" cuuid_skale_EF80 "0000EF80-0000-1000-8000-00805F9B34FB" cuuid_skale_EF81 "0000EF81-0000-1000-8000-00805F9B34FB" cuuid_skale_EF82 "0000EF82-0000-1000-8000-00805F9B34FB" @@ -221,6 +222,7 @@ array set ::de1_cuuids_to_command_names { $::de1(cuuid_10) FrameWrite $::de1(cuuid_11) WaterLevels $::de1(cuuid_12) Calibration + $::de1(cuuid_13) BengleShotSample } array set ::de1_command_names_to_cuuids [reverse_array ::de1_cuuids_to_command_names] diff --git a/de1plus/shot.tcl b/de1plus/shot.tcl index 598eb657..a5013f3a 100644 --- a/de1plus/shot.tcl +++ b/de1plus/shot.tcl @@ -307,7 +307,7 @@ namespace eval ::shot { popup [translate_toast "Converting old shot files"] - blt::vector create espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_elapsed espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance + blt::vector create espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_elapsed steam_milk_temperature espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance blt::vector create espresso_de1_explanation_chart_pressure espresso_de1_explanation_chart_flow espresso_de1_explanation_chart_elapsed espresso_de1_explanation_chart_elapsed_flow espresso_water_dispensed espresso_flow_weight_raw espresso_de1_explanation_chart_temperature espresso_de1_explanation_chart_temperature_10 espresso_de1_explanation_chart_selected_step blt::vector create espresso_de1_explanation_chart_flow_1 espresso_de1_explanation_chart_elapsed_flow_1 espresso_de1_explanation_chart_flow_2 espresso_de1_explanation_chart_elapsed_flow_2 espresso_de1_explanation_chart_flow_3 espresso_de1_explanation_chart_elapsed_flow_3 blt::vector create espresso_de1_explanation_chart_elapsed_1 espresso_de1_explanation_chart_elapsed_2 espresso_de1_explanation_chart_elapsed_3 espresso_de1_explanation_chart_pressure_1 espresso_de1_explanation_chart_pressure_2 espresso_de1_explanation_chart_pressure_3 @@ -329,7 +329,7 @@ namespace eval ::shot { array set ::settings [array get ::settings_backup] unset -nocomplain ::settings_backup - blt::vector destroy espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_elapsed espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance + blt::vector destroy espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_elapsed steam_milk_temperature espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance blt::vector destroy espresso_de1_explanation_chart_pressure espresso_de1_explanation_chart_flow espresso_de1_explanation_chart_elapsed espresso_de1_explanation_chart_elapsed_flow espresso_water_dispensed espresso_flow_weight_raw espresso_de1_explanation_chart_temperature espresso_de1_explanation_chart_temperature_10 espresso_de1_explanation_chart_selected_step blt::vector destroy espresso_de1_explanation_chart_flow_1 espresso_de1_explanation_chart_elapsed_flow_1 espresso_de1_explanation_chart_flow_2 espresso_de1_explanation_chart_elapsed_flow_2 espresso_de1_explanation_chart_flow_3 espresso_de1_explanation_chart_elapsed_flow_3 blt::vector destroy espresso_de1_explanation_chart_elapsed_1 espresso_de1_explanation_chart_elapsed_2 espresso_de1_explanation_chart_elapsed_3 espresso_de1_explanation_chart_pressure_1 espresso_de1_explanation_chart_pressure_2 espresso_de1_explanation_chart_pressure_3 diff --git a/de1plus/utils.tcl b/de1plus/utils.tcl index d634136d..e0fdf603 100644 --- a/de1plus/utils.tcl +++ b/de1plus/utils.tcl @@ -1592,7 +1592,7 @@ proc load_settings {} { set ::settings(log_enabled) True - blt::vector create espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_flow_goal steam_elapsed espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket espresso_temperature_basket10th god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_temperature_goal10th espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance + blt::vector create espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_flow_goal steam_elapsed steam_milk_temperature espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket espresso_temperature_basket10th god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_temperature_goal10th espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance blt::vector create espresso_de1_explanation_chart_pressure espresso_de1_explanation_chart_flow espresso_de1_explanation_chart_elapsed espresso_de1_explanation_chart_elapsed_flow espresso_water_dispensed espresso_flow_weight_raw espresso_de1_explanation_chart_temperature espresso_de1_explanation_chart_temperature_10 espresso_de1_explanation_chart_selected_step blt::vector create espresso_de1_explanation_chart_flow_1 espresso_de1_explanation_chart_elapsed_flow_1 espresso_de1_explanation_chart_flow_2 espresso_de1_explanation_chart_elapsed_flow_2 espresso_de1_explanation_chart_flow_3 espresso_de1_explanation_chart_elapsed_flow_3 blt::vector create espresso_de1_explanation_chart_elapsed_1 espresso_de1_explanation_chart_elapsed_2 espresso_de1_explanation_chart_elapsed_3 espresso_de1_explanation_chart_pressure_1 espresso_de1_explanation_chart_pressure_2 espresso_de1_explanation_chart_pressure_3 diff --git a/de1plus/vars.tcl b/de1plus/vars.tcl index 40df752f..2140f9c9 100644 --- a/de1plus/vars.tcl +++ b/de1plus/vars.tcl @@ -1365,6 +1365,18 @@ proc steamtemp_text {{integer 0}} { return [return_temperature_measurement [steamtemp] $integer] } +proc milktemp {} { + return [ifexists ::de1(milk_temperature)] +} + +proc milktemp_text {{integer 0}} { + set t [milktemp] + if {$t == "" || $t == 0} { + return "" + } + return [return_temperature_measurement $t $integer] +} + proc pressure_text {} { if {$::de1(language_rtl) == 1} { return [subst {[translate "bar"] [commify [round_to_one_digits [pressure]]]}] From 3a8cdb72b58106d1cd0626001fbb755d39130289 Mon Sep 17 00:00:00 2001 From: ChampionDesigns Date: Tue, 25 Aug 2026 15:22:57 +1000 Subject: [PATCH 2/4] Machine autonomy: clock, wake schedule, sleep timeout, cup warmer The machine only wakes, sleeps and pre-warms while the tablet app is running and connected. A Bengle can do all of it itself. This gives the firmware what it needs. CupWarmerMode 0x008038AC RAM only, re-sent every connect MatHeaterDrivePct 0x008038B4 read-only MatTempFault 0x008038B8 read-only InactivitySleepTimeout 0x008038BC minutes, 0-240 SetLocalTimeOfWeek 0x008038C0 seconds since Sunday, 0-604800 ScheduleEntry 0x008038C4 packed (dow<<22)|(start<<11)|end ScheduleControl 0x008038C8 0 clear+disable, 1 enable MatPreheatEnable 0x008038D0 persisted MatPreheatLeadMin 0x008038D4 persisted, 0-120 Every address, clamp and the schedule packing match decentespresso/decaid. There is no battery-backed RTC. The firmware keeps a software wall clock, seeded on connect and re-sent every 30 minutes. A power cut loses it and the next connection re-seeds it; until then only the inactivity timer runs, so the machine can still sleep itself but cannot wake on schedule. Schedule sources are the D_Scheduler plugin's per-weekday wake times and the built-in scheduler's keep-warm window. They cannot double up: the plugin clears scheduler_enable. Pre-warm is the firmware's job. With MatPreheatEnable set it runs the mat from MatPreheatLeadMin minutes before a scheduled wake with no tablet connected. Write order follows decaid: enabling sends the lead first so the firmware never acts on a stale one; disabling clears the enable first. CupWarmerMode is deliberately sent as 0 on every connect. The warmer turns on only when the user asks or the pre-warm fires -- a machine does not start heating by itself after a blackout. utils.tcl resets the flag at app start for the same reason. Adds the cup warmer page as page 4 of the calibration flow, with live status from MatHeaterDrivePct and MatTempFault. Nothing requested those two reads before, so that status could never update; get_cupwarmer_status now asks for them on connect. Corrects four addresses in the page's header comment, each of which named the register one slot below the real one. Co-Authored-By: Claude Opus 5 (1M context) --- de1plus/bluetooth.tcl | 17 ++ de1plus/de1_comms.tcl | 191 ++++++++++++++++++++ de1plus/machine.tcl | 3 + de1plus/skins/default/de1_skin_settings.tcl | 173 +++++++++++++++++- de1plus/utils.tcl | 6 + 5 files changed, 385 insertions(+), 5 deletions(-) diff --git a/de1plus/bluetooth.tcl b/de1plus/bluetooth.tcl index 0eebffc2..7b04254e 100755 --- a/de1plus/bluetooth.tcl +++ b/de1plus/bluetooth.tcl @@ -2363,6 +2363,23 @@ proc later_new_de1_connection_setup {} { #set_feature_flags 1 set_heater_tweaks + # Push the machine's autonomous inactivity-sleep timeout so it self-sleeps + # even when the tablet is off/disconnected. Reuses the tablet's existing + # "return to sleep after" (screen_saver_delay) value, in minutes. + set_sleep_timeout_minutes $::settings(screen_saver_delay) + + # Phase 2: sync the firmware wall-clock (and re-arm periodic re-sync) and push + # the weekly wake schedule, so the machine wakes/keeps-warm on schedule and + # self-sleeps off-schedule even with no tablet connected. + machine_clock_resync + set_wake_schedule + + # Cup-warmer pre-warm is firmware-side and flash-persisted; push the app's + # setting so a change made while disconnected reaches the machine. + set_cupwarmer_preheat [ifexists ::settings(cupwarmer_prewarm_enable) 0] \ + [ifexists ::settings(cupwarmer_prewarm_minutes) 30] + get_cupwarmer_status + get_refill_kit_present get_sn diff --git a/de1plus/de1_comms.tcl b/de1plus/de1_comms.tcl index c4fda87e..b5f10b34 100644 --- a/de1plus/de1_comms.tcl +++ b/de1plus/de1_comms.tcl @@ -375,6 +375,21 @@ proc de1_event_handler { command_name value {update_received 0}} { # dupe copy, of what we receive via firmware so we can NOT let them change it if we did receive it via BLE set ::de1(sn) $sn + } elseif {$mmr_id == "8038AC"} { + # CupWarmerMode (0=Off, 1=On). RAM only on FW side. + ::comms::msg -INFO "MMRead: cupwarmer_mode: '$mmr_val'" + set ::de1(cupwarmer_mode) $mmr_val + + } elseif {$mmr_id == "8038B4"} { + # MatHeaterDrivePct 0-100. Shown on the cup warmer page as "Heating - N%". + set ::de1(mat_heater_drive) $mmr_val + + } elseif {$mmr_id == "8038B8"} { + # MatTempFault 0=OK, 1=OpenOrShort, 2=Runaway. Shown on the cup + # warmer page as the NTC-disconnected warning. + ::comms::msg -INFO "MMRead: mat_temp_fault: '$mmr_val'" + set ::de1(mat_temp_fault) $mmr_val + } elseif {$mmr_id == "80385C"} { ::comms::msg -NOTICE "MMRead: get_refill_kit_present: '$mmr_val'" @@ -1244,6 +1259,177 @@ proc set_flush_flow_rate {rate} { mmr_write "set_flush_flow_rate" "803840" "04" [zero_pad [long_to_little_endian_hex [expr {int(10 * $rate)}] ] 2] } +# Cup warmer enable: 0=Off, 1=On. Not persisted on firmware side — must be +# (re)sent on every BLE reconnect and whenever the user toggles it. +proc set_cupwarmer_mode {mode} { + if {[is_bengle_model] != 1} { + return + } + set mode [expr {$mode ? 1 : 0}] + ::comms::msg -NOTICE set_cupwarmer_mode "'$mode'" + remove_matching_ble_queue_entries {^MMR set_cupwarmer_mode} + mmr_write "set_cupwarmer_mode" "8038AC" "04" [zero_pad [long_to_little_endian_hex $mode] 2] +} + +# Read the cup warmer's live status. Nothing polled these before, so the +# warmer page's heater percentage and NTC-fault warning could never update. +# Called on connect and whenever the cup warmer page is opened. +proc get_cupwarmer_status {} { + if {[is_bengle_model] != 1} { + return + } + remove_matching_ble_queue_entries {^MMR get_cupwarmer_status} + mmr_read "get_cupwarmer_status drive" "8038B4" "00" + mmr_read "get_cupwarmer_status fault" "8038B8" "00" +} + +# Cup-warmer pre-warm. The FIRMWARE owns the timing: with MatPreheatEnable set +# it starts the mat MatPreheatLeadMin minutes before a scheduled wake, and it +# does so with no tablet connected. Both registers are flash-persisted, so this +# only needs sending on connect and when the user changes the setting. +# +# Write order matters and matches decentespresso/decaid: when enabling, send +# the lead first so the firmware never acts on a stale one; when disabling, +# clear the enable first. +proc set_cupwarmer_preheat {enabled lead_minutes} { + if {[is_bengle_model] != 1} { + return + } + set lead [expr {int(round($lead_minutes))}] + if {$lead < 0} { set lead 0 } + if {$lead > 120} { set lead 120 } + set on [expr {$enabled ? 1 : 0}] + ::comms::msg -NOTICE set_cupwarmer_preheat "enabled=$on lead=$lead min" + remove_matching_ble_queue_entries {^MMR set_cupwarmer_preheat} + if {$on} { + mmr_write "set_cupwarmer_preheat lead" "8038D4" "04" [zero_pad [long_to_little_endian_hex $lead] 2] + mmr_write "set_cupwarmer_preheat on" "8038D0" "04" [zero_pad [long_to_little_endian_hex 1] 2] + } else { + mmr_write "set_cupwarmer_preheat off" "8038D0" "04" [zero_pad [long_to_little_endian_hex 0] 2] + mmr_write "set_cupwarmer_preheat lead" "8038D4" "04" [zero_pad [long_to_little_endian_hex $lead] 2] + } +} + +# Send the machine's autonomous inactivity-sleep timeout (whole minutes). +# The firmware self-sleeps after this long idle WHEN NO TABLET IS CONNECTED, so +# the machine sleeps even if the tablet is off/disconnected. We reuse the tablet's +# existing screen_saver_delay value (already in minutes) as the source. 0 = the +# firmware never auto-sleeps. Firmware default (when never set) is 60. +proc set_sleep_timeout_minutes {minutes} { + if {[is_bengle_model] != 1} { + return + } + set raw [expr {int(round($minutes))}] + if {$raw < 0} { set raw 0 } + if {$raw > 240} { set raw 240 } + ::comms::msg -NOTICE set_sleep_timeout_minutes "'$minutes' (raw $raw)" + remove_matching_ble_queue_entries {^MMR set_sleep_timeout_minutes} + mmr_write "set_sleep_timeout_minutes" "8038BC" "04" [zero_pad [long_to_little_endian_hex $raw] 2] +} + +# --------------------------------------------------------------------------- +# Phase 2: tablet-synced firmware clock + weekly wake schedule. +# +# The firmware keeps its own software wall-clock and weekly wake schedule so it +# can wake / keep warm on schedule and self-sleep off-schedule even with NO +# tablet connected. The tablet is just the source: it pushes the current local +# time (re-synced periodically) and the schedule (on connect and on edit). There +# is no battery-backed RTC, so the firmware clock is lost on a full power cut and +# re-synced the moment a tablet reconnects; until then only the Phase-1 +# inactivity timer runs. dow convention 0 = Sunday matches Tcl %w and the firmware. +# --------------------------------------------------------------------------- + +# Push the firmware's local wall-clock as seconds-since-Sunday-00:00:00 (local). +proc set_machine_clock {} { + if {[is_bengle_model] != 1} { + return + } + set now [clock seconds] + set w [scan [clock format $now -format %w] %d] ;# 0=Sun .. 6=Sat + set h [scan [clock format $now -format %H] %d] + set m [scan [clock format $now -format %M] %d] + set s [scan [clock format $now -format %S] %d] + set sow [expr {($w * 86400) + ($h * 3600) + ($m * 60) + $s}] + ::comms::msg -NOTICE set_machine_clock "sec-of-week=$sow" + remove_matching_ble_queue_entries {^set_machine_clock} + mmr_write "set_machine_clock" "8038C0" "04" [long32_to_little_endian_hex $sow] +} + +# Push the current clock now, and re-arm a periodic re-sync so the firmware clock +# does not drift while a tablet stays connected for a long time. +proc machine_clock_resync {} { + if {[info exists ::machine_clock_resync_handle]} { + after cancel $::machine_clock_resync_handle + } + set_machine_clock + set ::machine_clock_resync_handle [after [expr {30 * 60 * 1000}] machine_clock_resync] +} + +proc dow_from_dayname {day} { + switch -- $day { + Sun {return 0} + Mon {return 1} + Tue {return 2} + Wed {return 3} + Thu {return 4} + Fri {return 5} + Sat {return 6} + } + return -1 +} + +# Build the firmware's weekly wake schedule from the app's scheduler settings and +# push it (clear -> entries -> enable). Each firmware window is packed as +# (dow<<22)|(startMin<<11)|endMin, minutes after local midnight, endMin exclusive. +proc set_wake_schedule {} { + if {[is_bengle_model] != 1} { + return + } + remove_matching_ble_queue_entries {^sched_} + # Clear the firmware table + disable while we (re)load it. + mmr_write "sched_clear" "8038C8" "04" [long32_to_little_endian_hex 0] + + set count 0 + + # D_Scheduler per-weekday WAKE times -> short wake windows [t, t+1): the + # firmware wakes at t, then the Phase-1 inactivity timer sleeps it. + foreach day {Sun Mon Tue Wed Thu Fri Sat} { + set dow [dow_from_dayname $day] + if {[info exists ::D_scheduler_minutes($day)]} { + foreach t $::D_scheduler_minutes($day) { + if {$count >= 32} { break } + set t [scan $t %d] + if {$t eq "" || $t < 0 || $t > 1439} { continue } + set endm [expr {$t + 1}] + set packed [expr {($dow << 22) | ($t << 11) | $endm}] + mmr_write "sched_entry" "8038C4" "04" [long32_to_little_endian_hex $packed] + incr count + } + } + } + + # Built-in scheduler keep-warm window [wake,sleep) (seconds-since-midnight), + # same every day, only when enabled. The D_Scheduler plugin disables + # scheduler_enable, so in practice these two sources do not double up. + if {[info exists ::settings(scheduler_enable)] && $::settings(scheduler_enable) == 1} { + set ws [expr {int($::settings(scheduler_wake) / 60)}] + set se [expr {int($::settings(scheduler_sleep) / 60)}] + if {$ws >= 0 && $ws < $se && $se <= 1440} { + foreach dow {0 1 2 3 4 5 6} { + if {$count >= 32} { break } + set packed [expr {($dow << 22) | ($ws << 11) | $se}] + mmr_write "sched_entry" "8038C4" "04" [long32_to_little_endian_hex $packed] + incr count + } + } + } + + if {$count > 0} { + mmr_write "sched_enable" "8038C8" "04" [long32_to_little_endian_hex 1] + } + ::comms::msg -NOTICE set_wake_schedule "pushed $count window(s)" +} + proc set_flush_timeout {seconds} { ::comms::msg -NOTICE set_flush_timeout "'$seconds'" remove_matching_ble_queue_entries {^MMR set_flush_timeout} @@ -1644,6 +1830,11 @@ proc de1_send_steam_hotwater_settings { {temporarily_disable_steam 0} } { # only works on Bengle set_cupwarmer_temperature $::settings(cupwarmer_temp) + # CupWarmerMode is NOT persisted on the firmware side; must (re)send. On + # every boot and reconnect the app sends 0 here -- the mode only goes to 1 + # after the user taps the toggle, or when the pre-warm scheduler fires. + # This is intentional: no auto-heat after a blackout. + set_cupwarmer_mode [ifexists ::settings(cupwarmer_enable) 0] } diff --git a/de1plus/machine.tcl b/de1plus/machine.tcl index ac502ccc..a554d74f 100644 --- a/de1plus/machine.tcl +++ b/de1plus/machine.tcl @@ -237,6 +237,9 @@ array set ::settings { steam_two_tap_stop 0 ble_debug 0 cupwarmer_temp 70 + cupwarmer_enable 0 + cupwarmer_prewarm_enable 0 + cupwarmer_prewarm_minutes 30 tank_desired_water_temperature 0 screen_size_height {} log_enabled True diff --git a/de1plus/skins/default/de1_skin_settings.tcl b/de1plus/skins/default/de1_skin_settings.tcl index 9f4f972d..4524c745 100644 --- a/de1plus/skins/default/de1_skin_settings.tcl +++ b/de1plus/skins/default/de1_skin_settings.tcl @@ -1730,8 +1730,8 @@ proc scheduler_feature_hide_show_refresh { } { #add_de1_widget "settings_2c" checkbutton 1538 830 {} -text [translate "4: Move on if..."] -padx 0 -pady 0 -indicatoron true -font Helv_9_bold -anchor nw -foreground #7f879a -activeforeground #7f879a -variable ::current_adv_step(exit_if) -borderwidth 0 -highlightthickness 0 -command save_current_adv_shot_step -selectcolor #f9f9f9 -activebackground #f9f9f9 -bg #f9f9f9 -relief flat # scheduled power up/down add_de1_text "settings_3" 180 1134 -justify left -anchor "nw" -font $optionfont -text [translate "Keep hot"] -fill "#4e85f4" -width [rescale_x_skin 1000] -dui add dtoggle "settings_3" 50 1140 -height 50 -width 100 -anchor nw -variable ::settings(scheduler_enable) -command scheduler_feature_hide_show_refresh -add_de1_button "settings_3" { set ::settings(scheduler_enable) [expr {! $::settings(scheduler_enable)}]; scheduler_feature_hide_show_refresh } 50 1140 500 1190 +dui add dtoggle "settings_3" 50 1140 -height 50 -width 100 -anchor nw -variable ::settings(scheduler_enable) -command { scheduler_feature_hide_show_refresh; set_alarms_for_de1_wake_sleep } +add_de1_button "settings_3" { set ::settings(scheduler_enable) [expr {! $::settings(scheduler_enable)}]; scheduler_feature_hide_show_refresh; set_alarms_for_de1_wake_sleep } 50 1140 500 1190 scheduler_feature_hide_show_refresh @@ -2194,9 +2194,164 @@ add_de1_text "calibrate calibrate2 calibrate3" 1280 290 -text [translate "Calibr #add_de1_text "calibrate2" 2520 1510 -text [subst {\[ [translate "Page 2 of 3"] \]}] -font Helv_10_bold -fill "#666666" -anchor "ne" #add_de1_text "calibrate3" 2520 1510 -text [subst {\[ [translate "Page 3 of 3"] \]}] -font Helv_10_bold -fill "#666666" -anchor "ne" - dui add dbutton "calibrate" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate_2 -label [subst {[translate "Page 1 of 3"] >}] - dui add dbutton "calibrate2" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate_3 -label [subst {[translate "Page 2 of 3"] >}] - dui add dbutton "calibrate3" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate -label [subst {[translate "Page 3 of 3"] >}] + dui add dbutton "calibrate" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate_2 -label [subst {[translate "Page 1"] >}] + dui add dbutton "calibrate2" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate_3 -label [subst {[translate "Page 2"] >}] + dui add dbutton "calibrate3" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate_3_next -label [subst {[translate "Page 3"] >}] + +proc show_page_calibrate_3_next {} { + say [translate {Done}] $::settings(sound_button_in) + if {[is_bengle_model]} { + # Bengle adds a Cup Warmer page after page 3. + page_to_show_when_off cupwarmer + } else { + set_heater_tweaks + page_to_show_when_off calibrate + } +} + +if {[is_bengle_model]} { + ######################################## + # Cup warmer sub-page (Bengle only) + # Lives in the Calibration workflow as Page 4 of 5 (between calibrate3 + # flow). Navigation in and out is via the + # "Page N >" button on the bottom right, matching the other calibrate + # pages. + # + # Firmware side (addresses per the firmware's MMR.def): + # MatSetPoint (0x00803874) - RWD, persisted, °C + # CupWarmerMode (0x008038AC) - RW, NOT persisted (0 on every boot) + # MatHeaterDrivePct (0x008038B4) - R, 0-100 live drive + # MatTempFault (0x008038B8) - R, 0=OK / 1=OpenOrShort + # MatPreheatEnable (0x008038D0) - RWD, persisted + # MatPreheatLeadMin (0x008038D4) - RWD, persisted, minutes + # The firmware gates the heater on API state: it runs only in + # Idle/SchedIdle, never in Sleep. Pre-warm timing is the firmware's too -- + # with MatPreheatEnable set it starts the mat MatPreheatLeadMin minutes + # before a scheduled wake, with no tablet connected. + ######################################## + + # Pushes the current UI state to firmware + persists settings.tdb. + # Throttled so a slider drag doesn't flood the BLE queue. + namespace eval ::cupwarmer {} + set ::cupwarmer::_commit_after "" + proc ::cupwarmer::_commit {} { + set ::cupwarmer::_commit_after "" + set_cupwarmer_temperature $::settings(cupwarmer_temp) + set_cupwarmer_mode $::settings(cupwarmer_enable) + # Pre-warm timing lives in the firmware (MatPreheatEnable / + # MatPreheatLeadMin), so the toggle and the lead slider must reach the + # machine, not only be saved locally. + set_cupwarmer_preheat [ifexists ::settings(cupwarmer_prewarm_enable) 0] \ + [ifexists ::settings(cupwarmer_prewarm_minutes) 30] + set_alarms_for_de1_wake_sleep + after idle save_settings + } + proc ::cupwarmer::_request_commit {} { + if {$::cupwarmer::_commit_after ne ""} { + after cancel $::cupwarmer::_commit_after + } + set ::cupwarmer::_commit_after [after 250 ::cupwarmer::_commit] + } + + proc ::cupwarmer::toggle_enable {} { + set ::settings(cupwarmer_enable) [expr {!$::settings(cupwarmer_enable)}] + ::cupwarmer::_request_commit + } + + proc ::cupwarmer::toggle_prewarm {} { + set ::settings(cupwarmer_prewarm_enable) [expr {!$::settings(cupwarmer_prewarm_enable)}] + ::cupwarmer::_request_commit + } + + proc ::cupwarmer::status_text {} { + set fault [ifexists ::de1(mat_temp_fault) 0] + if {$fault == 1} { return [translate "NTC disconnected — warmer disabled"] } + if {[ifexists ::settings(cupwarmer_enable) 0] != 1} { + return [translate "Warmer is off"] + } + set pct [ifexists ::de1(mat_heater_drive) 0] + if {$pct > 0} { + return "[translate "Heating"] — ${pct}%" + } + return [translate "At target"] + } + + proc ::cupwarmer::prewarm_preview {} { + if {[ifexists ::settings(scheduler_enable) 0] != 1} { + return [translate "Enable the wake schedule first"] + } + if {[ifexists ::settings(cupwarmer_prewarm_enable) 0] != 1} { + return "" + } + set mins [ifexists ::settings(cupwarmer_prewarm_minutes) 0] + set wake_at [next_alarm_time $::settings(scheduler_wake)] + set prewarm_at [expr {$wake_at - 60 * $mins}] + return "[translate "Heater turns on at"] [time_format $prewarm_at]" + } + + # --- Page registration + layout ------------------------------------------- + # Shares settings_message.png background with the other calibrate pages + # so the cupwarmer page blends into the workflow. + add_de1_page "cupwarmer" "settings_message.png" "default" + + # Matches the calibrate-page title style. + add_de1_text "cupwarmer" 1280 290 -text [translate "Cup Warmer"] -font Helv_20_bold -width 1200 -fill "#444444" -anchor "center" -justify "center" + + # Two white cards: left = main control, right = schedule + status. + dui add canvas_item rect cupwarmer 60 400 1260 1400 -style insight_front_box + dui add canvas_item rect cupwarmer 1300 400 2500 1400 -style insight_front_box + + # ------------- Left card: enable + target temperature -------------------- + add_de1_text "cupwarmer" 100 440 -text [translate "Cup Warmer"] -font Helv_15_bold -fill "#2d3046" -anchor "nw" + add_de1_variable "cupwarmer" 100 510 -text "" -font Helv_9 -fill "#7f879a" -anchor "nw" -width [rescale_y_skin 1100] -textvariable {[::cupwarmer::status_text]} + + # Enable toggle. The button is drawn on top of the dtoggle so it catches + # all taps in the overlapping area; the dtoggle exists purely for the + # visual. Keep it -command-less so we don't have two handlers on the + # same click. The dtoggle's -variable trace still repaints when + # toggle_enable flips the setting, so the visual stays in sync. + add_de1_text "cupwarmer" 100 610 -text [translate "Warmer on"] -font Helv_10_bold -fill "#4e85f4" -anchor "nw" + dui add dtoggle "cupwarmer" 540 620 -height 60 -width 120 -anchor nw -variable ::settings(cupwarmer_enable) + add_de1_button "cupwarmer" {::cupwarmer::toggle_enable} 540 610 720 690 + + # Target temperature section + add_de1_text "cupwarmer" 100 780 -text [translate "Target temperature"] -font Helv_10_bold -fill "#7f879a" -anchor "nw" + add_de1_variable "cupwarmer" 100 860 -text "" -font Helv_20_bold -fill "#2d3046" -anchor "nw" -textvariable {[format "%d °C" [round_to_integer $::settings(cupwarmer_temp)]]} + + add_de1_widget "cupwarmer" scale 100 1050 {} -from 20 -to 80 -background #e4d1c1 -borderwidth 1 -bigincrement 5 -showvalue 0 -resolution 1 -length [rescale_x_skin 1100] -width [rescale_y_skin 135] -variable ::settings(cupwarmer_temp) -font Helv_10_bold -sliderlength [rescale_x_skin 125] -relief flat -orient horizontal -foreground #FFFFFF -troughcolor "#d9cfbf" -borderwidth 0 -highlightthickness 0 -command {::cupwarmer::_request_commit; list} + + add_de1_text "cupwarmer" 100 1200 -text [translate "20 °C"] -font Helv_7 -fill "#7f879a" -anchor "nw" + add_de1_text "cupwarmer" 1200 1200 -text [translate "80 °C"] -font Helv_7 -fill "#7f879a" -anchor "ne" + + add_de1_variable "cupwarmer" 100 1290 -text "" -font Helv_8 -fill "#7f879a" -anchor "nw" -width [rescale_y_skin 1100] -justify "left" -textvariable {[if {[ifexists ::de1(mat_temp_fault) 0] == 1} { return [translate "⚠ NTC sensor disconnected. Check the J7 probe."] } else { return [translate "Cups warm to target while the machine is in Idle. The warmer turns off when the machine is asleep, pulling a shot, steaming, or dispensing hot water."] }]} + + # ------------- Right card: pre-warm schedule ----------------------------- + add_de1_text "cupwarmer" 1340 440 -text [translate "Pre-warm on schedule"] -font Helv_15_bold -fill "#2d3046" -anchor "nw" + add_de1_variable "cupwarmer" 1340 510 -text "" -font Helv_8 -fill "#7f879a" -anchor "nw" -width [rescale_y_skin 1100] -justify "left" -textvariable {[if {[ifexists ::settings(scheduler_enable) 0] == 1} { return [translate "Turns the warmer on automatically a few minutes before the daily wake-up time, so the cups are hot when you are."] } else { return [translate "The daily wake-up schedule is currently disabled. Enable it on the Settings tab to use this feature."] }]} + + # Pre-warm enable toggle. + add_de1_text "cupwarmer" 1340 650 -text [translate "Pre-warm cups before wake"] -font Helv_10_bold -fill "#4e85f4" -anchor "nw" + dui add dtoggle "cupwarmer" 2340 650 -height 60 -width 120 -anchor nw -variable ::settings(cupwarmer_prewarm_enable) + add_de1_button "cupwarmer" {::cupwarmer::toggle_prewarm} 2340 640 2490 720 + + # Minutes-before-wake slider + add_de1_text "cupwarmer" 1340 800 -text [translate "Start heater before wake"] -font Helv_10_bold -fill "#7f879a" -anchor "nw" + add_de1_variable "cupwarmer" 1340 880 -text "" -font Helv_20_bold -fill "#2d3046" -anchor "nw" -textvariable {[format "%d [translate "min"]" [round_to_integer $::settings(cupwarmer_prewarm_minutes)]]} + + add_de1_widget "cupwarmer" scale 1340 1070 {} -from 0 -to 120 -background #e4d1c1 -borderwidth 1 -bigincrement 5 -showvalue 0 -resolution 5 -length [rescale_x_skin 1100] -width [rescale_y_skin 135] -variable ::settings(cupwarmer_prewarm_minutes) -font Helv_10_bold -sliderlength [rescale_x_skin 125] -relief flat -orient horizontal -foreground #FFFFFF -troughcolor "#d9cfbf" -borderwidth 0 -highlightthickness 0 -command {::cupwarmer::_request_commit; list} + + add_de1_text "cupwarmer" 1340 1220 -text [translate "0 min"] -font Helv_7 -fill "#7f879a" -anchor "nw" + add_de1_text "cupwarmer" 2440 1220 -text [translate "2 h"] -font Helv_7 -fill "#7f879a" -anchor "ne" + + add_de1_variable "cupwarmer" 1340 1310 -text "" -font Helv_8 -fill "#4e85f4" -anchor "nw" -width [rescale_y_skin 1100] -justify "left" -textvariable {[::cupwarmer::prewarm_preview]} + + # "Page 4 >" advance button — lives at the same coordinates as the + # calibrate pages so the navigation feels continuous. + # (Scale Calibration, page 5). The label numbering here matches the + # pattern set by the other calibrate pages: the button on page N says + # "Page N >" and takes you to page N+1. + dui add dbutton "cupwarmer" 2050 1460 -style insight_ok -anchor nw -command {say [translate {Done}] $::settings(sound_button_in); page_to_show_when_off calibrate} -label [subst {[translate "Page 4"] >}] +} ;# end is_bengle_model proc show_page_calibrate {} { say [translate {Done}] $::settings(sound_button_in) @@ -2508,3 +2663,11 @@ proc flush_log_loop {} { #after 2 show_settings decent_login #after 2 show_settings email_support + +# Arm the wake / prewarm timers once on app start so a tablet reboot mid-day +# still fires the scheduled wake (and the cup-warmer pre-warm) correctly. The +# previous version only rearmed when scheduler_wake naturally fired or when +# the user hit OK on the settings pages (exit_settings_pages at line 40). Use +# `after idle` to defer until the event loop is running, so this works no +# matter the source-order of vars.tcl. +after idle set_alarms_for_de1_wake_sleep diff --git a/de1plus/utils.tcl b/de1plus/utils.tcl index d634136d..12e7afa9 100644 --- a/de1plus/utils.tcl +++ b/de1plus/utils.tcl @@ -1546,6 +1546,12 @@ proc load_settings {} { set ::settings(stress_test) 0 + # Cup warmer must not auto-activate after a power cut / app restart. The + # target temperature and pre-warm settings persist, but the enable flag + # always returns to 0 on app start. User / scheduler explicitly turns it + # back on. Mirrors the firmware's RAM-only CupWarmerMode behaviour. + set ::settings(cupwarmer_enable) 0 + # rao request to increase these defaults to 300 (from 120) to aid in pour-overs. Will remove this settings.tdb override in the future, once # everyone's settings.tdb has had time to save this new default From c697379b13550e34b80a19ce8189e867288b3126 Mon Sep 17 00:00:00 2001 From: ChampionDesigns Date: Tue, 25 Aug 2026 15:29:32 +1000 Subject: [PATCH 3/4] Integrated scale support for Bengle A Bengle has a scale in the machine, with no BLE peripheral and no handle. Its weight and gravimetric flow arrive on 0xA013. Every scale check in the app is really a check for a paired BLE address, so on a Bengle the weight readouts return empty, the stop-at-weight slider is hidden, and the timer falls back to volumetric text while a working scale streams the whole time. _apply_shotvalue feeds Weight into ::device::scale::process_weight_update, the same entry point all fourteen BLE scale drivers use. That pipeline owns the weight history, the filtered weight and flow, the scale_stop_at_half_shot two-cup scaling, tare-completion detection, the watchdog, the app-side stop-at-weight check and drink-weight recording. Setting ::de1(scale_weight) directly instead writes the pipeline's outputs and skips all of it. GFlow, the firmware's own gravimetric flow, is NOT used for scale_weight_rate. The pipeline derives flow from the weight history as it does for every other scale. GFlow is kept on ::de1(integrated_scale_flow) for the comparison chart; switching to the firmware estimate wants a bench comparison first. ::device::scale::tare selects on scale_type, which a Bengle does not have, so the Bengle case is handled before the switch and writes ScaleTare (0x0080388C). This matters more than a tare button: tare is called automatically before every espresso and hot-water pour when a cup is on the platform, from the HotWater state handler, and from any profile step whose message contains "tare". set_end_of_shot_weight writes the per-profile target to EndOfShotWeight (0x00803864, grams x100, clamped 0-1000000) on every frame upload, so the machine-side target follows the loaded profile. Writing 0 clears one left on disk by a previous profile. Three chart vectors record the integrated series beside the existing ones so the two sources can be compared. On a DE1 they stay empty. The Visualizer payload maps by protocol. On v1 nothing changes. On v2 the integrated series fill by_weight and weight, and a paired BLE scale is emitted as by_weight_external / weight_external rather than dropped. by_weight_raw is emitted on both, as today. sensor_lag selects on scale_type, so a Bengle falls to the 0.38 s BLE default. An integrated scale should be lower. Left at the default rather than guessed; it needs measuring. Co-Authored-By: Claude Opus 5 (1M context) --- de1plus/de1_comms.tcl | 41 +++++++++++++++++++++ de1plus/de1_de1.tcl | 29 +++++++++++++++ de1plus/device_scale.tcl | 20 ++++++++++ de1plus/gui.tcl | 12 ++++++ de1plus/machine.tcl | 2 + de1plus/shot.tcl | 38 ++++++++++++++++--- de1plus/skins/default/de1_skin_settings.tcl | 4 +- de1plus/utils.tcl | 2 +- de1plus/vars.tcl | 27 ++++++++++---- 9 files changed, 160 insertions(+), 15 deletions(-) diff --git a/de1plus/de1_comms.tcl b/de1plus/de1_comms.tcl index 86e1fe3a..8589d6d1 100644 --- a/de1plus/de1_comms.tcl +++ b/de1plus/de1_comms.tcl @@ -1132,6 +1132,35 @@ proc mmr_write { note address length value} { userdata_append "$note" [list de1_comm write "WriteToMMR" $data] 1 } +# Bengle-only: target weight at which the machine auto-ends the shot using +# its integrated scale. 0 disables. Value persists to disk in the firmware, +# so we always write on shot start to keep it aligned with the app setting. +proc set_end_of_shot_weight {weight_grams} { + if {![::de1::packet::use_ble_v2]} { return } + + if {$weight_grams eq "" || ![string is double -strict $weight_grams] || $weight_grams < 0} { + set weight_grams 0 + } + # MMR value = grams * 100; firmware clamps to 0..1_000_000 (10 kg). + set scaled [expr {int(round($weight_grams * 100))}] + if {$scaled < 0} { set scaled 0 } + if {$scaled > 1000000} { set scaled 1000000 } + + ::comms::msg -NOTICE "set_end_of_shot_weight '${weight_grams}g' (raw=${scaled})" + remove_matching_ble_queue_entries {^MMR set_end_of_shot_weight} + mmr_write "set_end_of_shot_weight ${weight_grams}g" "803864" "04" [long32_to_little_endian_hex $scaled] +} + +# Bengle integrated-scale instant tare (ScaleTare, 0x0080388C). Lives here +# rather than in the calibration wizard so the ordinary scale tare path can +# reach it without loading the wizard. Matches tareIntegratedScale in +# decentespresso/decaid, which writes the same value to the same register. +proc set_bengle_scale_tare {} { + if {![::de1::packet::use_ble_v2]} { return } + ::comms::msg -NOTICE set_bengle_scale_tare + mmr_write "ScaleTare" "80388C" "04" [long32_to_little_endian_hex 1] +} + proc set_tank_temperature_threshold {temp} { ::comms::msg -NOTICE set_tank_temperature_threshold "'$temp'" @@ -1571,6 +1600,18 @@ proc de1_send_shot_frames { {override {}} } { set_tank_temperature_threshold 0 } + # Bengle integrated-scale stop-at-weight. Each profile carries its own + # final_desired_shot_weight[_advanced]; push it to MMR 0x00803864 every + # time we upload frames so the machine-side target stays aligned with + # the profile currently on the app. Writing 0 when the profile has no + # target clears any value left persisted on disk by a prior profile. + if {$::settings(settings_profile_type) == "settings_2c"} { + set _saw_target [ifexists ::settings(final_desired_shot_weight_advanced) 0] + } else { + set _saw_target [ifexists ::settings(final_desired_shot_weight) 0] + } + set_end_of_shot_weight $_saw_target + userdata_append "Confirm that all shot frames were correctly sent" [list confirm_de1_send_shot_frames_worked [lindex $parts 1]] 1 return } diff --git a/de1plus/de1_de1.tcl b/de1plus/de1_de1.tcl index 9359ba8d..7047f8eb 100644 --- a/de1plus/de1_de1.tcl +++ b/de1plus/de1_de1.tcl @@ -609,6 +609,35 @@ namespace eval ::de1::state::update { } + # Bengle ShotSample v2 only: integrated-scale fields. + # Always stored on their own names for dual-trace charting. + if {[info exists ShotSample(GFlow)]} { + set ::de1(integrated_scale_flow) $ShotSample(GFlow) + } + if {[info exists ShotSample(Weight)]} { + set ::de1(integrated_scale_weight) $ShotSample(Weight) + } + # Feed the integrated scale through the SAME pipeline every BLE + # scale uses, rather than writing that pipeline's output variables + # directly. process_weight_update owns the weight history, the + # filtered weight and flow estimates, the scale_stop_at_half_shot + # two-cup scaling, tare-completion detection, the watchdog, the + # app-side stop-at-weight check and the drink-weight recording. + # Assigning ::de1(scale_weight) here instead would skip all of it. + # + # Skipped when an external BLE scale is connected -- that scale is + # already driving the same pipeline and must keep priority. + # + # NOTE: the pipeline derives flow from the weight history, so the + # firmware's own gravimetric GFlow is NOT used for + # ::de1(scale_weight_rate). GFlow is kept on + # ::de1(integrated_scale_flow) above for the comparison chart + # series. Switching the app to the firmware estimate is a separate + # change and needs a bench comparison first. + if {[info exists ShotSample(Weight)] && $::de1(scale_device_handle) == 0} { + ::device::scale::process_weight_update $ShotSample(Weight) $update_received + } + # TODO: Determine if excessive change in SampleTime should invalidate all deltas diff --git a/de1plus/device_scale.tcl b/de1plus/device_scale.tcl index a47697be..dad34cae 100644 --- a/de1plus/device_scale.tcl +++ b/de1plus/device_scale.tcl @@ -128,6 +128,12 @@ namespace eval ::device::scale { proc is_connected {} { + # Bengle v2 has an integrated scale that is always available + # (no separate BLE peripheral, no handle to manage). Treat the + # v2 protocol being active as "scale connected" so all + # scale-aware UI (SAW slider, weight readouts, scale-derived + # notifications) and SAW vs SAV mode-picking work naturally. + if { [::de1::packet::use_ble_v2] } { return 1 } expr { [info exists ::de1(scale_device_handle)] == 1 && $::de1(scale_device_handle) != 0 } } @@ -138,6 +144,8 @@ namespace eval ::device::scale { # Boolean to determine if should be a "problem" that the scale isn't connected and reporting proc expecting_present {} { + # True if a BLE scale address is paired (no v2 special case + # needed -- is_connected handles that). expr { [::device::scale::bluetooth_address] != "" } } @@ -343,6 +351,16 @@ namespace eval ::device::scale { msg -INFO "tare request" } + # Bengle integrated scale: no BLE peripheral and no scale_type, so + # the switch below cannot reach it. Tare over MMR instead. Without + # this the auto-tare before every pour silently does nothing while + # still arming _tare_awaiting_zero. + if { [::de1::packet::use_ble_v2] && $::de1(scale_device_handle) == 0 } { + + set_bengle_scale_tare + + } else { + switch -exact $::settings(scale_type) { atomaxskale { skale_tare } @@ -372,6 +390,8 @@ namespace eval ::device::scale { timemore_dot { timemore_dot_tare } } + } + set ::device::scale::_tare_last_requested [clock milliseconds] set ::device::scale::_tare_awaiting_zero True diff --git a/de1plus/gui.tcl b/de1plus/gui.tcl index 33b1d644..bff76c83 100644 --- a/de1plus/gui.tcl +++ b/de1plus/gui.tcl @@ -3476,6 +3476,18 @@ namespace eval ::gui::update { espresso_weight append [round_to_two_digits $::de1(scale_weight)] espresso_weight_chartable append [round_to_two_digits [expr {0.10 * $::de1(scale_weight)}]] + # Bengle integrated-scale series: recorded side-by-side with + # the external-BLE-scale data so noise/reliability can be + # compared. Only populated on v2 — on v1 (DE1) the vars stay + # at 0 and the Insight skin hides the chart lines. + if {[::de1::packet::use_ble_v2]} { + set _iw [ifexists ::de1(integrated_scale_weight) 0] + set _if [ifexists ::de1(integrated_scale_flow) 0] + espresso_weight_integrated append [round_to_two_digits $_iw] + espresso_weight_integrated_chartable append [round_to_two_digits [expr {0.10 * $_iw}]] + espresso_flow_weight_integrated append [round_to_two_digits $_if] + } + espresso_pressure append [round_to_two_digits $GroupPressure] espresso_flow append [round_to_two_digits $GroupFlow] espresso_flow_2x append [round_to_two_digits [expr {2.0 * $GroupFlow}]] diff --git a/de1plus/machine.tcl b/de1plus/machine.tcl index 07312744..7c9eab6d 100644 --- a/de1plus/machine.tcl +++ b/de1plus/machine.tcl @@ -153,6 +153,8 @@ array set ::de1 { scale_timestamp 0 scale_weight_rate 0 scale_weight_rate_raw 0 + integrated_scale_flow 0 + integrated_scale_weight 0 final_water_weight 0 voltage 110 has_catering_kit 0 diff --git a/de1plus/shot.tcl b/de1plus/shot.tcl index a5013f3a..646da942 100644 --- a/de1plus/shot.tcl +++ b/de1plus/shot.tcl @@ -120,12 +120,34 @@ namespace eval ::shot { goal [huddle list {*}[get_vector espresso_pressure_goal]] \ ] - set flow [huddle create \ + # Scale-source mapping for the Visualizer payload: + # v1: a single scale exists (BLE if paired). by_weight/weight come + # from the legacy series; no _external fields. + # v2: Bengle's integrated scale is always primary -> by_weight/weight + # come from the *_integrated series. If an external BLE scale + # is *also* paired, emit by_weight_external/weight_external from + # the legacy series (which the de1_de1 bridge leaves untouched + # when an external handle is live, so it holds the BLE values). + if {[::de1::packet::use_ble_v2]} { + set _by_weight_series espresso_flow_weight_integrated + set _weight_series espresso_weight_integrated + set _emit_external [expr {$::settings(scale_bluetooth_address) != ""}] + } else { + set _by_weight_series espresso_flow_weight + set _weight_series espresso_weight + set _emit_external 0 + } + + set _flow_pairs [list \ flow [huddle list {*}[get_vector espresso_flow]] \ - by_weight [huddle list {*}[get_vector espresso_flow_weight]] \ + by_weight [huddle list {*}[get_vector $_by_weight_series]] \ by_weight_raw [huddle list {*}[get_vector espresso_flow_weight_raw]] \ goal [huddle list {*}[get_vector espresso_flow_goal]] \ ] + if {$_emit_external} { + lappend _flow_pairs by_weight_external [huddle list {*}[get_vector espresso_flow_weight]] + } + set flow [huddle create {*}$_flow_pairs] set temperature [huddle create \ basket [huddle list {*}[get_vector espresso_temperature_basket]] \ @@ -133,10 +155,14 @@ namespace eval ::shot { goal [huddle list {*}[get_vector espresso_temperature_goal]] \ ] - set totals [huddle create \ - weight [huddle list {*}[get_vector espresso_weight]] \ + set _totals_pairs [list \ + weight [huddle list {*}[get_vector $_weight_series]] \ water_dispensed [huddle list {*}[get_vector espresso_water_dispensed]] \ ] + if {$_emit_external} { + lappend _totals_pairs weight_external [huddle list {*}[get_vector espresso_weight]] + } + set totals [huddle create {*}$_totals_pairs] set resistance [huddle create \ resistance [huddle list {*}[get_vector espresso_resistance]] \ @@ -307,7 +333,7 @@ namespace eval ::shot { popup [translate_toast "Converting old shot files"] - blt::vector create espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_elapsed steam_milk_temperature espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance + blt::vector create espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_elapsed steam_milk_temperature espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance espresso_flow_weight_integrated espresso_weight_integrated espresso_weight_integrated_chartable blt::vector create espresso_de1_explanation_chart_pressure espresso_de1_explanation_chart_flow espresso_de1_explanation_chart_elapsed espresso_de1_explanation_chart_elapsed_flow espresso_water_dispensed espresso_flow_weight_raw espresso_de1_explanation_chart_temperature espresso_de1_explanation_chart_temperature_10 espresso_de1_explanation_chart_selected_step blt::vector create espresso_de1_explanation_chart_flow_1 espresso_de1_explanation_chart_elapsed_flow_1 espresso_de1_explanation_chart_flow_2 espresso_de1_explanation_chart_elapsed_flow_2 espresso_de1_explanation_chart_flow_3 espresso_de1_explanation_chart_elapsed_flow_3 blt::vector create espresso_de1_explanation_chart_elapsed_1 espresso_de1_explanation_chart_elapsed_2 espresso_de1_explanation_chart_elapsed_3 espresso_de1_explanation_chart_pressure_1 espresso_de1_explanation_chart_pressure_2 espresso_de1_explanation_chart_pressure_3 @@ -329,7 +355,7 @@ namespace eval ::shot { array set ::settings [array get ::settings_backup] unset -nocomplain ::settings_backup - blt::vector destroy espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_elapsed steam_milk_temperature espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance + blt::vector destroy espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_elapsed steam_milk_temperature espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance espresso_flow_weight_integrated espresso_weight_integrated espresso_weight_integrated_chartable blt::vector destroy espresso_de1_explanation_chart_pressure espresso_de1_explanation_chart_flow espresso_de1_explanation_chart_elapsed espresso_de1_explanation_chart_elapsed_flow espresso_water_dispensed espresso_flow_weight_raw espresso_de1_explanation_chart_temperature espresso_de1_explanation_chart_temperature_10 espresso_de1_explanation_chart_selected_step blt::vector destroy espresso_de1_explanation_chart_flow_1 espresso_de1_explanation_chart_elapsed_flow_1 espresso_de1_explanation_chart_flow_2 espresso_de1_explanation_chart_elapsed_flow_2 espresso_de1_explanation_chart_flow_3 espresso_de1_explanation_chart_elapsed_flow_3 blt::vector destroy espresso_de1_explanation_chart_elapsed_1 espresso_de1_explanation_chart_elapsed_2 espresso_de1_explanation_chart_elapsed_3 espresso_de1_explanation_chart_pressure_1 espresso_de1_explanation_chart_pressure_2 espresso_de1_explanation_chart_pressure_3 diff --git a/de1plus/skins/default/de1_skin_settings.tcl b/de1plus/skins/default/de1_skin_settings.tcl index 9f4f972d..627c802c 100644 --- a/de1plus/skins/default/de1_skin_settings.tcl +++ b/de1plus/skins/default/de1_skin_settings.tcl @@ -481,7 +481,9 @@ add_de1_button "settings_2c2" { profile_has_changed_set; dui page open_dialog du # (beta) weight based shot ending, only displayed if a skale is connected -if {$::settings(scale_bluetooth_address) != ""} { +# Stop-at-weight needs a scale of some kind: a paired BLE scale, +# or a Bengle integrated scale reached over the v2 protocol. +if {$::settings(scale_bluetooth_address) != "" || [::de1::packet::use_ble_v2]} { add_de1_text "settings_2a settings_2b" 1730 1100 -text [translate "4: stop at weight:"] -font Helv_10_bold -fill "#7f879a" -anchor "nw" -width [rescale_x_skin 1600] -justify "center" add_de1_widget "settings_2a settings_2b" scale 1730 1175 {} -to 2000 -from 0 -background $::settings(color_stage_3) -showvalue 0 -borderwidth 1 -bigincrement 1 -resolution 0.2 -length [rescale_x_skin 546] -width [rescale_x_skin 150] -variable ::settings(final_desired_shot_weight) -font Helv_15_bold -sliderlength [rescale_x_skin 125] -relief flat -command "profile_has_changed_set; update_de1_explanation_chart_soon" -foreground #FFFFFF -troughcolor $slider_trough_color -borderwidth 0 -highlightthickness 0 -orient horizontal add_de1_variable "settings_2a settings_2b" 1730 1325 -text "" -font Helv_8 -fill "#4e85f4" -anchor "nw" -width [rescale_x_skin 1200] -justify "left" -textvariable {[return_stop_at_weight_measurement $::settings(final_desired_shot_weight)]} diff --git a/de1plus/utils.tcl b/de1plus/utils.tcl index e0fdf603..ef524316 100644 --- a/de1plus/utils.tcl +++ b/de1plus/utils.tcl @@ -1592,7 +1592,7 @@ proc load_settings {} { set ::settings(log_enabled) True - blt::vector create espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_flow_goal steam_elapsed steam_milk_temperature espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket espresso_temperature_basket10th god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_temperature_goal10th espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance + blt::vector create espresso_elapsed god_espresso_elapsed god_espresso_pressure steam_pressure steam_temperature steam_temperature100th steam_flow steam_flow_goal steam_elapsed steam_milk_temperature espresso_pressure espresso_flow god_espresso_flow espresso_flow_weight god_espresso_flow_weight espresso_flow_weight_2x god_espresso_flow_weight_2x espresso_flow_2x god_espresso_flow_2x espresso_flow_delta espresso_pressure_delta espresso_temperature_mix espresso_temperature_basket espresso_temperature_basket10th god_espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_temperature_goal10th espresso_weight espresso_weight_chartable espresso_resistance_weight espresso_resistance espresso_flow_weight_integrated espresso_weight_integrated espresso_weight_integrated_chartable blt::vector create espresso_de1_explanation_chart_pressure espresso_de1_explanation_chart_flow espresso_de1_explanation_chart_elapsed espresso_de1_explanation_chart_elapsed_flow espresso_water_dispensed espresso_flow_weight_raw espresso_de1_explanation_chart_temperature espresso_de1_explanation_chart_temperature_10 espresso_de1_explanation_chart_selected_step blt::vector create espresso_de1_explanation_chart_flow_1 espresso_de1_explanation_chart_elapsed_flow_1 espresso_de1_explanation_chart_flow_2 espresso_de1_explanation_chart_elapsed_flow_2 espresso_de1_explanation_chart_flow_3 espresso_de1_explanation_chart_elapsed_flow_3 blt::vector create espresso_de1_explanation_chart_elapsed_1 espresso_de1_explanation_chart_elapsed_2 espresso_de1_explanation_chart_elapsed_3 espresso_de1_explanation_chart_pressure_1 espresso_de1_explanation_chart_pressure_2 espresso_de1_explanation_chart_pressure_3 diff --git a/de1plus/vars.tcl b/de1plus/vars.tcl index 2140f9c9..e20078fb 100644 --- a/de1plus/vars.tcl +++ b/de1plus/vars.tcl @@ -39,6 +39,9 @@ proc clear_espresso_chart {} { espresso_flow_goal_2x length 0 espresso_temperature_goal length 0 espresso_temperature_goal10th length 0 + espresso_flow_weight_integrated length 0 + espresso_weight_integrated length 0 + espresso_weight_integrated_chartable length 0 espresso_de1_explanation_chart_elapsed length 0 espresso_de1_explanation_chart_elapsed_1 length 0 @@ -77,6 +80,10 @@ proc clear_espresso_chart {} { espresso_temperature_goal append [return_temperature_number $::settings(espresso_temperature)] espresso_temperature_goal10th append [round_to_two_digits [expr {[return_temperature_number $::settings(espresso_temperature)] / 10.0}]] + espresso_flow_weight_integrated append 0 + espresso_weight_integrated append 0 + espresso_weight_integrated_chartable append 0 + god_shot_reference_reset catch { @@ -87,7 +94,7 @@ proc clear_espresso_chart {} { } proc espresso_chart_structures {} { - return [list espresso_elapsed espresso_pressure espresso_weight espresso_weight_chartable espresso_flow espresso_flow_weight espresso_flow_weight_raw espresso_water_dispensed espresso_flow_weight_2x espresso_flow_2x espresso_resistance espresso_resistance_weight espresso_pressure_delta espresso_flow_delta espresso_flow_delta_negative espresso_flow_delta_negative_2x espresso_temperature_mix espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_temperature_goal10th espresso_de1_explanation_chart_flow espresso_de1_explanation_chart_elapsed_flow espresso_de1_explanation_chart_flow_2x espresso_de1_explanation_chart_flow_1_2x espresso_de1_explanation_chart_flow_2_2x espresso_de1_explanation_chart_flow_3_2x espresso_de1_explanation_chart_pressure espresso_de1_explanation_chart_temperature espresso_de1_explanation_chart_temperature_10 espresso_de1_explanation_chart_pressure_1 espresso_de1_explanation_chart_pressure_2 espresso_de1_explanation_chart_pressure_3 espresso_de1_explanation_chart_elapsed_flow espresso_de1_explanation_chart_elapsed_flow_1 espresso_de1_explanation_chart_elapsed_flow_2 espresso_de1_explanation_chart_elapsed_flow_3 espresso_de1_explanation_chart_elapsed espresso_de1_explanation_chart_elapsed_1 espresso_de1_explanation_chart_elapsed_2 espresso_de1_explanation_chart_elapsed_3] + return [list espresso_elapsed espresso_pressure espresso_weight espresso_weight_chartable espresso_flow espresso_flow_weight espresso_flow_weight_raw espresso_water_dispensed espresso_flow_weight_2x espresso_flow_2x espresso_resistance espresso_resistance_weight espresso_pressure_delta espresso_flow_delta espresso_flow_delta_negative espresso_flow_delta_negative_2x espresso_temperature_mix espresso_temperature_basket espresso_state_change espresso_pressure_goal espresso_flow_goal espresso_flow_goal_2x espresso_temperature_goal espresso_temperature_goal10th espresso_flow_weight_integrated espresso_weight_integrated espresso_weight_integrated_chartable espresso_de1_explanation_chart_flow espresso_de1_explanation_chart_elapsed_flow espresso_de1_explanation_chart_flow_2x espresso_de1_explanation_chart_flow_1_2x espresso_de1_explanation_chart_flow_2_2x espresso_de1_explanation_chart_flow_3_2x espresso_de1_explanation_chart_pressure espresso_de1_explanation_chart_temperature espresso_de1_explanation_chart_temperature_10 espresso_de1_explanation_chart_pressure_1 espresso_de1_explanation_chart_pressure_2 espresso_de1_explanation_chart_pressure_3 espresso_de1_explanation_chart_elapsed_flow espresso_de1_explanation_chart_elapsed_flow_1 espresso_de1_explanation_chart_elapsed_flow_2 espresso_de1_explanation_chart_elapsed_flow_3 espresso_de1_explanation_chart_elapsed espresso_de1_explanation_chart_elapsed_1 espresso_de1_explanation_chart_elapsed_2 espresso_de1_explanation_chart_elapsed_3] } proc backup_espresso_chart {} { @@ -1112,7 +1119,9 @@ proc pouring_timer_text {} { return "[return_liquid_measurement [round_to_integer $::settings(final_desired_shot_volume_advanced)]] < [translate {pouring}] [translate {s}][espresso_elapsed_timer]" } - if {$::settings(scale_bluetooth_address) == "" && $::settings(final_desired_shot_volume) > 0 && ($::settings(settings_profile_type) == "settings_2a" || $::settings(settings_profile_type) == "settings_2b")} { + # Volume-based fallback only when there's no scale AT ALL + # (no external BLE scale AND no Bengle integrated scale). + if {$::settings(scale_bluetooth_address) == "" && ![::de1::packet::use_ble_v2] && $::settings(final_desired_shot_volume) > 0 && ($::settings(settings_profile_type) == "settings_2a" || $::settings(settings_profile_type) == "settings_2b")} { return "[translate {s}][espresso_pour_timer] [translate {pouring}] < [return_liquid_measurement [round_to_integer $::settings(final_desired_shot_volume)]]" } else { @@ -1124,7 +1133,7 @@ proc pouring_timer_text {} { return "[espresso_elapsed_timer][translate {s}] [translate {pouring}] < [return_liquid_measurement [round_to_integer $::settings(final_desired_shot_volume_advanced)]]" } - if {$::settings(scale_bluetooth_address) == "" && $::settings(final_desired_shot_volume) > 0 && ($::settings(settings_profile_type) == "settings_2a" || $::settings(settings_profile_type) == "settings_2b")} { + if {$::settings(scale_bluetooth_address) == "" && ![::de1::packet::use_ble_v2] && $::settings(final_desired_shot_volume) > 0 && ($::settings(settings_profile_type) == "settings_2a" || $::settings(settings_profile_type) == "settings_2b")} { return "[espresso_pour_timer][translate {s}] [translate {pouring}] < [return_liquid_measurement [round_to_integer $::settings(final_desired_shot_volume)]]" } return "[espresso_pour_timer][translate {s}] [translate {pouring}]" @@ -1197,14 +1206,14 @@ proc waterweightflow_text {} { #return [return_flow_weight_measurement [expr {(rand() * 6)}]] } - if {$::de1(scale_weight) == "" || [ifexists ::settings(scale_bluetooth_address)] == ""} { + if {$::de1(scale_weight) == "" || ([ifexists ::settings(scale_bluetooth_address)] == "" && ![::de1::packet::use_ble_v2])} { return "" } return [return_flow_weight_measurement $::de1(scale_weight_rate)] } proc finalwaterweight_text {} { - if {$::de1(scale_weight) == "" || [ifexists ::settings(scale_bluetooth_address)] == ""} { + if {$::de1(scale_weight) == "" || ([ifexists ::settings(scale_bluetooth_address)] == "" && ![::de1::packet::use_ble_v2])} { return "" } @@ -1235,7 +1244,7 @@ proc dump_stack {args} { #trace add variable de1(final_water_weight) write dump_stack proc waterweight_text {} { - if {$::de1(scale_weight) == "" || [ifexists ::settings(scale_bluetooth_address)] == ""} { + if {$::de1(scale_weight) == "" || ([ifexists ::settings(scale_bluetooth_address)] == "" && ![::de1::packet::use_ble_v2])} { return "" } @@ -1259,7 +1268,7 @@ proc waterweight_text {} { #return [return_weight_measurement [expr {round((rand() * 20))}]] } - if {$::de1(scale_device_handle) == "0"} { + if {$::de1(scale_device_handle) == "0" && ![::de1::packet::use_ble_v2]} { return [translate "Disconnected"] } @@ -1269,6 +1278,10 @@ proc waterweight_text {} { } proc waterweight_label_text {} { + # Bengle v2: integrated scale is always present — skip BLE-scale checks + if {[::de1::packet::use_ble_v2]} { + return [translate "Weight"] + } if {[ifexists ::settings(scale_bluetooth_address)] == ""} { return "" } From 7a8f2d4a87ae68b16f340fc62298050f1dc64ad2 Mon Sep 17 00:00:00 2001 From: ChampionDesigns Date: Tue, 25 Aug 2026 16:25:26 +1000 Subject: [PATCH 4/4] Bengle integrated scale calibration wizard Seven-step wizard driving the firmware's calibration state machine over MMR, plus the calibrate4 page that presents it. ScaleCalCmd W 0x00803880 0 abort, 1 zero, 2 latch ScaleCalState R 0x00803884 packed U32 ScaleCalWeight RW 0x00803888 grams x10 ScaleCalState packs Step(31:24) DetectedCell(23:20) SubState(19:16) SecondsRemaining(15:8) Status(7:0). Step values are 0,1,2,4,5,6 -- there is no step 3. Addresses, packing, command values and every enum match decentespresso/decaid, the only other implementation of this word. Steps 3 and 4 do not ask which cell is which: the firmware reports DetectedCell from where the weight was placed. ScaleTare is not added here. It lives in de1_comms.tcl as set_bengle_scale_tare, added by the integrated-scale PR, because the ordinary scale tare path needs it whether or not this wizard ships. The reference mass is bounded to 1 g - 10 kg, matching decaid and both reaprime skins. All nine firmware status codes map to distinct messages. The tare step polls for the firmware's own completion rather than declaring success a fixed delay after the write. Leaving via Ok aborts a run in progress rather than leaving the poll timer armed. This branch sits on both the integrated-scale PR (for the tare write) and the machine-autonomy PR (for the calibration-flow navigation). Co-Authored-By: Claude Opus 5 (1M context) --- de1plus/bluetooth.tcl | 11 + de1plus/de1_comms.tcl | 13 + de1plus/gui.tcl | 1 + de1plus/pkgIndex.tcl | 2 + de1plus/scale_calibration.tcl | 410 ++++++++++++++++++++ de1plus/skins/default/de1_skin_settings.tcl | 61 ++- de1plus/skins/default/standard_includes.tcl | 2 +- 7 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 de1plus/scale_calibration.tcl diff --git a/de1plus/bluetooth.tcl b/de1plus/bluetooth.tcl index c1fef3b4..79fcb95a 100755 --- a/de1plus/bluetooth.tcl +++ b/de1plus/bluetooth.tcl @@ -2966,6 +2966,17 @@ proc de1_ble_handler { event data } { } elseif {$mmr_id == "80385C"} { ::bt::msg -INFO "MMR read: steam_highflow_start: '$mmr_val'" set ::de1(refill_kit_detected) $mmr_val + } elseif {$mmr_id == "803884"} { + # ScaleCalState — packed U32: Step(31:24) DetectedCell(23:20) + # SubState(19:16) SecondsRemaining(15:8) Status(7:0) + set raw [ifexists arr2(Data0)] + ::bt::msg -INFO "MMR read: ScaleCalState raw=$raw" + ::scale_cal::on_state_read $raw + } elseif {$mmr_id == "803888"} { + # ScaleCalWeight read-back (grams * 10) + set raw [ifexists arr2(Data0)] + ::bt::msg -INFO "MMR read: ScaleCalWeight raw=$raw" + set ::scale_cal::cal_weight_readback $raw } else { ::bt::msg -ERROR "Uknown type of direct MMR read" \ [::logging::format_mmr $value] diff --git a/de1plus/de1_comms.tcl b/de1plus/de1_comms.tcl index 52e29f4f..50079f9a 100644 --- a/de1plus/de1_comms.tcl +++ b/de1plus/de1_comms.tcl @@ -2,6 +2,7 @@ package provide de1_comms 1.1 package require de1_bluetooth package require de1_logging 1.2 +package require de1_scale_calibration 1.0 package require lambda ### Globals @@ -491,6 +492,18 @@ proc de1_event_handler { command_name value {update_received 0}} { ::comms::msg -INFO "MMRead: cupwarmer_temp: '$mmr_val'" set ::de1(cupwarmer_temp) $mmr_val + } elseif {$mmr_id == "803884"} { + # ScaleCalState — packed U32: Step(31:24) DetectedCell(23:20) + # SubState(19:16) SecondsRemaining(15:8) Status(7:0) + set raw [ifexists arr2(Data0)] + ::comms::msg -INFO "MMRead: ScaleCalState raw=$raw" + ::scale_cal::on_state_read $raw + + } elseif {$mmr_id == "803888"} { + # ScaleCalWeight read-back (value * 10 = grams * 10) + set raw [ifexists arr2(Data0)] + ::comms::msg -INFO "MMRead: ScaleCalWeight raw=$raw" + set ::scale_cal::cal_weight_readback $raw } else { ::comms::msg -INFO "MMR read (undecoded): '[::logging::format_mmr $value]'" } diff --git a/de1plus/gui.tcl b/de1plus/gui.tcl index bff76c83..0c8a35ab 100644 --- a/de1plus/gui.tcl +++ b/de1plus/gui.tcl @@ -6,6 +6,7 @@ package require de1_logging 1.1 package require de1_plugins 1.0 package require de1_dui 1.0 package require de1_history_viewer 1.1 +package require de1_scale_calibration 1.0 ### ### ::gui namespace defined after globals diff --git a/de1plus/pkgIndex.tcl b/de1plus/pkgIndex.tcl index 39b3d3fe..cb44b44e 100644 --- a/de1plus/pkgIndex.tcl +++ b/de1plus/pkgIndex.tcl @@ -32,6 +32,8 @@ package ifneeded de1_shot 2.0 [list source [file join "./" shot.tcl]] package ifneeded de1_de1 1.4 [list source [file join "./" de1_de1.tcl]] package ifneeded de1_device_scale 1.5 [list source [file join "./" device_scale.tcl]] +package ifneeded de1_scale_calibration 1.0 [list source [file join "./" scale_calibration.tcl]] + package ifneeded de1_history_viewer 1.1 [list source [file join "./" history_viewer.tcl]] package ifneeded de1_metadata 1.0 [list source [file join "./" metadata.tcl]] package ifneeded de1_profiler 1.0 [list source [file join "./" profiler.tcl]] diff --git a/de1plus/scale_calibration.tcl b/de1plus/scale_calibration.tcl new file mode 100644 index 00000000..44c44e0f --- /dev/null +++ b/de1plus/scale_calibration.tcl @@ -0,0 +1,410 @@ +package provide de1_scale_calibration 1.0 + +######################################################################## +# scale_calibration.tcl — Bengle integrated scale calibration wizard +# +# Drives the 7-step calibration procedure via MMR commands: +# ScaleCalCmd (W) 0x803880 — start a calibration step +# ScaleCalState (R) 0x803884 — poll progress +# ScaleCalWeight (RW) 0x803888 — set/read known weight (grams*10) +# ScaleTare (W) 0x80388C — instant tare +# +# The UI lives on the "calibrate4" page. This file provides the +# ::scale_cal namespace with state management and MMR interaction. +######################################################################## + +namespace eval ::scale_cal { + + # --- Wizard step (1-7) --- + variable step 1 + + # --- Firmware state from last ScaleCalState poll --- + variable fw_step 0 ;# 0=idle,1=zeroing,2=latching,4=taring,5=complete,6=error + variable fw_status 255 ;# E_CalStatus low byte (255 = none/in-progress) + variable fw_substate 0 ;# 0=settling,1=averaging,2=done,3=error + variable fw_remaining 0 ;# seconds remaining in current phase + variable fw_cell -1 ;# detected cell: 0=left, 1=right, -1=none + ;# wire nibble is 0=none, 1=cell A, 2=cell B + + # --- Calibration tracking --- + variable CAL_WEIGHT_MIN_G 1.0 + variable CAL_WEIGHT_MAX_G 10000.0 + variable cal_weight 200.0 ;# known weight in grams (validated) + variable cal_weight_entry "200.0" ;# user entry field (string, validated on focus-out) + variable cal_weight_readback 0 ;# read-back from firmware + variable cells_done {} ;# list of cells calibrated so far (0 and/or 1) + variable poll_after_id "" ;# after ID for polling timer + variable busy 0 ;# 1 while a measurement is running + + # --- UI status text (bound via textvariable) --- + variable status_text "" + variable progress_pct 0 + variable weight_display "" + variable weight_error "" + variable error_colour "#4CAF50" ;# green/yellow/red + + # ====================================================================== + # Public API + # ====================================================================== + + # Called when navigating to calibrate4 + proc enter_page {} { + variable step + variable cells_done + variable busy + variable cal_weight + variable cal_weight_entry + set step 1 + set cells_done {} + set busy 0 + set cal_weight_entry [format "%.1f" $cal_weight] + update_status_for_step + } + + # Called when leaving calibrate4 (Ok button or page navigation) + proc abort_if_running {} { + variable busy + variable poll_after_id + if {$busy} { + # Send abort command + mmr_write "ScaleCalCmd abort" "803880" "04" [long32_to_little_endian_hex 0] + set busy 0 + } + stop_polling + } + + # ====================================================================== + # Step navigation + # ====================================================================== + + proc next_step {} { + variable step + variable busy + if {$busy} return + incr step + if {$step > 7} { set step 7 } + update_status_for_step + } + + proc prev_step {} { + variable step + variable busy + if {$busy} return + incr step -1 + if {$step < 1} { set step 1 } + update_status_for_step + } + + proc go_to_step {n} { + variable step + variable busy + if {$busy} return + set step $n + update_status_for_step + } + + # ====================================================================== + # Step actions + # ====================================================================== + + # Step 2: Start precision zero + proc start_zero {} { + variable busy + variable fw_step + set busy 1 + set fw_step 0 + update_status "Zeroing load cells..." + mmr_write "ScaleCalCmd zero" "803880" "04" [long32_to_little_endian_hex 1] + start_polling + } + + # Step 3/4: Start weight calibration (auto-detect cell) + proc start_weight_cal {} { + variable busy + variable cal_weight + variable fw_step + set busy 1 + set fw_step 0 + + # Write the known weight first (grams * 10) + set weight_val [expr {int($cal_weight * 10)}] + mmr_write "ScaleCalWeight $cal_weight" "803888" "04" [long32_to_little_endian_hex $weight_val] + + # Small delay then start calibration + after 200 { + ::scale_cal::update_status "Calibrating..." + mmr_write "ScaleCalCmd weight_cal" "803880" "04" [long32_to_little_endian_hex 2] + ::scale_cal::start_polling + } + } + + # Step 6: Instant tare. Polls for the firmware's own completion rather + # than assuming success one second after the write. + proc do_tare {} { + variable busy + variable fw_step + set busy 1 + set fw_step 0 + set_bengle_scale_tare + update_status "Taring..." + start_polling + } + + # Step 7: Start live weight display + proc start_verify {} { + variable poll_after_id + update_verify_display + } + + # ====================================================================== + # Polling + # ====================================================================== + + proc start_polling {} { + variable poll_after_id + stop_polling + poll_once + } + + proc stop_polling {} { + variable poll_after_id + if {$poll_after_id ne ""} { + after cancel $poll_after_id + set poll_after_id "" + } + } + + proc poll_once {} { + variable poll_after_id + mmr_read "ScaleCalState" "803884" "00" + set poll_after_id [after 1000 ::scale_cal::poll_once] + } + + # Called from the MMR read handler in de1_comms.tcl + proc on_state_read {raw} { + variable fw_step + variable fw_substate + variable fw_remaining + variable fw_cell + variable busy + variable step + variable cells_done + + variable fw_status + set fw_step [expr {($raw >> 24) & 0xFF}] + # SubState byte: phase in the low nibble, auto-detected cell in the + # high nibble (0 = none, 1 = cell A/left, 2 = cell B/right). + set fw_substate [expr {($raw >> 16) & 0x0F}] + set cell_nibble [expr {($raw >> 20) & 0x0F}] + set fw_cell [expr {$cell_nibble == 0 ? -1 : $cell_nibble - 1}] + set fw_remaining [expr {($raw >> 8) & 0xFF}] + set fw_status [expr {$raw & 0xFF}] + + # Update progress display. + # Firmware step values are 0 idle, 1 zeroing, 2 latching, 4 taring, + # 5 complete, 6 error. There is no step 3 -- an earlier version of + # this file polled for one. Matches ScaleCalibrationStep in + # decentespresso/decaid. + if {$fw_step == 1 || $fw_step == 2 || $fw_step == 4} { + # Active measurement + set total_seconds 15 + set elapsed [expr {$total_seconds - $fw_remaining}] + variable progress_pct + set progress_pct [expr {int(100.0 * $elapsed / $total_seconds)}] + + if {$fw_substate == 0} { + update_status "Settling... ${fw_remaining}s" + } elseif {$fw_substate == 1} { + update_status "Averaging... ${fw_remaining}s" + } + } elseif {$fw_step == 5} { + # Complete + stop_polling + set busy 0 + variable progress_pct + set progress_pct 100 + + if {$step == 2} { + # Zero complete + update_status "Zero complete!" + after 1500 [list ::scale_cal::next_step] + } elseif {$step == 6} { + # Tare complete -- confirmed by the firmware rather than + # assumed one second after the write. + update_status "Tared!" + after 1500 [list ::scale_cal::next_step] + } elseif {$step == 3 || $step == 4} { + # Weight cal complete — track which cell was done + if {$fw_cell == 0} { + update_status "Left cell calibrated!" + } elseif {$fw_cell == 1} { + update_status "Right cell calibrated!" + } + if {$fw_cell ni $cells_done} { + lappend cells_done $fw_cell + } + after 1500 [list ::scale_cal::next_step] + } + } elseif {$fw_step == 6} { + # Error — the low byte carries the firmware's reject reason. + stop_polling + set busy 0 + # Codes match ScaleCalibrationStatus in decentespresso/decaid. + switch -- $fw_status { + 1 { update_status "Error: calibration incomplete. Start again from the zero step." } + 2 { update_status "Error: zero the cells first." } + 3 { update_status "Error: reading drifted. Keep everything still and retry." } + 4 { update_status "Error: weight value implausible. Check the entered weight." } + 5 { update_status "Error: cell reading implausible. Check the weight and placement." } + 6 { update_status "Error: cells too alike to separate. Place the weight further to one side." } + 7 { update_status "Error: reading out of range. Check the load cell wiring." } + 8 { update_status "Error: weight not on a single cell. Remove the platform and retry." } + default { update_status "Error! Check weight placement and retry." } + } + } + } + + # ====================================================================== + # Verify step — live weight display + # ====================================================================== + + proc update_verify_display {} { + variable cal_weight + variable weight_display + variable weight_error + variable error_colour + variable poll_after_id + + # Read weight from integrated scale + set w [ifexists ::de1(integrated_scale_weight)] + if {$w eq ""} { set w 0.0 } + + set weight_display [format "%.1fg" $w] + set err [expr {$w - $cal_weight}] + set weight_error [format "%+.1fg (%.1f%%)" $err [expr {abs($err) / $cal_weight * 100.0}]] + + if {abs($err) <= 1.0} { + set error_colour "#4CAF50" ;# green + } elseif {abs($err) <= 5.0} { + set error_colour "#FF9800" ;# yellow/orange + } else { + set error_colour "#F44336" ;# red + } + + # Keep updating at ~2 Hz + set poll_after_id [after 500 ::scale_cal::update_verify_display] + } + + # ====================================================================== + # UI helpers + # ====================================================================== + + proc update_status {msg} { + variable status_text + set status_text $msg + } + + proc update_status_for_step {} { + variable step + variable progress_pct + variable status_text + set progress_pct 0 + + switch -- $step { + 1 { set status_text "Remove the drip tray platform and drip tray." } + 2 { set status_text "Keep the scale area clear. Press Start to zero." } + 3 { set status_text "Place weight directly on EITHER bare cell. Press Start." } + 4 { set status_text "Move weight to the OTHER cell. Press Start." } + 5 { set status_text "Replace the drip tray and platform." } + 6 { set status_text "Taring with empty platform..." } + 7 { + set status_text "Place weight on platform to verify." + start_verify + } + } + } + + # Return the step title for the UI + proc step_title {} { + variable step + switch -- $step { + 1 { return "Remove Platform" } + 2 { return "Zero Load Cells" } + 3 { return "Calibrate First Cell" } + 4 { return "Calibrate Second Cell" } + 5 { return "Replace Platform" } + 6 { return "Tare" } + 7 { return "Verify" } + } + } + + # Is the current step an action step needing a "Start" button? + proc step_needs_start {} { + variable step + return [expr {$step == 2 || $step == 3 || $step == 4}] + } + + # Is the current step a "Done, Next" step? + proc step_needs_done {} { + variable step + return [expr {$step == 1 || $step == 5}] + } + + # Handle the main action button press + proc action_button_pressed {} { + variable step + variable busy + if {$busy} return + + switch -- $step { + 1 { next_step } + 2 { start_zero } + 3 { start_weight_cal } + 4 { start_weight_cal } + 5 { next_step; do_tare } + 6 { do_tare } + 7 { } + } + } + + # Handle retry after error + proc retry {} { + variable step + switch -- $step { + 2 { start_zero } + 3 { start_weight_cal } + 4 { start_weight_cal } + } + } + + # Restart entire calibration + proc recalibrate {} { + stop_polling + enter_page + } + + # Validate and apply the user-entered calibration weight + proc validate_weight {} { + variable cal_weight_entry + variable cal_weight + + # Strip whitespace and trailing 'g' if user typed it + set entry [string trim $cal_weight_entry] + set entry [string trimright $entry "gG"] + set entry [string trim $entry] + + # Bounds match ScaleCalibrationCapability in decentespresso/decaid + # and the reaprime skins: 1 g to 10 kg. Without an upper bound a + # mistyped entry is sent to the firmware as a real reference mass. + if {[string is double -strict $entry] + && $entry >= $::scale_cal::CAL_WEIGHT_MIN_G + && $entry <= $::scale_cal::CAL_WEIGHT_MAX_G} { + set cal_weight [expr {double($entry)}] + set cal_weight_entry [format "%.1f" $cal_weight] + } else { + # Revert to current valid value + set cal_weight_entry [format "%.1f" $cal_weight] + } + } + +} diff --git a/de1plus/skins/default/de1_skin_settings.tcl b/de1plus/skins/default/de1_skin_settings.tcl index 75abbd2a..0c64090b 100644 --- a/de1plus/skins/default/de1_skin_settings.tcl +++ b/de1plus/skins/default/de1_skin_settings.tcl @@ -2190,7 +2190,7 @@ set calibration_row_spacing 115 # (re)calibration page -add_de1_text "calibrate calibrate2 calibrate3" 1280 290 -text [translate "Calibrate"] -font Helv_20_bold -width [rescale_x_skin 2400] -fill "#444444" -anchor "center" -justify "center" +add_de1_text "calibrate calibrate2 calibrate3 calibrate4" 1280 290 -text [translate "Calibrate"] -font Helv_20_bold -width [rescale_x_skin 2400] -fill "#444444" -anchor "center" -justify "center" #add_de1_text "calibrate" 2520 1510 -text [subst {\[ [translate "Page 1 of 3"] \]}] -font Helv_10_bold -fill "#666666" -anchor "ne" #add_de1_text "calibrate2" 2520 1510 -text [subst {\[ [translate "Page 2 of 3"] \]}] -font Helv_10_bold -fill "#666666" -anchor "ne" @@ -2198,6 +2198,8 @@ add_de1_text "calibrate calibrate2 calibrate3" 1280 290 -text [translate "Calibr dui add dbutton "calibrate" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate_2 -label [subst {[translate "Page 1"] >}] dui add dbutton "calibrate2" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate_3 -label [subst {[translate "Page 2"] >}] + # Page 5 = calibrate4 = Scale Calibration; loops back to page 1 (calibrate). + dui add dbutton "calibrate4" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate -label [subst {[translate "Page 5"] >}] dui add dbutton "calibrate3" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate_3_next -label [subst {[translate "Page 3"] >}] proc show_page_calibrate_3_next {} { @@ -2352,9 +2354,15 @@ if {[is_bengle_model]} { # (Scale Calibration, page 5). The label numbering here matches the # pattern set by the other calibrate pages: the button on page N says # "Page N >" and takes you to page N+1. - dui add dbutton "cupwarmer" 2050 1460 -style insight_ok -anchor nw -command {say [translate {Done}] $::settings(sound_button_in); page_to_show_when_off calibrate} -label [subst {[translate "Page 4"] >}] + dui add dbutton "cupwarmer" 2050 1460 -style insight_ok -anchor nw -command show_page_calibrate_4 -label [subst {[translate "Page 4"] >}] } ;# end is_bengle_model +proc show_page_calibrate_4 {} { + say [translate {Done}] $::settings(sound_button_in) + ::scale_cal::enter_page + page_to_show_when_off calibrate4 +} + proc show_page_calibrate {} { say [translate {Done}] $::settings(sound_button_in) set_heater_tweaks; @@ -2481,14 +2489,59 @@ proc send_refill_kit_override_from_gui {args} { } - add_de1_text "calibrate calibrate2 calibrate3" 1280 1310 -text [translate "Ok"] -font Helv_10_bold -fill "#fAfBff" -anchor "center" - add_de1_button "calibrate calibrate2 calibrate3" {say [translate {Done}] $::settings(sound_button_in); + ############################################################################################### + # Calibrate page 4: Scale Calibration (Bengle only) + ############################################################################################### + + add_de1_text "calibrate4" 1280 370 -text [translate "Scale Calibration"] -font Helv_10_bold -fill "#7f879a" -anchor "center" -justify "center" + + # Step indicator: "Step N of 7: Title" + add_de1_variable "calibrate4" 1280 440 -text "" -font Helv_9_bold -fill "#444444" -anchor "center" -justify "center" -textvariable {[translate "Step $::scale_cal::step of 7: [::scale_cal::step_title]"]} + + # Status / instruction text + add_de1_variable "calibrate4" 1280 550 -text "" -font Helv_8 -fill "#7f879a" -anchor "center" -justify "center" -width 1800 -textvariable {[translate $::scale_cal::status_text]} + + # Progress bar area (shown during active measurement steps 2,3,4) + add_de1_variable "calibrate4" 1280 700 -text "" -font Helv_15_bold -fill "#4e85f4" -anchor "center" -justify "center" -textvariable {[if {$::scale_cal::busy} {return "$::scale_cal::progress_pct%"} else {return ""}]} + + # Verification display — large weight reading (step 7 only) + add_de1_variable "calibrate4" 1280 680 -text "" -font Helv_20_bold -fill "#444444" -anchor "center" -justify "center" -textvariable {[if {$::scale_cal::step == 7} {return $::scale_cal::weight_display} else {return ""}]} + + # Verification error line (step 7 only) + add_de1_variable "calibrate4" 1280 780 -text "" -font Helv_9_bold -fill "#7f879a" -anchor "center" -justify "center" -textvariable {[if {$::scale_cal::step == 7} {return "Expected: ${::scale_cal::cal_weight}g Error: $::scale_cal::weight_error"} else {return ""}]} + + # Main action button — "Done, Next" for steps 1,5 or "Start" for steps 2,3,4 + add_de1_variable "calibrate4" 1280 900 -text "" -font Helv_10_bold -fill "#4e85f4" -anchor "center" -textvariable {[if {$::scale_cal::step == 7} {return ""} elseif {$::scale_cal::busy} {return ""} elseif {[::scale_cal::step_needs_done]} {return "\[ [translate {Done, Next}] \]"} elseif {[::scale_cal::step_needs_start]} {return "\[ [translate {Start}] \]"} else {return "\[ [translate {Next}] \]"}]} + add_de1_button "calibrate4" {::scale_cal::action_button_pressed} 880 860 1680 960 "" + + # Retry button (shown after error) + add_de1_variable "calibrate4" 1280 1000 -text "" -font Helv_8_bold -fill "#F44336" -anchor "center" -textvariable {[if {$::scale_cal::fw_step == 6 && !$::scale_cal::busy} {return "\[ [translate {Retry}] \]"} else {return ""}]} + add_de1_button "calibrate4" {if {$::scale_cal::fw_step == 6} {::scale_cal::retry}} 880 960 1680 1060 "" + + # Recalibrate button (step 7 only) + add_de1_variable "calibrate4" 700 1100 -text "" -font Helv_8_bold -fill "#4e85f4" -anchor "center" -textvariable {[if {$::scale_cal::step == 7} {return "\[ [translate {Recalibrate}] \]"} else {return ""}]} + add_de1_button "calibrate4" {if {$::scale_cal::step == 7} {::scale_cal::recalibrate}} 400 1060 1000 1160 "" + + # "Looks Good" button (step 7 only) — exits calibration + add_de1_variable "calibrate4" 1860 1100 -text "" -font Helv_8_bold -fill "#4CAF50" -anchor "center" -textvariable {[if {$::scale_cal::step == 7} {return "\[ [translate {Looks Good}] \]"} else {return ""}]} + add_de1_button "calibrate4" {if {$::scale_cal::step == 7} {say [translate {Done}] $::settings(sound_button_in); ::scale_cal::abort_if_running; save_settings; set_next_page off settings_3; page_show settings_3}} 1560 1060 2160 1160 "" + + # Calibration weight entry — user can type any weight value + add_de1_text "calibrate4" 1080 1170 -text [translate "Calibration weight (g):"] -font Helv_7_bold -fill "#7f879a" -anchor "ne" + add_de1_widget "calibrate4" entry 1100 1155 { + bind $widget { ::scale_cal::validate_weight; hide_android_keyboard } + bind $widget { ::scale_cal::validate_weight; hide_android_keyboard } + } -width [expr {int(6 * $::globals(entry_length_multiplier))}] -font Helv_8 -borderwidth 1 -bg #FFFFFF -foreground #4e85f4 -textvariable ::scale_cal::cal_weight_entry -relief flat -highlightthickness 1 -highlightcolor #000000 + + add_de1_text "calibrate calibrate2 calibrate3 calibrate4" 1280 1310 -text [translate "Ok"] -font Helv_10_bold -fill "#fAfBff" -anchor "center" + add_de1_button "calibrate calibrate2 calibrate3 calibrate4" {say [translate {Done}] $::settings(sound_button_in); if {[ifexists ::calibration_disabled_fahrenheit] == 1} { set ::settings(enable_fahrenheit) 1 unset -nocomplain ::calibration_disabled_fahrenheit msg "Calibration re-enabled Fahrenheit" } + ::scale_cal::abort_if_running save_settings; set_next_page off settings_3; set_heater_tweaks; page_show settings_3;} 980 1210 1580 1410 "" diff --git a/de1plus/skins/default/standard_includes.tcl b/de1plus/skins/default/standard_includes.tcl index 296d9cff..c9b50412 100644 --- a/de1plus/skins/default/standard_includes.tcl +++ b/de1plus/skins/default/standard_includes.tcl @@ -9,7 +9,7 @@ add_de1_page "sleep" "sleep.jpg" "default" add_de1_page "tankfilling" "filling_tank.jpg" "default" add_de1_page "tankempty refill" "fill_tank.jpg" "default" add_de1_page "cleaning" "cleaning.jpg" "default" -add_de1_page "message calibrate calibrate2 calibrate3 infopage versionpage tabletstyles languages extensions profile_notes measurements temperature_steps bev_type enter_de1_sn decent_login email_support" "settings_message.png" "default" +add_de1_page "message calibrate calibrate2 calibrate3 calibrate4 infopage versionpage tabletstyles languages extensions profile_notes measurements temperature_steps bev_type enter_de1_sn decent_login email_support" "settings_message.png" "default" add_de1_page "create_preset" "settings_3_choices.png" "default" add_de1_page "firmware_update_3" "firmware_upgrade.jpg" "default" add_de1_page "firmware_update_1 firmware_update_4" "firmware_upgrade_off.jpg" "default"