Compare commits

...

28 Commits

Author SHA1 Message Date
b6387c0acc various heatpump adjustements 2026-07-09 23:21:09 +02:00
2b2261e248 add red shelly button as a presence source 2026-07-09 23:20:00 +02:00
89a2f55583 tweaked living_room_ambiance_light 2026-07-09 23:19:45 +02:00
976b829a8d vertex2_has_input_signal condition is now disabled when vertex 2 is unavailable 2026-07-09 23:18:58 +02:00
e7e11e3429 Added a motion light to the porche 2026-07-09 23:17:29 +02:00
e5d99438d8 garage light doesn't check light level if the door is close 2026-07-09 23:16:39 +02:00
426c6b79cf new ambiance light for the desk 2026-07-09 23:15:01 +02:00
dd27cdc952 Add sleep switch for living room 2026-07-09 23:14:23 +02:00
30965c2a8a Add motion sensors for desk and porche 2026-07-09 23:11:00 +02:00
cb2e70ee57 Add storeban 2026-07-09 23:09:57 +02:00
2e8dd176b8 Fix issue with temperature sensor handling in informationscollector 2026-07-09 23:09:26 +02:00
d9744ff8f1 Update subproject commit reference in ad_toolbox 2026-07-09 23:07:50 +02:00
bf1445759d Update presence simulation conditions for home alarm logic 2026-06-21 18:42:29 +02:00
3087ad1f80 first version of presence simulation with light 2026-06-21 12:26:42 +02:00
6310e39fd9 add some home cinema automation 2026-06-21 09:30:33 +02:00
85fb207ca0 fix a missing sensor error on restart 2026-06-21 09:16:59 +02:00
757ece983c Refactor home alarm notifications and enhance intrusion detection logic 2026-06-19 08:51:18 +02:00
eca30960f9 Add living room window control logic to heatpump configuration 2026-06-19 08:50:58 +02:00
ec2fb31068 first iteration on the alarm system 2026-06-17 19:21:49 +02:00
8cc9e31ce6 Update ad_toolbox subproject commit reference 2026-06-17 19:21:22 +02:00
9607e6e78d Enhance notification message rendering by adding entity state resolution and regex pattern matching 2026-06-17 19:19:25 +02:00
fd0769fd12 Update p1p_light smart_conditions to include camera streaming and simplify blocking_conditions 2026-06-15 18:52:40 +02:00
c50a1c3a24 new is_dark_outside sensor 2026-06-15 18:52:12 +02:00
3737606542 Adjust temperature thresholds for heating conditions and update target temperature calculations 2026-06-15 18:50:54 +02:00
92d7f1c869 new smartshutter app 2026-06-15 18:50:22 +02:00
654168425e fix an issue on quick_resume sensor init value 2026-06-15 18:50:09 +02:00
09681d93bb P1P integration 2026-06-08 19:40:58 +02:00
77cd648d03 Add SonosController and player configuration with event handling for play, cycle radio, and album features 2026-06-05 23:31:45 +02:00
20 changed files with 2482 additions and 51 deletions

View File

@@ -17,7 +17,7 @@ heating_sensors:
value: 18 value: 18
default: default:
conditions: True conditions: True
value: 27 value: input_number.heatpump_temperature
smart_heating_templates_library: smart_heating_templates_library:
module: templatelibrary module: templatelibrary
@@ -27,7 +27,7 @@ smart_heating_templates_library:
templates_library: templates_library:
clim_conditions: clim_conditions:
local_expressions: local_expressions:
temp_threshold: "sleeping_in_the_room ? 0.5 : 1" temp_threshold: "sleeping_in_the_room ? 0 : (ext_temperature >= 35 ? 0.5 : 1)"
trigger_conditions: trigger_conditions:
- "not binary_sensor.heatpump_winter_mode and (ext_temperature >= room_temperature or ext_temperature >= target_temperature or cant_open_window) and room_temperature >= (quick_resume ? target_temperature : (target_temperature + temp_threshold))" - "not binary_sensor.heatpump_winter_mode and (ext_temperature >= room_temperature or ext_temperature >= target_temperature or cant_open_window) and room_temperature >= (quick_resume ? target_temperature : (target_temperature + temp_threshold))"
- not binary_sensor.heatpump_winter_mode and self and (ext_temperature >= target_temperature or cant_open_window) - not binary_sensor.heatpump_winter_mode and self and (ext_temperature >= target_temperature or cant_open_window)
@@ -35,7 +35,7 @@ smart_heating_templates_library:
- binary_sensor.heatpump_winter_mode and self and room_temperature < target_temperature - binary_sensor.heatpump_winter_mode and self and room_temperature < target_temperature
blocking_conditions: blocking_conditions:
- binary_sensor.is_everybody_sleeping and not sleeping_in_the_room and not stay_on_while_sleeping - binary_sensor.is_everybody_sleeping and not sleeping_in_the_room and not stay_on_while_sleeping
- not room_occupancy - not room_occupancy and not (sleeping_in_the_room or (occupied_tonight and binary_sensor.day_interval_night))
- not binary_sensor.someone_home - not binary_sensor.someone_home
- window_open - window_open
@@ -55,12 +55,13 @@ desk_heatpump:
cant_open_window: binary_sensor.is_desk_sleeping cant_open_window: binary_sensor.is_desk_sleeping
sleeping_in_the_room: binary_sensor.is_desk_sleeping sleeping_in_the_room: binary_sensor.is_desk_sleeping
quick_resume: binary_sensor.desk_heatpump_quick_resume quick_resume: binary_sensor.desk_heatpump_quick_resume
occupied_tonight: input_boolean.desk_occupied_tonight
stay_on_while_sleeping: false stay_on_while_sleeping: false
virtual_sensors: virtual_sensors:
sensors: sensors:
value_selector.desk_heatpump_fan_mode: value_selector.desk_heatpump_fan_mode:
auto: binary_sensor.heatpump_winter_mode auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1
quiet: True quiet: True
target_temperature: sensor.target_temperature_heatpump target_temperature: sensor.target_temperature_heatpump
@@ -101,15 +102,16 @@ bedroom_heatpump:
cant_open_window: binary_sensor.is_bedroom_sleeping cant_open_window: binary_sensor.is_bedroom_sleeping
sleeping_in_the_room: binary_sensor.is_bedroom_sleeping sleeping_in_the_room: binary_sensor.is_bedroom_sleeping
quick_resume: binary_sensor.bedroom_heatpump_quick_resume quick_resume: binary_sensor.bedroom_heatpump_quick_resume
stay_on_while_sleeping: true occupied_tonight: input_boolean.bedroom_occupied_tonight
stay_on_while_sleeping: false
virtual_sensors: virtual_sensors:
sensors: sensors:
value_selector.bedroom_heatpump_fan_mode: value_selector.bedroom_heatpump_fan_mode:
auto: binary_sensor.heatpump_winter_mode auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1
quiet: True quiet: True
target_temperature: sensor.target_temperature_heatpump + 0.5 target_temperature: sensor.target_temperature_heatpump # + 0.5
hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'" hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'"
fan_mode: sensor.bedroom_heatpump_fan_mode fan_mode: sensor.bedroom_heatpump_fan_mode
@@ -137,6 +139,10 @@ living_room_heatpump:
virtual_sensors: virtual_sensors:
sensors: sensors:
binary_sensor.living_room_any_window_open: binary_sensor.living_room_french_door or binary_sensor.living_room_window binary_sensor.living_room_any_window_open: binary_sensor.living_room_french_door or binary_sensor.living_room_window
binary_sensor.living_room_cant_open_window: binary_sensor.is_living_room_sleeping or binary_sensor.projector_on
value_selector.living_room_heatpump_fan_mode:
auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1
quiet: True
constants: constants:
window_open: binary_sensor.living_room_any_window_open window_open: binary_sensor.living_room_any_window_open
@@ -144,13 +150,15 @@ living_room_heatpump:
room_temperature: sensor.living_room_temperature room_temperature: sensor.living_room_temperature
room_occupancy: binary_sensor.living_room_occupancy room_occupancy: binary_sensor.living_room_occupancy
target_temperature: sensor.target_temperature_heatpump target_temperature: sensor.target_temperature_heatpump
cant_open_window: binary_sensor.is_living_room_sleeping cant_open_window: binary_sensor.living_room_cant_open_window
sleeping_in_the_room: binary_sensor.is_living_room_sleeping sleeping_in_the_room: binary_sensor.is_living_room_sleeping
quick_resume: binary_sensor.living_room_heatpump_quick_resume quick_resume: binary_sensor.living_room_heatpump_quick_resume
occupied_tonight: input_boolean.living_room_occupied_tonight
stay_on_while_sleeping: false stay_on_while_sleeping: false
target_temperature: sensor.target_temperature_heatpump target_temperature: sensor.target_temperature_heatpump + 0.5
hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'" hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'"
fan_mode: sensor.living_room_heatpump_fan_mode
smart_conditions: smart_conditions:
callback_delay: 10 # on the morning it can happen that we ask to start and stop the heat pump immediately afterwards which in result in the heat pump ignoring the second command callback_delay: 10 # on the morning it can happen that we ask to start and stop the heat pump immediately afterwards which in result in the heat pump ignoring the second command

174
apps/global/home_alarm.yaml Normal file
View File

