Compare commits

..

47 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
6cb86875f1 Implement control mode selector for smart heat pumps and enhance reset event handling 2026-06-03 21:53:32 +02:00
4571c14e91 Add good_bye event to virtual events with occupancy condition 2026-06-03 21:51:05 +02:00
89b08b1fc5 Enhance smart heating configurations to include sleeping conditions and update fan mode handling in SmartHeatpump 2026-05-31 21:50:06 +02:00
fc6673e98b Add notifications manager 2026-05-31 21:49:48 +02:00
728577647a Add off events for bed LED strip to include good morning trigger 2026-05-29 08:47:49 +02:00
aa6fb42dd7 Add bedroom heat pump configuration and update presence conditions for bedroom occupancy 2026-05-27 23:06:16 +02:00
af6abec832 Refactor living room light configurations and enhance blocking conditions for smart lights 2026-05-27 23:06:00 +02:00
4756d1669a Add sleep sensor configurations and update conditions for light controls 2026-05-26 22:02:40 +02:00
b4cbe014c2 Update log file search pattern and add missing packages to system_packages.txt 2026-05-26 22:01:50 +02:00
63386406ce Add light_living_room configuration with toggle and off events 2026-05-26 08:36:49 +02:00
80cecdb9f0 removed q_timeout which prevent appDaemon from starting with latest version 2026-05-26 08:33:06 +02:00
b77fe3b703 Update subproject commit reference in ad_toolbox 2026-05-26 08:32:29 +02:00
cd7b8e21be Add startup instructions for archiving logs script 2026-05-25 21:54:49 +02:00
b27e902b24 Add heating sensor configuration and smart heat pump logic 2026-05-24 22:10:54 +02:00
2c9044553e Enhance motion tracking and occupancy detection with new sensor configurations and improved logic 2026-05-24 22:10:41 +02:00
f483c85502 Increase wake-up event brightness to 100% 2026-05-24 22:09:45 +02:00
41346d59a9 Fix typo in kitchen occupancy sensor reference 2026-05-24 22:08:47 +02:00
3788900afc Update subproject commit reference in ad_toolbox 2026-05-24 22:07:59 +02:00
a555345293 Reduce pre-wakeup callback delay from 5 minutes to 1 minute 2026-05-24 22:03:15 +02:00
28 changed files with 3160 additions and 46 deletions

View File

@@ -12,7 +12,6 @@ appdaemon:
ha_url: http://10.0.0.21:8123 ha_url: http://10.0.0.21:8123
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiZThhYWI2ZTlkNmQ0NWU2YTk1ODU5OWJjOWM3MWJkYiIsImlhdCI6MTc3NjEwOTc0NCwiZXhwIjoyMDkxNDY5NzQ0fQ.BorkjFjWlWCZqnUa9-NMUxGsDiupDoRZ3cEgsmeSofM token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiZThhYWI2ZTlkNmQ0NWU2YTk1ODU5OWJjOWM3MWJkYiIsImlhdCI6MTc3NjEwOTc0NCwiZXhwIjoyMDkxNDY5NzQ0fQ.BorkjFjWlWCZqnUa9-NMUxGsDiupDoRZ3cEgsmeSofM
cert_verify: True cert_verify: True
q_timeout: 1200
ws_timeout: 00:10:00 ws_timeout: 00:10:00
http: http:
url: http://0.0.0.0:5050 url: http://0.0.0.0:5050

View File

@@ -181,7 +181,7 @@ class AlarmClock(SmartObject):
wakeup_in = value wakeup_in = value
if wakeup_in > 0: if wakeup_in > 0:
self.handle_prewakueup_cb = self.run_in(self.on_pre_wakeup, 5 * 60,wakeup_in = wakeup_in - 5) self.handle_prewakueup_cb = self.run_in(self.on_pre_wakeup, 1 * 60,wakeup_in = wakeup_in - 1)
wakeup_message = "On se reveil dans " + str(wakeup_in) + " minutes" wakeup_message = "On se reveil dans " + str(wakeup_in) + " minutes"

View File

@@ -60,3 +60,14 @@ informations_collector:
# - group.dumb_lights # - group.dumb_lights
# output_sensor: sensor.ad_number_of_lights_on # output_sensor: sensor.ad_number_of_lights_on
haw_switch:
module: smartswitch
class: SmartSwitch
entity: input_boolean.haw
auto_switch_off_after: 28800 # 8h
off_events:
- WAKEUP

175
apps/global/heating.yaml Normal file
View File

