# ClockClock 24 - board D, wall column 3: mini-clocks 7 / 9 / 11, the right
# column of the hours-units digit, top to bottom.
#
# THE MASTER. The only board on the wall with a network: it gets the time from
# SNTP and broadcasts it down the one-wire bus once a second, so the other
# seven boards stay in step. It drives three panels like every other board - it
# is not a ninth box.
#
#   esphome run board_d.yaml
#
# WHY D AND NOT A. D sits in the middle of the row, which is also where the
# MP1584 and the 5 V feed belong - so one board carries the regulator, the
# power input and the network, and that board is the one you build and test
# first. It is the whole wall in miniature: chain any other carrier to it with
# a 4-pin cable and that carrier gets 3.3 V for its panels and the time, with
# nothing else set up.
#
# Wiring: D1 is TX here and reaches every other board through the daisy chain,
# in both directions. Common ground.

esphome:
  name: cc24-board-d
  # HA splits project.name on the dot into manufacturer and model, so this
  # registers as `tuct` / `digitalclockclock24` in the device registry. It is
  # how the Home Assistant add-on finds the master: a house has plenty of
  # `text.` entities, and guessing at their names is not identification.
  project:
    name: "tuct.digitalclockclock24"
    version: "1.1"

packages:
  node: !include common.yaml

substitutions:
  clock_index_a: "7"     # row 0
  clock_index_b: "9"     # row 1
  clock_index_c: "11"    # row 2
  # `time` = the real clock, `demo` = a fake minute every 5 s for bring-up.
  # Master-only, because the master owns the wall's mode: it broadcasts what it
  # is doing and the other seven boards follow, demo minute counter included.
  # There is nothing to set on a slave. Pass it rather than editing this:
  #   esphome -s clock_mode demo run board_d.yaml
  clock_mode: "time"

# The only networked board on the wall. The other seven carry none of this.
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

# Needed for the pattern and cycle entities below - they are Home Assistant
# entities, and `api:` is what publishes them.
api:
  encryption:
    key: !secret api_key

ota:
  - platform: esphome
    password: !secret ota_password

# TX only - nothing on the bus talks back.
uart:
  id: sync_bus
  tx_pin: ${sync_pin}
  baud_rate: 115200

time:
  # The master's own clock, and the widgets' `time_id`.
  - platform: sntp
    id: clock_time
    timezone: "CET-1CEST,M3.5.0,M10.5.0/3"
  # The broadcaster. `broadcast_interval` is what puts it in master role;
  # `lvgl_clock_id` makes the idle animation mode travel with the time, so the
  # whole wall spins/flies/shows time together.
  #
  # All three widgets are listed, and the order matters: the FIRST one is the
  # wall's only picker - it runs `cycle_modes:` and its choice is what goes on
  # the wire. The other two are marked followers and are handed the same mode,
  # exactly as a slave board is. That way nothing on the wall, on this board or
  # any other, ever chooses a choreography for itself.
  - platform: lvgl_clock
    id: sync_out
    uart_id: sync_bus
    lvgl_clock_id: [dc_a, dc_b, dc_c]
    broadcast_interval: 1s
    # The wall's temperature source, for `mode: temp`. Master only - the
    # reading rides along in the sync packet to every other board.
    temperature_sensor_id: room_temp
    # Motion patterns exported from tools/clockclock24-sim. Baked into THIS
    # board's firmware and pushed to the other seven over the bus, so a new
    # pattern is one file plus a reflash of the master - the slaves need no
    # filesystem and no separate flash step.
    #
    # `pattern_delay` is why this works at all: the master is the only board
    # with Wi-Fi, so it is up and talking while the slaves are still bringing
    # up PSRAM, three panels and LVGL. 30 s clears that comfortably. The repeat
    # is so a board rebooted later picks them up on its own.
    patterns: patterns
    pattern_delay: 30s
    pattern_repeat: 5min

