diff --git a/esphome/esp-astroclockstepper1.yaml b/esphome/esp-astroclockstepper1.yaml new file mode 100644 index 0000000..9f6b9ac --- /dev/null +++ b/esphome/esp-astroclockstepper1.yaml @@ -0,0 +1,821 @@ +#:########################################################################################:# +# 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.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 +# - Fast Reverse: GPIO0 / D3 +# - Slow 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 +#:########################################################################################:# +# OPERATION NOTES: +# - Normal rotation starts automatically after boot. +# - 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. +#:########################################################################################:# +# OFFLINE NOTES: +# a) Home Assistant OFFLINE, but Network and MQTT ONLINE +# - Device remains controllable via local web portal and MQTT commands +# - HA entities unavailable until HA returns +# b) MQTT OFFLINE, but WiFi/Network and HA API ONLINE +# - Device remains controllable from Home Assistant via API +# - Local web portal remains available +# c) Entire WiFi/Network OFFLINE +# - No HA API, MQTT, or web control available +# - Physical buttons continue to work +# - Normal motor timing continues from device uptime +# d) Device power loss / reboot +# - V1 does not restore exact astronomical position after reboot +# - Re-align manually using the web or physical button controls +#:########################################################################################:# + + +#:########################################################################################:# +# 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.0" + + # Passwords & Secrets (Unfortunately, you cannot use substitutions inside secret names) + api_key: !secret esp-api_key + ota_pass: !secret esp-ota_pass + static_ip_address: !secret esp-astroclockstepper1_ip + + # If we are changing IP addresses, you must update the current IP address here, otherwise it remains + # Don't forget to switch it back when changed. + current_ip_address: ${static_ip_address} + + # MQTT Topics + mqtt_command_main_topic: !secret mqtt_command_main_topic + mqtt_status_main_topic: !secret mqtt_status_main_topic + mqtt_device_name: "astro-clock-stepper1" + mqtt_command_topic: "${mqtt_command_main_topic}/${mqtt_device_name}" + mqtt_status_topic: "${mqtt_status_main_topic}/${mqtt_device_name}" + + # Device Settings + log_level: "INFO" + sensor_update_interval: "1s" + calculated_sensor_update_interval: "60s" + + # Astronomical Clock Settings + rotation_days: "365.2422" + steps_per_rotation: "4096" + normal_direction_name: "Clockwise" + + # Use 1 for normal direction. + # Use -1 if the motor rotates the wrong way. + normal_direction_multiplier: "1" + + # Manual Movement Speeds + manual_fast_rotation_seconds: "60" + manual_slow_rotation_seconds: "300" + + # Coil Release + coil_release_ms: "500" + + # 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_fast_reverse_pin: GPIO0 + button_slow_reverse_pin: GPIO16 + + # Entity Naming + 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" + + +#:########################################################################################:# +# PACKAGES: Included Common Packages +# https://esphome.io/components/packages.html +#:########################################################################################:# +packages: + #### WIFI, Network (Static/DHCP/IPV6 etc), Fallback AP, Safemode #### + common_wifi: !include + file: common/network_common.yaml + vars: + local_device_name: "${device_name}" + local_static_ip_address: "${static_ip_address}" + local_ota_pass: "${ota_pass}" + local_current_ip_address: "${current_ip_address}" + + #### 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 + + #### 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.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: + - logger.log: + level: INFO + format: "Astronomical Clock Stepper started. Normal rotation is %s days per full revolution." + args: + - '"${rotation_days}"' + + +#:########################################################################################:# +# ESP PLATFORM AND FRAMEWORK: +# https://esphome.io/components/esp8266.html +#:########################################################################################:# +esp8266: + board: d1_mini + restore_from_flash: true + +preferences: + flash_write_interval: 5min + +mdns: + disabled: false + + +#:########################################################################################:# +# LOGGING: +# https://esphome.io/components/logger.html +#:########################################################################################:# +logger: + level: "${log_level}" + baud_rate: 0 + + +#:########################################################################################:# +# GLOBALS: +# Internal position, timing, mode, and held-button state +# https://esphome.io/components/globals.html +#:########################################################################################:# +globals: + - id: current_position_step + type: int + restore_value: no + initial_value: "0" + + - id: current_phase_index + type: int + restore_value: no + 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" + + # 0 = Normal + # 1 = Fast Forward + # 2 = Slow Forward + # 3 = Fast Reverse + # 4 = Slow Reverse + - 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" + + +#:########################################################################################:# +# OUTPUT: +# Stepper driver GPIO outputs +# 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} + + +#:########################################################################################:# +# SWITCH: +# Web held-button controls +# 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" + + +#:########################################################################################:# +# 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_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" + + +#:########################################################################################:# +# 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" + - logger.log: + level: INFO + format: "Position counter zeroed." + + +#:########################################################################################:# +# 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}; + + +#:########################################################################################:# +# 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: |- + 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 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}"}; + + +#:########################################################################################:# +# INTERVAL: +# Stepper scheduler +# https://esphome.io/components/interval.html +#:########################################################################################:# +interval: + - interval: 5ms + then: + - lambda: |- + const int STEPS_PER_ROTATION = ${steps_per_rotation}; + const int NORMAL_DIRECTION_MULTIPLIER = ${normal_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 uint32_t COIL_RELEASE_MS = ${coil_release_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 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 direction, uint32_t now_ms) { + if (direction >= 0) { + id(current_phase_index) = (id(current_phase_index) + 1) % 8; + id(current_position_step) = (id(current_position_step) + 1) % STEPS_PER_ROTATION; + } else { + id(current_phase_index) = (id(current_phase_index) + 7) % 8; + id(current_position_step) = (id(current_position_step) + STEPS_PER_ROTATION - 1) % STEPS_PER_ROTATION; + } + + set_coils_for_phase(id(current_phase_index)); + id(last_step_ms) = 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); + + int selected_mode = 0; + int selected_direction = NORMAL_DIRECTION_MULTIPLIER; + double selected_step_interval_ms = NORMAL_STEP_INTERVAL_MS; + + if (fast_forward_active) { + selected_mode = 1; + selected_direction = NORMAL_DIRECTION_MULTIPLIER; + selected_step_interval_ms = FAST_STEP_INTERVAL_MS; + } else if (slow_forward_active) { + selected_mode = 2; + selected_direction = NORMAL_DIRECTION_MULTIPLIER; + selected_step_interval_ms = SLOW_STEP_INTERVAL_MS; + } else if (fast_reverse_active) { + selected_mode = 3; + selected_direction = -NORMAL_DIRECTION_MULTIPLIER; + selected_step_interval_ms = FAST_STEP_INTERVAL_MS; + } else if (slow_reverse_active) { + selected_mode = 4; + selected_direction = -NORMAL_DIRECTION_MULTIPLIER; + selected_step_interval_ms = SLOW_STEP_INTERVAL_MS; + } + + const uint32_t now_ms = millis(); + + if (id(last_loop_ms) == 0) { + id(last_loop_ms) = now_ms; + id(current_motion_mode) = selected_mode; + id(step_accumulator_ms) = 0.0; + return; + } + + const uint32_t elapsed_ms = now_ms - id(last_loop_ms); + id(last_loop_ms) = now_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_direction, now_ms); + 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(); + } + + +#:########################################################################################:# +# 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 \ No newline at end of file