#:########################################################################################:#
# TITLE: ASTRONOMICAL CLOCK STEPPER
# zorruno.com layout v1.1 2026
#:########################################################################################:#
# REPO:
# https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-astroclockstepper.yaml
#:########################################################################################:#
# VERSIONS:
# V1.7 2026-06-22 Rebuilt direction model so clockwise always equals forward and position count increase
# V1.6 2026-06-22 Fixed auto-position cycle calculation to use rotation_days / solar-year cycle
# V1.5 2026-06-22 Changed Motor Stop to OFF on boot and added shortest-path auto-positioning
# V1.4 2026-06-22 Added Motor Stop switch, swapped reverse buttons 3/4, and separated auto-position motor direction
# V1.3 2026-06-22 Added SNTP year auto-positioning button and restored boot auto-position switch
# V1.2 2026-06-22 Added DHCP IPv4 address flash sequence on onboard LED after WiFi connects
# V1.1 2026-06-22 Added onboard D1 Mini LED flash on each motor step
# V1.0 2026-06-22 Initial Version
#:########################################################################################:#
# HARDWARE:
# ESP8266 D1 Mini Clone
# 5V 28BYJ-48 Stepper Motor
# Keyestudio / ULN2003 style 5V stepper driver board
#
# Stepper Driver:
# - IN1: GPIO14 / D5
# - IN2: GPIO12 / D6
# - IN3: GPIO13 / D7
# - IN4: GPIO15 / D8
# - VCC: 5V motor supply
# - GND: Common GND with ESP8266
#
# Physical Buttons:
# - Fast Reverse / Anti-clockwise: GPIO5  / D1 / Button 1
# - Slow Reverse / Anti-clockwise: GPIO4  / D2 / Button 2
# - Slow Forward / Clockwise:     GPIO0  / D3 / Button 3
# - Fast Forward / Clockwise:     GPIO16 / D0 / Button 4
#
# Button board:
# - Uses built-in 103 pull-up resistors, 10k ohm
# - Buttons should pull the GPIO pin to GND when pressed
# - Pull-ups must go to 3.3V, not 5V
# - Fast modes are on the two end buttons
#
# Onboard Step LED:
# - GPIO2 / D4
# - Active-low on most D1 Mini boards
#:########################################################################################:#
# OPERATION NOTES:
# - Normal rotation starts automatically after boot because Motor Stop defaults OFF.
# - Motor Stop is OFF by default after every reboot and is not restored.
# - Motor Stop ON turns off all motor coils and pauses physical position counting.
# - Default rotation is one full revolution clockwise every 365.2422 days.
# - Default step count is 4096 steps per full revolution.
# - Normal movement is extremely slow, about one step every 2.14 hours.
# - Clockwise physical movement is forward.
# - Forward movement increases Position Step and Position Days.
# - Anti-clockwise physical movement is reverse.
# - Reverse movement decreases Position Step and Position Days.
# - Motor coils are powered only while stepping, then released.
# - No holding torque is used.
# - Web template switches mimic held physical buttons.
# - Manual movement only applies while a physical button or web switch is held/on.
# - Position counter wraps back to zero after one full rotation.
# - Zero Position Counter sets the current physical pointer location as Jan-zero.
# - Zero Position Counter does not reset the stepper coil phase.
# - Onboard D1 Mini LED flashes briefly every time a motor step is issued.
# - After WiFi connects, the onboard LED flashes the DHCP IPv4 address.
# - IPv4 flash sequence is decimal digit based.
# - A digit of 0 is represented as 10 flashes.
# - SNTP is used for current year-position calculation.
# - Auto Position Now calculates the target using the configured 365.2422-day rotation cycle.
# - Auto Position Now moves to the current calculated year position by the shortest path.
# - Auto Position can move clockwise or anti-clockwise, whichever is quicker.
# - Auto Position On Boot is off by default, restored on power loss, and runs after boot/IP flash if Motor Stop is OFF.
#:########################################################################################:#
# OFFLINE NOTES:
# a) Home Assistant OFFLINE, but Network ONLINE
#    - Device remains controllable via local web portal
#    - HA entities unavailable until HA returns
# b) WiFi/Network OFFLINE
#    - No HA API or web control available
#    - Physical buttons continue to work if Motor Stop is OFF
#    - Fallback AP / captive portal should become available from network_common_dhcp.yaml
# c) SNTP NOT SYNCED
#    - Normal motor timing continues if Motor Stop is OFF
#    - Manual controls continue if Motor Stop is OFF
#    - Auto Position Now will not run until SNTP time is valid
# d) Device power loss / reboot
#    - Current position counter is restored from flash
#    - Auto Position On Boot setting is restored from flash
#    - Motor Stop returns to OFF after every reboot
#    - Physical position is assumed valid because the motor housing/dial is fixed
#:########################################################################################:#


