Compare commits
22
Commits
956ade4d03
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3aefa8680 | ||
|
|
8863122afc | ||
|
|
13115ad1ba | ||
|
|
8cd2a414cd | ||
|
|
4d502c5911 | ||
|
|
545ebf2bd8 | ||
|
|
5ef7b27448 | ||
|
|
9802982d73 | ||
|
|
ff39f4cb2f | ||
|
|
b76f107f01 | ||
|
|
f5a586c379 | ||
|
|
94754e6285 | ||
|
|
10c5b9e043 | ||
|
|
9f32a666c3 | ||
|
|
3130df69ce | ||
|
|
35856b2ba9 | ||
|
|
a5258b1985 | ||
|
|
42ae8f4bc4 | ||
|
|
2c87c6a6f3 | ||
|
|
d8e604d64d | ||
|
|
bed50f1d09 | ||
|
|
07abff7054 |
No files matched your search
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
logs/
|
logs/
|
||||||
apps/data/
|
apps/data/
|
||||||
secrets.yaml
|
secrets.yaml
|
||||||
|
llm/
|
||||||
@@ -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.
|
||||||
+1
-1
Submodule apps/ad_toolbox updated: b97445ce92...86b5441603.
+81
-8
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
Reference in new issue
Block a user