globals:
  # Latches once the clock is real and the wall has been put on the time. See
  # the interval below for why it exists.
  - id: boot_time_shown
    type: bool
    restore_value: false
    initial_value: "false"

interval:
  # Boot phases: spin while connecting, birds while waiting for NTP, then the
  # real time. Only the master runs this - the mode it lands on rides the sync
  # packet, so the slaves follow rather than each deciding for themselves.
  #
  # Addressed to dc_a ONLY, on purpose. dc_a is the picker; dc_b and dc_c are
  # followers and are handed its mode by the broadcaster once a second. Driving
  # them directly here would fight that: mid-choreography this would snap them
  # back to the time every second while dc_a kept animating, and the board's
  # three panels would visibly disagree.
  - interval: 1s
    then:
      # The substitution is baked into the lambda at codegen - it comes out as
      # strcmp("time", "demo") - so the test is a constant the compiler folds
      # away, and the branch is decided by the value you build with rather than
      # re-evaluated every second.
      - if:
          condition:
            lambda: 'return strcmp("${clock_mode}", "demo") == 0;'
          then:
            # Bring-up: hold the fake-minute counter instead of the real clock.
            # Re-asserted every second rather than set once at boot, so it also
            # reclaims the wall after each cycle_modes window - set_mode is a
            # no-op when it is already demo, and is deferred (not lost) while a
            # choreography is playing.
            - lvgl_clock.demo: dc_a
          else:
            - if:
                condition:
                  lambda: "return id(clock_time).now().is_valid();"
                then:
                  # ONCE, on the transition to a valid clock - not every
                  # second. Re-asserting it every second fought anything that
                  # set a mode from outside: pick `spiral` in Home Assistant
                  # and this put the wall back on `time` a second later, which
                  # looked exactly like the select was broken.
                  #
                  # It does not need repeating any more: a choreography window
                  # restores the mode it interrupted when it closes.
                  - if:
                      condition:
                        lambda: "return !id(boot_time_shown);"
                      then:
                        - lvgl_clock.show_time: dc_a
                        - lambda: "id(boot_time_shown) = true;"
                else:
                  - if:
                      condition:
                        wifi.connected:
                      then:
                        - lvgl_clock.flying_birds: dc_a
                      else:
                        - lvgl_clock.rotate_left: dc_a
                  # Lost the clock: let the boot sequence land on the time
                  # again when it comes back.
                  - lambda: "id(boot_time_shown) = false;"

