# =============================================================================
# AirMate IR - ESPHome prototype firmware (IEMI Labs)
# Version 0.1.0 - 2026-10-23 - NOT YET COMPILED (no ESPHome toolchain was
# available when this was written). Every key below was checked against the
# esphome.io page cited next to it; keys marked [UNVERIFIED] were not found on
# the docs page and must be confirmed by the first `esphome config` / compile.
# =============================================================================
#
# GPIO MAP - ESP32-C6-DevKitC-1 (ESP32-C6-WROOM-1, 8 MB flash)
#   Sources: https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/user_guide.html
#            https://docs.espressif.com/projects/esp-idf/en/stable/esp32c6/api-reference/peripherals/gpio.html
#   Function                  GPIO    Header    Notes
#   IR LED driver (MOSFET G)  GPIO18  J3-10     RMT TX; gate HIGH = LEDs on
#   IR receiver OUT (TSOP)    GPIO19  J3-9      RMT RX; active-low, pull-up
#   LD2410C TX -> ESP RX      GPIO20  J3-8      UART1 RX, 256000 8N1
#   LD2410C RX <- ESP TX      GPIO21  J3-7      UART1 TX
#   SHT45 SDA                 GPIO22  J3-6      I2C, 4.7k pull-ups to 3V3
#   SHT45 SCL                 GPIO23  J3-5      I2C
#   WS2812 status LED         GPIO8   onboard   STRAPPING PIN - used only
#                                               because the DevKit wires its
#                                               RGB LED here (exception)
#   User button (to GND)      GPIO3   J1-13     internal pull-up
#   LD2410C VCC               5V      J1-14     radar needs 5 V (5-12 V)
#   Avoid on C6: GPIO4, 5, 8, 9, 15 (strapping); GPIO12/13 (USB-JTAG, used by
#   the logger); GPIO16/17 (UART0 to the USB-UART bridge); GPIO24-30 (flash).
#   NOTE: the brief's draft map (IR TX GPIO4, IR RX GPIO5) uses two C6
#   STRAPPING pins (MTMS/MTDI), so it was replaced.
#   The hardware pin map (products/airmate-ir/hardware/wiring.md) did not exist
#   when this was written; if it now exists and differs, it wins - edit the
#   substitutions below.
#
# GPIO MAP - ESP32-C3-DevKitM-1 alternative (ESP32-C3-MINI-1, 4 MB flash)
#   Source: https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c3/esp32-c3-devkitm-1/user_guide.html
#   IR TX GPIO4 | IR RX GPIO5 | radar TX->ESP RX GPIO6 | ESP TX->radar RX GPIO7
#   SDA GPIO10 | SCL GPIO3 | WS2812 GPIO8 (onboard, strapping) | button GPIO1
#   Avoid on C3: GPIO2, 8, 9 (strapping); GPIO18/19 (USB); GPIO20/21 (UART0 to
#   the USB-UART bridge); GPIO11-17 (flash, not on headers).
# =============================================================================

substitutions:
  name: airmate-ir
  friendly_name: AirMate IR
  room_id: "room01"              # written into CSV logs for ML data collection
  timezone: "Asia/Kolkata"       # IANA tz; change per site
  # ---- AC brand: pick the ESPHome climate_ir platform for your unit --------
  # https://esphome.io/components/climate/climate_ir/  (supported-units table)
  # Platforms that need NO extra keys and support the receiver (drop-in here):
  #   coolix (Midea/Voltas/Lloyd/Beko/Toshiba-OEM "Coolix"), daikin, daikin_arc,
  #   daikin_brc, delonghi, emmeti, fujitsu_general, hitachi_ac344,
  #   hitachi_ac424, climate_ir_lg, midea_ir, mitsubishi, noblex, tcl112,
  #   toshiba, whirlpool, whynter, zhlt01, ballu
  # Gree, Samsung, Panasonic, Haier etc.: use the commented "gree" or
  # "heatpumpir" block in the climate: section instead.
  ac_platform: coolix
  ac_min_temp: "16"
  ac_max_temp: "30"
  ha_notify_action: notify.notify   # HA action used for heat-safety alerts
  # ---- pins (ESP32-C6-DevKitC-1 defaults; see map above) ------------------
  pin_ir_tx: GPIO18
  pin_ir_rx: GPIO19
  pin_radar_rx: GPIO20
  pin_radar_tx: GPIO21
  pin_i2c_sda: GPIO22
  pin_i2c_scl: GPIO23
  pin_rgb_led: GPIO8
  pin_button: GPIO3
  # ---- C3 pins: uncomment and comment out the C6 pins above ---------------
  # pin_ir_tx: GPIO4
  # pin_ir_rx: GPIO5
  # pin_radar_rx: GPIO6
  # pin_radar_tx: GPIO7
  # pin_i2c_sda: GPIO10
  # pin_i2c_scl: GPIO3
  # pin_rgb_led: GPIO8
  # pin_button: GPIO1

# https://esphome.io/components/esphome/
esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2025.11.0
  project:
    name: "iemi_labs.airmate_ir"
    version: "0.1.0"
  on_boot:
    - priority: -100
      then:
        # Engineering mode is needed for per-gate energies (uncertainty rule,
        # ML features, CSV logging). https://esphome.io/components/sensor/ld2410/
        - delay: 10s
        - switch.turn_on: radar_engineering
        - lambda: |-
            id(auto_off_today).publish_state(id(auto_off_count));
            id(boot_ms) = millis();
        - script.execute:
            id: log_action
            what: "Booted; presence logic armed after 5 min warm-up"

# https://esphome.io/components/esp32/
# ESP32-C6 REQUIRES the esp-idf framework ("It is required for ESP32-C2,
# ESP32-C5, ESP32-C6 ..."); esp-idf is also the default for every variant.
# `variant` is used instead of `board` ("board ... is no longer recommended").
# The PlatformIO board id for this kit would be esp32-c6-devkitc-1 [UNVERIFIED
# on esphome.io - not needed when `variant` is set].
esp32:
  variant: esp32c6
  flash_size: 8MB          # DevKitC-1-N8. Do NOT set more than the module has.
  framework:
    type: esp-idf
