# Pinewood Derby reference v1.1 - 2026-10-13
# Supplied firmware logic; credentials removed. Not hardware-validated.
# Physical board: Seeed Studio XIAO ESP32C3. Supplied board target retained.
esphome:
  name: pine
  friendly_name: pine

  on_boot:
    priority: -100

    then:

      # =========================================
      # SET DYNAMIC SLEEP TIME
      # =========================================

      - lambda: |-
          id(ds).set_sleep_duration(
            id(sleep_dur_min).state * 60 * 1000
          );

      # =========================================
      # SET UP MPU6050
      # =========================================

      - lambda: |-
          id(mpu_registers).write_byte(0x6B, 0x01);

          delay(100);

          id(mpu_registers).write_byte(0x1C, 0x01);

          id(mpu_registers).write_byte(
            0x1F,
            (uint8_t) id(motion_sensitivity).state
          );

          id(mpu_registers).write_byte(0x20, 1);
          id(mpu_registers).write_byte(0x38, 0x40);

      # =========================================
      # CAR IS NOW ACTIVE
      # =========================================

      - binary_sensor.template.publish:
          id: car_active
          state: ON

      - logger.log:
          level: INFO
          format: "Car status -> ACTIVE"

      # =========================================
      # START THE AWAKE/SLEEP TIMER
      # =========================================

      - script.execute:
          id: car_sleep_cycle

esp32:
  board: esp32-c3-devkitm-1
  framework:
    type: arduino

preferences:
  flash_write_interval: 1min

globals:
  # Zero offsets
  - id: fb_zero
    type: float
    restore_value: yes
    initial_value: '0.0'

  - id: lr_zero
    type: float
    restore_value: yes
    initial_value: '0.0'

  # Calibration captures
  - id: flat_ax
    type: float
    restore_value: no
    initial_value: '0.0'
  - id: flat_ay
    type: float
    restore_value: no
    initial_value: '0.0'
  - id: flat_az
    type: float
    restore_value: no
    initial_value: '0.0'

  - id: front_ax
    type: float
    restore_value: no
    initial_value: '0.0'
  - id: front_ay
    type: float
    restore_value: no
    initial_value: '0.0'
  - id: front_az
    type: float
    restore_value: no
    initial_value: '0.0'

  - id: side_ax
    type: float
    restore_value: no
    initial_value: '0.0'
  - id: side_ay
    type: float
    restore_value: no
    initial_value: '0.0'
  - id: side_az
    type: float
    restore_value: no
    initial_value: '0.0'

  # Solved car axes (unit vectors in sensor coordinates)
  - id: car_xx
    type: float
    restore_value: yes
    initial_value: '1.0'
  - id: car_xy
    type: float
    restore_value: yes
    initial_value: '0.0'
  - id: car_xz
    type: float
    restore_value: yes
    initial_value: '0.0'

  - id: car_yx
    type: float
    restore_value: yes
    initial_value: '0.0'
  - id: car_yy
    type: float
    restore_value: yes
    initial_value: '1.0'
  - id: car_yz
    type: float
    restore_value: yes
    initial_value: '0.0'

  - id: car_zx
    type: float
    restore_value: yes
    initial_value: '0.0'
  - id: car_zy
    type: float
    restore_value: yes
    initial_value: '0.0'
  - id: car_zz
    type: float
    restore_value: yes
    initial_value: '1.0'

script:
  - id: save_preferences_script
    mode: restart
    then:
      - delay: 1s
      - lambda: |-
          global_preferences->sync();
  - id: car_sleep_cycle
    mode: restart

    then:

      # Stay awake for the configured Wake Duration.
      - delay: !lambda |-
          return (uint32_t) (id(wake_dur_sec).state * 1000.0f);

      # Tell Home Assistant FIRST that the car
      # is about to sleep.
      - binary_sensor.template.publish:
          id: car_active
          state: OFF

      - logger.log:
          level: INFO
          format: "Car status -> INACTIVE, preparing for deep sleep"

      # Give Wi-Fi/API enough time to send the OFF state
      # to Home Assistant before shutting down.
      - delay: 1s

      # Now actually sleep.
      - deep_sleep.enter: ds
deep_sleep:
  id: ds

  # This still gets dynamically updated
  # from your Sleep Duration number.
  sleep_duration: 30min

  wakeup_pin:
    number: 5
    inverted: false

api:
  encryption:
    key: !secret pine_api_encryption_key

