Кухня научилась отличать человека от растений

Обещанная история про автоматизацию наконец готова. На кухне — три умные лампы, датчик движения, физическая кнопка на стене и Home Assistant, которому нужно было развести между собой два совершенно разных запроса: «человек зашёл» и «растениям пора светить».

Первая версия автоматики называлась просто «Kitchen Controller» и прожила недолго — она путала одно с другим: то гасила свет растениям, потому что никто не двигался, то держала яркий холодный свет ради человека, который зашёл на десять секунд налить воды. Переписанная версия развела логику на отдельные сценарии с чётким приоритетом.

Главный — ручной режим, в него автоматика не лезет вообще. Если он не включён, дальше решает, кто сейчас «хозяин» освещения: если на кухне активен режим для растений — движение человека игнорируется полностью, досветка не должна прерываться каждый раз, когда кто-то прошёл мимо. Если нет — обычная логика присутствия: одна лампа тёплого света на минимальной яркости для случайного захода, две — если движение повторяется достаточно часто, чтобы считать кухню «занятой».

Досветка для растений включается отдельным условием — только когда темно, только в дневное окно (примерно с шести утра до одиннадцати вечера, чтобы не мешать ночному циклу), и только если растения явно не поставлены на паузу. Тогда врубаются все три лампы разом, на максимум яркости и на холодный дневной свет — визуально это совсем не то же самое, что тёплый вечерний свет для человека, спутать одно с другим больше нельзя.

Самая приятная деталь — как гаснет свет. Не разом, а по одной лампе, растягивая на несколько минут, и в любой момент, если кто-то вернулся или тронул кнопку — гашение прерывается на середине. Мелочь, но именно из-за таких мелочей автоматика в итоге начинает ощущаться как что-то разумное, а не как реле по таймеру.

Ушло на это несколько итераций и забытых edge-case'ов. Кота автоматика по-прежнему не видит — но это уже другая история.


Если хотите повторить

Ниже — реальные automation-скрипты Home Assistant, только с обезличенными ID устройств (замените на свои: три лампы, датчик движения, при желании — физическая Zigbee-кнопка с одиночным/двойным/долгим кликом). Каждый скрипт свёрнут — разворачивайте по одному.

Что завести заранее

Кроме самих ламп и датчика движения, автоматике нужны вспомогательные объекты (создаются в Home Assistant как Helpers, вручную или через configuration.yaml):

  • input_select.kitchen_mode — варианты: off, auto, manual, plants
  • input_boolean.kitchen_automation_enabled — общий рубильник автоматики
  • input_boolean.kitchen_plants_enabled — отдельный тумблер именно для режима растений
  • input_boolean.kitchen_button_direction_up — направление следующего цикла яркости по кнопке
  • input_datetime.kitchen_last_motion, input_datetime.kitchen_last_button, input_datetime.kitchen_soft_window_started
  • input_number.kitchen_light_count
  • counter.kitchen_soft_motion_counter
  • timer.kitchen_timer
  • свои скрипты script.kitchen_apply, script.kitchen_idle_timer, script.kitchen_button, script.kitchen_motion, script.kitchen_plants_tick, script.kitchen_wind_down, script.kitchen_plants_resume — сюда их роутит главный скрипт ниже

Kitchen — главный роутер