#:########################################################################################:#
# SUBSTITUTIONS: Specific device variable substitutions
# If NOT using a secrets file, just replace these with the passwords etc (in quotes)
#:########################################################################################:#
substitutions:
  # Device Naming
  device_name: "esp-astroclockstepper1"
  friendly_name: "Astronomical Clock Stepper"
  description_comment: "Astronomical Clock Stepper :: ESP8266 D1 Mini with 28BYJ-48 Stepper Motor (Layout V1.1)"
  device_area: "Standalone"

  # Project Naming
  project_name: "zorruno.Astronomical Clock Stepper"
  project_version: "v1.7"

  # Passwords & Secrets
  api_key: !secret esp-api_key
  ota_pass: !secret esp-ota_pass

  # Device Settings
  log_level: "INFO"
  sensor_update_interval: "1s"
  calculated_sensor_update_interval: "60s"

  # Time Settings
  timezone_name: "Pacific/Auckland"

  # Astronomical Clock Settings
  rotation_days: "365.2422"
  steps_per_rotation: "4096"

  # Physical Direction Calibration
  # This is the motor phase direction that makes the needle move clockwise.
  # Your testing showed the old reverse direction moved clockwise, so default is -1.
  # If clockwise/anti-clockwise is still backwards, change this to 1.
  clockwise_motor_direction_multiplier: "-1"

  # Manual Movement Speeds
  manual_fast_rotation_seconds: "30"
  manual_slow_rotation_seconds: "300"

  # Auto Position Path
  # true = move clockwise or anti-clockwise, whichever is quicker
  # false = always move clockwise / forward through the year
  auto_position_use_shortest_path: "true"

  # Auto Position Speed
  # Default is the same as Slow Forward: one full rotation every 300 seconds.
  auto_position_rotation_seconds: "300"

  # Coil Release
  coil_release_ms: "500"

  # Step LED
  onboard_step_led_pin: GPIO2
  step_led_flash_ms: "25"

  # DHCP IP Address LED Flash Settings
  ip_flash_enabled: "true"
  ip_flash_repeat_count: "5"
  ip_flash_zero_digit_blinks: "10"
  ip_flash_loop_interval: "25ms"

  # Suggested human-countable timing
  ip_flash_on_ms: "200"
  ip_flash_between_flash_gap_ms: "450"
  ip_flash_digit_gap_ms: "1500"
  ip_flash_octet_gap_ms: "3500"
  ip_flash_repeat_gap_ms: "8000"

  # Stepper Output Pins
  stepper_in1_pin: GPIO14
  stepper_in2_pin: GPIO12
  stepper_in3_pin: GPIO13
  stepper_in4_pin: GPIO15

  # Physical Button Input Pins
  # Button 1 and 2 physically move anti-clockwise, so they are reverse.
  # Button 3 and 4 physically move clockwise, so they are forward.
  button_fast_reverse_pin: GPIO5
  button_slow_reverse_pin: GPIO4
  button_slow_forward_pin: GPIO0
  button_fast_forward_pin: GPIO16

  # Entity Naming
  motor_stop_name: "Motor Stop"

  web_fast_forward_name: "Web Fast Forward"
  web_slow_forward_name: "Web Slow Forward"
  web_fast_reverse_name: "Web Fast Reverse"
  web_slow_reverse_name: "Web Slow Reverse"

  physical_fast_forward_name: "Physical Fast Forward"
  physical_slow_forward_name: "Physical Slow Forward"
  physical_fast_reverse_name: "Physical Fast Reverse"
  physical_slow_reverse_name: "Physical Slow Reverse"

  zero_position_name: "Zero Position Counter"
  replay_ip_flash_name: "Replay IP Address Flash"

  auto_position_now_name: "Auto Position Now"
  auto_position_on_boot_name: "Auto Position On Boot"
  auto_position_active_name: "Auto Position Active"
  sntp_time_valid_name: "SNTP Time Valid"


#:########################################################################################:#
# PACKAGES: Included Common Packages
# https://esphome.io/components/packages.html
#:########################################################################################:#
packages:
  #### WIFI, Network (DHCP/IPV6 etc), Fallback AP, Safemode ####
  common_wifi: !include
    file: common/network_common_dhcp.yaml
    vars:
      local_device_name: "${device_name}"
      local_ota_pass: "${ota_pass}"

  #### HOME ASSISTANT API (choose encryption or no encryption options) ####
  common_api: !include
    file: common/api_common.yaml
    #file: common/api_common_noencryption.yaml
    vars:
      local_api_key: "${api_key}"

  #### MQTT ####
  #common_mqtt: !include
  #  file: common/mqtt_common.yaml
  #  vars:
  #    local_device_name: "${device_name}"

  #### WEB PORTAL ####
  #common_webportal: !include common/webportal_common.yaml
  common_webportal: !include common/webportal_common_nopasswd.yaml

  #### SNTP (Only use if you want/need accurate timeclocks) ####
  #common_sntp:      !include common/sntp_common.yaml

  #### DIAGNOSTICS Sensors ####
  diag_basic: !include common/include_basic_diag_sensors.yaml
  diag_more: !include common/include_more_diag_sensors_nomqtt.yaml
  #diag_debug: !include common/include_debug_diag_sensors.yaml
  #diag_resetcount: !include common/include_resetcount_diag_sensors.yaml


#:########################################################################################:#
# ESPHOME:
# https://esphome.io/components/esphome.html
#:########################################################################################:#
esphome:
  name: "${device_name}"
  friendly_name: "${friendly_name}"
  comment: "${description_comment}"
  area: "${device_area}"
  project:
    name: "${project_name}"
    version: "${project_version}"

  on_boot:
    priority: -100
    then:
      - output.turn_off: onboard_step_led

      # Motor Stop should always be OFF on boot so normal movement can run.
      - switch.turn_off: motor_stop_switch

      - logger.log:
          level: INFO
          format: "Astronomical Clock Stepper started. Normal rotation is %s days per full revolution."
          args:
            - '"${rotation_days}"'

      # Wait for WiFi/DHCP, then flash the assigned IPv4 address.
      # This does not stop the main stepper scheduler from running.
      - wait_until:
          condition:
            wifi.connected:

      - delay: 3s

      - script.execute: start_ip_flash_script

      # Give the IP flash state machine time to start.
      - delay: 100ms

      # Wait for the boot IP flash sequence to finish before optional auto-positioning.
      - wait_until:
          condition:
            lambda: |-
              return !id(ip_flash_active);

      - if:
          condition:
            switch.is_on: auto_position_on_boot_switch
          then:
            - globals.set:
                id: auto_position_on_boot_pending
                value: "true"

            - if:
                condition:
                  lambda: |-
                    return id(sntp_time).now().is_valid();
                then:
                  - globals.set:
                      id: auto_position_on_boot_pending
                      value: "false"
                  - script.execute: start_auto_position_script


#:########################################################################################:#
# ESP PLATFORM AND FRAMEWORK:
# https://esphome.io/components/esp8266.html
#:########################################################################################:#
esp8266:
  board: d1_mini
  restore_from_flash: true

preferences:
  flash_write_interval: 1min

mdns:
  disabled: false


#:########################################################################################:#
# LOGGING:
# https://esphome.io/components/logger.html
#:########################################################################################:#
logger:
  level: "${log_level}"
  baud_rate: 0


#:########################################################################################:#
# TIME:
# SNTP time source for year-position calculation
# https://esphome.io/components/time/sntp.html
#:########################################################################################:#
time:
  - platform: sntp
    id: sntp_time
    timezone: "${timezone_name}"
    on_time_sync:
      then:
        - if:
            condition:
              lambda: |-
                return id(auto_position_on_boot_pending) && id(auto_position_on_boot_switch).state;
            then:
              - globals.set:
                  id: auto_position_on_boot_pending
                  value: "false"
              - script.execute: start_auto_position_script


#:########################################################################################:#
# GLOBALS:
# Internal position, timing, mode, held-button state, IP flash state, and auto-position state
# https://esphome.io/components/globals.html
#:########################################################################################:#
globals:
  - id: current_position_step
    type: int
    restore_value: yes
    initial_value: "0"

  # Electrical stepper phase.
  # This should not be reset when setting the calendar zero point.
  - id: current_phase_index
    type: int
    restore_value: yes
    initial_value: "0"

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

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

  - id: step_accumulator_ms
    type: double
    restore_value: no
    initial_value: "0.0"

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

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

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

  # 0 = Normal
  # 1 = Fast Forward
  # 2 = Slow Forward
  # 3 = Fast Reverse
  # 4 = Slow Reverse
  # 5 = Auto Position
  # 6 = Motor Stopped
  - id: current_motion_mode
    type: int
    restore_value: no
    initial_value: "0"

  # Physical Button States
  - id: physical_fast_forward_active
    type: bool
    restore_value: no
    initial_value: "false"

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

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

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

  # Web Button States
  - id: web_fast_forward_active
    type: bool
    restore_value: no
    initial_value: "false"

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

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

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

  # IP Address Flash State
  - id: ip_flash_active
    type: bool
    restore_value: no
    initial_value: "false"

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

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

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

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

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

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

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

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

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

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

  # Auto Position State
  - id: auto_position_active
    type: bool
    restore_value: no
    initial_value: "false"

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

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

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

  # 1 = clockwise / forward / increasing position
  # -1 = anti-clockwise / reverse / decreasing position
  - id: auto_position_logical_direction
    type: int
    restore_value: no
    initial_value: "1"

  - id: auto_position_cycle_seconds
    type: double
    restore_value: no
    initial_value: "0.0"

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

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


#:########################################################################################:#
# OUTPUT:
# Stepper driver GPIO outputs and onboard step LED
# https://esphome.io/components/output/gpio.html
#:########################################################################################:#
output:
  - platform: gpio
    id: stepper_in1
    pin: ${stepper_in1_pin}

  - platform: gpio
    id: stepper_in2
    pin: ${stepper_in2_pin}

  - platform: gpio
    id: stepper_in3
    pin: ${stepper_in3_pin}

  - platform: gpio
    id: stepper_in4
    pin: ${stepper_in4_pin}

  # D1 Mini onboard LED GPIO2 / D4 and active-low
  - platform: gpio
    id: onboard_step_led
    pin:
      number: ${onboard_step_led_pin}
      inverted: true


