diff --git a/grinder.yaml b/grinder.yaml index 1dbfac1..167472c 100644 --- a/grinder.yaml +++ b/grinder.yaml @@ -6,9 +6,20 @@ esphome: name: ${device_name} friendly_name: ${friendly_name} on_boot: - priority: -10 + priority: -100 then: - script.execute: status_led_loop + - lambda: |- + // Push the restored UI values into the backend globals exactly once on startup + id(target_g) = id(ui_target_g).state; + id(grind_delay_ms) = (int)id(ui_grind_delay_ms).state; + id(delay_lr) = id(ui_delay_lr).state; + id(rate_min_samples) = (int)id(ui_rate_min_samples).state; + id(rate_fallback_gps) = id(ui_rate_fallback_gps).state; + id(max_run_ms) = (int)(id(ui_max_run_s).state * 1000.0f); + id(settle_after_stop_ms) = (int)id(ui_settle_after_stop_ms).state; + id(tare_on_dose) = id(ui_tare_on_dose).state; + esp32: board: esp32-c3-devkitm-1 @@ -32,6 +43,8 @@ wifi: ssid: "${device_name}-fallback" password: !secret fallback_ap_password + use_address: 192.168.20.201 + captive_portal: api: @@ -64,7 +77,7 @@ switch: - platform: template name: "Tare on dose" - id: tare_on_dose_sw + id: ui_tare_on_dose restore_mode: RESTORE_DEFAULT_ON optimistic: true turn_on_action: @@ -118,6 +131,13 @@ ble_client: auto_connect: true globals: + - id: stop_deadline_ms + type: uint32_t + initial_value: "0" + - id: stop_deadline_valid + type: bool + initial_value: "false" + - id: last_weight_g type: float initial_value: "0.0" @@ -230,6 +250,7 @@ script: - id: do_stop mode: restart then: + - script.stop: do_dose # Force-kills the dosing script and any active while loops - lambda: |- id(abort_requested) = true; id(dosing_active) = false; @@ -252,9 +273,17 @@ script: id(last_weight_valid) = false; id(last_weight_ms) = 0; + // Reset deadline states for the new session + id(stop_deadline_valid) = false; + id(stop_deadline_ms) = 0; + + // I exmperimented with this; but then decided that it's not worth it + //id(rate_gps) = id(rate_fallback_gps); + //id(rate_valid) = true; + - if: condition: - switch.is_on: tare_on_dose_sw + switch.is_on: ui_tare_on_dose then: - logger.log: level: INFO @@ -304,6 +333,24 @@ script: if (id(abort_requested)) return false; if ((millis() - id(dose_start_ms)) > (uint32_t)id(max_run_ms)) return false; + // SAFETY LAYER 2: Active Heartbeat Watchdog + // Only arm the 2000ms timeout ONCE the scale has registered its first valid weight increase. + if (id(rate_samples) > 0) { + if (id(last_weight_ms) != 0 && (millis() - id(last_weight_ms) > 2000)) { + ESP_LOGE("dose", "EMERGENCY: no updates from the scale for 2000ms mid-grind! Aborting the grind."); + id(abort_requested) = true; + return false; + } + } + + // NEW TRAJECTORY DEADLINE LOGIC + if (id(stop_deadline_valid)) { + if (millis() >= id(stop_deadline_ms)) { + ESP_LOGI("dose", "Trajectory deadline hit! Switching relay off."); + return false; + } + } + const float w = id(myscale_grams).state; if (isnan(w)) return true; @@ -315,7 +362,8 @@ script: return w_pred < id(target_g); then: - - delay: 30ms + - delay: 5ms + - switch.turn_off: grinder_relay - delay: !lambda "return id(settle_after_stop_ms);" @@ -351,6 +399,17 @@ script: ); id(grind_delay_ms) = new_delay; + //id(ui_grind_delay_ms).publish_state(id(grind_delay_ms)); //this way the Web UI will be updated, but this value will not be stored persistently + id(ui_grind_delay_ms).make_call().set_value(id(grind_delay_ms)).perform(); //this way it will be persistent, as if a user entered it in the UI manually + + + // I tried this, but then reverted back + // Persist the finalized run rate directly into the UI component (saves to flash) + //if (std::isfinite(id(rate_gps)) && id(rate_gps) > 0.05f) { + // id(ui_rate_fallback_gps).make_call().set_value(id(rate_gps)).perform(); + // ESP_LOGI("diagnose", "Saved rate: %.2fg/s", id(ui_rate_fallback_gps).state); + //} + } else { ESP_LOGI( "dose", @@ -447,27 +506,57 @@ sensor: const uint32_t now_ms = millis(); const float w = id(myscale_grams).state; - if (!isnan(w) && id(last_weight_valid) && id(last_weight_ms) != 0) { + // ONLY update the EMA tracking if the scale reading is valid AND the grinder relay is actively ON + if (id(grinder_relay).state && !isnan(w) && id(last_weight_valid) && id(last_weight_ms) != 0) { const float dw = w - id(last_weight_g); const float dt = (now_ms - id(last_weight_ms)) / 1000.0f; if (dt >= id(rate_min_dt_s) && dt <= id(rate_max_dt_s)) { + //if (dw >= id(rate_min_dg)) { + // ADDED: Both the delta AND total accumulated weight must clear their gates. + // Discarding all weight updates, as the grinder is starting up, until the weight is higher than 1.0g if (dw >= id(rate_min_dg)) { const float inst = dw / dt; - - if (std::isfinite(inst) && fabsf(inst) <= id(rate_max_abs_gps)) { - const float alpha = (id(rate_samples) < 3) ? 0.4f : 0.25f; - id(rate_gps) = (id(rate_gps) * (1.0f - alpha)) + (inst * alpha); - - id(rate_samples) += 1; - if (id(rate_samples) >= id(rate_min_samples) && id(rate_gps) > 0.1f) { - id(rate_valid) = true; + if(w <= 1.0f) { + ESP_LOGI("diagnose", "Weight: %5.2fg | Inst Rate: %5.2fg/s | EMA Rate: %5.2fg/s", w, inst, id(rate_gps)); + } else { + if (std::isfinite(inst) && fabsf(inst) <= id(rate_max_abs_gps)) { + + if (id(rate_samples) == 0) { + // Instantly establish baseline because the 1.0g gate means the grinder is already at full speed + id(rate_gps) = inst; + } else { + // Smooth subsequent packets normally + const float alpha = (id(rate_samples) < 3) ? 0.4f : 0.25f; + + id(rate_gps) = (id(rate_gps) * (1.0f - alpha)) + (inst * alpha); + } + + ESP_LOGI("diagnose", "Weight: %5.2fg | Inst Rate: %5.2fg/s | EMA Rate: %5.2fg/s", w, inst, id(rate_gps)); + + id(rate_samples) += 1; + if (id(rate_samples) >= id(rate_min_samples) && id(rate_gps) > 0.1f) { + id(rate_valid) = true; + } + + // --- CALCULATE RUNTIME TRAJECTORY DEADLINE --- + float current_rate = id(rate_valid) ? id(rate_gps) : id(rate_fallback_gps); + if (!std::isfinite(current_rate) || current_rate <= 0.05f) current_rate = id(rate_fallback_gps); + + // Time to reach target weight minus the physical mechanical stopping overrun + float time_to_stop_s = ((id(target_g) - w) / current_rate) - ((float)id(grind_delay_ms) / 1000.0f); + + if (time_to_stop_s < 0.0f) time_to_stop_s = 0.0f; + + id(stop_deadline_ms) = now_ms + (uint32_t)(time_to_stop_s * 1000.0f); + id(stop_deadline_valid) = true; + + ESP_LOGI("trajectory", "Deadline projected in %dms", (uint32_t)(time_to_stop_s * 1000.0f)); } } } } } - if (!isnan(w)) { id(last_weight_g) = w; id(last_weight_ms) = now_ms; @@ -475,11 +564,13 @@ sensor: } - script.execute: connection_watchdog + number: - platform: template name: "Dose target (g)" + id: ui_target_g mode: box - min_value: 10 + min_value: 6 max_value: 30 step: 0.1 restore_value: true @@ -490,6 +581,7 @@ number: - platform: template name: "Grind delay (ms)" + id: ui_grind_delay_ms mode: box min_value: 0 max_value: 2500 @@ -502,6 +594,7 @@ number: - platform: template name: "Delay learning rate" + id: ui_delay_lr mode: box min_value: 0.0 max_value: 1.0 @@ -514,6 +607,7 @@ number: - platform: template name: "Rate fallback (g/s)" + id: ui_rate_fallback_gps mode: box min_value: 0.1 max_value: 5.0 @@ -526,6 +620,7 @@ number: - platform: template name: "Rate min samples" + id: ui_rate_min_samples mode: box min_value: 1 max_value: 10 @@ -538,6 +633,7 @@ number: - platform: template name: "Settle after stop (ms)" + id: ui_settle_after_stop_ms mode: box min_value: 0 max_value: 3000 @@ -550,6 +646,7 @@ number: - platform: template name: "Max run time (s)" + id: ui_max_run_s mode: box min_value: 5 max_value: 60 @@ -561,6 +658,23 @@ number: - lambda: 'id(max_run_ms) = (int)(x * 1000.0f);' button: + - platform: template + name: "Log Diagnostic Vars" + id: log_diagnostic_vars + icon: "mdi:bug" + on_press: + - lambda: |- + ESP_LOGI("diagnose", "Dose Target: %.1fg", id(target_g)); + ESP_LOGI("diagnose", "Saved grind rate: %.2fg/s", id(rate_fallback_gps)); + ESP_LOGI("diagnose", "Grind Delay: %dms", id(grind_delay_ms)); + ESP_LOGI("diagnose", "Delay Learning Rate: %.3f", id(delay_lr)); + //ESP_LOGI("diagnose", "Rate Min Samples: %d", id(rate_min_samples)); + ESP_LOGI("diagnose", "Max Run Time: %dms", id(max_run_ms)); + ESP_LOGI("diagnose", "Settle After Stop: %dms", id(settle_after_stop_ms)); + ESP_LOGI("diagnose", "Tare on Dose: %s", id(tare_on_dose) ? "true" : "false"); + + + - platform: template name: "Dose now" id: dose_now