@@ -0,0 +1,174 @@
home_alarm:
module: homealarm
class: HomeAlarm
smart_conditions:
trigger_conditions: not binary_sensor.someone_home or input_select.alarm_mode == 'On'
blocking_conditions: input_select.alarm_mode == 'Off'
opening_sensors:
- binary_sensor.group_doors
- binary_sensor.group_windows
motion_sensors:
- binary_sensor.corridor_motion
- binary_sensor.hallway_motion
- binary_sensor.restroom_motion
- binary_sensor.garage_motion
- binary_sensor.kitchen_motion
- binary_sensor.mezzanine_motion
- binary_sensor.living_room_presence_presence
motion_intrusion_increment: 0.2
opening_intrusion_increment: 0.9
intrusion_cooldown_step: 0.1
intrusion_cooldown_interval: 60
reset_button: input_button.alarm_reset
presence_simulation:
smart_conditions: input_boolean.simulate_presence and binary_sensor.home_alarm_armed
target_apps:
restroom:
# 5 minutes every 2 hours with 1 hour jitter
app: light_restroom
mode: method
start_payload:
brightness_pct: 100
transition: 1
stop_payload:
transition: 1
on_duration_min: 5
on_jitter_min: 1
interval_min: 120
interval_jitter_min: 60
start_delay_min: 60
start_delay_jitter_min: 20
light_living_room:
# Exemple "bureau toute la soiree": longues periodes allumees le soir
# on_duration_min / interval_min are optional:
# if omitted, the app stays active for the whole simulation window
# (still gated by smart_conditions + time_window).
app: light_living_room
status_sensor: binary_sensor.home_alarm_presence_mezzanine
mode: method
smart_conditions: binary_sensor.is_dark_outside
time_window:
stop: '23:30'
stop_jitter_min: 30
start_payload:
brightness_pct: 100
transition: 2
stop_payload:
transition: 2
home_alarm_notifications:
module: notificationsmanager
class: NotificationsManager
priority: 100 # default priority app is 50, since the notification_manager doesn't create any sensor but is based on sensor created by many app, it's important it's created last
notifications:
alarm_armed_notification:
recipients:
all:
services:
- notify/mobile_app_iphone_de_pierre
- notify/mobile_app_mae
conditions: binary_sensor.home_alarm_armed
message:
title: Alarme
content: Activation de l'alarme
timeout: 600
data:
tag: home_alarm_notification
windows_open_notification:
recipients:
all:
services:
- notify/mobile_app_iphone_de_pierre
- notify/mobile_app_mae
conditions: binary_sensor.home_alarm_armed and sensor.ad_number_of_windows_open > 0 and binary_sensor.home_alarm_just_armed
message:
title: Alarme
content: "{sensor.ad_number_of_windows_open} fenêtre(s) ouvertes"
data:
tag: home_alarm_notification
push:
interruption-level: critical
doors_open_notification:
recipients:
all:
services:
- notify/mobile_app_iphone_de_pierre
- notify/mobile_app_mae
conditions: binary_sensor.home_alarm_armed and sensor.ad_number_of_doors_open > 0 and binary_sensor.home_alarm_just_armed
message:
title: Alarme
content: "{sensor.ad_number_of_doors_open} porte(s) ouvertes"
data:
tag: home_alarm_notification
push:
interruption-level: critical
intrusion_detected_notification:
recipients:
all:
services:
- notify/mobile_app_iphone_de_pierre
- notify/mobile_app_mae
conditions: binary_sensor.home_alarm_armed and sensor.home_alarm_intrusion_level >= 1
message:
title: Alarme
content: Intrusion détectée
data:
tag: home_alarm_notification
push:
interruption-level: critical
intrusion_suspected_notification:
recipients:
all:
services:
- notify/mobile_app_iphone_de_pierre
- notify/mobile_app_mae
conditions: binary_sensor.home_alarm_armed and sensor.home_alarm_intrusion_level > 0 and sensor.home_alarm_intrusion_level < 1
message:
title: Alarme
content: Intrusion suspectée
data:
tag: home_alarm_notification
alarm_unarmed_notification:
recipients:
all:
services:
- notify/mobile_app_iphone_de_pierre
- notify/mobile_app_mae
conditions: not binary_sensor.home_alarm_armed
message:
title: Alarme
content: Désactivation de l'alarme
timeout: 600
clear_home_alarm_notification:
recipients:
all:
services:
- notify/mobile_app_iphone_de_pierre
- notify/mobile_app_mae
conditions: not binary_sensor.home_alarm_armed
message:
content: clear_notification
data:
tag: home_alarm_notification

View File

@@ -7,14 +7,12 @@ notifications_manager:
notifications: notifications:
back_home_notification: back_home_notification:
recipients: recipients:
all: away:
services: services: notify/mobile_app_ipad_pro_11_de_pierre
- notify/mobile_app_ipad_pro_11_de_pierre conditions: input_boolean.haw and person.maeva == 'home' and not binary_sensor.is_bedroom_sleeping
conditions: sleeping:
trigger_conditions: services: notify/mobile_app_ipad_pro_11_de_pierre
- person.maeva == 'home' conditions: input_boolean.haw and binary_sensor.is_bedroom_sleeping and (binary_sensor.hallway_motion or binary_sensor.corridor_motion or binary_sensor.bedroom_motion_bed_pierre)
- binary_sensor.is_bedroom_sleeping and (binary_sensor.hallway_motion or binary_sensor.corridor_motion or binary_sensor.bedroom_motion_bed_pierre)
blocking_conditions: not input_boolean.haw or not binary_sensor.someone_home
message: message:
content: Bisous content: Bisous
@@ -23,8 +21,7 @@ notifications_manager:
leave_home_notification: leave_home_notification:
recipients: recipients:
all: all:
services: services: notify/mobile_app_macbook_m3_pro_de_pierre
- notify/mobile_app_macbook_m3_pro_de_pierre
conditions: input_boolean.haw and person.maeva != 'home' and binary_sensor.someone_home conditions: input_boolean.haw and person.maeva != 'home' and binary_sensor.someone_home
message: message:

View File

@@ -13,6 +13,11 @@ motion_tracker:
motion_sensors: binary_sensor.hallway_motion motion_sensors: binary_sensor.hallway_motion
restroom: restroom:
motion_sensors: binary_sensor.restroom_motion motion_sensors: binary_sensor.restroom_motion
porche:
motion_sensors: binary_sensor.porche_motion
desk:
update_on_both_front: true
motion_sensors: binary_sensor.desk_presence_presence
garage: garage:
door_generate_motion: true door_generate_motion: true
motion_sensors: motion_sensors:
@@ -33,11 +38,16 @@ occupancy_sensors:
class: VirtualSensorsApp class: VirtualSensorsApp
priority: 8 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first priority: 8 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first
default_values:
binary_sensor.someone_home: off
sensors: sensors:
binary_sensor.living_room_occupancy: binary_sensor.living_room_occupancy:
trigger_conditions: sensor.living_room_last_motion <= 5 trigger_conditions: sensor.living_room_last_motion <= 5
binary_sensor.kitchen_occupancy: binary_sensor.kitchen_occupancy:
trigger_conditions: sensor.kitchen_last_motion <= 5 trigger_conditions: sensor.kitchen_last_motion <= 5
binary_sensor.garage_occupancy:
trigger_conditions: sensor.garage_last_motion <= 5 or binary_sensor.garage_entrance or binary_sensor.garage_door
binary_sensor.restroom_occupancy: binary_sensor.restroom_occupancy:
trigger_conditions: sensor.restroom_last_motion <= 3 trigger_conditions: sensor.restroom_last_motion <= 3
binary_sensor.bedroom_occupancy: binary_sensor.bedroom_occupancy:
@@ -47,5 +57,6 @@ occupancy_sensors:
retain_time: 5 * 60 retain_time: 5 * 60
conditions: conditions:
trigger_conditions: trigger_conditions:
- binary_sensor.alienware_r10 - sensor.desk_last_motion <= 5
- sensor.macbook_m3_pro_de_pierre_frontmost_app != 'loginwindow' and sensor.macbook_m3_pro_de_pierre_displays == 2 #- binary_sensor.alienware_r10
#- sensor.macbook_m3_pro_de_pierre_frontmost_app != 'loginwindow' and sensor.macbook_m3_pro_de_pierre_displays == 2

39
apps/global/sonos.yaml Normal file
View File

@@ -0,0 +1,39 @@
sonos_controller:
module: sonoscontroller
class: SonosController
players:
sonos_garage:
entity: media_player.garage
off_conditions: binary_sensor.is_everybody_sleeping or not binary_sensor.someone_home
play_stop_events:
btn_play_stop:
event_name: state_changed
debounce_seconds: 0.5
event_data:
entity_id: event.garage_bilresa_button_bouton_1
new_state:
attributes:
event_type: multi_press_1
cycle_radio_events:
btn_play_stop:
event_name: state_changed
debounce_seconds: 0.5
event_data:
entity_id: event.garage_bilresa_button_bouton_1
new_state:
attributes:
event_type: long_press
cycle_album_events:
btn_play_stop:
event_name: state_changed
debounce_seconds: 0.5
event_data:
entity_id: event.garage_bilresa_button_bouton_2
new_state:
attributes:
event_type: multi_press_1

View File

@@ -17,4 +17,6 @@ virtual_events:
event_name: turn_off_all_lights event_name: turn_off_all_lights
event_condition: binary_sensor.is_everybody_sleeping event_condition: binary_sensor.is_everybody_sleeping
turn_off_all_living_room_lights:
event_name: turn_off_all_living_room_lights
event_condition: binary_sensor.is_living_room_sleeping or binary_sensor.projector_on

View File

@@ -4,22 +4,68 @@ virtual_sensors:
priority: 10 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first priority: 10 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first
sensors: sensors:
continuous_condition.is_apple_tv_idle: binary_sensor.someone_home: person.maeva == 'home' or person.pierre == 'home' or (input_boolean.use_red_shelly_presence and binary_sensor.red_shelly_home)
time: 5 binary_sensor.is_dark_outside: sun.sun == 'below_horizon'
conditions: media_player.apple_tv == 'paused' or media_player.apple_tv == 'idle' or media_player.apple_tv == 'standby' event_sensor.red_shelly_home:
retain_time: 120
binary_sensor.is_apple_tv_on: media_player.apple_tv != 'off' activation_events:
binary_sensor.someone_home: person.maeva == 'home' or person.pierre == 'home' signal_strenght:
event_name: state_changed
event_data:
entity_id: sensor.shelly_button_red_signal_strength
# living room sensors # living room sensors
value_selector.living_room_ambiance_light: value_selector.living_room_ambiance_light:
values: values:
0: 0:
- not binary_sensor.living_room_occupancy - not binary_sensor.living_room_occupancy
- binary_sensor.is_apple_tv_on and not binary_sensor.is_apple_tv_idle - binary_sensor.projector_on and not binary_sensor.is_vertex2_content_idle
- light.living_room - light.living_room
- sensor.mezzanine_motion_light_level > 10 - sensor.mezzanine_motion_light_level > 15
1: binary_sensor.is_everybody_sleeping - binary_sensor.is_living_room_sleeping
1:
- binary_sensor.is_everybody_sleeping
- binary_sensor.projector_on
2: true 2: true
# home cinema sensors
binary_sensor.projector_on: switch.living_room_projector_plug and sensor.living_room_projector_plug_power > 50
continuous_condition.projector_off_for_a_while:
time: 90
default_value: on
conditions: not binary_sensor.projector_on
# is_apple_tv_on is not reliable as the the apple tv integration sometimes return "paused" when it's actually off,
# you should use is_using_apple_tv instead for most automation
binary_sensor.is_apple_tv_on: media_player.apple_tv != 'off' and media_player.apple_tv != 'idle'
binary_sensor.is_xbox_on: media_player.xbox != 'off'
binary_sensor.is_playstation_on: switch.ps5_136_power
retain_condition.vertex2_has_input_signal:
retain_time: 10
conditions:
trigger_conditions: sensor.hdfury_vertex2_07_output_tx0 or sensor.hdfury_vertex2_07_audio_output
disable_conditions: sensor.hdfury_vertex2_07_output_tx0 == 'unavailable'
binary_sensor.is_using_apple_tv: binary_sensor.is_apple_tv_on and binary_sensor.vertex2_has_input_signal and select.hdfury_vertex2_07_port_select_tx0 == 0
binary_sensor.is_using_xbox: binary_sensor.is_xbox_on and binary_sensor.vertex2_has_input_signal and select.hdfury_vertex2_07_port_select_tx0 == 1
binary_sensor.is_using_playstation: binary_sensor.is_playstation_on and binary_sensor.vertex2_has_input_signal and select.hdfury_vertex2_07_port_select_tx0 == 2
continuous_condition.is_vertex2_content_idle:
time: 5
conditions:
trigger_conditions:
- binary_sensor.is_using_apple_tv and (media_player.apple_tv == 'paused' or media_player.apple_tv == 'idle' or media_player.apple_tv == 'standby')
# - binary_sensor.is_using_playstation and sensor.ps5_136_activity == 'idle'
# - binary_sensor.is_using_xbox and not binary_sensor.yetanotherpedro_in_game
#p1p sensors
binary_sensor.p1p_is_printing:
trigger_conditions: sensor.garage_p1p_etat_de_l_impression != 'idle' and sensor.garage_p1p_etat_de_l_impression != 'finish' and sensor.garage_p1p_etat_de_l_impression != 'idle' and sensor.garage_p1p_etat_de_l_impression != 'offline'
disable_conditions: sensor.garage_p1p_etat_de_l_impression == 'offline' and switch.garage_p1p_plug
blocking_conditions: not switch.garage_p1p_plug
binary_sensor.p1p_is_active:
trigger_conditions:
- binary_sensor.p1p_is_printing
- fan.garage_p1p_ventilateur_de_la_chambre or fan.garage_p1p_ventilateur_de_refroidissement
- sensor.garage_p1p_temperature_cible_de_la_buse > 0 or sensor.garage_p1p_temperature_cible_du_lit > 0
blocking_conditions: not switch.garage_p1p_plug

1033
apps/homealarm.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -62,7 +62,7 @@ class InformationsCollector(SmartObject):
self.min_temp_yesterday_sensor = self.create_entity(output_sensors['min_temp_yesterday']) self.min_temp_yesterday_sensor = self.create_entity(output_sensors['min_temp_yesterday'])
current_min_temp, current_max_temp = self.compute_min_max_temp() current_min_temp, current_max_temp = self.compute_min_max_temp()
if not self.max_temp_sensor.exists() or self.max_temp_sensor.get_state() == 'unavailable': if not self.max_temp_sensor.exists() or self.max_temp_sensor.get_state() == 'unavailable' or self.max_temp_sensor.get_state() == 'unknown':
max_temp = current_max_temp max_temp = current_max_temp
try: try:
if max_temp is None or self.dataset['max_temp'] > max_temp: if max_temp is None or self.dataset['max_temp'] > max_temp:
@@ -70,7 +70,7 @@ class InformationsCollector(SmartObject):
self.log_info(f"Restoring max_temp from dataset ({max_temp}°C)") self.log_info(f"Restoring max_temp from dataset ({max_temp}°C)")
except KeyError: pass except KeyError: pass
self.set_max_temp_sensor(max_temp) self.set_max_temp_sensor(max_temp)
if not self.min_temp_sensor.exists() or self.min_temp_sensor.get_state() == 'unavailable': if not self.min_temp_sensor.exists() or self.min_temp_sensor.get_state() == 'unavailable' or self.min_temp_sensor.get_state() == 'unknown':
min_temp = current_min_temp min_temp = current_min_temp
try: try:
if min_temp is None or self.dataset['min_temp'] < min_temp: if min_temp is None or self.dataset['min_temp'] < min_temp:
@@ -199,8 +199,12 @@ class InformationsCollector(SmartObject):
min_temp = None min_temp = None
for sensor in self.args['min_max_temp_sensors']['temperature_sensors']: for sensor in self.args['min_max_temp_sensors']['temperature_sensors']:
temp = self.get_state(sensor) temp = self.get_state(sensor)
if not max_temp or temp > max_temp: max_temp = temp try:
if not min_temp or temp < min_temp: min_temp = temp temp = float(temp)
except (TypeError, ValueError):
continue
if max_temp is None or temp > max_temp: max_temp = temp
if min_temp is None or temp < min_temp: min_temp = temp
return min_temp,max_temp return min_temp,max_temp
def on_log_error(self, name, ts, level, type, message, kwargs): def on_log_error(self, name, ts, level, type, message, kwargs):
@@ -224,10 +228,17 @@ class InformationsCollector(SmartObject):
def on_temperature_change(self, entity, attribute, old, new, kwargs): def on_temperature_change(self, entity, attribute, old, new, kwargs):
if new != old: if new != old:
try:
new_value = float(new)
except (TypeError, ValueError):
self.log_warning(f"Invalid temperature value from {entity}: {new}")
return
min_temp = self.min_temp_sensor.get_state() min_temp = self.min_temp_sensor.get_state()
max_temp = self.max_temp_sensor.get_state() max_temp = self.max_temp_sensor.get_state()
if max_temp is None or max_temp == 'unavailable' or new > max_temp: self.set_max_temp_sensor(new) if max_temp is None or max_temp == 'unavailable' or new_value > float(max_temp): self.set_max_temp_sensor(new_value)
if min_temp is None or min_temp == 'unavailable' or new < min_temp: self.set_min_temp_sensor(new) if min_temp is None or min_temp == 'unavailable' or new_value < float(min_temp): self.set_min_temp_sensor(new_value)
def run_at_midnight(self, kwargs): def run_at_midnight(self, kwargs):
self.max_temp_yesterday_sensor.set_state(state=self.max_temp_sensor.get_state(), attributes={'unit_of_measurement': '°C', 'device_class': 'temperature'}) self.max_temp_yesterday_sensor.set_state(state=self.max_temp_sensor.get_state(), attributes={'unit_of_measurement': '°C', 'device_class': 'temperature'})

View File