#:########################################################################################:#
# SCRIPT:
# Utility scripts
# https://esphome.io/components/script.html
#:########################################################################################:#
script:
  - id: start_ip_flash_script
    mode: restart
    then:
      - lambda: |-
          const bool IP_FLASH_ENABLED = ${ip_flash_enabled};

          if (!IP_FLASH_ENABLED) {
            ESP_LOGI("ip_flash", "IP flash is disabled.");
            return;
          }

          if (WiFi.status() != WL_CONNECTED) {
            ESP_LOGW("ip_flash", "WiFi is not connected. Cannot flash IP address.");
            return;
          }

          IPAddress ip = WiFi.localIP();

          if (ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0) {
            ESP_LOGW("ip_flash", "Invalid IPv4 address 0.0.0.0. Cannot flash IP address.");
            return;
          }

          id(ip_flash_octet_0) = ip[0];
          id(ip_flash_octet_1) = ip[1];
          id(ip_flash_octet_2) = ip[2];
          id(ip_flash_octet_3) = ip[3];

          id(ip_flash_repeats_completed) = 0;
          id(ip_flash_octet_index) = 0;
          id(ip_flash_digit_index) = 0;
          id(ip_flash_blinks_completed) = 0;
          id(ip_flash_led_is_on) = false;
          id(ip_flash_next_action_ms) = millis();
          id(ip_flash_active) = true;

          // Keep the IP flash sequence visually clean.
          id(step_led_is_on) = false;
          id(onboard_step_led).turn_off();

          ESP_LOGI(
            "ip_flash",
            "Flashing IPv4 address %u.%u.%u.%u on onboard LED. Repeats: %d",
            ip[0],
            ip[1],
            ip[2],
            ip[3],
            ${ip_flash_repeat_count}
          );

  - id: start_auto_position_script
    mode: restart
    then:
      - lambda: |-
          const int STEPS_PER_ROTATION = ${steps_per_rotation};
          const bool AUTO_POSITION_USE_SHORTEST_PATH = ${auto_position_use_shortest_path};

          const double ROTATION_SECONDS =
            ${rotation_days} * 24.0 * 60.0 * 60.0;

          auto now = id(sntp_time).now();

          if (!now.is_valid()) {
            ESP_LOGW("auto_position", "SNTP time is not valid yet. Auto position cancelled.");
            return;
          }

          if (id(motor_stop_switch).state) {
            ESP_LOGW("auto_position", "Motor Stop is ON. Auto position cancelled.");
            return;
          }

          const int year = now.year;
          const int month = now.month;
          const int day_of_month = now.day_of_month;

          const bool is_leap_year =
            ((year % 4 == 0) && (year % 100 != 0)) ||
            (year % 400 == 0);

          const int days_before_month_common[12] = {
            0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334
          };

          const int days_before_month_leap[12] = {
            0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335
          };

          int safe_month = month;

          if (safe_month < 1) {
            safe_month = 1;
          }

          if (safe_month > 12) {
            safe_month = 12;
          }

          int safe_day_of_month = day_of_month;

          if (safe_day_of_month < 1) {
            safe_day_of_month = 1;
          }

          if (safe_day_of_month > 31) {
            safe_day_of_month = 31;
          }

          const int day_of_year =
            (is_leap_year ? days_before_month_leap[safe_month - 1] : days_before_month_common[safe_month - 1]) +
            safe_day_of_month;

          const uint32_t seconds_elapsed_this_calendar_year =
            ((uint32_t) (day_of_year - 1) * 86400UL) +
            ((uint32_t) now.hour * 3600UL) +
            ((uint32_t) now.minute * 60UL) +
            ((uint32_t) now.second);

          // The display zero mark is Jan 1 local midnight.
          // The dial cycle is the configured solar-year length, not 365 or 366 days.
          double cycle_seconds = (double) seconds_elapsed_this_calendar_year;

          while (cycle_seconds >= ROTATION_SECONDS) {
            cycle_seconds -= ROTATION_SECONDS;
          }

          while (cycle_seconds < 0.0) {
            cycle_seconds += ROTATION_SECONDS;
          }

          int target_step =
            (int) ((cycle_seconds / ROTATION_SECONDS) * (double) STEPS_PER_ROTATION);

          target_step = target_step % STEPS_PER_ROTATION;

          int current_step = id(current_position_step) % STEPS_PER_ROTATION;

          if (current_step < 0) {
            current_step += STEPS_PER_ROTATION;
          }

          const int forward_steps =
            (target_step - current_step + STEPS_PER_ROTATION) % STEPS_PER_ROTATION;

          const int reverse_steps =
            (current_step - target_step + STEPS_PER_ROTATION) % STEPS_PER_ROTATION;

          int chosen_steps = forward_steps;
          int chosen_logical_direction = 1;

          if (AUTO_POSITION_USE_SHORTEST_PATH && reverse_steps < forward_steps) {
            chosen_steps = reverse_steps;
            chosen_logical_direction = -1;
          }

          id(auto_position_target_step) = target_step;
          id(auto_position_steps_remaining) = chosen_steps;
          id(auto_position_logical_direction) = chosen_logical_direction;
          id(auto_position_cycle_seconds) = cycle_seconds;
          id(auto_position_forward_steps) = forward_steps;
          id(auto_position_reverse_steps) = reverse_steps;

          if (chosen_steps == 0) {
            id(auto_position_active) = false;
            ESP_LOGI(
              "auto_position",
              "Already at correct solar-year position. Year: %d, target step: %d.",
              year,
              target_step
            );
            return;
          }

          id(auto_position_active) = true;

          ESP_LOGI(
            "auto_position",
            "Auto positioning started. Year: %d, day_of_year: %d, cycle_seconds: %.2f / %.2f, target step: %d, current step: %d, forward steps: %d, reverse steps: %d, chosen direction: %s, moving %d steps.",
            year,
            day_of_year,
            cycle_seconds,
            ROTATION_SECONDS,
            target_step,
            current_step,
            forward_steps,
            reverse_steps,
            chosen_logical_direction >= 0 ? "clockwise/forward" : "anti-clockwise/reverse",
            chosen_steps
          );


#:########################################################################################:#
# SWITCH:
# Motor stop, web held-button controls, and restored auto-position boot option
# https://esphome.io/components/switch/template.html
#:########################################################################################:#
switch:
  - platform: template
    name: "${web_fast_forward_name}"
    id: web_fast_forward_switch
    icon: mdi:fast-forward
    optimistic: true
    restore_mode: ALWAYS_OFF
    turn_on_action:
      - switch.turn_off: web_slow_forward_switch
      - switch.turn_off: web_fast_reverse_switch
      - switch.turn_off: web_slow_reverse_switch
      - globals.set:
          id: web_fast_forward_active
          value: "true"
    turn_off_action:
      - globals.set:
          id: web_fast_forward_active
          value: "false"

  - platform: template
    name: "${web_slow_forward_name}"
    id: web_slow_forward_switch
    icon: mdi:play-speed
    optimistic: true
    restore_mode: ALWAYS_OFF
    turn_on_action:
      - switch.turn_off: web_fast_forward_switch
      - switch.turn_off: web_fast_reverse_switch
      - switch.turn_off: web_slow_reverse_switch
      - globals.set:
          id: web_slow_forward_active
          value: "true"
    turn_off_action:
      - globals.set:
          id: web_slow_forward_active
          value: "false"

  - platform: template
    name: "${web_fast_reverse_name}"
    id: web_fast_reverse_switch
    icon: mdi:rewind
    optimistic: true
    restore_mode: ALWAYS_OFF
    turn_on_action:
      - switch.turn_off: web_fast_forward_switch
      - switch.turn_off: web_slow_forward_switch
      - switch.turn_off: web_slow_reverse_switch
      - globals.set:
          id: web_fast_reverse_active
          value: "true"
    turn_off_action:
      - globals.set:
          id: web_fast_reverse_active
          value: "false"

  - platform: template
    name: "${web_slow_reverse_name}"
    id: web_slow_reverse_switch
    icon: mdi:rewind-outline
    optimistic: true
    restore_mode: ALWAYS_OFF
    turn_on_action:
      - switch.turn_off: web_fast_forward_switch
      - switch.turn_off: web_slow_forward_switch
      - switch.turn_off: web_fast_reverse_switch
      - globals.set:
          id: web_slow_reverse_active
          value: "true"
    turn_off_action:
      - globals.set:
          id: web_slow_reverse_active
          value: "false"

  - platform: template
    name: "${auto_position_on_boot_name}"
    id: auto_position_on_boot_switch
    icon: mdi:calendar-sync
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF

  - platform: template
    name: "${motor_stop_name}"
    id: motor_stop_switch
    icon: mdi:motor-off
    optimistic: true

    # OFF by default after every boot. State is not restored.
    # OFF means the motor is allowed to run.
    # ON means motor outputs are disabled.
    restore_mode: ALWAYS_OFF

    turn_on_action:
      - switch.turn_off: web_fast_forward_switch
      - switch.turn_off: web_slow_forward_switch
      - switch.turn_off: web_fast_reverse_switch
      - switch.turn_off: web_slow_reverse_switch
      - lambda: |-
          id(web_fast_forward_active) = false;
          id(web_slow_forward_active) = false;
          id(web_fast_reverse_active) = false;
          id(web_slow_reverse_active) = false;

          id(stepper_in1).turn_off();
          id(stepper_in2).turn_off();
          id(stepper_in3).turn_off();
          id(stepper_in4).turn_off();
          id(coils_are_on) = false;

          if (id(auto_position_active)) {
            id(auto_position_active) = false;
            id(auto_position_steps_remaining) = 0;
            ESP_LOGW("motor_stop", "Motor Stop enabled. Auto positioning cancelled.");
          }

          id(step_accumulator_ms) = 0.0;
          id(current_motion_mode) = 6;

    turn_off_action:
      - lambda: |-
          id(step_accumulator_ms) = 0.0;
          id(current_motion_mode) = 0;
          ESP_LOGI("motor_stop", "Motor Stop disabled. Motor outputs enabled.");


