LilyGo T-Call Vehicle GPS Tracker

January 19, 2025
2059
Views

Introduction

See the newer LTE version based on the LilyGo T-A7670SA here.

This is a simple and highly customizable vehicle GPS tracker, Immobilizer and Alarm using esphome. The device and code is highly customizable to suit your needs and allows for expansion.

This device uses the GSM network and requires credit to send SMS messages.

When installing, keep the GPS antenna positioned towards the sky. Also try to place in an area with minimal obstruction above it.

Below is a list of available features.

Features
Program up to 3 central phone numbers for management. Notifications are only sent to phone_number_1
Fuel Shutoff Relay – Manually activated fuel shutoff relay. This relay will NOT activate if the ignition state is ON regardless of any automations for safety. (relay_on, relay_off)
Query Status for multiple sensors such as GPS Fix, GPS Satellite’s, Ignition Status, Uptime, Relay Status, and AP status and more. (status)
Alarm Mode – When armed, turns the fuel shutoff relay on and enables notifications for the Ignition, when the ignition is turned on, a notification will be sent to all phone numbers. Also enables the speed alarm and lock and unlocks the doors. (arm, disarm).
Speed Alarm – When armed in alarm mode, enables a Speed alarm, if the vehicle exceeds 10km/h while armed, a notification is sent to phone_number_1. This is useful if you are getting towed.
Query Device Location – Provides coordinates and a link to google maps, also provides the Ignition status and GPS Speed. (where)
Query, Add and Delete the current central phone numbers with phone_number_x, new_number_x, remove_number_x commands (phone_number_x, new_number_x, remove_number_x)
Access Point – Enable and Disable the access point (ap_on, ap_off)
Reboot Notification – Sends a notification to phone_number_1 when the device is rebooted. The device can also be rebooted via SMS. (reboot)
Checks and confirms that messages received match that set from phone_number_1, phone_number_2 or phone_number_3, otherwise an “Unauthorized” command is sent back to the sender.
Central Locking Control – lock and unlock the doors (lock, unlock).
Battery voltage monitor with low battery notifications. An alert is sent to phone_number_1 when the battery voltage is below the set value, The battery voltage can be dynamically set (battery_voltage 12.00)
Door Open Alarm – When armed, activates the alarm and sends a notification to phone_number_1when the doors are opened.
Ignition On Alarm – When armed, activates the alarm and sends a notification to phone_number_1 when the ignition is turned on.
Find your Car! If you lose your car, send the find command to activate the horn. This pulses the horn for 200ms, 3 times per query (find).
Enable door open notifications. Sends a notification every time the doors are opened. Handy if you forgot to arm the alarm, or just leaving the car for a short time. (door_alerts_on, door_alerts_off).
Enable or Disable feedback notifications for the lock, unlock, arm and disarm commands. This cuts down on sending SMS messages. When sending the lock, unlock, arm or disarm commands, you will not receive a response when notifications are set to off. (notifications_on, notifications_off)
Horn activates and pulses for 1 second on alarm activation. This is turned off with the disarm command.
Horn feedback for lock and unlock. When the car locks (lock or arm) the horn will sound once, when the car unlocks (unlock or disarm) the horn sounds twice. (horn_feedback_on, horn_feedback_off)
Dynamically set the horn_feedback realy bounce time. This is useful for different vehicles as the horns respond differently to different pulse times. (horn_pulsetime 250ms)
Auto-Arm function automatically arms the alarm after the ignition is turned off after x minutes. The alarm will not auto arm if the doors are open. (auto_arm_on, auto_arm_off, auto_arm_time 10)
Add up to 3 schedules for arming and disarming the alarm. Schedules will not ARM when the ignition is ON for safety. SMS arming and disarming alerts are disabled when armed and disarmed via schedules. (schedule_x, new_schedule_x, delete_schedule_x)
Query the SIM800L for the network signal strength. Displayed in dBm and %. (status)
Watchdog – This will send a SMS every x days to confirm the device is online (watchdog_on, watchdog_off, watchdog_days x)
Tripple Axis Gyro + Accelerometer – Monitors the vehicle for any movement and activates the alarm when roll or pitch angle is exceeded. (calibrate_level, imu_status)
Impact Alarm – Alarm activates when impact is detected. Impact sensitivity can be dynamically adjusted via Web UI or SMS. This can detect a broken window.
Deep Sleep cuts power consumption by 80% (deep_sleep_enabled, deep_sleep_disabled, sleep_time 22:00, wake_time 07:00)
Query the device for a full list of commands and examples. (help)
Functions can be manually overridden and viewed from the Web UI over Wi-Fi,
Parts List
LillyGo T-Call ESP32 SIM800L
RS232 GPS/GNSS 5v
5x HF46F-005-HS1 Relays
2x HF46F-012-HS1 Relays
INA219 Battery Monitor
Pololu 12V to 5V Regulator 5V
8 Way Terminal Pin Headers 2.54mm
TBS M10Q GLONASS Module (or any GLONASS GPS module.)
GY521 – MPU6050 Tripple Axis Gyro + Accelerometer
Enclosure
Nano SIM Card

Media

ESPhome YAML

Replace the intiail_value for phone_number_1, phone_number_2 and phone_number_3 with your phone numbers in the globals section. Also replace the Access Point password and SSID in the AP section, and also in the # Handle the “ap_on” and “ap_off” commands section. All field requiring changes are highlighted in GREEN

esphome:
  name: "car-gps"
  friendly_name: "car-gps"
  on_boot:
    - priority: 0
      then:
        - lambda: |-
            id(sms_message).publish_state("");
            ESP_LOGD("on_boot", "SMS message cleared on boot.");

        - lambda: |-
            ESP_LOGD("on_boot", "Device has rebooted. Waiting for GPS lock...");

        - lambda: |-
            ESP_LOGD("on_boot", "Phone Number 1: %s", id(phone_number_1).c_str());
            ESP_LOGD("on_boot", "Phone Number 2: %s", id(phone_number_2).c_str());
            ESP_LOGD("on_boot", "Phone Number 3: %s", id(phone_number_3).c_str());

        - lambda: |-
            ESP_LOGD("on_boot", "Stored schedules after reboot:");
            ESP_LOGD("on_boot", "Schedule 1: %s", id(schedule_1).c_str());
            ESP_LOGD("on_boot", "Schedule 2: %s", id(schedule_2).c_str());
            ESP_LOGD("on_boot", "Schedule 3: %s", id(schedule_3).c_str());

        - lambda: |-
            id(watchdog_last_sent) = 0;
            ESP_LOGI("watchdog", "Manually reset watchdog_last_sent to 0 for testing.");

        - delay: 5s
        - uart.write:
            id: sim
            data: "AT+CMGD=1,4\r\n"
        - delay: 2s
        - uart.write:
            id: sim
            data: "AT+CMGF=1\r\n"
        - delay: 1s
        - lambda: |-
            ESP_LOGD("on_boot", "Cleared all stored SMS messages on boot.");

    - priority: -100
      then:
        - switch.turn_off: unlock_switch
        - switch.turn_off: lock_switch

globals:
  - id: sms_sent
    type: bool
    initial_value: "false"
  - id: alarm_armed
    type: bool
    initial_value: "false"
  - id: speed_alarm_triggered
    type: bool
    initial_value: "false"
  - id: battery_alert_sent
    type: bool
    restore_value: no
    initial_value: "false"
  - id: critical_battery_alert_sent
    type: bool
    restore_value: no
    initial_value: "false"
  - id: door_alert_triggered
    type: bool
    restore_value: no
    initial_value: "false"
  - id: schedule_lock_triggered
    type: bool
    restore_value: no
    initial_value: "false"
  - id: schedule_unlock_triggered
    type: bool
    restore_value: no
    initial_value: "false"

  - id: phone_number_1
    type: std::string
    restore_value: yes 
    initial_value: '"+61234567890"'
  - id: phone_number_2
    type: std::string
    restore_value: yes
    initial_value: '"+61234567890"'
  - id: phone_number_3
    type: std::string
    restore_value: yes
    initial_value: '"+61234567890"'

  - id: schedule_1
    type: std::string
    restore_value: yes
    initial_value: '""'
  - id: schedule_2
    type: std::string
    restore_value: yes
    initial_value: '""'
  - id: schedule_3
    type: std::string
    restore_value: yes
    initial_value: '""'

  - id: override_relay_on
    type: bool
    restore_value: no
    initial_value: "false"

  - id: alarm_start_time
    type: int
    restore_value: no
    initial_value: "0"

  - id: alarm_triggered
    type: bool
    initial_value: "false"

  - id: horn_feedback_enabled
    type: bool
    restore_value: yes
    initial_value: "false"

  - id: door_alerts_enabled
    type: bool
    restore_value: yes
    initial_value: "false"

  - id: notifications_enabled
    type: bool
    restore_value: yes
    initial_value: "true"

  - id: battery_voltage_threshold
    type: float
    restore_value: yes
    initial_value: '12.00'
  - id: battery_voltage_below_threshold_sent
    type: bool
    restore_value: no
    initial_value: "false"

  - id: horn_pulse_time_ms
    type: int
    restore_value: yes
    initial_value: "250"

  - id: watchdog_enabled
    type: bool
    restore_value: true
    initial_value: "true"

  - id: watchdog_interval_days
    type: int
    restore_value: true
    initial_value: "1"

  - id: watchdog_last_sent
    type: int
    restore_value: true
    initial_value: "0"

  - id: siren_runtime_seconds
    type: int
    restore_value: yes
    initial_value: '30'

  - id: ax0
    type: float
    restore_value: yes
    initial_value: "0.0"
  - id: ay0
    type: float
    restore_value: yes
    initial_value: "0.0"
  - id: az0
    type: float
    restore_value: yes
    initial_value: "0.0"

  - id: pitch_offset_deg
    type: float
    restore_value: yes
    initial_value: "0.0"
  - id: roll_offset_deg
    type: float
    restore_value: yes
    initial_value: "0.0"

  - id: gyro_z_bias
    type: float
    restore_value: yes
    initial_value: "0.0"

  - id: yaw_offset_deg
    type: float
    restore_value: yes
    initial_value: "0.0"

  - id: level_tol_deg
    type: float
    restore_value: yes
    initial_value: "1.5"

  - id: yaw_integrator_deg
    type: float
    restore_value: no
    initial_value: "0.0"

  - id: last_yaw_update_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: auto_arm_enabled
    type: bool
    restore_value: yes
    initial_value: "false"

  - id: auto_arm_delay_minutes
    type: int
    restore_value: yes
    initial_value: "10"

  - id: ignition_off_timestamp
    type: int
    restore_value: no
    initial_value: "0"

  - id: tilt_alarm_threshold_deg
    type: float
    restore_value: yes
    initial_value: '3.0'

  - id: tilt_over_start_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: calibrate_on_arm
    type: bool
    restore_value: yes
    initial_value: "false"

  - id: siren_enabled
    type: bool
    restore_value: yes
    initial_value: "true"

