Compare commits
38 Commits
80cecdb9f0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b6387c0acc | |||
| 2b2261e248 | |||
| 89a2f55583 | |||
| 976b829a8d | |||
| e7e11e3429 | |||
| e5d99438d8 | |||
| 426c6b79cf | |||
| dd27cdc952 | |||
| 30965c2a8a | |||
| cb2e70ee57 | |||
| 2e8dd176b8 | |||
| d9744ff8f1 | |||
| bf1445759d | |||
| 3087ad1f80 | |||
| 6310e39fd9 | |||
| 85fb207ca0 | |||
| 757ece983c | |||
| eca30960f9 | |||
| ec2fb31068 | |||
| 8cc9e31ce6 | |||
| 9607e6e78d | |||
| fd0769fd12 | |||
| c50a1c3a24 | |||
| 3737606542 | |||
| 92d7f1c869 | |||
| 654168425e | |||
| 09681d93bb | |||
| 77cd648d03 | |||
| 6cb86875f1 | |||
| 4571c14e91 | |||
| 89b08b1fc5 | |||
| fc6673e98b | |||
| 728577647a | |||
| aa6fb42dd7 | |||
| af6abec832 | |||
| 4756d1669a | |||
| b4cbe014c2 | |||
| 63386406ce |
Submodule apps/ad_toolbox updated: 943361453a...b997836401
@@ -60,3 +60,14 @@ informations_collector:
|
||||
# - group.dumb_lights
|
||||
# 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
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ heating_sensors:
|
||||
value: 18
|
||||
default:
|
||||
conditions: True
|
||||
value: 27
|
||||
value: input_number.heatpump_temperature
|
||||
|
||||
smart_heating_templates_library:
|
||||
module: templatelibrary
|
||||
@@ -26,15 +26,17 @@ smart_heating_templates_library:
|
||||
|
||||
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 + 1))"
|
||||
- "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
|
||||
- not binary_sensor.someone_home #and not (binary_sensor.someone_in_town and sensor.day_interval == 'Nuit')
|
||||
- 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:
|
||||
@@ -50,15 +52,16 @@ desk_heatpump:
|
||||
room_temperature: sensor.desk_temperature
|
||||
room_occupancy: binary_sensor.desk_occupancy
|
||||
target_temperature: sensor.target_temperature_heatpump
|
||||
cant_open_window: false #binary_sensor.cant_open_window
|
||||
sleeping_in_the_room: false #binary_sensor.is_bureau_sleeping
|
||||
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
|
||||
auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1
|
||||
quiet: True
|
||||
|
||||
target_temperature: sensor.target_temperature_heatpump
|
||||
@@ -69,10 +72,104 @@ desk_heatpump:
|
||||
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_event: good_morning
|
||||
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
174
apps/global/home_alarm.yaml
Normal 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
|
||||
29
apps/global/notificationsmanagers.yaml
Normal file
29
apps/global/notificationsmanagers.yaml
Normal 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
|
||||
@@ -13,6 +13,11 @@ motion_tracker:
|
||||
motion_sensors: binary_sensor.hallway_motion
|
||||
restroom:
|
||||
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:
|
||||
door_generate_motion: true
|
||||
motion_sensors:
|
||||
@@ -33,16 +38,25 @@ occupancy_sensors:
|
||||
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
|
||||
|
||||
default_values:
|
||||
binary_sensor.someone_home: off
|
||||
|
||||
sensors:
|
||||
binary_sensor.living_room_occupancy:
|
||||
trigger_conditions: sensor.living_room_last_motion <= 5
|
||||
binary_sensor.kitchen_occupancy:
|
||||
trigger_conditions: sensor.kitchen_last_motion <= 5
|
||||
binary_sensor.garage_occupancy:
|
||||
trigger_conditions: sensor.garage_last_motion <= 5 or binary_sensor.garage_entrance or binary_sensor.garage_door
|
||||
binary_sensor.restroom_occupancy:
|
||||
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:
|
||||
- binary_sensor.alienware_r10
|
||||
- sensor.macbook_m3_pro_de_pierre_frontmost_app != 'loginwindow' and sensor.macbook_m3_pro_de_pierre_displays == 2
|
||||
- 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
18
apps/global/sleep.yaml
Normal 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
39
apps/global/sonos.yaml
Normal 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
|
||||
@@ -7,10 +7,16 @@ virtual_events:
|
||||
virtual_events:
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -4,11 +4,68 @@ virtual_sensors:
|
||||
priority: 10 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first
|
||||
|
||||
sensors:
|
||||
continuous_condition.is_apple_tv_idle:
|
||||
binary_sensor.someone_home: person.maeva == 'home' or person.pierre == 'home' or (input_boolean.use_red_shelly_presence and binary_sensor.red_shelly_home)
|
||||
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: media_player.apple_tv == 'paused' or media_player.apple_tv == 'idle' or media_player.apple_tv == 'standby'
|
||||
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
|
||||
|
||||
binary_sensor.is_apple_tv_on: media_player.apple_tv != 'off'
|
||||
binary_sensor.someone_home: person.maeva == 'home' or person.pierre == 'home'
|
||||
binary_sensor.is_everybody_sleeping: input_boolean.sleeping
|
||||
|
||||
|
||||
1033
apps/homealarm.py
Normal file
1033
apps/homealarm.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ class InformationsCollector(SmartObject):
|
||||
self.min_temp_yesterday_sensor = self.create_entity(output_sensors['min_temp_yesterday'])
|
||||
|
||||
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
|
||||
try:
|
||||
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)")
|
||||
except KeyError: pass
|
||||
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
|
||||
try:
|
||||
if min_temp is None or self.dataset['min_temp'] < min_temp:
|
||||
@@ -199,8 +199,12 @@ class InformationsCollector(SmartObject):
|
||||
min_temp = None
|
||||
for sensor in self.args['min_max_temp_sensors']['temperature_sensors']:
|
||||
temp = self.get_state(sensor)
|
||||
if not max_temp or temp > max_temp: max_temp = temp
|
||||
if not min_temp or temp < min_temp: min_temp = temp
|
||||
try:
|
||||
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
|
||||
|
||||
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):
|
||||
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()
|
||||
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 min_temp is None or min_temp == 'unavailable' or new < min_temp: self.set_min_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_value < float(min_temp): self.set_min_temp_sensor(new_value)
|
||||
|
||||
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'})
|
||||
|
||||
123
apps/notificationsmanager.py
Normal file
123
apps/notificationsmanager.py
Normal 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)
|
||||
@@ -2,7 +2,7 @@ sleep_switch:
|
||||
module: smartswitch
|
||||
class: SmartSwitch
|
||||
|
||||
entity: input_boolean.sleeping
|
||||
entity: input_boolean.bedroom_sleeping
|
||||
|
||||
toggle_events:
|
||||
button_long_press_pierre:
|
||||
@@ -64,7 +64,7 @@ sonos_led:
|
||||
|
||||
entity: switch.bedroom_led
|
||||
|
||||
smart_conditions: not input_boolean.sleeping
|
||||
smart_conditions: not binary_sensor.is_bedroom_sleeping
|
||||
|
||||
light_bedroom_bedlight_pierre:
|
||||
module: smartlight
|
||||
@@ -152,7 +152,7 @@ bed_ledstrip:
|
||||
entity: light.bedroom_ledstripe
|
||||
|
||||
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
|
||||
|
||||
on_events_with_transition:
|
||||
@@ -167,6 +167,10 @@ bed_ledstrip:
|
||||
|
||||
light_brightness_pct: 1
|
||||
|
||||
off_events:
|
||||
- turn_off_all_lights
|
||||
- good_morning
|
||||
|
||||
alarm_clock:
|
||||
module: alarmclock
|
||||
class: AlarmClock
|
||||
@@ -177,4 +181,4 @@ alarm_clock:
|
||||
inputdate_alarmclock_secondary: input_datetime.secondary_alarmclock
|
||||
input_text_wakeup_time: input_text.wakeup_time
|
||||
|
||||
sleeping_condition: input_boolean.sleeping
|
||||
sleeping_condition: binary_sensor.is_bedroom_sleeping
|
||||
@@ -8,7 +8,7 @@ light_corridor:
|
||||
blocking_conditions: sensor.corridor_motion_light_level > 10 and not self
|
||||
|
||||
light_brightness_pct:
|
||||
1: input_boolean.sleeping
|
||||
1: binary_sensor.is_everybody_sleeping
|
||||
25: binary_sensor.day_interval_night
|
||||
100: True
|
||||
|
||||
@@ -22,7 +22,7 @@ light_hallway:
|
||||
blocking_conditions: sensor.hallway_motion_light_level > 10 and not self
|
||||
|
||||
light_brightness_pct:
|
||||
1: input_boolean.sleeping
|
||||
1: binary_sensor.is_everybody_sleeping
|
||||
25: binary_sensor.day_interval_night
|
||||
100: True
|
||||
|
||||
@@ -37,9 +37,18 @@ light_restroom:
|
||||
blocking_conditions: sensor.restroom_motion_light_level > 10 and not self
|
||||
|
||||
light_brightness_pct:
|
||||
1: input_boolean.sleeping
|
||||
1: binary_sensor.is_everybody_sleeping
|
||||
25: binary_sensor.day_interval_night
|
||||
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
6
apps/rooms/desk.yaml
Normal 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
|
||||
@@ -5,4 +5,31 @@ light_garage:
|
||||
|
||||
smart_conditions:
|
||||
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
|
||||
|
||||
@@ -9,7 +9,7 @@ light_kitchen_sink:
|
||||
|
||||
light_brightness_pct:
|
||||
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
|
||||
|
||||
light_kitchen_sink_switch:
|
||||
@@ -91,3 +91,18 @@ senseo_quadrante:
|
||||
event_name: good_morning
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -5,13 +5,19 @@ light_mezzanine_01:
|
||||
|
||||
smart_conditions:
|
||||
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:
|
||||
15: input_boolean.sleeping
|
||||
15: binary_sensor.is_everybody_sleeping or binary_sensor.projector_on
|
||||
35: binary_sensor.day_interval_night
|
||||
100: True
|
||||
|
||||
off_events:
|
||||
- turn_off_all_lights
|
||||
- turn_off_all_living_room_lights
|
||||
|
||||
light_mezzanine_02:
|
||||
module: smartlight
|
||||
class: SmartLight
|
||||
@@ -19,25 +25,118 @@ light_mezzanine_02:
|
||||
|
||||
smart_conditions:
|
||||
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:
|
||||
5: input_boolean.sleeping
|
||||
5: binary_sensor.is_everybody_sleeping or binary_sensor.projector_on
|
||||
25: binary_sensor.day_interval_night
|
||||
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:
|
||||
trigger_conditions: binary_sensor.living_room_occupancy
|
||||
blocking_conditions:
|
||||
- sensor.mezzanine_motion_light_level > 10
|
||||
- binary_sensor.is_apple_tv_on and not binary_sensor.is_apple_tv_idle
|
||||
smart_conditions: sensor.living_room_ambiance_light >= 1
|
||||
|
||||
#light_brightness_pct:
|
||||
# 5: input_boolean.sleeping
|
||||
# 25: binary_sensor.day_interval_night
|
||||
# 100: True
|
||||
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
|
||||
@@ -1,19 +1,28 @@
|
||||
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.listen_event(lambda event_name, data, kwargs: self.set_state(self.args['quick_resume']['sensor'],state = 'off'),self.args['quick_resume']['reset_event'])
|
||||
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)
|
||||
@@ -22,15 +31,135 @@ class SmartHeatpump(SmartObject):
|
||||
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):
|
||||
@@ -44,7 +173,7 @@ class SmartHeatpump(SmartObject):
|
||||
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)
|
||||
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)
|
||||
@@ -53,6 +182,7 @@ class SmartHeatpump(SmartObject):
|
||||
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:
|
||||
@@ -66,6 +196,10 @@ class SmartHeatpump(SmartObject):
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -114,6 +114,8 @@ from ad_toolbox.eventhandler import EventHandler
|
||||
class SmartLight(SmartSwitch):
|
||||
|
||||
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 : currently active brightness % (str label)
|
||||
self.light_brightness_pct_list = list()
|
||||
@@ -196,6 +198,15 @@ class SmartLight(SmartSwitch):
|
||||
# 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.
|
||||
def switch_on(self):
|
||||
if self.light_brightness_pct != None:
|
||||
@@ -225,4 +236,61 @@ class SmartLight(SmartSwitch):
|
||||
self.log_info("Turning off")
|
||||
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
247
apps/smartshutter.py
Normal 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
646
apps/sonoscontroller.py
Normal 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
|
||||
@@ -11,7 +11,7 @@ TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
DEST=$ARCHIVE_DIR/$TIMESTAMP
|
||||
|
||||
# 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
|
||||
echo "[archiveLogs] No log files to archive."
|
||||
|
||||
@@ -2,3 +2,5 @@ libstdc++
|
||||
gcompat
|
||||
bash
|
||||
git
|
||||
nodejs
|
||||
npm
|
||||
Reference in New Issue
Block a user