#:########################################################################################:# # 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.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 Forward: GPIO5 / D1 # - Slow Forward: GPIO4 / D2 # - Slow Reverse: GPIO0 / D3 # - Fast Reverse: GPIO16 / D0 # # 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 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. # - 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. # - 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. # - User should manually align the dial to year-zero, then press Zero Position Counter. # - 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 motor direction is separately adjustable from normal/manual movement. # - 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.5" # Passwords & Secrets (Unfortunately, you cannot use substitutions inside secret names) 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" normal_direction_name: "Clockwise" # Normal/manual motor direction. # Use 1 or -1 if normal/manual movement rotates the wrong way. normal_direction_multiplier: "1" # Auto-position motor direction. # This is the physical motor phase direction used when the logical year position increases. # Reverse auto-position uses the opposite of this automatically. # Set to -1 because auto-position was observed to be running anti-clockwise when this was 1. auto_position_motor_direction_multiplier: "-1" # Auto Position Path # true = move clockwise or anti-clockwise, whichever is quicker # false = always move logically forward through the year auto_position_use_shortest_path: "true" # Manual Movement Speeds manual_fast_rotation_seconds: "30" manual_slow_rotation_seconds: "300" # 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_fast_forward_pin: GPIO5 button_slow_forward_pin: GPIO4 button_slow_reverse_pin: GPIO0 button_fast_reverse_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" - 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 = logical forward / clockwise year position increase # -1 = logical reverse / anti-clockwise year position decrease - id: auto_position_logical_direction type: int restore_value: no initial_value: "1" #:########################################################################################:# # 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}; 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_in_year = is_leap_year ? 366 : 365; 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_year = ((uint32_t) (day_of_year - 1) * 86400UL) + ((uint32_t) now.hour * 3600UL) + ((uint32_t) now.minute * 60UL) + ((uint32_t) now.second); const double seconds_in_year = (double) days_in_year * 86400.0; int target_step = (int) (((double) seconds_elapsed_this_year / seconds_in_year) * (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; if (chosen_steps == 0) { id(auto_position_active) = false; ESP_LOGI( "auto_position", "Already at correct 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/%d, target step: %d, current step: %d, forward steps: %d, reverse steps: %d, chosen direction: %s, moving %d logical steps.", year, day_of_year, days_in_year, target_step, current_step, forward_steps, reverse_steps, chosen_logical_direction >= 0 ? "forward" : "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_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: 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_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_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: 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: current_phase_index 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" - logger.log: level: INFO format: "Position counter zeroed." - 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 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 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}; #:########################################################################################:# # 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 {"${normal_direction_name}"}; - 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 {"Reverse / Anti-clockwise"}; } return {"Forward / Clockwise"}; #:########################################################################################:# # 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 NORMAL_MOTOR_DIRECTION_MULTIPLIER = ${normal_direction_multiplier}; const int AUTO_POSITION_MOTOR_DIRECTION_MULTIPLIER = ${auto_position_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, int motor_direction, uint32_t now_ms) { // Logical direction updates the remembered year-position counter. 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; } // Motor direction updates the coil phase, allowing physical direction to be tuned separately. 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; int selected_motor_direction = NORMAL_MOTOR_DIRECTION_MULTIPLIER; double selected_step_interval_ms = NORMAL_STEP_INTERVAL_MS; if (fast_forward_active) { selected_mode = 1; selected_logical_direction = 1; selected_motor_direction = NORMAL_MOTOR_DIRECTION_MULTIPLIER; selected_step_interval_ms = FAST_STEP_INTERVAL_MS; } else if (slow_forward_active) { selected_mode = 2; selected_logical_direction = 1; selected_motor_direction = NORMAL_MOTOR_DIRECTION_MULTIPLIER; selected_step_interval_ms = SLOW_STEP_INTERVAL_MS; } else if (fast_reverse_active) { selected_mode = 3; selected_logical_direction = -1; selected_motor_direction = -NORMAL_MOTOR_DIRECTION_MULTIPLIER; selected_step_interval_ms = FAST_STEP_INTERVAL_MS; } else if (slow_reverse_active) { selected_mode = 4; selected_logical_direction = -1; selected_motor_direction = -NORMAL_MOTOR_DIRECTION_MULTIPLIER; 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_motor_direction = selected_logical_direction * AUTO_POSITION_MOTOR_DIRECTION_MULTIPLIER; selected_step_interval_ms = AUTO_POSITION_STEP_INTERVAL_MS; } else { selected_mode = 0; selected_logical_direction = 1; selected_motor_direction = NORMAL_MOTOR_DIRECTION_MULTIPLIER; 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, selected_motor_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