interval:
  - interval: 10s
    then:
      - lambda: |-
          if (!id(sms_sent) && id(gps_time).now().is_valid() && id(gps_satellites).state > 3) {
            auto now = id(gps_time).now();
            char buffer[100];
            snprintf(
              buffer, sizeof(buffer),
              "Device rebooted successfully at (GPS time): %04d-%02d-%02d %02d:%02d:%02d UTC",
              now.year, now.month, now.day_of_month, now.hour, now.minute, now.second
            );
            std::vector<std::string> nums = {
              id(phone_number_1)
            };
            for (auto &pn : nums) {
              if (!pn.empty()) {
                id(gsm).send_sms(pn.c_str(), buffer);
              }
            }
            id(sms_sent) = true;
            ESP_LOGD("interval", "Reboot notification sent.");
          }

  - interval: 1s
    then:
      - lambda: |-
          if (id(alarm_armed) && id(speed).has_state()) {
            float car_speed = id(speed).state; 
            if (car_speed > 10.0 && !id(speed_alarm_triggered)) {
              std::vector<std::string> nums = {
                id(phone_number_1)
              };
              for (auto &pn : nums) {
                if (!pn.empty()) {
                  id(gsm).send_sms(pn.c_str(), "Car speed alarm activated. Car is travelling more than 10km/h.");
                }
              }
              id(speed_alarm_triggered) = true;
              ESP_LOGD("speed_alarm", "Car speed exceeds 10 km/h. Alarm triggered.");
            } else if (car_speed <= 10.0 && id(speed_alarm_triggered)) {
              id(speed_alarm_triggered) = false;
              ESP_LOGD("speed_alarm", "Car speed is below 10 km/h. No alarm.");
            }
          }

  - interval: 10s
    then:
      - lambda: |-
          static int last_minute = -1;
          if (!id(gps_time).now().is_valid()) return;

          time_t now = id(gps_time).now().timestamp;
          struct tm *current_time = localtime(&now);
          int current_minute = current_time->tm_min;

          if (current_minute == last_minute) return;
          last_minute = current_minute;

          char time_buffer[6];
          strftime(time_buffer, sizeof(time_buffer), "%H:%M", current_time);
          std::string current_time_str(time_buffer);

          char day_buffer[4];
          strftime(day_buffer, sizeof(day_buffer), "%a", current_time);
          std::string current_day(day_buffer);
          std::transform(current_day.begin(), current_day.end(), current_day.begin(), ::tolower);

          std::vector<std::string> schedules = { id(schedule_1), id(schedule_2), id(schedule_3) };

          for (const std::string &schedule : schedules) {
            if (schedule.empty()) continue;

            size_t first_sep = schedule.find("|");
            size_t second_sep = schedule.find("|", first_sep + 1);
            if (first_sep == std::string::npos || second_sep == std::string::npos) continue;

            std::string days = schedule.substr(0, first_sep);
            std::string arm_time = schedule.substr(first_sep + 1, second_sep - (first_sep + 1));
            std::string disarm_time = schedule.substr(second_sep + 1);

            if (days.find(current_day) == std::string::npos) continue;

            // ARM SCHEDULE
            if (arm_time == current_time_str && !id(schedule_lock_triggered)) {
              bool original_feedback = id(horn_feedback_enabled);
              id(horn_feedback_enabled) = false; // silence any honks
              ESP_LOGI("schedule", "Scheduled ARM at %s", arm_time.c_str());
              id(set_alarm_state).execute(true, true);   // armed, silent
              id(horn_feedback_enabled) = original_feedback;
              id(schedule_lock_triggered) = true;
              id(schedule_unlock_triggered) = false;
            }

            // DISARM SCHEDULE
            if (disarm_time == current_time_str && !id(schedule_unlock_triggered)) {
              bool original_feedback = id(horn_feedback_enabled);
              id(horn_feedback_enabled) = false; // silence any honks
              ESP_LOGI("schedule", "Scheduled DISARM at %s", disarm_time.c_str());
              id(set_alarm_state).execute(false, true);  // disarmed, silent
              id(horn_feedback_enabled) = original_feedback;
              id(schedule_unlock_triggered) = true;
              id(schedule_lock_triggered) = false;
            }
          }

  - interval: 120s
    then:
      - lambda: |-
          if (id(ina219_bus_voltage).has_state()) {
            float battery_voltage = id(ina219_bus_voltage).state;

            if (battery_voltage < id(battery_voltage_threshold) && !id(battery_voltage_below_threshold_sent)) {
              char msg[64];
              snprintf(msg, sizeof(msg), "Battery voltage low: %.2fV", battery_voltage);
              id(gsm).send_sms(id(phone_number_1).c_str(), msg);
              id(battery_voltage_below_threshold_sent) = true;
              ESP_LOGD("battery", "Low voltage alert sent: %.2fV", battery_voltage);
            } else if (battery_voltage >= id(battery_voltage_threshold)) {
              id(battery_voltage_below_threshold_sent) = false;
            }
          }

  - interval: 1s
    id: alarm_pulse
    then:
      - lambda: |-
          if (id(alarm_siren_switch).state) {
            if (id(gps_time).now().is_valid()) {
              int elapsed = id(gps_time).now().timestamp - id(alarm_start_time);
              if (elapsed >= id(siren_runtime_seconds)) {
                ESP_LOGD("alarm", "Siren timed out after %d seconds.", id(siren_runtime_seconds));
                id(alarm_siren_switch).turn_off();
                id(alarm_triggered) = false;
                return;
              }
            }
            id(horn_output).turn_on();
            delay(500);
            id(horn_output).turn_off();
          }

  - interval: 200ms
    id: find_pulse
    then:
      - lambda: |-
          static int find_count = 0;

          if (id(find_switch).state) {
            if (find_count < 3) {
              id(horn_output).turn_on();
              delay(200);
              id(horn_output).turn_off();
              find_count++;
            } else {
              id(find_switch).turn_off(); 
              find_count = 0;
            }
          } else {
            find_count = 0;
          }

  - interval: 1s
    then:
      - lambda: |-
          if (id(alarm_armed)) {
            id(status_led).turn_on();
          } else {
            id(status_led).turn_off();
          }

  - interval: 1min
    then:
      - lambda: |-
          if (!id(watchdog_enabled)) {
            ESP_LOGD("watchdog", "Watchdog disabled.");
            return;
          }
          if (!id(gps_time).now().is_valid()) {
            ESP_LOGW("watchdog", "GPS time not valid yet.");
            return;
          }

          auto now = id(gps_time).now();
          int now_day = now.timestamp / 86400;
          int now_hour = now.hour;
          int now_minute = now.minute;

          ESP_LOGD("watchdog", "Now: %04d-%02d-%02d %02d:%02d:%02d (UTC)", now.year, now.month, now.day_of_month, now.hour, now.minute, now.second);
          ESP_LOGD("watchdog", "Last sent: %d | Today: %d | Interval: %d", id(watchdog_last_sent), now_day, id(watchdog_interval_days));

          if (now_hour == 12 && now_minute == 00 && (now_day - id(watchdog_last_sent)) >= id(watchdog_interval_days)) {
            id(watchdog_last_sent) = now_day;
            std::string msg = "Watchdog: Device is online and functioning.";
            id(gsm).send_sms(id(phone_number_1).c_str(), msg.c_str());
            ESP_LOGI("watchdog", "Watchdog SMS sent to phone_number_1.");
          } else {
            ESP_LOGD("watchdog", "Conditions not met - no SMS sent.");
          }

  - interval: 1min
    then:
      - lambda: |-
          if (!id(auto_arm_enabled)) return;
          if (!id(gps_time).now().is_valid()) return;
          if (id(ignition_off_timestamp) <= 0) return;

          if (id(ignition).state) return; // ignition currently ON
          if (id(doors).state) return;    // doors currently OPEN
          if (id(alarm_armed)) return;    // already armed

          int elapsed = id(gps_time).now().timestamp - id(ignition_off_timestamp);
          int needed  = id(auto_arm_delay_minutes) * 60;

          if (elapsed >= needed) {
            ESP_LOGI("auto_arm", "Auto-arming after %d minute(s) of ignition OFF.", id(auto_arm_delay_minutes));

            bool original_feedback = id(horn_feedback_enabled);
            id(horn_feedback_enabled) = false;  // suppress honk on auto-arm

            id(set_alarm_state).execute(true, true);  // armed, silent

            id(horn_feedback_enabled) = original_feedback;

            id(ignition_off_timestamp) = 0;
          }

  - interval: 250ms
    then:
      - lambda: |-
          if (!id(alarm_armed)) { id(tilt_over_start_ms) = 0; return; }
          if (!id(tilt_magnitude_deg).has_state()) { id(tilt_over_start_ms) = 0; return; }

          float effective_thresh = std::max(id(tilt_alarm_threshold_deg), id(level_tol_deg));

          float mag = id(tilt_magnitude_deg).state;
          uint32_t now_ms = millis();

          if (mag >= effective_thresh) {
            if (id(tilt_over_start_ms) == 0) {
              id(tilt_over_start_ms) = now_ms;
            } else if ((now_ms - id(tilt_over_start_ms)) >= 3000 && !id(alarm_triggered)) {
              // 3 seconds exceeded -> trigger alarm
              id(alarm_triggered) = true;
              id(alarm_siren_switch).turn_on();
              if (id(notifications_enabled)) {
                char msg[80];
                snprintf(msg, sizeof(msg), "ALERT: Tilt exceeded %.1f deg for 3s.", effective_thresh);
                id(gsm).send_sms(id(phone_number_1).c_str(), msg);
              }
              ESP_LOGI("alarm", "Tilt alarm fired: mag=%.2f deg, thresh=%.2f deg", mag, effective_thresh);
            }
          } else {
            id(tilt_over_start_ms) = 0;
          }