# --- ESP32-C3-DevKitM-1 alternative -----------------------------------------
# esp32:
#   variant: esp32c3
#   flash_size: 4MB        # ESP32-C3-MINI-1 has 4 MB in-package flash
#   framework:
#     type: esp-idf        # arduino is also allowed on C3, not on C6

# https://esphome.io/components/logger/
# Default on C3 and C6 is USB_SERIAL_JTAG (C6: GPIO12/13 = the kit's "USB"
# Type-C port). On the C3-DevKitM-1 the only USB connector goes to the
# USB-UART bridge on GPIO20/21, so set hardware_uart: UART0 there.
logger:
  level: DEBUG
  hardware_uart: USB_SERIAL_JTAG   # C3-DevKitM-1: UART0

# https://esphome.io/components/api/
api:
  encryption:
    key: !secret api_encryption_key      # 32-byte base64
  # Default 15min reboots the device when no HA client is connected. The
  # presence/heat-safety logic must keep running if HA is down, so disable.
  reboot_timeout: 0s

# https://esphome.io/components/ota/esphome/
# (The page recommends `encryption:` over `password:` for new devices; the
# brief asked for a password via !secret. Both are documented keys.)
ota:
  - platform: esphome
    password: !secret ota_password

# https://esphome.io/components/wifi/
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  # Default 15min reboot on Wi-Fi loss is disabled: rebooting would restart
  # timers and the 5-min warm-up. Local logic keeps running offline.
  reboot_timeout: 0s
  power_save_mode: NONE        # IR timing + radar UART prefer no modem sleep
  ap:
    ssid: "AirMate-IR Setup"
    password: !secret fallback_ap_password   # no default passwords (PSTI)
    ap_timeout: 90s

# https://esphome.io/components/captive_portal/  [page not fetched; component
# is referenced by the wifi page as the way to send Wi-Fi credentials]
captive_portal:

# https://esphome.io/components/improv_serial/ - lets web.esphome.io /
# ESPHome dashboard send Wi-Fi credentials over the logger's serial port.
# [UNVERIFIED] the page does not say whether USB_SERIAL_JTAG is supported.
improv_serial:

# Improv over BLE (optional, uses notable RAM). https://esphome.io/components/improv_ble/
# (`esp32_improv:` is the deprecated alias, removed in 2027.4.0)
# improv_ble:
#   authorizer: user_button

# https://esphome.io/components/web_server/
web_server:
  port: 80
  version: 3
  local: true          # works with no internet
  auth:
    username: !secret web_username
    password: !secret web_password

# https://esphome.io/components/time/  and  /components/time/homeassistant/
time:
  - platform: sntp
    id: clock_sntp
    timezone: ${timezone}
  - platform: homeassistant
    id: clock_ha

# https://esphome.io/components/uart/
# LD2410 needs parity NONE, stop bits 1, 256000 baud (ld2410 page).
uart:
  id: radar_uart
  tx_pin: ${pin_radar_tx}
  rx_pin: ${pin_radar_rx}
  baud_rate: 256000
  parity: NONE
  stop_bits: 1
  data_bits: 8
  rx_buffer_size: 512        # engineering-mode frames are larger than normal

# https://esphome.io/components/sensor/ld2410/
ld2410:
  id: radar
  uart_id: radar_uart

# https://esphome.io/components/i2c/
i2c:
  id: bus_i2c
  sda: ${pin_i2c_sda}
  scl: ${pin_i2c_scl}
  scan: true
  frequency: 100kHz

# https://esphome.io/components/remote_transmitter/
# C3/C6 RMT: 96 TX symbols in 48-symbol blocks, shared by every TX user, so
# the IR transmitter and the WS2812 strip get one 48-symbol block each.
remote_transmitter:
  id: ir_tx
  pin: ${pin_ir_tx}
  carrier_duty_percent: 50%
  rmt_symbols: 48

# https://esphome.io/components/remote_receiver/
# TSOP38238 output is active-low -> inverted + pull-up (page example).
# On C3/C6 RX has its own 96 symbols (not shared with TX).
# `dump: all` logs every decoded protocol plus raw/pronto for code learning.
remote_receiver:
  id: ir_rx
  pin:
    number: ${pin_ir_rx}
    inverted: true
    mode:
      input: true
      pullup: true
  dump: all
  tolerance: 55%          # value used in the climate_ir receiver example
  rmt_symbols: 96
  idle: 10ms              # max on C3/C6 is 32767us
  # [RISK] AC remotes send long frames (Daikin ~150+ symbols in one frame).
  # receive_symbols defaults to 192; whether C6 RMT RX can capture more than
  # its 96 hardware symbols without DMA was NOT verified. Test with each brand.

# -----------------------------------------------------------------------------
# GLOBALS  https://esphome.io/components/globals/
# am_state: 0 IDLE (AC off by user)  1 MONITORING (AC on, room occupied)
#           2 AWAY_PENDING (empty, countdown)  3 AUTO_OFF (we switched it off)
#           4 HEAT_SAFETY (we switched cooling back on for heat)
# -----------------------------------------------------------------------------
globals:
  - id: am_state
    type: int
    restore_value: yes          # survives power loss (AUTO_OFF is remembered)
    initial_value: '0'
  - id: prev_mode               # climate::ClimateMode as int, saved at auto-off
    type: int
    restore_value: yes
    initial_value: '2'          # 2 = CLIMATE_MODE_COOL [enum value UNVERIFIED]
  - id: prev_target
    type: float
    restore_value: yes
    initial_value: '24.0'
  - id: prev_fan                # climate::ClimateFanMode as int, -1 = none
    type: int
    restore_value: yes
    initial_value: '-1'
  - id: auto_off_count
    type: int
    restore_value: yes
    initial_value: '0'
  - id: count_day               # day_of_year the counter belongs to
    type: int
    restore_value: yes
    initial_value: '-1'
  - id: empty_since_ms
    type: uint32_t
    initial_value: '0'
  - id: heat_since_ms
    type: uint32_t
    initial_value: '0'
  - id: boot_ms
    type: uint32_t
    initial_value: '0'
  - id: present_streak_s        # consecutive seconds of has_target
    type: int
    initial_value: '0'
  - id: sleep_sig_s             # consecutive seconds of "sleep signature"
    type: int
    initial_value: '0'
  - id: sleep_latch             # night: a sleeper was seen -> no night auto-off
    type: bool
    initial_value: 'false'
  - id: is_night
    type: bool
    initial_value: 'true'       # conservative until the clock is valid
  - id: presence_class          # 0 empty, 1 uncertain(=present), 2 present
    type: int
    initial_value: '1'
  - id: self_cmd                # true while OUR code is commanding the AC
    type: bool
    initial_value: 'false'
  - id: last_seen_mode
    type: int
    initial_value: '-1'
  - id: ml_p_empty              # on-device classifier output, 0..1
    type: float
    initial_value: '0.0'
  - id: data_label
    type: std::string
    restore_value: no
    initial_value: '"unlabelled"'