alias: Kitchen
description: Тонкий роутер кухни. Старый Kitchen Controller должен быть выключен.
triggers:
  - trigger: homeassistant
    event: start
    id: startup
  - trigger: state
    entity_id: binary_sensor.motion_sensor
    from: "off"
    to: "on"
    id: motion
  - trigger: state
    entity_id: event.kitchen_button_click
    id: button_single
    not_from:
      - unavailable
    not_to:
      - unavailable
  - trigger: state
    entity_id: event.kitchen_button_double_click
    id: button_double
    not_from:
      - unavailable
    not_to:
      - unavailable
  - trigger: state
    entity_id: event.kitchen_button_long_press
    id: button_long
    not_from:
      - unavailable
    not_to:
      - unavailable
  - trigger: event
    event_type: timer.finished
    event_data:
      entity_id: timer.kitchen_timer
    id: timer_finished
  - trigger: state
    entity_id: light.kitchen_hub_led
    id: dark_changed
  - trigger: state
    entity_id: sun.sun
    to:
      - above_horizon
      - below_horizon
    id: dark_changed
  - trigger: state
    entity_id: input_boolean.kitchen_plants_enabled
    id: dark_changed
  - trigger: time
    at: "06:00:00"
    id: dark_changed
  - trigger: time
    at: "23:00:00"
    id: dark_changed
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: motion
        sequence:
          - action: script.kitchen_motion
      - conditions:
          - condition: trigger
            id: button_single
        sequence:
          - action: script.kitchen_button
            data:
              kind: single
      - conditions:
          - condition: trigger
            id: button_double
        sequence:
          - action: script.kitchen_button
            data:
              kind: double
      - conditions:
          - condition: trigger
            id: button_long
        sequence:
          - action: script.kitchen_button
            data:
              kind: long
      - conditions:
          - condition: trigger
            id: timer_finished
        sequence:
          - action: script.turn_on
            target:
              entity_id: script.kitchen_wind_down
      - conditions:
          - condition: trigger
            id:
              - dark_changed
              - startup
        sequence:
          - action: script.kitchen_plants_tick
          - action: logbook.log
            data:
              name: Kitchen
              message: >
                dark={{ is_state('light.kitchen_hub_led', 'on') or
                (states('light.kitchen_hub_led') in ['unavailable',
                'unknown']
                    and is_state('sun.sun', 'below_horizon')) }}
                hub={{ states('light.kitchen_hub_led') }} sun={{
                states('sun.sun') }} mode={{ states('input_select.kitchen_mode')
                }}
mode: queued
max: 15

Kitchen Motion — реакция на движение

alias: Kitchen Motion
mode: queued
max: 10
sequence:
  - action: input_datetime.set_datetime
    target:
      entity_id: input_datetime.kitchen_last_motion
    data:
      datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"
  - variables:
      hub: "{{ states('light.kitchen_hub_led') }}"
      hub_ok: "{{ hub not in ['unavailable', 'unknown'] }}"
      is_dark: "{{ (hub == 'on') if hub_ok else is_state('sun.sun', 'below_horizon') }}"
      auto_on: "{{ is_state('input_boolean.kitchen_automation_enabled', 'on') }}"
      mode_now: "{{ states('input_select.kitchen_mode') }}"
      any_on: |
        {{ is_state('light.yeelight_kitchen_1', 'on')
           or is_state('light.yeelight_kitchen_2', 'on')
           or is_state('light.yeelight_kitchen_3', 'on') }}
      window_ok: >
        {% set started = states('input_datetime.kitchen_soft_window_started') %}
        {% if started in ['unknown', 'unavailable', 'none', ''] %}
          false
        {% else %}
          {{ (as_timestamp(now()) - as_timestamp(started)) < 300 }}
        {% endif %}
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ not auto_on }}"
        sequence:
          - if:
              - condition: template
                value_template: "{{ any_on }}"
            then:
              - action: script.kitchen_idle_timer
                data:
                  minutes: 60
          - stop: automation off, only failsafe
  - if:
      - condition: template
        value_template: "{{ mode_now == 'plants' }}"
    then:
      - stop: plants ignore people
  - action: script.turn_off
    target:
      entity_id: script.kitchen_wind_down
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ not window_ok }}"
        sequence:
          - action: counter.reset
            target:
              entity_id: counter.kitchen_soft_motion_counter
          - action: input_datetime.set_datetime
            target:
              entity_id: input_datetime.kitchen_soft_window_started
            data:
              datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"
  - action: counter.increment
    target:
      entity_id: counter.kitchen_soft_motion_counter
  - variables:
      busy: "{{ (states('counter.kitchen_soft_motion_counter') | int(0)) >= 3 }}"
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ mode_now == 'manual' }}"
        sequence:
          - action: script.kitchen_idle_timer
            data:
              minutes: 10
      - conditions:
          - condition: template
            value_template: "{{ not any_on }}"
        sequence:
          - if:
              - condition: template
                value_template: "{{ is_dark }}"
            then:
              - action: input_select.select_option
                target:
                  entity_id: input_select.kitchen_mode
                data:
                  option: auto
              - action: script.kitchen_apply
                data:
                  count: "{{ 2 if busy else 1 }}"
                  brightness_pct: 30
                  kelvin: 2700
              - action: script.kitchen_idle_timer
                data:
                  minutes: 5
    default:
      - action: input_select.select_option
        target:
          entity_id: input_select.kitchen_mode
        data:
          option: auto
      - action: script.kitchen_apply
        data:
          count: "{{ 2 if busy else 1 }}"
          brightness_pct: 30
          kelvin: 2700
      - action: script.kitchen_idle_timer
        data:
          minutes: 5
