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:
commit
8394b3d4ce
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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> </center>
|
||||
{%- endif %}
|
||||
{%- else -%}
|
||||
<h3> </h3>
|
||||
<span class="title"> </br></span>
|
||||
<span class="artist"> </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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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}")
|
||||
|
||||
|
|
@ -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}")
|
||||
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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)")
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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.")
|
||||
|
|
@ -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.")
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 są 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())
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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.")
|
||||
|
|
@ -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).")
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
|
@ -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: ''
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue