#
#  HomeMaster Thermostat for OpenTherm — OTT-R1
#  Factory firmware shipped on the device.
#
#  Heating control runs entirely on this device. Home Assistant is used for
#  visibility and remote control, never as a dependency: with the network
#  down the room is still heated to the stored schedule.
#
#  Hardware:  ESP32-WROOM-32U (16 MB)
#             ST7789V 320x240 display, SPI
#             SHT40 temperature/humidity, BH1750 ambient light, I2C
#             HLK-LD2410C 24 GHz presence radar, UART + GPIO
#             rotary encoder + 4 keys
#             OpenTherm master, IN GPIO21 / OUT GPIO26
#
#  Licence:   MIT
#  Source:    https://github.com/isystemsautomation/homemaster-dev
#

substitutions:
  device_name: homemaster-thermostat
  friendly_name: HomeMaster Thermostat

  # Left and right margins from the edge of the frame buffer, deliberately
  # NOT equal. The panel's visible window sits off-centre within the
  # controller's 320-pixel line: it cuts more on the left. These two numbers
  # were measured on the assembled unit — do not "tidy" them to one value, or
  # the first character of every left-aligned line goes off the glass.
  # Cut off on the left: increase margin_left.
  # Empty band on the right: decrease margin_right.
  margin_left: "42"
  margin_right: "14"

  # A temperature pushed from Home Assistant is ignored once it is older
  # than this. Without it, a Home Assistant that stops sending would leave
  # the thermostat regulating on a stale reading indefinitely.
  ha_value_timeout_ms: "1800000"

  # Backlight level while the screen is awake.
  bl_active: "0.85"
  # Screen blanks this long after the radar stops seeing anyone.
  screen_timeout: 45s

esphome:
  name: ${device_name}
  friendly_name: ${friendly_name}
  name_add_mac_suffix: true
  min_version: 2026.10.0
  project:
    name: homemaster.opentherm_thermostat
    version: "1.1.4"
  on_boot:
    priority: -100
    then:
      - light.turn_on:
          id: backlight
          brightness: ${bl_active}
      # The restored Time Zone has to reach the clock before the first
      # schedule pass, or the thermostat spends its first minutes deciding
      # comfort against the build server's timezone.
      - script.execute: apply_tz

dashboard_import:
  package_import_url: github://isystemsautomation/homemaster-dev/OpenthermThermostat/Firmware/thermostat.yaml@main
  import_full_config: true

esp32:
  variant: esp32
  board: esp32dev
  flash_size: 16MB
  framework:
    type: esp-idf
    version: recommended

logger:
  baud_rate: 115200

api:
  id: api_server
  encryption:
    # No `key:` here — intentional and required. The key is set during
    # provisioning. A key baked into a publicly published factory binary
    # would be identical on every unit, i.e. equivalent to no key at all.

provisioning:
  timeout: 15min
  on_timeout:
    then:
      - logger.log: "Provisioning window closed. Power-cycle the device to reopen it."

# No fallback access point and no captive portal. Both were removed from the
# gateway firmware at version 1.2.0 under RED Art. 3(3)(d) / EN 18031-1: an open
# access point that accepts new Wi-Fi credentials is an unauthenticated path to
# reconfigure the device. Provisioning above replaces them.
wifi:
  id: wifi_component
  on_connect:
    then:
      - delay: 10s
      - component.update: firmware_update

improv_ble:
  authorizer: none

improv_serial:

http_request:
  id: http_client

ota:
  - platform: esphome
    id: ota_esphome
  - platform: http_request
    id: ota_http

update:
  - platform: http_request
    id: firmware_update
    name: "Firmware Update"
    source: https://isystemsautomation.github.io/homemaster-dev/OpenthermThermostat/Firmware/manifest.json
    update_interval: 6h

time:
  # The timezone compiled in is only the starting point. Home Assistant sends
  # its own from 2026.3.0 and overrides it; without Home Assistant the Time
  # Zone selector below decides. It is set explicitly all the same: left out,
  # ESPHome takes the timezone of whatever machine did the build, so the
  # factory image would carry the build server's UTC and every schedule would
  # run two hours late in this country.
  - platform: homeassistant
    id: ha_time
    timezone: Europe/Berlin
  - platform: sntp
    id: sntp_time
    timezone: Europe/Berlin

# ---------------------------------------------------------------- buses

i2c:
  id: i2c_bus
  sda: GPIO18
  scl: GPIO22
  scan: true
  frequency: 100kHz

spi:
  id: spi_bus
  clk_pin: GPIO5
  mosi_pin: GPIO23

uart:
  id: uart_radar
  tx_pin: GPIO14
  rx_pin: GPIO16
  baud_rate: 256000
  parity: NONE
  stop_bits: 1

ld2410:
  id: radar

opentherm:
  id: ot_bus
  in_pin: GPIO21
  out_pin: GPIO26

# ---------------------------------------------------------------- outputs

output:
  - platform: ledc
    id: bl_pwm
    pin: GPIO25
    # 25 kHz, above the audible range: at 2 kHz the ceramic capacitors
    # on the +5 V rail sing.
    frequency: 25000Hz

light:
  - platform: monochromatic
    id: backlight
    name: "Backlight"
    output: bl_pwm
    default_transition_length: 300ms
    restore_mode: ALWAYS_ON

# ---------------------------------------------------------------- state

globals:
  - id: page
    type: int
    restore_value: false
    initial_value: '0'
  - id: focus
    type: int
    restore_value: false
    initial_value: '0'
  - id: menu_open
    type: bool
    restore_value: false
    initial_value: 'false'
  - id: heat_want
    type: bool
    restore_value: false
    initial_value: 'false'
  # millis() at which each pushed temperature last arrived. Zero means never.
  # Deliberately not restored: after a restart nothing has been pushed yet.
  - id: ha_room_at
    type: uint32_t
    restore_value: false
    initial_value: '0'
  - id: ha_hum_at
    type: uint32_t
    restore_value: false
    initial_value: '0'

  - id: ha_outside_at
    type: uint32_t
    restore_value: false
    initial_value: '0'

# ---------------------------------------------------------------- setpoints

number:
  - platform: template
    id: sp_comfort
    name: "Comfort Temperature"
    unit_of_measurement: "°C"
    min_value: 15
    max_value: 26
    step: 0.5
    initial_value: 21.5
    optimistic: true
    restore_value: true
    mode: box

  - platform: template
    id: sp_eco
    name: "Eco Temperature"
    unit_of_measurement: "°C"
    min_value: 10
    max_value: 24
    step: 0.5
    initial_value: 19
    optimistic: true
    restore_value: true
    mode: box

  - platform: template
    id: sp_boost_delta
    name: "Boost Offset"
    unit_of_measurement: "°C"
    min_value: 0
    max_value: 4
    step: 0.5
    initial_value: 1
    optimistic: true
    restore_value: true
    mode: box
    entity_category: config

  - platform: template
    id: sp_hyst
    name: "Room Hysteresis"
    unit_of_measurement: "°C"
    min_value: 0.1
    max_value: 2
    step: 0.1
    initial_value: 0.3
    optimistic: true
    restore_value: true
    mode: box
    entity_category: config

  - platform: template
    id: curve_cold_out
    name: "Curve Cold Outside"
    unit_of_measurement: "°C"
    min_value: -30
    max_value: 0
    step: 1
    initial_value: -15
    optimistic: true
    restore_value: true
    mode: box
    entity_category: config

  - platform: template
    id: curve_cold_flow
    name: "Curve Cold Flow"
    unit_of_measurement: "°C"
    min_value: 30
    max_value: 85
    step: 1
    initial_value: 65
    optimistic: true
    restore_value: true
    mode: box
    entity_category: config

  - platform: template
    id: curve_warm_out
    name: "Curve Warm Outside"
    unit_of_measurement: "°C"
    min_value: 0
    max_value: 25
    step: 1
    initial_value: 15
    optimistic: true
    restore_value: true
    mode: box
    entity_category: config

  - platform: template
    id: curve_warm_flow
    name: "Curve Warm Flow"
    unit_of_measurement: "°C"
    min_value: 20
    max_value: 60
    step: 1
    initial_value: 35
    optimistic: true
    restore_value: true
    mode: box
    entity_category: config

  # Written by a Home Assistant automation, not by hand. Any entity can feed
  # them, so the customer picks the sensor in Home Assistant instead of
  # editing the firmware.
  - platform: template
    id: ha_room_in
    name: "Room Temperature Input"
    icon: mdi:home-import-outline
    unit_of_measurement: "°C"
    min_value: -50
    max_value: 100
    step: 0.1
    optimistic: true
    restore_value: false
    entity_category: config
    mode: box
    on_value:
      then:
        - lambda: 'id(ha_room_at) = millis();'

  # Quarter-hour steps because not every country is on the hour: India is on
  # the half, Nepal and the Chatham Islands on the quarter.
  - platform: template
    id: tz_offset
    name: "UTC Offset"
    icon: mdi:earth
    unit_of_measurement: "h"
    min_value: -12
    max_value: 14
    step: 0.25
    initial_value: 1
    optimistic: true
    restore_value: true
    entity_category: config
    mode: box
    on_value:
      - script.execute: apply_tz

  - platform: template
    id: ha_hum_in
    name: "Room Humidity Input"
    icon: mdi:water-percent
    unit_of_measurement: "%"
    min_value: 0
    max_value: 100
    step: 0.1
    optimistic: true
    restore_value: false
    entity_category: config
    mode: box
    on_value:
      then:
        - lambda: 'id(ha_hum_at) = millis();'

  - platform: template
    id: ha_outside_in
    name: "Outside Temperature Input"
    icon: mdi:thermometer-chevron-down
    unit_of_measurement: "°C"
    min_value: -50
    max_value: 100
    step: 0.1
    optimistic: true
    restore_value: false
    entity_category: config
    mode: box
    on_value:
      then:
        - lambda: 'id(ha_outside_at) = millis();'

  - platform: template
    id: flow_fixed
    name: "Fixed Flow Setpoint"
    icon: mdi:radiator
    unit_of_measurement: "°C"
    min_value: 20
    max_value: 85
    step: 1
    initial_value: 55
    optimistic: true
    restore_value: true
    entity_category: config
    mode: box

  - platform: template
    id: room_offset
    name: "Room Sensor Offset"
    unit_of_measurement: "°C"
    min_value: -5
    max_value: 5
    step: 0.1
    initial_value: 0
    optimistic: true
    restore_value: true
    mode: box
    entity_category: config

  - platform: opentherm
    # Both of these were left without restore_value and without
    # initial_value. The component then publishes initial_value_, which is
    # NaN, on every boot: Home Assistant drew the slider at an undefined
    # position, the encoder could not move the value because NaN plus one
    # detent is still NaN, and the boiler was handed a hot-water setpoint
    # that meant nothing until somebody typed a number in by hand.
    t_set:
      id: ot_t_set
      name: "Boiler CH Setpoint"
      min_value: 0
      max_value: 85
      step: 0.5
      initial_value: 0
      restore_value: false
    t_dhw_set:
      id: ot_t_dhw_set
      name: "Boiler DHW Setpoint"
      min_value: 35
      max_value: 65
      step: 1
      initial_value: 50
      restore_value: true
    max_t_set:
      id: ot_max_t_set
      name: "Boiler Max CH Setpoint"
      min_value: 30
      max_value: 85
      step: 1
      # High enough not to clip the weather curve, which tops out at 65.
      initial_value: 80
      restore_value: true

  - platform: ld2410
    timeout:
      id: radar_timeout
      name: "Radar Timeout"
      entity_category: config
    max_move_distance_gate:
      id: radar_move_gate
      name: "Radar Max Move Gate"
      entity_category: config
    max_still_distance_gate:
      id: radar_still_gate
      name: "Radar Max Still Gate"
      entity_category: config

datetime:
  - platform: template
    id: sch_wd_on
    name: "Weekday Comfort Start"
    type: time
    optimistic: true
    restore_value: true
    initial_value:
      hour: 17
      minute: 0
      second: 0
    entity_category: config
  - platform: template
    id: sch_wd_off
    name: "Weekday Comfort End"
    type: time
    optimistic: true
    restore_value: true
    initial_value:
      hour: 23
      minute: 0
      second: 0
    entity_category: config
  - platform: template
    id: sch_we_on
    name: "Weekend Comfort Start"
    type: time
    optimistic: true
    restore_value: true
    initial_value:
      hour: 9
      minute: 0
      second: 0
    entity_category: config
  - platform: template
    id: sch_we_off
    name: "Weekend Comfort End"
    type: time
    optimistic: true
    restore_value: true
    initial_value:
      hour: 23
      minute: 0
      second: 0
    entity_category: config

switch:
  - platform: template
    id: heat_auto
    name: "Heating Automatic"
    icon: mdi:home-thermometer
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON

  - platform: template
    id: curve_enable
    name: "Weather Compensation"
    icon: mdi:chart-bell-curve-cumulative
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON

  - platform: template
    id: heat_boost
    name: "Boost Until Next Change"
    icon: mdi:fire-circle
    optimistic: true
    restore_mode: ALWAYS_OFF

  - platform: opentherm
    ch_enable:
      id: ot_ch_enable
      name: "Boiler CH Enable"
      restore_mode: RESTORE_DEFAULT_ON
    dhw_enable:
      id: ot_dhw_enable
      name: "Boiler DHW Enable"
      restore_mode: RESTORE_DEFAULT_ON

# ---------------------------------------------------------------- sources

# Where the two temperatures come from. Both default to the device's own
# source, so a unit that is never adopted still behaves as it did before.
select:
  # NTP carries the moment, never the zone, so a thermostat without Home
  # Assistant has no way to learn where it stands. Summer time is the half of
  # the question that cannot be expressed as an offset: the four rules below
  # are every schedule in common use, and Off gives correct standard time the
  # year round anywhere else.
  - platform: template
    id: tz_dst
    name: "Summer Time"
    icon: mdi:sun-clock
    optimistic: true
    restore_value: true
    entity_category: config
    options:
      - "Off"
      - "Europe"
      - "North America"
      - "Australia"
      - "New Zealand"
    initial_option: "Europe"
    on_value:
      - script.execute: apply_tz
  - platform: template
    id: src_outside
    name: "Outside Temperature Source"
    icon: mdi:thermometer-lines
    optimistic: true
    restore_value: true
    entity_category: config
    options:
      - "Boiler, then Home Assistant"
      - "Boiler"
      - "Home Assistant"
    initial_option: "Boiler, then Home Assistant"

  - platform: template
    id: src_room
    name: "Room Temperature Source"
    icon: mdi:home-thermometer-outline
    optimistic: true
    restore_value: true
    entity_category: config
    options:
      - "Built-in sensor"
      - "Home Assistant"
    initial_option: "Built-in sensor"

# ---------------------------------------------------------------- sensors