@@ -0,0 +1,175 @@
heating_sensors:
module: virtualsensors
class: VirtualSensorsApp
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:
binary_sensor.heatpump_winter_mode:
trigger_conditions: false #sensor.month >= 10 or sensor.month <= 4
value_selector.target_temperature_heatpump:
attributes:
unit_of_measurement: °C
device_class: temperature
icon: mdi:thermometer
values:
winter_mode:
conditions: binary_sensor.heatpump_winter_mode
value: 18
default:
conditions: True
value: input_number.heatpump_temperature
smart_heating_templates_library:
module: templatelibrary
class: TemplateLibrary
priority: 1
templates_library:
clim_conditions:
local_expressions:
temp_threshold: "sleeping_in_the_room ? 0 : (ext_temperature >= 35 ? 0.5 : 1)"
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 self and (ext_temperature >= target_temperature or cant_open_window)
- binary_sensor.heatpump_winter_mode and room_temperature <= (target_temperature - 1) and ext_temperature <= 10
- binary_sensor.heatpump_winter_mode and self and room_temperature < target_temperature
blocking_conditions:
- binary_sensor.is_everybody_sleeping and not sleeping_in_the_room and not stay_on_while_sleeping
- not room_occupancy and not (sleeping_in_the_room or (occupied_tonight and binary_sensor.day_interval_night))
- not binary_sensor.someone_home
- window_open
desk_heatpump:
module: smartheatpump
class: SmartHeatpump
entity: climate.desk_heatpump
templates_library: smart_heating_templates_library
constants:
window_open: binary_sensor.desk_window
ext_temperature: sensor.ext_front_temperature
room_temperature: sensor.desk_temperature
room_occupancy: binary_sensor.desk_occupancy
target_temperature: sensor.target_temperature_heatpump
cant_open_window: binary_sensor.is_desk_sleeping
sleeping_in_the_room: binary_sensor.is_desk_sleeping
quick_resume: binary_sensor.desk_heatpump_quick_resume
occupied_tonight: input_boolean.desk_occupied_tonight
stay_on_while_sleeping: false
virtual_sensors:
sensors:
value_selector.desk_heatpump_fan_mode:
auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1
quiet: True
target_temperature: sensor.target_temperature_heatpump
hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'"
fan_mode: sensor.desk_heatpump_fan_mode
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
template_conditions: <clim_conditions>
control_mode_selector:
sensor: input_select.desk_heatpump_override
reset_events:
- good_morning
- good_bye
quick_resume:
sensor: binary_sensor.desk_heatpump_quick_resume
reset_events: good_morning
#input_buttons:
# turn_on: input_button.clim_du_bureau_turn_on
# turn_off: input_button.clim_du_bureau_turn_off
bedroom_heatpump:
module: smartheatpump
class: SmartHeatpump
entity: climate.bedroom_heatpump
templates_library: smart_heating_templates_library
constants:
window_open: binary_sensor.bedroom_french_door
ext_temperature: sensor.ext_front_temperature
room_temperature: sensor.bedroom_temperature
room_occupancy: binary_sensor.bedroom_occupancy
target_temperature: sensor.target_temperature_heatpump
cant_open_window: binary_sensor.is_bedroom_sleeping
sleeping_in_the_room: binary_sensor.is_bedroom_sleeping
quick_resume: binary_sensor.bedroom_heatpump_quick_resume
occupied_tonight: input_boolean.bedroom_occupied_tonight
stay_on_while_sleeping: false
virtual_sensors:
sensors:
value_selector.bedroom_heatpump_fan_mode:
auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1
quiet: True
target_temperature: sensor.target_temperature_heatpump # + 0.5
hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'"
fan_mode: sensor.bedroom_heatpump_fan_mode
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
template_conditions: <clim_conditions>
quick_resume:
sensor: binary_sensor.bedroom_heatpump_quick_resume
reset_events: good_morning
control_mode_selector:
sensor: input_select.bedroom_heatpump_override
reset_events:
- good_morning
- good_bye
living_room_heatpump:
module: smartheatpump
class: SmartHeatpump
entity: climate.living_room_heatpump
templates_library: smart_heating_templates_library
virtual_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_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:
window_open: binary_sensor.living_room_any_window_open
ext_temperature: sensor.ext_front_temperature
room_temperature: sensor.living_room_temperature
room_occupancy: binary_sensor.living_room_occupancy
target_temperature: sensor.target_temperature_heatpump
cant_open_window: binary_sensor.living_room_cant_open_window
sleeping_in_the_room: binary_sensor.is_living_room_sleeping
quick_resume: binary_sensor.living_room_heatpump_quick_resume
occupied_tonight: input_boolean.living_room_occupied_tonight
stay_on_while_sleeping: false
target_temperature: sensor.target_temperature_heatpump + 0.5
hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'"
fan_mode: sensor.living_room_heatpump_fan_mode
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
template_conditions: <clim_conditions>
control_mode_selector:
sensor: input_select.living_room_heatpump_override
reset_events:
- good_morning
- good_bye
quick_resume:
sensor: binary_sensor.living_room_heatpump_quick_resume
reset_events: good_morning

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

@@ -0,0 +1,29 @@
notifications_manager:
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:
back_home_notification:
recipients:
away:
services: notify/mobile_app_ipad_pro_11_de_pierre
conditions: input_boolean.haw and person.maeva == 'home' and not binary_sensor.is_bedroom_sleeping
sleeping:
services: notify/mobile_app_ipad_pro_11_de_pierre
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)
message:
content: Bisous
timeout: 600
leave_home_notification:
recipients:
all:
services: notify/mobile_app_macbook_m3_pro_de_pierre
conditions: input_boolean.haw and person.maeva != 'home' and binary_sensor.someone_home
message:
content: Good Bye
timeout: 600

View File