@@ -1,10 +1,13 @@
import appdaemon.plugins.hass.hassapi as hass import appdaemon.plugins.hass.hassapi as hass
import appdaemon.exceptions as ad_exception import appdaemon.exceptions as ad_exception
import ad_toolbox.smartcondition as SmartCondition import ad_toolbox.smartcondition as SmartCondition
import re
import uuid import uuid
from expressionparser import ParsingException from expressionparser import ParsingException
class Notification: class Notification:
ENTITY_PLACEHOLDER_PATTERN = re.compile(r"\{([^{}]+)\}")
def __init__(self,ad_api,config,name): def __init__(self,ad_api,config,name):
self.recipients = dict() self.recipients = dict()
self.config = config self.config = config
@@ -50,14 +53,40 @@ class Notification:
for service in recipient_yaml['services']: for service in recipient_yaml['services']:
clear_notification(service,tag) clear_notification(service,tag)
def render_message_content(self, content):
if not isinstance(content, str):
return content
def replace_entity_state(match):
entity_id = match.group(1).strip()
if entity_id == "":
return match.group(0)
try:
state = self.ad_api.get_state(entity_id)
except Exception as e:
self.log_error(f"Failed to resolve '{entity_id}' in notification content: {e}")
return match.group(0)
if state is None:
self.log_error(f"Unable to resolve '{entity_id}' in notification content")
return match.group(0)
return str(state)
return self.ENTITY_PLACEHOLDER_PATTERN.sub(replace_entity_state, content)
def send_notification(self,recipient): def send_notification(self,recipient):
try: title = self.config['message']['title'] try: title_template = self.config['message']['title']
except KeyError: title = "" except KeyError: title_template = ""
try: message = self.config['message']['content'] try: message_content = self.config['message']['content']
except KeyError: message = "" except KeyError: message_content = ""
try: data = self.config['message']['data'] try: data = self.config['message']['data']
except KeyError: data = dict() except KeyError: data = dict()
title = self.render_message_content(title_template)
message = self.render_message_content(message_content)
#TODO : maybe it's better to not send tag when it's not needed #TODO : maybe it's better to not send tag when it's not needed
if not 'tag' in data: if not 'tag' in data:
data['tag'] = self.uuid data['tag'] = self.uuid

View File

@@ -41,5 +41,14 @@ light_restroom:
25: binary_sensor.day_interval_night 25: binary_sensor.day_interval_night
100: True 100: True
light_porche:
module: smartlight
class: SmartLight
entity: light.porche
smart_conditions:
trigger_conditions: sensor.porche_last_motion < 3
blocking_conditions: sensor.porche_motion_light_level > 10 and not self

6
apps/rooms/desk.yaml Normal file
View File

@@ -0,0 +1,6 @@
light_ambiance:
module: smartlight
class: SmartLight
entity: light.desk_ambiance
smart_conditions: sensor.desk_last_motion < 3 and binary_sensor.is_dark_outside

View File

@@ -5,4 +5,31 @@ light_garage:
smart_conditions: smart_conditions:
trigger_conditions: sensor.garage_last_motion < 3 trigger_conditions: sensor.garage_last_motion < 3
blocking_conditions: sensor.garage_motion_light_level > 10 blocking_conditions: sensor.garage_motion_light_level > 10 and binary_sensor.garage_entrance
p1p_light:
module: smartswitch
class: SmartSwitch
entity: light.garage_p1p_lumiere_de_la_chambre
smart_conditions:
unavaibility_result: Disabled
trigger_conditions: binary_sensor.garage_occupancy or binary_sensor.garage_p1p_timelapse or camera.garage_p1p_camera == 'streaming'
blocking_conditions: not binary_sensor.p1p_is_printing and not fan.garage_p1p_ventilateur_de_refroidissement and not fan.garage_p1p_ventilateur_de_la_chambre
p1p_switch:
module: smartswitch
class: SmartSwitch
entity: switch.garage_p1p_plug
off_conditions:
trigger_conditions: binary_sensor.is_anybody_sleeping or not binary_sensor.someone_home
blocking_conditions: binary_sensor.p1p_is_active
toggle_events:
garage_button:
event_name: zha_event
event_data:
device_name: 'garage_p1p_button'
args:
attribute_name: on_off

View File

@@ -90,4 +90,19 @@ senseo_quadrante:
good_morning: good_morning:
event_name: good_morning event_name: good_morning
button: button.senseo_quadrante_brew_coffee_double button: button.senseo_quadrante_brew_coffee_double
condition: binary_sensor.senseo_quadrante_has_program and binary_sensor.senseo_quadrante_has_program.cup_size == 2 and not binary_sensor.senseo_quadrante_has_program.power_pressed condition: binary_sensor.senseo_quadrante_has_program and binary_sensor.senseo_quadrante_has_program.cup_size == 2 and not binary_sensor.senseo_quadrante_has_program.power_pressed
storeban:
module: smartshutter
class: SmartShutter
entity: cover.storeban
virtual_sensors:
sensors:
binary_sensor.storeban_autoclose: sensor.sun_solar_elevation < 0 or (sensor.sun_solar_azimuth > 235 and sensor.sun_solar_elevation < 45)
close_conditions:
trigger_conditions: binary_sensor.storeban_autoclose
disable_conditions: not binary_sensor.storeban_autoclose

View File

@@ -10,10 +10,14 @@ light_mezzanine_01:
- light.living_room - light.living_room
light_brightness_pct: light_brightness_pct:
15: binary_sensor.is_everybody_sleeping 15: binary_sensor.is_everybody_sleeping or binary_sensor.projector_on
35: binary_sensor.day_interval_night 35: binary_sensor.day_interval_night
100: True 100: True
off_events:
- turn_off_all_lights
- turn_off_all_living_room_lights
light_mezzanine_02: light_mezzanine_02:
module: smartlight module: smartlight
class: SmartLight class: SmartLight
@@ -26,10 +30,14 @@ light_mezzanine_02:
- light.living_room - light.living_room
light_brightness_pct: light_brightness_pct:
5: binary_sensor.is_everybody_sleeping 5: binary_sensor.is_everybody_sleeping or binary_sensor.projector_on
25: binary_sensor.day_interval_night 25: binary_sensor.day_interval_night
100: True 100: True
off_events:
- turn_off_all_lights
- turn_off_all_living_room_lights
light_bookshelves: light_bookshelves:
module: smartlight module: smartlight
class: SmartLight class: SmartLight
@@ -76,4 +84,59 @@ light_living_room:
off_events: off_events:
- turn_off_all_lights - turn_off_all_lights
- turn_off_all_living_room_lights
projection_screen:
module: smartshutter
class: SmartShutter
entity: cover.living_room_projection_screen
close_position: 22
close_conditions: # close mean screen down
# when the projector is turn back on, it can happen that this condition is evaluated before projector_off_for_a_while is updated
# so I need to test both binary_sensor.projector_on and not binary_sensor.projector_off_for_a_while
trigger_conditions: binary_sensor.projector_on or not binary_sensor.projector_off_for_a_while
disable_conditions:
- binary_sensor.day_interval_night and not binary_sensor.projector_on
- binary_sensor.is_everybody_sleeping and not binary_sensor.projector_on
bravia_theatre:
module: smartswitch
class: SmartSwitch
entity: media_player.bravia_theatre
smart_conditions: binary_sensor.vertex2_has_input_signal
w4000i:
module: smartswitch
class: SmartSwitch
entity: media_player.benq_w4000i
smart_conditions: binary_sensor.vertex2_has_input_signal
mezzanine_curtains:
module: smartshutter
class: SmartShutter
entity: cover.salon_mezzanine_curtain
close_conditions:
trigger_conditions: binary_sensor.projector_on or not binary_sensor.projector_off_for_a_while
disable_conditions: self == 'closed' and (binary_sensor.is_dark_outside or binary_sensor.is_anybody_sleeping)
living_room_sleep_switch:
module: smartswitch
class: SmartSwitch
entity: input_boolean.living_room_sleeping
toggle_events:
button_press:
event_name: state_changed
debounce_seconds: 0.5
event_data:
entity_id: event.living_room_bilresa_button_bouton_2
new_state:
attributes:
event_type: multi_press_1

View File

@@ -19,9 +19,9 @@ class SmartHeatpump(SmartObject):
if 'quick_resume' in self.args: if 'quick_resume' in self.args:
self.quick_resume_sensor = self.create_entity(self.args['quick_resume']['sensor']) self.quick_resume_sensor = self.create_entity(self.args['quick_resume']['sensor'])
#is_on = 'on' if self.entity.get_state() != 'off' else 'off' is_on = 'on' if self.entity.get_state() != 'off' else 'off'
#if self.get_state(self.args['quick_resume']['sensor']) != 'on': # if the sensor doesn't exist I wan't to set it as well if self.get_state(self.args['quick_resume']['sensor']) != 'on': # if the sensor doesn't exist I wan't to set it as well
# self.set_state(self.args['quick_resume']['sensor'],state = is_on) self.set_state(self.args['quick_resume']['sensor'],state = is_on)
self.register_reset_events(self.args['quick_resume'], "quick_resume") self.register_reset_events(self.args['quick_resume'], "quick_resume")
self.target_temperature = SmartValue.Evaluator(self,self.args['target_temperature'], expression_name = 'target_temperature', on_change_cb = self.on_change_target_temperature, constants = self.constants) self.target_temperature = SmartValue.Evaluator(self,self.args['target_temperature'], expression_name = 'target_temperature', on_change_cb = self.on_change_target_temperature, constants = self.constants)

View File

