# =============================================================================
#  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
#
# =============================================================================