@@ -13,13 +13,24 @@ 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:
motion_sensors: binary_sensor.garage_motion door_generate_motion: true
motion_sensors:
- binary_sensor.garage_motion
door_sensors:
- binary_sensor.garage_entrance
- binary_sensor.garage_door
kitchen: kitchen:
motion_sensors: binary_sensor.kitchen_motion motion_sensors: binary_sensor.kitchen_motion
mezzanine: mezzanine:
motion_sensors: binary_sensor.mezzanine_motion motion_sensors: binary_sensor.mezzanine_motion
living_room: living_room:
update_on_both_front: true
motion_sensors: binary_sensor.living_room_presence_presence motion_sensors: binary_sensor.living_room_presence_presence
occupancy_sensors: occupancy_sensors:
@@ -27,10 +38,25 @@ 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.occupancy_living_room: binary_sensor.living_room_occupancy:
trigger_conditions: sensor.living_room_last_motion <= 5 trigger_conditions: sensor.living_room_last_motion <= 5
binary_sensor.occupancy_kitchen: binary_sensor.kitchen_occupancy:
trigger_conditions: sensor.kitchen_last_motion <= 5 trigger_conditions: sensor.kitchen_last_motion <= 5
binary_sensor.occupancy_restroom: 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:
trigger_conditions: sensor.restroom_last_motion <= 3 trigger_conditions: sensor.restroom_last_motion <= 3
binary_sensor.bedroom_occupancy:
trigger_conditions: binary_sensor.is_bedroom_sleeping or binary_sensor.day_interval_night
blocking_conditions: not binary_sensor.someone_home
retain_condition.desk_occupancy:
retain_time: 5 * 60
conditions:
trigger_conditions:
- sensor.desk_last_motion <= 5
#- binary_sensor.alienware_r10
#- sensor.macbook_m3_pro_de_pierre_frontmost_app != 'loginwindow' and sensor.macbook_m3_pro_de_pierre_displays == 2

18
apps/global/sleep.yaml Normal file
View File

@@ -0,0 +1,18 @@
sleep_sensors:
module: virtualsensors
class: VirtualSensorsApp
priority: 5 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first
sensors:
binary_sensor.is_bedroom_sleeping: input_boolean.bedroom_sleeping
binary_sensor.is_living_room_sleeping: input_boolean.living_room_sleeping
binary_sensor.is_desk_sleeping: input_boolean.desk_sleeping
binary_sensor.is_guestroom_sleeping: input_boolean.guestroom_sleeping
binary_sensor.is_everybody_sleeping:
- (binary_sensor.is_bedroom_sleeping or not input_boolean.bedroom_occupied_tonight) and (binary_sensor.is_living_room_sleeping or not input_boolean.living_room_occupied_tonight) and (binary_sensor.is_desk_sleeping or not input_boolean.desk_occupied_tonight) and (binary_sensor.is_guestroom_sleeping or not input_boolean.guestroom_occupied_tonight)
binary_sensor.is_anybody_sleeping:
- binary_sensor.is_bedroom_sleeping and input_boolean.bedroom_occupied_tonight
- binary_sensor.is_living_room_sleeping and input_boolean.living_room_occupied_tonight
- binary_sensor.is_desk_sleeping and input_boolean.desk_occupied_tonight
- binary_sensor.is_guestroom_sleeping and input_boolean.guestroom_occupied_tonight

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

@@ -7,10 +7,16 @@ virtual_events:
virtual_events: virtual_events:
good_morning: good_morning:
event_name: good_morning event_name: good_morning
event_condition: not input_boolean.sleeping event_condition: not binary_sensor.is_anybody_sleeping
good_bye:
event_name: good_bye
event_condition: not binary_sensor.someone_home
turn_off_all_lights: turn_off_all_lights:
event_name: turn_off_all_lights event_name: turn_off_all_lights
event_condition: input_boolean.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

@@ -0,0 +1,71 @@
virtual_sensors:
module: virtualsensors
class: VirtualSensorsApp
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:
binary_sensor.someone_home: person.maeva == 'home' or person.pierre == 'home' or (input_boolean.use_red_shelly_presence and binary_sensor.red_shelly_home)
binary_sensor.is_dark_outside: sun.sun == 'below_horizon'
event_sensor.red_shelly_home:
retain_time: 120
activation_events:
signal_strenght:
event_name: state_changed
event_data:
entity_id: sensor.shelly_button_red_signal_strength
# living room sensors
value_selector.living_room_ambiance_light:
values:
0:
- not binary_sensor.living_room_occupancy
- binary_sensor.projector_on and not binary_sensor.is_vertex2_content_idle
- light.living_room
- sensor.mezzanine_motion_light_level > 15
- binary_sensor.is_living_room_sleeping
1:
- binary_sensor.is_everybody_sleeping
- binary_sensor.projector_on
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