# ---------------------------------------------------------------------------
# Runtime control from Home Assistant. MASTER ONLY, and it needs no changes on
# the other seven boards at all:
#
#   - the cycle list and its interval are read only by the picker, which is
#     this board, so they never go on the wire;
#   - patterns do go on the wire, but through the push that already exists.
#
# These are stock `template` entities rather than a custom ESPHome platform, so
# they can be renamed, dropped or added to here without touching the component.
# ---------------------------------------------------------------------------
text:
  # Eight pattern slots, read AND write. Reading back matters as much as
  # writing: the state is the pattern that is actually loaded, so you can copy
  # one wall's pattern into another by copying the string.
  #
  # The format is "<name>:<base64>" - about 170 characters, which is what the
  # packing in pattern_store.h exists to achieve. A pattern as JSON is ~5 kB
  # and a Home Assistant text entity holds 255.
  #
  # Export one from tools/clockclock24-sim with "Copy for ESPHome".
  - platform: template
    name: "Pattern 1"
    id: pattern_1
    mode: text
    max_length: 255
    lambda: 'return id(sync_out).get_pattern_text(0);'
    set_action:
      - lambda: |-
          id(sync_out).set_pattern_text(0, x);
          id(pattern_1).publish_state(id(sync_out).get_pattern_text(0));
    update_interval: 60s
  - platform: template
    name: "Pattern 2"
    id: pattern_2
    mode: text
    max_length: 255
    lambda: 'return id(sync_out).get_pattern_text(1);'
    set_action:
      - lambda: |-
          id(sync_out).set_pattern_text(1, x);
          id(pattern_2).publish_state(id(sync_out).get_pattern_text(1));
    update_interval: 60s
  - platform: template
    name: "Pattern 3"
    id: pattern_3
    mode: text
    max_length: 255
    lambda: 'return id(sync_out).get_pattern_text(2);'
    set_action:
      - lambda: |-
          id(sync_out).set_pattern_text(2, x);
          id(pattern_3).publish_state(id(sync_out).get_pattern_text(2));
    update_interval: 60s
  - platform: template
    name: "Pattern 4"
    id: pattern_4
    mode: text
    max_length: 255
    lambda: 'return id(sync_out).get_pattern_text(3);'
    set_action:
      - lambda: |-
          id(sync_out).set_pattern_text(3, x);
          id(pattern_4).publish_state(id(sync_out).get_pattern_text(3));
    update_interval: 60s
  - platform: template
    name: "Pattern 5"
    id: pattern_5
    mode: text
    max_length: 255
    lambda: 'return id(sync_out).get_pattern_text(4);'
    set_action:
      - lambda: |-
          id(sync_out).set_pattern_text(4, x);
          id(pattern_5).publish_state(id(sync_out).get_pattern_text(4));
    update_interval: 60s
  - platform: template
    name: "Pattern 6"
    id: pattern_6
    mode: text
    max_length: 255
    lambda: 'return id(sync_out).get_pattern_text(5);'
    set_action:
      - lambda: |-
          id(sync_out).set_pattern_text(5, x);
          id(pattern_6).publish_state(id(sync_out).get_pattern_text(5));
    update_interval: 60s
  - platform: template
    name: "Pattern 7"
    id: pattern_7
    mode: text
    max_length: 255
    lambda: 'return id(sync_out).get_pattern_text(6);'
    set_action:
      - lambda: |-
          id(sync_out).set_pattern_text(6, x);
          id(pattern_7).publish_state(id(sync_out).get_pattern_text(6));
    update_interval: 60s
  - platform: template
    name: "Pattern 8"
    id: pattern_8
    mode: text
    max_length: 255
    lambda: 'return id(sync_out).get_pattern_text(7);'
    set_action:
      - lambda: |-
          id(sync_out).set_pattern_text(7, x);
          id(pattern_8).publish_state(id(sync_out).get_pattern_text(7));
    update_interval: 60s

  # What the wall is drawn in, as #rrggbb. On the wire like everything else, so
  # one automation warms all 24 clocks at sunset instead of eight reflashes:
  #
  #   - service: text.set_value
  #     target: {entity_id: text.cc24_board_d_hand_colour}
  #     data: {value: "#ff8c3c"}
  #
  # Rejected rather than guessed at if it is not a colour - a hand colour that
  # silently becomes black is a wall that has stopped working.
  - platform: template
    name: "Hand colour"
    id: hand_colour_text
    mode: text
    max_length: 7
    lambda: 'return lvgl_clock::LvglClock::rgb_to_string(id(dc_a).get_foreground_rgb());'
    set_action:
      - lambda: |-
          uint32_t v;
          if (lvgl_clock::LvglClock::rgb_from_string(x, v)) {
            id(dc_a).set_foreground_rgb(v);
          } else {
            ESP_LOGW("cc24", "Hand colour '%s' is not #rrggbb - ignored", x.c_str());
          }
          id(hand_colour_text).publish_state(
              lvgl_clock::LvglClock::rgb_to_string(id(dc_a).get_foreground_rgb()));
    update_interval: 30s

  - platform: template
    name: "Background colour"
    id: bg_colour_text
    mode: text
    max_length: 7
    lambda: 'return lvgl_clock::LvglClock::rgb_to_string(id(dc_a).get_background_rgb());'
    set_action:
      - lambda: |-
          uint32_t v;
          if (lvgl_clock::LvglClock::rgb_from_string(x, v)) {
            id(dc_a).set_background_rgb(v);
          } else {
            ESP_LOGW("cc24", "Background '%s' is not #rrggbb - ignored", x.c_str());
          }
          id(bg_colour_text).publish_state(
              lvgl_clock::LvglClock::rgb_to_string(id(dc_a).get_background_rgb()));
    update_interval: 30s

  # The choreography rotation, in order. Repeats are meaningful: listing `temp`
  # every other entry gives it half the slots. Unknown names are dropped with a
  # warning and the field republishes what was accepted, so a typo shows up
  # rather than silently changing the wall.
  - platform: template
    name: "Cycle modes"
    id: cycle_modes_text
    mode: text
    max_length: 255
    lambda: 'return id(dc_a).get_cycle_modes_text();'
    set_action:
      # Publish the result straight back, rather than waiting for the next
      # poll. Without this the field snaps to its old value in Home Assistant
      # for up to 30 s after you type - which reads as "it did not work", and
      # you cannot see which entries were accepted.
      - lambda: |-
          id(dc_a).set_cycle_modes_text(x);
          id(cycle_modes_text).publish_state(id(dc_a).get_cycle_modes_text());
    update_interval: 30s

