diff --git a/esphome/esp-astroclockstepper1.yaml b/esphome/esp-astroclockstepper1.yaml index 496af1f..3d1a272 100644 --- a/esphome/esp-astroclockstepper1.yaml +++ b/esphome/esp-astroclockstepper1.yaml @@ -6,6 +6,7 @@ # https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-astroclockstepper.yaml #:########################################################################################:# # VERSIONS: +# 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 @@ -52,6 +53,10 @@ # - 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 forward only to the current calculated year position. +# - Auto Position On Boot is off by default, restored on power loss, and runs after boot/IP flash. #:########################################################################################:# # OFFLINE NOTES: # a) Home Assistant OFFLINE, but Network ONLINE @@ -62,9 +67,14 @@ # - Physical buttons continue to work # - Normal motor timing continues from device uptime # - Fallback AP / captive portal should become available from network_common_dhcp.yaml -# c) Device power loss / reboot -# - V1 does not restore exact astronomical position after reboot -# - Re-align manually using the web or physical button controls +# c) SNTP NOT SYNCED +# - Normal motor timing continues +# - Manual controls continue +# - 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 +# - Physical position is assumed valid because the motor housing/dial is fixed #:########################################################################################:# @@ -81,7 +91,7 @@ substitutions: # Project Naming project_name: "zorruno.Astronomical Clock Stepper" - project_version: "v1.2" + project_version: "v1.3" # Passwords & Secrets (Unfortunately, you cannot use substitutions inside secret names) api_key: !secret esp-api_key @@ -92,6 +102,9 @@ substitutions: 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" @@ -105,6 +118,10 @@ substitutions: 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" @@ -121,9 +138,9 @@ substitutions: # Suggested human-countable timing ip_flash_on_ms: "200" ip_flash_between_flash_gap_ms: "450" - ip_flash_digit_gap_ms: "1000" - ip_flash_octet_gap_ms: "4500" - ip_flash_repeat_gap_ms: "10000" + 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 @@ -151,6 +168,11 @@ substitutions: 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 @@ -181,13 +203,8 @@ packages: #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 - common_sntp: !include common/sntp_common_public.yaml - #### DIAGNOSTICS Sensors #### diag_basic: !include common/include_basic_diag_sensors.yaml - diag_more: !include common/include_more_diag_sensors_nomqtt.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 @@ -210,6 +227,7 @@ esphome: priority: -100 then: - output.turn_off: onboard_step_led + - logger.log: level: INFO format: "Astronomical Clock Stepper started. Normal rotation is %s days per full revolution." @@ -221,9 +239,38 @@ esphome: - 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: @@ -234,7 +281,7 @@ esp8266: restore_from_flash: true preferences: - flash_write_interval: 5min + flash_write_interval: 1min mdns: disabled: false @@ -249,20 +296,42 @@ logger: 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, and IP flash state +# 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: no + restore_value: yes initial_value: "0" - id: current_phase_index type: int - restore_value: no + restore_value: yes initial_value: "0" - id: last_loop_ms @@ -300,6 +369,7 @@ globals: # 2 = Slow Forward # 3 = Fast Reverse # 4 = Slow Reverse + # 5 = Auto Position - id: current_motion_mode type: int restore_value: no @@ -403,6 +473,27 @@ globals: 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" + #:########################################################################################:# # OUTPUT: @@ -490,10 +581,115 @@ script: ${ip_flash_repeat_count} ); + - id: start_auto_position_script + mode: restart + then: + - lambda: |- + const int STEPS_PER_ROTATION = ${steps_per_rotation}; + + auto now = id(sntp_time).now(); + + if (!now.is_valid()) { + ESP_LOGW("auto_position", "SNTP time is not valid yet. 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 steps_to_move = + (target_step - current_step + STEPS_PER_ROTATION) % STEPS_PER_ROTATION; + + id(auto_position_target_step) = target_step; + id(auto_position_steps_remaining) = steps_to_move; + + if (steps_to_move == 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, moving forward %d steps.", + year, + day_of_year, + days_in_year, + target_step, + current_step, + steps_to_move + ); + #:########################################################################################:# # SWITCH: -# Web held-button controls +# Web held-button controls and restored auto-position boot option # https://esphome.io/components/switch/template.html #:########################################################################################:# switch: @@ -569,6 +765,13 @@ switch: 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 + #:########################################################################################:# # BINARY SENSORS: @@ -652,6 +855,20 @@ binary_sensor: id: physical_slow_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: @@ -673,6 +890,12 @@ button: - 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" - logger.log: level: INFO format: "Position counter zeroed." @@ -684,6 +907,13 @@ button: 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: @@ -740,6 +970,34 @@ sensor: 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: @@ -762,6 +1020,8 @@ text_sensor: return {"Fast Reverse"}; case 4: return {"Slow Reverse"}; + case 5: + return {"Auto Position"}; case 0: default: return {"Normal"}; @@ -797,6 +1057,9 @@ interval: 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}; @@ -883,6 +1146,18 @@ interval: 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; + + 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_direction = NORMAL_DIRECTION_MULTIPLIER; double selected_step_interval_ms = NORMAL_STEP_INTERVAL_MS; @@ -903,6 +1178,10 @@ interval: selected_mode = 4; selected_direction = -NORMAL_DIRECTION_MULTIPLIER; selected_step_interval_ms = SLOW_STEP_INTERVAL_MS; + } else if (id(auto_position_active)) { + selected_mode = 5; + selected_direction = NORMAL_DIRECTION_MULTIPLIER; + selected_step_interval_ms = AUTO_POSITION_STEP_INTERVAL_MS; } const uint32_t now_ms = millis(); @@ -926,6 +1205,23 @@ interval: if (id(step_accumulator_ms) >= selected_step_interval_ms) { do_one_step(selected_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(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 @@ -1076,4 +1372,52 @@ interval: 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; \ No newline at end of file + 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 \ No newline at end of file