@@ -29,12 +29,14 @@ import time
# update_on_both_front: true # optional, default false # update_on_both_front: true # optional, default false
# When true, motion is also recorded on the falling edge # When true, motion is also recorded on the falling edge
# (sensor going on→off), not only on the rising edge. # (sensor going on→off), not only on the rising edge.
# While any configured motion sensor remains on, the area
# timestamp is refreshed during the periodic update loop.
# max_time: <minutes> # optional # max_time: <minutes> # optional
# Per-area cap, overrides the app-level max_time. # Per-area cap, overrides the app-level max_time.
# door_sensor: <entity_id> # optional # door_sensors: <entity_id | list of entity_ids> # optional
# Binary sensor for the area door. When the door closes # One or more binary sensors for the area door. When any
# (on→off), the close timestamp is stored and exposed as # door closes (on→off), the close timestamp is stored and
# a sensor (see OUTPUT ENTITIES below). # exposed as a sensor (see OUTPUT ENTITIES below).
# # Shorthand form (no extra options needed): # # Shorthand form (no extra options needed):
# <area_name>: <entity_id | list of entity_ids> # <area_name>: <entity_id | list of entity_ids>
# #
@@ -98,7 +100,7 @@ import time
# - binary_sensor.living_room_motion_1 # - binary_sensor.living_room_motion_1
# - binary_sensor.living_room_motion_2 # - binary_sensor.living_room_motion_2
# max_time: 60 # max_time: 60
# door_sensor: binary_sensor.living_room_door # door_sensors: binary_sensor.living_room_door
# garage: # garage:
# motion_sensors: binary_sensor.garage_motion # motion_sensors: binary_sensor.garage_motion
# update_on_both_front: true # update_on_both_front: true
@@ -150,6 +152,7 @@ class MotionTracker(SmartObject):
self.output_last_motion_sensors = dict() self.output_last_motion_sensors = dict()
self.output_last_motion_time_sensors = dict() self.output_last_motion_time_sensors = dict()
self.output_door_close_time_sensors = dict() self.output_door_close_time_sensors = dict()
self.area_motion_sensors = dict()
self.app_max_time = self.args.get('max_time', self.MAX_TIME) self.app_max_time = self.args.get('max_time', self.MAX_TIME)
self.areas_max_time = dict() self.areas_max_time = dict()
self.update_cb_handle = None self.update_cb_handle = None
@@ -159,16 +162,18 @@ class MotionTracker(SmartObject):
current_time = time.time() current_time = time.time()
for area in self.args['areas']: for area in self.args['areas']:
update_on_both_front = False update_on_both_front = False
door_generate_motion = False
self.output_last_motion_sensors[area] = self.create_entity(f"sensor.{area}_last_motion") self.output_last_motion_sensors[area] = self.create_entity(f"sensor.{area}_last_motion")
self.output_last_motion_time_sensors[area] = self.create_entity(f"sensor.{area}_last_motion_time") self.output_last_motion_time_sensors[area] = self.create_entity(f"sensor.{area}_last_motion_time")
self.area_motion_sensors[area] = list()
if isinstance(self.args['areas'][area], dict): if isinstance(self.args['areas'][area], dict):
sensor_entities = self.args['areas'][area]['motion_sensors'] sensor_entities = self.args['areas'][area]['motion_sensors']
try: update_on_both_front = self.args['areas'][area]['update_on_both_front'] update_on_both_front = self.args['areas'][area].get('update_on_both_front', False)
except KeyError: pass door_generate_motion = self.args['areas'][area].get('door_generate_motion', False)
if 'max_time' in self.args['areas'][area]: if 'max_time' in self.args['areas'][area]:
self.areas_max_time[area] = self.args['areas'][area]['max_time'] self.areas_max_time[area] = self.args['areas'][area]['max_time']
if 'door_sensor' in self.args['areas'][area]: if 'door_sensors' in self.args['areas'][area]:
self.output_door_close_time_sensors[area] = self.create_entity(f"sensor.{area}_door_close_time") self.output_door_close_time_sensors[area] = self.create_entity(f"sensor.{area}_door_close_time")
if not area in self.dataset['areas_door_close_time']: if not area in self.dataset['areas_door_close_time']:
@@ -177,7 +182,13 @@ class MotionTracker(SmartObject):
if not self.output_door_close_time_sensors[area].exists(): if not self.output_door_close_time_sensors[area].exists():
self.output_door_close_time_sensors[area].set_state(state = self.dataset['areas_door_close_time'][area],attributes = {'unit_of_measurement' : "s"}) self.output_door_close_time_sensors[area].set_state(state = self.dataset['areas_door_close_time'][area],attributes = {'unit_of_measurement' : "s"})
self.listen_state(self.on_door_close,self.args['areas'][area]['door_sensor'],new = 'off', old = 'on',area = area) door_sensor_entities = self.args['areas'][area]['door_sensors']
if not isinstance(door_sensor_entities, list):
door_sensor_entities = [door_sensor_entities]
for door_entity in door_sensor_entities:
self.listen_state(self.on_door_close, door_entity, new = 'off', old = 'on', area = area)
if door_generate_motion:
self.listen_state(self.on_motion_detected, door_entity, area = area)
else: else:
sensor_entities = self.args['areas'][area] sensor_entities = self.args['areas'][area]
@@ -233,6 +244,7 @@ class MotionTracker(SmartObject):
self.log(f"Registering sensor {sensor_entity} for area {area}") self.log(f"Registering sensor {sensor_entity} for area {area}")
if sensor_entity not in self.input_sensors: if sensor_entity not in self.input_sensors:
self.input_sensors[sensor_entity] = area self.input_sensors[sensor_entity] = area
self.area_motion_sensors[area].append(sensor_entity)
self.listen_state(self.on_motion_detected,sensor_entity,old = "off", new = "on", area = area) self.listen_state(self.on_motion_detected,sensor_entity,old = "off", new = "on", area = area)
if update_on_both_front: if update_on_both_front:
self.listen_state(self.on_motion_detected,sensor_entity,old = "on", new = "off", area = area) self.listen_state(self.on_motion_detected,sensor_entity,old = "on", new = "off", area = area)
@@ -247,6 +259,11 @@ class MotionTracker(SmartObject):
assert time_elapsed != None assert time_elapsed != None
self.output_last_motion_sensors[area].set_state(state = int(time_elapsed),attributes = {'unit_of_measurement' : "min"}) self.output_last_motion_sensors[area].set_state(state = int(time_elapsed),attributes = {'unit_of_measurement' : "min"})
# Returns True when at least one motion sensor in the area is currently on.
# Used for presence sensors that can remain latched on for long periods.
def is_area_active(self,area):
return any(self.get_state(sensor_entity) == "on" for sensor_entity in self.area_motion_sensors.get(area, []))
# Returns True when the area is listed under # Returns True when the area is listed under
# areas_excluded_from_last_area_with_movement and should not update # areas_excluded_from_last_area_with_movement and should not update
# sensor.last_area_with_movement # sensor.last_area_with_movement
@@ -283,7 +300,11 @@ class MotionTracker(SmartObject):
#For example if A = 5 and B = 6 and add 2 to the both of them starting by A #For example if A = 5 and B = 6 and add 2 to the both of them starting by A
#A will be bigger than B (A = 7 and B = 6) for a very short time, and might trigger some automation #A will be bigger than B (A = 7 and B = 6) for a very short time, and might trigger some automation
for area in sorted(self.dataset['areas_movement_time'], key = lambda area: self.dataset['areas_movement_time'][area]): for area in sorted(self.dataset['areas_movement_time'], key = lambda area: self.dataset['areas_movement_time'][area]):
if (self.is_area_initialized(area)): self.update_area_sensor(area,current_time) if (self.is_area_initialized(area)):
if self.is_area_active(area):
self.dataset['areas_movement_time'][area] = current_time
self.output_last_motion_time_sensors[area].set_state(state = current_time)
self.update_area_sensor(area,current_time)
self.output_last_area_with_movement_sensor.set_state(self.dataset['last_area_with_movement']) self.output_last_area_with_movement_sensor.set_state(self.dataset['last_area_with_movement'])
self.update_cb_handle = self.run_in(self.update_areas_data,30) self.update_cb_handle = self.run_in(self.update_areas_data,30)