select:
  # What the wall is doing right now, and a way to change it.
  #
  # This is an OVERRIDE, not a preference: with a cycle interval set, the next
  # window opens on schedule and takes the wall back. Set the interval to `off`
  # to make a choice stick.
  - platform: template
    name: "Mode"
    id: mode_select
    options:
      - "time"
      - "rotate_left"
      - "flying_birds"
      - "wave"
      - "spiral"
      - "wind"
      - "rotating_maze"
      - "zipper"
      - "mirror_wave"
      - "love"
      - "temp"
      - "pattern"
    lambda: 'return id(dc_a).get_mode_name();'
    set_action:
      - lambda: |-
          id(dc_a).set_mode_by_name(x);
          id(mode_select).publish_state(id(dc_a).get_mode_name());
    # A second, so the picker follows the wall when the cycle moves it on. The
    # mode is one enum read - this costs nothing.
    update_interval: 1s

  # Which pattern `mode: pattern` draws. Separate from Mode on purpose: Mode
  # says WHAT the wall is doing, this says WHICH pattern - so switching between
  # your patterns does not mean re-picking the mode each time.
  #
  # Changing it while the wall is in `pattern` swaps immediately. Slots with
  # nothing in them fall back to showing the time, which is why all eight are
  # listed even when the patterns/ folder holds fewer: it is the capacity, not
  # a promise. The names live in the Pattern 1..8 text fields above.
  - platform: template
    name: "Pattern"
    id: pattern_select
    options: ["1", "2", "3", "4", "5", "6", "7", "8"]
    lambda: |-
      return std::string(1, (char) ('1' + id(dc_a).get_pattern_slot()));
    set_action:
      - lambda: |-
          id(dc_a).set_pattern_slot(atoi(x.c_str()) - 1);
          id(pattern_select).publish_state(x);
    update_interval: 5s

  # How often a window opens. The 35 s window itself is fixed - only the
  # cadence is yours to set.
  #
  # `off` stops the rotation entirely: the wall then shows whatever the Mode
  # select says, and only changes when you or an automation change it.
  - platform: template
    name: "Cycle interval"
    id: cycle_interval_select
    options: ["off", "1 min", "2 min", "3 min", "4 min", "5 min", "10 min", "15 min", "30 min", "60 min"]
    lambda: |-
      uint32_t s = id(dc_a).get_cycle_interval();
      if (s == 0) return std::string("off");
      // to_string rather than a fixed buffer: a uint32_t is up to ten digits,
      // so "%u min" into char[12] is a truncation warning even though nothing
      // above 60 is ever offered. No buffer, nothing to size wrong.
      return std::to_string(s / 60) + " min";
    set_action:
      # atoi("off") is 0, which is exactly what "no cycling" is - but spell it
      # out rather than leaning on that.
      - lambda: |-
          uint32_t secs = (x == "off") ? 0 : (uint32_t) atoi(x.c_str()) * 60;
          id(dc_a).set_cycle_interval(secs);
          id(cycle_interval_select).publish_state(x);
    update_interval: 30s

  # How the two hands travel to a new digit. Cosmetic, but it changes the
  # character of every sweep on the wall - `opposite` is the ClockClock look,
  # the hands arriving from either side; `long` takes the scenic route.
  #
  # Set here, broadcast to all seven listeners. It has to be the whole wall or
  # none of it: two boards routing differently is visible in a single digit.
  - platform: template
    name: "Movement"
    id: movement_select
    options: ["opposite", "clockwise", "counter", "long"]
    lambda: 'return id(dc_a).get_movement_name();'
    set_action:
      - lambda: |-
          id(dc_a).set_movement_by_name(x);
          id(movement_select).publish_state(id(dc_a).get_movement_name());
    update_interval: 30s