# -----------------------------------------------------------------------------
# USER SETTINGS (Home Assistant entities)
# https://esphome.io/components/number/template/
# https://esphome.io/components/switch/template/  + /components/switch/ (restore_mode)
# -----------------------------------------------------------------------------
number:
  - platform: template
    id: away_delay
    name: "Away delay"
    icon: mdi:timer-outline
    unit_of_measurement: min
    min_value: 1
    max_value: 240
    step: 1
    initial_value: 15
    optimistic: true
    restore_value: true
    entity_category: config
  - platform: template
    id: night_delay
    name: "Night away delay"
    icon: mdi:weather-night
    unit_of_measurement: min
    min_value: 10
    max_value: 480
    step: 5
    initial_value: 60
    optimistic: true
    restore_value: true
    entity_category: config
  - platform: template
    id: safe_max_temp
    name: "Heat-safety max temperature"
    icon: mdi:thermometer-alert
    unit_of_measurement: "°C"
    min_value: 26
    max_value: 40
    step: 0.5
    initial_value: 30
    optimistic: true
    restore_value: true
    entity_category: config
  - platform: template
    id: heat_setpoint
    name: "Heat-safety cooling setpoint"
    unit_of_measurement: "°C"
    min_value: 18
    max_value: 30
    step: 0.5
    initial_value: 26
    optimistic: true
    restore_value: true
    entity_category: config
  - platform: template
    id: night_start
    name: "Night start hour"
    min_value: 0
    max_value: 23
    step: 1
    initial_value: 22
    optimistic: true
    restore_value: true
    entity_category: config
  - platform: template
    id: night_end
    name: "Night end hour"
    min_value: 0
    max_value: 23
    step: 1
    initial_value: 7
    optimistic: true
    restore_value: true
    entity_category: config
  - platform: template
    id: uncert_margin
    name: "Presence uncertainty margin"
    icon: mdi:help-circle-outline
    min_value: 0
    max_value: 40
    step: 1
    initial_value: 10       # energy points below a gate threshold that still
    optimistic: true        # count as "maybe a person" (doubled at night)
    restore_value: true
    entity_category: config
  - platform: template
    id: ml_threshold
    name: "ML empty threshold"
    min_value: 0.5
    max_value: 1.0
    step: 0.0001
    initial_value: 0.99     # replace with the value printed by train_presence.py
    optimistic: true
    restore_value: true
    entity_category: config

  # ---- LD2410 radar configuration numbers  (ld2410 page) --------------------
  - platform: ld2410
    timeout:
      name: "Radar hold time"
    light_threshold:
      name: "Radar light threshold"
      disabled_by_default: true
    max_move_distance_gate:
      name: "Radar max move gate"
    max_still_distance_gate:
      id: max_still_gate
      name: "Radar max still gate"
    g0:
      move_threshold: { id: g0_mv_th, name: "g0 move threshold" }
      still_threshold: { id: g0_st_th, name: "g0 still threshold" }
    g1:
      move_threshold: { id: g1_mv_th, name: "g1 move threshold" }
      still_threshold: { id: g1_st_th, name: "g1 still threshold" }
    g2:
      move_threshold: { id: g2_mv_th, name: "g2 move threshold" }
      still_threshold: { id: g2_st_th, name: "g2 still threshold" }
    g3:
      move_threshold: { id: g3_mv_th, name: "g3 move threshold" }
      still_threshold: { id: g3_st_th, name: "g3 still threshold" }
    g4:
      move_threshold: { id: g4_mv_th, name: "g4 move threshold" }
      still_threshold: { id: g4_st_th, name: "g4 still threshold" }
    g5:
      move_threshold: { id: g5_mv_th, name: "g5 move threshold" }
      still_threshold: { id: g5_st_th, name: "g5 still threshold" }
    g6:
      move_threshold: { id: g6_mv_th, name: "g6 move threshold" }
      still_threshold: { id: g6_st_th, name: "g6 still threshold" }
    g7:
      move_threshold: { id: g7_mv_th, name: "g7 move threshold" }
      still_threshold: { id: g7_st_th, name: "g7 still threshold" }
    g8:
      move_threshold: { id: g8_mv_th, name: "g8 move threshold" }
      still_threshold: { id: g8_st_th, name: "g8 still threshold" }

switch:
  - platform: template
    id: never_off
    name: "Never auto-off"
    icon: mdi:shield-home
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    on_turn_on:
      - script.execute: { id: log_action, what: "Never auto-off ON" }
    on_turn_off:
      - script.execute: { id: log_action, what: "Never auto-off OFF" }
  - platform: template
    id: night_force
    name: "Night mode"
    icon: mdi:weather-night
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
  - platform: template
    id: night_sched
    name: "Night mode by schedule"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    entity_category: config
  - platform: template
    id: restore_on_return
    name: "Restore AC when someone returns"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    entity_category: config
  - platform: template
    id: ml_enable
    name: "ML empty-room check"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF   # placeholder weights never say "empty"
    entity_category: config
  - platform: template
    id: csv_logging
    name: "Radar CSV logging"
    optimistic: true
    restore_mode: ALWAYS_OFF
    entity_category: diagnostic
  - platform: template
    id: led_enable
    name: "Status LED"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    entity_category: config
    on_turn_off:
      - light.turn_off: status_rgb

  # ---- LD2410 switches (ld2410 page) ----------------------------------------
  - platform: ld2410
    engineering_mode:
      id: radar_engineering
      name: "Radar engineering mode"
    bluetooth:
      name: "Radar Bluetooth"    # turn OFF in deployed rooms (HLK app access)