View File

@@ -0,0 +1,123 @@
import appdaemon.plugins.hass.hassapi as hass
import appdaemon.exceptions as ad_exception
import ad_toolbox.smartcondition as SmartCondition
import re
import uuid
from expressionparser import ParsingException
class Notification:
ENTITY_PLACEHOLDER_PATTERN = re.compile(r"\{([^{}]+)\}")
def __init__(self,ad_api,config,name):
self.recipients = dict()
self.config = config
self.ad_api = ad_api
try: self.auto_clear = config['message']['auto_clear']
except KeyError: self.auto_clear = False
self.uuid = str(uuid.uuid4())
for recipient in config["recipients"]:
try: self.recipients[recipient] = SmartCondition.Evaluator(self.ad_api,self.config["recipients"][recipient]['conditions'],condition_name = name,on_succeed_cb = self.on_condition_succeed, on_fail_cb = self.on_conditon_fail,pass_condition_name_to_cb = recipient, trigger_callback_on_activation = False)
except ParsingException as e:
self.log_error(str(e))
continue
def on_conditon_fail(self, recipient):
if self.auto_clear:
try: tag = self.config['message']['data']['tag']
except KeyError: tag = self.uuid
self.clear_notification(recipient,tag)
def on_condition_succeed(self,recipient):
self.send_notification(recipient)
def on_timeout_expired(self,kwargs):
tag = kwargs['tag']
recipient = kwargs['recipient']
self.clear_notification(recipient,tag)
def clear_notification(self,recipient,tag):
message = 'clear_notification'
data = { 'tag' : tag }
def clear_notification(service,tag):
self.ad_api.log(f"Clear notification {data} from {service}")
try: self.ad_api.call_service(service,message = message, data = data)
except ad_exception.ServiceException: self.log_error(f"Invalid service: {service}")
recipient_yaml = self.config["recipients"][recipient]
if isinstance(recipient_yaml['services'], str):
clear_notification(recipient_yaml['services'],tag)
else:
for service in recipient_yaml['services']:
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):
try: title_template = self.config['message']['title']
except KeyError: title_template = ""
try: message_content = self.config['message']['content']
except KeyError: message_content = ""
try: data = self.config['message']['data']
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
if not 'tag' in data:
data['tag'] = self.uuid
if 'timeout' in self.config['message']:
timeout = self.config['message']['timeout']
self.ad_api.run_in(self.on_timeout_expired,timeout,tag = data['tag'],recipient = recipient)
def send_notification(service):
self.ad_api.log(f"Sending notification '{message}' to {service}{f' with data {data}' if data else ''}")
try: self.ad_api.call_service(service,title = title,message = message, data = data)
except ad_exception.ServiceException: self.log_error(f"Invalid service: {service}")
recipient_yaml = self.config["recipients"][recipient]
if isinstance(recipient_yaml['services'], str):
send_notification(recipient_yaml['services'])
else:
for service in recipient_yaml['services']:
send_notification(service)
def log_error(self,message,stop_app = False):
self.ad_api.log(message,level = "ERROR", log = "main_log")
self.ad_api.log(message,level = "ERROR", log = "error_log")
if stop_app:
self.ad_api.stop_app(self.ad_api.name)
class NotificationsManager(hass.Hass):
def initialize(self):
self.notifications = dict()
if "notifications" in self.args:
for notification in self.args["notifications"]:
self.notifications[notification] = Notification(self,self.args["notifications"][notification],notification)

View File