sensor:
  - platform: sht4x
    i2c_id: i2c_bus
    address: 0x44
    update_interval: 10s
    temperature:
      id: room_temp_raw
      name: "Room Temperature Raw"
      entity_category: diagnostic
      disabled_by_default: true
    humidity:
      id: room_hum_raw
      name: "Room Humidity Raw"
      entity_category: diagnostic
      disabled_by_default: true

  - platform: template
    id: room_temp
    name: "Room Temperature"
    unit_of_measurement: "°C"
    device_class: temperature
    state_class: measurement
    accuracy_decimals: 1
    update_interval: 10s
    lambda: |-
      // Room Temperature Source decides where this comes from. The offset is
      // applied to the built-in sensor only: a value from Home Assistant
      // comes from a thermometer that is already calibrated, and correcting
      // it here would be a second, invisible correction.
      if (id(src_room).current_option() == "Home Assistant") {
        const uint32_t at = id(ha_room_at);
        if (at != 0 && (millis() - at) < ${ha_value_timeout_ms} &&
            !std::isnan(id(ha_room_in).state))
          return id(ha_room_in).state;
        return {};
      }
      if (!id(room_temp_raw).has_state() || std::isnan(id(room_temp_raw).state))
        return {};
      return id(room_temp_raw).state + id(room_offset).state;

  - platform: template
    id: room_humidity
    name: "Room Humidity"
    unit_of_measurement: "%"
    device_class: humidity
    state_class: measurement
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      // Follows Room Temperature Source. A room whose temperature is fed from
      // Home Assistant has no reason to take its humidity from a sensor that
      // may not be fitted, so the same selector governs both.
      if (id(src_room).current_option() == "Home Assistant") {
        const uint32_t at = id(ha_hum_at);
        if (at != 0 && (millis() - at) < ${ha_value_timeout_ms} &&
            !std::isnan(id(ha_hum_in).state))
          return id(ha_hum_in).state;
        return {};
      }
      if (!id(room_hum_raw).has_state() || std::isnan(id(room_hum_raw).state))
        return {};
      return id(room_hum_raw).state;

  - platform: bh1750
    i2c_id: i2c_bus
    address: 0x23
    id: ambient_light
    name: "Ambient Light"
    update_interval: 30s

  - platform: rotary_encoder
    id: encoder
    name: "Encoder"
    internal: true
    pin_a:
      number: GPIO32
      mode:
        input: true
    pin_b:
      number: GPIO33
      mode:
        input: true
    resolution: 2
    on_clockwise:
      - script.execute: wake_screen
      - script.execute: nav_plus
    on_anticlockwise:
      - script.execute: wake_screen
      - script.execute: nav_minus

  - platform: opentherm
    t_boiler:
      id: ot_t_boiler
      name: "Boiler Water Temperature"
      unit_of_measurement: "°C"
    rel_mod_level:
      id: ot_rel_mod_level
      name: "Boiler Relative Modulation Level"
      unit_of_measurement: "%"
    t_ret:
      id: ot_t_ret
      name: "Boiler Return Temperature"
      unit_of_measurement: "°C"
      disabled_by_default: true
      filters:
        # A boiler with no return sensor answers 0.00 rather than refusing the
        # request, and the screen then showed 0 degrees of return water next to
        # 48 degrees of flow. Below 5 C is not a reading from a live circuit,
        # so it is dropped and the tile prints --.
        - lambda: 'if (x < 5.0f) return {}; return x;'
    t_dhw:
      id: ot_t_dhw
      name: "Boiler DHW Temperature"
      unit_of_measurement: "°C"
      disabled_by_default: true
    ch_pressure:
      id: ot_ch_pressure
      name: "Boiler CH Pressure"
      unit_of_measurement: "bar"
    t_outside:
      id: ot_t_outside
      name: "Outside Temperature"
      unit_of_measurement: "°C"

  # The outside temperature actually feeding the curve, after the source
  # select has been applied. Published so the choice is visible instead of
  # being buried in a lambda.
  - platform: template
    id: outside_temp
    name: "Outside Temperature In Use"
    unit_of_measurement: "°C"
    device_class: temperature
    state_class: measurement
    accuracy_decimals: 1
    update_interval: 30s
    lambda: |-
      const bool boiler_ok = id(ot_t_outside).has_state() &&
                             !std::isnan(id(ot_t_outside).state);
      const uint32_t at = id(ha_outside_at);
      const bool ha_ok = at != 0 && (millis() - at) < ${ha_value_timeout_ms} &&
                         !std::isnan(id(ha_outside_in).state);

      const auto src = id(src_outside).current_option();
      if (src == "Boiler")
        return boiler_ok ? optional<float>(id(ot_t_outside).state)
                         : optional<float>{};
      if (src == "Home Assistant")
        return ha_ok ? optional<float>(id(ha_outside_in).state)
                     : optional<float>{};
      // "Boiler, then Home Assistant": the boiler's own sensor wins when it
      // has one, because it does not depend on the network.
      if (boiler_ok) return optional<float>(id(ot_t_outside).state);
      if (ha_ok) return optional<float>(id(ha_outside_in).state);
      return optional<float>{};

  - platform: template
    id: heat_target
    name: "Heating Target"
    unit_of_measurement: "°C"
    device_class: temperature
    state_class: measurement
    accuracy_decimals: 1
    update_interval: 30s
    lambda: |-
      float t = id(in_comfort).state ? id(sp_comfort).state : id(sp_eco).state;
      if (id(heat_boost).state) t = id(sp_comfort).state + id(sp_boost_delta).state;
      return t;

  - platform: template
    id: flow_setpoint
    name: "Flow Setpoint By Curve"
    unit_of_measurement: "°C"
    device_class: temperature
    state_class: measurement
    accuracy_decimals: 1
    update_interval: 30s
    lambda: |-
      // Weather compensation, with two deliberate exits.
      //
      // Compensation switched off: the boiler is asked for Fixed Flow
      // Setpoint and nothing else.
      //
      // No outside reading from the chosen source: also Fixed Flow Setpoint.
      // The previous firmware slid to the warm end of the curve instead,
      // which on a January morning is a silent under-heat that looks like a
      // broken thermostat. A fallback the installer sets is honest; a
      // fallback that happens to be the minimum is not.
      float cap = id(ot_max_t_set).state;
      auto capped = [&](float v) {
        if (!std::isnan(cap) && v > cap) v = cap;
        return v;
      };

      const float fixed = id(flow_fixed).state;
      if (!id(curve_enable).state) return capped(fixed);

      if (!id(outside_temp).has_state() || std::isnan(id(outside_temp).state))
        return capped(fixed);

      float x1 = id(curve_cold_out).state, y1 = id(curve_cold_flow).state;
      float x2 = id(curve_warm_out).state, y2 = id(curve_warm_flow).state;
      if (x2 <= x1) return capped(fixed);

      const float x = id(outside_temp).state;
      float out;
      if (x <= x1) out = y1;
      else if (x >= x2) out = y2;
      else out = y1 + (y2 - y1) * (x - x1) / (x2 - x1);
      return capped(out);

  - platform: uptime
    id: esp_uptime
    name: "Uptime"
    update_interval: 60s
    entity_category: diagnostic

  - platform: wifi_signal
    id: wifi_signal_db
    name: "WiFi Signal"
    update_interval: 60s
    entity_category: diagnostic

  - platform: ld2410
    moving_distance:
      id: radar_moving_distance
      name: "Radar Moving Distance"
      entity_category: diagnostic
    still_distance:
      id: radar_still_distance
      name: "Radar Still Distance"
      entity_category: diagnostic
    detection_distance:
      id: radar_detection_distance
      name: "Radar Detection Distance"
      entity_category: diagnostic