# https://esphome.io/components/select/template/
select:
  - platform: template
    id: label_select
    name: "Data label"
    icon: mdi:tag-outline
    entity_category: diagnostic
    optimistic: true
    options:
      - unlabelled
      - empty
      - empty_fan_on
      - empty_curtains
      - empty_ac_louvre
      - pet_only
      - sleeping
      - reading
      - desk_work
      - tv_sofa
      - doorway
      - multiple_people
    initial_option: unlabelled
    set_action:
      - globals.set:
          id: data_label
          value: !lambda 'return x;'

  # ---- LD2410 selects (ld2410 page) -----------------------------------------
  - platform: ld2410
    distance_resolution:
      name: "Radar distance resolution"   # keep 0.75m: ML features assume it
    light_function:
      name: "Radar light function"
      disabled_by_default: true
    out_pin_level:
      name: "Radar OUT level"
      disabled_by_default: true
    # baud_rate: deliberately NOT exposed - changing it breaks the UART until
    # you reflash with a matching uart: baud_rate (ld2410 page warning).

button:
  # https://esphome.io/components/button/factory_reset/
  - platform: factory_reset
    id: factory_reset_btn
    name: "Factory reset (erase Wi-Fi + settings)"
  # https://esphome.io/components/button/restart/  [page not fetched]
  - platform: restart
    name: "Restart"
  # ld2410 page
  - platform: ld2410
    factory_reset:
      name: "Radar factory reset"
    restart:
      name: "Radar restart"
    query_params:
      name: "Radar query parameters"

# -----------------------------------------------------------------------------
# SENSORS
# -----------------------------------------------------------------------------
binary_sensor:
  # ld2410 page: has_target / has_moving_target / has_still_target
  # (each has a default `settle: 1000ms` filter)
  - platform: ld2410
    has_target:
      id: radar_has_target
      name: "Radar presence (raw)"
    has_moving_target:
      id: radar_has_moving
      name: "Radar moving target"
    has_still_target:
      id: radar_has_still
      name: "Radar still target"

  # Fail-safe occupancy used by the logic: "uncertain" counts as occupied.
  # https://esphome.io/components/binary_sensor/template/  [page not fetched]
  - platform: template
    id: occupancy_safe
    name: "Occupancy (safe)"
    device_class: occupancy
    lambda: 'return id(presence_class) != 0;'

  # https://esphome.io/components/binary_sensor/gpio/
  - platform: gpio
    id: user_button
    name: "Button"
    pin:
      number: ${pin_button}
      inverted: true
      mode:
        input: true
        pullup: true
    filters:
      - delayed_on: 20ms
    on_multi_click:
      # hold >= 10 s: factory reset (LED goes red first)
      - timing:
          - ON for at least 10s
        then:
          - light.turn_on: { id: status_rgb, red: 100%, green: 0%, blue: 0%, brightness: 100% }
          - delay: 1s
          - button.press: factory_reset_btn
      # short press: toggle "Never auto-off"
      - timing:
          - ON for at most 1s
          - OFF for at least 0.4s
        then:
          - switch.toggle: never_off

sensor:
  # https://esphome.io/components/sensor/sht4x/
  - platform: sht4x
    address: 0x44
    update_interval: 10s
    precision: High
    heater_max_duty: 0.0        # heater off (default) - avoids self-heating
    temperature:
      id: room_temp
      name: "Room temperature"
      # Calibrate against a reference thermometer; ESP32 + radar warm the case.
      filters:
        - offset: 0.0
    humidity:
      id: room_humidity
      name: "Room humidity"

  # ld2410 page. Distances in cm, energies 0-100. Gate energies need
  # engineering mode. Default filter: throttle_with_priority 1000ms.
  - platform: ld2410
    moving_distance:
      id: radar_moving_distance
      name: "Radar moving distance"
    still_distance:
      id: radar_still_distance
      name: "Radar still distance"
    moving_energy:
      id: radar_moving_energy
      name: "Radar moving energy"
    still_energy:
      id: radar_still_energy
      name: "Radar still energy"
    detection_distance:
      id: radar_detection_distance
      name: "Radar detection distance"
    light:
      name: "Radar light"
      disabled_by_default: true
    g0:
      move_energy: { id: g0_mv, name: "g0 move energy", disabled_by_default: true, entity_category: diagnostic }
      still_energy: { id: g0_st, name: "g0 still energy", disabled_by_default: true, entity_category: diagnostic }
    g1:
      move_energy: { id: g1_mv, name: "g1 move energy", disabled_by_default: true, entity_category: diagnostic }
      still_energy: { id: g1_st, name: "g1 still energy", disabled_by_default: true, entity_category: diagnostic }
    g2:
      move_energy: { id: g2_mv, name: "g2 move energy", disabled_by_default: true, entity_category: diagnostic }
      still_energy: { id: g2_st, name: "g2 still energy", disabled_by_default: true, entity_category: diagnostic }
    g3:
      move_energy: { id: g3_mv, name: "g3 move energy", disabled_by_default: true, entity_category: diagnostic }
      still_energy: { id: g3_st, name: "g3 still energy", disabled_by_default: true, entity_category: diagnostic }
    g4:
      move_energy: { id: g4_mv, name: "g4 move energy", disabled_by_default: true, entity_category: diagnostic }
      still_energy: { id: g4_st, name: "g4 still energy", disabled_by_default: true, entity_category: diagnostic }
    g5:
      move_energy: { id: g5_mv, name: "g5 move energy", disabled_by_default: true, entity_category: diagnostic }
      still_energy: { id: g5_st, name: "g5 still energy", disabled_by_default: true, entity_category: diagnostic }
    g6:
      move_energy: { id: g6_mv, name: "g6 move energy", disabled_by_default: true, entity_category: diagnostic }
      still_energy: { id: g6_st, name: "g6 still energy", disabled_by_default: true, entity_category: diagnostic }
    g7:
      move_energy: { id: g7_mv, name: "g7 move energy", disabled_by_default: true, entity_category: diagnostic }
      still_energy: { id: g7_st, name: "g7 still energy", disabled_by_default: true, entity_category: diagnostic }
    g8:
      move_energy: { id: g8_mv, name: "g8 move energy", disabled_by_default: true, entity_category: diagnostic }
      still_energy: { id: g8_st, name: "g8 still energy", disabled_by_default: true, entity_category: diagnostic }

  # Template sensors (publish from lambdas). sensor.template.publish is listed
  # on https://esphome.io/automations/actions/
  - platform: template
    id: auto_off_today
    name: "Auto-off count today"
    icon: mdi:counter
    accuracy_decimals: 0
    state_class: measurement
    update_interval: never
  - platform: template
    id: away_remaining
    name: "Away countdown"
    unit_of_measurement: min
    accuracy_decimals: 1
    update_interval: 15s
    lambda: |-
      if (id(am_state) != 2) return 0.0f;
      float d = id(is_night) ? id(night_delay).state : id(away_delay).state;
      float el = (millis() - id(empty_since_ms)) / 60000.0f;
      return std::max(0.0f, d - el);
  - platform: template
    id: ml_p_empty_sensor
    name: "ML empty probability"
    entity_category: diagnostic
    disabled_by_default: true
    accuracy_decimals: 4
    update_interval: 10s
    lambda: 'return id(ml_p_empty);'