@@ -2,7 +2,7 @@ sleep_switch:
module: smartswitch module: smartswitch
class: SmartSwitch class: SmartSwitch
entity: input_boolean.sleeping entity: input_boolean.bedroom_sleeping
toggle_events: toggle_events:
button_long_press_pierre: button_long_press_pierre:
@@ -64,7 +64,7 @@ sonos_led:
entity: switch.bedroom_led entity: switch.bedroom_led
smart_conditions: not input_boolean.sleeping smart_conditions: not binary_sensor.is_bedroom_sleeping
light_bedroom_bedlight_pierre: light_bedroom_bedlight_pierre:
module: smartlight module: smartlight
@@ -152,13 +152,13 @@ bed_ledstrip:
entity: light.bedroom_ledstripe entity: light.bedroom_ledstripe
smart_conditions: smart_conditions:
trigger_conditions: (input_boolean.sleeping or binary_sensor.day_interval_night) and (binary_sensor.bedroom_motion_bed_pierre or binary_sensor.bedroom_motion_bed_maeva) trigger_conditions: (binary_sensor.is_bedroom_sleeping or binary_sensor.day_interval_night) and (binary_sensor.bedroom_motion_bed_pierre or binary_sensor.bedroom_motion_bed_maeva)
blocking_conditions: switch.bedroom_light or light.bedroom_bedlight_pierre or light.bedroom_bedlight_maeva blocking_conditions: switch.bedroom_light or light.bedroom_bedlight_pierre or light.bedroom_bedlight_maeva
on_events_with_transition: on_events_with_transition:
wake_up_events: wake_up_events:
transition_time: 120 transition_time: 120
brightness_pct: 50 brightness_pct: 100
events: events:
WAKEUP_IN: WAKEUP_IN:
event_name: WAKEUP_IN event_name: WAKEUP_IN
@@ -167,6 +167,10 @@ bed_ledstrip:
light_brightness_pct: 1 light_brightness_pct: 1
off_events:
- turn_off_all_lights
- good_morning
alarm_clock: alarm_clock:
module: alarmclock module: alarmclock
class: AlarmClock class: AlarmClock
@@ -177,4 +181,4 @@ alarm_clock:
inputdate_alarmclock_secondary: input_datetime.secondary_alarmclock inputdate_alarmclock_secondary: input_datetime.secondary_alarmclock
input_text_wakeup_time: input_text.wakeup_time input_text_wakeup_time: input_text.wakeup_time
sleeping_condition: input_boolean.sleeping sleeping_condition: binary_sensor.is_bedroom_sleeping

View File

@@ -8,7 +8,7 @@ light_corridor:
blocking_conditions: sensor.corridor_motion_light_level > 10 and not self blocking_conditions: sensor.corridor_motion_light_level > 10 and not self
light_brightness_pct: light_brightness_pct:
1: input_boolean.sleeping 1: binary_sensor.is_everybody_sleeping
25: binary_sensor.day_interval_night 25: binary_sensor.day_interval_night
100: True 100: True
@@ -22,7 +22,7 @@ light_hallway:
blocking_conditions: sensor.hallway_motion_light_level > 10 and not self blocking_conditions: sensor.hallway_motion_light_level > 10 and not self
light_brightness_pct: light_brightness_pct:
1: input_boolean.sleeping 1: binary_sensor.is_everybody_sleeping
25: binary_sensor.day_interval_night 25: binary_sensor.day_interval_night
100: True 100: True
@@ -37,9 +37,18 @@ light_restroom:
blocking_conditions: sensor.restroom_motion_light_level > 10 and not self blocking_conditions: sensor.restroom_motion_light_level > 10 and not self
light_brightness_pct: light_brightness_pct:
1: input_boolean.sleeping 1: binary_sensor.is_everybody_sleeping
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

@@ -5,11 +5,11 @@ light_kitchen_sink:
smart_conditions: smart_conditions:
- light.kitchen_sink_switch - light.kitchen_sink_switch
- binary_sensor.occupancy_kitchen and (sensor.kitchen_motion_light_level < 10 or self) - binary_sensor.kitchen_occupancy and (sensor.kitchen_motion_light_level < 10 or self)
light_brightness_pct: light_brightness_pct:
100: light.kitchen_sink_switch 100: light.kitchen_sink_switch
1: binary_sensor.day_interval_night or input_boolean.sleeping 1: binary_sensor.day_interval_night or binary_sensor.is_everybody_sleeping
15: True 15: True
light_kitchen_sink_switch: light_kitchen_sink_switch:
@@ -91,3 +91,18 @@ senseo_quadrante:
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

@@ -5,13 +5,19 @@ light_mezzanine_01:
smart_conditions: smart_conditions:
trigger_conditions: sensor.mezzanine_last_motion < 3 trigger_conditions: sensor.mezzanine_last_motion < 3
blocking_conditions: sensor.mezzanine_motion_light_level > 10 and not self blocking_conditions:
- sensor.mezzanine_motion_light_level > 10 and not self
- light.living_room
light_brightness_pct: light_brightness_pct:
15: input_boolean.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
@@ -19,9 +25,118 @@ light_mezzanine_02:
smart_conditions: smart_conditions:
trigger_conditions: sensor.mezzanine_last_motion < 3 trigger_conditions: sensor.mezzanine_last_motion < 3
blocking_conditions: sensor.mezzanine_motion_light_level > 10 and not self blocking_conditions:
- sensor.mezzanine_motion_light_level > 10 and not self
- light.living_room
light_brightness_pct: light_brightness_pct:
5: input_boolean.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:
module: smartlight
class: SmartLight
entity: light.living_room_bookshelves
smart_conditions: sensor.living_room_ambiance_light >= 1
light_brightness_pct:
25: sensor.living_room_ambiance_light == 1
100: True
light_tree:
module: smartlight
class: SmartLight
entity: light.living_room_tree
smart_conditions: sensor.living_room_ambiance_light >= 2
light_brightness_pct: 35
light_art_deco:
module: smartlight
class: SmartLight
entity: light.living_room_art_deco
smart_conditions: sensor.living_room_ambiance_light >= 2
light_brightness_pct: 35
light_living_room:
module: smartlight
class: SmartLight
entity: light.living_room
toggle_events:
button_press:
event_name: state_changed
debounce_seconds: 0.5
event_data:
entity_id: event.living_room_bilresa_button_bouton_1
new_state:
attributes:
event_type: multi_press_1
off_events:
- 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

