#:########################################################################################:# # TITLE: LED MATRIX 1 # zorruno.com layout v1.1 2026 # ESP32-C3 SuperMini MAX7219 LED matrix clock and scroll display. #:########################################################################################:# # REFERENCES: # Original Project Reference https://github.com/RealDeco/matrixclock-esphome # Fonts: https://github.com/RealDeco/matrixclock-esphome/tree/main/fonts # 3D Printed Case (includes project notes in the 3mf files) # https://github.com/RealDeco/matrixclock-esphome/tree/main/3DPrint #:########################################################################################:# # REPO: # https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-ledmatrix1.yaml #:########################################################################################:# # VERSIONS: # V1.14 2026-06-04 Added per-announcement static wait and stored sound_id parameter. # V1.13 2026-06-04 Fixed remaining leading-space stripping in static transitions and MQTT announce parsing. # V1.12 2026-06-04 Preserved leading spaces in scroll/static/announcement text for layout control. # V1.11 2026-06-04 Shifted 24h clock display one pixel right and changed Static Wait Time range to 0-30s. # V1.10 2026-06-03 Added real-font clipped partial next character for long static message entry/hold. # V1.9 2026-06-03 Improved static long-message first-screen calculation using # measured font text bounds instead of a fixed character estimate. # V1.8 2026-06-03 Refactored static long-message handling so entry/hold uses only # the first screenful at x=0, then full text scrolls left after wait. # V1.7 2026-06-03 Reworked long static-message sequence so text rolls in at x=0, # waits, scrolls left cleanly from x=0, then returns to clock. # V1.6 2026-06-03 Improved static notification centering and long-message static scrolling. # V1.5 2026-06-03 Added static notification transition effects, static wait time, # centred short static text, and long static-message scrolling. # V1.4 2026-06-01 Added queued MQTT announcement mode with cancel IDs, per-message speed, # per-message transition mode, repeat scheduling, and optional sounder GPIO. # V1.3 2026-05-24 Added native API action for Home Assistant direct scroll text commands. # V1.2 2026-05-24 Added 1-15 brightness level slider and improved operation notes. # V1.1 2026-05-23 Updated yaml to zorruno layout V1.1, cleaned comments, fixed SNTP time IDs, # added current_mins fallback display, removed unused MQTT topic, and fixed # blank scroll handling. # V1.0 2026-05-23 Initial Version #:########################################################################################:# # HARDWARE: # - ESP32-C3 SuperMini. # - MAX7219 4-chip LED matrix display, typically 32 x 8 pixels. # - SPI wiring configured below: # - MOSI: GPIO8 # - CS: GPIO9 # - SCK: GPIO10 # - Optional sounder / beeper output defaults to GPIO4. # - Power note: 4 MAX7219 modules needs a suitable external 5 V supply. # - Keep ESP32-C3 GND and matrix power supply GND connected together. #:########################################################################################:# # OPERATION NOTES: # - Main display: # - Shows a 32 x 8 LED matrix clock using the MAX7219 display component. # - Uses common/sntp_common.yaml as the primary time source via id(sntp_time). # - If SNTP is not valid, uses id(current_mins) as a fallback clock. # - In fallback mode the colon stays solid, so it is obvious time is not fully synced. # # - Clock controls: # - Clock format can be selected as 12h or 24h from Home Assistant or MQTT. # - 12h mode draws a small A or P indicator at the far right of the display. # # - Brightness controls: # - The Display light entity can turn the matrix on/off and set proportional brightness. # - The Brightness Level number slider sets the exact MAX7219 intensity from 1 to 15. # - MQTT brightness accepts 0 to turn the display off, or 1-15 to set exact intensity. # - The 1-15 slider keeps the last non-zero brightness value when the display is off. # # - Scroll text controls: # - Scroll Text is exposed as a Home Assistant text entity. # - Native API action esphome.esp_ledmatrix1_scroll_text can scroll text directly from Home Assistant. # - The text box clears itself after a valid scroll request is accepted. # - Empty scroll requests are ignored and do not replay the previous message. # - Leading spaces in scroll/static/announcement text are preserved for manual layout control. # - Long static transition messages use leading spaces as a pixel offset before the text. # - Scroll Delay controls the default delay in milliseconds between scroll steps. # - Static Wait Time controls how long static notifications hold before scrolling/returning. # - Static Wait Time supports 0-30 seconds; 0 starts scrolling/returning immediately. # - MQTT /scroll uses the selected default scroll delay and selected default transition effect. # # - Announcement controls: # - MQTT /announce queues repeated announcement messages with per-message parameters. # - Announcement format is: # Message text [identifier,transition_mode,speed,repeat_seconds,repeat_times,static_wait,sounder_quantity,sound_id] # - identifier is cleaned to letters, numbers, underscore, or dash, and is limited to 8 characters. # - transition_mode is 0, 1, 2, 3, or 4. # - 0=Slide up, 1=Slide down, 2=Pixel fall, 3=Static Up, Down, 4=Static Down, Up. # - speed is the per-message scroll delay in ms, clamped from 1 to 100. # - repeat_seconds is the delay between completed scrolls, clamped from 0 to 99. # - repeat_times is the total number of scroll displays, clamped from 0 to 99. # - repeat_times 0 is treated as a one-shot announcement. # - static_wait is the per-message static wait time in seconds, clamped from 0 to 30. # - static_wait only affects transition modes 3 and 4. Other modes ignore it. # - sounder_quantity is the number of announcement displays that should beep first. # - sounder_quantity 0 means no sounder beep. # - sound_id is stored for future sounder logic but is not currently used. # - If a new announcement arrives with the same identifier, older queued copies are removed # and the current matching announcement is cancelled after its current scroll finishes. # - MQTT /cancel_id cancels future repeats for a queued or active announcement ID. # - The active scroll is allowed to finish cleanly when cancelled. # # - Scroll transitions: # - Before scrolling, the clock always slides down and off the display. # - After scrolling, the selected re-entry effect runs. # - Available re-entry effects are Slide up, Slide down, Pixel fall, Static Up, Down, and Static Down, Up. # - Pixel fall hides the clock until the particle effect finishes. # - Static Up, Down rolls the clock upward, centres short static text across the full display, waits, then rolls the clock back down. # - Static Down, Up rolls the clock downward, centres short static text across the full display, waits, then rolls the clock back up. # - Long static text rolls in using only the first complete screenful at x=0. # - The first screenful is measured using display text bounds for the active font, # so it should use as much of the 32-pixel display as possible without clipping. # - Long static text then waits without horizontal movement, scrolls the full text left # from x=0 until fully off-screen, then the clock rolls back in. #:########################################################################################:# # MQTT COMMANDS: # - ${mqtt_command_topic}/scroll : Text payload to scroll once across the display. # - ${mqtt_command_topic}/announce : Queued announcement payload: # Message text [id,transition,speed,repeat_seconds,repeat_times,static_wait,sounder_quantity,sound_id] # - ${mqtt_command_topic}/cancel_id : Payload is the short ID to cancel future repeats. # - ${mqtt_command_topic}/mode : Payload 12 or 24 to select clock format. # - ${mqtt_command_topic}/bright : Payload 0 turns display off, 1-15 sets exact brightness. # - Transition IDs: 0=Slide up, 1=Slide down, 2=Pixel fall, 3=Static Up, Down, 4=Static Down, Up. # - ${mqtt_status_topic}/status : Publishes online/offline status. # # - Example announce payload: # Washing machine complete [wash,1,15,10,6,5,1,0] #:########################################################################################:# # HOME ASSISTANT API ACTIONS: # - esphome.esp_ledmatrix1_scroll_text # data: # message: "Text to scroll" #:########################################################################################:# # OFFLINE NOTES: # a) Home Assistant offline, but WiFi/network and MQTT online: # - Clock display should continue using SNTP if the NTP servers are reachable. # - MQTT scroll, announce, cancel_id, brightness, and 12h/24h mode commands should still work. # # b) MQTT offline, but WiFi/network and Home Assistant API online: # - Home Assistant text, light, number, and select entities should still work. # - Clock display should continue using SNTP if the NTP servers are reachable. # - MQTT announce and cancel_id commands will not be available. # # c) Entire WiFi/network offline: # - SNTP will not be available after boot. # - Clock display will use the current_mins fallback from common/sntp_common.yaml. # - Accurate time is needed for the clock, so fallback time will drift. # - For a rough reset, power-cycle the device at 12:00 noon. # - Home Assistant and MQTT controls will not be available while offline. #:########################################################################################:# #:########################################################################################:# # 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-ledmatrix1" friendly_name: "LED Matrix Display" description_comment: "LED Matrix Display (Layout V1.1)" device_area: "Lounge" # Allows ESP device to be automatically linked to an Area in Home Assistant. # Project Naming project_name: "Generic.ESP32 Supermini" project_version: "v1.15a" # Passwords & Secrets api_key: !secret esp-api_key ota_pass: !secret esp-ota_pass static_ip_address: !secret esp-ledmatrix1_ip # Substitutions cannot be used inside secret names. mqtt_command_main_topic: !secret mqtt_command_main_topic mqtt_status_main_topic: !secret mqtt_status_main_topic # If changing IP addresses, update current_ip_address here, otherwise it remains static_ip_address. # Do not forget to switch it back when the address change is complete. current_ip_address: ${static_ip_address} # MQTT LOCAL Controls mqtt_device_name: "ledmatrix1" 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" # NONE, ERROR, WARN, INFO, DEBUG, VERBOSE, VERY_VERBOSE update_interval: "60s" # Matrix Display Settings pin_mosi: GPIO8 pin_cs: GPIO9 pin_sck: GPIO10 num_chips: "4" scroll_delay_ms: "15" # ms between scroll steps matrix_initial_brightness: "2" # Default MAX7219 intensity, 1-15. static_wait_seconds_default: "5" # Default time to hold static messages on screen. static_text_gap_pixels: "1" # Vertical blank pixels between clock and static text during roll transitions. static_transition_delay_ms: "60" # Delay per vertical step during static roll transitions. y_offset: "0" y_scroll_offset: "0" # Announcement / Sounder Settings pin_sounder: GPIO4 sounder_pulse_ms: "120" #:########################################################################################:# # 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 (Needed for the clock display) #### 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}" # Appears on the ESPHome page in Home Assistant. area: "${device_area}" project: name: "${project_name}" version: "${project_version}" #:########################################################################################:# # ESP PLATFORM AND FRAMEWORK: # https://esphome.io/components/esp32/ #:########################################################################################:# esp32: variant: esp32c3 framework: type: esp-idf version: recommended #esp8266: # board: esp01_1m # early_pin_init: false # Recommended false where switches are involved. Defaults to true. # board_flash_mode: dout # Default is dout. #:########################################################################################:# # LOGGER: ESPHome Logging Enable # https://esphome.io/components/logger.html #:########################################################################################:# logger: level: "${log_level}" # INFO suggested, or DEBUG for testing. #baud_rate: 0 # Set to 0 for no UART logging if UART is used for another device. #esp8266_store_log_strings_in_flash: false #tx_buffer_size: 64 #:########################################################################################:# # GLOBALS: # https://esphome.io/components/globals.html #:########################################################################################:# globals: # Text currently being scrolled across the matrix. - id: scroll_text type: std::string initial_value: "" # Current X position of the scrolling text. - id: scroll_x type: int initial_value: '0' # True while a scroll message is active. - id: scrolling type: bool initial_value: 'false' # False = 24h mode, true = 12h mode. - id: use_12h_mode type: bool restore_value: true initial_value: 'false' # Runtime scroll delay, controlled by the Scroll Delay number entity. - id: scroll_delay_runtime type: int restore_value: true initial_value: '${scroll_delay_ms}' # Current scroll-specific settings. # Normal /scroll commands copy the current default settings into these. # /announce commands set these per message. - id: scroll_effective_transition_mode type: int restore_value: false initial_value: '-1' - id: scroll_effective_delay_ms type: int restore_value: false initial_value: '-1' # Per-message static wait override. # -1 means use the default Static Wait Time number entity. - id: scroll_effective_static_wait_seconds type: int restore_value: false initial_value: '-1' # Stored for future sounder logic. Currently not used by display logic. - id: scroll_effective_sound_id type: int restore_value: false initial_value: '0' # Static notification display state. # Used by transition IDs 3 and 4. - id: static_message type: std::string restore_value: false initial_value: "" # First complete screenful of a long static message. # Used only during the roll-in and hold phases. - id: static_visible_message type: std::string restore_value: false initial_value: "" # Optional next character for long static messages. # Drawn with clipping so the right edge can show a partial character # without starting the horizontal scroll early. - id: static_partial_char type: std::string restore_value: false initial_value: "" - id: static_partial_x type: int restore_value: false initial_value: '0' - id: static_text_x type: int restore_value: false initial_value: '0' # Extra pixel offset used only for long static messages with leading spaces. # This makes leading spaces visibly shift the x=0 start position to the right. - id: static_long_x_offset type: int restore_value: false initial_value: '0' - id: static_text_y type: int restore_value: false initial_value: '0' - id: static_clock_y type: int restore_value: false initial_value: '0' - id: static_text_width type: int restore_value: false initial_value: '0' - id: static_text_fits type: bool restore_value: false initial_value: 'false' - id: static_active type: bool restore_value: false initial_value: 'false' - id: static_show_clock type: bool restore_value: false initial_value: 'false' - id: static_show_text type: bool restore_value: false initial_value: 'false' - id: static_scrolling type: bool restore_value: false initial_value: 'false' - id: static_wait_seconds type: int restore_value: true initial_value: '${static_wait_seconds_default}' - id: static_transition_step type: int restore_value: false initial_value: '0' # MAX7219 intensity: 0=OFF, 1..15=ON. Driven by the HA light and brightness slider. - id: matrix_intensity type: int restore_value: false initial_value: '${matrix_initial_brightness}' # Last non-zero brightness level selected by HA, MQTT, or the 1-15 slider. # This allows the exact slider to keep its value when the display is switched off. - id: matrix_last_nonzero_intensity type: int restore_value: true initial_value: '${matrix_initial_brightness}' # Brightness sync loop protection for HA light <-> MQTT brightness updates. - id: suppress_mqtt_bright_sync type: bool restore_value: false initial_value: 'false' - id: target_mqtt_bright type: int restore_value: false initial_value: '-1' - id: last_mqtt_bright type: int restore_value: false initial_value: '-1' # Re-entry effect after scrolling: 0=Slide up, 1=Slide down, 2=Pixel fall. - id: transition_mode type: int restore_value: true initial_value: '0' # Slide transition state. - id: transition_active type: bool restore_value: false initial_value: 'false' - id: transition_y type: int restore_value: false initial_value: '0' # Pixel-fall transition state. - id: pixel_fall_active type: bool restore_value: false initial_value: 'false' - id: pixel_fall_gen type: int restore_value: false initial_value: '0' # Announcement scheduler. Not restored after reboot. # Each item has its own due time so repeated messages can interleave. - id: announce_ids type: std::vector restore_value: false initial_value: 'std::vector()' - id: announce_messages type: std::vector restore_value: false initial_value: 'std::vector()' - id: announce_transition_modes type: std::vector restore_value: false initial_value: 'std::vector()' - id: announce_speeds type: std::vector restore_value: false initial_value: 'std::vector()' - id: announce_repeat_seconds type: std::vector restore_value: false initial_value: 'std::vector()' - id: announce_static_wait_seconds type: std::vector restore_value: false initial_value: 'std::vector()' - id: announce_sound_ids type: std::vector restore_value: false initial_value: 'std::vector()' - id: announce_remaining type: std::vector restore_value: false initial_value: 'std::vector()' - id: announce_sounder_remaining type: std::vector restore_value: false initial_value: 'std::vector()' - id: announce_next_due_ms type: std::vector restore_value: false initial_value: 'std::vector()' - id: announce_tokens type: std::vector restore_value: false initial_value: 'std::vector()' # Incrementing token used so duplicate IDs cannot accidentally alter a newer message. - id: announce_next_token type: uint32_t restore_value: false initial_value: '1' # Scheduler worker scratch values. - id: announce_due_index type: int restore_value: false initial_value: '-1' - id: announce_current_token type: uint32_t restore_value: false initial_value: '0' - id: announce_current_id type: std::string restore_value: false initial_value: "" - id: announce_current_message type: std::string restore_value: false initial_value: "" - id: announce_current_transition_mode type: int restore_value: false initial_value: '0' - id: announce_current_speed type: int restore_value: false initial_value: '15' - id: announce_current_repeat_seconds type: int restore_value: false initial_value: '0' - id: announce_current_static_wait_seconds type: int restore_value: false initial_value: '-1' - id: announce_current_sound_id type: int restore_value: false initial_value: '0' - id: announce_current_should_sound type: bool restore_value: false initial_value: 'false' #:########################################################################################:# # FONT COMPONENT: # https://esphome.io/components/font/ #:########################################################################################:# font: # Clock digits. Keep glyphs limited to reduce firmware size. - file: "fonts/Eight-Bit-Dragon.ttf" id: digit5 size: 8 glyphs: ["0123456789: -"] # General 5x8 font for scroll text. - file: "fonts/5x8.bdf" id: font5x8 size: 8 #:########################################################################################:# # SPI COMPONENT: # https://esphome.io/components/spi.html #:########################################################################################:# spi: clk_pin: ${pin_sck} mosi_pin: ${pin_mosi} #:########################################################################################:# # TEXT COMPONENT: # https://esphome.io/components/text/template/ #:########################################################################################:# text: - platform: template id: ha_scroll_text name: "${friendly_name} Scroll Text" icon: "mdi:message-text-outline" mode: text max_length: 80 optimistic: false set_action: - lambda: |- std::string s = x.c_str(); s.erase(std::remove_if(s.begin(), s.end(), [](unsigned char c){ return c < 0x20; }), s.end()); // Publish the cleaned text so the HA UI updates immediately. id(ha_scroll_text).publish_state(s.c_str()); // Empty text only clears the entity. It should not start a scroll. if (s.empty()) return; id(scroll_text) = s; id(scroll_x) = ${num_chips} * 8; id(scroll_effective_transition_mode) = id(transition_mode); id(scroll_effective_delay_ms) = id(scroll_delay_runtime); id(scroll_effective_static_wait_seconds) = -1; id(scroll_effective_sound_id) = 0; id(start_scroll_sequence).execute(); id(clear_scroll_text_box).execute(); #:########################################################################################:# # NUMBER COMPONENT: # https://esphome.io/components/number/template/ #:########################################################################################:# number: - platform: template id: scroll_delay_number name: "${friendly_name} Scroll Delay" icon: "mdi:timer-outline" unit_of_measurement: "ms" min_value: 1 max_value: 100 step: 1 restore_value: true initial_value: ${scroll_delay_ms} optimistic: true set_action: - lambda: |- id(scroll_delay_runtime) = (int) x; - platform: template id: static_wait_number name: "${friendly_name} Static Wait Time" icon: "mdi:timer-sand" unit_of_measurement: "s" min_value: 0 max_value: 30 step: 1 restore_value: true initial_value: ${static_wait_seconds_default} optimistic: true set_action: - lambda: |- int v = (int) lroundf(x); if (v < 0) v = 0; if (v > 30) v = 30; id(static_wait_seconds) = v; - platform: template id: matrix_brightness_number name: "${friendly_name} Brightness Level" icon: "mdi:brightness-6" min_value: 1 max_value: 15 step: 1 restore_value: true initial_value: ${matrix_initial_brightness} optimistic: true set_action: - lambda: |- int v = (int) lroundf(x); if (v < 1) v = 1; if (v > 15) v = 15; id(matrix_last_nonzero_intensity) = v; // Setting the exact brightness slider also turns the display on. auto call = id(matrix_light).turn_on(); call.set_brightness((float) v / 15.0f); call.perform(); #:########################################################################################:# # OUTPUT COMPONENT: # https://esphome.io/components/output/ #:########################################################################################:# output: # Template output lets the HA light and brightness slider control MAX7219 intensity. - platform: template id: matrix_brightness_out type: float write_action: - lambda: |- int pub = 0; if (state <= 0.001f) { id(matrix_intensity) = 0; id(matrix).turn_on_off(false); pub = 0; } else { int v = (int) lroundf(state * 15.0f); if (v < 1) v = 1; if (v > 15) v = 15; id(matrix_intensity) = v; id(matrix_last_nonzero_intensity) = v; id(matrix_brightness_number).publish_state((float) v); id(matrix).turn_on_off(true); pub = v; } id(target_mqtt_bright) = pub; - component.update: matrix # Publish brightness back to MQTT unless this update came from MQTT. - if: condition: lambda: |- return !id(suppress_mqtt_bright_sync) && (id(target_mqtt_bright) != id(last_mqtt_bright)); then: - mqtt.publish: topic: "${mqtt_command_topic}/bright" payload: !lambda |- char buf[6]; snprintf(buf, sizeof(buf), "%d", id(target_mqtt_bright)); return std::string(buf); qos: 0 retain: true - lambda: |- id(last_mqtt_bright) = id(target_mqtt_bright); # Optional announcement sounder / active beeper output. # Default GPIO is GPIO4. Change pin_sounder if this conflicts with your wiring. - platform: gpio id: announcement_sounder_output pin: ${pin_sounder} #:########################################################################################:# # LIGHT COMPONENT: # https://esphome.io/components/light/monochromatic/ #:########################################################################################:# light: - platform: monochromatic id: matrix_light name: "${friendly_name} Display" icon: "mdi:brightness-6" output: matrix_brightness_out gamma_correct: 1.0 default_transition_length: 0s restore_mode: RESTORE_DEFAULT_ON #:########################################################################################:# # SELECT COMPONENT: # https://esphome.io/components/select/template/ #:########################################################################################:# select: - platform: template id: clock_format_select name: "${friendly_name} Clock Format" icon: "mdi:clock-time-three-outline" options: - "24" - "12" lambda: |- return esphome::optional(std::string(id(use_12h_mode) ? "12" : "24")); set_action: - lambda: |- id(use_12h_mode) = (x == "12"); - component.update: clock_format_select - component.update: matrix - platform: template id: transition_select name: "${friendly_name} Transition Effect" icon: "mdi:transition" options: - "Slide up" - "Slide down" - "Pixel fall" - "Static Up, Down" - "Static Down, Up" lambda: |- if (id(transition_mode) == 4) return std::string("Static Down, Up"); if (id(transition_mode) == 3) return std::string("Static Up, Down"); if (id(transition_mode) == 2) return std::string("Pixel fall"); if (id(transition_mode) == 1) return std::string("Slide down"); return std::string("Slide up"); set_action: - lambda: |- if (x == "Static Down, Up") id(transition_mode) = 4; else if (x == "Static Up, Down") id(transition_mode) = 3; else if (x == "Pixel fall") id(transition_mode) = 2; else if (x == "Slide down") id(transition_mode) = 1; else id(transition_mode) = 0; - component.update: transition_select #:########################################################################################:# # SCRIPT COMPONENT: # https://esphome.io/components/script.html #:########################################################################################:# script: # Clear the HA text box shortly after a scroll request has been accepted. - id: clear_scroll_text_box mode: restart then: - delay: 400ms - lambda: |- id(ha_scroll_text).publish_state(""); # Short active-beeper pulse before selected announcement repeats. - id: announcement_sounder_beep mode: restart then: - output.turn_on: announcement_sounder_output - delay: ${sounder_pulse_ms}ms - output.turn_off: announcement_sounder_output # Interleaved announcement scheduler worker. # Each item scrolls once when due, then reschedules itself if repeats remain. - id: announcement_queue_worker mode: single then: - while: condition: lambda: |- return !id(announce_ids).empty(); then: # Wait until the display is free before selecting the next due announcement. - wait_until: condition: lambda: |- return !id(scrolling) && !id(transition_active) && !id(pixel_fall_active) && !id(static_active); # Find the oldest due announcement. # If nothing is due yet, leave announce_due_index as -1. - lambda: |- id(announce_due_index) = -1; if (id(announce_ids).empty()) return; const uint32_t now_ms = millis(); int best_index = -1; uint32_t best_due = 0; for (int i = 0; i < (int) id(announce_ids).size(); i++) { const uint32_t due = id(announce_next_due_ms)[i]; // Signed subtraction handles millis rollover safely enough for this use. if ((int32_t) (now_ms - due) >= 0) { if (best_index < 0 || (int32_t) (best_due - due) > 0) { best_index = i; best_due = due; } } } id(announce_due_index) = best_index; # Nothing is due yet. Check again shortly so new messages can still run quickly. - if: condition: lambda: |- return id(announce_due_index) < 0; then: - delay: 250ms # A message is due. Copy it into current scratch globals and scroll it once. - if: condition: lambda: |- return id(announce_due_index) >= 0 && id(announce_due_index) < (int) id(announce_ids).size(); then: - lambda: |- const int i = id(announce_due_index); id(announce_current_token) = id(announce_tokens)[i]; id(announce_current_id) = id(announce_ids)[i]; id(announce_current_message) = id(announce_messages)[i]; id(announce_current_transition_mode) = id(announce_transition_modes)[i]; id(announce_current_speed) = id(announce_speeds)[i]; id(announce_current_repeat_seconds) = id(announce_repeat_seconds)[i]; id(announce_current_static_wait_seconds) = id(announce_static_wait_seconds)[i]; id(announce_current_sound_id) = id(announce_sound_ids)[i]; id(announce_current_should_sound) = false; if (id(announce_sounder_remaining)[i] > 0) { id(announce_current_should_sound) = true; id(announce_sounder_remaining)[i] -= 1; } id(scroll_text) = id(announce_current_message); id(scroll_x) = ${num_chips} * 8; id(scroll_effective_transition_mode) = id(announce_current_transition_mode); id(scroll_effective_delay_ms) = id(announce_current_speed); id(scroll_effective_static_wait_seconds) = id(announce_current_static_wait_seconds); id(scroll_effective_sound_id) = id(announce_current_sound_id); - if: condition: lambda: |- return id(announce_current_should_sound); then: - script.execute: announcement_sounder_beep - script.execute: start_scroll_sequence # Give the scroll sequence time to enter its active state. - delay: 100ms # Wait for scroll and re-entry animation to finish before rescheduling. - wait_until: condition: lambda: |- return !id(scrolling) && !id(transition_active) && !id(pixel_fall_active) && !id(static_active); # Find the same token again. It may have been cancelled or replaced while scrolling. # If it still exists, decrement repeats and either remove or reschedule it. - lambda: |- int idx = -1; for (int i = 0; i < (int) id(announce_tokens).size(); i++) { if (id(announce_tokens)[i] == id(announce_current_token)) { idx = i; break; } } if (idx < 0) { ESP_LOGI("announce", "Announcement token already cancelled/replaced id=%s", id(announce_current_id).c_str()); return; } id(announce_remaining)[idx] -= 1; if (id(announce_remaining)[idx] <= 0) { ESP_LOGI("announce", "Announcement complete id=%s", id(announce_ids)[idx].c_str()); id(announce_ids).erase(id(announce_ids).begin() + idx); id(announce_messages).erase(id(announce_messages).begin() + idx); id(announce_transition_modes).erase(id(announce_transition_modes).begin() + idx); id(announce_speeds).erase(id(announce_speeds).begin() + idx); id(announce_repeat_seconds).erase(id(announce_repeat_seconds).begin() + idx); id(announce_static_wait_seconds).erase(id(announce_static_wait_seconds).begin() + idx); id(announce_sound_ids).erase(id(announce_sound_ids).begin() + idx); id(announce_remaining).erase(id(announce_remaining).begin() + idx); id(announce_sounder_remaining).erase(id(announce_sounder_remaining).begin() + idx); id(announce_next_due_ms).erase(id(announce_next_due_ms).begin() + idx); id(announce_tokens).erase(id(announce_tokens).begin() + idx); } else { const uint32_t gap_ms = (uint32_t) id(announce_repeat_seconds)[idx] * 1000UL; id(announce_next_due_ms)[idx] = millis() + gap_ms; ESP_LOGI("announce", "Rescheduled announcement id=%s, remaining=%d, gap=%ds", id(announce_ids)[idx].c_str(), id(announce_remaining)[idx], id(announce_repeat_seconds)[idx]); } id(announce_due_index) = -1; id(announce_current_token) = 0; id(announce_current_id) = ""; id(announce_current_message) = ""; id(announce_current_static_wait_seconds) = -1; id(announce_current_sound_id) = 0; id(announce_current_should_sound) = false; # Static transition sequence used by transition IDs 3 and 4. # ID 3: Static Up, Down. Clock rolls up, message rolls up, then clock rolls down. # ID 4: Static Down, Up. Clock rolls down, message rolls down, then clock rolls up. - id: static_display_sequence mode: restart then: - lambda: |- const int matrix_width = ${num_chips} * 8; id(static_message) = id(scroll_text); // Preserve leading spaces for manual layout control. // Remove only trailing whitespace so accidental spaces before the parameters block // do not affect centering or width calculations. id(static_message).erase( std::find_if(id(static_message).rbegin(), id(static_message).rend(), [](unsigned char ch){ return !std::isspace(ch); }).base(), id(static_message).end()); // Measure text using the actual ESPHome display font bounds instead of a // fixed pixels-per-character estimate. This allows the first long-message // screenful to use as much of the 32-pixel display as possible. auto measure_text_width = [&](const std::string &text) -> int { if (text.empty()) return 0; int x1 = 0; int y1 = 0; int width = 0; int height = 0; id(matrix).get_text_bounds( 0, 0, text.c_str(), id(font5x8), esphome::display::TextAlign::TOP_LEFT, &x1, &y1, &width, &height ); // For text printed at x=0, the right edge is what matters for fitting // completely inside the 32-pixel display. In normal fonts x1 is 0, but // using the right edge makes this safer with different font metrics. int right_edge = x1 + width; if (right_edge < width) right_edge = width; if (right_edge < 0) right_edge = 0; return right_edge; }; // Count normal leading spaces before measuring long static messages. // Some display font bounds/render paths do not make leading space width obvious, // so long static messages use leading spaces as an explicit pixel offset. int leading_space_count = 0; while (leading_space_count < (int) id(static_message).length() && id(static_message)[leading_space_count] == ' ') { leading_space_count += 1; } std::string message_without_leading_spaces = id(static_message); if (leading_space_count > 0) { message_without_leading_spaces = id(static_message).substr(leading_space_count); } const int manual_leading_offset = leading_space_count * 4; const int stripped_text_width = measure_text_width(message_without_leading_spaces); const int effective_text_width = manual_leading_offset + stripped_text_width; id(static_long_x_offset) = 0; id(static_text_width) = measure_text_width(id(static_message)); id(static_text_fits) = id(static_text_width) <= matrix_width; // If the normal bounds calculation treats leading spaces as too small, // also test the explicit-offset width. This is mainly for long static messages. if (effective_text_width > matrix_width) { id(static_text_fits) = false; } if (id(static_text_fits)) { id(static_visible_message) = id(static_message); // Actual centering is done with TextAlign::TOP_CENTER in the display lambda. id(static_text_x) = matrix_width / 2; } else { // Long static text: // - preserve leading spaces visually using static_long_x_offset // - render/scroll the non-space part of the message from that offset // - hold the first fixed viewport until Static Wait Time has elapsed id(static_message) = message_without_leading_spaces; id(static_long_x_offset) = manual_leading_offset; id(static_text_width) = stripped_text_width; const int available_width = matrix_width - id(static_long_x_offset); std::string best_fit = ""; int best_fit_width = 0; const int char_count = (int) id(static_message).length(); for (int len = 1; len <= char_count; len++) { std::string candidate = id(static_message).substr(0, len); int candidate_width = measure_text_width(candidate); if (candidate_width <= available_width) { best_fit = candidate; best_fit_width = candidate_width; } else { break; } } // Safety fallback: show at least the first character if measurement fails. if (best_fit.empty() && char_count > 0) { best_fit = id(static_message).substr(0, 1); best_fit_width = measure_text_width(best_fit); } id(static_visible_message) = best_fit; id(static_partial_char) = ""; id(static_partial_x) = 0; if (best_fit_width < available_width && (int) best_fit.length() < char_count) { id(static_partial_char) = id(static_message).substr(best_fit.length(), 1); id(static_partial_x) = id(static_long_x_offset) + best_fit_width; } id(static_text_x) = id(static_long_x_offset); } id(static_text_y) = 0; id(static_clock_y) = 0; id(static_active) = true; id(static_show_clock) = true; id(static_show_text) = true; id(static_scrolling) = false; id(static_transition_step) = 0; id(scrolling) = false; id(pixel_fall_active) = false; id(transition_active) = true; # Entry transition: move the clock off-screen while the static text moves on-screen. - while: condition: lambda: |- return id(static_transition_step) <= (8 + ${static_text_gap_pixels}); then: - lambda: |- const int distance = 8 + ${static_text_gap_pixels}; const int step = id(static_transition_step); int tm = id(scroll_effective_transition_mode); if (tm < 0) tm = id(transition_mode); if (tm == 4) { // Static Down, Up: clock exits down, text enters from above. id(static_clock_y) = step; id(static_text_y) = -distance + step; } else { // Static Up, Down: clock exits up, text enters from below. id(static_clock_y) = -step; id(static_text_y) = distance - step; } id(transition_y) = id(static_clock_y); id(static_show_clock) = true; id(static_show_text) = true; id(static_transition_step) += 1; - component.update: matrix - delay: ${static_transition_delay_ms}ms # Hold the message. Short text stays centred; long text stays fixed at x=0 before scrolling. - lambda: |- id(static_text_y) = 0; id(static_show_clock) = false; id(static_show_text) = true; id(transition_active) = false; - component.update: matrix - delay: !lambda |- int s = id(scroll_effective_static_wait_seconds); if (s < 0) s = id(static_wait_seconds); if (s < 0) s = 0; if (s > 30) s = 30; return (uint32_t) s * 1000UL; # Long text now follows the requested sequence: # roll in at x=0, wait, scroll left from x=0 until fully off-screen, # then roll the clock back in. - if: condition: lambda: |- return !id(static_text_fits); then: - script.execute: do_static_scroll - script.wait: do_static_scroll # Return transition: bring the clock back and move the static text off-screen. - lambda: |- id(static_transition_step) = 0; id(static_show_clock) = true; id(static_show_text) = id(static_text_fits); id(transition_active) = true; - while: condition: lambda: |- return id(static_transition_step) <= (8 + ${static_text_gap_pixels}); then: - lambda: |- const int distance = 8 + ${static_text_gap_pixels}; const int step = id(static_transition_step); int tm = id(scroll_effective_transition_mode); if (tm < 0) tm = id(transition_mode); if (tm == 4) { // Static Down, Up return: clock rises from below, text exits upward. id(static_clock_y) = distance - step; id(static_text_y) = -step; } else { // Static Up, Down return: clock drops from above, text exits downward. id(static_clock_y) = -distance + step; id(static_text_y) = step; } id(transition_y) = id(static_clock_y); id(static_transition_step) += 1; - component.update: matrix - delay: ${static_transition_delay_ms}ms - lambda: |- id(static_active) = false; id(static_message) = ""; id(static_visible_message) = ""; id(static_partial_char) = ""; id(static_partial_x) = 0; id(static_long_x_offset) = 0; id(static_show_clock) = false; id(static_show_text) = false; id(static_scrolling) = false; id(transition_active) = false; id(transition_y) = ${y_offset}; - component.update: matrix # Scroll long static text after it has rolled in and waited. # The long message has already been displayed at x=0 during the hold. # This routine then moves it left from x=0 until the whole message is off-screen. - id: do_static_scroll mode: restart then: - lambda: |- id(static_scrolling) = true; id(static_show_clock) = false; id(static_show_text) = true; id(static_text_y) = 0; id(static_text_x) = id(static_long_x_offset); id(transition_active) = false; - while: condition: lambda: |- return id(static_scrolling); then: # Move first, then draw. The message was already held at x=0, # so the first scroll frame should be x=-1 rather than another # extra static frame at x=0. - lambda: |- id(static_text_x) -= 1; int end_limit = -id(static_text_width); if (end_limit >= 0) end_limit = -1; if (id(static_text_x) <= end_limit) { id(static_scrolling) = false; id(static_show_text) = false; } - component.update: matrix - delay: !lambda |- int d = id(scroll_effective_delay_ms); if (d < 1) d = id(scroll_delay_runtime); if (d < 1) d = 1; if (d > 100) d = 100; return (uint32_t) d; # Sequence: exit slide-down -> scroll -> selected re-entry. - id: start_scroll_sequence mode: restart then: - lambda: |- id(scrolling) = false; id(pixel_fall_active) = false; id(static_active) = false; - if: condition: lambda: |- int tm = id(scroll_effective_transition_mode); if (tm < 0) tm = id(transition_mode); return tm == 3 || tm == 4; then: - script.execute: static_display_sequence else: - component.update: matrix - script.execute: clock_exit_down - script.wait: clock_exit_down - lambda: |- id(scrolling) = true; - script.execute: do_scroll # Scroll the stored text until it has moved fully off the left side. - id: do_scroll mode: restart then: - while: condition: lambda: |- return id(scrolling); then: - lambda: |- id(scroll_x) -= 1; const int end_limit = - (int(id(scroll_text).length()) * 6); if (id(scroll_x) < end_limit) { id(scrolling) = false; int tm = id(scroll_effective_transition_mode); if (tm < 0) tm = id(transition_mode); if (tm == 1) { id(clock_slide_down_entry).execute(); } else if (tm == 2) { id(clock_pixel_fall_transition).execute(); } else { id(clock_slide_up_entry).execute(); } } - component.update: matrix - delay: !lambda |- int d = id(scroll_effective_delay_ms); if (d < 1) d = id(scroll_delay_runtime); if (d < 1) d = 1; if (d > 100) d = 100; return (uint32_t) d; # Exit: slide clock down off-screen and hold it there to prevent a one-frame flash. - id: clock_exit_down mode: restart then: - lambda: |- id(transition_active) = true; id(transition_y) = ${y_offset}; - repeat: count: 9 then: - lambda: |- id(transition_y) += 1; - component.update: matrix - delay: 45ms - lambda: |- id(transition_active) = true; id(transition_y) = 8; - component.update: matrix # Re-entry: clock slides down into place from above. - id: clock_slide_down_entry mode: restart then: - lambda: |- id(transition_active) = true; id(transition_y) = -8; - repeat: count: 9 then: - lambda: |- id(transition_y) += 1; - component.update: matrix - delay: 60ms - lambda: |- id(transition_active) = false; id(transition_y) = ${y_offset}; - component.update: matrix # Re-entry: clock slides up into place from below. - id: clock_slide_up_entry mode: restart then: - lambda: |- id(transition_active) = true; id(transition_y) = 8; - repeat: count: 9 then: - lambda: |- id(transition_y) -= 1; - component.update: matrix - delay: 60ms - lambda: |- id(transition_active) = false; id(transition_y) = ${y_offset}; - component.update: matrix # Re-entry: falling pixels effect before the clock redraws. - id: clock_pixel_fall_transition mode: restart then: - lambda: |- id(transition_active) = false; id(transition_y) = ${y_offset}; id(pixel_fall_active) = true; id(pixel_fall_gen) += 1; - repeat: count: 18 then: - component.update: matrix - delay: 55ms - lambda: |- id(pixel_fall_active) = false; id(transition_active) = false; id(transition_y) = ${y_offset}; - component.update: matrix #:########################################################################################:# # DISPLAY COMPONENT: # https://esphome.io/components/display/max7219digit/ #:########################################################################################:# display: - platform: max7219digit id: matrix cs_pin: ${pin_cs} num_chips: ${num_chips} intensity: 2 update_interval: 500ms rotate_chip: 0 reverse_enable: false flip_x: false lambda: |- // Apply brightness only when it changes to reduce flicker. static int last_brightness = -1; if (id(matrix_intensity) != last_brightness) { if (id(matrix_intensity) > 0) { int v = id(matrix_intensity); if (v > 15) v = 15; it.intensity((uint8_t) v); } last_brightness = id(matrix_intensity); } if (id(matrix_intensity) == 0) return; it.clear(); // Static notifications draw the message and optionally the clock during roll transitions. if (id(static_active)) { if (id(static_show_text)) { if (id(static_text_fits) && !id(static_scrolling)) { // Short text is centred across the whole 32-pixel display. it.print((${num_chips} * 8) / 2, id(static_text_y), id(font5x8), TextAlign::TOP_CENTER, id(static_visible_message).c_str()); } else if (!id(static_scrolling)) { // Long text entry/hold phase: show a fixed first viewport at x=0. // The complete prefix is printed normally. If part of the next // character fits at the right edge, print only that character inside // a clipping window so its shape comes from the actual font. // No horizontal scrolling is allowed before Static Wait Time. it.start_clipping(0, 0, ${num_chips} * 8, 8); it.print(id(static_text_x), id(static_text_y), id(font5x8), id(static_visible_message).c_str()); if (!id(static_partial_char).empty()) { it.print(id(static_partial_x), id(static_text_y), id(font5x8), id(static_partial_char).c_str()); } it.end_clipping(); } else { // Long text scroll phase: after Static Wait Time, scroll the full message from x=0. it.print(id(static_text_x), id(static_text_y), id(font5x8), id(static_message).c_str()); } } if (!id(static_show_clock)) { return; } } // Scroll text has priority over clock display. if (id(scrolling)) { it.print(id(scroll_x), ${y_scroll_offset}, id(font5x8), id(scroll_text).c_str()); return; } // Pixel fall shows only particles while active. if (id(pixel_fall_active)) { static int last_gen = -1; static int px[24]; static int py[24]; static uint32_t rng = 1; auto rand_u32 = [&]() -> uint32_t { rng = rng * 1664525u + 1013904223u; return rng; }; if (last_gen != id(pixel_fall_gen)) { last_gen = id(pixel_fall_gen); rng = 0xA5A5A5A5u ^ (uint32_t) id(pixel_fall_gen); for (int i = 0; i < 24; i++) { px[i] = (int)(rand_u32() % 32); py[i] = - (int)(rand_u32() % 16); } } for (int i = 0; i < 24; i++) { int spd = 1 + (int)(rand_u32() % 2); py[i] += spd; if (py[i] > 10) { px[i] = (int)(rand_u32() % 32); py[i] = - (int)(rand_u32() % 10); } for (int t = 0; t < 2; t++) { int yy = py[i] - t; int xx = px[i]; if (xx >= 0 && xx <= 31 && yy >= 0 && yy <= 7) { it.draw_pixel_at(xx, yy, Color::WHITE); } } } return; } // Prefer real SNTP time. Fall back to current_mins from common/sntp_common.yaml. auto now = id(sntp_time).now(); bool time_valid = now.is_valid(); int raw_hour = 0; int minute_to_show = 0; int second_to_show = 0; if (time_valid) { raw_hour = now.hour; minute_to_show = now.minute; second_to_show = now.second; } else { int mins = id(current_mins); if (mins < 0) mins = 0; if (mins >= 1440) mins = mins % 1440; raw_hour = mins / 60; minute_to_show = mins % 60; second_to_show = 0; } int hour_to_show = raw_hour; if (id(use_12h_mode)) { hour_to_show = raw_hour % 12; if (hour_to_show == 0) hour_to_show = 12; } char hh[3], mm[3]; snprintf(hh, sizeof(hh), "%02d", hour_to_show); snprintf(mm, sizeof(mm), "%02d", minute_to_show); // In 24h mode there is no A/P indicator on the right, so shift the clock one pixel right. const int clock_x_offset = id(use_12h_mode) ? 0 : 1; const int x_h1 = 3 + clock_x_offset; const int x_h2 = 9 + clock_x_offset; const int x_m1 = 17 + clock_x_offset; const int x_m2 = 23 + clock_x_offset; const int y = (id(transition_active) ? id(transition_y) : ${y_offset}); char ch[2] = { hh[0], 0 }; it.print(x_h1, y, id(digit5), ch); ch[0] = hh[1]; it.print(x_h2, y, id(digit5), ch); ch[0] = mm[0]; it.print(x_m1, y, id(digit5), ch); ch[0] = mm[1]; it.print(x_m2, y, id(digit5), ch); // In 12h mode, draw a tiny A or P indicator at the far right. if (id(use_12h_mode)) { const bool is_pm = raw_hour >= 12; const uint8_t A_rows[4] = { 2, 5, 7, 5 }; const uint8_t P_rows[4] = { 6, 5, 6, 4 }; const uint8_t *L = is_pm ? P_rows : A_rows; const int x_ampm = 29; const int y_ampm = y + 4; for (int r = 0; r < 4; r++) { uint8_t row = L[r]; for (int c = 0; c < 3; c++) { if (row & (1 << (2 - c))) { int xx = x_ampm + c; int yy = y_ampm + r; if (yy >= 0 && yy <= 7 && xx >= 0 && xx <= 31) { it.draw_pixel_at(xx, yy, Color::WHITE); } } } } } // Blink the colon when SNTP is valid. Keep it solid during fallback time. const bool colon_on = time_valid ? ((second_to_show % 2) == 0) : true; if (colon_on) { const int x_col = (x_h2 + x_m1) / 2 + 2; int y1 = y + 2; int y2 = y + 5; if (y1 >= 0 && y1 <= 7) it.draw_pixel_at(x_col, y1, Color::WHITE); if (y2 >= 0 && y2 <= 7) it.draw_pixel_at(x_col, y2, Color::WHITE); } #:########################################################################################:# # API COMPONENT: Device-specific Home Assistant native API actions # Common API encryption/reboot settings remain in common/api_common.yaml. # https://esphome.io/components/api.html #:########################################################################################:# api: actions: # Home Assistant action: esphome.esp_ledmatrix1_scroll_text # Example HA data: { message: "Hello from Home Assistant" } - action: scroll_text variables: message: string then: - lambda: |- std::string s = message; s.erase(std::remove_if(s.begin(), s.end(), [](unsigned char c){ return c < 0x20; }), s.end()); // Ignore empty API scroll commands completely. if (s.empty()) return; // Keep the HA text entity visually in sync with this direct API action. id(ha_scroll_text).publish_state(s.c_str()); id(scroll_text) = s; id(scroll_x) = ${num_chips} * 8; id(scroll_effective_transition_mode) = id(transition_mode); id(scroll_effective_delay_ms) = id(scroll_delay_runtime); id(scroll_effective_static_wait_seconds) = -1; id(scroll_effective_sound_id) = 0; id(start_scroll_sequence).execute(); id(clear_scroll_text_box).execute(); #:########################################################################################:# # MQTT COMPONENT: Device-specific MQTT command triggers # Common broker, login, topic_prefix, and reboot settings remain in common/mqtt_common.yaml. #:########################################################################################:# mqtt: discovery: false birth_message: topic: "${mqtt_status_topic}/status" payload: "online" qos: 0 retain: true will_message: topic: "${mqtt_status_topic}/status" payload: "offline" qos: 0 retain: true on_message: # Scroll a text payload once across the matrix. - topic: "${mqtt_command_topic}/scroll" then: - lambda: |- std::string s = x.c_str(); s.erase(std::remove_if(s.begin(), s.end(), [](unsigned char c){ return c < 0x20; }), s.end()); // Ignore empty MQTT scroll commands completely. if (s.empty()) return; id(scroll_text) = s; id(scroll_x) = ${num_chips} * 8; id(scroll_effective_transition_mode) = id(transition_mode); id(scroll_effective_delay_ms) = id(scroll_delay_runtime); id(scroll_effective_static_wait_seconds) = -1; id(scroll_effective_sound_id) = 0; id(start_scroll_sequence).execute(); # Scheduled/interleaved announcement with per-message parameters. # Format: # Message text [identifier,transition_mode,speed,repeat_seconds,repeat_times,static_wait,sounder_quantity,sound_id] # # Example: # Washing machine complete [wash,1,15,10,6,5,1,0] - topic: "${mqtt_command_topic}/announce" then: - lambda: |- std::string payload = x.c_str(); payload.erase(std::remove_if(payload.begin(), payload.end(), [](unsigned char c){ return c < 0x20 || c == 0x7F; }), payload.end()); auto trim = [](std::string &s) { s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch){ return !std::isspace(ch); })); s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch){ return !std::isspace(ch); }).base(), s.end()); }; // Preserve leading spaces in the displayed message. // Do not trim the whole payload before extracting the message/parameter split. if (payload.empty()) return; size_t rb = payload.rfind(']'); size_t lb = payload.rfind('['); if (lb == std::string::npos || rb == std::string::npos || rb <= lb) { ESP_LOGW("announce", "Invalid announcement format. Missing [parameters]."); return; } std::string message = payload.substr(0, lb); std::string vars = payload.substr(lb + 1, rb - lb - 1); // Preserve leading spaces in the displayed message for manual layout. // Only remove trailing spaces before the [parameters] block. message.erase(std::find_if(message.rbegin(), message.rend(), [](unsigned char ch){ return !std::isspace(ch); }).base(), message.end()); trim(vars); if (message.empty() || vars.empty()) { ESP_LOGW("announce", "Invalid announcement. Message or parameters are empty."); return; } std::vector parts; std::string part; for (char c : vars) { if (c == ',') { trim(part); parts.push_back(part); part.clear(); } else { part.push_back(c); } } trim(part); parts.push_back(part); if (parts.size() != 8) { ESP_LOGW("announce", "Invalid announcement. Expected 8 parameters."); return; } auto clean_id = [](std::string s) -> std::string { s.erase(std::remove_if(s.begin(), s.end(), [](unsigned char c){ return !(std::isalnum(c) || c == '_' || c == '-'); }), s.end()); if (s.length() > 8) s = s.substr(0, 8); return s; }; auto parse_int = [](std::string s, int min_v, int max_v, int fallback) -> int { s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch){ return !std::isspace(ch); })); s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch){ return !std::isspace(ch); }).base(), s.end()); if (s.empty()) return fallback; for (char c : s) { if (!std::isdigit((unsigned char) c)) return fallback; } int v = atoi(s.c_str()); if (v < min_v) v = min_v; if (v > max_v) v = max_v; return v; }; std::string announce_id = clean_id(parts[0]); if (announce_id.empty()) { ESP_LOGW("announce", "Invalid announcement. Identifier is empty after cleaning."); return; } int new_transition_mode = parse_int(parts[1], 0, 4, 0); int new_speed = parse_int(parts[2], 1, 100, id(scroll_delay_runtime)); int new_repeat_seconds = parse_int(parts[3], 0, 99, 0); int new_repeat_times = parse_int(parts[4], 0, 99, 1); int new_static_wait_seconds = parse_int(parts[5], 0, 30, id(static_wait_seconds)); int new_sounder_quantity = parse_int(parts[6], 0, 99, 0); int new_sound_id = parse_int(parts[7], 0, 999, 0); // Treat repeat_times 0 as a one-shot announcement. if (new_repeat_times == 0) new_repeat_times = 1; if (new_sounder_quantity > new_repeat_times) { new_sounder_quantity = new_repeat_times; } // If the same ID already exists, remove all older scheduled copies first. // The token system prevents the currently scrolling old copy from altering this new copy. for (int i = (int) id(announce_ids).size() - 1; i >= 0; i--) { if (id(announce_ids)[i] == announce_id) { id(announce_ids).erase(id(announce_ids).begin() + i); id(announce_messages).erase(id(announce_messages).begin() + i); id(announce_transition_modes).erase(id(announce_transition_modes).begin() + i); id(announce_speeds).erase(id(announce_speeds).begin() + i); id(announce_repeat_seconds).erase(id(announce_repeat_seconds).begin() + i); id(announce_static_wait_seconds).erase(id(announce_static_wait_seconds).begin() + i); id(announce_sound_ids).erase(id(announce_sound_ids).begin() + i); id(announce_remaining).erase(id(announce_remaining).begin() + i); id(announce_sounder_remaining).erase(id(announce_sounder_remaining).begin() + i); id(announce_next_due_ms).erase(id(announce_next_due_ms).begin() + i); id(announce_tokens).erase(id(announce_tokens).begin() + i); } } uint32_t token = id(announce_next_token); id(announce_next_token) += 1; if (id(announce_next_token) == 0) id(announce_next_token) = 1; id(announce_ids).push_back(announce_id); id(announce_messages).push_back(message); id(announce_transition_modes).push_back(new_transition_mode); id(announce_speeds).push_back(new_speed); id(announce_repeat_seconds).push_back(new_repeat_seconds); id(announce_static_wait_seconds).push_back(new_static_wait_seconds); id(announce_sound_ids).push_back(new_sound_id); id(announce_remaining).push_back(new_repeat_times); id(announce_sounder_remaining).push_back(new_sounder_quantity); id(announce_next_due_ms).push_back(millis()); id(announce_tokens).push_back(token); ESP_LOGI("announce", "Scheduled announcement id=%s, token=%u, repeats=%d, gap=%ds, static_wait=%ds, sound_id=%d", announce_id.c_str(), token, new_repeat_times, new_repeat_seconds, new_static_wait_seconds, new_sound_id); - script.execute: announcement_queue_worker # Cancel an active or scheduled announcement by short ID. - topic: "${mqtt_command_topic}/cancel_id" then: - lambda: |- std::string cancel_id = x.c_str(); cancel_id.erase(std::remove_if(cancel_id.begin(), cancel_id.end(), [](unsigned char c){ return !(std::isalnum(c) || c == '_' || c == '-'); }), cancel_id.end()); if (cancel_id.length() > 8) cancel_id = cancel_id.substr(0, 8); if (cancel_id.empty()) return; bool found = false; for (int i = (int) id(announce_ids).size() - 1; i >= 0; i--) { if (id(announce_ids)[i] == cancel_id) { id(announce_ids).erase(id(announce_ids).begin() + i); id(announce_messages).erase(id(announce_messages).begin() + i); id(announce_transition_modes).erase(id(announce_transition_modes).begin() + i); id(announce_speeds).erase(id(announce_speeds).begin() + i); id(announce_repeat_seconds).erase(id(announce_repeat_seconds).begin() + i); id(announce_static_wait_seconds).erase(id(announce_static_wait_seconds).begin() + i); id(announce_sound_ids).erase(id(announce_sound_ids).begin() + i); id(announce_remaining).erase(id(announce_remaining).begin() + i); id(announce_sounder_remaining).erase(id(announce_sounder_remaining).begin() + i); id(announce_next_due_ms).erase(id(announce_next_due_ms).begin() + i); id(announce_tokens).erase(id(announce_tokens).begin() + i); found = true; } } if (found) { ESP_LOGI("announce", "Cancelled scheduled announcement id=%s", cancel_id.c_str()); } else { ESP_LOGI("announce", "Cancel ID not found: %s", cancel_id.c_str()); } # Set 12h or 24h clock mode from MQTT. - topic: "${mqtt_command_topic}/mode" then: - lambda: |- if (x == "12") { id(use_12h_mode) = true; } else if (x == "24") { id(use_12h_mode) = false; } - component.update: clock_format_select - component.update: matrix # Brightness from MQTT. 0=OFF, 1..15=ON brightness level. - topic: "${mqtt_command_topic}/bright" then: - lambda: |- std::string s = x.c_str(); s.erase(std::remove_if(s.begin(), s.end(), [](unsigned char c){ return c < 0x20 || c == 0x7F; }), s.end()); s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch){ return !std::isspace(ch); })); s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch){ return !std::isspace(ch); }).base(), s.end()); if (s.empty() || !std::all_of(s.begin(), s.end(), [](unsigned char c){ return std::isdigit(c); })) return; int v = atoi(s.c_str()); if (v < 0) v = 0; if (v > 15) v = 15; id(suppress_mqtt_bright_sync) = true; if (v == 0) { id(matrix_light).turn_off().perform(); } else { id(matrix_last_nonzero_intensity) = v; id(matrix_brightness_number).publish_state((float) v); auto call = id(matrix_light).turn_on(); call.set_brightness((float) v / 15.0f); call.perform(); } id(last_mqtt_bright) = v; id(suppress_mqtt_bright_sync) = false;