esp32:
  board: esp32dev
  framework:
    type: arduino

logger:
  level: INFO
  baud_rate: 0
  logs:
    mpu6050: NONE
    sensor: INFO
    component: INFO
    wifi: INFO
    api: WARN
    alarm: INFO
    sms: INFO
    sleep: INFO
    imu: INFO
    boot: INFO

ota:
  - platform: esphome
    password: ""
  - platform: web_server
    

wifi:
  # networks:
  # - ssid: !secret wifi_ssid
  #   password: !secret wifi_password
  #   bssid: 
  # fast_connect: true
  ap:
    ssid: "car-gps"
    password: "password"
    id: wifi_ap

i2c:
  sda: GPIO25
  scl: GPIO14
  scan: true
  id: bus_a
  frequency: 400kHz

web_server:
  port: 80
  log: true
  local: True
  version: 3
  include_internal: true

text_sensor:
  - platform: template
    id: sms_sender
    name: "Sms Sender"
  - platform: template
    id: sms_message
    name: "Sms Message"
  - platform: template
    id: alarm_state
    name: "Alarm State"
    lambda: |-
      if (id(alarm_armed)) {
        return {"Armed"};
      } else {
        return {"Disarmed"};
      }
    update_interval: 10s
  - platform: template
    id: phone_number_sms
    name: "Phone Number"

  - platform: template
    id: gps_time_text
    name: "GPS Time"
    update_interval: 10s
    lambda: |-
      if (!id(gps_time).now().is_valid()) {
        return {"GPS Time: N/A"};
      }
      time_t now = id(gps_time).now().timestamp;
      struct tm *timeinfo = localtime(&now);
      char time_buffer[20];
      strftime(time_buffer, sizeof(time_buffer), "%Y-%m-%d %H:%M:%S", timeinfo);
      return {std::string(time_buffer)};

  - platform: template
    name: "Phone Number 1"
    lambda: |-
      return {id(phone_number_1)};
  - platform: template
    name: "Phone Number 2"
    lambda: |-
      return {id(phone_number_2)};
  - platform: template
    name: "Phone Number 3"
    lambda: |-
      return {id(phone_number_3)};
  - platform: template
    name: "Schedule 1"
    lambda: |-
      return {id(schedule_1)};
  - platform: template
    name: "Schedule 2"
    lambda: |-
      return {id(schedule_2)};
  - platform: template
    name: "Schedule 3"
    lambda: |-
      return {id(schedule_3)};
  - platform: template
    name: "Battery Voltage Threshold"
    lambda: |-
      char buffer[10];
      snprintf(buffer, sizeof(buffer), "%.2f", id(battery_voltage_threshold));
      return {std::string(buffer)};
  - platform: template
    name: "Notifications Enabled"
    lambda: |-
      return {id(notifications_enabled) ? "ON" : "OFF"};
  - platform: template
    name: "Door Alerts Enabled"
    lambda: |-
      return {id(door_alerts_enabled) ? "ON" : "OFF"};
  - platform: template
    name: "Horn Feedback Enabled"
    lambda: |-
      return {id(horn_feedback_enabled) ? "ON" : "OFF"};
  - platform: template
    name: "Watchdog Enabled"
    lambda: |-
      return {id(watchdog_enabled) ? "ON" : "OFF"};
  - platform: template
    name: "Watchdog Interval Days"
    lambda: |-
      return {std::to_string(id(watchdog_interval_days))};
  - platform: template
    name: "Siren Runtime (sec)"
    lambda: |-
      return {std::to_string(id(siren_runtime_seconds))};
  - platform: template
    name: "Horn Pulse Time (ms)"
    lambda: |-
      return {std::to_string(id(horn_pulse_time_ms))};

  - platform: template
    name: "Watchdog Last Sent"
    lambda: |-
      if (id(watchdog_last_sent) <= 0 || !id(gps_time).now().is_valid()) {
        return {"N/A"};
      }
      time_t ts = id(watchdog_last_sent) * 86400;
      struct tm *timeinfo = localtime(&ts);
      char buffer[20];
      strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M", timeinfo);
      return {std::string(buffer)};
    update_interval: 60s

  - platform: template
    name: "Tilt Threshold (deg)"
    lambda: |-
      char b[16];
      snprintf(b, sizeof(b), "%.1f", id(tilt_alarm_threshold_deg));
      return {std::string(b)};

