New apps Added
@@ -1 +1 @@
|
||||
2026.6.3
|
||||
2026.7.1
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 924 B |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -1 +1 @@
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.6.3", "start_ts": 1781553089.670326}
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.7.1", "start_ts": 1783446559.6159952}
|
||||
@@ -189,7 +189,7 @@
|
||||
hours: 0
|
||||
minutes: 45
|
||||
seconds: 0
|
||||
below: 1
|
||||
below: 0.2
|
||||
id: freezer_down
|
||||
- trigger: numeric_state
|
||||
entity_id:
|
||||
@@ -198,7 +198,7 @@
|
||||
hours: 0
|
||||
minutes: 20
|
||||
seconds: 0
|
||||
below: 1
|
||||
below: 0.2
|
||||
id: fridge_down
|
||||
conditions: []
|
||||
actions:
|
||||
@@ -303,14 +303,12 @@
|
||||
mode: single
|
||||
- id: '1781034724187'
|
||||
alias: 'Office: Turn Off Lights on Exit'
|
||||
description: Turns off office lights 2 minutes after the door closes, but only if living
|
||||
room motion was detected after the door shut.
|
||||
description: Turns off office lights 2 minutes after the door closes, but only if
|
||||
living room motion was detected after the door shut.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.office_door_contact
|
||||
to: 'off'
|
||||
for:
|
||||
minutes: 2
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: light.playroom_light
|
||||
@@ -319,6 +317,8 @@
|
||||
value_template: '{{ states.binary_sensor.basement.last_changed > states.binary_sensor.office_door_contact.last_changed
|
||||
}}'
|
||||
actions:
|
||||
- delay:
|
||||
minutes: 2
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id: light.playroom_light
|
||||
@@ -708,44 +708,471 @@
|
||||
entity_id: sensor.coffee_maker_power
|
||||
above: 1000
|
||||
id: coffee_started
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: input_boolean.coffee_maker_running
|
||||
state: 'off'
|
||||
actions:
|
||||
- action: input_boolean.turn_on
|
||||
target:
|
||||
entity_id: input_boolean.coffee_maker_running
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: ☕ Coffee Time
|
||||
message: Someone is making coffee.
|
||||
- action: notify.persistent_notification
|
||||
data:
|
||||
title: ☕ Coffee Time
|
||||
message: Someone is making coffee.
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 4
|
||||
seconds: 0
|
||||
- wait_template: '{{ states(''sensor.coffee_maker_power'') | float(0) < 350 }}'
|
||||
timeout:
|
||||
hours: 0
|
||||
minutes: 5
|
||||
seconds: 0
|
||||
- action: input_boolean.turn_off
|
||||
target:
|
||||
entity_id: input_boolean.coffee_maker_running
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: ☕ Coffee Ready
|
||||
message: Your coffee is ready to enjoy!
|
||||
mode: single
|
||||
- id: '1781793117883'
|
||||
alias: Basement Temperature Change
|
||||
description: ''
|
||||
triggers:
|
||||
- type: temperature
|
||||
device_id: 3e67afd88a4b4d17552c8f370aaf40b5
|
||||
entity_id: 64689573a8b11a83829efcae0022d7c1
|
||||
domain: sensor
|
||||
trigger: device
|
||||
above: 15
|
||||
below: 10
|
||||
conditions: []
|
||||
actions:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: coffee_started
|
||||
- condition: state
|
||||
entity_id: input_boolean.coffee_maker_running
|
||||
state: 'off'
|
||||
sequence:
|
||||
- action: input_boolean.turn_on
|
||||
target:
|
||||
entity_id: input_boolean.coffee_maker_running
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: ☕ Coffee Time
|
||||
message: Someone is making coffee.
|
||||
- action: notify.persistent_notification
|
||||
data:
|
||||
title: ☕ Coffee Time
|
||||
message: Someone is making coffee.
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 4
|
||||
seconds: 0
|
||||
- wait_for_trigger:
|
||||
- trigger: numeric_state
|
||||
entity_id: sensor.coffee_maker_power
|
||||
below: 350
|
||||
timeout:
|
||||
hours: 0
|
||||
minutes: 3
|
||||
seconds: 0
|
||||
- action: input_boolean.turn_off
|
||||
target:
|
||||
entity_id: input_boolean.coffee_maker_running
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: ☕ Coffee Ready
|
||||
message: Your coffee is ready to enjoy!
|
||||
- action: notify.notify
|
||||
metadata: {}
|
||||
data:
|
||||
title: Basement Temperature Change
|
||||
message: Basement Temperature Changed!
|
||||
mode: single
|
||||
- id: '1782161891986'
|
||||
alias: 'Notification: Pool Sensor Low Battery'
|
||||
description: Alert when the pool temperature sensor battery drops below 15%
|
||||
triggers:
|
||||
- entity_id: sensor.pool_temperature_battery
|
||||
below: 15
|
||||
trigger: numeric_state
|
||||
conditions: []
|
||||
actions:
|
||||
- data:
|
||||
title: Pool Sensor Battery Low
|
||||
message: The pool temperature sensor battery is currently at {{ states('sensor.pool_temperature_battery')
|
||||
}}%. Please change it soon.
|
||||
action: notify.persistent_notification
|
||||
- action: notify.notify
|
||||
metadata: {}
|
||||
data:
|
||||
title: Pool Sensor Battery Low
|
||||
message: The pool temperature sensor battery is currently at {{ states('sensor.pool_temperature_battery')
|
||||
}}%. Please change it soon.
|
||||
mode: single
|
||||
- &id001
|
||||
id: commute_morning_traffic_alert_map
|
||||
alias: 'Commute: Morning Traffic Alert with Map'
|
||||
description: Checks commute times at 7:15 AM and attaches a map if traffic is bad.
|
||||
triggers:
|
||||
- at: 07:15:00
|
||||
trigger: time
|
||||
conditions:
|
||||
- condition: or
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: '{{ states(''sensor.google_travel_time_work_work'') | float(0)
|
||||
> 35 }}'
|
||||
- condition: template
|
||||
value_template: '{{ states(''sensor.waze_travel_time'') | float(0) > 35 }}'
|
||||
actions:
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.camera_commute_map
|
||||
data:
|
||||
filename: /config/www/commute_snapshot.jpg
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: "\U0001F6A8 Commute Delay Alert!"
|
||||
message: "Traffic is heavier than usual this morning. \nGoogle Maps: {{ states('sensor.google_travel_time_work_work')
|
||||
}} mins. Waze: {{ states('sensor.waze_travel_time') }} mins."
|
||||
data:
|
||||
image: /local/commute_snapshot.jpg
|
||||
- action: notify.persistent_notification
|
||||
data:
|
||||
title: "\U0001F6A8 Commute Delay Alert!"
|
||||
message: "Traffic is heavier than usual this morning. \nGoogle Maps: {{ states('sensor.google_travel_time_work_work')
|
||||
}} mins. Waze: {{ states('sensor.waze_travel_time') }} mins.\n"
|
||||
mode: single
|
||||
- *id001
|
||||
- *id001
|
||||
- id: '1782999027626'
|
||||
alias: 'Commute: Morning Traffic Alert'
|
||||
description: Pushes an interactive, live traffic map to my phone before the commute.
|
||||
triggers:
|
||||
- at: 07:15:00
|
||||
trigger: time
|
||||
actions:
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: Morning Commute Traffic
|
||||
message: Swipe down to view live traffic along your route.
|
||||
data:
|
||||
push:
|
||||
category: map
|
||||
action_data:
|
||||
latitude: '45.474668'
|
||||
longitude: '-73.737158'
|
||||
shows_traffic: true
|
||||
shows_scale: true
|
||||
- id: '1783000087983'
|
||||
alias: Morning Commute Alert
|
||||
description: Notify when commute is longer than 35 minutes.
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: 07:15:00
|
||||
conditions:
|
||||
- condition: time
|
||||
weekday:
|
||||
- mon
|
||||
- tue
|
||||
- wed
|
||||
- thu
|
||||
- fri
|
||||
- condition: or
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.google_travel_time_work_work
|
||||
above: 35
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.waze_travel_time
|
||||
above: 35
|
||||
actions:
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: "\U0001F697 Morning Commute"
|
||||
message: "Google: {{ states('sensor.google_travel_time_work_work') }} min\nWaze:
|
||||
{{ states('sensor.waze_travel_time') }} min\n{% set g = states('sensor.google_travel_time_work_work')
|
||||
| float(0) %} {% if g > 50 %} \U0001F6A8 Major traffic delays. {% elif g >
|
||||
40 %} ⚠️ Heavy traffic. {% else %} \U0001F7E1 Moderate traffic. {% endif %}"
|
||||
mode: single
|
||||
- id: '1783049020714'
|
||||
alias: Aqara-Magic-Cube-T1-Pro-(CTP-R01) - Franco
|
||||
description: ''
|
||||
use_blueprint:
|
||||
path: SirGoodenough/Zigbee2MQTT-Aqara-Magic-Cube-T1-Pro-CTP-R01-Xiaomi-Lumi.yaml
|
||||
input:
|
||||
topic: zigbee2mqtt/Ziggy
|
||||
action_slide_face_1:
|
||||
- action: light.toggle
|
||||
metadata: {}
|
||||
target:
|
||||
device_id: 2f8c5ab2ca0d4be31919fba6df365813
|
||||
data:
|
||||
brightness_pct: 25
|
||||
action_doubletap_face_1: []
|
||||
action_rotate_cw_face_1:
|
||||
- action: script.cube_dimmer_control
|
||||
metadata: {}
|
||||
data:
|
||||
light: light.playroom_light
|
||||
angle: -20
|
||||
action_rotate_ccw_face_1: []
|
||||
action_flipped90_face_1: []
|
||||
- id: '1783162325964'
|
||||
alias: Backyard Shed Camera Motion AI Description
|
||||
description: Takes a snapshot when the Blink camera detects motion, analyzes it
|
||||
with AI, and sends a mobile and persistent notification.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.backyard_shed_motion
|
||||
to: 'on'
|
||||
actions:
|
||||
- action: blink.trigger_camera
|
||||
target:
|
||||
entity_id: camera.backyard_shed
|
||||
- delay: 00:00:02
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.backyard_shed
|
||||
data:
|
||||
filename: /media/blink.jpg
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.backyard_shed
|
||||
data:
|
||||
filename: /config/www/blink.jpg
|
||||
- delay: 00:00:02
|
||||
- action: ai_task.generate_data
|
||||
continue_on_error: true
|
||||
data:
|
||||
task_name: Backyard Shed Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
instructions: Describe what you see in this image in brief. Focus on any people,
|
||||
objects, animals or activities. Text needs to be max 240 characters.
|
||||
attachments:
|
||||
- media_content_id: media-source://media_source/local/blink.jpg
|
||||
media_content_type: image/jpeg
|
||||
response_variable: ai_profile
|
||||
- action: notify.notify
|
||||
continue_on_error: true
|
||||
data:
|
||||
title: Motion Detected - Backyard Shed
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}'
|
||||
- action: notify.persistent_notification
|
||||
data:
|
||||
title: Motion Detected - Backyard Shed
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}
|
||||
|
||||
'
|
||||
data:
|
||||
notification_id: backyard_shed_motion
|
||||
mode: queued
|
||||
max: 10
|
||||
- id: '1783293479398'
|
||||
alias: Blink Camera Fast Update Cycle
|
||||
description: Polls the Blink cloud every 30 seconds to bypass the default 5-minute
|
||||
delay and force instant motion detection state updates.
|
||||
triggers:
|
||||
- trigger: time_pattern
|
||||
seconds: /30
|
||||
actions:
|
||||
- action: homeassistant.update_entity
|
||||
target:
|
||||
entity_id:
|
||||
- binary_sensor.backyard_shed_motion
|
||||
- binary_sensor.front_door_motion
|
||||
mode: single
|
||||
- id: '1783304677475'
|
||||
alias: Motion Detection AI Tree
|
||||
description: Takes a snapshot when the Blink camera detects motion, analyzes it
|
||||
with AI, and sends a mobile and persistent notification.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.backyard_tree_motion
|
||||
to: 'on'
|
||||
conditions: []
|
||||
actions:
|
||||
- action: blink.trigger_camera
|
||||
target:
|
||||
entity_id: camera.backyard_tree
|
||||
- delay: 00:00:02
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.backyard_tree
|
||||
data:
|
||||
filename: /media/blinkTree.jpg
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.backyard_tree
|
||||
data:
|
||||
filename: /config/www/blinkTree.jpg
|
||||
- delay: 00:00:02
|
||||
- action: ai_task.generate_data
|
||||
continue_on_error: true
|
||||
data:
|
||||
task_name: Backyard Shed Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
instructions: Describe what you see in this image in brief. Focus on any people,
|
||||
objects, animals or activities. Text needs to be max 240 characters.
|
||||
attachments:
|
||||
- media_content_id: media-source://media_source/local/blinkTree.jpg
|
||||
media_content_type: image/jpeg
|
||||
response_variable: ai_profile
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: Motion Detected - Backyard Tree
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}'
|
||||
- action: persistent_notification.create
|
||||
data:
|
||||
title: Motion Detected - Backyard Tree
|
||||
notification_id: backyard_tree_motion
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}
|
||||
|
||||
'
|
||||
mode: queued
|
||||
max: 10
|
||||
- id: '1783387792679'
|
||||
description: Takes a snapshot when the Blink camera detects motion, analyzes it
|
||||
with AI, and sends a mobile and persistent notification.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.backyard_shed_motion
|
||||
to: 'on'
|
||||
conditions: []
|
||||
actions:
|
||||
- action: blink.trigger_camera
|
||||
target:
|
||||
entity_id: camera.living_room_living_room_camera
|
||||
- delay: 00:00:02
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.living_room_living_room_camera
|
||||
data:
|
||||
filename: /media/blink.jpg
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.living_room_living_room_camera
|
||||
data:
|
||||
filename: /config/www/nest.jpg
|
||||
- delay: 00:00:02
|
||||
- action: ai_task.generate_data
|
||||
continue_on_error: true
|
||||
data:
|
||||
task_name: Backyard Shed Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
instructions: Describe what you see in this image in brief. Focus on any people,
|
||||
objects, or activities. Text needs to be max 225 characters.
|
||||
attachments:
|
||||
- media_content_id: media-source://media_source/local/nest.jpg
|
||||
media_content_type: image/jpeg
|
||||
response_variable: ai_profile
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: Motion Detected - Backyard Shed
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}'
|
||||
- action: persistent_notification.create
|
||||
data:
|
||||
title: Motion Detected - Backyard Shed
|
||||
notification_id: backyard_shed_motion2
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}
|
||||
|
||||
'
|
||||
mode: queued
|
||||
max: 10
|
||||
- id: '1783452320698'
|
||||
alias: Nest Test Motion
|
||||
description: Takes a snapshot when the Blink camera detects motion, analyzes it
|
||||
with AI, and sends a mobile and persistent notification.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.backyard_tree_motion
|
||||
to: 'on'
|
||||
conditions: []
|
||||
actions:
|
||||
- delay: 00:00:02
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id:
|
||||
- camera.front
|
||||
- camera.backyard_shed
|
||||
data:
|
||||
filename: /media/nest_Living2.jpg
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.backyard_tree
|
||||
data:
|
||||
filename: /config/www/blinkTree.jpg
|
||||
- delay: 00:00:02
|
||||
- action: ai_task.generate_data
|
||||
continue_on_error: true
|
||||
data:
|
||||
task_name: Backyard Shed Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
instructions: Describe what you see in this image in brief. Focus on any people,
|
||||
objects, or activities. Text needs to be max 225 characters.
|
||||
attachments:
|
||||
- media_content_id: media-source://media_source/local/blinkTree.jpg
|
||||
media_content_type: image/jpeg
|
||||
response_variable: ai_profile
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: Motion Detected - Backyard Tree
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}'
|
||||
- action: persistent_notification.create
|
||||
data:
|
||||
title: Motion Detected - Backyard Tree
|
||||
notification_id: backyard_tree_motion
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}
|
||||
|
||||
'
|
||||
mode: queued
|
||||
max: 10
|
||||
- id: '1783469453396'
|
||||
alias: Motion Front Door - AI Description
|
||||
description: Takes a snapshot when the Nest camera detects motion, a person, an
|
||||
animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent
|
||||
notification.
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: state_changed
|
||||
event_data:
|
||||
entity_id: event.front_door_front_door_motion
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'',
|
||||
''person'', ''animal'', ''chime''] }}'
|
||||
actions:
|
||||
- action: blink.trigger_camera
|
||||
target:
|
||||
entity_id: camera.front
|
||||
data: {}
|
||||
- delay: 00:00:02
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.front
|
||||
data:
|
||||
filename: /media/blink_Front.jpg
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.front
|
||||
data:
|
||||
filename: /config/www/blink_Front.jpg
|
||||
- delay: 00:00:02
|
||||
- action: ai_task.generate_data
|
||||
continue_on_error: true
|
||||
data:
|
||||
task_name: Nest Front Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
instructions: Describe what you see in this image in brief. Focus on any people,
|
||||
objects, animals or activities. Text needs to be max 240 characters.
|
||||
attachments:
|
||||
- media_content_id: media-source://media_source/local/blink_Front.jpg
|
||||
media_content_type: image/jpeg
|
||||
response_variable: ai_profile
|
||||
- action: notify.notify
|
||||
continue_on_error: true
|
||||
data:
|
||||
title: Motion Detected - Backyard Shed
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}'
|
||||
- action: notify.persistent_notification
|
||||
data:
|
||||
title: Motion Detected - Backyard Shed
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}
|
||||
|
||||
'
|
||||
data:
|
||||
notification_id: nest_front_motion
|
||||
mode: queued
|
||||
max: 10
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
blueprint:
|
||||
name: dimmer_control_4_magic_cube_and_other_things - 2023-12-07
|
||||
author: SirGoodenough
|
||||
domain: script
|
||||
homeassistant:
|
||||
min_version: 2023.8.0
|
||||
source_url: https://github.com/SirGoodenough/HA_Blueprints/blob/master/Scripts/dimmer_control_4_magic_cube.yaml
|
||||
description: "This script was invented to be used with my Magic Cube Blueprints.
|
||||
I released it as a standalone Script Blueprint because I saw others struggling
|
||||
with how so convert cube rotation to light dimming in other places.\n\nTo use
|
||||
this blueprint, install it from the source in the normal way. After that add your
|
||||
unique name for the Script, and add an icon and change the entity_id if desired.
|
||||
You should only need to run the blueprint once, as you will be calling the unique
|
||||
name/entity you added above to use the code. This script has 2 inputs in the Blueprint
|
||||
screen to set the sensitivity of the rotation action and the minimum bbrightness
|
||||
level for your lights. If the rotation is too sensitive the lights will change
|
||||
from dim to bright in too short of a rotation so make it less sensitive. If the
|
||||
lights flicker at low brightness, raise the minimum brightness level to stop it.
|
||||
The data is sent to the generated script to control the action live at each use.
|
||||
You only need to ececute this blueprint generator script if you wish to change
|
||||
in input values or create another dimmer script.\n\nThis requires you call this
|
||||
script wth 2 data values. One is a positive or negative number between 360 and
|
||||
-360 that in the original, represents the input angle from the cube movement.
|
||||
The other data point is the entity of the light or light group that you are trying
|
||||
to control.\n***NOTE:*** the trigger variable below will be different for Z2M
|
||||
and ZHA and others. Look at my documentation for that cube integration help if
|
||||
you need it.\n\n## \U0001F5FFNotice\U0001F46E\U0001F3FF♂️:\n* Copies of the original
|
||||
Blueprint that were converted via the 'Take Control' feature or other means are
|
||||
officially not supported by me.\n\n* I may or may not be able to support you when
|
||||
you have a problem after you make changes to my code, as some of the code is no
|
||||
longer mine.\n\n* I & my license also require attribution as a link back to the
|
||||
original should you use this code in your own creation.\n\n* [Here is a link to
|
||||
my license & the original github post](https://github.com/SirGoodenough/HA_Blueprints?tab=License-1-ov-file)
|
||||
expected to be followed & referenced as attribution should you use this code elsewhere.\n\n
|
||||
\ Sample call / use of this script for ZHA:\n ```yaml\n - action: script.cube_dimmer_control_bp\n
|
||||
\ data:\n angle: '{{ trigger.event.data.args.relative_degrees | default(0.1)
|
||||
| float(0.2) }}'\n light: light.grp_studio\n ```\n\n Sample call /
|
||||
use of this script for Z2M:\n ```yaml\n - action: script.cube_dimmer_control_bp\n
|
||||
\ data:\n angle: '{{ trigger.payload_json.action_angle | default(0.1)
|
||||
| float(0.2) }}'\n light: light.grp_kitch\n ```\n"
|
||||
input:
|
||||
sensi:
|
||||
name: Sensitivity of the cube rotational motion.
|
||||
description: 'This sets how ''fast'' the changes happen compared to how far
|
||||
you move the cube. Higher percentage is faster, lower is slower.
|
||||
|
||||
I have found that 40% is a good number, but you can adjust as desired.
|
||||
|
||||
'
|
||||
default: 40
|
||||
selector:
|
||||
number:
|
||||
min: 1.0
|
||||
max: 200.0
|
||||
step: 1.0
|
||||
mode: box
|
||||
min_b:
|
||||
name: Minimum Brighness
|
||||
description: 'This sets the lowest brightness it will set before it turns off.
|
||||
Some bulbs react poorly below a certain percent, you can adjust that here.
|
||||
|
||||
I have found that 10% is a good number, but you can adjust as desired.
|
||||
|
||||
'
|
||||
default: 10
|
||||
selector:
|
||||
number:
|
||||
min: 5.0
|
||||
max: 90.0
|
||||
step: 1.0
|
||||
mode: slider
|
||||
transition_i:
|
||||
name: Transition Input
|
||||
description: Seconds to take to change light one level to another
|
||||
default: []
|
||||
selector:
|
||||
number:
|
||||
min: 0.0
|
||||
max: 1800.0
|
||||
step: 0.5
|
||||
mode: slider
|
||||
fields:
|
||||
angle:
|
||||
name: Angle to represent new brightness
|
||||
description: ' This is the new angle to be used in the calculation for Brightness'
|
||||
required: true
|
||||
example: '-120.7'
|
||||
default: -120.7
|
||||
selector:
|
||||
number:
|
||||
min: -360
|
||||
max: 360
|
||||
step: 0.1
|
||||
mode: box
|
||||
light:
|
||||
name: Light entity to control
|
||||
description: Add the light entity you want to control here
|
||||
required: true
|
||||
example: light.grp_studio
|
||||
default: light.grp_studio
|
||||
selector:
|
||||
entity:
|
||||
multiple: false
|
||||
filter:
|
||||
domain: light
|
||||
transition:
|
||||
name: Transition Field
|
||||
description: Seconds to take to change light one level to another
|
||||
required: false
|
||||
example: '4'
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 1800
|
||||
step: 0.5
|
||||
variables:
|
||||
sensi_var: !input sensi
|
||||
sens_d: '{{ sensi_var / 100 | default( 0.4 ) }}'
|
||||
min_b_var: !input min_b
|
||||
bright_p: "{% set step_size = angle * sens_d %}\n {# Get brightness as a percent.
|
||||
#}\n{% set cb = (state_attr( light, 'brightness') | float(10) / 255.0) * 100.0
|
||||
%} {% set new_brightness = cb | int(10) + step_size %} {% if new_brightness <
|
||||
5 %}\n {# If it gets really low set to 0. Adjust if needed for your lights. #}\n
|
||||
\ 0\n{% elif new_brightness <= min_b_var %}\n {# If it's not quite 0, set to
|
||||
a minimum working brightness. #}\n {{ min_b_var }}\n{% elif 91 <= new_brightness
|
||||
< (90 + step_size) %}\n {# If it's almost full brightness, set to 100%. #}\n
|
||||
\ 100\n{% else %}\n {# Send actual calculated value. #}\n{{ new_brightness }}
|
||||
{% endif %}"
|
||||
trans_i: !input transition_i
|
||||
transition_var: "{% if is_number(transition | default('none')) %}\n {{ transition
|
||||
}}\n{% elif is_number(trans_i | default('none')) %}\n {{ trans_i }}\n{% else
|
||||
%}\n none\n{% endif %}"
|
||||
sequence:
|
||||
- if:
|
||||
- alias: turn the light & set brightness
|
||||
condition: template
|
||||
value_template: '{{ is_number(transition_var) }}'
|
||||
then:
|
||||
- alias: Transition number IS available
|
||||
action: light.turn_on
|
||||
data:
|
||||
entity_id: '{{ light }}'
|
||||
brightness_pct: '{{ bright_p }}'
|
||||
transition: '{{ transition_var }}'
|
||||
else:
|
||||
- alias: NO transition number available
|
||||
action: light.turn_on
|
||||
data:
|
||||
entity_id: '{{ light }}'
|
||||
brightness_pct: '{{ bright_p }}'
|
||||
icon: mdi:lightbulb-on-50
|
||||
mode: queued
|
||||
max: 10
|
||||
@@ -60,13 +60,13 @@ template:
|
||||
device_class: power
|
||||
state_class: measurement
|
||||
state: >
|
||||
{% set hvac = states.climate | selectattr('entity_id', 'search', 'living_room') | map(attribute='entity_id') | first %}
|
||||
{% if hvac %}
|
||||
{% set hvac = 'climate.living_room_living_room' %}
|
||||
{% if states(hvac) not in ['unknown', 'unavailable'] %}
|
||||
{% set action = state_attr(hvac, 'hvac_action') | string | lower %}
|
||||
{% if action == 'cooling' %}
|
||||
2.12
|
||||
{% elif action == 'heating' %}
|
||||
{% if is_state_attr(hvac, 'is_aux_heat', true) %}
|
||||
{% if is_state_attr(hvac, 'preset_mode', 'eco') or is_state_attr(hvac, 'aux_heat', 'on') %}
|
||||
20.5
|
||||
{% else %}
|
||||
2.5
|
||||
@@ -116,9 +116,7 @@ http:
|
||||
use_x_forwarded_for: true
|
||||
trusted_proxies:
|
||||
- 192.168.122.1
|
||||
- 192.168.1.130
|
||||
- 192.168.1.125
|
||||
- 192.168.1.135
|
||||
- 192.168.1.0/24
|
||||
|
||||
# Legacy and Integration Sensor Section
|
||||
sensor:
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
|
||||
from .store import SlideshowStore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[str] = ["camera", "sensor", "button", "number", "select", "text", "switch"]
|
||||
|
||||
CARD_STATIC_PATH = "/album_slideshow_static"
|
||||
CARD_FILE = "album-slideshow-card.js"
|
||||
|
||||
|
||||
async def _async_register_card(hass: HomeAssistant) -> None:
|
||||
"""Serve the Lovelace card JS and register it as a frontend module.
|
||||
|
||||
Idempotent: only the first config entry to load triggers registration
|
||||
for the HA session. The card lets dashboards cross-fade between slides
|
||||
in the browser (GPU compositor) instead of forcing the camera entity
|
||||
to render a JPEG burst on the event loop.
|
||||
"""
|
||||
if hass.data.get(DOMAIN, {}).get("card_registered"):
|
||||
return
|
||||
|
||||
integration_dir = os.path.dirname(__file__)
|
||||
www_dir = os.path.join(integration_dir, "www")
|
||||
card_path = os.path.join(www_dir, CARD_FILE)
|
||||
|
||||
if not os.path.isfile(card_path):
|
||||
# Some HACS upgrade paths (and broken zip extractors) drop the
|
||||
# ``www/`` subdirectory. Try to recover by checking whether the
|
||||
# integration root has the file under a literal-backslash name
|
||||
# (a symptom of zips written with Windows path separators) or
|
||||
# directly at the root, and salvage it into ``www/`` so the
|
||||
# rest of the registration can proceed.
|
||||
recovered = await hass.async_add_executor_job(
|
||||
_recover_card_from_root, integration_dir, www_dir, card_path
|
||||
)
|
||||
if not recovered:
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow card missing on disk (%s). Re-install"
|
||||
" the integration via HACS (3-dot menu -> Redownload)"
|
||||
" or copy %s/%s into the album_slideshow folder."
|
||||
" The custom:album-slideshow-card type will not be"
|
||||
" available until this is fixed.",
|
||||
card_path,
|
||||
"www",
|
||||
CARD_FILE,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(CARD_STATIC_PATH, www_dir, False)]
|
||||
)
|
||||
except Exception: # noqa: BLE001 - many possible failure modes here
|
||||
_LOGGER.exception(
|
||||
"Failed to register static path for Album Slideshow card"
|
||||
)
|
||||
return
|
||||
|
||||
# Cache-bust the card URL with the integration version so dashboards
|
||||
# always pick up the script that matches the running integration
|
||||
# rather than a stale copy from a previous release.
|
||||
version = await hass.async_add_executor_job(
|
||||
_read_manifest_version, integration_dir
|
||||
)
|
||||
card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
|
||||
if version:
|
||||
card_url = f"{card_url}?v={version}"
|
||||
|
||||
# Prefer registering the card as a Lovelace resource for storage-mode
|
||||
# dashboards. Resources are loaded as part of the Lovelace bootstrap,
|
||||
# before any dashboard renders custom cards, which removes the race
|
||||
# where the dashboard can hit "Custom element doesn't exist" if the
|
||||
# browser hasn't finished loading the module yet (a real risk after
|
||||
# HA restarts and integration upgrades that bust the cache).
|
||||
#
|
||||
# The storage-mode resources collection is only consumed by
|
||||
# storage-mode dashboards; YAML-mode dashboards (whether the user
|
||||
# has ``lovelace.mode: yaml`` globally or per-dashboard
|
||||
# ``mode: yaml`` entries) read only the resources declared in their
|
||||
# own YAML and would otherwise never load the card. That's why we
|
||||
# *always* also call ``add_extra_js_url``: the frontend injects a
|
||||
# ``<script>`` tag on every Lovelace render regardless of mode, so
|
||||
# it's the universal fallback. Module loads are deduplicated by URL
|
||||
# and the card's ``customElements.define()`` calls have an
|
||||
# idempotency guard, so a storage-mode dashboard picking up both
|
||||
# paths is a harmless no-op.
|
||||
resource_registered = await _try_register_lovelace_resource(hass, card_url)
|
||||
if resource_registered:
|
||||
hass.data.setdefault(DOMAIN, {})[
|
||||
"lovelace_resource_registered"
|
||||
] = True
|
||||
|
||||
try:
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
|
||||
add_extra_js_url(hass, card_url)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception(
|
||||
"Failed to add Album Slideshow card via add_extra_js_url"
|
||||
" (URL %s); the card may still load if a Lovelace resource"
|
||||
" was registered above.",
|
||||
card_url,
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})["card_registered"] = True
|
||||
if resource_registered:
|
||||
_LOGGER.info(
|
||||
"Album Slideshow card registered (Lovelace resource +"
|
||||
" add_extra_js_url) at %s",
|
||||
card_url,
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Album Slideshow card registered via add_extra_js_url at"
|
||||
" %s; Lovelace resources collection was not available so"
|
||||
" no resource entry was created.",
|
||||
card_url,
|
||||
)
|
||||
|
||||
|
||||
async def _try_register_lovelace_resource(
|
||||
hass: HomeAssistant, card_url: str
|
||||
) -> bool:
|
||||
"""Register the card as a Lovelace resource for storage-mode dashboards.
|
||||
|
||||
Returns ``True`` if the resource was added (or already present at the
|
||||
requested version); ``False`` if Lovelace isn't loaded, the dashboard
|
||||
is in YAML mode, or anything else went wrong - in which case the
|
||||
caller is expected to fall back to ``add_extra_js_url``.
|
||||
|
||||
The resource collection API is internal to HA and has changed shape
|
||||
between versions, so we feel our way through it with ``getattr`` and
|
||||
swallow any unexpected exception. The downside of getting this wrong
|
||||
is one extra script tag in the dashboard, not a crash.
|
||||
"""
|
||||
try:
|
||||
lovelace_data = hass.data.get("lovelace")
|
||||
if lovelace_data is None:
|
||||
_LOGGER.debug(
|
||||
"Lovelace data not yet present; cannot register resource"
|
||||
)
|
||||
return False
|
||||
|
||||
# In recent HA the lovelace key is a LovelaceData object exposing
|
||||
# ``resources``; in older versions it was a dict with the same key.
|
||||
if isinstance(lovelace_data, dict):
|
||||
resources = lovelace_data.get("resources")
|
||||
else:
|
||||
resources = getattr(lovelace_data, "resources", None)
|
||||
|
||||
if resources is None or not hasattr(resources, "async_create_item"):
|
||||
# YAML-mode dashboards expose a ResourceYAMLCollection that is
|
||||
# read-only; users edit ``configuration.yaml`` themselves.
|
||||
_LOGGER.debug(
|
||||
"Lovelace resources unavailable or read-only;"
|
||||
" falling back to add_extra_js_url"
|
||||
)
|
||||
return False
|
||||
|
||||
# Make sure the storage collection has loaded its file. Some HA
|
||||
# versions lazy-load on first ``async_items()`` access; calling
|
||||
# ``async_load`` explicitly is safe either way.
|
||||
if hasattr(resources, "async_load"):
|
||||
try:
|
||||
await resources.async_load()
|
||||
except Exception: # noqa: BLE001
|
||||
# Storage collection may be in an unloaded state with no
|
||||
# file yet - treated the same as "no items".
|
||||
pass
|
||||
|
||||
items = []
|
||||
if hasattr(resources, "async_items"):
|
||||
try:
|
||||
items = list(resources.async_items())
|
||||
except Exception: # noqa: BLE001
|
||||
items = []
|
||||
|
||||
base = card_url.split("?", 1)[0]
|
||||
same_version_present = False
|
||||
stale_ids: list[str] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
url = item.get("url", "") or ""
|
||||
item_id = item.get("id")
|
||||
else:
|
||||
url = getattr(item, "url", "") or ""
|
||||
item_id = getattr(item, "id", None)
|
||||
if url.split("?", 1)[0] != base:
|
||||
continue
|
||||
if url == card_url:
|
||||
same_version_present = True
|
||||
elif item_id:
|
||||
stale_ids.append(item_id)
|
||||
|
||||
# Strip stale registrations (older versions of the card pinned via
|
||||
# an out-of-date ``?v=...`` query) so the dashboard doesn't pull
|
||||
# both the new and the old script.
|
||||
for item_id in stale_ids:
|
||||
try:
|
||||
await resources.async_delete_item(item_id)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Could not delete stale Lovelace resource id=%s",
|
||||
item_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not same_version_present:
|
||||
await resources.async_create_item(
|
||||
{"url": card_url, "res_type": "module"}
|
||||
)
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Lovelace resource registration failed; will fall back to"
|
||||
" add_extra_js_url",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _read_manifest_version(integration_dir: str) -> str | None:
|
||||
try:
|
||||
with open(
|
||||
os.path.join(integration_dir, "manifest.json"),
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as fh:
|
||||
return json.load(fh).get("version")
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _recover_card_from_root(
|
||||
integration_dir: str, www_dir: str, card_path: str
|
||||
) -> bool:
|
||||
"""Salvage the card file from a broken extraction.
|
||||
|
||||
PowerShell's ``Compress-Archive`` writes zip entries with backslash
|
||||
separators, which Linux unzip implementations may treat as literal
|
||||
filenames. The resulting layout is::
|
||||
|
||||
custom_components/album_slideshow/www\\album-slideshow-card.js
|
||||
|
||||
instead of the expected ``www/album-slideshow-card.js``. Move it to
|
||||
the right place so subsequent installs don't need a re-download.
|
||||
"""
|
||||
candidates = [
|
||||
os.path.join(integration_dir, f"www\\{CARD_FILE}"),
|
||||
os.path.join(integration_dir, CARD_FILE),
|
||||
]
|
||||
for src in candidates:
|
||||
if os.path.isfile(src):
|
||||
try:
|
||||
os.makedirs(www_dir, exist_ok=True)
|
||||
os.replace(src, card_path)
|
||||
_LOGGER.info(
|
||||
"Recovered Album Slideshow card from %s", src
|
||||
)
|
||||
return True
|
||||
except OSError:
|
||||
_LOGGER.exception(
|
||||
"Found candidate card at %s but could not move it"
|
||||
" to %s",
|
||||
src,
|
||||
card_path,
|
||||
)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
async def _async_cleanup_legacy_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
registry = er.async_get(hass)
|
||||
# Server-side transitions were tried in earlier 0.7-rc builds and
|
||||
# removed before the first public 0.7 pre-release because the
|
||||
# resource cost on low-end hardware was too high. Drop any leftover
|
||||
# transition entities so users don't see stale disabled rows under
|
||||
# the device.
|
||||
legacy_unique_ids = {
|
||||
f"{entry.entry_id}_max_items",
|
||||
f"{entry.entry_id}_transition",
|
||||
f"{entry.entry_id}_transition_duration_ms",
|
||||
f"{entry.entry_id}_transition_fps",
|
||||
}
|
||||
|
||||
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
|
||||
if entity.unique_id in legacy_unique_ids:
|
||||
registry.async_remove(entity.entity_id)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
"""Register the Lovelace card during HA bootstrap.
|
||||
|
||||
Running this in ``async_setup`` (not only in ``async_setup_entry``)
|
||||
registers the card's static path and frontend resource at
|
||||
integration load time, well before any config entry finishes
|
||||
setting up. The earlier the resource is registered, the smaller
|
||||
the window in which a dashboard can render a card before the
|
||||
script's ``customElements.define()`` has run, which is what
|
||||
produces the intermittent "Custom element doesn't exist:
|
||||
album-slideshow-card" errors on slow devices (tablets) that open
|
||||
the dashboard during HA startup.
|
||||
|
||||
We also retry the Lovelace storage-resource registration once HA
|
||||
finishes starting, because ``hass.data['lovelace']`` may not yet
|
||||
be populated when ``async_setup`` runs. The storage-resource path
|
||||
is the only mechanism that *gates* dashboard render on resource
|
||||
load (``add_extra_js_url`` just injects a script tag with no
|
||||
ordering guarantee), so we want it to succeed even if we beat
|
||||
Lovelace to the punch on the first attempt.
|
||||
"""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
await _async_register_card(hass)
|
||||
|
||||
async def _retry_lovelace_resource(_event) -> None:
|
||||
if hass.data.get(DOMAIN, {}).get("lovelace_resource_registered"):
|
||||
return
|
||||
integration_dir = os.path.dirname(__file__)
|
||||
version = await hass.async_add_executor_job(
|
||||
_read_manifest_version, integration_dir
|
||||
)
|
||||
card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
|
||||
if version:
|
||||
card_url = f"{card_url}?v={version}"
|
||||
if await _try_register_lovelace_resource(hass, card_url):
|
||||
hass.data.setdefault(DOMAIN, {})[
|
||||
"lovelace_resource_registered"
|
||||
] = True
|
||||
_LOGGER.info(
|
||||
"Album Slideshow card registered as Lovelace resource"
|
||||
" on HA started (late retry; Lovelace was not yet"
|
||||
" ready during integration setup)"
|
||||
)
|
||||
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STARTED, _retry_lovelace_resource
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
from .coordinator import AlbumCoordinator
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
# Domain-wide concurrency limit on compose work. Multiple album cameras
|
||||
# share HA's small executor pool; without coordination they can all
|
||||
# decode + render in parallel and saturate the loop. One ticket means
|
||||
# at most one album does PIL work at a time, queueing the rest.
|
||||
if "compose_semaphore" not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN]["compose_semaphore"] = asyncio.Semaphore(1)
|
||||
|
||||
# Register the Lovelace card once per HA session. The card runs the
|
||||
# GPU-composited transitions in the browser so the camera entity
|
||||
# itself never has to render a transition burst on the event loop.
|
||||
await _async_register_card(hass)
|
||||
|
||||
await _async_cleanup_legacy_entities(hass, entry)
|
||||
|
||||
store = SlideshowStore()
|
||||
coordinator = AlbumCoordinator(hass, entry, store)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
"coordinator": coordinator,
|
||||
"store": store,
|
||||
"camera": None,
|
||||
}
|
||||
|
||||
async def _next_slide(call) -> None:
|
||||
entry_id = call.data.get(ATTR_ENTRY_ID)
|
||||
if not entry_id:
|
||||
return
|
||||
data = hass.data.get(DOMAIN, {}).get(entry_id)
|
||||
if not data:
|
||||
return
|
||||
cam = data.get("camera")
|
||||
if cam:
|
||||
await cam.async_force_next()
|
||||
|
||||
async def _refresh_album(call) -> None:
|
||||
entry_id = call.data.get(ATTR_ENTRY_ID)
|
||||
if not entry_id:
|
||||
return
|
||||
data = hass.data.get(DOMAIN, {}).get(entry_id)
|
||||
if not data:
|
||||
return
|
||||
cam = data.get("camera")
|
||||
if cam:
|
||||
await cam.async_force_refresh()
|
||||
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_NEXT_SLIDE):
|
||||
hass.services.async_register(DOMAIN, SERVICE_NEXT_SLIDE, _next_slide)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM):
|
||||
hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
store.notify()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
domain_data.pop(entry.entry_id, None)
|
||||
# Drop the shared semaphore once the last album is gone so it's
|
||||
# re-created if the integration is re-added later.
|
||||
entry_keys = [
|
||||
k for k in domain_data.keys() if k != "compose_semaphore"
|
||||
]
|
||||
if not entry_keys:
|
||||
domain_data.pop("compose_semaphore", None)
|
||||
return unload_ok
|
||||
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
|
||||
from .coordinator import AlbumCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
async_add_entities(
|
||||
[
|
||||
NextSlideButton(hass, entry, coordinator),
|
||||
RefreshAlbumButton(hass, entry, coordinator),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _BaseButton(ButtonEntity):
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.coordinator = coordinator
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
|
||||
class NextSlideButton(_BaseButton):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(hass, entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_next_button"
|
||||
self._attr_name = "Next slide"
|
||||
self._attr_icon = "mdi:skip-next"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_NEXT_SLIDE,
|
||||
{ATTR_ENTRY_ID: self.entry.entry_id},
|
||||
blocking=False,
|
||||
)
|
||||
|
||||
|
||||
class RefreshAlbumButton(_BaseButton):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(hass, entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_refresh_button"
|
||||
self._attr_name = "Refresh album"
|
||||
self._attr_icon = "mdi:refresh"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_REFRESH_ALBUM,
|
||||
{ATTR_ENTRY_ID: self.entry.entry_id},
|
||||
blocking=False,
|
||||
)
|
||||
@@ -0,0 +1,922 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import async_timeout
|
||||
from PIL import Image
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
MAX_RESOLUTION_SHORT_EDGE,
|
||||
ORIENTATION_MISMATCH_PAIR,
|
||||
ORIENTATION_MISMATCH_AVOID,
|
||||
ORDER_ALBUM,
|
||||
ORDER_RANDOM,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
)
|
||||
from . import image_processing as ip
|
||||
from . import playlist
|
||||
from .coordinator import AlbumCoordinator, MediaItem
|
||||
from .store import SlideshowStore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Cap a single download at 64 MB. Larger images are rejected before decode
|
||||
# to protect low-memory devices. This is well above any realistic camera
|
||||
# JPEG; RAW/NEF/etc. aren't supported as camera frames anyway.
|
||||
_MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024
|
||||
|
||||
# Only these content types are accepted as image bodies. If a server returns
|
||||
# HTML (captive portal, 404 page rendered as 200, etc.) we reject it early.
|
||||
_ACCEPTED_IMAGE_PREFIX = ("image/",)
|
||||
|
||||
# Max candidates we'll scan when searching for a mismatched-orientation
|
||||
# pairing partner. Metadata-only checks are nearly free; decode-only checks
|
||||
# (no metadata available) are expensive.
|
||||
_PAIR_SEARCH_LIMIT = 12
|
||||
_SKIP_SEARCH_LIMIT = 30
|
||||
|
||||
|
||||
def _ts_to_iso(ts_ms: int | None) -> str | None:
|
||||
"""Convert epoch milliseconds to an ISO-8601 string in UTC, or None."""
|
||||
if not isinstance(ts_ms, int):
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat()
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class _DownloadCache:
|
||||
"""Byte-budget LRU cache for downloaded image data, O(1) per operation."""
|
||||
|
||||
def __init__(self, max_bytes: int) -> None:
|
||||
self._cache: "OrderedDict[str, bytes]" = OrderedDict()
|
||||
self._total_bytes: int = 0
|
||||
self._max_bytes: int = max(max_bytes, 1)
|
||||
|
||||
@property
|
||||
def total_bytes(self) -> int:
|
||||
return self._total_bytes
|
||||
|
||||
def get(self, url: str) -> bytes | None:
|
||||
data = self._cache.get(url)
|
||||
if data is None:
|
||||
return None
|
||||
self._cache.move_to_end(url)
|
||||
return data
|
||||
|
||||
def put(self, url: str, data: bytes) -> None:
|
||||
if len(data) > self._max_bytes:
|
||||
# Item exceeds the entire cache budget; skip caching but don't raise.
|
||||
return
|
||||
if url in self._cache:
|
||||
self._total_bytes -= len(self._cache[url])
|
||||
del self._cache[url]
|
||||
self._cache[url] = data
|
||||
self._total_bytes += len(data)
|
||||
self._evict()
|
||||
|
||||
def resize(self, max_bytes: int) -> None:
|
||||
self._max_bytes = max(max_bytes, 1)
|
||||
self._evict()
|
||||
|
||||
def _evict(self) -> None:
|
||||
while self._total_bytes > self._max_bytes and self._cache:
|
||||
_, data = self._cache.popitem(last=False)
|
||||
self._total_bytes -= len(data)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
|
||||
cam = AlbumSlideshowCamera(hass, entry, coordinator, store)
|
||||
hass.data[DOMAIN][entry.entry_id]["camera"] = cam
|
||||
|
||||
async_add_entities([cam])
|
||||
|
||||
|
||||
class AlbumSlideshowCamera(Camera):
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
|
||||
super().__init__()
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.coordinator = coordinator
|
||||
self.store = store
|
||||
|
||||
self._attr_name = f"Album Slideshow {entry.title}"
|
||||
self._attr_unique_id = f"{entry.entry_id}_camera"
|
||||
|
||||
self._rng = random.Random()
|
||||
self._index = 0
|
||||
self._random_order: list[int] = []
|
||||
self._random_pos = 0
|
||||
|
||||
self._download_cache = _DownloadCache(
|
||||
max_bytes=store.image_cache_mb * 1024 * 1024
|
||||
)
|
||||
self._recent_urls: list[str] = []
|
||||
self._last_is_portrait: bool | None = None
|
||||
# When the current frame is a paired image, this is [taken_a, taken_b]
|
||||
# ISO strings (top/left first); None for single frames.
|
||||
self._last_captured_at_pair: list[str | None] | None = None
|
||||
# Full per-half caption metadata for a paired frame: a list of two
|
||||
# dicts (top/left first) each carrying captured_at / location /
|
||||
# latitude / longitude. None for single frames. Lets the Lovelace
|
||||
# card overlay an accurate caption on each half of a pair.
|
||||
self._last_pair_frames: list[dict] | None = None
|
||||
# ``horizontal`` (side-by-side, left/right) or ``vertical`` (stacked,
|
||||
# top/bottom) for a paired frame; None for single frames.
|
||||
self._last_pair_orientation: str | None = None
|
||||
# Cached effective playlist (after date filter + ordering). Invalidated
|
||||
# by any store change or coordinator update.
|
||||
self._effective_cache: tuple[int, list[MediaItem]] | None = None
|
||||
|
||||
self._framebuffer: bytes | None = None
|
||||
|
||||
# MJPEG subscribers. Each open stream owns an asyncio.Queue of JPEG
|
||||
# byte payloads. The render loop pushes the latest still as soon
|
||||
# as it's encoded; if a subscriber falls behind we drop frames
|
||||
# for that subscriber rather than block the whole loop.
|
||||
self._mjpeg_subscribers: set[asyncio.Queue[bytes]] = set()
|
||||
|
||||
# Monotonic counter incremented every time a new still is committed.
|
||||
# Exposed as the ``frame_id`` state attribute so the Lovelace card
|
||||
# has an unambiguous "new frame ready" signal even when other
|
||||
# attributes happen not to change between slides.
|
||||
self._frame_id: int = 0
|
||||
|
||||
self._interrupt_event: asyncio.Event = asyncio.Event()
|
||||
self._force_next: bool = False
|
||||
self._consecutive_failures: int = 0
|
||||
self._render_task: asyncio.Task | None = None
|
||||
|
||||
def _on_coordinator_update() -> None:
|
||||
self._effective_cache = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
coordinator.async_add_listener(_on_coordinator_update)
|
||||
|
||||
def _on_store_change() -> None:
|
||||
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
|
||||
self._effective_cache = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
store.add_listener(_on_store_change)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
# Restore last framebuffer (if the store kept one) so the camera has
|
||||
# something to show immediately after a restart, rather than a broken
|
||||
# image placeholder while the first render completes.
|
||||
restored = getattr(self.store, "last_frame", None)
|
||||
if isinstance(restored, (bytes, bytearray)) and restored:
|
||||
self._framebuffer = bytes(restored)
|
||||
# Stagger the first render across multiple albums so they don't all
|
||||
# decode + encode at the same instant on HA startup. Deterministic
|
||||
# offset based on entry_id keeps the pattern stable across
|
||||
# restarts. Up to ~3 s spread across albums.
|
||||
startup_delay = (hash(self.entry.entry_id) % 3000) / 1000.0
|
||||
self._render_task = self.hass.async_create_background_task(
|
||||
self._render_loop(initial_delay=startup_delay),
|
||||
name="album_slideshow_render_loop",
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
if self._render_task is not None:
|
||||
self._render_task.cancel()
|
||||
try:
|
||||
await self._render_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
if self.coordinator.provider == PROVIDER_GOOGLE_SHARED:
|
||||
return "mdi:google-photos"
|
||||
return "mdi:folder-multiple-image"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
data = self.coordinator.data or {}
|
||||
items: list[MediaItem] = self._effective_items()
|
||||
cur = items[self._index] if items and 0 <= self._index < len(items) else None
|
||||
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
|
||||
captured_at_pair = self._last_captured_at_pair
|
||||
return {
|
||||
"album_title": data.get("title"),
|
||||
"media_count": len(items),
|
||||
"media_count_total": len(data.get("items", []) or []),
|
||||
"current_index": self._index,
|
||||
"current_filename": getattr(cur, "filename", None),
|
||||
"current_url": getattr(cur, "url", None),
|
||||
"current_is_portrait": self._last_is_portrait,
|
||||
"captured_at": captured_at_pair if captured_at_pair else captured_at,
|
||||
"captured_at_primary": captured_at,
|
||||
"uploaded_at": _ts_to_iso(getattr(cur, "uploaded_at", None)),
|
||||
"byte_size": getattr(cur, "byte_size", None),
|
||||
# GPS + reverse-geocoded label come from EXIF for local-folder
|
||||
# entries; Google albums leave these as ``None``.
|
||||
"latitude": getattr(cur, "latitude", None),
|
||||
"longitude": getattr(cur, "longitude", None),
|
||||
"location": getattr(cur, "location", None),
|
||||
# Structured per-image caption metadata. A single-element list for
|
||||
# normal slides; two elements (top/left first) for paired slides,
|
||||
# so the card can overlay an accurate date/location on each half.
|
||||
# ``pair_orientation`` tells the card how the two halves are laid
|
||||
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
|
||||
"caption_frames": self._caption_frames(cur, captured_at),
|
||||
"pair_orientation": self._last_pair_orientation,
|
||||
"slide_interval": int(self.store.slide_interval),
|
||||
"fill_mode": self.store.fill_mode,
|
||||
"portrait_mode": self.store.portrait_mode,
|
||||
"order_mode": self.store.order_mode,
|
||||
"date_filter": self.store.date_filter,
|
||||
"paused": bool(self.store.paused),
|
||||
"refresh_hours": int(self.store.refresh_hours),
|
||||
"aspect_ratio": self.store.aspect_ratio,
|
||||
"pair_divider_px": int(self.store.pair_divider_px),
|
||||
"pair_divider_color": self.store.pair_divider_color,
|
||||
"frame_id": self._frame_id,
|
||||
"pagination_debug": data.get("pagination_debug"),
|
||||
}
|
||||
|
||||
def _caption_frames(self, cur, captured_at: str | None) -> list[dict]:
|
||||
"""Per-image caption metadata for the current slide.
|
||||
|
||||
Returns a list with one dict for a normal slide, or two (top/left
|
||||
first) for a paired slide. Each dict carries ``captured_at`` (ISO
|
||||
string or ``None``), ``location`` (human label or ``None``), and
|
||||
``latitude`` / ``longitude``. The card reads this to overlay an
|
||||
accurate caption on each image, including each half of a pair.
|
||||
"""
|
||||
if self._last_pair_frames:
|
||||
return self._last_pair_frames
|
||||
return [
|
||||
{
|
||||
"captured_at": captured_at,
|
||||
"location": getattr(cur, "location", None),
|
||||
"latitude": getattr(cur, "latitude", None),
|
||||
"longitude": getattr(cur, "longitude", None),
|
||||
}
|
||||
]
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the camera proxy URL with a per-frame cache-buster.
|
||||
|
||||
HA core's default ``entity_picture`` only changes when the access
|
||||
token rotates (about every five minutes). Browsers happily serve
|
||||
the cached image to the more-info dialog and other surfaces in
|
||||
between rotations, so they end up showing the previous slide
|
||||
while a fresh slide is already in the framebuffer. Appending the
|
||||
``frame_id`` invalidates that cache as soon as a new slide is
|
||||
committed, no matter where in HA the picture is rendered.
|
||||
"""
|
||||
base = super().entity_picture
|
||||
if not base:
|
||||
return base
|
||||
sep = "&" if "?" in base else "?"
|
||||
return f"{base}{sep}frame={self._frame_id}"
|
||||
|
||||
@property
|
||||
def cache_usage_mb(self) -> float:
|
||||
return round(self._download_cache.total_bytes / (1024 * 1024), 1)
|
||||
|
||||
def _effective_items(self) -> list[MediaItem]:
|
||||
"""Return the playlist after applying the date filter and order mode.
|
||||
|
||||
Cached until the coordinator or store changes (see invalidations
|
||||
wired up in __init__).
|
||||
"""
|
||||
data = self.coordinator.data or {}
|
||||
raw: list[MediaItem] = data.get("items", []) or []
|
||||
cache_key = (
|
||||
id(raw),
|
||||
self.store.date_filter,
|
||||
self.store.order_mode,
|
||||
)
|
||||
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
|
||||
return self._effective_cache[1]
|
||||
|
||||
filtered = playlist.filter_items(
|
||||
raw,
|
||||
mode=self.store.date_filter,
|
||||
)
|
||||
ordered = playlist.order_items(filtered, self.store.order_mode)
|
||||
self._effective_cache = (hash(cache_key), ordered)
|
||||
return ordered
|
||||
|
||||
async def async_force_next(self) -> None:
|
||||
self._force_next = True
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_force_refresh(self) -> None:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
|
||||
return self._framebuffer
|
||||
|
||||
async def handle_async_mjpeg_stream(self, request):
|
||||
"""Stream the slideshow as multipart MJPEG.
|
||||
|
||||
Each open client gets a bounded asyncio.Queue that the render loop
|
||||
pushes JPEG payloads into when a new still is committed. Visible
|
||||
transitions are now handled by the Lovelace card on the client
|
||||
side, so this stream just emits the latest still per slide change.
|
||||
"""
|
||||
# Imported lazily so the module still loads in test environments
|
||||
# that stub out homeassistant without installing aiohttp.
|
||||
from aiohttp import web
|
||||
|
||||
boundary = "frame"
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={
|
||||
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
|
||||
"Cache-Control": "no-cache, private",
|
||||
"Pragma": "no-cache",
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
# Bounded queue: a slow client should fall behind on slide commits
|
||||
# rather than balloon memory.
|
||||
queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=4)
|
||||
self._mjpeg_subscribers.add(queue)
|
||||
|
||||
# Push the held still immediately so the client renders something
|
||||
# before the next slide change.
|
||||
if self._framebuffer is not None:
|
||||
try:
|
||||
queue.put_nowait(self._framebuffer)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
try:
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
try:
|
||||
await response.write(
|
||||
b"--" + boundary.encode() + b"\r\n"
|
||||
b"Content-Type: image/jpeg\r\n"
|
||||
b"Content-Length: " + str(len(payload)).encode() + b"\r\n\r\n"
|
||||
+ payload + b"\r\n"
|
||||
)
|
||||
except (ConnectionResetError, asyncio.CancelledError):
|
||||
raise
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Album Slideshow: mjpeg client write failed: %s", err)
|
||||
break
|
||||
except (ConnectionResetError, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
self._mjpeg_subscribers.discard(queue)
|
||||
try:
|
||||
await response.write_eof()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
# Older HA cores may dispatch via the alt name; alias for compatibility.
|
||||
async def async_handle_async_mjpeg_stream(self, request):
|
||||
return await self.handle_async_mjpeg_stream(request)
|
||||
|
||||
async def _wait_or_interrupt(self, timeout: float) -> bool:
|
||||
"""Wait up to ``timeout`` seconds, returning True if interrupted.
|
||||
|
||||
Safe wrapper around clear() + wait_for() - callers don't have to
|
||||
worry about the ordering of the two operations. The clear() runs
|
||||
synchronously before the awaitable is created, so no interrupt can
|
||||
be lost on the single-threaded event loop.
|
||||
"""
|
||||
self._interrupt_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def _render_loop(self, initial_delay: float = 0.0) -> None:
|
||||
"""Background task: render slides into _framebuffer, advance on timer or interrupt."""
|
||||
if initial_delay > 0:
|
||||
try:
|
||||
await asyncio.sleep(initial_delay)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
should_advance = False # Don't advance on the very first render
|
||||
while True:
|
||||
try:
|
||||
await self._render_cycle(advance=should_advance)
|
||||
self._consecutive_failures = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
self._consecutive_failures += 1
|
||||
backoff = min(2 ** self._consecutive_failures, 60)
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: render cycle failed (attempt %d), retrying in %ds: %s",
|
||||
self._consecutive_failures, backoff, err,
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
should_advance = True # Skip the broken image on retry
|
||||
continue
|
||||
|
||||
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
|
||||
if interrupted:
|
||||
should_advance = self._force_next
|
||||
self._force_next = False
|
||||
else:
|
||||
# Paused slideshows hold the current frame until the user
|
||||
# un-pauses or hits "next slide" explicitly.
|
||||
should_advance = not bool(self.store.paused)
|
||||
|
||||
async def _render_cycle(self, advance: bool) -> None:
|
||||
"""Render one frame.
|
||||
|
||||
The slideshow is just "advance index, compose, encode, broadcast".
|
||||
Visible transitions are handled by the Lovelace card client-side,
|
||||
so this path stays minimal: at most one PIL decode + encode per
|
||||
slide change.
|
||||
|
||||
Compose work is serialised across all albums via a domain-wide
|
||||
semaphore so 4 cameras don't all decode + encode at once.
|
||||
"""
|
||||
items: list[MediaItem] = self._effective_items()
|
||||
if not items:
|
||||
return
|
||||
|
||||
count = len(items)
|
||||
if advance:
|
||||
self._do_advance(count, items)
|
||||
|
||||
async with self._compose_semaphore:
|
||||
composed, meta = await self._compose_for_index(items)
|
||||
if composed is None:
|
||||
return
|
||||
try:
|
||||
await self._commit_composed(composed, meta)
|
||||
finally:
|
||||
ip.safe_close(composed)
|
||||
|
||||
async def _commit_composed(self, composed: Image.Image, meta: dict) -> None:
|
||||
"""Encode the composed slide into the framebuffer and broadcast.
|
||||
|
||||
Encodes off the loop so the JPEG encode (30-80 ms at 1080p, more
|
||||
at 4K) doesn't block HA.
|
||||
"""
|
||||
encoded = await self.hass.async_add_executor_job(
|
||||
ip.encode_image, composed
|
||||
)
|
||||
|
||||
self._framebuffer = encoded
|
||||
self.store.last_frame = encoded
|
||||
self._frame_id += 1
|
||||
if meta:
|
||||
self._last_is_portrait = meta.get("is_portrait")
|
||||
else:
|
||||
self._last_is_portrait = None
|
||||
self._last_captured_at_pair = meta.get("captured_at_pair") if meta else None
|
||||
self._last_pair_frames = meta.get("pair_frames") if meta else None
|
||||
self._last_pair_orientation = meta.get("pair_orientation") if meta else None
|
||||
|
||||
self._broadcast_frame(encoded)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def _compose_semaphore(self) -> asyncio.Semaphore:
|
||||
"""Return the domain-wide compose semaphore, creating it on demand.
|
||||
|
||||
``__init__.py`` populates it during setup, but defensive
|
||||
initialisation here means a partially-loaded integration can
|
||||
still render without crashing.
|
||||
"""
|
||||
domain_data = self.hass.data.setdefault(DOMAIN, {})
|
||||
sem = domain_data.get("compose_semaphore")
|
||||
if sem is None:
|
||||
sem = asyncio.Semaphore(1)
|
||||
domain_data["compose_semaphore"] = sem
|
||||
return sem
|
||||
|
||||
def _broadcast_frame(self, payload: bytes) -> None:
|
||||
"""Push a frame to every active MJPEG subscriber.
|
||||
|
||||
Slow subscribers get their frame dropped rather than backing up the
|
||||
queue; the next still emission will catch them up.
|
||||
"""
|
||||
for queue in list(self._mjpeg_subscribers):
|
||||
try:
|
||||
queue.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
# Drain one and retry once so a wedged client still sees
|
||||
# the latest frame eventually instead of forever stale.
|
||||
try:
|
||||
queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
queue.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
def _do_advance(self, count: int, items: list) -> None:
|
||||
"""Advance _index to the next slide and commit random-order position."""
|
||||
if count <= 0:
|
||||
self._index = 0
|
||||
return
|
||||
|
||||
self._index %= count
|
||||
order_mode = self.store.order_mode
|
||||
|
||||
# Sequential modes (album order + sorted-by-time orderings) walk in
|
||||
# order. The list is already pre-sorted by ``order_items``, so we
|
||||
# only need to step forward.
|
||||
if order_mode != ORDER_RANDOM:
|
||||
self._index = (self._index + 1) % count
|
||||
return
|
||||
|
||||
self._index = self._next_random_index(count)
|
||||
cur_url = items[self._index].url
|
||||
self._recent_urls.append(cur_url)
|
||||
keep = min(20, max(1, count - 1))
|
||||
if len(self._recent_urls) > keep:
|
||||
self._recent_urls = self._recent_urls[-keep:]
|
||||
|
||||
def _peek_advance(self, count: int, items: list) -> None:
|
||||
"""Advance _index without committing to random-order bookkeeping.
|
||||
|
||||
Used by the orientation-avoid search so that rejected candidates
|
||||
don't burn through the random cycle and cause premature repeats.
|
||||
"""
|
||||
if count <= 0:
|
||||
self._index = 0
|
||||
return
|
||||
self._index = (self._index + 1) % count
|
||||
|
||||
async def _compose_for_index(
|
||||
self, items: list[MediaItem]
|
||||
) -> tuple[Image.Image | None, dict | None]:
|
||||
"""Compose the slide at ``self._index`` into a PIL image.
|
||||
|
||||
Returns ``(composed, meta)`` where ``meta`` carries the orientation
|
||||
and paired-capture metadata that ``_commit_composed`` will publish
|
||||
as state attributes. Returns ``(None, None)`` if compose failed.
|
||||
|
||||
Pure compose - does NOT mutate ``self._framebuffer`` or
|
||||
``self._last_*`` state. The caller commits via ``_commit_composed``.
|
||||
"""
|
||||
fill_mode = self.store.fill_mode
|
||||
portrait_mode = self.store.portrait_mode
|
||||
divider = max(0, int(self.store.pair_divider_px))
|
||||
divider_fill, transparent_divider = ip.parse_divider_color(self.store.pair_divider_color)
|
||||
max_short_edge = MAX_RESOLUTION_SHORT_EDGE.get(self.store.max_resolution)
|
||||
width, height = ip.resolve_output_size(None, None, self.store.aspect_ratio, max_short_edge)
|
||||
|
||||
cur = items[self._index]
|
||||
is_portrait_canvas = height > width
|
||||
|
||||
# Metadata fast path: if we can resolve orientation without downloading,
|
||||
# we may short-circuit the mismatch handling before any bytes are read.
|
||||
meta_portrait = ip.is_portrait_item_by_metadata(cur)
|
||||
if (
|
||||
meta_portrait is not None
|
||||
and meta_portrait != is_portrait_canvas
|
||||
and portrait_mode == ORIENTATION_MISMATCH_AVOID
|
||||
):
|
||||
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
|
||||
|
||||
cur_bytes = await self._fetch_bytes(cur.url)
|
||||
if not cur_bytes:
|
||||
raise RuntimeError(f"Failed to fetch image: {cur.url}")
|
||||
|
||||
img = await self.hass.async_add_executor_job(
|
||||
ip.open_image, cur_bytes, (width, height)
|
||||
)
|
||||
try:
|
||||
cur_is_portrait = ip.is_portrait_item(cur, img)
|
||||
orientation_mismatch = cur_is_portrait != is_portrait_canvas
|
||||
|
||||
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_AVOID:
|
||||
ip.safe_close(img)
|
||||
img = None
|
||||
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
|
||||
|
||||
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_PAIR:
|
||||
pair = await self._find_next_mismatch_image(
|
||||
items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
|
||||
)
|
||||
other_img = pair[0] if pair else None
|
||||
other_item = pair[1] if pair else None
|
||||
pair_meta: list[str | None] | None = None
|
||||
pair_frames: list[dict] | None = None
|
||||
try:
|
||||
if other_img is not None:
|
||||
composed = await self.hass.async_add_executor_job(
|
||||
ip.pair_images, img, other_img, width, height, fill_mode,
|
||||
is_portrait_canvas, divider, divider_fill, transparent_divider,
|
||||
)
|
||||
pair_frames = [
|
||||
{
|
||||
"captured_at": _ts_to_iso(getattr(cur, "captured_at", None)),
|
||||
"location": getattr(cur, "location", None),
|
||||
"latitude": getattr(cur, "latitude", None),
|
||||
"longitude": getattr(cur, "longitude", None),
|
||||
},
|
||||
{
|
||||
"captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
|
||||
"location": getattr(other_item, "location", None),
|
||||
"latitude": getattr(other_item, "latitude", None),
|
||||
"longitude": getattr(other_item, "longitude", None),
|
||||
},
|
||||
]
|
||||
pair_meta = [f["captured_at"] for f in pair_frames]
|
||||
else:
|
||||
composed = await self.hass.async_add_executor_job(
|
||||
ip.render_image, img, fill_mode, width, height,
|
||||
)
|
||||
finally:
|
||||
ip.safe_close(other_img)
|
||||
meta = {
|
||||
"is_portrait": cur_is_portrait,
|
||||
"captured_at_pair": pair_meta,
|
||||
"pair_frames": pair_frames,
|
||||
# ``pair_images`` stacks images top/bottom on a portrait
|
||||
# canvas and places them left/right on a landscape canvas.
|
||||
"pair_orientation": (
|
||||
("vertical" if is_portrait_canvas else "horizontal")
|
||||
if pair_frames
|
||||
else None
|
||||
),
|
||||
}
|
||||
return composed, meta
|
||||
|
||||
composed = await self.hass.async_add_executor_job(
|
||||
ip.render_image, img, fill_mode, width, height
|
||||
)
|
||||
return composed, {
|
||||
"is_portrait": cur_is_portrait,
|
||||
"captured_at_pair": None,
|
||||
}
|
||||
finally:
|
||||
ip.safe_close(img)
|
||||
|
||||
async def _compose_skip_mismatch(
|
||||
self,
|
||||
items: list[MediaItem],
|
||||
width: int,
|
||||
height: int,
|
||||
fill_mode: str,
|
||||
is_portrait_canvas: bool,
|
||||
) -> tuple[Image.Image | None, dict | None]:
|
||||
"""Skip-mismatch variant of ``_compose_for_index``.
|
||||
|
||||
Walks forward (peek-advancing for non-matches) until it finds an
|
||||
image whose orientation matches the canvas, then composes it.
|
||||
"""
|
||||
count = len(items)
|
||||
if count <= 0:
|
||||
return None, None
|
||||
|
||||
start = self._index
|
||||
|
||||
for _ in range(min(count, _SKIP_SEARCH_LIMIT)):
|
||||
cur = items[self._index]
|
||||
|
||||
meta_portrait = ip.is_portrait_item_by_metadata(cur)
|
||||
if meta_portrait is not None:
|
||||
if meta_portrait != is_portrait_canvas:
|
||||
self._peek_advance(count, items)
|
||||
continue
|
||||
if self._index != start:
|
||||
self._do_advance(count, items)
|
||||
return await self._compose_single(cur, width, height, fill_mode)
|
||||
|
||||
b = await self._fetch_bytes(cur.url)
|
||||
if not b:
|
||||
self._peek_advance(count, items)
|
||||
continue
|
||||
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
|
||||
try:
|
||||
if ip.is_portrait_item(cur, img) != is_portrait_canvas:
|
||||
self._peek_advance(count, items)
|
||||
continue
|
||||
if self._index != start:
|
||||
self._do_advance(count, items)
|
||||
composed = await self.hass.async_add_executor_job(
|
||||
ip.render_image, img, fill_mode, width, height
|
||||
)
|
||||
return composed, {
|
||||
"is_portrait": is_portrait_canvas,
|
||||
"captured_at_pair": None,
|
||||
}
|
||||
finally:
|
||||
ip.safe_close(img)
|
||||
|
||||
self._index = start
|
||||
return await self._compose_single(items[self._index], width, height, fill_mode)
|
||||
|
||||
async def _compose_single(
|
||||
self,
|
||||
item: MediaItem,
|
||||
width: int,
|
||||
height: int,
|
||||
fill_mode: str,
|
||||
) -> tuple[Image.Image | None, dict | None]:
|
||||
b = await self._fetch_bytes(item.url)
|
||||
if not b:
|
||||
return None, None
|
||||
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
|
||||
try:
|
||||
cur_is_portrait = ip.is_portrait_item(item, img)
|
||||
composed = await self.hass.async_add_executor_job(
|
||||
ip.render_image, img, fill_mode, width, height
|
||||
)
|
||||
return composed, {
|
||||
"is_portrait": cur_is_portrait,
|
||||
"captured_at_pair": None,
|
||||
}
|
||||
finally:
|
||||
ip.safe_close(img)
|
||||
|
||||
async def _render_current(self, items: list[MediaItem]) -> bytes | None:
|
||||
"""Compatibility wrapper: compose + encode the current slide.
|
||||
|
||||
Kept as a thin wrapper because external code paths (e.g., tests)
|
||||
may still call it. ``_render_cycle`` no longer does.
|
||||
"""
|
||||
composed, _ = await self._compose_for_index(items)
|
||||
if composed is None:
|
||||
return None
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(ip.encode_image, composed)
|
||||
finally:
|
||||
ip.safe_close(composed)
|
||||
|
||||
async def _find_next_mismatch_image(
|
||||
self,
|
||||
items: list[MediaItem],
|
||||
is_portrait_canvas: bool,
|
||||
width: int,
|
||||
height: int,
|
||||
limit: int = _PAIR_SEARCH_LIMIT,
|
||||
) -> tuple[Image.Image, MediaItem] | None:
|
||||
"""Find an image with the opposite orientation of the canvas.
|
||||
|
||||
Uses metadata wherever possible - only candidates without width/height
|
||||
metadata are downloaded and decoded for their orientation. The returned
|
||||
PIL image is the caller's to close. The matching ``MediaItem`` is
|
||||
returned alongside so the caller can attribute timestamps etc.
|
||||
"""
|
||||
if not items:
|
||||
return None
|
||||
n = len(items)
|
||||
tries = 0
|
||||
offset = 1
|
||||
while tries < limit and offset < n:
|
||||
idx = (self._index + offset) % n
|
||||
it = items[idx]
|
||||
offset += 1
|
||||
tries += 1
|
||||
|
||||
if it.url in self._recent_urls:
|
||||
continue
|
||||
|
||||
meta_portrait = ip.is_portrait_item_by_metadata(it)
|
||||
if meta_portrait is not None and meta_portrait == is_portrait_canvas:
|
||||
# Metadata says this one is the wrong orientation for pairing; skip.
|
||||
continue
|
||||
|
||||
b = await self._fetch_bytes(it.url)
|
||||
if not b:
|
||||
continue
|
||||
|
||||
try:
|
||||
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if ip.is_portrait_item(it, img) != is_portrait_canvas:
|
||||
return img, it
|
||||
ip.safe_close(img)
|
||||
|
||||
return None
|
||||
|
||||
def _next_random_index(self, count: int) -> int:
|
||||
if count <= 1:
|
||||
self._random_order = [0]
|
||||
self._random_pos = 0
|
||||
return 0
|
||||
|
||||
needs_new_cycle = len(self._random_order) != count or self._random_pos >= len(self._random_order)
|
||||
if needs_new_cycle:
|
||||
self._random_order = list(range(count))
|
||||
self._rng.shuffle(self._random_order)
|
||||
self._random_pos = 0
|
||||
|
||||
if self._random_order and self._random_order[0] == self._index:
|
||||
self._random_order.append(self._random_order.pop(0))
|
||||
|
||||
idx = self._random_order[self._random_pos]
|
||||
self._random_pos += 1
|
||||
return idx
|
||||
|
||||
async def _fetch_bytes(self, url: str) -> bytes | None:
|
||||
cached = self._download_cache.get(url)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if url.startswith("file://"):
|
||||
try:
|
||||
p = Path(url[7:])
|
||||
data = await self.hass.async_add_executor_job(p.read_bytes)
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Album Slideshow: failed to read local image: %s", err)
|
||||
return None
|
||||
if len(data) > _MAX_DOWNLOAD_BYTES:
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: local image %s is %d bytes, exceeds %d byte limit; skipping",
|
||||
url, len(data), _MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
data = await self._http_get(url)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
self._download_cache.put(url, data)
|
||||
return data
|
||||
|
||||
async def _http_get(self, url: str) -> bytes | None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with async_timeout.timeout(30):
|
||||
async with session.get(url) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
primary = content_type.split(";", 1)[0].strip().lower()
|
||||
if primary and not primary.startswith(_ACCEPTED_IMAGE_PREFIX):
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow: rejecting %s, content-type %r is not an image",
|
||||
url, primary,
|
||||
)
|
||||
return None
|
||||
|
||||
content_length = resp.headers.get("Content-Length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared = int(content_length)
|
||||
except ValueError:
|
||||
declared = -1
|
||||
if declared > _MAX_DOWNLOAD_BYTES:
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: %s advertises %d bytes, exceeds %d byte limit; skipping",
|
||||
url, declared, _MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
return None
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in resp.content.iter_chunked(64 * 1024):
|
||||
total += len(chunk)
|
||||
if total > _MAX_DOWNLOAD_BYTES:
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: %s exceeded %d byte limit mid-download; aborting",
|
||||
url, _MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Album Slideshow: failed to fetch image: %s", err)
|
||||
return None
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_PROVIDER,
|
||||
CONF_ALBUM_NAME,
|
||||
CONF_ALBUM_URL,
|
||||
CONF_LOCAL_PATH,
|
||||
CONF_RECURSIVE,
|
||||
CONF_REVERSE_GEOCODE,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_local_path(hass, path: str) -> str:
|
||||
p = path.strip()
|
||||
if p.startswith("/local/"):
|
||||
p = "/config/www/" + p[len("/local/"):]
|
||||
elif p == "/local":
|
||||
p = "/config/www"
|
||||
elif p.startswith("local/"):
|
||||
p = "/config/www/" + p[len("local/"):]
|
||||
elif p.startswith("/media/local/"):
|
||||
p = "/media/" + p[len("/media/local/"):]
|
||||
elif p.startswith("media/local/"):
|
||||
p = "/media/" + p[len("media/local/"):]
|
||||
elif p.startswith("media/"):
|
||||
p = "/media/" + p[len("media/"):]
|
||||
elif p == "media":
|
||||
p = "/media"
|
||||
if not p.startswith("/"):
|
||||
p = hass.config.path(p)
|
||||
return p
|
||||
|
||||
|
||||
ALBUM_URL_RE = re.compile(r"^https?://photos\.app\.goo\.gl/[^/]+/?$")
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._provider: str | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
) -> config_entries.OptionsFlow:
|
||||
"""Return the options flow handler.
|
||||
|
||||
Only local-folder entries expose user-tunable options today (the
|
||||
reverse-geocode toggle); Google entries get a no-op handler so
|
||||
that the "Configure" button doesn't appear empty in the UI.
|
||||
|
||||
Note: do NOT pass ``config_entry`` to the OptionsFlow constructor.
|
||||
Since Home Assistant 2024.12 the base class manages
|
||||
``self.config_entry`` as a property and assigning to it in
|
||||
``__init__`` raises (the symptom is a 500 when the user clicks
|
||||
Configure).
|
||||
"""
|
||||
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_LOCAL_FOLDER:
|
||||
return LocalFolderOptionsFlow()
|
||||
return _NoOptionsFlow()
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||
if user_input is not None:
|
||||
self._provider = user_input[CONF_PROVIDER]
|
||||
if self._provider == PROVIDER_LOCAL_FOLDER:
|
||||
return await self.async_step_local_folder()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PROVIDER, default=PROVIDER_GOOGLE_SHARED): vol.In({
|
||||
PROVIDER_GOOGLE_SHARED: "Google Photos",
|
||||
PROVIDER_LOCAL_FOLDER: "Local Folder",
|
||||
})
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=schema)
|
||||
|
||||
async def async_step_google_shared(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
url = user_input[CONF_ALBUM_URL].strip()
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
|
||||
if not ALBUM_URL_RE.match(url):
|
||||
errors[CONF_ALBUM_URL] = "invalid_album_url"
|
||||
else:
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}")
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_GOOGLE_SHARED,
|
||||
CONF_ALBUM_URL: url,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_NAME): str,
|
||||
vol.Required(CONF_ALBUM_URL): str,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="google_shared", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_local_folder(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
path = _normalize_local_path(self.hass, user_input[CONF_LOCAL_PATH])
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
recursive = bool(user_input.get(CONF_RECURSIVE, DEFAULT_RECURSIVE))
|
||||
|
||||
if not path:
|
||||
errors[CONF_LOCAL_PATH] = "invalid_path"
|
||||
else:
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}")
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_LOCAL_FOLDER,
|
||||
CONF_LOCAL_PATH: path,
|
||||
CONF_RECURSIVE: recursive,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_NAME): str,
|
||||
vol.Required(CONF_LOCAL_PATH): str,
|
||||
vol.Optional(CONF_RECURSIVE, default=DEFAULT_RECURSIVE): bool,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="local_folder", data_schema=schema, errors=errors)
|
||||
|
||||
|
||||
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Options for local-folder entries.
|
||||
|
||||
Currently exposes a single toggle: ``reverse_geocode``. Users with
|
||||
privacy concerns about handing EXIF GPS coordinates to an external
|
||||
OSM endpoint can turn this off; the GPS coordinates remain available
|
||||
as ``latitude``/``longitude`` attributes regardless.
|
||||
|
||||
``self.config_entry`` is provided by ``OptionsFlow`` as a managed
|
||||
property (HA 2024.12+); we deliberately do NOT define ``__init__``
|
||||
or assign to it, since doing so raises in newer cores.
|
||||
"""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
current = self.config_entry.options.get(
|
||||
CONF_REVERSE_GEOCODE, DEFAULT_REVERSE_GEOCODE
|
||||
)
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_REVERSE_GEOCODE, default=bool(current)
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="init", data_schema=schema)
|
||||
|
||||
|
||||
class _NoOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Fallback options flow for providers that expose nothing tunable."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
return self.async_create_entry(title="", data={})
|
||||
@@ -0,0 +1,94 @@
|
||||
DOMAIN = "album_slideshow"
|
||||
|
||||
CONF_PROVIDER = "provider"
|
||||
CONF_ALBUM_URL = "album_url"
|
||||
CONF_ALBUM_NAME = "album_name"
|
||||
CONF_LOCAL_PATH = "local_path"
|
||||
CONF_RECURSIVE = "recursive"
|
||||
CONF_IMAGE_CACHE_MB = "image_cache_mb"
|
||||
# Local-folder option: when True (default) the coordinator does best-effort
|
||||
# reverse geocoding of EXIF GPS coordinates via the public Nominatim
|
||||
# (OpenStreetMap) endpoint and exposes a human-readable ``location``
|
||||
# attribute. Users with privacy concerns can disable this from the
|
||||
# integration's options dialog.
|
||||
CONF_REVERSE_GEOCODE = "reverse_geocode"
|
||||
DEFAULT_REVERSE_GEOCODE = True
|
||||
|
||||
PROVIDER_GOOGLE_SHARED = "google_shared"
|
||||
PROVIDER_LOCAL_FOLDER = "local_folder"
|
||||
|
||||
FILL_COVER = "cover"
|
||||
FILL_CONTAIN = "contain"
|
||||
FILL_BLUR = "blur"
|
||||
|
||||
ORIENTATION_MISMATCH_PAIR = "pair"
|
||||
ORIENTATION_MISMATCH_SINGLE = "single"
|
||||
ORIENTATION_MISMATCH_AVOID = "avoid"
|
||||
|
||||
ORDER_RANDOM = "random"
|
||||
ORDER_ALBUM = "album_order"
|
||||
ORDER_NEWEST_TAKEN = "newest_taken"
|
||||
ORDER_OLDEST_TAKEN = "oldest_taken"
|
||||
ORDER_NEWEST_ADDED = "newest_added"
|
||||
ORDER_OLDEST_ADDED = "oldest_added"
|
||||
|
||||
ORDER_OPTIONS = [
|
||||
ORDER_RANDOM,
|
||||
ORDER_ALBUM,
|
||||
ORDER_NEWEST_TAKEN,
|
||||
ORDER_OLDEST_TAKEN,
|
||||
ORDER_NEWEST_ADDED,
|
||||
ORDER_OLDEST_ADDED,
|
||||
]
|
||||
|
||||
DATE_FILTER_OFF = "off"
|
||||
DATE_FILTER_LAST_7 = "last_7_days"
|
||||
DATE_FILTER_LAST_30 = "last_30_days"
|
||||
DATE_FILTER_LAST_365 = "last_365_days"
|
||||
DATE_FILTER_THIS_MONTH = "this_month"
|
||||
DATE_FILTER_THIS_YEAR = "this_year"
|
||||
DATE_FILTER_ON_THIS_DAY = "on_this_day"
|
||||
|
||||
DATE_FILTER_OPTIONS = [
|
||||
DATE_FILTER_OFF,
|
||||
DATE_FILTER_LAST_7,
|
||||
DATE_FILTER_LAST_30,
|
||||
DATE_FILTER_LAST_365,
|
||||
DATE_FILTER_THIS_MONTH,
|
||||
DATE_FILTER_THIS_YEAR,
|
||||
DATE_FILTER_ON_THIS_DAY,
|
||||
]
|
||||
DEFAULT_DATE_FILTER = DATE_FILTER_OFF
|
||||
|
||||
DEFAULT_SLIDE_INTERVAL = 60
|
||||
DEFAULT_REFRESH_HOURS = 24
|
||||
DEFAULT_FILL_MODE = FILL_BLUR
|
||||
DEFAULT_ORIENTATION_MISMATCH_MODE = ORIENTATION_MISMATCH_PAIR
|
||||
DEFAULT_ORDER_MODE = ORDER_RANDOM
|
||||
DEFAULT_ASPECT_RATIO = "16:9"
|
||||
DEFAULT_PAIR_DIVIDER_PX = 8
|
||||
DEFAULT_PAIR_DIVIDER_COLOR = "#FFFFFF"
|
||||
DEFAULT_RECURSIVE = True
|
||||
# Per-album download cache. Multiple albums add up: 4 × 150 MB = 600 MB
|
||||
# of just-in-case downloaded JPEGs. 75 MB caches roughly 10-20 photos
|
||||
# at typical Google Photos resolutions, which is enough for the preload
|
||||
# path. Users with one album and lots of RAM can bump this via the
|
||||
# Image cache size number entity.
|
||||
DEFAULT_IMAGE_CACHE_MB = 75
|
||||
|
||||
MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"]
|
||||
DEFAULT_MAX_RESOLUTION = "1080p"
|
||||
MAX_RESOLUTION_SHORT_EDGE: dict[str, int | None] = {
|
||||
"480p": 480,
|
||||
"720p": 720,
|
||||
"1080p": 1080,
|
||||
"1440p": 1440,
|
||||
"4K (2160p)": 2160,
|
||||
"original": None,
|
||||
}
|
||||
|
||||
PUBLICALBUM_ENDPOINT = "https://www.publicalbum.org/api/v2/webapp/embed-player/jsonrpc"
|
||||
|
||||
SERVICE_NEXT_SLIDE = "next_slide"
|
||||
SERVICE_REFRESH_ALBUM = "refresh_album"
|
||||
ATTR_ENTRY_ID = "entry_id"
|
||||
@@ -0,0 +1,472 @@
|
||||
"""Google Photos shared album client that bypasses the ~300 item HTML cap.
|
||||
|
||||
Strategy
|
||||
--------
|
||||
1. Fetch the share URL HTML (with browser User-Agent so Google serves the
|
||||
real page, not a JS-only shim).
|
||||
2. Extract the album media key and auth key from the embedded
|
||||
``AF_dataServiceRequests`` blob - the same parameters Google's JS uses
|
||||
when it scrolls and needs more pages.
|
||||
3. Page through the album by POSTing to the public ``batchexecute`` endpoint
|
||||
with the ``snAcKc`` RPC (the same one googlephotos.com calls). Each page
|
||||
carries up to 300 items plus a continuation token; we loop until the
|
||||
token is empty.
|
||||
|
||||
This mirrors the approach used by community projects like
|
||||
``xob0t/google-photos-toolkit``. It uses only undocumented public endpoints
|
||||
and no auth.
|
||||
|
||||
Brittleness
|
||||
-----------
|
||||
Google occasionally reshuffles the per-item array layout. We keep parsing
|
||||
positional but verify each field at access time and skip malformed entries
|
||||
rather than failing the whole batch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from .coordinator import MediaItem
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_BROWSER_UA = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# AF_dataServiceRequests block in the HTML carries the snAcKc request payload:
|
||||
# "snAcKc",ext: ... ,request:["<albumKey>",null,null,"<authKey>"]
|
||||
_REQUEST_RE = re.compile(
|
||||
r"snAcKc[^}]*?request:\s*\[\s*\"([A-Za-z0-9_-]+)\"\s*,\s*null\s*,\s*null\s*,\s*\"([A-Za-z0-9_-]+)\"",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# XSSI prefix Google prepends to batchexecute responses. We strip it before parsing.
|
||||
_XSSI = ")]}'"
|
||||
|
||||
# Hard ceiling - matches the upstream Google album item limit.
|
||||
_MAX_ITEMS = 20_000
|
||||
|
||||
# Per-page item count Google returns. Used only for sanity logging.
|
||||
_PAGE_SIZE = 300
|
||||
|
||||
# Strip any existing ``=w...-h...`` suffix from a Google CDN URL so we can
|
||||
# attach our own size hint based on the photo's known dimensions.
|
||||
_SIZE_SUFFIX_RE = re.compile(r"=[wh]\d+(?:-[a-z0-9]+)*$", re.IGNORECASE)
|
||||
|
||||
_VIDEO_DURATION_KEY = 76647426 # presence indicates a video; we skip those
|
||||
_LIVEPHOTO_KEY = 146008172
|
||||
|
||||
|
||||
class _AlbumKeys:
|
||||
__slots__ = ("album_key", "auth_key")
|
||||
|
||||
def __init__(self, album_key: str, auth_key: str) -> None:
|
||||
self.album_key = album_key
|
||||
self.auth_key = auth_key
|
||||
|
||||
|
||||
async def fetch_album(session, share_url: str, *, timeout: float = 30.0) -> tuple[str | None, list[MediaItem]]:
|
||||
"""Fetch a shared album in full. Returns (title, items).
|
||||
|
||||
The HTML page is fetched once - just to recover the album/auth keys and
|
||||
title. All actual photo enumeration goes through Google's ``batchexecute``
|
||||
endpoint, which is the only way to reach photos beyond the first ~300.
|
||||
"""
|
||||
keys, title = await _fetch_album_keys(session, share_url, timeout=timeout)
|
||||
if keys is None:
|
||||
return None, []
|
||||
|
||||
items: list[MediaItem] = []
|
||||
seen_urls: set[str] = set()
|
||||
page_id: str | None = None
|
||||
page_no = 0
|
||||
while True:
|
||||
page_no += 1
|
||||
try:
|
||||
page_items, page_id = await _fetch_album_page(
|
||||
session, keys, page_id, timeout=timeout
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
"Album scrape: page %d batchexecute failed (%s); returning %d items so far",
|
||||
page_no, err, len(items),
|
||||
)
|
||||
break
|
||||
|
||||
added = 0
|
||||
for it in page_items:
|
||||
if it.url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(it.url)
|
||||
items.append(it)
|
||||
added += 1
|
||||
if len(items) >= _MAX_ITEMS:
|
||||
break
|
||||
_LOGGER.debug(
|
||||
"Album scrape: page %d returned %d items (%d new), running total %d",
|
||||
page_no, len(page_items), added, len(items),
|
||||
)
|
||||
if not page_id or len(items) >= _MAX_ITEMS or added == 0:
|
||||
break
|
||||
|
||||
_LOGGER.info(
|
||||
"Album scraper: batchexecute fetched %d photos in %d page(s)",
|
||||
len(items), page_no,
|
||||
)
|
||||
return title, items
|
||||
|
||||
|
||||
# -- Internals ---------------------------------------------------------------
|
||||
|
||||
async def _fetch_album_keys(
|
||||
session, share_url: str, *, timeout: float
|
||||
) -> tuple[_AlbumKeys | None, str | None]:
|
||||
"""Fetch the share URL HTML and extract the album/auth keys + title."""
|
||||
headers = {
|
||||
"User-Agent": _BROWSER_UA,
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
try:
|
||||
async with session.get(
|
||||
share_url, headers=headers, timeout=timeout, allow_redirects=True
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
ct = resp.headers.get("Content-Type", "").lower()
|
||||
if "html" not in ct and "text" not in ct:
|
||||
_LOGGER.debug("Album scrape: unexpected content-type %r", ct)
|
||||
return None, None
|
||||
html = await resp.text()
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Album scrape: failed to fetch %s: %s", share_url, err)
|
||||
return None, None
|
||||
|
||||
keys = _extract_keys(html)
|
||||
if keys is None:
|
||||
_LOGGER.debug("Album scrape: could not locate album keys in HTML")
|
||||
return None, None
|
||||
|
||||
return keys, _extract_title(html)
|
||||
|
||||
|
||||
def _extract_keys(html: str) -> _AlbumKeys | None:
|
||||
m = _REQUEST_RE.search(html)
|
||||
if not m:
|
||||
return None
|
||||
return _AlbumKeys(album_key=m.group(1), auth_key=m.group(2))
|
||||
|
||||
|
||||
def _extract_title(html: str) -> str | None:
|
||||
m = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
title = m.group(1).strip()
|
||||
suffix = " - Google Photos"
|
||||
if title.endswith(suffix):
|
||||
title = title[: -len(suffix)].strip()
|
||||
return title or None
|
||||
|
||||
|
||||
def _extract_first_page_items(html: str) -> list[MediaItem]:
|
||||
"""Pull the first 300 items out of the AF_initDataCallback blocks.
|
||||
|
||||
Returns an empty list if the embedded data can't be parsed - the caller
|
||||
will still get the rest via batchexecute pagination.
|
||||
"""
|
||||
candidates: list[list[Any]] = []
|
||||
for blob in _iter_af_data_blobs(html):
|
||||
try:
|
||||
tree = json.loads(blob)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
candidates.extend(_collect_album_item_lists(tree))
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
best = max(candidates, key=len)
|
||||
items: list[MediaItem] = []
|
||||
seen: set[str] = set()
|
||||
for raw in best:
|
||||
item = _parse_album_item(raw)
|
||||
if item is None or item.url in seen:
|
||||
continue
|
||||
seen.add(item.url)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _next_page_token_for_first_page(
|
||||
first_items: list[MediaItem], first_page_size: int
|
||||
) -> str | None:
|
||||
"""The first page's nextPageId isn't easy to find in the HTML AF blob.
|
||||
|
||||
Strategy: if the first page is exactly the standard page size we use a
|
||||
sentinel empty token so the caller fetches page 2 with ``pageId=None``.
|
||||
The batchexecute endpoint, given ``pageId=None``, returns page 1 again
|
||||
along with the real continuation token, which we then use for the rest.
|
||||
Slightly wasteful (we re-fetch page 1) but robust to layout changes.
|
||||
"""
|
||||
if first_page_size >= _PAGE_SIZE:
|
||||
return "" # sentinel: drives the first batchexecute call
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_album_page(
|
||||
session,
|
||||
keys: _AlbumKeys,
|
||||
page_id: str | None,
|
||||
*,
|
||||
timeout: float,
|
||||
) -> tuple[list[MediaItem], str | None]:
|
||||
"""Call snAcKc once. ``page_id=""`` is treated as ``None`` (initial fetch)."""
|
||||
pid = page_id or None
|
||||
inner = json.dumps([keys.album_key, pid, None, keys.auth_key])
|
||||
envelope = json.dumps([[["snAcKc", inner, None, "generic"]]])
|
||||
form = f"f.req={quote(envelope)}"
|
||||
|
||||
url = (
|
||||
"https://photos.google.com/u/0/_/PhotosUi/data/batchexecute"
|
||||
f"?rpcids=snAcKc&source-path=/share/{quote(keys.album_key)}"
|
||||
)
|
||||
headers = {
|
||||
"User-Agent": _BROWSER_UA,
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"Accept": "*/*",
|
||||
"Origin": "https://photos.google.com",
|
||||
"Referer": f"https://photos.google.com/share/{keys.album_key}?key={keys.auth_key}",
|
||||
}
|
||||
async with session.post(url, data=form, headers=headers, timeout=timeout) as resp:
|
||||
resp.raise_for_status()
|
||||
body = await resp.text()
|
||||
return _parse_batchexecute_album_page(body)
|
||||
|
||||
|
||||
def _parse_batchexecute_album_page(body: str) -> tuple[list[MediaItem], str | None]:
|
||||
"""Parse a batchexecute response for one snAcKc call.
|
||||
|
||||
Format (per line, after the XSSI prefix):
|
||||
[["wrb.fr", "snAcKc", "<json-encoded inner>", null, null, "generic"], ...]
|
||||
The inner data shape (per gptk-toolkit):
|
||||
data[1] = list of album items
|
||||
data[2] = nextPageId (str) or null
|
||||
"""
|
||||
text = body.lstrip()
|
||||
if text.startswith(_XSSI):
|
||||
text = text[len(_XSSI):]
|
||||
text = text.lstrip()
|
||||
|
||||
line = text.split("\n", 1)[0].strip()
|
||||
if not line:
|
||||
return [], None
|
||||
try:
|
||||
outer = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return [], None
|
||||
|
||||
inner_json: str | None = None
|
||||
for entry in outer:
|
||||
if isinstance(entry, list) and len(entry) >= 3 and entry[0] == "wrb.fr":
|
||||
inner_json = entry[2]
|
||||
break
|
||||
if not isinstance(inner_json, str):
|
||||
return [], None
|
||||
|
||||
try:
|
||||
inner = json.loads(inner_json)
|
||||
except json.JSONDecodeError:
|
||||
return [], None
|
||||
|
||||
raw_items = inner[1] if len(inner) > 1 and isinstance(inner[1], list) else []
|
||||
next_page = inner[2] if len(inner) > 2 and isinstance(inner[2], str) else None
|
||||
if next_page == "":
|
||||
next_page = None
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for raw in raw_items:
|
||||
item = _parse_album_item(raw)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
return items, next_page
|
||||
|
||||
|
||||
def _parse_album_item(raw: Any) -> MediaItem | None:
|
||||
"""Parse a single album item array.
|
||||
|
||||
Layout used in both AF blocks and snAcKc responses:
|
||||
[mediaKey, [url, w, h, ..., [byte_size]], captured_ms, dedupKey,
|
||||
timezoneOffsetMin, uploaded_ms, ..., {<numeric_keys>: ...}]
|
||||
"""
|
||||
if not isinstance(raw, list) or len(raw) < 2:
|
||||
return None
|
||||
visual = raw[1]
|
||||
if not isinstance(visual, list) or len(visual) < 3:
|
||||
return None
|
||||
|
||||
url = visual[0]
|
||||
if not isinstance(url, str) or not url.startswith("http"):
|
||||
return None
|
||||
width = visual[1] if isinstance(visual[1], int) else None
|
||||
height = visual[2] if isinstance(visual[2], int) else None
|
||||
|
||||
# Skip videos: their last element is a dict with key 76647426 (duration).
|
||||
if raw and isinstance(raw[-1], dict):
|
||||
if _VIDEO_DURATION_KEY in raw[-1] or "76647426" in raw[-1]:
|
||||
# Note: live photos also carry a duration but are still images;
|
||||
# we treat the presence of duration as "video". If a user reports
|
||||
# missing live photos we can revisit by checking 146008172 too.
|
||||
return None
|
||||
|
||||
captured_at = raw[2] if len(raw) > 2 and _looks_like_timestamp_ms(raw[2]) else None
|
||||
uploaded_at = raw[5] if len(raw) > 5 and _looks_like_timestamp_ms(raw[5]) else None
|
||||
|
||||
# File size, when present, lives at visual[-1][0] as a single int.
|
||||
byte_size: int | None = None
|
||||
if visual and isinstance(visual[-1], list) and visual[-1]:
|
||||
candidate = visual[-1][0]
|
||||
if isinstance(candidate, int) and candidate > 0:
|
||||
byte_size = candidate
|
||||
|
||||
return MediaItem(
|
||||
url=_normalise_size(url, width, height),
|
||||
width=width,
|
||||
height=height,
|
||||
mime_type=None,
|
||||
filename=None,
|
||||
captured_at=captured_at,
|
||||
uploaded_at=uploaded_at,
|
||||
byte_size=byte_size,
|
||||
)
|
||||
|
||||
|
||||
# Plausible epoch-ms range: 2000-01-01 to 2100-01-01.
|
||||
_MIN_TS_MS = 946_684_800_000
|
||||
_MAX_TS_MS = 4_102_444_800_000
|
||||
|
||||
|
||||
def _looks_like_timestamp_ms(value: Any) -> bool:
|
||||
return isinstance(value, int) and _MIN_TS_MS <= value <= _MAX_TS_MS
|
||||
|
||||
|
||||
# -- AF block parsing (for the initial 300 items embedded in the HTML) ------
|
||||
|
||||
_AF_BLOCK_RE = re.compile(r"AF_initDataCallback\s*\(\s*\{", re.DOTALL)
|
||||
_DATA_KEY_RE = re.compile(r"[\s,{]data\s*:\s*\[", re.DOTALL)
|
||||
|
||||
|
||||
def _iter_af_data_blobs(html: str):
|
||||
"""Yield the JSON text of every AF_initDataCallback ``data:`` value."""
|
||||
for m in _AF_BLOCK_RE.finditer(html):
|
||||
block_end = _balanced_close(html, m.end() - 1, "{", "}")
|
||||
if block_end is None:
|
||||
continue
|
||||
block = html[m.end() - 1: block_end + 1]
|
||||
data_match = _DATA_KEY_RE.search(block)
|
||||
if data_match is None:
|
||||
continue
|
||||
open_pos = data_match.end() - 1
|
||||
close_pos = _balanced_close(block, open_pos, "[", "]")
|
||||
if close_pos is None:
|
||||
continue
|
||||
yield block[open_pos: close_pos + 1]
|
||||
|
||||
|
||||
def _balanced_close(s: str, open_idx: int, open_char: str, close_char: str) -> int | None:
|
||||
if open_idx >= len(s) or s[open_idx] != open_char:
|
||||
return None
|
||||
depth = 0
|
||||
in_string: str | None = None
|
||||
i = open_idx
|
||||
n = len(s)
|
||||
while i < n:
|
||||
c = s[i]
|
||||
if in_string is not None:
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == in_string:
|
||||
in_string = None
|
||||
i += 1
|
||||
continue
|
||||
if c in ("'", '"'):
|
||||
in_string = c
|
||||
i += 1
|
||||
continue
|
||||
if c == open_char:
|
||||
depth += 1
|
||||
elif c == close_char:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def _collect_album_item_lists(node: Any, _out: list[list[Any]] | None = None) -> list[list[Any]]:
|
||||
"""Walk the AF data tree, collecting lists of album-item-shaped entries."""
|
||||
out = _out if _out is not None else []
|
||||
if isinstance(node, list):
|
||||
if _list_looks_like_album_items(node):
|
||||
out.append(node)
|
||||
for child in node:
|
||||
_collect_album_item_lists(child, out)
|
||||
elif isinstance(node, dict):
|
||||
for child in node.values():
|
||||
_collect_album_item_lists(child, out)
|
||||
return out
|
||||
|
||||
|
||||
def _list_looks_like_album_items(lst: list[Any]) -> bool:
|
||||
if not lst:
|
||||
return False
|
||||
sample = lst[:20]
|
||||
for item in sample:
|
||||
if _parse_album_item(item) is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# -- URL normalisation -------------------------------------------------------
|
||||
|
||||
def _normalise_size(url: str, width: int | None, height: int | None) -> str:
|
||||
"""Strip any existing size suffix and request a 4K-capped version."""
|
||||
base = _SIZE_SUFFIX_RE.sub("", url)
|
||||
if width and height:
|
||||
try:
|
||||
w = int(width)
|
||||
h = int(height)
|
||||
except (TypeError, ValueError):
|
||||
return f"{base}=w1920-h1080"
|
||||
longest = max(w, h)
|
||||
if longest > 3840:
|
||||
scale = 3840 / longest
|
||||
w = max(1, int(round(w * scale)))
|
||||
h = max(1, int(round(h * scale)))
|
||||
return f"{base}=w{w}-h{h}"
|
||||
return f"{base}=w1920-h1080"
|
||||
|
||||
|
||||
# Backwards-compatible names so the existing tests still find the helpers.
|
||||
def parse_album_html(html: str) -> list[MediaItem]:
|
||||
"""Parse only the first-page items embedded in the HTML.
|
||||
|
||||
Retained for backwards compatibility with the 0.5.0-rc1 surface; new
|
||||
callers should use ``fetch_album`` for the full paginated result.
|
||||
"""
|
||||
return _extract_first_page_items(html)
|
||||
|
||||
|
||||
_PHOTO_HOST_RE = re.compile(
|
||||
r"^https?://(?:[a-z0-9-]+\.)?(?:googleusercontent\.com|google\.com)/",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_dimension(v: Any) -> bool:
|
||||
return isinstance(v, int) and 16 <= v <= 20_000
|
||||
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
|
||||
from PIL import Image, ImageColor, ImageFilter, ImageOps
|
||||
|
||||
from .coordinator import MediaItem
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Re-export fill mode constants so callers can import from here.
|
||||
FILL_COVER = "cover"
|
||||
FILL_CONTAIN = "contain"
|
||||
FILL_BLUR = "blur"
|
||||
|
||||
# Absolute pixel ceiling. A 20000x20000 JPEG decodes to ~1.2 GB of RGB; Pillow
|
||||
# raises DecompressionBombError above MAX_IMAGE_PIXELS. We set this high enough
|
||||
# that 4K+ sources still decode, but reject anything absurd to protect
|
||||
# low-memory devices like the Home Assistant Green.
|
||||
_MAX_IMAGE_PIXELS = 80_000_000 # ~8K x 10K
|
||||
Image.MAX_IMAGE_PIXELS = _MAX_IMAGE_PIXELS
|
||||
|
||||
|
||||
def open_image(
|
||||
data: bytes,
|
||||
target_size: tuple[int, int] | None = None,
|
||||
) -> Image.Image:
|
||||
"""Open image bytes, apply EXIF orientation, normalise to RGB/RGBA.
|
||||
|
||||
If ``target_size`` is given, uses PIL's ``draft`` mode so libjpeg decodes
|
||||
at a reduced scale. Big speed/memory win on low-power devices when the
|
||||
source is much larger than the output canvas.
|
||||
"""
|
||||
img = Image.open(io.BytesIO(data))
|
||||
if target_size is not None and img.format == "JPEG":
|
||||
try:
|
||||
img.draft("RGB", target_size)
|
||||
except Exception:
|
||||
pass
|
||||
img = ImageOps.exif_transpose(img)
|
||||
# Force pixel data into memory; BytesIO must stay reachable until here.
|
||||
img.load()
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGB")
|
||||
return img
|
||||
|
||||
|
||||
def safe_close(img: Image.Image | None) -> None:
|
||||
"""Close a PIL image without raising. No-op on None."""
|
||||
if img is None:
|
||||
return
|
||||
try:
|
||||
img.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def is_portrait_img(img: Image.Image) -> bool:
|
||||
try:
|
||||
w, h = img.size
|
||||
return h >= w
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_portrait_item(item: MediaItem, img: Image.Image | None = None) -> bool:
|
||||
by_meta = _is_portrait_dims(item.width, item.height)
|
||||
if by_meta is not None:
|
||||
return by_meta
|
||||
if img is not None:
|
||||
return is_portrait_img(img)
|
||||
return False
|
||||
|
||||
|
||||
def is_portrait_item_by_metadata(item: MediaItem) -> bool | None:
|
||||
"""Return portrait/landscape from item metadata only, or None if unknown."""
|
||||
return _is_portrait_dims(item.width, item.height)
|
||||
|
||||
|
||||
def resolve_output_size(
|
||||
req_w: int | None,
|
||||
req_h: int | None,
|
||||
ratio: str,
|
||||
max_short_edge: int | None = None,
|
||||
) -> tuple[int, int]:
|
||||
ratio_w, ratio_h = _parse_aspect_ratio(ratio)
|
||||
target = ratio_w / ratio_h
|
||||
|
||||
if req_w is None and req_h is None:
|
||||
if ratio_w >= ratio_h:
|
||||
width = 3840
|
||||
height = max(1, int(round(width / target)))
|
||||
else:
|
||||
height = 3840
|
||||
width = max(1, int(round(height * target)))
|
||||
elif req_w is None:
|
||||
height = max(1, int(req_h or 2160))
|
||||
width = max(1, int(round(height * target)))
|
||||
elif req_h is None:
|
||||
width = max(1, int(req_w or 3840))
|
||||
height = max(1, int(round(width / target)))
|
||||
else:
|
||||
req_w = max(1, int(req_w))
|
||||
req_h = max(1, int(req_h))
|
||||
if (req_w / req_h) >= target:
|
||||
height = req_h
|
||||
width = max(1, int(round(height * target)))
|
||||
else:
|
||||
width = req_w
|
||||
height = max(1, int(round(width / target)))
|
||||
|
||||
if max_short_edge is not None:
|
||||
short = min(width, height)
|
||||
if short > max_short_edge:
|
||||
scale = max_short_edge / short
|
||||
width = max(1, int(round(width * scale)))
|
||||
height = max(1, int(round(height * scale)))
|
||||
|
||||
return (width, height)
|
||||
|
||||
|
||||
def render_image(img: Image.Image, fill_mode: str, width: int, height: int) -> Image.Image:
|
||||
"""Render img into a (width x height) canvas using the given fill mode."""
|
||||
if fill_mode == FILL_CONTAIN:
|
||||
return _resize_contain(img, width, height)
|
||||
if fill_mode == FILL_BLUR:
|
||||
return _blur_fill(img, width, height)
|
||||
return _resize_cover(img, width, height)
|
||||
|
||||
|
||||
def pair_images(
|
||||
img1: Image.Image,
|
||||
img2: Image.Image,
|
||||
target_w: int,
|
||||
target_h: int,
|
||||
fill_mode: str,
|
||||
portrait_canvas: bool,
|
||||
divider: int,
|
||||
divider_fill: tuple[int, int, int] | tuple[int, int, int, int],
|
||||
transparent_divider: bool,
|
||||
) -> Image.Image:
|
||||
canvas_mode = "RGBA" if transparent_divider else "RGB"
|
||||
canvas = Image.new(canvas_mode, (target_w, target_h), divider_fill)
|
||||
|
||||
if portrait_canvas:
|
||||
top_h = max(1, (target_h - divider) // 2)
|
||||
bottom_h = max(1, target_h - divider - top_h)
|
||||
top_img = render_image(img1, fill_mode, target_w, top_h)
|
||||
bottom_img = render_image(img2, fill_mode, target_w, bottom_h)
|
||||
canvas.paste(top_img.convert(canvas_mode), (0, 0))
|
||||
canvas.paste(bottom_img.convert(canvas_mode), (0, top_h + divider))
|
||||
safe_close(top_img)
|
||||
safe_close(bottom_img)
|
||||
return canvas
|
||||
|
||||
left_w = max(1, (target_w - divider) // 2)
|
||||
right_w = max(1, target_w - divider - left_w)
|
||||
left_img = render_image(img1, fill_mode, left_w, target_h)
|
||||
right_img = render_image(img2, fill_mode, right_w, target_h)
|
||||
canvas.paste(left_img.convert(canvas_mode), (0, 0))
|
||||
canvas.paste(right_img.convert(canvas_mode), (left_w + divider, 0))
|
||||
safe_close(left_img)
|
||||
safe_close(right_img)
|
||||
return canvas
|
||||
|
||||
|
||||
def encode_image(img: Image.Image) -> bytes:
|
||||
"""Encode a PIL image to a client-compatible JPEG or PNG.
|
||||
|
||||
JPEGs are written as baseline (non-progressive) with 4:2:0 subsampling and
|
||||
without EXIF, which maximises compatibility with Android WebView and older
|
||||
clients. RGBA images are encoded as PNG to preserve alpha.
|
||||
"""
|
||||
out = io.BytesIO()
|
||||
if "A" in img.getbands():
|
||||
img.save(out, format="PNG", optimize=True)
|
||||
return out.getvalue()
|
||||
rgb = img if img.mode == "RGB" else img.convert("RGB")
|
||||
rgb.save(
|
||||
out,
|
||||
format="JPEG",
|
||||
quality=88,
|
||||
optimize=True,
|
||||
progressive=False,
|
||||
subsampling=2,
|
||||
)
|
||||
if rgb is not img:
|
||||
safe_close(rgb)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def parse_divider_color(color: str) -> tuple[tuple[int, int, int] | tuple[int, int, int, int], bool]:
|
||||
raw = (color or "").strip().lower()
|
||||
compact = raw.replace(" ", "")
|
||||
if compact in ("transparent", "transperant", "none", "clear", "rgba(0,0,0,0)"):
|
||||
return (0, 0, 0, 0), True
|
||||
try:
|
||||
return ImageColor.getrgb(color), False
|
||||
except Exception:
|
||||
return (255, 255, 255), False
|
||||
|
||||
|
||||
# -- Private helpers ---------------------------------------------------------
|
||||
|
||||
def _is_portrait_dims(width: int | None, height: int | None) -> bool | None:
|
||||
if not width or not height:
|
||||
return None
|
||||
try:
|
||||
w, h = int(width), int(height)
|
||||
if w <= 0 or h <= 0:
|
||||
return None
|
||||
return h >= w
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_aspect_ratio(ratio: str) -> tuple[int, int]:
|
||||
try:
|
||||
left, right = ratio.split(":", maxsplit=1)
|
||||
w, h = int(left), int(right)
|
||||
if w > 0 and h > 0:
|
||||
return (w, h)
|
||||
except Exception:
|
||||
pass
|
||||
return (16, 9)
|
||||
|
||||
|
||||
def _resize_cover(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
|
||||
src_w, src_h = img.size
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return img.resize((target_w, target_h))
|
||||
scale = max(target_w / src_w, target_h / src_h)
|
||||
new_w = max(1, int(round(src_w * scale)))
|
||||
new_h = max(1, int(round(src_h * scale)))
|
||||
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
left = max(0, int(round((new_w - target_w) / 2)))
|
||||
top = max(0, int(round((new_h - target_h) / 2)))
|
||||
cropped = resized.crop((left, top, left + target_w, top + target_h))
|
||||
if cropped is not resized:
|
||||
safe_close(resized)
|
||||
return cropped
|
||||
|
||||
|
||||
def _resize_contain(img: Image.Image, target_w: int, target_h: int, bg=(0, 0, 0)) -> Image.Image:
|
||||
src_w, src_h = img.size
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return img.resize((target_w, target_h))
|
||||
scale = min(target_w / src_w, target_h / src_h)
|
||||
new_w = max(1, int(src_w * scale))
|
||||
new_h = max(1, int(src_h * scale))
|
||||
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
canvas = Image.new("RGB", (target_w, target_h), bg)
|
||||
rgb_resized = resized if resized.mode == "RGB" else resized.convert("RGB")
|
||||
canvas.paste(rgb_resized, ((target_w - new_w) // 2, (target_h - new_h) // 2))
|
||||
if rgb_resized is not resized:
|
||||
safe_close(rgb_resized)
|
||||
safe_close(resized)
|
||||
return canvas
|
||||
|
||||
|
||||
def _blur_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
|
||||
bg = _resize_cover(img, target_w, target_h).filter(ImageFilter.GaussianBlur(radius=24))
|
||||
src_w, src_h = img.size
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return bg
|
||||
scale = min(target_w / src_w, target_h / src_h)
|
||||
new_w = max(1, int(src_w * scale))
|
||||
new_h = max(1, int(src_h * scale))
|
||||
fg = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
rgb_fg = fg if fg.mode == "RGB" else fg.convert("RGB")
|
||||
bg.paste(rgb_fg, ((target_w - new_w) // 2, (target_h - new_h) // 2))
|
||||
if rgb_fg is not fg:
|
||||
safe_close(rgb_fg)
|
||||
safe_close(fg)
|
||||
return bg
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "album_slideshow",
|
||||
"name": "Album Slideshow Camera",
|
||||
"codeowners": ["@eyalgal"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "frontend"],
|
||||
"documentation": "https://github.com/eyalgal/album_slideshow",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["Pillow"],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberMode
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AlbumCoordinator
|
||||
from .store import SlideshowStore
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SlideIntervalNumber(entry, store),
|
||||
RefreshHoursNumber(entry, store, coordinator),
|
||||
PairDividerWidthNumber(entry, store),
|
||||
ImageCacheMbNumber(entry, store),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _BaseNumber(NumberEntity, RestoreEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_attr_mode = NumberMode.BOX
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
self.entry = entry
|
||||
self.store = store
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.store.add_listener(self.async_write_ha_state)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
|
||||
class SlideIntervalNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:timer-outline"
|
||||
_attr_native_min_value = 3
|
||||
_attr_native_max_value = 3600
|
||||
_attr_native_step = 1
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_interval"
|
||||
self._attr_name = "Slide interval (seconds)"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return int(self.store.slide_interval)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
self.store.slide_interval = int(value)
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
try:
|
||||
self.store.slide_interval = int(float(old.state))
|
||||
self.store.notify()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
class RefreshHoursNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:refresh"
|
||||
_attr_native_min_value = 1
|
||||
_attr_native_max_value = 168
|
||||
_attr_native_step = 1
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(entry, store)
|
||||
self.coordinator = coordinator
|
||||
self._attr_unique_id = f"{entry.entry_id}_refresh_minutes"
|
||||
self._attr_name = "Album refresh (hours)"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return int(self.store.refresh_hours)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
self.store.refresh_hours = int(value)
|
||||
self.store.notify()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
try:
|
||||
val = int(float(old.state))
|
||||
# Migration: old values were in minutes; convert if above max hours
|
||||
if val > self._attr_native_max_value:
|
||||
val = max(int(val / 60), int(self._attr_native_min_value))
|
||||
self.store.refresh_hours = val
|
||||
self.store.notify()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
class PairDividerWidthNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:border-vertical"
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_max_value = 64
|
||||
_attr_native_step = 1
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_pair_divider_px"
|
||||
self._attr_name = "Pair divider size (px)"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return int(self.store.pair_divider_px)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
self.store.pair_divider_px = max(0, int(value))
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
try:
|
||||
self.store.pair_divider_px = max(0, int(float(old.state)))
|
||||
self.store.notify()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
class ImageCacheMbNumber(_BaseNumber):
|
||||
_attr_icon = "mdi:database-outline"
|
||||
_attr_native_min_value = 50
|
||||
_attr_native_max_value = 1000
|
||||
_attr_native_step = 50
|
||||
_attr_native_unit_of_measurement = "MB"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_image_cache_mb"
|
||||
self._attr_name = "Image cache size (MB)"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return int(self.store.image_cache_mb)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
self.store.image_cache_mb = max(50, int(value))
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
try:
|
||||
self.store.image_cache_mb = max(50, int(float(old.state)))
|
||||
self.store.notify()
|
||||
except Exception:
|
||||
return
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Playlist construction: ordering and date filtering.
|
||||
|
||||
Pure, dependency-free helpers so the camera and tests can share a single
|
||||
implementation. Operates on ``MediaItem``-like objects that expose
|
||||
``captured_at`` / ``uploaded_at`` (epoch ms or ``None``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Iterable, TypeVar
|
||||
|
||||
from .const import (
|
||||
DATE_FILTER_LAST_7,
|
||||
DATE_FILTER_LAST_30,
|
||||
DATE_FILTER_LAST_365,
|
||||
DATE_FILTER_OFF,
|
||||
DATE_FILTER_ON_THIS_DAY,
|
||||
DATE_FILTER_THIS_MONTH,
|
||||
DATE_FILTER_THIS_YEAR,
|
||||
ORDER_ALBUM,
|
||||
ORDER_NEWEST_ADDED,
|
||||
ORDER_NEWEST_TAKEN,
|
||||
ORDER_OLDEST_ADDED,
|
||||
ORDER_OLDEST_TAKEN,
|
||||
ORDER_RANDOM,
|
||||
)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def order_items(items: list[T], order_mode: str) -> list[T]:
|
||||
"""Return a new list ordered per ``order_mode``.
|
||||
|
||||
``random`` and ``album_order`` are no-ops here - random shuffling lives
|
||||
in the camera so it can dedupe recent slides; ``album_order`` keeps the
|
||||
source order untouched. The taken/added orderings are stable; items
|
||||
without the required timestamp keep their relative position at the end.
|
||||
"""
|
||||
if order_mode == ORDER_RANDOM or order_mode == ORDER_ALBUM:
|
||||
return list(items)
|
||||
|
||||
key_attr, reverse = _order_key(order_mode)
|
||||
if key_attr is None:
|
||||
return list(items)
|
||||
|
||||
with_ts: list[tuple[int, int, T]] = []
|
||||
without_ts: list[tuple[int, T]] = []
|
||||
for idx, it in enumerate(items):
|
||||
ts = getattr(it, key_attr, None)
|
||||
if isinstance(ts, int):
|
||||
with_ts.append((ts, idx, it))
|
||||
else:
|
||||
without_ts.append((idx, it))
|
||||
|
||||
with_ts.sort(key=lambda t: (t[0], t[1]), reverse=reverse)
|
||||
return [it for _, _, it in with_ts] + [it for _, it in without_ts]
|
||||
|
||||
|
||||
def _order_key(order_mode: str) -> tuple[str | None, bool]:
|
||||
if order_mode == ORDER_NEWEST_TAKEN:
|
||||
return "captured_at", True
|
||||
if order_mode == ORDER_OLDEST_TAKEN:
|
||||
return "captured_at", False
|
||||
if order_mode == ORDER_NEWEST_ADDED:
|
||||
return "uploaded_at", True
|
||||
if order_mode == ORDER_OLDEST_ADDED:
|
||||
return "uploaded_at", False
|
||||
return None, False
|
||||
|
||||
|
||||
def filter_items(
|
||||
items: Iterable[T],
|
||||
*,
|
||||
mode: str,
|
||||
now: datetime | None = None,
|
||||
) -> list[T]:
|
||||
"""Filter items by ``captured_at`` according to ``mode``.
|
||||
|
||||
Items with no ``captured_at`` are kept by default unless the mode is
|
||||
``on_this_day`` (treated as a strict filter).
|
||||
|
||||
``now`` is overridable for deterministic tests.
|
||||
"""
|
||||
if not mode or mode == DATE_FILTER_OFF:
|
||||
return list(items)
|
||||
|
||||
today_utc = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
|
||||
pred, strict = _build_predicate(mode, today_utc)
|
||||
if pred is None:
|
||||
return list(items)
|
||||
|
||||
out: list[T] = []
|
||||
for it in items:
|
||||
ts = getattr(it, "captured_at", None)
|
||||
if not isinstance(ts, int):
|
||||
if not strict:
|
||||
out.append(it)
|
||||
continue
|
||||
if pred(ts):
|
||||
out.append(it)
|
||||
return out
|
||||
|
||||
|
||||
def _build_predicate(
|
||||
mode: str,
|
||||
today_utc: datetime,
|
||||
):
|
||||
"""Return (predicate, strict). ``strict`` drops items without timestamps."""
|
||||
if mode == DATE_FILTER_LAST_7:
|
||||
cutoff = int((today_utc - timedelta(days=7)).timestamp() * 1000)
|
||||
return (lambda ts: ts >= cutoff), False
|
||||
if mode == DATE_FILTER_LAST_30:
|
||||
cutoff = int((today_utc - timedelta(days=30)).timestamp() * 1000)
|
||||
return (lambda ts: ts >= cutoff), False
|
||||
if mode == DATE_FILTER_LAST_365:
|
||||
cutoff = int((today_utc - timedelta(days=365)).timestamp() * 1000)
|
||||
return (lambda ts: ts >= cutoff), False
|
||||
if mode == DATE_FILTER_THIS_MONTH:
|
||||
start = today_utc.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
cutoff = int(start.timestamp() * 1000)
|
||||
return (lambda ts: ts >= cutoff), False
|
||||
if mode == DATE_FILTER_THIS_YEAR:
|
||||
start = today_utc.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
cutoff = int(start.timestamp() * 1000)
|
||||
return (lambda ts: ts >= cutoff), False
|
||||
if mode == DATE_FILTER_ON_THIS_DAY:
|
||||
# Match items whose UTC month+day equals today's. Useful for daily
|
||||
# "memories"-style rotation across all years.
|
||||
today_md = (today_utc.month, today_utc.day)
|
||||
|
||||
def _on_this_day(ts: int) -> bool:
|
||||
d = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
|
||||
return (d.month, d.day) == today_md
|
||||
|
||||
return _on_this_day, True
|
||||
|
||||
return None, False
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FILL_COVER,
|
||||
FILL_CONTAIN,
|
||||
FILL_BLUR,
|
||||
MAX_RESOLUTION_OPTIONS,
|
||||
ORIENTATION_MISMATCH_PAIR,
|
||||
ORIENTATION_MISMATCH_SINGLE,
|
||||
ORIENTATION_MISMATCH_AVOID,
|
||||
ORDER_OPTIONS,
|
||||
ORDER_RANDOM,
|
||||
DATE_FILTER_OPTIONS,
|
||||
DATE_FILTER_OFF,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
ASPECT_RATIO_OPTIONS = ["16:9", "16:10", "4:3", "1:1", "3:4", "10:16", "9:16"]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
async_add_entities(
|
||||
[
|
||||
FillModeSelect(entry, store),
|
||||
PortraitModeSelect(entry, store),
|
||||
OrderModeSelect(entry, store),
|
||||
AspectRatioSelect(entry, store),
|
||||
MaxResolutionSelect(entry, store),
|
||||
DateFilterSelect(entry, store),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _BaseSelect(SelectEntity, RestoreEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_attr_entity_registry_enabled_default = True
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
self.entry = entry
|
||||
self.store = store
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.store.add_listener(self.async_write_ha_state)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
|
||||
class FillModeSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:aspect-ratio"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_fill_mode"
|
||||
self._attr_name = "Fill mode"
|
||||
self._attr_options = [FILL_BLUR, FILL_COVER, FILL_CONTAIN]
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.fill_mode
|
||||
return value if value in self.options else self.options[0]
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.fill_mode = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state in self.options:
|
||||
self.store.fill_mode = old.state
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class PortraitModeSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:account-box-outline"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_portrait_mode"
|
||||
self._attr_name = "Orientation mismatch mode"
|
||||
self._attr_options = [
|
||||
ORIENTATION_MISMATCH_PAIR,
|
||||
ORIENTATION_MISMATCH_SINGLE,
|
||||
ORIENTATION_MISMATCH_AVOID,
|
||||
]
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.portrait_mode
|
||||
return value if value in self.options else self.options[0]
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.portrait_mode = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old:
|
||||
restored = old.state
|
||||
if restored in ("blur", "crop"):
|
||||
restored = ORIENTATION_MISMATCH_SINGLE
|
||||
if restored in self.options:
|
||||
self.store.portrait_mode = restored
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class OrderModeSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:shuffle-variant"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_order_mode"
|
||||
self._attr_name = "Order mode"
|
||||
self._attr_options = list(ORDER_OPTIONS)
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.order_mode
|
||||
return value if value in self.options else ORDER_RANDOM
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.order_mode = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state in self.options:
|
||||
self.store.order_mode = old.state
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class AspectRatioSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:crop"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_aspect_ratio"
|
||||
self._attr_name = "Aspect ratio"
|
||||
self._attr_options = ASPECT_RATIO_OPTIONS
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.aspect_ratio
|
||||
return value if value in self.options else self.options[0]
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.aspect_ratio = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state in self.options:
|
||||
self.store.aspect_ratio = old.state
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class MaxResolutionSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:image-size-select-large"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_max_resolution"
|
||||
self._attr_name = "Max resolution"
|
||||
self._attr_options = MAX_RESOLUTION_OPTIONS
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.max_resolution
|
||||
return value if value in self.options else self.options[0]
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.max_resolution = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state in self.options:
|
||||
self.store.max_resolution = old.state
|
||||
self.store.notify()
|
||||
|
||||
|
||||
class DateFilterSelect(_BaseSelect):
|
||||
_attr_icon = "mdi:calendar-filter"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
super().__init__(entry, store)
|
||||
self._attr_unique_id = f"{entry.entry_id}_date_filter"
|
||||
self._attr_name = "Date filter"
|
||||
self._attr_options = list(DATE_FILTER_OPTIONS)
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
value = self.store.date_filter
|
||||
return value if value in self.options else DATE_FILTER_OFF
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option not in self.options:
|
||||
return
|
||||
self.store.date_filter = option
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state in self.options:
|
||||
self.store.date_filter = old.state
|
||||
self.store.notify()
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, PROVIDER_GOOGLE_SHARED, PROVIDER_LOCAL_FOLDER
|
||||
from .coordinator import AlbumCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
entities: list[SensorEntity] = [
|
||||
AlbumCountSensor(entry, coordinator),
|
||||
AlbumTitleSensor(entry, coordinator),
|
||||
CacheUsageSensor(entry, coordinator),
|
||||
]
|
||||
if coordinator.provider == PROVIDER_LOCAL_FOLDER:
|
||||
# Diagnostic surface for the local-folder background enrichment
|
||||
# (EXIF reads + reverse-geocode). For Google albums there's no
|
||||
# enrichment work, so this sensor is omitted to keep the device
|
||||
# screen tidy.
|
||||
entities.append(EnrichmentProgressSensor(entry, coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class _BaseAlbumSensor(SensorEntity):
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
self.entry = entry
|
||||
self.coordinator = coordinator
|
||||
coordinator.async_add_listener(self.async_write_ha_state)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
def _provider_icon(self) -> str:
|
||||
if self.coordinator.provider == PROVIDER_GOOGLE_SHARED:
|
||||
return "mdi:google-photos"
|
||||
return "mdi:folder-multiple-image"
|
||||
|
||||
|
||||
class AlbumCountSensor(_BaseAlbumSensor):
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_count"
|
||||
self._attr_name = "Photo count"
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return self._provider_icon()
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
data = self.coordinator.data or {}
|
||||
return len(data.get("items", []))
|
||||
|
||||
|
||||
class AlbumTitleSensor(_BaseAlbumSensor):
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_title"
|
||||
self._attr_name = "Album title"
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return self._provider_icon()
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
data = self.coordinator.data or {}
|
||||
return data.get("title")
|
||||
|
||||
|
||||
class CacheUsageSensor(_BaseAlbumSensor):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement = "MB"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_icon = "mdi:database"
|
||||
_attr_should_poll = True
|
||||
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_cache_usage"
|
||||
self._attr_name = "Image cache usage"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
cam = self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id, {}).get("camera")
|
||||
if cam is None:
|
||||
return None
|
||||
return cam.cache_usage_mb
|
||||
|
||||
|
||||
class EnrichmentProgressSensor(_BaseAlbumSensor):
|
||||
"""Percent-complete sensor for the local-folder enrichment worker.
|
||||
|
||||
Reports the slower-changing of the two phases:
|
||||
- ``exif``: reading capture date + GPS from EXIF tags.
|
||||
- ``geocoding``: reverse-geocoding GPS to a city/country label.
|
||||
|
||||
Holds at ``100`` when both phases finish and stays there until the
|
||||
next coordinator refresh discovers new files. The ``phase`` and
|
||||
raw counts are surfaced as state attributes so dashboards can show
|
||||
progress text alongside the bar.
|
||||
"""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement = "%"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_icon = "mdi:map-marker-radius"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_enrichment_progress"
|
||||
self._attr_name = "Enrichment progress"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
prog = getattr(self.coordinator, "_enrich_progress", None) or {}
|
||||
phase = prog.get("phase")
|
||||
if phase == "geocoding":
|
||||
total = prog.get("geocode_total") or 0
|
||||
done = prog.get("geocode_done") or 0
|
||||
else:
|
||||
total = prog.get("exif_total") or 0
|
||||
done = prog.get("exif_done") or 0
|
||||
if total <= 0:
|
||||
return None
|
||||
return max(0, min(100, round(100 * done / total)))
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
prog = getattr(self.coordinator, "_enrich_progress", None) or {}
|
||||
return {
|
||||
"phase": prog.get("phase"),
|
||||
"exif_total": prog.get("exif_total", 0),
|
||||
"exif_done": prog.get("exif_done", 0),
|
||||
"geocode_total": prog.get("geocode_total", 0),
|
||||
"geocode_done": prog.get("geocode_done", 0),
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
next_slide:
|
||||
name: Next slide
|
||||
description: Advance the slideshow to the next image for a specific config entry.
|
||||
fields:
|
||||
entry_id:
|
||||
name: Entry ID
|
||||
description: The config entry id for the album slideshow instance.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
refresh_album:
|
||||
name: Refresh album
|
||||
description: Refresh the album list for a specific config entry.
|
||||
fields:
|
||||
entry_id:
|
||||
name: Entry ID
|
||||
description: The config entry id for the album slideshow instance.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from .const import (
|
||||
DEFAULT_SLIDE_INTERVAL,
|
||||
DEFAULT_REFRESH_HOURS,
|
||||
DEFAULT_FILL_MODE,
|
||||
DEFAULT_ORIENTATION_MISMATCH_MODE,
|
||||
DEFAULT_ORDER_MODE,
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
DEFAULT_PAIR_DIVIDER_PX,
|
||||
DEFAULT_PAIR_DIVIDER_COLOR,
|
||||
DEFAULT_IMAGE_CACHE_MB,
|
||||
DEFAULT_MAX_RESOLUTION,
|
||||
DEFAULT_DATE_FILTER,
|
||||
)
|
||||
|
||||
|
||||
Listener = Callable[[], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlideshowStore:
|
||||
slide_interval: int = DEFAULT_SLIDE_INTERVAL
|
||||
refresh_hours: int = DEFAULT_REFRESH_HOURS
|
||||
fill_mode: str = DEFAULT_FILL_MODE
|
||||
portrait_mode: str = DEFAULT_ORIENTATION_MISMATCH_MODE
|
||||
order_mode: str = DEFAULT_ORDER_MODE
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO
|
||||
pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX
|
||||
pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR
|
||||
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
|
||||
max_resolution: str = DEFAULT_MAX_RESOLUTION
|
||||
|
||||
# Date filter mode (preset windows like this_year / on_this_day).
|
||||
date_filter: str = DEFAULT_DATE_FILTER
|
||||
|
||||
# Pause toggle - when True, the slideshow holds on the current frame.
|
||||
paused: bool = False
|
||||
|
||||
# In-memory last rendered frame. Not user-configurable; used to re-serve
|
||||
# the previous slide instantly across a camera reload.
|
||||
last_frame: bytes | None = None
|
||||
|
||||
_listeners: list[Listener] = field(default_factory=list)
|
||||
|
||||
def add_listener(self, cb: Listener) -> None:
|
||||
if cb not in self._listeners:
|
||||
self._listeners.append(cb)
|
||||
|
||||
def notify(self) -> None:
|
||||
for cb in list(self._listeners):
|
||||
cb()
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add album slideshow",
|
||||
"description": "Choose a source for the slideshow.",
|
||||
"data": {
|
||||
"provider": "Source"
|
||||
}
|
||||
},
|
||||
"google_shared": {
|
||||
"title": "Google Photos",
|
||||
"description": "Paste a Google Photos shared album share link.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"album_url": "Shared album link"
|
||||
}
|
||||
},
|
||||
"local_folder": {
|
||||
"title": "Local Folder",
|
||||
"description": "Pick a folder path on your Home Assistant filesystem. Use /local/... (maps to /config/www/...) or /media/local/... for NAS-mounted media folders. See the README for details.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"local_path": "Folder path",
|
||||
"recursive": "Include subfolders"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_album_url": "That does not look like a Google Photos shared album link.",
|
||||
"invalid_path": "Path is empty or invalid."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Local Folder options",
|
||||
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
||||
"data": {
|
||||
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .store import SlideshowStore
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
async_add_entities([PauseSwitch(entry, store)])
|
||||
|
||||
|
||||
class PauseSwitch(SwitchEntity, RestoreEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_attr_icon = "mdi:pause"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
self.entry = entry
|
||||
self.store = store
|
||||
self._attr_unique_id = f"{entry.entry_id}_paused"
|
||||
self._attr_name = "Pause slideshow"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
return bool(self.store.paused)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
self.store.paused = True
|
||||
self.store.notify()
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
self.store.paused = False
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.store.add_listener(self.async_write_ha_state)
|
||||
old = await self.async_get_last_state()
|
||||
if old is not None and old.state in ("on", "off"):
|
||||
self.store.paused = old.state == "on"
|
||||
self.store.notify()
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.text import TextEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DOMAIN, DEFAULT_PAIR_DIVIDER_COLOR
|
||||
from .store import SlideshowStore
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
|
||||
async_add_entities([PairDividerColorText(entry, store)])
|
||||
|
||||
|
||||
class PairDividerColorText(TextEntity, RestoreEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_attr_icon = "mdi:palette"
|
||||
_attr_native_min = 1
|
||||
_attr_native_max = 32
|
||||
|
||||
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
|
||||
self.entry = entry
|
||||
self.store = store
|
||||
|
||||
self._attr_unique_id = f"{entry.entry_id}_pair_divider_color"
|
||||
self._attr_name = "Pair divider color"
|
||||
|
||||
def _on_store_change() -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
store.add_listener(_on_store_change)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
return self.store.pair_divider_color
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.entry.entry_id)},
|
||||
"name": f"Album Slideshow {self.entry.title}",
|
||||
"manufacturer": "Album Slideshow",
|
||||
}
|
||||
|
||||
async def async_set_value(self, value: str) -> None:
|
||||
val = (value or "").strip()
|
||||
self.store.pair_divider_color = val or DEFAULT_PAIR_DIVIDER_COLOR
|
||||
self.store.notify()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.store.add_listener(self.async_write_ha_state)
|
||||
old = await self.async_get_last_state()
|
||||
if old and old.state not in (None, "unknown", "unavailable"):
|
||||
self.store.pair_divider_color = old.state.strip() or DEFAULT_PAIR_DIVIDER_COLOR
|
||||
self.store.notify()
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add album slideshow",
|
||||
"description": "Choose a source for the slideshow.",
|
||||
"data": {
|
||||
"provider": "Source"
|
||||
}
|
||||
},
|
||||
"google_shared": {
|
||||
"title": "Google Photos",
|
||||
"description": "Paste a Google Photos shared album share link.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"album_url": "Shared album link"
|
||||
}
|
||||
},
|
||||
"local_folder": {
|
||||
"title": "Local Folder",
|
||||
"description": "Pick a folder path on your Home Assistant filesystem. Use /local/... (maps to /config/www/...) or /media/local/... for NAS-mounted media folders. See the README for details.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"local_path": "Folder path",
|
||||
"recursive": "Include subfolders"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_album_url": "That does not look like a Google Photos shared album link.",
|
||||
"invalid_path": "Path is empty or invalid."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Local Folder options",
|
||||
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
||||
"data": {
|
||||
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
"""The Climate Scheduler integration."""
|
||||
import logging
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.components.http import HomeAssistantView, StaticPathConfig
|
||||
from homeassistant.util import json as json_util
|
||||
from aiohttp import web
|
||||
|
||||
from .const import DOMAIN, UPDATE_INTERVAL_SECONDS
|
||||
from .coordinator import HeatingSchedulerCoordinator
|
||||
from .storage import ScheduleStorage
|
||||
# Expose dynamic service descriptions for Home Assistant UI
|
||||
from .services import async_get_services # noqa: E402,F401
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _async_setup_common(hass: HomeAssistant) -> None:
|
||||
"""Common setup for storage, coordinator, services and panel."""
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
# Initialize storage once
|
||||
storage: ScheduleStorage | None = hass.data[DOMAIN].get("storage")
|
||||
if storage is None:
|
||||
storage = ScheduleStorage(hass)
|
||||
await storage.async_load()
|
||||
hass.data[DOMAIN]["storage"] = storage
|
||||
|
||||
# Initialize coordinator once
|
||||
coordinator: HeatingSchedulerCoordinator | None = hass.data[DOMAIN].get("coordinator")
|
||||
if coordinator is None:
|
||||
coordinator = HeatingSchedulerCoordinator(
|
||||
hass,
|
||||
storage,
|
||||
timedelta(seconds=UPDATE_INTERVAL_SECONDS)
|
||||
)
|
||||
hass.data[DOMAIN]["coordinator"] = coordinator
|
||||
|
||||
# Start coordinator updates
|
||||
_LOGGER.info(f"Starting coordinator with {UPDATE_INTERVAL_SECONDS}s update interval")
|
||||
await coordinator.async_refresh()
|
||||
_LOGGER.info("Forcing initial temperature sync for all entities")
|
||||
await coordinator.force_update_all()
|
||||
|
||||
# Schedule periodic updates
|
||||
async def _scheduled_update(now):
|
||||
"""Handle scheduled update."""
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
async_track_time_interval(
|
||||
hass,
|
||||
_scheduled_update,
|
||||
timedelta(seconds=UPDATE_INTERVAL_SECONDS)
|
||||
)
|
||||
|
||||
# Avoid re-registering services, but be robust across updates/reloads.
|
||||
# During an in-process reload/upgrade, `hass.data` can persist while new
|
||||
# services from a newer version are missing. If so, re-register.
|
||||
if hass.data[DOMAIN].get("services_registered"):
|
||||
# Keep this list in sync with the services registered in `services.py`.
|
||||
expected_services = (
|
||||
"recreate_all_sensors",
|
||||
"cleanup_malformed_sensors",
|
||||
"set_schedule",
|
||||
"get_schedule",
|
||||
"clear_schedule",
|
||||
"enable_schedule",
|
||||
"disable_schedule",
|
||||
"set_ignored",
|
||||
"sync_all",
|
||||
"create_group",
|
||||
"delete_group",
|
||||
"rename_group",
|
||||
"add_to_group",
|
||||
"remove_from_group",
|
||||
"get_groups",
|
||||
"list_groups",
|
||||
"list_profiles",
|
||||
"set_group_schedule",
|
||||
"enable_group",
|
||||
"disable_group",
|
||||
"get_settings",
|
||||
"save_settings",
|
||||
"reload_integration",
|
||||
"advance_schedule",
|
||||
"advance_group",
|
||||
"cancel_advance",
|
||||
"get_advance_status",
|
||||
"clear_advance_history",
|
||||
"test_fire_event",
|
||||
"create_profile",
|
||||
"delete_profile",
|
||||
"rename_profile",
|
||||
"set_active_profile",
|
||||
"get_profiles",
|
||||
"cleanup_derivative_sensors",
|
||||
"factory_reset",
|
||||
"reregister_card",
|
||||
"diagnostics",
|
||||
)
|
||||
missing = [s for s in expected_services if not hass.services.has_service(DOMAIN, s)]
|
||||
if not missing:
|
||||
# Services are already registered; still ensure frontend resources
|
||||
# are registered/updated (important after install/upgrade or when
|
||||
# Lovelace wasn't ready earlier during startup).
|
||||
await _register_frontend_resources(hass)
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"Climate Scheduler services flagged as registered but missing %s; re-registering services",
|
||||
missing,
|
||||
)
|
||||
hass.data[DOMAIN]["services_registered"] = False
|
||||
|
||||
# Register services from services module
|
||||
from . import services as service_module
|
||||
await service_module.async_setup_services(hass)
|
||||
|
||||
hass.data[DOMAIN]["services_registered"] = True
|
||||
|
||||
# Register frontend card resources
|
||||
await _register_frontend_resources(hass)
|
||||
|
||||
|
||||
async def _register_frontend_resources(hass: HomeAssistant) -> None:
|
||||
"""Register the bundled frontend card as a Lovelace resource."""
|
||||
# Get version from manifest.json first
|
||||
manifest_path = Path(__file__).parent / "manifest.json"
|
||||
try:
|
||||
import json
|
||||
manifest_text = await hass.async_add_executor_job(manifest_path.read_text)
|
||||
manifest = json.loads(manifest_text)
|
||||
frontend_version = manifest.get("version", f"u{int(time.time())}")
|
||||
_LOGGER.debug("Current integration version: %s", frontend_version)
|
||||
except Exception as e:
|
||||
_LOGGER.warning("Failed to read manifest version: %s", e)
|
||||
frontend_version = f"u{int(time.time())}"
|
||||
|
||||
# Always attempt to (re)register frontend resources on startup/update.
|
||||
# We intentionally do not short-circuit when the stored version matches
|
||||
# the current one, because we want to remove any existing resource
|
||||
# entries and recreate them during install/update/reboot.
|
||||
|
||||
# Register static path for frontend files
|
||||
frontend_path = Path(__file__).parent / "frontend"
|
||||
if not frontend_path.exists():
|
||||
_LOGGER.warning("Frontend directory not found at %s", frontend_path)
|
||||
return
|
||||
|
||||
# Register the static path using the correct HA API.
|
||||
# This only needs to happen once per HA runtime; avoid repeated registration
|
||||
# and noisy logs when config entries reload.
|
||||
should_cache = False
|
||||
if not hass.data[DOMAIN].get("frontend_static_registered"):
|
||||
# Expose at /<domain>/static so integrations can reliably reference it
|
||||
await hass.http.async_register_static_paths([
|
||||
StaticPathConfig(f"/{DOMAIN}/static", str(frontend_path), should_cache)
|
||||
])
|
||||
hass.data[DOMAIN]["frontend_static_registered"] = True
|
||||
_LOGGER.info("Registered frontend static path: %s -> %s", f"/{DOMAIN}/static", frontend_path)
|
||||
else:
|
||||
_LOGGER.debug("Frontend static path already registered")
|
||||
|
||||
# Register Lovelace resource
|
||||
try:
|
||||
# Get lovelace resources
|
||||
lovelace_data = hass.data.get("lovelace")
|
||||
if lovelace_data is None:
|
||||
_LOGGER.warning("Lovelace integration not available, card will need manual registration")
|
||||
hass.data[DOMAIN]["frontend_registered_version"] = frontend_version
|
||||
return
|
||||
|
||||
# Get resources based on HA version
|
||||
from homeassistant.const import __version__ as ha_version
|
||||
version_parts = [int(x) for x in ha_version.split(".")[:2]]
|
||||
version_number = version_parts[0] * 1000 + version_parts[1]
|
||||
|
||||
if version_number >= 2025002: # 2025.2.0 and later
|
||||
resources = lovelace_data.resources
|
||||
else:
|
||||
resources = lovelace_data.get("resources")
|
||||
|
||||
if resources is None:
|
||||
_LOGGER.warning("Lovelace resources not available, card will need manual registration")
|
||||
return
|
||||
|
||||
# Check for YAML mode
|
||||
if not hasattr(resources, "store") or resources.store is None:
|
||||
_LOGGER.info("Lovelace YAML mode detected, card must be registered manually")
|
||||
|
||||
# Check if the card is already registered in YAML
|
||||
base_url = f"/{DOMAIN}/static/climate-scheduler-card.js"
|
||||
card_already_registered = False
|
||||
|
||||
try:
|
||||
for entry in resources.async_items():
|
||||
entry_url = entry.get("url", "")
|
||||
entry_base_url = entry_url.split("?")[0]
|
||||
if entry_base_url == base_url:
|
||||
card_already_registered = True
|
||||
_LOGGER.info("Card is already registered in YAML configuration")
|
||||
# Dismiss any existing notification since card is now present
|
||||
await hass.services.async_call(
|
||||
"persistent_notification",
|
||||
"dismiss",
|
||||
{"notification_id": f"{DOMAIN}_yaml_mode"},
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Could not check YAML resources: %s", e)
|
||||
|
||||
# Only create notification if card is NOT registered
|
||||
if not card_already_registered:
|
||||
_LOGGER.warning("Card is not registered in YAML configuration - notification will be created")
|
||||
await hass.services.async_call(
|
||||
"persistent_notification",
|
||||
"create",
|
||||
{
|
||||
"notification_id": f"{DOMAIN}_yaml_mode",
|
||||
"title": "Climate Scheduler: Manual Card Registration Required",
|
||||
"message": (
|
||||
"Your Lovelace dashboard is configured via YAML mode. "
|
||||
"The Climate Scheduler card cannot be auto-registered.\n\n"
|
||||
"**To add the card manually:**\n\n"
|
||||
"Add this to your Lovelace resources configuration:\n\n"
|
||||
f"```yaml\n"
|
||||
f"lovelace:\n"
|
||||
f" mode: yaml\n"
|
||||
f" resources:\n"
|
||||
f" - url: /{DOMAIN}/static/climate-scheduler-card.js?v={frontend_version}\n"
|
||||
f" type: module\n"
|
||||
f"```\n\n"
|
||||
"Then restart Home Assistant and refresh your browser."
|
||||
),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# Ensure resources are loaded
|
||||
if not resources.loaded:
|
||||
await resources.async_load()
|
||||
|
||||
# Build URL with version for cache busting
|
||||
# Use the integration-hosted static path we just registered
|
||||
base_url = f"/{DOMAIN}/static/climate-scheduler-card.js"
|
||||
url = f"{base_url}?v={frontend_version}"
|
||||
|
||||
# Check for old standalone card installations and remove them
|
||||
old_card_patterns = [
|
||||
"/hacsfiles/climate-scheduler/",
|
||||
"/local/community/climate-scheduler/",
|
||||
"climate-scheduler-card.js",
|
||||
f"/{DOMAIN}/static/"
|
||||
]
|
||||
|
||||
existing_entry = None
|
||||
removed_old = False
|
||||
for entry in list(resources.async_items()):
|
||||
entry_url = entry["url"]
|
||||
entry_base_url = entry_url.split("?")[0]
|
||||
|
||||
# Check if this is our new bundled card
|
||||
if entry_base_url == base_url:
|
||||
existing_entry = entry
|
||||
continue
|
||||
|
||||
# Check if this is an old standalone card installation
|
||||
if any(pattern in entry_url for pattern in old_card_patterns):
|
||||
_LOGGER.info("Removing old standalone card registration: %s", entry_url)
|
||||
await resources.async_delete_item(entry["id"])
|
||||
removed_old = True
|
||||
|
||||
# If there's an existing bundled registration, remove it first so
|
||||
# we always create a fresh entry (ensures consistent behavior on
|
||||
# install/update/reboot and avoids stale IDs or metadata).
|
||||
if existing_entry:
|
||||
try:
|
||||
_LOGGER.info("Removing existing bundled frontend card registration: %s", existing_entry.get("url"))
|
||||
await resources.async_delete_item(existing_entry["id"])
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug("Failed to remove existing bundled frontend resource, will attempt to (re)create it")
|
||||
|
||||
# Create a new resource entry for the bundled card
|
||||
if hasattr(resources, "async_create_item"):
|
||||
await resources.async_create_item({"res_type": "module", "url": url})
|
||||
|
||||
if removed_old:
|
||||
_LOGGER.info("Successfully migrated to bundled frontend card (version %s)", frontend_version)
|
||||
else:
|
||||
_LOGGER.info("Successfully registered bundled frontend card (version %s)", frontend_version)
|
||||
else:
|
||||
_LOGGER.warning("Cannot auto-register card: Lovelace resources are in YAML mode")
|
||||
|
||||
# Note: Version mismatch detection and refresh notification is handled by the frontend
|
||||
# JavaScript, which can accurately detect when the browser cache is stale
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.error("Failed to auto-register frontend card: %s", err)
|
||||
|
||||
# Store the version we just registered
|
||||
hass.data[DOMAIN]["frontend_registered_version"] = frontend_version
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up via YAML by importing into a config entry, else no-op."""
|
||||
if DOMAIN in config:
|
||||
# Import legacy YAML into a config entry
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Climate Scheduler from a config entry."""
|
||||
await _async_setup_common(hass)
|
||||
|
||||
# Get current version from manifest
|
||||
manifest_path = Path(__file__).parent / "manifest.json"
|
||||
current_version = None
|
||||
try:
|
||||
manifest_text = await hass.async_add_executor_job(manifest_path.read_text)
|
||||
manifest = json.loads(manifest_text)
|
||||
current_version = manifest.get("version")
|
||||
except Exception as e:
|
||||
_LOGGER.warning("Failed to read manifest version: %s", e)
|
||||
|
||||
# Check if this is first install or an upgrade
|
||||
stored_version = entry.data.get("version")
|
||||
is_first_install = stored_version is None
|
||||
is_upgrade = stored_version is not None and current_version != stored_version
|
||||
|
||||
# Update version in config entry if changed
|
||||
if current_version and (is_first_install or is_upgrade):
|
||||
new_data = dict(entry.data)
|
||||
new_data["version"] = current_version
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
if is_first_install:
|
||||
_LOGGER.info("First install detected (version %s) - will reload integration after setup", current_version)
|
||||
elif is_upgrade:
|
||||
_LOGGER.info("Upgrade detected (%s -> %s) - will reload integration after setup", stored_version, current_version)
|
||||
|
||||
# Forward entry setup to sensor, climate, and switch platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, ["sensor", "climate", "switch"])
|
||||
|
||||
# Reload integration after first install or upgrade to ensure UI is properly initialized
|
||||
if is_first_install or is_upgrade:
|
||||
async def _delayed_reload():
|
||||
"""Reload integration after a short delay to allow setup to complete."""
|
||||
await asyncio.sleep(2) # Wait for platforms to fully initialize
|
||||
try:
|
||||
_LOGGER.info("Performing post-setup reload to initialize UI")
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
except Exception as e:
|
||||
_LOGGER.warning("Post-setup reload failed: %s", e)
|
||||
|
||||
import asyncio
|
||||
hass.async_create_task(_delayed_reload())
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
# Unload all platforms
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor", "climate", "switch"])
|
||||
|
||||
# Remove panel and services only if this is the last entry
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
if len(entries) <= 1:
|
||||
_LOGGER.debug("[BACKEND] Unloading last config entry - removing all services (including set_group_schedule)")
|
||||
# Unregister services
|
||||
for svc in [
|
||||
"set_schedule","get_schedule","clear_schedule","enable_schedule","disable_schedule",
|
||||
"sync_all","create_group","delete_group","add_to_group","remove_from_group",
|
||||
"get_groups","set_group_schedule","enable_group","disable_group","get_settings",
|
||||
"save_settings","reload_integration","advance_schedule","advance_group",
|
||||
"cancel_advance","get_advance_status","clear_advance_history","test_fire_event","create_profile",
|
||||
"delete_profile","rename_profile","set_active_profile","get_profiles",
|
||||
"cleanup_derivative_sensors","factory_reset"
|
||||
]:
|
||||
try:
|
||||
hass.services.async_remove(DOMAIN, svc)
|
||||
if svc == "set_group_schedule":
|
||||
_LOGGER.debug("[BACKEND] Removed service: set_group_schedule")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_LOGGER.info("[BACKEND] All services removed during unload")
|
||||
hass.data.pop(DOMAIN, None)
|
||||
else:
|
||||
_LOGGER.info("[BACKEND] Unloading entry but keeping services (not last entry)")
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Climate platform for Climate Scheduler."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime, timedelta, time
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
HVACAction,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HeatingSchedulerCoordinator
|
||||
from .storage import ScheduleStorage
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["async_setup_entry", "ClimateSchedulerGroupEntity"]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Climate Scheduler climate entities from a config entry."""
|
||||
storage: ScheduleStorage = hass.data[DOMAIN]["storage"]
|
||||
coordinator: HeatingSchedulerCoordinator = hass.data[DOMAIN]["coordinator"]
|
||||
|
||||
# Get all groups - create climate cards for all groups with entities
|
||||
groups = await storage.async_get_groups()
|
||||
entities = []
|
||||
groups_normalized = False
|
||||
|
||||
for group_name, group_data in groups.items():
|
||||
# Normalize _is_single_entity_group flag based on actual entity count
|
||||
# This fixes inconsistency where groups with 1 entity might not have the flag set
|
||||
entity_count = len(group_data.get("entities", []))
|
||||
is_single_entity = entity_count == 1
|
||||
has_flag = group_data.get("_is_single_entity_group", False)
|
||||
|
||||
# If flag doesn't match actual entity count, normalize it
|
||||
if is_single_entity != has_flag:
|
||||
group_data["_is_single_entity_group"] = is_single_entity
|
||||
groups_normalized = True
|
||||
_LOGGER.info(
|
||||
f"Normalized _is_single_entity_group flag for group '{group_name}': "
|
||||
f"entity_count={entity_count}, flag={is_single_entity}"
|
||||
)
|
||||
|
||||
# Skip virtual groups (no entities) - but include all groups with at least one entity
|
||||
if entity_count == 0:
|
||||
continue
|
||||
|
||||
# Create climate entity for this group (including single-entity groups)
|
||||
entities.append(
|
||||
ClimateSchedulerGroupEntity(
|
||||
coordinator,
|
||||
storage,
|
||||
group_name,
|
||||
group_data,
|
||||
)
|
||||
)
|
||||
_LOGGER.info(f"Created climate entity for group '{group_name}' with {entity_count} entities")
|
||||
|
||||
# Save normalized flags if any were updated
|
||||
if groups_normalized:
|
||||
await storage.async_save()
|
||||
_LOGGER.info("Saved normalized group flags to storage")
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities, True)
|
||||
_LOGGER.info(f"Added {len(entities)} climate scheduler group entities")
|
||||
|
||||
|
||||
class ClimateSchedulerGroupEntity(CoordinatorEntity, ClimateEntity):
|
||||
"""Climate entity representing a schedule group."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HeatingSchedulerCoordinator,
|
||||
storage: ScheduleStorage,
|
||||
group_name: str,
|
||||
group_data: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the climate entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._storage = storage
|
||||
self._group_name = group_name
|
||||
self._attr_has_entity_name = True
|
||||
self._attr_name = f"Climate Schedule {group_name}"
|
||||
self._attr_unique_id = f"climate_scheduler_group_{group_name.lower().replace(' ', '_')}"
|
||||
|
||||
# Climate entity configuration - will be set when added to hass
|
||||
self._attr_temperature_unit = UnitOfTemperature.CELSIUS # Default, updated in async_added_to_hass
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE |
|
||||
ClimateEntityFeature.TURN_ON |
|
||||
ClimateEntityFeature.TURN_OFF |
|
||||
ClimateEntityFeature.PRESET_MODE
|
||||
)
|
||||
|
||||
# Temperature control settings
|
||||
self._attr_min_temp = 5.0
|
||||
self._attr_max_temp = 35.0
|
||||
self._attr_target_temp_step = 0.5
|
||||
|
||||
# Simplified modes: Auto (Active - follows schedule) or Off (Idle)
|
||||
self._attr_hvac_modes = [
|
||||
HVACMode.AUTO, # Active - follows schedule
|
||||
HVACMode.OFF, # Idle - turns off members
|
||||
]
|
||||
|
||||
# Preset modes from profiles
|
||||
profiles = group_data.get("profiles", {})
|
||||
self._attr_preset_modes = list(profiles.keys()) if profiles else []
|
||||
self._attr_preset_mode = group_data.get("active_profile", "Default")
|
||||
|
||||
# State tracking
|
||||
self._member_entities: List[str] = group_data.get("entities", [])
|
||||
self._enabled: bool = group_data.get("enabled", True)
|
||||
self._attr_current_temperature: Optional[float] = None
|
||||
self._attr_target_temperature: Optional[float] = None
|
||||
self._attr_hvac_mode: Optional[HVACMode] = HVACMode.AUTO if self._enabled else HVACMode.OFF
|
||||
self._attr_hvac_action: Optional[HVACAction] = None # Will be set in _update_state
|
||||
|
||||
# Cache for attributes
|
||||
self._active_node: Optional[Dict[str, Any]] = None
|
||||
self._next_node: Optional[Dict[str, Any]] = None
|
||||
self._member_temps: Dict[str, float] = {}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass, set temperature unit from system config."""
|
||||
await super().async_added_to_hass()
|
||||
# Use the same approach as storage.py - get temperature unit from system config
|
||||
self._attr_temperature_unit = self.hass.config.units.temperature_unit
|
||||
|
||||
# Set temperature step from first member entity if available
|
||||
if self._member_entities:
|
||||
first_member = self.hass.states.get(self._member_entities[0])
|
||||
if first_member:
|
||||
member_step = first_member.attributes.get("target_temp_step", 0.5)
|
||||
self._attr_target_temp_step = member_step
|
||||
|
||||
_LOGGER.debug(
|
||||
"Climate entity %s initialized with temperature unit: %s, step: %s",
|
||||
self.entity_id,
|
||||
self._attr_temperature_unit,
|
||||
self._attr_target_temp_step,
|
||||
)
|
||||
# Perform initial state update
|
||||
self._update_state()
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return unique ID."""
|
||||
return self._attr_unique_id
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device info to link this entity to the Climate Scheduler integration."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, "climate_scheduler")},
|
||||
"name": "Climate Scheduler",
|
||||
"manufacturer": "Climate Scheduler",
|
||||
"model": "Schedule Manager",
|
||||
}
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
# Update enabled state and profiles from storage
|
||||
group_data = self._storage._data.get("groups", {}).get(self._group_name)
|
||||
if group_data:
|
||||
self._enabled = group_data.get("enabled", True)
|
||||
profiles = group_data.get("profiles", {})
|
||||
self._attr_preset_modes = list(profiles.keys()) if profiles else []
|
||||
self._attr_preset_mode = group_data.get("active_profile", "Default")
|
||||
# Update HVAC mode based on enabled state
|
||||
self._attr_hvac_mode = HVACMode.AUTO if self._enabled else HVACMode.OFF
|
||||
self._update_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _update_state(self) -> None:
|
||||
"""Update entity state from member entities and schedule."""
|
||||
# 1. Calculate average current temperature from members (in their native unit)
|
||||
temps = []
|
||||
temp_map = {}
|
||||
|
||||
for entity_id in self._member_entities:
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state and state.attributes.get("current_temperature") is not None:
|
||||
try:
|
||||
temp = float(state.attributes["current_temperature"])
|
||||
# Store temperature in its native unit, no conversion
|
||||
temps.append(temp)
|
||||
temp_map[entity_id] = round(temp, 1)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if temps:
|
||||
self._attr_current_temperature = round(sum(temps) / len(temps), 1)
|
||||
else:
|
||||
self._attr_current_temperature = None
|
||||
|
||||
self._member_temps = temp_map
|
||||
|
||||
# 2. Check if any member is actively requesting heat/cool and determine overall action
|
||||
heating_count = 0
|
||||
cooling_count = 0
|
||||
for entity_id in self._member_entities:
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state:
|
||||
hvac_action = state.attributes.get("hvac_action")
|
||||
if hvac_action == "heating":
|
||||
heating_count += 1
|
||||
elif hvac_action == "cooling":
|
||||
cooling_count += 1
|
||||
|
||||
# Set hvac_action based on member states and enabled status
|
||||
if not self._enabled:
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
elif heating_count > 0:
|
||||
self._attr_hvac_action = HVACAction.HEATING
|
||||
elif cooling_count > 0:
|
||||
self._attr_hvac_action = HVACAction.COOLING
|
||||
else:
|
||||
self._attr_hvac_action = HVACAction.IDLE
|
||||
|
||||
# 3. Get active node from schedule to determine setpoint
|
||||
current_time = datetime.now().time()
|
||||
current_day = datetime.now().strftime('%a').lower()
|
||||
|
||||
# Get the schedule data from storage (synchronously accessible)
|
||||
group_data = self._storage._data.get("groups", {}).get(self._group_name, {})
|
||||
schedule_mode = group_data.get("schedule_mode", "all_days")
|
||||
schedules = group_data.get("schedules", {})
|
||||
|
||||
# Get nodes for current day using the same logic as coordinator
|
||||
if schedule_mode == "all_days":
|
||||
nodes = schedules.get("all_days", [])
|
||||
elif schedule_mode == "5/2":
|
||||
if current_day in ["mon", "tue", "wed", "thu", "fri"]:
|
||||
nodes = schedules.get("weekday", [])
|
||||
else:
|
||||
nodes = schedules.get("weekend", [])
|
||||
else: # individual
|
||||
nodes = schedules.get(current_day, [])
|
||||
|
||||
# In individual or 5/2 mode, if current time is before all nodes today,
|
||||
# use previous day's last node
|
||||
if schedule_mode in ["individual", "5/2"] and nodes:
|
||||
sorted_nodes = sorted(nodes, key=lambda n: self._storage._time_to_minutes(n["time"]))
|
||||
current_minutes = current_time.hour * 60 + current_time.minute
|
||||
if sorted_nodes and current_minutes < self._storage._time_to_minutes(sorted_nodes[0]["time"]):
|
||||
# We're before the first node of today, need previous day/period's last node
|
||||
days_of_week = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
current_day_index = days_of_week.index(current_day)
|
||||
prev_day = days_of_week[(current_day_index - 1) % 7]
|
||||
|
||||
# Get previous day's nodes based on schedule mode
|
||||
if schedule_mode == "5/2":
|
||||
# In 5/2 mode, determine if previous day is weekday or weekend
|
||||
if prev_day in ["mon", "tue", "wed", "thu", "fri"]:
|
||||
prev_day_nodes = schedules.get("weekday", [])
|
||||
else:
|
||||
prev_day_nodes = schedules.get("weekend", [])
|
||||
else:
|
||||
# In individual mode, use specific day
|
||||
prev_day_nodes = schedules.get(prev_day, [])
|
||||
|
||||
if prev_day_nodes:
|
||||
sorted_prev_nodes = sorted(prev_day_nodes, key=lambda n: self._storage._time_to_minutes(n["time"]))
|
||||
last_prev_node = sorted_prev_nodes[-1]
|
||||
# Prepend previous day's last node with time "00:00"
|
||||
carryover_node = {**last_prev_node, "time": "00:00"}
|
||||
nodes = [carryover_node] + nodes
|
||||
|
||||
# Determine the active setpoint from schedule
|
||||
active_setpoint = None
|
||||
if nodes:
|
||||
active_node = self._storage.get_active_node(nodes, current_time)
|
||||
if active_node:
|
||||
active_setpoint = active_node.get("temp")
|
||||
_LOGGER.debug(f"Active node for {self._group_name}: temp={active_setpoint}")
|
||||
else:
|
||||
_LOGGER.debug(f"No active node for {self._group_name} at {current_time}")
|
||||
else:
|
||||
_LOGGER.debug(f"No schedule data for {self._group_name} on {current_day}")
|
||||
|
||||
# Show the scheduled setpoint as target temperature, or use a default
|
||||
if active_setpoint is not None:
|
||||
self._attr_target_temperature = active_setpoint
|
||||
else:
|
||||
# Fallback: use average of member target temps, or 20°C default
|
||||
member_targets = []
|
||||
for entity_id in self._member_entities:
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state and state.attributes.get("temperature") is not None:
|
||||
try:
|
||||
member_targets.append(float(state.attributes["temperature"]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if member_targets:
|
||||
self._attr_target_temperature = round(sum(member_targets) / len(member_targets), 1)
|
||||
else:
|
||||
self._attr_target_temperature = 20.0 # Default fallback
|
||||
|
||||
_LOGGER.debug(f"No schedule setpoint for {self._group_name}, using fallback: {self._attr_target_temperature}")
|
||||
|
||||
# HVAC mode reflects enabled state (not member states)
|
||||
# This is already set in _handle_coordinator_update
|
||||
|
||||
self._active_node = None
|
||||
self._next_node = None
|
||||
|
||||
def _map_hvac_mode(self, mode_str: str) -> HVACMode:
|
||||
"""Map string hvac_mode to HVACMode enum."""
|
||||
mapping = {
|
||||
"off": HVACMode.OFF,
|
||||
"heat": HVACMode.HEAT,
|
||||
"cool": HVACMode.COOL,
|
||||
"heat_cool": HVACMode.HEAT_COOL,
|
||||
"auto": HVACMode.AUTO,
|
||||
"dry": HVACMode.DRY,
|
||||
"fan_only": HVACMode.FAN_ONLY,
|
||||
}
|
||||
return mapping.get(mode_str.lower() if mode_str else "heat", HVACMode.HEAT)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Dict[str, Any]:
|
||||
"""Return entity specific state attributes."""
|
||||
attrs = {
|
||||
"member_entities": self._member_entities,
|
||||
"member_count": len(self._member_entities),
|
||||
"group_name": self._group_name,
|
||||
"schedule_enabled": self._enabled,
|
||||
}
|
||||
|
||||
# Add member temperatures if available
|
||||
if self._member_temps:
|
||||
attrs["member_temperatures"] = self._member_temps
|
||||
|
||||
# Placeholder for future phases - active node info
|
||||
if self._active_node:
|
||||
attrs["active_node"] = {
|
||||
"time": self._active_node.get("time"),
|
||||
"temp": self._active_node.get("temp"),
|
||||
"hvac_mode": self._active_node.get("hvac_mode"),
|
||||
}
|
||||
|
||||
if self._next_node:
|
||||
attrs["next_node"] = {
|
||||
"time": self._next_node.get("time"),
|
||||
"temp": self._next_node.get("temp"),
|
||||
"hvac_mode": self._next_node.get("hvac_mode"),
|
||||
}
|
||||
|
||||
return attrs
|
||||
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
"""Set new target temperature for all member entities."""
|
||||
temperature = kwargs.get("temperature")
|
||||
if temperature is None:
|
||||
_LOGGER.warning("No temperature provided to async_set_temperature")
|
||||
return
|
||||
|
||||
# Apply temperature to all member entities
|
||||
for entity_id in self._member_entities:
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
"climate",
|
||||
"set_temperature",
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"temperature": temperature,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
_LOGGER.info(f"Set temperature {temperature} for {entity_id}")
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Failed to set temperature for {entity_id}: {e}")
|
||||
|
||||
# Set override for each member until next scheduled node
|
||||
current_time = datetime.now().time()
|
||||
current_day = datetime.now().strftime('%a').lower()
|
||||
|
||||
group_data = self._storage._data.get("groups", {}).get(self._group_name, {})
|
||||
schedules = group_data.get("schedules", {})
|
||||
schedule_data = schedules.get(current_day)
|
||||
|
||||
if schedule_data and "nodes" in schedule_data:
|
||||
nodes = schedule_data["nodes"]
|
||||
next_node = self._storage.get_next_node(nodes, current_time)
|
||||
if next_node:
|
||||
# Calculate when override expires (at next node time)
|
||||
next_node_time_str = next_node["time"]
|
||||
next_node_hours, next_node_minutes = map(int, next_node_time_str.split(":"))
|
||||
override_until = datetime.now().replace(
|
||||
hour=next_node_hours,
|
||||
minute=next_node_minutes,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
# If next node time is earlier in the day than current time, it's tomorrow
|
||||
if override_until <= datetime.now():
|
||||
override_until += timedelta(days=1)
|
||||
|
||||
# Set override for all members
|
||||
for entity_id in self._member_entities:
|
||||
self.coordinator.override_until[entity_id] = override_until
|
||||
|
||||
_LOGGER.info(
|
||||
f"Set manual override for group {self._group_name} until {override_until}"
|
||||
)
|
||||
|
||||
# Update target temperature immediately and trigger state refresh
|
||||
self._attr_target_temperature = temperature
|
||||
|
||||
# Schedule a state update after a short delay to allow member entities to react
|
||||
async def delayed_update():
|
||||
await asyncio.sleep(2) # Wait for members to start heating
|
||||
self._update_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.hass.async_create_task(delayed_update())
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set HVAC mode - AUTO (active) or OFF (idle)."""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
# Turn off schedule (idle mode)
|
||||
await self.async_turn_off()
|
||||
elif hvac_mode == HVACMode.AUTO:
|
||||
# Turn on schedule (active mode)
|
||||
await self.async_turn_on()
|
||||
else:
|
||||
_LOGGER.warning(f"Unsupported HVAC mode {hvac_mode} for climate scheduler group")
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode (profile) for this group."""
|
||||
try:
|
||||
await self._storage.async_set_active_profile(self._group_name, preset_mode)
|
||||
except ValueError as err:
|
||||
_LOGGER.error(f"Profile switch failed for group {self._group_name}: {err}")
|
||||
return
|
||||
|
||||
# Force coordinator refresh to apply new schedule immediately
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
_LOGGER.info(f"Set profile {preset_mode} for group {self._group_name}")
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Enable the schedule."""
|
||||
# Enable schedule for all member entities
|
||||
for entity_id in self._member_entities:
|
||||
await self._storage.async_set_enabled(entity_id, True)
|
||||
self._enabled = True
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.info("Enabled schedule for group %s", self._group_name)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Disable the schedule."""
|
||||
# Disable schedule for all member entities
|
||||
for entity_id in self._member_entities:
|
||||
await self._storage.async_set_enabled(entity_id, False)
|
||||
self._enabled = False
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.info("Disabled schedule for group %s", self._group_name)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class ClimateSchedulerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input: dict | None = None):
|
||||
# Only allow a single entry
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="Climate Scheduler", data={})
|
||||
|
||||
# No options to configure — empty form confirms creation
|
||||
return self.async_show_form(step_id="user", data_schema=vol.Schema({}))
|
||||
|
||||
async def async_step_import(self, user_input: dict | None = None):
|
||||
# Support YAML import for backward compatibility
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="already_configured")
|
||||
return self.async_create_entry(title="Climate Scheduler", data={})
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Constants for the Climate Scheduler integration."""
|
||||
|
||||
DOMAIN = "climate_scheduler"
|
||||
|
||||
# Storage
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "climate_scheduler_data"
|
||||
|
||||
# Default schedule nodes (time in HH:MM format, temp in Celsius)
|
||||
DEFAULT_SCHEDULE = []
|
||||
|
||||
# Temperature settings (in Celsius)
|
||||
MIN_TEMP = 5.0
|
||||
MAX_TEMP = 30.0
|
||||
NO_CHANGE_TEMP = None # Special value to indicate temperature should not be changed
|
||||
|
||||
# Update interval
|
||||
UPDATE_INTERVAL_SECONDS = 60 # Check schedules every minute
|
||||
# Settings keys
|
||||
SETTING_USE_WORKDAY = "use_workday_integration" # Whether to use Workday integration for 5/2 scheduling
|
||||
SETTING_WORKDAYS = "workdays" # List of days considered workdays (e.g., ["mon", "tue", "wed", "thu", "fri"])
|
||||
|
||||
# Default workdays (Monday through Friday)
|
||||
DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"]
|
||||
@@ -0,0 +1 @@
|
||||
1.15.1
|
||||
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Home Assistant API Integration
|
||||
* Handles communication with Home Assistant via WebSocket API
|
||||
* Supports both custom panel mode (using hass object) and iframe mode (WebSocket)
|
||||
*/
|
||||
|
||||
class HomeAssistantAPI {
|
||||
constructor() {
|
||||
this.connection = null;
|
||||
this.messageId = 1;
|
||||
this.pendingRequests = new Map();
|
||||
this.stateUpdateCallbacks = [];
|
||||
this.hass = null; // For custom panel mode
|
||||
this.usingHassObject = false; // Track which mode we're in
|
||||
}
|
||||
|
||||
/**
|
||||
* Set hass object (custom panel mode)
|
||||
*/
|
||||
setHassObject(hass) {
|
||||
this.hass = hass;
|
||||
this.usingHassObject = true;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
// If we already have a hass object, we're in custom panel mode
|
||||
if (this.hass && this.usingHassObject) {
|
||||
console.log('Using existing hass connection from custom panel');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
// Get auth token - works in both browser and mobile app
|
||||
const authToken = await this.getAuthToken();
|
||||
|
||||
// Connect to Home Assistant WebSocket API
|
||||
// Use relative path for mobile app compatibility
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/api/websocket`;
|
||||
|
||||
this.connection = new WebSocket(wsUrl);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.connection.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
this.connection.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
this.handleMessage(message, authToken, resolve, reject);
|
||||
};
|
||||
|
||||
this.connection.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
this.connection.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(message, authToken, resolveConnection, rejectConnection) {
|
||||
if (message.type === 'auth_required') {
|
||||
// Send authentication
|
||||
this.send({
|
||||
type: 'auth',
|
||||
access_token: authToken
|
||||
});
|
||||
} else if (message.type === 'auth_ok') {
|
||||
console.log('Authenticated successfully');
|
||||
resolveConnection();
|
||||
} else if (message.type === 'auth_invalid') {
|
||||
console.error('Authentication failed');
|
||||
rejectConnection(new Error('Authentication failed'));
|
||||
} else if (message.type === 'result') {
|
||||
// Handle response to our request
|
||||
const pending = this.pendingRequests.get(message.id);
|
||||
if (pending) {
|
||||
if (message.success) {
|
||||
pending.resolve(message.result);
|
||||
} else {
|
||||
pending.reject(message.error);
|
||||
}
|
||||
this.pendingRequests.delete(message.id);
|
||||
}
|
||||
} else if (message.type === 'event') {
|
||||
// Handle state change events
|
||||
if (message.event && message.event.event_type === 'state_changed') {
|
||||
this.notifyStateUpdate(message.event.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthToken() {
|
||||
// Method 1: Check if running in Home Assistant panel context (works in mobile app)
|
||||
if (window.hassConnection) {
|
||||
try {
|
||||
const auth = await window.hassConnection;
|
||||
if (auth && auth.auth && auth.auth.data && auth.auth.data.access_token) {
|
||||
return auth.auth.data.access_token;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not get token from hassConnection:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Try to get from parent window context
|
||||
if (window.parent && window.parent.hassConnection && window.parent !== window) {
|
||||
try {
|
||||
const auth = await window.parent.hassConnection;
|
||||
if (auth && auth.auth && auth.auth.data && auth.auth.data.access_token) {
|
||||
return auth.auth.data.access_token;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not get token from parent.hassConnection:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Fallback to localStorage (browser only)
|
||||
try {
|
||||
const token = localStorage.getItem('hassTokens');
|
||||
if (token) {
|
||||
const tokens = JSON.parse(token);
|
||||
if (tokens.access_token) {
|
||||
return tokens.access_token;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not get token from localStorage:', e);
|
||||
}
|
||||
|
||||
throw new Error('No authentication token found. Please ensure the panel is loaded within Home Assistant.');
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(message));
|
||||
} else {
|
||||
throw new Error('WebSocket not connected');
|
||||
}
|
||||
}
|
||||
|
||||
async sendRequest(message) {
|
||||
// If using hass object (custom panel mode), use hass.callWS
|
||||
if (this.hass && this.usingHassObject) {
|
||||
try {
|
||||
return await this.hass.callWS(message);
|
||||
} catch (error) {
|
||||
console.error('Error calling hass.callWS:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to WebSocket mode
|
||||
const id = this.messageId++;
|
||||
const request = { id, ...message };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
this.send(request);
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error('Request timeout'));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
async getStates() {
|
||||
// Custom panel mode
|
||||
if (this.hass && this.usingHassObject) {
|
||||
return Object.values(this.hass.states);
|
||||
}
|
||||
// WebSocket mode
|
||||
return await this.sendRequest({ type: 'get_states' });
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
// Custom panel mode
|
||||
if (this.hass && this.usingHassObject) {
|
||||
return this.hass.config;
|
||||
}
|
||||
// WebSocket mode
|
||||
return await this.sendRequest({ type: 'get_config' });
|
||||
}
|
||||
|
||||
async getClimateEntities() {
|
||||
const states = await this.getStates();
|
||||
return states.filter(state =>
|
||||
state.entity_id.startsWith('climate.') &&
|
||||
!state.entity_id.startsWith('climate.climate_scheduler_')
|
||||
);
|
||||
}
|
||||
|
||||
async callService(domain, service, serviceData, returnResponse = false) {
|
||||
// Custom panel mode
|
||||
if (this.hass && this.usingHassObject) {
|
||||
// Use callWS for return_response to avoid triggering haptic feedback
|
||||
if (returnResponse) {
|
||||
const result = await this.hass.callWS({
|
||||
type: 'call_service',
|
||||
domain,
|
||||
service,
|
||||
service_data: serviceData,
|
||||
return_response: true
|
||||
});
|
||||
return result?.response;
|
||||
} else {
|
||||
// Use callService for non-return-response calls
|
||||
return await this.hass.callService(domain, service, serviceData);
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket mode
|
||||
const requestData = {
|
||||
type: 'call_service',
|
||||
domain,
|
||||
service,
|
||||
service_data: serviceData
|
||||
};
|
||||
|
||||
if (returnResponse) {
|
||||
requestData.return_response = true;
|
||||
}
|
||||
|
||||
return await this.sendRequest(requestData);
|
||||
}
|
||||
|
||||
async setLogLevel(level = 'debug') {
|
||||
return await this.callService('logger', 'set_level', {
|
||||
'custom_components.climate_scheduler': level
|
||||
});
|
||||
}
|
||||
|
||||
async getSchedule(entityId, day = null) {
|
||||
try {
|
||||
const serviceData = {
|
||||
schedule_id: entityId
|
||||
};
|
||||
|
||||
if (day) {
|
||||
serviceData.day = day;
|
||||
}
|
||||
|
||||
// Call our custom service to get schedule with return_response
|
||||
const result = await this.callService('climate_scheduler', 'get_schedule',
|
||||
serviceData, true); // Pass true to enable return_response
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get schedule:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setSchedule(entityId, nodes, day = null, scheduleMode = null) {
|
||||
const serviceData = {
|
||||
schedule_id: entityId,
|
||||
nodes: nodes
|
||||
};
|
||||
|
||||
if (day) {
|
||||
serviceData.day = day;
|
||||
}
|
||||
if (scheduleMode) {
|
||||
serviceData.schedule_mode = scheduleMode;
|
||||
}
|
||||
|
||||
return await this.callService('climate_scheduler', 'set_schedule', serviceData);
|
||||
}
|
||||
|
||||
async enableSchedule(entityId) {
|
||||
return await this.callService('climate_scheduler', 'enable_schedule', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async disableSchedule(entityId) {
|
||||
return await this.callService('climate_scheduler', 'disable_schedule', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async advanceSchedule(entityId) {
|
||||
return await this.callService('climate_scheduler', 'advance_schedule', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async advanceGroup(groupName) {
|
||||
return await this.callService('climate_scheduler', 'advance_group', {
|
||||
schedule_id: groupName
|
||||
});
|
||||
}
|
||||
|
||||
async cancelAdvance(entityId) {
|
||||
return await this.callService('climate_scheduler', 'cancel_advance', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async getAdvanceStatus(entityId) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'get_advance_status', {
|
||||
schedule_id: entityId
|
||||
}, true);
|
||||
// Normalize across modes:
|
||||
// - hass.callWS path returns `result.response`
|
||||
// - websocket path may return `{response: ...}`
|
||||
return result?.response ?? result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get advance status:', error);
|
||||
return { is_active: false, history: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async clearAdvanceHistory(entityId) {
|
||||
return await this.callService('climate_scheduler', 'clear_advance_history', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async testFireEvent(groupName, node, day) {
|
||||
return await this.callService('climate_scheduler', 'test_fire_event', {
|
||||
schedule_id: groupName,
|
||||
node: JSON.stringify(node),
|
||||
day: day
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async getOverrideStatus(entityId) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'get_override_status', {
|
||||
schedule_id: entityId
|
||||
}, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get override status:', error);
|
||||
return { has_override: false };
|
||||
}
|
||||
}
|
||||
|
||||
async clearSchedule(entityId) {
|
||||
return await this.callService('climate_scheduler', 'clear_schedule', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async setIgnored(entityId, ignored) {
|
||||
return await this.callService('climate_scheduler', 'set_ignored', {
|
||||
schedule_id: entityId,
|
||||
ignored: ignored
|
||||
});
|
||||
}
|
||||
|
||||
// Group management methods
|
||||
async createGroup(groupName) {
|
||||
return await this.callService('climate_scheduler', 'create_group', {
|
||||
schedule_id: groupName
|
||||
});
|
||||
}
|
||||
|
||||
async deleteGroup(groupName) {
|
||||
return await this.callService('climate_scheduler', 'delete_group', {
|
||||
schedule_id: groupName
|
||||
});
|
||||
}
|
||||
|
||||
async renameGroup(oldName, newName) {
|
||||
return await this.callService('climate_scheduler', 'rename_group', {
|
||||
old_name: oldName,
|
||||
new_name: newName
|
||||
});
|
||||
}
|
||||
|
||||
async addToGroup(groupName, entityId) {
|
||||
return await this.callService('climate_scheduler', 'add_to_group', {
|
||||
schedule_id: groupName,
|
||||
entity_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async removeFromGroup(groupName, entityId) {
|
||||
return await this.callService('climate_scheduler', 'remove_from_group', {
|
||||
schedule_id: groupName,
|
||||
entity_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async getGroups() {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'get_groups', {}, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get groups:', error);
|
||||
return { groups: {} };
|
||||
}
|
||||
}
|
||||
|
||||
async setGroupSchedule(groupName, nodes, day = null, scheduleMode = null, profileName = null) {
|
||||
const callStartTime = performance.now();
|
||||
console.debug('[HA-API] setGroupSchedule called', {
|
||||
timestamp: new Date().toISOString(),
|
||||
groupName,
|
||||
nodeCount: nodes?.length,
|
||||
day,
|
||||
scheduleMode,
|
||||
profileName,
|
||||
usingHassObject: this.usingHassObject
|
||||
});
|
||||
|
||||
// Guard: ensure a valid groupName is provided before calling HA service.
|
||||
if (!groupName) {
|
||||
const msg = 'setGroupSchedule called without a valid groupName';
|
||||
console.error('[HA-API]', msg, groupName, nodes, day, scheduleMode);
|
||||
// Throw so callers can handle the error rather than sending null to HA
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const serviceData = {
|
||||
schedule_id: groupName,
|
||||
nodes: nodes
|
||||
};
|
||||
|
||||
if (day) {
|
||||
serviceData.day = day;
|
||||
}
|
||||
if (scheduleMode) {
|
||||
serviceData.schedule_mode = scheduleMode;
|
||||
}
|
||||
if (profileName) {
|
||||
serviceData.profile_name = profileName;
|
||||
}
|
||||
|
||||
console.debug('[HA-API] Calling climate_scheduler.set_group_schedule', {
|
||||
serviceData: { ...serviceData, nodes: `[${nodes?.length} nodes]` },
|
||||
connectionMode: this.usingHassObject ? 'hass-object' : 'websocket'
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'set_group_schedule', serviceData);
|
||||
console.debug('[HA-API] set_group_schedule succeeded', {
|
||||
duration: (performance.now() - callStartTime).toFixed(2) + 'ms',
|
||||
result
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[HA-API] setGroupSchedule failed:', {
|
||||
error,
|
||||
errorCode: error?.code,
|
||||
errorMessage: error?.message,
|
||||
translationKey: error?.translation_key,
|
||||
translationPlaceholders: error?.translation_placeholders,
|
||||
groupName,
|
||||
duration: (performance.now() - callStartTime).toFixed(2) + 'ms',
|
||||
connectionMode: this.usingHassObject ? 'hass-object' : 'websocket'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async enableGroup(groupName) {
|
||||
return await this.callService('climate_scheduler', 'enable_group', {
|
||||
schedule_id: groupName
|
||||
});
|
||||
}
|
||||
|
||||
async disableGroup(groupName) {
|
||||
return await this.callService('climate_scheduler', 'disable_group', {
|
||||
schedule_id: groupName
|
||||
});
|
||||
}
|
||||
|
||||
async getHistory(entityId, startTime, endTime) {
|
||||
try {
|
||||
// Format times as ISO strings
|
||||
const start = startTime.toISOString();
|
||||
const end = endTime ? endTime.toISOString() : new Date().toISOString();
|
||||
|
||||
// Use recorder history API
|
||||
const result = await this.sendRequest({
|
||||
type: 'history/history_during_period',
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
entity_ids: [entityId],
|
||||
minimal_response: false,
|
||||
no_attributes: false
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get history:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeToStateChanges() {
|
||||
// Custom panel mode - hass object handles state updates automatically
|
||||
if (this.hass && this.usingHassObject) {
|
||||
// Set up listener for hass state changes
|
||||
// The panel will call updateHassConnection when hass changes
|
||||
console.log('Using hass object state updates');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// WebSocket mode
|
||||
return await this.sendRequest({
|
||||
type: 'subscribe_events',
|
||||
event_type: 'state_changed'
|
||||
});
|
||||
}
|
||||
|
||||
async getSettings() {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'get_settings', {}, true);
|
||||
|
||||
// Normalize across execution modes:
|
||||
// - In custom panel mode, callService(..., true) returns the service response directly.
|
||||
// - In raw websocket mode, callService may return { response: <service_response> }.
|
||||
const payload = result?.response ?? result ?? {};
|
||||
|
||||
// Service response includes version metadata, but app.js expects the raw settings dict.
|
||||
// If the response shape is { settings: {...}, version: {...} }, return settings.
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
payload.version &&
|
||||
payload.settings &&
|
||||
typeof payload.settings === 'object'
|
||||
) {
|
||||
return payload.settings;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('Failed to get settings:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(settings) {
|
||||
try {
|
||||
await this.callService('climate_scheduler', 'save_settings', { settings: JSON.stringify(settings) });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupDerivativeSensors(confirmDeleteAll = false) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'cleanup_derivative_sensors', {
|
||||
confirm_delete_all: confirmDeleteAll
|
||||
}, true);
|
||||
return result?.response || result || {};
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup derivative sensors:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupOrphanedClimateEntities(deleteEntities = false) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'cleanup_orphaned_climate_entities', {
|
||||
delete: deleteEntities
|
||||
}, true);
|
||||
return result?.response || result || {};
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup orphaned climate entities:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupUnmonitoredStorage(deleteEntries = false) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'cleanup_unmonitored_storage', {
|
||||
delete: deleteEntries
|
||||
}, true);
|
||||
return result?.response || result || {};
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup unmonitored storage:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Profile management methods
|
||||
async createProfile(scheduleId, profileName) {
|
||||
return await this.callService('climate_scheduler', 'create_profile', {
|
||||
schedule_id: scheduleId,
|
||||
profile_name: profileName
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProfile(scheduleId, profileName) {
|
||||
return await this.callService('climate_scheduler', 'delete_profile', {
|
||||
schedule_id: scheduleId,
|
||||
profile_name: profileName
|
||||
});
|
||||
}
|
||||
|
||||
async renameProfile(scheduleId, oldName, newName) {
|
||||
return await this.callService('climate_scheduler', 'rename_profile', {
|
||||
schedule_id: scheduleId,
|
||||
old_name: oldName,
|
||||
new_name: newName
|
||||
});
|
||||
}
|
||||
|
||||
async setActiveProfile(scheduleId, profileName) {
|
||||
return await this.callService('climate_scheduler', 'set_active_profile', {
|
||||
schedule_id: scheduleId,
|
||||
profile_name: profileName
|
||||
});
|
||||
}
|
||||
|
||||
async getProfiles(scheduleId) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'get_profiles', {
|
||||
schedule_id: scheduleId
|
||||
}, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get profiles:', error);
|
||||
return { profiles: {}, active_profile: null };
|
||||
}
|
||||
}
|
||||
|
||||
async runDiagnostics() {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'diagnostics', {}, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to run diagnostics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
onStateUpdate(callback) {
|
||||
this.stateUpdateCallbacks.push(callback);
|
||||
}
|
||||
|
||||
notifyStateUpdate(data) {
|
||||
this.stateUpdateCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error('Error in state update callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async subscribeToEvents(eventType, callback) {
|
||||
if (this.usingHassObject && this.hass) {
|
||||
// Use hass connection for event subscription
|
||||
const conn = this.hass.connection;
|
||||
if (conn && conn.subscribeEvents) {
|
||||
return await conn.subscribeEvents(callback, eventType);
|
||||
}
|
||||
}
|
||||
console.warn('Event subscription not available');
|
||||
return null;
|
||||
}}
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Climate Scheduler Custom Panel
|
||||
* Modern Home Assistant custom panel implementation (replaces legacy iframe approach)
|
||||
*/
|
||||
// Version checking - detect if browser cache is stale
|
||||
(async function () {
|
||||
try {
|
||||
const scriptUrl = document.currentScript?.src || new URL(import.meta.url).href;
|
||||
const loadedVersion = new URL(scriptUrl).searchParams.get('v');
|
||||
// Fetch the current server version
|
||||
const response = await fetch('/climate_scheduler/static/.version');
|
||||
if (response.ok) {
|
||||
const serverVersion = (await response.text()).trim().split(',')[0];
|
||||
// Compare versions - check the aren't None and if they don't match, user has stale cache
|
||||
if ((loadedVersion && serverVersion) && loadedVersion !== serverVersion) {
|
||||
console.warn('[Climate Scheduler] Version mismatch detected. Loaded:', loadedVersion, 'Server:', serverVersion);
|
||||
// Store in sessionStorage to avoid showing repeatedly
|
||||
const notificationKey = 'climate_scheduler_refresh_shown';
|
||||
const shownVersion = sessionStorage.getItem(notificationKey);
|
||||
if (shownVersion !== serverVersion) {
|
||||
// Show persistent notification
|
||||
const event = new CustomEvent('hass-notification', {
|
||||
bubbles: true,
|
||||
cancelable: false,
|
||||
composed: true,
|
||||
detail: {
|
||||
message: 'Climate Scheduler has been updated. Please refresh your browser (Ctrl+F5 or Cmd+Shift+R) to load the new version.',
|
||||
duration: 0 // Persistent notification
|
||||
}
|
||||
});
|
||||
document.body.dispatchEvent(event);
|
||||
// Mark as shown for this session
|
||||
sessionStorage.setItem(notificationKey, serverVersion);
|
||||
console.info('[Climate Scheduler] Refresh notification displayed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('[Climate Scheduler] Version check failed:', e);
|
||||
}
|
||||
})();
|
||||
// Load other JavaScript files
|
||||
const loadScript = (src) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
};
|
||||
// Track if scripts are loaded
|
||||
let scriptsLoaded = false;
|
||||
const getVersion = () => {
|
||||
const scriptUrl = import.meta.url;
|
||||
const version = new URL(scriptUrl).searchParams.get('v');
|
||||
if (!version)
|
||||
return null;
|
||||
// If version has comma (dev: "tag,timestamp"), use timestamp for cache busting
|
||||
if (version.includes(',')) {
|
||||
const parts = version.split(',');
|
||||
return parts[1]; // timestamp
|
||||
}
|
||||
// Otherwise use version as-is (HACS tag or production tag)
|
||||
return version;
|
||||
};
|
||||
// Load dependencies in order
|
||||
const loadScripts = () => {
|
||||
if (scriptsLoaded)
|
||||
return Promise.resolve();
|
||||
// Determine base path from where panel.js was loaded
|
||||
const scriptUrl = import.meta.url;
|
||||
const url = new URL(scriptUrl);
|
||||
// Remove panel.js and query params to get base path
|
||||
const basePath = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/'));
|
||||
const version = getVersion();
|
||||
return Promise.all([
|
||||
loadScript(`${basePath}/utils.js?v=${version}`),
|
||||
loadScript(`${basePath}/ha-api.js?v=${version}`)
|
||||
]).then(() => {
|
||||
return loadScript(`${basePath}/app.js?v=${version}`);
|
||||
}).then(() => {
|
||||
scriptsLoaded = true;
|
||||
}).catch(error => {
|
||||
console.error('Failed to load Climate Scheduler scripts:', error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
class ClimateSchedulerPanel extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._hass = null;
|
||||
this.narrow = false;
|
||||
this.panel = null;
|
||||
}
|
||||
// Declare properties that Home Assistant looks for
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
narrow: { type: Boolean },
|
||||
route: { type: Object },
|
||||
panel: { type: Object }
|
||||
};
|
||||
}
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
// Store reference to this panel element globally so app.js can query within it
|
||||
window.climateSchedulerPanelRoot = this;
|
||||
// Wait for scripts to load before initializing
|
||||
try {
|
||||
await loadScripts();
|
||||
// Small delay to ensure DOM is fully rendered
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// Update version info in footer
|
||||
const versionElement = this.querySelector('#version-info');
|
||||
if (versionElement) {
|
||||
try {
|
||||
const scriptUrl = import.meta.url;
|
||||
const versionParam = new URL(scriptUrl).searchParams.get('v');
|
||||
let version = '';
|
||||
if (versionParam) {
|
||||
if (versionParam.includes(',')) {
|
||||
// Has timestamp - dev deployment: "tag,timestamp"
|
||||
const parts = versionParam.split(',');
|
||||
const tag = (parts[0] || 'unknown').replace(/^v/, '');
|
||||
version = `v${tag} (dev)`;
|
||||
}
|
||||
else {
|
||||
// No timestamp - production: just tag
|
||||
const tag = versionParam.replace(/^v/, '');
|
||||
version = `v${tag}`;
|
||||
}
|
||||
}
|
||||
else {
|
||||
version = '(manual)';
|
||||
}
|
||||
versionElement.textContent = `Climate Scheduler ${version}`;
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to determine version:', e);
|
||||
versionElement.textContent = 'Climate Scheduler';
|
||||
}
|
||||
}
|
||||
// Initialize the app when panel is loaded and scripts are ready
|
||||
if (window.initClimateSchedulerApp) {
|
||||
window.initClimateSchedulerApp(this.hass);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to initialize Climate Scheduler:', error);
|
||||
}
|
||||
}
|
||||
set hass(value) {
|
||||
this._hass = value;
|
||||
// Apply theme based on Home Assistant theme mode
|
||||
if (value && value.themes) {
|
||||
const isDark = value.themes.darkMode;
|
||||
if (isDark) {
|
||||
// Dark mode is default, remove attribute
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
this.removeAttribute('data-theme');
|
||||
}
|
||||
else {
|
||||
// Light mode needs explicit attribute
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
this.setAttribute('data-theme', 'light');
|
||||
}
|
||||
}
|
||||
// Pass hass object to app if it's already initialized
|
||||
if (window.updateHassConnection && value) {
|
||||
window.updateHassConnection(value);
|
||||
}
|
||||
}
|
||||
get hass() {
|
||||
return this._hass;
|
||||
}
|
||||
render() {
|
||||
if (!this.querySelector('.container')) {
|
||||
// Load CSS using same base path detection as scripts
|
||||
const scriptUrl = import.meta.url;
|
||||
const url = new URL(scriptUrl);
|
||||
const basePath = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/'));
|
||||
const version = getVersion();
|
||||
const styleLink = document.createElement('link');
|
||||
styleLink.rel = 'stylesheet';
|
||||
styleLink.href = `${basePath}/styles.css?v=${version}`;
|
||||
this.appendChild(styleLink);
|
||||
// Create container div for content
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = `
|
||||
<div class="container">
|
||||
<section class="entity-selector">
|
||||
<div class="groups-section">
|
||||
<h3 class="section-title">Monitored (<span id="groups-count">0</span>)</h3>
|
||||
<div id="groups-list" class="groups-list">
|
||||
<!-- Dynamically populated with groups -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profiles-section">
|
||||
<div class="group-container collapsed" id="global-profile-container">
|
||||
<div class="group-header" id="toggle-global-profiles">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span class="group-toggle-icon" style="transform: rotate(-90deg);">▼</span>
|
||||
<span class="group-title">Profiles</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="global-profile-list" style="display: none; padding: 12px 16px;">
|
||||
<div class="profile-controls">
|
||||
<select id="profile-dropdown" class="profile-dropdown">
|
||||
<option value="" disabled selected>Select a profile to edit...</option>
|
||||
</select>
|
||||
<button id="new-profile-btn" class="btn-profile" title="Create new profile">+</button>
|
||||
<button id="rename-profile-btn" class="btn-profile" title="Rename profile">✎</button>
|
||||
<button id="delete-profile-btn" class="btn-profile" title="Delete profile">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ignored-section">
|
||||
<div class="group-container collapsed" id="ignored-container">
|
||||
<div class="group-header" id="toggle-ignored">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span class="group-toggle-icon" style="transform: rotate(-90deg);">▼</span>
|
||||
<span class="group-title">Unmonitored</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ignored-entity-list" class="entity-list ignored-list" style="display: none;">
|
||||
<div class="filter-box">
|
||||
<input type="text" id="ignored-filter" placeholder="Filter by name..." />
|
||||
</div>
|
||||
<div id="ignored-entities-container">
|
||||
<span id="ignored-count" style="display: none;">0</span>
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="confirm-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Clear Schedule?</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to clear the entire schedule for <strong id="confirm-entity-name"></strong>?</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="confirm-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="confirm-clear" class="btn-danger">Clear Schedule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="create-group-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Create New Group</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label for="new-group-name">Group Name:</label>
|
||||
<input type="text" id="new-group-name" placeholder="e.g., Bedrooms" style="width: 100%; padding: 8px; margin-top: 8px;" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="create-group-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="create-group-confirm" class="btn-primary">Create Group</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="add-to-group-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Add to Group</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Add <strong id="add-entity-name"></strong> to group:</p>
|
||||
<select id="add-to-group-select" style="width: 100%; padding: 8px; margin-top: 8px; margin-bottom: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
<p style="text-align: center; color: var(--text-secondary); margin: 8px 0;">or</p>
|
||||
<input type="text" id="new-group-name-inline" placeholder="Create new group..." style="width: 100%; padding: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="add-to-group-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="add-to-group-confirm" class="btn-primary">Add to Group</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="convert-temperature-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Convert All Schedules</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: 16px;">This will convert all saved schedules (entities and groups) as well as the default schedule and min/max settings.</p>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Current unit (convert FROM):</label>
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-from-unit" value="°C" id="convert-from-celsius" style="cursor: pointer;">
|
||||
<span>Celsius (°C)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-from-unit" value="°F" id="convert-from-fahrenheit" style="cursor: pointer;">
|
||||
<span>Fahrenheit (°F)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Target unit (convert TO):</label>
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-to-unit" value="°C" id="convert-to-celsius" style="cursor: pointer;">
|
||||
<span>Celsius (°C)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-to-unit" value="°F" id="convert-to-fahrenheit" style="cursor: pointer;">
|
||||
<span>Fahrenheit (°F)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="color: var(--warning, #ff9800); font-size: 0.9rem;"><strong>Warning:</strong> This action cannot be undone. Make sure you select the correct source and target units.</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="convert-temperature-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="convert-temperature-confirm" class="btn-primary">Convert Schedules</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-group-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Group</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label for="edit-group-name">Group Name:</label>
|
||||
<input type="text" id="edit-group-name" placeholder="Group name" style="width: 100%; padding: 8px; margin-top: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="edit-group-delete" class="btn-danger">Delete Group</button>
|
||||
<div style="flex: 1;"></div>
|
||||
<button id="edit-group-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="edit-group-save" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions Section (Collapsible) -->
|
||||
<div class="instructions-container">
|
||||
<div id="global-instructions-toggle" class="instructions-toggle">
|
||||
<span class="toggle-icon">▶</span>
|
||||
<span class="toggle-text">Instructions</span>
|
||||
</div>
|
||||
<div id="global-graph-instructions" class="graph-instructions collapsed" style="display: none;">
|
||||
<p>📍 <strong>Double-click or double-tap</strong> the line to add a new node</p>
|
||||
<p>👆 <strong>Drag nodes</strong> vertically to change temperature or horizontally to move their time</p>
|
||||
<p>⬌ <strong>Drag the horizontal segment</strong> between two nodes to shift that period while preserving its duration</p>
|
||||
<p>📋 <strong>Copy / Paste</strong> buttons duplicate a schedule across days or entities</p>
|
||||
<p>⚙️ <strong>Tap a node</strong> to open its settings panel for HVAC/fan/swing/preset values</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Panel -->
|
||||
<div id="settings-panel" class="settings-panel collapsed">
|
||||
<div class="settings-header" id="settings-toggle">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span class="collapse-indicator" style="transform: rotate(-90deg);">▼</span>
|
||||
<h3>Settings</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-flex" style="display: flex; gap: 24px; align-items: flex-start;">
|
||||
<div class="settings-main" style="flex: 1; min-width: 0;">
|
||||
<div class="settings-section">
|
||||
<h4>Group Management</h4>
|
||||
<button id="create-group-btn" class="btn-secondary" style="width: 100%;">
|
||||
+ Create New Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Default Schedule</h4>
|
||||
<p class="settings-description">Set the default temperature schedule used when clearing or creating new schedules</p>
|
||||
|
||||
<div class="graph-container">
|
||||
<keyframe-timeline id="default-schedule-graph" class="temperature-graph" showHeader="false"></keyframe-timeline>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px;">
|
||||
<button id="clear-default-schedule-btn" class="btn-danger-outline">Clear Schedule</button>
|
||||
</div>
|
||||
|
||||
<div id="default-node-settings-panel" class="node-settings-panel" style="display: none;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h4>Node Settings</h4>
|
||||
<button id="default-delete-node-btn" class="btn-danger-outline" style="padding: 4px 12px; font-size: 0.9rem;">Delete Node</button>
|
||||
</div>
|
||||
<div class="node-info">
|
||||
<span>Time: <strong id="default-node-time">--:--</strong></span>
|
||||
<span>Temperature: <strong id="default-node-temp">--°C</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-hvac-mode-item">
|
||||
<label for="default-node-hvac-mode">HVAC Mode:</label>
|
||||
<select id="default-node-hvac-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-fan-mode-item">
|
||||
<label for="default-node-fan-mode">Fan Mode:</label>
|
||||
<select id="default-node-fan-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-swing-mode-item">
|
||||
<label for="default-node-swing-mode">Swing Mode:</label>
|
||||
<select id="default-node-swing-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-preset-mode-item">
|
||||
<label for="default-node-preset-mode">Preset Mode:</label>
|
||||
<select id="default-node-preset-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Graph Options</h4>
|
||||
<div class="setting-row" style="display:flex; gap:18px; align-items:flex-start; flex-wrap: wrap;">
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="tooltip-mode">Tooltip Display:</label>
|
||||
<select id="tooltip-mode">
|
||||
<option value="history">Show Historical Temperature</option>
|
||||
<option value="cursor">Show Cursor Position</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Choose what information to display when hovering over the graph</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:12px; align-items:center;">
|
||||
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||
<label for="min-temp" style="font-weight:600;">Min Temp (<span id="min-unit">°C</span>)</label>
|
||||
<input id="min-temp" type="number" step="0.1" placeholder="e.g. 5.0" style="width:120px; padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;" />
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||
<label for="max-temp" style="font-weight:600;">Max Temp (<span id="max-unit">°C</span>)</label>
|
||||
<input id="max-temp" type="number" step="0.1" placeholder="e.g. 30.0" style="width:120px; padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 12px;">
|
||||
<label>
|
||||
<input type="checkbox" id="debug-panel-toggle" style="margin-right: 8px;"> Show Debug Panel
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Temperature Precision</h4>
|
||||
<div class="setting-row" style="display:flex; gap:18px; align-items:flex-start; flex-wrap: wrap;">
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="graph-snap-step">Graph Snap Step:</label>
|
||||
<select id="graph-snap-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
|
||||
<option value="0.1">0.1°</option>
|
||||
<option value="0.5">0.5°</option>
|
||||
<option value="1">1.0°</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Temperature rounding when dragging nodes on the graph</p>
|
||||
</div>
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="input-temp-step">Input Field Step:</label>
|
||||
<select id="input-temp-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
|
||||
<option value="0.1">0.1°</option>
|
||||
<option value="0.5">0.5°</option>
|
||||
<option value="1">1.0°</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Step size for temperature input fields and up/down buttons</p>
|
||||
</div>
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="humidity-step">Humidity Slider Step:</label>
|
||||
<select id="humidity-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
|
||||
<option value="1">1%</option>
|
||||
<option value="2">2%</option>
|
||||
<option value="5">5%</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Step size for humidity slider in node settings dialog</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Derivative Sensors</h4>
|
||||
<p class="settings-description">Automatically create sensors to track heating/cooling rates for performance analysis</p>
|
||||
<div class="setting-item" style="max-width: 100%;">
|
||||
<label>
|
||||
<input type="checkbox" id="create-derivative-sensors" style="margin-right: 8px;"> Auto-create derivative sensors
|
||||
</label>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">When enabled, creates sensor.climate_scheduler_[name]_rate for each thermostat to track temperature change rate (°C/h)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Workday Integration</h4>
|
||||
<p class="settings-description">Configure which days are workdays for 5/2 mode scheduling</p>
|
||||
<div class="setting-item" style="max-width: 100%;">
|
||||
<label id="use-workday-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="use-workday-integration" style="margin-right: 8px;" disabled>
|
||||
<span>Use Workday integration for 5/2 scheduling</span>
|
||||
</label>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;" id="workday-help-text">Checking if Workday integration is installed...</p>
|
||||
</div>
|
||||
|
||||
<div id="workday-selector" style="margin-top: 16px; display: none;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Select Workdays:</label>
|
||||
<p class="settings-description" style="margin-bottom: 8px; font-size: 0.85rem;">Choose which days are considered workdays when Workday integration is disabled</p>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="mon" style="cursor: pointer;">
|
||||
<span>Monday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="tue" style="cursor: pointer;">
|
||||
<span>Tuesday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="wed" style="cursor: pointer;">
|
||||
<span>Wednesday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="thu" style="cursor: pointer;">
|
||||
<span>Thursday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="fri" style="cursor: pointer;">
|
||||
<span>Friday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="sat" style="cursor: pointer;">
|
||||
<span>Saturday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="sun" style="cursor: pointer;">
|
||||
<span>Sunday</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- right column removed: min/max now inline in Graph Options -->
|
||||
</div>
|
||||
|
||||
<div class="settings-actions" style="margin-top: 12px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button id="refresh-entities-menu" class="btn-secondary">Refresh Entities</button>
|
||||
<button id="sync-all-menu" class="btn-secondary">Sync All Thermostats</button>
|
||||
<button id="reload-integration-menu" class="btn-secondary">Reload Integration</button>
|
||||
<button id="convert-temperature-btn" class="btn-secondary">Convert All Schedules...</button>
|
||||
<button id="run-diagnostics-btn" class="btn-secondary">Run Diagnostics</button>
|
||||
<button id="cleanup-derivative-sensors-btn" class="btn-secondary">Cleanup Derivative Sensors</button>
|
||||
<button id="cleanup-orphaned-climate-btn" class="btn-secondary">Cleanup Orphaned Entities</button>
|
||||
<button id="cleanup-storage-btn" class="btn-secondary">Cleanup Unmonitored Storage</button>
|
||||
<button id="reset-defaults" class="btn-secondary">Reset to Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Panel -->
|
||||
<div id="debug-panel" class="debug-panel" style="display: none;">
|
||||
<div class="debug-header">
|
||||
<h3>Debug Console</h3>
|
||||
<button id="clear-debug" class="btn-secondary" style="padding: 4px 8px; font-size: 0.85rem;">Clear</button>
|
||||
</div>
|
||||
<div id="debug-content" class="debug-content">
|
||||
<!-- Debug messages will appear here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<img alt="Integration Usage" src="https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.climate_scheduler.total" />
|
||||
<p><span id="version-info">Climate Scheduler</span>, created by <a href="https://neave.engineering" target="_blank" rel="noopener noreferrer" style="color: var(--primary)">Keegan Neave</a></p>
|
||||
<p><a href="https://www.buymeacoffee.com/kneave" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">☕ Buy me a coffee</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
this.appendChild(container);
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('climate-scheduler-panel', ClimateSchedulerPanel);
|
||||
//# sourceMappingURL=panel.js.map
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Shared Utility Functions
|
||||
* Common utilities used across the Climate Scheduler frontend
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// TIMEZONE UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a Date-like object in the server's timezone
|
||||
* @param {Date} date - The UTC date to convert
|
||||
* @param {string} serverTimeZone - The server's timezone (e.g., 'America/New_York')
|
||||
* @returns {Object} Date-like object with methods that return values in server timezone
|
||||
*/
|
||||
function getServerDate(date, serverTimeZone) {
|
||||
if (!serverTimeZone) return date; // Fallback to local time if no timezone set
|
||||
|
||||
// Use Intl.DateTimeFormat to get date components in server timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: serverTimeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(date);
|
||||
const partsObj = {};
|
||||
parts.forEach(part => {
|
||||
partsObj[part.type] = part.value;
|
||||
});
|
||||
|
||||
return {
|
||||
getFullYear: () => parseInt(partsObj.year),
|
||||
getMonth: () => parseInt(partsObj.month) - 1, // JS months are 0-indexed
|
||||
getDate: () => parseInt(partsObj.day),
|
||||
getHours: () => parseInt(partsObj.hour),
|
||||
getMinutes: () => parseInt(partsObj.minute),
|
||||
getSeconds: () => parseInt(partsObj.second),
|
||||
getTime: () => date.getTime(),
|
||||
_originalDate: date
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current server time as Date-like object
|
||||
* @param {string} serverTimeZone - The server's timezone
|
||||
* @returns {Object} Date-like object with current time in server timezone
|
||||
*/
|
||||
function getServerNow(serverTimeZone) {
|
||||
return getServerDate(new Date(), serverTimeZone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UTC timestamp to server timezone Date-like object
|
||||
* @param {Date} utcDate - The UTC date to convert
|
||||
* @param {string} serverTimeZone - The server's timezone
|
||||
* @returns {Object} Date-like object in server timezone
|
||||
*/
|
||||
function utcToServerDate(utcDate, serverTimeZone) {
|
||||
return getServerDate(utcDate, serverTimeZone);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEMPERATURE CONVERSION UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert Celsius to Fahrenheit
|
||||
* @param {number} celsius - Temperature in Celsius
|
||||
* @returns {number} Temperature in Fahrenheit
|
||||
*/
|
||||
function celsiusToFahrenheit(celsius) {
|
||||
return (celsius * 9/5) + 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Fahrenheit to Celsius
|
||||
* @param {number} fahrenheit - Temperature in Fahrenheit
|
||||
* @returns {number} Temperature in Celsius
|
||||
*/
|
||||
function fahrenheitToCelsius(fahrenheit) {
|
||||
return (fahrenheit - 32) * 5/9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert temperature between units
|
||||
* @param {number} temp - Temperature value
|
||||
* @param {string} fromUnit - Source unit ('°C' or '°F')
|
||||
* @param {string} toUnit - Target unit ('°C' or '°F')
|
||||
* @returns {number} Converted temperature
|
||||
*/
|
||||
function convertTemperature(temp, fromUnit, toUnit) {
|
||||
if (fromUnit === toUnit) return temp;
|
||||
if (fromUnit === '°C' && toUnit === '°F') return celsiusToFahrenheit(temp);
|
||||
if (fromUnit === '°F' && toUnit === '°C') return fahrenheitToCelsius(temp);
|
||||
return temp;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TIME UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert time string to minutes since midnight
|
||||
* @param {string} timeStr - Time in 'HH:MM' format
|
||||
* @returns {number} Minutes since midnight
|
||||
*/
|
||||
function timeToMinutes(timeStr) {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert minutes since midnight to time string
|
||||
* @param {number} minutes - Minutes since midnight
|
||||
* @returns {string} Time in 'HH:MM' format
|
||||
*/
|
||||
function minutesToTime(minutes) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = Math.floor(minutes % 60);
|
||||
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format hours and minutes as 'HH:MM' time string
|
||||
* @param {number} hours - Hours (0-23)
|
||||
* @param {number} minutes - Minutes (0-59)
|
||||
* @returns {string} Time in 'HH:MM' format
|
||||
*/
|
||||
function formatTimeString(hours, minutes) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust time by adding/subtracting minutes with 24-hour wraparound
|
||||
* @param {string} timeStr - Time in 'HH:MM' format
|
||||
* @param {number} minutesToAdd - Minutes to add (negative to subtract)
|
||||
* @returns {string} New time in 'HH:MM' format
|
||||
*/
|
||||
function adjustTime(timeStr, minutesToAdd) {
|
||||
let totalMinutes = timeToMinutes(timeStr) + minutesToAdd;
|
||||
|
||||
// Handle wraparound
|
||||
while (totalMinutes < 0) totalMinutes += 1440;
|
||||
while (totalMinutes >= 1440) totalMinutes -= 1440;
|
||||
|
||||
return minutesToTime(totalMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate temperature at a given time (step function - hold until next node)
|
||||
* @param {Array} nodes - Array of schedule nodes with {time, temp} properties
|
||||
* @param {string} timeStr - Time in 'HH:MM' format
|
||||
* @returns {number} Interpolated temperature
|
||||
*/
|
||||
function interpolateTemperature(nodes, timeStr) {
|
||||
if (nodes.length === 0) return 18;
|
||||
|
||||
const sorted = [...nodes].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
|
||||
const currentMinutes = timeToMinutes(timeStr);
|
||||
|
||||
// Find the most recent node before or at current time
|
||||
let activeNode = null;
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const nodeMinutes = timeToMinutes(sorted[i].time);
|
||||
if (nodeMinutes <= currentMinutes) {
|
||||
activeNode = sorted[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no node found before current time, use last node (wrap around from previous day)
|
||||
if (!activeNode) {
|
||||
activeNode = sorted[sorted.length - 1];
|
||||
}
|
||||
|
||||
return activeNode.temp;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"domain": "climate_scheduler",
|
||||
"name": "Climate Scheduler",
|
||||
"codeowners": [
|
||||
"@kneave"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"climate",
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/kneave/climate-scheduler",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/kneave/climate-scheduler/issues",
|
||||
"requirements": [
|
||||
|
||||
],
|
||||
"version": "1.15.1"
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Sensor platform for Climate Scheduler."""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# How many historical samples to keep for derivative calculation
|
||||
SAMPLE_SIZE = 10
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Climate Scheduler sensors from a config entry."""
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
storage = hass.data[DOMAIN]["storage"]
|
||||
coordinator = hass.data[DOMAIN]["coordinator"]
|
||||
|
||||
# Get settings to check if derivative sensors are enabled
|
||||
settings = storage._data.get("settings", {})
|
||||
sensors = []
|
||||
|
||||
# Always create coldest and warmest sensors
|
||||
sensors.append(ColdestEntitySensor(hass, storage, coordinator))
|
||||
sensors.append(WarmestEntitySensor(hass, storage, coordinator))
|
||||
|
||||
if not settings.get("create_derivative_sensors", True):
|
||||
async_add_entities(sensors, True)
|
||||
return
|
||||
|
||||
# Get registries
|
||||
entity_registry = er.async_get(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Get all entities from groups (both single and multi-entity groups)
|
||||
all_entity_ids = await storage.async_get_all_entities()
|
||||
|
||||
for entity_id in all_entity_ids:
|
||||
# Skip ignored entities
|
||||
if await storage.async_is_ignored(entity_id):
|
||||
continue
|
||||
|
||||
# Find the device for this climate entity
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
_LOGGER.debug(f"Entity entry for {entity_id}: {entity_entry}")
|
||||
|
||||
device_id = None
|
||||
if entity_entry and entity_entry.device_id:
|
||||
device_id = entity_entry.device_id
|
||||
_LOGGER.debug(f"Found device {device_id} for {entity_id}")
|
||||
|
||||
# Create main temperature rate sensor
|
||||
_LOGGER.debug(f"Creating derivative sensor for {entity_id}")
|
||||
sensors.append(ClimateSchedulerRateSensor(hass, entity_id, entity_id, "current_temperature", device_id))
|
||||
|
||||
if device_id:
|
||||
# Find all sensor entities on the same device
|
||||
device_sensors = [
|
||||
entry for entry in entity_registry.entities.values()
|
||||
if entry.device_id == device_id
|
||||
and entry.domain == "sensor"
|
||||
and "floor" in entry.entity_id.lower() # Look for "floor" in the entity_id
|
||||
]
|
||||
|
||||
_LOGGER.debug(f"Found {len(device_sensors)} floor sensors for {entity_id}: {[s.entity_id for s in device_sensors]}")
|
||||
|
||||
# Create derivative sensors for floor temperature sensors
|
||||
for floor_sensor in device_sensors:
|
||||
_LOGGER.debug(f"Creating floor derivative sensor for {entity_id} tracking {floor_sensor.entity_id}")
|
||||
sensors.append(ClimateSchedulerRateSensor(
|
||||
hass,
|
||||
entity_id, # Associated climate entity
|
||||
floor_sensor.entity_id, # Floor sensor to track
|
||||
"state", # Use state instead of attribute
|
||||
device_id # Link to same device
|
||||
))
|
||||
else:
|
||||
_LOGGER.warning(f"No device found for {entity_id}, skipping floor sensor detection")
|
||||
|
||||
if sensors:
|
||||
async_add_entities(sensors, True)
|
||||
|
||||
|
||||
class ClimateSchedulerRateSensor(SensorEntity):
|
||||
"""Sensor that tracks the rate of temperature change for a climate entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
climate_entity_id: str,
|
||||
source_entity_id: str,
|
||||
temperature_attribute: str = "current_temperature",
|
||||
device_id: str = None
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.hass = hass
|
||||
self._climate_entity_id = climate_entity_id
|
||||
self._source_entity_id = source_entity_id
|
||||
self._temperature_attribute = temperature_attribute
|
||||
self._device_id = device_id # Store device_id for device_info
|
||||
|
||||
# Extract name from entity_id (e.g., climate.bedroom -> bedroom)
|
||||
entity_name = climate_entity_id.split(".")[-1]
|
||||
friendly_name = entity_name.replace("_", " ").title()
|
||||
|
||||
# Determine if this is a floor sensor (tracking a separate sensor entity)
|
||||
is_floor = source_entity_id != climate_entity_id
|
||||
|
||||
if is_floor:
|
||||
# For floor sensors, include the floor sensor name in unique_id to avoid collisions
|
||||
floor_sensor_name = source_entity_id.split(".")[-1]
|
||||
# Strip any previously-created climate_scheduler prefix to avoid recursive names
|
||||
if floor_sensor_name.startswith("climate_scheduler_"):
|
||||
floor_sensor_name = floor_sensor_name[len("climate_scheduler_") :]
|
||||
# Strip trailing _rate if present
|
||||
if floor_sensor_name.endswith("_rate"):
|
||||
floor_sensor_name = floor_sensor_name[: -len("_rate")]
|
||||
|
||||
# Prevent duplicated segments like "front_room_front_room_..."
|
||||
if floor_sensor_name.startswith(f"{entity_name}_"):
|
||||
floor_sensor_suffix = floor_sensor_name[len(entity_name) + 1 :]
|
||||
else:
|
||||
floor_sensor_suffix = floor_sensor_name
|
||||
|
||||
suffix = f"{floor_sensor_suffix.replace('_', ' ').title()} Rate"
|
||||
unique_suffix = f"{floor_sensor_suffix}_rate"
|
||||
else:
|
||||
suffix = "Rate"
|
||||
unique_suffix = "rate"
|
||||
|
||||
_LOGGER.debug(f"Creating sensor climate_scheduler_{entity_name}_{unique_suffix} with device_id: {device_id}")
|
||||
|
||||
self._attr_name = f"Climate Scheduler {friendly_name} {suffix}"
|
||||
self._attr_unique_id = f"climate_scheduler_{entity_name}_{unique_suffix}"
|
||||
# Let Home Assistant manage the `entity_id` from the `unique_id`
|
||||
self._attr_device_class = None # No standard device class for rate
|
||||
self._attr_state_class = SensorStateClass.MEASUREMENT
|
||||
self._attr_native_unit_of_measurement = "°C/h"
|
||||
self._attr_icon = "mdi:thermometer-lines" if not is_floor else "mdi:floor-plan"
|
||||
|
||||
# Storage for temperature samples (timestamp, temperature)
|
||||
self._samples = []
|
||||
self._attr_native_value = 0.0 # Start at 0 instead of None
|
||||
|
||||
# Set device_info during initialization to ensure proper device linking
|
||||
if device_id:
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get(device_id)
|
||||
|
||||
if device:
|
||||
self._attr_device_info = {
|
||||
"identifiers": device.identifiers,
|
||||
}
|
||||
_LOGGER.debug(f"Linked sensor {self._attr_unique_id} to device {device_id} with identifiers {device.identifiers}")
|
||||
else:
|
||||
_LOGGER.error(f"Could not find device {device_id} in registry for sensor {self._attr_unique_id}")
|
||||
else:
|
||||
_LOGGER.warning(f"No device_id provided for sensor {self._attr_unique_id}")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state listener when entity is added."""
|
||||
# Listen to state changes of the source entity
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self._source_entity_id],
|
||||
self._async_source_state_changed,
|
||||
)
|
||||
)
|
||||
|
||||
# Get initial state
|
||||
state = self.hass.states.get(self._source_entity_id)
|
||||
if state:
|
||||
# For separate sensor entities, use the state value
|
||||
if self._temperature_attribute == "state":
|
||||
try:
|
||||
temp = float(state.state)
|
||||
now = dt_util.utcnow()
|
||||
self._samples.append((now, temp))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# For climate entity attributes, use the attribute
|
||||
elif state.attributes.get(self._temperature_attribute) is not None:
|
||||
temp = float(state.attributes[self._temperature_attribute])
|
||||
now = dt_util.utcnow()
|
||||
self._samples.append((now, temp))
|
||||
|
||||
@callback
|
||||
def _async_source_state_changed(self, event) -> None:
|
||||
"""Handle source entity state changes."""
|
||||
new_state = event.data.get("new_state")
|
||||
if not new_state:
|
||||
return
|
||||
|
||||
try:
|
||||
# For separate sensor entities, use the state value
|
||||
if self._temperature_attribute == "state":
|
||||
temp = float(new_state.state)
|
||||
# For climate entity attributes, use the attribute
|
||||
else:
|
||||
current_temp = new_state.attributes.get(self._temperature_attribute)
|
||||
if current_temp is None:
|
||||
return
|
||||
temp = float(current_temp)
|
||||
now = dt_util.utcnow()
|
||||
|
||||
# Add new sample
|
||||
self._samples.append((now, temp))
|
||||
|
||||
# Keep only last SAMPLE_SIZE samples
|
||||
if len(self._samples) > SAMPLE_SIZE:
|
||||
self._samples = self._samples[-SAMPLE_SIZE:]
|
||||
|
||||
# Calculate derivative if we have enough samples
|
||||
if len(self._samples) >= 2:
|
||||
self._calculate_rate()
|
||||
|
||||
self.async_write_ha_state()
|
||||
except (ValueError, TypeError) as e:
|
||||
_LOGGER.debug(f"Error processing temperature for {self._climate_entity_id}: {e}")
|
||||
|
||||
def _calculate_rate(self) -> None:
|
||||
"""Calculate the rate of temperature change."""
|
||||
if len(self._samples) < 2:
|
||||
self._attr_native_value = 0.0 # Not enough data, rate is 0
|
||||
return
|
||||
|
||||
# Get oldest and newest samples
|
||||
oldest_time, oldest_temp = self._samples[0]
|
||||
newest_time, newest_temp = self._samples[-1]
|
||||
|
||||
# Calculate time difference in hours
|
||||
time_diff = (newest_time - oldest_time).total_seconds() / 3600
|
||||
|
||||
if time_diff == 0:
|
||||
self._attr_native_value = 0.0
|
||||
return
|
||||
|
||||
# Calculate temperature change rate (°C per hour)
|
||||
temp_diff = newest_temp - oldest_temp
|
||||
rate = temp_diff / time_diff
|
||||
|
||||
# Round to 2 decimal places
|
||||
self._attr_native_value = round(rate, 2)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional state attributes."""
|
||||
return {
|
||||
"climate_entity": self._climate_entity_id,
|
||||
"source_entity": self._source_entity_id,
|
||||
"temperature_attribute": self._temperature_attribute,
|
||||
"sample_count": len(self._samples),
|
||||
"time_window_minutes": 5,
|
||||
}
|
||||
|
||||
|
||||
class ColdestEntitySensor(SensorEntity):
|
||||
"""Sensor that shows the coldest climate entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
storage,
|
||||
coordinator
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.hass = hass
|
||||
self._storage = storage
|
||||
self._coordinator = coordinator
|
||||
|
||||
self._attr_name = "Climate Scheduler Coldest Entity"
|
||||
self._attr_unique_id = "climate_scheduler_coldest_entity"
|
||||
# Let Home Assistant manage the `entity_id` from the `unique_id`
|
||||
self._attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
self._attr_state_class = SensorStateClass.MEASUREMENT
|
||||
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
self._attr_icon = "mdi:snowflake"
|
||||
self._attr_native_value = None
|
||||
self._coldest_entity_id = None
|
||||
self._coldest_friendly_name = None
|
||||
self._remove_listener = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state listener when entity is added."""
|
||||
# Listen to coordinator updates
|
||||
self._remove_listener = self._coordinator.async_add_listener(self._handle_coordinator_update)
|
||||
# Initial update
|
||||
await self._async_update()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister listener when entity is removed."""
|
||||
if self._remove_listener:
|
||||
self._remove_listener()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.hass.async_create_task(self._async_update())
|
||||
|
||||
async def _async_update(self) -> None:
|
||||
"""Update the sensor value."""
|
||||
# Get all entities from groups
|
||||
all_entities = await self._storage.async_get_all_entities()
|
||||
|
||||
coldest_temp = None
|
||||
coldest_entity = None
|
||||
coldest_name = None
|
||||
|
||||
for entity_id in all_entities:
|
||||
# Skip ignored entities
|
||||
if await self._storage.async_is_ignored(entity_id):
|
||||
continue
|
||||
|
||||
state = self.hass.states.get(entity_id)
|
||||
if not state:
|
||||
continue
|
||||
|
||||
# Check if entity has current_temperature attribute
|
||||
current_temp = state.attributes.get("current_temperature")
|
||||
if current_temp is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
temp = float(current_temp)
|
||||
if coldest_temp is None or temp < coldest_temp:
|
||||
coldest_temp = temp
|
||||
coldest_entity = entity_id
|
||||
coldest_name = state.attributes.get("friendly_name", entity_id)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
self._attr_native_value = coldest_temp
|
||||
self._coldest_entity_id = coldest_entity
|
||||
self._coldest_friendly_name = coldest_name
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional state attributes."""
|
||||
return {
|
||||
"entity_id": self._coldest_entity_id,
|
||||
"friendly_name": self._coldest_friendly_name,
|
||||
}
|
||||
|
||||
|
||||
class WarmestEntitySensor(SensorEntity):
|
||||
"""Sensor that shows the warmest climate entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
storage,
|
||||
coordinator
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.hass = hass
|
||||
self._storage = storage
|
||||
self._coordinator = coordinator
|
||||
|
||||
self._attr_name = "Climate Scheduler Warmest Entity"
|
||||
self._attr_unique_id = "climate_scheduler_warmest_entity"
|
||||
# Let Home Assistant manage the `entity_id` from the `unique_id`
|
||||
self._attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
self._attr_state_class = SensorStateClass.MEASUREMENT
|
||||
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
self._attr_icon = "mdi:fire"
|
||||
self._attr_native_value = None
|
||||
self._warmest_entity_id = None
|
||||
self._warmest_friendly_name = None
|
||||
self._remove_listener = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state listener when entity is added."""
|
||||
# Listen to coordinator updates
|
||||
self._remove_listener = self._coordinator.async_add_listener(self._handle_coordinator_update)
|
||||
# Initial update
|
||||
await self._async_update()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister listener when entity is removed."""
|
||||
if self._remove_listener:
|
||||
self._remove_listener()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.hass.async_create_task(self._async_update())
|
||||
|
||||
async def _async_update(self) -> None:
|
||||
"""Update the sensor value."""
|
||||
# Get all entities from groups
|
||||
all_entities = await self._storage.async_get_all_entities()
|
||||
|
||||
warmest_temp = None
|
||||
warmest_entity = None
|
||||
warmest_name = None
|
||||
|
||||
for entity_id in all_entities:
|
||||
# Skip ignored entities
|
||||
if await self._storage.async_is_ignored(entity_id):
|
||||
continue
|
||||
|
||||
state = self.hass.states.get(entity_id)
|
||||
if not state:
|
||||
continue
|
||||
|
||||
# Check if entity has current_temperature attribute
|
||||
current_temp = state.attributes.get("current_temperature")
|
||||
if current_temp is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
temp = float(current_temp)
|
||||
if warmest_temp is None or temp > warmest_temp:
|
||||
warmest_temp = temp
|
||||
warmest_entity = entity_id
|
||||
warmest_name = state.attributes.get("friendly_name", entity_id)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
self._attr_native_value = warmest_temp
|
||||
self._warmest_entity_id = warmest_entity
|
||||
self._warmest_friendly_name = warmest_name
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional state attributes."""
|
||||
return {
|
||||
"entity_id": self._warmest_entity_id,
|
||||
"friendly_name": self._warmest_friendly_name,
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
# Static service definitions for Climate Scheduler.
|
||||
# These provide UI documentation and selectors. The services are still
|
||||
# registered dynamically in Python; keep this file up-to-date when fields
|
||||
# or selectors change.
|
||||
|
||||
recreate_all_sensors:
|
||||
name: Recreate all sensors
|
||||
description: >-
|
||||
Delete ALL Climate Scheduler sensor entities and reload the integration to
|
||||
recreate them cleanly. Requires confirmation.
|
||||
fields:
|
||||
confirm:
|
||||
description: Must be set to true to confirm deletion of all sensors
|
||||
example: true
|
||||
required: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
cleanup_malformed_sensors:
|
||||
name: Cleanup malformed sensors
|
||||
description: >-
|
||||
Scan for unexpected Climate Scheduler sensor entities and optionally remove
|
||||
them. Returns expected and unexpected sensor entity lists.
|
||||
fields:
|
||||
delete:
|
||||
description: Whether to actually delete the unexpected entities (default
|
||||
is false for dry-run)
|
||||
required: false
|
||||
example: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
cleanup_orphaned_climate_entities:
|
||||
name: Cleanup orphaned entities
|
||||
description: >-
|
||||
Scan for orphaned Climate Scheduler entities (climate, sensor, and switch entities without matching
|
||||
groups or climate entities in storage) and optionally remove them. Returns expected count and orphaned entity lists.
|
||||
fields:
|
||||
delete:
|
||||
description: Whether to actually delete the orphaned entities (default
|
||||
is false for dry-run)
|
||||
required: false
|
||||
example: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
set_schedule:
|
||||
name: Set climate schedule
|
||||
description: Configure temperature schedule for a schedule target
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID
|
||||
required: true
|
||||
example: climate.living_room
|
||||
selector:
|
||||
entity:
|
||||
domain: climate
|
||||
nodes:
|
||||
description: >-
|
||||
List of schedule nodes with time and temperature (JSON string).
|
||||
required: true
|
||||
example: '[{"time": "07:00", "temp": 21}, {"time": "23:00", "temp": 18}]'
|
||||
day:
|
||||
description: Day of week for this schedule (all_days, mon, tue, wed, thu,
|
||||
fri, sat, sun, weekday, weekend)
|
||||
required: false
|
||||
default: all_days
|
||||
selector:
|
||||
text: {}
|
||||
schedule_mode:
|
||||
description: Schedule mode (all_days, 5/2, individual)
|
||||
required: false
|
||||
default: all_days
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
|
||||
get_schedule:
|
||||
name: Get climate schedule
|
||||
description: Retrieve the current schedule for a schedule target
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: climate
|
||||
|
||||
clear_schedule:
|
||||
name: Clear climate schedule
|
||||
description: Clear the schedule for a schedule target
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: climate
|
||||
|
||||
enable_schedule:
|
||||
name: Enable schedule
|
||||
description: Enable the schedule for a schedule target
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
disable_schedule:
|
||||
name: Disable schedule
|
||||
description: Disable the schedule for a schedule target
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
set_ignored:
|
||||
name: Set ignored status
|
||||
description: Mark a schedule target as ignored or not
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
ignored:
|
||||
description: True to ignore, False to un-ignore
|
||||
required: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
sync_all:
|
||||
name: Sync all
|
||||
description: Force immediate sync of all thermostats managed by Climate Scheduler
|
||||
fields: {}
|
||||
|
||||
create_group:
|
||||
name: Create group
|
||||
description: Create a new Climate Scheduler group
|
||||
fields:
|
||||
schedule_id:
|
||||
description: New group name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
delete_group:
|
||||
name: Delete group
|
||||
description: Delete an existing group
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Group name to delete
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
rename_group:
|
||||
name: Rename group
|
||||
description: Rename an existing group
|
||||
fields:
|
||||
old_name:
|
||||
description: Existing group name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
new_name:
|
||||
description: New group name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
add_to_group:
|
||||
name: Add entity to group
|
||||
description: Add a climate entity to a group
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Target group name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
entity_id:
|
||||
description: Climate entity id to add
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: climate
|
||||
|
||||
remove_from_group:
|
||||
name: Remove entity from group
|
||||
description: Remove a climate entity from a group
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Target group name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
entity_id:
|
||||
description: Climate entity id to remove
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: climate
|
||||
|
||||
get_groups:
|
||||
name: Get groups
|
||||
description: Return stored groups data
|
||||
fields: {}
|
||||
|
||||
list_groups:
|
||||
name: List groups
|
||||
description: Return a simple list of group names
|
||||
fields: {}
|
||||
|
||||
list_profiles:
|
||||
name: List profiles
|
||||
description: Return a list of profiles across all groups
|
||||
fields: {}
|
||||
|
||||
set_group_schedule:
|
||||
name: Set group schedule
|
||||
description: Set schedule for a whole group
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Target group name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
nodes:
|
||||
description: JSON string of schedule nodes
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
day:
|
||||
description: Day identifier
|
||||
required: false
|
||||
selector:
|
||||
text: {}
|
||||
schedule_mode:
|
||||
description: Schedule mode
|
||||
required: false
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
enable_group:
|
||||
name: Enable group
|
||||
description: Enable a group's schedule
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Group name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
disable_group:
|
||||
name: Disable group
|
||||
description: Disable a group's schedule
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Group name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
get_settings:
|
||||
name: Get settings
|
||||
description: Retrieve current integration settings and version info
|
||||
fields: {}
|
||||
|
||||
save_settings:
|
||||
name: Save settings
|
||||
description: Save integration settings (JSON string)
|
||||
fields:
|
||||
settings:
|
||||
description: Settings JSON string
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
reload_integration:
|
||||
name: Reload integration
|
||||
description: Reload the Climate Scheduler integration
|
||||
fields: {}
|
||||
|
||||
reregister_card:
|
||||
name: Reregister card
|
||||
description: >-
|
||||
Automatically registers the Climate Scheduler card in Lovelace resources.
|
||||
Removes any existing registrations and creates a fresh entry.
|
||||
Use this if the card is not appearing in the card picker.
|
||||
fields:
|
||||
resource_url:
|
||||
description: >-
|
||||
(Optional) Custom URL for the card resource. If not provided,
|
||||
automatically uses the bundled card with the current integration version.
|
||||
required: false
|
||||
example: /climate_scheduler/static/climate-scheduler-card.js
|
||||
selector:
|
||||
text: {}
|
||||
resource_type:
|
||||
description: Resource type (e.g. `module` or `js`)
|
||||
required: false
|
||||
default: module
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
advance_schedule:
|
||||
name: Advance schedule
|
||||
description: Temporarily advance a schedule for a schedule target
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
advance_group:
|
||||
name: Advance group
|
||||
description: Advance schedule for a group
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Group name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
cancel_advance:
|
||||
name: Cancel advance
|
||||
description: Cancel an advanced schedule for a schedule target
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID (entity id)
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
get_advance_status:
|
||||
name: Get advance status
|
||||
description: Get current advance status for a schedule target
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID (entity id)
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
clear_advance_history:
|
||||
name: Clear advance history
|
||||
description: Clear the advance history for a schedule target
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID (entity id)
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
test_fire_event:
|
||||
name: Test fire event
|
||||
description: Fire a test node_activated event with specific node settings for testing automations
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Group name or entity ID to fire event for
|
||||
required: true
|
||||
example: Downstairs
|
||||
selector:
|
||||
text: {}
|
||||
node:
|
||||
description: Node data (time, temp, hvac_mode, etc.) as JSON string
|
||||
required: false
|
||||
example: '{"time": "08:00", "temp": 21, "hvac_mode": "heat"}'
|
||||
selector:
|
||||
text: {}
|
||||
day:
|
||||
description: Day of week (mon, tue, wed, thu, fri, sat, sun)
|
||||
required: false
|
||||
example: mon
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
create_profile:
|
||||
name: Create profile
|
||||
description: Create a profile for a schedule
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID (entity id or group id)
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
profile_name:
|
||||
description: New profile name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
delete_profile:
|
||||
name: Delete profile
|
||||
description: Delete a profile from a schedule
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID (entity id or group id)
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
profile_name:
|
||||
description: Profile name to delete
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
rename_profile:
|
||||
name: Rename profile
|
||||
description: Rename a profile
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID (entity id or group id)
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
old_name:
|
||||
description: Existing profile name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
new_name:
|
||||
description: New profile name
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
set_active_profile:
|
||||
name: Set active profile
|
||||
description: Set the active profile for a schedule
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID (entity id or group id)
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
profile_name:
|
||||
description: Profile name to set active
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
get_profiles:
|
||||
name: Get profiles
|
||||
description: Get profiles for a schedule
|
||||
fields:
|
||||
schedule_id:
|
||||
description: Schedule ID (entity id or group id)
|
||||
required: true
|
||||
selector:
|
||||
text: {}
|
||||
|
||||
cleanup_derivative_sensors:
|
||||
name: Cleanup derivative sensors
|
||||
description: Remove derivative sensors based on auto-creation setting
|
||||
fields:
|
||||
confirm_delete_all:
|
||||
description: Confirm deletion of all derivative sensors
|
||||
required: false
|
||||
default: false
|
||||
example: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
cleanup_unmonitored_storage:
|
||||
name: Cleanup unmonitored storage
|
||||
description: >-
|
||||
Remove stale storage references for unmonitored or missing entities,
|
||||
including obsolete groups, invalid profiles, stale entity references,
|
||||
and orphaned advance history entries.
|
||||
fields:
|
||||
delete:
|
||||
description: Execute deletion; set false to preview only
|
||||
required: false
|
||||
default: false
|
||||
example: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
factory_reset:
|
||||
name: Factory reset
|
||||
description: Reset all stored data
|
||||
fields:
|
||||
confirm:
|
||||
description: Must be set to true to confirm factory reset
|
||||
required: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
diagnostics:
|
||||
name: Run diagnostics
|
||||
description: >-
|
||||
Run comprehensive diagnostics to troubleshoot card visibility issues.
|
||||
Returns integration version, card registration status, and accessibility checks.
|
||||
Use this when users report they cannot find the card in Lovelace.
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Switch platform for Climate Scheduler - Exposes schedules in scheduler-component format."""
|
||||
import logging
|
||||
import hashlib
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Climate Scheduler switch entities from a config entry."""
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
storage = hass.data[DOMAIN]["storage"]
|
||||
coordinator = hass.data[DOMAIN]["coordinator"]
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
# Clean up old switch entities that don't have the token suffix
|
||||
# (These are from before we added the unique_id token)
|
||||
old_entities_removed = 0
|
||||
for entry in list(entity_registry.entities.values()):
|
||||
if entry.platform != DOMAIN or entry.domain != "switch":
|
||||
continue
|
||||
|
||||
# Old entities have unique_id like "climate_scheduler_schedule_bathroom"
|
||||
# New entities have unique_id like "climate_scheduler_schedule_climate.bathroom_abc123"
|
||||
# or "climate_scheduler_schedule_Bathroom_abc123"
|
||||
if entry.unique_id and entry.unique_id.startswith(f"{DOMAIN}_schedule_"):
|
||||
# Check if it's missing the token (no underscore followed by 6 hex chars at the end)
|
||||
parts = entry.unique_id.split("_")
|
||||
last_part = parts[-1] if parts else ""
|
||||
|
||||
# If the last part is not a 6-character hex token, it's an old entity
|
||||
if len(last_part) != 6 or not all(c in "0123456789abcdef" for c in last_part.lower()):
|
||||
_LOGGER.info(f"Removing old scheduler entity: {entry.entity_id} (unique_id: {entry.unique_id})")
|
||||
entity_registry.async_remove(entry.entity_id)
|
||||
old_entities_removed += 1
|
||||
|
||||
if old_entities_removed > 0:
|
||||
_LOGGER.info(f"Removed {old_entities_removed} old scheduler switch entities")
|
||||
|
||||
switches = []
|
||||
|
||||
# Create switch entity for each group (including single-entity groups)
|
||||
all_groups = await storage.async_get_groups()
|
||||
for group_name, group_data in all_groups.items():
|
||||
# Skip ignored groups
|
||||
if group_data.get("ignored", False):
|
||||
continue
|
||||
|
||||
switches.append(ClimateSchedulerSwitch(
|
||||
hass,
|
||||
coordinator,
|
||||
storage,
|
||||
group_name,
|
||||
group_data
|
||||
))
|
||||
|
||||
if switches:
|
||||
async_add_entities(switches, True)
|
||||
_LOGGER.info(f"Created {len(switches)} scheduler switch entities")
|
||||
|
||||
|
||||
class ClimateSchedulerSwitch(CoordinatorEntity, SwitchEntity):
|
||||
"""Switch entity that exposes a climate schedule in scheduler-component format."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
coordinator,
|
||||
storage,
|
||||
group_name: str,
|
||||
group_data: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self.hass = hass
|
||||
self.storage = storage
|
||||
self._group_name = group_name
|
||||
self._group_data = group_data
|
||||
|
||||
# Generate a stable token for the entity_id (6 chars like scheduler-component)
|
||||
token = hashlib.md5(group_name.encode()).hexdigest()[:6]
|
||||
|
||||
# For single-entity groups, use a cleaner name
|
||||
if group_name.startswith("__entity_"):
|
||||
entity_id = group_name.replace("__entity_", "")
|
||||
self._attr_name = f"Schedule {entity_id.split('.')[-1]}"
|
||||
self._attr_unique_id = f"{DOMAIN}_schedule_{entity_id}_{token}"
|
||||
else:
|
||||
self._attr_name = f"Schedule {group_name}"
|
||||
self._attr_unique_id = f"{DOMAIN}_schedule_{group_name}_{token}"
|
||||
|
||||
self._attr_has_entity_name = False
|
||||
self._attr_should_poll = True
|
||||
|
||||
# Cache for computed attributes
|
||||
self._cached_next_trigger: Optional[datetime] = None
|
||||
self._cached_next_slot: Optional[int] = None
|
||||
self._cached_actions: List[Dict[str, Any]] = []
|
||||
self._cached_next_entries: List[Dict[str, Any]] = []
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if schedule is enabled."""
|
||||
# Refresh group data from storage
|
||||
self._refresh_group_data()
|
||||
return self._group_data.get("enabled", True)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""Return the state of the scheduler.
|
||||
|
||||
States match scheduler-component:
|
||||
- off: Schedule is disabled
|
||||
- on: Schedule is enabled and waiting for next trigger
|
||||
- triggered: Currently executing (we'll use this briefly after firing)
|
||||
"""
|
||||
if not self.is_on:
|
||||
return "off"
|
||||
|
||||
# Check if this was recently triggered (within last minute)
|
||||
# For now, we'll just return "on" - can enhance later
|
||||
return "on"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Dict[str, Any]:
|
||||
"""Return scheduler-component compatible attributes."""
|
||||
self._refresh_group_data()
|
||||
self._compute_schedule_attributes()
|
||||
|
||||
active_profile = self._group_data.get("active_profile", "Default")
|
||||
profiles = self._group_data.get("profiles", {})
|
||||
profile_data = profiles.get(active_profile, {})
|
||||
|
||||
schedule_mode = profile_data.get("schedule_mode", "all_days")
|
||||
schedules = profile_data.get("schedules", {})
|
||||
|
||||
# Get entities affected by this schedule
|
||||
entities = self._group_data.get("entities", [])
|
||||
|
||||
attrs = {
|
||||
# Core scheduler-component attributes
|
||||
"next_trigger": self._cached_next_trigger.isoformat() if self._cached_next_trigger else None,
|
||||
"next_slot": self._cached_next_slot,
|
||||
"actions": self._cached_actions,
|
||||
|
||||
# Fallback format
|
||||
"next_entries": self._cached_next_entries,
|
||||
|
||||
# Additional metadata
|
||||
"schedule_mode": schedule_mode,
|
||||
"schedules": schedules,
|
||||
"active_profile": active_profile,
|
||||
"profiles": list(profiles.keys()),
|
||||
"entities": entities,
|
||||
|
||||
# Compatibility fields
|
||||
"weekdays": self._get_weekdays_list(schedule_mode),
|
||||
"timeslots": self._cached_actions, # Alias
|
||||
}
|
||||
|
||||
return attrs
|
||||
|
||||
def _refresh_group_data(self) -> None:
|
||||
"""Refresh group data from storage."""
|
||||
# This is called frequently, so we cache the lookup
|
||||
import asyncio
|
||||
if asyncio.iscoroutinefunction(self.storage.async_get_group):
|
||||
# Can't call async from sync property, so we'll need to handle this differently
|
||||
# For now, keep the cached version and update on coordinator updates
|
||||
pass
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
# Refresh group data when coordinator updates
|
||||
loop = self.hass.loop
|
||||
if loop and loop.is_running():
|
||||
loop.create_task(self._async_refresh_group_data())
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_refresh_group_data(self) -> None:
|
||||
"""Async refresh of group data."""
|
||||
group_data = await self.storage.async_get_group(self._group_name)
|
||||
if group_data:
|
||||
self._group_data = group_data
|
||||
|
||||
def _compute_schedule_attributes(self) -> None:
|
||||
"""Compute next_trigger, next_slot, actions, and next_entries."""
|
||||
now = dt_util.now()
|
||||
current_time = now.time()
|
||||
current_day = now.strftime("%a").lower() # mon, tue, wed, etc.
|
||||
|
||||
active_profile = self._group_data.get("active_profile", "Default")
|
||||
profiles = self._group_data.get("profiles", {})
|
||||
profile_data = profiles.get(active_profile, {})
|
||||
|
||||
schedule_mode = profile_data.get("schedule_mode", "all_days")
|
||||
schedules = profile_data.get("schedules", {})
|
||||
|
||||
# Determine which schedule to use based on mode and current day
|
||||
current_schedule = self._get_schedule_for_day(schedules, schedule_mode, current_day)
|
||||
|
||||
if not current_schedule:
|
||||
self._cached_next_trigger = None
|
||||
self._cached_next_slot = None
|
||||
self._cached_actions = []
|
||||
self._cached_next_entries = []
|
||||
return
|
||||
|
||||
# Sort nodes by time
|
||||
sorted_nodes = sorted(current_schedule, key=lambda n: self._time_str_to_minutes(n.get("time", "00:00")))
|
||||
|
||||
# Find next node
|
||||
next_node = None
|
||||
next_node_index = None
|
||||
|
||||
for idx, node in enumerate(sorted_nodes):
|
||||
node_time_str = node.get("time", "00:00")
|
||||
node_minutes = self._time_str_to_minutes(node_time_str)
|
||||
current_minutes = current_time.hour * 60 + current_time.minute
|
||||
|
||||
if node_minutes > current_minutes:
|
||||
next_node = node
|
||||
next_node_index = idx
|
||||
break
|
||||
|
||||
# If no node found after current time, use first node tomorrow
|
||||
if next_node is None and sorted_nodes:
|
||||
next_node = sorted_nodes[0]
|
||||
next_node_index = 0
|
||||
# Add one day
|
||||
next_trigger_dt = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
else:
|
||||
next_trigger_dt = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if next_node:
|
||||
# Parse time
|
||||
time_str = next_node.get("time", "00:00")
|
||||
try:
|
||||
hours, minutes = time_str.split(":")
|
||||
next_trigger_dt = next_trigger_dt.replace(hour=int(hours), minute=int(minutes))
|
||||
except (ValueError, AttributeError):
|
||||
_LOGGER.warning(f"Invalid time format: {time_str}")
|
||||
next_trigger_dt = None
|
||||
|
||||
self._cached_next_trigger = next_trigger_dt
|
||||
self._cached_next_slot = next_node_index
|
||||
|
||||
# Build actions list for all nodes
|
||||
entities = self._group_data.get("entities", [])
|
||||
|
||||
# If entities list is empty, try to derive from group name for single-entity groups
|
||||
if not entities and self._group_name.startswith("__entity_"):
|
||||
entities = [self._group_name.replace("__entity_", "")]
|
||||
_LOGGER.debug(f"Derived entity from group name: {entities}")
|
||||
|
||||
self._cached_actions = []
|
||||
|
||||
for node in sorted_nodes:
|
||||
temp = node.get("temp")
|
||||
|
||||
# If still no entities, create a generic action without entity_id
|
||||
# This allows the data structure to be populated even if entities are missing
|
||||
if not entities:
|
||||
action = {
|
||||
"entity_id": "unknown",
|
||||
"service": "climate.set_temperature",
|
||||
"data": {"temperature": temp}
|
||||
}
|
||||
|
||||
# Add HVAC mode if present
|
||||
if "hvac_mode" in node:
|
||||
action["data"]["hvac_mode"] = node["hvac_mode"]
|
||||
|
||||
# Add preset mode if present
|
||||
if "preset_mode" in node:
|
||||
action["service"] = "climate.set_preset_mode"
|
||||
action["data"] = {"preset_mode": node["preset_mode"]}
|
||||
|
||||
self._cached_actions.append(action)
|
||||
else:
|
||||
# Normal case: create actions for each entity
|
||||
for entity_id in entities:
|
||||
action = {
|
||||
"entity_id": entity_id,
|
||||
"service": "climate.set_temperature",
|
||||
"data": {"temperature": temp}
|
||||
}
|
||||
|
||||
# Add HVAC mode if present
|
||||
if "hvac_mode" in node:
|
||||
action["data"]["hvac_mode"] = node["hvac_mode"]
|
||||
|
||||
# Add preset mode if present
|
||||
if "preset_mode" in node:
|
||||
action["service"] = "climate.set_preset_mode"
|
||||
action["data"] = {"preset_mode": node["preset_mode"]}
|
||||
|
||||
self._cached_actions.append(action)
|
||||
|
||||
# Build next_entries (fallback format)
|
||||
self._cached_next_entries = []
|
||||
for node in sorted_nodes:
|
||||
temp = node.get("temp")
|
||||
time_str = node.get("time", "00:00")
|
||||
|
||||
# Calculate absolute datetime for this node
|
||||
try:
|
||||
hours, minutes = time_str.split(":")
|
||||
node_dt = now.replace(hour=int(hours), minute=int(minutes), second=0, microsecond=0)
|
||||
|
||||
# If node time is in the past today, it's for tomorrow
|
||||
if node_dt < now:
|
||||
node_dt += timedelta(days=1)
|
||||
|
||||
entry_actions = []
|
||||
|
||||
# If still no entities, create a generic action
|
||||
if not entities:
|
||||
entry_actions.append({
|
||||
"entity_id": "unknown",
|
||||
"service": "climate.set_temperature",
|
||||
"data": {"temperature": temp}
|
||||
})
|
||||
else:
|
||||
for entity_id in entities:
|
||||
entry_actions.append({
|
||||
"entity_id": entity_id,
|
||||
"service": "climate.set_temperature",
|
||||
"data": {"temperature": temp}
|
||||
})
|
||||
|
||||
self._cached_next_entries.append({
|
||||
"time": node_dt.isoformat(),
|
||||
"trigger_time": node_dt.isoformat(),
|
||||
"actions": entry_actions
|
||||
})
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
else:
|
||||
self._cached_next_trigger = None
|
||||
self._cached_next_slot = None
|
||||
self._cached_actions = []
|
||||
self._cached_next_entries = []
|
||||
|
||||
def _get_schedule_for_day(
|
||||
self,
|
||||
schedules: Dict[str, List[Dict[str, Any]]],
|
||||
schedule_mode: str,
|
||||
current_day: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get the appropriate schedule for the current day."""
|
||||
if schedule_mode == "all_days":
|
||||
return schedules.get("all_days", [])
|
||||
elif schedule_mode == "5/2":
|
||||
if current_day in ["mon", "tue", "wed", "thu", "fri"]:
|
||||
return schedules.get("weekday", [])
|
||||
else:
|
||||
return schedules.get("weekend", [])
|
||||
elif schedule_mode == "individual":
|
||||
return schedules.get(current_day, [])
|
||||
else:
|
||||
return schedules.get("all_days", [])
|
||||
|
||||
def _get_weekdays_list(self, schedule_mode: str) -> List[str]:
|
||||
"""Get weekdays list for scheduler-component compatibility."""
|
||||
if schedule_mode == "all_days":
|
||||
return ["daily"]
|
||||
elif schedule_mode == "5/2":
|
||||
return ["workday", "weekend"]
|
||||
elif schedule_mode == "individual":
|
||||
return ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
else:
|
||||
return ["daily"]
|
||||
|
||||
@staticmethod
|
||||
def _time_str_to_minutes(time_str: str) -> int:
|
||||
"""Convert HH:MM string to minutes since midnight."""
|
||||
try:
|
||||
hours, minutes = time_str.split(":")
|
||||
return int(hours) * 60 + int(minutes)
|
||||
except (ValueError, AttributeError):
|
||||
return 0
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Enable the schedule."""
|
||||
await self.storage.async_enable_schedule(self._group_name)
|
||||
await self.coordinator.async_request_refresh()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Disable the schedule."""
|
||||
await self.storage.async_disable_schedule(self._group_name)
|
||||
await self.coordinator.async_request_refresh()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device info for this scheduler."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, f"scheduler_{self._group_name}")},
|
||||
"name": self._attr_name,
|
||||
"manufacturer": "Climate Scheduler",
|
||||
"model": "Schedule Controller",
|
||||
"sw_version": "1.0",
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"title": "Climate Scheduler",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configure Climate Scheduler",
|
||||
"description": "Climate Scheduler is ready to use. No additional configuration needed."
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_schedule": {
|
||||
"name": "Set climate schedule",
|
||||
"description": "Configure temperature schedule for a schedule target"
|
||||
},
|
||||
"get_schedule": {
|
||||
"name": "Get climate schedule",
|
||||
"description": "Retrieve the current schedule for a schedule target"
|
||||
},
|
||||
"clear_schedule": {
|
||||
"name": "Clear climate schedule",
|
||||
"description": "Clear the schedule for a schedule target"
|
||||
},
|
||||
"enable_schedule": {
|
||||
"name": "Enable climate schedule",
|
||||
"description": "Enable the schedule for a schedule target"
|
||||
},
|
||||
"disable_schedule": {
|
||||
"name": "Disable climate schedule",
|
||||
"description": "Disable the schedule for a schedule target"
|
||||
},
|
||||
"create_group": {
|
||||
"name": "Create thermostat group",
|
||||
"description": "Create a new group to share schedules between multiple thermostats"
|
||||
},
|
||||
"delete_group": {
|
||||
"name": "Delete thermostat group",
|
||||
"description": "Delete a thermostat group"
|
||||
},
|
||||
"add_to_group": {
|
||||
"name": "Add thermostat to group",
|
||||
"description": "Add a climate entity to a group"
|
||||
},
|
||||
"remove_from_group": {
|
||||
"name": "Remove thermostat from group",
|
||||
"description": "Remove a climate entity from a group"
|
||||
},
|
||||
"get_groups": {
|
||||
"name": "Get all groups",
|
||||
"description": "Retrieve all thermostat groups"
|
||||
},
|
||||
"set_group_schedule": {
|
||||
"name": "Set group schedule",
|
||||
"description": "Set schedule for all thermostats in a group"
|
||||
},
|
||||
"sync_all": {
|
||||
"name": "Sync all thermostats",
|
||||
"description": "Force synchronization of all enabled thermostats with their schedules"
|
||||
},
|
||||
"enable_group": {
|
||||
"name": "Enable group schedule",
|
||||
"description": "Enable automatic scheduling for all thermostats in a group"
|
||||
},
|
||||
"disable_group": {
|
||||
"name": "Disable group schedule",
|
||||
"description": "Disable automatic scheduling for all thermostats in a group"
|
||||
},
|
||||
"get_settings": {
|
||||
"name": "Get settings",
|
||||
"description": "Retrieve global integration settings (min/max temp, default schedule, etc.)"
|
||||
},
|
||||
"save_settings": {
|
||||
"name": "Save settings",
|
||||
"description": "Save global integration settings"
|
||||
},
|
||||
"reload_integration": {
|
||||
"name": "Reload integration (Dev)",
|
||||
"description": "Reload the Climate Scheduler integration (development only)"
|
||||
}
|
||||
,
|
||||
"reregister_card": {
|
||||
"name": "Reregister card",
|
||||
"description": "Automatically registers the Climate Scheduler card in Lovelace resources. Removes any existing registrations and creates a fresh entry."
|
||||
}
|
||||
,
|
||||
"recreate_all_sensors": {
|
||||
"name": "Recreate all sensors",
|
||||
"description": "Delete and recreate all derivative sensors"
|
||||
},
|
||||
"cleanup_malformed_sensors": {
|
||||
"name": "Cleanup malformed sensors",
|
||||
"description": "Scan for unexpected derivative sensors and optionally remove them"
|
||||
},
|
||||
"set_ignored": {
|
||||
"name": "Set ignored",
|
||||
"description": "Mark a schedule target as ignored or not"
|
||||
},
|
||||
"advance_schedule": {
|
||||
"name": "Advance schedule",
|
||||
"description": "Temporarily advance a schedule for a schedule target"
|
||||
},
|
||||
"advance_group": {
|
||||
"name": "Advance group",
|
||||
"description": "Temporarily advance schedules for a group"
|
||||
},
|
||||
"cancel_advance": {
|
||||
"name": "Cancel advance",
|
||||
"description": "Cancel an advanced schedule action for a schedule target"
|
||||
},
|
||||
"get_advance_status": {
|
||||
"name": "Get advance status",
|
||||
"description": "Get current advance status for a schedule target"
|
||||
},
|
||||
"clear_advance_history": {
|
||||
"name": "Clear advance history",
|
||||
"description": "Clear stored advance history for a schedule target"
|
||||
},
|
||||
"test_fire_event": {
|
||||
"name": "Test fire event",
|
||||
"description": "Fire a test node_activated event with the current active node settings for testing automations"
|
||||
},
|
||||
"create_profile": {
|
||||
"name": "Create profile",
|
||||
"description": "Create a profile for a schedule"
|
||||
},
|
||||
"delete_profile": {
|
||||
"name": "Delete profile",
|
||||
"description": "Delete a profile from a schedule"
|
||||
},
|
||||
"rename_profile": {
|
||||
"name": "Rename profile",
|
||||
"description": "Rename a profile"
|
||||
},
|
||||
"set_active_profile": {
|
||||
"name": "Set active profile",
|
||||
"description": "Set the active profile for a schedule"
|
||||
},
|
||||
"get_profiles": {
|
||||
"name": "Get profiles",
|
||||
"description": "Get profiles for a schedule"
|
||||
},
|
||||
"cleanup_derivative_sensors": {
|
||||
"name": "Cleanup derivative sensors",
|
||||
"description": "Remove derivative sensors based on the auto-creation setting"
|
||||
},
|
||||
"factory_reset": {
|
||||
"name": "Factory reset",
|
||||
"description": "Reset all Climate Scheduler data to a fresh state (destructive)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"title": "Climate Scheduler",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Konfigurácia plánovača klimatizácie",
|
||||
"description": "Plánovač klimatizácie je pripravený na použitie. Nie je potrebná žiadna ďalšia konfigurácia."
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_schedule": {
|
||||
"name": "Nastavenie klimatického harmonogramu",
|
||||
"description": "Konfigurácia teplotného plánu pre cieľový plán"
|
||||
},
|
||||
"get_schedule": {
|
||||
"name": "Získajte klimatický harmonogram",
|
||||
"description": "Načítať aktuálny rozvrh pre cieľ rozvrhu"
|
||||
},
|
||||
"clear_schedule": {
|
||||
"name": "Vymazať klimatický harmonogram",
|
||||
"description": "Vymazať plán pre cieľ plánu"
|
||||
},
|
||||
"enable_schedule": {
|
||||
"name": "Povoliť klimatický plán",
|
||||
"description": "Povoliť plán pre cieľ plánu"
|
||||
},
|
||||
"disable_schedule": {
|
||||
"name": "Zakázať klimatizačný plán",
|
||||
"description": "Zakázať plán pre cieľ plánu"
|
||||
},
|
||||
"create_group": {
|
||||
"name": "Vytvoriť skupinu termostatov",
|
||||
"description": "Vytvorte novú skupinu na zdieľanie harmonogramov medzi viacerými termostatmi"
|
||||
},
|
||||
"delete_group": {
|
||||
"name": "Odstrániť skupinu termostatov",
|
||||
"description": "Odstrániť skupinu termostatov"
|
||||
},
|
||||
"add_to_group": {
|
||||
"name": "Pridať termostat do skupiny",
|
||||
"description": "Pridanie klimatickej entity do skupiny"
|
||||
},
|
||||
"remove_from_group": {
|
||||
"name": "Odstrániť termostat zo skupiny",
|
||||
"description": "Odstránenie klimatickej entity zo skupiny"
|
||||
},
|
||||
"get_groups": {
|
||||
"name": "Získať všetky skupiny",
|
||||
"description": "Načítať všetky skupiny termostatov"
|
||||
},
|
||||
"set_group_schedule": {
|
||||
"name": "Nastaviť rozvrh skupiny",
|
||||
"description": "Nastavenie plánu pre všetky termostaty v skupine"
|
||||
},
|
||||
"sync_all": {
|
||||
"name": "Synchronizovať všetky termostaty",
|
||||
"description": "Vynútiť synchronizáciu všetkých povolených termostatov s ich harmonogramami"
|
||||
},
|
||||
"enable_group": {
|
||||
"name": "Povoliť skupinový rozvrh",
|
||||
"description": "Povoliť automatické plánovanie pre všetky termostaty v skupine"
|
||||
},
|
||||
"disable_group": {
|
||||
"name": "Zakázať skupinový rozvrh",
|
||||
"description": "Zakázať automatické plánovanie pre všetky termostaty v skupine"
|
||||
},
|
||||
"get_settings": {
|
||||
"name": "Získať nastavenia",
|
||||
"description": "Získanie globálnych nastavení integrácie (min./max. teplota, predvolený harmonogram atď.)"
|
||||
},
|
||||
"save_settings": {
|
||||
"name": "Uložiť nastavenia",
|
||||
"description": "Uložiť nastavenia globálnej integrácie"
|
||||
},
|
||||
"reload_integration": {
|
||||
"name": "Integrácia obnovenia (vývojár)",
|
||||
"description": "Obnoviť integráciu plánovača klimatizácie (iba pre vývojárov)"
|
||||
}
|
||||
,
|
||||
"reregister_card": {
|
||||
"name": "Znovu zaregistrovať kartu",
|
||||
"description": "Automaticky zaregistruje kartu Climate Scheduler v zdrojoch Lovelace. Odstráni existujúce registrácie a vytvorí novú."
|
||||
}
|
||||
,
|
||||
"recreate_all_sensors": {
|
||||
"name": "Znovu vytvorte všetky senzory",
|
||||
"description": "Odstráňte a znovu vytvorte všetky odvodené senzory"
|
||||
},
|
||||
"cleanup_malformed_sensors": {
|
||||
"name": "Vyčistenie chybných senzorov",
|
||||
"description": "Vyhľadajte neočakávané derivačné senzory a voliteľne ich odstráňte"
|
||||
},
|
||||
"set_ignored": {
|
||||
"name": "Ignorované",
|
||||
"description": "Označiť cieľ plánu ako ignorovaný alebo nie"
|
||||
},
|
||||
"advance_schedule": {
|
||||
"name": "Pokročilý rozvrh",
|
||||
"description": "Dočasný pokročilý plán pre cieľ plánu"
|
||||
},
|
||||
"advance_group": {
|
||||
"name": "Pokročilá skupina",
|
||||
"description": "Dočasné pokročilé rozvrhy pre skupinu"
|
||||
},
|
||||
"cancel_advance": {
|
||||
"name": "Zrušiť pokročilé",
|
||||
"description": "Zrušenie rozšírenej akcie plánovania pre cieľ plánovania"
|
||||
},
|
||||
"get_advance_status": {
|
||||
"name": "Získať stav zálohy",
|
||||
"description": "Získanie aktuálneho stavu zálohy pre cieľ plánu"
|
||||
},
|
||||
"clear_advance_history": {
|
||||
"name": "Vymazať históriu záloh",
|
||||
"description": "Vymazať uloženú históriu predbežných nastavení pre cieľ plánu"
|
||||
},
|
||||
"test_fire_event": {
|
||||
"name": "Test fire event",
|
||||
"description": "Fire a test node_activated event with the current active node settings for testing automations"
|
||||
},
|
||||
"create_profile": {
|
||||
"name": "Vytvorriť profil",
|
||||
"description": "Vytvorenie profilu pre rozvrh"
|
||||
},
|
||||
"delete_profile": {
|
||||
"name": "Zmazať profil",
|
||||
"description": "Odstránenie profilu z rozvrhu"
|
||||
},
|
||||
"rename_profile": {
|
||||
"name": "Premenovať profil",
|
||||
"description": "Premenovať profil"
|
||||
},
|
||||
"set_active_profile": {
|
||||
"name": "Nastaviť aktívny profil",
|
||||
"description": "Nastavenie aktívneho profilu pre plán"
|
||||
},
|
||||
"get_profiles": {
|
||||
"name": "Získať profily",
|
||||
"description": "Získajte profily pre rozvrh"
|
||||
},
|
||||
"cleanup_derivative_sensors": {
|
||||
"name": "Čistenie derivačných senzorov",
|
||||
"description": "Odstrániť odvodené senzory na základe nastavenia automatického vytvárania"
|
||||
},
|
||||
"factory_reset": {
|
||||
"name": "Obnovenie továrenských nastavení",
|
||||
"description": "Obnoviť všetky údaje plánovača klimatizácie do nového stavu (deštruktívne)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
"""The Intelligent Heating Pilot integration - DDD Architecture.
|
||||
|
||||
This module sets up the integration using a clean DDD architecture:
|
||||
- Domain: Pure business logic (entities, value objects, services)
|
||||
- Infrastructure: HA adapters (readers, commanders, event bridge)
|
||||
- Application: Use case orchestration
|
||||
|
||||
The coordinator here is reduced to a thin setup/teardown manager.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.event import async_track_point_in_time
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_IHP_ENABLED, DOMAIN, SERVICE_CALCULATE_ANTICIPATED_START_TIME
|
||||
from .heating_application import HeatingApplication
|
||||
from .utils.config_helpers import as_bool
|
||||
from .view import async_register_http_views
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[str] = [Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
_STARTUP_UPDATE_BASE_DELAY_SECONDS = 5
|
||||
_STARTUP_UPDATE_JITTER_SECONDS = 55
|
||||
_STARTUP_EXTRACTION_BASE_DELAY_SECONDS = 120
|
||||
_STARTUP_EXTRACTION_JITTER_SECONDS = 600
|
||||
_LATE_UPDATE_BASE_DELAY_SECONDS = 30
|
||||
_LATE_UPDATE_JITTER_SECONDS = 90
|
||||
|
||||
|
||||
def _stable_jitter_seconds(seed: str, spread_seconds: int) -> int:
|
||||
"""Return deterministic jitter in [0, spread_seconds] for a given seed."""
|
||||
if spread_seconds <= 0:
|
||||
return 0
|
||||
digest = hashlib.blake2s(seed.encode("utf-8"), digest_size=4).digest()
|
||||
value = int.from_bytes(digest, "big")
|
||||
return value % (spread_seconds + 1)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
"""Set up the Intelligent Heating Pilot component."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
# Initialize shared recorder access queue (FIFO) for all IHP devices.
|
||||
# This must be created before any device entry setup to ensure all adapters
|
||||
# share the same queue and recorder access is serialized across instances.
|
||||
from .infrastructure.recorder_queue import get_recorder_queue
|
||||
|
||||
get_recorder_queue(hass)
|
||||
|
||||
# Store hass in http app context for REST API views to access it
|
||||
hass.http.app["hass"] = hass
|
||||
|
||||
# Register HTTP views once at the integration level (not per device)
|
||||
await async_register_http_views(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Intelligent Heating Pilot from a config entry.
|
||||
|
||||
REFACTORED: Now uses HADeviceConfigReader to extract configuration
|
||||
and injects DeviceConfig into the Coordinator instead of passing
|
||||
config_entry directly.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry: Config entry to set up
|
||||
|
||||
Returns:
|
||||
True if setup succeeded
|
||||
"""
|
||||
_LOGGER.debug("Setting up IHP config entry: %s", entry.entry_id)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
# ============================================================================
|
||||
# REFACTORING: Create device config reader and extract configuration
|
||||
# ============================================================================
|
||||
# Instead of passing config_entry to the Coordinator, we:
|
||||
# 1. Create HADeviceConfigReader (infrastructure adapter)
|
||||
# 2. Read DeviceConfig from config_entry (data + options)
|
||||
# 3. Inject DeviceConfig into Coordinator (dependency injection)
|
||||
|
||||
from .infrastructure.adapters.device_config_reader import HADeviceConfigReader
|
||||
|
||||
# Create device configuration reader (infrastructure layer)
|
||||
device_config_reader = HADeviceConfigReader(hass, entry)
|
||||
|
||||
# Read complete device configuration (applies "options override data" logic)
|
||||
try:
|
||||
device_config = await device_config_reader.get_device_config(entry.entry_id)
|
||||
except ValueError as err:
|
||||
_LOGGER.error("Failed to load device configuration: %s", err)
|
||||
return False
|
||||
|
||||
_LOGGER.debug(
|
||||
"Loaded device configuration: vtherm=%s, schedulers=%d, ihp_enabled=%s",
|
||||
device_config.vtherm_entity_id,
|
||||
len(device_config.scheduler_entities),
|
||||
device_config.ihp_enabled,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Create and load coordinator with injected configuration
|
||||
# ============================================================================
|
||||
# NEW: Coordinator ONLY receives device_config (pure DDD)
|
||||
# config_entry is passed later via setup_config_entry_access for options updates
|
||||
|
||||
coordinator = HeatingApplication(hass, device_config)
|
||||
coordinator.setup_config_entry_access(entry)
|
||||
coordinator._options_snapshot = dict(entry.options)
|
||||
await coordinator.async_load()
|
||||
|
||||
# Store coordinator
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
# Setup event listeners
|
||||
coordinator.setup_listeners()
|
||||
|
||||
def _schedule_startup_work() -> None:
|
||||
"""Schedule startup work with deterministic per-entry staggering.
|
||||
|
||||
This avoids synchronized bursts when many IHP entries start together.
|
||||
"""
|
||||
extraction_delay = _STARTUP_EXTRACTION_BASE_DELAY_SECONDS + _stable_jitter_seconds(
|
||||
f"{entry.entry_id}:extract", _STARTUP_EXTRACTION_JITTER_SECONDS
|
||||
)
|
||||
update_delay = _STARTUP_UPDATE_BASE_DELAY_SECONDS + _stable_jitter_seconds(
|
||||
f"{entry.entry_id}:update", _STARTUP_UPDATE_JITTER_SECONDS
|
||||
)
|
||||
|
||||
@callback
|
||||
def _run_extraction(_now):
|
||||
hass.async_create_task(coordinator.async_initialize_cycle_extraction())
|
||||
|
||||
@callback
|
||||
def _run_update(_now):
|
||||
hass.async_create_task(coordinator.async_update())
|
||||
|
||||
entry.async_on_unload(
|
||||
async_track_point_in_time(
|
||||
hass,
|
||||
_run_update,
|
||||
dt_util.now() + dt_util.dt.timedelta(seconds=update_delay),
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(
|
||||
async_track_point_in_time(
|
||||
hass,
|
||||
_run_extraction,
|
||||
dt_util.now() + dt_util.dt.timedelta(seconds=extraction_delay),
|
||||
)
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"[%s] Startup workload staggered: update in %ss, extraction in %ss",
|
||||
entry.entry_id,
|
||||
update_delay,
|
||||
extraction_delay,
|
||||
)
|
||||
|
||||
# Wait for HA to be fully started before initializing cycle extraction and first update
|
||||
# This ensures all entities (especially VTherm and scheduler entities) are available
|
||||
@callback
|
||||
def _ha_started(_event):
|
||||
_LOGGER.info(
|
||||
"[%s] HA started, initializing cycle extraction and triggering updates", entry.entry_id
|
||||
)
|
||||
_schedule_startup_work()
|
||||
|
||||
# Schedule initial tasks asynchronously to avoid blocking config flow
|
||||
# This prevents HA watchdog restart during device creation with scheduler
|
||||
if hass.is_running:
|
||||
_LOGGER.debug("[%s] HA already running, scheduling staggered startup work", entry.entry_id)
|
||||
_schedule_startup_work()
|
||||
else:
|
||||
_LOGGER.debug("[%s] Waiting for HA start event before initialization", entry.entry_id)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _ha_started)
|
||||
|
||||
# Small delayed update for late attribute population
|
||||
@callback
|
||||
def _delayed_update(_now):
|
||||
_LOGGER.debug("[%s] Delayed update", entry.entry_id)
|
||||
hass.async_create_task(coordinator.async_update())
|
||||
|
||||
late_update_delay = _LATE_UPDATE_BASE_DELAY_SECONDS + _stable_jitter_seconds(
|
||||
f"{entry.entry_id}:late_update", _LATE_UPDATE_JITTER_SECONDS
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_track_point_in_time(
|
||||
hass,
|
||||
_delayed_update,
|
||||
dt_util.now() + dt_util.dt.timedelta(seconds=late_update_delay),
|
||||
)
|
||||
)
|
||||
|
||||
# Register options update listener
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
# Register services (once per integration, not per device)
|
||||
def _resolve_coordinator(entity_id: str) -> HeatingApplication:
|
||||
"""Resolve an IHP coordinator from an entity ID.
|
||||
|
||||
Looks up the entity in the entity registry to find its config entry,
|
||||
then returns the corresponding HeatingApplication coordinator.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If the entity or its coordinator cannot be found,
|
||||
so callers receive immediate actionable feedback in the HA UI.
|
||||
"""
|
||||
entity_reg = er.async_get(hass)
|
||||
entity_entry = entity_reg.async_get(entity_id)
|
||||
if not entity_entry:
|
||||
raise ServiceValidationError(
|
||||
f"Entity '{entity_id}' was not found in the Home Assistant entity registry. "
|
||||
"Please provide a valid IHP sensor entity ID, for example: "
|
||||
"sensor.intelligent_heating_pilot_<device_name>_anticipated_start_time."
|
||||
)
|
||||
coord = hass.data[DOMAIN].get(entity_entry.config_entry_id)
|
||||
if not coord or not isinstance(coord, HeatingApplication):
|
||||
raise ServiceValidationError(
|
||||
f"No IHP device found for entity '{entity_id}' "
|
||||
f"(config_entry_id={entity_entry.config_entry_id}). "
|
||||
"Make sure the IHP integration is properly configured."
|
||||
)
|
||||
return coord
|
||||
|
||||
if not hass.services.has_service(DOMAIN, "reset_learning"):
|
||||
|
||||
async def handle_reset_learning(call: ServiceCall) -> None:
|
||||
"""Handle reset_learning service.
|
||||
|
||||
Resolves the target IHP device from the entity_id provided in the service
|
||||
call, then delegates to that device's orchestrator. This allows callers
|
||||
to choose which device to reset when multiple IHP config entries exist.
|
||||
"""
|
||||
_LOGGER.debug("Entering handle_reset_learning")
|
||||
|
||||
coord = _resolve_coordinator(call.data["entity_id"])
|
||||
await coord._orchestrator.reset_all_learning_data(
|
||||
device_id=coord.get_device_id()
|
||||
)
|
||||
_LOGGER.info(
|
||||
"reset_learning: learning data cleared for device %s", coord.get_device_id()
|
||||
)
|
||||
|
||||
reset_learning_schema = vol.Schema(
|
||||
{
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
}
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"reset_learning",
|
||||
handle_reset_learning,
|
||||
schema=reset_learning_schema,
|
||||
)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_CALCULATE_ANTICIPATED_START_TIME):
|
||||
|
||||
async def handle_calculate_anticipated_start_time(call: ServiceCall):
|
||||
"""Handle calculate_anticipated_start_time service.
|
||||
|
||||
This service calculates the anticipated start time for a given IHP device
|
||||
to reach a target temperature at a specified time.
|
||||
|
||||
Delegates to orchestrator's calculate_anticipation_only() - no business logic here.
|
||||
"""
|
||||
_LOGGER.debug("Entering handle_calculate_anticipated_start_time")
|
||||
|
||||
# Extract service call parameters
|
||||
entity_id = call.data["entity_id"]
|
||||
target_time = call.data["target_time"]
|
||||
target_temp = call.data.get("target_temp")
|
||||
|
||||
# Ensure target_time has timezone
|
||||
if target_time.tzinfo is None:
|
||||
target_time = dt_util.as_local(target_time)
|
||||
|
||||
# Resolve the coordinator for this device
|
||||
device_coordinator = _resolve_coordinator(entity_id)
|
||||
|
||||
# Get target_temp from service call or VTherm
|
||||
if target_temp is None:
|
||||
# Try to get target temp from VTherm using public accessor
|
||||
vtherm_entity_id = device_coordinator.get_vtherm_entity()
|
||||
if vtherm_entity_id:
|
||||
vtherm_state = hass.states.get(vtherm_entity_id)
|
||||
if vtherm_state:
|
||||
target_temp = vtherm_state.attributes.get("temperature")
|
||||
if target_temp is not None:
|
||||
target_temp = float(target_temp)
|
||||
|
||||
# Delegate to orchestrator (pure routing - no business logic)
|
||||
anticipation_data = await device_coordinator._orchestrator.calculate_anticipation_only(
|
||||
target_time=target_time,
|
||||
target_temp=target_temp,
|
||||
)
|
||||
|
||||
# Extract fields from anticipation_data (which is already structured)
|
||||
anticipated_start_time = anticipation_data.get("anticipated_start_time")
|
||||
response_target_time = anticipation_data.get("next_schedule_time") or target_time
|
||||
response_target_temp = anticipation_data.get("next_target_temperature")
|
||||
if response_target_temp is None:
|
||||
response_target_temp = target_temp
|
||||
if anticipated_start_time is None:
|
||||
_LOGGER.warning("Could not calculate anticipated start time (insufficient data)")
|
||||
# Return structure with None values
|
||||
return {
|
||||
"anticipated_start_time": None,
|
||||
"target_time": response_target_time.isoformat(),
|
||||
"target_temp": response_target_temp,
|
||||
"current_temp": anticipation_data.get("current_temp"),
|
||||
"estimated_duration_minutes": None,
|
||||
"learned_heating_slope": anticipation_data.get("learned_heating_slope"),
|
||||
"confidence_level": None,
|
||||
}
|
||||
|
||||
_LOGGER.info(
|
||||
"Service calculate_anticipated_start_time: "
|
||||
"anticipated_start=%s, target_time=%s, target_temp=%.1f°C, "
|
||||
"current_temp=%.1f°C, LHS=%.2f°C/h, confidence=%.2f",
|
||||
anticipated_start_time.isoformat(),
|
||||
response_target_time.isoformat(),
|
||||
response_target_temp or 0.0,
|
||||
anticipation_data.get("current_temp") or 0.0,
|
||||
anticipation_data.get("learned_heating_slope") or 0.0,
|
||||
anticipation_data.get("confidence_level") or 0.0,
|
||||
)
|
||||
|
||||
# Return the result as service response data
|
||||
return {
|
||||
"anticipated_start_time": anticipated_start_time.isoformat(),
|
||||
"target_time": response_target_time.isoformat(),
|
||||
"target_temp": response_target_temp,
|
||||
"current_temp": anticipation_data.get("current_temp"),
|
||||
"estimated_duration_minutes": anticipation_data.get("estimated_duration_minutes"),
|
||||
"learned_heating_slope": anticipation_data.get("learned_heating_slope"),
|
||||
"confidence_level": anticipation_data.get("confidence_level"),
|
||||
}
|
||||
|
||||
# Define service schema
|
||||
calculate_anticipated_start_time_schema = vol.Schema(
|
||||
{
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
vol.Required("target_time"): cv.datetime,
|
||||
vol.Optional("target_temp"): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_CALCULATE_ANTICIPATED_START_TIME,
|
||||
handle_calculate_anticipated_start_time,
|
||||
schema=calculate_anticipated_start_time_schema,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# Forward setup to platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
coordinator = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if coordinator:
|
||||
await coordinator.async_cleanup()
|
||||
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return bool(unload_ok)
|
||||
|
||||
|
||||
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Update options and reload integration."""
|
||||
from .const import CONF_DATA_RETENTION_DAYS
|
||||
|
||||
coordinator: HeatingApplication | None = hass.data[DOMAIN].get(entry.entry_id)
|
||||
|
||||
previous_options = (
|
||||
dict(getattr(coordinator, "_options_snapshot", {}) or {}) if coordinator else {}
|
||||
)
|
||||
previous_no_toggle = {k: v for k, v in previous_options.items() if k != CONF_IHP_ENABLED}
|
||||
current_no_toggle = {k: v for k, v in entry.options.items() if k != CONF_IHP_ENABLED}
|
||||
ihp_enabled = as_bool(entry.options.get(CONF_IHP_ENABLED), default=True)
|
||||
|
||||
# Check if only retention days changed (reconfiguration of cycle refresh)
|
||||
retention_only_changed = (
|
||||
coordinator is not None
|
||||
and previous_no_toggle != current_no_toggle
|
||||
and len(set(previous_no_toggle.keys()) ^ set(current_no_toggle.keys())) == 1
|
||||
and CONF_DATA_RETENTION_DAYS
|
||||
in (set(previous_no_toggle.keys()) ^ set(current_no_toggle.keys()))
|
||||
)
|
||||
|
||||
if retention_only_changed:
|
||||
# Handle retention change without reload
|
||||
new_retention_days = int(entry.options.get(CONF_DATA_RETENTION_DAYS, 10))
|
||||
_LOGGER.info(
|
||||
"[%s] Data retention changed to %d days, updating cycle refresh",
|
||||
entry.entry_id,
|
||||
new_retention_days,
|
||||
)
|
||||
if coordinator:
|
||||
coordinator._options_snapshot = dict(entry.options)
|
||||
coordinator._ihp_enabled = ihp_enabled
|
||||
await coordinator.async_notify_retention_change(new_retention_days)
|
||||
return
|
||||
|
||||
# If only the ihp_enabled flag changed, skip full reload
|
||||
if coordinator and previous_no_toggle == current_no_toggle:
|
||||
_LOGGER.info("[%s] Options updated (ihp_enabled only), skipping reload", entry.entry_id)
|
||||
coordinator._options_snapshot = dict(entry.options)
|
||||
coordinator._ihp_enabled = ihp_enabled
|
||||
await coordinator.async_update()
|
||||
return
|
||||
|
||||
_LOGGER.info("[%s] Options updated, reloading", entry.entry_id)
|
||||
if coordinator:
|
||||
coordinator._options_snapshot = dict(entry.options)
|
||||
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
# Schedule async update after reload (non-blocking)
|
||||
coordinator = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if coordinator:
|
||||
hass.async_create_task(coordinator.async_update())
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Application layer - use case orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .orchestrator import HeatingOrchestrator
|
||||
|
||||
__all__ = ["HeatingOrchestrator"]
|
||||