ota:
  - platform: esphome
    password: !secret pine_ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "Pine Fallback Hotspot"
    password: !secret pine_fallback_password

captive_portal:

web_server:
  port: 80
  local: true

logger:

i2c:
  id: bus_a
  sda: 6
  scl: 7
  scan: true

i2c_device:
  id: mpu_registers
  address: 0x68

switch:
  - platform: gpio
    id: gpio_9
    pin: 9
    internal: true
    restore_mode: ALWAYS_ON
    interlock: &gpio_interlock_10 [gpio_10]
    interlock_wait_time: 200ms

  - platform: gpio
    id: gpio_10
    pin: 10
    internal: true
    restore_mode: ALWAYS_OFF
    interlock: *gpio_interlock_10
    interlock_wait_time: 200ms

  - platform: gpio
    id: gpio_3
    pin: 3
    internal: true
    restore_mode: ALWAYS_OFF
    interlock: &gpio_interlock_2 [gpio_2]
    interlock_wait_time: 200ms

  - platform: gpio
    id: gpio_2
    pin: 2
    internal: true
    restore_mode: ALWAYS_ON
    interlock: *gpio_interlock_2
    interlock_wait_time: 200ms

  - platform: template
    name: "Master Enable"
    id: pwm_enable_switch
    optimistic: true
    restore_mode: ALWAYS_ON
    on_turn_off:
      then:
        - output.set_level:
            id: pwm_output
            level: 0%

output:
  - platform: ledc
    pin: 8
    id: pwm_output
    frequency: 850Hz

sensor:
  - platform: adc
    # XIAO D2/A2 = GPIO4 (ADC1 channel 4).
    # External monitor output must be half battery voltage; no direct LiPo input.
    pin: 4
    id: battery_voltage
    name: "Battery Voltage"
    update_interval: 10s
    attenuation: auto
    unit_of_measurement: "V"
    accuracy_decimals: 2
    filters:
      # Assumes V_ADC = V_battery / 2; verify against a multimeter.
      - multiply: 2.0

  - platform: template
    # Estimated percentage from Battery Voltage; no additional ADC pin.
    name: "Battery Level"
    unit_of_measurement: "%"
    device_class: battery
    state_class: measurement
    lambda: |-
      float v = id(battery_voltage).state;
      if (v >= 4.1) return 100.0;
      if (v <= 3.55) return 0.0;
      return (v - 3.55) * 100.0 / (4.1 - 3.55);

  - platform: mpu6050
    id: mpu_sensor
    address: 0x68
    update_interval: 200ms

    accel_x:
      name: "Accel X"
      id: accel_x
      filters:
        - sliding_window_moving_average:
            window_size: 6
            send_every: 1

    accel_y:
      name: "Accel Y"
      id: accel_y
      filters:
        - sliding_window_moving_average:
            window_size: 6
            send_every: 1

    accel_z:
      name: "Accel Z"
      id: accel_z
      filters:
        - sliding_window_moving_average:
            window_size: 6
            send_every: 1

    temperature:
      name: "MPU Temperature"
      id: mpu_temperature
      accuracy_decimals: 1
      filters:
        - throttle: 2s
          
  # Car-axis projected acceleration
  - platform: template
    name: "Car X"
    id: car_x
    internal: true
    update_interval: 200ms
    lambda: |-
      float ax = id(accel_x).state;
      float ay = id(accel_y).state;
      float az = id(accel_z).state;
      return ax * id(car_xx) + ay * id(car_xy) + az * id(car_xz);

  - platform: template
    name: "Car Y"
    id: car_y
    internal: true
    update_interval: 200ms
    lambda: |-
      float ax = id(accel_x).state;
      float ay = id(accel_y).state;
      float az = id(accel_z).state;
      return ax * id(car_yx) + ay * id(car_yy) + az * id(car_yz);

  - platform: template
    name: "Car Z"
    id: car_z
    internal: true
    update_interval: 200ms
    lambda: |-
      float ax = id(accel_x).state;
      float ay = id(accel_y).state;
      float az = id(accel_z).state;
      return ax * id(car_zx) + ay * id(car_zy) + az * id(car_zz);

  - platform: template
    name: "Front Back Raw"
    id: front_back_raw
    internal: true
    unit_of_measurement: "°"
    accuracy_decimals: 2
    update_interval: 200ms
    lambda: |-
      float cx = id(car_x).state;
      float cy = id(car_y).state;
      float cz = id(car_z).state;
      return atan2(cx, sqrt(cy * cy + cz * cz)) * 180.0 / PI;
    filters:
      - sliding_window_moving_average:
          window_size: 4
          send_every: 1

  - platform: template
    name: "Left Right Raw"
    id: left_right_raw
    internal: true
    unit_of_measurement: "°"
    accuracy_decimals: 2
    update_interval: 200ms
    lambda: |-
      float cx = id(car_x).state;
      float cy = id(car_y).state;
      float cz = id(car_z).state;
      return atan2(cy, sqrt(cx * cx + cz * cz)) * 180.0 / PI;
    filters:
      - sliding_window_moving_average:
          window_size: 4
          send_every: 1

  - platform: template
    name: "Front Back Tilt"
    id: front_back_tilt
    unit_of_measurement: "°"
    icon: mdi:car-arrow-left
    accuracy_decimals: 1
    update_interval: 200ms
    lambda: |-
      float val = id(front_back_raw).state - id(fb_zero);
      if (fabs(val) < 0.3) return 0.0;
      return val;
    filters:
      - sliding_window_moving_average:
          window_size: 3
          send_every: 1

  - platform: template
    name: "Left Right Tilt"
    id: left_right_tilt
    unit_of_measurement: "°"
    icon: mdi:car-side
    accuracy_decimals: 1
    update_interval: 200ms
    lambda: |-
      float val = id(left_right_raw).state - id(lr_zero);
      if (fabs(val) < 0.3) return 0.0;
      return val;
    filters:
      - sliding_window_moving_average:
          window_size: 3
          send_every: 1