binary_sensor:
  - platform: status
    id: esp_status
    name: "ESP Status"
    entity_category: diagnostic

  - platform: template
    id: in_comfort
    name: "Comfort Window"
    icon: mdi:calendar-clock
    lambda: |-
      auto t = id(ha_time).now();
      if (!t.is_valid()) t = id(sntp_time).now();
      // Without a valid clock, hold comfort: never let the house go cold
      // because the time is unknown.
      if (!t.is_valid()) return true;
      const int mins = t.hour * 60 + t.minute;
      const bool weekend = (t.day_of_week == 1 || t.day_of_week == 7);
      const int on  = weekend
          ? (id(sch_we_on).hour * 60 + id(sch_we_on).minute)
          : (id(sch_wd_on).hour * 60 + id(sch_wd_on).minute);
      const int off = weekend
          ? (id(sch_we_off).hour * 60 + id(sch_we_off).minute)
          : (id(sch_wd_off).hour * 60 + id(sch_wd_off).minute);
      if (on == off) return false;
      if (on < off) return mins >= on && mins < off;
      return mins >= on || mins < off;

  - platform: template
    id: heat_demand
    name: "Heat Demand"
    device_class: heat
    lambda: 'return id(heat_want);'

  - platform: opentherm
    fault_indication:
      id: ot_fault_indication
      name: "Boiler Fault Indication"
      entity_category: diagnostic
    flame_on:
      id: ot_flame_on
      name: "Boiler Flame On"
    ch_active:
      id: ot_ch_active
      name: "Boiler CH Active"
    dhw_active:
      id: ot_dhw_active
      name: "Boiler DHW Active"
    low_water_pressure:
      id: ot_low_water_pressure
      name: "Boiler Low Water Pressure"
      entity_category: diagnostic
    flame_fault:
      id: ot_flame_fault
      name: "Boiler Flame Fault"
      entity_category: diagnostic
    service_request:
      id: ot_service_request
      name: "Boiler Service Request"
      entity_category: diagnostic

  - platform: gpio
    id: presence_gpio
    name: "Presence"
    device_class: occupancy
    pin:
      number: GPIO4
      mode:
        input: true
        pulldown: true
    filters:
      - delayed_on_off: 50ms
    on_press:
      - script.execute: wake_screen

  - platform: ld2410
    has_target:
      id: radar_has_target
      name: "Radar Target"
      on_press:
        - script.execute: wake_screen
      on_release:
        - script.execute: wake_screen
    has_moving_target:
      id: radar_moving
      name: "Radar Moving Target"
      entity_category: diagnostic
    has_still_target:
      id: radar_still
      name: "Radar Still Target"
      entity_category: diagnostic

  # Navigation. The same four keys do the same thing on every page:
  #   menu closed:  left/right change page, the encoder changes the main value
  #   up or down:   open the menu and move through it
  #   menu open:    the encoder changes the selected item, left closes the menu
  - platform: gpio
    id: btn_right
    name: "Key Right"
    entity_category: diagnostic
    pin:
      number: GPIO34
      inverted: true
      mode:
        input: true
    filters:
      - delayed_on: 20ms
    on_press:
      - script.execute: wake_screen
      - lambda: |-
          if (id(menu_open)) id(menu_open) = false;
          else id(page) = (id(page) + 1) % 4;
          id(focus) = 0;
      - component.update: lcd

  - platform: gpio
    id: btn_left
    name: "Key Left"
    entity_category: diagnostic
    pin:
      number: GPIO39
      inverted: true
      mode:
        input: true
    filters:
      - delayed_on: 20ms
    on_press:
      - script.execute: wake_screen
      - lambda: |-
          if (id(menu_open)) id(menu_open) = false;
          else id(page) = (id(page) + 3) % 4;
          id(focus) = 0;
      - component.update: lcd

  - platform: gpio
    id: btn_up
    name: "Key Up"
    entity_category: diagnostic
    pin:
      number: GPIO35
      inverted: true
      mode:
        input: true
    filters:
      - delayed_on: 20ms
    on_press:
      - script.execute: wake_screen
      - lambda: |-
          const int items[4] = {4, 3, 7, 3};
          if (!id(menu_open)) { id(menu_open) = true; id(focus) = 0; }
          else {
            int n = items[id(page)];
            id(focus) = (id(focus) + n - 1) % n;
          }
      - component.update: lcd

  - platform: gpio
    id: btn_down
    name: "Key Down"
    entity_category: diagnostic
    pin:
      number: GPIO36
      inverted: true
      mode:
        input: true
    filters:
      - delayed_on: 20ms
    on_press:
      - script.execute: wake_screen
      - lambda: |-
          const int items[4] = {4, 3, 7, 3};
          if (!id(menu_open)) { id(menu_open) = true; id(focus) = 0; }
          else id(focus) = (id(focus) + 1) % items[id(page)];
      - component.update: lcd

text_sensor:
  - platform: version
    id: esphome_version
    name: "ESPHome Version"
    entity_category: diagnostic

  - platform: wifi_info
    ip_address:
      id: esp_ip_address
      name: "IP Address"
      entity_category: diagnostic

# ---------------------------------------------------------------- control

# Every 30 s:
#   1. target from the schedule — comfort inside the window, eco outside it,
#      comfort plus the boost offset while Boost is on;
#   2. room demand with hysteresis;
#   3. flow setpoint from the weather curve, capped by the boiler maximum;
#   4. CH enable follows the demand.
# "Heating Automatic" off suspends all of it and leaves the boiler as found.
interval:
  - interval: 30s
    id: control_loop
    then:
      - lambda: |-
          if (!id(heat_auto).state) return;
          if (!id(room_temp).has_state() || std::isnan(id(room_temp).state)) return;
          if (!id(heat_target).has_state() || std::isnan(id(heat_target).state)) return;

          const float room = id(room_temp).state;
          const float target = id(heat_target).state;
          const float hyst = id(sp_hyst).state;

          if (room <= target - hyst) id(heat_want) = true;
          else if (room >= target + hyst) id(heat_want) = false;

          float flow = id(flow_setpoint).has_state() ? id(flow_setpoint).state : NAN;
          if (std::isnan(flow)) flow = 0.0f;
          const float want = id(heat_want) ? flow : 0.0f;
          if (!id(ot_t_set).has_state() || fabsf(id(ot_t_set).state - want) >= 0.5f) {
            id(ot_t_set).make_call().set_value(want).perform();
          }

          if (id(heat_want) != id(ot_ch_enable).state) {
            if (id(heat_want)) id(ot_ch_enable).turn_on();
            else id(ot_ch_enable).turn_off();
          }
      - component.update: lcd

# ---------------------------------------------------------------- fonts

font:
  - file: &roboto_bold
      type: gfonts
      family: Roboto
      weight: 700
    id: f_hero
    size: 64
    bpp: 4
    glyphs: "0123456789.-°"

  - file: *roboto_bold
    id: f_target
    size: 32
    bpp: 4
    glyphs: &glyphset "0123456789.-°%:+/_ ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

  - file: *roboto_bold
    id: f_tile
    size: 32
    bpp: 4
    glyphs: *glyphset

  - file: *roboto_bold
    id: f_val
    size: 19
    bpp: 4
    glyphs: *glyphset

  - file: *roboto_bold
    id: f_tab
    size: 15
    bpp: 4
    glyphs: *glyphset

  - file:
      type: gfonts
      family: Roboto
      weight: 500
    id: f_sm
    size: 12
    bpp: 4
    glyphs: *glyphset

  - file: &mdi "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.ttf"
    id: f_icon
    size: 20
    bpp: 4
    glyphs: &icons
      - "\U000F0238"   # fire
      - "\U000F029A"   # gauge
      - "\U000F050F"   # thermometer
      - "\U000F0F92"   # water-boiler
      - "\U000F05A9"   # wifi
      - "\U000F05AA"   # wifi-off
      - "\U000F0026"   # alert
      - "\U000F0335"   # lightbulb
      - "\U000F02DC"   # home
      - "\U000F0438"   # radiator

  - file: *mdi
    id: f_icon_sm
    size: 16
    bpp: 4
    glyphs: *icons