@@ -114,6 +114,8 @@ from ad_toolbox.eventhandler import EventHandler
class SmartLight(SmartSwitch): class SmartLight(SmartSwitch):
def on_initialize_smart_object(self): def on_initialize_smart_object(self):
self.presence_simulation_slots_active = set()
# light_brightness_pct_list : ordered list of (Evaluator, label) pairs # light_brightness_pct_list : ordered list of (Evaluator, label) pairs
# light_brightness_pct : currently active brightness % (str label) # light_brightness_pct : currently active brightness % (str label)
self.light_brightness_pct_list = list() self.light_brightness_pct_list = list()
@@ -196,6 +198,15 @@ class SmartLight(SmartSwitch):
# SmartSwitch overrides # SmartSwitch overrides
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# While simulation is active for this light, ignore auto-off transitions
# from smart_conditions to keep deterministic on-duration windows.
def on_smart_conditions_change(self,prev_result,result):
if len(self.presence_simulation_slots_active) > 0:
if result == SmartCondition.Result.Succeeded and self.is_off():
self.switch_on()
return
super().on_smart_conditions_change(prev_result,result)
# Override: apply light_brightness_pct when turning on, if one is active. # Override: apply light_brightness_pct when turning on, if one is active.
def switch_on(self): def switch_on(self):
if self.light_brightness_pct != None: if self.light_brightness_pct != None:
@@ -225,4 +236,61 @@ class SmartLight(SmartSwitch):
self.log_info("Turning off") self.log_info("Turning off")
self.call_service("light/turn_off", entity_id = self.entity_id) self.call_service("light/turn_off", entity_id = self.entity_id)
# ------------------------------------------------------------------
# Presence simulation API (called by HomeAlarm)
# ------------------------------------------------------------------
def start_presence_simulation(self, source_app=None, slot_name=None, payload=None):
if slot_name:
self.presence_simulation_slots_active.add(slot_name)
if not isinstance(payload, dict):
payload = {}
brightness_pct = payload.get('brightness_pct')
transition = payload.get('transition')
if brightness_pct is None and transition is None:
self.switch_on()
return
service_kwargs = {'entity_id': self.entity_id}
if brightness_pct is not None:
service_kwargs['brightness_pct'] = brightness_pct
if transition is not None:
service_kwargs['transition'] = transition
self.call_service("light/turn_on", **service_kwargs)
def stop_presence_simulation(self, source_app=None, slot_name=None, payload=None):
if slot_name and slot_name in self.presence_simulation_slots_active:
self.presence_simulation_slots_active.remove(slot_name)
if not isinstance(payload, dict):
payload = {}
if payload.get('keep_on', False):
return
transition = payload.get('transition')
if transition is None:
self.switch_off()
else:
self.call_service(
"light/turn_off",
entity_id=self.entity_id,
transition=transition,
)
# If no simulation slot is still active, re-apply current automation
# state so the light immediately converges to the expected result.
if len(self.presence_simulation_slots_active) == 0 and self.smart_conditions_evaluator:
result = self.smart_conditions_evaluator.evaluate(False)
if result == SmartCondition.Result.Succeeded:
if self.is_off():
self.switch_on()
else:
if self.is_on():
self.switch_off()

247
apps/smartshutter.py Normal file
View File

@@ -0,0 +1,247 @@
import appdaemon.plugins.hass.hassapi as hass
import ad_toolbox.smartcondition as SmartCondition
from ad_toolbox.smartobject import SmartObject
import time
class SmartShutter(SmartObject):
#@SmartCondition.catch_smartcondition_exception(lambda self, message: self.log_error(message,stop_app = True))
def on_initialize_smart_object(self):
#super().initialize()
self.slow_transition = False
self.slow_transition_start_time = 0
self.slow_transition_cb = None
self.target_position = None
self.target_position_reach_time = None
self.ready = False
self.open_conditions = None
self.close_conditions = None
if "auto_switch" in self.args:
self.auto_switch = self.args['auto_switch']
self.listen_state(self.on_auto_switch_on,self.auto_switch, old = 'off', new = 'on')
else:
self.auto_switch = None
if "open_position" in self.args:
try: self.open_position = int(self.args["open_position"])
except:
self.open_position_sensor = self.args["open_position"]
self.open_position = int(float(self.get_state(self.open_position_sensor)))
self.log(f"Registering sensor {self.open_position_sensor} for open position")
self.listen_state(self.on_open_position_change,self.open_position_sensor)
else:
self.open_position = 100
if "close_position" in self.args:
try: self.close_position = int(self.args["close_position"])
except:
self.close_position_sensor = self.args["close_position"]
self.close_position = int(float(self.get_state(self.close_position_sensor)))
self.log(f"Registering sensor {self.close_position_sensor} for close position")
self.listen_state(self.on_close_position_change,self.close_position_sensor)
else:
self.close_position = 0
#it need to be called before on_update
if "sleeping_switch" in self.args:
self.listen_state(self.on_stop_sleeping,self.args['sleeping_switch'], old = 'on', new = 'off')
extra_entities_to_listen = list()
if "emergency_close_conditions" in self.args:
self.emergency_close_conditions = SmartCondition.Evaluator(self,self.args["emergency_close_conditions"], condition_name = "emergency_close_conditions", templates_library = self.templates_library, constants = self.constants)
extra_entities_to_listen.extend(self.emergency_close_conditions.get_entities_to_listen_as_list())
else:
self.emergency_close_conditions = None
if 'close_conditions' in self.args:
self.close_conditions = SmartCondition.Evaluator(self,self.args["close_conditions"], condition_name = "close_conditions", templates_library = self.templates_library,on_update_cb = self.on_update,extra_entities_to_listen = extra_entities_to_listen, log_callback_trigger_reason = False, constants = self.constants)
if 'open_conditions' in self.args:
self.log("Both a close_conditions and an open_conditions has been defined, please use only one",level = 'ERROR')
elif 'open_conditions' in self.args:
self.open_conditions = SmartCondition.Evaluator(self,self.args["open_conditions"], condition_name = "open_conditions", templates_library = self.templates_library,on_update_cb = self.on_update,extra_entities_to_listen = extra_entities_to_listen, log_callback_trigger_reason = False, constants = self.constants)
else:
self.log("No close_conditions nor open_conditions defined",level = 'ERROR')
#for object that take a long time to change state, we need reevalute desired state once the state has changed
#in case the conditions change during the state change
#in case of shutter we can probably improve that and cancel the state change has it is updating
# it seems to messed up with the auto switch and I'm not sure it's relevant anyway
#self.listen_state(self.on_state_change,self.entity_id)
self.listen_state(self.on_position_change,self.entity_id,attribute = 'current_position')
self.ready = True
self.on_update()
def terminate(self):
self.emergency_close_conditions = None
self.close_conditions = None
self.open_conditions = None
super().terminate()
def on_state_change(self, entity, attribute, old, new, kwargs):
if self.ready == False:
self.log(f"on_state_change called before the app is ready",level = "WARNING")
return
#for object that take a long time to change state, we need reevalute desired state once the state has changed
#in case the conditions change during the state change
#in case of shutter we can probably improve that and cancel the state change has it is updating
if old != new and not self.slow_transition:
self.log("state changed from " + str(old) + " to " + str(new))
self.on_update()
def on_position_change(self, entity, attribute, old, new, kwargs):
if self.ready == False:
self.log(f"on_position_change called before the app is ready",level = "WARNING")
return
if self.target_position == None:
if self.target_position_reach_time != None and time.time() - self.target_position_reach_time < 60:
self.log(f"A position changed ({old} -> {new}) occured {time.time() - self.target_position_reach_time}s after a requested position change has been complete. This won't be considered as a manual command")
else:
self.log(f"Manual command has been received {old} -> {new}")
if self.close_conditions: smart_conditions = self.close_conditions
else: smart_conditions = self.open_conditions
if self.auto_switch and smart_conditions.evaluate(False) != SmartCondition.Result.Disabled:
self.set_state(self.auto_switch,state = "off")
else:
if self.target_position == new:
self.log(f"Movement completed old: {old}, new: {new}, target_position: {self.target_position}, current_position: {self.get_state(self.entity_id,attribute = 'current_position')}")
self.target_position = None
self.target_position_reach_time = time.time()
def on_open_position_change(self, entity, attribute, old, new, kwargs):
if self.ready == False:
self.log(f"on_open_position_change called before the app is ready",level = "WARNING")
return
if new != self.open_position:
self.open_position = int(float(new))
if abs(self.open_position - int(float(self.get_state(self.entity_id,attribute = "current_position")))) >= 5 or self.open_position == 100:
self.on_update()
def on_close_position_change(self, entity, attribute, old, new, kwargs):
if self.ready == False:
self.log(f"on_close_position_change called before the app is ready",level = "WARNING")
return
if new != self.close_position:
self.close_position = int(float(new))
if abs(self.close_position - int(float(self.get_state(self.entity_id,attribute = "current_position")))) >= 5 or self.close_position == 0:
self.on_update()
def on_stop_sleeping(self, entity, attribute, old, new, kwargs):
if self.get_state('sun.sun') != 'below_horizon':
self.slow_transition = True
self.slow_transition_start_time = self.datetime()
self.on_update()
def on_auto_switch_on(self, entity, attribute, old, new, kwargs):
self.on_update()
def is_auto_switch_on(self):
if self.auto_switch:
return self.get_state(self.auto_switch) == 'on'
else:
return True
def calculate_transition_position(self):
time_since_transition_start = self.datetime() - self.slow_transition_start_time
time_since_transition_start.total_seconds()
return min((time_since_transition_start.total_seconds() / 10) + 10,self.open_position)
def on_transition_update(self, kwargs):
self.slow_transition_cb = None
if self.slow_transition: self.on_update()
def stop_slow_transition(self):
self.slow_transition = False
if self.slow_transition_cb:
self.cancel_timer(self.slow_transition_cb)
self.slow_transition_cb = None
def on_retry_update(self, kwargs):
self.log("on_retry_update")
self.on_update()
def on_update(self):
def log_evaluation_result(smart_conditions):
smart_conditions.log_callback_trigger_reason()
smart_conditions.log_evaluation_result()
if self.emergency_close_conditions and self.emergency_close_conditions.evaluate(False) == SmartCondition.Result.Succeeded:
if self.get_state(self.entity_id) != 'closed':
log_evaluation_result(self.emergency_close_conditions)
self.log("Closing shutter (Emergency close)")
self.call_service("cover/close_cover", entity_id = self.entity_id)
if self.auto_switch: self.set_state(self.auto_switch,state = "on")
elif self.is_auto_switch_on():
if self.close_conditions: smart_conditions = self.close_conditions
else: smart_conditions = self.open_conditions
if smart_conditions: result = smart_conditions.evaluate(False)
else:
# it seems sometimes during HA reboot, the CB is called and both close_conditions and open_conditions are equal to None
# this should probably require more investigation
self.log_warning(f"Both close_conditions and open_conditions are None")
return
if result != SmartCondition.Result.Disabled:
if self.get_state(self.entity_id,attribute = "current_position") != None:
if self.close_conditions != None:
should_be_close = (result == SmartCondition.Result.Succeeded)
else:
should_be_close = (result == SmartCondition.Result.Failed)
current_position = int(self.get_state(self.entity_id,attribute = "current_position"))
current_state = self.get_state(self.entity_id)
#if current_position > 20 and should_be_close:
if should_be_close:
if self.close_position > 0:
if current_position != self.close_position:
log_evaluation_result(smart_conditions)
self.target_position = self.close_position
self.log("Closing shutter at " + str(self.close_position) + " (previous position was " + str(current_position) + ")")
self.call_service("cover/set_cover_position", entity_id = self.entity_id,position = self.close_position)
elif current_state != 'closed':
log_evaluation_result(smart_conditions)
self.target_position = self.close_position
self.log(f"Closing shutter, state was {current_state}")
self.call_service("cover/close_cover", entity_id = self.entity_id)
self.stop_slow_transition()
elif abs(current_position - self.open_position) > 5 and not should_be_close:
self.target_position = self.open_position
if self.slow_transition:
if self.slow_transition_cb == None:
wanted_position = self.calculate_transition_position()
if wanted_position > 60:
self.call_service("cover/open_cover", entity_id = self.entity_id)
self.stop_slow_transition()
else:
self.call_service("cover/set_cover_position", entity_id = self.entity_id,position = wanted_position)
self.slow_transition_cb = self.run_in(self.on_transition_update, 120)
else:
self.log(f"Update ignored becaused we are waiting for a slow transition CB")
else:
if self.open_position == 100:
self.log("Opening shutter")
log_evaluation_result(smart_conditions)
self.call_service("cover/open_cover", entity_id = self.entity_id)
else:
log_evaluation_result(smart_conditions)
self.log("Opening shutter at " + str(self.open_position) + " (previous position was " + str(current_position) + ")")
self.call_service("cover/set_cover_position", entity_id = self.entity_id,position = self.open_position)
else : self.run_in(self.on_retry_update,60) #during HASS restart, shutter can be irresponsive untill the z-wave network is properly initialized
else:
log_evaluation_result(smart_conditions)
self.stop_slow_transition()