#:########################################################################################:#
# BINARY SENSORS:
# Physical button inputs using external 103 / 10k pull-ups
# https://esphome.io/components/binary_sensor/gpio.html
#:########################################################################################:#
binary_sensor:
  - platform: gpio
    name: "${physical_fast_reverse_name}"
    id: physical_fast_reverse_button
    pin:
      number: ${button_fast_reverse_pin}
      mode: INPUT
      inverted: true
    filters:
      - delayed_on: 20ms
      - delayed_off: 20ms
    on_press:
      - globals.set:
          id: physical_fast_reverse_active
          value: "true"
    on_release:
      - globals.set:
          id: physical_fast_reverse_active
          value: "false"

  - platform: gpio
    name: "${physical_slow_reverse_name}"
    id: physical_slow_reverse_button
    pin:
      number: ${button_slow_reverse_pin}
      mode: INPUT
      inverted: true
    filters:
      - delayed_on: 20ms
      - delayed_off: 20ms
    on_press:
      - globals.set:
          id: physical_slow_reverse_active
          value: "true"
    on_release:
      - globals.set:
          id: physical_slow_reverse_active
          value: "false"

  - platform: gpio
    name: "${physical_slow_forward_name}"
    id: physical_slow_forward_button
    pin:
      number: ${button_slow_forward_pin}
      mode: INPUT
      inverted: true
    filters:
      - delayed_on: 20ms
      - delayed_off: 20ms
    on_press:
      - globals.set:
          id: physical_slow_forward_active
          value: "true"
    on_release:
      - globals.set:
          id: physical_slow_forward_active
          value: "false"

  - platform: gpio
    name: "${physical_fast_forward_name}"
    id: physical_fast_forward_button
    pin:
      number: ${button_fast_forward_pin}
      mode: INPUT
      inverted: true
    filters:
      - delayed_on: 20ms
      - delayed_off: 20ms
    on_press:
      - globals.set:
          id: physical_fast_forward_active
          value: "true"
    on_release:
      - globals.set:
          id: physical_fast_forward_active
          value: "false"

  - platform: template
    name: "${auto_position_active_name}"
    id: auto_position_active_sensor
    icon: mdi:calendar-sync
    lambda: |-
      return id(auto_position_active);

  - platform: template
    name: "${sntp_time_valid_name}"
    id: sntp_time_valid_sensor
    icon: mdi:clock-check-outline
    lambda: |-
      return id(sntp_time).now().is_valid();


#:########################################################################################:#
# BUTTON:
# Utility buttons
# https://esphome.io/components/button/template.html
#:########################################################################################:#
button:
  - platform: template
    name: "${zero_position_name}"
    id: zero_position_counter_button
    icon: mdi:counter
    on_press:
      - globals.set:
          id: current_position_step
          value: "0"
      - globals.set:
          id: step_accumulator_ms
          value: "0.0"
      - globals.set:
          id: auto_position_active
          value: "false"
      - globals.set:
          id: auto_position_steps_remaining
          value: "0"
      - globals.set:
          id: auto_position_logical_direction
          value: "1"
      - globals.set:
          id: auto_position_cycle_seconds
          value: "0.0"
      - globals.set:
          id: auto_position_forward_steps
          value: "0"
      - globals.set:
          id: auto_position_reverse_steps
          value: "0"
      - logger.log:
          level: INFO
          format: "Position counter zeroed. Current physical pointer location is now Jan-zero."

  - platform: template
    name: "${replay_ip_flash_name}"
    id: replay_ip_flash_button
    icon: mdi:ip-network
    on_press:
      - script.execute: start_ip_flash_script

  - platform: template
    name: "${auto_position_now_name}"
    id: auto_position_now_button
    icon: mdi:calendar-clock
    on_press:
      - script.execute: start_auto_position_script