236
apps/smartheatpump.py Normal file
View File

@@ -0,0 +1,236 @@
import ad_toolbox.smartcondition as SmartCondition
import ad_toolbox.smartvalue as SmartValue
from ad_toolbox.eventhandler import EventHandler
from ad_toolbox.smartobject import SmartObject
import time
class SmartHeatpump(SmartObject):
AUTO_CONTROL_MODE = 'auto'
FORCE_ON_CONTROL_MODE = 'on'
FORCE_OFF_CONTROL_MODE = 'off'
def on_initialize_smart_object(self):
super().on_initialize_smart_object()
self.event_handlers = []
self.control_mode_entity_id = None
self.control_mode = self.AUTO_CONTROL_MODE
if 'quick_resume' in self.args:
self.quick_resume_sensor = self.create_entity(self.args['quick_resume']['sensor'])
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
self.set_state(self.args['quick_resume']['sensor'],state = is_on)
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.hvac_mode = SmartValue.Evaluator(self,self.args['hvac_mode'], expression_name = 'hvac_mode', on_change_cb = self.on_change_hvac_mode, constants = self.constants)
if 'horizontal_vane' in self.args:
self.horizontal_vane = SmartValue.Evaluator(self,self.args['horizontal_vane'], expression_name = 'horizontal_vane', on_change_cb = self.on_change_horizontal_vane, constants = self.constants)
else:
self.horizontal_vane = None
if 'fan_mode' in self.args:
self.fan_mode = SmartValue.Evaluator(self,self.args['fan_mode'], expression_name = 'fan_mode', on_change_cb = self.on_change_fan_mode, constants = self.constants)
else:
self.fan_mode = None
self.smart_conditions_evaluator = SmartCondition.Evaluator(self,self.args['smart_conditions'],condition_name = "smart_conditions",on_change_cb = self.on_smart_conditions_change,constants = self.constants, templates_library = self.templates_library)
if 'control_mode_selector' in self.args or 'heatpump_override' in self.args:
self.initialize_control_mode_selector()
if 'input_buttons' in self.args:
if 'turn_on' in self.args['input_buttons']:
self.listen_state(lambda entity, attribute, old, new, kwargs: self.on_turn_on_button(),self.args['input_buttons']['turn_on'])
if 'turn_off' in self.args['input_buttons']:
self.listen_state(lambda entity, attribute, old, new, kwargs: self.on_turn_off_button(),self.args['input_buttons']['turn_off'])
def initialize_control_mode_selector(self):
selector_args = self.args['control_mode_selector'] if 'control_mode_selector' in self.args else self.args['heatpump_override']
if isinstance(selector_args, str):
self.control_mode_entity_id = selector_args
elif isinstance(selector_args, dict) and 'sensor' in selector_args:
self.control_mode_entity_id = selector_args['sensor']
else:
self.log_warning("control_mode_selector must be a string entity_id or a dict with a 'sensor' key")
return
self.create_entity(
self.control_mode_entity_id,
state = self.AUTO_CONTROL_MODE,
attributes = {
'options': [self.AUTO_CONTROL_MODE, self.FORCE_ON_CONTROL_MODE, self.FORCE_OFF_CONTROL_MODE]
},
icon = 'mdi:power-settings'
)
current_mode = self.get_state(self.control_mode_entity_id)
self.control_mode = self.normalize_control_mode(current_mode)
self.listen_state(self.on_control_mode_change, self.control_mode_entity_id)
self.listen_event(
self.on_control_mode_select_option_service,
'call_service',
domain='input_select',
service='select_option',
entity_id=self.control_mode_entity_id
)
self.register_reset_events(selector_args, "control_mode_selector")
self.apply_control_mode(self.control_mode)
def on_control_mode_select_option_service(self, event_name, data, kwargs):
service_data = data.get('service_data', {}) if isinstance(data, dict) else {}
service_entity_id = service_data.get('entity_id')
if isinstance(service_entity_id, list):
if self.control_mode_entity_id not in service_entity_id:
return
elif isinstance(service_entity_id, str):
if service_entity_id != self.control_mode_entity_id:
return
else:
return
mode = service_data.get('option')
if mode == None or mode == self.control_mode:
return
self.set_state(self.control_mode_entity_id, state = mode)
self.on_control_mode_change(self.control_mode_entity_id, 'state', self.control_mode, mode, {})
def register_reset_events(self, config, config_name):
if not isinstance(config, dict):
return
reset_events = config.get('reset_events', config.get('reset_event'))
if reset_events is None:
return
if isinstance(reset_events, str):
reset_events = [reset_events]
elif not isinstance(reset_events, list):
self.log_warning(f"{config_name}.reset_events must be a string or a list of events")
return
self.event_handlers.append(EventHandler(self, reset_events, self.on_reset_event, config_name))
def on_reset_event(self, event_name, event_data, config_name):
if config_name == "quick_resume":
if 'quick_resume' in self.args:
self.set_state(self.args['quick_resume']['sensor'], state='off')
elif config_name == "control_mode_selector":
if self.control_mode_entity_id:
self.set_state(self.control_mode_entity_id, state=self.AUTO_CONTROL_MODE)
def normalize_control_mode(self, mode):
if mode in [self.AUTO_CONTROL_MODE, self.FORCE_ON_CONTROL_MODE, self.FORCE_OFF_CONTROL_MODE]:
return mode
return self.AUTO_CONTROL_MODE
def on_control_mode_change(self, entity, attribute, old, new, kwargs):
if old == new:
return
previous_mode = self.control_mode
self.control_mode = self.normalize_control_mode(new)
if self.control_mode != new:
self.log_warning(f"Invalid control mode '{new}' on {entity}, falling back to '{self.control_mode}'")
self.set_state(entity, state = self.control_mode)
self.apply_control_mode(self.control_mode, previous_mode)
def apply_control_mode(self, mode, previous_mode = None):
if mode == self.AUTO_CONTROL_MODE:
self.log_info("Heatpump control mode is auto, SmartCondition is enabled")
self.smart_conditions_evaluator.activate()
return
self.smart_conditions_evaluator.deactivate()
if mode == self.FORCE_ON_CONTROL_MODE:
self.log_info("Heatpump control mode is on, forcing Heatpump on")
self.start_cooling()
elif mode == self.FORCE_OFF_CONTROL_MODE:
self.log_info("Heatpump control mode is off, forcing Heatpump off")
self.stop_cooling()
if 'quick_resume' in self.args:
self.set_state(self.args['quick_resume']['sensor'],state = 'off')
def on_turn_on_button(self): self.start_cooling()
def on_turn_off_button(self):
self.stop_cooling()
# we don't wan't quick resum if we force turn off the a/c
if 'quick_resume' in self.args:
self.set_state(self.args['quick_resume']['sensor'],state = 'off')
def start_cooling(self):
if 'quick_resume' in self.args:
self.set_state(self.args['quick_resume']['sensor'],state = 'on')
temperature = self.target_temperature.evaluate(False)
hvac_mode = self.hvac_mode.evaluate(False)
fan_mode = self.fan_mode.evaluate(False) if self.fan_mode else None
self.log_info(f"Starting Heatpump in {hvac_mode} mode, with target_temperature of {temperature}° and {fan_mode} fan speed")
#self.turn_on(self.entity_id)
#time.sleep(2)
self.call_service("climate/set_hvac_mode", entity_id = self.entity_id,hvac_mode = hvac_mode)
time.sleep(1)
self.call_service("climate/set_temperature", entity_id = self.entity_id,temperature = temperature,hvac_mode = hvac_mode)
if self.fan_mode:
time.sleep(1)
self.call_service("climate/set_fan_mode", entity_id = self.entity_id,fan_mode = fan_mode)
if self.horizontal_vane:
time.sleep(1)
horizontal_vane = self.horizontal_vane.evaluate(False)
self.set_horizontal_vane(horizontal_vane)
def stop_cooling(self):
self.log_info("Setting Heatpump hvac_mode to off")
self.call_service("climate/set_hvac_mode", entity_id = self.entity_id,hvac_mode = "off")
def on_smart_conditions_change(self,prev_result,result):
if self.control_mode != self.AUTO_CONTROL_MODE:
self.log_info(f"SmartCondition update ignored because control mode is '{self.control_mode}'")
return
if result == SmartCondition.Result.Succeeded:
self.start_cooling()
else:
self.log_info("Turning Off Heatpump")
self.stop_cooling()
def on_change_target_temperature(self,prev_result,result):
if self.entity.get_state() != 'off':
self.log_info(f"Setting target temperature to {result}°")
self.call_service("climate/set_temperature", entity_id = self.entity_id,temperature = result)
def on_change_hvac_mode(self,prev_result,result):
if self.entity.get_state() != 'off':
self.log_info(f"Setting hvac_mode to {result}")
self.call_service("climate/set_hvac_mode", entity_id = self.entity_id,hvac_mode = result)
def on_change_fan_mode(self,prev_result,result):
if self.entity.get_state() != 'off':
self.log_info(f"Setting fan_mode to {result}")
self.call_service("climate/set_fan_mode", entity_id = self.entity_id,fan_mode = result)
def set_horizontal_vane(self,value):
device, entity = self.split_entity(self.entity_id)
self.call_service("select/select_option", entity_id = f"select.{entity}_horizontal_vane",option = value)
#self.set_state(f"select.{entity}_horizontal_vane",state = value)
def on_change_horizontal_vane(self,prev_result,result):
if self.entity.get_state() != 'off':
self.log_info(f"Setting horizontal_vane to {result}")
self.set_horizontal_vane(result)

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