color:
  - id: c_white
    hex: FFFFFF
  - id: c_grey
    hex: "9AA3AF"
  - id: c_dim
    hex: "3A4049"
  - id: c_line
    hex: "6B7684"
  - id: c_blue
    hex: "2563EB"
  - id: c_cyan
    hex: "22D3EE"
  - id: c_amber
    hex: F59E0B
  # Amber used to mean both "this circuit is running" and "the boiler has
  # faulted", which left the two unreadable from each other. Amber now means
  # running only; a fault is red, and the two places that print the word for
  # a healthy state print it green.
  - id: c_red
    hex: EF4444
  - id: c_green
    hex: 22C55E

# ---------------------------------------------------------------- display

display:
  - platform: mipi_spi
    spi_id: spi_bus
    model: ST7789V
    id: lcd
    cs_pin: GPIO17
    dc_pin: GPIO19
    reset_pin: GPIO13
    dimensions:
      width: 240
      height: 320
    rotation: 90
    invert_colors: false
    data_rate: 20MHz
    update_interval: 2s
    lambda: |-
      const int W = 320, H = 240;
      const int PAD_L = ${margin_left}, PAD_R = ${margin_right};
      const int CX = (PAD_L + (W - 1 - PAD_R)) / 2;

      const char *I_FIRE  = "\U000F0238";
      const char *I_GAUGE = "\U000F029A";
      const char *I_TEMP  = "\U000F050F";
      const char *I_BOIL  = "\U000F0F92";
      const char *I_WIFI  = "\U000F05A9";
      const char *I_NOWIFI= "\U000F05AA";
      const char *I_ALERT = "\U000F0026";
      const char *I_BULB  = "\U000F0335";
      const char *I_RAD   = "\U000F0438";

      auto okf = [](float v) { return !std::isnan(v); };
      // Takes a pointer, not a reference: ESPHome substitutes id(x) as a bare
      // pointer unless it is immediately followed by a dot, in which case it
      // substitutes the dereference. So id(s).state is s->state, but has(id(s))
      // passes sensor::Sensor *. A reference parameter does not compile.
      auto has = [&](sensor::Sensor *s) { return s->has_state() && okf(s->state); };

      const int L = PAD_L, R = W - 1 - PAD_R;
      int p = id(page);
      int f = id(focus);
      bool menu = id(menu_open);
      char b[48], b2[32];

      it.fill(Color::BLACK);

      const char *titles[4] = {"CLIMATE", "BOILER", "SCHEDULE", "SYSTEM"};
      it.print(L, 16, id(f_tab), id(c_white), TextAlign::CENTER_LEFT, titles[p]);
      for (int i = 0; i < 4; i++)
        it.filled_rectangle(L + 86 + i * 13, 13, 7, 7,
                            i == p ? id(c_white) : id(c_dim));
      auto clock = id(ha_time).now();
      if (!clock.is_valid()) clock = id(sntp_time).now();
      it.strftime(R, 16, id(f_tab), id(c_grey), TextAlign::CENTER_RIGHT,
                  clock.is_valid() ? "%H:%M" : "--:--", clock);
      // One icon, one fact. The glyph and its colour both follow Wi-Fi
      // association now: the colour used to carry the Home Assistant
      // connection as grey against dim, which nobody can tell apart from two
      // metres away. The Home Assistant link has its own row on SYSTEM.
      it.print(R - 42, 16, id(f_icon_sm),
               id(wifi_component).is_connected() ? id(c_white) : id(c_dim),
               TextAlign::CENTER_RIGHT,
               id(wifi_component).is_connected() ? I_WIFI : I_NOWIFI);
      it.print(R - 62, 16, id(f_icon_sm),
               id(ot_fault_indication).state ? id(c_red) : id(c_dim),
               TextAlign::CENTER_RIGHT, I_BOIL);
      it.line(L, 30, R, 30, id(c_line));

      // Icon and value are coloured apart. The icon carries the state of the
      // circuit, the value carries the measurement: 59 degrees of stored hot
      // water is a real reading whether or not anyone is drawing it, and used
      // to be dimmed to the same grey as a missing one.
      auto tile = [&](int x, int y, const char *ic, const char *val,
                      Color ic_c, Color val_c) {
        it.print(x, y + 16, id(f_icon), ic_c, TextAlign::CENTER_LEFT, ic);
        it.print(x + 28, y + 16, id(f_tile), val_c, TextAlign::CENTER_LEFT, val);
      };

      // A value is white when it is a reading and dim when it is the placeholder.
      auto vcol = [&](const char *v) {
        return strcmp(v, "--") == 0 ? id(c_dim) : id(c_white);
      };

      auto row = [&](int idx, int y, const char *label, const char *value,
                     Color vc) {
        bool sel = (idx >= 0 && idx == f);
        if (sel) {
          it.filled_rectangle(L, y, R - L, 26, id(c_dim));
          it.filled_rectangle(L, y, 4, 26, id(c_blue));
        }
        it.print(L + 10, y + 13, id(f_sm), sel ? id(c_white) : id(c_grey),
                 TextAlign::CENTER_LEFT, label);
        it.print(R - 8, y + 13, id(f_val), vc, TextAlign::CENTER_RIGHT, value);
      };

      bool comf = id(in_comfort).state;
      float sp = comf ? id(sp_comfort).state : id(sp_eco).state;

      if (!menu) {
        if (p == 0) {
          if (has(id(room_temp)))
            it.printf(CX, 68, id(f_hero), id(c_white), TextAlign::CENTER,
                      "%.1f°", id(room_temp).state);
          else
            it.print(CX, 68, id(f_hero), id(c_dim), TextAlign::CENTER, "--.-");

          snprintf(b, sizeof(b), "%.1f°", sp);
          it.print(CX, 120, id(f_target), id(c_cyan), TextAlign::CENTER, b);
          it.print(R, 128, id(f_sm), id(c_grey), TextAlign::CENTER_RIGHT,
                   id(heat_boost).state ? "BOOST"
                                        : (comf ? "COMFORT" : "ECO"));
          it.line(L, 144, R, 144, id(c_line));
          int half = (R - L) / 2;
          it.line(L + half, 148, L + half, 236, id(c_line));

          bool flame = id(ot_flame_on).state;
          if (flame && has(id(ot_rel_mod_level)))
            snprintf(b, sizeof(b), "%.0f%%", id(ot_rel_mod_level).state);
          else strcpy(b, flame ? "ON" : "IDLE");
          tile(L + 8, 152, I_FIRE, b,
               flame ? id(c_amber) : id(c_grey), vcol(b));

          if (has(id(ot_t_boiler))) snprintf(b2, sizeof(b2), "%.0f°", id(ot_t_boiler).state);
          else strcpy(b2, "--");
          tile(L + half + 14, 152, I_BOIL, b2,
               id(ot_ch_active).state ? id(c_amber) : id(c_grey), vcol(b2));

          if (has(id(outside_temp))) snprintf(b, sizeof(b), "%.0f°", id(outside_temp).state);
          else strcpy(b, "--");
          tile(L + 8, 194, I_TEMP, b, id(c_grey), vcol(b));

          if (has(id(ot_ch_pressure))) snprintf(b2, sizeof(b2), "%.2f", id(ot_ch_pressure).state);
          else strcpy(b2, "--");
          tile(L + half + 14, 194, I_GAUGE, b2,
               id(ot_low_water_pressure).state ? id(c_red) : id(c_grey),
               id(ot_low_water_pressure).state ? id(c_red) : vcol(b2));

        } else if (p == 1) {
          if (has(id(ot_t_boiler)))
            it.printf(CX, 68, id(f_hero), id(c_white), TextAlign::CENTER,
                      "%.1f°", id(ot_t_boiler).state);
          else
            it.print(CX, 68, id(f_hero), id(c_dim), TextAlign::CENTER, "--.-");
          if (has(id(flow_setpoint))) snprintf(b, sizeof(b), "%.0f°", id(flow_setpoint).state);
          else strcpy(b, "--");
          it.print(CX, 120, id(f_target), id(c_cyan), TextAlign::CENTER, b);
          it.print(R, 128, id(f_sm), id(c_grey), TextAlign::CENTER_RIGHT, "FLOW SET");
          it.line(L, 144, R, 144, id(c_line));
          int half = (R - L) / 2;
          it.line(L + half, 148, L + half, 236, id(c_line));

          if (has(id(ot_rel_mod_level))) snprintf(b, sizeof(b), "%.0f%%", id(ot_rel_mod_level).state);
          else strcpy(b, "--");
          tile(L + 8, 152, I_FIRE, b,
               id(ot_flame_on).state ? id(c_amber) : id(c_grey), vcol(b));
          if (has(id(ot_t_ret))) snprintf(b2, sizeof(b2), "%.0f°", id(ot_t_ret).state);
          else strcpy(b2, "--");
          tile(L + half + 14, 152, I_TEMP, b2, id(c_grey), vcol(b2));
          if (has(id(ot_t_dhw))) snprintf(b, sizeof(b), "%.0f°", id(ot_t_dhw).state);
          else strcpy(b, "--");
          tile(L + 8, 194, I_BOIL, b,
               id(ot_dhw_active).state ? id(c_amber) : id(c_grey), vcol(b));
          tile(L + half + 14, 194, I_ALERT,
               id(ot_fault_indication).state ? "FAULT" : "OK",
               id(ot_fault_indication).state ? id(c_red) : id(c_green),
               id(ot_fault_indication).state ? id(c_red) : id(c_green));

        } else if (p == 2) {
          it.print(CX, 62, id(f_target), id(c_white), TextAlign::CENTER,
                   comf ? "COMFORT" : "ECO");
          snprintf(b, sizeof(b), "%.1f°", sp);
          it.print(CX, 104, id(f_tile), id(c_cyan), TextAlign::CENTER, b);
          it.line(L, 130, R, 130, id(c_line));
          snprintf(b, sizeof(b), "%02d:%02d - %02d:%02d",
                   id(sch_wd_on).hour, id(sch_wd_on).minute,
                   id(sch_wd_off).hour, id(sch_wd_off).minute);
          row(-1, 140, "WEEKDAYS", b, id(c_white));
          snprintf(b2, sizeof(b2), "%02d:%02d - %02d:%02d",
                   id(sch_we_on).hour, id(sch_we_on).minute,
                   id(sch_we_off).hour, id(sch_we_off).minute);
          row(-1, 172, "WEEKEND", b2, id(c_white));
          row(-1, 204, "BOOST", id(heat_boost).state ? "ON" : "OFF",
              id(heat_boost).state ? id(c_amber) : id(c_grey));

        } else {
          it.print(L, 56, id(f_sm), id(c_grey), TextAlign::CENTER_LEFT, "IP");
          it.print(R, 56, id(f_val), id(c_white), TextAlign::CENTER_RIGHT,
                   id(esp_ip_address).state.c_str());
          it.print(L, 80, id(f_sm), id(c_grey), TextAlign::CENTER_LEFT, "FIRMWARE");
          // The project version as compiled in, never a literal: a hard-coded
          // string here lies after the first update and makes it impossible
          // to tell from the glass which firmware is running.
          it.print(R, 80, id(f_val), id(c_white), TextAlign::CENTER_RIGHT,
                   ESPHOME_PROJECT_VERSION);
          it.print(L, 104, id(f_sm), id(c_grey), TextAlign::CENTER_LEFT, "BOILER LINK");
          it.print(R, 104, id(f_val),
                   has(id(ot_t_boiler)) ? id(c_green) : id(c_red),
                   TextAlign::CENTER_RIGHT,
                   has(id(ot_t_boiler)) ? "OK" : "NONE");
          // The Home Assistant link used to be encoded in the colour of the
          // Wi-Fi icon, where it could not be read. It belongs here, next to
          // the other things you check when something is not answering.
          it.print(L, 128, id(f_sm), id(c_grey), TextAlign::CENTER_LEFT, "HOME ASSISTANT");
          it.print(R, 128, id(f_val),
                   id(esp_status).state ? id(c_green) : id(c_grey),
                   TextAlign::CENTER_RIGHT,
                   id(esp_status).state ? "OK" : "NONE");
          // Shown because nothing else reveals it: a thermostat with no Home
          // Assistant cannot learn where it stands, and a wrong zone looks
          // exactly like a working clock.
          it.print(L, 152, id(f_sm), id(c_grey), TextAlign::CENTER_LEFT, "TIME ZONE");
          // While Home Assistant is connected it sends its own zone and these
          // two settings are not what the clock is using, so say so rather
          // than print a setting that is being ignored.
          if (id(esp_status).state) {
            it.print(R, 152, id(f_val), id(c_white), TextAlign::CENTER_RIGHT,
                     "FROM HA");
          } else {
            const float h = id(tz_offset).state;
            const char *tag = "";
            const auto r = id(tz_dst).current_option();
            if (r == "Europe") tag = " EU";
            else if (r == "North America") tag = " NA";
            else if (r == "Australia") tag = " AU";
            else if (r == "New Zealand") tag = " NZ";
            if (h == (int) h) snprintf(b, sizeof(b), "UTC%+d%s", (int) h, tag);
            else snprintf(b, sizeof(b), "UTC%+.2f%s", h, tag);
            it.print(R, 152, id(f_val), id(c_white), TextAlign::CENTER_RIGHT, b);
          }
          it.print(L, 176, id(f_sm), id(c_grey), TextAlign::CENTER_LEFT, "ROOM RAW");
          if (has(id(room_temp_raw))) snprintf(b, sizeof(b), "%.1f°", id(room_temp_raw).state);
          else strcpy(b, "--");
          it.print(R, 176, id(f_val), vcol(b), TextAlign::CENTER_RIGHT, b);
          it.print(L, 200, id(f_sm), id(c_grey), TextAlign::CENTER_LEFT, "LIGHT");
          if (has(id(ambient_light))) snprintf(b2, sizeof(b2), "%.0f lx", id(ambient_light).state);
          else strcpy(b2, "--");
          it.print(R, 200, id(f_val), vcol(b2), TextAlign::CENTER_RIGHT, b2);
          it.print(L, 224, id(f_sm), id(c_grey), TextAlign::CENTER_LEFT, "PRESENCE");
          it.print(R, 224, id(f_val),
                   id(radar_has_target).state ? id(c_amber) : id(c_grey),
                   TextAlign::CENTER_RIGHT,
                   id(radar_has_target).state ? "YES" : "NO");
        }

      } else {
        int n = 0;
        std::string lab[8], val[8];
        Color col[8];
        for (int k = 0; k < 8; k++) col[k] = id(c_grey);
        // A time window is four numbers, so the Schedule menu is seven items
        // long and has to be drawn tighter than the others.
        auto hhmm = [&](datetime::TimeEntity *t) {
          char x[8];
          snprintf(x, sizeof(x), "%02d:%02d", t->hour, t->minute);
          return std::string(x);
        };

        if (p == 0) {
          lab[0] = comf ? "COMFORT SETPOINT" : "ECO SETPOINT";
          snprintf(b, sizeof(b), "%.1f°", sp); val[0] = b; col[0] = id(c_cyan);
          lab[1] = "HEATING";
          val[1] = id(heat_auto).state ? "AUTO" : "MANUAL";
          col[1] = id(heat_auto).state ? id(c_grey) : id(c_amber);
          lab[2] = "BOOST";
          val[2] = id(heat_boost).state ? "ON" : "OFF";
          col[2] = id(heat_boost).state ? id(c_amber) : id(c_grey);
          lab[3] = "HYSTERESIS";
          snprintf(b, sizeof(b), "%.1f°", id(sp_hyst).state); val[3] = b;
          n = 4;
        } else if (p == 1) {
          lab[0] = "DHW SETPOINT";
          snprintf(b, sizeof(b), "%.0f°", id(ot_t_dhw_set).state); val[0] = b;
          col[0] = id(c_cyan);
          lab[1] = "MAX FLOW";
          snprintf(b, sizeof(b), "%.0f°", id(ot_max_t_set).state); val[1] = b;
          col[1] = id(c_cyan);
          lab[2] = "DHW";
          val[2] = id(ot_dhw_enable).state ? "ON" : "OFF";
          col[2] = id(ot_dhw_enable).state ? id(c_amber) : id(c_grey);
          n = 3;
        } else if (p == 2) {
          lab[0] = "COMFORT";
          snprintf(b, sizeof(b), "%.1f°", id(sp_comfort).state); val[0] = b;
          col[0] = id(c_cyan);
          lab[1] = "ECO";
          snprintf(b, sizeof(b), "%.1f°", id(sp_eco).state); val[1] = b;
          col[1] = id(c_cyan);
          lab[2] = "BOOST OFFSET";
          snprintf(b, sizeof(b), "%.1f°", id(sp_boost_delta).state); val[2] = b;
          // Boost itself is on the Climate menu; repeating it here cost a row
          // the comfort windows need. The windows used to be readable on this
          // page and settable only from Home Assistant.
          lab[3] = "WEEKDAY FROM"; val[3] = hhmm(id(sch_wd_on)); col[3] = id(c_cyan);
          lab[4] = "WEEKDAY TO";   val[4] = hhmm(id(sch_wd_off)); col[4] = id(c_cyan);
          lab[5] = "WEEKEND FROM"; val[5] = hhmm(id(sch_we_on)); col[5] = id(c_cyan);
          lab[6] = "WEEKEND TO";   val[6] = hhmm(id(sch_we_off)); col[6] = id(c_cyan);
          n = 7;
        } else {
          lab[0] = "ROOM OFFSET";
          snprintf(b, sizeof(b), "%.1f°", id(room_offset).state); val[0] = b;
          col[0] = id(c_cyan);
          lab[1] = "UTC OFFSET";
          snprintf(b, sizeof(b), "%+.2f h", id(tz_offset).state); val[1] = b;
          col[1] = id(c_cyan);
          lab[2] = "SUMMER TIME";
          val[2] = id(tz_dst).current_option();
          col[2] = id(c_cyan);
          n = 3;
        }

        const int TOP = (n > 4) ? 40 : 44;
        const int PITCH = (n > 4) ? 28 : 34;
        for (int k = 0; k < n; k++)
          row(k, TOP + k * PITCH, lab[k].c_str(), val[k].c_str(), col[k]);
      }