#:########################################################################################:#
# SENSOR:
# Position and calculated movement sensors
# https://esphome.io/components/sensor/template.html
#:########################################################################################:#
sensor:
  - platform: template
    name: "Position Days"
    id: position_days_sensor
    icon: mdi:calendar-clock
    unit_of_measurement: "days"
    accuracy_decimals: 4
    update_interval: "${sensor_update_interval}"
    lambda: |-
      return ((double) id(current_position_step) * ${rotation_days}) / ${steps_per_rotation};

  - platform: template
    name: "Position Step"
    id: position_step_sensor
    icon: mdi:counter
    accuracy_decimals: 0
    update_interval: "${sensor_update_interval}"
    lambda: |-
      return id(current_position_step);

  - platform: template
    name: "Days Per Step"
    id: days_per_step_sensor
    icon: mdi:calendar-arrow-right
    unit_of_measurement: "days/step"
    accuracy_decimals: 8
    update_interval: "${calculated_sensor_update_interval}"
    lambda: |-
      return ${rotation_days} / ${steps_per_rotation};

  - platform: template
    name: "Hours Per Step"
    id: hours_per_step_sensor
    icon: mdi:timer-outline
    unit_of_measurement: "h/step"
    accuracy_decimals: 8
    update_interval: "${calculated_sensor_update_interval}"
    lambda: |-
      return (${rotation_days} * 24.0) / ${steps_per_rotation};

  - platform: template
    name: "Normal Step Interval"
    id: normal_step_interval_sensor
    icon: mdi:timer-cog-outline
    unit_of_measurement: "s/step"
    accuracy_decimals: 4
    update_interval: "${calculated_sensor_update_interval}"
    lambda: |-
      return (${rotation_days} * 24.0 * 60.0 * 60.0) / ${steps_per_rotation};

  - platform: template
    name: "Auto Position Target Step"
    id: auto_position_target_step_sensor
    icon: mdi:target
    accuracy_decimals: 0
    update_interval: "${sensor_update_interval}"
    lambda: |-
      return id(auto_position_target_step);

  - platform: template
    name: "Auto Position Target Days"
    id: auto_position_target_days_sensor
    icon: mdi:calendar-clock
    unit_of_measurement: "days"
    accuracy_decimals: 4
    update_interval: "${sensor_update_interval}"
    lambda: |-
      return ((double) id(auto_position_target_step) * ${rotation_days}) / ${steps_per_rotation};

  - platform: template
    name: "Auto Position Steps Remaining"
    id: auto_position_steps_remaining_sensor
    icon: mdi:counter
    accuracy_decimals: 0
    update_interval: "${sensor_update_interval}"
    lambda: |-
      return id(auto_position_steps_remaining);

  - platform: template
    name: "Auto Position Forward Steps"
    id: auto_position_forward_steps_sensor
    icon: mdi:rotate-right
    accuracy_decimals: 0
    update_interval: "${sensor_update_interval}"
    lambda: |-
      return id(auto_position_forward_steps);

  - platform: template
    name: "Auto Position Reverse Steps"
    id: auto_position_reverse_steps_sensor
    icon: mdi:rotate-left
    accuracy_decimals: 0
    update_interval: "${sensor_update_interval}"
    lambda: |-
      return id(auto_position_reverse_steps);

  - platform: template
    name: "Auto Position Cycle Days"
    id: auto_position_cycle_days_sensor
    icon: mdi:calendar-today
    unit_of_measurement: "days"
    accuracy_decimals: 4
    update_interval: "${sensor_update_interval}"
    lambda: |-
      return id(auto_position_cycle_seconds) / 86400.0;


#:########################################################################################:#
# TEXT SENSOR:
# Human-readable status sensors
# https://esphome.io/components/text_sensor/template.html
#:########################################################################################:#
text_sensor:
  - platform: template
    name: "Motion Mode"
    id: motion_mode_text_sensor
    icon: mdi:rotate-3d-variant
    update_interval: "${sensor_update_interval}"
    lambda: |-
      if (id(motor_stop_switch).state) {
        return {"Motor Stopped"};
      }

      switch (id(current_motion_mode)) {
        case 1:
          return {"Fast Forward"};
        case 2:
          return {"Slow Forward"};
        case 3:
          return {"Fast Reverse"};
        case 4:
          return {"Slow Reverse"};
        case 5:
          return {"Auto Position"};
        case 6:
          return {"Motor Stopped"};
        case 0:
        default:
          return {"Normal"};
      }

  - platform: template
    name: "Normal Direction"
    id: normal_direction_text_sensor
    icon: mdi:rotate-right
    update_interval: "${calculated_sensor_update_interval}"
    lambda: |-
      return {"Clockwise / Forward"};

  - platform: template
    name: "Auto Position Direction"
    id: auto_position_direction_text_sensor
    icon: mdi:rotate-3d-variant
    update_interval: "${sensor_update_interval}"
    lambda: |-
      if (!id(auto_position_active)) {
        return {"Idle"};
      }

      if (id(auto_position_logical_direction) < 0) {
        return {"Anti-clockwise / Reverse"};
      }

      return {"Clockwise / Forward"};