description: ""

Kitchen Plants Tick — досветка растений

alias: Kitchen Plants Tick
mode: queued
max: 5
sequence:
  - variables:
      hub: "{{ states('light.kitchen_hub_led') }}"
      hub_ok: "{{ hub not in ['unavailable', 'unknown'] }}"
      is_dark: "{{ (hub == 'on') if hub_ok else is_state('sun.sun', 'below_horizon') }}"
      time_ok: >-
        {{ now().strftime('%H:%M') >= '06:00' and now().strftime('%H:%M') <
        '23:00' }}
      paused: "{{ is_state('script.kitchen_plants_resume', 'on') }}"
      auto_on: "{{ is_state('input_boolean.kitchen_automation_enabled', 'on') }}"
      plants_on: "{{ is_state('input_boolean.kitchen_plants_enabled', 'on') }}"
      eligible: "{{ auto_on and plants_on and is_dark and time_ok and not paused }}"
      mode_now: "{{ states('input_select.kitchen_mode') }}"
      motion_recent: >
        {% set last = states('input_datetime.kitchen_last_motion') %} {% if last
        in ['unknown', 'unavailable', 'none', ''] %}
          false
        {% else %}
          {{ (as_timestamp(now()) - as_timestamp(last)) < 300 }}
        {% endif %}
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ eligible }}"
        sequence:
          - action: input_select.select_option
            target:
              entity_id: input_select.kitchen_mode
            data:
              option: plants
          - action: script.kitchen_apply
            data:
              count: 3
              brightness_pct: 100
              kelvin: 4000
          - action: timer.cancel
            target:
              entity_id: timer.kitchen_timer
      - conditions:
          - condition: template
            value_template: "{{ mode_now == 'plants' and not eligible }}"
        sequence:
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ not time_ok }}"
                sequence:
                  - choose:
                      - conditions:
                          - condition: template
                            value_template: >-
                              {{ is_state('binary_sensor.motion_sensor', 'on')
                              or motion_recent }}
                        sequence:
                          - action: input_select.select_option
                            target:
                              entity_id: input_select.kitchen_mode
                            data:
                              option: auto
                          - action: script.kitchen_apply
                            data:
                              count: 1
                              brightness_pct: 30
                              kelvin: 2700
                          - action: script.kitchen_idle_timer
                            data:
                              minutes: 5
                    default:
                      - action: script.turn_on
                        target:
                          entity_id: script.kitchen_wind_down
            default:
              - action: script.kitchen_apply
                data:
                  count: 0
                  brightness_pct: 30
                  kelvin: 2700
              - action: timer.cancel
                target:
                  entity_id: timer.kitchen_timer
              - action: input_select.select_option
                target:
                  entity_id: input_select.kitchen_mode
                data:
                  option: "off"