text_sensor:
  # https://esphome.io/components/text_sensor/template/
  - platform: template
    id: last_action
    name: "Last action"
    icon: mdi:history
    update_interval: never
  - platform: template
    id: logic_state
    name: "Presence logic state"
    update_interval: 5s
    lambda: |-
      switch (id(am_state)) {
        case 0: return {"Idle (AC off)"};
        case 1: return {"Monitoring"};
        case 2: return {"Away countdown"};
        case 3: return {"Auto-off (waiting for return)"};
        case 4: return {"Heat-safety cooling"};
      }
      return {"Unknown"};
  - platform: template
    id: presence_text
    name: "Presence"
    update_interval: 5s
    lambda: |-
      if (id(presence_class) == 2) return {"Present"};
      if (id(presence_class) == 1) return {"Uncertain (treated as present)"};
      return {"Empty"};
  # ld2410 page
  - platform: ld2410
    version:
      name: "Radar firmware"
    mac_address:
      name: "Radar BT MAC"
      disabled_by_default: true

# -----------------------------------------------------------------------------
# CLIMATE  https://esphome.io/components/climate/climate_ir/
#          https://esphome.io/components/climate/  (visual, on_state, make_call)
# IR is one-way: ESPHome ASSUMES the AC is in the last state it sent, unless
# the platform decodes the original remote via receiver_id.
# -----------------------------------------------------------------------------
climate:
  - platform: ${ac_platform}
    id: ac
    name: "Air conditioner"
    transmitter_id: ir_tx
    receiver_id: ir_rx        # remove for platforms without receiver support
    sensor: room_temp
    humidity_sensor: room_humidity
    supports_cool: true
    supports_heat: true
    visual:
      min_temperature: ${ac_min_temp}
      max_temperature: ${ac_max_temp}
      temperature_step: 1
    on_state:
      - lambda: |-
          // Detect changes made by a person (HA UI or decoded original remote)
          // rather than by our own presence logic.
          int m = (int) id(ac).mode;
          if (m != id(last_seen_mode)) {
            if (!id(self_cmd) && id(last_seen_mode) != -1) {
              id(log_action)->execute(std::string("User set AC mode ") +
                  (id(ac).mode == climate::CLIMATE_MODE_OFF ? "OFF" : "ON"));
            }
            id(last_seen_mode) = m;
          }

  # --- GREE (needs `model`, no receiver support) ------------------------------
  # - platform: gree
  #   id: ac
  #   name: "Air conditioner"
  #   model: yan            # generic | yan | yaa | yac | yac1fb9 | yx1ff | yag
  #   transmitter_id: ir_tx
  #   sensor: room_temp
  #   on_state: (copy the on_state lambda above)

  # --- Arduino-HeatpumpIR fallback (Samsung, Panasonic, Hisense, Carrier ...) --
  # "works with the arduino framework and ESP-IDF (on ESP32)"; whether that
  # includes the C6/C3 RISC-V variants is NOT stated - compile-test first.
  # Third-party library, no ESPHome support. Use only if no native platform.
  # - platform: heatpumpir
  #   id: ac
  #   name: "Air conditioner"
  #   protocol: samsung_aqv  # e.g. daikin, mitsubishi_msy, panasonic_ckp,
  #                          # panasonic_jke, samsung_fjm, greeyan, hisense_aud,
  #                          # midea, sharp, toshiba, carrier_mca (full list on page)
  #   horizontal_default: middle   # left|mleft|middle|mright|right|auto
  #   vertical_default: middle     # down|mdown|middle|mup|up|auto
  #   min_temperature: 16
  #   max_temperature: 30
  #   transmitter_id: ir_tx
  #   sensor: room_temp
  #   on_state: (copy the on_state lambda above)

# -----------------------------------------------------------------------------
# STATUS LED  https://esphome.io/components/light/esp32_rmt_led_strip/
# `status_led:` (https://esphome.io/components/status_led/) toggles a plain
# GPIO and cannot drive a WS2812, so status is shown with colours instead:
#   green dim = monitoring, yellow = away countdown, blue = auto-off,
#   red = heat-safety, amber blink = no Wi-Fi, off = idle / LED disabled.
# -----------------------------------------------------------------------------
light:
  - platform: esp32_rmt_led_strip
    id: status_rgb
    name: "Status RGB LED"
    pin: ${pin_rgb_led}
    num_leds: 1
    chipset: WS2812
    channel_colors: GRB
    rmt_symbols: 48           # second 48-symbol TX block (IR TX has the first)
    default_transition_length: 0s

