Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 129 additions & 15 deletions grinder.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +43,8 @@ wifi:
ssid: "${device_name}-fallback"
password: !secret fallback_ap_password

use_address: 192.168.20.201

captive_portal:

api:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;

Expand All @@ -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);"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -447,39 +506,71 @@ 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;
id(last_weight_valid) = true;
}
- 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
Expand All @@ -490,6 +581,7 @@ number:

- platform: template
name: "Grind delay (ms)"
id: ui_grind_delay_ms
mode: box
min_value: 0
max_value: 2500
Expand All @@ -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
Expand All @@ -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
Expand All @@ -526,6 +620,7 @@ number:

- platform: template
name: "Rate min samples"
id: ui_rate_min_samples
mode: box
min_value: 1
max_value: 10
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down