646
apps/sonoscontroller.py Normal file
View File

@@ -0,0 +1,646 @@
import appdaemon.plugins.hass.hassapi as hass
from ad_toolbox.smartobject import SmartObject
import ad_toolbox.smartcondition as SmartCondition
import time
from ad_toolbox.virtualsensors import ValueSelector
from ad_toolbox.eventhandler import EventHandler
MIN_TIME_BETWEEN_GROUP_COMMANDS = 5
class PlayerDataSet:
class VolumeSlot:
def __init__(self,context,volume):
self.context = list()
self.context.extend(context)
self.volume = volume
def __init__(self):
self.volume_slots = list()
def get_volume_slots_as_string(self):
def get_context_as_string(context):
string = ""
for item in context:
string += f"{item[0]}={item[1]}, "
return string[:-2]
string = "\n"
for slot in self.volume_slots:
string = string + f"[{get_context_as_string(slot.context)}] {slot.volume}\n"
if string == "\n": string = "No slot saved"
else : string = string[:-1]
return string
def get_volume(self,controller,entity_list, logger = None):
context = list()
for entity in entity_list:
context.append((entity,controller.get_state(entity)))
if logger:
logger(f"context = {context}")
#search for a similar context
for slot in self.volume_slots:
if slot.context == context:
if logger:
logger(f"Mathing context = {slot.context}")
logger(f"Mathing volume = {slot.volume}")
return slot.volume
return -1.0
def save_volume(self,controller,entity_list, volume):
context = list()
for entity in entity_list:
context.append((entity,controller.get_state(entity)))
#search for a similar context
for slot in self.volume_slots:
if slot.context == context:
slot.volume = volume
return
self.volume_slots.append(PlayerDataSet.VolumeSlot(context,volume))
def clean(self,entity_list):
for slot in self.volume_slots:
if slot.volume == None:
self.volume_slots.remove(slot)
self.clean(entity_list)
return
if len(entity_list) != len(slot.context):
self.volume_slots.remove(slot)
self.clean(entity_list)
return
for i in range(0,len(slot.context)):
if slot.context[i][0] != entity_list[i]:
self.volume_slots.remove(slot)
self.clean(entity_list)
return
class ControllerDataSet:
def __init__(self):
self.players_dataset = dict()
class SonosPlayer:
class VolumeTransition():
def __init__(self,current_volume,target_volume,duration):
assert current_volume != target_volume
self.start_time = time.time()
self.end_time = self.start_time + duration
self.start_volume = current_volume
self.target_volume = target_volume
def __str__(self):
return "[" + str(self.start_volume) + " => " + str(self.target_volume) + "] step = " + str(self.get_transition_step())
def get_transition_volume(self):
if time.time() > self.end_time:
return self.target_volume
else:
time_elapse = time.time() - self.start_time
percent = time_elapse / (self.end_time - self.start_time)
return self.start_volume + percent*(self.target_volume - self.start_volume)
def get_transition_step(self):
ideal_number_of_step = abs(self.target_volume - self.start_volume) / 0.01
step = (self.end_time - self.start_time) / ideal_number_of_step if ideal_number_of_step > 0 else 0
return max(1.0,round(step,2))
def should_stop_transition(self,old,new):
if old and new:
if self.target_volume > self.start_volume:
if new >= self.target_volume: return True
if new < self.start_volume: return True
if old > new : return True #we are decreasing volume while the transition is increasing
elif self.target_volume < self.start_volume:
if new <= self.target_volume: return True
if new > self.start_volume: return True
if old < new : return True #we are increasing volume while the transition is decreasing
return False
class VolumeContextData():
def __init__(self,entity,transition = 0,force_volume = -1):
self.entity = entity
self.force_volume = force_volume
self.transition = transition
def __init__(self,controller, player_name, yaml_conf,dataset : PlayerDataSet):
self.controller = controller
self.player_name = player_name
self.group_master = None
self.dataset = dataset
if "entity" in yaml_conf:
self.entity_id = yaml_conf["entity"]
self.log(f"Initializing {player_name} with entity {self.entity_id}")
else:
self.entity_id = None
self.log_error(f"Can't initialize {player_name}, not entity was configured",True)
if "status_light" in yaml_conf:
self.status_light_entity = yaml_conf["status_light"]["entity"]
self.light_entity_conditions_evaluator = SmartCondition.Evaluator(self.controller,yaml_conf['status_light']["conditions"],constants = { 'self' : self.status_light_entity, 'super' : self.entity_id},condition_name = 'status_light',on_change_cb = self.on_change_status_light_conditions)
if "audio_delay" in yaml_conf:
# self.audio_delay_entity = yaml_conf["status_light"]["audio_delay"]
self.log(f"Creating a ValueSelector for the audio_delay")
self.audio_delay_selector = ValueSelector(self.controller,self.controller,sensor_name = yaml_conf["audio_delay"]["entity"],super_entity_id = self.entity_id,yaml_block = yaml_conf["audio_delay"]['value'],self_initialize = True)
self.is_playing_podcast_sensor = f'binary_sensor.{player_name}_is_playing_podcast'
is_playing_podcast_sensor_entity = self.controller.get_entity(self.is_playing_podcast_sensor)
if not is_playing_podcast_sensor_entity.exists():
is_playing_podcast_sensor_entity.add()
self.controller.listen_state(self.on_state_change,self.entity_id)
self.controller.listen_state(self.on_update_media_artist,self.entity_id,attribute = "media_artist")
self.update_is_playing_podcast_sensor()
if "leave_conditions" in yaml_conf:
self.leave_conditions_evaluator = SmartCondition.Evaluator(self.controller,yaml_conf["leave_conditions"],constants = { 'self' : self.entity_id},condition_name = player_name,on_update_cb = self.on_update_leave_condition, log_callback_trigger_reason = False ,extra_entities_to_listen = "sensor.ad_number_of_music_player_active")
self.on_update_leave_condition()
else:
self.leave_conditions_evaluator = None
if "join_conditions" in yaml_conf:
self.join_conditions_evaluator = SmartCondition.Evaluator(self.controller,yaml_conf["join_conditions"],constants = { 'self' : self.entity_id},condition_name = player_name,on_update_cb = self.on_update_join_condition, log_callback_trigger_reason = False, extra_entities_to_listen = "sensor.ad_number_of_music_player_active")
self.on_update_join_condition()
else:
self.join_conditions_evaluator = None
self.off_conditions_evaluator = SmartCondition.Evaluator(self.controller,yaml_conf["off_conditions"],constants = { 'self' : self.entity_id},condition_name = player_name,on_update_cb = self.on_update_off_conditions, log_callback_trigger_reason = False)
self.on_update_off_conditions()
#Volume controller stuffs
self.volume_context_data = list()
self.volume_context_entities = list()
self.volume_transition = None
if 'volume_controller' in yaml_conf:
volume_controller_conf = yaml_conf['volume_controller']
if "context_entities" in volume_controller_conf:
for element in volume_controller_conf["context_entities"]:
if "entity" in element: entity = element["entity"]
else: self.log_error("Not entity defined")
if "transition" in element: transition = element["transition"]
else: transition = 0
if "force_volume" in element: force_volume = element["force_volume"]
else : force_volume = -1
self.log(str(entity) + " : transition = " + str(transition) + ", force_volume = " + str(force_volume))
if force_volume < 0: self.volume_context_entities.append(entity)
self.volume_context_data.append(SonosPlayer.VolumeContextData(entity,transition,force_volume))
if len(self.volume_context_entities) > 0:
#I don't want the context save to be dependent of the order of the yaml file
self.volume_context_entities.sort()
for element in self.volume_context_data:
self.log(f"adding context for {element.entity}")
self.controller.listen_state(self.on_volume_context_change,element.entity)
self.dataset.clean(self.volume_context_entities)
self.log(f"Volumes slots: {self.dataset.get_volume_slots_as_string()}")
self.controller.listen_state(self.on_volume_change,self.entity_id,attribute = "volume_level")
#self.controller.listen_state(self.on_state_change,self.entity_id,new = 'playing')
#self.controller.listen_state(self.on_state_change,self.entity_id,old = 'playing')
if self.is_playing():
self.update_volume()
else: self.log("Not context entities defined")
# --- Playback event handlers ---
self._current_radio_index = -1
self._current_album_index = -1
if 'play_stop_events' in yaml_conf:
self._play_stop_event_handler = EventHandler(self.controller, yaml_conf['play_stop_events'], self.on_play_stop_event)
if 'cycle_radio_events' in yaml_conf:
self._cycle_radio_event_handler = EventHandler(self.controller, yaml_conf['cycle_radio_events'], self.on_cycle_radio_event)
if 'cycle_album_events' in yaml_conf:
self._cycle_album_event_handler = EventHandler(self.controller, yaml_conf['cycle_album_events'], self.on_cycle_album_event)
def get_player_id(self): return self.entity_id
def set_group_master(self,player_id): self.group_master = player_id
def log(self,message,**kwargs):
self.controller.log(f"[{self.player_name}]{message}",**kwargs)
def stop_playback(self):
# if self.get_number_of_sonos_player_active() > 1:
# self.leave_group()
# else:
self.log(f"{self.entity_id} is pausing the playback")
self.controller.call_service("media_player/media_pause", entity_id = self.entity_id)
# -----------------------------------------------------------------------
# Playback event handlers
# -----------------------------------------------------------------------
def _browse_media(self, media_content_type, media_content_id):
"""Call browse_media and return the children list, or [] on error."""
try:
result = self.controller.call_service(
"media_player/browse_media",
entity_id=self.entity_id,
media_content_type=media_content_type,
media_content_id=media_content_id,
)
if result:
browse_data = (result.get("result") or {}).get("response") or {}
entity_data = browse_data.get(self.entity_id) or {}
return entity_data.get("children") or []
except Exception as e:
self.log(f"_browse_media({media_content_type}) error: {e}", level="WARNING")
return []
def _fetch_sonos_favorites(self):
"""Browse the Sonos favorites tree and return a flat list of individual
favorites. Each item is tagged with '_is_radio' (True/False).
The Sonos integration organises favorites into typed folders
(media_content_type='favorites_folder'). We browse into each folder
and tag its children:
- folders whose media_content_id contains 'audioBroadcast' → radio
- all other folders → album/playlist
If the response is already a flat list (no folders), we fall back to
tagging by can_expand (False → radio, True → album).
"""
folders = self._browse_media("favorites", "")
items = []
for folder in folders:
if folder.get("media_content_type") == "favorites_folder":
folder_id = folder.get("media_content_id", "")
is_radio = "audioBroadcast" in folder_id or "audioItem" in folder_id
children = self._browse_media(folder["media_content_type"], folder_id)
for child in children:
tagged = dict(child)
tagged["_is_radio"] = is_radio
items.append(tagged)
else:
tagged = dict(folder)
tagged["_is_radio"] = not folder.get("can_expand", False)
items.append(tagged)
self.log(f"_fetch_sonos_favorites: {len(items)} items "
f"({sum(1 for f in items if f['_is_radio'])} radio, "
f"{sum(1 for f in items if not f['_is_radio'])} albums)")
return items
def _play_favorite(self, fav):
"""Play a Sonos favourite by name via select_source."""
title = fav.get("title", "")
self.log(f"playing favourite '{title}' via select_source")
self.controller.call_service(
"media_player/select_source",
entity_id=self.entity_id,
source=title,
)
def on_play_stop_event(self, event_name, event_data, context):
"""Toggle play/pause. If the player is idle/off, start playback from
the first Sonos favourite fetched via browse_media."""
player_state = self.controller.get_state(self.entity_id)
self.log(f"play_stop_event: player_state = {player_state}")
if player_state == 'playing':
self.log("play_stop_event: pausing playback")
self.controller.call_service("media_player/media_pause", entity_id=self.entity_id)
elif player_state == 'paused':
self.log("play_stop_event: resuming playback")
self.controller.call_service("media_player/media_play", entity_id=self.entity_id)
else:
favs = self._fetch_sonos_favorites()
if favs:
self._play_favorite(favs[0])
else:
self.log("play_stop_event: no favourites available", level="WARNING")
def on_cycle_radio_event(self, event_name, event_data, context):
"""Cycle through Sonos radio favourites (folders tagged as audioBroadcast)."""
radios = [f for f in self._fetch_sonos_favorites() if f.get("_is_radio")]
if not radios:
self.log("cycle_radio_event: no radio favourites found", level="WARNING")
return
self._current_radio_index = (self._current_radio_index + 1) % len(radios)
fav = radios[self._current_radio_index]
self.log(f"cycle_radio_event: '{fav.get('title')}' (index {self._current_radio_index}/{len(radios)})")
self._play_favorite(fav)
def on_cycle_album_event(self, event_name, event_data, context):
"""Cycle through Sonos album/playlist favourites (all non-radio folders)."""
albums = [f for f in self._fetch_sonos_favorites() if not f.get("_is_radio")]
if not albums:
self.log("cycle_album_event: no album/playlist favourites found", level="WARNING")
return
self._current_album_index = (self._current_album_index + 1) % len(albums)
fav = albums[self._current_album_index]
self.log(f"cycle_album_event: '{fav.get('title')}' (index {self._current_album_index}/{len(albums)})")
self._play_favorite(fav)
def on_change_status_light_conditions(self,prev_result,result):
if result == SmartCondition.Result.Succeeded:
self.controller.turn_on(self.status_light_entity)
else:
self.controller.turn_off(self.status_light_entity)
def is_playing(self):
player_state = self.controller.get_state(self.entity_id)
#self.log(f"Current state = {player_state}")
return player_state == 'playing'
def on_update_join_condition(self,*args):
if self.join_conditions_evaluator.evaluate(False) == SmartCondition.Result.Succeeded:
if self.controller.get_number_of_players_active() >= 1 and not self.is_playing() and self.group_master == None:
self.join_conditions_evaluator.log_callback_trigger_reason()
self.join_conditions_evaluator.log_evaluation_result()
if self.controller.can_send_group_command():
self.controller.join_group(self.entity_id)
else:
self.log(f"A group command has been send recently, trying again in 2s")
self.controller.run_in(self.on_update_join_condition,2)
def on_update_leave_condition(self,*args): #*args is needed because the function is also use as a run_in cb
if self.leave_conditions_evaluator.evaluate(False) == SmartCondition.Result.Succeeded:
if self.group_master != None:
self.leave_conditions_evaluator.log_callback_trigger_reason()
self.leave_conditions_evaluator.log_evaluation_result()
if self.controller.can_send_group_command():
self.controller.leave_group(self.entity_id)
else:
self.log(f"A group command has been send recently, trying again in 2s")
self.controller.run_in(self.on_update_leave_condition,2)
def on_state_change(self, entity, attribute, old, new, *kwargs):
self.log(f"on_state_change, entity = {entity}, attribute = {attribute}, old = {old}, new = {new}")
if old != new and self.is_playing():
transition_time = 5 if self.get_desired_volume() > self.controller.get_state(self.entity_id, attribute="volume_level") else 0
self.update_volume(transition_time)
self.update_is_playing_podcast_sensor()
def on_update_media_artist(self, entity, attribute, old, new, *kwargs):
#self.log(f"on_update_media_artist, entity = {entity}, attribute = {attribute}, old = {old}, new = {new}")
self.update_is_playing_podcast_sensor()
def update_is_playing_podcast_sensor(self):
if self.is_playing_podcast_sensor and self.is_playing():
prev_state = self.controller.get_state(self.is_playing_podcast_sensor)
if self.controller.get_state(self.entity_id,attribute = "media_artist") == None:
if prev_state != 'on':
self.log(f"setting {self.is_playing_podcast_sensor} to on")
self.controller.set_state(self.is_playing_podcast_sensor,state = "on")
else:
if prev_state != 'off':
self.log(f"setting {self.is_playing_podcast_sensor} to off")
self.controller.set_state(self.is_playing_podcast_sensor,state = "off")
def on_update_off_conditions(self):
if self.off_conditions_evaluator.evaluate(False) == SmartCondition.Result.Succeeded:
if self.is_playing():
self.off_conditions_evaluator.log_callback_trigger_reason()
self.off_conditions_evaluator.log_evaluation_result()
self.stop_playback()
def is_volume_forced(self):
for element in self.volume_context_data:
if element.force_volume >= 0 and self.controller.get_state(element.entity) != "off": return True
return False
def get_force_volume(self):
for element in self.volume_context_data:
if element.force_volume >= 0 and self.controller.get_state(element.entity) != "off": return element.force_volume
return -1.0
def is_volume_transitioning(self):
return self.volume_transition != None #and round(float(self.volume_transition.target_volume),2) != round(float(self.controller.get_state(self.entity_id, attribute="volume_level")),2)
def cancel_volume_transition(self):
self.volume_transition = None
def on_update_volume_transition(self, kwargs):
if self.volume_transition:
transition_step = self.volume_transition.get_transition_step()
if self.is_playing() == False:
self.cancel_volume_transition()
else:
self.controller.call_service("media_player/volume_set", entity_id = self.entity_id, volume_level = self.volume_transition.get_transition_volume())
self.controller.run_in(self.on_update_volume_transition, transition_step)
def on_volume_context_change(self, entity, attribute, old, new, *kwargs):
if old != new:
if self.is_playing():
self.log(str(entity) + " state changed from " + str(old) + " to " + str(new) + ", attribute = " + str(attribute))
transition_time = 0.0
for element in self.volume_context_data:
if element.entity == entity:
transition_time = element.transition
self.update_volume(transition_time)
def on_volume_change(self, entity, attribute, old, new, *kwargs):
if new != old and new != None and self.is_playing():
self.log("Volume changed from " + str(old) + " to " + str(new))
if self.is_volume_transitioning():
self.log("Volume Transition active: " + str(self.volume_transition))
if self.volume_transition.should_stop_transition(old,new):
self.log("Volume Transition Stopped")
self.cancel_volume_transition()
if not self.is_volume_forced(): self.dataset.save_volume(self.controller,self.volume_context_entities,new)
elif not self.is_volume_forced():
self.dataset.save_volume(self.controller,self.volume_context_entities,new)
def get_desired_volume(self):
desired_volume = self.get_force_volume()
if desired_volume < 0: desired_volume = self.dataset.get_volume(self.controller,self.volume_context_entities,self.log)
else: self.log(f"volume has been forced too {desired_volume}")
return desired_volume
def update_volume(self, transition_time = 0.0):
desired_volume = self.get_desired_volume()
current_volume = self.controller.get_state(self.entity_id, attribute="volume_level")
if desired_volume < 0:
#no context saved, return current volume
self.log("No context was found, creating a new one",level = "WARNING")
if current_volume != None and not self.is_volume_forced():
self.dataset.save_volume(self.controller,self.volume_context_entities,current_volume)
elif desired_volume != current_volume:
if transition_time > 0:
self.volume_transition = SonosPlayer.VolumeTransition(current_volume,desired_volume,transition_time)
self.log("Request transition to " + str(desired_volume))
self.controller.run_in(self.on_update_volume_transition, 1)
else:
self.log("Request Volume changed to " + str(desired_volume))
self.cancel_volume_transition()
self.controller.call_service("media_player/volume_set", entity_id = self.entity_id, volume_level = desired_volume)
elif self.volume_transition: #the current_volume is equal to the desired_volume and we are in the middle of a transition
self.cancel_volume_transition()
class SonosController(SmartObject):
GROUP_ATTRIBUTE = "group_members"
#@SmartCondition.catch_smartcondition_exception(lambda self, message: self.log_error(message,stop_app = True))
def on_initialize_smart_object(self):
#super().initialize()
self.players = dict()
self.players_groups = dict()
self.group_command_time = 0
#self.depends_on_module("virtualsensors")
#Creating a new dataset if nothing has been deserialized from initialize
if self.dataset == None:
self.dataset = ControllerDataSet()
if "players" in self.args:
for player in self.args['players']:
self.log(f"Creating player {player}")
player_id = self.args['players'][player]["entity"]
if not player_id in self.dataset.players_dataset:
self.log(f"Creating dataset for {player_id}")
self.dataset.players_dataset[player_id] = PlayerDataSet()
self.players[player_id] = SonosPlayer(self,player,self.args['players'][player],self.dataset.players_dataset[player_id])
self.listen_state(self.on_sonos_group_changed,player_id,attribute = self.GROUP_ATTRIBUTE)
self.update_player_group(player_id)
self.log_groups()
else:
self.log(f"No players found",level ="ERROR")
def update_player_group(self,player_id):
self.log(f"[{player_id}]updating players group")
if player_id != None:
players_list = self.get_state(player_id, attribute=self.GROUP_ATTRIBUTE)
if players_list:
self.add_player_to_group(player_id,players_list[0])
def add_player_to_group(self,player_id,group_id):
group_changed = False
group_master = None
#let's confirm the group_master know me
players_list = self.get_state(group_id, attribute=self.GROUP_ATTRIBUTE)
if player_id != group_id and not player_id in players_list:
# sometimes, some player think they belong to a group, but the group master doesn't know them
# in such case I send them back to their own group
self.log(f"{group_id} doesn't have {player_id} in it's group, although {player_id} seems to think otherwise")
group_id = player_id
else:
if players_list != None and len(players_list) > 1:
group_master = players_list[0]
self.get_player(player_id).set_group_master(group_master)
#self.set_state(player_id,attributes = {"group_master" : group_master})
empty_group = list()
for current_group_id in self.players_groups:
if current_group_id != group_id and player_id in self.players_groups[current_group_id]:
self.log(f"Removing {player_id} from it's previous group {current_group_id}")
group_changed = True
self.players_groups[current_group_id].remove(player_id)
if len(self.players_groups[current_group_id]) == 0:
empty_group.append(current_group_id)
for group in empty_group:
del self.players_groups[group]
if group_id in self.players_groups:
if not player_id in self.players_groups[group_id]:
self.log(f"Adding {player_id} to group {group_id}")
group_changed = True
self.players_groups[group_id].append(player_id)
else:
self.log(f"Creating group {group_id} and adding {player_id}")
group_changed = True
self.players_groups[group_id] = [player_id]
return group_changed
def get_player(self,player_id : str): return self.players[player_id]
def try_get_player(self,player_id : str):
try: return self.players[player_id]
except KeyError: return None
def log_groups(self):
solo_players = list()
for group in self.players_groups:
if len(self.players_groups[group]) > 1:
self.log(f"Group[{group}] : {self.players_groups[group]}")
else:
solo_players.extend(self.players_groups[group])
if len(solo_players) >= 1:
self.log(f"Solo players : {solo_players}")
def on_sonos_group_changed(self, entity, attribute, old, new, kwargs):
self.log(f"on_sonos_group_changed, entity = {entity}, attribute = {attribute}, old = {old}, new = {new}")
if new and new != old:
if self.add_player_to_group(entity,new[0]):
self.log_groups()
def join_group(self,player_id):
if self.can_send_group_command():
best_group = None
best_group_size = 0
for group_master,group_members in self.players_groups.items():
if self.players[group_master].is_playing(): #TODO : ignore here stand-alone player
group_size = len(group_members)
if group_size > best_group_size:
best_group_size = group_size
best_group = group_master
if best_group:
self.log(f"Join group of {best_group}")
self.call_service("media_player/join", entity_id = best_group, group_members = [ player_id ] )
self.group_command_time = time.time()
return
self.log(f"Couldn't find any group to join",level = "WARNING")
else:
self.log(f"A previous group command has been sent less than {MIN_TIME_BETWEEN_GROUP_COMMANDS}s ago, please wait",level = "WARNING")
def leave_group(self,player_id):
if self.can_send_group_command():
group_master = self.get_player(player_id).group_master
self.log(f"{player_id} is leaving the group of {group_master}")
self.call_service("media_player/unjoin", entity_id = player_id)
self.group_command_time = time.time()
self.get_player(player_id).set_group_master(None)
else:
self.log(f"A previous group command has been sent less than {MIN_TIME_BETWEEN_GROUP_COMMANDS}s ago, please wait",level = "WARNING")
def can_send_group_command(self): return time.time() - self.group_command_time > MIN_TIME_BETWEEN_GROUP_COMMANDS
def get_number_of_players_active(self):
number_of_players_active = 0
for player_id in self.players:
if self.players[player_id].is_playing():
number_of_players_active += 1
return number_of_players_active