sensor:
  - platform: uptime
    name: "Uptime"
    id: uptime_sensor
  - platform: ina219
    address: 0x41
    shunt_resistance: 0.1 ohm
    current:
      name: "INA219 Current"
      id: ina219_current
    power:
      name: "INA219 Power"
    bus_voltage:
      name: "INA219 Bus Voltage"
      id: ina219_bus_voltage
    shunt_voltage:
      name: "INA219 Shunt Voltage"
    max_voltage: 32.0V
    max_current: 3.2A
    update_interval: 60s

  - platform: template
    name: "SIM800L Signal Strength (dBm)"
    id: sim800l_rssi
    unit_of_measurement: "dBm"
    icon: "mdi:signal"
    accuracy_decimals: 0
    lambda: |-
      int rssi_val = id(sim800l_rssi_raw).state;
      if (rssi_val == 99) return NAN;  // No signal
      return (rssi_val * 2) - 113; // Convert to dBm

  - platform: template
    name: "SIM800L Signal Strength (%)"
    id: sim800l_signal_percent
    unit_of_measurement: "%"
    icon: "mdi:signal-cellular-outline"
    accuracy_decimals: 0
    lambda: |-
      int rssi_val = id(sim800l_rssi_raw).state;
      if (rssi_val == 99) return 0; // No signal
      int percent = (rssi_val * 100) / 31;  // Convert RSSI (0-31) to 0-100%
      return std::max(0, std::min(100, percent)); // Ensure it's within 0-100%

  - platform: mpu6050
    address: 0x68
    update_interval: 50ms
    accel_x:
      id: ax_raw
      name: "MPU6050 Accel X (raw)"
    accel_y:
      id: ay_raw
      name: "MPU6050 Accel Y (raw)"
    accel_z:
      id: az_raw
      name: "MPU6050 Accel Z (raw)"
    gyro_x:
      id: gx_raw_dps
      name: "MPU6050 Gyro X (deg/s raw)"
    gyro_y:
      id: gy_raw_dps
      name: "MPU6050 Gyro Y (deg/s raw)"
    gyro_z:
      id: gz_raw_dps
      name: "MPU6050 Gyro Z (deg/s raw)"
    temperature:
      id: mpu_temp
      name: "MPU6050 Temperature"

  - platform: template
    id: ax
    name: "Accel X (cal)"
    unit_of_measurement: "m/s^2"
    update_interval: 100ms
    lambda: |-
      return id(ax_raw).state - id(ax0);
    filters:
      - median:
          window_size: 7
          send_every: 1
          send_first_at: 1

  - platform: template
    id: ay
    name: "Accel Y (cal)"
    unit_of_measurement: "m/s^2"
    update_interval: 100ms
    lambda: |-
      return id(ay_raw).state - id(ay0);
    filters:
      - median:
          window_size: 7
          send_every: 1
          send_first_at: 1

  - platform: template
    id: az
    name: "Accel Z (cal)"
    unit_of_measurement: "m/s^2"
    update_interval: 100ms
    lambda: |-
      return id(az_raw).state - id(az0);
    filters:
      - median:
          window_size: 7
          send_every: 1
          send_first_at: 1

  - platform: template
    id: gz_dps
    name: "Gyro Z (deg/s, cal)"
    unit_of_measurement: "deg/s"
    update_interval: 100ms
    lambda: |-
      // gz_raw_dps is deg/s; subtract learned bias
      return id(gz_raw_dps).state - id(gyro_z_bias);
    filters:
      - median:
          window_size: 7
          send_every: 1
          send_first_at: 1

  - platform: template
    id: pitch_deg
    name: "Pitch (deg)"
    unit_of_measurement: "deg"
    update_interval: 150ms
    lambda: |-
      if (!id(ax).has_state() || !id(ay).has_state() || !id(az).has_state()) return NAN;
      const float RAD2DEG = 57.2957795f;
      float axv = id(ax).state;
      float ayv = id(ay).state;
      float azv = id(az).state;
      float pitch_raw = atan2f(-axv, sqrtf(ayv*ayv + azv*azv)) * RAD2DEG;
      float out = pitch_raw - id(pitch_offset_deg);
      return out;
    filters:
      - median:
          window_size: 7
          send_every: 1
          send_first_at: 1
      - lambda: |-
          return (fabsf(x) < id(level_tol_deg)) ? 0.0f : x;

  - platform: template
    id: roll_deg
    name: "Roll (deg)"
    unit_of_measurement: "deg"
    update_interval: 150ms
    lambda: |-
      if (!id(ax).has_state() || !id(ay).has_state() || !id(az).has_state()) return NAN;
      const float RAD2DEG = 57.2957795f;
      float ayv = id(ay).state;
      float azv = id(az).state;
      float roll_raw = atan2f(ayv, azv) * RAD2DEG;
      float out = roll_raw - id(roll_offset_deg);
      return out;
    filters:
      - median:
          window_size: 7
          send_every: 1
          send_first_at: 1
      - lambda: |-
          return (fabsf(x) < id(level_tol_deg)) ? 0.0f : x;

  - platform: template
    id: yaw_deg
    name: "Yaw (deg)"
    unit_of_measurement: "deg"
    update_interval: 100ms
    lambda: |-
      // Integrate gz_dps over time, store in globals
      uint32_t now_ms = millis();
      if (id(last_yaw_update_ms) == 0) {
        id(last_yaw_update_ms) = now_ms;
        return id(yaw_integrator_deg) - id(yaw_offset_deg);
      }
      float dt = (now_ms - id(last_yaw_update_ms)) / 1000.0f;
      id(last_yaw_update_ms) = now_ms;

      float rate = id(gz_dps).state;
      id(yaw_integrator_deg) += rate * dt;

      if (id(yaw_integrator_deg) > 180.0f) id(yaw_integrator_deg) -= 360.0f;
      if (id(yaw_integrator_deg) < -180.0f) id(yaw_integrator_deg) += 360.0f;

      return id(yaw_integrator_deg) - id(yaw_offset_deg);
    filters:
      - median:
          window_size: 5
          send_every: 1
          send_first_at: 1

  - platform: template
    id: tilt_magnitude_deg
    name: "Tilt Magnitude (deg)"
    unit_of_measurement: "deg"
    update_interval: 150ms
    lambda: |-
      float p = fabsf(id(pitch_deg).state);
      float r = fabsf(id(roll_deg).state);
      return sqrtf(p*p + r*r);
    filters:
      - median:
          window_size: 5
          send_every: 1
          send_first_at: 1

  - platform: sim800l
    rssi:
      id: sim800l_rssi_raw

uart:
  - id: sim
    baud_rate: 9600
    tx_pin: 27
    rx_pin: 26
  - id: gpsmodule
    baud_rate: 38400
    rx_pin: 34