description: ""

Kitchen Wind Down — плавное гашение

alias: Kitchen Wind Down
mode: restart
sequence:
  - variables:
      started: "{{ as_timestamp(now()) }}"
      on_lamps: >
        {{ expand(
          'light.yeelight_kitchen_1',
          'light.yeelight_kitchen_2',
          'light.yeelight_kitchen_3'
        ) | selectattr('state', 'eq', 'on') | map(attribute='entity_id') | list
        }}
      "n": "{{ on_lamps | count }}"
      step: "{{ [((300 / [n | int, 1] | max) | int), 30] | max }}"
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ n | int == 0 }}"
        sequence:
          - action: timer.cancel
            target:
              entity_id: timer.kitchen_timer
          - action: input_select.select_option
            target:
              entity_id: input_select.kitchen_mode
            data:
              option: "off"
          - stop: already off
  - repeat:
      for_each: "{{ on_lamps[::-1] }}"
      sequence:
        - delay:
            seconds: "{{ step | int }}"
        - if:
            - condition: template
              value_template: |
                {{ is_state('binary_sensor.motion_sensor', 'on')
                   or (as_timestamp(states('input_datetime.kitchen_last_button')) | float(0))
                      > (started | float) }}
          then:
            - stop: interrupted
        - action: light.turn_off
          target:
            entity_id: "{{ repeat.item }}"
  - action: timer.cancel
    target:
      entity_id: timer.kitchen_timer
  - action: input_select.select_option
    target:
      entity_id: input_select.kitchen_mode
    data:
      option: "off"
  - action: input_number.set_value
    target:
      entity_id: input_number.kitchen_light_count
    data:
      value: 0
  - action: input_boolean.turn_on
    target:
      entity_id: input_boolean.kitchen_button_direction_up
description: ""

Подводные камни

  • kitchen_apply(count, brightness_pct, kelvin) и kitchen_idle_timer(minutes) — не сами automation, а отдельные вспомогательные скрипты, здесь не приведены целиком. Их поведение видно из того, как их вызывают: kitchen_apply включает нужное число ламп с заданной яркостью и температурой, kitchen_idle_timer просто заводит (или обновляет) таймер на N минут.
  • Один и тот же id dark_changed висит сразу на нескольких разных триггерах (смена состояния лампы-индикатора хаба, восход/закат, ручной тумблер, два фиксированных времени) — это не дублирование, а осознанное решение: любое из этих событий должно просто пересчитать «темно сейчас или нет», а не запускать разную логику под каждое.
  • is_dark сначала пытается определить темноту по состоянию лампы-индикатора хаба, и только если та недоступна — падает на восход/закат. Без этого резерва вся логика зависает, если сам хаб временно отвалился.
  • mode: queued с ограничением по параллельным запускам в главном роутере — чтобы быстрые события подряд (например, двойной клик сразу после одиночного) не создавали гонку состояний.
  • Kitchen Wind Down — mode: restart, а не queued: если во время затухания приходит новое движение, скрипт должен начаться заново (по факту — прерваться), а не встать в очередь и доиграть старое гашение поверх нового состояния.
  • Счётчик «занятости» кухни считает срабатывания датчика движения в скользящем 5-минутном окне, а не одно единственное движение — иначе один случайный проход считался бы «людной кухней».
  • Режим plants намеренно игнорирует присутствие человека — без этой развязки досветка растений дёргалась бы каждый раз, когда кто-то просто проходит мимо.
  • Entity ID здесь обезличены (light.yeelight_kitchen_1/2/3, event.kitchen_button_*) — замените на настоящие имена ваших устройств в Home Assistant, иначе ничего не сработает.

раньше Админ наблюдает за процессом. Процесс явно опережает по темпу.

новее Мирабилис достиг стадии «это точно комнатное растение?»

Обсуждение

Комментировать могут зарегистрированные читатели.

Войти Регистрация
Войти