interval:
  - interval: 200ms
    then:
      - switch.toggle: gpio_10
      - switch.toggle: gpio_2
      - switch.toggle: gpio_9
      - switch.toggle: gpio_3

  - interval: 400ms
    then:
      - if:
          condition:
            switch.is_on: pwm_enable_switch
          then:
            - output.ledc.set_frequency:
                id: pwm_output
                frequency: !lambda return id(freq_low).state;
            - output.set_level:
                id: pwm_output
                level: !lambda return id(siren_volume).state / 100.0;
            - delay: 200ms
            - output.ledc.set_frequency:
                id: pwm_output
                frequency: !lambda return id(freq_high).state;
            - output.set_level:
                id: pwm_output
                level: !lambda return id(siren_volume).state / 100.0;

number:
  - platform: template
    name: "Siren Volume"
    id: siren_volume
    min_value: 0
    max_value: 100
    step: 1
    unit_of_measurement: "%"
    restore_value: true
    initial_value: 83
    optimistic: true
    on_value:
      then:
        - script.execute: save_preferences_script

  - platform: template
    name: "Motion Sensitivity"
    id: motion_sensitivity
    min_value: 1
    max_value: 255
    step: 1
    restore_value: true
    initial_value: 10
    optimistic: true
    on_value:
      then:
        - script.execute: save_preferences_script

  - platform: template
    name: "Sleep Duration"
    id: sleep_dur_min
    min_value: 1
    max_value: 1440
    step: 1
    unit_of_measurement: "min"
    restore_value: true
    initial_value: 30
    optimistic: true
    on_value:
      then:
        - script.execute: save_preferences_script

  - platform: template
    name: "Wake Duration"
    id: wake_dur_sec
    min_value: 5
    max_value: 300
    step: 5
    unit_of_measurement: "s"
    restore_value: true
    initial_value: 30
    optimistic: true
    on_value:
      then:
        - script.execute: save_preferences_script

  - platform: template
    name: "Siren Tone Low"
    id: freq_low
    min_value: 100
    max_value: 3000
    step: 10
    unit_of_measurement: "Hz"
    restore_value: true
    initial_value: 850
    optimistic: true
    on_value:
      then:
        - script.execute: save_preferences_script

  - platform: template
    name: "Siren Tone High"
    id: freq_high
    min_value: 100
    max_value: 3000
    step: 10
    unit_of_measurement: "Hz"
    restore_value: true
    initial_value: 950
    optimistic: true
    on_value:
      then:
        - script.execute: save_preferences_script