sim800l:
  id: gsm
  uart_id: sim
  on_sms_received:
    - lambda: |-
        id(sms_sender).publish_state(sender);
        id(sms_message).publish_state(message);

        std::string sender_str(sender);
        std::string message_str(message);

        ESP_LOGD("sms", "Received SMS from '%s': '%s'", sender_str.c_str(), message_str.c_str());

        if (
          sender_str != id(phone_number_1) &&
          sender_str != id(phone_number_2) &&
          sender_str != id(phone_number_3)
        ) {
          id(gsm).send_sms(sender_str.c_str(), "Unauthorized");
          return;
        }

        auto to_lower = [](std::string s){
          std::transform(s.begin(), s.end(), s.begin(), ::tolower);
          return s;
        };

        std::string msg_lower = to_lower(message_str);

        // Query stored numbers
        if (msg_lower == "phone_number_1") { id(gsm).send_sms(sender_str.c_str(), ("Phone Number 1: " + id(phone_number_1)).c_str()); return; }
        if (msg_lower == "phone_number_2") { id(gsm).send_sms(sender_str.c_str(), ("Phone Number 2: " + id(phone_number_2)).c_str()); return; }
        if (msg_lower == "phone_number_3") { id(gsm).send_sms(sender_str.c_str(), ("Phone Number 3: " + id(phone_number_3)).c_str()); return; }

        // Update numbers (persistent)
        if (msg_lower.rfind("new_number_1 ", 0) == 0) { std::string n = message_str.substr(13); id(phone_number_1)=n; id(gsm).send_sms(sender_str.c_str(), ("Phone Number 1 updated to: " + n).c_str()); return; }
        if (msg_lower.rfind("new_number_2 ", 0) == 0) { std::string n = message_str.substr(13); id(phone_number_2)=n; id(gsm).send_sms(sender_str.c_str(), ("Phone Number 2 updated to: " + n).c_str()); return; }
        if (msg_lower.rfind("new_number_3 ", 0) == 0) { std::string n = message_str.substr(13); id(phone_number_3)=n; id(gsm).send_sms(sender_str.c_str(), ("Phone Number 3 updated to: " + n).c_str()); return; }

        // Remove numbers
        if (msg_lower == "remove_number_1") { id(phone_number_1).clear(); id(gsm).send_sms(sender_str.c_str(), "Phone Number 1 removed."); return; }
        if (msg_lower == "remove_number_2") { id(phone_number_2).clear(); id(gsm).send_sms(sender_str.c_str(), "Phone Number 2 removed."); return; }
        if (msg_lower == "remove_number_3") { id(phone_number_3).clear(); id(gsm).send_sms(sender_str.c_str(), "Phone Number 3 removed."); return; }

        // Relay on/off
        if (msg_lower == "relay_on")  { id(override_relay_on)=true;  id(shutoff).turn_on();  delay(100); id(gsm).send_sms(sender_str.c_str(), id(shutoff).state ? "Fuel Shutoff Relay turned on." : "Fuel Shutoff Relay failed to turn on."); return; }
        if (msg_lower == "relay_off") { id(override_relay_on)=false; id(shutoff).turn_off(); delay(100); id(gsm).send_sms(sender_str.c_str(), !id(shutoff).state ? "Fuel Shutoff Relay turned off." : "Fuel Shutoff Relay failed to turn off."); return; }

        // Where
        if (msg_lower == "where") {
          std::string response;
          if (id(latitude).has_state() && id(longitude).has_state()) {
            float lat = id(latitude).state, lon = id(longitude).state;
            response += "Device location:\n";
            response += "Lat: " + std::to_string(lat) + "\n";
            response += "Lon: " + std::to_string(lon) + "\n";
            response += "Google Maps: https://www.google.com/maps?q=" + std::to_string(lat) + "," + std::to_string(lon) + "\n";
          } else {
            response += "GPS coordinates are not available yet.\n";
          }
          response += std::string("Ignition: ") + (id(ignition).state ? "ON\n" : "OFF\n");
          response += std::string("Doors: ") + (id(doors).state ? "OPEN\n" : "CLOSED\n");
          if (id(speed).has_state()) response += "Speed: " + std::to_string(id(speed).state) + " km/h\n"; else response += "Speed: N/A\n";
          id(gsm).send_sms(sender_str.c_str(), response.c_str());
          return;
        }

        // ARM/DISARM (unified)
        if (msg_lower == "arm")    { id(set_alarm_state).execute(true,  false); if (id(notifications_enabled)) id(gsm).send_sms(sender_str.c_str(), "Alarm armed. Notifications enabled, fuel shutoff is ON and doors are locked."); return; }
        if (msg_lower == "disarm") { id(set_alarm_state).execute(false, false); if (id(notifications_enabled)) id(gsm).send_sms(sender_str.c_str(), "Alarm disarmed. Notifications disabled, fuel shutoff is OFF and doors are unlocked."); return; }

        // Status
        if (msg_lower == "status") {
          std::string response;
          if (id(gps_time).now().is_valid()) {
            response += "GPS Fix: True\n";
            time_t now = id(gps_time).now().timestamp; struct tm *ti = localtime(&now);
            char tb[20]; strftime(tb, sizeof(tb), "%Y-%m-%d %H:%M:%S", ti);
            response += std::string("GPS Time: ") + tb + "\n";
          } else { response += "GPS Fix: False\nGPS Time: N/A\n"; }
          if (id(gps_satellites).has_state()) response += "Satellites: " + std::to_string((int)id(gps_satellites).state) + "\n"; else response += "Satellites: N/A\n";
          if (id(uptime_sensor).has_state()) {
            int ts = (int)id(uptime_sensor).state; int d=ts/86400, h=(ts%86400)/3600, m=(ts%3600)/60;
            response += "Uptime: " + std::to_string(d) + "d " + std::to_string(h) + "h " + std::to_string(m) + "m\n";
          } else response += "Uptime: N/A\n";
          response += std::string("Ignition: ") + (id(ignition).state ? "ON\n":"OFF\n");
          response += std::string("Doors: ") + (id(doors).state ? "OPEN\n":"CLOSED\n");
          response += std::string("Siren: ") + (id(siren_enabled) ? "ON\n" : "OFF\n");
          response += std::string("Relay: ") + (id(shutoff).state ? "ON\n":"OFF\n");
          response += std::string("Alarm State: ") + (id(alarm_armed) ? "Armed\n":"Disarmed\n");
          response += std::string("Horn Feedback: ") + (id(horn_feedback_enabled) ? "ON\n":"OFF\n");
          response += std::string("Door Alerts: ") + (id(door_alerts_enabled) ? "ON\n":"OFF\n");
          response += std::string("Notifications: ") + (id(notifications_enabled) ? "ON\n":"OFF\n");
          if (id(sim800l_rssi).has_state()) response += "SIM800L RSSI: " + std::to_string((int)id(sim800l_rssi).state) + " dBm\n"; else response += "SIM800L RSSI: N/A\n";
          if (id(sim800l_signal_percent).has_state()) response += "SIM800L Signal: " + std::to_string((int)id(sim800l_signal_percent).state) + "%\n"; else response += "SIM800L Signal: N/A\n";
          response += "Horn Pulsetime: " + std::to_string(id(horn_pulse_time_ms)) + " ms\n";
          response += "Siren Runtime: " + std::to_string(id(siren_runtime_seconds)) + " sec\n";
          response += std::string("Auto-arm: ") + (id(auto_arm_enabled) ? "ON\n" : "OFF\n");
          response += "Auto-arm Delay: " + std::to_string(id(auto_arm_delay_minutes)) + " min\n";
          char th[10]; snprintf(th, sizeof(th), "%.2fV", id(battery_voltage_threshold)); response += "Voltage Alarm: " + std::string(th) + "\n";
          if (id(ina219_bus_voltage).has_state() && id(ina219_current).has_state()) {
            char vs[10], cs[10]; snprintf(vs, sizeof(vs), "%.2fV", id(ina219_bus_voltage).state); snprintf(cs, sizeof(cs), "%.2fA", id(ina219_current).state);
            response += "Battery Voltage: " + std::string(vs) + "\n";
            response += "Current Draw: " + std::string(cs) + "\n";
          } else response += "Battery: N/A\n";
          response += std::string("Watchdog: ") + (id(watchdog_enabled) ? "ON\n":"OFF\n");
          response += "Watchdog Interval: " + std::to_string(id(watchdog_interval_days)) + " day(s)\n";
          id(gsm).send_sms(sender_str.c_str(), response.c_str());
          return;
        }

        // Add schedule (flexible)
        if (msg_lower.rfind("new_schedule_", 0) == 0) {
          auto trim = [](std::string s)->std::string { auto issp=[](unsigned char c){return std::isspace(c);}; while(!s.empty()&&issp(s.front())) s.erase(s.begin()); while(!s.empty()&&issp(s.back())) s.pop_back(); return s; };
          auto split_ws = [](const std::string& s){ std::vector<std::string> out; std::string cur; for(char c: s){ if (std::isspace((unsigned char)c)){ if(!cur.empty()){ out.push_back(cur); cur.clear(); } } else { cur.push_back(c);} } if(!cur.empty()) out.push_back(cur); return out; };
          auto to_hhmm = [](const std::string& t)->std::string { int h=-1,m=-1,s=0; if (sscanf(t.c_str(), "%d:%d:%d", &h, &m, &s) < 2) return ""; if (h<0||h>23||m<0||m>59) return ""; char buf[6]; snprintf(buf,sizeof(buf),"%02d:%02d",h,m); return std::string(buf); };
          auto add_days = [](std::array<bool,7>& d, std::string tok){ if (!tok.empty()&&(tok.back()==','||tok.back()==';')) tok.pop_back(); if (tok=="mon"||tok=="monday") d[0]=true; else if (tok=="tue"||tok=="tues"||tok=="tuesday") d[1]=true; else if (tok=="wed"||tok=="weds"||tok=="wednesday") d[2]=true; else if (tok=="thu"||tok=="thur"||tok=="thurs"||tok=="thursday") d[3]=true; else if (tok=="fri"||tok=="friday") d[4]=true; else if (tok=="sat"||tok=="saturday") d[5]=true; else if (tok=="sun"||tok=="sunday") d[6]=true; };

          size_t pref = std::string("new_schedule_").size(); size_t i = pref; while (i < msg_lower.size() && std::isdigit((unsigned char)msg_lower[i])) i++;
          if (i==pref) { id(gsm).send_sms(sender_str.c_str(), "Invalid schedule index."); return; }
          int idx = atoi(msg_lower.substr(pref, i-pref).c_str()); if (idx<1||idx>3) { id(gsm).send_sms(sender_str.c_str(), "Schedule index must be 1-3."); return; }

          std::string rest = trim(msg_lower.substr(i));
          for (char& c : rest) { if (c==',' || c=='\n' || c=='\t' || c==';') c=' '; }

          std::array<bool,7> days{}; bool saw=false, wkd=false, wke=false, all=false;
          std::string arm_hhmm, disarm_hhmm;

          std::vector<std::string> toks = split_ws(rest);
          for (size_t k=0;k<toks.size();++k) {
            std::string t = toks[k];
            if (t=="arm" && (k+1)<toks.size()) { std::string cand = to_hhmm(toks[k+1]); if (cand.empty()){ id(gsm).send_sms(sender_str.c_str(), "Invalid arm time. Use HH:MM."); return; } arm_hhmm=cand; k++; continue; }
            if (t=="disarm" && (k+1)<toks.size()) { std::string cand = to_hhmm(toks[k+1]); if (cand.empty()){ id(gsm).send_sms(sender_str.c_str(), "Invalid disarm time. Use HH:MM."); return; } disarm_hhmm=cand; k++; continue; }
            if (t=="everyday"||t=="daily"||t=="all") { all=true; continue; }
            if (t=="weekdays"||t=="weekday") { wkd=true; continue; }
            if (t=="weekends"||t=="weekend") { wke=true; continue; }
            size_t before = std::count(days.begin(), days.end(), true);
            add_days(days, t);
            size_t after  = std::count(days.begin(), days.end(), true);
            if (after>before) saw=true;
          }

          if (all) { for(int d=0; d<7; ++d) days[d]=true; saw=true; }
          if (wkd) { for(int d=0; d<=4; ++d) days[d]=true; saw=true; }
          if (wke) { days[5]=true; days[6]=true; saw=true; }

          if (arm_hhmm.empty() || disarm_hhmm.empty()) { id(gsm).send_sms(sender_str.c_str(), "Missing arm/disarm time. Example: arm 21:00 disarm 07:00"); return; }
          if (!saw) { id(gsm).send_sms(sender_str.c_str(), "No days specified. Use mon..sun or weekdays/weekends/everyday."); return; }

          const char* shortn[7] = {"mon","tue","wed","thu","fri","sat","sun"};
          std::string days_str; for (int d=0; d<7; ++d) if (days[d]) { if (!days_str.empty()) days_str += ","; days_str += shortn[d]; }

          std::string schedule_data = days_str + "|" + arm_hhmm + "|" + disarm_hhmm;
          if (idx==1) id(schedule_1)=schedule_data; else if (idx==2) id(schedule_2)=schedule_data; else id(schedule_3)=schedule_data;

          std::string conf = "Schedule " + std::to_string(idx) + " set: " + days_str + " arm " + arm_hhmm + " disarm " + disarm_hhmm;
          id(gsm).send_sms(sender_str.c_str(), conf.c_str());
          return;
        }

        // Remove schedule
        if (msg_lower.find("remove_schedule_") == 0) {
          std::string id_part = msg_lower.substr(16);
          bool deleted=false;
          if (id_part=="1" && !id(schedule_1).empty()) { id(schedule_1)=""; deleted=true; }
          else if (id_part=="2" && !id(schedule_2).empty()) { id(schedule_2)=""; deleted=true; }
          else if (id_part=="3" && !id(schedule_3).empty()) { id(schedule_3)=""; deleted=true; }
          id(gsm).send_sms(sender_str.c_str(), deleted ? ("Schedule " + id_part + " removed successfully.").c_str() : ("Schedule " + id_part + " not found.").c_str());
          return;
        }

        // Query schedules
        if (msg_lower == "schedule_1" || msg_lower == "schedule_2" || msg_lower == "schedule_3") {
          std::string val = (msg_lower=="schedule_1")?id(schedule_1):(msg_lower=="schedule_2")?id(schedule_2):id(schedule_3);
          if (val.empty()) { id(gsm).send_sms(sender_str.c_str(), msg_lower + " is not set."); }
          else {
            size_t a = val.find("|"), b = val.find("|", a+1);
            if (a!=std::string::npos && b!=std::string::npos) {
              std::string days = val.substr(0,a), at = val.substr(a+1, b-(a+1)), dt = val.substr(b+1);
              id(gsm).send_sms(sender_str.c_str(), (msg_lower + ":\nDays: " + days + "\nArm: " + at + "\nDisarm: " + dt).c_str());
            } else id(gsm).send_sms(sender_str.c_str(), (msg_lower + " contains invalid data.").c_str());
          }
          return;
        }

        // Lock/Unlock (unified)
        if (msg_lower == "lock")   { id(do_lock).execute(false);   if (id(notifications_enabled)) id(gsm).send_sms(sender_str.c_str(), "Doors Locked.");   return; }
        if (msg_lower == "unlock") { id(do_unlock).execute(false); if (id(notifications_enabled)) id(gsm).send_sms(sender_str.c_str(), "Doors Unlocked."); return; }

        // Time
        if (msg_lower == "time") {
          std::string response;
          if (id(gps_time).now().is_valid()) {
            time_t now = id(gps_time).now().timestamp; struct tm *ti = localtime(&now);
            char tb[20]; strftime(tb, sizeof(tb), "%Y-%m-%d %H:%M:%S", ti);
            response = std::string("GPS Time: ") + tb;
          } else response = "GPS Time: N/A";
          id(gsm).send_sms(sender_str.c_str(), response.c_str());
          return;
        }

        // Reboot
        if (msg_lower == "reboot") { id(gsm).send_sms(sender_str.c_str(), "Device is rebooting..."); id(reboot_trigger).press(); return; }

        // Horn feedback on/off
        if (msg_lower == "horn_feedback_on")  { id(horn_feedback_enabled)=true;  id(gsm).send_sms(sender_str.c_str(), "Horn feedback enabled.");  return; }
        if (msg_lower == "horn_feedback_off") { id(horn_feedback_enabled)=false; id(gsm).send_sms(sender_str.c_str(), "Horn feedback disabled."); return; }

        // Find
        if (msg_lower == "find") { id(find_switch).turn_on(); id(gsm).send_sms(sender_str.c_str(), "Find command activated. Horn will sound 3 times."); return; }

        // Battery voltage threshold
        if (msg_lower.rfind("battery_voltage ", 0) == 0) {
          float val = strtof(message_str.substr(16).c_str(), nullptr);
          if (val > 0.0f && val < 20.0f) {
            id(battery_voltage_threshold) = val; id(battery_voltage_below_threshold_sent)=false;
            char buf[64]; snprintf(buf, sizeof(buf), "Battery alert voltage set to %.2fV", val);
            id(gsm).send_sms(sender_str.c_str(), buf);
          } else id(gsm).send_sms(sender_str.c_str(), "Invalid battery voltage threshold.");
          return;
        }

        // Notifications on/off
        if (msg_lower == "notifications_on")  { id(notifications_enabled)=true;  id(gsm).send_sms(sender_str.c_str(), "Notifications have been enabled.");  return; }
        if (msg_lower == "notifications_off") { id(notifications_enabled)=false; id(gsm).send_sms(sender_str.c_str(), "Notifications have been disabled."); return; }

        // Door alerts on/off
        if (msg_lower == "door_alerts_on")  { id(door_alerts_enabled)=true;  id(gsm).send_sms(sender_str.c_str(), "Door alerts enabled.");  return; }
        if (msg_lower == "door_alerts_off") { id(door_alerts_enabled)=false; id(gsm).send_sms(sender_str.c_str(), "Door alerts disabled."); return; }

        // Battery snapshot
        if (msg_lower == "battery") {
          std::string response;
          if (id(ina219_bus_voltage).has_state() && id(ina219_current).has_state()) {
            char vs[10], cs[10]; snprintf(vs, sizeof(vs), "%.2fV", id(ina219_bus_voltage).state); snprintf(cs, sizeof(cs), "%.2fA", id(ina219_current).state);
            response += "Battery Voltage: " + std::string(vs) + "\n";
            response += "Current Draw: " + std::string(cs);
          } else response += "Battery data not available.";
          id(gsm).send_sms(sender_str.c_str(), response.c_str());
          return;
        }

        // Watchdog on/off
        if (msg_lower == "watchdog_on")  { id(watchdog_enabled)=true;  id(gsm).send_sms(sender_str.c_str(), "Watchdog enabled.");  return; }
        if (msg_lower == "watchdog_off") { id(watchdog_enabled)=false; id(gsm).send_sms(sender_str.c_str(), "Watchdog disabled."); return; }

        // Watchdog days
        if (msg_lower.rfind("watchdog_days ", 0) == 0) {
          int days = atoi(msg_lower.substr(14).c_str());
          if (days > 0) { id(watchdog_interval_days)=days; id(gsm).send_sms(sender_str.c_str(), ("Watchdog interval set to " + std::to_string(days) + " day(s).").c_str()); id(watchdog_last_sent)=id(gps_time).now().timestamp; }
          else { id(gsm).send_sms(sender_str.c_str(), "Invalid watchdog_days value."); }
          return;
        }

        // Horn pulse time
        if (msg_lower.rfind("horn_pulsetime ", 0) == 0) {
          int pulse = atoi(message_str.substr(15).c_str());
          if (pulse >= 50 && pulse <= 2000) {
            id(horn_pulse_time_ms)=pulse; char buf[64]; snprintf(buf, sizeof(buf), "Horn pulse time set to %d ms", pulse);
            id(gsm).send_sms(sender_str.c_str(), buf);
          } else id(gsm).send_sms(sender_str.c_str(), "Invalid pulse time. Range: 50-2000 ms.");
          return;
        }

        // Siren runtime
        if (msg_lower.rfind("siren_runtime ", 0) == 0) {
          int runtime = atoi(message_str.substr(14).c_str());
          if (runtime > 0 && runtime <= 300) { id(siren_runtime_seconds)=runtime; std::string c="Siren runtime updated to "+std::to_string(runtime)+" seconds."; id(gsm).send_sms(sender_str.c_str(), c.c_str()); }
          else id(gsm).send_sms(sender_str.c_str(), "Invalid siren runtime. Must be between 1 and 300 seconds.");
          return;
        } 

        // IMU calibration
        if (msg_lower == "calibrate_level") {
          const int N = 200; float sax=0, say=0, saz=0, sgz=0; int n_acc=0, n_gz=0;
          for (int i = 0; i < N; i++) {
            if (id(ax).has_state() && id(ay).has_state() && id(az).has_state()) { sax+=id(ax).state; say+=id(ay).state; saz+=id(az).state; n_acc++; }
            if (id(gz_dps).has_state()) { sgz+=id(gz_dps).state; n_gz++; }
            delay(10);
          }
          if (n_acc == 0 || n_gz == 0) { id(gsm).send_sms(sender_str.c_str(), "IMU cal failed: no samples."); return; }
          const float rad2deg = 57.2957795f;
          float axm=sax/n_acc, aym=say/n_acc, azm=saz/n_acc, gzm=sgz/n_gz;
          float pitch_raw = atan2f(-axm, sqrtf(aym*aym + azm*azm)) * rad2deg;
          float roll_raw  = atan2f(aym, azm) * rad2deg;
          float pitch_now = pitch_raw - id(pitch_offset_deg);
          float roll_now  = roll_raw  - id(roll_offset_deg);
          bool already_level = (fabsf(pitch_now) <= id(level_tol_deg)) && (fabsf(roll_now) <= id(level_tol_deg));
          id(pitch_offset_deg)=pitch_raw; id(roll_offset_deg)=roll_raw; id(gyro_z_bias)=gzm;
          if (id(yaw_deg).has_state()) id(yaw_offset_deg)=id(yaw_deg).state + id(yaw_offset_deg); else id(yaw_offset_deg)=0.0f;
          char msg[128]; snprintf(msg, sizeof(msg), "IMU calibrated. PitchOff=%.2f deg, RollOff=%.2f deg, GzBias=%.3f deg/s. Level: %s",
                                  id(pitch_offset_deg), id(roll_offset_deg), id(gyro_z_bias), (already_level?"YES":"NO"));
          id(gsm).send_sms(sender_str.c_str(), msg);
          return;
        }

        // ------------------------------
        // "imu_status" - report pitch and roll (deg)
        // ------------------------------
        if (msg_lower == "imu_status") {
          if (id(pitch_deg).has_state() && id(roll_deg).has_state()) {
            char pbuf[16], rbuf[16];
            snprintf(pbuf, sizeof(pbuf), "%.2f", id(pitch_deg).state);
            snprintf(rbuf, sizeof(rbuf), "%.2f", id(roll_deg).state);

            std::string resp = "Pitch: " + std::string(pbuf) + " deg\n"
                              "Roll: "  + std::string(rbuf) + " deg";
            id(gsm).send_sms(sender_str.c_str(), resp.c_str());
          } else {
            id(gsm).send_sms(sender_str.c_str(), "Pitch/Roll not available yet.");
          }
          return;
        }

        // Auto-arm on/off
        if (msg_lower == "auto_arm_on")  { id(auto_arm_enabled)=true;  id(gsm).send_sms(sender_str.c_str(), "Auto-arm enabled.");  return; }
        if (msg_lower == "auto_arm_off") { id(auto_arm_enabled)=false; id(gsm).send_sms(sender_str.c_str(), "Auto-arm disabled."); return; }

        // Auto-arm time
        if (msg_lower.rfind("auto_arm_time ", 0) == 0) {
          int mins = atoi(msg_lower.substr(14).c_str());
          if (mins >= 1 && mins <= 1440) { id(auto_arm_delay_minutes)=mins; char b[64]; snprintf(b,sizeof(b),"Auto-arm time set to %d minute(s).", mins); id(gsm).send_sms(sender_str.c_str(), b); }
          else { id(gsm).send_sms(sender_str.c_str(), "Invalid auto_arm_time. Range: 1-1440 minutes."); }
          return;
        }

        // ------------------------------
        // Siren enable/disable (persisted)
        // ------------------------------
        if (msg_lower == "siren_on") {
          id(siren_enabled) = true;
          id(gsm).send_sms(sender_str.c_str(), "Siren enabled. Alerts + horn will sound on alarm.");
          ESP_LOGI("sms", "Siren enabled via SMS.");
          return;
        }
        if (msg_lower == "siren_off") {
          id(siren_enabled) = false;
          id(gsm).send_sms(sender_str.c_str(), "Siren disabled. Alerts will send, horn will stay silent.");
          ESP_LOGI("sms", "Siren disabled via SMS.");
          return;
        }

        // Fallback
        id(gsm).send_sms(sender_str.c_str(), "Unrecognized Command");