# -----------------------------------------------------------------------------
# SCRIPTS  https://esphome.io/components/script/
# -----------------------------------------------------------------------------
script:
  - id: log_action
    mode: queued
    max_runs: 10
    parameters:
      what: string
    then:
      - text_sensor.template.publish:
          id: last_action
          state: !lambda |-
            auto t = id(clock_sntp).now();
            if (!t.is_valid()) t = id(clock_ha).now();
            char buf[8] = "--:--";
            if (t.is_valid()) snprintf(buf, sizeof(buf), "%02d:%02d", t.hour, t.minute);
            return std::string(buf) + " " + what;
      - logger.log:
          format: "ACTION: %s"
          args: ['what.c_str()']

  # LightCall API (turn_on()/turn_off()/set_rgb/set_brightness) is ESPHome's
  # C++ light API [UNVERIFIED on the docs pages fetched; YAML light.turn_on
  # red/green/blue/brightness keys ARE documented on /components/light/].
  - id: set_led
    mode: restart
    parameters:
      code: int
    then:
      - lambda: |-
          static int last = -2; static bool blink = false;
          if (!id(led_enable).state) { last = -2; return; }
          if (code == 9) blink = !blink;
          else if (code == last) return;            // avoid flooding HA with state
          last = code;
          float r = 0, g = 0, b = 0, br = 0.15f;
          switch (code) {
            case 1: g = 1; br = 0.05f; break;                          // monitoring
            case 2: r = 1; g = 0.8f; break;                            // away countdown
            case 3: b = 1; br = 0.08f; break;                          // auto-off
            case 4: r = 1; br = 0.4f; break;                           // heat-safety
            case 9: r = 1; g = 0.5f; br = blink ? 0.2f : 0.0f; break;  // no Wi-Fi
            default: br = 0; break;                                    // idle
          }
          auto call = (br > 0) ? id(status_rgb).turn_on() : id(status_rgb).turn_off();
          if (br > 0) { call.set_rgb(r, g, b); call.set_brightness(br); }
          call.set_transition_length(0);
          call.perform();

  - id: notify_ha
    mode: queued
    max_runs: 5
    parameters:
      message: string
    then:
      - logger.log:
          level: WARN
          format: "NOTIFY: %s"
          args: ['message.c_str()']
      # Needs "Allow the device to perform Home Assistant actions" enabled in
      # the HA ESPHome integration options (api page).
      - if:
          condition:
            api.connected:
          then:
            - homeassistant.action:
                action: ${ha_notify_action}
                data:
                  title: "AirMate IR"
                data_template:
                  message: "{{ msg }}"
                variables:
                  msg: !lambda 'return message;'