number:
  # How long a digit sweep takes. The 35 s choreography window is fixed; this
  # is the time the hands spend travelling to a new minute, and the time a mode
  # takes to fade in.
  - platform: template
    name: "Transition length"
    id: transition_number
    unit_of_measurement: "s"
    min_value: 0.5
    max_value: 20
    step: 0.5
    mode: slider
    lambda: 'return id(dc_a).get_transition_length() / 1000.0f;'
    set_action:
      - lambda: |-
          id(dc_a).set_transition_length((uint32_t) lroundf(x * 1000.0f));
          id(transition_number).publish_state(id(dc_a).get_transition_length() / 1000.0f);
    update_interval: 30s

  # Choreography speed, x1.0 = the base rates.
  #
  # A choreography is evaluated at `t * mode_speed`, so changing this moves
  # where the animation IS, not only how fast it runs. The component blends
  # into the new position rather than snapping - the same thing entering a mode
  # does - and every board applies the same number from the same packet, so the
  # wall eases across together instead of falling out of phase.
  - platform: template
    name: "Mode speed"
    id: mode_speed_number
    min_value: 0.1
    max_value: 5
    step: 0.1
    mode: slider
    lambda: 'return id(dc_a).get_mode_speed();'
    set_action:
      - lambda: |-
          id(dc_a).set_mode_speed(x);
          id(mode_speed_number).publish_state(id(dc_a).get_mode_speed());
    update_interval: 30s

button:
  # Throw away anything written from Home Assistant and go back to the
  # patterns/ folder as compiled in. The way out of a bad edit.
  - platform: template
    name: "Reload patterns from firmware"
    on_press:
      - lambda: 'id(sync_out).reload_patterns_from_firmware();'

  # Movement, sweep length, speed, both colours, the cycle list and its
  # interval are SAVED TO FLASH about ten seconds after you change them, so the
  # wall comes back the way you left it after a power cut.
  #
  # Which means flash now wins over panel.yaml: edit `mode_speed` there,
  # reflash, and nothing visible happens. This is the way back - the same role
  # the button above plays for patterns.
  - platform: template
    name: "Reset look to firmware"
    on_press:
      - lambda: 'id(sync_out).reset_wall_prefs_to_firmware();'

# Stand-in temperature for `mode: temp` until a real sensor is wired up. It
# walks 18->24 C and back so the digits actually change while you watch. Swap
# the platform for a real one (dht, bme280, homeassistant, ...) and keep the id.
#
# MASTER ONLY, on purpose: the reading travels in the sync packet, so every
# other board shows this number without a sensor of its own. Eight sensors
# would just be eight opinions about the same room.
sensor:
  - platform: template
    id: room_temp
    name: "Room Temperature"
    unit_of_measurement: "°C"
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      static int step = 0;
      step = (step + 1) % 14;
      return 18.0f + (step < 7 ? step : 14 - step);