# ---------------------------------------------------------------- scripts

script:
  # Applies the Time Zone selector to the clock. Home Assistant overwrites
  # this whenever it connects, and that is correct: it knows where the house
  # is. The selector is what a thermostat without Home Assistant has instead.
  #
  # time::set_global_tz is not part of the documented configuration surface —
  # there is no runtime timezone action in ESPHome — so this reaches into
  # esphome/components/time/posix_tz.h. If a future release changes that
  # structure the build fails here, loudly, which is the right way round.
  # Applies UTC Offset and Summer Time to the clock. Home Assistant overwrites
  # this whenever it connects, and that is correct: it knows where the house
  # is. These two settings are what a thermostat without Home Assistant has
  # instead, and between them they describe any place on earth.
  #
  # time::set_global_tz is not part of the documented configuration surface —
  # ESPHome has no runtime timezone action — so this reaches into
  # esphome/components/time/posix_tz.h. If a future release changes that
  # structure the build fails here, loudly, which is the right way round.
  #
  # The rule values are not guesses: each was produced by ESPHome's own POSIX
  # parser from the tzdata strings EST5EDT,M3.2.0,M11.1.0 (North America),
  # AEST-10AEDT,M10.1.0,M4.1.0/3 (Australia), NZST-12NZDT,M9.5.0,M4.1.0/3
  # (New Zealand) and CET-1CEST,M3.5.0,M10.5.0/3 (Europe).
  - id: apply_tz
    mode: restart
    then:
      - lambda: |-
          // POSIX counts offsets positive to the west, so the sign inverts.
          const int off = (int) lroundf(id(tz_offset).state * 3600.0f);
          const auto rule = id(tz_dst).current_option();

          time::ParsedTimezone tz{};
          tz.std_offset_seconds = -off;
          tz.dst_offset_seconds = -off - 3600;
          tz.dst_start.type = time::DSTRuleType::MONTH_WEEK_DAY;
          tz.dst_end.type = time::DSTRuleType::MONTH_WEEK_DAY;
          tz.dst_start.day_of_week = 0;   // every one of these falls on Sunday
          tz.dst_end.day_of_week = 0;

          if (rule == "Europe") {
            // The whole union turns at the same instant, 01:00 UTC, which is
            // a different local hour in each zone - hence the offset here.
            tz.dst_start.month = 3;  tz.dst_start.week = 5;
            tz.dst_start.time_seconds = off + 3600;
            tz.dst_end.month = 10;   tz.dst_end.week = 5;
            tz.dst_end.time_seconds = off + 7200;
          } else if (rule == "North America") {
            // Local 02:00 in each zone, so the same figure everywhere.
            tz.dst_start.month = 3;  tz.dst_start.week = 2;
            tz.dst_start.time_seconds = 7200;
            tz.dst_end.month = 11;   tz.dst_end.week = 1;
            tz.dst_end.time_seconds = 7200;
          } else if (rule == "Australia") {
            tz.dst_start.month = 10; tz.dst_start.week = 1;
            tz.dst_start.time_seconds = 7200;
            tz.dst_end.month = 4;    tz.dst_end.week = 1;
            tz.dst_end.time_seconds = 10800;
          } else if (rule == "New Zealand") {
            tz.dst_start.month = 9;  tz.dst_start.week = 5;
            tz.dst_start.time_seconds = 7200;
            tz.dst_end.month = 4;    tz.dst_end.week = 1;
            tz.dst_end.time_seconds = 10800;
          } else {
            // Off: standard time the year round.
            tz.dst_start.type = time::DSTRuleType::NONE;
            tz.dst_end.type = time::DSTRuleType::NONE;
            tz.dst_offset_seconds = -off;
          }
          time::set_global_tz(tz);

  # Redraw at most once every 120 ms. A full frame costs about 100 ms, so
  # redrawing on every encoder detent stalls the main loop.
  - id: ui_tick
    mode: restart
    then:
      - delay: 120ms
      - component.update: lcd

  - id: nav_plus
    mode: restart
    then:
      - lambda: |-
          // A detent moves a comfort window by ten minutes and wraps at
          // midnight. Written here rather than as a shared script because a
          // script call cannot carry which entity to move.
          auto bump = [](datetime::TimeEntity *t, int d) {
            int m = t->hour * 60 + t->minute + d;
            m = ((m % 1440) + 1440) % 1440;
            auto c = t->make_call();
            c.set_time(m / 60, m % 60, 0);
            c.perform();
          };
          // A boiler setpoint that was never set is NaN, and NaN plus a
          // detent is NaN. Start from the shipped default instead.
          auto num = [](number::Number *x, float fallback) {
            return std::isnan(x->state) ? fallback : x->state;
          };
          bool sp_edit = (id(page) == 0) && (!id(menu_open) || id(focus) == 0);
          if (sp_edit) {
            if (id(in_comfort).state) {
              auto c = id(sp_comfort).make_call();
              c.set_value(id(sp_comfort).state + 0.5f); c.perform();
            } else {
              auto c = id(sp_eco).make_call();
              c.set_value(id(sp_eco).state + 0.5f); c.perform();
            }
          } else if (id(menu_open) && id(page) == 0 && id(focus) == 1) {
            id(heat_auto).turn_on();
          } else if (id(menu_open) && id(page) == 0 && id(focus) == 2) {
            id(heat_boost).turn_on();
          } else if (id(menu_open) && id(page) == 0 && id(focus) == 3) {
            auto c = id(sp_hyst).make_call();
            c.set_value(id(sp_hyst).state + 0.1f); c.perform();
          } else if (id(menu_open) && id(page) == 1 && id(focus) == 0) {
            auto c = id(ot_t_dhw_set).make_call();
            c.set_value(num(id(ot_t_dhw_set), 50.0f) + 1.0f); c.perform();
          } else if (id(menu_open) && id(page) == 1 && id(focus) == 1) {
            auto c = id(ot_max_t_set).make_call();
            c.set_value(num(id(ot_max_t_set), 80.0f) + 1.0f); c.perform();
          } else if (id(menu_open) && id(page) == 1 && id(focus) == 2) {
            id(ot_dhw_enable).turn_on();
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 0) {
            auto c = id(sp_comfort).make_call();
            c.set_value(id(sp_comfort).state + 0.5f); c.perform();
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 1) {
            auto c = id(sp_eco).make_call();
            c.set_value(id(sp_eco).state + 0.5f); c.perform();
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 2) {
            auto c = id(sp_boost_delta).make_call();
            c.set_value(id(sp_boost_delta).state + 0.5f); c.perform();
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 3) {
            bump(id(sch_wd_on), 10);
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 4) {
            bump(id(sch_wd_off), 10);
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 5) {
            bump(id(sch_we_on), 10);
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 6) {
            bump(id(sch_we_off), 10);
          } else if (id(menu_open) && id(page) == 3 && id(focus) == 0) {
            auto c = id(room_offset).make_call();
            c.set_value(id(room_offset).state + 0.1f); c.perform();
          } else if (id(menu_open) && id(page) == 3 && id(focus) == 1) {
            auto c = id(tz_offset).make_call();
            c.set_value(id(tz_offset).state + 0.25f); c.perform();
          } else if (id(menu_open) && id(page) == 3 && id(focus) == 2) {
            id(tz_dst).make_call().select_next(true).perform();
          }
      - script.execute: ui_tick

  - id: nav_minus
    mode: restart
    then:
      - lambda: |-
          // A detent moves a comfort window by ten minutes and wraps at
          // midnight. Written here rather than as a shared script because a
          // script call cannot carry which entity to move.
          auto bump = [](datetime::TimeEntity *t, int d) {
            int m = t->hour * 60 + t->minute + d;
            m = ((m % 1440) + 1440) % 1440;
            auto c = t->make_call();
            c.set_time(m / 60, m % 60, 0);
            c.perform();
          };
          // A boiler setpoint that was never set is NaN, and NaN plus a
          // detent is NaN. Start from the shipped default instead.
          auto num = [](number::Number *x, float fallback) {
            return std::isnan(x->state) ? fallback : x->state;
          };
          bool sp_edit = (id(page) == 0) && (!id(menu_open) || id(focus) == 0);
          if (sp_edit) {
            if (id(in_comfort).state) {
              auto c = id(sp_comfort).make_call();
              c.set_value(id(sp_comfort).state - 0.5f); c.perform();
            } else {
              auto c = id(sp_eco).make_call();
              c.set_value(id(sp_eco).state - 0.5f); c.perform();
            }
          } else if (id(menu_open) && id(page) == 0 && id(focus) == 1) {
            id(heat_auto).turn_off();
          } else if (id(menu_open) && id(page) == 0 && id(focus) == 2) {
            id(heat_boost).turn_off();
          } else if (id(menu_open) && id(page) == 0 && id(focus) == 3) {
            auto c = id(sp_hyst).make_call();
            c.set_value(id(sp_hyst).state - 0.1f); c.perform();
          } else if (id(menu_open) && id(page) == 1 && id(focus) == 0) {
            auto c = id(ot_t_dhw_set).make_call();
            c.set_value(num(id(ot_t_dhw_set), 50.0f) - 1.0f); c.perform();
          } else if (id(menu_open) && id(page) == 1 && id(focus) == 1) {
            auto c = id(ot_max_t_set).make_call();
            c.set_value(num(id(ot_max_t_set), 80.0f) - 1.0f); c.perform();
          } else if (id(menu_open) && id(page) == 1 && id(focus) == 2) {
            id(ot_dhw_enable).turn_off();
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 0) {
            auto c = id(sp_comfort).make_call();
            c.set_value(id(sp_comfort).state - 0.5f); c.perform();
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 1) {
            auto c = id(sp_eco).make_call();
            c.set_value(id(sp_eco).state - 0.5f); c.perform();
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 2) {
            auto c = id(sp_boost_delta).make_call();
            c.set_value(id(sp_boost_delta).state - 0.5f); c.perform();
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 3) {
            bump(id(sch_wd_on), -10);
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 4) {
            bump(id(sch_wd_off), -10);
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 5) {
            bump(id(sch_we_on), -10);
          } else if (id(menu_open) && id(page) == 2 && id(focus) == 6) {
            bump(id(sch_we_off), -10);
          } else if (id(menu_open) && id(page) == 3 && id(focus) == 0) {
            auto c = id(room_offset).make_call();
            c.set_value(id(room_offset).state - 0.1f); c.perform();
          } else if (id(menu_open) && id(page) == 3 && id(focus) == 1) {
            auto c = id(tz_offset).make_call();
            c.set_value(id(tz_offset).state - 0.25f); c.perform();
          } else if (id(menu_open) && id(page) == 3 && id(focus) == 2) {
            id(tz_dst).make_call().select_previous(true).perform();
          }
      - script.execute: ui_tick

  - id: wake_screen
    mode: restart
    then:
      - light.turn_on:
          id: backlight
          brightness: ${bl_active}
      - delay: ${screen_timeout}
      - if:
          condition:
            binary_sensor.is_off: radar_has_target
          then:
            - light.turn_off: backlight
            - lambda: 'id(menu_open) = false; id(focus) = 0; id(page) = 0;'
