#:########################################################################################:#
# 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.16 2026-06-04 Added leading /s token support for intentional display spaces in scroll and announce text.
# V1.15 2026-06-04 Preserved visible leading spaces in long static transition messages using a pixel offset.
# 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.
#   - Leading /s tokens are converted to display spaces, so /s/sHello becomes two leading spaces.
#   - 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.16"

  # 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<std::string>
    restore_value: false
    initial_value: 'std::vector<std::string>()'

  - id: announce_messages
    type: std::vector<std::string>
    restore_value: false
    initial_value: 'std::vector<std::string>()'

  - id: announce_transition_modes
    type: std::vector<int>
    restore_value: false
    initial_value: 'std::vector<int>()'

  - id: announce_speeds
    type: std::vector<int>
    restore_value: false
    initial_value: 'std::vector<int>()'

  - id: announce_repeat_seconds
    type: std::vector<int>
    restore_value: false
    initial_value: 'std::vector<int>()'

  - id: announce_static_wait_seconds
    type: std::vector<int>
    restore_value: false
    initial_value: 'std::vector<int>()'

  - id: announce_sound_ids
    type: std::vector<int>
    restore_value: false
    initial_value: 'std::vector<int>()'

  - id: announce_remaining
    type: std::vector<int>
    restore_value: false
    initial_value: 'std::vector<int>()'

  - id: announce_sounder_remaining
    type: std::vector<int>
    restore_value: false
    initial_value: 'std::vector<int>()'

  - id: announce_next_due_ms
    type: std::vector<uint32_t>
    restore_value: false
    initial_value: 'std::vector<uint32_t>()'

  - id: announce_tokens
    type: std::vector<uint32_t>
    restore_value: false
    initial_value: 'std::vector<uint32_t>()'

  # 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());

          auto expand_leading_space_tokens = [](std::string &s) {
            int spaces_to_add = 0;
            size_t pos = 0;

            // Convert leading /s tokens into real display spaces.
            // Supports /sHello, /s/sHello, and /s/s/s/Hello.
            while ((pos + 1) < s.length() && s[pos] == '/' && s[pos + 1] == 's') {
              spaces_to_add += 1;
              pos += 2;
            }

            // Allow one optional separator slash after a run of /s tokens.
            if (spaces_to_add > 0 && pos < s.length() && s[pos] == '/') {
              pos += 1;
            }

            if (spaces_to_add > 0) {
              s = std::string(spaces_to_add, ' ') + s.substr(pos);
            }
          };

          expand_leading_space_tokens(s);

          // 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>(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());

            auto expand_leading_space_tokens = [](std::string &s) {
              int spaces_to_add = 0;
              size_t pos = 0;

              // Convert leading /s tokens into real display spaces.
              // Supports /sHello, /s/sHello, and /s/s/s/Hello.
              while ((pos + 1) < s.length() && s[pos] == '/' && s[pos + 1] == 's') {
                spaces_to_add += 1;
                pos += 2;
              }

              // Allow one optional separator slash after a run of /s tokens.
              if (spaces_to_add > 0 && pos < s.length() && s[pos] == '/') {
                pos += 1;
              }

              if (spaces_to_add > 0) {
                s = std::string(spaces_to_add, ' ') + s.substr(pos);
              }
            };

            expand_leading_space_tokens(s);

            // 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());

            auto expand_leading_space_tokens = [](std::string &s) {
              int spaces_to_add = 0;
              size_t pos = 0;

              // Convert leading /s tokens into real display spaces.
              // Supports /sHello, /s/sHello, and /s/s/s/Hello.
              while ((pos + 1) < s.length() && s[pos] == '/' && s[pos + 1] == 's') {
                spaces_to_add += 1;
                pos += 2;
              }

              // Allow one optional separator slash after a run of /s tokens.
              if (spaces_to_add > 0 && pos < s.length() && s[pos] == '/') {
                pos += 1;
              }

              if (spaces_to_add > 0) {
                s = std::string(spaces_to_add, ' ') + s.substr(pos);
              }
            };

            expand_leading_space_tokens(s);

            // 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());

            auto expand_leading_space_tokens = [](std::string &s) {
              int spaces_to_add = 0;
              size_t pos = 0;

              // Convert leading /s tokens into real display spaces.
              // Supports /sHello, /s/sHello, and /s/s/s/Hello.
              while ((pos + 1) < s.length() && s[pos] == '/' && s[pos + 1] == 's') {
                spaces_to_add += 1;
                pos += 2;
              }

              // Allow one optional separator slash after a run of /s tokens.
              if (spaces_to_add > 0 && pos < s.length() && s[pos] == '/') {
                pos += 1;
              }

              if (spaces_to_add > 0) {
                s = std::string(spaces_to_add, ' ') + s.substr(pos);
              }
            };

            expand_leading_space_tokens(message);

            trim(vars);

            if (message.empty() || vars.empty()) {
              ESP_LOGW("announce", "Invalid announcement. Message or parameters are empty.");
              return;
            }

            std::vector<std::string> 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;
