Compare commits

...
22 Commits
Author SHA1 Message Date
Pierre d3aefa8680 add llm folder to gitignore 2026-11-13 13:59:52 +01:00
Pierre 8863122afc created a new agents.md 2026-11-13 13:35:47 +01:00
Pierre 13115ad1ba some llm docs 2026-11-13 13:15:03 +01:00
Pierre 8cd2a414cd test update 2026-11-13 13:10:37 +01:00
Pierre 4d502c5911 upgrade to latest version of ad toolbox 2026-11-13 13:09:48 +01:00
Pierre 545ebf2bd8 smartshutter now correctly go through the logger interface 2026-11-13 13:09:17 +01:00
Pierre 5ef7b27448 tweak light and projection screen auto close timing 2026-11-12 22:51:54 +01:00
Pierre 9802982d73 fix(alarmclock): fiabiliser le rafraîchissement du calendrier ICS
- Ajouter deux tentatives de reprise après 2 puis 5 minutes en cas d’échec.
- Mémoriser le dernier succès et adapter le niveau des logs à l’ancienneté du calendrier.
- Distinguer les erreurs réseau, HTTP et les données ICS invalides.
- Définir les délais de connexion et de lecture et vérifier le statut HTTP.
- Actualiser le statut du jour travaillé et l’heure du réveil après une reprise.
2026-11-10 19:31:13 +01:00
Pierre ff39f4cb2f fix(vertex2): fiabiliser les lectures +5V et enrichir les diagnostics TCP
- Regrouper les demandes de lecture +5V et ignorer les callbacks obsolètes.
- Retenter la lecture après 5 secondes au premier échec et journaliser le rétablissement.
- Annuler les lectures planifiées à la réinitialisation, à la suspension et à l’arrêt.
- Détailler les timeouts TCP : champs reçus et manquants, octets reçus, interruptions et dernières lignes.
2026-11-10 19:30:18 +01:00
Pierre b76f107f01 fix christmas tree commit 2026-11-10 19:14:27 +01:00
Pierre f5a586c379 upgrade notificaiton manager to use LoggerInterface 2026-11-10 19:13:47 +01:00
Pierre 94754e6285 New christmas tree 2026-11-10 19:12:06 +01:00
Pierre 10c5b9e043 fixed error caused by sensor change in the last P1P update 2026-11-10 19:11:28 +01:00
Pierre 9f32a666c3 temperature tweak 2026-11-10 19:10:32 +01:00
Pierre 3130df69ce latest appdaemon version came now from docker image 2026-11-08 10:48:50 +01:00
Pierre 35856b2ba9 ignore code stuff from git 2026-11-08 10:48:03 +01:00
Pierre a5258b1985 Tweaked desk presence 2026-11-06 21:55:24 +01:00
Pierre 42ae8f4bc4 add main power off notification 2026-11-05 22:33:40 +01:00
Pierre 2c87c6a6f3 added a notification for commut rain 2026-11-05 18:45:37 +01:00
Pierre d8e604d64d Heatpump now works during the winter as well 2026-11-03 19:19:48 +01:00
Pierre bed50f1d09 fixed a few timer warning 2026-11-03 19:16:30 +01:00
Pierre 07abff7054 New aquarium light automation 2026-10-25 20:41:33 +01:00
25 changed files with 1461 additions and 96 deletions

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ __pycache__/
logs/ logs/
apps/data/ apps/data/
secrets.yaml secrets.yaml
llm/
+94
View File
@@ -0,0 +1,94 @@
# Repository instructions
## Language
Use French when communicating with the user unless they ask otherwise. All new
technical artifacts must be in English: code, identifiers, variable and
function names, Home Assistant entity and sensor names, log messages, comments,
test names, configuration keys, and technical documentation. Do not rename
existing French identifiers or entities unless explicitly requested.
## Context
This repository is an AppDaemon configuration for Home Assistant. Python apps
in `apps/` control real equipment, including heating, shutters, lighting, a
projector, and a television. Application configuration is in `apps/apps.yaml`,
supplemented by YAML files in `apps/global/` and `apps/rooms/`. Runtime
configuration is in `appdaemon.yaml`.
`apps/ad_toolbox` is a shared Git submodule. It provides the base classes,
conditions, virtual sensors, and logging used by the apps. Only change it when
the request explicitly concerns that component. Do not update its revision or
discard its local changes without approval.
## Security and operations
- Never display, copy into responses, or commit tokens, passwords,
`secrets.yaml`, `apps/theframe_token.txt`, or persisted data in `apps/data/`.
Refer to their paths and redact sensitive values in diagnostics.
- Do not start a second AppDaemon instance, rerun `dockerStart.sh`, or execute
`archiveLogs.sh` manually while the service is running. The archive script
moves active log files.
- Do not restart the container, send device commands, or change heating
setpoints unless the user explicitly requests it. To test heating logic, use
mocks first or `send_commands: false` where available.
- Local AppDaemon-core and startup-script patches may exist outside this
persistent repository. Read `docs/appdaemon-local-changes.md` and
`docs/appdaemon-upgrade-rollback-instructions.md` before upgrading or
changing the runtime. Do not overwrite or "clean up" these patches.
## Logs and diagnosis
When asked to investigate a warning, error, or runtime behavior, inspect
`/conf/logs` first:
1. `appdaemon.log`, `error.log`, and `virtualsensors.log` for the current run;
2. `archive/<timestamp>/` for previous runs;
3. `memory_usage.csv` when memory use is suspected.
Always inspect the lines following a warning: a successful reconnection or
recovery can make an incident transient. Distinguish application problems from
Home Assistant, MQTT, or hardware connectivity losses.
## Changes
- Follow the style and conventions of the app or module being changed. Apps
often inherit from `SmartObject` and use `SmartCondition`; do not bypass
these abstractions without a clear reason.
- Keep an app, its `apps/apps.yaml` entry, its Home Assistant entities, and any
related `apps/global/` or `apps/rooms/` configuration consistent.
- Preserve AppDaemon lifecycle behavior: cancel timers, tasks, listeners, and
sockets on reload or termination when a change introduces them.
- Do not overwrite or reorganize the user's existing work. Keep diffs limited
to the requested change.
- Update documentation in `docs/` when operational behavior, a hardware
protocol, or a deployment procedure changes.
## Validation
The project uses `unittest`; no formatter or linter is configured. After a
Python change, run the focused test when one exists, for example:
```sh
python3 -m unittest discover -s tests -p 'test_vertex2.py' -v
python3 -m unittest discover -s tests -p 'test_boilerclimate.py' -v
python3 -m unittest discover -s tests -p 'test_samsungtheframe_async.py' -v
```
For shared changes or before a broader delivery:
```sh
python3 -m unittest discover -s tests -v
python3 -m unittest discover -s apps/ad_toolbox/unit_tests -p 'test_*.py' -v
```
The legacy YAML test runner for `ad_toolbox` is:
```sh
python3 apps/ad_toolbox/unittests.py
```
After a shell-script change, validate syntax at minimum with
`sh -n <script>`. Changes to the VS Code extension in
`apps/ad_toolbox/vscode-appdaemon/` must also follow its own `AGENTS.md`,
including compilation, packaging, and installation.
+81 -8
View File
@@ -32,7 +32,19 @@ class CalendarEvent:
class AlarmClock(SmartObject): class AlarmClock(SmartObject):
CALENDAR_RETRY_DELAYS = (2 * 60, 5 * 60)
@property
def calendar_last_success(self):
return self.dataset.get('calendar_last_success')
@calendar_last_success.setter
def calendar_last_success(self, value):
self.dataset['calendar_last_success'] = value
def on_initialize_smart_object(self): def on_initialize_smart_object(self):
if self.dataset is None:
self.dataset = {}
self.switch_alarmclock_main = self.args['switch_alarmclock_main'] self.switch_alarmclock_main = self.args['switch_alarmclock_main']
self.switch_alarmclock_secondary = self.args['switch_alarmclock_secondary'] self.switch_alarmclock_secondary = self.args['switch_alarmclock_secondary']
self.inputdate_alarmclock_main = self.args['inputdate_alarmclock_main'] self.inputdate_alarmclock_main = self.args['inputdate_alarmclock_main']
@@ -46,6 +58,8 @@ class AlarmClock(SmartObject):
self.handle_program_next_wakueup_cb = None self.handle_program_next_wakueup_cb = None
self.handle_prewakueup_cb = None self.handle_prewakueup_cb = None
self.handle_wakueup_cb = None self.handle_wakueup_cb = None
self.calendar_retry_handle = None
self.calendar_retry_attempt = 0
self.handle_switch_reveil_principal = self.listen_state(self.on_alarm_clock_set, self.switch_alarmclock_main) self.handle_switch_reveil_principal = self.listen_state(self.on_alarm_clock_set, self.switch_alarmclock_main)
self.handle_switch_reveil_secondaire = self.listen_state(self.on_alarm_clock_set, self.switch_alarmclock_secondary) self.handle_switch_reveil_secondaire = self.listen_state(self.on_alarm_clock_set, self.switch_alarmclock_secondary)
self.listen_state(self.on_alarm_clock_set, self.inputdate_alarmclock_main) self.listen_state(self.on_alarm_clock_set, self.inputdate_alarmclock_main)
@@ -64,17 +78,64 @@ class AlarmClock(SmartObject):
def refresh_cal(self): def refresh_cal(self):
with self.log_group("refresh_cal"): with self.log_group("refresh_cal"):
new_cal = list() new_cal = list()
#if self.load_cal_from_google_calendar(new_cal): #if self.load_cal_from_google_calendar(new_cal):
if self.load_cal_from_ics(new_cal): if self.load_cal_from_ics(new_cal):
self.calendar_last_success = datetime.datetime.now(datetime.timezone.utc)
self.calendar_retry_attempt = 0
self._cancel_calendar_retry()
if not self.are_cal_identical(self.calendar,new_cal): if not self.are_cal_identical(self.calendar,new_cal):
self.calendar = new_cal self.calendar = new_cal
self.log_info("Calendar has been updated!") self.log_info("Calendar has been updated!")
self.print_cal(self.calendar) self.print_cal(self.calendar)
return True return True
else:
self._schedule_calendar_retry()
return False return False
def _log_calendar_failure(self, reason):
last_success = self.calendar_last_success
if last_success is None:
log = self.log_error
freshness = 'no successful refresh recorded'
else:
age = datetime.datetime.now(datetime.timezone.utc) - last_success
if age < datetime.timedelta(hours=24):
log = self.log_info
elif age < datetime.timedelta(hours=48):
log = self.log_warning
else:
log = self.log_error
freshness = (f'last_success={last_success.isoformat()}, '
f'age={age.total_seconds() / 3600:.2f}h')
log(f"Couldn't load calendar: {reason}; {freshness}")
def _schedule_calendar_retry(self):
if self.calendar_retry_handle is not None:
return
if self.calendar_retry_attempt >= len(self.CALENDAR_RETRY_DELAYS):
return
delay = self.CALENDAR_RETRY_DELAYS[self.calendar_retry_attempt]
self.calendar_retry_attempt += 1
self.calendar_retry_handle = self.run_in(self.on_calendar_retry, delay)
self.log_info(f"Calendar refresh retry scheduled in {delay // 60} minutes")
def _cancel_calendar_retry(self):
if self.calendar_retry_handle is None:
return
if self.timer_running(self.calendar_retry_handle):
self.cancel_timer(self.calendar_retry_handle)
self.calendar_retry_handle = None
def on_calendar_retry(self, kwargs):
self.calendar_retry_handle = None
calendar_changed = self.refresh_cal()
self.update_workday_status()
if calendar_changed:
self.update_wakeup_time()
def are_cal_identical(self,calendar1,calendar2): def are_cal_identical(self,calendar1,calendar2):
if len(calendar1) != len(calendar2): return False if len(calendar1) != len(calendar2): return False
@@ -93,7 +154,8 @@ class AlarmClock(SmartObject):
try: try:
self.log_info(f"Retrieving calengar from outlook.office365.com") self.log_info(f"Retrieving calengar from outlook.office365.com")
r = get("https://outlook.office365.com/owa/calendar/0c64359c70eb41cd9713fe92c470d3df@ubisoft.com/5edb3fb5150f4203b66d9cf8db3184be11626033572842691749/calendar.ics", timeout=10)#, headers=headers, verify=False) r = get("https://outlook.office365.com/owa/calendar/0c64359c70eb41cd9713fe92c470d3df@ubisoft.com/5edb3fb5150f4203b66d9cf8db3184be11626033572842691749/calendar.ics", timeout=(3, 5))#, headers=headers, verify=False)
r.raise_for_status()
#self.log_info(f"Processing Calendar") #self.log_info(f"Processing Calendar")
today = datetime.datetime.combine(datetime.date.today(), datetime.time(0,0, 0)) today = datetime.datetime.combine(datetime.date.today(), datetime.time(0,0, 0))
@@ -101,7 +163,7 @@ class AlarmClock(SmartObject):
end_load_date = today + datetime.timedelta(days=2) end_load_date = today + datetime.timedelta(days=2)
#self.log_info("[" + str(start_load_date) + "]-[" + str(end_load_date) + "]") #self.log_info("[" + str(start_load_date) + "]-[" + str(end_load_date) + "]")
calendar_ubi = icalendar.Calendar.from_ical(r.text) calendar_ubi = icalendar.Calendar.from_ical(r.content)
for component in calendar_ubi.walk(): for component in calendar_ubi.walk():
if component.name == "VEVENT": if component.name == "VEVENT":
event_summary = component.get('summary') event_summary = component.get('summary')
@@ -137,11 +199,20 @@ class AlarmClock(SmartObject):
calendar.append(CalendarEvent(start_datetime,end_datetime,event_type)) calendar.append(CalendarEvent(start_datetime,end_datetime,event_type))
else: self.log_warning(f"[Unknow event : {event_summary}]Start at {start_datetime},Finish at {end_datetime}") else: self.log_warning(f"[Unknow event : {event_summary}]Start at {start_datetime},Finish at {end_datetime}")
except ValueError: except requests.exceptions.Timeout as error:
self.log_error("Couldn't load calendar") self._log_calendar_failure(f"request timed out ({type(error).__name__})")
return False return False
except (requests.exceptions.ConnectionError,requests.exceptions.ReadTimeout): except requests.exceptions.ConnectionError:
self.log_warning("Couldn't load calendar, request Timeout") self._log_calendar_failure("connection failed")
return False
except requests.exceptions.HTTPError as error:
self._log_calendar_failure(f"HTTP {error.response.status_code}")
return False
except requests.exceptions.RequestException as error:
self._log_calendar_failure(type(error).__name__)
return False
except (ValueError, TypeError, KeyError):
self._log_calendar_failure("invalid ICS data")
return False return False
#self.log_info(f"Calendar loaded") #self.log_info(f"Calendar loaded")
@@ -151,6 +222,8 @@ class AlarmClock(SmartObject):
self.update_wakeup_time() self.update_wakeup_time()
def on_refresh_cal(self, kwargs): def on_refresh_cal(self, kwargs):
self._cancel_calendar_retry()
self.calendar_retry_attempt = 0
calendar_changed = self.refresh_cal() calendar_changed = self.refresh_cal()
self.update_workday_status() self.update_workday_status()
if calendar_changed: if calendar_changed:
+10 -7
View File
@@ -5,7 +5,9 @@ heating_sensors:
sensors: sensors:
binary_sensor.heatpump_winter_mode: binary_sensor.heatpump_winter_mode:
trigger_conditions: false #sensor.month >= 10 or sensor.month <= 4 local_expressions:
temp_threshold: "self ? 20 : 15"
trigger_conditions: sensor.min_temp_outside < temp_threshold
value_selector.target_temperature_heatpump: value_selector.target_temperature_heatpump:
attributes: attributes:
unit_of_measurement: °C unit_of_measurement: °C
@@ -14,7 +16,7 @@ heating_sensors:
values: values:
winter_mode: winter_mode:
conditions: binary_sensor.heatpump_winter_mode conditions: binary_sensor.heatpump_winter_mode
value: 18 value: input_number.heatpump_temperature_winter
default: default:
conditions: True conditions: True
value: input_number.heatpump_temperature value: input_number.heatpump_temperature
@@ -31,10 +33,11 @@ smart_heating_templates_library:
trigger_conditions: trigger_conditions:
- "not binary_sensor.heatpump_winter_mode and (ext_temperature >= room_temperature or ext_temperature >= target_temperature or cant_open_window) and room_temperature >= (quick_resume ? target_temperature : (target_temperature + temp_threshold))" - "not binary_sensor.heatpump_winter_mode and (ext_temperature >= room_temperature or ext_temperature >= target_temperature or cant_open_window) and room_temperature >= (quick_resume ? target_temperature : (target_temperature + temp_threshold))"
- not binary_sensor.heatpump_winter_mode and self and (ext_temperature >= target_temperature or cant_open_window) - not binary_sensor.heatpump_winter_mode and self and (ext_temperature >= target_temperature or cant_open_window)
- binary_sensor.heatpump_winter_mode and room_temperature <= (target_temperature - 1) and ext_temperature <= 10 - binary_sensor.heatpump_winter_mode and room_temperature < target_temperature and ext_temperature <= target_temperature
- binary_sensor.heatpump_winter_mode and self and room_temperature < target_temperature - binary_sensor.heatpump_winter_mode and self and room_temperature < target_temperature + 1
blocking_conditions: blocking_conditions:
- binary_sensor.is_everybody_sleeping and not sleeping_in_the_room and not stay_on_while_sleeping - binary_sensor.is_everybody_sleeping and not sleeping_in_the_room and not stay_on_while_sleeping
- binary_sensor.is_everybody_sleeping and binary_sensor.heatpump_winter_mode
- not room_occupancy and not (sleeping_in_the_room or (occupied_tonight and binary_sensor.day_interval_night)) - not room_occupancy and not (sleeping_in_the_room or (occupied_tonight and binary_sensor.day_interval_night))
- not binary_sensor.someone_home - not binary_sensor.someone_home
- window_open - window_open
@@ -112,7 +115,7 @@ bedroom_heatpump:
auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1 auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1
quiet: True quiet: True
target_temperature: sensor.target_temperature_heatpump - 0.5 target_temperature: "sensor.target_temperature_heatpump - (binary_sensor.heatpump_winter_mode ? 1 : 0.5)"
hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'" hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'"
fan_mode: sensor.bedroom_heatpump_fan_mode fan_mode: sensor.bedroom_heatpump_fan_mode
@@ -147,7 +150,7 @@ living_room_heatpump:
constants: constants:
window_open: binary_sensor.living_room_any_window_open window_open: binary_sensor.living_room_any_window_open
ext_temperature: sensor.ext_front_temperature ext_temperature: sensor.ext_back_temperature
room_temperature: sensor.living_room_temperature room_temperature: sensor.living_room_temperature
room_occupancy: binary_sensor.living_room_occupancy room_occupancy: binary_sensor.living_room_occupancy
target_temperature: sensor.target_temperature_heatpump target_temperature: sensor.target_temperature_heatpump
@@ -157,7 +160,7 @@ living_room_heatpump:
occupied_tonight: input_boolean.living_room_occupied_tonight occupied_tonight: input_boolean.living_room_occupied_tonight
stay_on_while_sleeping: false stay_on_while_sleeping: false
target_temperature: sensor.target_temperature_heatpump + 0.5 target_temperature: "sensor.target_temperature_heatpump + (binary_sensor.heatpump_winter_mode ? 0 : 0.5)"
hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'" hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'"
fan_mode: sensor.living_room_heatpump_fan_mode fan_mode: sensor.living_room_heatpump_fan_mode
+24
View File
@@ -27,3 +27,27 @@ notifications_manager:
message: message:
content: Good Bye content: Good Bye
timeout: 600 timeout: 600
commut_rain_notification:
recipients:
pierre:
services: notify/mobile_app_iphone_de_pierre
conditions: binary_sensor.commut_rain
message:
title: Pluie prévue sur votre trajet
content: "Il va pleuvoir dans {sensor.commut_rain_minutes_until_rain} minutes"
power_off_notification:
recipients:
all:
services:
- notify/mobile_app_iphone_de_pierre
- notify/mobile_app_mae
conditions: binary_sensor.main_power_off and not binary_sensor.someone_home
message:
title: Coupure de courant
content: Le courant a été coupé
auto_clear: true
+3 -7
View File
@@ -60,10 +60,6 @@ occupancy_sensors:
binary_sensor.guestroom_occupancy: binary_sensor.guestroom_occupancy:
trigger_conditions: binary_sensor.is_guestroom_sleeping or binary_sensor.day_interval_night trigger_conditions: binary_sensor.is_guestroom_sleeping or binary_sensor.day_interval_night
blocking_conditions: not binary_sensor.someone_home or not input_boolean.guestroom_occupied_tonight blocking_conditions: not binary_sensor.someone_home or not input_boolean.guestroom_occupied_tonight
retain_condition.desk_occupancy: binary_sensor.desk_occupancy:
retain_time: 5 * 60 trigger_conditions: sensor.desk_last_motion <= 5 or binary_sensor.is_desk_sleeping
conditions: blocking_conditions: not binary_sensor.someone_home
trigger_conditions:
- sensor.desk_last_motion <= 5
#- binary_sensor.alienware_r10
#- sensor.macbook_m3_pro_de_pierre_frontmost_app != 'loginwindow' and sensor.macbook_m3_pro_de_pierre_displays == 2
+9 -7
View File
@@ -82,25 +82,27 @@ virtual_sensors:
event_data: event_data:
entity_id: sensor.shelly_button_red_signal_strength entity_id: sensor.shelly_button_red_signal_strength
binary_sensor.main_power_off: sensor.ups_code_d_etat != 'OL'
# living room sensors # living room sensors
value_selector.living_room_ambiance_light: value_selector.living_room_ambiance_light:
values: values:
0: 0: #0 off
- not binary_sensor.living_room_occupancy - not binary_sensor.living_room_occupancy
- binary_sensor.projector_on and not binary_sensor.is_vertex2_content_idle - binary_sensor.projector_on and not binary_sensor.is_vertex2_content_idle
- binary_sensor.is_tv_on - binary_sensor.is_tv_on
- light.living_room - light.living_room
- sensor.mezzanine_motion_light_level > 15 - "sensor.mezzanine_motion_light_level > (self == 0 ? 15 : 20)"
- binary_sensor.is_living_room_sleeping - binary_sensor.is_living_room_sleeping
1: 1: # dimmed
- binary_sensor.is_everybody_sleeping - binary_sensor.is_everybody_sleeping
- binary_sensor.projector_on - binary_sensor.projector_on
2: true 2: true #full
# home cinema sensors # home cinema sensors
binary_sensor.projector_on: switch.living_room_projector_plug and sensor.living_room_projector_plug_power > 50 binary_sensor.projector_on: switch.projector #switch.living_room_projector_plug and sensor.living_room_projector_plug_power > 50
continuous_condition.projector_off_for_a_while: continuous_condition.projector_off_for_a_while:
time: 90 time: 300
default_value: on default_value: on
conditions: not binary_sensor.projector_on conditions: not binary_sensor.projector_on
# is_apple_tv_on is not reliable as the the apple tv integration sometimes return "paused" when it's actually off, # is_apple_tv_on is not reliable as the the apple tv integration sometimes return "paused" when it's actually off,
@@ -137,7 +139,7 @@ virtual_sensors:
binary_sensor.p1p_is_active: binary_sensor.p1p_is_active:
trigger_conditions: trigger_conditions:
- binary_sensor.p1p_is_printing - binary_sensor.p1p_is_printing
- fan.garage_p1p_ventilateur_de_la_chambre or fan.garage_p1p_ventilateur_de_refroidissement - sensor.garage_p1p_vitesse_du_ventilateur_de_la_chambre or sensor.garage_p1p_vitesse_du_ventilateur_de_refroidissement
- sensor.garage_p1p_temperature_cible_de_la_buse > 0 or sensor.garage_p1p_temperature_cible_du_lit > 0 - sensor.garage_p1p_temperature_cible_de_la_buse > 0 or sensor.garage_p1p_temperature_cible_du_lit > 0
blocking_conditions: not switch.garage_p1p_plug blocking_conditions: not switch.garage_p1p_plug
+22 -8
View File
@@ -4,11 +4,14 @@ from importlib import metadata
import json import json
import pickle import pickle
import os import os
import re
from urllib.request import urlopen from urllib.request import urlopen
class InformationsCollector(SmartObject): class InformationsCollector(SmartObject):
APPDAEMON_PYPI_URL = "https://pypi.org/pypi/appdaemon/json" APPDAEMON_DOCKER_TAGS_URL = (
"https://hub.docker.com/v2/repositories/acockburn/appdaemon/tags?page_size=100"
)
def on_initialize_smart_object(self): def on_initialize_smart_object(self):
super().on_initialize_smart_object() super().on_initialize_smart_object()
@@ -26,7 +29,7 @@ class InformationsCollector(SmartObject):
sensor_name, sensor_name,
state='unknown', state='unknown',
attributes={ attributes={
'latest_official_version': self.dataset.get('appdaemon_latest_official_version'), 'latest_official_version': self.dataset.get('appdaemon_latest_docker_version'),
'update_available': None, 'update_available': None,
}, },
icon='mdi:package-up' icon='mdi:package-up'
@@ -143,9 +146,9 @@ class InformationsCollector(SmartObject):
latest_official_version = self._get_latest_official_appdaemon_version() latest_official_version = self._get_latest_official_appdaemon_version()
if latest_official_version is not None: if latest_official_version is not None:
self.dataset['appdaemon_latest_official_version'] = latest_official_version self.dataset['appdaemon_latest_docker_version'] = latest_official_version
else: else:
latest_official_version = self.dataset.get('appdaemon_latest_official_version') latest_official_version = self.dataset.get('appdaemon_latest_docker_version')
update_available = self._is_appdaemon_update_available(installed_version, latest_official_version) update_available = self._is_appdaemon_update_available(installed_version, latest_official_version)
@@ -168,11 +171,22 @@ class InformationsCollector(SmartObject):
def _get_latest_official_appdaemon_version(self): def _get_latest_official_appdaemon_version(self):
try: try:
with urlopen(self.APPDAEMON_PYPI_URL, timeout=10) as response: url = self.APPDAEMON_DOCKER_TAGS_URL
payload = json.load(response) versions = {}
return payload.get('info', {}).get('version') while url:
with urlopen(url, timeout=10) as response:
payload = json.load(response)
for tag in payload['results']:
name = tag.get('name', '')
# Only stable version tags with published container images.
if re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', name) and tag.get('images'):
versions[tuple(map(int, name.split('.')))] = name
url = payload.get('next')
if not versions:
raise ValueError("No stable AppDaemon Docker image found")
return versions[max(versions)]
except Exception as err: except Exception as err:
self.log_warning(f"Unable to fetch latest official AppDaemon version: {err}") self.log_warning(f"Unable to fetch latest official AppDaemon Docker version: {err}")
return None return None
def _is_appdaemon_update_available(self, installed_version, latest_official_version): def _is_appdaemon_update_available(self, installed_version, latest_official_version):
+13 -16
View File
@@ -3,7 +3,8 @@ import appdaemon.exceptions as ad_exception
import ad_toolbox.smartcondition as SmartCondition import ad_toolbox.smartcondition as SmartCondition
import re import re
import uuid import uuid
from expressionparser import ParsingException from ad_toolbox.expressionparser import ParsingException
from ad_toolbox.logger_interface import LoggerInterface
class Notification: class Notification:
ENTITY_PLACEHOLDER_PATTERN = re.compile(r"\{([^{}]+)\}") ENTITY_PLACEHOLDER_PATTERN = re.compile(r"\{([^{}]+)\}")
@@ -18,7 +19,7 @@ class Notification:
for recipient in config["recipients"]: for recipient in config["recipients"]:
try: self.recipients[recipient] = SmartCondition.Evaluator(self.ad_api,self.config["recipients"][recipient]['conditions'],condition_name = name,on_succeed_cb = self.on_condition_succeed, on_fail_cb = self.on_conditon_fail,pass_condition_name_to_cb = recipient, trigger_callback_on_activation = False) try: self.recipients[recipient] = SmartCondition.Evaluator(self.ad_api,self.config["recipients"][recipient]['conditions'],condition_name = name,on_succeed_cb = self.on_condition_succeed, on_fail_cb = self.on_conditon_fail,pass_condition_name_to_cb = recipient, trigger_callback_on_activation = False)
except ParsingException as e: except ParsingException as e:
self.log_error(str(e)) self.ad_api.log_error(str(e))
continue continue
def on_conditon_fail(self, recipient): def on_conditon_fail(self, recipient):
@@ -42,9 +43,9 @@ class Notification:
data = { 'tag' : tag } data = { 'tag' : tag }
def clear_notification(service,tag): def clear_notification(service,tag):
self.ad_api.log(f"Clear notification {data} from {service}") self.ad_api.log_info(f"Clear notification {data} from {service}")
try: self.ad_api.call_service(service,message = message, data = data) try: self.ad_api.call_service(service,message = message, data = data)
except ad_exception.ServiceException: self.log_error(f"Invalid service: {service}") except ad_exception.ServiceException: self.ad_api.log_error(f"Invalid service: {service}")
recipient_yaml = self.config["recipients"][recipient] recipient_yaml = self.config["recipients"][recipient]
if isinstance(recipient_yaml['services'], str): if isinstance(recipient_yaml['services'], str):
@@ -65,11 +66,11 @@ class Notification:
try: try:
state = self.ad_api.get_state(entity_id) state = self.ad_api.get_state(entity_id)
except Exception as e: except Exception as e:
self.log_error(f"Failed to resolve '{entity_id}' in notification content: {e}") self.ad_api.log_error(f"Failed to resolve '{entity_id}' in notification content: {e}")
return match.group(0) return match.group(0)
if state is None: if state is None:
self.log_error(f"Unable to resolve '{entity_id}' in notification content") self.ad_api.log_error(f"Unable to resolve '{entity_id}' in notification content")
return match.group(0) return match.group(0)
return str(state) return str(state)
@@ -96,9 +97,9 @@ class Notification:
self.ad_api.run_in(self.on_timeout_expired,timeout,tag = data['tag'],recipient = recipient) self.ad_api.run_in(self.on_timeout_expired,timeout,tag = data['tag'],recipient = recipient)
def send_notification(service): def send_notification(service):
self.ad_api.log(f"Sending notification '{message}' to {service}{f' with data {data}' if data else ''}") self.ad_api.log_info(f"Sending notification '{message}' to {service}{f' with data {data}' if data else ''}")
try: self.ad_api.call_service(service,title = title,message = message, data = data) try: self.ad_api.call_service(service,title = title,message = message, data = data)
except ad_exception.ServiceException: self.log_error(f"Invalid service: {service}") except ad_exception.ServiceException: self.ad_api.log_error(f"Invalid service: {service}")
recipient_yaml = self.config["recipients"][recipient] recipient_yaml = self.config["recipients"][recipient]
if isinstance(recipient_yaml['services'], str): if isinstance(recipient_yaml['services'], str):
@@ -107,17 +108,13 @@ class Notification:
for service in recipient_yaml['services']: for service in recipient_yaml['services']:
send_notification(service) send_notification(service)
def log_error(self,message,stop_app = False): class NotificationsManager(hass.Hass, LoggerInterface):
self.ad_api.log(message,level = "ERROR", log = "main_log")
self.ad_api.log(message,level = "ERROR", log = "error_log")
if stop_app:
self.ad_api.stop_app(self.ad_api.name)
class NotificationsManager(hass.Hass):
def initialize(self): def initialize(self):
# Preserve the default app logger used by the previous direct log calls.
self.initialize_logger_interface(self.get_ad_api(), default_log=None)
self.notifications = dict() self.notifications = dict()
if "notifications" in self.args: if "notifications" in self.args:
for notification in self.args["notifications"]: for notification in self.args["notifications"]:
self.notifications[notification] = Notification(self,self.args["notifications"][notification],notification) self.notifications[notification] = Notification(self,self.args["notifications"][notification],notification)
+1 -1
View File
@@ -4,5 +4,5 @@ light_ambiance:
entity: light.desk_ambiance entity: light.desk_ambiance
smart_conditions: smart_conditions:
trigger_conditions: sensor.desk_last_motion < 3 and binary_sensor.is_dark_outside trigger_conditions: binary_sensor.desk_occupancy and binary_sensor.is_dark_outside
blocking_conditions: binary_sensor.is_desk_sleeping blocking_conditions: binary_sensor.is_desk_sleeping
+12
View File
@@ -0,0 +1,12 @@
light_aquarium:
module: smartswitch
class: SmartSwitch
entity: switch.aquarium_light
virtual_sensors:
sensors:
time_period.aquarium_light_schedule:
start: input_datetime.aquarium_light_start
end: input_datetime.aquarium_light_stop
smart_conditions: binary_sensor.aquarium_light_schedule
+1 -1
View File
@@ -15,7 +15,7 @@ p1p_light:
smart_conditions: smart_conditions:
unavaibility_result: Disabled unavaibility_result: Disabled
trigger_conditions: binary_sensor.garage_occupancy or binary_sensor.garage_p1p_timelapse or camera.garage_p1p_camera == 'streaming' trigger_conditions: binary_sensor.garage_occupancy or binary_sensor.garage_p1p_timelapse or camera.garage_p1p_camera == 'streaming'
blocking_conditions: not binary_sensor.p1p_is_printing and not fan.garage_p1p_ventilateur_de_refroidissement and not fan.garage_p1p_ventilateur_de_la_chambre blocking_conditions: not binary_sensor.p1p_is_printing and not sensor.garage_p1p_vitesse_du_ventilateur_de_refroidissement and not sensor.garage_p1p_vitesse_du_ventilateur_de_la_chambre
p1p_switch: p1p_switch:
module: smartswitch module: smartswitch
+8 -1
View File
@@ -49,6 +49,13 @@ light_bookshelves:
25: sensor.living_room_ambiance_light == 1 25: sensor.living_room_ambiance_light == 1
100: True 100: True
light_christmas_tree:
module: smartswitch
class: SmartSwitch
entity: switch.chrismas_tree
smart_conditions: sensor.living_room_ambiance_light >= 1
light_tree: light_tree:
module: smartlight module: smartlight
class: SmartLight class: SmartLight
@@ -102,7 +109,7 @@ mezzanine_curtains:
close_conditions: close_conditions:
trigger_conditions: binary_sensor.projector_on or not binary_sensor.projector_off_for_a_while trigger_conditions: binary_sensor.projector_on or not binary_sensor.projector_off_for_a_while
disable_conditions: self == 'closed' and (binary_sensor.is_dark_outside or binary_sensor.is_anybody_sleeping) disable_conditions: binary_sensor.is_dark_outside or binary_sensor.is_anybody_sleeping
living_room_sleep_switch: living_room_sleep_switch:
module: smartswitch module: smartswitch
+21 -21
View File
@@ -30,7 +30,7 @@ class SmartShutter(SmartObject):
except: except:
self.open_position_sensor = self.args["open_position"] self.open_position_sensor = self.args["open_position"]
self.open_position = int(float(self.get_state(self.open_position_sensor))) self.open_position = int(float(self.get_state(self.open_position_sensor)))
self.log(f"Registering sensor {self.open_position_sensor} for open position") self.log_info(f"Registering sensor {self.open_position_sensor} for open position")
self.listen_state(self.on_open_position_change,self.open_position_sensor) self.listen_state(self.on_open_position_change,self.open_position_sensor)
else: else:
self.open_position = 100 self.open_position = 100
@@ -40,7 +40,7 @@ class SmartShutter(SmartObject):
except: except:
self.close_position_sensor = self.args["close_position"] self.close_position_sensor = self.args["close_position"]
self.close_position = int(float(self.get_state(self.close_position_sensor))) self.close_position = int(float(self.get_state(self.close_position_sensor)))
self.log(f"Registering sensor {self.close_position_sensor} for close position") self.log_info(f"Registering sensor {self.close_position_sensor} for close position")
self.listen_state(self.on_close_position_change,self.close_position_sensor) self.listen_state(self.on_close_position_change,self.close_position_sensor)
else: else:
self.close_position = 0 self.close_position = 0
@@ -60,11 +60,11 @@ class SmartShutter(SmartObject):
if 'close_conditions' in self.args: if 'close_conditions' in self.args:
self.close_conditions = SmartCondition.Evaluator(self,self.args["close_conditions"], condition_name = "close_conditions", templates_library = self.templates_library,on_update_cb = self.on_update,extra_entities_to_listen = extra_entities_to_listen, log_callback_trigger_reason = False, constants = self.constants) self.close_conditions = SmartCondition.Evaluator(self,self.args["close_conditions"], condition_name = "close_conditions", templates_library = self.templates_library,on_update_cb = self.on_update,extra_entities_to_listen = extra_entities_to_listen, log_callback_trigger_reason = False, constants = self.constants)
if 'open_conditions' in self.args: if 'open_conditions' in self.args:
self.log("Both a close_conditions and an open_conditions has been defined, please use only one",level = 'ERROR') self.log_error("Both a close_conditions and an open_conditions has been defined, please use only one")
elif 'open_conditions' in self.args: elif 'open_conditions' in self.args:
self.open_conditions = SmartCondition.Evaluator(self,self.args["open_conditions"], condition_name = "open_conditions", templates_library = self.templates_library,on_update_cb = self.on_update,extra_entities_to_listen = extra_entities_to_listen, log_callback_trigger_reason = False, constants = self.constants) self.open_conditions = SmartCondition.Evaluator(self,self.args["open_conditions"], condition_name = "open_conditions", templates_library = self.templates_library,on_update_cb = self.on_update,extra_entities_to_listen = extra_entities_to_listen, log_callback_trigger_reason = False, constants = self.constants)
else: else:
self.log("No close_conditions nor open_conditions defined",level = 'ERROR') self.log_error("No close_conditions nor open_conditions defined")
#for object that take a long time to change state, we need reevalute desired state once the state has changed #for object that take a long time to change state, we need reevalute desired state once the state has changed
#in case the conditions change during the state change #in case the conditions change during the state change
@@ -96,7 +96,7 @@ class SmartShutter(SmartObject):
try: try:
current_position = float(current_position) current_position = float(current_position)
except (TypeError, ValueError): except (TypeError, ValueError):
self.log("Toggle ignored: current position is unavailable", level="WARNING") self.log_warning("Toggle ignored: current position is unavailable")
return return
# Reverse the requested movement when another press arrives in transit. # Reverse the requested movement when another press arrives in transit.
@@ -114,7 +114,7 @@ class SmartShutter(SmartObject):
if self.auto_switch: if self.auto_switch:
self.set_state(self.auto_switch, state='off') self.set_state(self.auto_switch, state='off')
self.target_position = self.close_position if should_close else self.open_position self.target_position = self.close_position if should_close else self.open_position
self.log(f"Toggle shutter to {self.target_position} (previous position was {current_position})") self.log_info(f"Toggle shutter to {self.target_position} (previous position was {current_position})")
if self.target_position == 0: if self.target_position == 0:
self.call_service("cover/close_cover", entity_id=self.entity_id) self.call_service("cover/close_cover", entity_id=self.entity_id)
elif self.target_position == 100: elif self.target_position == 100:
@@ -124,25 +124,25 @@ class SmartShutter(SmartObject):
def on_state_change(self, entity, attribute, old, new, kwargs): def on_state_change(self, entity, attribute, old, new, kwargs):
if self.ready == False: if self.ready == False:
self.log(f"on_state_change called before the app is ready",level = "WARNING") self.log_warning(f"on_state_change called before the app is ready")
return return
#for object that take a long time to change state, we need reevalute desired state once the state has changed #for object that take a long time to change state, we need reevalute desired state once the state has changed
#in case the conditions change during the state change #in case the conditions change during the state change
#in case of shutter we can probably improve that and cancel the state change has it is updating #in case of shutter we can probably improve that and cancel the state change has it is updating
if old != new and not self.slow_transition: if old != new and not self.slow_transition:
self.log("state changed from " + str(old) + " to " + str(new)) self.log_info("state changed from " + str(old) + " to " + str(new))
self.on_update() self.on_update()
def on_position_change(self, entity, attribute, old, new, kwargs): def on_position_change(self, entity, attribute, old, new, kwargs):
if self.ready == False: if self.ready == False:
self.log(f"on_position_change called before the app is ready",level = "WARNING") self.log_warning(f"on_position_change called before the app is ready")
return return
if self.target_position == None: if self.target_position == None:
if self.target_position_reach_time != None and time.time() - self.target_position_reach_time < 60: if self.target_position_reach_time != None and time.time() - self.target_position_reach_time < 60:
self.log(f"A position changed ({old} -> {new}) occured {time.time() - self.target_position_reach_time}s after a requested position change has been complete. This won't be considered as a manual command") self.log_info(f"A position changed ({old} -> {new}) occured {time.time() - self.target_position_reach_time}s after a requested position change has been complete. This won't be considered as a manual command")
else: else:
self.log(f"Manual command has been received {old} -> {new}") self.log_info(f"Manual command has been received {old} -> {new}")
if self.close_conditions: smart_conditions = self.close_conditions if self.close_conditions: smart_conditions = self.close_conditions
else: smart_conditions = self.open_conditions else: smart_conditions = self.open_conditions
@@ -150,13 +150,13 @@ class SmartShutter(SmartObject):
self.set_state(self.auto_switch,state = "off") self.set_state(self.auto_switch,state = "off")
else: else:
if self.target_position == new: if self.target_position == new:
self.log(f"Movement completed old: {old}, new: {new}, target_position: {self.target_position}, current_position: {self.get_state(self.entity_id,attribute = 'current_position')}") self.log_info(f"Movement completed old: {old}, new: {new}, target_position: {self.target_position}, current_position: {self.get_state(self.entity_id,attribute = 'current_position')}")
self.target_position = None self.target_position = None
self.target_position_reach_time = time.time() self.target_position_reach_time = time.time()
def on_open_position_change(self, entity, attribute, old, new, kwargs): def on_open_position_change(self, entity, attribute, old, new, kwargs):
if self.ready == False: if self.ready == False:
self.log(f"on_open_position_change called before the app is ready",level = "WARNING") self.log_warning(f"on_open_position_change called before the app is ready")
return return
if new != self.open_position: if new != self.open_position:
@@ -167,7 +167,7 @@ class SmartShutter(SmartObject):
def on_close_position_change(self, entity, attribute, old, new, kwargs): def on_close_position_change(self, entity, attribute, old, new, kwargs):
if self.ready == False: if self.ready == False:
self.log(f"on_close_position_change called before the app is ready",level = "WARNING") self.log_warning(f"on_close_position_change called before the app is ready")
return return
if new != self.close_position: if new != self.close_position:
@@ -207,7 +207,7 @@ class SmartShutter(SmartObject):
self.slow_transition_cb = None self.slow_transition_cb = None
def on_retry_update(self, kwargs): def on_retry_update(self, kwargs):
self.log("on_retry_update") self.log_info("on_retry_update")
self.on_update() self.on_update()
def on_update(self): def on_update(self):
@@ -218,7 +218,7 @@ class SmartShutter(SmartObject):
if self.emergency_close_conditions and self.emergency_close_conditions.evaluate(False) == SmartCondition.Result.Succeeded: if self.emergency_close_conditions and self.emergency_close_conditions.evaluate(False) == SmartCondition.Result.Succeeded:
if self.get_state(self.entity_id) != 'closed': if self.get_state(self.entity_id) != 'closed':
log_evaluation_result(self.emergency_close_conditions) log_evaluation_result(self.emergency_close_conditions)
self.log("Closing shutter (Emergency close)") self.log_info("Closing shutter (Emergency close)")
self.call_service("cover/close_cover", entity_id = self.entity_id) self.call_service("cover/close_cover", entity_id = self.entity_id)
if self.auto_switch: self.set_state(self.auto_switch,state = "on") if self.auto_switch: self.set_state(self.auto_switch,state = "on")
elif self.is_auto_switch_on(): elif self.is_auto_switch_on():
@@ -247,12 +247,12 @@ class SmartShutter(SmartObject):
if current_position != self.close_position: if current_position != self.close_position:
log_evaluation_result(smart_conditions) log_evaluation_result(smart_conditions)
self.target_position = self.close_position self.target_position = self.close_position
self.log("Closing shutter at " + str(self.close_position) + " (previous position was " + str(current_position) + ")") self.log_info("Closing shutter at " + str(self.close_position) + " (previous position was " + str(current_position) + ")")
self.call_service("cover/set_cover_position", entity_id = self.entity_id,position = self.close_position) self.call_service("cover/set_cover_position", entity_id = self.entity_id,position = self.close_position)
elif current_state != 'closed': elif current_state != 'closed':
log_evaluation_result(smart_conditions) log_evaluation_result(smart_conditions)
self.target_position = self.close_position self.target_position = self.close_position
self.log(f"Closing shutter, state was {current_state}") self.log_info(f"Closing shutter, state was {current_state}")
self.call_service("cover/close_cover", entity_id = self.entity_id) self.call_service("cover/close_cover", entity_id = self.entity_id)
self.stop_slow_transition() self.stop_slow_transition()
@@ -268,15 +268,15 @@ class SmartShutter(SmartObject):
self.call_service("cover/set_cover_position", entity_id = self.entity_id,position = wanted_position) self.call_service("cover/set_cover_position", entity_id = self.entity_id,position = wanted_position)
self.slow_transition_cb = self.run_in(self.on_transition_update, 120) self.slow_transition_cb = self.run_in(self.on_transition_update, 120)
else: else:
self.log(f"Update ignored becaused we are waiting for a slow transition CB") self.log_info(f"Update ignored becaused we are waiting for a slow transition CB")
else: else:
if self.open_position == 100: if self.open_position == 100:
self.log("Opening shutter") self.log_info("Opening shutter")
log_evaluation_result(smart_conditions) log_evaluation_result(smart_conditions)
self.call_service("cover/open_cover", entity_id = self.entity_id) self.call_service("cover/open_cover", entity_id = self.entity_id)
else: else:
log_evaluation_result(smart_conditions) log_evaluation_result(smart_conditions)
self.log("Opening shutter at " + str(self.open_position) + " (previous position was " + str(current_position) + ")") self.log_info("Opening shutter at " + str(self.open_position) + " (previous position was " + str(current_position) + ")")
self.call_service("cover/set_cover_position", entity_id = self.entity_id,position = self.open_position) self.call_service("cover/set_cover_position", entity_id = self.entity_id,position = self.open_position)
else : self.run_in(self.on_retry_update,60) #during HASS restart, shutter can be irresponsive untill the z-wave network is properly initialized else : self.run_in(self.on_retry_update,60) #during HASS restart, shutter can be irresponsive untill the z-wave network is properly initialized
+68 -11
View File
@@ -7,6 +7,7 @@ import time
import json import json
import socket import socket
import threading import threading
from collections import deque
import re import re
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -14,6 +15,7 @@ class Vertex2(SmartObject):
def on_initialize_smart_object(self): def on_initialize_smart_object(self):
# HASS can call initialize() again without calling terminate() first. # HASS can call initialize() again without calling terminate() first.
self._stop_interrupt_listener() self._stop_interrupt_listener()
self._cancel_input_5v_refresh()
self._interrupt_stop = threading.Event() self._interrupt_stop = threading.Event()
self._status_requested = threading.Event() self._status_requested = threading.Event()
self._last_logged_status = None self._last_logged_status = None
@@ -217,9 +219,28 @@ class Vertex2(SmartObject):
if sensor.get_state() != 'unavailable': if sensor.get_state() != 'unavailable':
sensor.set_state(state='unavailable') sensor.set_state(state='unavailable')
def _schedule_input_5v_refresh(self): def _cancel_input_5v_refresh(self):
# Device monitoring must run even when no HA input sensors are exposed. handle = getattr(self, '_input_5v_refresh_handle', None)
self.run_in(self._refresh_input_5v_sensors, 0.1) if handle is not None:
self.cancel_timer(handle, silent=True)
self._input_5v_refresh_handle = None
self._input_5v_refresh_generation = getattr(self, '_input_5v_refresh_generation', 0) + 1
self._input_5v_failure_since = None
self._input_5v_failures = 0
def _schedule_input_5v_refresh(self, delay=1):
# Coalesce bursts without postponing a pending retry or starving reads.
if self._input_5v_refresh_handle is not None:
return
self._input_5v_refresh_generation += 1
self._input_5v_refresh_handle = self.run_in(
self._on_input_5v_refresh, delay, generation=self._input_5v_refresh_generation)
def _on_input_5v_refresh(self, kwargs):
if kwargs['generation'] != self._input_5v_refresh_generation:
return
self._input_5v_refresh_handle = None
self._refresh_input_5v_sensors()
def _refresh_input_5v_sensors(self, kwargs=None): def _refresh_input_5v_sensors(self, kwargs=None):
if not self._vertex2_ready(): if not self._vertex2_ready():
@@ -233,11 +254,27 @@ class Vertex2(SmartObject):
raise ValueError('expected an information object') raise ValueError('expected an information object')
except (RequestException, ValueError) as error: except (RequestException, ValueError) as error:
self._invalidate_input_5v_sensors() self._invalidate_input_5v_sensors()
self.log_warning(f'Vertex2 input +5V readback failed: {error}') if not self._vertex2_ready():
self._cancel_input_5v_refresh()
return
self._input_5v_failures += 1
if self._input_5v_failure_since is None:
self._input_5v_failure_since = time.monotonic()
log = self.log_warning if self._input_5v_failures == 2 else self.log_info
log(f'Vertex2 input +5V readback failed '
f'(attempt={self._input_5v_failures}, {type(error).__name__}): {error}')
if self._input_5v_failures == 1:
self._schedule_input_5v_refresh(delay=5)
return return
if not self._vertex2_ready(): if not self._vertex2_ready():
self._invalidate_input_5v_sensors() self._invalidate_input_5v_sensors()
return return
if self._input_5v_failure_since is not None:
self.log_info(
f'Vertex2 input +5V readback restored after '
f'{time.monotonic() - self._input_5v_failure_since:.1f}s '
f'({self._input_5v_failures} failed attempts)')
self._cancel_input_5v_refresh()
states = {} states = {}
for index in range(4): for index in range(4):
# Firmware uses 2 for present and 1 for absent, not boolean 1/0. # Firmware uses 2 for present and 1 for absent, not boolean 1/0.
@@ -358,7 +395,7 @@ class Vertex2(SmartObject):
return return
with self._spam_lock: with self._spam_lock:
if self._spam_timer is not None: if self._spam_timer is not None:
self.cancel_timer(self._spam_timer) self.cancel_timer(self._spam_timer, silent=True)
self._spam_timer = None self._spam_timer = None
self._spam_deadline = None self._spam_deadline = None
self._spam_last_interrupt = None self._spam_last_interrupt = None
@@ -399,7 +436,7 @@ class Vertex2(SmartObject):
if self.no_signal_spam_sensor.get_state() != state: if self.no_signal_spam_sensor.get_state() != state:
self.no_signal_spam_sensor.set_state(state=state) self.no_signal_spam_sensor.set_state(state=state)
if self._spam_timer is not None: if self._spam_timer is not None:
self.cancel_timer(self._spam_timer) self.cancel_timer(self._spam_timer, silent=True)
self._spam_deadline = at + 4 self._spam_deadline = at + 4
self._spam_timer = self.run_in( self._spam_timer = self.run_in(
self._expire_no_signal_spam, max(0, self._spam_deadline - now), self._expire_no_signal_spam, max(0, self._spam_deadline - now),
@@ -442,11 +479,26 @@ class Vertex2(SmartObject):
status_started = None status_started = None
reconfigure = True reconfigure = True
pending_interrupt_at = None pending_interrupt_at = None
recent_lines = deque(maxlen=12)
received_bytes = 0
interrupt_count = 0
def status_timeout_error():
return OSError(
'Vertex2 TCP status response timed out '
f'(elapsed={time.monotonic() - status_started:.2f}s, limit=5s, '
f'received={sorted(status)}, missing={sorted(expected - status.keys())}, '
f'bytes={received_bytes}, interrupts={interrupt_count}, '
f'reconfigure={reconfigure}, '
f'recent_lines={list(recent_lines)!r}, partial_line={buffer[-256:]!r})')
while not interrupt_stop.is_set() and self._vertex2_powered(): while not interrupt_stop.is_set() and self._vertex2_powered():
if status_started is None and self._status_requested.is_set(): if status_started is None and self._status_requested.is_set():
self._status_requested.clear() self._status_requested.clear()
status = {} status = {}
recent_lines.clear()
received_bytes = 0
interrupt_count = 0
status_started = time.monotonic() status_started = time.monotonic()
request_interrupt_at = pending_interrupt_at request_interrupt_at = pending_interrupt_at
pending_interrupt_at = None pending_interrupt_at = None
@@ -457,7 +509,7 @@ class Vertex2(SmartObject):
data = connection.recv(1024) data = connection.recv(1024)
except socket.timeout: except socket.timeout:
if status_started is not None and time.monotonic() - status_started > 5: if status_started is not None and time.monotonic() - status_started > 5:
raise OSError('Vertex2 TCP status response timed out') raise status_timeout_error()
continue continue
if interrupt_stop.is_set() or not self._vertex2_powered(): if interrupt_stop.is_set() or not self._vertex2_powered():
@@ -465,6 +517,7 @@ class Vertex2(SmartObject):
if not data: if not data:
raise OSError('Vertex2 closed the IP INTERRUPT connection') raise OSError('Vertex2 closed the IP INTERRUPT connection')
received_bytes += len(data)
buffer += data.decode('ascii', errors='ignore') buffer += data.decode('ascii', errors='ignore')
if len(buffer) > 4096: if len(buffer) > 4096:
raise OSError('Vertex2 TCP response exceeded buffer limit') raise OSError('Vertex2 TCP response exceeded buffer limit')
@@ -472,7 +525,9 @@ class Vertex2(SmartObject):
while '\n' in buffer: while '\n' in buffer:
line, buffer = buffer.split('\n', 1) line, buffer = buffer.split('\n', 1)
line = line.strip() line = line.strip()
recent_lines.append(line[:256])
if line == 'int': if line == 'int':
interrupt_count += 1
pending_interrupt_at = time.monotonic() pending_interrupt_at = time.monotonic()
self._status_requested.set() self._status_requested.set()
elif status_started is not None: elif status_started is not None:
@@ -497,7 +552,7 @@ class Vertex2(SmartObject):
status_started = None status_started = None
if status_started is not None and time.monotonic() - status_started > 5: if status_started is not None and time.monotonic() - status_started > 5:
raise OSError('Vertex2 TCP status response timed out') raise status_timeout_error()
except (OSError, ValueError) as error: except (OSError, ValueError) as error:
if interrupt_stop.is_set(): if interrupt_stop.is_set():
@@ -553,10 +608,11 @@ class Vertex2(SmartObject):
with processing_lock: with processing_lock:
self._command_processing_deadline = None self._command_processing_deadline = None
if self._command_processing_handle is not None: if self._command_processing_handle is not None:
self.cancel_timer(self._command_processing_handle) self.cancel_timer(self._command_processing_handle, silent=True)
self._command_processing_handle = None self._command_processing_handle = None
def terminate(self): def terminate(self):
self._cancel_input_5v_refresh()
if getattr(self, '_input_select_config', None): if getattr(self, '_input_select_config', None):
self._publish_input_select('/availability', 'offline') self._publish_input_select('/availability', 'offline')
self._stop_interrupt_listener() self._stop_interrupt_listener()
@@ -721,6 +777,7 @@ class Vertex2(SmartObject):
) )
def _suspend_vertex2(self): def _suspend_vertex2(self):
self._cancel_input_5v_refresh()
self._invalidate_input_5v_sensors() self._invalidate_input_5v_sensors()
self._reset_no_signal_spam() self._reset_no_signal_spam()
self._invalidate_plus5_sensors() self._invalidate_plus5_sensors()
@@ -735,7 +792,7 @@ class Vertex2(SmartObject):
with self._command_processing_lock: with self._command_processing_lock:
self._command_processing_deadline = None self._command_processing_deadline = None
if self._command_processing_handle is not None: if self._command_processing_handle is not None:
self.cancel_timer(self._command_processing_handle) self.cancel_timer(self._command_processing_handle, silent=True)
self._command_processing_handle = None self._command_processing_handle = None
if self.command_processing_sensor is not None: if self.command_processing_sensor is not None:
self.command_processing_sensor.set_state(state='off') self.command_processing_sensor.set_state(state='off')
@@ -767,7 +824,7 @@ class Vertex2(SmartObject):
if only_if_active and self._command_processing_deadline is None: if only_if_active and self._command_processing_deadline is None:
return return
if self._command_processing_handle is not None: if self._command_processing_handle is not None:
self.cancel_timer(self._command_processing_handle) self.cancel_timer(self._command_processing_handle, silent=True)
self._command_processing_deadline = time.monotonic() + self.command_processing_timeout self._command_processing_deadline = time.monotonic() + self.command_processing_timeout
if self.command_processing_sensor.get_state() != 'on': if self.command_processing_sensor.get_state() != 'on':
self.command_processing_sensor.set_state(state='on') self.command_processing_sensor.set_state(state='on')
+23
View File
@@ -0,0 +1,23 @@
--- 4.5.10/plugin_management.py
+++ installed/plugin_management.py
@@ -490,7 +490,7 @@
ns = cfg.namespace
self.AD.state.update_namespace_state(ns, state)
finally:
- await self.refresh_update_time(plugin)
+ await self.refresh_update_time(plugin.name)
def required_meta_check(self):
OK = True
--- 4.5.10/utils.py
+++ installed/utils.py
@@ -398,6 +398,9 @@
def deep_compare(check: dict, data: dict) -> bool:
"""Compares 2 nested dictionaries of values"""
data = data or {} # Replaces a None value with an empty dict
+
+ if check.keys() != data.keys():
+ return False
for k, v in tuple(check.items()):
if isinstance(v, dict) and isinstance(data[k], dict):
+319
View File
@@ -0,0 +1,319 @@
{
"reference": "https://github.com/AppDaemon/appdaemon/tree/4.5.10",
"date": "2026-11-04",
"allowed_differences": [
"plugin_management.py",
"utils.py"
],
"sha256": {
"__init__.py": "0007f5d9141995768e07f5ba472a22ee19ad571677b1da7ab836d7a6ba8c9237",
"__main__.py": "2a5ba7b454eb8c306969f43968bd6d8068debed593a5db6ceb63d991a354f84f",
"adapi.py": "711d1d0542f746efa5872afb6cf0a2512bfb3f328fcac35ee38c610590e24545",
"adbase.py": "f884423fe0c275d9fb30242f6065250357a329dcd3ee0d7aed51941af0fd2c31",
"admin.py": "2991ddc31cd64abde2002ef0d987aaab57a8795b970a7457d2c9c37e01839e25",
"admin_loop.py": "25419606baaf3763009005c1d276c5115bc16f86e6042ae8a8426de7c64617ad",
"app_management.py": "75bec7c0998409d03957ff68e57142530faacccbce50ad8d1589c451638ad7f0",
"appdaemon.py": "75649168292a643c73ead23f6fb06be63b75f3cf9bc46c49de129ddb5491ad44",
"callbacks.py": "b664da26c08870dbfdc1a974f403edaaa4eb49d71e9d2b4ee3f6ce795db112f0",
"dashboard.py": "ac07dfb7528f485c747acfc14c1fd403e4b6ca9c2e79046df6b799db46c82c3d",
"dependency.py": "19be55c1d2781879b756f48c29e03b38fbb6b5fa7978c5d8d85962f4758d690d",
"dependency_manager.py": "845eed4f87a14eb711267eab05201f5bf2162f33d7f67a9169e58703da830936",
"entity.py": "76d15e924d8e0454c2bf5f1e8141b91df2e2b89a23f328d00ad2e1361645601b",
"events.py": "fff272c0236153d4163b0aa5d7504791812910bb4fb8a6f44c680f6e543aee26",
"exceptions.py": "12802e1a95bec30179946cf0df4539f6d263d52ba49da82efadfd6a1454fec5d",
"futures.py": "04613f65789706db264fb609bc89b9e7750c814ba714a87e83a7f3d867b68860",
"http.py": "cfc62804e3588151072b154fc66d1419b1d9a00b4546f457d06155b84355aebe",
"logging.py": "cae00ddbcdb7d713fa41821264b3eddc9c13366d68ce8b072225e53d7ea53c04",
"plugin_management.py": "89529671cfd1c5b5589e107f82c4b62fa5d48eff9b63ae7058c2d3770c5472c4",
"pyversions.py": "accaca278ca62c7d23bf19fb7386e15b8ab700afe69aa61155af8b3c3a80f4fc",
"scheduler.py": "d268d1f940b42c8dc8a210d1b9a471b193c3172d6728fd01df185bfd199ed0c5",
"sequences.py": "6bd113404a26735ec4a3c636cb29b60e3520ae4e8ae8d285b15de80d6df231d4",
"services.py": "3ef4dc986fa5c4f7ed579b4e1ca205da152d3f310b5fd574db6663829acfd4e9",
"state.py": "b8f0c7fc1a70d214493f88d2867518d56d48b5a98ee2b439d2529673d1ba0fca",
"thread_async.py": "1e99604cd9ec804b8d5b9f4a3bb927b2e4dffa1532bc928bd93fd6c1b96629c0",
"threads.py": "bcdea68ed11b42027427c8706c848be25df0d34a5d2b6b6a09a4ce4fe08fd401",
"utility_loop.py": "9723a23f622c97366294093b47bcba93218fd558c04326d7dddb30135a8a978f",
"utils.py": "43fcd79677bdf1cf06e47af9226f761e9b559a2db8d51c0fc7af732be5ebe9c4",
"version.py": "94c208125a4f88f014e7a18aa906215396966ade72afa3219a70420d79f33bbe",
"aui/index-prod.html": "193e86be7182e43a0c58b5f28ac8fea01f606653649d7966069a757ae532fe3c",
"models/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"plugins/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"stream/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"stream/adstream.py": "cd3a2a85130e126199125d26385954770fa604a52c3b5a470954e0f3c7cb339d",
"stream/socketio_handler.py": "f75c8409580393de7ba95fc527c60ee011686903162e8da244a7ae9a54b250db",
"stream/sockjs_handler.py": "58f67242277118fc11419f0a3991ffe9165909f085e5a9cf71a260f3cab4b9e2",
"stream/ws_handler.py": "ba6dda951c7d1fbab30e63913953b2ef909124d97393c404c21ee0d245d6131b",
"widgets/AdminLog.yaml": "36f2f3a85bd4103e26f6932875dbdd3162689934e200a9860b5a091bc5710ad6",
"widgets/AdminSummary.yaml": "bd525c531791ba7a397969919d47e7443828545252afe3f9a9e7bd666b1f2f3f",
"widgets/AdminTable.yaml": "1966c04efb764cf069a99e11e4ef4e7c0b8183e6b975339f7591fd5adc47b07e",
"widgets/alarm.yaml": "6f020ffd36fec1e9a6a7615cb8a51429002bd4e58a192f135bdc19c2d8df7f99",
"widgets/binary_sensor.yaml": "85cf49c6edffb3ac4854418a6814a65c082e19b1dff520eab3a43ae1c1ea44cf",
"widgets/camera.yaml": "ccdcd7f79cc824c4ac064cd36d1a3bfa785d6a12d264bb81341d2c56db0126f6",
"widgets/climate.yaml": "cfa3058bb5b5e6f9cd0bdad26612b7e270fc6764724d84662de3321f6a8a192f",
"widgets/clock.yaml": "0f86aa3c509e415bc79077375994b04f596a3cd4dabb90c6e6b528cab13db17d",
"widgets/cover.yaml": "4a1cd1580e935967b4903781adacb455b66f93180724a2550ee9e148ee6faf06",
"widgets/device_tracker.yaml": "d6c98b6e619bc4d9766213fb5c674413f107face43791d39a16c38a1ba3a5990",
"widgets/entitypicture.yaml": "9fae3c21e00aa9942228aca9b19530a1557e61a93a66ba3dfbf2808d8beccfa6",
"widgets/fan.yaml": "efa6ac5ab0280f2a2d4f978147fd6a80f5c3eb642da559ca9f9c1e9c4838f839",
"widgets/gauge.yaml": "833f595c5ba050cc34a8c9e14493b1b7b42879c95a14bd6a43c02319625126fc",
"widgets/group.yaml": "0afd0d4783ee54faa720c7b2e912b0bc2caefab985a89eecd3740717e327673c",
"widgets/heater.yaml": "490b95f8efe42cfd29038cbf8716e9fa6be4de25f1def6785efc28f98d860346",
"widgets/icon.yaml": "5e8f5b67219d7a8b9f36c2c20919120e3acc97f26d6b7c305a612ee39f87b16e",
"widgets/iframe.yaml": "77496c4300b8ab442743d5e58d09747f76875af7c42572c6cbe3e7b38b53449e",
"widgets/input_boolean.yaml": "7de2bd30dcc6e69270523fda0084e2e8393d873890f2f01bf563c1f3aad533a0",
"widgets/input_datetime.yaml": "7eca2be9e6ffa20b3b5176c62e809f3d7249f263f12e9d3a86a6db1609e465ca",
"widgets/input_number.yaml": "33f1f127fa69cc2e8f9f0e1eed2402ac22191a3d92d0f1fe8a43c281fe6aa30a",
"widgets/input_select.yaml": "a35aa8abb82ffeaefe83f27ea030b16bfeda9a14b233464b10d9af75667ba22a",
"widgets/input_slider.yaml": "fb1555d441f17aecd7ba3da1d2564a3487984aad1d637acc3ff402921c0dacc6",
"widgets/input_text.yaml": "3a9e428ff148de795adeea7ce0584dbc5f400fc3920cf811ddc24a3e0f4c2773",
"widgets/javascript.yaml": "952656bf90bd5c8adbd7b7330a6a6f3171e524caebb56fae89b1b1927069620e",
"widgets/label.yaml": "6d5694f2f01f3575d3b59154a3c651a6776ac751b518ce5a14cb1343f96ddfb2",
"widgets/light.yaml": "a49d98e912c36bd3d68f7d3689bcf8a1407ae8d5aab8dbd49c0d6478c20da904",
"widgets/lock.yaml": "e64f8fc90574d41bc7c04998759097633a5c08d6a15d8bd12342762ece8450af",
"widgets/london_underground.yaml": "ddea7cd7eafb2bc52ef2f75fd451490354dfbd247dab50bdb5212b6fa33e0a55",
"widgets/media_player.yaml": "8442b43d2767c3b35a72d829a781ddff6e64d87a5c8b0cae309a9be1c2516549",
"widgets/mode.yaml": "0f2ddcdbe445f4506f4b01a88bec3b2cc3447fcee167dce93333f8fee13d7a95",
"widgets/navigate.yaml": "fe07aca210cdf6f1432f2e2e2f793a8dd4046a83c31a1c234b0b17f9e51c6717",
"widgets/person.yaml": "892dcf64b8c43a63911479f20f7c8a98e2ed21b4c194bc9cbcf77c9087104fa7",
"widgets/pirateweather.yaml": "408b853117ef2e243a4e75a5752c4bb58100f09b370c2bcf15321f47d653c8d0",
"widgets/radial.yaml": "aca17401122a83cadc9788781e4a4040d10568d0dde90bdfe4edadd199a2c1fd",
"widgets/reload.yaml": "8408a90810c8d0cd064cd5ae675b8a67aea580ccd6b39d89d246d2f93a93d1c6",
"widgets/rss.yaml": "431fe8c22619da3962180e492893773296c8d8b3e4cd7e0b166b06b21831f714",
"widgets/scene.yaml": "0f61a077590c1de7b8c8a5ddde0e4084df612fdffa14ade7241e377ef644f040",
"widgets/script.yaml": "6d77a900434bcf94badaa03e8d975f5339afcbcd5759bb8f1206e9df88447bf6",
"widgets/sensor.yaml": "4b07cab76dd222528a96c24e6d88e0d474119f614dd8fa17c9d781c1f01949cd",
"widgets/sequence.yaml": "cf950e34f974f08f9eefc1a32a88570738e2b6b6e5e4a017046c12a536937666",
"widgets/switch.yaml": "2ec1863654c1c0f19703024e3990f5b30aeff9f18ddb9cbb1048cab447a38e58",
"widgets/temperature.yaml": "dc7a5536bf964be529b086e62d6b9b9f3cd65b1550cb0fe600e1b4b61b478740",
"widgets/text_sensor.yaml": "dfe72ca03524cca5db086d453b1b72f5b26c65b36ee0c3a4bff322fcfefb4a43",
"widgets/weather.yaml": "d16f02fb76545811a34fd5cc8ef03569c68f8982d32af58efabd13cf23ac56dd",
"widgets/weather_summary.yaml": "ec52da9a3a2178336768375814edfd7cbca03bb8bcfe6eefab0224c806f064b4",
"assets/aui/appdaemon.png": "252fa8d5415ac605181056e813caf902e18e13b376b3b4a8262edce19c0d5554",
"assets/aui/favicon.ico": "3d3f93c1cb1128905183270584d523168fbd35e937db712e9f6930e94a7a2cad",
"assets/aui/index.html": "e51891677050ef1aac795d4e65dc8b5189732aedfef129515bc322f4271fc6e0",
"assets/css/all.css": "462fc2981ff1e4df0af8902d356dd65726082c9a43d93b912419ce36568901eb",
"assets/css/climacons-font.css": "8619c9c4ca2ad2eee221a0b6df87338c249485f7c12862d74bedab732743f203",
"assets/css/fontawesome.min.css": "39ee099e0a46898ee5af78f65c8754a951fc72cce5b6d31596104bb6b3874dc9",
"assets/css/fonts.css": "16dd24ed9fb06a48a4db43684f27f2cceb5805c5435cec8bc383d876109b7bfb",
"assets/css/jquery-ui.css": "a7ac54f58ba507b13621ceb6fcf5fe879f5ac9bdcf049d16153110c6ad048c7c",
"assets/css/jquery.gridster.css": "d059374489ef40c069a8da9b798fb8a1bc7aa08f1f40b94d5bd4aafe2f352e6f",
"assets/css/main.css": "257719cff84654dc49b4968dd4964bb8ea44e8396dc175a2b4444ff442fed42c",
"assets/css/materialdesignicons.min.css": "b73dc5385b050b7e8f3383bf7ac8c38e808330b9c8092df8987f7d55a63e528d",
"assets/css/non_dashboard.css": "3eb54b421644bc1a9dfdf9c02a0d844e23bd99ada840d2a37a7d3be5a399f3ee",
"assets/css/rickshaw.min.css": "87ed7cdada83fcbf84b99cf585bf04567b041afed53326ecd4ecb6d88fede1bb",
"assets/css/v4-shims.min.css": "c939af78a3deaf2987bb2b6403888932b15fe072a7a4befe4a4e235f61f09e11",
"assets/fonts/TickingTimebombBB.ttf": "2944c6e24e20d7eebf3b75c213e399c68e2cfa004984713229f4df17be154f5e",
"assets/fonts/climacons-webfont.eot": "00a0ac962bb138510c6164685b44eaff2f75645a99ad5cfe650a92bec05ded29",
"assets/fonts/climacons-webfont.svg": "bef3ab62931cc54c7fe1815103ee27020e05b54338f6d5dc428df456935eb7c9",
"assets/fonts/climacons-webfont.ttf": "e59c14a9d1b33874af4c93d1faf78eb57a006558b713c8031ac2f010572705d4",
"assets/fonts/climacons-webfont.woff": "7d3cffc3e2dc69ec1b687e77e0f1e152b5eff1db1494b9b8e8e0fb03b0f5cb37",
"assets/fonts/digital-7-mono.eot": "3eb301951faa2552498717d73157698c0a6eb9b3a8bd9bcf7fc98d72a8004004",
"assets/fonts/digital-7-mono.ttf": "58a6983851a3e3c433bbad4d9ac97b01ec204ee81fb29a51222a9286a45598c2",
"assets/fonts/materialdesignicons-webfont.eot": "0b183104d2fc5f26196e773c77bdaf2e05509fd4259d2d15ecedca79b87e84f9",
"assets/fonts/materialdesignicons-webfont.ttf": "61e8aba5a4e981fe22cf7c8e8bcdbea00476e75c62c37f01bf7ee33361d68428",
"assets/fonts/materialdesignicons-webfont.woff": "a5928a0d5c2f624e46f98d9b15c2f60045377f7c594dd78a1759132ea3b463eb",
"assets/fonts/materialdesignicons-webfont.woff2": "662fefa8f2f8a95c18588d21774789c107c64e771cbe65a69af46291c4311afc",
"assets/fonts/repetition.ttf": "69a9f0671b9e86bf52519ebe54cc6d47bda1cabb8e28226834a7b241a1a32fe8",
"assets/images/Blank.gif": "4e0705327480ad2323cb03d9c450ffcae4a98bf3a5382fa0c7882145ed620e49",
"assets/images/android-chrome-192x192.png": "252fa8d5415ac605181056e813caf902e18e13b376b3b4a8262edce19c0d5554",
"assets/images/android-chrome-512x512.png": "9ba4aeffee60a5e784e9300d4135dfdbc6a9edfe5529db7ea7702ce2a58715bb",
"assets/images/apple-touch-icon.png": "3f632ff46def9612f365fbe4de8ec444ef159b1a0ab2e2c3adf42df7664e090e",
"assets/images/browserconfig.xml": "c348b0d6df3d900efef7ae4b4dfc8b62b1737b04d09d4584fef243211c266866",
"assets/images/favicon-16x16.png": "70ef6d0a8ff6d3c2290b390d06e3fcb37d0326c80e0366e22dff6158090cd7da",
"assets/images/favicon-32x32.png": "0cd943b8fe52fab2d231aff19ac10891e6a371eb7b9f4e5512d3eeccf567e220",
"assets/images/favicon.ico": "3d3f93c1cb1128905183270584d523168fbd35e937db712e9f6930e94a7a2cad",
"assets/images/manifest.json": "a471cc0ded86fb614911a6a9bed67c931f197c34fcb327709229aa0832848876",
"assets/images/mstile-144x144.png": "014f3854abf436024bafdbcf82cdd87467300ba58fb82a7f4788df1ad29d9f39",
"assets/images/mstile-150x150.png": "bc21b7beb73d3fcf66b7bbd4e36f7a4c96713735d855e4db4941b5b329560284",
"assets/images/mstile-310x150.png": "c5b2fdfe4fb8e5161bc8c917f0c3feaa2ac552a0863721a833a219f20af4556d",
"assets/images/mstile-310x310.png": "90c6cf90a99e0e2a041bf7c1ba7654004c425e3fd049e7147aac7c12ab41c697",
"assets/images/mstile-70x70.png": "de9d1301957625cf9f81e05bf421aec79ad02472077b2306933ad2cbb5aeeab8",
"assets/javascript/admin.js": "b7fa92e27a55ad4e0cdee94815300e0f122af0e9a4a843462fbc1eb91d99626c",
"assets/javascript/d3.layout.min.js": "8f25034da0ee3457e8a9a5fad9507576357698965791b7439bd553b7aa3aad18",
"assets/javascript/d3.min.js": "4ec5104fe7c61d5b87b25346af8f4b834b1f6164b1101514c692cde21dc0f550",
"assets/javascript/dashboard.js": "993e2d307ff994be25811ec44445297257f8ef36f0ea3e04e21e06240cb55b96",
"assets/javascript/gauge.min.js": "9f9f43e8b5ffc5d660cd47d802c89f8521c02d98be195bfb0ea0179d2b332ad6",
"assets/javascript/jquery-3.1.1.min.js": "119bad7b860ec25a136da766c4061dfdd7ff645c3f49980b3e01d98d2d924472",
"assets/javascript/jquery-ui.js": "e0e1c1f83cae13611b8ab09cc5c6dcb73465071831501e48aff2c3d9d1f1b795",
"assets/javascript/jquery.gridster.js": "8042c3522b3bfad7f5a545fc2b469a9f8288c94cb99aade199812c0245a19429",
"assets/javascript/justgage.js": "b3ea81d552a1a2ce2561c555f2122c679268158a46dc70b8249d47fc45944dcb",
"assets/javascript/knockout-3.4.1.js": "3d7f7359568809409e92573359a4bd0cb05bf6e18dee90a4093d0acb3d5e951a",
"assets/javascript/list.min.js": "fe1e2e056d0070d6ae1c3de0fb3423c53f6334d8cfd3c33d96a3f012032f02d2",
"assets/javascript/plotly.min.js": "37bdb72e5e71e4c1e5e4709589fabe8eae5b203000d659c30af867260e62d8d8",
"assets/javascript/raphael-2.1.4.min.js": "181ac9fcdb0f5fdf94e248bcf2d4fcc082125b1c0afbc75ddfcbe850112e20d1",
"assets/javascript/reconnecting-websocket.min.js": "03827095c0efa8ee095e9bc4b6f598d511fc24010cbb95b6d703fc1945cb50db",
"assets/javascript/rickshaw.min.js": "59ac7a8608c17bd1141a1a7f668fffed8ba8864a2ad109e1231c6d8a9b71c83a",
"assets/javascript/socket.io.js": "9c104866f002c8679b0b000cc8af1d1cc270fee443acf3ed425c299809d75e31",
"assets/javascript/sockjs.min.js": "cf870a4f7c85f9a7c89fc7855c973e9d1a46b707263440a5bdffa2920b19711a",
"assets/javascript/stream.js": "76cd8fa3e7c12451672425485ef89ff3f66e1b1a3b24e9525b5e6143d67b87ab",
"assets/templates/admin.jinja2": "d1b0ce0a9659db62aa3f743290c46cc08f9f195b3875cfa219b83fb3daf78da6",
"assets/templates/body_include.jinja2": "43bea5403dcec932ad43c235317fe3f8af6294c0175d80a2f80a6b2c626b8f3b",
"assets/templates/dashboard.jinja2": "c1a3f2cecdcaa291c0560ca35215640a44cd9ccd03d81459cf5f00d29f71d1fc",
"assets/templates/dashinit.jinja2": "9e3cee77b52a8754fa442334f1edc9caddbb1b438bce461fffbeaf3b4ab01c00",
"assets/templates/error.jinja2": "be9d8dc22aeb9eb6d4e684aaefb5e206163587484e5cf2563a237df9dc504738",
"assets/templates/head_include.jinja2": "a2d438f6c51e1fae2c598eb6ebeb3f01b560b5ab34392fa16780bcec673b150e",
"assets/templates/list.jinja2": "3649fd6eb7e5818f5a1a70c8e4f4d315678104dc3932476cbdb815f0bf4c297c",
"assets/templates/logon.jinja2": "67f2ea7a4b9c89304091c46c1df189503a59211f5a691eda789f8de571327abe",
"assets/webfonts/fa-brands-400.eot": "5d68cb5fc50d0d366f0a4999778a2905303a5b1f53bb1e9ff679c6537d56156c",
"assets/webfonts/fa-brands-400.svg": "6b9a88c4426c61a0aa2d2a6be434bdf5b5df9bba493896a2ff19aed4e5b61d4b",
"assets/webfonts/fa-brands-400.ttf": "3b8b359a8de499509dff8e39478fe04fbf3ebe244254b108e66816f048d0ecf8",
"assets/webfonts/fa-brands-400.woff": "f21ddd7e9eb9033d530a62766fd05269b5f0c2b1c2d005b872decd00c71ea5ab",
"assets/webfonts/fa-brands-400.woff2": "8386356e1d512f570aac1b4883b928fe4bfd649c1d54a4d0f618357359cd3b74",
"assets/webfonts/fa-regular-400.eot": "44165afb8b1984ee93ab86f358ca0c00cdf95f57731e85c5edb6a0bb8ec673d7",
"assets/webfonts/fa-regular-400.svg": "aa106eda9929cea6c5fe38eae44daf75f957f3ceba120dd45d4ce5a358434bab",
"assets/webfonts/fa-regular-400.ttf": "25a854973a8a66612f9f80c4600b906d82dde6530c2a080342fc4e8bbf8b894b",
"assets/webfonts/fa-regular-400.woff": "28a52249e8a966d0ebc168bc983c138f30f1c1ab83926d342a05da36f61f2f2f",
"assets/webfonts/fa-regular-400.woff2": "4d64f142cf22d40343bbf54ee0e0ec48b7b2bc654b9be78517d5634207286524",
"assets/webfonts/fa-solid-900.eot": "d338e920e4771513976e2ba92d260e4b61522b3ba21486232dddf63ef02a7bd7",
"assets/webfonts/fa-solid-900.svg": "dd2440abba3f5060a9da4390e954df253762bbec62a1fd6c3fccad2cc060374d",
"assets/webfonts/fa-solid-900.ttf": "4892669c1d80a9e837be9edea4db711e42260eccc6933e16ee449bcaaafd7e78",
"assets/webfonts/fa-solid-900.woff": "b7e697ebc01ea8d96bcda256f6fe634afaf5b8f1bf98522c3d7263382ba5bfc7",
"assets/webfonts/fa-solid-900.woff2": "a5587a9dca3673b604a8a0e144d268f3dcb180aac337e2b2e163704bc1fc508a",
"assets/aui/css/app.css": "96ad563f162727c8f444e00033a754cd96a82d3480110e6916dd4e3952709ac5",
"assets/aui/css/chunk-vendors.css": "ebb321a0638f4d962d3e28c9a904fd12ee13a4b007b562fd1db952405a8f498d",
"assets/aui/js/app.js": "3e4ee235a31ace6caa8933a59d3ee893eae3bb095990f5a94620e71be4d0898b",
"assets/aui/js/app.js.map": "e4cbc2c757f2f887b4b6b699d26ff0942f75d00da370c930c687ff1f0c1ad5f3",
"assets/aui/js/chunk-vendors.js": "353ad1516dd3789b994b4afd36217129c9cbc77455c6117b3a378b4398aff14a",
"assets/aui/js/chunk-vendors.js.map": "90de6e8efce85cc2714f4d4f3b42bd7bea69a76dd9c24f4dac58121a2d6fa827",
"assets/css/default/dashboard.css": "1c91e4afc8bd102e12891f2bb1bae5acb1754bc1c88806d9129616d7b52ce770",
"assets/css/default/variables.yaml": "f509281ce4456d3e1f0aa72e751f3506fed29b68e00eebc82e5b08a28d10e2e2",
"assets/css/glassic/dashboard.css": "6cc29bfbb702571431e833bc37b8d66d94bc57be7ceecb6d36989076f12c5093",
"assets/css/glassic/variables.yaml": "9e67612ad0da0c207a58290781da106bd27801fa181a26a4cc656fecf0a88df2",
"assets/css/images/ui-icons_444444_256x240.png": "eaf7c7ee2747275dda6c53e731a10db1a7b15f4fbb46e1b69d6bb2056bc9fd81",
"assets/css/images/ui-icons_555555_256x240.png": "5d04051dfd9d2d740355404f98a683d1ec0feb2fca7d76e533c1a6e5ce92dd2e",
"assets/css/images/ui-icons_777620_256x240.png": "9dbe4a0d03feed6f65eb2560a0a8b4ba4264545ee8fd31c1763a3b219d0328d6",
"assets/css/images/ui-icons_777777_256x240.png": "e75b27211e16fcf94715168001bb7055ca376d46f928110ba3d0825232452e5a",
"assets/css/images/ui-icons_cc0000_256x240.png": "02891575d4358e9edde3e42d94057f8e9f82a9d64376f71ee86cef149d30537e",
"assets/css/images/ui-icons_ffffff_256x240.png": "b6b06deef2b924cc3835d63f4883d47882734a33e71b212d917a68cede3b8e9d",
"assets/css/obsidian/dashboard.css": "67f3cda7f1c7efab50357dad051c2c6e7c28d53d6ac91e55ca23e2230b5e8e2b",
"assets/css/obsidian/variables.yaml": "ae315b1199ee65a7f37aa85edaf5ab6e9c3b728de483c32da1430eeafbbbcfad",
"assets/css/simplyred/dashboard.css": "6d685d5c836b54edd552247a10019a0931f0b4ad78bbdb245afb6f3851fec127",
"assets/css/simplyred/variables.yaml": "cca292500a3dbaf20dfe69c02aa269feaf13408bfabe46196dc731622313c8cc",
"assets/css/zen/dashboard.css": "be0e085329c6def90175322acf2e0a7ae5750a0255ff60c5a3b4aedd93feba2f",
"assets/css/zen/variables.yaml": "034998cc51f6a29fc4ee4af9334be79001d8fe8ba1f1fb693206d6a7e8c706c3",
"assets/css/glassic/img/carbon1.jpg": "44adafb6b35b20588e272c5cd7397388e884172e887152b7363d481d14e214dc",
"assets/css/glassic/img/carbon2.jpg": "926471d8e113c031715a3d14fd69c603e0c8d7d247af15bd5c02727bbd134896",
"assets/css/glassic/img/carbon3.jpg": "4251d187f88f55d8536d0505ddfc28fb4f323ed27088db9f27b436c31dd2da3e",
"assets/css/glassic/img/carbon4.jpg": "c8a0df27c3d991c8b8807a1b8016cf57eac770d957385158daa9efb5865ddc09",
"assets/css/glassic/img/glassic_bg.jpg": "3e1c5e037b3ebad3115f75e6ad07991b5b4c5857b2b6292583deaf90332307d8",
"assets/css/obsidian/img/obsidian_bg.jpg": "b8e6314886c56128beb728abc7b7b23c3ea7bb99fda982ae4dcc76d688b8a207",
"assets/css/obsidian/img/obsidian_w_bg.jpg": "d480a4635ff86ebdc794bfbdf62e5bc0a48b3b3e4c28c82441cff84b0edc288c",
"assets/css/obsidian/img/obsidianbg.jpg": "b8e6314886c56128beb728abc7b7b23c3ea7bb99fda982ae4dcc76d688b8a207",
"assets/css/obsidian/img/widgetbg.jpg": "d480a4635ff86ebdc794bfbdf62e5bc0a48b3b3e4c28c82441cff84b0edc288c",
"assets/css/simplyred/img/goldgradient.png": "79bcabeb9e10fcb3eae0411feef3d28aee06f7f5836e8172724c2def7ab6e640",
"assets/css/simplyred/img/goldtexture.jpg": "80310ad4546ebdc45e17575bdc25c6c0775af68eb63b00ba88ea2077261ba3c0",
"assets/css/simplyred/js/timer.js": "aa441f5479b7d76c72eb679ad1b6186760db4e307ccdd3e276f7aabbea94c2bc",
"assets/css/zen/img/zen_bg.jpg": "6111faf3252c15a350511bc88a4ebaa1f0b9b18bc82e43f53ff9c0e94bc58609",
"assets/css/zen/img/zen_bg2.jpg": "a724c70447a0471384b11048ac2ed52cb84cfd099890db6542a395e9622982a2",
"assets/css/zen/img/zen_weatherbg.jpg": "83fde892f0fda16d788afd46ed94ffd62be6701c66aa3943c3cbbf972c48aa6a",
"models/config/__init__.py": "19d3ad9c40382cf38ac6dde5ab88bbf3f3db5645edb5bdb1ff008ba8439fced9",
"models/config/api.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"models/config/app.py": "4d41c603190784d70d62614e3e5a25d922e2bd1c2f9a3ec2d0288e35e36ca05a",
"models/config/appdaemon.py": "320c19ea8276537581e61efadbfe64a510bc00823e3d396b81e24db7b3dd467d",
"models/config/common.py": "273b27bb751ac4d364cd5ace5b55b521142639b4a1515d75b784d81a6e8ca773",
"models/config/dashboard.py": "f1fc4927af62c7efb4bb348e4691a145ee7b50bd4afafff7cd9ca7ead96ae890",
"models/config/http.py": "a73a8429a232057a928cf8785d9376882a6c3a8ed4e04bb220fd009bb4ab512d",
"models/config/log.py": "7c043fc01ef5c755ff0d53b83ef27449523e22320addf068de12d4dfd059feaf",
"models/config/misc.py": "9e97bf8937677cfd9f74ed865e8bb834902e57aa13d8afa776ffd2bcda2be04c",
"models/config/plugin.py": "0d8076c34eae84e87f17c1cc741f9cdd38d660054d5b621f3e1446c2095b4151",
"models/config/sequence.py": "b98e3f14e0e971d52c2f3093c5b193b33f162d50e53cec3aa6f9f2d10a38dd1a",
"models/config/yaml.py": "b733c6b180108eaf1394d500505d240d6015042c15034310800afb2ab3fe8917",
"models/internal/__init__.py": "025052dfa21c98592410f3cbf77250c92618bfc9ea3f218caf530da6a92d544e",
"models/internal/app_management.py": "943b29ead0e04578463e1a4169dd99f7d07a926b1e25b7cec2a6ab53ae554da6",
"models/internal/file_check.py": "cc75fe5956a990da8675e117223615094ec1d6a34c769fe49d9a4edb5801b5b4",
"models/internal/plugin.py": "c5bb9956ed86e298524846450c5d1750746294ff18a92bd22672c347bd064510",
"models/internal/state.py": "152f40cdbed02ad2e7c430ed42086b35f3a9414b6392940c7c2127c2d7fff2f2",
"models/internal/threading.py": "38941ea25c9dcf2900c4c6395d1401015eb35de67fbf8999cdf21f4e144deb3b",
"models/notification/__init__.py": "1432554b95229c2a338bb0ece10ea141068df573bbbfa43adde2e3776257aea8",
"models/notification/android.py": "62d118cfe4c6fa50d685a0b160ab03301badc469019fb9c9d36ce733dbe4dd2d",
"models/notification/base.py": "8d05d671e2e893d78dad4f0c053f3d232861ac6e141195e2a60937fdbd80c2f8",
"models/notification/iOS.py": "61c756b984a0a26b28a771df220e368b8414f6efa5bb2733ff91b975f0bb3c91",
"plugins/dummy/dummyapi.py": "a92cf5e6cdc4b671e5fe040d75a077b9a2adc728f8d474cb55e8bbd66689340c",
"plugins/dummy/dummyplugin.py": "f448378c5aa59e7a82d1f205874c1cd347981f23d68daf822c3f9243aed8846b",
"plugins/hass/__init__.py": "7552f2a227213504a064605ce7e280c4bec244b218dd4dfad565b3f059233d13",
"plugins/hass/exceptions.py": "ec057567ac3fe16c57452cf3e5ce3992f4535e3eea40442db7c4ecfd251d8a16",
"plugins/hass/hassapi.py": "3bbca695ca78e055828f1b96d887d8ae017d12af499055cebf456e3fe73d34e3",
"plugins/hass/hassplugin.py": "0aab3aaa8e0f879904270152aac3c361dd86c6a494ed479bccfcdd8f74ad10fe",
"plugins/hass/models.py": "889178c31d3dc612f1344812a9d1248147e2c1ce45b967fa304911a82e277607",
"plugins/hass/notifications.py": "efd661d918294eb03213bac8d8fa674a006294dfe54b46ce4352368006ac2dfa",
"plugins/hass/utils.py": "c6f1d1fa0d53b1c2d858e50c4a0180cfdef9d1d2220fa30020a8840bd1b781f0",
"plugins/mqtt/__init__.py": "df5ae958839fdce576295b8668be07c5f97aac8081aefb292694ad3b562ae3a2",
"plugins/mqtt/mqttapi.py": "dda16cda56235564ce8af70334d403ce2a0f0059fda9439d9297d6f704a676b2",
"plugins/mqtt/mqttplugin.py": "da6216827adfb449d033137a30333028e4c00f4864d67d9951a4bf13d46cdf02",
"widgets/basealarm/basealarm.css": "2a1e61c42ebd49c915c539b922c16b3eb4ad3cf6eddda1776e344a04845b4d74",
"widgets/basealarm/basealarm.html": "7b9d436f4f05c7bb731b32df485ef2b127e5da6f3750aa037c745687d45816d9",
"widgets/basealarm/basealarm.js": "ebff1ed1de3a4db2eab28ce81509cd279a061450ebe5a8a92edbf361b36d3305",
"widgets/basecamera/basecamera.css": "06746a98f7098e2194084285d204c4ca21e1ea314a5b0b2cb7986004c9c9c34a",
"widgets/basecamera/basecamera.html": "d14808f4d3a9bf8785fced9992269ee82841421bc5f9b4df73990206136cb6f4",
"widgets/basecamera/basecamera.js": "6f81b1ea8faaefafcf30aba159308a844d1040bd730f4796c5cc7ce980a990e8",
"widgets/baseclimate/baseclimate.css": "c8fc911e355e83cd0d9e8ae9dabb36414cd71812d1cfcfdca5f4709ce1b1f559",
"widgets/baseclimate/baseclimate.html": "1e4083b2b92d8f141fa7c5a6bf3960305ffda8f77a2e7c97d9c17ae5b6489d5c",
"widgets/baseclimate/baseclimate.js": "5186e853da9e58b4882d82cc9b5356a60f31dc799a786f5ccba41ff2e5eed671",
"widgets/baseclock/baseclock.css": "f81cbca78857941b31923b21f68d8bc0d7a03011ac7d88862463b7b79c59dcb8",
"widgets/baseclock/baseclock.html": "9f988da94522d7440101ec045f2779fd946c2ce3b30a02239f67e2d8e8cb4dbb",
"widgets/baseclock/baseclock.js": "a7fbc2c815da0f17b02bb39430e88b07fa1ac5861cd4305f660b071a426d2a73",
"widgets/basedatetime/basedatetime.css": "b411d3f21af285092826029b5147cfcb6f4b046dee9162392922c9094fc254f7",
"widgets/basedatetime/basedatetime.html": "fba7f69c1446852c83af18102e938ec3b520ebb1f9672f125fd369d46c3bb337",
"widgets/basedatetime/basedatetime.js": "093ca34c8a464b7c1cad15bccfc2e4f564bd7e8335f941d559d8435c46e2a61f",
"widgets/basedisplay/basedisplay.css": "4ac1bdc04804b9fd76c68b807bc486555f437d0657d0ea5f57578d88be02dbc8",
"widgets/basedisplay/basedisplay.html": "2446e4bffdf9bbd946b8c8ab33f453848e1e92eed6ecdec283702ae55929b9cf",
"widgets/basedisplay/basedisplay.js": "990ef146432d26cc03e1a6ab8b25e88aba6f8820125cabcb84a5cec72e5d5cc8",
"widgets/baseentitypicture/baseentitypicture.css": "7930f8168dab7133a5831f34478f046f6649e9ca102edea29e0d3746c7250110",
"widgets/baseentitypicture/baseentitypicture.html": "ebac4368b209ebe733abca60d3d2da00d2cd515f82b79f16023247b73005d46c",
"widgets/baseentitypicture/baseentitypicture.js": "85981dbcc600e697547e78d76d06d886c379e461144b76e0b89ff941b121ba6a",
"widgets/baseerror/baseerror.css": "f997c5a1914c0bbebbcc12ba417b49cf857271d95df2e5cb4a7b080bbcbb0fc3",
"widgets/baseerror/baseerror.html": "549c6cfbeed80a60fc2309aa2c5ffde1ffc800b69027349a3e9403f1b29ed797",
"widgets/baseerror/baseerror.js": "ab33ee3b901f12bacf2aa13e0ab5d0ff6a975d3ffb40f3eaabf8c6e7e168d30f",
"widgets/basefan/basefan.css": "2e08ac65108cea6fa8651d0f7a0e2204e7214535c3aa85015e3a0625614da1cb",
"widgets/basefan/basefan.html": "aa0e34d7f9bcaac0f77616dda9640f452b1aea702d09aecf8e14ad657d15c2a1",
"widgets/basefan/basefan.js": "79184e6ca836325d4e2d704d45538ef90746ea701ffa7c153d99590ffb30dcfd",
"widgets/basegauge/basegauge.css": "ae2fdb3e66df2c586d187079bd83bca52bbda617003452c91ebd7e70c582ccbe",
"widgets/basegauge/basegauge.html": "6fd7ae8f200b6fff8699ef92912d5ba9688ec4c5e074c12883ab0d069c8a1a3b",
"widgets/basegauge/basegauge.js": "77d7e17d4f7e0fb138f17a52b90e5c08caf7e6ee58e944dffc2d62faccb666b0",
"widgets/baseheater/baseheater.css": "66e042528095583f6661fdf576b42e699f5f28e679281c67313d09feb8d745ca",
"widgets/baseheater/baseheater.html": "a64b2c49bc680b378402e55b2744a19387bf18705005fb7646c7b5937fb2387b",
"widgets/baseheater/baseheater.js": "0d2397338225cec9fa62bbb8c0f2c1b43ef004dba262af921b5ef11e58fbdfdb",
"widgets/baseicon/baseicon.css": "f4fcb35555a1eb66b88feed361bb4ede17c8dbe9fe0c2bde22d53d7afd26b2ec",
"widgets/baseicon/baseicon.html": "f6a2895ed41ea25df2aebb96c1215d7851e7bd13521cfd0f97cc28533274daca",
"widgets/baseicon/baseicon.js": "8f8c342717ee7bbac51e0659d1ee19e22892a033b2b65270d10a77749d88f779",
"widgets/baseiframe/baseiframe.css": "16580cf89459eb3b1e7660068d3c430bf5350b849507c15d1955fa150f625849",
"widgets/baseiframe/baseiframe.html": "18046647c99af56e6fde5b1b0aec0455beda89d08963f1636fba159ad92645f6",
"widgets/baseiframe/baseiframe.js": "74c5429a066ea1b15183ebd54fbb05c753dbd0d82a2c14fcd2fc9764a4986efb",
"widgets/baseinputnumber/baseinputnumber.css": "d55f605297675ba6404a966b16a9c01177a5e1be031e6e7497f2c42e9e5c8227",
"widgets/baseinputnumber/baseinputnumber.html": "38456d5bc91bba7ed6ccb9b92852c78013b92f21131e2ce1ed7ebb468ad59ba4",
"widgets/baseinputnumber/baseinputnumber.js": "7259915d555b87dc584176d824fed2d8c1d595ea762bf19ad34c6cbbb475cba9",
"widgets/basejavascript/basejavascript.css": "d17d4d54287f06de9369ac95bcaeac8de4cdefcf42f554a0cef3ded4eb97f58e",
"widgets/basejavascript/basejavascript.html": "b7f1d44e7e3fdc89ad5f3792c9ebae0155c6dde63e861e93fd392a2dc2222e0b",
"widgets/basejavascript/basejavascript.js": "99622aec3748b20970e0a83ae85e6c6b1407ef624bfae277baa2fa3974326a70",
"widgets/baselight/baselight.css": "a1b954ef193460cfd969292fac6d4f556c3e53867914730c88be01d0430f5d96",
"widgets/baselight/baselight.html": "a1baccd66519491c388d63bc0846e4ae35d6319affdbf364564bbc4e3b3f946f",
"widgets/baselight/baselight.js": "45e3ef822d398bbbb6aee90d298e0a4130473b3cb7302861ad0f1a4f137ac20f",
"widgets/basemedia/basemedia.css": "ec65b602cf58cdc1f01eb8c948356030307d789059dd1479673dc47f5cbcc665",
"widgets/basemedia/basemedia.html": "e61e2076fc34c1af20348c91e479b95c240ae024100a8ab13b261ffebd58e889",
"widgets/basemedia/basemedia.js": "c185da4d1debcfa72ef7876654aebb68a848bbb7ebb9d76ff5be863cfc087861",
"widgets/baseradial/baseradial.css": "c1e4406adf0147bc23858297c5c574a71da5c81e6e16fef84674f2d4ba373e0a",
"widgets/baseradial/baseradial.html": "5f7d96e1ba751be912657ef6d07660960d9bb3547b8041875944a63b04c4b1f3",
"widgets/baseradial/baseradial.js": "b2c30ec3c4cb72b8d7014abf22cab4bf58698c61a94b174428f712544800484f",
"widgets/baserss/baserss.css": "a7769b2363980bd935dc7975e37f1427311827803517cffd26969be0c06a1a87",
"widgets/baserss/baserss.html": "7e93dd7c6d7ade8fee6a56bd259de2fc66f591c7cbff20fafc9923f1fb31fa86",
"widgets/baserss/baserss.js": "adf3c55c6c8d7dc5ca5b777cb868bf102e33bb1d8683edcc695f0102f1d1bca0",
"widgets/baseselect/baseselect.css": "ef67b8844652931f6629a90cef7dd65a78b30dd78a86c169fa41eeced419705d",
"widgets/baseselect/baseselect.html": "67abc1bf525e59279f3b6ffad5ce3c35675d40446e30f341dfd68e5dc7a59420",
"widgets/baseselect/baseselect.js": "105ca03ee639477e56f906532d22cb652a6b0eef7448f34f8ed49dbe2ebdfaeb",
"widgets/baseslider/baseslider.css": "55b64db166666b3983fe600293882195ae911c3aa2ec2934180d6357bb30f4d0",
"widgets/baseslider/baseslider.html": "a463c6178c22f578bc211abba6df0c60eaf955166fb6d61c5d4efbdc989a8e53",
"widgets/baseslider/baseslider.js": "5126a3f32728f5b35bb5553e2c692054341bdbb8394953a38b4041dc1cb9975b",
"widgets/baseswitch/baseswitch.css": "b355c6bbc52db5cd923b53a9268f9fbf6157a5432c032a4b94369502b296cb75",
"widgets/baseswitch/baseswitch.html": "00cc2c9fc2dfa2299225e06c6f50bb13545b1f9237081862af6d51fc96160648",
"widgets/baseswitch/baseswitch.js": "cad8aa10d86683e7274b6da074be15a148697d95f3b15cd1ee2ccc3919cf8b35",
"widgets/basetemperature/basetemperature.css": "c59246676b8d6b0758ff67e008a18bdbc6d4debb8c363ddd8bf941b3fb0489a8",
"widgets/basetemperature/basetemperature.html": "5f7d96e1ba751be912657ef6d07660960d9bb3547b8041875944a63b04c4b1f3",
"widgets/basetemperature/basetemperature.js": "1b8db4c0b483fe06878a3ee147fa97bdccdfd58a7876bf7073663ad8cf4c97a5",
"widgets/basetext/basetext.css": "46c8bf64f98c4d34fc0baba4569a02b93ba5d1121654f007e7fa64c6a34b7356",
"widgets/basetext/basetext.html": "daf7d6c4de3097131704122a701fb789e59b56a314b3f8626fc3714020972ec9",
"widgets/basetext/basetext.js": "36a276e65536823bfe8f0099c48817a738ca6fd6557ecb1a75ec7fa45aed4d77",
"widgets/baseweather/baseweather.css": "7dd09c754a150d31513e35d4919dccdb62df276a14df254f7170fba78cc41b6d",
"widgets/baseweather/baseweather.html": "90b4efd2283938e5cc64817e3ebf736f9d7f4e7a74fe9d91b2d9f4925bab8eb9",
"widgets/baseweather/baseweather.js": "67acac0fa64944442aea0c9f776d67c1d4f49974f6d855068d8545142f76bea1"
}
}
+83
View File
@@ -0,0 +1,83 @@
# Modifications locales AppDaemon — état au 4 novembre 2026
Toute l’instrumentation des utility loops a été retirée sur disque : chronométrages,
profils de piles, décorateurs et messages de diagnostic. Le module ajouté
`_utility_diagnostics.py` et son cache ont été supprimés.
## Seuls correctifs conservés dans le core
- `plugin_management.py` : `refresh_update_time(plugin.name)`, décrit dans
[APPDAEMON_BANDWIDTH_BUG_FIX.md](../APPDAEMON_BANDWIDTH_BUG_FIX.md).
- `utils.py` : comparaison des ensembles de clés dans `deep_compare`, décrite dans
[APPDAEMON_RELOAD_BUG_FIX.md](../APPDAEMON_RELOAD_BUG_FIX.md).
Comparaison de **309 fichiers** au tag officiel
[4.5.10](https://github.com/AppDaemon/appdaemon/tree/4.5.10) : tous sont identiques
à l’octet près, sauf ces deux modifications exactes. Aucun fichier source supplémentaire.
Les caches Python générés sont exclus de cette comparaison.
Le fichier statique `aui/index-prod.html`, absent du paquet installé mais présent
au tag, a également été remis depuis ce tag pour respecter cette référence.
- [Diff intégral restant](appdaemon-core-4.5.10.diff)
- [Empreintes SHA-256 des fichiers vérifiés](appdaemon-core-audit.json)
## Optimisation applicative conservée
`apps/ad_toolbox/logger_interface.py` conserve le raccourci évitant l’inspection
inutile des piles pour les messages ordinaires. `adapi.py` est identique au tag.
Les sept tests `test_logger_interface_fast_path.py` passent après restauration.
Les onze vérifications de `deep_compare` du document du correctif passent aussi.
## Archive et retour arrière
Les anciens scripts, tests spécifiques au diagnostic, sauvegardes et documents
historiques sont conservés dans
`tools/archive/appdaemon-diagnostics-retired-20261104.tar.gz`.
Ils ne sont plus installés ni collectés par la suite de tests.
Les deux fichiers `.bak` du paquet ont été déplacés dans cette archive
(sous `site-packages-backups/`). Les anciens chemins de sauvegarde décrits dans
les documents historiques ne sont donc plus actifs.
**Un redémarrage AppDaemon reste nécessaire** pour décharger le code instrumenté
déjà en mémoire. Le service n’a pas été redémarré durant cette intervention.
Les réglages applicatifs et les exclusions de dossiers existantes n’ont pas été modifiés.
## Réapplication après rollback — 8 novembre 2026
Version vérifiée : **4.5.10**, avec `/usr/local/bin/python3` et le paquet
`/usr/local/lib/python3.12/site-packages/appdaemon`. PID 1 :
`python3 -m appdaemon -c /conf`.
Les trois changements absents ont été réappliqués :
- `plugin_management.py` : `refresh_update_time(plugin.name)`.
- `utils.py` : comparaison des clés au début de `deep_compare`.
- `/usr/src/app/dockerStart.sh` : un seul appel à
`sh "$CONF/archiveLogs.sh"`, avant le lancement AppDaemon.
Sauvegardes originales et manifeste (chemins absolus, version, SHA-256) :
`/conf/tools/backups/appdaemon-4.5.10-20261108T094103312482Z/manifest.json`.
Le lien `/conf/dockerStart.sh` pointe toujours vers le script ci-dessus.
Le raccourci applicatif de `LoggerInterface` était déjà présent et a été conservé.
Aucun changement aux apps, YAML, exclusions ou à `archiveLogs.sh`.
Validations exécutées : 11 cas `deep_compare` réussis, syntaxe des deux fichiers
Python valide, correctif HASS vérifié, syntaxe shell des deux scripts valide,
un seul appel d'archivage placé avant l'exec. Les 7 tests
`test_logger_interface_fast_path.py` passent, sans tests ignorés.
Sur 5 tests `test_logger_interface_grouping.py`, 2 passent et 3 échouent
sur les libellés/groupes (`test_log_group_takes_priority_over_condition_name`,
`test_smart_object_groups_its_own_lifecycle`,
`test_smartcondition_uses_smartobject_api_scope_without_logger_interface_argument`).
Le code applicatif concerné n'a pas été modifié pendant cette intervention.
**Redémarrage non effectué** : le service est PID 1 du conteneur, sans CLI Docker,
socket Docker ou superviseur disponible. Redémarrer le conteneur existant depuis
son gestionnaire habituel pour charger les correctifs Python et exécuter
l'archivage. Une recréation depuis l'image d'origine effacerait ces patchs.
Ne pas lancer le script de démarrage ou d'archivage dans le service actif.
Après redémarrage, restent à vérifier : maintien des patchs, démarrage et connexion
HASS, archivage et suivi mémoire unique, puis absence de rafraîchissements HASS
répétés après le délai configuré. Aucun résultat de validation du service
redémarré n'est affirmé ici. L'audit intégral et le diff historiques ci-dessus
n'ont pas été régénérés pour cette intervention.
@@ -0,0 +1,274 @@
# Instructions après mise à jour ou rollback AppDaemon
Préparé le 8 novembre 2026 pour cette installation `/conf`.
À suivre par Codex après installation de **4.5.14**, ou retour en **4.5.10**.
Ce document prépare l'intervention : sa rédaction n'a appliqué aucun patch ni redémarré le service.
## 1. Identifier l'installation avant toute modification
Lire ce document et `docs/appdaemon-local-changes.md`. Conserver les modifications
applicatives existantes, les configurations YAML et les exclusions de dossiers.
Ne pas restaurer les anciennes instrumentations des utility loops : elles ont été
retirées volontairement et archivées dans `tools/archive/`.
Exécuter dans le conteneur, avec le Python réellement utilisé par AppDaemon :
```sh
python3 - <<'PY'
import inspect, sys
from importlib.metadata import version
import appdaemon
from appdaemon.utils import deep_compare
print('Python :', sys.executable)
print('Version :', version('appdaemon'))
print('Paquet :', appdaemon.__file__)
print('deep_compare :', inspect.getsourcefile(deep_compare))
print(inspect.getsource(deep_compare))
PY
```
Si le processus utilise un autre environnement, employer son interpréteur pour
les inspections, modifications et tests. Ne pas déduire la version du seul tag
Docker. Vérifier aussi l'Entrypoint/Cmd du conteneur et le script effectivement
exécuté ; une image personnalisée peut différer de l'image officielle.
Pour une autre version que 4.5.10 ou 4.5.14, refaire la comparaison avec ses sources.
Avant chaque édition, sauvegarder le fichier original dans un dossier daté sous
`/conf/tools/backups/appdaemon-<version>-<date>/`, en notant son chemin absolu,
sa version et son empreinte SHA-256. Ne pas écraser une sauvegarde précédente.
N'appliquer chaque changement qu'une fois ; si déjà présent, le vérifier.
Ne jamais remplacer un fichier 4.5.14 par une sauvegarde 4.5.10.
## 2. Tableau des changements
| Élément | 4.5.10 (rollback) | 4.5.14 |
|---|---|---|
| Timestamp de rafraîchissement HASS | Appliquer le patch `plugin.name` | Déjà intégré ; vérifier seulement |
| Comparaison des clés dans `deep_compare` | Appliquer dans `utils.py` | Appliquer dans `utils/misc.py` |
| Optimisation des logs ordinaires | Conserver le raccourci de `LoggerInterface` | Optimisation intégrée au core ; conserver le wrapper existant si compatible |
| Archivage au démarrage Docker | Ajouter l'appel dans `dockerStart.sh` | Ajouter l'appel dans `/start.sh` pour l'image officielle |
## 3. Patch HASS : 4.5.10 seulement
Dans le paquet installé, fichier `plugin_management.py`, méthode
`update_plugin_state()`, remplacer uniquement cet appel dans le `finally` :
```diff
- await self.refresh_update_time(plugin)
+ await self.refresh_update_time(plugin.name)
```
Cela évite de redemander l'état complet de Home Assistant chaque seconde après
expiration du délai de rafraîchissement (10 minutes par défaut).
Le correctif est intégré depuis 4.5.12 et confirmé dans le tag 4.5.14.
En 4.5.14, contrôler la ligne, sans ajouter un second patch.
Référence locale : `APPDAEMON_BANDWIDTH_BUG_FIX.md`.
## 4. Patch deep_compare : les deux versions
Localiser le fichier avec `inspect.getsourcefile(deep_compare)` :
- 4.5.10 : `appdaemon/utils.py`.
- 4.5.14 : `appdaemon/utils/misc.py` (le module a été réorganisé).
Dans `deep_compare()`, ajouter immédiatement après `data = data or {}` :
```python
if check.keys() != data.keys():
return False
```
Conserver tout le reste de la fonction propre à la version installée.
En particulier, garder `current = data.get(k)` en 4.5.14.
Le début de fonction doit donc ressembler à ceci :
```python
def deep_compare(check: dict, data: dict) -> bool:
"""Compares 2 nested dictionaries of values"""
data = data or {}
if check.keys() != data.keys():
return False
# Suite existante, différente selon la version : ne pas la remplacer.
```
La 4.5.14 évite le KeyError à l'ajout d'une clé, mais ne détecte toujours pas
certaines suppressions : la fonction officielle renvoie `True` pour
`deep_compare({}, {'input_selection': {}})`. Le patch corrige aussi les
suppressions imbriquées et distingue une clé absente d'une valeur `None`.
Vérifier dans un nouveau processus Python après modification :
```sh
python3 - <<'PY'
from appdaemon.utils import deep_compare
assert deep_compare({'input_selection': {}}, {}) is False
assert deep_compare({}, {'input_selection': {}}) is False
assert deep_compare({'x': {'a': 1}}, {'x': {}}) is False
assert deep_compare({'x': {}}, {'x': {'a': 1}}) is False
assert deep_compare({'x': None}, {}) is False
assert deep_compare({'x': [1, 2]}, {'x': [1, 2]}) is True
assert deep_compare({'x': [1, 2]}, {'x': [2, 1]}) is False
assert deep_compare({'x': {'a': 1}}, {'x': {'a': 1}}) is True
assert deep_compare({'x': {}}, {'x': None}) is False
assert deep_compare({}, None) is True
assert deep_compare({'input_selection': {}}, None) is False
print('OK : 11 vérifications deep_compare')
PY
```
Référence locale : `APPDAEMON_RELOAD_BUG_FIX.md`. Ses anciens chemins de
sauvegarde `.bak` ne sont plus actifs ; utiliser la sauvegarde de cette intervention.
## 5. LoggerInterface : conserver les fonctionnalités locales
Fichier persistant : `apps/ad_toolbox/logger_interface.py`, méthode `_write_log()`.
En **4.5.10**, conserver le raccourci existant : pour un message texte sans
`__module__`, `__line__` ou `__function__`, et avec les méthodes standard ADAPI,
appeler `api._log()` sans passer par l'inspection de pile. Garder le repli vers
`api.log()` pour les substitutions et les méthodes personnalisées. Préserver les
options d'encodage, le routage des logs et les groupes de messages.
Ne pas ajouter un autre patch dans `adapi.py` : il était volontairement revenu
au code officiel 4.5.10.
En **4.5.14**, `_sub_stack()` contient déjà le raccourci évitant `inspect.stack()`
sans marqueurs. Aucun patch core de logging n'est nécessaire. Conserver le
wrapper applicatif et ses fonctionnalités ; la suppression du raccourci local
redondant est facultative, pas un prérequis de migration. Tester sa compatibilité.
Tests existants :
```sh
python3 -m unittest discover -s /conf/apps/ad_toolbox/unit_tests -p 'test_logger_interface_fast_path.py' -v
python3 -m unittest discover -s /conf/apps/ad_toolbox/unit_tests -p 'test_logger_interface_grouping.py' -v
```
**Adaptation de test nécessaire en 4.5.14 :**
`test_direct_log_is_not_modified` attend actuellement `stack.assert_called_once()`
pour `api.log('Direct log')`. Cette attente correspond à 4.5.10. Pour 4.5.14,
attendre `stack.assert_not_called()`. Si le test doit couvrir les deux versions,
choisir explicitement l'attente selon la version installée, en conservant
l'assertion d'identité de `_sub_stack`. Ne pas modifier le core pour satisfaire
l'ancienne attente. Vérifier que les tests d'intégration ne sont pas ignorés
faute d'import AppDaemon.
## 6. Patch dockerStart / start.sh : les deux versions
### Comportement local à préserver
Avant le lancement d'AppDaemon, appeler une seule fois :
```sh
sh "$CONF/archiveLogs.sh"
```
Ce script persistant archive les anciens `*.log*` et `memory_usage.csv` dans
`/conf/logs/archive/<horodatage>/`, puis lance le suivi mémoire en arrière-plan.
Conserver le contenu actuel de `/conf/archiveLogs.sh`.
Ne pas lancer le script manuellement pendant qu'AppDaemon écrit ses logs pour
le « tester » : il déplacerait les fichiers ouverts. Ne pas lancer le script
de démarrage complet dans le conteneur déjà actif : cela lancerait un second AppDaemon.
### 4.5.10
Le script observé est `/usr/src/app/dockerStart.sh`.
`/conf/dockerStart.sh` est un **lien symbolique** vers ce fichier, pas une copie
persistante du patch. Après recréation du conteneur, la cible peut être remplacée.
Ajouter l'appel après les installations de dépendances, juste avant le lancement :
```sh
sh "$CONF/archiveLogs.sh"
# Lets run it!
exec python3 -m appdaemon -c $CONF "$@"
```
### 4.5.14 : changement de point d'entrée confirmé dans le tag officiel
Le Dockerfile copie `scripts/start.sh` vers **`/start.sh`** et définit
`ENTRYPOINT ["/start.sh"]`. Il n'utilise plus `dockerStart.sh`.
Dans cette image, patcher `/start.sh`, pas l'ancien lien `/conf/dockerStart.sh`.
Ajouter le même appel après l'installation des dépendances et avant l'exec :
```sh
sh "$CONF/archiveLogs.sh"
# Lets run it!
exec uv run --frozen appdaemon -c $CONF "$@"
```
Conserver le lancement `uv`, les installations via `uv pip`, le `PYTHONPATH`
et le répertoire de configuration exemple `/opt/conf` propres à cette version.
Ne pas recopier l'ancien script 4.5.10 par-dessus `/start.sh`.
Si l'image déployée possède un autre point d'entrée, transposer cet appel dans
le script réellement utilisé et noter le chemin dans le compte rendu.
L'ancien lien peut rester inutilisé ; ne pas le prendre comme preuve du patch.
### Vérification sans exécution
```sh
sh -n /conf/archiveLogs.sh
# Choisir le script réellement utilisé :
sh -n /usr/src/app/dockerStart.sh # 4.5.10
# OU
sh -n /start.sh # 4.5.14 officielle
```
Inspecter le script : exactement un appel actif à `archiveLogs.sh`, avant l'exec.
Ces modifications hors `/conf` sont perdues lors d'une recréation du conteneur,
comme les patchs Python. Les réappliquer à chaque nouvelle image, ou les intégrer
explicitement à une image dérivée si cela fait partie de l'intervention demandée.
## 7. Redémarrage et validation finale
Après application et vérifications, redémarrer le conteneur selon le mécanisme
habituel de cette installation, dans le périmètre de l'intervention demandée.
Un simple rechargement d'app ne remplace pas les fonctions core déjà importées
et ne réexécute pas le script de démarrage.
Vérifier après redémarrage :
1. Version réellement lancée et absence d'erreurs de démarrage/import.
2. Patch `deep_compare` toujours présent et 11 vérifications réussies dans le
même environnement que le service (une installation de dépendances au boot
pourrait avoir remplacé le paquet).
3. Message `[archiveLogs]`, archive créée si des anciens logs existaient,
nouveau `memory_usage.csv`, un seul processus `archiveLogs.sh --watch-memory`.
Vérifier l'ajout de mesures à l'intervalle configuré (300 secondes par défaut).
Avec `uv`, le PID 1 peut être uv : si les colonnes RSS AppDaemon sont vides,
vérifier le processus réel ; les métriques du conteneur restent distinctes.
4. Connexion Home Assistant et absence de rafraîchissements complets chaque
seconde après le délai de refresh configuré (10 minutes par défaut).
5. Tests du logger réussis avec l'attente adaptée à la version.
6. Si un essai de reload réel est nécessaire, utiliser une app de test isolée
sans action domotique ; vérifier ajout/suppression d'une clé imbriquée et
nettoyer l'essai. Ne pas modifier chauffage/volets pour ce test.
Mettre à jour `docs/appdaemon-local-changes.md` avec la version, les fichiers
réellement modifiés, les sauvegardes, résultats et statut du redémarrage.
Séparer les vérifications exécutées de celles restant à observer ; ne pas
annoncer une validation du service sur la seule base des tests unitaires.
## 8. Rollback vers 4.5.10
Revenir à l'image 4.5.10 prévue par le déploiement, puis reprendre les sections
1 à 7 pour **4.5.10** : patch HASS + clés `deep_compare` + raccourci applicatif
logger + appel d'archivage dans le vrai `dockerStart.sh`.
Les fichiers `/conf` et le sous-module `apps/ad_toolbox` doivent être conservés.
Si le test du logger a été rendu compatible avec les deux versions, le conserver.
Ne pas restaurer de fichiers core 4.5.14 dans 4.5.10, ni les anciennes sondes de
diagnostic archivées. Vérifier à nouveau après le redémarrage du rollback.
## Sources de référence
- Inventaire : [appdaemon-local-changes.md](appdaemon-local-changes.md).
- Diff core local 4.5.10 : [appdaemon-core-4.5.10.diff](appdaemon-core-4.5.10.diff).
- [4.5.14 : plugin_management.py](https://github.com/AppDaemon/appdaemon/blob/4.5.14/appdaemon/plugin_management.py).
- [4.5.14 : utils/misc.py](https://github.com/AppDaemon/appdaemon/blob/4.5.14/appdaemon/utils/misc.py).
- [4.5.14 : adapi.py](https://github.com/AppDaemon/appdaemon/blob/4.5.14/appdaemon/adapi.py).
- [4.5.14 : Dockerfile](https://github.com/AppDaemon/appdaemon/blob/4.5.14/Dockerfile).
- [4.5.14 : scripts/start.sh](https://github.com/AppDaemon/appdaemon/blob/4.5.14/scripts/start.sh).
- [4.5.10 : dockerStart.sh](https://github.com/AppDaemon/appdaemon/blob/4.5.10/dockerStart.sh).
+10
View File
@@ -0,0 +1,10 @@
# Diagnostic utility loop — retiré
Toute l’instrumentation ajoutée a été retirée le 4 novembre 2026.
Voir [l’état actuel et la vérification du core](appdaemon-local-changes.md).
L’ancien document, les scripts et les sauvegardes sont dans
`tools/archive/appdaemon-diagnostics-retired-20261104.tar.gz`.
Ne plus utiliser les anciennes commandes install/upgrade/restore : ces scripts
ne sont plus actifs. Un redémarrage AppDaemon est nécessaire pour décharger
l’instrumentation restée en mémoire.
+144
View File
@@ -0,0 +1,144 @@
from contextlib import nullcontext
import datetime
import pickle
import tempfile
from pathlib import Path
import sys
import unittest
from unittest.mock import Mock, patch
sys.path[:0] = [str(Path(__file__).resolve().parents[1] / 'apps'),
str(Path(__file__).resolve().parents[1] / 'apps/ad_toolbox')]
from alarmclock import AlarmClock
class Harness(AlarmClock):
def __init__(self):
self.calendar = ['previous calendar']
self.calendar_retry_handle = None
self.calendar_retry_attempt = 0
self.dataset = {}
self.load_cal_from_ics = Mock(return_value=False)
self.run_in = Mock(side_effect=lambda callback, delay: f'timer-{delay}')
self.timer_running = Mock(return_value=True)
self.cancel_timer = Mock()
self.log_info = Mock()
self.log_warning = Mock()
self.log_error = Mock()
self.log_group = Mock(side_effect=lambda name: nullcontext())
self.update_workday_status = Mock()
self.update_wakeup_time = Mock()
class CalendarRefreshTests(unittest.TestCase):
def test_last_success_survives_dataset_save_and_restore(self):
app = Harness()
app.calendar = []
app.load_cal_from_ics.return_value = True
app.refresh_cal()
last_success = app.calendar_last_success
self.assertIsNotNone(last_success)
with tempfile.TemporaryDirectory() as folder:
dataset_path = Path(folder) / 'alarm_clock.dataset'
app.get_dataset_name = Mock(return_value=str(dataset_path))
app.terminate()
restored = Harness()
with dataset_path.open('rb') as stream:
restored.dataset = pickle.load(stream)
self.assertEqual(restored.calendar_last_success, last_success)
restored._log_calendar_failure('request timed out')
restored.log_info.assert_called_once()
restored.log_warning.assert_not_called()
restored.log_error.assert_not_called()
def test_dataset_without_timestamp_has_no_known_success(self):
app = Harness()
app.dataset = {'other_data': 42}
self.assertIsNone(app.calendar_last_success)
app._log_calendar_failure('request timed out')
app.log_error.assert_called_once()
self.assertEqual(app.dataset, {'other_data': 42})
def test_failure_preserves_calendar_and_schedules_two_retries(self):
app = Harness()
self.assertFalse(app.refresh_cal())
self.assertEqual(app.calendar, ['previous calendar'])
app.run_in.assert_called_once_with(app.on_calendar_retry, 120)
app.on_calendar_retry({})
self.assertEqual(app.run_in.call_args_list[-1].args, (app.on_calendar_retry, 300))
app.on_calendar_retry({})
self.assertEqual(app.run_in.call_count, 2)
def test_failure_level_depends_on_last_success(self):
now = datetime.datetime(2026, 11, 4, 12, tzinfo=datetime.timezone.utc)
for hours, level in ((0, 'info'), (23.999, 'info'), (24, 'warning'),
(47.999, 'warning'), (48, 'error'), (72, 'error'),
(None, 'error')):
with self.subTest(hours=hours):
app = Harness()
last_success = None if hours is None else now - datetime.timedelta(hours=hours)
app.calendar_last_success = last_success
with patch('alarmclock.datetime.datetime') as clock:
clock.now.return_value = now
app._log_calendar_failure('request timed out')
getattr(app, f'log_{level}').assert_called_once()
for other in {'info', 'warning', 'error'} - {level}:
getattr(app, f'log_{other}').assert_not_called()
self.assertEqual(app.calendar_last_success, last_success)
message = getattr(app, f'log_{level}').call_args.args[0]
self.assertIn('request timed out', message)
if last_success:
self.assertIn(last_success.isoformat(), message)
def test_success_updates_timestamp_even_when_calendar_is_unchanged(self):
app = Harness()
app.calendar = []
app.calendar_last_success = datetime.datetime(2026, 11, 1, tzinfo=datetime.timezone.utc)
app.calendar_retry_attempt = 2
app.calendar_retry_handle = 'pending-timer'
app.load_cal_from_ics.return_value = True
now = datetime.datetime(2026, 11, 4, tzinfo=datetime.timezone.utc)
with patch('alarmclock.datetime.datetime') as clock:
clock.now.return_value = now
self.assertFalse(app.refresh_cal())
app.cancel_timer.assert_called_once_with('pending-timer')
self.assertEqual(app.calendar_last_success, now)
self.assertEqual(app.calendar_retry_attempt, 0)
@patch('alarmclock.get')
def test_timeout_uses_freshness_level_and_preserves_calendar(self, get):
import requests
app = Harness()
app.calendar_last_success = datetime.datetime.now(datetime.timezone.utc)
last_success = app.calendar_last_success
app.load_cal_from_ics = lambda calendar: AlarmClock.load_cal_from_ics(app, calendar)
get.side_effect = requests.exceptions.ReadTimeout()
self.assertFalse(app.refresh_cal())
app.log_warning.assert_not_called()
app.log_error.assert_not_called()
self.assertTrue(any('ReadTimeout' in call.args[0] for call in app.log_info.call_args_list))
self.assertEqual(app.calendar_last_success, last_success)
self.assertEqual(app.calendar, ['previous calendar'])
app.run_in.assert_called_once_with(app.on_calendar_retry, 120)
@patch('alarmclock.icalendar.Calendar.from_ical')
@patch('alarmclock.get')
def test_http_request_uses_short_timeouts_and_validates_status(self, get, from_ical):
response = Mock(content=b'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n')
get.return_value = response
from_ical.return_value.walk.return_value = []
app = Harness()
self.assertTrue(AlarmClock.load_cal_from_ics(app, []))
self.assertEqual(get.call_args.kwargs['timeout'], (3, 5))
response.raise_for_status.assert_called_once_with()
from_ical.assert_called_once_with(response.content)
if __name__ == '__main__':
unittest.main()
+49 -1
View File
@@ -1,7 +1,9 @@
from pathlib import Path from pathlib import Path
import sys import sys
import io
import json
import unittest import unittest
from unittest.mock import Mock from unittest.mock import Mock, patch
sys.path[:0] = [str(Path(__file__).resolve().parents[1] / 'apps'), sys.path[:0] = [str(Path(__file__).resolve().parents[1] / 'apps'),
str(Path(__file__).resolve().parents[1] / 'apps/ad_toolbox')] str(Path(__file__).resolve().parents[1] / 'apps/ad_toolbox')]
@@ -41,5 +43,51 @@ class WarningCollectorTests(unittest.TestCase):
) )
class AppDaemonDockerVersionTests(unittest.TestCase):
def setUp(self):
self.app = object.__new__(InformationsCollector)
self.app.log_warning = Mock()
self.app.dataset = {'appdaemon_latest_official_version': '4.5.14'}
self.app.appdaemon_version_sensor = Mock()
@staticmethod
def response(tags, next_url=None):
return io.StringIO(json.dumps({'results': tags, 'next': next_url}))
def test_selects_highest_stable_published_tag_across_pages(self):
pages = [
self.response([{'name': name, 'images': [{}]} for name in
['latest', 'dev', '4.5.14rc1', '4.5.9']], 'https://hub.docker.com/page2'),
self.response([{'name': '4.5.13', 'images': [{}]},
{'name': '4.5.14', 'images': []},
{'name': '4.5.12', 'images': [{}]}]),
]
with patch('informationscollector.urlopen', side_effect=pages) as fetch:
self.assertEqual(self.app._get_latest_official_appdaemon_version(), '4.5.13')
self.assertEqual(fetch.call_count, 2)
fetch.assert_any_call(self.app.APPDAEMON_DOCKER_TAGS_URL, timeout=10)
self.app.log_warning.assert_not_called()
def test_empty_stable_results_are_reported(self):
with patch('informationscollector.urlopen', return_value=self.response([])):
self.assertIsNone(self.app._get_latest_official_appdaemon_version())
self.app.log_warning.assert_called_once()
def test_sensor_uses_docker_cache_without_reusing_pypi_cache(self):
with patch.object(InformationsCollector, '_get_installed_appdaemon_version', return_value='4.5.10'):
with patch('informationscollector.urlopen', side_effect=OSError('offline')):
self.app.update_appdaemon_version_sensor()
self.app.appdaemon_version_sensor.set_state.assert_called_with(
state='4.5.10', attributes={'latest_official_version': None, 'update_available': None})
with patch('informationscollector.urlopen', return_value=self.response([
{'name': '4.5.13', 'images': [{}]}])):
self.app.update_appdaemon_version_sensor()
self.assertEqual(self.app.dataset['appdaemon_latest_docker_version'], '4.5.13')
with patch('informationscollector.urlopen', side_effect=OSError('offline')):
self.app.update_appdaemon_version_sensor()
self.app.appdaemon_version_sensor.set_state.assert_called_with(
state='4.5.10', attributes={'latest_official_version': '4.5.13', 'update_available': True})
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
@@ -0,0 +1,78 @@
"""Notification logging must avoid stack inspection without changing delivery."""
import logging
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, call, patch
sys.path[:0] = [str(Path(__file__).resolve().parents[1] / 'apps'),
str(Path(__file__).resolve().parents[1] / 'apps/ad_toolbox')]
from appdaemon.adapi import ADAPI
from notificationsmanager import NotificationsManager, Notification, SmartCondition
class NotificationLoggingTests(unittest.TestCase):
def setUp(self):
self.api = ADAPI.__new__(ADAPI)
self.api.AD = SimpleNamespace(config=SimpleNamespace(ascii_encode=False))
self.api.logger = Mock()
self.sinks = {'main_log': Mock(), 'error_log': Mock()}
self.api.get_user_log = Mock(side_effect=self.sinks.__getitem__)
self.app = NotificationsManager.__new__(NotificationsManager)
self.app.get_ad_api = Mock(return_value=self.api)
self.app.args = {}
self.app.call_service = Mock()
self.app.run_in = Mock()
self.app.stop_app = Mock()
self.app._config_model = SimpleNamespace(name='notifications_manager')
self.app.initialize()
self.config = {
'message': {'title': 'Alarme', 'content': 'Activée', 'timeout': 10,
'data': {'tag': 'alarm'}},
'recipients': {'phones': {'services': ['notify/one', 'notify/two']}}
}
self.notification = Notification(self.app, {'message': {}, 'recipients': {}}, 'alarm')
self.notification.config = self.config
def test_send_and_clear_keep_delivery_without_inspecting_stack(self):
with patch('appdaemon.adapi.inspect.stack', side_effect=AssertionError('stack inspection')):
self.notification.send_notification('phones')
self.notification.clear_notification('phones', 'alarm')
self.app.call_service.assert_has_calls([
call('notify/one', title='Alarme', message='Activée', data={'tag': 'alarm'}),
call('notify/two', title='Alarme', message='Activée', data={'tag': 'alarm'}),
call('notify/one', message='clear_notification', data={'tag': 'alarm'}),
call('notify/two', message='clear_notification', data={'tag': 'alarm'}),
])
self.app.run_in.assert_called_once_with(self.notification.on_timeout_expired, 10,
tag='alarm', recipient='phones')
self.assertEqual(self.api.logger.log.call_count, 4)
self.api.get_user_log.assert_not_called()
def test_errors_use_app_logger_and_error_log_and_stop(self):
with patch('appdaemon.adapi.inspect.stack', side_effect=AssertionError('stack inspection')):
self.app.log_error('Échec', stop_app=True)
for sink in (self.api.logger, self.sinks['error_log']):
self.assertEqual(sink.log.call_args.args, (logging.ERROR, 'Échec'))
self.sinks['main_log'].log.assert_not_called()
self.app.stop_app.assert_called_once_with('notifications_manager')
def test_smartcondition_fallback_uses_initialized_interface(self):
self.config['recipients']['phones']['conditions'] = True
self.app.args = {'notifications': {'alarm': self.config}}
real_class = SmartCondition.Evaluator
def factory(api, *args, **kwargs):
condition = real_class.__new__(real_class)
condition._Evaluator__logger_interface = None
condition._Evaluator__appdaemon_api = api
condition._Evaluator__log_info('Condition évaluée')
return condition
with patch('appdaemon.adapi.inspect.stack', side_effect=AssertionError('stack inspection')):
with patch.object(SmartCondition, 'Evaluator', side_effect=factory):
self.app.initialize()
self.assertEqual(self.api.logger.log.call_args.args, (logging.INFO, 'Condition évaluée'))
if __name__ == '__main__':
unittest.main()
+112 -6
View File
@@ -20,6 +20,7 @@ class Harness(Vertex2):
self._status_requested = threading.Event() self._status_requested = threading.Event()
self._last_logged_status = None self._last_logged_status = None
self._input_5v_states = {} self._input_5v_states = {}
self._cancel_input_5v_refresh()
self._schedule_input_5v_refresh = Mock() self._schedule_input_5v_refresh = Mock()
self._connection_lock = threading.Lock() self._connection_lock = threading.Lock()
self._interrupt_connection = None self._interrupt_connection = None
@@ -65,7 +66,7 @@ class PowerTests(unittest.TestCase):
connection.shutdown.assert_called_once_with(socket.SHUT_RDWR) connection.shutdown.assert_called_once_with(socket.SHUT_RDWR)
app.turn_off.assert_called_once_with('switch.vertex2') app.turn_off.assert_called_once_with('switch.vertex2')
self.assertFalse(app._vertex2_powered()) self.assertFalse(app._vertex2_powered())
app.cancel_timer.assert_called_once_with('timer') app.cancel_timer.assert_called_once_with('timer', silent=True)
self.assertIsNone(app._command_processing_deadline) self.assertIsNone(app._command_processing_deadline)
app.command_processing_sensor.set_state.assert_called_once_with(state='off') app.command_processing_sensor.set_state.assert_called_once_with(state='off')
app.unreachable_sensor.set_state.assert_called_once_with(state='off') app.unreachable_sensor.set_state.assert_called_once_with(state='off')
@@ -197,6 +198,54 @@ class PowerTests(unittest.TestCase):
app.log_warning.assert_not_called() app.log_warning.assert_not_called()
class StatusTimeoutDiagnosticsTests(unittest.TestCase):
def test_timeout_reports_missing_fields_and_partial_response(self):
for silent in (True, False):
with self.subTest(silent=silent):
app = Harness()
app.video_tx_signal_sensor = Mock()
app.video_tx_signal_sensors = {}
app.allm_sensor = Mock()
app.allm_sensors = {}
app._reset_no_signal_spam = Mock()
app.log_warning.side_effect = lambda message: app._interrupt_stop.set()
connection = Mock()
fields = ('rx0', 'rx1', 'tx0', 'tx1', 'tx0sink', 'tx1sink',
'aud0', 'aud1', 'audout', 'spd0')
response = ('\n'.join(f'{field.upper()}: no signal' for field in fields)
+ '\ninsel 0 0\nint\nSPD1: incom').encode('ascii')
clock = [100.0]
calls = [0]
def recv(size):
calls[0] += 1
if calls[0] == 1:
return response
clock[0] = 106.0
if silent:
raise socket.timeout()
return b'plete'
connection.recv.side_effect = recv
context = Mock(__enter__=Mock(return_value=connection),
__exit__=Mock(return_value=False))
with patch('vertex2.socket.create_connection', return_value=context), \
patch('vertex2.time.monotonic', side_effect=lambda: clock[0]):
app._interrupt_listener(app._interrupt_stop)
app.log_warning.assert_called_once()
message = app.log_warning.call_args.args[0]
self.assertIn("missing=['spd1']", message)
self.assertIn('elapsed=6.00s, limit=5s', message)
self.assertIn('interrupts=1', message)
self.assertIn(f'bytes={len(response) + (0 if silent else 5)}', message)
self.assertIn("'insel 0 0'", message)
partial = 'incom' if silent else 'incomplete'
self.assertIn(f"partial_line='SPD1: {partial}'", message)
self.assertIn('reconfigure=True', message)
app.run_in.assert_not_called()
app.unreachable_sensor.set_state.assert_called_with(state='on')
class ReconfigurationTests(unittest.TestCase): class ReconfigurationTests(unittest.TestCase):
def test_reconnect_reapplies_current_plus5_and_selections(self): def test_reconnect_reapplies_current_plus5_and_selections(self):
app = Harness() app = Harness()
@@ -360,10 +409,11 @@ class StatusChangeTests(unittest.TestCase):
app._refresh_input_5v_sensors.reset_mock() app._refresh_input_5v_sensors.reset_mock()
app._apply_vertex2_status(dict(self.status, **metadata)) app._apply_vertex2_status(dict(self.status, **metadata))
app._refresh_input_5v_sensors.assert_not_called() app._refresh_input_5v_sensors.assert_not_called()
app.run_in.assert_called_once_with(app._refresh_input_5v_sensors, 0.1) app.run_in.assert_called_once_with(app._on_input_5v_refresh, 1,
generation=app._input_5v_refresh_generation)
callback = app.run_in.call_args.args[0] callback = app.run_in.call_args.args[0]
callback({}) callback(app.run_in.call_args.kwargs)
app._refresh_input_5v_sensors.assert_called_once_with({}) app._refresh_input_5v_sensors.assert_called_once_with()
def test_identical_status_is_silent_but_still_applied(self): def test_identical_status_is_silent_but_still_applied(self):
app = self.app app = self.app
@@ -486,8 +536,8 @@ class SpamSensorTests(unittest.TestCase):
self.assertTrue(app._interrupt_stop.is_set()) self.assertTrue(app._interrupt_stop.is_set())
self.assertIsNone(app._spam_timer) self.assertIsNone(app._spam_timer)
self.assertIsNone(app._spam_deadline) self.assertIsNone(app._spam_deadline)
app.cancel_timer.assert_any_call('spam_timer') app.cancel_timer.assert_any_call('spam_timer', silent=True)
app.cancel_timer.assert_any_call('timer') app.cancel_timer.assert_any_call('timer', silent=True)
app.no_signal_spam_sensor.set_state.assert_not_called() app.no_signal_spam_sensor.set_state.assert_not_called()
# An already queued expiry callback must also leave the state alone. # An already queued expiry callback must also leave the state alone.
app._expire_no_signal_spam({'deadline': 110}) app._expire_no_signal_spam({'deadline': 110})
@@ -668,6 +718,62 @@ class InputSelectTests(unittest.TestCase):
class Input5VTests(unittest.TestCase): class Input5VTests(unittest.TestCase):
def test_bursts_share_one_read_and_retry_then_recover(self):
from requests.exceptions import ConnectTimeout
app = self.app
del app._schedule_input_5v_refresh
for _ in range(10):
app._schedule_input_5v_refresh()
self.assertEqual(app.run_in.call_count, 1)
first = app.run_in.call_args
with patch('vertex2.get', side_effect=ConnectTimeout('test')):
first.args[0](first.kwargs)
app.log_warning.assert_not_called()
self.assertEqual(app.run_in.call_count, 2)
retry = app.run_in.call_args
self.assertEqual(retry.args[1], 5)
for _ in range(10):
app._schedule_input_5v_refresh()
self.assertEqual(app.run_in.call_count, 2)
with patch('vertex2.get') as get:
get.return_value.json.return_value = {'rx0in5v': '2'}
retry.args[0](retry.kwargs)
get.assert_called_once()
self.assertEqual(app._input_5v_states[0], 'on')
self.assertEqual(app._input_5v_failures, 0)
self.assertIsNone(app._input_5v_refresh_handle)
app.log_warning.assert_not_called()
self.assertTrue(any('restored after' in c.args[0] for c in app.log_info.call_args_list))
def test_failed_retry_warns_once_without_an_endless_retry_loop(self):
from requests.exceptions import ConnectTimeout
app = self.app
del app._schedule_input_5v_refresh
with patch('vertex2.get', side_effect=ConnectTimeout('test')):
app._refresh_input_5v_sensors()
retry = app.run_in.call_args
retry.args[0](retry.kwargs)
app._refresh_input_5v_sensors()
app.log_warning.assert_called_once()
self.assertIn('attempt=2', app.log_warning.call_args.args[0])
self.assertEqual(app.run_in.call_count, 1)
def test_power_off_cancels_retry_and_discards_stale_callback(self):
from requests.exceptions import ConnectTimeout
app = self.app
del app._schedule_input_5v_refresh
with patch('vertex2.get', side_effect=ConnectTimeout('test')):
app._refresh_input_5v_sensors()
retry = app.run_in.call_args
handle = app._input_5v_refresh_handle
app._suspend_vertex2()
app.cancel_timer.assert_any_call(handle, silent=True)
# Even if power returns before a cancelled callback arrives, ignore it.
with patch('vertex2.get') as get:
retry.args[0](retry.kwargs)
get.assert_not_called()
self.assertEqual(app._input_5v_failures, 0)
def setUp(self): def setUp(self):
self.app = app = SelectHarness() self.app = app = SelectHarness()
app.input_names = {'AppleTV': 0, 'Xbox': 1} app.input_names = {'AppleTV': 0, 'Xbox': 1}