# ============================================================================= # PRESENCE PLATE # ESP32-C3 + HX711 load cell + WS2812B ring # # Detects whether a small object is sitting on a plate, and exposes it to # Home Assistant as an occupancy binary sensor plus an addressable light. # # Presence is decided by EDGES, not levels. Load cells drift with # temperature and creep — often by more than the weight of a small object — # so any fixed threshold eventually lies. This watches for the STEP of the # object landing or leaving, and tracks the baseline continuously the rest # of the time. That makes it immune to drift of any magnitude. # # Calibration is guided, on-device and takes about 15 seconds: press a # button (or knock a rhythm on the plate) and follow the colours. # # ----------------------------------------------------------------------------- # FILL IN THE 4 VALUES IN "CONFIGURE ME" BELOW. Everything else has a # sensible default. You do not need to measure anything to get started. # ----------------------------------------------------------------------------- # # MIT licence. Attribution appreciated, not required. # ============================================================================= substitutions: # =========================================================================== # CONFIGURE ME # =========================================================================== # 1. Device name. Lowercase, hyphens only — becomes the hostname and the # entity_id prefix. device_name: "presence-plate" # 2. What is being detected. Appears in entity names, e.g. "keys on plate". # Keep it lowercase and short. object_name: "object" # 3. How many pixels on your ring or strip. num_leds: "15" # 4. Load cell rating, in GRAMS — the number printed on the cell or in the # listing. Used to derive counts-per-gram, so it matters. # # Common ratings and the counts/g they imply at gain 128, 1.0 mV/V: # 100 g -> 21475 2 kg -> 1074 # 200 g -> 10737 5 kg -> 429 # 500 g -> 4295 10 kg -> 215 # 1 kg -> 2147 20 kg -> 107 # # Pick the cell to suit the object. Drift is roughly 0.05% of FULL # SCALE regardless of what is on the plate, so a 5 kg cell drifts ~2.5 g # whether it is weighing 2 g or 2 kg. For anything under ~20 g, a 100 g # or 200 g cell is worth more than any amount of clever firmware. cell_rating_g: "1000" # =========================================================================== # OPTIONAL — you can ignore all of this # =========================================================================== # Measured counts per gram. Leave at "0" to derive it from cell_rating_g, # which lands within whatever your cell's real sensitivity is (usually # ±30%). That is plenty for presence detection. # # To measure it: note "Load cell raw", put a known mass on the plate, note # it again, then override = (difference / grams). 10 ml of water is # 10.000 g by definition and costs nothing. cell_scale_override: "0" # Cell sensitivity in mV/V from its datasheet. 1.0 is by far the most # common. Only affects the derived scale, not an override. cell_mv_v: "1.0" # ---- PINS ---- ring_pin: GPIO6 hx_dout: GPIO4 hx_sck: GPIO5 # ---- STEP DETECTION ---- # An event is a change of this FRACTION of the learned weight occurring # inside step_window_ms. BOTH must hold: big enough AND fast enough. # Drift cannot do it - 0.75 of the object in half a second is around a # thousand times the rate thermal drift moves at. step_fraction: "0.75" # The window the change is measured across, ms. Rounded down to a multiple # of the 100 ms detector tick. # # TRADE-OFF: a placement SLOWER than this window is MISSED, because no # single window ever sees the full change. At 500 ms you have to set the # object down in about half a second - normal placing, but not a slow # lower. If gentle placements get ignored, widen this to 800-1000 ms # rather than lowering step_fraction. step_window_ms: "500" # Upper bound. A change LARGER than this multiple of the learned weight is # not our object - it is something else being put on the plate. Real data # from a bedside plate showed 147 g and 327 g excursions (a phone, a glass) # which would otherwise register as the object arriving and leaving. # # Set it wide enough to cover a heavy-handed placement of the real object # but far below anything else that lives near the plate. 4x is generous. step_max_factor: "4" # After an event, ignore further events for this long. Stops chatter. event_lockout_ms: "2000" # "Plate weight" holds its last published value until the reading moves at # least this far. Roughly 2% of your object's weight. report_deadband_g: "0.05" # ---- HARD REJECT ---- # Any reading further than this from the tare is DISCARDED outright - not # clamped, not smoothed, just dropped, so it never reaches the median, the # weight entity, or Home Assistant's history. # # This is for values that cannot be real: nothing that belongs on this # plate weighs this much. Whether they come from a corrupted HX711 read or # something heavy being dumped on the plate, neither is data worth keeping. # # Applied to the FILTERED path only. cell_fast stays untouched because the # tap detector needs the impulses, and a firm knuckle rap really can exceed # this for a few milliseconds. # # Set to 0 to disable. reject_above_g: "750" # ---- CALIBRATION ---- # Placements to average over. Keep it a divisor of num_leds and the ring # fills exactly (15 / 3 = 5 pixels per sample). cal_target: "3" # Placement detection DURING calibration, before the weight is known. # Must sit well under your object and well over the noise floor. cal_detect_g: "0.5" # Clear-the-plate countdown at the start. cal_clear_ms: "4000" cal_clear_time: "4s" # Settle time after placement, before the sample is read. Do NOT go below # ~800 ms: cell_raw is a 5-wide median over 150 ms samples, so it needs # 750 ms to fully reflect a new value. Anything shorter averages in # readings from before the object landed. cal_settle: "900ms" # Pause after removal before asking for the next placement. cal_gap: "300ms" # Per-prompt patience before giving up. cal_timeout: "45s" cal_done_orbit: "3s" # ---- TAP TO CALIBRATE ---- # Knock this rhythm on the plate to arm calibration: . . _ _ . # ("clap-clap, CLAP, CLAP, clap") = short, long, long, short. # # SAMPLE RATE. The green HX711 boards ship strapped to 10 SPS — one sample # per 100 ms — and a tap is an impulse a few ms long, so a fast tap can # fall entirely between samples. The windows below are sized for # DELIBERATELY SLOW tapping, where every interval spans several samples: # # tap ·· 0.5s ·· tap ·· 1.0s ·· tap ·· 1.0s ·· tap ·· 0.5s ·· tap # # For natural-speed tapping, do the 80 SPS mod — cut the trace tying HX711 # pin 15 (RATE) to GND, tie pin 15 to DVDD — set cell_poll to 15ms, and # halve the four window values. cell_poll: "100ms" tap_threshold_g: "5.0" # a knock is tens of grams; noise is ~0.1 g tap_min_gap_ms: "200" # one knock must only ever count once tap_short_min: "250" tap_short_max: "700" tap_long_min: "750" tap_long_max: "2000" tap_reset_ms: "3000" # longer than this and the pattern restarts confirm_ms: "5000" # red countdown: tap again to proceed confirm_timeout: "5s" # ---- STARTUP DISPLAY ---- connect_timeout: "120s" # The ONLY full-ring moment at boot. A full ring is ~1 A on 15 pixels; if # your supply is marginal and the board resets at boot, drop this first. connect_flash_pct: "60" # ----------------------------------------------------------------------------- esphome: name: ${device_name} friendly_name: ${device_name} # The only boot-time light work is the startup script: a slow orbit while # connecting (one pixel at a time, negligible current) and one brief flash # on connect. Deliberately NOT a full-ring animation — driving the whole # ring during startup coincides with the WiFi peak draw, which browns out # marginal supplies and resets the board. on_boot: priority: -100 then: - script.execute: startup esp32: board: esp32-c3-devkitm-1 variant: ESP32C3 flash_size: 4MB framework: type: esp-idf logger: level: INFO api: # Add an encryption key here if your network is not trusted: # encryption: # key: !secret api_key ota: - platform: esphome wifi: ssid: !secret wifi_ssid password: !secret wifi_password # The C3 radio sleeps between beacons by default. Some APs (UniFi in # particular) expect a response inside the auth window and tear the # association down when a sleeping radio misses it, reported as # 'Auth Expired' (deauth reason 2). Disabling power save is the usual fix. power_save_mode: none # output_power is NOT optional on the C3 SuperMini and boards like it. # At the default 20 dBm the little 3V3 regulator cannot hold the rail # through a transmit burst; the dip lands mid-handshake and the AP reports # deauth reason 2, 'Auth Expired'. It looks like a WiFi negotiation problem # and is actually a power problem. 15 dBm roughly halves the TX current and # is still ample at any sane signal level. Verified fix on this hardware. # # On a board with a proper regulator (a plain esp32dev, say) none of this # is needed — which is why an ESP32 on the same SSID connects with no WiFi # config at all. output_power: 15dB # Skips the scan and associates directly, removing the scan/associate race # that shows up as two failed attempts per cycle. fast_connect: true ap: ssid: "${device_name} fallback" captive_portal: # ----------------------------------------------------------------------------- # STATE # ----------------------------------------------------------------------------- globals: # Counts per gram. Uses cell_scale_override if you set one, otherwise # derives it from the cell rating. At gain 128 the HX711's full-scale # differential input is +/-0.5 * AVDD / 128, and the bridge puts out # sens * AVDD at rated load, so: # # counts/g = (mV/V / 1000) * 128 * 2 * 2^23 / full_scale_grams # = 2147483.6 * mV/V / full_scale_grams # # Sanity: 1.0 mV/V on a 1 kg cell -> 2147 counts/g; on 5 kg -> 429. # # AVDD does not appear because it cancels — it is both the bridge # excitation AND the ADC reference — so 3.3 V and 5 V give the same value. - id: cell_scale type: float restore_value: false initial_value: '(${cell_scale_override} > 0 ? (float)${cell_scale_override} : 2147483.6f * ${cell_mv_v} / (float)${cell_rating_g})' # Manual tare. Used by the weight entity and calibration only — the step # detector does not depend on it, which is the whole point. - id: tare_counts type: float restore_value: true initial_value: '0' # Learned object weight, grams. 0 = never calibrated. - id: learned_weight_g type: float restore_value: true initial_value: '0' # Last measured change across the detection window, grams. Diagnostic only - # the detector keeps its own rolling history in a static buffer. - id: last_delta_g type: float restore_value: false initial_value: '0' # Latched presence. Toggled only by detected steps. - id: present_latch type: bool restore_value: false initial_value: 'false' - id: last_event_ms type: uint32_t restore_value: false initial_value: '0' # ---- guided calibration ---- - id: cal_samples type: int restore_value: false initial_value: '0' - id: cal_sum type: float restore_value: false initial_value: '0' - id: cal_abort type: bool restore_value: false initial_value: 'false' # Suspends the detector while calibrating, so placing the object during # the sequence cannot fight the progress display for the ring. - id: cal_running type: bool restore_value: false initial_value: 'false' # 0 = waiting for you to PUT IT ON, 1 = waiting for you to TAKE IT OFF. # Drives the ring colour, so the two waits never look alike. - id: cal_phase type: int restore_value: false initial_value: '0' # ---- tap rhythm ---- - id: tap_quiet type: float restore_value: false initial_value: 'NAN' - id: tap_last_ms type: uint32_t restore_value: false initial_value: '0' - id: tap_count type: int restore_value: false initial_value: '0' - id: tap_armed type: bool restore_value: false initial_value: 'true' - id: awaiting_confirm type: bool restore_value: false initial_value: 'false' - id: tap_confirmed type: bool restore_value: false initial_value: 'false' - id: countdown_start_ms type: uint32_t restore_value: false initial_value: '0' - id: countdown_total_ms type: uint32_t restore_value: false initial_value: '1' # ----------------------------------------------------------------------------- # SENSING # ----------------------------------------------------------------------------- sensor: # Fast, UNFILTERED samples. The tap detector needs impulses, and a median # filter is precisely a device for removing impulses — so the filtering # happens downstream in cell_raw instead of here. - platform: hx711 id: cell_fast internal: true dout_pin: ${hx_dout} clk_pin: ${hx_sck} gain: 128 update_interval: ${cell_poll} on_value: - lambda: |- if (id(cal_running)) return; float q = id(tap_quiet); if (isnan(q)) { id(tap_quiet) = x; return; } float dev = fabsf(x - q) / id(cell_scale); // grams uint32_t now = millis(); if (dev <= ${tap_threshold_g}) { id(tap_quiet) = q + (x - q) * 0.15f; // creep the reference id(tap_armed) = true; return; } // Transient. One knock must only ever count once. if (!id(tap_armed)) return; if ((now - id(tap_last_ms)) < ${tap_min_gap_ms}) return; id(tap_armed) = false; uint32_t gap = now - id(tap_last_ms); id(tap_last_ms) = now; // A tap during the red countdown means "yes, calibrate". if (id(awaiting_confirm)) { id(tap_confirmed) = true; return; } // Stale pattern - this tap becomes the new first one. if (id(tap_count) > 0 && gap > ${tap_reset_ms}) id(tap_count) = 0; if (id(tap_count) == 0) { id(tap_count) = 1; return; } // Intervals must run short, long, long, short. int idx = id(tap_count) - 1; bool want_long = (idx == 1 || idx == 2); bool ok = want_long ? (gap >= ${tap_long_min} && gap <= ${tap_long_max}) : (gap >= ${tap_short_min} && gap <= ${tap_short_max}); if (!ok) { id(tap_count) = 1; return; } id(tap_count) += 1; if (id(tap_count) >= 5) { id(tap_count) = 0; ESP_LOGI("tap", "Rhythm matched - tap once more to calibrate"); id(tap_confirm).execute(); } # Filtered view of the same chip. Everything except the tap detector uses # this. 5-wide median over 150 ms samples = 750 ms of smoothing. - platform: template id: cell_raw name: "Load cell raw" entity_category: diagnostic # state_class makes HA keep LONG-TERM STATISTICS (5-minute and hourly # min/max/mean) for this entity. Without it only short-term history is # kept, which is purged after ~10 days and is impractical to search - # a day of this sensor is ~350,000 states. If you ever need to prove # what the raw count did at 3 a.m. last Tuesday, this line is why you # can. state_class: measurement update_interval: 150ms lambda: 'return id(cell_fast).state;' filters: # Reject FIRST, so an impossible value never enters the median window # and cannot drag it for the next 5 samples. Returning {} drops the # sample entirely rather than substituting anything for it. - lambda: |- if (${reject_above_g} > 0) { float g = (x - id(tare_counts)) / id(cell_scale); if (fabsf(g) > ${reject_above_g}) { ESP_LOGW("reject", "Discarded %.0f g reading (raw %.0f)", g, x); return {}; } } return x; - median: window_size: 5 send_every: 1 send_first_at: 1 # Absolute weight against the manual tare. For calibration and for your own # sanity — the detector does not consult it. - platform: template id: weight_g name: "Plate weight" unit_of_measurement: "g" device_class: weight state_class: measurement accuracy_decimals: 2 update_interval: 200ms lambda: |- float raw = id(cell_raw).state; if (isnan(raw) || isnan(id(tare_counts))) return {}; return (raw - id(tare_counts)) / id(cell_scale); filters: - delta: ${report_deadband_g} # What the detector actually sees: how far the reading moved across the last # window. Sits near 0 and spikes to roughly the object's weight when you # place or lift it. If the spike never reaches # learned_weight x step_fraction, you are placing it too slowly - widen # step_window_ms. - platform: template name: "Change per window" unit_of_measurement: "g" accuracy_decimals: 2 entity_category: diagnostic update_interval: 1s lambda: 'return id(last_delta_g);' - platform: template name: "Learned ${object_name} weight" unit_of_measurement: "g" accuracy_decimals: 2 entity_category: diagnostic update_interval: 60s lambda: 'return id(learned_weight_g);' - platform: template name: "Counts per gram" accuracy_decimals: 0 entity_category: diagnostic update_interval: 300s lambda: 'return id(cell_scale);' - platform: wifi_signal name: "WiFi signal" update_interval: 120s entity_category: diagnostic binary_sensor: - platform: template id: object_present name: "${object_name} on plate" device_class: occupancy lambda: |- if (id(learned_weight_g) <= 0.0f) return {}; return id(present_latch); # ----------------------------------------------------------------------------- # STEP DETECTOR # # Runs at 10 Hz. Three jobs, in order: # 1. Positive step -> latch present, re-baseline # 2. Negative step -> latch absent, re-baseline # 3. Otherwise, creep the baseline toward the reading, rate limited # # Re-baselining on every event is what lets step 3 run unconditionally. The # baseline absorbs the object's weight the moment presence latches, so it # keeps tracking drift while the object sits there. A level-threshold design # cannot do that, which is why one eventually decides a stationary object has # vanished. # ----------------------------------------------------------------------------- interval: - interval: 100ms then: - lambda: |- // --- glitch rejection ------------------------------------------- // Every sample goes through a 3-wide median BEFORE entering the // window, so one corrupted HX711 read cannot create an event. The // detector reads cell_fast (unfiltered - the tap detector needs the // impulses) so this is the only thing standing between a bad read // and a false trigger. Costs 200 ms of latency. static float m3[3]; static int mi = 0, mn = 0; // --- rolling window --------------------------------------------- static float hist[32]; static int hidx = 0, hn = 0; int N = ${step_window_ms} / 100; if (N < 1) N = 1; if (N > 32) N = 32; if (id(cal_running)) { hn = 0; mn = 0; return; } float raw = id(cell_fast).state; if (isnan(raw)) return; float lw = id(learned_weight_g); if (lw <= 0.0f) return; // not calibrated yet m3[mi] = raw; mi = (mi + 1) % 3; if (mn < 3) { mn++; return; } float a = m3[0], b = m3[1], c = m3[2]; float med = fmaxf(fminf(a, b), fminf(fmaxf(a, b), c)); // Read the value from N ticks ago BEFORE overwriting it. float oldest = hist[hidx]; bool have = (hn >= N); hist[hidx] = med; hidx = (hidx + 1) % N; if (hn < N) hn++; if (!have) return; // window not full yet float delta = (med - oldest) / id(cell_scale); // grams per window id(last_delta_g) = delta; uint32_t now = millis(); if ((now - id(last_event_ms)) < ${event_lockout_ms}) return; float lo = lw * ${step_fraction}; // big enough to be our object float hi = lw * ${step_max_factor}; // small enough to BE our object float mag = fabsf(delta); if (mag < lo) return; // drift, noise, a nudge // Too big to be the object we learned. Almost certainly something // else being set down. Logged, not acted on. if (mag > hi) { ESP_LOGW("step", "Ignoring %.1f g change - %.0fx the learned %.2f g, not our object", delta, mag / lw, lw); return; } // Latched and directional: with nothing on the plate only a RISE // counts, and once present only a FALL does. One event cannot // double-trigger, and the window is flushed after each event so the // same change is not counted again as it rolls out. if (!id(present_latch) && delta > 0) { id(present_latch) = true; id(last_event_ms) = now; hn = 0; ESP_LOGI("step", "PUT ON (+%.2f g in %d ms)", delta, ${step_window_ms}); return; } if (id(present_latch) && delta < 0) { id(present_latch) = false; id(last_event_ms) = now; hn = 0; ESP_LOGI("step", "TAKEN OFF (%.2f g in %d ms)", delta, ${step_window_ms}); return; } # ----------------------------------------------------------------------------- # THE RING # Exposed to HA as a normal light. Drive it from your own automations. # ----------------------------------------------------------------------------- light: - platform: esp32_rmt_led_strip id: ring name: "${object_name} ring" pin: ${ring_pin} num_leds: ${num_leds} chipset: WS2812 rgb_order: GRB # if red and green swap, use RGB default_transition_length: 500ms restore_mode: ALWAYS_OFF effects: - pulse: name: "Breathe" transition_length: 1200ms update_interval: 1200ms - pulse: name: "Urgent" transition_length: 400ms update_interval: 400ms - addressable_scan: name: "Chase" move_interval: 80ms # One bright pixel with a fading comet tail, in whatever colour HA set. # Draws ~1/15th of a full-ring effect at the same brightness. - addressable_lambda: name: "Orbit" update_interval: 50ms lambda: |- static int head = 0; if (initial_run) { head = 0; it.all() = Color::BLACK; } it.all().fade_to_black(48); it[head] = current_color; head = (head + 1) % it.size(); # Half speed, longer tail. Used by the startup and prompt displays. - addressable_lambda: name: "Orbit Slow" update_interval: 150ms lambda: |- static int head = 0; if (initial_run) { head = 0; it.all() = Color::BLACK; } it.all().fade_to_black(28); it[head] = current_color; head = (head + 1) % it.size(); # Calibration guidance. Reads the calibration globals directly, so the # script switches this on and the ring follows along. # # THE WHOLE POINT is that you never have to remember what stage you are # at. The two waits are different colours, not different fill levels: # BLUE breathing = PUT IT ON # AMBER breathing = TAKE IT OFF # GREEN solid = captured so far - addressable_lambda: name: "Calibrating" update_interval: 50ms lambda: |- int per = it.size() / ${cal_target}; if (per < 1) per = 1; int filled = id(cal_samples) * per; it.all() = Color::BLACK; for (int i = 0; i < filled && i < it.size(); i++) it[i] = Color(0, 255, 60); float ph = (millis() % 1600) / 1600.0f; float b = 0.15f + 0.85f * (0.5f - 0.5f * cosf(6.28318f * ph)); if (filled < it.size()) { // Still slots left: breathe the NEXT one. Color c = (id(cal_phase) == 0) ? Color(0, (uint8_t)(40 * b), (uint8_t)(255 * b)) // blue : Color((uint8_t)(255 * b), (uint8_t)(120 * b), 0); // amber for (int i = filled; i < filled + per && i < it.size(); i++) it[i] = c; } else if (id(cal_phase) == 1) { // LAST sample captured, so the ring is completely full and // there is no free slot left to prompt with - but the object is // still sitting on the plate and the sequence cannot finish // until it comes off. Breathe the WHOLE ring amber instead. // // Without this the ring just goes solid green, reads as "done", // and the removal wait times out after cal_timeout - throwing // the entire calibration away at the final step. for (int i = 0; i < it.size(); i++) it[i] = Color((uint8_t)(255 * b), (uint8_t)(120 * b), 0); } # Ring fills over countdown_total_ms in whatever colour the script set. - addressable_lambda: name: "Countdown" update_interval: 50ms lambda: |- uint32_t total = id(countdown_total_ms); if (total == 0) total = 1; uint32_t el = millis() - id(countdown_start_ms); float f = (float)el / (float)total; if (f < 0.0f) f = 0.0f; if (f > 1.0f) f = 1.0f; int n = (int)(f * it.size() + 0.5f); it.all() = Color::BLACK; for (int i = 0; i < n && i < it.size(); i++) it[i] = current_color; - addressable_rainbow: name: "Rainbow" speed: 8 - addressable_twinkle: name: "Twinkle" # ----------------------------------------------------------------------------- # SCRIPTS # ----------------------------------------------------------------------------- script: # ---- STARTUP: connecting -> connected -> calibration prompt ---- - id: startup then: - light.turn_on: id: ring brightness: 35% red: 0% green: 40% blue: 100% effect: "Orbit Slow" - wait_until: condition: api.connected: timeout: ${connect_timeout} - if: condition: api.connected: then: - light.turn_on: id: ring effect: "None" - light.turn_on: id: ring brightness: ${connect_flash_pct}% red: 0% green: 100% blue: 30% transition_length: 0s - delay: 600ms - light.turn_off: id: ring transition_length: 400ms - if: condition: lambda: 'return id(learned_weight_g) <= 0.0f;' then: - logger.log: "Not calibrated - press Calibrate or knock the rhythm" - light.turn_on: id: ring brightness: 40% red: 100% green: 0% blue: 0% effect: "Orbit Slow" # No timeout - runs UNTIL CALIBRATED. One pixel is ~8 mA, so # leaving it on indefinitely costs nothing. Deliberately no # turn_off after: calibrate owns the ring once it starts and # turns it off itself, and the learned weight is set before # its celebration runs. - wait_until: condition: lambda: 'return id(learned_weight_g) > 0.0f;' - logger.log: "Calibrated - startup prompt cleared" else: - logger.log: "No API connection - startup display gives up" - light.turn_off: id: ring transition_length: 600ms # ---- TAP RHYTHM -> CONFIRM -> CALIBRATE ---- - id: tap_confirm mode: restart then: - lambda: |- id(tap_confirmed) = false; id(awaiting_confirm) = true; id(countdown_start_ms) = millis(); id(countdown_total_ms) = ${confirm_ms}; - light.turn_on: id: ring brightness: 60% red: 100% green: 0% blue: 0% effect: "Countdown" - wait_until: condition: lambda: 'return id(tap_confirmed);' timeout: ${confirm_timeout} - lambda: 'id(awaiting_confirm) = false;' - if: condition: lambda: 'return id(tap_confirmed);' then: # calibrate does its own clear-the-plate step, so both entry # points behave identically from here. - script.execute: calibrate else: - logger.log: "No confirming tap - calibration not started" - light.turn_off: id: ring transition_length: 500ms # ---- GUIDED CALIBRATION ---- ~15 seconds start to finish # # Both entry points — the Calibrate button and the tap rhythm — run THIS # script from the top, including the clear-the-plate step. Neither can # therefore tare with something still sitting on the plate. # # Averaging several placements matters because a single sample is easily # thrown by a knock or by where on the plate the object lands. - id: calibrate mode: restart then: - lambda: |- id(cal_samples) = 0; id(cal_sum) = 0.0f; id(cal_abort) = false; id(cal_phase) = 1; // amber: take it off id(cal_running) = true; id(countdown_start_ms) = millis(); id(countdown_total_ms) = ${cal_clear_ms}; ESP_LOGI("cal", "Clear the plate"); # AMBER countdown = take everything off. The same colour that means # "take it off" during sampling, so it reads the same way. - light.turn_on: id: ring brightness: 60% red: 100% green: 47% blue: 0% effect: "Countdown" - delay: ${cal_clear_time} # Zero against the now-empty plate. - lambda: |- float raw = id(cell_raw).state; if (isnan(raw)) { id(cal_abort) = true; return; } id(tare_counts) = raw; id(present_latch) = false; ESP_LOGI("cal", "Tared - now put the ${object_name} on"); - light.turn_on: id: ring brightness: 60% effect: "Calibrating" - while: condition: lambda: 'return id(cal_samples) < ${cal_target} && !id(cal_abort);' then: # ---- BLUE: put it on ---- - lambda: 'id(cal_phase) = 0;' - wait_until: condition: lambda: |- float raw = id(cell_raw).state; if (isnan(raw)) return false; return ((raw - id(tare_counts)) / id(cell_scale)) > ${cal_detect_g}; timeout: ${cal_timeout} - lambda: |- float raw = id(cell_raw).state; float w = isnan(raw) ? 0.0f : (raw - id(tare_counts)) / id(cell_scale); if (w <= ${cal_detect_g}) { ESP_LOGW("cal", "Timed out waiting for the ${object_name}"); id(cal_abort) = true; } - if: condition: lambda: 'return !id(cal_abort);' then: - delay: ${cal_settle} - lambda: |- float raw = id(cell_raw).state; if (isnan(raw)) { id(cal_abort) = true; return; } float w = (raw - id(tare_counts)) / id(cell_scale); id(cal_sum) += w; id(cal_samples) += 1; id(cal_phase) = 1; // ring turns amber ESP_LOGI("cal", "Sample %d/%s = %.2f g - take it off", id(cal_samples), "${cal_target}", w); # ---- AMBER: take it off ---- - wait_until: condition: lambda: |- float raw = id(cell_raw).state; if (isnan(raw)) return false; return ((raw - id(tare_counts)) / id(cell_scale)) < (${cal_detect_g} * 0.5f); timeout: ${cal_timeout} - lambda: |- float raw = id(cell_raw).state; float w = isnan(raw) ? 99.0f : (raw - id(tare_counts)) / id(cell_scale); if (w >= (${cal_detect_g} * 0.5f)) { ESP_LOGW("cal", "Timed out waiting for removal"); id(cal_abort) = true; } - delay: ${cal_gap} - if: condition: lambda: 'return id(cal_abort) || id(cal_samples) < ${cal_target};' then: - light.turn_on: id: ring effect: "None" - light.turn_on: id: ring brightness: 70% red: 100% green: 0% blue: 0% transition_length: 0s - delay: 900ms - light.turn_off: id: ring transition_length: 500ms - logger.log: "Calibration aborted - learned weight unchanged" else: - lambda: |- id(learned_weight_g) = id(cal_sum) / (float)${cal_target}; ESP_LOGI("cal", "Calibrated: %.2f g -> step threshold %.2f g", id(learned_weight_g), id(learned_weight_g) * ${step_fraction}); - light.turn_on: id: ring effect: "None" - light.turn_on: id: ring brightness: 90% red: 0% green: 100% blue: 30% transition_length: 0s - delay: 800ms - light.turn_on: id: ring brightness: 70% red: 0% green: 100% blue: 30% effect: "Orbit" - delay: ${cal_done_orbit} - light.turn_off: id: ring transition_length: 800ms # Hand the ring back and restart detection from a clean baseline. - lambda: |- float raw = id(cell_raw).state; if (!isnan(raw)) id(present_latch) = false; id(last_event_ms) = millis(); id(cal_running) = false; # ----------------------------------------------------------------------------- # CONTROLS # ----------------------------------------------------------------------------- button: - platform: template name: "Calibrate" entity_category: config on_press: - script.execute: calibrate - platform: template name: "Tare" entity_category: config on_press: - lambda: |- float raw = id(cell_raw).state; if (isnan(raw)) { ESP_LOGW("tare", "No reading yet - tare ignored"); return; } id(tare_counts) = raw; id(present_latch) = false; ESP_LOGI("tare", "Tared at %.0f counts", raw); - platform: restart name: "Restart" entity_category: config # ============================================================================= # CALIBRATION — what the ring is telling you # ============================================================================= # # Trigger it with the Calibrate button, or knock . . _ _ . on the plate # (then one more tap during the red countdown to confirm). # # AMBER fills the ring (4 s) -> take EVERYTHING off the plate # BLUE breathing -> PUT IT ON # 5 pixels turn GREEN -> captured # AMBER breathing -> TAKE IT OFF # ...repeats 3 times, ring fills green... # GREEN flash, then Orbit -> done # RED flash -> timed out, nothing changed # # Only two things to remember: BLUE = put it on, AMBER = take it off. # Colour, not fill level, tells you what to do — the two waits look # completely different rather than nearly identical. # # About 15 seconds if you keep up. You should never need to press Tare. # # ============================================================================= # TUNING # ============================================================================= # # Events are missed (you place the object and nothing happens): # lower step_fraction to 0.4 or 0.35. Watch "Change per window" while # placing it — the spike must exceed learned_weight x step_fraction. # # False events with nothing happening: # raise step_fraction toward 0.6. # # Double-triggering on placement: # raise event_lockout_ms. # # Calibration reads low: # raise cal_settle. Below ~800 ms the 5-wide median has not finished # catching up and the sample includes pre-placement readings. # # Tap rhythm never matches: # check the log for "Rhythm matched". If taps are not seen at all, lower # tap_threshold_g. If they are seen but the pattern never completes, you # are tapping too fast for 10 SPS — slow down, or do the 80 SPS mod. # # Weight reads wildly wrong: # cell_rating_g is probably wrong. Check "Counts per gram" against the # table at the top, and set cell_scale_override if your cell is unusual. # # ============================================================================= # WIRING # ============================================================================= # # 5V PSU -> --+-- strip 5V [1000 uF across strip 5V/GND] # +-- ESP 5V pin # +-- GND ---- star point ---- ESP GND, HX711 GND, strip GND # # ESP 3V3 -> HX711 VCC [10 uF + 100 nF right at the HX711 pins] # ESP GPIO4 -> HX711 DT (3.3 V both ways, no level shifting needed) # ESP GPIO5 -> HX711 SCK # ESP GPIO6 -> [470 ohm] -> strip DIN # # Load cell: red=E+ black=E- white=A- green=A+ # # Keep HX711 GND on the star point rather than daisy-chained through the # strip's ground. The strip draws amps; that return path is where ground # bounce comes from, and a 24-bit ADC resolves microvolts. # # HX711 at 3.3 V is within its 2.6-5.5 V range, and the measurement is # ratiometric (AVDD is both the excitation and the ADC reference), so # counts per gram does not change between 3.3 V and 5 V. # # LEVEL SHIFTING: a WS2812B on 5 V wants VIH = 0.7 x VDD = 3.5 V, and the # C3 drives 3.3 V. If the strip does nothing, flickers, or the first pixel # is garbage, put one 1N4007 in series with the strip's +5 V. That drops it # to ~4.3 V so VIH becomes 3.0 V. Silicon, not Schottky — you want the # full 0.7 V. # # ============================================================================= # HOME ASSISTANT # ============================================================================= # # Entities (device_name "presence-plate", object_name "object"): # binary_sensor.presence_plate_object_on_plate # light.presence_plate_object_ring # sensor.presence_plate_plate_weight # sensor.presence_plate_change_per_window (diagnostic) # sensor.presence_plate_learned_object_weight (diagnostic) # sensor.presence_plate_counts_per_gram (diagnostic) # button.presence_plate_calibrate # # Example — glow when the object has been left behind: # # automation: # - alias: "Plate: nag if left behind" # triggers: # - trigger: state # entity_id: binary_sensor.presence_plate_object_on_plate # to: "on" # for: "00:05:00" # actions: # - action: light.turn_on # target: # entity_id: light.presence_plate_object_ring # data: # brightness_pct: 60 # rgb_color: [255, 90, 0] # effect: "Orbit" # # - alias: "Plate: stop nagging once taken" # triggers: # - trigger: state # entity_id: binary_sensor.presence_plate_object_on_plate # to: "off" # actions: # - action: light.turn_off # target: # entity_id: light.presence_plate_object_ring # # =============================================================================