Initial commit: Home Assistant configuration

- Added Home Assistant config files (automations, scripts, pyscripts, etc.)
- Created .gitignore to exclude sensitive files (secrets, tokens, API keys)
- Excluded binary files and database files
- Excluded Home Assistant runtime directories (.storage, .cloud, etc.)
- Configuration reflects latest updates to Frigate, vacuum automations, etc.
This commit is contained in:
blasebast 2026-03-26 19:00:12 +01:00
commit 8394b3d4ce
46 changed files with 16476 additions and 0 deletions

126
.gitignore vendored Normal file
View File

@ -0,0 +1,126 @@
# Environment and secrets
.env
.env.local
*.secret
secrets.yaml
secrets.yml
# Home Assistant sensitive files and directories
homeassistant/config/.storage/
homeassistant/config/.cloud/
homeassistant/config/auth_providers.yaml
homeassistant/config/onboarding
homeassistant/config/.homeassistant
homeassistant/config/tts/
homeassistant/config/www/frigate_notification/
homeassistant/config/translations/
homeassistant/config/secrets.yaml
homeassistant/config/.git/
homeassistant/frigate/
homeassistant/ssh_dir/
homeassistant/doorbell/
homeassistant/sysctl.d/
homeassistant/tmp.sh
homeassistant/resolv.conf
# Binary and media files
*.jpg
*.jpeg
*.png
*.gif
*.mp4
*.mkv
*.avi
*.mov
*.mp3
*.wav
*.flac
*.webp
# Database files
*.db
*.sqlite
*.sqlite3
*.sql
*.dump
# Cache and runtime
.cache/
*.pyc
__pycache__/
*.egg-info/
.pytest_cache/
.coverage
*.log
*.tmp
# System files
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# IDE
.vscode/
.idea/
*.code-workspace
# Docker
.dockerignore
# Temporary files
temp/
tmp/
*_backup
*.backup
# Node modules and dependencies
node_modules/
package-lock.json
yarn.lock
# Build artifacts
dist/
build/
out/
# Frigate
frigate/clips/
frigate/recordings/
frigate/cache/
# Immich
immich/data/
immich/cache/
# Media server data
jellyfin/data/
jellyfin/cache/
jellyfin/plugins/
# Unifi
unifi/data/
# Redis
redis/data/
# PostgreSQL
postgres/data/
postgres18/data/
# Vault
vaultwarden/data/
# Traefik
traefik/letsencrypt/
# Loki
loki/chunks/
loki/indexes/
# Victoriametrics
victoriametrics/
# Side agent
side-agent/manifest/

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,76 @@
# Updated buxus watering automation with weather conditions
# Replace the existing buxus.watering.reminder automation with this version
- id: '1735529205000'
alias: buxus.watering.reminder
description: Remind to water buxus plant based on seasonal schedule and weather conditions - daily in summer, every 3-5 days in spring/autumn, every 2-3 weeks in winter. Skips watering if raining or recently rained.
triggers:
- at: '07:00:00'
trigger: time
conditions:
# Check if it's time to water based on schedule
- condition: template
value_template: >
{% set last_watered_raw = states('input_datetime.buxus_last_watered') %}
{% set today = now().date() %}
{% set current_month = now().month %}
{% if last_watered_raw in ['unknown', 'unavailable', ''] %}
true
{% else %}
{% set last_date = strptime(last_watered_raw, '%Y-%m-%d %H:%M:%S').date() %}
{% set days_since = (today - last_date).days %}
{% if current_month in [6, 7, 8] %}
{# Summer - daily or every 1-2 days #}
{{ days_since >= 1 }}
{% elif current_month in [3, 4, 5, 9, 10, 11] %}
{# Spring/Autumn - every 3-5 days #}
{{ days_since >= 3 }}
{% else %}
{# Winter - every 2-3 weeks #}
{{ days_since >= 14 }}
{% endif %}
{% endif %}
# Check weather conditions - don't water if raining or recently rained
- condition: template
value_template: >
{% set current_weather = states('weather.forecast_home') %}
{% set forecast = state_attr('weather.forecast_home', 'forecast') %}
{# Don't water if currently raining/snowing #}
{% if current_weather in ['rainy', 'snowy', 'pouring', 'lightning-rainy'] %}
false
{# Check if rain is expected in next few hours #}
{% elif forecast and forecast|length > 0 %}
{% set rain_soon = forecast[:3] | selectattr('condition', 'in', ['rainy', 'snowy', 'pouring', 'lightning-rainy']) | list | length > 0 %}
{{ not rain_soon }}
{% else %}
{# If no forecast data, allow watering unless currently raining #}
{{ current_weather not in ['rainy', 'snowy', 'pouring', 'lightning-rainy'] }}
{% endif %}
actions:
- data:
message: >
{% set current_month = now().month %}
{% set weather = states('weather.forecast_home') %}
{% if current_month in [6, 7, 8] %}
🌱 Czas podlać buxus! Latem (gorąco, słońce) - podlewaj codziennie lub co 1-2 dni.
{% elif current_month in [3, 4, 5, 9, 10, 11] %}
🌱 Czas podlać buxus! Wiosna/jesień - podlewaj co 3-5 dni w zależności od pogody.
{% else %}
🌱 Czas podlać buxus! Zimą - podlewaj raz na 2-3 tygodnie.
{% endif %}
☀️ Pogoda: {{ weather|title }}
title: Przypomnienie o podlewaniu
data:
channel: plant_care
push:
sound: default
actions:
- action: 'WATERED_BUXUS'
title: 'Podlane'
- action: 'SNOOZE_BUXUS'
title: 'Przypomnij jutro'
mode: single

View File

@ -0,0 +1,442 @@
default_config:
conversation:
bluetooth:
recorder:
purge_keep_days: 14
db_url: !secret db_connection
exclude:
entity_globs:
# --- Systemowe & Sieciowe (Unifi, HA, Serwer) ---
- sensor.load_*
- sensor.processor_*
- sensor.memory_*
- sensor.*_memory_utilization # To wytnie spam z Unifi U6/Pro
- sensor.network_*
- sensor.*_uptime* # Wycina Athom uptime i inne liczniki czasu
- sensor.*_wifi_signal_strength # RSSI/Sygnał to częsty spam
- sensor.*_rssi
- sensor.*_ssid
# --- Elektryka (Zostawiamy kWh, wycinamy parametry techniczne) ---
- sensor.*_voltage
- sensor.*_current
- sensor.*_amperage
- sensor.*_power_factor
# Jeśli nie potrzebujesz wykresów mocy chwilowej (W) dla innych gniazdek:
# - sensor.*_power
# --- Urządzenia i Diagnostyka ---
- sensor.*_device_temperature
- sensor.*_chip_temperature
- sensor.*_presence_light_sensor # Athom light sensor często skacze
# --- Logi Automatyzacji ---
- automation.camera_snapshot_*
- automation.*jenny_set_metrics
entities:
# --- TOP SPAMMERS (Konkretne encje z Twojego SQL) ---
- sensor.clamp_1_power # 12k wpisów - moc chwilowa (W)
- sensor.quit_czk # 10k wpisów - kursy walut/akcji
- sensor.athom_presence_uptime_sensor
# --- Inne z Twojej listy ---
- sensor.temperature_humidity_sensor_1dd0_humidity
- sensor.office_th
logger:
default: info
logs:
homeassistant.components.zha.core.device: info
homeassistant.core: info
bellows.uart: error
bellows.zigbee.application: error
google_nest_sdm.streaming_manager: critical
filters:
homeassistant.components.automation.camera_snapshot_day_all:
- ".*Initialized trigger .*.snapshot.day.all"
- ".*snapshot.day.all: Running automation actions.*"
- ".*snapshot.day.all: Executing step call service.*"
google_nest_sdm.streaming_manager:
- ".*Disconnected from event stream: API error when streaming iterator: 503.*"
frontend.js.modern.202511051:
- "Failed to format translation for key 'ui.components.language-picker"
zeroconf:
homeassistant:
auth_providers:
- type: homeassistant
- type: trusted_networks
trusted_networks:
- 172.17.0.0/24
- 172.18.0.0/24
- 172.18.0.10
- 172.20.0.0/24
- 192.168.1.0/24
- 81.201.50.209/32
latitude: 50.1174738
longitude: 14.1322881
elevation: 385
external_url: "https://ha.sebson.space/"
internal_url: "http://192.168.1.132:8123"
unit_system: metric
time_zone: Europe/Prague
country: CZ
name: home
allowlist_external_dirs:
- "/hikvision16t/"
- "/config"
auth:
prometheus:
namespace: hass
component_config_glob:
sensor.*_hum:
override_metric: humidity_percent
sensor.*_temp:
override_metric: temperature_c
sensor.temperature*:
override_metric: temperature_c
sensor.*_bat:
override_metric: battery_percent
filter:
include_domains:
- sensor
- input_boolean
exclude_entity_globs:
- sensor.weather_*
include_entity_globs:
- input_boolean.*jenny*
http:
server_port: 8123
use_x_forwarded_for: true
cors_allowed_origins:
- https://google.com
- https://www.home-assistant.io
trusted_proxies:
- 127.0.0.1
- ::1
- 172.16.0.0/12 # To jedno pokrywa wszystkie Twoje kontenery Dockerowe (17, 18, 19...)
ip_ban_enabled: true
login_attempts_threshold: 10
tts:
- platform: google_translate
cache: true
cache_dir: /tmp/tts
time_memory: 300
service_name: google_say
- platform: google_translate
service_name: google_say
language: 'en'
cache: true
cache_dir: /tmp/tts
time_memory: 300
automation: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
input_number: !include input_numbers.yaml
input_datetime: !include input_datetime.yaml
input_boolean: !include input_boolean.yaml
config:
frontend:
themes: !include themes.yaml
shell_command:
doorbell_open: !secret doorbell_open_curl
copy_doorbell_snap: /config/shell_scripts/copy_doorbell_snap.sh
copy_gateopen_snap: /config/shell_scripts/copy_gateopen_snap.sh
camera:
- platform: proxy
entity_id: camera.reolink_1_fluent
name: reolink_1_proxy_gate
mode: crop
max_image_width: 200
max_image_height: 150
max_stream_width: 200
max_stream_height: 150
image_left: 430
image_top: 80
force_resize: true
notify:
- name: doorbell_notifications
platform: group
services:
- service: mobile_app_your_phone
- service: persistent_notification
# ------------------------------------------------------------------------------
# SENSOR PLATFORMS (Trend, Statistics, MQTT Room, etc.)
# ------------------------------------------------------------------------------
binary_sensor:
- platform: trend
sensors:
is_moving_away_home:
entity_id: sensor.home_is17_distance
sample_duration: 60
min_gradient: 0.5
device_class: moving
sensor:
- platform: rest
name: "RPI4 Temperature"
resource: "http://192.168.1.132:29090/api/v1/query?query=node_hwmon_temp_celsius{instance='rpi4:9100',chip='thermal_thermal_zone0'}"
username: !secret prometheus_user
password: !secret prometheus_password
authentication: basic
value_template: "{{ value_json.data.result[0].value[1] | float | round(1) }}"
unit_of_measurement: "°C"
scan_interval: 30
- platform: mqtt_room
device_id: "Espresense-iPhone-seb"
name: "Espresense-iPhone-seb"
state_topic: "espresense/devices/Espresense-iPhone-seb"
timeout: 10
away_timeout: 120
- platform: mqtt_room
device_id: "garmin:74abbc920095"
name: "garmin-fenix"
state_topic: "espresense/devices/garmin:b4c26ab7d6d4"
timeout: 10
away_timeout: 120
# ------------------------------------------------------------------------------
# CROWDSEC SENSORS
# ------------------------------------------------------------------------------
command_line:
- sensor:
name: "CrowdSec Bany Łącznie"
unique_id: crowdsec_bans_total
command: "curl -s http://localhost:6060/metrics | grep '^cs_active_decisions{action=\"ban\"' | awk '{sum += $2} END {print sum+0}'"
unit_of_measurement: "IP"
icon: "mdi:shield-lock"
scan_interval: 120
- sensor:
name: "CrowdSec Bany Lokalne"
unique_id: crowdsec_bans_local
command: "curl -s http://localhost:6060/metrics | grep '^cs_active_decisions{action=\"ban\",origin=\"crowdsec\"' | awk '{sum += $2} END {print sum+0}'"
unit_of_measurement: "IP"
icon: "mdi:shield-alert"
scan_interval: 120
- sensor:
name: "CrowdSec Bany CAPI"
unique_id: crowdsec_bans_capi
command: "curl -s http://localhost:6060/metrics | grep '^cs_active_decisions{action=\"ban\",origin=\"CAPI\"' | awk '{sum += $2} END {print sum+0}'"
unit_of_measurement: "IP"
icon: "mdi:earth"
scan_interval: 120
- sensor:
name: "CrowdSec Alerty"
unique_id: crowdsec_alerts_total
command: "curl -s http://localhost:6060/metrics | grep '^cs_alerts{' | awk '{sum += $2} END {print sum+0}'"
unit_of_measurement: "alert"
icon: "mdi:alert-circle"
scan_interval: 120
# ------------------------------------------------------------------------------
# UNIFIED TEMPLATE SECTION
# ------------------------------------------------------------------------------
template:
- sensor:
# --- Netatmo Legacy Fixes (Przywracamy stare sensory) ---
- name: "Netatmo Bathroom Temp"
unique_id: netatmo_bathroom_temp_fix
unit_of_measurement: "°C"
device_class: temperature
state: "{{ state_attr('climate.bathroom', 'current_temperature') }}"
availability: "{{ state_attr('climate.bathroom', 'current_temperature') is not none }}"
- name: "Netatmo Bedroom Temp"
unique_id: netatmo_bedroom_temp_fix
unit_of_measurement: "°C"
device_class: temperature
state: "{{ state_attr('climate.bedroom', 'current_temperature') }}"
availability: "{{ state_attr('climate.bedroom', 'current_temperature') is not none }}"
- name: "Netatmo Bedroom Stefi Temp"
unique_id: netatmo_bedroom_stefi_temp_fix
unit_of_measurement: "°C"
device_class: temperature
state: "{{ state_attr('climate.bedroom_stefi', 'current_temperature') }}"
availability: "{{ state_attr('climate.bedroom_stefi', 'current_temperature') is not none }}"
- name: "Netatmo Living Room Temp"
unique_id: netatmo_living_room_temp_fix
unit_of_measurement: "°C"
device_class: temperature
state: "{{ state_attr('climate.living_room', 'current_temperature') }}"
availability: "{{ state_attr('climate.living_room', 'current_temperature') is not none }}"
# --- Other Sensors ---
- name: "Outside Temperature"
unique_id: outside_temperature_weather
unit_of_measurement: "°C"
device_class: temperature
state: "{{ state_attr('weather.forecast_home', 'temperature') }}"
availability: "{{ state_attr('weather.forecast_home', 'temperature') is not none }}"
- name: "Average temperature home"
unit_of_measurement: "°C"
state_class: measurement
device_class: temperature
state: >
{% set sensors = [
'sensor.stefi_temperature',
'sensor.bedroom_temperature',
'sensor.lumi_stefi_temp_aq2',
'sensor.office_temp',
'sensor.netatmo_bedroom_temp',
'sensor.netatmo_living_room_temp',
'sensor.main_thermostat'
] %}
{% set valid = sensors | map('states') | reject('in', ['unavailable', 'unknown']) | map('float') | list %}
{{ (valid | sum / valid | count) | round(1) if valid else 'unavailable' }}
- name: "quit days"
unique_id: sensor_quit_days
unit_of_measurement: days
state: '{{ ((as_timestamp(now())-(states.input_datetime.quit_date.attributes.timestamp)) | int /60/1440) | round(0) }}'
- name: "quit weeks"
unique_id: sensor_quit_weeks
unit_of_measurement: weeks
state: '{{ ((as_timestamp(now())-(states.input_datetime.quit_date.attributes.timestamp)) | int /60/1440/7) | round(1) }}'
- name: "quit months"
unique_id: sensor_quit_months
unit_of_measurement: months
state: '{{ ((as_timestamp(now())-(states.input_datetime.quit_date.attributes.timestamp)) | int /60/1440/30) | round(2) }}'
- name: "quit years"
unique_id: sensor_quit_years
unit_of_measurement: years
state: '{{ ((as_timestamp(now())-(states.input_datetime.quit_date.attributes.timestamp)) | int /60/1440/365) | round(2) }}'
- name: "quit czk"
unique_id: sensor_quit_czk
unit_of_measurement: CZK
state: '{{ ((as_timestamp(now())-(states.input_datetime.quit_date.attributes.timestamp)) | int /60/1440*100) | round(2) }}'
- name: "volume gspeakers" # <--- To wygeneruje ID: sensor.volume_gspeakers
unique_id: volume_gspeakers_restored
state: >-
{% set hourminute = now().strftime('%H%M') | int %}
{% set volume_day = states('input_number.volume_day_group_speakers') | int(20) %}
{% set volume_night = states('input_number.volume_night_group_speakers') | int(10) %}
{# Logika: 07:16 do 22:29 = DZIEŃ #}
{% if hourminute < 716 or hourminute > 2229 %}
{{ volume_night | float / 100 }}
{% else %}
{{ volume_day | float / 100 }}
{% endif %}
- name: "sonos_speakers_volume"
unique_id: sonos_speakers_volume_calc
state: >-
{% set hourminute = now().strftime('%H%M') | int %}
{% set volume_day = states('input_number.volume_day_group_speakers') | int(20) %}
{% set volume_night = states('input_number.volume_night_group_speakers') | int(10) %}
{% if hourminute < 716 or hourminute > 2229 %}
{{ volume_night | float / 100 }}
{% else %}
{{ volume_day | float / 100 }}
{% endif %}
- binary_sensor:
- name: "Any Window Door Open"
unique_id: any_window_door_open
device_class: door
state: >-
{{
is_state('binary_sensor.living_room_sensor_contact', 'on') or
is_state('binary_sensor.bathroom_sensor_contact', 'on') or
is_state('binary_sensor.office_sensor_contact', 'on') or
is_state('binary_sensor.bedroom_sensor_contact', 'on') or
is_state('binary_sensor.bedroom_stefi_sensor_contact', 'on')
}}
- name: "Doorbell Motion"
unique_id: doorbell_motion
device_class: motion
state: "{{ is_state('binary_sensor.doorbell_button_pressed', 'on') }}"
delay_off:
seconds: 10
- name: "All Windows Doors Closed"
unique_id: all_windows_doors_closed
state: >
{{ [
states('binary_sensor.bathroom_window'),
states('binary_sensor.bedroom_window'),
states('binary_sensor.living_room_window'),
states('binary_sensor.main_door'),
states('binary_sensor.terrace_door'),
states('binary_sensor.gate_door'),
states('binary_sensor.stefi_window'),
states('binary_sensor.window_kitchen_farm'),
states('binary_sensor.office_window'),
states('binary_sensor.technicka_window')
] | select('eq', 'on') | list | length == 0 }}
delay_on:
minutes: 1
delay_off:
minutes: 5
- switch:
- name: "Doorbell Unlock"
unique_id: doorbell_unlock
state: "{{ is_state('input_boolean.doorbell_trigger', 'on') }}"
turn_on:
- action: input_boolean.turn_on
target: { entity_id: input_boolean.doorbell_trigger }
- action: shell_command.doorbell_open
- delay: 5
- action: input_boolean.turn_off
target: { entity_id: input_boolean.doorbell_trigger }
turn_off:
- action: input_boolean.turn_off
target: { entity_id: input_boolean.doorbell_trigger }
lovelace:
resources:
- url: /local/xiaomi/vacuum-card.js
type: module
zha:
enable_quirks: true
custom_quirks_path: /config/custom_zha_quirks
sonoff:
username: rewelacyjny.rower@gmail.com
password: !secret sonoff_password
force_update: [temperature, power]
scan_interval: '00:05:00'
sensors: [temperature, humidity, power, current, voltage]
pyscript:
allow_all_imports: true
hass_is_global: true
zone:
- name: wday
latitude: 50.08866882
longitude: 14.4354
radius: 279.46221343572046
icon: mdi:briefcase

View File

View File

@ -0,0 +1,337 @@
background: center / cover no-repeat fixed url('/local/lovelace/bg_long.png?v=1')
kiosk_mode:
mobile_settings:
hide_header: true
views:
- icon: 'mdi:music'
panel: false
badges: []
cards:
- type: vertical-stack
cards:
- type: horizontal-stack
cards:
- type: 'custom:button-card'
size: 25px
icon: 'mdi:close'
tap_action:
action: navigate
navigation_path: /lovelace/0
styles:
card:
- width: 40px
- height: 40px
- background-color: 'rgba(255,255,255,0)'
- box-shadow: none
icon:
- margin-left: px
- type: 'custom:gap-card'
height: <height>
size: <size>
- type: 'custom:button-card'
size: 25px
icon: |
[[[
if (states['media_player.entry'].state == "playing")
return "mdi:cast-connected";
else if (states['media_player.front_living_room'].state == "playing")
return "mdi:cast-connected";
else if (states['media_player.kitchen_speaker'].state == "playing")
return "mdi:cast-connected";
else if (states['media_player.living_rooms'].state == "playing")
return "mdi:cast-connected";
else if (states['media_player.office_speaker'].state == "playing")
return "mdi:cast-connected";
else if (states['media_player.family_room_speaker_cast'].state == "playing")
return "mdi:cast-connected";
else
return "mdi:cast";
]]]
styles:
card:
- width: 40px
- height: 40px
- background-color: 'rgba(255,255,255,0)'
- box-shadow: none
icon:
- margin-left: px
tap_action:
action: fire-dom-event
browser_mod:
command: popup
title: Select where to cast
card:
type: vertical-stack
cards:
- type: 'custom:mini-media-player'
group: true
min_volume: 5
max_volume: 75
entity: media_player.office_speaker
name: Office
tap_action:
action: call-service
service: input_boolean.toggle
service_data:
entity_id: input_boolean.office_mp
hide:
controls: true
icon: false
info: true
power: true
progress: true
state_label: false
volume_level: false
- type: 'custom:mini-media-player'
name: Kitchen
group: true
min_volume: 5
max_volume: 75
tap_action:
action: call-service
service: input_boolean.toggle
service_data:
entity_id: input_boolean.kitchen_mp
hide:
controls: true
icon: false
info: true
power: true
progress: true
state_label: false
volume_level: false
entity: media_player.kitchen_speaker
- type: 'custom:mini-media-player'
group: true
min_volume: 5
max_volume: 75
name: Family Room
tap_action:
action: call-service
service: input_boolean.toggle
service_data:
entity_id: input_boolean.familyroom_mp
hide:
controls: true
icon: false
info: true
power: true
progress: true
state_label: false
volume_level: false
entity: media_player.family_room_speaker_cast
- type: 'custom:mini-media-player'
entity: media_player.front_living_room
group: true
min_volume: 5
max_volume: 75
name: Front Room
tap_action:
action: call-service
service: input_boolean.toggle
service_data:
entity_id: input_boolean.frontroom_mp
hide:
controls: true
icon: false
info: true
power: true
progress: true
state_label: false
volume_level: false
- type: 'custom:mini-media-player'
name: Living Rooms
group: true
min_volume: 5
max_volume: 75
tap_action:
action: call-service
service: input_boolean.toggle
service_data:
entity_id: input_boolean.livingrooms_gp
hide:
controls: true
icon: false
info: true
power: true
progress: true
state_label: false
volume_level: false
entity: media_player.living_rooms
- type: 'custom:mini-media-player'
entity: media_player.entry
group: true
min_volume: 5
max_volume: 75
name: Entry
tap_action:
action: call-service
service: input_boolean.toggle
service_data:
entity_id: input_boolean.entry_gp
hide:
controls: true
icon: false
info: true
power: true
progress: true
state_label: false
volume_level: false
deviceID:
- this
- dashboard
style:
$: >
.mdc-dialog .mdc-dialog__container .mdc-dialog__surface
{
border-radius: 0px;
}
.: |
:host {
--mdc-theme-surface: rgba(0,0,0,0.8);
--secondary-background-color: rgba(69,90,100,1);
--ha-card-background: rgba(0,0,0,0.5);
}
:host .content {
width: 90vw;
height: 95vh;
}
- type: picture-glance
entities: []
camera_image: camera.album_art
tap_action:
action: none
hold_action:
action: none
card_mod:
style: |
ha-card {
width: 98%;
margin: auto;
border-radius: 0;
position: relative;
}
.box {
background: rgba(255,255,255,0) !important;
}
- type: 'custom:mod-card'
card_mod:
style: |
ha-card {
background: rgba(255, 255, 255, 0.0);
border-radius: 0;
box-shadow: none;
position: relative;
top: -10px;
}
card:
type: 'custom:html-template-card'
ignore_line_breaks: true
content: |
<style>
.title {
display: block;
font-weight: 500;
font-size: 1.2em;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; !important;
}
.artist {
display: block;
text-align: center;
}
</style>
<div style="">
{% if state_attr("media_player.music", "media_playlist") -%}
<h3>{{ state_attr('media_player.music', 'media_playlist') }}</h3>
<span class="title">{{ state_attr('media_player.music', 'media_title') }}</br></span>
{% if state_attr("media_player.music", "media_artist") -%}
<span class="artist">{{ state_attr('media_player.music', 'media_artist') }}</span>
{%- else -%}
<center>&nbsp;</center>
{%- endif %}
{%- else -%}
<h3>&nbsp;</h3>
<span class="title">&nbsp;</br></span>
<span class="artist">&nbsp;</span>
{%- endif %}
</div>
- type: 'custom:mini-media-player'
entity: media_player.music
toggle_power: true
artwork: none
source: full
name: Music Stream
group: true
scale: '1.25'
info: scroll
hide:
info: true
icon: true
name: true
runtime: true
volume: true
power: false
controls: true
progress: true
style: |
ha-card {
float:right;
position: relative;
top: -140px;
}
- type: 'custom:mini-media-player'
entity: media_player.music
toggle_power: true
artwork: none
source: full
name: Music Stream
info: scroll
group: true
scale: '1.25'
hide:
info: true
icon: true
name: true
runtime: false
volume: true
power: true
source: true
controls: true
card_mod:
style: |
ha-card {
width: 93%;
margin: auto;
position: relative;
top: -100px;
}
- type: 'custom:mini-media-player'
entity: media_player.music
toggle_power: true
artwork: none
source: full
name: Music Stream
info: scroll
group: true
scale: '1.25'
hide:
info: true
icon: true
name: true
volume: true
power: true
source: true
progress: true
play_pause: true
play_stop: false
card_mod:
style: |
ha-card {
margin-left: auto;
position: relative;
top: -100px;
}
title: Music

View File

@ -0,0 +1,98 @@
# DS-KH6350 Doorbell Automations
# Copy these automations to your main automations.yaml file
# Doorbell Button Press Notification
- id: doorbell_button_pressed
alias: "Doorbell Button Pressed"
description: "Send notification when doorbell button is pressed"
trigger:
- platform: state
entity_id: binary_sensor.doorbell_button_pressed
to: 'on'
action:
- service: notify.doorbell_notifications
data:
title: "🔔 Doorbell"
message: "Someone is at the door!"
data:
image: "/local/doorbell_snapshot.jpg"
actions:
- action: "view_doorbell"
title: "View Live"
- action: "unlock_door"
title: "Unlock Door"
# Play doorbell sound
- service: media_player.play_media
target:
entity_id: media_player.your_speaker # Replace with your speaker entity
data:
media_content_id: "/local/doorbell/doorbell_door.mp3"
media_content_type: "music"
# Save snapshot
- service: shell_command.copy_doorbell_snap
# TTS Announcement
- service: tts.google_say
data:
entity_id: media_player.your_speaker # Replace with your speaker entity
message: "Someone is at the door"
language: "en"
# Doorbell Motion Detection
- id: doorbell_motion_detected
alias: "Doorbell Motion Detected"
description: "Log motion at doorbell"
trigger:
- platform: state
entity_id: binary_sensor.doorbell_motion
to: 'on'
condition:
- condition: time
after: '06:00:00'
before: '23:00:00'
action:
- service: logbook.log
data:
name: "Doorbell Motion"
message: "Motion detected at front door"
entity_id: binary_sensor.doorbell_motion
# Auto-unlock door (optional - only if you have smart lock)
- id: doorbell_auto_unlock
alias: "Doorbell Auto Unlock"
description: "Auto unlock door for family members"
trigger:
- platform: state
entity_id: binary_sensor.doorbell_button_pressed
to: 'on'
condition:
- condition: state
entity_id: person.your_name # Replace with your person entity
state: 'home'
- condition: time
after: '07:00:00'
before: '22:00:00'
action:
- service: lock.unlock
target:
entity_id: lock.front_door # Replace with your lock entity if you have one
- service: notify.mobile_app_your_phone
data:
message: "Door automatically unlocked for family member"
# Doorbell Camera Snapshot on Motion
- id: doorbell_snapshot_on_motion
alias: "Doorbell Snapshot on Motion"
description: "Take snapshot when motion detected"
trigger:
- platform: state
entity_id: binary_sensor.doorbell_motion
to: 'on'
action:
- service: camera.snapshot
target:
entity_id: camera.doorbell_camera
data:
filename: "/config/www/doorbell_latest.jpg"

View File

@ -0,0 +1,88 @@
- cal_id: 0h6s0nklhnu9jf7ku154ugk2ks@group.calendar.google.com
entities:
- device_id: the_ultimate_ufc_calendar
ignore_availability: true
name: The Ultimate UFC Calendar
track: true
- cal_id: '#daynum@group.v.calendar.google.com'
entities:
- device_id: day_of_the_year
ignore_availability: true
name: Day of the Year
track: true
- cal_id: p#weeknum@group.v.calendar.google.com
entities:
- device_id: week_numbers
ignore_availability: true
name: Week Numbers
track: true
- cal_id: i_2a00z1028z838ez441az1c59za687z8287z2a41#sunrise@group.v.calendar.google.com
entities:
- device_id: sunrise_and_sunset_for_prague
ignore_availability: true
name: Sunrise and sunset for Prague
track: true
- cal_id: family12884184840560780322@group.calendar.google.com
entities:
- device_id: family
ignore_availability: true
name: Family
track: true
- cal_id: addressbook#contacts@group.v.calendar.google.com
entities:
- device_id: birthdays
ignore_availability: true
name: Birthdays
track: true
- cal_id: en.polish#holiday@group.v.calendar.google.com
entities:
- device_id: holidays_in_poland
ignore_availability: true
name: Holidays in Poland
track: true
- cal_id: eu37qo9idg8p2v17omrj5aq9ko@group.calendar.google.com
entities:
- device_id: premier_boxing_champions_usa
ignore_availability: true
name: Premier Boxing Champions (USA)
track: true
- cal_id: sebastian.blasiak@gmail.com
entities:
- device_id: sebastian_blasiak
ignore_availability: true
name: Sebastian Blasiak
track: true
- cal_id: en.czech#holiday@group.v.calendar.google.com
entities:
- device_id: holidays_in_czechia
ignore_availability: true
name: Holidays in Czechia
track: true
- cal_id: ked0ao233h92ogj8l80b7e9a4g@group.calendar.google.com
entities:
- device_id: stephanie
ignore_availability: true
name: Stephanie
- cal_id: krgome98bvo8vrdu0k9kjjki70@group.calendar.google.com
entities:
- device_id: stefi_medical
ignore_availability: true
name: Stefi medical
- cal_id: family10173867005854311693@group.calendar.google.com
entities:
- device_id: family
ignore_availability: true
name: Family

View File

View File

@ -0,0 +1,38 @@
vacuum_room_corridor:
name: "Vacuum: Korytarz"
icon: mdi:home-floor-negative-1
vacuum_room_office:
name: "Vacuum: Biuro"
icon: mdi:desk
vacuum_room_bathroom:
name: "Vacuum: Łazienka"
icon: mdi:shower
vacuum_room_hall:
name: "Vacuum: Przedpokój"
icon: mdi:shoe-sneaker
vacuum_room_living_room:
name: "Vacuum: Salon"
icon: mdi:sofa
vacuum_room_kitchen:
name: "Vacuum: Kuchnia"
icon: mdi:silverware-fork-knife
vacuum_room_bedroom:
name: "Vacuum: Sypialnia"
icon: mdi:bed
vacuum_room_stefi:
name: "Vacuum: Pokój Stefi"
icon: mdi:human-child
office_attendance_today:
name: Office Attendance Today
icon: mdi:office-building-marker
doorbell_trigger:
name: Doorbell Trigger

View File

@ -0,0 +1,43 @@
kitchen_last_activity:
name: "Kitchen Last PIR Activity"
has_date: true
has_time: true
day_start:
name: Day Start
has_date: false
has_time: true
day_end:
name: Day End
has_date: false
has_time: true
late_day_start:
name: Late Day Start
has_date: false
has_time: true
late_day_end:
name: Late Day End
has_date: false
has_time: true
school_start:
name: School Start
has_date: false
has_time: true
school_end:
name: School End
has_date: false
has_time: true
buxus_last_watered:
name: Buxus Last Watered
has_date: true
has_time: true
office_attendance_last_updated:
name: Office Attendance Last Updated
has_date: true
has_time: true
office_attendance_quarter_start:
name: Current Quarter Start Date
has_date: true
has_time: false

View File

@ -0,0 +1,84 @@
vacuum_repeats:
name: "Vacuum: Powtórzenia"
min: 1
max: 3
step: 1
initial: 1
mode: slider
icon: mdi:repeat
distance_threshold:
name: Distance Threshold
min: 0
max: 20000
step: 100
unit_of_measurement: m
icon: mdi:map-marker-distance
temperature_when_window_open:
name: Last termostat temperature
min: 10
max: 36
step: 0.1
unit_of_measurement: °C
icon: mdi:temperature-celsius
# Office attendance tracking
office_attendance_days_total:
name: Office Attendance Days Total
min: 0
max: 365
step: 1
mode: box
icon: mdi:calendar-month
office_attendance_days_present:
name: Office Attendance Days Present
min: 0
max: 365
step: 1
mode: box
icon: mdi:calendar-check
office_attendance_percentage:
name: Office Attendance Percentage
min: 0
max: 100
step: 0.1
mode: box
unit_of_measurement: '%'
icon: mdi:percent
office_attendance_quarter_days_total:
name: Quarterly Office Attendance Days Total
min: 0
max: 100
step: 1
mode: box
icon: mdi:calendar-month
office_attendance_quarter_days_present:
name: Quarterly Office Attendance Days Present
min: 0
max: 100
step: 1
mode: box
icon: mdi:calendar-check
office_attendance_quarter_percentage:
name: Quarterly Office Attendance Percentage
min: 0
max: 100
step: 0.1
mode: box
unit_of_measurement: '%'
icon: mdi:percent
office_attendance_current_quarter:
name: Current Fiscal Quarter
min: 1
max: 4
step: 1
mode: box
icon: mdi:calendar-clock
initial: 1

View File

@ -0,0 +1,10 @@
46.135.5.141:
banned_at: '2025-11-28T06:45:35.314629+00:00'
46.135.4.64:
banned_at: '2025-11-30T13:27:25.459906+00:00'
192.168.1.102:
banned_at: '2026-02-23T09:18:43.966606+00:00'

View File

@ -0,0 +1,17 @@
# Example configuration.yaml entry
yeelight:
devices:
192.168.1.54:
name: Kitchen Led
transition: 1000
use_music_mode: true
save_on_change: true
custom_effects:
- name: 'Fire Flicker'
flow_params:
count: 0
transitions:
- TemperatureTransition: [1900, 1000, 80]
- TemperatureTransition: [1900, 2000, 60]
- SleepTransition: [1000]

View File

@ -0,0 +1,38 @@
# Manual Office Attendance Check
# Use this to manually trigger attendance tracking
automation:
- id: 'office_attendance_manual_trigger_service'
alias: Office Attendance Manual Check Service
description: Provides a service to manually check office attendance
trigger:
- platform: event
event_type: call_service
event_data:
domain: script
service: office_attendance_manual_check
action:
- event: office_attendance_manual_check
event_data:
source: manual_service
- service: notify.mobile_app_is17
data:
message: "Manual office attendance check triggered"
title: "Office Attendance"
script:
office_attendance_manual_check:
alias: Check Office Attendance Now
description: Manually trigger office attendance check
sequence:
- event: office_attendance_manual_check
event_data:
source: manual_script
- service: notify.mobile_app_is17
data:
message: >-
Manual attendance check triggered.
Currently in wday zone: {{ is_state('person.seba', 'wday') }}
Workday sensor: {{ states('binary_sensor.workday_sensor') }}
Timer state: {{ states('timer.office_attendance_timer') }}
title: "Office Attendance Check"

View File

@ -0,0 +1,70 @@
# --- CONFIG ---
FAN_ENTITY = "switch.attic_fan"
SENSOR_ATTIC = "sensor.attic_temperature"
SENSOR_SWITCH = "sensor.usw_pro_max_16_poe_temperature"
# Progi włączenia (ON)
THRESH_ATTIC_ON = 40
THRESH_SWITCH_ON = 60
# Progi wyłączenia (OFF) - Histereza
THRESH_ATTIC_OFF = 38
THRESH_SWITCH_OFF = 55
# --- LOGIC ---
# Triggerujemy przy zmianie którejkolwiek temperatury LUB co 5 minut (Watchdog) LUB przy starcie
@state_trigger(f"{SENSOR_ATTIC}, {SENSOR_SWITCH}")
@time_trigger("period(now, 5min)", "startup")
def attic_fan_thermal_control():
"""
Steruje wentylatorem na strychu.
Zasada Fail-Safe:
- Przy włączaniu: Brak odczytu = 0 (Nie włączaj pochopnie)
- Przy wyłączaniu: Brak odczytu = 100 (Nie wyłączaj, jeśli nie wiesz czy ostygło!)
"""
# 1. Pobieramy stany
state_attic = state.get(SENSOR_ATTIC)
state_switch = state.get(SENSOR_SWITCH)
fan_state = state.get(FAN_ENTITY)
# 2. Parsowanie (z logiką Fail-Safe jak w YAML)
try:
temp_attic = float(state_attic)
except (ValueError, TypeError):
temp_attic = None # Sensor padł/nieznany
try:
temp_switch = float(state_switch)
except (ValueError, TypeError):
temp_switch = None
# --- WARUNEK WŁĄCZENIA (ON) ---
# Logika: Attic > 40 OR Switch > 60
# Safety: Jeśli temp jest None, traktuj jako 0 (nie włączaj bez powodu)
check_attic_on = (temp_attic if temp_attic is not None else 0) > THRESH_ATTIC_ON
check_switch_on = (temp_switch if temp_switch is not None else 0) > THRESH_SWITCH_ON
if check_attic_on or check_switch_on:
if fan_state == 'off':
log.info(f"Attic Fan: COOLING START. (Attic: {state_attic}, Switch: {state_switch})")
switch.turn_on(entity_id=FAN_ENTITY)
notify.mobile_app_is17(
message=f"Cooling started. Attic: {state_attic}°C, Switch: {state_switch}°C"
)
return # Koniec, nie sprawdzaj warunku OFF
# --- WARUNEK WYŁĄCZENIA (OFF) ---
# Logika: Attic < 38 AND Switch < 55
# Safety: Jeśli temp jest None, traktuj jako 100 (nie wyłączaj, bo może się gotuje!)
check_attic_off = (temp_attic if temp_attic is not None else 100) < THRESH_ATTIC_OFF
check_switch_off = (temp_switch if temp_switch is not None else 100) < THRESH_SWITCH_OFF
if check_attic_off and check_switch_off:
if fan_state == 'on':
log.info(f"Attic Fan: COOLING STOP. (Attic: {state_attic}, Switch: {state_switch})")
switch.turn_off(entity_id=FAN_ENTITY)
notify.mobile_app_is17(
message=f"Cooling finished. Attic: {state_attic}°C, Switch: {state_switch}°C"
)

View File

@ -0,0 +1,77 @@
import os
# --- KONFIGURACJA ---
CAMERA_ENTITY = "camera.frigate_hikvision_4"
SAVE_PATH = "/config/www/frigate_notification/hikvision_4_street-robo-person.jpg"
TRIGGER_SENSORS = [
"binary_sensor.street_left_person_occupancy",
"binary_sensor.robo_person_occupancy"
]
# Lista atrybutów, których NIE chcemy widzieć w logach (śmieci systemowe)
GARBAGE_ATTRS = [
"icon", "friendly_name", "device_class", "access_token",
"entity_picture", "supported_features", "restored",
"state_class", "unit_of_measurement", "attribution"
]
# --- HELPERY ---
def get_clean_attributes(entity_id):
"""Pobiera atrybuty i usuwa z nich systemowe śmieci."""
raw = state.getattr(entity_id)
if not raw: return {}
clean = {}
for k, v in raw.items():
if k not in GARBAGE_ATTRS:
clean[k] = v
return clean
# --- LOGIKA ---
@state_trigger(f"{TRIGGER_SENSORS[0]} == 'on' or {TRIGGER_SENSORS[1]} == 'on'")
def snapshot_street_robo(var_name=None, value=None):
"""
Robi snapshot i loguje CZYSTE dane o detekcji.
"""
trigger_entity = var_name
# 1. Zbieramy dane
# A. Z sensora, który wywołał (np. binary_sensor)
details = get_clean_attributes(trigger_entity)
# B. PRO TIP: Próbujemy pobrać dane z sensora "count" (bliźniaka)
# Frigate często tam trzyma listę obiektów/stref
sibling_entity = trigger_entity.replace("binary_sensor.", "sensor.").replace("_occupancy", "_count")
if state.get(sibling_entity) not in ["unavailable", "unknown", None]:
sibling_attrs = get_clean_attributes(sibling_entity)
if sibling_attrs:
details.update(sibling_attrs) # Dokładamy dane z licznika do raportu
# 2. Budujemy ładny log (tekstowy, nie JSON)
log_msg = f"📸 Snapshot Triggered by: {trigger_entity}"
if details:
log_msg += "\n📊 Detection Info:"
for k, v in details.items():
log_msg += f"\n - {k}: {v}"
log.info(log_msg)
# 3. Zapis zdjęcia
directory = os.path.dirname(SAVE_PATH)
if not os.path.exists(directory):
try:
os.makedirs(directory, exist_ok=True)
except Exception as e:
log.error(f"Directory error: {e}")
return
try:
camera.snapshot(entity_id=CAMERA_ENTITY, filename=SAVE_PATH)
except Exception as e:
log.error(f"Snapshot error: {e}")

View File

@ -0,0 +1,73 @@
from datetime import datetime
# Konfiguracja mapowania: Trigger Entity -> Dane kamery
CAMERA_MAPPING = {
"binary_sensor.reolink_4_person": {
"camera": "camera.frigate_reolink_4",
"dir": "reolink_4",
"code": "reo_4"
},
"binary_sensor.reolink_5_person": {
"camera": "camera.frigate_reolink_5",
"dir": "reolink_5",
"code": "reo_5"
},
"binary_sensor.frigate_hikvision_2_person_occupancy": {
"camera": "camera.frigate_hikvision_2",
"dir": "hikvision_2",
"code": "hik_2"
},
"binary_sensor.frigate_reolink_1_person_occupancy": {
"camera": "camera.frigate_reolink_1",
"dir": "reolink_1",
"code": "reo_1"
},
"binary_sensor.frigate_reolink_2_person_occupancy": {
"camera": "camera.frigate_reolink_2",
"dir": "reolink_2",
"code": "reo_2"
},
"binary_sensor.frigate_hikvision_4_person_occupancy": {
"camera": "camera.frigate_hikvision_4",
"dir": "hikvision_4",
"code": "hik_4"
},
"binary_sensor.frigate_reolink_3_person_occupancy": {
"camera": "camera.frigate_reolink_3",
"dir": "reolink_3",
"code": "reo_3"
}
}
# --- POPRAWKA TUTAJ ---
# Zamiast samej listy kluczy, tworzymy listę warunków logicznych.
# Wynik to np.: ["binary_sensor.x == 'on'", "binary_sensor.y == 'on'", ...]
TRIGGERS = [f"{entity} == 'on'" for entity in CAMERA_MAPPING.keys()]
# Usuwamy błędny argument state="on". Przekazujemy tylko listę warunków.
@state_trigger(TRIGGERS)
def handle_person_snapshot(trigger_type=None, var_name=None, value=None):
"""
Robi zdjęcie, gdy wykryto osobę na zdefiniowanych kamerach.
"""
cam_config = CAMERA_MAPPING.get(var_name)
if not cam_config:
log.warning(f"Otrzymano trigger z {var_name}, ale brak go w mapowaniu.")
return
now = datetime.now()
ts_date = now.strftime("%Y-%m-%d")
ts_stamp = now.strftime("%Y%m%d-%H%M%S")
filename_path = (
f"/hikvision16t/{cam_config['dir']}/{ts_date}/"
f"{ts_stamp}_{cam_config['code']}_ha_person_snap.jpg"
)
try:
camera.snapshot(entity_id=cam_config['camera'], filename=filename_path)
log.info(f"Zrobiono zdjęcie: {filename_path} z kamery {cam_config['camera']}")
except Exception as e:
log.error(f"Błąd podczas robienia zdjęcia dla {cam_config['camera']}: {e}")

View File

@ -0,0 +1,81 @@
import os
from datetime import datetime
# --- KONFIGURACJA GŁÓWNA ---
BASE_PATH = "/hikvision16t"
CAMERAS_DAY = {
"camera.reolink_4_fluent": {"folder": "reolink_4", "prefix": "reo_4"},
"camera.frigate_hikvision_2": {"folder": "hikvision_2", "prefix": "hik_2"},
"camera.reolink_5_fluent": {"folder": "reolink_5", "prefix": "reo_5"},
"camera.frigate_hikvision_4": {"folder": "hikvision_4", "prefix": "hik_4"},
"camera.reolink_1_fluent": {"folder": "reolink_1", "prefix": "reo_1"},
"camera.reolink_2_main": {"folder": "reolink_2", "prefix": "reo_2"},
"camera.reolink_3_fluent": {"folder": "reolink_3", "prefix": "reo_3"},
}
CAMERAS_NIGHT = {
"camera.reolink_4_fluent": {"folder": "reolink_4", "prefix": "reo_4"},
"camera.reolink_5_fluent": {"folder": "reolink_5", "prefix": "reo_5"},
"camera.frigate_hikvision_2": {"folder": "hikvision_2", "prefix": "hik_2"},
"camera.frigate_hikvision_4": {"folder": "hikvision_4", "prefix": "hik_4"},
"camera.frigate_reolink_1": {"folder": "reolink_1", "prefix": "reo_1"},
"camera.frigate_reolink_2": {"folder": "reolink_2", "prefix": "reo_2"},
"camera.frigate_reolink_3": {"folder": "reolink_3", "prefix": "reo_3"},
}
# --- HELPER (Wspólna logika) ---
def take_snapshots_batch(camera_dict, mode_suffix):
"""
Wykonuje zdjęcia, tworząc uprzednio strukturę katalogów.
"""
now = datetime.now()
date_str = now.strftime("%Y-%m-%d")
time_str = now.strftime("%Y%m%d-%H%M%S")
total_cams = len(camera_dict)
success_count = 0
for entity_id, config in camera_dict.items():
folder = config["folder"]
prefix = config["prefix"]
# Budowanie ścieżek
dir_path = f"{BASE_PATH}/{folder}/{date_str}"
filename = f"{dir_path}/{time_str}_{prefix}_ha_int_snap_{mode_suffix}.jpg"
try:
# Tworzenie katalogu
if not os.path.exists(dir_path):
os.makedirs(dir_path, exist_ok=True)
# Wykonanie usługi
camera.snapshot(
entity_id=entity_id,
filename=filename
)
success_count += 1
except Exception as e:
log.error(f"❌ Snapshot ERROR ({entity_id}): {e}")
# --- RAPORT KOŃCOWY (To zobaczysz w logach) ---
if success_count == total_cams:
log.info(f"📸 Batch {mode_suffix.upper()}: Success. Saved {success_count}/{total_cams} images.")
else:
log.warning(f"⚠️ Batch {mode_suffix.upper()}: Partial success. Saved {success_count}/{total_cams} images.")
# --- TRIGGERY ---
@time_trigger("period(now, 45s)")
def loop_snapshots_day():
if state.get("sun.sun") == "above_horizon":
take_snapshots_batch(CAMERAS_DAY, "day")
@time_trigger("cron(*/5 * * * *)")
def loop_snapshots_night():
if state.get("sun.sun") == "below_horizon":
take_snapshots_batch(CAMERAS_NIGHT, "night")

View File

@ -0,0 +1,134 @@
from datetime import datetime, timedelta, timezone
# --- KONFIGURACJA (GLOBALS) ---
SEASON_START_NOV = "12-05"
SEASON_END_JAN = "01-06"
NO_CURFEW_DATES = ["12-24", "12-25", "12-26"]
LIGHTS_BASIC = [
"switch.tasmota_christmas_lights",
"switch.christmas_tree"
]
LIGHTS_LED = [
"switch.christmas_tree_led_hidden",
"light.christmas_tree_led"
]
ALL_LIGHTS = LIGHTS_BASIC + LIGHTS_LED
# --- HELPERY (BULLETPROOF LOGIC) ---
def is_christmas_season():
now_str = datetime.now().strftime('%m-%d')
is_nov_dec = (SEASON_START_NOV <= now_str <= "12-31")
is_jan = ("01-01" <= now_str <= SEASON_END_JAN)
return is_nov_dec or is_jan
def safe_control(entity_list, action="turn_on"):
"""
Bezpiecznie steruje listą urządzeń.
Ignoruje niedostępne (unavailable), wykonuje resztę.
"""
success_count = 0
for entity in entity_list:
current_state = state.get(entity)
if current_state in ["unavailable", "unknown", None]:
log.warning(f"🎄 Christmas Skip: {entity} is {current_state}. Skipping.")
continue
try:
if action == "turn_on":
homeassistant.turn_on(entity_id=entity)
else:
homeassistant.turn_off(entity_id=entity)
success_count += 1
except Exception as e:
log.error(f"🎄 Christmas Error: Failed to {action} {entity}. Reason: {e}")
return success_count
# --- LOGIKA ---
# 0. WATCHDOG - poza sezonem wyłącz po 15 sekundach
@state_trigger(f"{LIGHTS_BASIC[0]} == 'on' or {LIGHTS_BASIC[1]} == 'on' or {LIGHTS_LED[0]} == 'on' or {LIGHTS_LED[1]} == 'on'")
def xmas_out_of_season_watchdog():
if is_christmas_season():
return
log.info("🎄 Christmas Watchdog: Lights turned on outside season. Will turn off in 15 seconds.")
task.sleep(15)
# Sprawdź ponownie czy nadal poza sezonem (edge case)
if not is_christmas_season():
log.info("🎄 Christmas Watchdog: 15s passed, turning off.")
safe_control(ALL_LIGHTS, "turn_off")
# 1. RANO ON (SCHEDULER: Wschód - 1h 35m)
@time_trigger("cron(0 2 * * *)")
def schedule_xmas_morning():
if not is_christmas_season(): return
sun_attrs = state.getattr("sun.sun")
next_rising_str = sun_attrs.get("next_rising")
if not next_rising_str:
log.warning("🎄 Christmas: Cannot determine sunrise time. Skipping morning lights.")
return
try:
sunrise_dt = datetime.fromisoformat(next_rising_str)
target_time = sunrise_dt - timedelta(hours=1, minutes=35)
now_utc = datetime.now(timezone.utc)
seconds_to_wait = (target_time - now_utc).total_seconds()
if seconds_to_wait > 0:
log.info(f"🎄 Christmas Scheduler: Target is {target_time}. Sleeping for {int(seconds_to_wait)} seconds.")
task.sleep(seconds_to_wait)
log.info("🎄 Christmas: Morning Time Reached. Attempting BASIC lights.")
count = safe_control(LIGHTS_BASIC, "turn_on")
if count > 0:
notify.mobile_app_is17(message=f"Christmas lights (Basic) ON. ({count}/{len(LIGHTS_BASIC)})")
else:
log.warning(f"🎄 Christmas Scheduler: Calculated time {target_time} is in the past. Lights logic skipped.")
except Exception as e:
log.error(f"🎄 Christmas Scheduler Error: {e}")
# 2. WIECZÓR ON (Zachód słońca) -> BASIC + LED
@state_trigger("sun.sun == 'below_horizon'")
def xmas_evening_on():
if not is_christmas_season(): return
if state.get(LIGHTS_BASIC[0]) == 'on':
return
log.info("🎄 Christmas: Sunset. Attempting ALL lights.")
count = safe_control(ALL_LIGHTS, "turn_on")
if count > 0:
notify.mobile_app_is17(message=f"Christmas lights (All) ON. ({count}/{len(ALL_LIGHTS)})")
# 3. RANO OFF (Wschód słońca) -> ALL OFF
@state_trigger("sun.sun == 'above_horizon'")
def xmas_morning_off():
if not is_christmas_season(): return
log.info("🎄 Christmas: Sun is up. Turning OFF all lights.")
safe_control(ALL_LIGHTS, "turn_off")
# 4. WIECZÓR OFF (22:00) -> ALL OFF
@time_trigger("cron(0 22 * * *)")
def xmas_night_off():
if not is_christmas_season(): return
now_str = datetime.now().strftime('%m-%d')
if now_str in NO_CURFEW_DATES:
log.info(f"🎄 Christmas: It's {now_str}, skipping curfew.")
return
log.info("🎄 Christmas: 22:00 Curfew. Turning OFF all lights.")
count = safe_control(ALL_LIGHTS, "turn_off")
notify.mobile_app_is17(message=f"Christmas lights OFF. ({count} devices responded)")

View File

@ -0,0 +1,38 @@
# --- CONFIG ---
LIGHT_DRIVEWAY = "light.light_driveway"
DISCREET_MODE = "input_boolean.dsc" # Tryb Dyskretny (Ninja Mode)
PERSON_SEBA = "person.seba"
TRIGGERS_MOTION = [
"binary_sensor.frigate_reolink_1_person_occupancy",
"binary_sensor.parking_person_occupancy"
]
# --- LOGIC ---
@state_trigger(f"{TRIGGERS_MOTION[0]} == 'on' or {TRIGGERS_MOTION[1]} == 'on'")
@state_trigger(f"{PERSON_SEBA} == 'home'")
def driveway_auto_on():
"""
Włącza podjazd po wykryciu ruchu lub powrocie do domu.
WARUNEK KONIECZNY: Tryb Dyskretny (DSC) musi być WYŁĄCZONY.
"""
# 1. Warunek: Ciemno (oszczędzamy prąd w dzień)
if state.get("sun.sun") != "below_horizon":
return
# 2. Warunek: Tryb Dyskretny (Jeśli ON -> nic nie rób)
# Chcesz wejść "po cichu/po ciemku"
if state.get(DISCREET_MODE) == "on":
log.info("Driveway: Auto-ON skipped (Discreet Mode active).")
return
# 3. Redukcja spamu (Jeśli już świeci, nie wysyłaj komendy)
if state.get(LIGHT_DRIVEWAY) == 'on':
return
# Action
log.info(f"Driveway: Motion detected. Turning ON. (Discreet: OFF)")
light.turn_on(entity_id=LIGHT_DRIVEWAY)

View File

@ -0,0 +1,121 @@
from datetime import datetime
# --- CONFIG ---
CHARGER_GARAGE = "switch.garage_plug_1"
CHARGER_TECHNICKA = "switch.technicka_charger"
TEMP_SENSOR = "sensor.home_temperature"
# Limity i Progi
TEMP_THRESHOLD = 5.0
MAX_RUNTIME_MIN = 15
# Harmonogram (Czas)
TIME_START = "14:10:14"
TIME_STOP = "14:29:10"
# --- HELPERY ---
def get_garage_temp():
try:
return float(state.get(TEMP_SENSOR))
except (ValueError, TypeError):
return None
# --- LOGIC: SCHEDULE START ---
@time_trigger(f"once({TIME_START})")
def charging_schedule_start():
"""
Start ładowania w cyklu "co 3 dzień".
Liczone na podstawie dni od Epoch, co zapewnia idealną ciągłość niezależnie od tygodni.
"""
now = datetime.now()
# Obliczamy surową liczbę dni od 1970 roku.
# To daje nam ciągły licznik dni: ... 19998, 19999, 20000 ...
days_since_epoch = int(now.timestamp() // 86400)
# LOGIKA CO 3 DNI:
# Reszta z dzielenia przez 3 da nam wynik: 0, 1 lub 2.
# Zmień '== 0' na '== 1' lub '== 2', jeśli chcesz przesunąć cykl o 1 lub 2 dni do przodu,
# aby trafić w dzisiejszy dzień (lub go pominąć).
if days_since_epoch % 3 != 0:
log.info(f"Charger Schedule: Skipped based on 3-day cycle (Day mod 3 = {days_since_epoch % 3})")
return
temp = get_garage_temp()
temp_str = temp if temp is not None else "?"
# 1. Technicka (Zawsze)
msg_tech = ""
if state.get(CHARGER_TECHNICKA) == 'on':
msg_tech = f"**House (Technicka) charger was already ON.** (Temp: {temp_str}°C)."
else:
switch.turn_on(entity_id=CHARGER_TECHNICKA)
msg_tech = f"**House (Technicka) charger started.** (Temp: {temp_str}°C)."
# 2. Garaż (Warunkowo)
msg_garage = ""
garage_ok = (temp is not None) and (temp >= TEMP_THRESHOLD)
if garage_ok:
if state.get(CHARGER_GARAGE) == 'on':
msg_garage = f"**Garage charger was already ON.** (Temp OK: {temp_str}°C)."
else:
switch.turn_on(entity_id=CHARGER_GARAGE)
msg_garage = f"**Garage charger started.** (Temp OK: {temp_str}°C)."
else:
msg_garage = f"**Garage charger skipped** (Too Cold: {temp_str}°C < {TEMP_THRESHOLD}°C)."
# Powiadomienie
notify.mobile_app_is17(
title="⚡ Charger Coordinator: Schedule Start (3-day cycle)",
message=f"{msg_tech}\n{msg_garage}"
)
# --- LOGIC: SCHEDULE STOP ---
@time_trigger(f"once({TIME_STOP})")
def charging_schedule_stop():
"""
Sztywne zatrzymanie o określonej godzinie.
"""
entities_to_stop = []
if state.get(CHARGER_GARAGE) == 'on':
entities_to_stop.append(CHARGER_GARAGE)
if state.get(CHARGER_TECHNICKA) == 'on':
entities_to_stop.append(CHARGER_TECHNICKA)
if entities_to_stop:
for e in entities_to_stop:
switch.turn_off(entity_id=e)
notify.mobile_app_is17(
title="Charger — scheduled stop",
message=f"Daily stop at {TIME_STOP} — turned OFF: {', '.join(entities_to_stop)}"
)
# --- LOGIC: SAFETY WATCHDOG (Max Runtime) ---
@state_trigger(f"{CHARGER_GARAGE} == 'on'", state_hold=MAX_RUNTIME_MIN*60)
@state_trigger(f"{CHARGER_TECHNICKA} == 'on'", state_hold=MAX_RUNTIME_MIN*60)
def charger_safety_cutoff(trigger_type=None, var_name=None):
"""
Bezpiecznik: Wyłącza ładowarkę, jeśli działa za długo.
"""
entity_id = var_name
if entity_id:
log.warning(f"Charger Safety: {entity_id} exceeded {MAX_RUNTIME_MIN}m. Cutting power.")
switch.turn_off(entity_id=entity_id)
friendly_name = state.getattr(entity_id).get("friendly_name", entity_id)
notify.mobile_app_is17(
title="Charger — safety stop",
message=f"{friendly_name} exceeded {MAX_RUNTIME_MIN} minutes runtime — forced OFF for safety."
)

View File

@ -0,0 +1,307 @@
from datetime import datetime, time
# --- CONFIG ---
CLIMATE_MAIN = "climate.termostat"
CLIMATE_ALL = ["climate.bathroom", "climate.bedroom", "climate.living_room", "climate.office", "climate.termostat"]
# Temperature Inputs
TEMP_ECO = "input_number.eco_temperature"
TEMP_DAY_HIGH = "input_number.temperature_day_high"
TEMP_DAY_LOW = "input_number.temperature_day_low"
TEMP_NIGHT_HIGH = "input_number.temperature_night_high"
TEMP_NIGHT_LOW = "input_number.temperature_night_low"
# Time Inputs
TIME_DAY_START = "input_datetime.day_start"
TIME_DAY_END = "input_datetime.day_end"
TIME_LATE_DAY_START = "input_datetime.late_day_start"
TIME_LATE_DAY_END = "input_datetime.late_day_end"
TIME_SCHOOL_START = "input_datetime.school_start"
TIME_SCHOOL_END = "input_datetime.school_end"
# Sensors & Switches
WEATHER_TEMP = "weather.forecast_home"
WORKDAY = "binary_sensor.workday_sensor"
STEFI_MODE = "input_boolean.stefinka"
SCHOOL_MODE = "input_boolean.school"
HEATING_MODE = "input_boolean.heating_season"
DISTANCE_DIST = "sensor.home_is17_distance"
# === REAL TEMPERATURE CONTROL ===
SENSOR_REAL_TEMP = "sensor.average_temperature_home"
TEMP_HYSTERESIS = 0.3
TEMP_BOOST_OFFSET = 3.0
# === DISTANCE THRESHOLDS ===
DISTANCE_LIGHTS_OFF = 100
DISTANCE_HEATING_TRSH = "input_number.distance_threshold"
DISTANCE_HYSTERESIS = 500
# Logic Flags & Scenes
INPUT_SEB_DEPARTED = "input_boolean.seb_departed"
SCENE_LIGHTS_OFF = "scene.all_lights_off"
WINDOW_SENSOR = "binary_sensor.any_window_door_open"
# Presence Sensors
PRESENCE_PRIMARY = "binary_sensor.aqara_presence_presence"
OCCUPANCY_SENSORS = [
"binary_sensor.athom_presence_occupancy",
"binary_sensor.small_hall",
"binary_sensor.living_room_motion",
"binary_sensor.kitchen_motion"
]
# === STATE ===
_heating_state = {
"eco_mode": False,
"last_sync_time": None
}
SYNC_DEBOUNCE_SECONDS = 55
# --- HELPERS ---
def get_time_from_input(entity_id):
state_val = state.get(entity_id)
try:
if not state_val:
return time(8, 0)
return datetime.strptime(state_val, "%H:%M:%S").time()
except:
return time(8, 0)
def is_house_occupied():
"""Sprawdza czy ktoś jest w domu - zwraca nazwę sensora lub None"""
if state.get(PRESENCE_PRIMARY) == 'on':
log.info(f"Occupancy detected: {PRESENCE_PRIMARY} is ON")
return PRESENCE_PRIMARY
for sensor in OCCUPANCY_SENSORS:
if state.get(sensor) == 'on':
log.info(f"Occupancy detected: {sensor} is ON")
return sensor
return None
def get_real_temperature():
try:
val = state.get(SENSOR_REAL_TEMP)
if val in ["unavailable", "unknown", None]:
return None
return round(float(val), 2)
except:
return None
def can_run_sync():
"""Debounce na wywołania sync - max raz na minutę"""
global _heating_state
now = datetime.now()
last_sync = _heating_state.get("last_sync_time")
if last_sync:
seconds_since = (now - last_sync).total_seconds()
if seconds_since < SYNC_DEBOUNCE_SECONDS:
return False
_heating_state["last_sync_time"] = now
return True
def execute_departure_lights(dist, reason=""):
log.info(f"House Manager: Lights OFF ({int(dist)}m). {reason}")
try:
input_boolean.turn_on(entity_id=INPUT_SEB_DEPARTED)
scene.turn_on(entity_id=SCENE_LIGHTS_OFF)
notify.mobile_app_is17(
title="🏠 Departure: Lights Off",
message=f"Distance: {int(dist)}m. House empty - lights secured."
)
except Exception as e:
log.error(f"Lights Off Execution Error: {e}")
def get_target_temp():
"""Oblicza docelową temperaturę z histerezą na dystans i wysyła notyfikacje o zmianie trybu ECO"""
global _heating_state
try:
dist = float(state.get(DISTANCE_DIST))
threshold = float(state.get(DISTANCE_HEATING_TRSH))
if _heating_state["eco_mode"]:
# W ECO - wyjdź TYLKO gdy dystans znacznie spadnie
if dist < (threshold - DISTANCE_HYSTERESIS):
_heating_state["eco_mode"] = False
log.info(f"Heating: EXIT ECO - dist={int(dist)}m < {int(threshold - DISTANCE_HYSTERESIS)}m")
notify.mobile_app_is17(
title="🔥 Ogrzewanie: Powrót do domu",
message=f"Dystans spadł poniżej {int(threshold - DISTANCE_HYSTERESIS)}m. Wyłączam tryb ECO, nagrzewam dom."
)
else:
# Zostań w ECO - ignoruj sensory presence gdy daleko
return float(state.get(TEMP_ECO))
else:
# Nie w ECO - wejdź gdy daleko
if dist > threshold:
_heating_state["eco_mode"] = True
log.info(f"Heating: ENTER ECO - dist={int(dist)}m > threshold={int(threshold)}m")
notify.mobile_app_is17(
title="❄️ Ogrzewanie: Tryb ECO",
message=f"Oddaliłeś się na {int(dist)}m. Włączam oszczędzanie energii (ECO)."
)
return float(state.get(TEMP_ECO))
except Exception as e:
log.error(f"Heating: Distance error: {e}")
# Normalna logika harmonogramu (nie ECO)
now_dt = datetime.now()
current_time = now_dt.time()
is_workday = state.get(WORKDAY) == 'on'
is_stefi = state.get(STEFI_MODE) == 'on'
is_school = state.get(SCHOOL_MODE) == 'on'
if is_workday:
t_start = get_time_from_input(TIME_DAY_START)
t_end = get_time_from_input(TIME_DAY_END)
else:
t_start = get_time_from_input(TIME_LATE_DAY_START)
t_end = get_time_from_input(TIME_LATE_DAY_END)
try:
weather_attrs = state.getattr(WEATHER_TEMP)
outside_temp = float(weather_attrs.get("temperature", 10)) if weather_attrs else 10
except:
outside_temp = 10
offset_min = 110
if outside_temp > 8:
offset_min = 45
elif outside_temp > 0:
offset_min = 65
curr_min = current_time.hour * 60 + current_time.minute
start_min = t_start.hour * 60 + t_start.minute
end_min = t_end.hour * 60 + t_end.minute
eff_start_min = start_min - offset_min
if curr_min < eff_start_min or curr_min >= end_min:
return float(state.get(TEMP_NIGHT_HIGH)) if is_stefi else float(state.get(TEMP_NIGHT_LOW))
if is_stefi and is_school and is_workday:
s_start = get_time_from_input(TIME_SCHOOL_START)
s_end = get_time_from_input(TIME_SCHOOL_END)
s_start_min = s_start.hour * 60 + s_start.minute
s_end_min = s_end.hour * 60 + s_end.minute
if s_start_min <= curr_min < (s_end_min - offset_min):
return float(state.get(TEMP_DAY_LOW))
return float(state.get(TEMP_DAY_HIGH)) if is_stefi else float(state.get(TEMP_DAY_LOW))
# --- TRIGGERS ---
@state_trigger(DISTANCE_DIST)
def handle_presence_change():
try:
dist_val = state.get(DISTANCE_DIST)
if dist_val in ["unavailable", "unknown", None]:
return
dist = float(dist_val)
heating_threshold = float(state.get(DISTANCE_HEATING_TRSH))
except:
return
# Światła - tylko gdy nikt w domu (sensory)
if dist > DISTANCE_LIGHTS_OFF and state.get(INPUT_SEB_DEPARTED) == 'off':
if not is_house_occupied():
execute_departure_lights(dist, "Distance trigger")
# Heating - zawsze sync gdy daleko
if dist > heating_threshold:
heating_schedule_sync()
# Powrót do domu
if dist <= DISTANCE_LIGHTS_OFF and state.get(INPUT_SEB_DEPARTED) == 'on':
input_boolean.turn_off(entity_id=INPUT_SEB_DEPARTED)
heating_schedule_sync()
@time_trigger("period(now, 1min)")
def departure_watchdog():
try:
dist = float(state.get(DISTANCE_DIST))
except:
return
# Światła
if dist > DISTANCE_LIGHTS_OFF and state.get(INPUT_SEB_DEPARTED) == 'off':
if not is_house_occupied():
execute_departure_lights(dist, "Watchdog")
# Heating - NIE wywołuj sync z watchdog (tylko z time_trigger)
# To eliminuje duplikaty
@time_trigger("period(now, 5min)")
@state_trigger(SENSOR_REAL_TEMP)
def heating_schedule_sync():
"""GŁÓWNY MANAGER OGRZEWANIA"""
global _heating_state
if not can_run_sync():
return
if state.get(HEATING_MODE) != 'on':
return
if state.get(WINDOW_SENSOR) == 'on':
log.info("Heating: Window open - skipping")
return
try:
target_temp = get_target_temp()
real_temp = get_real_temperature()
if real_temp is None:
log.warning("Heating: Real temperature unavailable")
return
if target_temp is None:
log.warning("Heating: Target temperature unavailable")
return
climate_attrs = state.getattr(CLIMATE_MAIN)
if not climate_attrs:
return
current_setpoint = float(climate_attrs.get("temperature", 0))
current_hvac = state.get(CLIMATE_MAIN)
heat_start_threshold = target_temp - TEMP_HYSTERESIS
heat_stop_threshold = target_temp
if real_temp < heat_start_threshold:
new_setpoint = target_temp + TEMP_BOOST_OFFSET
action = "HEATING"
hvac_mode = "heat"
elif real_temp >= heat_stop_threshold:
new_setpoint = target_temp
action = "TARGET REACHED"
hvac_mode = "heat"
else:
new_setpoint = current_setpoint
action = "HYSTERESIS"
hvac_mode = current_hvac if current_hvac != 'off' else "heat"
new_setpoint = max(17.0, min(26.0, new_setpoint))
setpoint_changed = abs(current_setpoint - new_setpoint) > 0.1
hvac_changed = current_hvac != hvac_mode
if setpoint_changed or hvac_changed:
log.info(f"Heating [{action}]: Real={real_temp}°C, Target={target_temp}°C, Setpoint={current_setpoint}->{new_setpoint}, ECO={_heating_state['eco_mode']}")
if setpoint_changed:
climate.set_temperature(entity_id=CLIMATE_MAIN, temperature=new_setpoint)
if hvac_changed and hvac_mode == "heat":
climate.set_hvac_mode(entity_id=CLIMATE_MAIN, hvac_mode="heat")
except Exception as e:
log.error(f"Heating Sync Error: {e}")

View File

@ -0,0 +1,343 @@
from datetime import datetime, time
# --- CONFIG ---
CLIMATE_MAIN = "climate.termostat"
CLIMATE_ALL = ["climate.bathroom", "climate.bedroom", "climate.living_room", "climate.office", "climate.termostat"]
# Temperature Inputs
TEMP_ECO = "input_number.eco_temperature"
TEMP_DAY_HIGH = "input_number.temperature_day_high"
TEMP_DAY_LOW = "input_number.temperature_day_low"
TEMP_NIGHT_HIGH = "input_number.temperature_night_high"
TEMP_NIGHT_LOW = "input_number.temperature_night_low"
# Time Inputs
TIME_DAY_START = "input_datetime.day_start"
TIME_DAY_END = "input_datetime.day_end"
TIME_LATE_DAY_START = "input_datetime.late_day_start"
TIME_LATE_DAY_END = "input_datetime.late_day_end"
TIME_SCHOOL_START = "input_datetime.school_start"
TIME_SCHOOL_END = "input_datetime.school_end"
# Sensors & Switches
WEATHER_TEMP = "weather.forecast_home"
WORKDAY = "binary_sensor.workday_sensor"
STEFI_MODE = "input_boolean.stefinka"
SCHOOL_MODE = "input_boolean.school"
HEATING_MODE = "input_boolean.heating_season"
DISTANCE_DIST = "sensor.home_is17_distance"
# === REAL TEMPERATURE CONTROL ===
SENSOR_REAL_TEMP = "sensor.average_temperature_home"
TEMP_HYSTERESIS = 0.3
TEMP_BOOST_OFFSET = 3.0
# === DISTANCE THRESHOLDS ===
DISTANCE_LIGHTS_OFF = 100
DISTANCE_HEATING_TRSH = "input_number.distance_threshold"
DISTANCE_HYSTERESIS = 500
# Logic Flags & Scenes
INPUT_SEB_DEPARTED = "input_boolean.seb_departed"
SCENE_LIGHTS_OFF = "scene.all_lights_off"
WINDOW_SENSOR = "binary_sensor.any_window_door_open"
# Presence Sensors
PRESENCE_PRIMARY = "binary_sensor.aqara_presence_presence"
OCCUPANCY_SENSORS = [
"binary_sensor.athom_presence_occupancy",
"binary_sensor.small_hall",
"binary_sensor.living_room_motion",
"binary_sensor.kitchen_motion"
]
# === DEBOUNCE & STATE ===
_heating_state = {
"eco_mode": False,
"last_action": None,
"last_setpoint": None,
"last_target": None,
"last_notify_time": None,
"last_sync_time": None
}
NOTIFY_DEBOUNCE_MINUTES = 30
SYNC_DEBOUNCE_SECONDS = 55
# --- HELPERS ---
def get_time_from_input(entity_id):
state_val = state.get(entity_id)
try:
if not state_val:
return time(8, 0)
return datetime.strptime(state_val, "%H:%M:%S").time()
except:
return time(8, 0)
def is_house_occupied():
"""Sprawdza czy ktoś jest w domu - zwraca nazwę sensora lub None"""
if state.get(PRESENCE_PRIMARY) == 'on':
log.info(f"Occupancy detected: {PRESENCE_PRIMARY} is ON")
return PRESENCE_PRIMARY
for sensor in OCCUPANCY_SENSORS:
if state.get(sensor) == 'on':
log.info(f"Occupancy detected: {sensor} is ON")
return sensor
return None
def get_real_temperature():
try:
val = state.get(SENSOR_REAL_TEMP)
if val in ["unavailable", "unknown", None]:
return None
return round(float(val), 2)
except:
return None
def should_notify_heating(action, new_setpoint, target_temp):
"""Debounce notyfikacji - tylko przy realnej zmianie"""
global _heating_state
now = datetime.now()
last_action = _heating_state.get("last_action")
last_setpoint = _heating_state.get("last_setpoint")
last_target = _heating_state.get("last_target")
last_time = _heating_state.get("last_notify_time")
# Zmiana akcji, setpointu lub targetu -> notyfikuj
action_changed = action != last_action
setpoint_changed = abs((new_setpoint or 0) - (last_setpoint or 0)) > 0.5
target_changed = abs((target_temp or 0) - (last_target or 0)) > 0.5
if action_changed or setpoint_changed or target_changed:
_heating_state["last_action"] = action
_heating_state["last_setpoint"] = new_setpoint
_heating_state["last_target"] = target_temp
_heating_state["last_notify_time"] = now
return True
# Ta sama sytuacja - sprawdź debounce czasowy
if last_time:
minutes_since = (now - last_time).total_seconds() / 60
if minutes_since < NOTIFY_DEBOUNCE_MINUTES:
return False
_heating_state["last_notify_time"] = now
return True
def can_run_sync():
"""Debounce na wywołania sync - max raz na minutę"""
global _heating_state
now = datetime.now()
last_sync = _heating_state.get("last_sync_time")
if last_sync:
seconds_since = (now - last_sync).total_seconds()
if seconds_since < SYNC_DEBOUNCE_SECONDS:
return False
_heating_state["last_sync_time"] = now
return True
def execute_departure_lights(dist, reason=""):
log.info(f"House Manager: Lights OFF ({int(dist)}m). {reason}")
try:
input_boolean.turn_on(entity_id=INPUT_SEB_DEPARTED)
scene.turn_on(entity_id=SCENE_LIGHTS_OFF)
notify.mobile_app_is17(
title="🏠 Departure: Lights Off",
message=f"Distance: {int(dist)}m. House empty - lights secured."
)
except Exception as e:
log.error(f"Lights Off Execution Error: {e}")
def get_target_temp():
"""Oblicza docelową temperaturę z histerezą na dystans"""
global _heating_state
try:
dist = float(state.get(DISTANCE_DIST))
threshold = float(state.get(DISTANCE_HEATING_TRSH))
if _heating_state["eco_mode"]:
# W ECO - wyjdź TYLKO gdy dystans znacznie spadnie
if dist < (threshold - DISTANCE_HYSTERESIS):
_heating_state["eco_mode"] = False
log.info(f"Heating: EXIT ECO - dist={int(dist)}m < {int(threshold - DISTANCE_HYSTERESIS)}m")
else:
# Zostań w ECO - ignoruj sensory presence gdy daleko
return float(state.get(TEMP_ECO))
else:
# Nie w ECO - wejdź gdy daleko
if dist > threshold:
_heating_state["eco_mode"] = True
log.info(f"Heating: ENTER ECO - dist={int(dist)}m > threshold={int(threshold)}m")
return float(state.get(TEMP_ECO))
except Exception as e:
log.error(f"Heating: Distance error: {e}")
# Normalna logika harmonogramu
now_dt = datetime.now()
current_time = now_dt.time()
is_workday = state.get(WORKDAY) == 'on'
is_stefi = state.get(STEFI_MODE) == 'on'
is_school = state.get(SCHOOL_MODE) == 'on'
if is_workday:
t_start = get_time_from_input(TIME_DAY_START)
t_end = get_time_from_input(TIME_DAY_END)
else:
t_start = get_time_from_input(TIME_LATE_DAY_START)
t_end = get_time_from_input(TIME_LATE_DAY_END)
try:
weather_attrs = state.getattr(WEATHER_TEMP)
outside_temp = float(weather_attrs.get("temperature", 10)) if weather_attrs else 10
except:
outside_temp = 10
offset_min = 110
if outside_temp > 8:
offset_min = 45
elif outside_temp > 0:
offset_min = 65
curr_min = current_time.hour * 60 + current_time.minute
start_min = t_start.hour * 60 + t_start.minute
end_min = t_end.hour * 60 + t_end.minute
eff_start_min = start_min - offset_min
if curr_min < eff_start_min or curr_min >= end_min:
return float(state.get(TEMP_NIGHT_HIGH)) if is_stefi else float(state.get(TEMP_NIGHT_LOW))
if is_stefi and is_school and is_workday:
s_start = get_time_from_input(TIME_SCHOOL_START)
s_end = get_time_from_input(TIME_SCHOOL_END)
s_start_min = s_start.hour * 60 + s_start.minute
s_end_min = s_end.hour * 60 + s_end.minute
if s_start_min <= curr_min < (s_end_min - offset_min):
return float(state.get(TEMP_DAY_LOW))
return float(state.get(TEMP_DAY_HIGH)) if is_stefi else float(state.get(TEMP_DAY_LOW))
# --- TRIGGERS ---
@state_trigger(DISTANCE_DIST)
def handle_presence_change():
try:
dist_val = state.get(DISTANCE_DIST)
if dist_val in ["unavailable", "unknown", None]:
return
dist = float(dist_val)
heating_threshold = float(state.get(DISTANCE_HEATING_TRSH))
except:
return
# Światła - tylko gdy nikt w domu (sensory)
if dist > DISTANCE_LIGHTS_OFF and state.get(INPUT_SEB_DEPARTED) == 'off':
if not is_house_occupied():
execute_departure_lights(dist, "Distance trigger")
# Heating - zawsze sync gdy daleko
if dist > heating_threshold:
heating_schedule_sync()
# Powrót do domu
if dist <= DISTANCE_LIGHTS_OFF and state.get(INPUT_SEB_DEPARTED) == 'on':
input_boolean.turn_off(entity_id=INPUT_SEB_DEPARTED)
heating_schedule_sync()
@time_trigger("period(now, 1min)")
def departure_watchdog():
try:
dist = float(state.get(DISTANCE_DIST))
heating_threshold = float(state.get(DISTANCE_HEATING_TRSH))
except:
return
# Światła
if dist > DISTANCE_LIGHTS_OFF and state.get(INPUT_SEB_DEPARTED) == 'off':
if not is_house_occupied():
execute_departure_lights(dist, "Watchdog")
# Heating - NIE wywołuj sync z watchdog (tylko z time_trigger)
# To eliminuje duplikaty
@time_trigger("period(now, 5min)")
@state_trigger(SENSOR_REAL_TEMP)
def heating_schedule_sync():
"""GŁÓWNY MANAGER OGRZEWANIA"""
global _heating_state
if not can_run_sync():
return
if state.get(HEATING_MODE) != 'on':
return
if state.get(WINDOW_SENSOR) == 'on':
log.info("Heating: Window open - skipping")
return
try:
target_temp = get_target_temp()
real_temp = get_real_temperature()
if real_temp is None:
log.warning("Heating: Real temperature unavailable")
return
if target_temp is None:
log.warning("Heating: Target temperature unavailable")
return
climate_attrs = state.getattr(CLIMATE_MAIN)
if not climate_attrs:
return
current_setpoint = float(climate_attrs.get("temperature", 0))
current_hvac = state.get(CLIMATE_MAIN)
heat_start_threshold = target_temp - TEMP_HYSTERESIS
heat_stop_threshold = target_temp
if real_temp < heat_start_threshold:
new_setpoint = target_temp + TEMP_BOOST_OFFSET
action = "HEATING"
hvac_mode = "heat"
elif real_temp >= heat_stop_threshold:
new_setpoint = target_temp
action = "TARGET REACHED"
hvac_mode = "heat"
else:
new_setpoint = current_setpoint
action = "HYSTERESIS"
hvac_mode = current_hvac if current_hvac != 'off' else "heat"
new_setpoint = max(17.0, min(26.0, new_setpoint))
setpoint_changed = abs(current_setpoint - new_setpoint) > 0.1
hvac_changed = current_hvac != hvac_mode
if setpoint_changed or hvac_changed:
log.info(f"Heating [{action}]: Real={real_temp}°C, Target={target_temp}°C, Setpoint={current_setpoint}->{new_setpoint}, ECO={_heating_state['eco_mode']}")
if setpoint_changed:
climate.set_temperature(entity_id=CLIMATE_MAIN, temperature=new_setpoint)
if hvac_changed and hvac_mode == "heat":
climate.set_hvac_mode(entity_id=CLIMATE_MAIN, hvac_mode="heat")
if should_notify_heating(action, new_setpoint, target_temp):
if action != "HYSTERESIS":
notify.mobile_app_is17(
title=f"🌡️ {action}",
message=f"Real: {real_temp}°C | Target: {target_temp}°C | Set: {new_setpoint}°C"
)
except Exception as e:
log.error(f"Heating Sync Error: {e}")

View File

@ -0,0 +1,125 @@
# --- CONFIG ---
REMOTE_DEVICE_ID = "f511daaa15c111eb93bfb774afd8e1e3"
# Entities
LIGHT_HALL = "light.small_hall_zbmini"
AUTO_HALL_MOTION = "automation.when_entering_home_in_dark_turn_small_hall_light_on"
GUEST_MODE = "input_boolean.guest_home"
MEDIA_PLAYER = "media_player.living_room_2"
TIMEOUT_INPUT = "input_number.lights_off_timeout"
GATE_COVER = "cover.my_gate"
# Groups/Lists of lights
OUTSIDE_LIGHTS = [
"light.light_driveway", "light.new_sidewalk",
"light.parking_light", "light.entrance_door", "light.terrace"
]
ALL_OUTSIDE_OFF_LIGHTS = OUTSIDE_LIGHTS + [
"light.garage_lights", "light.garage_bulb",
"switch.sonoff_1002434bbe", "switch.sonoff_1002431b6a"
]
BEEP_SOUND_URL = "https://www.soundjay.com/buttons/sounds/beep-07.mp3"
# --- HELPERY ---
def speak(message):
"""
Wypowiada dowolny komunikat (dynamiczny), jeśli Guest Mode jest OFF.
"""
if state.get(GUEST_MODE) == 'on':
return
if message:
try:
tts.google_say(
entity_id=MEDIA_PLAYER,
message=message
)
except Exception as e:
log.warning(f"IKEA Remote TTS Error: {e}")
def play_real_bip():
"""Odtwarza krótki plik dźwiękowy."""
try:
media_player.play_media(
entity_id=MEDIA_PLAYER,
media_content_id=BEEP_SOUND_URL,
media_content_type="music"
)
except Exception as e:
log.warning(f"IKEA Remote Beep Error: {e}")
# --- LOGIC ---
@event_trigger("zha_event", f"device_id == '{REMOTE_DEVICE_ID}'")
def ikea_entrance_controller(**kwargs):
"""
IKEA Tradfri 5-Button Controller Logic.
"""
command = kwargs.get("command")
args = kwargs.get("args", [])
params = kwargs.get("params", {})
if params is None: params = {}
step_mode = params.get("step_mode")
log.debug(f"IKEA Remote: Cmd={command}, Args={args}, Mode={step_mode}")
# 1. CENTER BUTTON (Toggle Hall)
if command in ['toggle', 'on', 'off', 'short_press']:
if state.get(LIGHT_HALL) == 'on':
# OFF Sequence
automation.turn_off(entity_id=AUTO_HALL_MOTION)
light.turn_off(entity_id=LIGHT_HALL)
speak("Hallway off. Motion sensor paused for 3 minutes.")
task.sleep(180)
automation.turn_on(entity_id=AUTO_HALL_MOTION)
else:
# ON Sequence
light.turn_on(entity_id=LIGHT_HALL)
automation.turn_on(entity_id=AUTO_HALL_MOTION)
speak("Toggling hallway light.")
# 2. TOP BUTTON (Outside ON + Timer based on current config)
elif command in ['step', 'step_with_on_off'] and step_mode == 0:
# A. Odczytaj obecny timeout (BEZ ZMIANIANIA GO)
try:
current_timeout = float(state.get(TIMEOUT_INPUT))
except (ValueError, TypeError):
current_timeout = 5.0 # Fallback gdyby input_number był niedostępny
# B. Włącz światła
light.turn_on(entity_id=OUTSIDE_LIGHTS)
# C. Powiedz tekst (Czytając odczytaną wartość)
speak(f"Outside lights on, safety off for {int(current_timeout)} minutes.")
# D. Czekaj tyle ile wynosi timeout
task.sleep(current_timeout * 60)
# E. Wyłącz światła (nie dotykamy input_number)
light.turn_off(entity_id=OUTSIDE_LIGHTS)
# 3. BOTTOM BUTTON (Outside OFF)
elif command in ['step', 'move', 'move_with_on_off'] and step_mode == 1:
light.turn_off(entity_id=ALL_OUTSIDE_OFF_LIGHTS)
speak("Turning off outside lights.")
# 4. LEFT/RIGHT BUTTONS
elif command == 'press':
# LEFT - Gate
if args and args[0] == 257:
cover.open_cover(entity_id=GATE_COVER)
notify.mobile_app_is17(message="Gate opened via Remote")
speak("Opening the gate.")
# RIGHT - Guest Mode
elif args and args[0] == 256:
input_boolean.toggle(entity_id=GUEST_MODE)
play_real_bip()

View File

@ -0,0 +1,326 @@
from datetime import datetime
# --- CONSTANTS & CONFIG ---
# DISCREET MODE - jeśli ON, światło NIE włącza się ruchem (np. śpimy na sofie)
DISCREET_MODE = "input_boolean.dsc"
# 1. PODZIAŁ SENSORÓW
# PIR: Reagują na duży ruch (chodzenie) - Dozwolone zawsze
PIR_SENSORS = [
"binary_sensor.kitchen_motion",
"binary_sensor.living_room_motion"
]
# PRESENCE: Reagują na oddech/spanie - W nocy dozwolone tylko po dłuższej nieaktywności
PRESENCE_SENSORS = [
"binary_sensor.athom_presence_occupancy",
"binary_sensor.aqara_presence_presence"
]
ALL_MOTION_SENSORS = PIR_SENSORS + PRESENCE_SENSORS
# Trigger dla Okapu
HOOD_TRIGGERS = [
"binary_sensor.kitchen_motion",
"binary_sensor.athom_presence_occupancy",
"binary_sensor.aqara_presence_presence"
]
# Lights & Switches
LIGHT_STRIP_POWER = "light.sonoff_mini_kitchen_led"
LIGHT_STRIP_RGB = "light.kitchen_led_light"
SWITCH_SIEMENS = "switch.zigbee_at_siemens"
SWITCH_HOOD = "switch.kitchen_hood"
USER_TRACKER = "person.seba"
# Input helper do śledzenia ostatniej aktywności (utwórz w HA: input_datetime.kitchen_last_activity)
LAST_ACTIVITY_HELPER = "input_datetime.kitchen_last_activity"
# --- TIME SETTINGS ---
TIME_DAY_START = 6
TIME_DAY_END = 20 # Dzień: 6:00 - 20:00
TIME_EVENING_END = 23 # Wieczór: 20:00 - 23:00, Noc: 23:00 - 6:00
HOOD_TIMEOUT = 7200
# Timeout dla presence w nocy (10 minut nieaktywności = można włączyć)
NIGHT_PRESENCE_UNLOCK_MINUTES = 10
# --- TIMEOUTS (w sekundach) ---
TIMEOUTS = {
"day": {
"to_ambient": 180, # 3 min do ambient
"to_off": 15 # 15 sek do wyłączenia po ambient
},
"evening": {
"to_ambient": 120, # 2 min do ambient
"to_off": 10 # 10 sek do wyłączenia
},
"night": {
"to_ambient": 45, # 45 sek do ambient (szybciej!)
"to_off": 5 # 5 sek do wyłączenia (szybciej!)
}
}
# --- LIGHT SCENES ---
# Ciepłe barwy zamiast białego, wieczorem/nocą bardziej czerwone
SCENES = {
# DZIEŃ - ciepłe, ale jasne (bez białego)
"day_active": {"rgb": [255, 180, 100], "bri": 100, "trans": 2},
"day_ambient": {"rgb": [255, 150, 70], "bri": 35, "trans": 8},
# WIECZÓR - cieplejsze, wpadające w czerwień
"evening_active": {"rgb": [255, 120, 50], "bri": 70, "trans": 2},
"evening_ambient": {"rgb": [255, 80, 30], "bri": 25, "trans": 8},
# NOC - bardzo ciepłe, czerwonawe, delikatne
"night_active": {"rgb": [255, 60, 20], "bri": 40, "trans": 2},
"night_ambient":{"rgb": [255, 30, 5], "bri": 8, "trans": 5}
}
# --- HELPER FUNCTIONS ---
def is_discreet_mode():
"""Sprawdza czy tryb discreet jest włączony (DSC ON = nie włączaj ruchem)"""
return state.get(DISCREET_MODE) == 'on'
def get_time_period():
"""Zwraca aktualny okres: 'day', 'evening' lub 'night'"""
hour = datetime.now().hour
if TIME_DAY_START <= hour < TIME_DAY_END:
return "day"
elif TIME_DAY_END <= hour < TIME_EVENING_END:
return "evening"
else:
return "night"
def get_minutes_since_last_activity():
"""Zwraca liczbę minut od ostatniej aktywności PIR"""
try:
last_activity_str = state.get(LAST_ACTIVITY_HELPER)
if last_activity_str in [None, 'unknown', 'unavailable']:
return 999 # Brak danych = traktuj jako dawno
last_activity = datetime.fromisoformat(last_activity_str)
delta = datetime.now() - last_activity
return delta.total_seconds() / 60
except Exception as e:
log.warning(f"Error getting last activity: {e}")
return 999
def update_last_activity():
"""Aktualizuje timestamp ostatniej aktywności PIR"""
try:
input_datetime.set_datetime(
entity_id=LAST_ACTIVITY_HELPER,
datetime=datetime.now().isoformat()
)
except Exception as e:
log.warning(f"Error updating last activity: {e}")
def can_presence_trigger_light():
"""
Sprawdza czy sensor presence może włączyć światło.
- W dzień/wieczór: zawsze TAK
- W nocy: tylko jeśli minęło >10 min od ostatniej aktywności PIR
"""
period = get_time_period()
if period in ["day", "evening"]:
return True
# Noc - sprawdź czy minęło wystarczająco dużo czasu
minutes_inactive = get_minutes_since_last_activity()
return minutes_inactive >= NIGHT_PRESENCE_UNLOCK_MINUTES
def apply_light_scene(scene_name):
"""Aplikuje scenę świetlną"""
settings = SCENES.get(scene_name)
if not settings:
return
if state.get(LIGHT_STRIP_POWER) != 'on':
light.turn_on(entity_id=LIGHT_STRIP_POWER)
task.sleep(0.5)
light.turn_on(
entity_id=LIGHT_STRIP_RGB,
rgb_color=settings["rgb"],
brightness_pct=settings["bri"],
transition=settings["trans"]
)
def get_scene_for_mode(mode):
"""Zwraca nazwę sceny dla aktualnego okresu i trybu (active/ambient)"""
period = get_time_period()
return f"{period}_{mode}"
# --- SYNC LOGIC ---
@state_trigger(f"{LIGHT_STRIP_RGB}")
def sync_siemens_light_follower():
"""Watchdog: Replicates state: RGB ON -> Siemens ON."""
led_state = state.get(LIGHT_STRIP_RGB)
siemens_state = state.get(SWITCH_SIEMENS)
if led_state == 'on' and siemens_state != 'on':
switch.turn_on(entity_id=SWITCH_SIEMENS)
elif led_state == 'off' and siemens_state != 'off':
switch.turn_off(entity_id=SWITCH_SIEMENS)
# --- MOTION & LIGHT LOGIC ---
@state_trigger(f"{ALL_MOTION_SENSORS[0]} == 'on' or {ALL_MOTION_SENSORS[1]} == 'on' or {ALL_MOTION_SENSORS[2]} == 'on' or {ALL_MOTION_SENSORS[3]} == 'on'")
def kitchen_presence_active(trigger_type=None, var_name=None, value=None):
"""
ACTIVE MODE: Ruch wykryty.
- PIR: Aktualizuje last_activity i zawsze włącza światło
- Presence: W nocy wymaga 10+ min nieaktywności
"""
# A. Jeśli to PIR - zawsze aktualizuj timestamp
if var_name in PIR_SENSORS:
update_last_activity()
# B. DISCREET MODE - jeśli włączony, nie włączaj światła
if is_discreet_mode():
log.debug(f"Discreet mode ON - ignoruję {var_name}")
return
# C. Sprawdź czy presence może włączyć światło
if var_name in PRESENCE_SENSORS:
if not can_presence_trigger_light():
if state.get(LIGHT_STRIP_RGB) == 'off':
log.debug(f"Sleep Guard: Ignoruję {var_name} w nocy (aktywność <{NIGHT_PRESENCE_UNLOCK_MINUTES} min temu)")
return
# D. Włącz światło w odpowiedniej scenie
scene = get_scene_for_mode("active")
apply_light_scene(scene)
# E. Obsługa Okapu
if var_name in HOOD_TRIGGERS:
if state.get(SWITCH_HOOD) == 'off':
log.info(f"Kitchen: Motion on {var_name}. Turning Hood ON.")
switch.turn_on(entity_id=SWITCH_HOOD)
# --- AMBIENT MODE (różne timeouty dla pór dnia) ---
@state_trigger("binary_sensor.kitchen_motion == 'off' and binary_sensor.living_room_motion == 'off'")
@state_active("light.kitchen_led_light == 'on'")
def kitchen_ambient_mode_day(state_hold=180):
"""AMBIENT MODE - DZIEŃ (3 min timeout)"""
if get_time_period() != "day":
return
log.info("Kitchen [DAY]: No motion (3 min). Fading to Ambient.")
apply_light_scene("day_ambient")
@state_trigger("binary_sensor.kitchen_motion == 'off' and binary_sensor.living_room_motion == 'off'", state_hold=120)
@state_active("light.kitchen_led_light == 'on'")
def kitchen_ambient_mode_evening():
"""AMBIENT MODE - WIECZÓR (2 min timeout)"""
if get_time_period() != "evening":
return
log.info("Kitchen [EVENING]: No motion (2 min). Fading to Ambient.")
apply_light_scene("evening_ambient")
@state_trigger("binary_sensor.kitchen_motion == 'off' and binary_sensor.living_room_motion == 'off'", state_hold=45)
@state_active("light.kitchen_led_light == 'on'")
def kitchen_ambient_mode_night():
"""AMBIENT MODE - NOC (45 sek timeout - szybciej!)"""
if get_time_period() != "night":
return
log.info("Kitchen [NIGHT]: No motion (45s). Fading to Ambient.")
apply_light_scene("night_ambient")
# --- EMPTY MODE (różne timeouty dla pór dnia) ---
@state_trigger(f"{ALL_MOTION_SENSORS[0]} == 'off' and {ALL_MOTION_SENSORS[1]} == 'off' and {ALL_MOTION_SENSORS[2]} == 'off' and {ALL_MOTION_SENSORS[3]} == 'off'", state_hold=15)
@state_active("light.kitchen_led_light == 'on'")
def kitchen_room_empty_day():
"""EMPTY MODE - DZIEŃ (15 sek po ambient)"""
if get_time_period() != "day":
return
_kitchen_shutdown()
@state_trigger(f"{ALL_MOTION_SENSORS[0]} == 'off' and {ALL_MOTION_SENSORS[1]} == 'off' and {ALL_MOTION_SENSORS[2]} == 'off' and {ALL_MOTION_SENSORS[3]} == 'off'", state_hold=10)
@state_active("light.kitchen_led_light == 'on'")
def kitchen_room_empty_evening():
"""EMPTY MODE - WIECZÓR (10 sek po ambient)"""
if get_time_period() != "evening":
return
_kitchen_shutdown()
@state_trigger(f"{ALL_MOTION_SENSORS[0]} == 'off' and {ALL_MOTION_SENSORS[1]} == 'off' and {ALL_MOTION_SENSORS[2]} == 'off' and {ALL_MOTION_SENSORS[3]} == 'off'", state_hold=5)
@state_active("light.kitchen_led_light == 'on'")
def kitchen_room_empty_night():
"""EMPTY MODE - NOC (5 sek po ambient - bardzo szybko!)"""
if get_time_period() != "night":
return
_kitchen_shutdown()
def _kitchen_shutdown():
"""Wspólna logika wyłączania kuchni"""
period = get_time_period()
transition = 2 if period == "day" else 1 # Szybsza tranzycja w nocy
log.info(f"Kitchen [{period.upper()}]: Room empty. Lights OFF.")
light.turn_off(entity_id=LIGHT_STRIP_RGB, transition=transition)
# Sprawdź czy wyłączyć okap (user away)
current_location = state.get(USER_TRACKER)
if current_location not in ['home', 'around']:
if state.get(SWITCH_HOOD) == 'on':
log.warning(f"Kitchen: User Away ({current_location}). Hood Force OFF.")
switch.turn_off(entity_id=SWITCH_HOOD)
# --- HOOD LOGIC ---
@state_trigger(f"{HOOD_TRIGGERS[0]} == 'off' and {HOOD_TRIGGERS[1]} == 'off' and {HOOD_TRIGGERS[2]} == 'off'", state_hold=HOOD_TIMEOUT)
def kitchen_hood_auto_off():
"""HOOD TIMER - auto off po 2h bez ruchu"""
if state.get(SWITCH_HOOD) == 'on':
log.info(f"Kitchen: Hood running for > 2h without motion. Turning OFF.")
switch.turn_off(entity_id=SWITCH_HOOD)
# --- MAINTENANCE ---
@state_trigger(f"{LIGHT_STRIP_RGB} == 'unavailable' and {LIGHT_STRIP_POWER} == 'on'", state_hold=120)
def kitchen_health_check_fix():
if state.get(LIGHT_STRIP_POWER) != 'on':
return
log.warning("HealthCheck: Kitchen LEDs unavailable > 2 min. Performing HARD RESET.")
light.turn_off(entity_id=LIGHT_STRIP_POWER)
task.sleep(5)
light.turn_on(entity_id=LIGHT_STRIP_POWER)
notify.mobile_app_is17(title="🔧 Auto-Fix", message="Zrestartowano LEDy w kuchni.")
@state_trigger(f"{LIGHT_STRIP_POWER} == 'off'", state_hold=10)
def kitchen_power_loss_guard():
log.info("Watchdog: Power strip OFF detected! Enforcing ALWAYS ON policy.")
light.turn_on(entity_id=LIGHT_STRIP_POWER)
# --- INITIALIZATION ---
@time_trigger("startup")
def kitchen_init():
"""Inicjalizacja przy starcie - ustaw last_activity na teraz"""
update_last_activity()
log.info("Kitchen automation initialized. Last activity timestamp set.")

View File

@ -0,0 +1,34 @@
import datetime # <-- Tego brakowało
# /config/pyscript/late_lamps_off.py
TIMEOUT = 900 # 15 minut (900 sekund) jako int
SENSORS = [
"binary_sensor.office_motion",
"binary_sensor.athom_presence_occupancy",
"binary_sensor.aqara_presence_presence",
"binary_sensor.kitchen_motion"
]
LAMPS = [
"switch.living_room_lamp",
"switch.office_lamp",
"light.standing_lamp",
"light.symfolamp"
]
# Trigger odpala się, gdy WSZYSTKIE sensory są 'off' przez 15 minut
@state_trigger(" and ".join([f"{s} == 'off'" for s in SENSORS]), state_hold=TIMEOUT)
def late_night_lamps_cleanup():
# 1. Sprawdź godzinę (22:00 - 02:59)
current_hour = datetime.datetime.now().hour
if not (current_hour >= 22 or current_hour <= 2):
return
# 2. Ściemnij Tasmotę w biurze do 1%
if state.get("light.tasmota") == "on":
light.turn_on(entity_id="light.tasmota", brightness_pct=1)
# 3. Sprawdź czy inne lampy są włączone i wyłącz sceną
any_lamp_on = any(state.get(l) == "on" for l in LAMPS)
if any_lamp_on:
scene.turn_on(entity_id="scene.lamps_off_when_late")
log.info("Brak ruchu/obecności przez 15min w nocy. Wyłączam lampy.")

View File

@ -0,0 +1,62 @@
# /config/pyscript/low_battery_digest.py
# Lista fraz do ignorowania (wpisz fragmenty entity_id)
IGNORE_KEYWORDS = ["aqara_presence"]
@service
def check_batteries(threshold=20):
"""
Skanuje wszystkie encje battery_* i wysyła raport, jeśli poziom < threshold.
Ignoruje encje pasujące do IGNORE_KEYWORDS.
"""
low_battery_devices = []
# Iterujemy po wszystkich encjach w systemie
for entity_id in state.names(domain="sensor"):
# 1. Sprawdź czy ignorować tę encję
should_skip = False
for keyword in IGNORE_KEYWORDS:
if keyword in entity_id:
should_skip = True
break
if should_skip:
continue
# Szukamy tych, które wyglądają na baterie
if "battery" in entity_id or "power" in entity_id:
curr_state = state.get(entity_id)
attrs = state.getattr(entity_id)
# Pomijamy niedostępne
if curr_state in ["unavailable", "unknown", None]:
continue
# Sprawdzamy device_class (najpewniejsza metoda)
dev_class = attrs.get("device_class")
if dev_class == "battery":
try:
level = float(curr_state)
if level <= threshold:
friendly_name = attrs.get("friendly_name", entity_id)
low_battery_devices.append(f"{friendly_name}: {int(level)}%")
except ValueError:
pass
if low_battery_devices:
# Tworzymy ładną listę stringów
msg_body = "\n".join(low_battery_devices)
msg = f"🪫 **Low Battery Alert:**\n{msg_body}"
# Wysyłamy powiadomienie na telefon
notify.mobile_app_is17(title="Battery Report", message=msg)
# LOGOWANIE PEŁNEJ LISTY:
log.warning(f"Battery Report sent. Low devices:\n{msg_body}")
else:
log.info("Battery Check: All good.")
# CRON: Poniedziałek 10:00 rano
@time_trigger("cron(0 10 * * 1)")
def scheduled_battery_check():
check_batteries()

View File

@ -0,0 +1,588 @@
"""Helper stub that exposes pyscript's dynamic built-ins to static analyzers.
The real implementations are injected by pyscript at runtime; only signatures
and documentation live here.
"""
# pylint: disable=unnecessary-ellipsis, invalid-name, redefined-outer-name
from __future__ import annotations
from asyncio import Task
from collections.abc import Callable
from datetime import datetime
from typing import Any, Literal
from homeassistant.core import HomeAssistant
hass: HomeAssistant
def service(
*service_name: str, supports_response: Literal["none", "only", "optional"] = "none"
) -> Callable[..., Any]:
"""Register the wrapped function as a Home Assistant service.
Args:
service_name: Optional ``DOMAIN.SERVICE`` aliases; defaults to ``pyscript.<function>``.
supports_response: Advertised response mode (``"none"``, ``"only"``, or ``"optional"``).
"""
...
def state_trigger(
*str_expr: str,
state_hold: int | float | None = None,
state_hold_false: int | float | None = None,
state_check_now: bool = False,
kwargs: dict | None = None,
watch: list[str] | set[str] | None = None,
) -> Callable[..., Any]:
"""Trigger when any provided state expression evaluates truthy.
Args:
str_expr: One or more state expressions (strings, lists, or sets) that are ORed together.
state_hold: Seconds the expression must stay true before firing; cancelled if it reverts.
state_hold_false: Seconds the expression must stay false before another trigger; ``0`` enforces edges.
state_check_now: Evaluate at registration time and fire immediately if the expression is true.
kwargs: Extra keywords injected into each call in addition to the standard trigger context.
watch: Explicit entities or attributes to monitor when autodetection from the expression is insufficient.
Trigger kwargs include ``trigger_type="state"``, ``var_name``, ``value`` and ``old_value`` when available.
"""
...
def state_active(str_expr: str) -> Callable[..., Any]:
"""Restrict trigger execution to state-based condition.
Args:
str_expr: Expression that must evaluate truthy for the trigger to run; ``.old`` values are available for state triggers.
"""
...
def time_trigger(*time_spec: str | None, **kwargs) -> Callable[..., Any]:
"""Schedule the function using time specifications.
Args:
*time_spec: Time expressions such as ``startup``, ``shutdown``, ``once()``, ``period()``, or ``cron()``.
**kwargs: Optional trigger keywords merged into each invocation.
"""
...
def task_unique(name: str, kill_me: bool = False) -> Callable[..., Any]:
"""Ensure only one running instance of the decorated task.
Args:
name: Identifier used to reclaim prior tasks that called ``task.unique`` or ``@task_unique``.
kill_me: Cancel the new run instead of the existing one when a conflict is found.
"""
...
def event_trigger(*event_type: str, str_expr: str = None, **kwargs) -> Callable[..., Any]:
"""Trigger when a Home Assistant event matches the criteria.
Args:
event_type: Event types to subscribe to; multiple values act as aliases.
str_expr: Optional filter evaluated against the event payload and context variables.
kwargs: Extra keyword arguments merged into each call.
Trigger kwargs include ``trigger_type="event"`` and the event data fields.
"""
...
def time_active(*time_spec: str, hold_off: int | float | None = None) -> Callable[..., Any]:
"""Restrict trigger execution to specific time windows.
Args:
time_spec: ``range()`` or ``cron()`` expressions (optionally prefixed with ``not``) checked on each trigger.
hold_off: Seconds to suppress further triggers after a successful run.
"""
...
def mqtt_trigger(
topic: str, str_expr: str | None = None, encoding: str = "utf-8", **kwargs
) -> Callable[..., Any]:
"""Trigger when a subscribed MQTT message matches the specification.
Args:
topic: MQTT topic to monitor; wildcards ``+`` and ``#`` are supported.
str_expr: Optional expression evaluated against ``payload``, ``payload_obj``, ``retain``, ``topic``, and ``qos``.
encoding: Character encoding for MQTT payload decoding; defaults to ``"utf-8"``.
kwargs: Extra keyword arguments merged into each invocation.
"""
...
def pyscript_compile() -> Callable[..., Any]:
"""Compile the wrapped function into native (synchronous) Python.
Compiled functions cannot use pyscript-only features but run at full CPython speed.
"""
...
def pyscript_executor() -> Callable[..., Any]:
"""Compile the wrapped function and run it transparently in ``task.executor``.
Use it for blocking or I/O-bound code so each call runs in a background thread.
"""
...
class log:
"""Logging helpers that mirror Home Assistant's logging levels."""
@staticmethod
def debug(msg: Any, *args, **kwargs) -> None:
"""Log a debug-level message scoped to the current pyscript context.
Args:
msg: Message or format string to log.
"""
...
@staticmethod
def info(msg: Any, *args, **kwargs) -> None:
"""Log an info-level message scoped to the current pyscript context.
Args:
msg: Message or format string to log.
"""
...
@staticmethod
def warning(msg: Any, *args, **kwargs) -> None:
"""Log a warning-level message scoped to the current pyscript context.
Args:
msg: Message or format string to log.
"""
...
@staticmethod
def error(msg: Any, *args, **kwargs) -> None:
"""Log an error-level message scoped to the current pyscript context.
Args:
msg: Message or format string to log.
"""
...
class state:
"""Utility functions for accessing and managing Home Assistant state."""
@staticmethod
def delete(name: str) -> None:
"""Remove an entity or attribute identified by ``name``.
Args:
name: Fully qualified entity or entity attribute to delete (``DOMAIN.entity[.attr]``).
"""
...
@staticmethod
def exist(name: str) -> bool:
"""Check whether a state variable or attribute exists.
Args:
name: Fully qualified entity or entity attribute name.
Returns:
bool: ``True`` if the entity or attribute is present, otherwise ``False``.
"""
...
@staticmethod
def get(name: str) -> Any:
"""Return the current value for an entity or attribute.
Args:
name: Fully qualified entity or entity attribute name.
Returns:
Any: State value or attribute value; raises ``NameError``/``AttributeError`` if missing.
"""
...
@staticmethod
def getattr(name: str) -> dict[str, Any] | None:
"""Return the attribute dictionary for an entity, if present.
Args:
name: Entity id or attribute path that resolves to an entity.
Returns:
dict[str, Any]: Attribute mapping, or ``None`` when the entity is unknown.
"""
...
@staticmethod
def names(domain: str | None = None) -> list[str]:
"""List entity ids within an optional domain.
Args:
domain: Domain prefix to filter by; returns every entity when omitted.
Returns:
list[str]: Entity ids known to Home Assistant.
"""
...
@staticmethod
def persist(
entity_id: str, default_value: Any = None, default_attributes: dict[str, Any] | None = None
) -> None:
"""Persist a ``pyscript.`` entity across restarts with optional defaults.
Args:
entity_id: Entity id that must live in the ``pyscript`` domain.
default_value: Value to seed when the entity is missing.
default_attributes: Attribute dictionary to seed when attributes are absent.
"""
...
@staticmethod
def set(
entity_id: str,
value: Any = None,
new_attributes: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Set an entity value and optionally update or replace attributes.
Args:
entity_id: Fully qualified entity id to update.
value: New state value; omit to leave the current value unchanged.
new_attributes: Attribute dictionary that replaces existing attributes.
"""
...
@staticmethod
def setattr(name: str, value: Any) -> None:
"""Assign a single attribute on the specified entity.
Args:
name: Entity attribute path in ``DOMAIN.entity.attr`` form.
value: Attribute value to write.
"""
...
class event:
"""Helpers for interacting with Home Assistant's event bus."""
@staticmethod
def fire(event_type: str, **kwargs: Any) -> None:
"""Send an event on the Home Assistant event bus.
Args:
event_type: Name of the event to publish.
**kwargs: Event payload delivered as event data.
"""
...
class task:
"""Asynchronous task utilities built on top of ``asyncio``."""
@staticmethod
def create(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Task:
"""Spawn a new pyscript task that executes ``func`` with the supplied arguments.
Args:
func: Callable to execute in the new task.
*args: Positional arguments forwarded to ``func``.
**kwargs: Keyword arguments forwarded to ``func``.
Returns:
Task: Newly created asyncio task.
"""
...
@staticmethod
def cancel(task_id: Task | None = None) -> None:
"""Cancel a task, defaulting to the current task.
Args:
task_id: Task returned by ``task.create``; cancels the current task when omitted.
"""
...
@staticmethod
def current_task() -> Task:
"""Return the currently running pyscript task.
Returns:
Task: Task representing the active pyscript coroutine.
"""
...
@staticmethod
def name2id(name: str | None = None) -> Task | dict[str, Task]:
"""Resolve registered task names (from ``task.unique``) to task objects.
Args:
name: Specific task name to resolve; return a mapping of all names when omitted.
Returns:
Task | dict[str, Task]: Task matching the name, or mapping of all names to tasks.
"""
...
@staticmethod
def wait(
task_set: list[Task],
timeout: int | float | None = None,
return_when: Literal["ALL_COMPLETED", "FIRST_COMPLETED", "FIRST_EXCEPTION"] = "ALL_COMPLETED",
) -> tuple[set[Task], set[Task]]:
"""Wait for tasks using ``asyncio.wait`` semantics.
Args:
task_set: List of asyncio tasks to monitor.
timeout: Seconds to wait before returning pending tasks; ``None`` waits forever.
return_when: Condition that ends the wait (see ``asyncio.wait``).
Returns:
tuple[set[Task], set[Task]]: Two sets ``(done, pending)`` mirroring ``asyncio.wait``.
"""
...
@staticmethod
def add_done_callback(
task_id: Task,
func: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> None:
"""Register a callback that runs when the task completes.
Args:
task_id: Task to monitor for completion.
func: Callback to invoke when the task finishes.
*args: Positional arguments forwarded to ``func``.
**kwargs: Keyword arguments forwarded to ``func``.
"""
...
@staticmethod
def remove_done_callback(task_id: Task, func: Callable[..., Any]) -> None:
"""Remove a previously registered completion callback.
Args:
task_id: Task the callback was attached to.
func: Callback function that should be removed.
"""
...
@staticmethod
def executor(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
"""Run a blocking callable in a background thread and return its result.
Args:
func: Synchronous callable to execute.
*args: Positional arguments forwarded to ``func``.
**kwargs: Keyword arguments forwarded to ``func``.
Returns:
Any: Result returned by ``func``.
"""
...
@staticmethod
def sleep(seconds: int | float) -> None:
"""Yield control for the given seconds without blocking the event loop.
Args:
seconds: Duration to suspend execution; fractional values are allowed.
"""
...
@staticmethod
def unique(task_name: str, kill_me: bool = False) -> None:
"""Assign a unique name to the current task, optionally killing peers.
Args:
task_name: Identifier shared with ``task.name2id`` and other callers.
kill_me: Cancel the current task if another live task already claimed the name.
"""
...
@staticmethod
def wait_until(
state_trigger: str | list[str] | None = None,
time_trigger: str | list[str] | None = None,
event_trigger: str | list[str] | None = None,
mqtt_trigger: str | list[str] | None = None,
mqtt_trigger_encoding: str | None = None,
webhook_trigger: str | list[str] | None = None,
webhook_local_only: bool = True,
webhook_methods: list[str] = ("POST", "PUT"),
timeout: int | float | None = None,
state_check_now: bool = True,
state_hold: int | float | None = None,
state_hold_false: int | float | None = None,
) -> dict[str, Any]:
"""Block until any supplied trigger fires or a timeout occurs.
Args:
state_trigger: State expressions matching ``@state_trigger`` semantics.
time_trigger: Time specifications matching ``@time_trigger`` semantics.
event_trigger: Event types or filters matching ``@event_trigger`` semantics.
mqtt_trigger: MQTT topics or filters matching ``@mqtt_trigger`` semantics.
mqtt_trigger_encoding: Character encoding for MQTT payload decoding; defaults to ``"utf-8"`` when omitted.
webhook_trigger: Webhook ids matching ``@webhook_trigger`` semantics.
webhook_local_only: Limit webhooks to local network clients when ``True``.
webhook_methods: Allowed HTTP methods for webhook triggers.
timeout: Seconds to wait before returning ``trigger_type="timeout"``.
state_check_now: Evaluate state expressions immediately when ``True``.
state_hold: Seconds a state expression must remain true before returning.
state_hold_false: Seconds a state expression must remain false before it can trigger again.
Returns:
dict[str, Any]: Trigger context mirroring decorator kwargs and always including ``trigger_type``.
"""
...
class pyscript(Any):
"""Runtime helpers for inspecting and switching pyscript global contexts."""
app_config: dict[str, Any]
@staticmethod
def get_global_ctx() -> str:
"""Return the name of the current pyscript global context.
Returns:
str: Active global context identifier.
"""
...
@staticmethod
def set_global_ctx(new_ctx_name: str) -> None:
"""Switch the active global context to ``new_ctx_name``.
Args:
new_ctx_name: Name of an existing global context to activate.
"""
...
@staticmethod
def list_global_ctx() -> list[str]:
"""Return available global context names, current first.
Returns:
list[str]: Global context names ordered with the active context first.
"""
...
@staticmethod
def reload() -> None:
"""Trigger a full pyscript reload, covering scripts, apps, and modules."""
...
class StateVal:
"""Representation of a Home Assistant entity state value."""
entity_id: str
friendly_name: str
device_class: str
icon: str
last_changed: datetime
last_updated: datetime
last_reported: datetime
def as_float(self, default: Any = object()) -> float:
"""Convert the state to ``float`` or return ``default`` on failure.
Args:
default: Fallback value used when conversion raises an error or the value is empty.
Returns:
float: Parsed float, or ``default`` when provided.
"""
...
def as_int(self, default: Any = object(), base: int = 10) -> int:
"""Convert the state to ``int`` (using ``base``) or return ``default``.
Args:
default: Fallback value used when conversion raises an error or the value is empty.
base: Numeric base to use when interpreting the value.
Returns:
int: Parsed integer, or ``default`` when provided.
"""
...
def as_bool(self, default: Any = object()) -> bool:
"""Interpret the state as ``bool`` or return ``default``.
Args:
default: Fallback value used when conversion raises an error or the value is empty.
Returns:
bool: Parsed boolean, or ``default`` when provided.
"""
...
def as_round(
self,
precision: int = 0,
method: Literal["common", "ceil", "floor", "half"] = "common",
default: Any = object(),
) -> float:
"""Convert the state to ``float`` and round it using the requested strategy.
Args:
precision: Decimal places to keep after rounding.
method: Rounding strategy supported by ``homeassistant.helpers.template``.
default: Fallback value used when conversion fails.
Returns:
float: Rounded floating-point value, or ``default`` when provided.
"""
...
def as_datetime(self, default: Any = object()) -> datetime:
"""Parse the state into a timezone-aware ``datetime`` if possible.
Args:
default: Fallback value used when parsing fails.
Returns:
datetime: Parsed datetime, or ``default`` when provided.
"""
...
def is_unknown(self) -> bool:
"""Return whether the entity reports the ``unknown`` sentinel.
Returns:
bool: ``True`` if the state equals ``unknown``.
"""
...
def is_unavailable(self) -> bool:
"""Return whether the entity reports the ``unavailable`` sentinel.
Returns:
bool: ``True`` if the state equals ``unavailable``.
"""
...
def has_value(self) -> bool:
"""Return whether the entity has a concrete (non-empty) value.
Returns:
bool: ``True`` if a non-empty state value is available.
"""
...

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,144 @@
from datetime import datetime, timezone
# --- KONFIGURACJA ---
# 1. Timeouty i Sceny
INPUT_TIMEOUT = "input_number.lights_off_timeout"
SCENE_OFF = "scene.outside_lights_off"
MIN_ON_TIME = 280 # Anti-Flap (sekundy)
# 2. Blokady Systemowe
INPUT_DSC = "input_boolean.dsc" # Blokada (np. uzbrojony alarm)
GATE_ENTITY = "cover.my_gate"
# 3. Światła
LIGHT_ENTRANCE = "light.entrance_door"
LIGHT_PARKING = "light.parking_light"
MONITORED_LIGHTS = [
LIGHT_ENTRANCE,
LIGHT_PARKING,
"light.light_driveway",
"light.new_sidewalk"
]
# --- KONFIGURACJA SENSORÓW ---
# A. Sensory AKTYWACYJNE (Włączają światło ORAZ blokują wyłączenie)
TRIGGER_SENSORS = [
"binary_sensor.frigate_reolink_1_person_occupancy",
"binary_sensor.frigate_reolink_2_person_occupancy",
"binary_sensor.frigate_reolink_3_person_occupancy",
"binary_sensor.frigate_reolink_4_person_occupancy",
"binary_sensor.frigate_reolink_5_person_occupancy",
"binary_sensor.frigate_hikvision_2_person_occupancy",
"binary_sensor.my_sidewalk_person_occupancy",
"binary_sensor.main_door",
"binary_sensor.reolink_4_person"
]
# B. Sensory PASYWNE (TYLKO blokują wyłączenie, NIE włączają światła)
PASSIVE_KEEP_ALIVE_SENSORS = [
"binary_sensor.frigate_reolink_1_car_occupancy"
]
# hikvision_4_person - całkowicie usunięty (nie włącza, nie blokuje)
# Generujemy string triggera TYLKO dla grupy A
TRIGGER_STRING = " or ".join([f"{s} == 'on'" for s in TRIGGER_SENSORS])
# --- HELPERY ---
def get_seconds_since_change(entity_id):
try:
entity_state_obj = hass.states.get(entity_id)
if entity_state_obj is None:
return 999999
last_changed = entity_state_obj.last_changed
if not last_changed:
return 999999
now_utc = datetime.now(timezone.utc)
diff = (now_utc - last_changed).total_seconds()
return diff
except Exception as e:
log.warning(f"Error calculating time for {entity_id}: {e}")
return 999999
def is_dark_outside():
return state.get("sun.sun") == "below_horizon"
def is_system_blocked():
if state.get(INPUT_DSC) != 'off':
return True
return False
# --- LOGIKA WŁĄCZANIA (ACTIVATION) ---
@state_trigger(TRIGGER_STRING)
def auto_light_on_motion():
if not is_dark_outside():
return
if is_system_blocked():
return
if state.get(LIGHT_ENTRANCE) != 'on':
log.info("💡 Dark & Human Activity detected. Turning ON Entrance Light.")
light.turn_on(entity_id=LIGHT_ENTRANCE)
@state_trigger(f"{GATE_ENTITY} or {LIGHT_PARKING} == 'on'")
def auto_light_on_gate_parking():
if is_system_blocked():
return
if state.get(LIGHT_PARKING) != 'on':
return
gate_age = get_seconds_since_change(GATE_ENTITY)
if gate_age < 30:
if state.get(LIGHT_ENTRANCE) != 'on':
log.info(f"💡 Gate moved ({int(gate_age)}s ago) & Parking ON. Turning ON Entrance Light.")
light.turn_on(entity_id=LIGHT_ENTRANCE)
# --- LOGIKA WYŁĄCZANIA (WATCHDOG / SAFETY) ---
@time_trigger("period(now, 1min)")
def safety_check_outside_lights():
active_lights = [l for l in MONITORED_LIGHTS if state.get(l) == 'on']
if not active_lights:
return
try:
timeout_minutes = int(float(state.get(INPUT_TIMEOUT)))
except (ValueError, TypeError):
timeout_minutes = 15
timeout_seconds = timeout_minutes * 60
for light_id in active_lights:
age = get_seconds_since_change(light_id)
if age < MIN_ON_TIME:
log.debug(f"🛡️ Anti-Flap: {light_id} is on for {int(age)}s (Min: {MIN_ON_TIME}s). Keeping ON.")
return
for light_id in active_lights:
age = get_seconds_since_change(light_id)
if age < timeout_seconds:
return
ALL_SENSORS = TRIGGER_SENSORS + PASSIVE_KEEP_ALIVE_SENSORS
for sensor_id in ALL_SENSORS:
if state.get(sensor_id) == 'on':
log.debug(f"🚫 Watchdog blocked: Active sensor {sensor_id}")
return
sensor_age = get_seconds_since_change(sensor_id)
if sensor_age < timeout_seconds:
return
log.info(f"🌑 Watchdog: No activity for {timeout_minutes}m. Executing SCENE OFF.")
scene.turn_on(entity_id=SCENE_OFF)
@state_trigger("sun.sun == 'above_horizon'")
def sun_rise_kill_switch():
scene.turn_on(entity_id=SCENE_OFF)

View File

@ -0,0 +1,154 @@
from datetime import datetime
# --- CONFIG ---
AIR_PURIFIER_BACKLIGHT = "light.air_purifier_backlight"
VITAL_DISPLAY = "switch.vital_100s_series_display"
FAN_SIGURO = "fan.air_purifier"
FAN_VITAL = "fan.vital_100s_series"
DISTANCE_SENSOR = "sensor.home_is17_distance"
AWAY_THRESHOLD = 500
# Stan away - żeby nie spamować
_purifier_state = {
"away_mode": False
}
# --- O 22:00 - tryb nocny ---
@time_trigger("cron(0 22 * * *)")
def purifiers_night_mode():
"""Tryb nocny o 22:00 - cichy + backlight off"""
log.info("Purifiers: Night mode ON (22:00)")
# Backlight OFF - Siguro
if state.get(AIR_PURIFIER_BACKLIGHT) == 'on':
light.turn_off(entity_id=AIR_PURIFIER_BACKLIGHT)
task.sleep(2)
# Backlight OFF - Vital (retry logic)
for attempt in range(3):
if state.get(VITAL_DISPLAY) == 'on':
try:
switch.turn_off(entity_id=VITAL_DISPLAY)
task.sleep(1)
if state.get(VITAL_DISPLAY) == 'off':
log.info("Vital display: OFF (success)")
break
except Exception as e:
log.warning(f"Vital display OFF attempt {attempt+1} failed: {e}")
task.sleep(3)
# Tryb sleep - Siguro (Używamy 100% zamiast presetu)
task.sleep(2)
try:
fan.set_percentage(entity_id=FAN_SIGURO, percentage=100)
log.info(f"{FAN_SIGURO}: sleep mode set (100%)")
except Exception as e:
log.warning(f"Could not set sleep mode on {FAN_SIGURO}: {e}")
# Tryb sleep - Vital (Ten obsługuje presety)
task.sleep(2)
try:
fan.set_preset_mode(entity_id=FAN_VITAL, preset_mode="sleep")
log.info(f"{FAN_VITAL}: sleep mode set")
except Exception as e:
log.warning(f"Could not set sleep mode on {FAN_VITAL}: {e}")
# --- O 6:05 - tryb dzienny ---
@time_trigger("cron(5 6 * * *)")
def purifiers_day_mode():
"""Tryb dzienny o 6:05 - auto + backlight on"""
log.info("Purifiers: Day mode ON (6:05)")
# Backlight ON - Siguro
if state.get(AIR_PURIFIER_BACKLIGHT) == 'off':
light.turn_on(entity_id=AIR_PURIFIER_BACKLIGHT)
task.sleep(2)
# Backlight ON - Vital (retry logic)
for attempt in range(3):
if state.get(VITAL_DISPLAY) == 'off':
try:
switch.turn_on(entity_id=VITAL_DISPLAY)
task.sleep(1)
if state.get(VITAL_DISPLAY) == 'on':
log.info("Vital display: ON (success)")
break
except Exception as e:
log.warning(f"Vital display ON attempt {attempt+1} failed: {e}")
task.sleep(3)
# Tryb auto - Siguro (Używamy 1% zamiast presetu)
task.sleep(2)
try:
fan.set_percentage(entity_id=FAN_SIGURO, percentage=1)
log.info(f"{FAN_SIGURO}: auto mode set (1%)")
except Exception as e:
log.warning(f"Could not set auto mode on {FAN_SIGURO}: {e}")
# Tryb auto - Vital
task.sleep(2)
try:
fan.set_preset_mode(entity_id=FAN_VITAL, preset_mode="auto")
log.info(f"{FAN_VITAL}: auto mode set")
except Exception as e:
log.warning(f"Could not set auto mode on {FAN_VITAL}: {e}")
# --- Gdy wychodzisz/wracasz do domu ---
@state_trigger(f"{DISTANCE_SENSOR}")
def purifiers_presence_mode():
"""Gdy daleko od domu - wyłącz (oszczędność filtra), gdy wracasz - auto"""
global _purifier_state
try:
dist = float(state.get(DISTANCE_SENSOR))
except:
return
# Wychodzisz - OFF (Lepiej wyłączyć całkiem dla oszczędności filtra)
if dist > AWAY_THRESHOLD and not _purifier_state["away_mode"]:
_purifier_state["away_mode"] = True
log.info(f"Purifiers: User AWAY ({int(dist)}m). Turning OFF.")
try:
fan.turn_off(entity_id=[FAN_SIGURO, FAN_VITAL])
except:
pass
# Wracasz - ON
elif dist <= AWAY_THRESHOLD and _purifier_state["away_mode"]:
_purifier_state["away_mode"] = False
log.info(f"Purifiers: User HOME ({int(dist)}m). Turning ON.")
# Sprawdź porę dnia
hour = datetime.now().hour
if 6 <= hour < 22:
# Tryb Dzienny (Auto)
try:
fan.turn_on(entity_id=FAN_SIGURO)
task.sleep(1)
fan.set_percentage(entity_id=FAN_SIGURO, percentage=1)
fan.turn_on(entity_id=FAN_VITAL)
task.sleep(1)
fan.set_preset_mode(entity_id=FAN_VITAL, preset_mode="auto")
except:
pass
else:
# Tryb Nocny (Sleep)
try:
fan.turn_on(entity_id=FAN_SIGURO)
task.sleep(1)
fan.set_percentage(entity_id=FAN_SIGURO, percentage=100)
fan.turn_on(entity_id=FAN_VITAL)
task.sleep(1)
fan.set_preset_mode(entity_id=FAN_VITAL, preset_mode="sleep")
except:
pass

View File

@ -0,0 +1,67 @@
from datetime import datetime
# --- CONFIG ---
TIMEOUT_ENTITY = "input_number.lights_off_timeout"
STEFI_ENTITY = "input_boolean.stefinka"
# Definicje Timeoutów (Minuty)
# Format: (Godzina_startu, Wartość_Stefi_ON, Wartość_Stefi_OFF)
# Godzina startu oznacza: "Od tej godziny używaj tych wartości"
SCHEDULE = {
22: {"stefi": 15.0, "solo": 6.0}, # Wieczór (od 22:00)
3: {"stefi": 10.0, "solo": 4.0}, # Rano (od 03:00)
}
# Domyślny fallback (opcjonalnie, gdyby uruchomiło się o innej porze)
DEFAULT_TIMEOUT = 5.0
# --- LOGIC ---
# Triggerujemy o 22:00, 03:00 ORAZ przy zmianie trybu Stefi (Reaktywność!)
@time_trigger("cron(0 22 * * *)", "cron(0 3 * * *)")
@state_trigger(STEFI_ENTITY)
def update_outside_lights_timeout():
"""
Ustawia timeout świateł zewnętrznych w zależności od pory dnia i obecności Stefi.
Działa zarówno wg harmonogramu, jak i reaguje na zmianę przełącznika Stefi.
"""
# 1. Pobieramy aktualną godzinę
now_hour = datetime.now().hour
# 2. Sprawdzamy stan Stefi
is_stefi_home = state.get(STEFI_ENTITY) == 'on'
# 3. Ustalamy, który blok czasowy nas obowiązuje
# Logika: Sprawdzamy czy jest noc (>= 22) czy rano (< 22, ale w praktyce od 3)
# W Twoim YAML logika była sztywna (==22 lub ==3).
# Tutaj zrobimy to mądrzej: "Jaki tryb obowiązuje TERAZ?"
target_value = None
mode_name = "Unknown"
# Zakres WIECZORNY (22:00 - 02:59)
if now_hour >= 22 or now_hour < 3:
vals = SCHEDULE[22]
target_value = vals["stefi"] if is_stefi_home else vals["solo"]
mode_name = "Evening"
# Zakres PORANNY (03:00 - reszta dnia, lub do 16:00 wg starej logiki)
# Przyjmijmy, że od 3 rano obowiązuje tryb poranny
elif now_hour >= 3:
vals = SCHEDULE[3]
target_value = vals["stefi"] if is_stefi_home else vals["solo"]
mode_name = "Morning"
# 4. Aplikacja wartości (tylko jeśli jest sensowna zmiana)
if target_value is not None:
# Sprawdźmy obecną wartość, żeby nie spamować bazy danych
try:
current_val = float(state.get(TIMEOUT_ENTITY))
except (ValueError, TypeError):
current_val = -1.0
if current_val != target_value:
log.info(f"Lights Timeout Manager: Setting {mode_name} mode. Stefi={is_stefi_home}. Timeout {current_val} -> {target_value}")
input_number.set_value(entity_id=TIMEOUT_ENTITY, value=target_value)

View File

@ -0,0 +1,114 @@
from datetime import datetime, timezone
# --- CONFIG (Small Hall) ---
LIGHT_ENTITY = "light.small_hall_zbmini"
MOTION_ENTITY = "binary_sensor.small_hall"
DOOR_ENTITY = "binary_sensor.main_door"
TIMEOUT_INPUT = "input_number.sien_timeout"
ILLUMINANCE_SENSOR = "sensor.aqara_presence_illuminance"
DISCREET_MODE = "input_boolean.dsc"
DISTANCE_SENSOR = "sensor.home_is17_distance"
MOTION_LIGHT_ENABLE = "input_boolean.small_hall_motion_light"
# --- CONFIG (Technická místnost) ---
TECH_SWITCH = "switch.technicka_0102"
TECH_MOTION = "binary_sensor.lumi_technicka_motion_aq2"
DEFAULT_TIMEOUT_MIN = 5.0
LUX_THRESHOLD = 20
PROXIMITY_THRESHOLD = 10
# --- HELPER ---
def is_at_home():
try:
dist = state.get(DISTANCE_SENSOR)
if dist is None or dist == 'unknown' or dist == 'unavailable':
return False
return float(dist) <= PROXIMITY_THRESHOLD
except (ValueError, TypeError):
return False
def get_seconds_since_change(entity_id):
try:
last_changed = state.get(f"{entity_id}.last_changed")
if not last_changed:
return 0
now_utc = datetime.now(timezone.utc)
diff = (now_utc - last_changed).total_seconds()
return diff
except Exception as e:
log.error(f"Error reading time for {entity_id}: {e}")
return 0
def is_discreet_mode():
return state.get(DISCREET_MODE) == 'on'
def is_motion_light_enabled():
return state.get(MOTION_LIGHT_ENABLE) == 'on'
# --- LOGIC: SMALL HALL AUTO ON ---
@state_trigger(f"{MOTION_ENTITY} == 'on' or {DOOR_ENTITY} == 'on'")
def small_hall_auto_on(trigger_type=None, var_name=None, value=None):
if not is_at_home():
log.debug(f"Small Hall: Trigger ignored. User distance > {PROXIMITY_THRESHOLD}m")
return
if is_discreet_mode():
log.debug("Small Hall: Discreet mode ON - ignoring motion")
return
# Motion trigger wymaga włączonego input_boolean
if var_name == MOTION_ENTITY and not is_motion_light_enabled():
log.debug("Small Hall: Motion light disabled - ignoring motion")
return
if state.get(LIGHT_ENTITY) == 'on':
return
is_sun_down = state.get('sun.sun') == 'below_horizon'
try:
lux_val = state.get(ILLUMINANCE_SENSOR)
current_lux = float(lux_val) if lux_val is not None else 1000.0
is_dark_lux = current_lux < LUX_THRESHOLD
except (ValueError, TypeError):
is_dark_lux = False
if not (is_sun_down or is_dark_lux):
return
if var_name == DOOR_ENTITY:
log.info(f"Small Hall: Door opened. Turning ON.")
light.turn_on(entity_id=LIGHT_ENTITY)
return
now = datetime.now()
curr_minutes = now.hour * 60 + now.minute
if (5 * 60 + 45) <= curr_minutes < (22 * 60):
log.info(f"Small Hall: Motion detected. Turning ON.")
light.turn_on(entity_id=LIGHT_ENTITY)
# --- LOGIC: WATCHDOG (SMALL HALL & TECHNICKA) ---
@time_trigger("period(now, 1min)")
def auto_off_watchdog():
raw_val = state.get(TIMEOUT_INPUT)
try:
base_minutes = float(raw_val)
except (ValueError, TypeError):
base_minutes = DEFAULT_TIMEOUT_MIN
limit_seconds = base_minutes * 60
if state.get(LIGHT_ENTITY) == 'on' and state.get(MOTION_ENTITY) != 'on':
sec_light = get_seconds_since_change(LIGHT_ENTITY)
sec_motion = get_seconds_since_change(MOTION_ENTITY)
sec_door = get_seconds_since_change(DOOR_ENTITY)
if sec_light > limit_seconds and sec_motion > limit_seconds and sec_door > limit_seconds:
log.info(f"Small Hall: Timeout {base_minutes}m reached. Turning OFF.")
light.turn_off(entity_id=LIGHT_ENTITY)
if state.get(TECH_SWITCH) == 'on' and state.get(TECH_MOTION) != 'on':
sec_switch = get_seconds_since_change(TECH_SWITCH)
sec_t_motion = get_seconds_since_change(TECH_MOTION)
if sec_switch > limit_seconds and sec_t_motion > limit_seconds:
log.info(f"Technicka: No motion for {base_minutes}m. Turning OFF switch.")
switch.turn_off(entity_id=TECH_SWITCH)

View File

@ -0,0 +1,162 @@
from datetime import datetime, date, timedelta
# --- KONFIGURACJA ---
INPUT_ENTITY = "input_boolean.stefinka"
NEXT_CHANGE_SENSOR = "sensor.stefi_next_change"
# Jarní prázdniny Kladno 2026
SPRING_BREAK_START = (3, 9)
SPRING_BREAK_END = (3, 13)
# Podzimní prázdniny Kladno 2026
AUTUMN_BREAK_START = (10, 29)
AUTUMN_BREAK_END = (10, 30)
# --- HELPERY ---
def get_easter_date(year):
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
def check_date_range(check_date, start_md, end_md):
start = date(check_date.year, start_md[0], start_md[1])
end = date(check_date.year, end_md[0], end_md[1])
return start <= check_date <= end
def effective_weekday_date(check_date):
"""
Weekend należy do kończącego się tygodnia roboczego,
bo przekazanie następuje dopiero w poniedziałek przy szkole.
Sobota/Niedziela zwraca poprzedni piątek (ten sam tydzień ISO).
"""
dow = check_date.weekday() # 0=Mon, 5=Sat, 6=Sun
if dow == 5:
return check_date - timedelta(days=1) # Sob → Pt
elif dow == 6:
return check_date - timedelta(days=2) # Nd → Pt
return check_date
def is_dad_custody(check_date):
"""
Zwraca True (Tata) lub False (Mama) dla danej daty.
Weekendy traktowane jak nadchodzący poniedziałek.
"""
# Dla weekendów używamy logiki nadchodzącego poniedziałku
effective_date = effective_weekday_date(check_date)
year = effective_date.year
is_even_year = (year % 2) == 0
week_num = effective_date.isocalendar()[1]
# Default: tydzień parzysty = Tata
result_dad = (week_num % 2) == 0
# 1. Jarní prázdniny (używamy effective_date dla spójności)
if check_date_range(effective_date, SPRING_BREAK_START, SPRING_BREAK_END):
return is_even_year
# 2. Velikonoce - Velký pátek i Velikonoční pondělí (+wtorek rano)
easter = get_easter_date(year)
good_friday = easter - timedelta(days=2)
easter_tuesday = easter + timedelta(days=2)
if good_friday <= effective_date <= easter_tuesday:
return not is_even_year # lichý rok = otec
# 3. Lato
if effective_date.month in [7, 8]:
d, m = effective_date.day, effective_date.month
block = 0
if m == 7:
block = 1 if d < 15 else 2
elif m == 8:
block = 3 if d < 15 else 4
if block > 0:
if is_even_year:
return (block == 1 or block == 3)
else:
return (block == 2 or block == 4)
# 4. Podzimní prázdniny
if check_date_range(effective_date, AUTUMN_BREAK_START, AUTUMN_BREAK_END):
return is_even_year
# 5. Vánoce (23.12 - 2.01)
is_xmas = (effective_date.month == 12 and effective_date.day >= 23) or \
(effective_date.month == 1 and effective_date.day <= 2)
if is_xmas:
eff_year = year if effective_date.month == 12 else year - 1
eff_even = (eff_year % 2) == 0
is_first_part = (effective_date.month == 12 and effective_date.day < 26)
if eff_even:
return not is_first_part
else:
return is_first_part
return result_dad
def apply_state_and_find_next(today):
dad_today = is_dad_custody(today)
if dad_today:
input_boolean.turn_on(entity_id=INPUT_ENTITY)
log.info("Stefi custody: TATA")
else:
input_boolean.turn_off(entity_id=INPUT_ENTITY)
log.info("Stefi custody: LINDA")
# Szukaj następnej zmiany — tylko w dni robocze (Pn-Pt)
next_change_date = None
days_until = 0
next_owner = "Unknown"
for i in range(1, 61):
future_date = today + timedelta(days=i)
# Pomijamy weekendy przy szukaniu następnej zmiany
if future_date.weekday() >= 5:
continue
if is_dad_custody(future_date) != dad_today:
next_change_date = future_date
days_until = i
next_owner = "tata" if is_dad_custody(future_date) else "linda"
break
if next_change_date:
state.set(
NEXT_CHANGE_SENSOR,
value=next_change_date.isoformat(),
new_attributes={
"days_until": days_until,
"next_owner": next_owner,
"friendly_name": "Next Custody Change",
"icon": "mdi:calendar-sync"
}
)
log.info(f"Następna zmiana: {next_owner} od {next_change_date} (za {days_until} dni)")
@time_trigger("cron(1 8 * * *)")
def calculate_stefi_custody():
apply_state_and_find_next(datetime.now().date())
@time_trigger("startup")
def stefi_on_startup():
apply_state_and_find_next(datetime.now().date())
@service
def stefi_update_sensor():
apply_state_and_find_next(datetime.now().date())

View File

@ -0,0 +1,110 @@
from datetime import datetime, time
# --- CONFIG ---
# Global toggles
INPUT_ENABLE = "input_boolean.stefinka"
INPUT_HOLIDAY = "input_boolean.holiday"
# People
PERSON_SEBA = "person.seba"
PERSON_STEFI = "person.stefi"
# Devices
SUN_ENTITY = "sun.sun"
MOTION_SENSOR = "binary_sensor.aqara_stefi_motion"
SWITCH_TASMOTA = "switch.tasmota" # Main light check for motion logic
SWITCH_NIGHTSTAND = "switch.nightstand_2"
# Scenes
SCENE_ON_EVENING = "scene.stefinka_evening_lights"
SCENE_ON_NIGHTSTAND = "scene.nightstand_single_on"
SCENE_OFF = "scene.stefinka_evening_lights_off"
# --- HELPERS ---
def is_automation_allowed():
"""Checks master switches (Stefi Mode ON, Holiday OFF)."""
return (state.get(INPUT_ENABLE) == 'on' and
state.get(INPUT_HOLIDAY) == 'off')
def is_dark():
return state.get(SUN_ENTITY) == "below_horizon"
# --- LOGIC 1: ROUTINE ON (Sunset or 20:30) ---
@state_trigger(f"{SUN_ENTITY} == 'below_horizon'")
@time_trigger("once(20:30:00)")
def stefi_routine_turn_on():
if not is_automation_allowed(): return
if not is_dark(): return
now = datetime.now()
if now.hour >= 23:
return
log.info("Stefi Lights: Routine Evening ON.")
scene.turn_on(entity_id=SCENE_ON_EVENING)
scene.turn_on(entity_id=SCENE_ON_NIGHTSTAND)
# --- LOGIC 2: ARRIVAL HANDLING ---
@state_trigger(f"{PERSON_SEBA} == 'home'")
@state_trigger(f"{PERSON_STEFI} == 'home'")
def stefi_arrival_handler():
if not is_automation_allowed(): return
if not is_dark(): return
now = datetime.now()
current_time = now.time()
# --- BLOCK: Quiet Period (00:00 - 06:30) ---
if time(0, 0) <= current_time < time(6, 30):
log.info(f"Stefi Lights: Arrival ignored during quiet hours ({current_time}).")
return
# CASE A: Late Arrival (23:00 - 23:59)
if now.hour >= 23:
log.info("Stefi Lights: Late Arrival (>23:00). 10min Timer started.")
scene.turn_on(entity_id=SCENE_ON_EVENING)
task.unique("stefi_late_arrival_timer")
task.sleep(600) # 10 minutes
log.info("Stefi Lights: Late Arrival Timer finished. Turning OFF.")
scene.turn_on(entity_id=SCENE_OFF)
switch.turn_off(entity_id=SWITCH_NIGHTSTAND)
# CASE B: Normal Arrival (06:30 - 22:59)
else:
log.info("Stefi Lights: Normal Arrival. Turning ON.")
scene.turn_on(entity_id=SCENE_ON_EVENING)
scene.turn_on(entity_id=SCENE_ON_NIGHTSTAND)
# --- LOGIC 3: SHUTDOWN (23:00 or No Motion) ---
@time_trigger("once(23:00:00)")
def stefi_hard_shutdown():
if not is_automation_allowed(): return
log.info("Stefi Lights: 23:00 Curfew. Turning OFF.")
scene.turn_on(entity_id=SCENE_OFF)
switch.turn_off(entity_id=SWITCH_NIGHTSTAND)
@state_trigger(f"{MOTION_SENSOR} == 'off'", state_hold=900)
def stefi_motion_shutdown():
if not is_automation_allowed(): return
now = datetime.now()
if now.hour < 21:
return
if state.get(SWITCH_TASMOTA) != 'on':
return
log.info("Stefi Lights: No motion for 15m (>21:00). Turning OFF.")
scene.turn_on(entity_id=SCENE_OFF)
switch.turn_off(entity_id=SWITCH_NIGHTSTAND)

View File

@ -0,0 +1,71 @@
import datetime
# Konfiguracja
PLAYER = "media_player.symfonisk_bookshelf_stefi"
MODE = "input_boolean.stefinka"
MOTION = "binary_sensor.aqara_stefi_motion"
VOL_LIMIT = 0.12
VOL_TARGET = 0.11
# --- FUNKCJA WYKONAWCZA ---
def enforce_stefi_rules():
"""Sprawdza i wymusza reguły natychmiast."""
if state.get(MODE) != "on":
return
now = datetime.datetime.now().time()
# --- LOGIKA GŁOŚNOŚCI (20:30 - 06:00) ---
if now >= datetime.time(20, 30) or now < datetime.time(6, 0):
attrs = state.getattr(PLAYER)
if attrs and "volume_level" in attrs:
try:
current_vol = float(attrs["volume_level"])
if current_vol > VOL_LIMIT:
log.info(f"STEFINKA: Wykryto głośność {current_vol}. Zmniejszam do {VOL_TARGET}.")
media_player.volume_set(entity_id=PLAYER, volume_level=VOL_TARGET)
except Exception as e:
log.error(f"STEFINKA Error: {e}")
# --- LOGIKA STOP (CZASOWA - SZTYWNE GODZINY) ---
if state.get(PLAYER) == "playing":
stop_minutes = [
(22, 15),
(22, 40),
(23, 22)
]
for hour, minute in stop_minutes:
target = datetime.time(hour, minute)
# Tolerancja 2 minuty
if target <= now <= datetime.time(hour, minute + 2):
media_player.media_stop(entity_id=PLAYER)
log.info(f"STEFINKA: Czas spać (Harmonogram). Zatrzymano o {now}.")
# --- WYZWALACZE ---
# 1. Zmiana głośności
@state_trigger(f"{PLAYER}.volume_level")
def on_volume_change(**kwargs):
enforce_stefi_rules()
# 2. Zmiana trybu / Start systemu
@state_trigger(f"{MODE} == 'on'")
@event_trigger("homeassistant_start")
def on_mode_or_start(**kwargs):
enforce_stefi_rules()
# 3. Wyzwalacze czasowe (start pilnowania i momenty stopu)
@time_trigger("once(20:30:00)", "once(22:15:00)", "once(22:40:00)", "once(23:22:00)")
def on_time_event(**kwargs):
enforce_stefi_rules()
# 4. BRAK RUCHU (Nowe zasady: > 35 minut i po 21:30)
# 2100 sekund = 35 minut
@state_trigger(f"{MOTION} == 'off'", state_hold=2100)
def on_no_motion(**kwargs):
if state.get(MODE) == "on" and state.get(PLAYER) == "playing":
# Sprawdzamy czy jest już po 21:30
if datetime.datetime.now().time() >= datetime.time(21, 30):
media_player.media_stop(entity_id=PLAYER)
log.info("STEFINKA: Brak ruchu przez 35min po 21:30. Stop.")

View File

@ -0,0 +1,105 @@
import datetime
# Konfiguracja encji
PLAYER = "media_player.symfonisk_bookshelf_stefi"
PLAYLIST = "spotify:playlist:17Lw7mMD2NumbiINU1sM1u"
SCENE_START = "scene.morning_lights_for_wake_up"
SCENE_END = "scene.morning_lights_sorting_after_wake_up"
LIGHT_TOP = "light.stefi_top"
# Warunki logiczne
IB_SCHOOL = "input_boolean.school"
IB_STEFINKA = "input_boolean.stefinka"
IB_HOLIDAY = "input_boolean.holiday"
IB_GUEST = "input_boolean.guest_home"
IB_DAYROOM = "input_boolean.dayroom"
BINARY_WORKDAY = "binary_sensor.workday_sensor"
@time_trigger("once(06:00:00)", "once(06:37:00)")
def stefi_wakeup_routine(**kwargs):
"""Główna rutyna budzika z prawdziwym Retry Logic."""
# 1. SPRAWDZENIE WARUNKÓW (Szybkie wyjście)
now = datetime.datetime.now()
trigger_time = now.strftime("%H:%M:%S")
# Sprawdź miesiące wakacyjne (Lipiec, Sierpień)
if now.month in [7, 8]:
log.info("Budzik Stefi: Pominięto (Wakacje - Lipiec/Sierpień)")
return
# Podstawowe przełączniki
if state.get(IB_SCHOOL) != "on" or state.get(IB_STEFINKA) != "on":
return
if state.get(BINARY_WORKDAY) != "on":
return
# Warunek gości/święta (z Twojego YAML: holiday OFF lub guest ON)
is_holiday = state.get(IB_HOLIDAY) == "on"
is_guest = state.get(IB_GUEST) == "on"
if not (not is_holiday or is_guest):
return
# Logika DAYROOM (06:00 dla ON, 06:37 dla OFF)
dayroom_on = state.get(IB_DAYROOM) == "on"
if trigger_time == "06:00:00" and not dayroom_on:
return
if trigger_time == "06:37:00" and dayroom_on:
return
# --- START RUTYNY ---
log.info(f"Budzik Stefi: START (Trigger: {trigger_time})")
# Uruchomienie światła górnego w osobnym wątku (Równolegle - jak w YAML)
task.create(handle_top_light)
# Scena startowa
scene.turn_on(entity_id=SCENE_START)
# Ustawienie głośności
media_player.volume_set(entity_id=PLAYER, volume_level=0.3)
# 2. PANCERNE ODTWARZANIE (Retry Logic)
success = False
for attempt in range(1, 6): # Próbuj 5 razy
log.info(f"Budzik Stefi: Próba odpalenia Spotify #{attempt}")
# Wyczyść i puść
media_player.clear_playlist(entity_id=PLAYER)
task.sleep(1)
media_player.play_media(
entity_id=PLAYER,
media_content_id=PLAYLIST,
media_content_type="playlist"
)
# Czekaj 5 sek na reakcję głośnika
task.sleep(5)
# Weryfikacja czy gra
if state.get(PLAYER) == "playing":
log.info("Budzik Stefi: SUKCES - Muzyka gra.")
success = True
break
else:
log.warning("Budzik Stefi: Głośnik nie ruszył, ponawiam...")
task.sleep(2) # Krótka przerwa przed kolejnym strzałem
if not success:
log.error("Budzik Stefi: KRYTYCZNE - Nie udało się uruchomić muzyki po 5 próbach!")
# Tu mógłbyś dodać powiadomienie na telefon rodzica!
# notify.mobile_app_seba(message="AWARIA BUDZIKA STEFI!", title="Wakeup Fail")
# 3. DŁUGIE CZEKANIE (45 min)
task.sleep(2700) # 45 minut * 60
# Scena końcowa
scene.turn_on(entity_id=SCENE_END)
log.info("Budzik Stefi: Zakończono rutynę (Scena końcowa).")
def handle_top_light():
"""Obsługa światła górnego z opóźnieniem (Równolegle)."""
task.sleep(300) # Czekaj 5 minut
light.turn_on(entity_id=LIGHT_TOP)
log.info("Budzik Stefi: Zapalono światło górne (po 5 min).")

View File

@ -0,0 +1,140 @@
from datetime import datetime, timezone
# --- CONFIG ---
LIGHT_TERRACE = "light.terrace"
DOOR_SENSOR = "binary_sensor.terrace_door"
TIMEOUT_INPUT = "input_number.lights_off_timeout"
SUN_ENTITY = "sun.sun"
# Lista triggerów ruchu (i innych włączników)
MOTION_SENSORS = [
"binary_sensor.reolink_5_person",
"binary_sensor.terrace_person_occupancy",
"light.reolink_5_floodlight" # Traktujemy włączenie floodlighta jak wykrycie ruchu
]
# Dodatkowy watchdog z drugiego YAML-a (safety timeout)
SAFETY_TIMEOUT_SEC = 1200 # 20 minut "Hard limit" jeśli coś pójdzie nie tak
# --- HELPERY ---
def get_seconds_since_last_activity():
"""
Zwraca liczbę sekund od ostatniej aktywności:
- Ostatni ruch na którymkolwiek sensorze
- LUB ostatnie włączenie światła (obsługa Manual ON)
"""
timestamps = []
now_utc = datetime.now(timezone.utc)
# 1. Sprawdź czas zmiany stanu światła
l_changed = state.get(f"{LIGHT_TERRACE}.last_changed")
if l_changed: timestamps.append(l_changed)
# 2. Sprawdź czas zmiany sensorów ruchu
for sensor in MOTION_SENSORS:
m_changed = state.get(f"{sensor}.last_changed")
if m_changed: timestamps.append(m_changed)
if not timestamps:
return 999999
# Znajdź najświeższą datę
last_active = max(timestamps)
return (now_utc - last_active).total_seconds()
def is_motion_active():
"""Sprawdza czy KTÓRYKOLWIEK sensor wykrywa ruch/osobę."""
for sensor in MOTION_SENSORS:
if state.get(sensor) == 'on':
return True
return False
# --- LOGIC 1: AUTO ON ---
# Triggerujemy na Drzwi LUB którykolwiek sensor ruchu
@state_trigger(f"{DOOR_SENSOR} == 'on'")
@state_trigger(f"{MOTION_SENSORS[0]} == 'on' or {MOTION_SENSORS[1]} == 'on' or {MOTION_SENSORS[2]} == 'on'")
def terrace_auto_on():
"""
Włącza światło na tarasie, jeśli jest ciemno.
"""
# Warunek: Noc
if state.get(SUN_ENTITY) != 'below_horizon':
return
# Jeśli już świeci, nie wysyłaj komendy (oszczędność sieci)
if state.get(LIGHT_TERRACE) == 'on':
return
log.info("Terrace: Activity detected (Door/Motion). Turning ON.")
light.turn_on(entity_id=LIGHT_TERRACE)
# --- LOGIC 2: DOOR CLOSED OFF (Anti-flap) ---
# Reaguje na zamknięcie drzwi na min. 3 sekundy
@state_trigger(f"{DOOR_SENSOR} == 'off'", state_hold=3)
def terrace_door_closed_off():
"""
Wymóg z YAML: Wyłącz natychmiast po zamknięciu drzwi (3s antyflap).
Nadpisuje timeout ruchu.
"""
if state.get(LIGHT_TERRACE) == 'on':
log.info("Terrace: Door closed. Immediate OFF.")
light.turn_off(entity_id=LIGHT_TERRACE)
# --- LOGIC 3: WATCHDOG (Idle Timer & Sun Safety) ---
@time_trigger("period(now, 1min)")
def terrace_light_watchdog():
"""
Zastępuje wait_template i drugi skrypt YAML.
Sprawdza timeouty i słońce.
"""
# 1. Jeśli światło zgaszone -> nic nie rób
if state.get(LIGHT_TERRACE) != 'on':
return
# 2. Safety: Słońce wzeszło? -> OFF
if state.get(SUN_ENTITY) == 'above_horizon':
log.info("Terrace: Sun is up. Safety OFF.")
light.turn_off(entity_id=LIGHT_TERRACE)
return
# 3. Jeśli drzwi otwarte -> ZAWSZE ON (Ignoruj timeouty)
if state.get(DOOR_SENSOR) == 'on':
return
# 4. Jeśli wciąż jest ruch -> ZAWSZE ON
if is_motion_active():
return
# 5. Sprawdzenie Timeoutu (Input Number)
try:
timeout_min = float(state.get(TIMEOUT_INPUT))
except (ValueError, TypeError):
timeout_min = 5.0 # Domyślnie 5 min
timeout_sec = timeout_min * 60
# Ile sekund minęło od ostatniego ruchu LUB włączenia światła?
seconds_idle = get_seconds_since_last_activity()
# Logika wyłączenia
if seconds_idle > timeout_sec:
log.info(f"Terrace: Idle timeout ({int(seconds_idle)}s > {int(timeout_sec)}s). Turning OFF.")
light.turn_off(entity_id=LIGHT_TERRACE)
return
# Safety Net z drugiego YAML-a (Hard limit 20 min/1200s, gdyby input_number był np. 0)
# Ten warunek z YAML 2: "light_long_enough"
light_on_time = 0
l_state = state.getattr(LIGHT_TERRACE)
if l_state and l_state.get("last_changed"):
light_on_time = (datetime.now(timezone.utc) - l_state.get("last_changed")).total_seconds()
if light_on_time > SAFETY_TIMEOUT_SEC and seconds_idle > SAFETY_TIMEOUT_SEC:
log.warning(f"Terrace: Safety Hard-Limit reached ({SAFETY_TIMEOUT_SEC}s). Force OFF.")
light.turn_off(entity_id=LIGHT_TERRACE)

View File

@ -0,0 +1,77 @@
from datetime import datetime, timezone
# --- CONFIG ---
TV_APPLE = "media_player.apple_tv"
SPEAKER_TTS = "media_player.living_room_homepod"
TIMEOUT_INPUT = "input_number.tv_off_timeout"
# Stany, w których uznajemy, że ktoś mógł zapomnieć wyłączyć TV
# (Pauzowanie filmu lub wiszenie w Menu)
# UWAGA: Usunąłem 'off' i 'standby' z tej listy, bo jak jest off, to nie ma co wyłączać.
AUTO_OFF_CANDIDATES = ['Paused', 'paused', 'idle', 'Idle']
# --- HELPER ---
def get_seconds_since_change(entity_id):
"""Zwraca liczbę sekund od ostatniej zmiany stanu."""
try:
# Pyscript bezpieczniej pobiera atrybuty tak:
last_changed = state.getattr(entity_id).get("last_changed")
if last_changed is None: return 0
# Konwersja i obliczenie różnicy
now_utc = datetime.now(timezone.utc)
return (now_utc - last_changed).total_seconds()
except Exception:
return 0
# --- WATCHDOG ---
@time_trigger("period(now, 1min)")
def tv_auto_off_watchdog():
"""
Sprawdza czy Apple TV jest 'zapauzowane' lub w menu przez długi czas.
Jeśli tak - wyłącza je (a przez HDMI-CEC wyłączy się też telewizor).
"""
# 1. Sprawdź stan Apple TV
apple_state = state.get(TV_APPLE)
# Safety check: Jeśli encja Apple TV jest niedostępna, przerwij
if apple_state in ["unavailable", "unknown", None]:
return
# 2. Sprawdź, czy stan kwalifikuje się do wyłączenia
# Jeśli oglądasz (playing) albo już jest wyłączone (standby/off) -> nic nie rób.
if apple_state not in AUTO_OFF_CANDIDATES:
return
# 3. Pobierz timeout z konfiguracji
try:
timeout_min = float(state.get(TIMEOUT_INPUT))
except (ValueError, TypeError):
timeout_min = 20 # Domyślnie 20 min
timeout_sec = timeout_min * 60
# 4. Sprawdź jak długo Apple TV "wisi" w tym stanie
sec_apple = get_seconds_since_change(TV_APPLE)
if sec_apple > timeout_sec:
log.info(f"TV Manager: Apple TV idle/paused for {int(sec_apple/60)}m (Limit: {timeout_min}m). Turning OFF.")
# Action 1: Pożegnanie (Opcjonalne, w try/except żeby nie zablokowało wyłączenia)
try:
tts.google_say(
entity_id=SPEAKER_TTS,
message="bye",
language="en"
)
except:
pass
# Action 2: Wyłączenie
# Wyłączenie Apple TV zazwyczaj wysyła sygnał CEC do TV Samsung, więc wyłączy się całość.
media_player.turn_off(entity_id=TV_APPLE)

View File

@ -0,0 +1,106 @@
from datetime import datetime, timezone
# --- KONFIGURACJA ---
VIGILANT_MODE = "input_boolean.vigilant"
STEFI_MODE = "input_boolean.stefinka"
MAIN_DOOR = "binary_sensor.main_door"
DISTANCE_SENSOR = "sensor.home_is17_distance"
SEB_DEPARTED = "input_boolean.seb_departed"
# Sensory "wewnętrzne" - potwierdzają obecność w środku
INTERNAL_MOTION = [
"binary_sensor.aqara_presence_presence", # <-- NAJWAŻNIEJSZY (Priorytet)
"binary_sensor.living_room_motion",
"binary_sensor.kitchen_motion",
"binary_sensor.small_hall"
# usunięto hall_motion
]
# --- HELPERY ---
def get_last_active_ts(entity_id):
"""
Zwraca czas ostatniej aktywności.
Dla czujników Presence: Jeśli jest 'on', zwraca TERAZ.
"""
try:
# 1. Jeśli sensor aktualnie wykrywa obecność/ruch -> Czas to TERAZ
# To naprawia problem "starego last_changed" przy długim siedzeniu w pokoju
if state.get(entity_id) == 'on':
return datetime.now(timezone.utc).timestamp()
# 2. Jeśli jest 'off', sprawdzamy kiedy ostatnio zmienił stan
lc = state.get(f"{entity_id}.last_changed")
if lc:
return lc.timestamp()
return 0.0
except Exception:
return 0.0
def get_latest_activity_ts():
"""Znajduje czas NAJNOWSZEJ aktywności w domu."""
timestamps = []
for sensor in INTERNAL_MOTION:
ts = get_last_active_ts(sensor)
timestamps.append(ts)
if not timestamps: return 0.0
return max(timestamps)
def get_door_change_ts(entity_id):
"""Dla drzwi interesuje nas tylko fizyczna zmiana stanu (last_changed)."""
try:
lc = state.get(f"{entity_id}.last_changed")
if lc:
return lc.timestamp()
return 0.0
except:
return 0.0
# --- LOGIKA ---
@time_trigger("period(now, 1min)")
@state_trigger(f"{MAIN_DOOR}")
@state_trigger(f"{DISTANCE_SENSOR}")
# Dodajemy trigger na Aqara, żeby reakcja była natychmiastowa jak wejdziesz
@state_trigger("binary_sensor.aqara_presence_presence")
def vigilant_presence_manager():
# 0. Warunek wstępny: Stefinka OFF
if state.get(STEFI_MODE) == 'on':
return
# 1. Pobierz dane
door_ts = get_door_change_ts(MAIN_DOOR)
motion_ts = get_latest_activity_ts()
try:
dist = float(state.get(DISTANCE_SENSOR))
except (ValueError, TypeError):
dist = 999.0
# --- ANALIZA SYTUACJI ---
# A. Aktywność wewnątrz jest nowsza niż ruch drzwi -> Jesteś w środku
# (Dzięki zmianie w helperze, jeśli Aqara jest ON, motion_ts jest zawsze "Teraz",
# więc ten warunek będzie zawsze TRUE dopóki jesteś w pokoju).
if motion_ts > door_ts:
if state.get(VIGILANT_MODE) == 'off':
log.info("Vigilant: Inside Presence Confirmed (Activity > Door). Arming.")
input_boolean.turn_on(entity_id=VIGILANT_MODE)
# B. Drzwi ruszały się PO ostatniej aktywności -> Mogłeś wyjść
else:
# Naprawdę wyjechałeś (seb_departed > 100m) -> Uzbrój
if state.get(SEB_DEPARTED) == 'on':
if state.get(VIGILANT_MODE) == 'off':
log.info(f"Vigilant: Away (seb_departed=on). Arming.")
input_boolean.turn_on(entity_id=VIGILANT_MODE)
# Na posesji (ogród, kojec, podjazd) -> Rozbrój
else:
if state.get(VIGILANT_MODE) == 'on':
log.info(f"Vigilant: On Property ({int(dist)}m). Door moved recently & No internal activity. Disarming.")
input_boolean.turn_off(entity_id=VIGILANT_MODE)

View File

@ -0,0 +1,86 @@
# /config/pyscript/windows_monitor.py
# --- KONFIGURACJA ---
# Słownik: { "entity_id": "Nazwa do wymówienia/powiadomienia" }
# Łatwo tu dodać nowe okna bez ruszania logiki.
WATCHED_WINDOWS = {
"binary_sensor.bathroom_window": "Bathroom window",
"binary_sensor.terrace_door": "Terrace door",
"binary_sensor.bedroom_window": "Bedroom window",
"binary_sensor.stefi_window": "Stefis window",
"binary_sensor.window_kitchen_farm": "Kitchen farm window",
"binary_sensor.office_window": "Office window",
"binary_sensor.living_room_window": "Living room window",
"binary_sensor.main_door": "Main door"
}
TEMP_SENSOR = "sensor.attic_temperature"
TEMP_THRESHOLD = 18.0 # Uruchom tylko jeśli jest chłodniej niż 18 st.
HOMEPOD_ENTITY = "media_player.living_room_homepod"
VOLUME_RESET_SCRIPT = "automation.set_speakers_volume_via_template"
# --- LOGIKA SRE ---
@time_trigger("cron(30 21 * * *)", "cron(30 22 * * *)")
def check_windows_at_night():
"""
Sprawdza otwarte okna o 21:30 i 22:30, jeśli na poddaszu jest zimno.
Generuje dynamiczny komunikat TTS.
"""
# 1. Sprawdzenie temperatury (Guard Clause)
try:
current_temp = float(state.get(TEMP_SENSOR))
except (ValueError, TypeError):
log.warning(f"SRE Windows: Cannot read temp from {TEMP_SENSOR}. Assuming cold.")
current_temp = 0.0
if current_temp >= TEMP_THRESHOLD:
log.info(f"SRE Windows: Attic temp ({current_temp}°C) is above limit ({TEMP_THRESHOLD}°C). Skipping check.")
return
# 2. Skanowanie okien
open_windows_names = []
for entity_id, spoken_name in WATCHED_WINDOWS.items():
# Używamy state.get(), co jest bezpieczne (zwraca None jeśli encja nie istnieje)
if state.get(entity_id) == 'on':
open_windows_names.append(spoken_name)
# 3. Jeśli wszystko zamknięte - koniec
if not open_windows_names:
log.info("SRE Windows: All secure. No action needed.")
return
# 4. Budowanie komunikatu (Natural Language Processing w wersji mini ;))
# Tworzy ładną listę np.: "Bathroom window, Office window is open"
windows_list_str = ", ".join(open_windows_names)
message = f"{windows_list_str} is open"
log.warning(f"SRE Windows: Alert! Open: {windows_list_str}")
# 5. Wykonanie akcji (Alerting)
# Powiadomienie na telefon
notify.mobile_app_is17(
title="🪟 Open Windows Alert",
message=message
)
# TTS na HomePod
# Ustawienie głośności
media_player.volume_set(entity_id=HOMEPOD_ENTITY, volume_level=0.6)
# Komunikat głosowy
tts.google_say(
entity_id=HOMEPOD_ENTITY,
message=message,
cache=True
)
# Czekaj na zakończenie komunikatu (nie blokuje HA, bo to task.sleep)
task.sleep(7)
# Reset głośności (wywołanie Twojej starej automatyzacji/skryptu)
automation.trigger(entity_id=VOLUME_RESET_SCRIPT)

View File

@ -0,0 +1,93 @@
# /config/pyscript/zombie_hunter.py
IGNORE_LIST = ["sensor.date", "sensor.time", "sensor.uptime"]
@time_trigger("cron(54 22 * * *)") # Codziennie o XX:XX
@service
def find_unavailable_entities():
"""
Skanuje encje w poszukiwaniu 'unavailable'/'unknown'.
Loguje pełną listę i instrukcję usuwania.
"""
zombies = []
# Domeny do sprawdzenia
domains = ["light", "switch", "binary_sensor", "sensor", "camera", "cover", "climate"]
for entity_id in state.names():
domain = entity_id.split(".")[0]
# Filtrowanie domen
if domain not in domains:
continue
# Filtrowanie ignorowanych
if entity_id in IGNORE_LIST:
continue
# Ignoruj grupy (często są unknown przy starcie)
if "group" in entity_id:
continue
curr_state = state.get(entity_id)
if curr_state in ["unavailable", "unknown"]:
# Pobieramy friendly_name dla czytelności
try:
attrs = state.getattr(entity_id)
friendly = attrs.get("friendly_name", entity_id) if attrs else entity_id
except:
friendly = entity_id
zombies.append(f"🔴 {entity_id} | Name: {friendly}")
if zombies:
count = len(zombies)
# --- 1. BUDOWANIE RAPORTU DO LOGÓW ---
log_report = f"\n{'='*50}\n"
log_report += f"🧟 ZOMBIE REPORT: Found {count} dead entities\n"
log_report += f"{'='*50}\n"
# Lista encji
log_report += "\n".join(zombies)
# Instrukcja naprawy (How-To Fix)
log_report += f"\n\n{'='*50}\n"
log_report += "🛠️ HOW TO REMOVE / FIX:\n"
log_report += f"{'-'*50}\n"
log_report += "1. INTEGRATION/UI:\n"
log_report += " Go to Settings > Devices & Services > Entities.\n"
log_report += " Search for the entity. If it has a red (!) icon:\n"
log_report += " Click it -> Settings (Gear icon) -> DELETE (if available).\n\n"
log_report += "2. YAML (Legacy):\n"
log_report += " If you defined it in configuration.yaml/sensors.yaml:\n"
log_report += " Delete the code block and restart Home Assistant.\n\n"
log_report += "3. MQTT Discovery:\n"
log_report += " These persist in the broker.\n"
log_report += " Use 'MQTT Explorer' app. Find the topic under 'homeassistant/'.\n"
log_report += " Delete the retained config message (Retained: Yes -> Empty payload).\n\n"
log_report += "4. MOBILE APP:\n"
log_report += " If it's an old phone sensor, go to App Configuration > Manage Sensors.\n"
log_report += f"{'='*50}"
# Wypisz do logów (jako WARNING, żeby było żółte/czerwone w Dozzle)
log.warning(log_report)
# --- 2. POWIADOMIENIE NA TELEFON (Skrócone) ---
msg_body = "\n".join(zombies[:8])
if count > 8:
msg_body += f"\n...and {count - 8} more."
msg_body += "\n\n👉 Check Dozzle/Logs for removal instructions."
notify.mobile_app_is15(
title=f"🧟 Zombie Alert: {count} Entities",
message=msg_body
)
else:
log.info("🧟 Zombie Hunter: No dead entities found. Clean system!")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,118 @@
roborock_clean_selected_rooms:
alias: "Roborock: sprzątaj zaznaczone pokoje"
icon: mdi:robot-vacuum
mode: single
sequence:
- variables:
segments: >-
{% set ns = namespace(rooms=[]) %}
{% if is_state('input_boolean.vacuum_room_corridor', 'on') %}{% set ns.rooms = ns.rooms + [1] %}{% endif %}
{% if is_state('input_boolean.vacuum_room_office', 'on') %}{% set ns.rooms = ns.rooms + [2] %}{% endif %}
{% if is_state('input_boolean.vacuum_room_bathroom', 'on') %}{% set ns.rooms = ns.rooms + [3] %}{% endif %}
{% if is_state('input_boolean.vacuum_room_hall', 'on') %}{% set ns.rooms = ns.rooms + [4] %}{% endif %}
{% if is_state('input_boolean.vacuum_room_living_room','on') %}{% set ns.rooms = ns.rooms + [5] %}{% endif %}
{% if is_state('input_boolean.vacuum_room_kitchen', 'on') %}{% set ns.rooms = ns.rooms + [6] %}{% endif %}
{% if is_state('input_boolean.vacuum_room_bedroom', 'on') %}{% set ns.rooms = ns.rooms + [7] %}{% endif %}
{% if is_state('input_boolean.vacuum_room_stefi', 'on') %}{% set ns.rooms = ns.rooms + [8] %}{% endif %}
{{ ns.rooms }}
repeats: "{{ states('input_number.vacuum_repeats') | int(1) }}"
- if:
- condition: template
value_template: "{{ segments | length == 0 }}"
then:
- stop: "Żaden pokój nie jest zaznaczony"
- action: vacuum.send_command
target:
entity_id: vacuum.roborock_qrevo_edge_series
data:
command: app_segment_clean
params:
- segments: "{{ segments }}"
repeat: "{{ repeats }}"
description: "Odkurza pokoje zaznaczone przez input_boolean.vacuum_room_*"
roborock_qrevo_clean_room:
alias: roborock.qrevo.clean.room
icon: mdi:robot-vacuum
mode: queued
max: 10
fields:
room_name:
description: Nazwa pokoju (angielska z mapy) lub polska nazwa
example: Kitchen
required: true
selector:
select:
options:
- Corridor
- Office
- Bathroom
- Hall
- Living room
- Kitchen
- Bedroom
- Stefi
repeats:
description: Liczba powtórzeń (1, 2 lub 3 razy)
default: 1
selector:
number:
min: 1
max: 3
sequence:
- variables:
room_id_map:
Corridor: 1
Office: 2
Bathroom: 3
Hall: 4
Living room: 5
Kitchen: 6
Bedroom: 7
Stefi: 8
target_id: '{{ room_id_map.get(room_name) }}'
- if:
- condition: template
value_template: '{{ target_id is none }}'
then:
- stop: 'Błąd: Nieznana nazwa pokoju. Sprawdź pisownię w skrypcie.'
- action: vacuum.send_command
target:
entity_id: vacuum.roborock_qrevo_edge_series
data:
command: app_segment_clean
params:
- segments:
- '{{ target_id }}'
repeat: '{{ repeats }}'
description: ''
roborock_clean_day_zone:
alias: roborock.clean.day.zone
icon: mdi:vacuum-outline
sequence:
- action: vacuum.send_command
target:
entity_id: vacuum.roborock_qrevo_edge_series
data:
command: app_segment_clean
params:
- segments:
- 1
- 4
- 6
repeat: 1
description: ''
roborock_clean_office:
sequence:
- action: vacuum.send_command
target:
entity_id: vacuum.roborock_qrevo_edge_series
data:
command: app_segment_clean
params:
- segments:
- 2
repeat: 1
alias: roborock.clean.office
icon: mdi:vacuum-outline
description: ''

View File

@ -0,0 +1,16 @@
happy:
primary-color: pink
text-primary-color: purple
mdc-theme-primary: plum
sad:
primary-color: steelblue
modes:
dark:
secondary-text-color: slategray
day_and_night:
primary-color: coral
modes:
light:
secondary-text-color: olive
dark:
secondary-text-color: slategray