New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
+1 -1
View File
@@ -1 +1 @@
2026.6.3
2026.7.1
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+1 -1
View File
@@ -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}
+445 -18
View File
@@ -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,16 +708,11 @@
entity_id: sensor.coffee_maker_power
above: 1000
id: coffee_started
conditions: []
actions:
- choose:
- conditions:
- condition: trigger
id: coffee_started
conditions:
- condition: state
entity_id: input_boolean.coffee_maker_running
state: 'off'
sequence:
actions:
- action: input_boolean.turn_on
target:
entity_id: input_boolean.coffee_maker_running
@@ -733,13 +728,10 @@
hours: 0
minutes: 4
seconds: 0
- wait_for_trigger:
- trigger: numeric_state
entity_id: sensor.coffee_maker_power
below: 350
- wait_template: '{{ states(''sensor.coffee_maker_power'') | float(0) < 350 }}'
timeout:
hours: 0
minutes: 3
minutes: 5
seconds: 0
- action: input_boolean.turn_off
target:
@@ -749,3 +741,438 @@
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:
- 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![Commute Map](/local/commute_snapshot.jpg)"
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.''
}}
![Snapshot](/local/blink.jpg)'
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.''
}}
![Snapshot](/local/blinkTree.jpg)'
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.''
}}
![Snapshot](/local/nest.jpg)'
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.''
}}
![Snapshot](/local/blinkTree.jpg)'
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.''
}}
![Snapshot](/local/blink_Front.jpg)'
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
+4 -6
View File
@@ -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
Binary file not shown.

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,
)
+922
View File
@@ -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"
File diff suppressed because it is too large Load Diff
@@ -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"
}
+174
View File
@@ -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
+241
View File
@@ -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()
+150
View File
@@ -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()
+60
View File
@@ -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"
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
1.15.1
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
}}
File diff suppressed because one or more lines are too long
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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,
}
File diff suppressed because it is too large Load Diff
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -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"]

Some files were not shown because too many files have changed in this diff Show More