binary_sensor:
  - platform: gpio
    id: doors
    pin:
      number: 13
      mode: INPUT_PULLUP
      inverted: True
    name: "Doors"
    filters:
      - delayed_on: 500ms
      - delayed_off: 500ms
    device_class: door
    on_press:
      then:
        - lambda: |-
            ESP_LOGD("binary_sensor", "Doors OPENED");
            id(doors).publish_state(true);
        - lambda: |-
            if (id(alarm_armed) && !id(alarm_triggered)) {
              id(alarm_triggered) = true;
              id(alarm_siren_switch).turn_on(); 
              id(gsm).send_sms(id(phone_number_1).c_str(), "ALERT: Door opened while alarm is armed!");
              ESP_LOGD("alarm", "Alarm triggered by DOOR sensor.");
            }
        - lambda: |-
            if (id(door_alerts_enabled)) {
              id(gsm).send_sms(id(phone_number_1).c_str(), "NOTICE: Door opened.");
              ESP_LOGD("door_alert", "Door opened alert sent.");
            }
    on_release:
      then:
        - lambda: |-
            ESP_LOGD("binary_sensor", "Doors CLOSED");
            id(doors).publish_state(false);

  - platform: gpio
    id: ignition
    pin:
      number: 21
      mode: INPUT_PULLUP
      inverted: True
    name: "Ignition"
    filters:
      - delayed_on: 100ms
      - delayed_off: 100ms
    device_class: power
    on_press:
      then:
        - lambda: |-
            ESP_LOGD("binary_sensor", "Ignition TURNED ON");
            id(ignition).publish_state(true);
            id(ignition_off_timestamp) = 0;
        - lambda: |-
            if (id(alarm_armed) && !id(alarm_triggered)) {
              id(alarm_triggered) = true;
              id(alarm_siren_switch).turn_on();
              id(gsm).send_sms(id(phone_number_1).c_str(), "ALERT: Ignition turned ON while alarm is armed!");
              ESP_LOGD("alarm", "Alarm triggered by IGNITION sensor.");
            }
    on_release:
      then:
        - lambda: |-
            ESP_LOGD("binary_sensor", "Ignition TURNED OFF");
            id(ignition).publish_state(false);
            if (id(gps_time).now().is_valid()) {
              id(ignition_off_timestamp) = id(gps_time).now().timestamp;
              ESP_LOGD("auto_arm", "Ignition off timestamp recorded: %d", id(ignition_off_timestamp));
            }
        - logger.log: "Ignition OFF -> running IMU calibration"
        - lambda: |-
            id(calibrate_imu_all).execute();

