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
|
# - group.dumb_lights
|
||||||
# output_sensor: sensor.ad_number_of_lights_on
|
# output_sensor: sensor.ad_number_of_lights_on
|
||||||
|
|
||||||
|
haw_switch:
|
||||||
|
module: smartswitch
|
||||||
|
class: SmartSwitch
|
||||||
|
|
||||||
|
entity: input_boolean.haw
|
||||||
|
|
||||||
|
auto_switch_off_after: 28800 # 8h
|
||||||
|
|
||||||
|
off_events:
|
||||||
|
- WAKEUP
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ heating_sensors:
|
|||||||
value: 18
|
value: 18
|
||||||
default:
|
default:
|
||||||
conditions: True
|
conditions: True
|
||||||
value: 27
|
value: input_number.heatpump_temperature
|
||||||
|
|
||||||
smart_heating_templates_library:
|
smart_heating_templates_library:
|
||||||
module: templatelibrary
|
module: templatelibrary
|
||||||
@@ -26,15 +26,17 @@ smart_heating_templates_library:
|
|||||||
|
|
||||||
templates_library:
|
templates_library:
|
||||||
clim_conditions:
|
clim_conditions:
|
||||||
|
local_expressions:
|
||||||
|
temp_threshold: "sleeping_in_the_room ? 0 : (ext_temperature >= 35 ? 0.5 : 1)"
|
||||||
trigger_conditions:
|
trigger_conditions:
|
||||||
- "not binary_sensor.heatpump_winter_mode and (ext_temperature >= room_temperature or ext_temperature >= target_temperature or cant_open_window) and room_temperature >= (quick_resume ? target_temperature : (target_temperature + 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)
|
- 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 room_temperature <= (target_temperature - 1) and ext_temperature <= 10
|
||||||
- binary_sensor.heatpump_winter_mode and self and room_temperature < target_temperature
|
- binary_sensor.heatpump_winter_mode and self and room_temperature < target_temperature
|
||||||
blocking_conditions:
|
blocking_conditions:
|
||||||
- binary_sensor.is_everybody_sleeping and not sleeping_in_the_room and not stay_on_while_sleeping
|
- binary_sensor.is_everybody_sleeping and not sleeping_in_the_room and not stay_on_while_sleeping
|
||||||
- not room_occupancy
|
- not room_occupancy and not (sleeping_in_the_room or (occupied_tonight and binary_sensor.day_interval_night))
|
||||||
- not binary_sensor.someone_home #and not (binary_sensor.someone_in_town and sensor.day_interval == 'Nuit')
|
- not binary_sensor.someone_home
|
||||||
- window_open
|
- window_open
|
||||||
|
|
||||||
desk_heatpump:
|
desk_heatpump:
|
||||||
@@ -50,15 +52,16 @@ desk_heatpump:
|
|||||||
room_temperature: sensor.desk_temperature
|
room_temperature: sensor.desk_temperature
|
||||||
room_occupancy: binary_sensor.desk_occupancy
|
room_occupancy: binary_sensor.desk_occupancy
|
||||||
target_temperature: sensor.target_temperature_heatpump
|
target_temperature: sensor.target_temperature_heatpump
|
||||||
cant_open_window: false #binary_sensor.cant_open_window
|
cant_open_window: binary_sensor.is_desk_sleeping
|
||||||
sleeping_in_the_room: false #binary_sensor.is_bureau_sleeping
|
sleeping_in_the_room: binary_sensor.is_desk_sleeping
|
||||||
quick_resume: binary_sensor.desk_heatpump_quick_resume
|
quick_resume: binary_sensor.desk_heatpump_quick_resume
|
||||||
|
occupied_tonight: input_boolean.desk_occupied_tonight
|
||||||
stay_on_while_sleeping: false
|
stay_on_while_sleeping: false
|
||||||
|
|
||||||
virtual_sensors:
|
virtual_sensors:
|
||||||
sensors:
|
sensors:
|
||||||
value_selector.desk_heatpump_fan_mode:
|
value_selector.desk_heatpump_fan_mode:
|
||||||
auto: binary_sensor.heatpump_winter_mode
|
auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1
|
||||||
quiet: True
|
quiet: True
|
||||||
|
|
||||||
target_temperature: sensor.target_temperature_heatpump
|
target_temperature: sensor.target_temperature_heatpump
|
||||||
@@ -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
|
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>
|
template_conditions: <clim_conditions>
|
||||||
|
|
||||||
|
control_mode_selector:
|
||||||
|
sensor: input_select.desk_heatpump_override
|
||||||
|
reset_events:
|
||||||
|
- good_morning
|
||||||
|
- good_bye
|
||||||
|
|
||||||
quick_resume:
|
quick_resume:
|
||||||
sensor: binary_sensor.desk_heatpump_quick_resume
|
sensor: binary_sensor.desk_heatpump_quick_resume
|
||||||
reset_event: good_morning
|
reset_events: good_morning
|
||||||
|
|
||||||
#input_buttons:
|
#input_buttons:
|
||||||
# turn_on: input_button.clim_du_bureau_turn_on
|
# turn_on: input_button.clim_du_bureau_turn_on
|
||||||
# turn_off: input_button.clim_du_bureau_turn_off
|
# 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
|
motion_sensors: binary_sensor.hallway_motion
|
||||||
restroom:
|
restroom:
|
||||||
motion_sensors: binary_sensor.restroom_motion
|
motion_sensors: binary_sensor.restroom_motion
|
||||||
|
porche:
|
||||||
|
motion_sensors: binary_sensor.porche_motion
|
||||||
|
desk:
|
||||||
|
update_on_both_front: true
|
||||||
|
motion_sensors: binary_sensor.desk_presence_presence
|
||||||
garage:
|
garage:
|
||||||
door_generate_motion: true
|
door_generate_motion: true
|
||||||
motion_sensors:
|
motion_sensors:
|
||||||
@@ -33,16 +38,25 @@ occupancy_sensors:
|
|||||||
class: VirtualSensorsApp
|
class: VirtualSensorsApp
|
||||||
priority: 8 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first
|
priority: 8 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first
|
||||||
|
|
||||||
|
default_values:
|
||||||
|
binary_sensor.someone_home: off
|
||||||
|
|
||||||
sensors:
|
sensors:
|
||||||
binary_sensor.living_room_occupancy:
|
binary_sensor.living_room_occupancy:
|
||||||
trigger_conditions: sensor.living_room_last_motion <= 5
|
trigger_conditions: sensor.living_room_last_motion <= 5
|
||||||
binary_sensor.kitchen_occupancy:
|
binary_sensor.kitchen_occupancy:
|
||||||
trigger_conditions: sensor.kitchen_last_motion <= 5
|
trigger_conditions: sensor.kitchen_last_motion <= 5
|
||||||
|
binary_sensor.garage_occupancy:
|
||||||
|
trigger_conditions: sensor.garage_last_motion <= 5 or binary_sensor.garage_entrance or binary_sensor.garage_door
|
||||||
binary_sensor.restroom_occupancy:
|
binary_sensor.restroom_occupancy:
|
||||||
trigger_conditions: sensor.restroom_last_motion <= 3
|
trigger_conditions: sensor.restroom_last_motion <= 3
|
||||||
|
binary_sensor.bedroom_occupancy:
|
||||||
|
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_condition.desk_occupancy:
|
||||||
retain_time: 5 * 60
|
retain_time: 5 * 60
|
||||||
conditions:
|
conditions:
|
||||||
trigger_conditions:
|
trigger_conditions:
|
||||||
- binary_sensor.alienware_r10
|
- sensor.desk_last_motion <= 5
|
||||||
- sensor.macbook_m3_pro_de_pierre_frontmost_app != 'loginwindow' and sensor.macbook_m3_pro_de_pierre_displays == 2
|
#- binary_sensor.alienware_r10
|
||||||
|
#- sensor.macbook_m3_pro_de_pierre_frontmost_app != 'loginwindow' and sensor.macbook_m3_pro_de_pierre_displays == 2
|
||||||
|
|||||||
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:
|
virtual_events:
|
||||||
good_morning:
|
good_morning:
|
||||||
event_name: good_morning
|
event_name: good_morning
|
||||||
event_condition: not input_boolean.sleeping
|
event_condition: not binary_sensor.is_anybody_sleeping
|
||||||
|
|
||||||
|
good_bye:
|
||||||
|
event_name: good_bye
|
||||||
|
event_condition: not binary_sensor.someone_home
|
||||||
|
|
||||||
turn_off_all_lights:
|
turn_off_all_lights:
|
||||||
event_name: turn_off_all_lights
|
event_name: turn_off_all_lights
|
||||||
event_condition: input_boolean.sleeping
|
event_condition: binary_sensor.is_everybody_sleeping
|
||||||
|
|
||||||
|
|
||||||
|
turn_off_all_living_room_lights:
|
||||||
|
event_name: turn_off_all_living_room_lights
|
||||||
|
event_condition: binary_sensor.is_living_room_sleeping or binary_sensor.projector_on
|
||||||
|
|||||||
@@ -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
|
priority: 10 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first
|
||||||
|
|
||||||
sensors:
|
sensors:
|
||||||
continuous_condition.is_apple_tv_idle:
|
binary_sensor.someone_home: person.maeva == 'home' or person.pierre == 'home' or (input_boolean.use_red_shelly_presence and binary_sensor.red_shelly_home)
|
||||||
|
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
|
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'])
|
self.min_temp_yesterday_sensor = self.create_entity(output_sensors['min_temp_yesterday'])
|
||||||
|
|
||||||
current_min_temp, current_max_temp = self.compute_min_max_temp()
|
current_min_temp, current_max_temp = self.compute_min_max_temp()
|
||||||
if not self.max_temp_sensor.exists() or self.max_temp_sensor.get_state() == 'unavailable':
|
if not self.max_temp_sensor.exists() or self.max_temp_sensor.get_state() == 'unavailable' or self.max_temp_sensor.get_state() == 'unknown':
|
||||||
max_temp = current_max_temp
|
max_temp = current_max_temp
|
||||||
try:
|
try:
|
||||||
if max_temp is None or self.dataset['max_temp'] > max_temp:
|
if max_temp is None or self.dataset['max_temp'] > max_temp:
|
||||||
@@ -70,7 +70,7 @@ class InformationsCollector(SmartObject):
|
|||||||
self.log_info(f"Restoring max_temp from dataset ({max_temp}°C)")
|
self.log_info(f"Restoring max_temp from dataset ({max_temp}°C)")
|
||||||
except KeyError: pass
|
except KeyError: pass
|
||||||
self.set_max_temp_sensor(max_temp)
|
self.set_max_temp_sensor(max_temp)
|
||||||
if not self.min_temp_sensor.exists() or self.min_temp_sensor.get_state() == 'unavailable':
|
if not self.min_temp_sensor.exists() or self.min_temp_sensor.get_state() == 'unavailable' or self.min_temp_sensor.get_state() == 'unknown':
|
||||||
min_temp = current_min_temp
|
min_temp = current_min_temp
|
||||||
try:
|
try:
|
||||||
if min_temp is None or self.dataset['min_temp'] < min_temp:
|
if min_temp is None or self.dataset['min_temp'] < min_temp:
|
||||||
@@ -199,8 +199,12 @@ class InformationsCollector(SmartObject):
|
|||||||
min_temp = None
|
min_temp = None
|
||||||
for sensor in self.args['min_max_temp_sensors']['temperature_sensors']:
|
for sensor in self.args['min_max_temp_sensors']['temperature_sensors']:
|
||||||
temp = self.get_state(sensor)
|
temp = self.get_state(sensor)
|
||||||
if not max_temp or temp > max_temp: max_temp = temp
|
try:
|
||||||
if not min_temp or temp < min_temp: min_temp = temp
|
temp = float(temp)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if max_temp is None or temp > max_temp: max_temp = temp
|
||||||
|
if min_temp is None or temp < min_temp: min_temp = temp
|
||||||
return min_temp,max_temp
|
return min_temp,max_temp
|
||||||
|
|
||||||
def on_log_error(self, name, ts, level, type, message, kwargs):
|
def on_log_error(self, name, ts, level, type, message, kwargs):
|
||||||
@@ -224,10 +228,17 @@ class InformationsCollector(SmartObject):
|
|||||||
|
|
||||||
def on_temperature_change(self, entity, attribute, old, new, kwargs):
|
def on_temperature_change(self, entity, attribute, old, new, kwargs):
|
||||||
if new != old:
|
if new != old:
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_value = float(new)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.log_warning(f"Invalid temperature value from {entity}: {new}")
|
||||||
|
return
|
||||||
|
|
||||||
min_temp = self.min_temp_sensor.get_state()
|
min_temp = self.min_temp_sensor.get_state()
|
||||||
max_temp = self.max_temp_sensor.get_state()
|
max_temp = self.max_temp_sensor.get_state()
|
||||||
if max_temp is None or max_temp == 'unavailable' or new > max_temp: self.set_max_temp_sensor(new)
|
if max_temp is None or max_temp == 'unavailable' or new_value > float(max_temp): self.set_max_temp_sensor(new_value)
|
||||||
if min_temp is None or min_temp == 'unavailable' or new < min_temp: self.set_min_temp_sensor(new)
|
if min_temp is None or min_temp == 'unavailable' or new_value < float(min_temp): self.set_min_temp_sensor(new_value)
|
||||||
|
|
||||||
def run_at_midnight(self, kwargs):
|
def run_at_midnight(self, kwargs):
|
||||||
self.max_temp_yesterday_sensor.set_state(state=self.max_temp_sensor.get_state(), attributes={'unit_of_measurement': '°C', 'device_class': 'temperature'})
|
self.max_temp_yesterday_sensor.set_state(state=self.max_temp_sensor.get_state(), attributes={'unit_of_measurement': '°C', 'device_class': 'temperature'})
|
||||||
|
|||||||
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
|
module: smartswitch
|
||||||
class: SmartSwitch
|
class: SmartSwitch
|
||||||
|
|
||||||
entity: input_boolean.sleeping
|
entity: input_boolean.bedroom_sleeping
|
||||||
|
|
||||||
toggle_events:
|
toggle_events:
|
||||||
button_long_press_pierre:
|
button_long_press_pierre:
|
||||||
@@ -64,7 +64,7 @@ sonos_led:
|
|||||||
|
|
||||||
entity: switch.bedroom_led
|
entity: switch.bedroom_led
|
||||||
|
|
||||||
smart_conditions: not input_boolean.sleeping
|
smart_conditions: not binary_sensor.is_bedroom_sleeping
|
||||||
|
|
||||||
light_bedroom_bedlight_pierre:
|
light_bedroom_bedlight_pierre:
|
||||||
module: smartlight
|
module: smartlight
|
||||||
@@ -152,7 +152,7 @@ bed_ledstrip:
|
|||||||
entity: light.bedroom_ledstripe
|
entity: light.bedroom_ledstripe
|
||||||
|
|
||||||
smart_conditions:
|
smart_conditions:
|
||||||
trigger_conditions: (input_boolean.sleeping or binary_sensor.day_interval_night) and (binary_sensor.bedroom_motion_bed_pierre or binary_sensor.bedroom_motion_bed_maeva)
|
trigger_conditions: (binary_sensor.is_bedroom_sleeping or binary_sensor.day_interval_night) and (binary_sensor.bedroom_motion_bed_pierre or binary_sensor.bedroom_motion_bed_maeva)
|
||||||
blocking_conditions: switch.bedroom_light or light.bedroom_bedlight_pierre or light.bedroom_bedlight_maeva
|
blocking_conditions: switch.bedroom_light or light.bedroom_bedlight_pierre or light.bedroom_bedlight_maeva
|
||||||
|
|
||||||
on_events_with_transition:
|
on_events_with_transition:
|
||||||
@@ -167,6 +167,10 @@ bed_ledstrip:
|
|||||||
|
|
||||||
light_brightness_pct: 1
|
light_brightness_pct: 1
|
||||||
|
|
||||||
|
off_events:
|
||||||
|
- turn_off_all_lights
|
||||||
|
- good_morning
|
||||||
|
|
||||||
alarm_clock:
|
alarm_clock:
|
||||||
module: alarmclock
|
module: alarmclock
|
||||||
class: AlarmClock
|
class: AlarmClock
|
||||||
@@ -177,4 +181,4 @@ alarm_clock:
|
|||||||
inputdate_alarmclock_secondary: input_datetime.secondary_alarmclock
|
inputdate_alarmclock_secondary: input_datetime.secondary_alarmclock
|
||||||
input_text_wakeup_time: input_text.wakeup_time
|
input_text_wakeup_time: input_text.wakeup_time
|
||||||
|
|
||||||
sleeping_condition: input_boolean.sleeping
|
sleeping_condition: binary_sensor.is_bedroom_sleeping
|
||||||
@@ -8,7 +8,7 @@ light_corridor:
|
|||||||
blocking_conditions: sensor.corridor_motion_light_level > 10 and not self
|
blocking_conditions: sensor.corridor_motion_light_level > 10 and not self
|
||||||
|
|
||||||
light_brightness_pct:
|
light_brightness_pct:
|
||||||
1: input_boolean.sleeping
|
1: binary_sensor.is_everybody_sleeping
|
||||||
25: binary_sensor.day_interval_night
|
25: binary_sensor.day_interval_night
|
||||||
100: True
|
100: True
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ light_hallway:
|
|||||||
blocking_conditions: sensor.hallway_motion_light_level > 10 and not self
|
blocking_conditions: sensor.hallway_motion_light_level > 10 and not self
|
||||||
|
|
||||||
light_brightness_pct:
|
light_brightness_pct:
|
||||||
1: input_boolean.sleeping
|
1: binary_sensor.is_everybody_sleeping
|
||||||
25: binary_sensor.day_interval_night
|
25: binary_sensor.day_interval_night
|
||||||
100: True
|
100: True
|
||||||
|
|
||||||
@@ -37,9 +37,18 @@ light_restroom:
|
|||||||
blocking_conditions: sensor.restroom_motion_light_level > 10 and not self
|
blocking_conditions: sensor.restroom_motion_light_level > 10 and not self
|
||||||
|
|
||||||
light_brightness_pct:
|
light_brightness_pct:
|
||||||
1: input_boolean.sleeping
|
1: binary_sensor.is_everybody_sleeping
|
||||||
25: binary_sensor.day_interval_night
|
25: binary_sensor.day_interval_night
|
||||||
100: True
|
100: True
|
||||||
|
|
||||||
|
light_porche:
|
||||||
|
module: smartlight
|
||||||
|
class: SmartLight
|
||||||
|
entity: light.porche
|
||||||
|
|
||||||
|
smart_conditions:
|
||||||
|
trigger_conditions: sensor.porche_last_motion < 3
|
||||||
|
blocking_conditions: sensor.porche_motion_light_level > 10 and not self
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6
apps/rooms/desk.yaml
Normal file
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:
|
smart_conditions:
|
||||||
trigger_conditions: sensor.garage_last_motion < 3
|
trigger_conditions: sensor.garage_last_motion < 3
|
||||||
blocking_conditions: sensor.garage_motion_light_level > 10
|
blocking_conditions: sensor.garage_motion_light_level > 10 and binary_sensor.garage_entrance
|
||||||
|
|
||||||
|
p1p_light:
|
||||||
|
module: smartswitch
|
||||||
|
class: SmartSwitch
|
||||||
|
entity: light.garage_p1p_lumiere_de_la_chambre
|
||||||
|
|
||||||
|
smart_conditions:
|
||||||
|
unavaibility_result: Disabled
|
||||||
|
trigger_conditions: binary_sensor.garage_occupancy or binary_sensor.garage_p1p_timelapse or camera.garage_p1p_camera == 'streaming'
|
||||||
|
blocking_conditions: not binary_sensor.p1p_is_printing and not fan.garage_p1p_ventilateur_de_refroidissement and not fan.garage_p1p_ventilateur_de_la_chambre
|
||||||
|
|
||||||
|
p1p_switch:
|
||||||
|
module: smartswitch
|
||||||
|
class: SmartSwitch
|
||||||
|
entity: switch.garage_p1p_plug
|
||||||
|
|
||||||
|
off_conditions:
|
||||||
|
trigger_conditions: binary_sensor.is_anybody_sleeping or not binary_sensor.someone_home
|
||||||
|
blocking_conditions: binary_sensor.p1p_is_active
|
||||||
|
|
||||||
|
toggle_events:
|
||||||
|
garage_button:
|
||||||
|
event_name: zha_event
|
||||||
|
event_data:
|
||||||
|
device_name: 'garage_p1p_button'
|
||||||
|
args:
|
||||||
|
attribute_name: on_off
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ light_kitchen_sink:
|
|||||||
|
|
||||||
light_brightness_pct:
|
light_brightness_pct:
|
||||||
100: light.kitchen_sink_switch
|
100: light.kitchen_sink_switch
|
||||||
1: binary_sensor.day_interval_night or input_boolean.sleeping
|
1: binary_sensor.day_interval_night or binary_sensor.is_everybody_sleeping
|
||||||
15: True
|
15: True
|
||||||
|
|
||||||
light_kitchen_sink_switch:
|
light_kitchen_sink_switch:
|
||||||
@@ -91,3 +91,18 @@ senseo_quadrante:
|
|||||||
event_name: good_morning
|
event_name: good_morning
|
||||||
button: button.senseo_quadrante_brew_coffee_double
|
button: button.senseo_quadrante_brew_coffee_double
|
||||||
condition: binary_sensor.senseo_quadrante_has_program and binary_sensor.senseo_quadrante_has_program.cup_size == 2 and not binary_sensor.senseo_quadrante_has_program.power_pressed
|
condition: binary_sensor.senseo_quadrante_has_program and binary_sensor.senseo_quadrante_has_program.cup_size == 2 and not binary_sensor.senseo_quadrante_has_program.power_pressed
|
||||||
|
|
||||||
|
storeban:
|
||||||
|
module: smartshutter
|
||||||
|
class: SmartShutter
|
||||||
|
entity: cover.storeban
|
||||||
|
|
||||||
|
virtual_sensors:
|
||||||
|
sensors:
|
||||||
|
binary_sensor.storeban_autoclose: sensor.sun_solar_elevation < 0 or (sensor.sun_solar_azimuth > 235 and sensor.sun_solar_elevation < 45)
|
||||||
|
|
||||||
|
close_conditions:
|
||||||
|
trigger_conditions: binary_sensor.storeban_autoclose
|
||||||
|
disable_conditions: not binary_sensor.storeban_autoclose
|
||||||
|
|
||||||
|
|
||||||
@@ -5,13 +5,19 @@ light_mezzanine_01:
|
|||||||
|
|
||||||
smart_conditions:
|
smart_conditions:
|
||||||
trigger_conditions: sensor.mezzanine_last_motion < 3
|
trigger_conditions: sensor.mezzanine_last_motion < 3
|
||||||
blocking_conditions: sensor.mezzanine_motion_light_level > 10 and not self
|
blocking_conditions:
|
||||||
|
- sensor.mezzanine_motion_light_level > 10 and not self
|
||||||
|
- light.living_room
|
||||||
|
|
||||||
light_brightness_pct:
|
light_brightness_pct:
|
||||||
15: input_boolean.sleeping
|
15: binary_sensor.is_everybody_sleeping or binary_sensor.projector_on
|
||||||
35: binary_sensor.day_interval_night
|
35: binary_sensor.day_interval_night
|
||||||
100: True
|
100: True
|
||||||
|
|
||||||
|
off_events:
|
||||||
|
- turn_off_all_lights
|
||||||
|
- turn_off_all_living_room_lights
|
||||||
|
|
||||||
light_mezzanine_02:
|
light_mezzanine_02:
|
||||||
module: smartlight
|
module: smartlight
|
||||||
class: SmartLight
|
class: SmartLight
|
||||||
@@ -19,25 +25,118 @@ light_mezzanine_02:
|
|||||||
|
|
||||||
smart_conditions:
|
smart_conditions:
|
||||||
trigger_conditions: sensor.mezzanine_last_motion < 3
|
trigger_conditions: sensor.mezzanine_last_motion < 3
|
||||||
blocking_conditions: sensor.mezzanine_motion_light_level > 10 and not self
|
blocking_conditions:
|
||||||
|
- sensor.mezzanine_motion_light_level > 10 and not self
|
||||||
|
- light.living_room
|
||||||
|
|
||||||
light_brightness_pct:
|
light_brightness_pct:
|
||||||
5: input_boolean.sleeping
|
5: binary_sensor.is_everybody_sleeping or binary_sensor.projector_on
|
||||||
25: binary_sensor.day_interval_night
|
25: binary_sensor.day_interval_night
|
||||||
100: True
|
100: True
|
||||||
|
|
||||||
|
off_events:
|
||||||
|
- turn_off_all_lights
|
||||||
|
- turn_off_all_living_room_lights
|
||||||
|
|
||||||
light_bookshelves:
|
light_bookshelves:
|
||||||
module: smartlight
|
module: smartlight
|
||||||
class: SmartLight
|
class: SmartLight
|
||||||
entity: light.living_room_bookshelves
|
entity: light.living_room_bookshelves
|
||||||
|
|
||||||
smart_conditions:
|
smart_conditions: sensor.living_room_ambiance_light >= 1
|
||||||
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
|
|
||||||
|
|
||||||
#light_brightness_pct:
|
light_brightness_pct:
|
||||||
# 5: input_boolean.sleeping
|
25: sensor.living_room_ambiance_light == 1
|
||||||
# 25: binary_sensor.day_interval_night
|
100: True
|
||||||
# 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.smartcondition as SmartCondition
|
||||||
import ad_toolbox.smartvalue as SmartValue
|
import ad_toolbox.smartvalue as SmartValue
|
||||||
|
from ad_toolbox.eventhandler import EventHandler
|
||||||
from ad_toolbox.smartobject import SmartObject
|
from ad_toolbox.smartobject import SmartObject
|
||||||
import time
|
import time
|
||||||
|
|
||||||
class SmartHeatpump(SmartObject):
|
class SmartHeatpump(SmartObject):
|
||||||
|
|
||||||
|
AUTO_CONTROL_MODE = 'auto'
|
||||||
|
FORCE_ON_CONTROL_MODE = 'on'
|
||||||
|
FORCE_OFF_CONTROL_MODE = 'off'
|
||||||
|
|
||||||
def on_initialize_smart_object(self):
|
def on_initialize_smart_object(self):
|
||||||
super().on_initialize_smart_object()
|
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:
|
if 'quick_resume' in self.args:
|
||||||
self.quick_resume_sensor = self.create_entity(self.args['quick_resume']['sensor'])
|
self.quick_resume_sensor = self.create_entity(self.args['quick_resume']['sensor'])
|
||||||
#is_on = 'on' if self.entity.get_state() != 'off' else 'off'
|
is_on = 'on' if self.entity.get_state() != 'off' else 'off'
|
||||||
#if self.get_state(self.args['quick_resume']['sensor']) != 'on': # if the sensor doesn't exist I wan't to set it as well
|
if self.get_state(self.args['quick_resume']['sensor']) != 'on': # if the sensor doesn't exist I wan't to set it as well
|
||||||
# self.set_state(self.args['quick_resume']['sensor'],state = is_on)
|
self.set_state(self.args['quick_resume']['sensor'],state = is_on)
|
||||||
self.listen_event(lambda event_name, data, kwargs: self.set_state(self.args['quick_resume']['sensor'],state = 'off'),self.args['quick_resume']['reset_event'])
|
self.register_reset_events(self.args['quick_resume'], "quick_resume")
|
||||||
|
|
||||||
self.target_temperature = SmartValue.Evaluator(self,self.args['target_temperature'], expression_name = 'target_temperature', on_change_cb = self.on_change_target_temperature, constants = self.constants)
|
self.target_temperature = SmartValue.Evaluator(self,self.args['target_temperature'], expression_name = 'target_temperature', on_change_cb = self.on_change_target_temperature, constants = self.constants)
|
||||||
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)
|
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:
|
else:
|
||||||
self.horizontal_vane = None
|
self.horizontal_vane = None
|
||||||
|
|
||||||
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)
|
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)
|
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 'input_buttons' in self.args:
|
||||||
if 'turn_on' in self.args['input_buttons']:
|
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'])
|
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']:
|
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'])
|
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_on_button(self): self.start_cooling()
|
||||||
|
|
||||||
def on_turn_off_button(self):
|
def on_turn_off_button(self):
|
||||||
@@ -44,7 +173,7 @@ class SmartHeatpump(SmartObject):
|
|||||||
self.set_state(self.args['quick_resume']['sensor'],state = 'on')
|
self.set_state(self.args['quick_resume']['sensor'],state = 'on')
|
||||||
temperature = self.target_temperature.evaluate(False)
|
temperature = self.target_temperature.evaluate(False)
|
||||||
hvac_mode = self.hvac_mode.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.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)
|
#self.turn_on(self.entity_id)
|
||||||
@@ -53,8 +182,9 @@ class SmartHeatpump(SmartObject):
|
|||||||
self.call_service("climate/set_hvac_mode", entity_id = self.entity_id,hvac_mode = hvac_mode)
|
self.call_service("climate/set_hvac_mode", entity_id = self.entity_id,hvac_mode = hvac_mode)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
self.call_service("climate/set_temperature", entity_id = self.entity_id,temperature = temperature,hvac_mode = hvac_mode)
|
self.call_service("climate/set_temperature", entity_id = self.entity_id,temperature = temperature,hvac_mode = hvac_mode)
|
||||||
time.sleep(1)
|
if self.fan_mode:
|
||||||
self.call_service("climate/set_fan_mode", entity_id = self.entity_id,fan_mode = 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:
|
if self.horizontal_vane:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
horizontal_vane = self.horizontal_vane.evaluate(False)
|
horizontal_vane = self.horizontal_vane.evaluate(False)
|
||||||
@@ -66,6 +196,10 @@ class SmartHeatpump(SmartObject):
|
|||||||
|
|
||||||
|
|
||||||
def on_smart_conditions_change(self,prev_result,result):
|
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:
|
if result == SmartCondition.Result.Succeeded:
|
||||||
self.start_cooling()
|
self.start_cooling()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ from ad_toolbox.eventhandler import EventHandler
|
|||||||
class SmartLight(SmartSwitch):
|
class SmartLight(SmartSwitch):
|
||||||
|
|
||||||
def on_initialize_smart_object(self):
|
def on_initialize_smart_object(self):
|
||||||
|
self.presence_simulation_slots_active = set()
|
||||||
|
|
||||||
# light_brightness_pct_list : ordered list of (Evaluator, label) pairs
|
# light_brightness_pct_list : ordered list of (Evaluator, label) pairs
|
||||||
# light_brightness_pct : currently active brightness % (str label)
|
# light_brightness_pct : currently active brightness % (str label)
|
||||||
self.light_brightness_pct_list = list()
|
self.light_brightness_pct_list = list()
|
||||||
@@ -196,6 +198,15 @@ class SmartLight(SmartSwitch):
|
|||||||
# SmartSwitch overrides
|
# SmartSwitch overrides
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# While simulation is active for this light, ignore auto-off transitions
|
||||||
|
# from smart_conditions to keep deterministic on-duration windows.
|
||||||
|
def on_smart_conditions_change(self,prev_result,result):
|
||||||
|
if len(self.presence_simulation_slots_active) > 0:
|
||||||
|
if result == SmartCondition.Result.Succeeded and self.is_off():
|
||||||
|
self.switch_on()
|
||||||
|
return
|
||||||
|
super().on_smart_conditions_change(prev_result,result)
|
||||||
|
|
||||||
# Override: apply light_brightness_pct when turning on, if one is active.
|
# Override: apply light_brightness_pct when turning on, if one is active.
|
||||||
def switch_on(self):
|
def switch_on(self):
|
||||||
if self.light_brightness_pct != None:
|
if self.light_brightness_pct != None:
|
||||||
@@ -225,4 +236,61 @@ class SmartLight(SmartSwitch):
|
|||||||
self.log_info("Turning off")
|
self.log_info("Turning off")
|
||||||
self.call_service("light/turn_off", entity_id = self.entity_id)
|
self.call_service("light/turn_off", entity_id = self.entity_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Presence simulation API (called by HomeAlarm)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start_presence_simulation(self, source_app=None, slot_name=None, payload=None):
|
||||||
|
if slot_name:
|
||||||
|
self.presence_simulation_slots_active.add(slot_name)
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
brightness_pct = payload.get('brightness_pct')
|
||||||
|
transition = payload.get('transition')
|
||||||
|
|
||||||
|
if brightness_pct is None and transition is None:
|
||||||
|
self.switch_on()
|
||||||
|
return
|
||||||
|
|
||||||
|
service_kwargs = {'entity_id': self.entity_id}
|
||||||
|
if brightness_pct is not None:
|
||||||
|
service_kwargs['brightness_pct'] = brightness_pct
|
||||||
|
if transition is not None:
|
||||||
|
service_kwargs['transition'] = transition
|
||||||
|
|
||||||
|
self.call_service("light/turn_on", **service_kwargs)
|
||||||
|
|
||||||
|
def stop_presence_simulation(self, source_app=None, slot_name=None, payload=None):
|
||||||
|
if slot_name and slot_name in self.presence_simulation_slots_active:
|
||||||
|
self.presence_simulation_slots_active.remove(slot_name)
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
if payload.get('keep_on', False):
|
||||||
|
return
|
||||||
|
|
||||||
|
transition = payload.get('transition')
|
||||||
|
if transition is None:
|
||||||
|
self.switch_off()
|
||||||
|
else:
|
||||||
|
self.call_service(
|
||||||
|
"light/turn_off",
|
||||||
|
entity_id=self.entity_id,
|
||||||
|
transition=transition,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If no simulation slot is still active, re-apply current automation
|
||||||
|
# state so the light immediately converges to the expected result.
|
||||||
|
if len(self.presence_simulation_slots_active) == 0 and self.smart_conditions_evaluator:
|
||||||
|
result = self.smart_conditions_evaluator.evaluate(False)
|
||||||
|
if result == SmartCondition.Result.Succeeded:
|
||||||
|
if self.is_off():
|
||||||
|
self.switch_on()
|
||||||
|
else:
|
||||||
|
if self.is_on():
|
||||||
|
self.switch_off()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
247
apps/smartshutter.py
Normal file
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
|
DEST=$ARCHIVE_DIR/$TIMESTAMP
|
||||||
|
|
||||||
# Find log files directly in the logs directory (non-recursive, skip archive subdir)
|
# Find log files directly in the logs directory (non-recursive, skip archive subdir)
|
||||||
LOG_FILES=$(find "$LOGS_DIR" -maxdepth 1 -type f -name "*.log")
|
LOG_FILES=$(find "$LOGS_DIR" -maxdepth 1 -type f -name "*.log*")
|
||||||
|
|
||||||
if [ -z "$LOG_FILES" ]; then
|
if [ -z "$LOG_FILES" ]; then
|
||||||
echo "[archiveLogs] No log files to archive."
|
echo "[archiveLogs] No log files to archive."
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ libstdc++
|
|||||||
gcompat
|
gcompat
|
||||||
bash
|
bash
|
||||||
git
|
git
|
||||||
|
nodejs
|
||||||
|
npm
|
||||||
Reference in New Issue
Block a user