View File

@@ -2,13 +2,16 @@
# Archive log files from the logs directory into a timestamped subdirectory, then delete them. # Archive log files from the logs directory into a timestamped subdirectory, then delete them.
# Add to dockerStart.sh to run this script at startup, after the configuration setup:
# sh $CONF/archiveLogs.sh
LOGS_DIR=/conf/logs LOGS_DIR=/conf/logs
ARCHIVE_DIR=$LOGS_DIR/archive ARCHIVE_DIR=$LOGS_DIR/archive
TIMESTAMP=$(date +"%Y%m%d_%H%M%S") TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
DEST=$ARCHIVE_DIR/$TIMESTAMP DEST=$ARCHIVE_DIR/$TIMESTAMP
# Find log files directly in the logs directory (non-recursive, skip archive subdir) # Find log files directly in the logs directory (non-recursive, skip archive subdir)
LOG_FILES=$(find "$LOGS_DIR" -maxdepth 1 -type f -name "*.log") LOG_FILES=$(find "$LOGS_DIR" -maxdepth 1 -type f -name "*.log*")
if [ -z "$LOG_FILES" ]; then if [ -z "$LOG_FILES" ]; then
echo "[archiveLogs] No log files to archive." echo "[archiveLogs] No log files to archive."

View File

@@ -2,3 +2,5 @@ libstdc++
gcompat gcompat
bash bash
git git
nodejs
npm