switch:
  - platform: gpio
    name: "SIM800_PWKEY"
    pin: 4
    restore_mode: ALWAYS_OFF

  - platform: gpio
    name: "SIM800_RST"
    pin: 5
    restore_mode: ALWAYS_ON

  - platform: gpio
    name: "SIM800_POWER"
    pin: 23
    restore_mode: ALWAYS_ON

  - platform: gpio
    id: shutoff
    pin: 18
    restore_mode: ALWAYS_OFF
    name: "Fuel Shutoff"
    inverted: false
    on_turn_on:
      - lambda: |-
          if (id(ignition).state && !id(override_relay_on)) {
              ESP_LOGD("relay", "Fuel shutoff relay blocked due to ignition being ON.");
              id(shutoff).turn_off(); 
          }

  - platform: gpio
    id: lock_switch
    name: "Lock Switch"
    pin: GPIO32
    inverted: false
    restore_mode: ALWAYS_OFF
    on_turn_on:
      - lambda: |-
          if (id(horn_feedback_enabled)) {
            int pulse = id(horn_pulse_time_ms);
            id(horn_output).turn_on();
            delay(pulse);
            id(horn_output).turn_off();
          }
      - delay: 100ms
      - switch.turn_off: lock_switch

  - platform: gpio
    id: unlock_switch
    name: "Unlock Switch"
    pin:
      number: GPIO22
      mode: OUTPUT
      inverted: false
    restore_mode: ALWAYS_OFF
    on_turn_on:
      - lambda: |-
          if (id(horn_feedback_enabled)) {
            int pulse = id(horn_pulse_time_ms);
            id(horn_output).turn_on();
            delay(pulse);
            id(horn_output).turn_off();
            delay(pulse);
            id(horn_output).turn_on();
            delay(pulse);
            id(horn_output).turn_off();
          }
      - delay: 100ms
      - switch.turn_off: unlock_switch

  - platform: template
    name: "Alarm Siren"
    id: alarm_siren_switch
    optimistic: true
    restore_mode: ALWAYS_OFF
    turn_on_action:
      - lambda: |-
          if (!id(siren_enabled)) {
            ESP_LOGI("siren", "Siren blocked: siren_enabled=false. Keeping horn silent.");
            // Revert optimistic ON immediately so alarm_pulse won't run
            id(alarm_siren_switch).turn_off();
            return;
          }
          if (id(gps_time).now().is_valid()) {
            id(alarm_start_time) = id(gps_time).now().timestamp;
          }
    turn_off_action:
      - lambda: |-
          id(alarm_start_time) = 0;

  - platform: gpio
    pin: GPIO15
    id: status_led
    name: "Status LED"
    restore_mode: ALWAYS_OFF
    on_turn_on:
      then:
        - lambda: |-
            ESP_LOGD("status_led", "LED turned ON (alarm armed)");
    on_turn_off:
      then:
        - lambda: |-
           ESP_LOGD("status_led", "LED turned OFF (alarm disarmed)");

  - platform: template
    name: "Find Horn"
    id: find_switch
    restore_mode: ALWAYS_OFF
    optimistic: true

  - platform: template
    name: "Alarm Control"
    id: alarm_web_switch
    restore_mode: ALWAYS_OFF
    lambda: |-
      return id(alarm_armed);
    turn_on_action:
      - logger.log: "Web UI -> ARM"
      - lambda: |-
          id(set_alarm_state).execute(true, false) ;
    turn_off_action:
      - logger.log: "Web UI -> DISARM"
      - lambda: |-
          id(set_alarm_state).execute(false, false);