# -----------------------------------------------------------------------------
# PRESENCE LOGIC - see ../presence-logic.md for the state machine and rules.
# https://esphome.io/components/interval/
# -----------------------------------------------------------------------------
interval:
  # ---- 1 Hz: presence classification + state machine ------------------------
  - interval: 1s
    then:
      - lambda: |-
          const uint32_t now = millis();
          // ---------- R0: clock / night window ----------
          auto t = id(clock_sntp).now();
          if (!t.is_valid()) t = id(clock_ha).now();
          bool night = id(night_force).state;
          if (!night && id(night_sched).state) {
            if (!t.is_valid()) {
              night = true;                         // unknown time -> conservative
            } else {
              int h = t.hour, s = (int) id(night_start).state, e = (int) id(night_end).state;
              night = (s <= e) ? (h >= s && h < e) : (h >= s || h < e);
            }
          }
          if (!night) id(sleep_latch) = false;      // latch clears when night ends
          id(is_night) = night;

          // ---------- daily counter reset ----------
          if (t.is_valid() && t.day_of_year != id(count_day)) {
            id(count_day) = t.day_of_year;
            id(auto_off_count) = 0;
            id(auto_off_today).publish_state(0);
          }

          // ---------- R1-R4: presence classification ----------
          bool radar_ok = id(radar_has_target).has_state();
          bool has_t = radar_ok && id(radar_has_target).state;
          bool has_still = id(radar_has_still).has_state() && id(radar_has_still).state;
          float mv_e = id(radar_moving_energy).state;
          // R3 near-threshold rule (engineering mode only)
          bool near = false;
          if (id(radar_engineering).state) {
            sensor::Sensor *mv[9] = {id(g0_mv), id(g1_mv), id(g2_mv), id(g3_mv), id(g4_mv), id(g5_mv), id(g6_mv), id(g7_mv), id(g8_mv)};
            sensor::Sensor *st[9] = {id(g0_st), id(g1_st), id(g2_st), id(g3_st), id(g4_st), id(g5_st), id(g6_st), id(g7_st), id(g8_st)};
            number::Number *mvth[9] = {id(g0_mv_th), id(g1_mv_th), id(g2_mv_th), id(g3_mv_th), id(g4_mv_th), id(g5_mv_th), id(g6_mv_th), id(g7_mv_th), id(g8_mv_th)};
            number::Number *stth[9] = {id(g0_st_th), id(g1_st_th), id(g2_st_th), id(g3_st_th), id(g4_st_th), id(g5_st_th), id(g6_st_th), id(g7_st_th), id(g8_st_th)};
            float margin = id(uncert_margin).state * (night ? 2.0f : 1.0f);
            int max_g = std::isnan(id(max_still_gate).state) ? 8 : (int) id(max_still_gate).state;
            for (int g = 2; g <= max_g && g < 9; g++) {   // g0/g1: near-field noise, fixed thresholds
              float s = st[g]->state, sth = stth[g]->state;
              float m = mv[g]->state, mth = mvth[g]->state;
              if (!std::isnan(s) && !std::isnan(sth) && sth > 0 && s > 0 && s >= sth - margin) near = true;
              if (!std::isnan(m) && !std::isnan(mth) && mth > 0 && m > 0 && m >= mth - margin) near = true;
            }
          }
          // R5 ML veto (optional): "empty" only if the model is very sure.
          bool ml_veto = id(ml_enable).state && !(id(ml_p_empty) >= id(ml_threshold).state);
          bool present = has_t || has_still;          // R1/R2 (low still energy still counts)
          bool uncertain = !present && (near || !radar_ok || ml_veto);  // R3/R4/R5
          id(presence_class) = present ? 2 : (uncertain ? 1 : 0);
          bool room_empty = (id(presence_class) == 0);

          id(present_streak_s) = has_t ? id(present_streak_s) + 1 : 0;

          // ---------- N2: sleep signature (night only) ----------
          // still target held with low movement for >= 10 min -> latch.
          if (!night) id(sleep_sig_s) = 0;
          else if (has_still && (std::isnan(mv_e) || mv_e < 15.0f)) id(sleep_sig_s)++;
          else if (!has_t) { /* keep the count through brief radar dropouts */ }
          else id(sleep_sig_s) = 0;                 // strong movement: awake
          if (night && id(sleep_sig_s) >= 600) id(sleep_latch) = true;

          // ---------- state machine ----------
          const bool warmup = (now - id(boot_ms)) < 300000UL;   // 5 min after boot
          const bool ac_on = id(ac).mode != climate::CLIMATE_MODE_OFF;
          const float temp = id(room_temp).state;
          const bool hot = !std::isnan(temp) && temp > id(safe_max_temp).state;
          const uint32_t delay_ms = (uint32_t) ((night ? id(night_delay).state : id(away_delay).state) * 60000.0f);
          const bool auto_off_blocked = id(never_off).state || (night && id(sleep_latch));
          int st = id(am_state);

          switch (st) {
            case 0:  // IDLE
              if (ac_on) st = 1;
              break;
            case 1:  // MONITORING
              if (!ac_on) { st = 0; break; }
              if (room_empty && !auto_off_blocked && !warmup) {
                st = 2; id(empty_since_ms) = now;
              }
              break;
            case 2:  // AWAY_PENDING
              if (!ac_on) { st = 0; break; }
              if (!room_empty || auto_off_blocked) {
                st = 1;               // any presence / uncertainty resets the timer
                break;
              }
              if (now - id(empty_since_ms) >= delay_ms) {
                id(prev_mode) = (int) id(ac).mode;
                id(prev_target) = id(ac).target_temperature;
                id(prev_fan) = id(ac).fan_mode.has_value() ? (int) id(ac).fan_mode.value() : -1;  // [UNVERIFIED API]
                id(self_cmd) = true;
                auto call = id(ac).make_call();
                call.set_mode(climate::CLIMATE_MODE_OFF);
                call.perform();
                id(self_cmd) = false;
                id(auto_off_count) += 1;
                id(auto_off_today).publish_state(id(auto_off_count));
                id(log_action)->execute(std::string(night ? "Night: room empty " : "Room empty ") +
                    std::to_string((int) (delay_ms / 60000)) + " min, AC turned OFF");
                st = 3;
              }
              break;
            case 3: {  // AUTO_OFF
              if (ac_on) { st = 1; break; }   // a person switched it back on
              bool restore = id(restore_on_return).state && id(present_streak_s) >= 5;
              if (restore) {
                id(self_cmd) = true;
                auto call = id(ac).make_call();
                call.set_mode((climate::ClimateMode) id(prev_mode));
                call.set_target_temperature(id(prev_target));
                if (id(prev_fan) >= 0) call.set_fan_mode((climate::ClimateFanMode) id(prev_fan));
                call.perform();
                id(self_cmd) = false;
                id(log_action)->execute(std::string("Presence returned, AC restored"));
                st = 1;
              } else if (hot && id(presence_class) != 0) {
                // H1 HEAT-SAFETY: auto-off + hot + present-or-uncertain -> cool
                float sp = id(heat_setpoint).state;
                if (!std::isnan(id(prev_target)) && id(prev_target) < sp) sp = id(prev_target);
                id(self_cmd) = true;
                auto call = id(ac).make_call();
                call.set_mode(climate::CLIMATE_MODE_COOL);
                call.set_target_temperature(sp);
                call.perform();
                id(self_cmd) = false;
                id(heat_since_ms) = now;
                char msg[120];
                snprintf(msg, sizeof(msg), "Heat safety: %.1f C > %.1f C with %s presence - cooling turned back ON (%.0f C)",
                         temp, id(safe_max_temp).state, id(presence_class) == 2 ? "detected" : "uncertain", sp);
                id(log_action)->execute(std::string("Heat safety: cooling back ON"));
                id(notify_ha)->execute(std::string(msg));
                st = 4;
              }
              break;
            }
            case 4:  // HEAT_SAFETY - no auto-off for >= 30 min and until 2 C below max
              if (!ac_on) { st = 0; break; }
              if ((now - id(heat_since_ms)) > 1800000UL && !std::isnan(temp) &&
                  temp < id(safe_max_temp).state - 2.0f) st = 1;
              break;
            default:
              st = ac_on ? 1 : 0;
          }
          id(am_state) = st;

  # ---- 1 Hz: optional on-device "really empty?" classifier --------------------
  # Feature order and maths MUST match ml/features.md and ml/train_presence.py.
  # The model block between BEGIN/END is replaced by the output of
  # train_presence.py (--export-yaml). The PLACEHOLDER below has zero weights
  # and bias -12, so p_empty ~ 6e-6: it can never declare a room empty.
  - interval: 1s
    then:
      - lambda: |-
          // BEGIN MODEL (PLACEHOLDER - NOT TRAINED)
          static const int NF = 33;
          static const float MEAN[NF] = {0};
          static const float SCALE[NF] = {1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1};
          static const float W[NF] = {0};
          static const float B = -12.0f;
          // END MODEL
          static const int WIN = 30;                 // 30 samples @ 1 Hz
          static float mvb[9][WIN], stb[9][WIN];
          static uint8_t tgb[WIN];
          static int idx = 0, filled = 0;
          static float th[21]; static int t_idx = 0, t_filled = 0;
          static uint32_t t_last = 0;
          if (!id(radar_engineering).state) { id(ml_p_empty) = 0.0f; filled = 0; return; }
          sensor::Sensor *mv[9] = {id(g0_mv), id(g1_mv), id(g2_mv), id(g3_mv), id(g4_mv), id(g5_mv), id(g6_mv), id(g7_mv), id(g8_mv)};
          sensor::Sensor *st[9] = {id(g0_st), id(g1_st), id(g2_st), id(g3_st), id(g4_st), id(g5_st), id(g6_st), id(g7_st), id(g8_st)};
          for (int g = 0; g < 9; g++) {
            if (std::isnan(mv[g]->state) || std::isnan(st[g]->state)) { id(ml_p_empty) = 0.0f; return; }
          }
          for (int g = 0; g < 9; g++) { mvb[g][idx] = mv[g]->state; stb[g][idx] = st[g]->state; }
          tgb[idx] = (id(radar_has_target).has_state() && id(radar_has_target).state) ? 1 : 0;
          idx = (idx + 1) % WIN; if (filled < WIN) filled++;
          // temperature history: one sample per 30 s, 21 slots = 10 min span
          float temp = id(room_temp).state;
          if (!std::isnan(temp) && (t_filled == 0 || millis() - t_last >= 30000UL)) {
            th[t_idx] = temp; t_idx = (t_idx + 1) % 21; if (t_filled < 21) t_filled++; t_last = millis();
          }
          if (filled < WIN) { id(ml_p_empty) = 0.0f; return; }
          float f[NF];
          float stmax_sum = 0, mvmax_max = 0, tg_sum = 0;
          for (int g = 0; g < 9; g++) {
            float sm = 0, ss = 0, mm = 0;
            for (int k = 0; k < WIN; k++) { mm += mvb[g][k]; sm += stb[g][k]; }
            mm /= WIN; sm /= WIN;
            for (int k = 0; k < WIN; k++) { float d = stb[g][k] - sm; ss += d * d; }
            f[g] = mm; f[9 + g] = sm; f[18 + g] = sqrtf(ss / WIN);   // population std
          }
          for (int k = 0; k < WIN; k++) {
            float smx = 0, mmx = 0;
            for (int g = 0; g < 9; g++) { smx = std::max(smx, stb[g][k]); mmx = std::max(mmx, mvb[g][k]); }
            stmax_sum += smx; mvmax_max = std::max(mvmax_max, mmx); tg_sum += tgb[k];
          }
          f[27] = stmax_sum / WIN;
          f[28] = mvmax_max;
          f[29] = tg_sum / WIN;
          auto t = id(clock_sntp).now(); if (!t.is_valid()) t = id(clock_ha).now();
          float hr = t.is_valid() ? (float) t.hour : 0.0f;   // integer hour, as logged in CSV
          f[30] = t.is_valid() ? sinf(2.0f * 3.14159265f * hr / 24.0f) : 0.0f;
          f[31] = t.is_valid() ? cosf(2.0f * 3.14159265f * hr / 24.0f) : 1.0f;
          // oldest slot is t_idx once the ring is full
          f[32] = (t_filled == 21) ? (th[(t_idx + 20) % 21] - th[t_idx]) : 0.0f;
          float z = B;
          for (int i = 0; i < NF; i++) z += W[i] * ((f[i] - MEAN[i]) / SCALE[i]);
          id(ml_p_empty) = 1.0f / (1.0f + expf(-z));

  # ---- 1 Hz: CSV line for data collection (ml/features.md "raw log schema") --
  # Capture with:  esphome logs airmate-ir.yaml > room01.log   then strip "CSV," prefix
  - interval: 1s
    then:
      - if:
          condition:
            lambda: 'return id(csv_logging).state;'
          then:
            - lambda: |-
                auto t = id(clock_sntp).now(); if (!t.is_valid()) t = id(clock_ha).now();
                long long epoch_ms = t.is_valid() ? (long long) t.timestamp * 1000LL : 0LL;
                sensor::Sensor *mv[9] = {id(g0_mv), id(g1_mv), id(g2_mv), id(g3_mv), id(g4_mv), id(g5_mv), id(g6_mv), id(g7_mv), id(g8_mv)};
                sensor::Sensor *st[9] = {id(g0_st), id(g1_st), id(g2_st), id(g3_st), id(g4_st), id(g5_st), id(g6_st), id(g7_st), id(g8_st)};
                char g[200]; int n = 0;
                for (int i = 0; i < 9; i++) n += snprintf(g + n, sizeof(g) - n, "%.0f,", mv[i]->state);
                for (int i = 0; i < 9; i++) n += snprintf(g + n, sizeof(g) - n, "%.0f,", st[i]->state);
                ESP_LOGI("csv", "CSV,%lld,%u,%s,%s,%s%d,%d,%d,%.0f,%.0f,%.0f,%.0f,%.0f,%.2f,%.1f,%d,%d,%d",
                  epoch_ms, (unsigned) millis(), "${room_id}", id(data_label).c_str(), g,
                  (int) id(radar_has_target).state, (int) id(radar_has_moving).state, (int) id(radar_has_still).state,
                  id(radar_moving_distance).state, id(radar_still_distance).state,
                  id(radar_moving_energy).state, id(radar_still_energy).state,
                  id(radar_detection_distance).state, id(room_temp).state, id(room_humidity).state,
                  t.is_valid() ? t.hour : -1, (int) (id(ac).mode != climate::CLIMATE_MODE_OFF), id(presence_class));

  # ---- 2 s: status LED --------------------------------------------------------
  # wifi.connected condition: https://esphome.io/automations/actions/
  - interval: 2s
    then:
      - if:
          condition:
            wifi.connected:
          then:
            - script.execute: { id: set_led, code: !lambda 'return id(am_state);' }
          else:
            - script.execute: { id: set_led, code: 9 }