button:
  - platform: template
    name: "Capture Flat"
    id: capture_flat
    on_press:
      then:
        - lambda: |-
            id(flat_ax) = id(accel_x).state;
            id(flat_ay) = id(accel_y).state;
            id(flat_az) = id(accel_z).state;
        - logger.log: "Captured flat position"

  - platform: template
    name: "Capture Front Lift"
    id: capture_front
    on_press:
      then:
        - lambda: |-
            id(front_ax) = id(accel_x).state;
            id(front_ay) = id(accel_y).state;
            id(front_az) = id(accel_z).state;
        - logger.log: "Captured front lift"

  - platform: template
    name: "Capture Side Lift"
    id: capture_side
    on_press:
      then:
        - lambda: |-
            id(side_ax) = id(accel_x).state;
            id(side_ay) = id(accel_y).state;
            id(side_az) = id(accel_z).state;
        - logger.log: "Captured side lift"

  - platform: template
    name: "Solve Car Calibration"
    id: solve_calibration
    on_press:
      then:
        - lambda: |-
            // Base gravity vector when flat
            float fx = id(flat_ax);
            float fy = id(flat_ay);
            float fz = id(flat_az);

            // Front lift delta
            float dfx = id(front_ax) - fx;
            float dfy = id(front_ay) - fy;
            float dfz = id(front_az) - fz;

            // Side lift delta
            float dsx = id(side_ax) - fx;
            float dsy = id(side_ay) - fy;
            float dsz = id(side_az) - fz;

            // Normalize front vector -> car X
            float magf = sqrt(dfx*dfx + dfy*dfy + dfz*dfz);
            if (magf < 0.0001) {
              ESP_LOGE("cal", "Front lift too small");
              return;
            }
            dfx /= magf;
            dfy /= magf;
            dfz /= magf;

            // Normalize side vector -> preliminary car Y
            float mags = sqrt(dsx*dsx + dsy*dsy + dsz*dsz);
            if (mags < 0.0001) {
              ESP_LOGE("cal", "Side lift too small");
              return;
            }
            dsx /= mags;
            dsy /= mags;
            dsz /= mags;

            // Flat gravity vector -> car Z
            float magz = sqrt(fx*fx + fy*fy + fz*fz);
            if (magz < 0.0001) {
              ESP_LOGE("cal", "Flat vector invalid");
              return;
            }
            fx /= magz;
            fy /= magz;
            fz /= magz;

            // Orthogonalize front against gravity
            float dotfg = dfx*fx + dfy*fy + dfz*fz;
            dfx -= dotfg * fx;
            dfy -= dotfg * fy;
            dfz -= dotfg * fz;

            magf = sqrt(dfx*dfx + dfy*dfy + dfz*dfz);
            if (magf < 0.0001) {
              ESP_LOGE("cal", "Front orthogonalization failed");
              return;
            }
            dfx /= magf;
            dfy /= magf;
            dfz /= magf;

            // Orthogonalize side against gravity and front
            float dotsg = dsx*fx + dsy*fy + dsz*fz;
            dsx -= dotsg * fx;
            dsy -= dotsg * fy;
            dsz -= dotsg * fz;

            float dotsf = dsx*dfx + dsy*dfy + dsz*dfz;
            dsx -= dotsf * dfx;
            dsy -= dotsf * dfy;
            dsz -= dotsf * dfz;

            mags = sqrt(dsx*dsx + dsy*dsy + dsz*dsz);
            if (mags < 0.0001) {
              ESP_LOGE("cal", "Side orthogonalization failed");
              return;
            }
            dsx /= mags;
            dsy /= mags;
            dsz /= mags;

            // Store solved car basis
            id(car_xx) = dfx;
            id(car_xy) = dfy;
            id(car_xz) = dfz;

            id(car_yx) = dsx;
            id(car_yy) = dsy;
            id(car_yz) = dsz;

            id(car_zx) = fx;
            id(car_zy) = fy;
            id(car_zz) = fz;

            global_preferences->sync();

            ESP_LOGI("cal", "Calibration solved successfully");
        - logger.log: "Car calibration solved"

  - platform: template
    name: "Zero Car Level"
    id: zero_car_level
    on_press:
      then:
        - lambda: |-
            id(fb_zero) = id(front_back_raw).state;
            id(lr_zero) = id(left_right_raw).state;
            global_preferences->sync();
        - logger.log: "Car level zeroed"

binary_sensor:

  - platform: template
    name: "Car Level"
    id: car_level
    icon: mdi:car-brake-hold
    lambda: |-
      return (fabs(id(front_back_tilt).state) <= 2.0 &&
              fabs(id(left_right_tilt).state) <= 2.0);

  - platform: template
    name: "Car Active"
    id: car_active
    icon: mdi:car-connected