#:########################################################################################:#
# INTERVAL:
# Stepper scheduler and IP flash scheduler
# https://esphome.io/components/interval.html
#:########################################################################################:#
interval:
  - interval: 5ms
    then:
      - lambda: |-
          const int STEPS_PER_ROTATION = ${steps_per_rotation};
          const int CLOCKWISE_MOTOR_DIRECTION_MULTIPLIER = ${clockwise_motor_direction_multiplier};

          const double NORMAL_STEP_INTERVAL_MS =
            (${rotation_days} * 24.0 * 60.0 * 60.0 * 1000.0) / ${steps_per_rotation};

          const double FAST_STEP_INTERVAL_MS =
            (${manual_fast_rotation_seconds} * 1000.0) / ${steps_per_rotation};

          const double SLOW_STEP_INTERVAL_MS =
            (${manual_slow_rotation_seconds} * 1000.0) / ${steps_per_rotation};

          const double AUTO_POSITION_STEP_INTERVAL_MS =
            (${auto_position_rotation_seconds} * 1000.0) / ${steps_per_rotation};

          const uint32_t COIL_RELEASE_MS = ${coil_release_ms};
          const uint32_t STEP_LED_FLASH_MS = ${step_led_flash_ms};

          auto all_coils_off = [&]() {
            id(stepper_in1).turn_off();
            id(stepper_in2).turn_off();
            id(stepper_in3).turn_off();
            id(stepper_in4).turn_off();
            id(coils_are_on) = false;
          };

          auto flash_step_led = [&](uint32_t now_ms) {
            if (!id(ip_flash_active)) {
              id(onboard_step_led).turn_on();
              id(step_led_started_ms) = now_ms;
              id(step_led_is_on) = true;
            }
          };

          auto set_coils_for_phase = [&](int phase) {
            const bool sequence[8][4] = {
              {true,  false, false, false},
              {true,  true,  false, false},
              {false, true,  false, false},
              {false, true,  true,  false},
              {false, false, true,  false},
              {false, false, true,  true },
              {false, false, false, true },
              {true,  false, false, true }
            };

            phase = ((phase % 8) + 8) % 8;

            if (sequence[phase][0]) {
              id(stepper_in1).turn_on();
            } else {
              id(stepper_in1).turn_off();
            }

            if (sequence[phase][1]) {
              id(stepper_in2).turn_on();
            } else {
              id(stepper_in2).turn_off();
            }

            if (sequence[phase][2]) {
              id(stepper_in3).turn_on();
            } else {
              id(stepper_in3).turn_off();
            }

            if (sequence[phase][3]) {
              id(stepper_in4).turn_on();
            } else {
              id(stepper_in4).turn_off();
            }

            id(coils_are_on) = true;
          };

          auto do_one_step = [&](int logical_direction, uint32_t now_ms) {
            // logical_direction >= 0 means clockwise / forward / increase position.
            // logical_direction < 0 means anti-clockwise / reverse / decrease position.
            int motor_direction =
              logical_direction >= 0
                ? CLOCKWISE_MOTOR_DIRECTION_MULTIPLIER
                : -CLOCKWISE_MOTOR_DIRECTION_MULTIPLIER;

            if (logical_direction >= 0) {
              id(current_position_step) =
                (id(current_position_step) + 1) % STEPS_PER_ROTATION;
            } else {
              id(current_position_step) =
                (id(current_position_step) + STEPS_PER_ROTATION - 1) % STEPS_PER_ROTATION;
            }

            if (motor_direction >= 0) {
              id(current_phase_index) = (id(current_phase_index) + 1) % 8;
            } else {
              id(current_phase_index) = (id(current_phase_index) + 7) % 8;
            }

            set_coils_for_phase(id(current_phase_index));
            id(last_step_ms) = now_ms;
            flash_step_led(now_ms);
          };

          const bool fast_forward_active =
            id(physical_fast_forward_active) || id(web_fast_forward_active);

          const bool slow_forward_active =
            id(physical_slow_forward_active) || id(web_slow_forward_active);

          const bool fast_reverse_active =
            id(physical_fast_reverse_active) || id(web_fast_reverse_active);

          const bool slow_reverse_active =
            id(physical_slow_reverse_active) || id(web_slow_reverse_active);

          const bool any_manual_control_active =
            fast_forward_active ||
            slow_forward_active ||
            fast_reverse_active ||
            slow_reverse_active;

          const uint32_t now_ms = millis();

          if (id(last_loop_ms) == 0) {
            id(last_loop_ms) = now_ms;
            id(step_accumulator_ms) = 0.0;

            if (id(motor_stop_switch).state) {
              id(current_motion_mode) = 6;
            } else {
              id(current_motion_mode) = 0;
            }

            return;
          }

          const uint32_t elapsed_ms = now_ms - id(last_loop_ms);
          id(last_loop_ms) = now_ms;

          if (id(motor_stop_switch).state) {
            if (id(auto_position_active)) {
              id(auto_position_active) = false;
              id(auto_position_steps_remaining) = 0;
              ESP_LOGW("motor_stop", "Motor Stop is ON. Auto positioning cancelled.");
            }

            all_coils_off();
            id(step_accumulator_ms) = 0.0;
            id(current_motion_mode) = 6;
            return;
          }

          if (any_manual_control_active && id(auto_position_active)) {
            id(auto_position_active) = false;
            id(auto_position_steps_remaining) = 0;
            ESP_LOGW("auto_position", "Auto positioning cancelled by manual control.");
          }

          int selected_mode = 0;
          int selected_logical_direction = 1;
          double selected_step_interval_ms = NORMAL_STEP_INTERVAL_MS;

          if (fast_forward_active) {
            selected_mode = 1;
            selected_logical_direction = 1;
            selected_step_interval_ms = FAST_STEP_INTERVAL_MS;
          } else if (slow_forward_active) {
            selected_mode = 2;
            selected_logical_direction = 1;
            selected_step_interval_ms = SLOW_STEP_INTERVAL_MS;
          } else if (fast_reverse_active) {
            selected_mode = 3;
            selected_logical_direction = -1;
            selected_step_interval_ms = FAST_STEP_INTERVAL_MS;
          } else if (slow_reverse_active) {
            selected_mode = 4;
            selected_logical_direction = -1;
            selected_step_interval_ms = SLOW_STEP_INTERVAL_MS;
          } else if (id(auto_position_active)) {
            selected_mode = 5;
            selected_logical_direction = id(auto_position_logical_direction);

            if (selected_logical_direction == 0) {
              selected_logical_direction = 1;
            }

            selected_step_interval_ms = AUTO_POSITION_STEP_INTERVAL_MS;
          } else {
            selected_mode = 0;
            selected_logical_direction = 1;
            selected_step_interval_ms = NORMAL_STEP_INTERVAL_MS;
          }

          if (selected_mode != id(current_motion_mode)) {
            id(current_motion_mode) = selected_mode;
            id(step_accumulator_ms) = 0.0;
          }

          id(step_accumulator_ms) += (double) elapsed_ms;

          if (id(step_accumulator_ms) >= selected_step_interval_ms) {
            do_one_step(selected_logical_direction, now_ms);

            if (selected_mode == 5 && id(auto_position_active)) {
              id(auto_position_steps_remaining)--;

              if (id(auto_position_steps_remaining) <= 0) {
                id(auto_position_steps_remaining) = 0;
                id(auto_position_active) = false;
                id(auto_position_logical_direction) = 1;
                id(current_motion_mode) = 0;

                ESP_LOGI(
                  "auto_position",
                  "Auto positioning complete. Current step: %d.",
                  id(current_position_step)
                );
              }
            }

            id(step_accumulator_ms) -= selected_step_interval_ms;

            // If the loop was badly delayed, avoid trying to catch up with a burst
            // of very fast steps that the motor may not physically follow.
            if (id(step_accumulator_ms) > selected_step_interval_ms) {
              id(step_accumulator_ms) = 0.0;
            }
          }

          if (
            id(coils_are_on) &&
            selected_mode == 0 &&
            ((uint32_t) (now_ms - id(last_step_ms)) >= COIL_RELEASE_MS)
          ) {
            all_coils_off();
          }

          if (
            !id(ip_flash_active) &&
            id(step_led_is_on) &&
            ((uint32_t) (now_ms - id(step_led_started_ms)) >= STEP_LED_FLASH_MS)
          ) {
            id(onboard_step_led).turn_off();
            id(step_led_is_on) = false;
          }

  - interval: ${ip_flash_loop_interval}
    then:
      - lambda: |-
          if (!id(ip_flash_active)) {
            return;
          }

          const int IP_FLASH_REPEAT_COUNT = ${ip_flash_repeat_count};
          const int IP_FLASH_ZERO_DIGIT_BLINKS = ${ip_flash_zero_digit_blinks};

          const uint32_t IP_FLASH_ON_MS = ${ip_flash_on_ms};
          const uint32_t IP_FLASH_BETWEEN_FLASH_GAP_MS = ${ip_flash_between_flash_gap_ms};
          const uint32_t IP_FLASH_DIGIT_GAP_MS = ${ip_flash_digit_gap_ms};
          const uint32_t IP_FLASH_OCTET_GAP_MS = ${ip_flash_octet_gap_ms};
          const uint32_t IP_FLASH_REPEAT_GAP_MS = ${ip_flash_repeat_gap_ms};

          const uint32_t now_ms = millis();

          if ((int32_t) (now_ms - id(ip_flash_next_action_ms)) < 0) {
            return;
          }

          auto get_octet = [&](int index) -> int {
            switch (index) {
              case 0:
                return id(ip_flash_octet_0);
              case 1:
                return id(ip_flash_octet_1);
              case 2:
                return id(ip_flash_octet_2);
              case 3:
              default:
                return id(ip_flash_octet_3);
            }
          };

          auto get_digit_count = [&](int value) -> int {
            if (value >= 100) {
              return 3;
            }

            if (value >= 10) {
              return 2;
            }

            return 1;
          };

          auto get_digit = [&](int value, int digit_index) -> int {
            const int digit_count = get_digit_count(value);

            if (digit_count == 3) {
              if (digit_index == 0) {
                return value / 100;
              }

              if (digit_index == 1) {
                return (value / 10) % 10;
              }

              return value % 10;
            }

            if (digit_count == 2) {
              if (digit_index == 0) {
                return value / 10;
              }

              return value % 10;
            }

            return value;
          };

          const int current_octet = get_octet(id(ip_flash_octet_index));
          const int current_digit_count = get_digit_count(current_octet);
          const int current_digit = get_digit(current_octet, id(ip_flash_digit_index));
          const int target_blinks =
            current_digit == 0 ? IP_FLASH_ZERO_DIGIT_BLINKS : current_digit;

          if (id(ip_flash_led_is_on)) {
            id(onboard_step_led).turn_off();
            id(ip_flash_led_is_on) = false;
            id(ip_flash_blinks_completed)++;

            if (id(ip_flash_blinks_completed) < target_blinks) {
              id(ip_flash_next_action_ms) = now_ms + IP_FLASH_BETWEEN_FLASH_GAP_MS;
              return;
            }

            id(ip_flash_blinks_completed) = 0;
            id(ip_flash_digit_index)++;

            if (id(ip_flash_digit_index) < current_digit_count) {
              id(ip_flash_next_action_ms) = now_ms + IP_FLASH_DIGIT_GAP_MS;
              return;
            }

            id(ip_flash_digit_index) = 0;
            id(ip_flash_octet_index)++;

            if (id(ip_flash_octet_index) < 4) {
              id(ip_flash_next_action_ms) = now_ms + IP_FLASH_OCTET_GAP_MS;
              return;
            }

            id(ip_flash_octet_index) = 0;
            id(ip_flash_repeats_completed)++;

            if (id(ip_flash_repeats_completed) >= IP_FLASH_REPEAT_COUNT) {
              id(ip_flash_active) = false;
              id(ip_flash_led_is_on) = false;
              id(onboard_step_led).turn_off();

              ESP_LOGI("ip_flash", "Finished flashing IPv4 address.");
              return;
            }

            id(ip_flash_next_action_ms) = now_ms + IP_FLASH_REPEAT_GAP_MS;
            return;
          }

          id(onboard_step_led).turn_on();
          id(ip_flash_led_is_on) = true;
          id(ip_flash_next_action_ms) = now_ms + IP_FLASH_ON_MS;