script:
  - id: do_lock
    parameters:
      silent: bool
    mode: restart
    then:
      - if:
          condition:
            lambda: 'return silent || !id(horn_feedback_enabled);'
          then:
            - switch.turn_on: lock_switch
          else:
            - lambda: |-
                id(lock_switch).turn_on();

  - id: do_unlock
    parameters:
      silent: bool
    mode: restart
    then:
      - if:
          condition:
            lambda: 'return silent || !id(horn_feedback_enabled);'
          then:
            - switch.turn_on: unlock_switch
          else:
            - lambda: |-
                id(unlock_switch).turn_on();

  - id: do_arm
    parameters:
      silent: bool
    mode: restart
    then:
      - lambda: |-
          static uint32_t last = 0;
          uint32_t now = millis();
          if (now - last < 1000) { ESP_LOGW("arm","Throttled arming."); return; }
          last = now;
          if (id(ignition).state) { ESP_LOGW("arm","Ignition is ON; arming anyway."); }
          id(alarm_armed) = true;
          id(alarm_triggered) = false;
      - switch.turn_on: shutoff
      - logger.log: "Arming -> running IMU calibration..."
      - lambda: |-
          id(calibrate_imu_all).execute();
      - lambda: |-
          id(do_lock).execute(silent);

  - id: do_disarm
    parameters:
      silent: bool
    mode: restart
    then:
      - lambda: |-
          static uint32_t last = 0;
          uint32_t now = millis();
          if (now - last < 1000) { ESP_LOGW("arm","Throttled disarming."); return; }
          last = now;
          id(alarm_armed) = false;
          id(alarm_triggered) = false;
      - switch.turn_off: alarm_siren_switch
      - switch.turn_off: shutoff
      - lambda: |-
          id(do_unlock).execute(silent);

  - id: set_alarm_state
    parameters:
      armed: bool
      silent: bool
    mode: restart
    then:
      - if:
          condition:
            lambda: 'return armed;'
          then:
            - lambda: |-
                id(do_arm).execute(silent);
          else:
            - lambda: |-
                id(do_disarm).execute(silent);

  # IMU calibration
  - id: calibrate_imu_all
    mode: restart
    then:
      - lambda: |-
          ESP_LOGI("imu", "Starting IMU calibration. Keep the vehicle perfectly still...");

          const int N = 200;
          float sax=0, say=0, saz=0, sgz=0;
          int n_acc=0, n_gz=0;

          for (int i = 0; i < N; i++) {
            if (id(ax).has_state() && id(ay).has_state() && id(az).has_state()) {
              sax += id(ax).state; say += id(ay).state; saz += id(az).state; n_acc++;
            }
            if (id(gz_dps).has_state()) { sgz += id(gz_dps).state; n_gz++; }
            delay(10);
          }

          if (n_acc == 0 || n_gz == 0) {
            ESP_LOGW("imu", "Calibration failed: missing IMU samples (acc:%d, gz:%d).", n_acc, n_gz);
            return;
          }

          float axm = sax / n_acc, aym = say / n_acc, azm = saz / n_acc;
          float gzm = sgz / n_gz;

          const float rad2deg = 57.2957795f;

          float pitch_raw = atan2f(-axm, sqrtf(aym*aym + azm*azm)) * rad2deg;
          float roll_raw  = atan2f(aym, azm) * rad2deg;

          float pitch_now = pitch_raw - id(pitch_offset_deg);
          float roll_now  = roll_raw  - id(roll_offset_deg);
          bool already_level = (fabsf(pitch_now) <= id(level_tol_deg)) && (fabsf(roll_now) <= id(level_tol_deg));

          id(pitch_offset_deg) = pitch_raw;
          id(roll_offset_deg)  = roll_raw;

          id(gyro_z_bias) = gzm;

          if (id(yaw_deg).has_state()) {
            id(yaw_offset_deg) = id(yaw_deg).state + id(yaw_offset_deg); // align to 0
          } else {
            id(yaw_offset_deg) = 0.0f;
          }

          ESP_LOGI("imu", "Calibration done. pitch_off=%.2f deg, roll_off=%.2f deg, gz_bias=%.3f deg/s. Already level: %s",
                   id(pitch_offset_deg), id(roll_offset_deg), id(gyro_z_bias),
                   already_level ? "YES" : "NO");

lock:
  - platform: template
    name: "Car Lock"
    id: car_lock
    optimistic: false
    lambda: |-
      if (id(alarm_armed)) {
        return esphome::lock::LOCK_STATE_LOCKED;
      } else {
        return esphome::lock::LOCK_STATE_UNLOCKED;
      }

    lock_action:
      - logger.log: "Locking via car_lock"
      - lambda: |-
          id(set_alarm_state).execute(true, false);

    unlock_action:
      - logger.log: "Unlocking via car_lock"
      - lambda: |-
          id(set_alarm_state).execute(false, false);

number:
  - platform: template
    name: "Tilt Alarm Threshold (deg)"
    id: tilt_threshold_number
    optimistic: true
    min_value: 1.0
    max_value: 45.0
    step: 0.5
    initial_value: 3.0
    set_action:
      - lambda: |-
          id(tilt_alarm_threshold_deg) = x;
          ESP_LOGI("tilt", "Tilt threshold set to %.1f deg", id(tilt_alarm_threshold_deg));  

button:
  - platform: restart
    name: "Restart"
    id: reboot_trigger

  - platform: template
    name: "Send Test SMS"
    on_press:
      then:
        - lambda: |-
            id(gsm)->send_sms("+61234567890", "Test SMS from ESPHome!");

  - platform: template
    id: imu_cal_button
    name: "Calibrate IMU"
    on_press:
      then:
        - lambda: |-
            id(calibrate_imu_all).execute();

output:
  - platform: gpio
    pin: GPIO19
    id: horn_output
    inverted: false

gps:
  uart_id: gpsmodule
  id: gps_sensor
  update_interval: 60s
  latitude:
    name: "GPS Latitude"
    id: latitude
  longitude:
    name: "GPS Longitude"
    id: longitude
  speed:
    name: "GPS Speed"
    id: speed
  satellites:
    name: "GPS Satellites"
    id: gps_satellites

time:
  - platform: gps
    id: gps_time

Commands

The following commands will be accepted, and replies will be sent accordingly.

relay_on
relay_off
lock
unlock
where
status
arm
disarm
ap_on
ap_off
time
reboot
help
find
battery_voltage
notifications_on
notifications_off
horn_feedback_on
horn_feedback_off
horn_pulsetime 100ms (in milliseconds)
door_alerts_on
door_alerts_off
auto_arm_on
auto_arm_off
auto_arm_time 10 (in minutes)
phone_number_1
phone_number_2
phone_number_3
new_number_1
new_number_2
new_number_3
remove_number_1
remove_number_2
remove_number_3
schedule_1
schedule_2
schedule_3
new_schedule_1
new_schedule_2
new_schedule_3
remove_schedule_1
remove_schedule_2
remove_schedule_3
watchdog_on
watchdog_off
watchdog_days 3
siren_runtime 30
siren_on
siren_off
deep_sleep_enable
deep_sleep_disable
sleep_time 23:00
wake_time 07:00
calibrate_level
imu_status

The following messages will be sent. The replies for the relay activation will be sent only when the state of the relay is set correctly. The unit will send a conformation message on boot when powered up or rebooted only when an active GPS lock and GPS time is acquired.

Example commands are shown in bold below.

(reboot)
Device booted successfully at (GPS time) 2025-01-01 22:00:00 UTC

(arm)
Alarm armed. Notifications enabled, fuel shutoff is ON and doors are locked.

(disarm) - Defaults to DISARMED when rebooted.
Alarm disarmed. Notifications disabled, fuel shutoff is OFF and doors are unlocked.

(relay_on, relay_off) - Defaults to OFF when rebooted.
Fuel Shutoff Relay has been turned on.
Fuel Shutoff Relay has been turned off.

(status)
GPS Fix: True
GPS Time: 2025-02-03 20:00:00
Satellites: 12
Uptime: 8d 1h 32m
Ignition: OFF/ON
Doors: Closed/Open
Relay: OFF
Alarm State: Armed
WiFi AP: Enabled
Horn Feedback: ON
Door Alerts: ON
Notifications: ON
SIM800L RSSI: -77dBm
SIM800L Signal: 58%
Horn Pulsetime: 250ms
Siren Runtime: 30 sec
Auto-arm: ON/OFF
Auto-arm Delay: 10 min
Voltage Alarm: 12.00v
Battery Voltage: 12.34v
Current Draw: -0.07A
Watchdog: ON
Watchdog Interval: 1 day(s)
Deep Sleep (UTC):
Deep Sleep: ENABLED
Sleep: 23:00
Wake: 09:00

(where)
Device Location:
Lat: xxx.xxxxxx
Long: xxx.xxxxxxx
Google Maps: Google maps link will be displayed here with co-ordinates
Ignition: On/Off
Doors: Closed/Open
Speed: 5 kkm/h

(phone_number_x)
Phone Number 1: +1234567890
Phone Number 2: +2212345678
Phone Number 3: +2412345567

(new_number_x +1234567890)
Phone number updated to: +1234567890

(remove_number_x)
Phone Number 2 removed

(lock, unlock)
Doors Locked.
Doors Unlocked.

(schedule_x)
schedule_1:
Days: mon,tues,wed,thurs,fri,sat,sun
Arm: 23:00
Disarm: 07:00

(remove_schedule_x)
Schedule x deleted successfully.

(new_schedule_x mon,tues,wed,thurs,fri,sat,sun arm 22:00:00 disarm 23:00:00)
Schedule added successfully.

(horn_feedback_on, horn_feedback_off)
Horn Feedback Enabled
Horn Feedback Disabled

(horn_pulsetime 250ms)
Horn pulse time set to 250ms.

(notifications_on, notifications_off)
Notifications have been enabled
Notifications have been disabled

(door_alerts_on, door_alerts_off)
Door alerts enabled.
Door alerts disabled.

(time)
GPS Time: 2025-02-03 22:00:00

(battery)
Battery Voltage: 12.41v
Current Draw: -0.07A

(battery_voltage 12.00)
Battery alert voltage set to 12.00V

(find)
Find command activated. Horn will sound 3 times.

(watchdog_on, watchdog_off)
Watchdog enabled.
Watchdog disabled.

(auto_arm_on, auto_arm_off)
Auto-arm Enabled.
Auto-arm Disabled.

(auto_arm_time 10)
Auto-arm time set to 10 minute(s).

(watchdog_days x)
Watchdog interval set to 1 day(s).

(siren_runtime 30)
Siren runtime updated to 30 seconds.

(deep_sleep_enabled, deep_sleep_disabled)
Deep Sleep ENABLED. Schedule: 23:00 -> 07:00 (UTC)
Deep Sleep DISABLED.

(calibrae_level)
IMU Calibrated. PitchOff=4.03@@,RollOff=-2.23@@,GzBias=0.610@@/s. Level: YES

(imu_status)
Pitch: 0.00 deg
Roll: 0.00 deg

Car speed alarm activated. Car is travelling more than 10km/h.

ALERT: Tilt 8.7@@ (Pitch 8.7@@, Roll 0.0@@)

Unauthorized

Unrecognized Command

This will look like the following.

1 2

Leave a Reply