#:########################################################################################:#
# MQTT:
# Device-specific MQTT command triggers, in addition to common MQTT config
# https://esphome.io/components/mqtt.html
#:########################################################################################:#
#mqtt:
#  on_message:
#    - topic: "${mqtt_command_topic}/fast_forward/set"
#      payload: "ON"
#      then:
#        - switch.turn_on: web_fast_forward_switch
#
#    - topic: "${mqtt_command_topic}/fast_forward/set"
#      payload: "OFF"
#      then:
#        - switch.turn_off: web_fast_forward_switch
#
#    - topic: "${mqtt_command_topic}/slow_forward/set"
#      payload: "ON"
#      then:
#        - switch.turn_on: web_slow_forward_switch
#
#    - topic: "${mqtt_command_topic}/slow_forward/set"
#      payload: "OFF"
#      then:
#        - switch.turn_off: web_slow_forward_switch
#
#    - topic: "${mqtt_command_topic}/fast_reverse/set"
#      payload: "ON"
#      then:
#        - switch.turn_on: web_fast_reverse_switch
#
#    - topic: "${mqtt_command_topic}/fast_reverse/set"
#      payload: "OFF"
#      then:
#        - switch.turn_off: web_fast_reverse_switch
#
#    - topic: "${mqtt_command_topic}/slow_reverse/set"
#      payload: "ON"
#      then:
#        - switch.turn_on: web_slow_reverse_switch
#
#    - topic: "${mqtt_command_topic}/slow_reverse/set"
#      payload: "OFF"
#      then:
#        - switch.turn_off: web_slow_reverse_switch