Compare commits

..
64 Commits
Author SHA1 Message Date
Pierre d3aefa8680 add llm folder to gitignore 2026-11-13 13:59:52 +01:00
Pierre 8863122afc created a new agents.md 2026-11-13 13:35:47 +01:00
Pierre 13115ad1ba some llm docs 2026-11-13 13:15:03 +01:00
Pierre 8cd2a414cd test update 2026-11-13 13:10:37 +01:00
Pierre 4d502c5911 upgrade to latest version of ad toolbox 2026-11-13 13:09:48 +01:00
Pierre 545ebf2bd8 smartshutter now correctly go through the logger interface 2026-11-13 13:09:17 +01:00
Pierre 5ef7b27448 tweak light and projection screen auto close timing 2026-11-12 22:51:54 +01:00
Pierre 9802982d73 fix(alarmclock): fiabiliser le rafraîchissement du calendrier ICS
- Ajouter deux tentatives de reprise après 2 puis 5 minutes en cas d’échec.
- Mémoriser le dernier succès et adapter le niveau des logs à l’ancienneté du calendrier.
- Distinguer les erreurs réseau, HTTP et les données ICS invalides.
- Définir les délais de connexion et de lecture et vérifier le statut HTTP.
- Actualiser le statut du jour travaillé et l’heure du réveil après une reprise.
2026-11-10 19:31:13 +01:00
Pierre ff39f4cb2f fix(vertex2): fiabiliser les lectures +5V et enrichir les diagnostics TCP
- Regrouper les demandes de lecture +5V et ignorer les callbacks obsolètes.
- Retenter la lecture après 5 secondes au premier échec et journaliser le rétablissement.
- Annuler les lectures planifiées à la réinitialisation, à la suspension et à l’arrêt.
- Détailler les timeouts TCP : champs reçus et manquants, octets reçus, interruptions et dernières lignes.
2026-11-10 19:30:18 +01:00
Pierre b76f107f01 fix christmas tree commit 2026-11-10 19:14:27 +01:00
Pierre f5a586c379 upgrade notificaiton manager to use LoggerInterface 2026-11-10 19:13:47 +01:00
Pierre 94754e6285 New christmas tree 2026-11-10 19:12:06 +01:00
Pierre 10c5b9e043 fixed error caused by sensor change in the last P1P update 2026-11-10 19:11:28 +01:00
Pierre 9f32a666c3 temperature tweak 2026-11-10 19:10:32 +01:00
Pierre 3130df69ce latest appdaemon version came now from docker image 2026-11-08 10:48:50 +01:00
Pierre 35856b2ba9 ignore code stuff from git 2026-11-08 10:48:03 +01:00
Pierre a5258b1985 Tweaked desk presence 2026-11-06 21:55:24 +01:00
Pierre 42ae8f4bc4 add main power off notification 2026-11-05 22:33:40 +01:00
Pierre 2c87c6a6f3 added a notification for commut rain 2026-11-05 18:45:37 +01:00
Pierre d8e604d64d Heatpump now works during the winter as well 2026-11-03 19:19:48 +01:00
Pierre bed50f1d09 fixed a few timer warning 2026-11-03 19:16:30 +01:00
Pierre 07abff7054 New aquarium light automation 2026-10-25 20:41:33 +01:00
Pierre 956ade4d03 Added warning to information collector 2026-10-23 19:19:24 +02:00
Pierre c34dc7fdd3 exclude a few folder from file watching 2026-10-23 19:18:40 +02:00
Pierre f6f61ddc2e tweaked vertex2_has_input_signal retain time 2026-10-22 22:12:54 +02:00
Pierre d6d470903c new vertex2 content sensor 2026-10-22 22:11:39 +02:00
Pierre 914d48ae55 added +5v sensor to vertex2 2026-10-22 22:10:32 +02:00
Pierre 264619565e clean up 2026-10-22 22:08:04 +02:00
Pierre 896fa2aa6d Removed the timeout warning of the smartprojector and pusshed all the command into a queue. 2026-10-22 22:07:48 +02:00
Pierre 1626fe83bf Reload bug fix 2026-10-18 13:19:24 +02:00
Pierre 5dbfd2a805 Added a select entity to manually switch input. 2026-10-18 00:09:10 +02:00
Pierre 1fd4dab916 Added support for power plug and spam sensor 2026-10-17 22:59:17 +02:00
Pierre 0de8352417 tweaked vertex2_has_input_signal 2026-10-17 22:57:34 +02:00
Pierre b499cb5a0d update to last version of ad toolbox 2026-10-17 22:48:22 +02:00
Pierre ce352ba7ef tv now count as occupancy trigger 2026-10-17 22:48:09 +02:00
Pierre 8cc2ae32df Shorter reconnection delay 2026-10-17 22:46:44 +02:00
Pierre 8a8cb02296 the switch from projector to tv now rely on an input_boolean (instead of the position of the screen), this made the whole things more reactive 2026-10-16 09:18:50 +02:00
Pierre d79bba15b0 Remove initialisation warning from the boiler 2026-10-14 19:05:40 +02:00
Pierre 9a58cc5296 New async version of samsungtheframe 2026-10-14 19:01:30 +02:00
Pierre 327c0b2558 The boiler smartcondition now stay off while one of the condition entity is unavailable 2026-10-11 11:30:59 +02:00
Pierre 40ce351fac samsungtheframe now use create_entity to create its output sensor 2026-10-11 11:29:50 +02:00
Pierre b030645082 sonoscontroller now use create_entity to create its output sensor 2026-10-11 11:29:39 +02:00
Pierre 79dd13f31d Added a smart condition to the boiler 2026-10-11 10:13:37 +02:00
Pierre 6447d754ca the boiler app now use mqtt instead of relying on an automation on ha side 2026-10-10 16:19:37 +02:00
Pierre a2f40f81ba first version of the boiler controlled by ebusd 2026-10-10 15:27:31 +02:00
Pierre c21291e9dd fix an init ordering problem 2026-10-09 22:47:42 +02:00
Pierre dd4335c811 Vertex2 tweaking 2026-10-09 22:47:26 +02:00
Pierre 6d2fd26f66 Deactivate +5v signal when there is not input signal 2026-10-06 22:47:29 +02:00
Pierre a734b5f780 increased vertex2_has_input_signal retain time 2026-10-06 22:46:58 +02:00
Pierre 486956cd1f Removed an obsolete sensor 2026-10-06 22:46:23 +02:00
Pierre 0e64711c4f fixed a log error 2026-10-05 20:42:44 +02:00
Pierre 9bead746e8 tweaked retain time of vertex2_has_input_signal 2026-10-04 22:10:48 +02:00
Pierre 842cfe6c6a commut_rain sensor simplification 2026-10-04 11:51:35 +02:00
Pierre 5061e4395b new commut_rain sensor 2026-10-04 10:26:39 +02:00
Pierre 3562e14c4c Added a workday sensor 2026-10-04 10:25:18 +02:00
Pierre aafc932818 replace a bunch of log by log_info 2026-10-02 22:18:16 +02:00
Pierre 533785151e Replaced a bunch of log by log_info 2026-09-27 22:55:17 +02:00
Pierre 098e95b439 set vertex2_has_input_signal to 10 2026-09-27 09:27:42 +02:00
Pierre 00f6b28342 fix issue with vertex2 reconnection after HA reboot 2026-09-27 08:52:03 +02:00
Pierre acdd14aaeb moved a virtual sensor to fix initilization + reduce retain time for vertex2_has_input_signal 2026-09-26 23:10:01 +02:00
Pierre 18b6613864 worked on audio edid 2026-09-26 19:58:18 +02:00
Pierre bcfaa83f10 One remote to rule them all 2026-09-26 19:29:28 +02:00
Pierre db12c81f6d fix a few home cinema sensors 2026-09-26 19:21:43 +02:00
Pierre ed59a72c3f Still iterating on the home cinema configuration 2026-09-26 16:43:24 +02:00
48 changed files with 6627 additions and 406 deletions

No files matched your search

+2
View File
@@ -4,3 +4,5 @@ __pycache__/
*.pyc *.pyc
logs/ logs/
apps/data/ apps/data/
secrets.yaml
llm/
+94
View File
@@ -0,0 +1,94 @@
# Repository instructions
## Language
Use French when communicating with the user unless they ask otherwise. All new
technical artifacts must be in English: code, identifiers, variable and
function names, Home Assistant entity and sensor names, log messages, comments,
test names, configuration keys, and technical documentation. Do not rename
existing French identifiers or entities unless explicitly requested.
## Context
This repository is an AppDaemon configuration for Home Assistant. Python apps
in `apps/` control real equipment, including heating, shutters, lighting, a
projector, and a television. Application configuration is in `apps/apps.yaml`,
supplemented by YAML files in `apps/global/` and `apps/rooms/`. Runtime
configuration is in `appdaemon.yaml`.
`apps/ad_toolbox` is a shared Git submodule. It provides the base classes,
conditions, virtual sensors, and logging used by the apps. Only change it when
the request explicitly concerns that component. Do not update its revision or
discard its local changes without approval.
## Security and operations
- Never display, copy into responses, or commit tokens, passwords,
`secrets.yaml`, `apps/theframe_token.txt`, or persisted data in `apps/data/`.
Refer to their paths and redact sensitive values in diagnostics.
- Do not start a second AppDaemon instance, rerun `dockerStart.sh`, or execute
`archiveLogs.sh` manually while the service is running. The archive script
moves active log files.
- Do not restart the container, send device commands, or change heating
setpoints unless the user explicitly requests it. To test heating logic, use
mocks first or `send_commands: false` where available.
- Local AppDaemon-core and startup-script patches may exist outside this
persistent repository. Read `docs/appdaemon-local-changes.md` and
`docs/appdaemon-upgrade-rollback-instructions.md` before upgrading or
changing the runtime. Do not overwrite or "clean up" these patches.
## Logs and diagnosis
When asked to investigate a warning, error, or runtime behavior, inspect
`/conf/logs` first:
1. `appdaemon.log`, `error.log`, and `virtualsensors.log` for the current run;
2. `archive/<timestamp>/` for previous runs;
3. `memory_usage.csv` when memory use is suspected.
Always inspect the lines following a warning: a successful reconnection or
recovery can make an incident transient. Distinguish application problems from
Home Assistant, MQTT, or hardware connectivity losses.
## Changes
- Follow the style and conventions of the app or module being changed. Apps
often inherit from `SmartObject` and use `SmartCondition`; do not bypass
these abstractions without a clear reason.
- Keep an app, its `apps/apps.yaml` entry, its Home Assistant entities, and any
related `apps/global/` or `apps/rooms/` configuration consistent.
- Preserve AppDaemon lifecycle behavior: cancel timers, tasks, listeners, and
sockets on reload or termination when a change introduces them.
- Do not overwrite or reorganize the user's existing work. Keep diffs limited
to the requested change.
- Update documentation in `docs/` when operational behavior, a hardware
protocol, or a deployment procedure changes.
## Validation
The project uses `unittest`; no formatter or linter is configured. After a
Python change, run the focused test when one exists, for example:
```sh
python3 -m unittest discover -s tests -p 'test_vertex2.py' -v
python3 -m unittest discover -s tests -p 'test_boilerclimate.py' -v
python3 -m unittest discover -s tests -p 'test_samsungtheframe_async.py' -v
```
For shared changes or before a broader delivery:
```sh
python3 -m unittest discover -s tests -v
python3 -m unittest discover -s apps/ad_toolbox/unit_tests -p 'test_*.py' -v
```
The legacy YAML test runner for `ad_toolbox` is:
```sh
python3 apps/ad_toolbox/unittests.py
```
After a shell-script change, validate syntax at minimum with
`sh -n <script>`. Changes to the VS Code extension in
`apps/ad_toolbox/vscode-appdaemon/` must also follow its own `AGENTS.md`,
including compilation, packaging, and installation.
+114
View File
@@ -0,0 +1,114 @@
# Bug AppDaemon 4.5.10 : KeyError au rechargement des apps
## Symptôme
Lors d'une modification de configuration YAML, le rechargement des apps
échoue dans la boucle utilitaire avec cette erreur :
```text
check_app_config_files → utils.deep_compare(current_app, prev_app)
if isinstance(v, dict) and isinstance(data[k], dict):
KeyError: 'input_selection'
```
Le nom de la clé dépend de la configuration modifiée.
## Cause
`deep_compare()` accède à `data[k]` sans vérifier que la clé existe dans
l'ancienne configuration. L'ajout d'une clé contenant un dictionnaire peut
ainsi provoquer un `KeyError`. La fonction peut aussi ignorer les clés
supprimées, car elle parcourt uniquement la nouvelle configuration.
Le même problème a été signalé avec la clé `mutes` dans les commentaires de
[AppDaemon/appdaemon#2396](https://github.com/AppDaemon/appdaemon/issues/2396).
Le correctif du problème initial de ce ticket concerne les plugins personnalisés,
pas nécessairement ce problème de comparaison signalé en commentaire.
## Statut
- Patch local appliqué dans le conteneur sur AppDaemon **4.5.10**.
- Le code défectueux et son appel lors du reload ont également été vérifiés
dans la version **4.5.13** : cette mise à jour seule ne corrige pas ce bug.
- Source de référence :
[utils.py en 4.5.13](https://github.com/AppDaemon/appdaemon/blob/4.5.13/appdaemon/utils.py).
- La correction compare les ensembles de clés avant de comparer les valeurs,
y compris dans les dictionnaires imbriqués.
## Comment réappliquer le patch
Fichier concerné : `/usr/local/lib/python3.12/site-packages/appdaemon/utils.py`
Dans `deep_compare()`, juste après `data = data or {}`, ajouter :
```python
if check.keys() != data.keys():
return False
```
Le début de la fonction devient :
```python
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):
```
La suite de la fonction reste inchangée. Avant de réappliquer sur une autre
version, vérifier son implémentation : ne pas remplacer aveuglément une
fonction qui aurait été corrigée en amont.
**Redémarrer AppDaemon après application.** Modifier le fichier sur disque
ne remplace pas la fonction déjà importée par le processus en cours ; un
simple reload d'app ne suffit pas.
Ce patch réside dans `site-packages`, hors de `/conf`. Il est perdu lors
d'une recréation du conteneur ou peut être écrasé par une réinstallation
ou mise à jour d'AppDaemon.
## Comment vérifier le patch
Exécuter dans le conteneur :
```sh
python - <<'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')
PY
```
Ces 11 vérifications ont réussi après application sur le module installé.
Elles vérifient la fonction dans un nouveau processus Python, pas le reload
complet dans le service en cours. Le service n'a pas été redémarré lors de
l'application de ce patch.
## Retour arrière
Une sauvegarde du fichier original a été créée dans le même répertoire :
`utils.py.before-deep-compare-fix.bak`.
Pour annuler le patch sur cette installation, avant toute autre modification
ou mise à jour de `utils.py` :
```sh
cp /usr/local/lib/python3.12/site-packages/appdaemon/utils.py.before-deep-compare-fix.bak \
/usr/local/lib/python3.12/site-packages/appdaemon/utils.py
```
Redémarrer ensuite AppDaemon. Ne pas restaurer cette sauvegarde sur une
version différente d'AppDaemon.
+25
View File
@@ -6,6 +6,8 @@ appdaemon:
internal_function_timeout: 00:20:00 internal_function_timeout: 00:20:00
exclude_dirs: exclude_dirs:
- unit_tests - unit_tests
- vscode-appdaemon
- node_modules
plugins: plugins:
HASS: HASS:
type: hass type: hass
@@ -13,6 +15,29 @@ appdaemon:
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiZThhYWI2ZTlkNmQ0NWU2YTk1ODU5OWJjOWM3MWJkYiIsImlhdCI6MTc3NjEwOTc0NCwiZXhwIjoyMDkxNDY5NzQ0fQ.BorkjFjWlWCZqnUa9-NMUxGsDiupDoRZ3cEgsmeSofM token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiZThhYWI2ZTlkNmQ0NWU2YTk1ODU5OWJjOWM3MWJkYiIsImlhdCI6MTc3NjEwOTc0NCwiZXhwIjoyMDkxNDY5NzQ0fQ.BorkjFjWlWCZqnUa9-NMUxGsDiupDoRZ3cEgsmeSofM
cert_verify: True cert_verify: True
ws_timeout: 00:10:00 ws_timeout: 00:10:00
MQTT:
type: mqtt
namespace: mqtt
client_host: 10.0.0.21
client_port: 1883
client_id: appdaemon_radiator_heating
client_user: !secret boiler_mqtt_user
client_password: !secret boiler_mqtt_password
client_timeout: 30
client_topics:
- appdaemon/radiator_heating/command/+
- appdaemon/radiator_heating/heartbeat
- themaplus-condens/bai/SetMode
- themaplus-condens/bai/Statenumber
- themaplus-condens/bai/FlowTemp
- homeassistant/status
birth_topic: appdaemon/radiator_heating/connection
birth_payload: online
birth_retain: true
will_topic: appdaemon/radiator_heating/connection
will_payload: offline
will_retain: true
shutdown_payload: offline
http: http:
url: http://0.0.0.0:5050 url: http://0.0.0.0:5050
admin: admin:
+130 -49
View File
@@ -32,13 +32,25 @@ 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']
self.inputdate_alarmclock_secondary = self.args['inputdate_alarmclock_secondary'] self.inputdate_alarmclock_secondary = self.args['inputdate_alarmclock_secondary']
self.input_text_wakeup_time = self.args['input_text_wakeup_time'] self.output_workday_sensor = self.args['output_workday_sensor']
#self.sensor_wakeup_datetime = self.args['sensor_wakeup_datetime'] self.sensor_wakeup_datetime_sensor = self.args['sensor_wakeup_datetime']
self.sleeping_condition = SmartCondition.Evaluator(self, self.args['sleeping_condition'], condition_name="sleeping_condition", constants=self.constants, templates_library=self.templates_library) self.sleeping_condition = SmartCondition.Evaluator(self, self.args['sleeping_condition'], condition_name="sleeping_condition", constants=self.constants, templates_library=self.templates_library)
self.lock = Lock() self.lock = Lock()
@@ -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)
@@ -53,27 +67,74 @@ class AlarmClock(SmartObject):
self.run_hourly(self.on_refresh_cal,datetime.time(0, 0, 0)) self.run_hourly(self.on_refresh_cal,datetime.time(0, 0, 0))
self.sensor_wakeup_datetime_entity = self.create_entity("sensor.wakeup_datetime", icon="mdi:alarm") self.sensor_wakeup_datetime_entity = self.create_entity(self.sensor_wakeup_datetime_sensor, icon="mdi:alarm")
#sensor_wakeup_datetime_entity = self.get_entity(self.sensor_wakeup_datetime) self.workday_entity = self.create_entity(self.output_workday_sensor, icon="mdi:briefcase-check")
#if not sensor_wakeup_datetime_entity.exists():
# sensor_wakeup_datetime_entity.add()
self.calendar = list() self.calendar = list()
self.refresh_cal() self.refresh_cal()
self.update_workday_status()
self.update_wakeup_time() self.update_wakeup_time()
def refresh_cal(self): def refresh_cal(self):
new_cal = list() with self.log_group("refresh_cal"):
#if self.load_cal_from_google_calendar(new_cal): new_cal = list()
if self.load_cal_from_ics(new_cal): #if self.load_cal_from_google_calendar(new_cal):
if not self.are_cal_identical(self.calendar,new_cal): if self.load_cal_from_ics(new_cal):
self.calendar = new_cal self.calendar_last_success = datetime.datetime.now(datetime.timezone.utc)
self.log("Calendar has been updated!") self.calendar_retry_attempt = 0
self.print_cal(self.calendar) self._cancel_calendar_retry()
return True if not self.are_cal_identical(self.calendar,new_cal):
self.calendar = new_cal
self.log_info("Calendar has been updated!")
self.print_cal(self.calendar)
return True
return False else:
self._schedule_calendar_retry()
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
@@ -86,29 +147,30 @@ class AlarmClock(SmartObject):
def print_cal(self,calendar): def print_cal(self,calendar):
for event in calendar: for event in calendar:
self.log(f"{event}") self.log_info(f"{event}")
def load_cal_from_ics(self,calendar): def load_cal_from_ics(self,calendar):
calendar.clear() calendar.clear()
try: try:
self.log(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)
#self.log(f"Processing Calendar") r.raise_for_status()
#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))
start_load_date = today - datetime.timedelta(days=1) start_load_date = today - datetime.timedelta(days=1)
end_load_date = today + datetime.timedelta(days=2) end_load_date = today + datetime.timedelta(days=2)
#self.log("[" + 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')
if event_summary == "Away" or event_summary == "Absent(e)": event_type = EventType.Vacation if event_summary == "Away" or event_summary == "Absent(e)": event_type = EventType.Vacation
elif event_summary == "Busy" or event_summary == "Occupé(e)": event_type = EventType.Busy elif event_summary == "Busy" or event_summary == "Occupé(e)": event_type = EventType.Busy
elif event_summary == "Tentative" or event_summary == "Provisoire": event_type = EventType.Tentative elif event_summary == "Tentative" or event_summary == "Provisoire": event_type = EventType.Tentative
elif event_summary == "Working elsewhere" or event_summary == "Free": event_type = EventType.Flex elif event_summary == "Working elsewhere" or event_summary == "Free" or event_summary == "Disponible": event_type = EventType.Flex
else: event_type = None else: event_type = None
start_datetime = component.get('dtstart').dt start_datetime = component.get('dtstart').dt
@@ -135,25 +197,45 @@ class AlarmClock(SmartObject):
if not outside_of_loading_period: if not outside_of_loading_period:
if event_type: if event_type:
calendar.append(CalendarEvent(start_datetime,end_datetime,event_type)) calendar.append(CalendarEvent(start_datetime,end_datetime,event_type))
else: self.log(f"[Unknow event : {event_summary}]Start at {start_datetime},Finish at {end_datetime}",level = "WARNING") 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("Couldn't load calendar, request Timeout",level = "WARNING") 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(f"Calendar loaded") #self.log_info(f"Calendar loaded")
return True return True
def on_alarm_clock_set(self, entity, attribute, old, new, kwargs): def on_alarm_clock_set(self, entity, attribute, old, new, kwargs):
self.update_wakeup_time() self.update_wakeup_time()
def on_refresh_cal(self, kwargs): def on_refresh_cal(self, kwargs):
if self.refresh_cal(): self._cancel_calendar_retry()
self.calendar_retry_attempt = 0
calendar_changed = self.refresh_cal()
self.update_workday_status()
if calendar_changed:
self.update_wakeup_time() self.update_wakeup_time()
def update_workday_status(self):
with self.log_group("update_workday_status"):
is_workday = self.is_wakeup_time_on_a_working_day(datetime.datetime.now())
new_state = "on" if is_workday else "off"
if self.workday_entity.get_state() != new_state:
self.workday_entity.set_state(state=new_state)
def on_program_next_wakeup(self, kwargs): def on_program_next_wakeup(self, kwargs):
self.handle_program_next_wakueup_cb = None self.handle_program_next_wakueup_cb = None
@@ -161,12 +243,12 @@ class AlarmClock(SmartObject):
#le reveil principal se reactive chaque matin #le reveil principal se reactive chaque matin
if self.get_state(self.switch_alarmclock_main) == 'off': if self.get_state(self.switch_alarmclock_main) == 'off':
self.log("Turning on main alarm clock") self.log_info("Turning on main alarm clock")
no_switch_changed = False no_switch_changed = False
self.turn_on(self.switch_alarmclock_main) self.turn_on(self.switch_alarmclock_main)
#le reveil secondaire ne sonne qu'une fois, il est donc desactivé chaque matin #le reveil secondaire ne sonne qu'une fois, il est donc desactivé chaque matin
if self.get_state(self.switch_alarmclock_secondary) == 'on': if self.get_state(self.switch_alarmclock_secondary) == 'on':
self.log("Turning off secondary alarm clock") self.log_info("Turning off secondary alarm clock")
no_switch_changed = False no_switch_changed = False
self.turn_off(self.switch_alarmclock_secondary) self.turn_off(self.switch_alarmclock_secondary)
@@ -185,7 +267,7 @@ class AlarmClock(SmartObject):
wakeup_message = "On se reveil dans " + str(wakeup_in) + " minutes" wakeup_message = "On se reveil dans " + str(wakeup_in) + " minutes"
self.log(wakeup_message) self.log_info(wakeup_message)
if self.sleeping_condition.evaluate() == SmartCondition.Result.Succeeded: self.fire_event("WAKEUP_IN", wakeup_in = wakeup_in) if self.sleeping_condition.evaluate() == SmartCondition.Result.Succeeded: self.fire_event("WAKEUP_IN", wakeup_in = wakeup_in)
#self.call_service("notify/ios_iphone_de_pierre", title = "Reveil", message = wakeup_message) #self.call_service("notify/ios_iphone_de_pierre", title = "Reveil", message = wakeup_message)
@@ -194,7 +276,7 @@ class AlarmClock(SmartObject):
wakeup_message = "WAKE UP!!!! It's " + self.wakeup_time_str wakeup_message = "WAKE UP!!!! It's " + self.wakeup_time_str
self.log(wakeup_message) self.log_info(wakeup_message)
if self.sleeping_condition.evaluate() == SmartCondition.Result.Succeeded: self.fire_event("WAKEUP") if self.sleeping_condition.evaluate() == SmartCondition.Result.Succeeded: self.fire_event("WAKEUP")
#self.call_service("notify/ios_iphone_de_pierre", title = "Reveil", message = wakeup_message) #self.call_service("notify/ios_iphone_de_pierre", title = "Reveil", message = wakeup_message)
@@ -237,11 +319,11 @@ class AlarmClock(SmartObject):
wake_up_datetime = busy_event.start_time - datetime.timedelta(minutes=30) wake_up_datetime = busy_event.start_time - datetime.timedelta(minutes=30)
new_wake_up_time = wake_up_datetime.strftime("%H:%M") new_wake_up_time = wake_up_datetime.strftime("%H:%M")
self.log(f"A meeting have been found at {busy_event.start_time}, we'll wake up at {new_wake_up_time}") self.log_info(f"A meeting have been found at {busy_event.start_time}, we'll wake up at {new_wake_up_time}")
return new_wake_up_time return new_wake_up_time
new_wake_up_time = wakeup_datetime.strftime("%H:%M") new_wake_up_time = wakeup_datetime.strftime("%H:%M")
#self.log(f"No meeting have been found before {wake_up_time}, we'll wake up at {wake_up_time}") #self.log_info(f"No meeting have been found before {wake_up_time}, we'll wake up at {wake_up_time}")
return new_wake_up_time return new_wake_up_time
def calculate_wakeup_time(self): def calculate_wakeup_time(self):
@@ -255,10 +337,10 @@ class AlarmClock(SmartObject):
new_wakeup_time = self.get_state(self.inputdate_alarmclock_main)[0:5] new_wakeup_time = self.get_state(self.inputdate_alarmclock_main)[0:5]
new_wakeup_datetime = self.generate_wakeup_datetime_from_string(new_wakeup_time) new_wakeup_datetime = self.generate_wakeup_datetime_from_string(new_wakeup_time)
if not self.is_wakeup_time_on_a_working_day(new_wakeup_datetime): if not self.is_wakeup_time_on_a_working_day(new_wakeup_datetime):
self.log(f"Primary Wake up time ({new_wakeup_datetime}) is not on a working day") self.log_info(f"Primary Wake up time ({new_wakeup_datetime}) is not on a working day")
new_wakeup_time = "--:--" new_wakeup_time = "--:--"
elif self.is_wakeup_time_on_a_flex_day(max_flex_wakeup_datetime): elif self.is_wakeup_time_on_a_flex_day(max_flex_wakeup_datetime):
self.log(f"Flex Wake up time ({max_flex_wakeup_datetime}) is on a flex office day") self.log_info(f"Flex Wake up time ({max_flex_wakeup_datetime}) is on a flex office day")
new_wakeup_time = self.offset_wakeup_time_for_meeting(max_flex_wakeup_datetime) new_wakeup_time = self.offset_wakeup_time_for_meeting(max_flex_wakeup_datetime)
else: else:
new_wakeup_time = self.offset_wakeup_time_for_meeting(new_wakeup_datetime) new_wakeup_time = self.offset_wakeup_time_for_meeting(new_wakeup_datetime)
@@ -279,15 +361,15 @@ class AlarmClock(SmartObject):
with self.lock: with self.lock:
#first, cancel all previous callbacks #first, cancel all previous callbacks
if self.handle_program_next_wakueup_cb: if self.handle_program_next_wakueup_cb:
self.log("Canceling previous program next wakeup cb") self.log_info("Canceling previous program next wakeup cb")
self.cancel_timer(self.handle_program_next_wakueup_cb) self.cancel_timer(self.handle_program_next_wakueup_cb)
self.handle_program_next_wakueup_cb = None self.handle_program_next_wakueup_cb = None
if self.handle_prewakueup_cb: if self.handle_prewakueup_cb:
self.log("Canceling previous pre wakeup cb") self.log_info("Canceling previous pre wakeup cb")
self.cancel_timer(self.handle_prewakueup_cb) self.cancel_timer(self.handle_prewakueup_cb)
self.handle_prewakueup_cb = None self.handle_prewakueup_cb = None
if self.handle_wakueup_cb: if self.handle_wakueup_cb:
self.log("Canceling previous wakeup cb") self.log_info("Canceling previous wakeup cb")
self.cancel_timer(self.handle_wakueup_cb) self.cancel_timer(self.handle_wakueup_cb)
self.handle_wakueup_cb = None self.handle_wakueup_cb = None
@@ -300,7 +382,7 @@ class AlarmClock(SmartObject):
if datetime.datetime.now().hour >= 4: if datetime.datetime.now().hour >= 4:
program_next_wakeup_time = program_next_wakeup_time + datetime.timedelta(days=1) program_next_wakeup_time = program_next_wakeup_time + datetime.timedelta(days=1)
self.log("program next wakeup cb registered for " + str(program_next_wakeup_time)) self.log_info("program next wakeup cb registered for " + str(program_next_wakeup_time))
self.handle_program_next_wakueup_cb = self.run_at(self.on_program_next_wakeup, program_next_wakeup_time) self.handle_program_next_wakueup_cb = self.run_at(self.on_program_next_wakeup, program_next_wakeup_time)
self.sensor_wakeup_datetime_entity.set_state(state = 'unavailable') self.sensor_wakeup_datetime_entity.set_state(state = 'unavailable')
else: else:
@@ -315,19 +397,18 @@ class AlarmClock(SmartObject):
prewakeup_datetime = wakeup_datetime - datetime.timedelta(minutes=prewakeup_offset) prewakeup_datetime = wakeup_datetime - datetime.timedelta(minutes=prewakeup_offset)
self.handle_prewakueup_cb = self.run_at(self.on_pre_wakeup, prewakeup_datetime,wakeup_in = prewakeup_offset) self.handle_prewakueup_cb = self.run_at(self.on_pre_wakeup, prewakeup_datetime,wakeup_in = prewakeup_offset)
self.log("prewakup cb registered for " + str(prewakeup_datetime)) self.log_info("prewakup cb registered for " + str(prewakeup_datetime))
self.handle_wakueup_cb = self.run_at(self.on_wakeup, wakeup_datetime) self.handle_wakueup_cb = self.run_at(self.on_wakeup, wakeup_datetime)
self.log("wakeup cb registered for " + str(wakeup_datetime)) self.log_info("wakeup cb registered for " + str(wakeup_datetime))
self.handle_program_next_wakueup_cb = self.run_at(self.on_program_next_wakeup, program_next_wakeup_datetime) self.handle_program_next_wakueup_cb = self.run_at(self.on_program_next_wakeup, program_next_wakeup_datetime)
self.log("program next wakeup cb registered for " + str(program_next_wakeup_datetime)) self.log_info("program next wakeup cb registered for " + str(program_next_wakeup_datetime))
self.wakeup_time_str = wakeup_time_str self.wakeup_time_str = wakeup_time_str
self.call_service("input_text/set_value", entity_id = self.input_text_wakeup_time, value = self.wakeup_time_str) #self.call_service("input_text/set_value", entity_id = self.input_text_wakeup_time, value = self.wakeup_time_str)
def update_wakeup_time(self): def update_wakeup_time(self):
self.register_callbacks(self.calculate_wakeup_time()) with self.log_group("update_wakeup_time"):
self.register_callbacks(self.calculate_wakeup_time())
+4
View File
@@ -18,6 +18,10 @@ informations_collector:
reset_error_count_button: input_button.reset_error_count reset_error_count_button: input_button.reset_error_count
output_sensor: sensor.ad_errors output_sensor: sensor.ad_errors
warning_collector:
reset_warning_count_button: input_button.reset_error_count
output_sensor: sensor.ad_warnings
min_max_temp_sensors: min_max_temp_sensors:
temperature_sensors: temperature_sensors:
- sensor.ext_front_temperature - sensor.ext_front_temperature
+333
View File
@@ -0,0 +1,333 @@
"""MQTT-discovered climate using AppDaemon's MQTT plugin and HA sensors."""
from datetime import datetime, timezone
import json
from pathlib import Path
import time
import appdaemon.plugins.hass.hassapi as hass
import ad_toolbox.smartcondition as SmartCondition
from boilerregulator import BoilerRegulator, finite, setmode
from logger_interface import LoggerInterface
class BoilerClimate(hass.Hass, LoggerInterface):
def initialize(self):
self.initialize_logger_interface(self.get_ad_api(), ascii_encode=False)
self.mqtt_namespace = self.args.get('mqtt_namespace', 'mqtt')
if self.AD.plugins.get_plugin_object(self.mqtt_namespace) is None:
raise RuntimeError('MQTT plugin missing; configure it and restart AppDaemon')
self.base = self.args.get('mqtt_topic', 'appdaemon/radiator_heating').rstrip('/')
self.boiler = self.args.get('boiler_topic', 'themaplus-condens').rstrip('/')
self.connection_topic = self.args.get('mqtt_connection_topic', self.base + '/connection')
self.uid = self.args.get('unique_id', 'appdaemon_radiator_heating')
self.entity_id = self.args.get('entity', 'climate.radiator_heating')
self.title = self.args.get('display_name', 'Chauffage radiateurs')
self.indoor = self.args['indoor_sensor']
outdoors = self.args.get('outdoor_sensors')
if outdoors is None:
outdoors = [self.args['outdoor_sensor']]
if not isinstance(outdoors, list) or not outdoors or any(not isinstance(e, str) or not e for e in outdoors):
raise ValueError('outdoor_sensors must be a non-empty list of entity IDs')
self.outdoors = tuple(dict.fromkeys(outdoors))
self.selected_outdoor = None
self.interval = finite(self.args.get('command_interval', 60))
if not 10 <= self.interval <= 300:
raise ValueError('command_interval must be between 10 and 300 seconds')
self.min_target, self.max_target = 10.0, 23.0
self.target = finite(self.args.get('target_temperature', 18))
self.mode = self.args.get('initial_mode', 'off')
if self.mode not in ('off', 'heat') or not self.min_target <= self.target <= self.max_target:
raise ValueError('Invalid initial mode or room target')
self.send_commands = self.args.get('send_commands', False) is True
self.controller = BoilerRegulator(**self.args.get('regulation', {}))
self.state_file = Path(str(self.AD.app_dir)) / 'data' / (self.uid + '.json')
self._restore()
self.previous_time = time.monotonic()
self.last_send = 0.0
self.last_payload = None
self.last_command_at = None
self.last_echo_at = None
self.telemetry = {}
self.sensor_error = None
self.last_status_time = 0.0
self._state = None
self.listen_event(self._mqtt_event, 'MQTT_MESSAGE', namespace=self.mqtt_namespace)
self.listen_state(self._sensor_changed, self.indoor, attribute='all')
for entity in self.outdoors:
self.listen_state(self._sensor_changed, entity, attribute='all')
self._discovery()
self._initialize_smart_conditions()
self.run_every(self._tick, 'now', self.interval)
self._control()
self.log_info(f'Boiler climate ready: MQTT writes={self.send_commands}, room target={self.target} °C')
def _initialize_smart_conditions(self, kwargs=None):
if getattr(self, 'smart_conditions_evaluator', None) is not None:
return
self.smart_conditions_evaluator = None
if 'smart_conditions' in self.args:
if not self.entity_exists(self.entity_id) or self.get_state(self.entity_id, attribute='temperature') is None:
self.mode = 'off'
self.run_in(self._initialize_smart_conditions, 1)
return
constants = {**self.args.get('constants', {}), 'self': self.entity_id}
self.smart_conditions_evaluator = SmartCondition.Evaluator(
self, self.args['smart_conditions'], condition_name='smart_conditions',
on_change_cb=self.on_smart_conditions_change,
constants=constants)
def on_smart_conditions_change(self, previous, result):
if result == SmartCondition.Result.Disabled:
return
mode = 'heat' if result == SmartCondition.Result.Succeeded else 'off'
if mode != self.mode:
self._set_mode(mode, 'SmartCondition')
def _set_mode(self, mode, source):
self.log_info(f'Climate mode: {self.mode} -> {mode} ({source})')
self.mode = mode
self.controller.reset()
self._control(force=True)
self._save_safely()
def _publish(self, topic, payload, retain=False):
if not isinstance(payload, str):
payload = json.dumps(payload, allow_nan=False, ensure_ascii=False)
self.call_service('mqtt/publish', topic=topic, payload=payload, qos=0,
retain=retain, namespace=self.mqtt_namespace)
def _discovery(self):
device = dict(identifiers=[self.uid], name=self.title,
manufacturer='AppDaemon', model='Weather compensation + room PI')
common = dict(device=device, availability=[{'topic': self.base + '/availability'},
{'topic': self.connection_topic}],
availability_mode='all')
config = dict(common, name=None, unique_id=self.uid,
default_entity_id=self.entity_id,
modes=['off', 'heat'], temperature_unit='C', precision=0.1,
temp_step=0.5, min_temp=self.min_target, max_temp=self.max_target,
mode_command_topic=self.base + '/command/mode',
temperature_command_topic=self.base + '/command/temperature',
mode_state_topic=self.base + '/state',
mode_state_template='{{ value_json.mode }}',
temperature_state_topic=self.base + '/state',
temperature_state_template='{{ value_json.target }}',
current_temperature_topic=self.base + '/state',
current_temperature_template='{{ value_json.indoor }}',
action_topic=self.base + '/state', action_template='{{ value_json.action }}',
json_attributes_topic=self.base + '/attributes', retain=False, qos=0)
self._publish('homeassistant/climate/' + self.uid + '/config', config, True)
for key, label in [('flow_requested', 'Température eau demandée'),
('curve', 'Loi d’eau'), ('proportional', 'Correction proportionnelle'),
('integral', 'Correction intégrale')]:
self._publish('homeassistant/sensor/' + self.uid + '/' + key + '/config',
dict(common, name=label, unique_id=self.uid + '_' + key,
state_topic=self.base + '/state',
value_template='{{ value_json.' + key + ' }}',
unit_of_measurement='°C', entity_category='diagnostic'), True)
self._publish('homeassistant/sensor/' + self.uid + '/reason/config',
dict(device=device, name='État de la régulation', unique_id=self.uid + '_reason',
state_topic=self.base + '/state', value_template='{{ value_json.reason }}',
json_attributes_topic=self.base + '/state',
json_attributes_template='{{ {"sensor_error": value_json.sensor_error} | tojson }}',
expire_after=180, entity_category='diagnostic'), True)
self._publish('homeassistant/sensor/' + self.uid + '/heartbeat/config',
dict(name='Heartbeat', device=device, unique_id=self.uid + '_heartbeat',
state_topic=self.base + '/heartbeat', expire_after=180,
entity_category='diagnostic', device_class='timestamp'), True)
def _restore(self):
try:
data = json.loads(self.state_file.read_text())
if not isinstance(data, dict) or data.get('mode') not in ('heat', 'off'):
raise ValueError('Invalid saved climate mode')
self.mode = data['mode']
target = finite(data['target'])
if not self.min_target <= target <= self.max_target:
raise ValueError('Saved room target out of bounds')
self.target = target
except FileNotFoundError:
pass # First start uses the configured initial mode and target.
except (OSError, ValueError, KeyError, TypeError) as exc:
self.mode = 'off'
self.log_warning(f'Could not restore climate state; heating remains off: {exc}')
def _save(self):
self.state_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self.state_file.with_suffix('.tmp')
tmp.write_text(json.dumps(dict(mode=self.mode, target=self.target)))
tmp.replace(self.state_file)
def _save_safely(self):
try:
self._save()
except OSError as exc:
self.log_error(f'Could not save climate state; boiler control continues: {exc}')
def _sensor_value(self, entity):
data = self.get_state(entity, attribute='all')
if not isinstance(data, dict):
raise ValueError(f'{entity}: sensor missing')
attributes = data.get('attributes', {})
name = attributes.get('friendly_name')
label = f'{entity} ({name})' if name else entity
if attributes.get('unit_of_measurement') != '°C':
raise ValueError(f'{label}: expected Celsius unit')
try:
value = finite(data.get('state'))
except (ValueError, TypeError):
raise ValueError(f"{label}: invalid state {data.get('state')!r}") from None
if not -40 <= value <= 60:
raise ValueError(f'{label}: implausible temperature {value} °C')
return value
def _sensor_changed(self, entity, attribute, old, new, kwargs):
self._control()
def _tick(self, kwargs=None):
self._control()
def _mqtt_event(self, event, data, kwargs):
topic, payload = data.get('topic'), data.get('payload', '')
if topic is None:
if data.get('state') == 'Connected':
self._discovery()
self._control(force=True)
return
if topic == 'homeassistant/status' and payload == 'online':
self._discovery()
self._control()
return
if topic == self.base + '/command/mode':
if payload not in ('off', 'heat'):
self.log_warning('Rejected invalid climate mode')
return
self._set_mode(payload, 'MQTT')
elif topic == self.base + '/command/temperature':
try:
target = finite(payload)
if not self.min_target <= target <= self.max_target:
raise ValueError('Target out of bounds')
except (ValueError, TypeError):
self.log_warning('Rejected invalid room target')
return
self.target = target
self.controller.reset()
self._control(force=True)
self._save_safely()
elif topic and topic.startswith(self.boiler + '/bai/'):
try:
value = payload if isinstance(payload, dict) else json.loads(payload)
if not isinstance(value, dict):
return
except (ValueError, TypeError):
return
name = topic.rsplit('/', 1)[-1]
self.telemetry[name] = value
if name == 'Statenumber':
self.last_status_time = time.monotonic()
if name == 'SetMode' and self.last_payload is not None:
if all(value.get(k) == v for k, v in self.last_payload.items()):
self.last_echo_at = datetime.now(timezone.utc).isoformat()
# Telemetry is diagnostic only: it never renews a boiler command.
if name == 'Statenumber':
self._publish_state()
self._attributes()
def _publish_state(self, now=None):
if getattr(self, '_state', None) is None:
return
now = time.monotonic() if now is None else now
status = self.telemetry.get('Statenumber', {}).get('value')
heating = status in (4, '4') and now - self.last_status_time < 180
self._state['action'] = 'off' if self.mode == 'off' else ('heating' if heating else 'idle')
self._publish(self.base + '/state', self._state.copy(), True)
def _attributes(self):
self._publish(self.base + '/attributes', dict(
indoor_sensor=self.indoor, outdoor_sensors=list(self.outdoors),
outdoor_sensor=self.selected_outdoor,
commands_enabled=self.send_commands,
sensor_error=getattr(self, 'sensor_error', None),
last_command_at=self.last_command_at, last_c6_echo_at=self.last_echo_at,
last_command=self.last_payload, boiler_telemetry=self.telemetry,
note='C6 echo confirms transmission, not effective boiler setpoint'), True)
def _control(self, force=False):
now = time.monotonic()
dt = min(max(0, now - self.previous_time), self.interval * 2)
self.previous_time = now
indoor = outdoor = None
result = None
readings, errors = {}, []
sensors = (self.indoor, *self.outdoors)
for entity in sensors:
try:
readings[entity] = self._sensor_value(entity)
except (ValueError, TypeError, KeyError) as exc:
errors.append(str(exc))
indoor = readings.get(self.indoor)
self.selected_outdoor = (min(self.outdoors, key=readings.__getitem__)
if all(entity in readings for entity in self.outdoors) else None)
outdoor = readings.get(self.selected_outdoor)
valid = not errors
sensor_error = '; '.join(errors) if errors else None
# Log once per set of failing sensors, not every minute as age increases.
signature = tuple((entity, entity in readings) for entity in sensors)
previous = getattr(self, '_sensor_fault_signature', None)
if not valid and signature != previous:
self.log_error(f'Heating inhibited: sensor fault: {sensor_error}')
elif valid and previous is not None and previous != signature:
self.log_info('Temperature sensors recovered; regulation can resume. '
f"Previous fault: {getattr(self, 'sensor_error', None)}")
self._sensor_fault_signature = signature
self.sensor_error = sensor_error
if self.mode == 'off' or not valid:
self.controller.reset()
reason = 'off' if self.mode == 'off' else 'invalid_sensor'
enabled, flow = False, 0
else:
result = self.controller.update(self.target, indoor, outdoor, dt)
enabled, flow, reason = result.enabled, result.flow, result.reason
command = setmode(flow, enabled)
if self.send_commands and (force or command != self.last_payload or now - self.last_send >= self.interval):
self._publish(self.boiler + '/bai/SetMode/set', command, False)
if command != self.last_payload:
self.log_info(f"Heating request: enabled={enabled}, flow={command['flowtempdesired']} °C, reason={reason}")
self.last_payload, self.last_send = command, now
self.last_command_at = datetime.now(timezone.utc).isoformat()
self._state = dict(mode=self.mode, target=self.target, indoor=indoor,
outdoor=outdoor, reason=reason, sensor_error=self.sensor_error,
heating_requested=enabled, flow_requested=flow if enabled else 0,
curve=round(result.curve, 2) if result else 0,
proportional=round(result.proportional, 2) if result else 0,
integral=round(result.integral, 3) if result else 0)
self._publish_state(now)
self._attributes()
self._publish(self.base + '/availability', 'online' if valid else 'offline', True)
self._publish(self.base + '/heartbeat', datetime.now(timezone.utc).isoformat())
# Request actual status, without writing or renewing SetMode.
self._publish(self.boiler + '/bai/Statenumber/get', {})
self._publish(self.boiler + '/bai/FlowTemp/get', {})
def terminate(self):
# An orderly shutdown explicitly inhibits space heating; a crash relies
# on boiler command expiration. No retained actuator command, ever.
if not hasattr(self, 'base'):
return
evaluator = getattr(self, 'smart_conditions_evaluator', None)
try:
if self.send_commands:
self._publish(self.boiler + '/bai/SetMode/set', setmode(0, False))
except Exception as exc:
self.log_error(f'Could not publish shutdown; boiler expiration remains the fallback: {exc}')
if evaluator is not None:
try:
evaluator.deactivate()
except Exception as exc:
self.log_warning(f'Could not deactivate SmartCondition: {exc}')
try:
self._publish(self.base + '/availability', 'offline', True)
except Exception as exc:
self.log_warning(f'Could not publish offline availability: {exc}')
self._save_safely()
+86
View File
@@ -0,0 +1,86 @@
"""Weather compensation and room PI correction, expressed in water degrees."""
from dataclasses import dataclass
import math
def finite(value):
value = float(value)
if not math.isfinite(value):
raise ValueError('Temperature must be finite')
return value
def clamp(value, low, high):
return max(low, min(high, value))
@dataclass
class Regulation:
flow: float
enabled: bool
curve: float
proportional: float
integral: float
reason: str
class BoilerRegulator:
def __init__(self, *, slope=1.5, offset=10, kp=5, ki=0.2,
integral_limit=8, correction_limit=12, min_flow=30,
max_flow=50, overheat=0.5, restart_margin=0.1):
self.slope, self.offset = finite(slope), finite(offset)
self.kp, self.ki = finite(kp), finite(ki)
self.integral_limit, self.correction_limit = finite(integral_limit), finite(correction_limit)
self.min_flow, self.max_flow = finite(min_flow), finite(max_flow)
self.overheat, self.restart_margin = finite(overheat), finite(restart_margin)
if not (0 <= self.min_flow < self.max_flow <= 80):
raise ValueError('Require 0 <= min_flow < max_flow <= 80')
if min(self.slope, self.kp, self.ki, self.integral_limit, self.correction_limit) < 0:
raise ValueError('Gains and limits must be non-negative')
if not 0 <= self.restart_margin < self.overheat:
raise ValueError('Require 0 <= restart_margin < overheat')
self.integral = 0.0
self.enabled = False
def reset(self):
self.integral = 0.0
self.enabled = False
def update(self, target, indoor, outdoor, dt):
target, indoor, outdoor, dt = map(finite, (target, indoor, outdoor, dt))
error = target - indoor
curve = target + self.slope * (target - outdoor) + self.offset
proportional = self.kp * error
# ki is water °C / (room °C * hour). No accumulated downtime.
candidate = clamp(self.integral + self.ki * error * max(0, dt) / 3600,
-self.integral_limit, self.integral_limit)
raw = curve + clamp(proportional + candidate, -self.correction_limit, self.correction_limit)
# Anti-windup for BOTH the water bounds and the correction bound.
saturated_high = raw >= self.max_flow or proportional + candidate >= self.correction_limit
saturated_low = raw <= self.min_flow or proportional + candidate <= -self.correction_limit
if not ((saturated_high and error > 0) or (saturated_low and error < 0)):
self.integral = candidate
raw = curve + clamp(proportional + self.integral, -self.correction_limit, self.correction_limit)
if indoor >= target + self.overheat:
self.enabled = False
reason = 'room_overheat'
elif raw <= self.min_flow - 1:
self.enabled = False
reason = 'low_demand'
elif not self.enabled:
if indoor <= target + self.restart_margin and raw >= self.min_flow + 1:
self.enabled = True
reason = 'regulating' if self.enabled else 'waiting_for_demand'
else:
reason = 'regulating'
flow = round(clamp(raw, self.min_flow, self.max_flow) * 2) / 2
return Regulation(flow, self.enabled, curve, proportional, self.integral, reason)
def setmode(flow, enabled):
"""Full C6 SetMode payload. No new domestic hot-water setpoint."""
return dict(hcmode='auto', flowtempdesired=finite(flow) if enabled else 0,
hwctempdesired=None, hwcflowtempdesired=None,
disablehc=0 if enabled else 1, disablehwctapping=0,
disablehwcload=0, remotecontrolhcpump=0,
releasebackup=0, releasecooling=0)
+50
View File
@@ -0,0 +1,50 @@
"""Independent AppDaemon worker monitoring the boiler climate heartbeat."""
import time
import appdaemon.plugins.hass.hassapi as hass
from logger_interface import LoggerInterface
from boilerregulator import finite
class BoilerWatchdog(hass.Hass, LoggerInterface):
def initialize(self):
self.initialize_logger_interface(self.get_ad_api(), ascii_encode=False)
self.base = self.args.get('mqtt_topic', 'appdaemon/radiator_heating').rstrip('/')
self.mqtt_namespace = self.args.get('mqtt_namespace', 'mqtt')
self.timeout = finite(self.args.get('heartbeat_timeout', 180))
if self.timeout < 60:
raise ValueError('heartbeat_timeout must be at least 60 seconds')
if self.AD.plugins.get_plugin_object(self.mqtt_namespace) is None:
raise RuntimeError('MQTT plugin missing; configure it and restart AppDaemon')
self.last_heartbeat = None
self.waiting_since = time.monotonic()
self.fault = False
self.warned = False
self.listen_event(self._mqtt_event, 'MQTT_MESSAGE', namespace=self.mqtt_namespace)
self.run_every(self._check, 'now', 10)
self._check()
def _mqtt_event(self, event, data, kwargs):
if data.get('topic') == self.base + '/heartbeat':
self.last_heartbeat = time.monotonic()
if self.warned:
self.log_info('Boiler climate heartbeat recovered')
self.fault = False
self.warned = False
elif data.get('topic') is None and data.get('state') == 'Connected':
self.last_heartbeat = None
self.waiting_since = time.monotonic()
self.fault = False
self.warned = False
self._check()
def _check(self, kwargs=None):
age = time.monotonic() - (self.waiting_since if self.last_heartbeat is None else self.last_heartbeat)
if self.last_heartbeat is None or age >= self.timeout:
if not self.fault:
self.call_service('mqtt/publish', namespace=self.mqtt_namespace,
topic=self.base + '/availability', payload='offline', qos=0, retain=True)
self.fault = True
if age >= self.timeout and not self.warned:
self.log_warning('Boiler climate unavailable: no heartbeat within the timeout')
self.warned = True
+62 -8
View File
@@ -5,7 +5,9 @@ heating_sensors:
sensors: sensors:
binary_sensor.heatpump_winter_mode: binary_sensor.heatpump_winter_mode:
trigger_conditions: false #sensor.month >= 10 or sensor.month <= 4 local_expressions:
temp_threshold: "self ? 20 : 15"
trigger_conditions: sensor.min_temp_outside < temp_threshold
value_selector.target_temperature_heatpump: value_selector.target_temperature_heatpump:
attributes: attributes:
unit_of_measurement: °C unit_of_measurement: °C
@@ -14,7 +16,7 @@ heating_sensors:
values: values:
winter_mode: winter_mode:
conditions: binary_sensor.heatpump_winter_mode conditions: binary_sensor.heatpump_winter_mode
value: 18 value: input_number.heatpump_temperature_winter
default: default:
conditions: True conditions: True
value: input_number.heatpump_temperature value: input_number.heatpump_temperature
@@ -31,10 +33,11 @@ smart_heating_templates_library:
trigger_conditions: trigger_conditions:
- "not binary_sensor.heatpump_winter_mode and (ext_temperature >= room_temperature or ext_temperature >= target_temperature or cant_open_window) and room_temperature >= (quick_resume ? target_temperature : (target_temperature + temp_threshold))" - "not binary_sensor.heatpump_winter_mode and (ext_temperature >= room_temperature or ext_temperature >= target_temperature or cant_open_window) and room_temperature >= (quick_resume ? target_temperature : (target_temperature + temp_threshold))"
- not binary_sensor.heatpump_winter_mode and self and (ext_temperature >= target_temperature or cant_open_window) - not binary_sensor.heatpump_winter_mode and self and (ext_temperature >= target_temperature or cant_open_window)
- binary_sensor.heatpump_winter_mode and room_temperature <= (target_temperature - 1) and ext_temperature <= 10 - binary_sensor.heatpump_winter_mode and room_temperature < target_temperature and ext_temperature <= target_temperature
- binary_sensor.heatpump_winter_mode and self and room_temperature < target_temperature - binary_sensor.heatpump_winter_mode and self and room_temperature < target_temperature + 1
blocking_conditions: blocking_conditions:
- binary_sensor.is_everybody_sleeping and not sleeping_in_the_room and not stay_on_while_sleeping - binary_sensor.is_everybody_sleeping and not sleeping_in_the_room and not stay_on_while_sleeping
- binary_sensor.is_everybody_sleeping and binary_sensor.heatpump_winter_mode
- not room_occupancy and not (sleeping_in_the_room or (occupied_tonight and binary_sensor.day_interval_night)) - not room_occupancy and not (sleeping_in_the_room or (occupied_tonight and binary_sensor.day_interval_night))
- not binary_sensor.someone_home - not binary_sensor.someone_home
- window_open - window_open
@@ -112,7 +115,7 @@ bedroom_heatpump:
auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1 auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1
quiet: True quiet: True
target_temperature: sensor.target_temperature_heatpump - 0.5 target_temperature: "sensor.target_temperature_heatpump - (binary_sensor.heatpump_winter_mode ? 1 : 0.5)"
hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'" hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'"
fan_mode: sensor.bedroom_heatpump_fan_mode fan_mode: sensor.bedroom_heatpump_fan_mode
@@ -147,7 +150,7 @@ living_room_heatpump:
constants: constants:
window_open: binary_sensor.living_room_any_window_open window_open: binary_sensor.living_room_any_window_open
ext_temperature: sensor.ext_front_temperature ext_temperature: sensor.ext_back_temperature
room_temperature: sensor.living_room_temperature room_temperature: sensor.living_room_temperature
room_occupancy: binary_sensor.living_room_occupancy room_occupancy: binary_sensor.living_room_occupancy
target_temperature: sensor.target_temperature_heatpump target_temperature: sensor.target_temperature_heatpump
@@ -157,7 +160,7 @@ living_room_heatpump:
occupied_tonight: input_boolean.living_room_occupied_tonight occupied_tonight: input_boolean.living_room_occupied_tonight
stay_on_while_sleeping: false stay_on_while_sleeping: false
target_temperature: sensor.target_temperature_heatpump + 0.5 target_temperature: "sensor.target_temperature_heatpump + (binary_sensor.heatpump_winter_mode ? 0 : 0.5)"
hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'" hvac_mode: "binary_sensor.heatpump_winter_mode ? 'heat' : 'cool'"
fan_mode: sensor.living_room_heatpump_fan_mode fan_mode: sensor.living_room_heatpump_fan_mode
@@ -216,4 +219,55 @@ guestroom_heatpump:
sensor: input_select.guestroom_heatpump_override sensor: input_select.guestroom_heatpump_override
reset_events: reset_events:
- good_morning - good_morning
- good_bye - good_bye
radiator_heating:
module: boilerclimate
class: BoilerClimate
entity: climate.radiator_heating
display_name: Chauffage radiateurs
unique_id: appdaemon_radiator_heating
indoor_sensor: sensor.corridor_temperature
outdoor_sensors:
- sensor.ext_front_temperature
- sensor.ext_back_temperature
target_temperature: 18
initial_mode: heat
smart_conditions:
unavaibility_result: Failed #if something is unavailable in the expression, the radiator stay off
local_expressions:
outdoor_temperature: "sensor.ext_front_temperature < sensor.ext_back_temperature ? sensor.ext_front_temperature : sensor.ext_back_temperature"
trigger_conditions: binary_sensor.someone_home and outdoor_temperature < self.temperature
blocking_conditions:
- binary_sensor.group_windows
- binary_sensor.kitchen_french_door or binary_sensor.living_room_french_door or binary_sensor.bedroom_french_door
# Radiator heating enabled with the room-thermostat bridge CLOSED.
# false keeps the climate/diagnostics but suppresses SetMode writes.
send_commands: true
command_interval: 60
mqtt_topic: appdaemon/radiator_heating
mqtt_namespace: mqtt
mqtt_connection_topic: appdaemon/radiator_heating/connection
boiler_topic: themaplus-condens
# Starting values, to tune from observations with radiator circulation enabled.
regulation:
slope: 1.5
offset: 10
kp: 5
ki: 0.2 # water °C / (room °C * hour)
integral_limit: 8
correction_limit: 12
min_flow: 30
max_flow: 50
overheat: 0.5
restart_margin: 0.1
radiator_heating_watchdog:
module: boilerwatchdog
class: BoilerWatchdog
mqtt_namespace: mqtt
mqtt_topic: appdaemon/radiator_heating
heartbeat_timeout: 180
+231 -42
View File
@@ -1,7 +1,12 @@
samsung_the_frame: samsung_the_frame:
module: samsungtheframe module: samsungtheframeasync
class: SamsungTheFrame class: SamsungTheFrame
volume_up_events:
- the_frame_volume_up
volume_down_events:
- the_frame_volume_down
ip_address: 10.0.0.58 ip_address: 10.0.0.58
#broadcast_address: 10.0.0.255 #broadcast_address: 10.0.0.255
#mac_address: 98:06:3C:85:93:38 #mac_address: 98:06:3C:85:93:38
@@ -14,22 +19,22 @@ samsung_the_frame:
virtual_sensors: virtual_sensors:
default_values: default_values:
binary_sensor.tv_auto_shutdown: false binary_sensor.tv_auto_shutdown: false
binary_sensor.tv_on: false binary_sensor.is_tv_on: false
sensors: sensors:
value_selector.samsung_the_frame_target_state: value_selector.samsung_the_frame_target_state:
power_off: power_off:
trigger_conditions: trigger_conditions:
- binary_sensor.is_everybody_sleeping or binary_sensor.is_living_room_sleeping or not binary_sensor.someone_home - binary_sensor.is_everybody_sleeping or binary_sensor.is_living_room_sleeping or not binary_sensor.someone_home
- binary_sensor.projector_on or binary_sensor.projector_screen_down - binary_sensor.projector_screen_down
hdmi: binary_sensor.vertex2_has_input_signal and not binary_sensor.projector_screen_down hdmi: binary_sensor.vertex2_has_input_signal and not binary_sensor.projector_screen_down
manual: binary_sensor.tv_on and not binary_sensor.tv_auto_shutdown manual: binary_sensor.is_tv_on and not binary_sensor.tv_auto_shutdown
tv_off: not binary_sensor.living_room_occupancy tv_off: not binary_sensor.living_room_occupancy
art_mode: True art_mode: True
tv_state: sensor.samsung_the_frame_target_state tv_state: sensor.samsung_the_frame_target_state
output_sensors: output_sensors:
is_on: binary_sensor.tv_on is_on: binary_sensor.is_tv_on
is_art_mode: binary_sensor.tv_in_art_mode is_art_mode: binary_sensor.tv_in_art_mode
tv_auto_shutdown: binary_sensor.tv_auto_shutdown tv_auto_shutdown: binary_sensor.tv_auto_shutdown
@@ -67,12 +72,30 @@ w4000i:
continuous_condition.projector_ready: continuous_condition.projector_ready:
conditions: switch.living_room_projector_plug conditions: switch.living_room_projector_plug
time: 30 time: 30
continuous_condition.has_allm_signal_for_a_while:
conditions: binary_sensor.vertex2_allm
time: 10
attributes_override: attributes_override:
icon: sensor.projector_icon icon: sensor.projector_icon
friendly_name: Projecteur friendly_name: Projecteur
press_enter_event: send_projector_enter press_enter_event: send_projector_enter
press_enter_events:
vertex2_allm:
event_name: state_changed
event_data:
entity_id: binary_sensor.has_allm_signal_for_a_while
new_state:
state: "on"
apple_tv_mute_short_press:
event_name: state_changed
event_data:
entity_id: binary_sensor.ir_bridge_apple_tv_mute_button_short_press
old_state:
state: "off"
new_state:
state: "on"
projector_ready_conditions: binary_sensor.projector_ready projector_ready_conditions: binary_sensor.projector_ready
@@ -80,10 +103,10 @@ w4000i:
#cooling_down_sensor: binary_sensor.projector_cooling_down #cooling_down_sensor: binary_sensor.projector_cooling_down
smart_conditions: smart_conditions:
trigger_conditions: binary_sensor.vertex2_has_input_signal trigger_conditions: binary_sensor.vertex2_has_input_signal and binary_sensor.projector_screen_down
disable_conditions: disable_conditions:
- self == 'unavailable' - self == 'unavailable'
- self == 'off' and not binary_sensor.projector_screen_down #- self == 'off' and not binary_sensor.projector_screen_down
#- not binary_sensor.projector_screen_down #- not binary_sensor.projector_screen_down
#- binary_sensor.projector_cooling_down #- binary_sensor.projector_cooling_down
- switch.projector.warming_up - switch.projector.warming_up
@@ -93,7 +116,33 @@ bravia_theatre:
class: SmartSwitch class: SmartSwitch
entity: media_player.bravia_theatre entity: media_player.bravia_theatre
smart_conditions: binary_sensor.vertex2_has_input_signal smart_conditions:
#trigger_conditions: binary_sensor.vertex2_has_input_signal
trigger_conditions: sensor.vertex2_output_selected == 'Projector' or input_boolean.use_bravia_theater_for_the_frame
blocking_conditions: not binary_sensor.vertex2_has_input_signal
projection_screen_switch:
module: smartswitch
class: SmartSwitch
entity: input_boolean.projection_screen
virtual_sensors:
sensors:
continuous_condition.projection_screen_on_for_a_while:
time: 300
conditions: input_boolean.projection_screen
off_conditions: binary_sensor.projection_screen_on_for_a_while and binary_sensor.projector_off_for_a_while
toggle_events:
apple_tv_mute_long_press:
event_name: state_changed
event_data:
entity_id: binary_sensor.ir_bridge_apple_tv_mute_button_long_press
old_state:
state: "off"
new_state:
state: "on"
projection_screen: projection_screen:
module: smartshutter module: smartshutter
@@ -101,19 +150,27 @@ projection_screen:
entity: cover.living_room_projection_screen entity: cover.living_room_projection_screen
close_position: 22 close_position: 22
close_conditions: # close mean screen down close_conditions: input_boolean.projection_screen
# when the projector is turn back on, it can happen that this condition is evaluated before projector_off_for_a_while is updated
# so I need to test both binary_sensor.projector_on and not binary_sensor.projector_off_for_a_while
trigger_conditions: binary_sensor.projector_on or not binary_sensor.projector_off_for_a_while
disable_conditions:
- binary_sensor.day_interval_night and not binary_sensor.projector_on
- binary_sensor.is_everybody_sleeping and not binary_sensor.projector_on
vertex_2: vertex_2:
module: vertex2 module: vertex2
class: Vertex2 class: Vertex2
priority: 5 priority: 5
virtual_sensors:
default_values:
binary_sensor.is_tv_on: false
binary_sensor.someone_home: false
binary_sensor.vertex2_has_input_signal: false
sensors:
binary_sensor.projector_screen_down: input_boolean.projection_screen
input_select:
entity: select.vertex2_input
mqtt_namespace: mqtt
mqtt_topic: appdaemon/vertex2/input
unique_id: vertex2_input
input_config: input_config:
default: default:
name: None name: None
@@ -121,16 +178,22 @@ vertex_2:
0: 0:
name: AppleTV name: AppleTV
icon: mdi:apple icon: mdi:apple
#reboot_when_leaving: True
1: 1:
name: Xbox name: Xbox
icon: mdi:microsoft-xbox icon: mdi:microsoft-xbox
2: 2:
name: Playstation name: Playstation
icon: mdi:sony-playstation icon: mdi:sony-playstation
# 3: 3:
# name: Switch name: Switch
# icon: mdi:nintendo-switch icon: mdi:nintendo-switch
# input_selection:
# None: binary_sensor.vertex2_has_input_signal # this will prevent input change if the current input is still active
# Switch: binary_sensor.vertex2_switch_5v
# Playstation: binary_sensor.vertex2_playstation_5v
# Xbox: binary_sensor.vertex2_xbox_5v
# AppleTV: true
output_config: output_config:
default: default:
@@ -139,42 +202,168 @@ vertex_2:
0: 0:
name: TV name: TV
icon: mdi:television icon: mdi:television
use_audio_out_audio_flags: input_boolean.use_bravia_theater_for_the_frame
force_plus5: not binary_sensor.vertex2_no_signal_spam
1: 1:
name: Projector name: Projector
icon: mdi:projector icon: mdi:projector
use_audio_out_audio_flags: True
#force_plus5: switch.living_room_projector_plug
# vertex2_power_plug: vertex2_power_plug:
# entity: switch.fibaro_vertex_plug_51 entity: switch.vertex2_power_plug
# # power_consumption: sensor.fibaro_vertex_plug_51_power # power_consumption: sensor.fibaro_vertex_plug_51_power
# conditions: conditions:
# trigger_conditions: binary_sensor.someone_home trigger_conditions: binary_sensor.someone_home #and (input_boolean.projection_screen or binary_sensor.the_frame_is_plugged_for_a_while)
# blocking_conditions: blocking_conditions: (binary_sensor.is_everybody_sleeping or binary_sensor.is_living_room_sleeping) and not binary_sensor.vertex2_has_input_signal
# - binary_sensor.is_everybody_sleeping or binary_sensor.is_salon_sleeping
# - binary_sensor.vertex2_unreachable and not binary_sensor.tv_on and not binary_sensor.projector_on
# mutetx0_conditions:
# trigger_conditions:
# - binary_sensor.is_sonos_amp_playing_tv and not media_player.piece_a_vivre.is_volume_muted
# - binary_sensor.is_hw_700_on
#filmmaker_mode_conditions: sensor.vertex2_input_select_tx0 == 'AppleTV' #filmmaker_mode_conditions: sensor.vertex2_input_select_tx0 == 'AppleTV'
output_selection: output_selection:
Projector: binary_sensor.projector_on Projector: binary_sensor.projector_screen_down
TV: True TV: True
# input_selection:
# None: binary_sensor.vertex2_has_input_signal # this will prevent input change if the current input is still active
# Switch: binary_sensor.is_playing_switch
# AppleTV: not binary_sensor.is_appletv_standy
# Playstation: switch.ps5_136_power
# Xbox: True
vertex2_address: http://10.0.0.33 vertex2_address: http://10.0.0.33
vertex2_tcp_port: 2220 vertex2_tcp_port: 2220
startup_delay: 30 # seconds after the power plug turns on
video_tx_signal_sensor: sensor.vertex2_video_signal video_tx_signal_sensor: sensor.vertex2_video_signal
allm_sensor: binary_sensor.vertex2_allm
#tx0_plus5_sensor: binary_sensor.vertex2_tx0_plus5
#tx1_plus5_sensor: binary_sensor.vertex2_tx1_plus5
audio_out_signal_sensor: sensor.vertex2_audio_signal audio_out_signal_sensor: sensor.vertex2_audio_signal
output_selected_sensor: sensor.vertex2_output_selected output_selected_sensor: sensor.vertex2_output_selected
input_selected_sensor: sensor.vertex2_input_selected input_selected_sensor: sensor.vertex2_input_selected
unreachable_sensor: binary_sensor.vertex2_unreachable no_signal_spam_sensor: binary_sensor.vertex2_no_signal_spam
unreachable_sensor: binary_sensor.vertex2_unreachable
command_processing_sensor: binary_sensor.vertex2_command_processing
command_processing_timeout: 20
use_bravia_theater_for_the_frame:
module: smartswitch
class: SmartSwitch
entity: input_boolean.use_bravia_theater_for_the_frame
toggle_events:
- toggle_use_bravia_theater_for_the_frame
apple_remote:
module: virtualevents
class: VirtualEvents
priority: 100 # default priority app is 50, since the virtual_events doesn't create any sensor but is based on sensor created by many app, it's important it's created last
virtual_events:
toggle_use_bravia_theater_for_the_frame:
event_name: toggle_use_bravia_theater_for_the_frame
event_condition: binary_sensor.is_tv_on and binary_sensor.ir_bridge_apple_tv_mute_button_short_press
# Volume buttons received by the IR bridge: Bravia > TV > Sonos.
homecinema_volume:
module: eventsforwarder
class: EventsForwarder
priority: 100
forwarders:
volume_up:
events_to_listen:
- esphome.ir_bridge_volume_up
events_to_send:
the_frame:
event_name: the_frame_volume_up
event_condition: not (media_player.bravia_theatre == 'on' or media_player.bravia_theatre == 'playing' or media_player.bravia_theatre == 'paused' or media_player.bravia_theatre == 'idle') and binary_sensor.is_tv_on == 'on'
services_to_call:
bravia:
service: media_player/volume_up
event_condition: (media_player.bravia_theatre == 'on' or media_player.bravia_theatre == 'playing' or media_player.bravia_theatre == 'paused' or media_player.bravia_theatre == 'idle')
data:
entity_id: media_player.bravia_theatre
sonos:
service: media_player/volume_up
event_condition: not (media_player.bravia_theatre == 'on' or media_player.bravia_theatre == 'playing' or media_player.bravia_theatre == 'paused' or media_player.bravia_theatre == 'idle') and binary_sensor.is_tv_on != 'on'
data:
entity_id: media_player.living_room_sonos
volume_down:
events_to_listen:
- esphome.ir_bridge_volume_down
events_to_send:
the_frame:
event_name: the_frame_volume_down
event_condition: not (media_player.bravia_theatre == 'on' or media_player.bravia_theatre == 'playing' or media_player.bravia_theatre == 'paused' or media_player.bravia_theatre == 'idle') and binary_sensor.is_tv_on == 'on'
services_to_call:
bravia:
service: media_player/volume_down
event_condition: (media_player.bravia_theatre == 'on' or media_player.bravia_theatre == 'playing' or media_player.bravia_theatre == 'paused' or media_player.bravia_theatre == 'idle')
data:
entity_id: media_player.bravia_theatre
sonos:
service: media_player/volume_down
event_condition: not (media_player.bravia_theatre == 'on' or media_player.bravia_theatre == 'playing' or media_player.bravia_theatre == 'paused' or media_player.bravia_theatre == 'idle') and binary_sensor.is_tv_on != 'on'
data:
entity_id: media_player.living_room_sonos
vertex_content:
module: vertexcontent
class: VertexContent
output: sensor.vertex2_current_content
input_selected: sensor.vertex2_input_selected
has_signal: binary_sensor.vertex2_has_input_signal
sources:
AppleTV:
label: Apple TV
platform: Apple TV
icon: mdi:apple
entity: media_player.apple_tv
title_attributes:
- media_title
series_title_attributes:
- media_series_title
application_attributes:
- app_name
content_type_attributes:
- media_content_type
artwork_attributes:
- entity_picture
pause_states:
- paused
# is_paused is meaningful only for apps known to report playback state.
pause_supported_apps:
- CANAL+
- Disney+
- HBO Max
- Netflix
- Plex
- Prime Video
- TV
- VLC
- YouTube
Xbox:
label: Xbox
platform: Xbox
content_type: game
icon: mdi:microsoft-xbox
entity: sensor.yetanotherpedro_now_playing
state_as_title: true
artwork_entity: image.yetanotherpedro_now_playing
Playstation:
label: PlayStation 5
platform: PlayStation 5
content_type: game
icon: mdi:sony-playstation
entity: sensor.ps5_136_activity
state_as_title: true
Switch:
label: Nintendo Switch
platform: Nintendo Switch
content_type: game
icon: mdi:nintendo-switch
+24
View File
@@ -27,3 +27,27 @@ notifications_manager:
message: message:
content: Good Bye content: Good Bye
timeout: 600 timeout: 600
commut_rain_notification:
recipients:
pierre:
services: notify/mobile_app_iphone_de_pierre
conditions: binary_sensor.commut_rain
message:
title: Pluie prévue sur votre trajet
content: "Il va pleuvoir dans {sensor.commut_rain_minutes_until_rain} minutes"
power_off_notification:
recipients:
all:
services:
- notify/mobile_app_iphone_de_pierre
- notify/mobile_app_mae
conditions: binary_sensor.main_power_off and not binary_sensor.someone_home
message:
title: Coupure de courant
content: Le courant a été coupé
auto_clear: true
+5 -8
View File
@@ -41,12 +41,13 @@ occupancy_sensors:
default_values: default_values:
binary_sensor.someone_home: off binary_sensor.someone_home: off
binary_sensor.projector_on: off binary_sensor.projector_on: off
binary_sensor.is_tv_on: off
sensors: sensors:
binary_sensor.living_room_occupancy: binary_sensor.living_room_occupancy:
trigger_conditions: trigger_conditions:
- sensor.living_room_last_motion <= 5 - sensor.living_room_last_motion <= 5
- binary_sensor.projector_on - binary_sensor.projector_on or binary_sensor.is_tv_on
binary_sensor.kitchen_occupancy: binary_sensor.kitchen_occupancy:
trigger_conditions: sensor.kitchen_last_motion <= 5 trigger_conditions: sensor.kitchen_last_motion <= 5
binary_sensor.garage_occupancy: binary_sensor.garage_occupancy:
@@ -59,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
+2 -2
View File
@@ -1,7 +1,7 @@
sleep_sensors: sleep_sensors:
module: virtualsensors module: virtualsensors
class: VirtualSensorsApp class: VirtualSensorsApp
priority: 5 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first priority: 1 # Start before apps such as alarm_clock that validate these sensors during initialization.
sensors: sensors:
binary_sensor.is_bedroom_sleeping: input_boolean.bedroom_sleeping or input_boolean.nap_switch binary_sensor.is_bedroom_sleeping: input_boolean.bedroom_sleeping or input_boolean.nap_switch
@@ -17,4 +17,4 @@ sleep_sensors:
- binary_sensor.is_bedroom_sleeping and input_boolean.bedroom_occupied_tonight - binary_sensor.is_bedroom_sleeping and input_boolean.bedroom_occupied_tonight
- binary_sensor.is_living_room_sleeping and input_boolean.living_room_occupied_tonight - binary_sensor.is_living_room_sleeping and input_boolean.living_room_occupied_tonight
- binary_sensor.is_desk_sleeping and input_boolean.desk_occupied_tonight - binary_sensor.is_desk_sleeping and input_boolean.desk_occupied_tonight
- binary_sensor.is_guestroom_sleeping and input_boolean.guestroom_occupied_tonight - binary_sensor.is_guestroom_sleeping and input_boolean.guestroom_occupied_tonight
+85 -16
View File
@@ -4,11 +4,76 @@ virtual_sensors:
priority: 10 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first priority: 10 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first
default_values: default_values:
binary_sensor.tv_on: false binary_sensor.is_tv_on: false
sensors: sensors:
binary_sensor.someone_home: person.maeva == 'home' or person.pierre == 'home' or (input_boolean.use_red_shelly_presence and binary_sensor.red_shelly_home) binary_sensor.someone_home: person.maeva == 'home' or person.pierre == 'home' or (input_boolean.use_red_shelly_presence and binary_sensor.red_shelly_home)
binary_sensor.is_dark_outside: sun.sun == 'below_horizon' binary_sensor.is_dark_outside: sun.sun == 'below_horizon'
value_selector.commut_rain_minutes_until_rain:
values:
le_cres_first:
conditions:
local_expressions:
cres_minutes: sensor.next_rain_le_cres.minutes_until_rain
castelnau_minutes: sensor.next_rain_castelnau_le_lez.minutes_until_rain
cres_minutes_available: cres_minutes != None and cres_minutes != 'unknown' and cres_minutes != 'unavailable'
castelnau_minutes_available: castelnau_minutes != None and castelnau_minutes != 'unknown' and castelnau_minutes != 'unavailable'
trigger_conditions: cres_minutes_available and (not castelnau_minutes_available or cres_minutes <= castelnau_minutes)
value: sensor.next_rain_le_cres.minutes_until_rain
castelnau_only:
conditions:
local_expressions:
cres_minutes: sensor.next_rain_le_cres.minutes_until_rain
castelnau_minutes: sensor.next_rain_castelnau_le_lez.minutes_until_rain
cres_minutes_available: cres_minutes != None and cres_minutes != 'unknown' and cres_minutes != 'unavailable'
castelnau_minutes_available: castelnau_minutes != None and castelnau_minutes != 'unknown' and castelnau_minutes != 'unavailable'
trigger_conditions: castelnau_minutes_available and (not cres_minutes_available or castelnau_minutes < cres_minutes)
value: sensor.next_rain_castelnau_le_lez.minutes_until_rain
unknown: true
value_selector.commut_rain_intensity:
values:
Pluie forte:
conditions:
local_expressions:
cres_intensity: sensor.next_rain_le_cres.intensity
castelnau_intensity: sensor.next_rain_castelnau_le_lez.intensity
trigger_conditions: cres_intensity == 'Pluie forte' or castelnau_intensity == 'Pluie forte'
Pluie modérée:
conditions:
local_expressions:
cres_intensity: sensor.next_rain_le_cres.intensity
castelnau_intensity: sensor.next_rain_castelnau_le_lez.intensity
trigger_conditions: cres_intensity == 'Pluie modérée' or castelnau_intensity == 'Pluie modérée'
Pluie faible:
conditions:
local_expressions:
cres_intensity: sensor.next_rain_le_cres.intensity
castelnau_intensity: sensor.next_rain_castelnau_le_lez.intensity
trigger_conditions: cres_intensity == 'Pluie faible' or castelnau_intensity == 'Pluie faible'
Temps sec:
conditions:
local_expressions:
cres_intensity: sensor.next_rain_le_cres.intensity
castelnau_intensity: sensor.next_rain_castelnau_le_lez.intensity
trigger_conditions: cres_intensity == 'Temps sec' and castelnau_intensity == 'Temps sec'
unknown: true
binary_sensor.commut_rain:
conditions:
trigger_conditions:
- sensor.next_rain_le_cres != 'unknown' and sensor.next_rain_le_cres != 'unavailable'
- sensor.next_rain_castelnau_le_lez != 'unknown' and sensor.next_rain_castelnau_le_lez != 'unavailable'
blocking_conditions:
- not person.pierre
- person.pierre == 'home' and not binary_sensor.morning_commut_time
- person.pierre != 'home' and not binary_sensor.evening_commut_time
- not binary_sensor.pierre_workday
sensor_attributes:
minutes_until_rain_le_cres: sensor.next_rain_le_cres.minutes_until_rain
minutes_until_rain_castelnau: sensor.next_rain_castelnau_le_lez.minutes_until_rain
minutes_until_rain: sensor.commut_rain_minutes_until_rain
intensity: sensor.commut_rain_intensity
event_sensor.red_shelly_home: event_sensor.red_shelly_home:
retain_time: 120 retain_time: 120
activation_events: activation_events:
@@ -17,26 +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.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
binary_sensor.projector_screen_down: cover.living_room_projection_screen.current_position < 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,
@@ -44,14 +110,17 @@ virtual_sensors:
binary_sensor.is_apple_tv_on: media_player.apple_tv != 'off' and media_player.apple_tv != 'idle' binary_sensor.is_apple_tv_on: media_player.apple_tv != 'off' and media_player.apple_tv != 'idle'
binary_sensor.is_xbox_on: binary_sensor.yetanotherpedro #media_player.xbox != 'off' and media_player.xbox != 'unavailable' binary_sensor.is_xbox_on: binary_sensor.yetanotherpedro #media_player.xbox != 'off' and media_player.xbox != 'unavailable'
binary_sensor.is_playstation_on: switch.ps5_136_power binary_sensor.is_playstation_on: switch.ps5_136_power
retain_condition.vertex2_has_input_signal: sensor.vertex2_input_selected_icon: sensor.vertex2_input_selected.icon
retain_time: 10 retain_condition.vertex2_has_input_signal:
retain_time: 20 #20 seems like the sweet spot to handle transition between source (AppleTV -> Xbox on projector)
conditions: conditions:
trigger_conditions: (sensor.vertex2_video_signal and sensor.vertex2_video_signal != 'no signal') or sensor.vertex2_audio_signal trigger_conditions: (sensor.vertex2_video_signal and sensor.vertex2_video_signal != 'no signal') or sensor.vertex2_audio_signal
disable_conditions: binary_sensor.vertex2_unreachable disable_conditions: self and (binary_sensor.vertex2_unreachable or binary_sensor.vertex2_command_processing)
binary_sensor.is_using_apple_tv: binary_sensor.is_apple_tv_on and binary_sensor.vertex2_has_input_signal #and select.hdfury_vertex2_07_port_select_tx0 == 0 sensor_attributes:
binary_sensor.is_using_xbox: binary_sensor.is_xbox_on and binary_sensor.vertex2_has_input_signal #and select.hdfury_vertex2_07_port_select_tx0 == 1 icon: sensor.vertex2_input_selected_icon
binary_sensor.is_using_playstation: binary_sensor.is_playstation_on and binary_sensor.vertex2_has_input_signal #and select.hdfury_vertex2_07_port_select_tx0 == 2 binary_sensor.is_using_apple_tv: binary_sensor.is_apple_tv_on and binary_sensor.vertex2_has_input_signal and sensor.vertex2_input_selected == 'AppleTV'
binary_sensor.is_using_xbox: binary_sensor.is_xbox_on and binary_sensor.vertex2_has_input_signal and sensor.vertex2_input_selected == 'Xbox'
binary_sensor.is_using_playstation: binary_sensor.is_playstation_on and binary_sensor.vertex2_has_input_signal and sensor.vertex2_input_selected == 'Playstation'
continuous_condition.is_vertex2_content_idle: continuous_condition.is_vertex2_content_idle:
@@ -70,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
+55 -8
View File
@@ -4,11 +4,14 @@ from importlib import metadata
import json import json
import pickle import pickle
import os import os
import re
from urllib.request import urlopen from urllib.request import urlopen
class InformationsCollector(SmartObject): class InformationsCollector(SmartObject):
APPDAEMON_PYPI_URL = "https://pypi.org/pypi/appdaemon/json" APPDAEMON_DOCKER_TAGS_URL = (
"https://hub.docker.com/v2/repositories/acockburn/appdaemon/tags?page_size=100"
)
def on_initialize_smart_object(self): def on_initialize_smart_object(self):
super().on_initialize_smart_object() super().on_initialize_smart_object()
@@ -26,7 +29,7 @@ class InformationsCollector(SmartObject):
sensor_name, sensor_name,
state='unknown', state='unknown',
attributes={ attributes={
'latest_official_version': self.dataset.get('appdaemon_latest_official_version'), 'latest_official_version': self.dataset.get('appdaemon_latest_docker_version'),
'update_available': None, 'update_available': None,
}, },
icon='mdi:package-up' icon='mdi:package-up'
@@ -51,6 +54,24 @@ class InformationsCollector(SmartObject):
else: else:
self.log_error("No output_sensor defined for error_collector") self.log_error("No output_sensor defined for error_collector")
if 'warning_collector' in self.args:
if 'output_sensor' in self.args['warning_collector']:
sensor_name = self.args['warning_collector']['output_sensor']
self.warning_list = []
self.warning_log_handle = self.listen_log(self.on_log_warning, "WARNING", log="main_log")
self.warning_count_sensor = self.create_entity(
sensor_name, state=0,
attributes={"last_warning": "", "warning_list": []},
icon="mdi:alert-outline"
)
if 'reset_warning_count_button' in self.args['warning_collector']:
self.listen_state(
self.on_reset_warning_count,
self.args['warning_collector']['reset_warning_count_button']
)
else:
self.log_error("No output_sensor defined for warning_collector")
if 'min_max_temp_sensors' in self.args: if 'min_max_temp_sensors' in self.args:
for sensor in self.args['min_max_temp_sensors']['temperature_sensors']: for sensor in self.args['min_max_temp_sensors']['temperature_sensors']:
self.listen_state(self.on_temperature_change, sensor) self.listen_state(self.on_temperature_change, sensor)
@@ -125,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)
@@ -150,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):
@@ -218,6 +250,21 @@ class InformationsCollector(SmartObject):
self.error_list.clear() self.error_list.clear()
self.error_count_sensor.set_state(state=0, attributes={"last_error": "", "error_list": self.error_list}) self.error_count_sensor.set_state(state=0, attributes={"last_error": "", "error_list": self.error_list})
def on_log_warning(self, name, ts, level, type, message, kwargs):
warning_count = int(self.warning_count_sensor.get_state()) + 1
self.warning_list.append(message)
self.warning_count_sensor.set_state(
state=warning_count,
attributes={"last_warning": message, "warning_list": self.warning_list}
)
def on_reset_warning_count(self, entity, attribute, old, new, kwargs):
self.warning_list.clear()
self.warning_count_sensor.set_state(
state=0,
attributes={"last_warning": "", "warning_list": self.warning_list}
)
def _on_group_condition_changed(self, prev_result,result): def _on_group_condition_changed(self, prev_result,result):
self.update_group_collectors() self.update_group_collectors()
+13 -16
View File
@@ -3,7 +3,8 @@ import appdaemon.exceptions as ad_exception
import ad_toolbox.smartcondition as SmartCondition import ad_toolbox.smartcondition as SmartCondition
import re import re
import uuid import uuid
from expressionparser import ParsingException from ad_toolbox.expressionparser import ParsingException
from ad_toolbox.logger_interface import LoggerInterface
class Notification: class Notification:
ENTITY_PLACEHOLDER_PATTERN = re.compile(r"\{([^{}]+)\}") ENTITY_PLACEHOLDER_PATTERN = re.compile(r"\{([^{}]+)\}")
@@ -18,7 +19,7 @@ class Notification:
for recipient in config["recipients"]: for recipient in config["recipients"]:
try: self.recipients[recipient] = SmartCondition.Evaluator(self.ad_api,self.config["recipients"][recipient]['conditions'],condition_name = name,on_succeed_cb = self.on_condition_succeed, on_fail_cb = self.on_conditon_fail,pass_condition_name_to_cb = recipient, trigger_callback_on_activation = False) try: self.recipients[recipient] = SmartCondition.Evaluator(self.ad_api,self.config["recipients"][recipient]['conditions'],condition_name = name,on_succeed_cb = self.on_condition_succeed, on_fail_cb = self.on_conditon_fail,pass_condition_name_to_cb = recipient, trigger_callback_on_activation = False)
except ParsingException as e: except ParsingException as e:
self.log_error(str(e)) self.ad_api.log_error(str(e))
continue continue
def on_conditon_fail(self, recipient): def on_conditon_fail(self, recipient):
@@ -42,9 +43,9 @@ class Notification:
data = { 'tag' : tag } data = { 'tag' : tag }
def clear_notification(service,tag): def clear_notification(service,tag):
self.ad_api.log(f"Clear notification {data} from {service}") self.ad_api.log_info(f"Clear notification {data} from {service}")
try: self.ad_api.call_service(service,message = message, data = data) try: self.ad_api.call_service(service,message = message, data = data)
except ad_exception.ServiceException: self.log_error(f"Invalid service: {service}") except ad_exception.ServiceException: self.ad_api.log_error(f"Invalid service: {service}")
recipient_yaml = self.config["recipients"][recipient] recipient_yaml = self.config["recipients"][recipient]
if isinstance(recipient_yaml['services'], str): if isinstance(recipient_yaml['services'], str):
@@ -65,11 +66,11 @@ class Notification:
try: try:
state = self.ad_api.get_state(entity_id) state = self.ad_api.get_state(entity_id)
except Exception as e: except Exception as e:
self.log_error(f"Failed to resolve '{entity_id}' in notification content: {e}") self.ad_api.log_error(f"Failed to resolve '{entity_id}' in notification content: {e}")
return match.group(0) return match.group(0)
if state is None: if state is None:
self.log_error(f"Unable to resolve '{entity_id}' in notification content") self.ad_api.log_error(f"Unable to resolve '{entity_id}' in notification content")
return match.group(0) return match.group(0)
return str(state) return str(state)
@@ -96,9 +97,9 @@ class Notification:
self.ad_api.run_in(self.on_timeout_expired,timeout,tag = data['tag'],recipient = recipient) self.ad_api.run_in(self.on_timeout_expired,timeout,tag = data['tag'],recipient = recipient)
def send_notification(service): def send_notification(service):
self.ad_api.log(f"Sending notification '{message}' to {service}{f' with data {data}' if data else ''}") self.ad_api.log_info(f"Sending notification '{message}' to {service}{f' with data {data}' if data else ''}")
try: self.ad_api.call_service(service,title = title,message = message, data = data) try: self.ad_api.call_service(service,title = title,message = message, data = data)
except ad_exception.ServiceException: self.log_error(f"Invalid service: {service}") except ad_exception.ServiceException: self.ad_api.log_error(f"Invalid service: {service}")
recipient_yaml = self.config["recipients"][recipient] recipient_yaml = self.config["recipients"][recipient]
if isinstance(recipient_yaml['services'], str): if isinstance(recipient_yaml['services'], str):
@@ -107,17 +108,13 @@ class Notification:
for service in recipient_yaml['services']: for service in recipient_yaml['services']:
send_notification(service) send_notification(service)
def log_error(self,message,stop_app = False): class NotificationsManager(hass.Hass, LoggerInterface):
self.ad_api.log(message,level = "ERROR", log = "main_log")
self.ad_api.log(message,level = "ERROR", log = "error_log")
if stop_app:
self.ad_api.stop_app(self.ad_api.name)
class NotificationsManager(hass.Hass):
def initialize(self): def initialize(self):
# Preserve the default app logger used by the previous direct log calls.
self.initialize_logger_interface(self.get_ad_api(), default_log=None)
self.notifications = dict() self.notifications = dict()
if "notifications" in self.args: if "notifications" in self.args:
for notification in self.args["notifications"]: for notification in self.args["notifications"]:
self.notifications[notification] = Notification(self,self.args["notifications"][notification],notification) self.notifications[notification] = Notification(self,self.args["notifications"][notification],notification)
+5 -2
View File
@@ -175,10 +175,13 @@ alarm_clock:
module: alarmclock module: alarmclock
class: AlarmClock class: AlarmClock
priority: 5
switch_alarmclock_main: input_boolean.main_alarmclock switch_alarmclock_main: input_boolean.main_alarmclock
switch_alarmclock_secondary: input_boolean.secondary_alarmclock switch_alarmclock_secondary: input_boolean.secondary_alarmclock
inputdate_alarmclock_main: input_datetime.main_alarmclock inputdate_alarmclock_main: input_datetime.main_alarmclock
inputdate_alarmclock_secondary: input_datetime.secondary_alarmclock inputdate_alarmclock_secondary: input_datetime.secondary_alarmclock
input_text_wakeup_time: input_text.wakeup_time sensor_wakeup_datetime: sensor.wakeup_datetime
output_workday_sensor: binary_sensor.pierre_workday
sleeping_condition: binary_sensor.is_bedroom_sleeping sleeping_condition: binary_sensor.is_bedroom_sleeping
+1 -1
View File
@@ -4,5 +4,5 @@ light_ambiance:
entity: light.desk_ambiance entity: light.desk_ambiance
smart_conditions: smart_conditions:
trigger_conditions: sensor.desk_last_motion < 3 and binary_sensor.is_dark_outside trigger_conditions: binary_sensor.desk_occupancy and binary_sensor.is_dark_outside
blocking_conditions: binary_sensor.is_desk_sleeping blocking_conditions: binary_sensor.is_desk_sleeping
+12
View File
@@ -0,0 +1,12 @@
light_aquarium:
module: smartswitch
class: SmartSwitch
entity: switch.aquarium_light
virtual_sensors:
sensors:
time_period.aquarium_light_schedule:
start: input_datetime.aquarium_light_start
end: input_datetime.aquarium_light_stop
smart_conditions: binary_sensor.aquarium_light_schedule
+1 -1
View File
@@ -15,7 +15,7 @@ p1p_light:
smart_conditions: smart_conditions:
unavaibility_result: Disabled unavaibility_result: Disabled
trigger_conditions: binary_sensor.garage_occupancy or binary_sensor.garage_p1p_timelapse or camera.garage_p1p_camera == 'streaming' trigger_conditions: binary_sensor.garage_occupancy or binary_sensor.garage_p1p_timelapse or camera.garage_p1p_camera == 'streaming'
blocking_conditions: not binary_sensor.p1p_is_printing and not fan.garage_p1p_ventilateur_de_refroidissement and not fan.garage_p1p_ventilateur_de_la_chambre blocking_conditions: not binary_sensor.p1p_is_printing and not sensor.garage_p1p_vitesse_du_ventilateur_de_refroidissement and not sensor.garage_p1p_vitesse_du_ventilateur_de_la_chambre
p1p_switch: p1p_switch:
module: smartswitch module: smartswitch
+8 -1
View File
@@ -49,6 +49,13 @@ light_bookshelves:
25: sensor.living_room_ambiance_light == 1 25: sensor.living_room_ambiance_light == 1
100: True 100: True
light_christmas_tree:
module: smartswitch
class: SmartSwitch
entity: switch.chrismas_tree
smart_conditions: sensor.living_room_ambiance_light >= 1
light_tree: light_tree:
module: smartlight module: smartlight
class: SmartLight class: SmartLight
@@ -102,7 +109,7 @@ mezzanine_curtains:
close_conditions: close_conditions:
trigger_conditions: binary_sensor.projector_on or not binary_sensor.projector_off_for_a_while trigger_conditions: binary_sensor.projector_on or not binary_sensor.projector_off_for_a_while
disable_conditions: self == 'closed' and (binary_sensor.is_dark_outside or binary_sensor.is_anybody_sleeping) disable_conditions: binary_sensor.is_dark_outside or binary_sensor.is_anybody_sleeping
living_room_sleep_switch: living_room_sleep_switch:
module: smartswitch module: smartswitch
+93 -49
View File
@@ -1,5 +1,6 @@
import appdaemon.plugins.hass.hassapi as hass import appdaemon.plugins.hass.hassapi as hass
from ad_toolbox.smartobject import SmartObject from ad_toolbox.smartobject import SmartObject
from ad_toolbox.eventhandler import EventHandler
import ad_toolbox.smartcondition as SmartCondition import ad_toolbox.smartcondition as SmartCondition
from samsungtvws import SamsungTVWS from samsungtvws import SamsungTVWS
from samsungtvws import exceptions as tv_exceptions from samsungtvws import exceptions as tv_exceptions
@@ -14,19 +15,21 @@ class SamsungTheFrame(SmartObject):
def on_initialize_smart_object(self): def on_initialize_smart_object(self):
super().on_initialize_smart_object() super().on_initialize_smart_object()
self.log(f"Initializing TheFrame with ip {self.args['ip_address']}") self.log_info(f"Initializing TheFrame with ip {self.args['ip_address']}")
#self.need_to_turn_off_when_hdmi_gone = False #self.need_to_turn_off_when_hdmi_gone = False
self.volume_connection = None
self.requested_state = None self.requested_state = None
self.requested_state_confirm_cb_handle = None self.requested_state_confirm_cb_handle = None
self.power_plug = self.args['input_sensors']['power_plug'] self.power_plug = self.args['input_sensors']['power_plug']
self.power_plug_power = self.args['input_sensors']['power_plug_power'] self.power_plug_power = self.args['input_sensors']['power_plug_power']
self.is_on_sensor = self.args['output_sensors']['is_on'] self.is_on_entity = self.create_entity(self.args['output_sensors']['is_on'])
self.is_art_mode_sensor = self.args['output_sensors']['is_art_mode'] self.is_art_mode_entity = self.create_entity(self.args['output_sensors']['is_art_mode'])
self.tv_auto_shutdown = self.args['output_sensors']['tv_auto_shutdown'] auto_shutdown_state = self.get_state(self.args['output_sensors']['tv_auto_shutdown'])
self.tv_auto_shutdown_entity = self.create_entity(
if self.get_state(self.tv_auto_shutdown) == None: self.args['output_sensors']['tv_auto_shutdown'],
self.set_state(self.tv_auto_shutdown,state = 'off') state=auto_shutdown_state if auto_shutdown_state is not None else 'off',
)
if 'app_list' in self.args: self.app_list = self.args['app_list'] if 'app_list' in self.args: self.app_list = self.args['app_list']
else: self.app_list = [] else: self.app_list = []
@@ -44,7 +47,14 @@ class SamsungTheFrame(SmartObject):
self.listen_state(self.update_sensors_cb,self.power_plug) self.listen_state(self.update_sensors_cb,self.power_plug)
self.listen_state(self.update_sensors_cb,self.power_plug_power) self.listen_state(self.update_sensors_cb,self.power_plug_power)
self.update_sensors() self.update_sensors()
self.volume_event_handlers = []
for direction in ('up', 'down'):
config = self.args.get(f'volume_{direction}_events')
if config:
self.volume_event_handlers.append(EventHandler(
self, config, self.on_volume_event, direction))
def on_update_tv_states(self): def on_update_tv_states(self):
for state_evaluator in self.tv_states_evaluators: for state_evaluator in self.tv_states_evaluators:
@@ -58,9 +68,40 @@ class SamsungTheFrame(SmartObject):
def on_update_tv_state_sensor(self, entity, attribute, old, new, kwargs): def on_update_tv_state_sensor(self, entity, attribute, old, new, kwargs):
if new != old: self.request_tv_state(new) if new != old: self.request_tv_state(new)
def connect(self): def connect(self, timeout=None):
#self.log(f"Connecting to {self.args['ip_address']}") #self.log(f"Connecting to {self.args['ip_address']}")
return SamsungTVWS(self.args['ip_address'],port = 8002, token_file = os.path.join(str(self.AD.app_dir),"theframe_token.txt")) return SamsungTVWS(self.args['ip_address'],port = 8002, timeout=timeout, token_file = os.path.join(str(self.AD.app_dir),"theframe_token.txt"))
def close_volume_connection(self):
tv = self.volume_connection
self.volume_connection = None
if tv is not None:
try:
tv.close()
except (OSError, websocket.WebSocketException):
pass
def terminate(self):
self.close_volume_connection()
super().terminate()
def on_volume_event(self, event_name, event_data, direction):
if self.is_on_entity.get_state() != 'on':
self.close_volume_connection()
return
try:
if self.volume_connection is None:
self.volume_connection = self.connect(timeout=1)
# ESPHome already limits repetitions to one per 300 ms. The
# library's default one-second delay would queue button events.
self.volume_connection.send_key(
'KEY_VOLUP' if direction == 'up' else 'KEY_VOLDOWN',
key_press_delay=0)
except (OSError, tv_exceptions.ConnectionFailure, tv_exceptions.ResponseError,
websocket.WebSocketException) as error:
self.log_warning(f"Volume {direction} failed: {error}")
# Reconnect on the next press, without replaying a failed command.
self.close_volume_connection()
def has_power(self): def has_power(self):
return self.get_state(self.power_plug) == 'on' and float(self.get_state(self.power_plug_power)) > 35 return self.get_state(self.power_plug) == 'on' and float(self.get_state(self.power_plug_power)) > 35
@@ -68,10 +109,10 @@ class SamsungTheFrame(SmartObject):
def wake_up(self): def wake_up(self):
if 'wake_up_service' in self.args: if 'wake_up_service' in self.args:
service = self.args['wake_up_service'] service = self.args['wake_up_service']
self.log(f"Calling service {service}") self.log_info(f"Calling service {service}")
self.call_service(service) self.call_service(service)
elif 'wol_switch' in self.args: elif 'wol_switch' in self.args:
self.log(f"Switching on {self.args['wol_switch']}") self.log_info(f"Switching on {self.args['wol_switch']}")
self.turn_on(self.args['wol_switch']) self.turn_on(self.args['wol_switch'])
if 'mac_address' in self.args: if 'mac_address' in self.args:
wakeonlan.send_magic_packet(self.args['mac_address']) wakeonlan.send_magic_packet(self.args['mac_address'])
@@ -88,23 +129,23 @@ class SamsungTheFrame(SmartObject):
selected_art = random.choice(available_art) selected_art = random.choice(available_art)
tv.art().select_image(selected_art['content_id'], show=True) tv.art().select_image(selected_art['content_id'], show=True)
except tv_exceptions.ResponseError as e: except tv_exceptions.ResponseError as e:
self.log(f"select_random_art with exception ResponseError {e}") self.log_info(f"select_random_art with exception ResponseError {e}")
except TimeoutError: except TimeoutError:
self.log(f"select_random_art with exception TimeoutError") self.log_info(f"select_random_art with exception TimeoutError")
def request_tv_state(self,requested_state): def request_tv_state(self,requested_state):
valid_states = ['art_mode','tv_off','hdmi','power_off','manual'] valid_states = ['art_mode','tv_off','hdmi','power_off','manual']
assert requested_state in valid_states, f"{requested_state} is not a valid states. Valid states are {valid_states}" assert requested_state in valid_states, f"{requested_state} is not a valid states. Valid states are {valid_states}"
self.log(f"Requesting TV to switch to {requested_state}") self.log_info(f"Requesting TV to switch to {requested_state}")
if requested_state == 'hdmi' and self.get_state(self.is_on_sensor) == 'off': if requested_state == 'hdmi' and self.is_on_entity.get_state() == 'off':
self.log(f"setting {self.tv_auto_shutdown} to 'on'") self.log_info(f"setting {self.tv_auto_shutdown_entity.entity_id} to 'on'")
self.set_state(self.tv_auto_shutdown,state = 'on') self.tv_auto_shutdown_entity.set_state(state = 'on')
#if we already have a confirm callback, let's cancel it #if we already have a confirm callback, let's cancel it
if self.requested_state_confirm_cb_handle: if self.requested_state_confirm_cb_handle:
self.log("Canceling previous confirm CB") self.log_info("Canceling previous confirm CB")
self.cancel_timer(self.requested_state_confirm_cb_handle) self.cancel_timer(self.requested_state_confirm_cb_handle)
self.requested_state_confirm_cb_handle = None self.requested_state_confirm_cb_handle = None
@@ -113,7 +154,7 @@ class SamsungTheFrame(SmartObject):
else: else:
self.requested_state = requested_state self.requested_state = requested_state
if self._process_state_request(): if self._process_state_request():
self.log(f"The TV was already in the right state") self.log_info(f"The TV was already in the right state")
def _process_state_request(self): def _process_state_request(self):
try: try:
@@ -121,32 +162,32 @@ class SamsungTheFrame(SmartObject):
if not success: if not success:
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, wait_time) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, wait_time)
return False return False
is_art_mode = self.get_state(self.is_art_mode_sensor) == 'on' is_art_mode = self.is_art_mode_entity.get_state() == 'on'
is_on = self.get_state(self.is_on_sensor) == 'on' is_on = self.is_on_entity.get_state() == 'on'
is_plugged = self.get_state(self.power_plug) == 'on' is_plugged = self.get_state(self.power_plug) == 'on'
if self.requested_state == 'tv_off': if self.requested_state == 'tv_off':
if not is_plugged: if not is_plugged:
self.log("Powering on TV") self.log_info("Powering on TV")
self.turn_on(self.power_plug) self.turn_on(self.power_plug)
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60)
return False return False
elif is_on: elif is_on:
tv = self.connect() tv = self.connect()
self.log("Switching to art mode") self.log_info("Switching to art mode")
tv.shortcuts().power() tv.shortcuts().power()
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 10) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 10)
return False return False
elif is_art_mode: elif is_art_mode:
tv = self.connect() tv = self.connect()
self.log("Turning off TV") self.log_info("Turning off TV")
tv.hold_key('KEY_POWER',4) tv.hold_key('KEY_POWER',4)
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 10) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 10)
return False return False
elif self.requested_state == 'art_mode': elif self.requested_state == 'art_mode':
if not is_art_mode: if not is_art_mode:
if not is_plugged: if not is_plugged:
self.log("Powering on TV") self.log_info("Powering on TV")
self.turn_on(self.power_plug) self.turn_on(self.power_plug)
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60)
return False return False
@@ -164,18 +205,18 @@ class SamsungTheFrame(SmartObject):
elif self.requested_state == "hdmi": elif self.requested_state == "hdmi":
if not is_plugged: if not is_plugged:
self.log("Powering on TV") self.log_info("Powering on TV")
self.turn_on(self.power_plug) self.turn_on(self.power_plug)
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60)
return False return False
elif not self.has_power(): elif not self.has_power():
self.log("Waking up TV") self.log_info("Waking up TV")
self.wake_up() self.wake_up()
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60)
return False return False
elif not is_on: elif not is_on:
tv = self.connect() tv = self.connect()
self.log("Turning on TV") self.log_info("Turning on TV")
tv.shortcuts().power() tv.shortcuts().power()
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 5) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 5)
return False return False
@@ -185,8 +226,8 @@ class SamsungTheFrame(SmartObject):
# {'id': '3201907018807', 'name': 'Netflix', 'running': True, 'version': '5.2.59020', 'visible': True} # {'id': '3201907018807', 'name': 'Netflix', 'running': True, 'version': '5.2.59020', 'visible': True}
app_status = tv.rest_app_status(str(app)) app_status = tv.rest_app_status(str(app))
if app_status['visible']: if app_status['visible']:
self.log(f"{app_status['name']} is active") self.log_info(f"{app_status['name']} is active")
self.log("Switching to Hdmi") self.log_info("Switching to Hdmi")
tv.send_key('KEY_HDMI') tv.send_key('KEY_HDMI')
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 5) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 5)
return False return False
@@ -200,14 +241,14 @@ class SamsungTheFrame(SmartObject):
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 10) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 10)
return False return False
elif not self.has_power(): elif not self.has_power():
self.log("Waking up TV") self.log_info("Waking up TV")
self.wake_up() self.wake_up()
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60)
return False return False
elif is_art_mode: #it's better to turn off while it's art mode to ensure the TV will wake up in art mode and support properly wake on lan elif is_art_mode: #it's better to turn off while it's art mode to ensure the TV will wake up in art mode and support properly wake on lan
self.select_random_art() self.select_random_art()
time.sleep(5) time.sleep(5)
self.log("Powering off TV") self.log_info("Powering off TV")
self.turn_off(self.power_plug) self.turn_off(self.power_plug)
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 5) self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 5)
return False return False
@@ -227,7 +268,7 @@ class SamsungTheFrame(SmartObject):
# if result == SmartCondition.Result.Succeeded: # if result == SmartCondition.Result.Succeeded:
# self.request_tv_state('art_mode') # self.request_tv_state('art_mode')
# else: # else:
# if self.get_state(self.is_art_mode_sensor) == 'on': # if self.is_art_mode_entity.get_state() == 'on':
# self.select_random_art() # self.select_random_art()
# #I need to request state if I'm not in art_mode as it will cancel a potential request pending # #I need to request state if I'm not in art_mode as it will cancel a potential request pending
# self.request_tv_state('off') # self.request_tv_state('off')
@@ -250,23 +291,23 @@ class SamsungTheFrame(SmartObject):
else: else:
is_on = "on" is_on = "on"
except KeyError as e: #sometimes the lib throw an exception in File "/samsungtvws/art.py", line 318, in get_artmode except KeyError as e: #sometimes the lib throw an exception in File "/samsungtvws/art.py", line 318, in get_artmode
self.log(f"get_artmode() throw a KeyError Exception {e}") self.log_info(f"get_artmode() throw a KeyError Exception {e}")
return False , 10 return False , 10
except websocket.WebSocketConnectionClosedException: except websocket.WebSocketConnectionClosedException:
self.log(f"update sensors failed with exception WebSocketConnectionClosedException") self.log_info(f"update sensors failed with exception WebSocketConnectionClosedException")
return False , 30 return False , 30
except ConnectionRefusedError: except ConnectionRefusedError:
self.log(f"update sensors failed with exception ConnectionRefusedError") self.log_info(f"update sensors failed with exception ConnectionRefusedError")
return False , 30 return False , 30
except tv_exceptions.ConnectionFailure: except tv_exceptions.ConnectionFailure:
self.log(f"update sensors failed with exception ConnectionFailure") self.log_info(f"update sensors failed with exception ConnectionFailure")
return False , 30 return False , 30
except OSError as error: except OSError as error:
self.log(f"update sensors failed with exception OsError {error}") self.log_info(f"update sensors failed with exception OsError {error}")
return False , 30 return False , 30
except tv_exceptions.ResponseError as e: except tv_exceptions.ResponseError as e:
self.log(f"update sensors failed with exception ResponseError {e}") self.log_info(f"update sensors failed with exception ResponseError {e}")
return False , 30 return False , 30
# except: # except:
# import traceback # import traceback
@@ -275,16 +316,19 @@ class SamsungTheFrame(SmartObject):
# self.log(f"update sensors failed with exception {exception_string}") # self.log(f"update sensors failed with exception {exception_string}")
# return False , 5 # return False , 5
if self.get_state(self.is_art_mode_sensor) != is_art_mode: if self.is_art_mode_entity.get_state() != is_art_mode:
self.log(f"Updating sensor: {self.is_art_mode_sensor} = '{is_art_mode}'") self.log_info(f"Updating sensor: {self.is_art_mode_entity.entity_id} = '{is_art_mode}'")
self.set_state(self.is_art_mode_sensor,state = is_art_mode) self.is_art_mode_entity.set_state(state = is_art_mode)
if self.get_state(self.is_on_sensor) != is_on: if is_on == 'off':
self.log(f"Updating sensor: {self.is_on_sensor} = '{is_on}'") self.close_volume_connection()
self.set_state(self.is_on_sensor,state = is_on)
if is_on == 'off' and self.get_state(self.tv_auto_shutdown) != 'off': if self.is_on_entity.get_state() != is_on:
self.log(f"setting {self.tv_auto_shutdown} to 'off'") self.log_info(f"Updating sensor: {self.is_on_entity.entity_id} = '{is_on}'")
self.set_state(self.tv_auto_shutdown,state = 'off') self.is_on_entity.set_state(state = is_on)
if is_on == 'off' and self.tv_auto_shutdown_entity.get_state() != 'off':
self.log_info(f"setting {self.tv_auto_shutdown_entity.entity_id} to 'off'")
self.tv_auto_shutdown_entity.set_state(state = 'off')
return True, 0 return True, 0
+585
View File
@@ -0,0 +1,585 @@
"""Persistent, non-blocking alternative to samsungtheframe.
Keep ``class: SamsungTheFrame`` and change only ``module: SamsungTheFrameAsync``.
No YAML changes are needed. Network I/O runs on AppDaemon's asyncio loop.
"""
import asyncio
import base64
from contextlib import suppress
import json
import math
from pathlib import Path
import random
import socket
import uuid
import aiohttp
from yarl import URL
from ad_toolbox.smartobject import SmartObject
from ad_toolbox.eventhandler import EventHandler
import ad_toolbox.smartcondition as SmartCondition
class TVError(Exception):
"""A failed connection or a rejected/malformed TV response."""
NETWORK_ERRORS = (TVError, OSError, TimeoutError, aiohttp.ClientError, ValueError)
class _TVConnection:
"""One reader per channel; Art responses and notifications share that reader.
This intentionally uses aiohttp directly: the installed samsungtvws Art
client is synchronous, and cannot share recv() with a notification listener.
"""
def __init__(self, session, host, token_file, on_event, timeout=2):
self.session = session
self.host = host
self.token_file = Path(token_file)
self.on_event = on_event
self.timeout = timeout
self.token = None
self.sockets = {}
self.readers = {}
self.pending = {}
self.locks = {name: asyncio.Lock() for name in ('art', 'remote')}
self.remote_lock = asyncio.Lock()
self.art_lock = asyncio.Lock()
self.closing = False
async def load_token(self):
try:
self.token = (await asyncio.to_thread(self.token_file.read_text)).strip() or None
except FileNotFoundError:
pass
def alive(self, channel):
ws = self.sockets.get(channel)
reader = self.readers.get(channel)
return ws is not None and not ws.closed and reader is not None and not reader.done()
async def connect(self, channel):
async with self.locks[channel]:
if self.alive(channel):
return self.sockets[channel]
await self.close_channel(channel)
endpoint = 'com.samsung.art-app' if channel == 'art' else 'samsung.remote.control'
query = {'name': base64.b64encode(b'SamsungTvRemote').decode()}
if self.token:
query['token'] = self.token
url = URL.build(scheme='wss', host=self.host, port=8002,
path=f'/api/v2/channels/{endpoint}', query=query)
ws = None
try:
# Bound the whole handshake, including TV authorization and ready.
async with asyncio.timeout(self.timeout):
ws = await self.session.ws_connect(url, ssl=False, heartbeat=20,
timeout=self.timeout)
connected = False
while True:
message = await ws.receive()
frame = self._frame(message)
event = frame.get('event')
if event in ('ms.channel.unauthorized', 'ms.error'):
raise TVError('TV rejected connection; check TV authorization')
if event == 'ms.channel.connect':
connected = True
data = frame.get('data', {})
if not isinstance(data, dict):
raise TVError('Invalid connection response')
token = data.get('token')
if token and str(token) != self.token:
self.token = str(token)
await asyncio.to_thread(self.token_file.write_text, self.token)
if connected and (channel == 'remote' or event == 'ms.channel.ready'):
break
except BaseException:
if ws is not None:
await ws.close()
raise
self.sockets[channel] = ws
self.readers[channel] = asyncio.create_task(self._read(channel, ws))
return ws
@staticmethod
def _frame(message):
if message.type != aiohttp.WSMsgType.TEXT:
raise TVError('TV WebSocket closed or returned a non-text message')
frame = json.loads(message.data)
if not isinstance(frame, dict):
raise TVError('Invalid TV WebSocket response')
return frame
def _dispatch(self, frame):
if frame.get('event') != 'd2d_service_message':
if frame.get('event') == 'ms.error':
raise TVError('TV rejected WebSocket command')
return
data = frame.get('data', {})
data = json.loads(data) if isinstance(data, str) else data
if not isinstance(data, dict):
raise TVError('Invalid Art response')
request_id = data.get('request_id') or data.get('id')
if request_id is not None and not isinstance(request_id, str):
raise TVError('Invalid Art request ID')
pending = self.pending.get(request_id)
if pending is not None and not pending.done():
if data.get('event') == 'error':
pending.set_exception(TVError(f"Art request rejected: {data.get('error_code')}"))
else:
pending.set_result(data)
# Correlated status responses are applied by _refresh(), which can
# reject a response older than a notification received while waiting.
if pending is None or data.get('event') != 'artmode_status':
self.on_event(data)
async def _read(self, channel, ws):
try:
async for message in ws:
self._dispatch(self._frame(message))
except NETWORK_ERRORS:
pass
finally:
if channel == 'art':
for future in tuple(self.pending.values()):
if not future.done():
future.set_exception(TVError('Art connection lost'))
await ws.close()
if not self.closing:
self.on_event({'event': 'connection_lost', 'channel': channel})
async def art_request(self, request, **params):
async with self.art_lock:
ws = await self.connect('art')
request_id = str(uuid.uuid4())
future = asyncio.get_running_loop().create_future()
self.pending[request_id] = future
try:
async with asyncio.timeout(self.timeout):
await ws.send_json({'method': 'ms.channel.emit', 'params': {
'event': 'art_app_request', 'to': 'host',
'data': json.dumps({'request': request, 'id': request_id,
'request_id': request_id, **params})}})
return await future
except (TimeoutError, aiohttp.ClientError, OSError):
# Drop a timed-out channel: a delayed response must not satisfy
# the next request, and the next attempt needs a clean handshake.
await self.close_channel('art')
raise
finally:
self.pending.pop(request_id, None)
if not future.done():
future.cancel()
async def send_key(self, key, hold=0):
async with self.remote_lock:
ws = await self.connect('remote')
async def send(cmd):
async with asyncio.timeout(self.timeout):
await ws.send_json({'method': 'ms.remote.control', 'params': {
'Cmd': cmd, 'DataOfCmd': key, 'Option': 'false',
'TypeOfRemote': 'SendRemoteKey'}})
try:
await send('Press' if hold else 'Click')
if hold:
try:
await asyncio.sleep(hold)
finally:
# Even a cancelled transition must release a held key.
await send('Release')
except NETWORK_ERRORS:
await self.close_channel('remote')
raise
async def app_status(self, app_id):
url = URL.build(scheme='https', host=self.host, port=8002,
path=f'/api/v2/applications/{app_id}')
async with self.session.get(url, ssl=False) as response:
response.raise_for_status()
result = await response.json(content_type=None)
if not isinstance(result, dict) or not isinstance(result.get('visible'), bool):
raise TVError('Invalid application status response')
return result
async def close_channel(self, channel):
reader = self.readers.pop(channel, None)
ws = self.sockets.pop(channel, None)
if reader is not None:
reader.cancel()
with suppress(asyncio.CancelledError):
await reader
if ws is not None:
await ws.close()
async def close(self):
self.closing = True
for channel in ('art', 'remote'):
await self.close_channel(channel)
class SamsungTheFrame(SmartObject):
"""Same YAML contract as samsungtheframe.SamsungTheFrame.
Optional settings: network_timeout (2 s), reconnect_delay (5 s, exponential
backoff capped at 60 s), fallback_refresh (60 s), power_threshold (35 W).
State/power callbacks only signal the async workers; they never contact TV.
"""
VALID_STATES = ('art_mode', 'tv_off', 'hdmi', 'power_off', 'manual')
def on_initialize_smart_object(self):
super().on_initialize_smart_object()
self._loop = self.AD.loop
self._stopped = False
self._wake = asyncio.Event()
self._ready = asyncio.Event()
self._volume = asyncio.Queue(maxsize=8)
self._target_task = None
self._tasks = set()
self._publish_lock = asyncio.Lock()
self._generation = 0
self.requested_state = None
self._observed = None
self._observation_revision = 0
self._refresh_at = 0
self._retry_at = 0
self._backoff = float(self.args.get('reconnect_delay', 5))
self._client = None
self.power_plug = self.args['input_sensors']['power_plug']
self.power_plug_power = self.args['input_sensors']['power_plug_power']
outputs = self.args['output_sensors']
self.is_on_entity = self.create_entity(outputs['is_on'])
self.is_art_mode_entity = self.create_entity(outputs['is_art_mode'])
self.tv_auto_shutdown_entity = self.create_entity(
outputs['tv_auto_shutdown'],
state=self.get_state(outputs['tv_auto_shutdown']) or 'off')
self.app_list = self.args.get('app_list', [])
self.tv_states_evaluators = []
self.listen_state(self.update_sensors_cb, self.power_plug)
self.listen_state(self.update_sensors_cb, self.power_plug_power)
if 'tv_state' in self.args:
self.listen_state(self.on_update_tv_state_sensor, self.args['tv_state'])
self.request_tv_state(self.get_state(self.args['tv_state']))
if 'tv_states' in self.args:
for state, condition in self.args['tv_states'].items():
self.tv_states_evaluators.append((SmartCondition.Evaluator(
self, condition, condition_name=state,
on_update_cb=self.on_update_tv_states), state))
self.on_update_tv_states()
self.volume_event_handlers = []
for direction in ('up', 'down'):
if self.args.get(f'volume_{direction}_events'):
self.volume_event_handlers.append(EventHandler(
self, self.args[f'volume_{direction}_events'],
self.on_volume_event, direction))
self._main_task = self.create_task(self._run())
self.log_info('SamsungTheFrameAsync initialized (persistent async WebSockets)')
def _post(self, callback, *args):
if not self._stopped:
self._loop.call_soon_threadsafe(callback, *args)
def update_sensors_cb(self, *args):
self._post(self._wake.set)
def on_update_tv_state_sensor(self, entity, attribute, old, new, kwargs):
if old != new:
self.request_tv_state(new)
def on_update_tv_states(self):
for evaluator, state in self.tv_states_evaluators:
if evaluator.evaluate(False) == SmartCondition.Result.Succeeded:
evaluator.log_evaluation_result()
self.request_tv_state(state)
break
def request_tv_state(self, state):
if state not in self.VALID_STATES:
self.log_info(f'Ignoring unavailable or invalid TV target: {state!r}')
return
self._post(self._set_target, state)
def _set_target(self, state):
if self._stopped:
return
self._generation += 1
# A fresh target must not inherit a long idle reconnection delay.
self._retry_at = min(self._retry_at, self._loop.time() + self._transition_retry_delay())
self.requested_state = None if state == 'manual' else state
previous = self._target_task
if previous is not None and not previous.cancelling():
previous.cancel()
self._target_task = self._spawn(
self._target_runner(previous, state, self._generation))
self._wake.set()
def on_volume_event(self, event_name, event_data, direction):
self._post(self._queue_volume, direction)
def _queue_volume(self, direction):
# Drop repetitions instead of replaying stale volume presses after an outage.
if not self._stopped and not self._volume.full():
self._volume.put_nowait((direction, self._loop.time()))
def _on_art_event(self, data):
event = data.get('event')
mode = data.get('status') if event == 'art_mode_changed' else data.get('value')
if event in ('art_mode_changed', 'artmode_status') and mode in ('on', 'off'):
self._observed = (mode == 'off', mode == 'on')
self._observation_revision += 1
elif event == 'go_to_standby':
self._observed = (False, False)
self._observation_revision += 1
elif event == 'wakeup':
self._refresh_at = 0
# Connection loss never means TV off. Keep the last confirmed state.
self._wake.set()
async def _power(self):
plug = await self.get_state(self.power_plug)
if plug == 'off':
return False, False
if plug != 'on':
return None, None
try:
power = float(await self.get_state(self.power_plug_power))
if not math.isfinite(power):
return True, None
return True, power > float(self.args.get('power_threshold', 35))
except (TypeError, ValueError):
return True, None
async def _publish(self, is_on, is_art):
async with self._publish_lock:
# Another reader may have delivered a newer state while HA was busy.
if self._observed is not None:
is_on, is_art = self._observed
if not is_on and await self.is_on_entity.get_state() == 'on':
await self._write(self.tv_auto_shutdown_entity, 'off')
await self._write(self.is_art_mode_entity, 'on' if is_art else 'off')
await self._write(self.is_on_entity, 'on' if is_on else 'off')
async def _write(self, entity, value):
if await entity.get_state() != value:
await entity.set_state(state=value)
async def _refresh(self):
revision = self._observation_revision
data = await self._client.art_request('get_artmode_status')
mode = data.get('value')
if mode not in ('on', 'off'):
raise TVError('Invalid art mode response')
# A notification arriving during the request takes precedence.
if self._observation_revision == revision:
self._observed = (mode == 'off', mode == 'on')
self._refresh_at = self._loop.time() + float(self.args.get('fallback_refresh', 60))
def _spawn(self, coro):
task = asyncio.create_task(coro)
self._tasks.add(task)
def done(future):
self._tasks.discard(future)
if not future.cancelled() and future.exception() is not None:
self.log_warning(f'TV background task failed: {future.exception()}')
task.add_done_callback(done)
return task
async def _run(self):
volume_task = None
try:
timeout = float(self.args.get('network_timeout', 2))
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session:
self._client = _TVConnection(
session, self.args['ip_address'],
Path(str(self.AD.app_dir)) / 'theframe_token.txt',
self._on_art_event, timeout)
try:
await self._client.load_token()
self._ready.set()
volume_task = self._spawn(self._volume_worker())
while not self._stopped:
self._wake.clear()
try:
plugged, powered = await self._power()
now = self._loop.time()
if plugged is False or powered is False:
self._observed = (False, False)
self._observation_revision += 1
self._refresh_at = 0
for channel in ('art', 'remote'):
await self._client.close_channel(channel)
elif plugged is True and now >= self._retry_at:
if not self._client.alive('art') or now >= self._refresh_at:
await self._refresh()
self._retry_at = 0
self._backoff = float(self.args.get('reconnect_delay', 5))
if self._observed is not None:
await self._publish(*self._observed)
except NETWORK_ERRORS as error:
self.log_info(f'TV refresh failed: {type(error).__name__}: {error}')
retry_delay = self._backoff
if self.requested_state is not None:
retry_delay = min(retry_delay, self._transition_retry_delay())
self._retry_at = self._loop.time() + retry_delay
self._backoff = min(60, self._backoff * 2)
# One refresh worker coalesces power events. WebSocket
# notifications wake it immediately; no network polling
# occurs for every consumption fluctuation.
deadline = (self._retry_at if self._retry_at > self._loop.time()
else self._refresh_at)
if not self._client.alive('art') and self._retry_at <= self._loop.time():
deadline = self._loop.time()
delay = max(0.2, min(60, deadline - self._loop.time()))
if powered is False or plugged is not True:
delay = 60
with suppress(TimeoutError):
await asyncio.wait_for(self._wake.wait(), delay)
finally:
tasks = list(self._tasks)
for task in tasks:
if not task.cancelling():
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
await self._client.close()
except asyncio.CancelledError:
raise
except Exception as error:
self.log_warning(f'SamsungTheFrameAsync stopped unexpectedly: {error}')
finally:
self._stopped = True
async def _volume_worker(self):
while True:
direction, received = await self._volume.get()
if (self._loop.time() - received > 1 or self._observed != (True, False)
or self._client.remote_lock.locked()):
continue
try:
await self._client.send_key('KEY_VOLUP' if direction == 'up' else 'KEY_VOLDOWN')
except NETWORK_ERRORS as error:
self.log_info(f'Volume {direction} failed (not replayed): {error}')
async def _wake_up(self):
if 'wake_up_service' in self.args:
await self.call_service(self.args['wake_up_service'])
elif 'wol_switch' in self.args:
await self.turn_on(self.args['wol_switch'])
if 'mac_address' in self.args:
mac = bytes.fromhex(self.args['mac_address'].replace(':', '').replace('-', ''))
if len(mac) != 6:
raise ValueError('Invalid Wake-on-LAN MAC address')
packet = b'\xff' * 6 + mac * 16
destinations = {'255.255.255.255', self.args.get('ip_address'),
self.args.get('broadcast_address')} - {None}
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.setblocking(False)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
for destination in destinations:
await self._loop.sock_sendto(sock, packet, (destination, 9))
async def _select_random_art(self):
data = await self._client.art_request('get_content_list', category=None)
available = data.get('content_list', [])
if isinstance(available, str):
available = json.loads(available)
if not isinstance(available, list):
raise TVError('Invalid artwork list')
if available:
selected = random.choice(available)
await self._client.art_request('select_image', content_id=selected['content_id'], show=True)
def _transition_retry_delay(self):
return max(0.2, float(self.args.get('transition_retry_delay', 2)))
async def _target_runner(self, previous, target, generation):
if previous is not None:
with suppress(asyncio.CancelledError):
await asyncio.shield(previous)
if target == 'manual' or self._stopped or generation != self._generation:
return
await self._ready.wait()
self.log_info(f'Requesting TV to switch to {target}')
if target == 'hdmi' and await self.is_on_entity.get_state() == 'off':
await self._write(self.tv_auto_shutdown_entity, 'on')
while generation == self._generation and not self._stopped:
try:
delay = await self._transition(target)
if delay is None:
self.log_info(f'The TV is confirmed in {target}')
if generation == self._generation:
self.requested_state = None
return
except NETWORK_ERRORS + (KeyError, TypeError) as error:
self.log_info(f'TV transition failed: {type(error).__name__}: {error}')
delay = self._transition_retry_delay()
# Cancellation interrupts both network waits and confirmation delays.
await asyncio.sleep(delay)
self._refresh_at = 0
self._wake.set()
async def _transition(self, target):
plugged, powered = await self._power()
if plugged is None or powered is None:
return self._transition_retry_delay()
if not plugged:
if target == 'power_off':
return None
await self.turn_on(self.power_plug)
return 5
if not powered:
if target == 'tv_off':
return None
await self._wake_up()
return 10
if self._loop.time() < self._retry_at:
return max(1, self._retry_at - self._loop.time())
# Always confirm the current state before a toggle. Serialize Art reads
# with the monitor; remote key sequences have a separate lock.
await self._refresh()
is_on, is_art = self._observed
await self._publish(is_on, is_art)
if target == 'tv_off':
if is_on:
await self._client.send_key('KEY_POWER')
return 10
if is_art:
await self._client.send_key('KEY_POWER', hold=4)
return 10
elif target == 'art_mode':
if not is_art:
await self._client.send_key('KEY_POWER')
return 10
elif target == 'hdmi':
if not is_on:
await self._client.send_key('KEY_POWER')
return 5
for app in self.app_list:
if (await self._client.app_status(str(app)))['visible']:
await self._client.send_key('KEY_HDMI')
return 5
elif target == 'power_off':
if is_on:
await self._client.send_key('KEY_POWER')
return 10
if not is_art:
await self._wake_up()
return 60
await self._select_random_art()
await asyncio.sleep(5)
await self.turn_off(self.power_plug)
return 5
return None
def terminate(self):
self._stopped = True
task = getattr(self, '_main_task', None)
if task is not None:
self._loop.call_soon_threadsafe(task.cancel)
super().terminate()
+2 -2
View File
@@ -190,7 +190,7 @@ class SmartLight(SmartSwitch):
def on_turn_on_with_transition(self, event_name, data, event_category): def on_turn_on_with_transition(self, event_name, data, event_category):
transition_time = self.args["on_events_with_transition"][event_category]["transition_time"] transition_time = self.args["on_events_with_transition"][event_category]["transition_time"]
brightness_pct = self.args["on_events_with_transition"][event_category]["brightness_pct"] brightness_pct = self.args["on_events_with_transition"][event_category]["brightness_pct"]
self.log(f"Turn on at {brightness_pct}% with a transition of {transition_time}s") self.log_info(f"Turn on at {brightness_pct}% with a transition of {transition_time}s")
self.call_service("light/turn_on", entity_id = self.entity_id,brightness_pct = 1) self.call_service("light/turn_on", entity_id = self.entity_id,brightness_pct = 1)
self.call_service("light/turn_on", entity_id = self.entity_id, transition = transition_time,brightness_pct = brightness_pct) self.call_service("light/turn_on", entity_id = self.entity_id, transition = transition_time,brightness_pct = brightness_pct)
@@ -210,7 +210,7 @@ class SmartLight(SmartSwitch):
# Override: apply light_brightness_pct when turning on, if one is active. # Override: apply light_brightness_pct when turning on, if one is active.
def switch_on(self): def switch_on(self):
if self.light_brightness_pct != None: if self.light_brightness_pct != None:
self.log(f"Turn on {self.entity_id} at {self.light_brightness_pct}%") self.log_info(f"Turn on {self.entity_id} at {self.light_brightness_pct}%")
self.call_service("light/turn_on", entity_id = self.entity_id,brightness_pct = self.light_brightness_pct) self.call_service("light/turn_on", entity_id = self.entity_id,brightness_pct = self.light_brightness_pct)
else: else:
super().switch_on() super().switch_on()
+94 -38
View File
@@ -1,16 +1,37 @@
import appdaemon.plugins.hass.hassapi as hass import appdaemon.plugins.hass.hassapi as hass
from smartswitch import SmartSwitch from smartswitch import SmartSwitch
from ad_toolbox.eventhandler import EventHandler
import ad_toolbox.smartcondition as SmartCondition import ad_toolbox.smartcondition as SmartCondition
import telnetlib import telnetlib
import time import queue
import threading
from functools import wraps
import datetime import datetime
def queued_operation(method):
"""Keep compound operations and their retries together in the FIFO worker."""
@wraps(method)
def enqueue(self, *args, **kwargs):
if self._command_stop.is_set():
return None
if threading.current_thread() is self._command_worker:
return method(self, *args, **kwargs)
self._command_queue.put((method, args, kwargs))
return None
return enqueue
class SmartProjector(SmartSwitch): class SmartProjector(SmartSwitch):
WARM_UP_TIME = 35 WARM_UP_TIME = 35
def get_default_entity_state(self): return 'off' def get_default_entity_state(self): return 'off'
def on_initialize_smart_object(self): def on_initialize_smart_object(self):
# Queue callbacks during initialization; start only once setup is complete.
self._command_queue = queue.Queue()
self._command_stop = threading.Event()
self._command_worker = threading.Thread(
target=self._process_commands, name=f"{self.name}-telnet", daemon=True)
#those variable are use withing get_default_entity_state() #those variable are use withing get_default_entity_state()
self.telnet_ip = self.args['telnet_connection']['ip'] self.telnet_ip = self.args['telnet_connection']['ip']
self.telnet_port = self.args['telnet_connection']['port'] self.telnet_port = self.args['telnet_connection']['port']
@@ -36,6 +57,8 @@ class SmartProjector(SmartSwitch):
if 'press_enter_event' in self.args: if 'press_enter_event' in self.args:
self.listen_event(lambda event_name, data, kwargs: self.press_enter(),self.args['press_enter_event']) self.listen_event(lambda event_name, data, kwargs: self.press_enter(),self.args['press_enter_event'])
if 'press_enter_events' in self.args:
self.event_handlers.append(EventHandler(self, self.args['press_enter_events'], self.on_press_enter_event))
self.projector_ready_conditions = SmartCondition.Evaluator(self,self.args["projector_ready_conditions"],condition_name = 'projector_ready_conditions') self.projector_ready_conditions = SmartCondition.Evaluator(self,self.args["projector_ready_conditions"],condition_name = 'projector_ready_conditions')
@@ -60,6 +83,7 @@ class SmartProjector(SmartSwitch):
self.reset_update_state_cb() self.reset_update_state_cb()
#self.entity.listen_state(self.on_state_change) #self.entity.listen_state(self.on_state_change)
self.update_state() self.update_state()
self._command_worker.start()
def on_power_change(self, entity, attribute, old, new, kwargs): def on_power_change(self, entity, attribute, old, new, kwargs):
self.update_state() self.update_state()
@@ -88,6 +112,7 @@ class SmartProjector(SmartSwitch):
def is_available(self): return self.entity.get_state() != 'unavailable' def is_available(self): return self.entity.get_state() != 'unavailable'
@queued_operation
def switch_on(self): def switch_on(self):
if self.is_available(): if self.is_available():
self.log(f"Turn on {self.entity_id}") self.log(f"Turn on {self.entity_id}")
@@ -108,6 +133,7 @@ class SmartProjector(SmartSwitch):
else: else:
self.log_warning("Turn On ignored as the projector is unavailable") self.log_warning("Turn On ignored as the projector is unavailable")
@queued_operation
def switch_off(self): def switch_off(self):
if self.is_available(): if self.is_available():
self.log(f"Turn off {self.entity_id}") self.log(f"Turn off {self.entity_id}")
@@ -137,6 +163,7 @@ class SmartProjector(SmartSwitch):
else: raise else: raise
else: return 'off' else: return 'off'
@queued_operation
def on_update_picture_mode(self): def on_update_picture_mode(self):
for evaluator in self.picture_mode_evaluators: for evaluator in self.picture_mode_evaluators:
if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded: if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
@@ -152,6 +179,7 @@ class SmartProjector(SmartSwitch):
self.entity.set_state(attributes = self.attributes) self.entity.set_state(attributes = self.attributes)
break break
@queued_operation
def on_update_lamp_mode(self): def on_update_lamp_mode(self):
for evaluator in self.lamp_mode_evaluators: for evaluator in self.lamp_mode_evaluators:
if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded: if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
@@ -160,6 +188,7 @@ class SmartProjector(SmartSwitch):
result = self.send_cmd(f"lampm={evaluator[1]}",True) result = self.send_cmd(f"lampm={evaluator[1]}",True)
break break
@queued_operation
def update_state(self,kwargs = None): def update_state(self,kwargs = None):
new_state = self.get_projector_state() new_state = self.get_projector_state()
@@ -168,7 +197,7 @@ class SmartProjector(SmartSwitch):
if new_state == 'on': if new_state == 'on':
# the projector is not responding during power on sequence # the projector is not responding during power on sequence
if self.attributes['warming_up'] == 'off': if not self.attributes['warming_up']:
cmd_result = self.send_cmd("appmod=?") cmd_result = self.send_cmd("appmod=?")
if cmd_result: self.attributes['picture_mode'] = cmd_result.split("=")[1] if cmd_result: self.attributes['picture_mode'] = cmd_result.split("=")[1]
else: self.attributes['picture_mode'] = "unavailable" else: self.attributes['picture_mode'] = "unavailable"
@@ -190,52 +219,79 @@ class SmartProjector(SmartSwitch):
self.attributes['warming_up'] = new_value self.attributes['warming_up'] = new_value
@queued_operation
def on_warm_up_done(self,*kwargs): def on_warm_up_done(self,*kwargs):
self.warming_up_done_cb_handle = None self.warming_up_done_cb_handle = None
self.attributes['warming_up'] = False self.attributes['warming_up'] = False
self.entity.set_state(state = self.get_projector_state(),attributes = self.attributes) self.entity.set_state(state = self.get_projector_state(),attributes = self.attributes)
def press_enter(self): def on_press_enter_event(self, event_name, data, event_context):
self.send_cmd("enter") self.press_enter()
@queued_operation
def press_enter(self):
if self.entity.get_state() == 'on':
self.send_cmd("enter")
@queued_operation
def send_cmd(self,cmd, log_command = False): def send_cmd(self,cmd, log_command = False):
cmd = f"*{cmd}#" cmd = f"*{cmd}#"
return self._send_cmd(cmd,log_command,3) return self._send_cmd(cmd,log_command,3)
def _send_cmd(self,cmd,log_command,try_count): def _process_commands(self):
invalid_result = [ "*Unsupported Item#", "*Block Item#", "*Illegal format#" ] while not self._command_stop.is_set():
operation = self._command_queue.get()
try:
if operation is None or self._command_stop.is_set():
return
method, args, kwargs = operation
try:
method(self, *args, **kwargs)
except Exception as error:
self.log_error(f"Projector operation {method.__name__} failed: {error}", dump_stack=True)
finally:
self._command_queue.task_done()
should_retry = False def terminate(self):
with telnetlib.Telnet(self.telnet_ip,self.telnet_port) as session: # Discard pending work and interrupt retry delays. An active exchange is
if log_command: self.log_info(f'Sending command "{cmd}"') # bounded by the connection and read timeouts (2 + 5 seconds).
try: self._command_stop.set()
session.write(bytes(cmd,'utf-8')) self._command_queue.put(None)
output = session.read_until(b"#",timeout = 5).decode('ascii') if self._command_worker.is_alive():
except (ConnectionResetError,EOFError): self._command_worker.join(timeout=8)
self.log_warning(f"Connection Reset While sending command {cmd}. {try_count} try left") super().terminate()
if try_count <= 0:
self.log_error(f"Could not send command {cmd}",dump_stack = True)
return None
else: should_retry = True
def _send_cmd(self, cmd, log_command, try_count):
# opening session recursivly inside the with doesn't sound like a good idea invalid_result = {"*Unsupported Item#", "*Block Item#", "*Illegal format#"}
if should_retry: for attempt in range(try_count + 1):
if try_count == 1: if self._command_stop.is_set():
# I know calling sleep() is in callback is evil
# but this is a very small sleep time and working with event would make the code much much more complexe
time.sleep(1)
return self._send_cmd(cmd,log_command,try_count - 1)
if log_command: self.log_info(f"Result = {output}")
if output in invalid_result:
self.log_error(f"Command rejected. {cmd} returned {output}",dump_stack = True)
return None
if not output:
if try_count > 0:
self.log_warning(f"Command output is empty. {cmd}. {try_count} try left")
return self._send_cmd(cmd,log_command,try_count - 1)
else:
self.log_error(f"Command output is empty. {cmd}. {try_count} try left",dump_stack = True)
return None return None
return output.strip("*").rstrip("#") try:
with telnetlib.Telnet(self.telnet_ip, self.telnet_port, timeout=2) as session:
if log_command:
self.log_info(f'Sending command "{cmd}"')
session.write(cmd.encode('utf-8'))
output = session.read_until(b"#", timeout=5).decode('ascii').strip()
if self._command_stop.is_set():
return None
if log_command:
self.log_info(f"Result = {output}")
if output in invalid_result:
self.log_error(f"Command rejected. {cmd} returned {output}", dump_stack=True)
return None
# read_until may return a partial response when its timeout expires.
if output.endswith("#"):
return output.strip("*").rstrip("#")
reason = f"empty or incomplete response: {output!r}"
except (OSError, EOFError) as error:
if self._command_stop.is_set():
return None
if isinstance(error, OSError) and error.errno == 113:
raise
reason = str(error)
remaining = try_count - attempt
self.log_info(f"Command {cmd} failed ({reason}); {remaining} retries left")
if remaining and self._command_stop.wait(1):
return None
self.log_error(f"Could not send command {cmd}", dump_stack=True)
return None
+60 -19
View File
@@ -1,6 +1,7 @@
import appdaemon.plugins.hass.hassapi as hass import appdaemon.plugins.hass.hassapi as hass
import ad_toolbox.smartcondition as SmartCondition import ad_toolbox.smartcondition as SmartCondition
from ad_toolbox.smartobject import SmartObject from ad_toolbox.smartobject import SmartObject
from ad_toolbox.eventhandler import EventHandler
import time import time
class SmartShutter(SmartObject): class SmartShutter(SmartObject):
@@ -29,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
@@ -39,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
@@ -59,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
@@ -75,33 +76,73 @@ class SmartShutter(SmartObject):
self.ready = True self.ready = True
self.on_update() self.on_update()
# toggle_events uses the same event filters as SmartSwitch.
self.event_handlers = []
if "toggle_events" in self.args:
self.event_handlers.append(EventHandler(self, self.args["toggle_events"], self.on_toggle_event))
def terminate(self): def terminate(self):
self.event_handlers = []
self.emergency_close_conditions = None self.emergency_close_conditions = None
self.close_conditions = None self.close_conditions = None
self.open_conditions = None self.open_conditions = None
super().terminate() super().terminate()
def on_toggle_event(self, event_name, data, event_context):
if not self.ready:
return
current_position = self.get_state(self.entity_id, attribute='current_position')
try:
current_position = float(current_position)
except (TypeError, ValueError):
self.log_warning("Toggle ignored: current position is unavailable")
return
# Reverse the requested movement when another press arrives in transit.
if self.target_position is not None:
should_close = self.target_position == self.open_position
else:
midpoint = (self.open_position + self.close_position) / 2
should_close = current_position >= midpoint
if not should_close and self.emergency_close_conditions and self.emergency_close_conditions.evaluate(False) == SmartCondition.Result.Succeeded:
self.on_update()
return
self.stop_slow_transition()
if self.auto_switch:
self.set_state(self.auto_switch, state='off')
self.target_position = self.close_position if should_close else self.open_position
self.log_info(f"Toggle shutter to {self.target_position} (previous position was {current_position})")
if self.target_position == 0:
self.call_service("cover/close_cover", entity_id=self.entity_id)
elif self.target_position == 100:
self.call_service("cover/open_cover", entity_id=self.entity_id)
else:
self.call_service("cover/set_cover_position", entity_id=self.entity_id, position=self.target_position)
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
@@ -109,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:
@@ -126,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:
@@ -166,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):
@@ -177,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():
@@ -206,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()
@@ -227,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
+13 -13
View File
@@ -104,7 +104,7 @@ class SmartSwitch(SmartObject):
if self.smart_conditions_evaluator == None: if self.smart_conditions_evaluator == None:
self.off_conditions_evaluator = SmartCondition.Evaluator(self,self.args['off_conditions'],condition_name = "off_conditions",on_succeed_cb = self.on_off_conditions,constants = self.constants, templates_library = self.templates_library) self.off_conditions_evaluator = SmartCondition.Evaluator(self,self.args['off_conditions'],condition_name = "off_conditions",on_succeed_cb = self.on_off_conditions,constants = self.constants, templates_library = self.templates_library)
else: else:
self.log(f"Warning you can't have both an off_conditons and a smart_conditions, the off_conditions will be ignored") self.log_warning(f"Warning you can't have both an off_conditons and a smart_conditions, the off_conditions will be ignored")
# Event-driven controls (off / on / toggle / iOS action) # Event-driven controls (off / on / toggle / iOS action)
self.event_handlers = [] self.event_handlers = []
@@ -120,12 +120,12 @@ class SmartSwitch(SmartObject):
self.auto_switch_cb_handle = None self.auto_switch_cb_handle = None
if 'auto_switch_on_after' in self.args: if 'auto_switch_on_after' in self.args:
if self.is_off(): if self.is_off():
self.log(f"Smartswitch has been restarted while it was on. Since we have auto_switch_on_after activated we turn it on as we can't know how long is left for the timer",level = 'WARNING') self.log_warning(f"Smartswitch has been restarted while it was on. Since we have auto_switch_on_after activated we turn it on as we can't know how long is left for the timer")
self.switch_on() self.switch_on()
self.listen_state(self.on_state_change,self.entity_id, old = 'on',new = 'off') self.listen_state(self.on_state_change,self.entity_id, old = 'on',new = 'off')
if 'auto_switch_off_after' in self.args: if 'auto_switch_off_after' in self.args:
if self.is_on(): if self.is_on():
self.log(f"Smartswitch has been restarted while it was on. Since we have auto_switch_off_after activated we turn it off as we can't know how long is left for the timer",level = 'WARNING') self.log_warning(f"Smartswitch has been restarted while it was on. Since we have auto_switch_off_after activated we turn it off as we can't know how long is left for the timer")
self.switch_off() self.switch_off()
self.listen_state(self.on_state_change,self.entity_id, old = 'off',new = 'on') self.listen_state(self.on_state_change,self.entity_id, old = 'off',new = 'on')
@@ -137,7 +137,7 @@ class SmartSwitch(SmartObject):
# listen_state callback for auto_switch_*_after. Cancels any pending # listen_state callback for auto_switch_*_after. Cancels any pending
# timer then arms a new one for the configured delay. # timer then arms a new one for the configured delay.
def on_state_change(self, entity, attribute, old, new, kwargs): def on_state_change(self, entity, attribute, old, new, kwargs):
self.log("state changed from " + str(old) + " to " + str(new)) self.log_info("state changed from " + str(old) + " to " + str(new))
if old != new: if old != new:
if self.auto_switch_cb_handle != None: if self.auto_switch_cb_handle != None:
self.cancel_timer(self.auto_switch_cb_handle) self.cancel_timer(self.auto_switch_cb_handle)
@@ -145,18 +145,18 @@ class SmartSwitch(SmartObject):
if new == 'on' and 'auto_switch_off_after' in self.args: if new == 'on' and 'auto_switch_off_after' in self.args:
delay = self.args['auto_switch_off_after'] delay = self.args['auto_switch_off_after']
self.log(f"{self.entity_id} will auto switch on in {delay}s") self.log_info(f"{self.entity_id} will auto switch on in {delay}s")
self.auto_switch_cb_handle = self.run_in(self.on_auto_switch_after, delay, new_state = 'off',autoswitch_delay = delay) self.auto_switch_cb_handle = self.run_in(self.on_auto_switch_after, delay, new_state = 'off',autoswitch_delay = delay)
if new == 'off' and 'auto_switch_on_after' in self.args: if new == 'off' and 'auto_switch_on_after' in self.args:
delay = self.args['auto_switch_on_after'] delay = self.args['auto_switch_on_after']
self.log(f"{self.entity_id} will auto switch off in {delay}s") self.log_info(f"{self.entity_id} will auto switch off in {delay}s")
self.auto_switch_cb_handle = self.run_in(self.on_auto_switch_after, delay, new_state = 'on',autoswitch_delay = delay) self.auto_switch_cb_handle = self.run_in(self.on_auto_switch_after, delay, new_state = 'on',autoswitch_delay = delay)
# Timer callback: applies the deferred state set by auto_switch_*_after. # Timer callback: applies the deferred state set by auto_switch_*_after.
def on_auto_switch_after(self, kwargs): def on_auto_switch_after(self, kwargs):
self.auto_switch_cb_handle = None self.auto_switch_cb_handle = None
new_state = kwargs['new_state'] new_state = kwargs['new_state']
self.log(f"Switching {self.entity_id} {new_state} after {kwargs['autoswitch_delay']}s") self.log_info(f"Switching {self.entity_id} {new_state} after {kwargs['autoswitch_delay']}s")
if new_state == 'on': if new_state == 'on':
self.switch_on() self.switch_on()
else: else:
@@ -167,7 +167,7 @@ class SmartSwitch(SmartObject):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def switch_on(self): def switch_on(self):
self.log(f"Turn on {self.entity_id}") self.log_info(f"Turn on {self.entity_id}")
self.turn_on(self.entity_id) self.turn_on(self.entity_id)
# not needed yet # not needed yet
# if self.smart_conditions_evaluator: # if self.smart_conditions_evaluator:
@@ -176,7 +176,7 @@ class SmartSwitch(SmartObject):
# self.off_conditions_evaluator.force_last_evaluation_result(SmartCondition.Result.Succeeded) # self.off_conditions_evaluator.force_last_evaluation_result(SmartCondition.Result.Succeeded)
def switch_off(self): def switch_off(self):
self.log(f"Turn off {self.entity_id}") self.log_info(f"Turn off {self.entity_id}")
self.turn_off(self.entity_id) self.turn_off(self.entity_id)
# not needed yet # not needed yet
# if self.smart_conditions_evaluator: # if self.smart_conditions_evaluator:
@@ -186,10 +186,10 @@ class SmartSwitch(SmartObject):
def on_toggle_event(self, event_name, data, kwargs): def on_toggle_event(self, event_name, data, kwargs):
if self.is_on(): if self.is_on():
self.log(f"Toggled off by event {event_name}") self.log_info(f"Toggled off by event {event_name}")
self.switch_off() self.switch_off()
else: else:
self.log(f"Toggled on by event {event_name}") self.log_info(f"Toggled on by event {event_name}")
self.switch_on() self.switch_on()
def is_on(self): return self.get_state(self.entity_id) != 'off' def is_on(self): return self.get_state(self.entity_id) != 'off'
@@ -201,11 +201,11 @@ class SmartSwitch(SmartObject):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def on_turn_off_event(self, event_name, data, kwargs): def on_turn_off_event(self, event_name, data, kwargs):
self.log(f"Turned off by event {event_name}") self.log_info(f"Turned off by event {event_name}")
self.switch_off() self.switch_off()
def on_turn_on_event(self, event_name, data, kwargs): def on_turn_on_event(self, event_name, data, kwargs):
self.log(f"Turned on by event {event_name}") self.log_info(f"Turned on by event {event_name}")
self.switch_on() self.switch_on()
def on_toggle_event_action(self, event_name, data, kwargs): def on_toggle_event_action(self, event_name, data, kwargs):
+9 -10
View File
@@ -159,10 +159,9 @@ class SonosPlayer:
self.log(f"Creating a ValueSelector for the audio_delay") self.log(f"Creating a ValueSelector for the audio_delay")
self.audio_delay_selector = ValueSelector(self.controller,self.controller,sensor_name = yaml_conf["audio_delay"]["entity"],super_entity_id = self.entity_id,yaml_block = yaml_conf["audio_delay"]['value'],self_initialize = True) self.audio_delay_selector = ValueSelector(self.controller,self.controller,sensor_name = yaml_conf["audio_delay"]["entity"],super_entity_id = self.entity_id,yaml_block = yaml_conf["audio_delay"]['value'],self_initialize = True)
self.is_playing_podcast_sensor = f'binary_sensor.{player_name}_is_playing_podcast' self.is_playing_podcast_entity = self.controller.create_entity(
is_playing_podcast_sensor_entity = self.controller.get_entity(self.is_playing_podcast_sensor) f'binary_sensor.{player_name}_is_playing_podcast',
if not is_playing_podcast_sensor_entity.exists(): )
is_playing_podcast_sensor_entity.add()
self.controller.listen_state(self.on_state_change,self.entity_id) self.controller.listen_state(self.on_state_change,self.entity_id)
self.controller.listen_state(self.on_update_media_artist,self.entity_id,attribute = "media_artist") self.controller.listen_state(self.on_update_media_artist,self.entity_id,attribute = "media_artist")
self.update_is_playing_podcast_sensor() self.update_is_playing_podcast_sensor()
@@ -401,16 +400,16 @@ class SonosPlayer:
self.update_is_playing_podcast_sensor() self.update_is_playing_podcast_sensor()
def update_is_playing_podcast_sensor(self): def update_is_playing_podcast_sensor(self):
if self.is_playing_podcast_sensor and self.is_playing(): if self.is_playing_podcast_entity and self.is_playing():
prev_state = self.controller.get_state(self.is_playing_podcast_sensor) prev_state = self.is_playing_podcast_entity.get_state()
if self.controller.get_state(self.entity_id,attribute = "media_artist") == None: if self.controller.get_state(self.entity_id,attribute = "media_artist") == None:
if prev_state != 'on': if prev_state != 'on':
self.log(f"setting {self.is_playing_podcast_sensor} to on") self.log(f"setting {self.is_playing_podcast_entity.entity_id} to on")
self.controller.set_state(self.is_playing_podcast_sensor,state = "on") self.is_playing_podcast_entity.set_state(state = "on")
else: else:
if prev_state != 'off': if prev_state != 'off':
self.log(f"setting {self.is_playing_podcast_sensor} to off") self.log(f"setting {self.is_playing_podcast_entity.entity_id} to off")
self.controller.set_state(self.is_playing_podcast_sensor,state = "off") self.is_playing_podcast_entity.set_state(state = "off")
def on_update_off_conditions(self): def on_update_off_conditions(self):
if self.off_conditions_evaluator.evaluate(False) == SmartCondition.Result.Succeeded: if self.off_conditions_evaluator.evaluate(False) == SmartCondition.Result.Succeeded:
+767 -120
View File
File diff suppressed because it is too large. Load diff
+166
View File
@@ -0,0 +1,166 @@
from ad_toolbox.smartobject import SmartObject
EMPTY_VALUES = {None, "", "unknown", "unavailable", "none", "null"}
def useful(value):
"""Return a displayable Home Assistant value, or None."""
if isinstance(value, str):
value = value.strip()
if value.lower() in EMPTY_VALUES:
return None
elif value in EMPTY_VALUES:
return None
return value
class VertexContent(SmartObject):
"""Expose normalized metadata for the source currently selected on a Vertex2."""
def on_initialize_smart_object(self):
super().on_initialize_smart_object()
self.output = self.args.get("output", "sensor.vertex2_current_content")
self.input_selected = self.args.get(
"input_selected", "sensor.vertex2_input_selected"
)
self.has_signal = self.args.get(
"has_signal", "binary_sensor.vertex2_has_input_signal"
)
self.sources = self.args.get("sources", {})
state, attributes = self.build_content()
self.output_entity = self.create_entity(
self.output, state=state, attributes=attributes
)
self.listen_state(self._state_changed, self.input_selected)
self.listen_state(self._state_changed, self.has_signal)
# attribute="all" is important: a media title can change while the
# media_player state remains "playing".
listened = set()
for config in self.sources.values():
for entity in (config.get("entity"), config.get("artwork_entity")):
if entity and entity not in listened:
self.listen_state(self._state_changed, entity, attribute="all")
listened.add(entity)
def _state_changed(self, entity, attribute, old, new, kwargs):
self.update_content()
def _entity_data(self, entity):
if not entity:
return None, {}
data = self.get_state(entity, attribute="all") or {}
return data.get("state"), data.get("attributes", {}) or {}
@staticmethod
def _first_attribute(attributes, names):
for name in names:
value = useful(attributes.get(name))
if value is not None:
return value
return None
def _artwork(self, config, attributes):
artwork = self._first_attribute(
attributes, config.get("artwork_attributes", ["entity_picture"])
)
if artwork is not None:
return artwork
_, artwork_attributes = self._entity_data(config.get("artwork_entity"))
return useful(artwork_attributes.get("entity_picture"))
@staticmethod
def _pause_supported(config, application, source_state):
pause_states = config.get("pause_states", [])
if not pause_states or useful(source_state) is None:
return False
supported_apps = config.get("pause_supported_apps")
if supported_apps is None:
return True
return application in supported_apps
def build_content(self):
selected = useful(self.get_state(self.input_selected))
has_signal = self.get_state(self.has_signal) == "on"
base = {
"friendly_name": "Contenu Vertex2",
"icon": "mdi:television-play",
"source": selected,
"platform": None,
"content_type": None,
"title": None,
"series_title": None,
"application": None,
"status": "idle",
"artwork": None,
"entity_picture": None,
"available": False,
"is_paused": False,
"is_pause_supported": False,
}
if not has_signal or selected is None:
base["source"] = selected
base["icon"] = "mdi:television-off"
return "idle", base
config = self.sources.get(str(selected), {})
label = config.get("label", selected)
source_state, attributes = self._entity_data(config.get("entity"))
application = self._first_attribute(
attributes, config.get("application_attributes", ["app_name"])
)
title = self._first_attribute(
attributes, config.get("title_attributes", ["media_title"])
)
series_title = self._first_attribute(
attributes, config.get("series_title_attributes", ["media_series_title"])
)
if title is None and config.get("state_as_title"):
title = useful(source_state)
artwork = self._artwork(config, attributes)
pause_supported = self._pause_supported(config, application, source_state)
pause_states = config.get("pause_states", [])
is_paused = pause_supported and source_state in pause_states
content_type = self._first_attribute(
attributes, config.get("content_type_attributes", ["media_content_type"])
)
if content_type is None:
content_type = config.get("content_type")
if content_type is None and application and title is None:
content_type = "application"
display = title or application or label
base.update({
"icon": config.get("icon", base["icon"]),
"platform": config.get("platform", label),
"content_type": content_type,
"title": title,
"series_title": series_title,
"application": application,
"status": useful(source_state) or "active",
"artwork": artwork,
"entity_picture": artwork,
"available": True,
"is_paused": is_paused,
"is_pause_supported": pause_supported,
})
return str(display)[:255], base
def update_content(self):
state, attributes = self.build_content()
current_state = self.output_entity.get_state()
current_attributes = self.output_entity.get_state(attribute="all") or {}
if isinstance(current_attributes, dict) and "attributes" in current_attributes:
current_attributes = current_attributes["attributes"]
expected_attributes = dict(attributes)
expected_attributes["ad_app"] = self.name
if current_state != state or current_attributes != expected_attributes:
self.output_entity.set_state(state=state, attributes=expected_attributes)
+23
View File
@@ -0,0 +1,23 @@
--- 4.5.10/plugin_management.py
+++ installed/plugin_management.py
@@ -490,7 +490,7 @@
ns = cfg.namespace
self.AD.state.update_namespace_state(ns, state)
finally:
- await self.refresh_update_time(plugin)
+ await self.refresh_update_time(plugin.name)
def required_meta_check(self):
OK = True
--- 4.5.10/utils.py
+++ installed/utils.py
@@ -398,6 +398,9 @@
def deep_compare(check: dict, data: dict) -> bool:
"""Compares 2 nested dictionaries of values"""
data = data or {} # Replaces a None value with an empty dict
+
+ if check.keys() != data.keys():
+ return False
for k, v in tuple(check.items()):
if isinstance(v, dict) and isinstance(data[k], dict):
+319
View File
@@ -0,0 +1,319 @@
{
"reference": "https://github.com/AppDaemon/appdaemon/tree/4.5.10",
"date": "2026-11-04",
"allowed_differences": [
"plugin_management.py",
"utils.py"
],
"sha256": {
"__init__.py": "0007f5d9141995768e07f5ba472a22ee19ad571677b1da7ab836d7a6ba8c9237",
"__main__.py": "2a5ba7b454eb8c306969f43968bd6d8068debed593a5db6ceb63d991a354f84f",
"adapi.py": "711d1d0542f746efa5872afb6cf0a2512bfb3f328fcac35ee38c610590e24545",
"adbase.py": "f884423fe0c275d9fb30242f6065250357a329dcd3ee0d7aed51941af0fd2c31",
"admin.py": "2991ddc31cd64abde2002ef0d987aaab57a8795b970a7457d2c9c37e01839e25",
"admin_loop.py": "25419606baaf3763009005c1d276c5115bc16f86e6042ae8a8426de7c64617ad",
"app_management.py": "75bec7c0998409d03957ff68e57142530faacccbce50ad8d1589c451638ad7f0",
"appdaemon.py": "75649168292a643c73ead23f6fb06be63b75f3cf9bc46c49de129ddb5491ad44",
"callbacks.py": "b664da26c08870dbfdc1a974f403edaaa4eb49d71e9d2b4ee3f6ce795db112f0",
"dashboard.py": "ac07dfb7528f485c747acfc14c1fd403e4b6ca9c2e79046df6b799db46c82c3d",
"dependency.py": "19be55c1d2781879b756f48c29e03b38fbb6b5fa7978c5d8d85962f4758d690d",
"dependency_manager.py": "845eed4f87a14eb711267eab05201f5bf2162f33d7f67a9169e58703da830936",
"entity.py": "76d15e924d8e0454c2bf5f1e8141b91df2e2b89a23f328d00ad2e1361645601b",
"events.py": "fff272c0236153d4163b0aa5d7504791812910bb4fb8a6f44c680f6e543aee26",
"exceptions.py": "12802e1a95bec30179946cf0df4539f6d263d52ba49da82efadfd6a1454fec5d",
"futures.py": "04613f65789706db264fb609bc89b9e7750c814ba714a87e83a7f3d867b68860",
"http.py": "cfc62804e3588151072b154fc66d1419b1d9a00b4546f457d06155b84355aebe",
"logging.py": "cae00ddbcdb7d713fa41821264b3eddc9c13366d68ce8b072225e53d7ea53c04",
"plugin_management.py": "89529671cfd1c5b5589e107f82c4b62fa5d48eff9b63ae7058c2d3770c5472c4",
"pyversions.py": "accaca278ca62c7d23bf19fb7386e15b8ab700afe69aa61155af8b3c3a80f4fc",
"scheduler.py": "d268d1f940b42c8dc8a210d1b9a471b193c3172d6728fd01df185bfd199ed0c5",
"sequences.py": "6bd113404a26735ec4a3c636cb29b60e3520ae4e8ae8d285b15de80d6df231d4",
"services.py": "3ef4dc986fa5c4f7ed579b4e1ca205da152d3f310b5fd574db6663829acfd4e9",
"state.py": "b8f0c7fc1a70d214493f88d2867518d56d48b5a98ee2b439d2529673d1ba0fca",
"thread_async.py": "1e99604cd9ec804b8d5b9f4a3bb927b2e4dffa1532bc928bd93fd6c1b96629c0",
"threads.py": "bcdea68ed11b42027427c8706c848be25df0d34a5d2b6b6a09a4ce4fe08fd401",
"utility_loop.py": "9723a23f622c97366294093b47bcba93218fd558c04326d7dddb30135a8a978f",
"utils.py": "43fcd79677bdf1cf06e47af9226f761e9b559a2db8d51c0fc7af732be5ebe9c4",
"version.py": "94c208125a4f88f014e7a18aa906215396966ade72afa3219a70420d79f33bbe",
"aui/index-prod.html": "193e86be7182e43a0c58b5f28ac8fea01f606653649d7966069a757ae532fe3c",
"models/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"plugins/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"stream/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"stream/adstream.py": "cd3a2a85130e126199125d26385954770fa604a52c3b5a470954e0f3c7cb339d",
"stream/socketio_handler.py": "f75c8409580393de7ba95fc527c60ee011686903162e8da244a7ae9a54b250db",
"stream/sockjs_handler.py": "58f67242277118fc11419f0a3991ffe9165909f085e5a9cf71a260f3cab4b9e2",
"stream/ws_handler.py": "ba6dda951c7d1fbab30e63913953b2ef909124d97393c404c21ee0d245d6131b",
"widgets/AdminLog.yaml": "36f2f3a85bd4103e26f6932875dbdd3162689934e200a9860b5a091bc5710ad6",
"widgets/AdminSummary.yaml": "bd525c531791ba7a397969919d47e7443828545252afe3f9a9e7bd666b1f2f3f",
"widgets/AdminTable.yaml": "1966c04efb764cf069a99e11e4ef4e7c0b8183e6b975339f7591fd5adc47b07e",
"widgets/alarm.yaml": "6f020ffd36fec1e9a6a7615cb8a51429002bd4e58a192f135bdc19c2d8df7f99",
"widgets/binary_sensor.yaml": "85cf49c6edffb3ac4854418a6814a65c082e19b1dff520eab3a43ae1c1ea44cf",
"widgets/camera.yaml": "ccdcd7f79cc824c4ac064cd36d1a3bfa785d6a12d264bb81341d2c56db0126f6",
"widgets/climate.yaml": "cfa3058bb5b5e6f9cd0bdad26612b7e270fc6764724d84662de3321f6a8a192f",
"widgets/clock.yaml": "0f86aa3c509e415bc79077375994b04f596a3cd4dabb90c6e6b528cab13db17d",
"widgets/cover.yaml": "4a1cd1580e935967b4903781adacb455b66f93180724a2550ee9e148ee6faf06",
"widgets/device_tracker.yaml": "d6c98b6e619bc4d9766213fb5c674413f107face43791d39a16c38a1ba3a5990",
"widgets/entitypicture.yaml": "9fae3c21e00aa9942228aca9b19530a1557e61a93a66ba3dfbf2808d8beccfa6",
"widgets/fan.yaml": "efa6ac5ab0280f2a2d4f978147fd6a80f5c3eb642da559ca9f9c1e9c4838f839",
"widgets/gauge.yaml": "833f595c5ba050cc34a8c9e14493b1b7b42879c95a14bd6a43c02319625126fc",
"widgets/group.yaml": "0afd0d4783ee54faa720c7b2e912b0bc2caefab985a89eecd3740717e327673c",
"widgets/heater.yaml": "490b95f8efe42cfd29038cbf8716e9fa6be4de25f1def6785efc28f98d860346",
"widgets/icon.yaml": "5e8f5b67219d7a8b9f36c2c20919120e3acc97f26d6b7c305a612ee39f87b16e",
"widgets/iframe.yaml": "77496c4300b8ab442743d5e58d09747f76875af7c42572c6cbe3e7b38b53449e",
"widgets/input_boolean.yaml": "7de2bd30dcc6e69270523fda0084e2e8393d873890f2f01bf563c1f3aad533a0",
"widgets/input_datetime.yaml": "7eca2be9e6ffa20b3b5176c62e809f3d7249f263f12e9d3a86a6db1609e465ca",
"widgets/input_number.yaml": "33f1f127fa69cc2e8f9f0e1eed2402ac22191a3d92d0f1fe8a43c281fe6aa30a",
"widgets/input_select.yaml": "a35aa8abb82ffeaefe83f27ea030b16bfeda9a14b233464b10d9af75667ba22a",
"widgets/input_slider.yaml": "fb1555d441f17aecd7ba3da1d2564a3487984aad1d637acc3ff402921c0dacc6",
"widgets/input_text.yaml": "3a9e428ff148de795adeea7ce0584dbc5f400fc3920cf811ddc24a3e0f4c2773",
"widgets/javascript.yaml": "952656bf90bd5c8adbd7b7330a6a6f3171e524caebb56fae89b1b1927069620e",
"widgets/label.yaml": "6d5694f2f01f3575d3b59154a3c651a6776ac751b518ce5a14cb1343f96ddfb2",
"widgets/light.yaml": "a49d98e912c36bd3d68f7d3689bcf8a1407ae8d5aab8dbd49c0d6478c20da904",
"widgets/lock.yaml": "e64f8fc90574d41bc7c04998759097633a5c08d6a15d8bd12342762ece8450af",
"widgets/london_underground.yaml": "ddea7cd7eafb2bc52ef2f75fd451490354dfbd247dab50bdb5212b6fa33e0a55",
"widgets/media_player.yaml": "8442b43d2767c3b35a72d829a781ddff6e64d87a5c8b0cae309a9be1c2516549",
"widgets/mode.yaml": "0f2ddcdbe445f4506f4b01a88bec3b2cc3447fcee167dce93333f8fee13d7a95",
"widgets/navigate.yaml": "fe07aca210cdf6f1432f2e2e2f793a8dd4046a83c31a1c234b0b17f9e51c6717",
"widgets/person.yaml": "892dcf64b8c43a63911479f20f7c8a98e2ed21b4c194bc9cbcf77c9087104fa7",
"widgets/pirateweather.yaml": "408b853117ef2e243a4e75a5752c4bb58100f09b370c2bcf15321f47d653c8d0",
"widgets/radial.yaml": "aca17401122a83cadc9788781e4a4040d10568d0dde90bdfe4edadd199a2c1fd",
"widgets/reload.yaml": "8408a90810c8d0cd064cd5ae675b8a67aea580ccd6b39d89d246d2f93a93d1c6",
"widgets/rss.yaml": "431fe8c22619da3962180e492893773296c8d8b3e4cd7e0b166b06b21831f714",
"widgets/scene.yaml": "0f61a077590c1de7b8c8a5ddde0e4084df612fdffa14ade7241e377ef644f040",
"widgets/script.yaml": "6d77a900434bcf94badaa03e8d975f5339afcbcd5759bb8f1206e9df88447bf6",
"widgets/sensor.yaml": "4b07cab76dd222528a96c24e6d88e0d474119f614dd8fa17c9d781c1f01949cd",
"widgets/sequence.yaml": "cf950e34f974f08f9eefc1a32a88570738e2b6b6e5e4a017046c12a536937666",
"widgets/switch.yaml": "2ec1863654c1c0f19703024e3990f5b30aeff9f18ddb9cbb1048cab447a38e58",
"widgets/temperature.yaml": "dc7a5536bf964be529b086e62d6b9b9f3cd65b1550cb0fe600e1b4b61b478740",
"widgets/text_sensor.yaml": "dfe72ca03524cca5db086d453b1b72f5b26c65b36ee0c3a4bff322fcfefb4a43",
"widgets/weather.yaml": "d16f02fb76545811a34fd5cc8ef03569c68f8982d32af58efabd13cf23ac56dd",
"widgets/weather_summary.yaml": "ec52da9a3a2178336768375814edfd7cbca03bb8bcfe6eefab0224c806f064b4",
"assets/aui/appdaemon.png": "252fa8d5415ac605181056e813caf902e18e13b376b3b4a8262edce19c0d5554",
"assets/aui/favicon.ico": "3d3f93c1cb1128905183270584d523168fbd35e937db712e9f6930e94a7a2cad",
"assets/aui/index.html": "e51891677050ef1aac795d4e65dc8b5189732aedfef129515bc322f4271fc6e0",
"assets/css/all.css": "462fc2981ff1e4df0af8902d356dd65726082c9a43d93b912419ce36568901eb",
"assets/css/climacons-font.css": "8619c9c4ca2ad2eee221a0b6df87338c249485f7c12862d74bedab732743f203",
"assets/css/fontawesome.min.css": "39ee099e0a46898ee5af78f65c8754a951fc72cce5b6d31596104bb6b3874dc9",
"assets/css/fonts.css": "16dd24ed9fb06a48a4db43684f27f2cceb5805c5435cec8bc383d876109b7bfb",
"assets/css/jquery-ui.css": "a7ac54f58ba507b13621ceb6fcf5fe879f5ac9bdcf049d16153110c6ad048c7c",
"assets/css/jquery.gridster.css": "d059374489ef40c069a8da9b798fb8a1bc7aa08f1f40b94d5bd4aafe2f352e6f",
"assets/css/main.css": "257719cff84654dc49b4968dd4964bb8ea44e8396dc175a2b4444ff442fed42c",
"assets/css/materialdesignicons.min.css": "b73dc5385b050b7e8f3383bf7ac8c38e808330b9c8092df8987f7d55a63e528d",
"assets/css/non_dashboard.css": "3eb54b421644bc1a9dfdf9c02a0d844e23bd99ada840d2a37a7d3be5a399f3ee",
"assets/css/rickshaw.min.css": "87ed7cdada83fcbf84b99cf585bf04567b041afed53326ecd4ecb6d88fede1bb",
"assets/css/v4-shims.min.css": "c939af78a3deaf2987bb2b6403888932b15fe072a7a4befe4a4e235f61f09e11",
"assets/fonts/TickingTimebombBB.ttf": "2944c6e24e20d7eebf3b75c213e399c68e2cfa004984713229f4df17be154f5e",
"assets/fonts/climacons-webfont.eot": "00a0ac962bb138510c6164685b44eaff2f75645a99ad5cfe650a92bec05ded29",
"assets/fonts/climacons-webfont.svg": "bef3ab62931cc54c7fe1815103ee27020e05b54338f6d5dc428df456935eb7c9",
"assets/fonts/climacons-webfont.ttf": "e59c14a9d1b33874af4c93d1faf78eb57a006558b713c8031ac2f010572705d4",
"assets/fonts/climacons-webfont.woff": "7d3cffc3e2dc69ec1b687e77e0f1e152b5eff1db1494b9b8e8e0fb03b0f5cb37",
"assets/fonts/digital-7-mono.eot": "3eb301951faa2552498717d73157698c0a6eb9b3a8bd9bcf7fc98d72a8004004",
"assets/fonts/digital-7-mono.ttf": "58a6983851a3e3c433bbad4d9ac97b01ec204ee81fb29a51222a9286a45598c2",
"assets/fonts/materialdesignicons-webfont.eot": "0b183104d2fc5f26196e773c77bdaf2e05509fd4259d2d15ecedca79b87e84f9",
"assets/fonts/materialdesignicons-webfont.ttf": "61e8aba5a4e981fe22cf7c8e8bcdbea00476e75c62c37f01bf7ee33361d68428",
"assets/fonts/materialdesignicons-webfont.woff": "a5928a0d5c2f624e46f98d9b15c2f60045377f7c594dd78a1759132ea3b463eb",
"assets/fonts/materialdesignicons-webfont.woff2": "662fefa8f2f8a95c18588d21774789c107c64e771cbe65a69af46291c4311afc",
"assets/fonts/repetition.ttf": "69a9f0671b9e86bf52519ebe54cc6d47bda1cabb8e28226834a7b241a1a32fe8",
"assets/images/Blank.gif": "4e0705327480ad2323cb03d9c450ffcae4a98bf3a5382fa0c7882145ed620e49",
"assets/images/android-chrome-192x192.png": "252fa8d5415ac605181056e813caf902e18e13b376b3b4a8262edce19c0d5554",
"assets/images/android-chrome-512x512.png": "9ba4aeffee60a5e784e9300d4135dfdbc6a9edfe5529db7ea7702ce2a58715bb",
"assets/images/apple-touch-icon.png": "3f632ff46def9612f365fbe4de8ec444ef159b1a0ab2e2c3adf42df7664e090e",
"assets/images/browserconfig.xml": "c348b0d6df3d900efef7ae4b4dfc8b62b1737b04d09d4584fef243211c266866",
"assets/images/favicon-16x16.png": "70ef6d0a8ff6d3c2290b390d06e3fcb37d0326c80e0366e22dff6158090cd7da",
"assets/images/favicon-32x32.png": "0cd943b8fe52fab2d231aff19ac10891e6a371eb7b9f4e5512d3eeccf567e220",
"assets/images/favicon.ico": "3d3f93c1cb1128905183270584d523168fbd35e937db712e9f6930e94a7a2cad",
"assets/images/manifest.json": "a471cc0ded86fb614911a6a9bed67c931f197c34fcb327709229aa0832848876",
"assets/images/mstile-144x144.png": "014f3854abf436024bafdbcf82cdd87467300ba58fb82a7f4788df1ad29d9f39",
"assets/images/mstile-150x150.png": "bc21b7beb73d3fcf66b7bbd4e36f7a4c96713735d855e4db4941b5b329560284",
"assets/images/mstile-310x150.png": "c5b2fdfe4fb8e5161bc8c917f0c3feaa2ac552a0863721a833a219f20af4556d",
"assets/images/mstile-310x310.png": "90c6cf90a99e0e2a041bf7c1ba7654004c425e3fd049e7147aac7c12ab41c697",
"assets/images/mstile-70x70.png": "de9d1301957625cf9f81e05bf421aec79ad02472077b2306933ad2cbb5aeeab8",
"assets/javascript/admin.js": "b7fa92e27a55ad4e0cdee94815300e0f122af0e9a4a843462fbc1eb91d99626c",
"assets/javascript/d3.layout.min.js": "8f25034da0ee3457e8a9a5fad9507576357698965791b7439bd553b7aa3aad18",
"assets/javascript/d3.min.js": "4ec5104fe7c61d5b87b25346af8f4b834b1f6164b1101514c692cde21dc0f550",
"assets/javascript/dashboard.js": "993e2d307ff994be25811ec44445297257f8ef36f0ea3e04e21e06240cb55b96",
"assets/javascript/gauge.min.js": "9f9f43e8b5ffc5d660cd47d802c89f8521c02d98be195bfb0ea0179d2b332ad6",
"assets/javascript/jquery-3.1.1.min.js": "119bad7b860ec25a136da766c4061dfdd7ff645c3f49980b3e01d98d2d924472",
"assets/javascript/jquery-ui.js": "e0e1c1f83cae13611b8ab09cc5c6dcb73465071831501e48aff2c3d9d1f1b795",
"assets/javascript/jquery.gridster.js": "8042c3522b3bfad7f5a545fc2b469a9f8288c94cb99aade199812c0245a19429",
"assets/javascript/justgage.js": "b3ea81d552a1a2ce2561c555f2122c679268158a46dc70b8249d47fc45944dcb",
"assets/javascript/knockout-3.4.1.js": "3d7f7359568809409e92573359a4bd0cb05bf6e18dee90a4093d0acb3d5e951a",
"assets/javascript/list.min.js": "fe1e2e056d0070d6ae1c3de0fb3423c53f6334d8cfd3c33d96a3f012032f02d2",
"assets/javascript/plotly.min.js": "37bdb72e5e71e4c1e5e4709589fabe8eae5b203000d659c30af867260e62d8d8",
"assets/javascript/raphael-2.1.4.min.js": "181ac9fcdb0f5fdf94e248bcf2d4fcc082125b1c0afbc75ddfcbe850112e20d1",
"assets/javascript/reconnecting-websocket.min.js": "03827095c0efa8ee095e9bc4b6f598d511fc24010cbb95b6d703fc1945cb50db",
"assets/javascript/rickshaw.min.js": "59ac7a8608c17bd1141a1a7f668fffed8ba8864a2ad109e1231c6d8a9b71c83a",
"assets/javascript/socket.io.js": "9c104866f002c8679b0b000cc8af1d1cc270fee443acf3ed425c299809d75e31",
"assets/javascript/sockjs.min.js": "cf870a4f7c85f9a7c89fc7855c973e9d1a46b707263440a5bdffa2920b19711a",
"assets/javascript/stream.js": "76cd8fa3e7c12451672425485ef89ff3f66e1b1a3b24e9525b5e6143d67b87ab",
"assets/templates/admin.jinja2": "d1b0ce0a9659db62aa3f743290c46cc08f9f195b3875cfa219b83fb3daf78da6",
"assets/templates/body_include.jinja2": "43bea5403dcec932ad43c235317fe3f8af6294c0175d80a2f80a6b2c626b8f3b",
"assets/templates/dashboard.jinja2": "c1a3f2cecdcaa291c0560ca35215640a44cd9ccd03d81459cf5f00d29f71d1fc",
"assets/templates/dashinit.jinja2": "9e3cee77b52a8754fa442334f1edc9caddbb1b438bce461fffbeaf3b4ab01c00",
"assets/templates/error.jinja2": "be9d8dc22aeb9eb6d4e684aaefb5e206163587484e5cf2563a237df9dc504738",
"assets/templates/head_include.jinja2": "a2d438f6c51e1fae2c598eb6ebeb3f01b560b5ab34392fa16780bcec673b150e",
"assets/templates/list.jinja2": "3649fd6eb7e5818f5a1a70c8e4f4d315678104dc3932476cbdb815f0bf4c297c",
"assets/templates/logon.jinja2": "67f2ea7a4b9c89304091c46c1df189503a59211f5a691eda789f8de571327abe",
"assets/webfonts/fa-brands-400.eot": "5d68cb5fc50d0d366f0a4999778a2905303a5b1f53bb1e9ff679c6537d56156c",
"assets/webfonts/fa-brands-400.svg": "6b9a88c4426c61a0aa2d2a6be434bdf5b5df9bba493896a2ff19aed4e5b61d4b",
"assets/webfonts/fa-brands-400.ttf": "3b8b359a8de499509dff8e39478fe04fbf3ebe244254b108e66816f048d0ecf8",
"assets/webfonts/fa-brands-400.woff": "f21ddd7e9eb9033d530a62766fd05269b5f0c2b1c2d005b872decd00c71ea5ab",
"assets/webfonts/fa-brands-400.woff2": "8386356e1d512f570aac1b4883b928fe4bfd649c1d54a4d0f618357359cd3b74",
"assets/webfonts/fa-regular-400.eot": "44165afb8b1984ee93ab86f358ca0c00cdf95f57731e85c5edb6a0bb8ec673d7",
"assets/webfonts/fa-regular-400.svg": "aa106eda9929cea6c5fe38eae44daf75f957f3ceba120dd45d4ce5a358434bab",
"assets/webfonts/fa-regular-400.ttf": "25a854973a8a66612f9f80c4600b906d82dde6530c2a080342fc4e8bbf8b894b",
"assets/webfonts/fa-regular-400.woff": "28a52249e8a966d0ebc168bc983c138f30f1c1ab83926d342a05da36f61f2f2f",
"assets/webfonts/fa-regular-400.woff2": "4d64f142cf22d40343bbf54ee0e0ec48b7b2bc654b9be78517d5634207286524",
"assets/webfonts/fa-solid-900.eot": "d338e920e4771513976e2ba92d260e4b61522b3ba21486232dddf63ef02a7bd7",
"assets/webfonts/fa-solid-900.svg": "dd2440abba3f5060a9da4390e954df253762bbec62a1fd6c3fccad2cc060374d",
"assets/webfonts/fa-solid-900.ttf": "4892669c1d80a9e837be9edea4db711e42260eccc6933e16ee449bcaaafd7e78",
"assets/webfonts/fa-solid-900.woff": "b7e697ebc01ea8d96bcda256f6fe634afaf5b8f1bf98522c3d7263382ba5bfc7",
"assets/webfonts/fa-solid-900.woff2": "a5587a9dca3673b604a8a0e144d268f3dcb180aac337e2b2e163704bc1fc508a",
"assets/aui/css/app.css": "96ad563f162727c8f444e00033a754cd96a82d3480110e6916dd4e3952709ac5",
"assets/aui/css/chunk-vendors.css": "ebb321a0638f4d962d3e28c9a904fd12ee13a4b007b562fd1db952405a8f498d",
"assets/aui/js/app.js": "3e4ee235a31ace6caa8933a59d3ee893eae3bb095990f5a94620e71be4d0898b",
"assets/aui/js/app.js.map": "e4cbc2c757f2f887b4b6b699d26ff0942f75d00da370c930c687ff1f0c1ad5f3",
"assets/aui/js/chunk-vendors.js": "353ad1516dd3789b994b4afd36217129c9cbc77455c6117b3a378b4398aff14a",
"assets/aui/js/chunk-vendors.js.map": "90de6e8efce85cc2714f4d4f3b42bd7bea69a76dd9c24f4dac58121a2d6fa827",
"assets/css/default/dashboard.css": "1c91e4afc8bd102e12891f2bb1bae5acb1754bc1c88806d9129616d7b52ce770",
"assets/css/default/variables.yaml": "f509281ce4456d3e1f0aa72e751f3506fed29b68e00eebc82e5b08a28d10e2e2",
"assets/css/glassic/dashboard.css": "6cc29bfbb702571431e833bc37b8d66d94bc57be7ceecb6d36989076f12c5093",
"assets/css/glassic/variables.yaml": "9e67612ad0da0c207a58290781da106bd27801fa181a26a4cc656fecf0a88df2",
"assets/css/images/ui-icons_444444_256x240.png": "eaf7c7ee2747275dda6c53e731a10db1a7b15f4fbb46e1b69d6bb2056bc9fd81",
"assets/css/images/ui-icons_555555_256x240.png": "5d04051dfd9d2d740355404f98a683d1ec0feb2fca7d76e533c1a6e5ce92dd2e",
"assets/css/images/ui-icons_777620_256x240.png": "9dbe4a0d03feed6f65eb2560a0a8b4ba4264545ee8fd31c1763a3b219d0328d6",
"assets/css/images/ui-icons_777777_256x240.png": "e75b27211e16fcf94715168001bb7055ca376d46f928110ba3d0825232452e5a",
"assets/css/images/ui-icons_cc0000_256x240.png": "02891575d4358e9edde3e42d94057f8e9f82a9d64376f71ee86cef149d30537e",
"assets/css/images/ui-icons_ffffff_256x240.png": "b6b06deef2b924cc3835d63f4883d47882734a33e71b212d917a68cede3b8e9d",
"assets/css/obsidian/dashboard.css": "67f3cda7f1c7efab50357dad051c2c6e7c28d53d6ac91e55ca23e2230b5e8e2b",
"assets/css/obsidian/variables.yaml": "ae315b1199ee65a7f37aa85edaf5ab6e9c3b728de483c32da1430eeafbbbcfad",
"assets/css/simplyred/dashboard.css": "6d685d5c836b54edd552247a10019a0931f0b4ad78bbdb245afb6f3851fec127",
"assets/css/simplyred/variables.yaml": "cca292500a3dbaf20dfe69c02aa269feaf13408bfabe46196dc731622313c8cc",
"assets/css/zen/dashboard.css": "be0e085329c6def90175322acf2e0a7ae5750a0255ff60c5a3b4aedd93feba2f",
"assets/css/zen/variables.yaml": "034998cc51f6a29fc4ee4af9334be79001d8fe8ba1f1fb693206d6a7e8c706c3",
"assets/css/glassic/img/carbon1.jpg": "44adafb6b35b20588e272c5cd7397388e884172e887152b7363d481d14e214dc",
"assets/css/glassic/img/carbon2.jpg": "926471d8e113c031715a3d14fd69c603e0c8d7d247af15bd5c02727bbd134896",
"assets/css/glassic/img/carbon3.jpg": "4251d187f88f55d8536d0505ddfc28fb4f323ed27088db9f27b436c31dd2da3e",
"assets/css/glassic/img/carbon4.jpg": "c8a0df27c3d991c8b8807a1b8016cf57eac770d957385158daa9efb5865ddc09",
"assets/css/glassic/img/glassic_bg.jpg": "3e1c5e037b3ebad3115f75e6ad07991b5b4c5857b2b6292583deaf90332307d8",
"assets/css/obsidian/img/obsidian_bg.jpg": "b8e6314886c56128beb728abc7b7b23c3ea7bb99fda982ae4dcc76d688b8a207",
"assets/css/obsidian/img/obsidian_w_bg.jpg": "d480a4635ff86ebdc794bfbdf62e5bc0a48b3b3e4c28c82441cff84b0edc288c",
"assets/css/obsidian/img/obsidianbg.jpg": "b8e6314886c56128beb728abc7b7b23c3ea7bb99fda982ae4dcc76d688b8a207",
"assets/css/obsidian/img/widgetbg.jpg": "d480a4635ff86ebdc794bfbdf62e5bc0a48b3b3e4c28c82441cff84b0edc288c",
"assets/css/simplyred/img/goldgradient.png": "79bcabeb9e10fcb3eae0411feef3d28aee06f7f5836e8172724c2def7ab6e640",
"assets/css/simplyred/img/goldtexture.jpg": "80310ad4546ebdc45e17575bdc25c6c0775af68eb63b00ba88ea2077261ba3c0",
"assets/css/simplyred/js/timer.js": "aa441f5479b7d76c72eb679ad1b6186760db4e307ccdd3e276f7aabbea94c2bc",
"assets/css/zen/img/zen_bg.jpg": "6111faf3252c15a350511bc88a4ebaa1f0b9b18bc82e43f53ff9c0e94bc58609",
"assets/css/zen/img/zen_bg2.jpg": "a724c70447a0471384b11048ac2ed52cb84cfd099890db6542a395e9622982a2",
"assets/css/zen/img/zen_weatherbg.jpg": "83fde892f0fda16d788afd46ed94ffd62be6701c66aa3943c3cbbf972c48aa6a",
"models/config/__init__.py": "19d3ad9c40382cf38ac6dde5ab88bbf3f3db5645edb5bdb1ff008ba8439fced9",
"models/config/api.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"models/config/app.py": "4d41c603190784d70d62614e3e5a25d922e2bd1c2f9a3ec2d0288e35e36ca05a",
"models/config/appdaemon.py": "320c19ea8276537581e61efadbfe64a510bc00823e3d396b81e24db7b3dd467d",
"models/config/common.py": "273b27bb751ac4d364cd5ace5b55b521142639b4a1515d75b784d81a6e8ca773",
"models/config/dashboard.py": "f1fc4927af62c7efb4bb348e4691a145ee7b50bd4afafff7cd9ca7ead96ae890",
"models/config/http.py": "a73a8429a232057a928cf8785d9376882a6c3a8ed4e04bb220fd009bb4ab512d",
"models/config/log.py": "7c043fc01ef5c755ff0d53b83ef27449523e22320addf068de12d4dfd059feaf",
"models/config/misc.py": "9e97bf8937677cfd9f74ed865e8bb834902e57aa13d8afa776ffd2bcda2be04c",
"models/config/plugin.py": "0d8076c34eae84e87f17c1cc741f9cdd38d660054d5b621f3e1446c2095b4151",
"models/config/sequence.py": "b98e3f14e0e971d52c2f3093c5b193b33f162d50e53cec3aa6f9f2d10a38dd1a",
"models/config/yaml.py": "b733c6b180108eaf1394d500505d240d6015042c15034310800afb2ab3fe8917",
"models/internal/__init__.py": "025052dfa21c98592410f3cbf77250c92618bfc9ea3f218caf530da6a92d544e",
"models/internal/app_management.py": "943b29ead0e04578463e1a4169dd99f7d07a926b1e25b7cec2a6ab53ae554da6",
"models/internal/file_check.py": "cc75fe5956a990da8675e117223615094ec1d6a34c769fe49d9a4edb5801b5b4",
"models/internal/plugin.py": "c5bb9956ed86e298524846450c5d1750746294ff18a92bd22672c347bd064510",
"models/internal/state.py": "152f40cdbed02ad2e7c430ed42086b35f3a9414b6392940c7c2127c2d7fff2f2",
"models/internal/threading.py": "38941ea25c9dcf2900c4c6395d1401015eb35de67fbf8999cdf21f4e144deb3b",
"models/notification/__init__.py": "1432554b95229c2a338bb0ece10ea141068df573bbbfa43adde2e3776257aea8",
"models/notification/android.py": "62d118cfe4c6fa50d685a0b160ab03301badc469019fb9c9d36ce733dbe4dd2d",
"models/notification/base.py": "8d05d671e2e893d78dad4f0c053f3d232861ac6e141195e2a60937fdbd80c2f8",
"models/notification/iOS.py": "61c756b984a0a26b28a771df220e368b8414f6efa5bb2733ff91b975f0bb3c91",
"plugins/dummy/dummyapi.py": "a92cf5e6cdc4b671e5fe040d75a077b9a2adc728f8d474cb55e8bbd66689340c",
"plugins/dummy/dummyplugin.py": "f448378c5aa59e7a82d1f205874c1cd347981f23d68daf822c3f9243aed8846b",
"plugins/hass/__init__.py": "7552f2a227213504a064605ce7e280c4bec244b218dd4dfad565b3f059233d13",
"plugins/hass/exceptions.py": "ec057567ac3fe16c57452cf3e5ce3992f4535e3eea40442db7c4ecfd251d8a16",
"plugins/hass/hassapi.py": "3bbca695ca78e055828f1b96d887d8ae017d12af499055cebf456e3fe73d34e3",
"plugins/hass/hassplugin.py": "0aab3aaa8e0f879904270152aac3c361dd86c6a494ed479bccfcdd8f74ad10fe",
"plugins/hass/models.py": "889178c31d3dc612f1344812a9d1248147e2c1ce45b967fa304911a82e277607",
"plugins/hass/notifications.py": "efd661d918294eb03213bac8d8fa674a006294dfe54b46ce4352368006ac2dfa",
"plugins/hass/utils.py": "c6f1d1fa0d53b1c2d858e50c4a0180cfdef9d1d2220fa30020a8840bd1b781f0",
"plugins/mqtt/__init__.py": "df5ae958839fdce576295b8668be07c5f97aac8081aefb292694ad3b562ae3a2",
"plugins/mqtt/mqttapi.py": "dda16cda56235564ce8af70334d403ce2a0f0059fda9439d9297d6f704a676b2",
"plugins/mqtt/mqttplugin.py": "da6216827adfb449d033137a30333028e4c00f4864d67d9951a4bf13d46cdf02",
"widgets/basealarm/basealarm.css": "2a1e61c42ebd49c915c539b922c16b3eb4ad3cf6eddda1776e344a04845b4d74",
"widgets/basealarm/basealarm.html": "7b9d436f4f05c7bb731b32df485ef2b127e5da6f3750aa037c745687d45816d9",
"widgets/basealarm/basealarm.js": "ebff1ed1de3a4db2eab28ce81509cd279a061450ebe5a8a92edbf361b36d3305",
"widgets/basecamera/basecamera.css": "06746a98f7098e2194084285d204c4ca21e1ea314a5b0b2cb7986004c9c9c34a",
"widgets/basecamera/basecamera.html": "d14808f4d3a9bf8785fced9992269ee82841421bc5f9b4df73990206136cb6f4",
"widgets/basecamera/basecamera.js": "6f81b1ea8faaefafcf30aba159308a844d1040bd730f4796c5cc7ce980a990e8",
"widgets/baseclimate/baseclimate.css": "c8fc911e355e83cd0d9e8ae9dabb36414cd71812d1cfcfdca5f4709ce1b1f559",
"widgets/baseclimate/baseclimate.html": "1e4083b2b92d8f141fa7c5a6bf3960305ffda8f77a2e7c97d9c17ae5b6489d5c",
"widgets/baseclimate/baseclimate.js": "5186e853da9e58b4882d82cc9b5356a60f31dc799a786f5ccba41ff2e5eed671",
"widgets/baseclock/baseclock.css": "f81cbca78857941b31923b21f68d8bc0d7a03011ac7d88862463b7b79c59dcb8",
"widgets/baseclock/baseclock.html": "9f988da94522d7440101ec045f2779fd946c2ce3b30a02239f67e2d8e8cb4dbb",
"widgets/baseclock/baseclock.js": "a7fbc2c815da0f17b02bb39430e88b07fa1ac5861cd4305f660b071a426d2a73",
"widgets/basedatetime/basedatetime.css": "b411d3f21af285092826029b5147cfcb6f4b046dee9162392922c9094fc254f7",
"widgets/basedatetime/basedatetime.html": "fba7f69c1446852c83af18102e938ec3b520ebb1f9672f125fd369d46c3bb337",
"widgets/basedatetime/basedatetime.js": "093ca34c8a464b7c1cad15bccfc2e4f564bd7e8335f941d559d8435c46e2a61f",
"widgets/basedisplay/basedisplay.css": "4ac1bdc04804b9fd76c68b807bc486555f437d0657d0ea5f57578d88be02dbc8",
"widgets/basedisplay/basedisplay.html": "2446e4bffdf9bbd946b8c8ab33f453848e1e92eed6ecdec283702ae55929b9cf",
"widgets/basedisplay/basedisplay.js": "990ef146432d26cc03e1a6ab8b25e88aba6f8820125cabcb84a5cec72e5d5cc8",
"widgets/baseentitypicture/baseentitypicture.css": "7930f8168dab7133a5831f34478f046f6649e9ca102edea29e0d3746c7250110",
"widgets/baseentitypicture/baseentitypicture.html": "ebac4368b209ebe733abca60d3d2da00d2cd515f82b79f16023247b73005d46c",
"widgets/baseentitypicture/baseentitypicture.js": "85981dbcc600e697547e78d76d06d886c379e461144b76e0b89ff941b121ba6a",
"widgets/baseerror/baseerror.css": "f997c5a1914c0bbebbcc12ba417b49cf857271d95df2e5cb4a7b080bbcbb0fc3",
"widgets/baseerror/baseerror.html": "549c6cfbeed80a60fc2309aa2c5ffde1ffc800b69027349a3e9403f1b29ed797",
"widgets/baseerror/baseerror.js": "ab33ee3b901f12bacf2aa13e0ab5d0ff6a975d3ffb40f3eaabf8c6e7e168d30f",
"widgets/basefan/basefan.css": "2e08ac65108cea6fa8651d0f7a0e2204e7214535c3aa85015e3a0625614da1cb",
"widgets/basefan/basefan.html": "aa0e34d7f9bcaac0f77616dda9640f452b1aea702d09aecf8e14ad657d15c2a1",
"widgets/basefan/basefan.js": "79184e6ca836325d4e2d704d45538ef90746ea701ffa7c153d99590ffb30dcfd",
"widgets/basegauge/basegauge.css": "ae2fdb3e66df2c586d187079bd83bca52bbda617003452c91ebd7e70c582ccbe",
"widgets/basegauge/basegauge.html": "6fd7ae8f200b6fff8699ef92912d5ba9688ec4c5e074c12883ab0d069c8a1a3b",
"widgets/basegauge/basegauge.js": "77d7e17d4f7e0fb138f17a52b90e5c08caf7e6ee58e944dffc2d62faccb666b0",
"widgets/baseheater/baseheater.css": "66e042528095583f6661fdf576b42e699f5f28e679281c67313d09feb8d745ca",
"widgets/baseheater/baseheater.html": "a64b2c49bc680b378402e55b2744a19387bf18705005fb7646c7b5937fb2387b",
"widgets/baseheater/baseheater.js": "0d2397338225cec9fa62bbb8c0f2c1b43ef004dba262af921b5ef11e58fbdfdb",
"widgets/baseicon/baseicon.css": "f4fcb35555a1eb66b88feed361bb4ede17c8dbe9fe0c2bde22d53d7afd26b2ec",
"widgets/baseicon/baseicon.html": "f6a2895ed41ea25df2aebb96c1215d7851e7bd13521cfd0f97cc28533274daca",
"widgets/baseicon/baseicon.js": "8f8c342717ee7bbac51e0659d1ee19e22892a033b2b65270d10a77749d88f779",
"widgets/baseiframe/baseiframe.css": "16580cf89459eb3b1e7660068d3c430bf5350b849507c15d1955fa150f625849",
"widgets/baseiframe/baseiframe.html": "18046647c99af56e6fde5b1b0aec0455beda89d08963f1636fba159ad92645f6",
"widgets/baseiframe/baseiframe.js": "74c5429a066ea1b15183ebd54fbb05c753dbd0d82a2c14fcd2fc9764a4986efb",
"widgets/baseinputnumber/baseinputnumber.css": "d55f605297675ba6404a966b16a9c01177a5e1be031e6e7497f2c42e9e5c8227",
"widgets/baseinputnumber/baseinputnumber.html": "38456d5bc91bba7ed6ccb9b92852c78013b92f21131e2ce1ed7ebb468ad59ba4",
"widgets/baseinputnumber/baseinputnumber.js": "7259915d555b87dc584176d824fed2d8c1d595ea762bf19ad34c6cbbb475cba9",
"widgets/basejavascript/basejavascript.css": "d17d4d54287f06de9369ac95bcaeac8de4cdefcf42f554a0cef3ded4eb97f58e",
"widgets/basejavascript/basejavascript.html": "b7f1d44e7e3fdc89ad5f3792c9ebae0155c6dde63e861e93fd392a2dc2222e0b",
"widgets/basejavascript/basejavascript.js": "99622aec3748b20970e0a83ae85e6c6b1407ef624bfae277baa2fa3974326a70",
"widgets/baselight/baselight.css": "a1b954ef193460cfd969292fac6d4f556c3e53867914730c88be01d0430f5d96",
"widgets/baselight/baselight.html": "a1baccd66519491c388d63bc0846e4ae35d6319affdbf364564bbc4e3b3f946f",
"widgets/baselight/baselight.js": "45e3ef822d398bbbb6aee90d298e0a4130473b3cb7302861ad0f1a4f137ac20f",
"widgets/basemedia/basemedia.css": "ec65b602cf58cdc1f01eb8c948356030307d789059dd1479673dc47f5cbcc665",
"widgets/basemedia/basemedia.html": "e61e2076fc34c1af20348c91e479b95c240ae024100a8ab13b261ffebd58e889",
"widgets/basemedia/basemedia.js": "c185da4d1debcfa72ef7876654aebb68a848bbb7ebb9d76ff5be863cfc087861",
"widgets/baseradial/baseradial.css": "c1e4406adf0147bc23858297c5c574a71da5c81e6e16fef84674f2d4ba373e0a",
"widgets/baseradial/baseradial.html": "5f7d96e1ba751be912657ef6d07660960d9bb3547b8041875944a63b04c4b1f3",
"widgets/baseradial/baseradial.js": "b2c30ec3c4cb72b8d7014abf22cab4bf58698c61a94b174428f712544800484f",
"widgets/baserss/baserss.css": "a7769b2363980bd935dc7975e37f1427311827803517cffd26969be0c06a1a87",
"widgets/baserss/baserss.html": "7e93dd7c6d7ade8fee6a56bd259de2fc66f591c7cbff20fafc9923f1fb31fa86",
"widgets/baserss/baserss.js": "adf3c55c6c8d7dc5ca5b777cb868bf102e33bb1d8683edcc695f0102f1d1bca0",
"widgets/baseselect/baseselect.css": "ef67b8844652931f6629a90cef7dd65a78b30dd78a86c169fa41eeced419705d",
"widgets/baseselect/baseselect.html": "67abc1bf525e59279f3b6ffad5ce3c35675d40446e30f341dfd68e5dc7a59420",
"widgets/baseselect/baseselect.js": "105ca03ee639477e56f906532d22cb652a6b0eef7448f34f8ed49dbe2ebdfaeb",
"widgets/baseslider/baseslider.css": "55b64db166666b3983fe600293882195ae911c3aa2ec2934180d6357bb30f4d0",
"widgets/baseslider/baseslider.html": "a463c6178c22f578bc211abba6df0c60eaf955166fb6d61c5d4efbdc989a8e53",
"widgets/baseslider/baseslider.js": "5126a3f32728f5b35bb5553e2c692054341bdbb8394953a38b4041dc1cb9975b",
"widgets/baseswitch/baseswitch.css": "b355c6bbc52db5cd923b53a9268f9fbf6157a5432c032a4b94369502b296cb75",
"widgets/baseswitch/baseswitch.html": "00cc2c9fc2dfa2299225e06c6f50bb13545b1f9237081862af6d51fc96160648",
"widgets/baseswitch/baseswitch.js": "cad8aa10d86683e7274b6da074be15a148697d95f3b15cd1ee2ccc3919cf8b35",
"widgets/basetemperature/basetemperature.css": "c59246676b8d6b0758ff67e008a18bdbc6d4debb8c363ddd8bf941b3fb0489a8",
"widgets/basetemperature/basetemperature.html": "5f7d96e1ba751be912657ef6d07660960d9bb3547b8041875944a63b04c4b1f3",
"widgets/basetemperature/basetemperature.js": "1b8db4c0b483fe06878a3ee147fa97bdccdfd58a7876bf7073663ad8cf4c97a5",
"widgets/basetext/basetext.css": "46c8bf64f98c4d34fc0baba4569a02b93ba5d1121654f007e7fa64c6a34b7356",
"widgets/basetext/basetext.html": "daf7d6c4de3097131704122a701fb789e59b56a314b3f8626fc3714020972ec9",
"widgets/basetext/basetext.js": "36a276e65536823bfe8f0099c48817a738ca6fd6557ecb1a75ec7fa45aed4d77",
"widgets/baseweather/baseweather.css": "7dd09c754a150d31513e35d4919dccdb62df276a14df254f7170fba78cc41b6d",
"widgets/baseweather/baseweather.html": "90b4efd2283938e5cc64817e3ebf736f9d7f4e7a74fe9d91b2d9f4925bab8eb9",
"widgets/baseweather/baseweather.js": "67acac0fa64944442aea0c9f776d67c1d4f49974f6d855068d8545142f76bea1"
}
}
+83
View File
@@ -0,0 +1,83 @@
# Modifications locales AppDaemon — état au 4 novembre 2026
Toute l’instrumentation des utility loops a été retirée sur disque : chronométrages,
profils de piles, décorateurs et messages de diagnostic. Le module ajouté
`_utility_diagnostics.py` et son cache ont été supprimés.
## Seuls correctifs conservés dans le core
- `plugin_management.py` : `refresh_update_time(plugin.name)`, décrit dans
[APPDAEMON_BANDWIDTH_BUG_FIX.md](../APPDAEMON_BANDWIDTH_BUG_FIX.md).
- `utils.py` : comparaison des ensembles de clés dans `deep_compare`, décrite dans
[APPDAEMON_RELOAD_BUG_FIX.md](../APPDAEMON_RELOAD_BUG_FIX.md).
Comparaison de **309 fichiers** au tag officiel
[4.5.10](https://github.com/AppDaemon/appdaemon/tree/4.5.10) : tous sont identiques
à l’octet près, sauf ces deux modifications exactes. Aucun fichier source supplémentaire.
Les caches Python générés sont exclus de cette comparaison.
Le fichier statique `aui/index-prod.html`, absent du paquet installé mais présent
au tag, a également été remis depuis ce tag pour respecter cette référence.
- [Diff intégral restant](appdaemon-core-4.5.10.diff)
- [Empreintes SHA-256 des fichiers vérifiés](appdaemon-core-audit.json)
## Optimisation applicative conservée
`apps/ad_toolbox/logger_interface.py` conserve le raccourci évitant l’inspection
inutile des piles pour les messages ordinaires. `adapi.py` est identique au tag.
Les sept tests `test_logger_interface_fast_path.py` passent après restauration.
Les onze vérifications de `deep_compare` du document du correctif passent aussi.
## Archive et retour arrière
Les anciens scripts, tests spécifiques au diagnostic, sauvegardes et documents
historiques sont conservés dans
`tools/archive/appdaemon-diagnostics-retired-20261104.tar.gz`.
Ils ne sont plus installés ni collectés par la suite de tests.
Les deux fichiers `.bak` du paquet ont été déplacés dans cette archive
(sous `site-packages-backups/`). Les anciens chemins de sauvegarde décrits dans
les documents historiques ne sont donc plus actifs.
**Un redémarrage AppDaemon reste nécessaire** pour décharger le code instrumenté
déjà en mémoire. Le service n’a pas été redémarré durant cette intervention.
Les réglages applicatifs et les exclusions de dossiers existantes n’ont pas été modifiés.
## Réapplication après rollback — 8 novembre 2026
Version vérifiée : **4.5.10**, avec `/usr/local/bin/python3` et le paquet
`/usr/local/lib/python3.12/site-packages/appdaemon`. PID 1 :
`python3 -m appdaemon -c /conf`.
Les trois changements absents ont été réappliqués :
- `plugin_management.py` : `refresh_update_time(plugin.name)`.
- `utils.py` : comparaison des clés au début de `deep_compare`.
- `/usr/src/app/dockerStart.sh` : un seul appel à
`sh "$CONF/archiveLogs.sh"`, avant le lancement AppDaemon.
Sauvegardes originales et manifeste (chemins absolus, version, SHA-256) :
`/conf/tools/backups/appdaemon-4.5.10-20261108T094103312482Z/manifest.json`.
Le lien `/conf/dockerStart.sh` pointe toujours vers le script ci-dessus.
Le raccourci applicatif de `LoggerInterface` était déjà présent et a été conservé.
Aucun changement aux apps, YAML, exclusions ou à `archiveLogs.sh`.
Validations exécutées : 11 cas `deep_compare` réussis, syntaxe des deux fichiers
Python valide, correctif HASS vérifié, syntaxe shell des deux scripts valide,
un seul appel d'archivage placé avant l'exec. Les 7 tests
`test_logger_interface_fast_path.py` passent, sans tests ignorés.
Sur 5 tests `test_logger_interface_grouping.py`, 2 passent et 3 échouent
sur les libellés/groupes (`test_log_group_takes_priority_over_condition_name`,
`test_smart_object_groups_its_own_lifecycle`,
`test_smartcondition_uses_smartobject_api_scope_without_logger_interface_argument`).
Le code applicatif concerné n'a pas été modifié pendant cette intervention.
**Redémarrage non effectué** : le service est PID 1 du conteneur, sans CLI Docker,
socket Docker ou superviseur disponible. Redémarrer le conteneur existant depuis
son gestionnaire habituel pour charger les correctifs Python et exécuter
l'archivage. Une recréation depuis l'image d'origine effacerait ces patchs.
Ne pas lancer le script de démarrage ou d'archivage dans le service actif.
Après redémarrage, restent à vérifier : maintien des patchs, démarrage et connexion
HASS, archivage et suivi mémoire unique, puis absence de rafraîchissements HASS
répétés après le délai configuré. Aucun résultat de validation du service
redémarré n'est affirmé ici. L'audit intégral et le diff historiques ci-dessus
n'ont pas été régénérés pour cette intervention.
@@ -0,0 +1,274 @@
# Instructions après mise à jour ou rollback AppDaemon
Préparé le 8 novembre 2026 pour cette installation `/conf`.
À suivre par Codex après installation de **4.5.14**, ou retour en **4.5.10**.
Ce document prépare l'intervention : sa rédaction n'a appliqué aucun patch ni redémarré le service.
## 1. Identifier l'installation avant toute modification
Lire ce document et `docs/appdaemon-local-changes.md`. Conserver les modifications
applicatives existantes, les configurations YAML et les exclusions de dossiers.
Ne pas restaurer les anciennes instrumentations des utility loops : elles ont été
retirées volontairement et archivées dans `tools/archive/`.
Exécuter dans le conteneur, avec le Python réellement utilisé par AppDaemon :
```sh
python3 - <<'PY'
import inspect, sys
from importlib.metadata import version
import appdaemon
from appdaemon.utils import deep_compare
print('Python :', sys.executable)
print('Version :', version('appdaemon'))
print('Paquet :', appdaemon.__file__)
print('deep_compare :', inspect.getsourcefile(deep_compare))
print(inspect.getsource(deep_compare))
PY
```
Si le processus utilise un autre environnement, employer son interpréteur pour
les inspections, modifications et tests. Ne pas déduire la version du seul tag
Docker. Vérifier aussi l'Entrypoint/Cmd du conteneur et le script effectivement
exécuté ; une image personnalisée peut différer de l'image officielle.
Pour une autre version que 4.5.10 ou 4.5.14, refaire la comparaison avec ses sources.
Avant chaque édition, sauvegarder le fichier original dans un dossier daté sous
`/conf/tools/backups/appdaemon-<version>-<date>/`, en notant son chemin absolu,
sa version et son empreinte SHA-256. Ne pas écraser une sauvegarde précédente.
N'appliquer chaque changement qu'une fois ; si déjà présent, le vérifier.
Ne jamais remplacer un fichier 4.5.14 par une sauvegarde 4.5.10.
## 2. Tableau des changements
| Élément | 4.5.10 (rollback) | 4.5.14 |
|---|---|---|
| Timestamp de rafraîchissement HASS | Appliquer le patch `plugin.name` | Déjà intégré ; vérifier seulement |
| Comparaison des clés dans `deep_compare` | Appliquer dans `utils.py` | Appliquer dans `utils/misc.py` |
| Optimisation des logs ordinaires | Conserver le raccourci de `LoggerInterface` | Optimisation intégrée au core ; conserver le wrapper existant si compatible |
| Archivage au démarrage Docker | Ajouter l'appel dans `dockerStart.sh` | Ajouter l'appel dans `/start.sh` pour l'image officielle |
## 3. Patch HASS : 4.5.10 seulement
Dans le paquet installé, fichier `plugin_management.py`, méthode
`update_plugin_state()`, remplacer uniquement cet appel dans le `finally` :
```diff
- await self.refresh_update_time(plugin)
+ await self.refresh_update_time(plugin.name)
```
Cela évite de redemander l'état complet de Home Assistant chaque seconde après
expiration du délai de rafraîchissement (10 minutes par défaut).
Le correctif est intégré depuis 4.5.12 et confirmé dans le tag 4.5.14.
En 4.5.14, contrôler la ligne, sans ajouter un second patch.
Référence locale : `APPDAEMON_BANDWIDTH_BUG_FIX.md`.
## 4. Patch deep_compare : les deux versions
Localiser le fichier avec `inspect.getsourcefile(deep_compare)` :
- 4.5.10 : `appdaemon/utils.py`.
- 4.5.14 : `appdaemon/utils/misc.py` (le module a été réorganisé).
Dans `deep_compare()`, ajouter immédiatement après `data = data or {}` :
```python
if check.keys() != data.keys():
return False
```
Conserver tout le reste de la fonction propre à la version installée.
En particulier, garder `current = data.get(k)` en 4.5.14.
Le début de fonction doit donc ressembler à ceci :
```python
def deep_compare(check: dict, data: dict) -> bool:
"""Compares 2 nested dictionaries of values"""
data = data or {}
if check.keys() != data.keys():
return False
# Suite existante, différente selon la version : ne pas la remplacer.
```
La 4.5.14 évite le KeyError à l'ajout d'une clé, mais ne détecte toujours pas
certaines suppressions : la fonction officielle renvoie `True` pour
`deep_compare({}, {'input_selection': {}})`. Le patch corrige aussi les
suppressions imbriquées et distingue une clé absente d'une valeur `None`.
Vérifier dans un nouveau processus Python après modification :
```sh
python3 - <<'PY'
from appdaemon.utils import deep_compare
assert deep_compare({'input_selection': {}}, {}) is False
assert deep_compare({}, {'input_selection': {}}) is False
assert deep_compare({'x': {'a': 1}}, {'x': {}}) is False
assert deep_compare({'x': {}}, {'x': {'a': 1}}) is False
assert deep_compare({'x': None}, {}) is False
assert deep_compare({'x': [1, 2]}, {'x': [1, 2]}) is True
assert deep_compare({'x': [1, 2]}, {'x': [2, 1]}) is False
assert deep_compare({'x': {'a': 1}}, {'x': {'a': 1}}) is True
assert deep_compare({'x': {}}, {'x': None}) is False
assert deep_compare({}, None) is True
assert deep_compare({'input_selection': {}}, None) is False
print('OK : 11 vérifications deep_compare')
PY
```
Référence locale : `APPDAEMON_RELOAD_BUG_FIX.md`. Ses anciens chemins de
sauvegarde `.bak` ne sont plus actifs ; utiliser la sauvegarde de cette intervention.
## 5. LoggerInterface : conserver les fonctionnalités locales
Fichier persistant : `apps/ad_toolbox/logger_interface.py`, méthode `_write_log()`.
En **4.5.10**, conserver le raccourci existant : pour un message texte sans
`__module__`, `__line__` ou `__function__`, et avec les méthodes standard ADAPI,
appeler `api._log()` sans passer par l'inspection de pile. Garder le repli vers
`api.log()` pour les substitutions et les méthodes personnalisées. Préserver les
options d'encodage, le routage des logs et les groupes de messages.
Ne pas ajouter un autre patch dans `adapi.py` : il était volontairement revenu
au code officiel 4.5.10.
En **4.5.14**, `_sub_stack()` contient déjà le raccourci évitant `inspect.stack()`
sans marqueurs. Aucun patch core de logging n'est nécessaire. Conserver le
wrapper applicatif et ses fonctionnalités ; la suppression du raccourci local
redondant est facultative, pas un prérequis de migration. Tester sa compatibilité.
Tests existants :
```sh
python3 -m unittest discover -s /conf/apps/ad_toolbox/unit_tests -p 'test_logger_interface_fast_path.py' -v
python3 -m unittest discover -s /conf/apps/ad_toolbox/unit_tests -p 'test_logger_interface_grouping.py' -v
```
**Adaptation de test nécessaire en 4.5.14 :**
`test_direct_log_is_not_modified` attend actuellement `stack.assert_called_once()`
pour `api.log('Direct log')`. Cette attente correspond à 4.5.10. Pour 4.5.14,
attendre `stack.assert_not_called()`. Si le test doit couvrir les deux versions,
choisir explicitement l'attente selon la version installée, en conservant
l'assertion d'identité de `_sub_stack`. Ne pas modifier le core pour satisfaire
l'ancienne attente. Vérifier que les tests d'intégration ne sont pas ignorés
faute d'import AppDaemon.
## 6. Patch dockerStart / start.sh : les deux versions
### Comportement local à préserver
Avant le lancement d'AppDaemon, appeler une seule fois :
```sh
sh "$CONF/archiveLogs.sh"
```
Ce script persistant archive les anciens `*.log*` et `memory_usage.csv` dans
`/conf/logs/archive/<horodatage>/`, puis lance le suivi mémoire en arrière-plan.
Conserver le contenu actuel de `/conf/archiveLogs.sh`.
Ne pas lancer le script manuellement pendant qu'AppDaemon écrit ses logs pour
le « tester » : il déplacerait les fichiers ouverts. Ne pas lancer le script
de démarrage complet dans le conteneur déjà actif : cela lancerait un second AppDaemon.
### 4.5.10
Le script observé est `/usr/src/app/dockerStart.sh`.
`/conf/dockerStart.sh` est un **lien symbolique** vers ce fichier, pas une copie
persistante du patch. Après recréation du conteneur, la cible peut être remplacée.
Ajouter l'appel après les installations de dépendances, juste avant le lancement :
```sh
sh "$CONF/archiveLogs.sh"
# Lets run it!
exec python3 -m appdaemon -c $CONF "$@"
```
### 4.5.14 : changement de point d'entrée confirmé dans le tag officiel
Le Dockerfile copie `scripts/start.sh` vers **`/start.sh`** et définit
`ENTRYPOINT ["/start.sh"]`. Il n'utilise plus `dockerStart.sh`.
Dans cette image, patcher `/start.sh`, pas l'ancien lien `/conf/dockerStart.sh`.
Ajouter le même appel après l'installation des dépendances et avant l'exec :
```sh
sh "$CONF/archiveLogs.sh"
# Lets run it!
exec uv run --frozen appdaemon -c $CONF "$@"
```
Conserver le lancement `uv`, les installations via `uv pip`, le `PYTHONPATH`
et le répertoire de configuration exemple `/opt/conf` propres à cette version.
Ne pas recopier l'ancien script 4.5.10 par-dessus `/start.sh`.
Si l'image déployée possède un autre point d'entrée, transposer cet appel dans
le script réellement utilisé et noter le chemin dans le compte rendu.
L'ancien lien peut rester inutilisé ; ne pas le prendre comme preuve du patch.
### Vérification sans exécution
```sh
sh -n /conf/archiveLogs.sh
# Choisir le script réellement utilisé :
sh -n /usr/src/app/dockerStart.sh # 4.5.10
# OU
sh -n /start.sh # 4.5.14 officielle
```
Inspecter le script : exactement un appel actif à `archiveLogs.sh`, avant l'exec.
Ces modifications hors `/conf` sont perdues lors d'une recréation du conteneur,
comme les patchs Python. Les réappliquer à chaque nouvelle image, ou les intégrer
explicitement à une image dérivée si cela fait partie de l'intervention demandée.
## 7. Redémarrage et validation finale
Après application et vérifications, redémarrer le conteneur selon le mécanisme
habituel de cette installation, dans le périmètre de l'intervention demandée.
Un simple rechargement d'app ne remplace pas les fonctions core déjà importées
et ne réexécute pas le script de démarrage.
Vérifier après redémarrage :
1. Version réellement lancée et absence d'erreurs de démarrage/import.
2. Patch `deep_compare` toujours présent et 11 vérifications réussies dans le
même environnement que le service (une installation de dépendances au boot
pourrait avoir remplacé le paquet).
3. Message `[archiveLogs]`, archive créée si des anciens logs existaient,
nouveau `memory_usage.csv`, un seul processus `archiveLogs.sh --watch-memory`.
Vérifier l'ajout de mesures à l'intervalle configuré (300 secondes par défaut).
Avec `uv`, le PID 1 peut être uv : si les colonnes RSS AppDaemon sont vides,
vérifier le processus réel ; les métriques du conteneur restent distinctes.
4. Connexion Home Assistant et absence de rafraîchissements complets chaque
seconde après le délai de refresh configuré (10 minutes par défaut).
5. Tests du logger réussis avec l'attente adaptée à la version.
6. Si un essai de reload réel est nécessaire, utiliser une app de test isolée
sans action domotique ; vérifier ajout/suppression d'une clé imbriquée et
nettoyer l'essai. Ne pas modifier chauffage/volets pour ce test.
Mettre à jour `docs/appdaemon-local-changes.md` avec la version, les fichiers
réellement modifiés, les sauvegardes, résultats et statut du redémarrage.
Séparer les vérifications exécutées de celles restant à observer ; ne pas
annoncer une validation du service sur la seule base des tests unitaires.
## 8. Rollback vers 4.5.10
Revenir à l'image 4.5.10 prévue par le déploiement, puis reprendre les sections
1 à 7 pour **4.5.10** : patch HASS + clés `deep_compare` + raccourci applicatif
logger + appel d'archivage dans le vrai `dockerStart.sh`.
Les fichiers `/conf` et le sous-module `apps/ad_toolbox` doivent être conservés.
Si le test du logger a été rendu compatible avec les deux versions, le conserver.
Ne pas restaurer de fichiers core 4.5.14 dans 4.5.10, ni les anciennes sondes de
diagnostic archivées. Vérifier à nouveau après le redémarrage du rollback.
## Sources de référence
- Inventaire : [appdaemon-local-changes.md](appdaemon-local-changes.md).
- Diff core local 4.5.10 : [appdaemon-core-4.5.10.diff](appdaemon-core-4.5.10.diff).
- [4.5.14 : plugin_management.py](https://github.com/AppDaemon/appdaemon/blob/4.5.14/appdaemon/plugin_management.py).
- [4.5.14 : utils/misc.py](https://github.com/AppDaemon/appdaemon/blob/4.5.14/appdaemon/utils/misc.py).
- [4.5.14 : adapi.py](https://github.com/AppDaemon/appdaemon/blob/4.5.14/appdaemon/adapi.py).
- [4.5.14 : Dockerfile](https://github.com/AppDaemon/appdaemon/blob/4.5.14/Dockerfile).
- [4.5.14 : scripts/start.sh](https://github.com/AppDaemon/appdaemon/blob/4.5.14/scripts/start.sh).
- [4.5.10 : dockerStart.sh](https://github.com/AppDaemon/appdaemon/blob/4.5.10/dockerStart.sh).
+169
View File
@@ -0,0 +1,169 @@
# Chauffage radiateurs — AppDaemon et C6
L'app `radiator_heating` expose `climate.radiator_heating` par MQTT Discovery.
La version MQTT directe utilise le plugin MQTT d'AppDaemon pour les publications
et les abonnements. Le plugin Home Assistant sert à lire les thermomètres.
Les commandes du climate et les retours C6 restent sur les mêmes topics.
## Configuration MQTT
Le plugin `MQTT` est configuré dans `appdaemon.yaml`, namespace `mqtt`.
Les identifiants du broker sont dans `secrets.yaml` (exclu de Git) et référencés
par `!secret`. Ils doivent être sauvegardés séparément pour une réinstallation.
Un redémarrage complet du conteneur/service AppDaemon est nécessaire pour charger
un nouveau plugin ; le rechargement des apps ne suffit pas.
Le climate et son watchdog exigent ce plugin. Les deux anciennes automatisations
Home Assistant ont été supprimées après vérification des commandes et de la
télémétrie en MQTT direct. Aucun relais MQTT dans Home Assistant n'est nécessaire.
## Fichiers
- `apps/boilerclimate.py` : MQTT Discovery, consigne, mode, mesures et commandes.
- `apps/boilerwatchdog.py` : surveillance indépendante du heartbeat.
- `apps/boilerregulator.py` : calcul indépendant de la température d'eau.
- `apps/global/heating.yaml`, blocs `radiator_heating` et
`radiator_heating_watchdog` : paramètres actifs des deux apps.
- `appdaemon.yaml`, plugin `MQTT` : connexion broker, abonnements et Last Will.
## Régulation
Le bloc facultatif `smart_conditions` contrôle le mode `heat`/`off` avec le même
évaluateur que les autres apps : `Succeeded` passe en `heat`, `Failed` passe
en `off`. L'évaluation initiale est appliquée au démarrage, puis chaque changement
de résultat. Un résultat `Disabled` suspend l'automatisme et conserve le mode.
Les commandes manuelles restent possibles ; elles restent en vigueur jusqu'au
prochain changement de résultat de la condition ou redémarrage de l'app.
Le changement de mode envoie immédiatement la commande chaudière et est persisté.
La consigne ambiante n'est pas modifiée et les sécurités capteurs restent actives.
Sans ce bloc, le mode reste entièrement manuel comme auparavant. Exemple :
```yaml
smart_conditions:
trigger_conditions:
- binary_sensor.heatpump_winter_mode
```
Les options standard (`blocking_conditions`, `disable_conditions`,
`callback_delay`, constantes locales) sont prises en charge.
La règle active demande `heat` uniquement si quelqu'un est à la maison,
si le minimum extérieur est inférieur à la consigne ambiante (`self.temperature`),
et si toutes les fenêtres et portes-fenêtres surveillées sont
fermées. `binary_sensor.group_windows` couvre bureau, salon, chambre d'amis
et dressing ; les portes-fenêtres cuisine, salon et chambre sont ajoutées
séparément. La règle utilise les états booléens de présence et d'ouverture.
Le mode est recalculé au démarrage et lors des changements de résultat de la
règle, sans temporisation ajoutée. Après un changement de consigne, la règle
est réévaluée lorsque Home Assistant actualise l'attribut `temperature` du climate.
`self` désigne l'entité configurée par `entity: climate.radiator_heating`.
Toutes les expressions lisent les états Home Assistant normalement. Au premier
démarrage, le chauffage reste en `off` pendant l'attente de l'entité et de sa
consigne, avant de créer l'évaluateur. Celui-ci est ensuite conservé pendant
toute la durée de vie de l'app, y compris lors des changements de consigne.
Avec S la consigne ambiante, Ti la température du couloir et Te la plus basse
des températures extérieures (`sensor.ext_front_temperature` et
`sensor.ext_back_temperature`, configurées dans `outdoor_sensors`) :
courbe = S + pente × (S − Te) + décalage
correction P = kp × (S − Ti)
correction I += ki × (S − Ti) × durée_en_heures
demande = courbe + correction bornée(P + I)
La sortie est arrondie au demi-degré et limitée entre 30 et 50 °C initialement.
`ki` est exprimé en degrés d'eau par degré d'écart intérieur et par heure.
Les bornes de sortie et de correction disposent d'une protection anti-windup.
L'intégrale est remise à zéro à l'arrêt, en cas de mesure invalide, au changement
manuel de consigne et au redémarrage. Consigne et mode sont persistés dans
`apps/data/appdaemon_radiator_heating.json`, exclu de Git.
Une sauvegarde absente au premier lancement utilise la configuration initiale.
Une sauvegarde illisible ou invalide impose le mode `off` avec un avertissement.
Une erreur d'écriture est journalisée sans bloquer la commande chaudière,
notamment l'arrêt demandé par l'utilisateur ou l'arrêt ordonné de l'app.
À l'équilibre, la chauffe peut rester demandée même lorsque Ti = S.
L'app inhibe le chauffage si Ti >= S + 0,5 °C, ou si la demande calculée descend
sous la température minimale utile (avec une hystérésis de 1 °C d'eau).
Après arrêt, le redémarrage exige Ti <= S + 0,1 °C et une demande suffisante.
Les gains sont des valeurs initiales à ajuster après les essais de circulation,
pas des coefficients identifiés pour cette maison.
Le capteur intérieur et les deux capteurs extérieurs doivent fournir des valeurs
finies en °C, entre −40 et 60 °C. Le minimum extérieur est recalculé à chaque
mise à jour de l'un des capteurs et à chaque cycle. L'attribut `outdoor_sensors`
liste les sondes utilisées et `outdoor_sensor` identifie celle sélectionnée.
Si l'une des sondes est invalide, le chauffage reste inhibé comme auparavant.
La configuration historique `outdoor_sensor` reste acceptée pour une seule sonde.
Une température inchangée, même ancienne, reste valide : ces sondes peuvent
ne publier qu'en cas de changement. Aucun délai sur l'âge de la mesure n'est
appliqué. Les états indisponibles/inconnus, les valeurs invalides et les unités
incorrectes provoquent toujours l'inhibition. Une sonde déconnectée conservant
une valeur plausible n'est donc pas détectée par ce contrôle.
Un défaut publie un log ERROR avec l'identifiant, le nom du capteur et sa cause
(état invalide, unité ou valeur incohérente). Le log est émis
à l'apparition du défaut ou au changement des capteurs concernés, sans répétition
chaque minute. Le détail actualisé reste disponible dans l'attribut `sensor_error`
du capteur « État de la régulation », même quand le climate est indisponible.
Un log INFO signale le rétablissement. L'app utilise le pipeline standard
`LoggerInterface` (`log_info`, `log_warning`, `log_error`) ; les erreurs sont
également envoyées dans `error_log`. L'option `ascii_encode=False`, propre à
cette app, conserve les accents et le symbole °C.
## MQTT et disponibilité
- Configuration : `homeassistant/climate/appdaemon_radiator_heating/config`.
- Commandes utilisateur : `appdaemon/radiator_heating/command/mode` (`heat`/`off`)
et `/command/temperature` (consigne ambiante entre 10 et 23 °C).
- État et diagnostics : `appdaemon/radiator_heating/state` et `/attributes`.
- Chaudière : `themaplus-condens/bai/SetMode/set`.
Le plugin transmet les commandes MQTT et les retours chaudière à l'événement
`MQTT_MESSAGE` du namespace `mqtt`, écouté directement par l'app. Le watchdog
AppDaemon marque le climate indisponible après trois minutes sans heartbeat
(vérification toutes les dix secondes). Il ne commande PAS la chaudière.
Il utilise un worker distinct du climate. Il publie `offline` au démarrage et
après reconnexion tant qu'aucun heartbeat récent n'est arrivé. Seul le climate
publie `online`, selon la validité de ses mesures.
La disponibilité MQTT Discovery exige simultanément la disponibilité de l'app
(`/availability`) et celle de la connexion MQTT (`/connection`). Le plugin
publie `online` sur `/connection` à la connexion, et le broker publie `offline`
via le Last Will si cette connexion est perdue. Ce mécanisme ne garantit pas
une détection immédiate et ne coupe pas le chauffage. Configuration et états
sont retenus ; les commandes utilisateur et chaudière ne doivent jamais l'être.
Une reconnexion MQTT republie la découverte et renouvelle la commande.
`SetMode` est renouvelé chaque minute et lors d'un changement de demande.
Les retours de télémétrie ne renouvellent pas la commande. Un arrêt ordonné
publie une inhibition du chauffage ; un crash repose sur l'expiration observée
de la commande chaudière. Les champs de consigne ECS sont `null` et les bits
d'inhibition sanitaire restent à zéro.
`heating_requested` désigne la demande de l'app. L'action `heating` du climate
n'est annoncée qu'avec un état chaudière S.04 récent et est actualisée dès
réception du statut, sans recalcul ni renouvellement de commande ; une chauffe sanitaire
n'est pas comptée comme chauffage des radiateurs. Sans télémétrie récente,
`idle` ne constitue pas une preuve d'absence de flamme : consulter aussi la
chaudière. L'écho SetMode confirme une transmission C6, pas la consigne effective.
## Essais actuels
Les écritures sont autorisées (`send_commands: true`), pont thermostat fermé.
Le climate démarre en mode `heat`, consigne 18 °C, sauf état utilisateur persisté.
Un couloir plus chaud que la consigne entraîne normalement une inhibition.
Le retour au réglage local de 10 °C après environ 15 minutes sans renouvellement
a été observé pont ouvert. L'arrêt réel après perte de communication reste à
vérifier avec circulation autorisée ; ce délai n'est pas une coupure immédiate.
Un premier essai pont fermé avec une consigne ambiante de 23 °C a produit une
demande d'eau à 46 °C et un démarrage de la chauffe. Les réglages restent à
ajuster selon l'évolution réelle des températures. Ne pas modifier les mesures
réelles pour simuler la chauffe.
`send_commands: false` permet de conserver l'interface et les calculs sans écrire
SetMode. Les lectures de télémétrie continuent.
## Vérification
python -m unittest discover -s tests -p test_boilerclimate.py -v
+91
View File
@@ -0,0 +1,91 @@
# SamsungTheFrameAsync
Alternative asynchrone à `apps/samsungtheframe.py`. La classe reste
`SamsungTheFrame` et utilise les mêmes entrées, sorties, consignes, événements
de volume, services de réveil et fichier `apps/theframe_token.txt`.
## Basculement manuel
Dans `apps/global/homecinema.yaml`, remplacer uniquement :
```yaml
module: samsungtheframe
```
par :
```yaml
module: SamsungTheFrameAsync
```
Conserver `class: SamsungTheFrame`. Revenir au nom de module précédent pour
restaurer l'ancienne implémentation. Ne pas lancer les deux apps simultanément :
elles piloteraient la même TV et les mêmes capteurs. Le YAML n'a pas été modifié
lors de l'ajout de ce module.
## Fonctionnement
- Les callbacks AppDaemon transmettent les demandes à la boucle asyncio et
retournent immédiatement, sans communication réseau avec la TV.
- Une connexion WebSocket Art reste ouverte tant que la TV est alimentée et que
sa consommation dépasse le seuil. Un lecteur unique reçoit notifications et
réponses, corrélées par identifiant de requête.
- `art_mode_changed`, `artmode_status`, `go_to_standby` et `wakeup` sont traités.
Les notifications mettent à jour les capteurs sans attendre le prochain relevé
de consommation. Leur disponibilité dépend du modèle et du firmware.
- Une connexion télécommande distincte est ouverte à la première commande puis
réutilisée. Les commandes sont sérialisées ; une touche maintenue est relâchée
même si la transition est annulée.
- Une lecture Art de secours a lieu toutes les 60 secondes. Les fluctuations de
consommation réveillent le moniteur sans multiplier ces lectures.
- Une perte réseau conserve les derniers états connus. Une consommation sous
le seuil ou une prise explicitement éteinte confirme l'arrêt et ferme les
connexions. Une consommation indisponible n'est pas assimilée à un arrêt.
- Après un échec de lecture, reconnexion avec attente progressive : 5, 10, 20,
40, puis 60 secondes. Les événements de consommation ne contournent pas cette
attente en surveillance seule. Pendant une transition, cette attente est plafonnée
à 2 secondes, y compris après une erreur de transition. Une nouvelle consigne
raccourcit aussi une attente de reconnexion déjà en cours.
- Les cinq consignes sont conservées : `manual`, `art_mode`, `tv_off`, `hdmi`,
`power_off`. Une nouvelle consigne annule la transition précédente, y compris
l'attente de 5 secondes avant coupure électrique. Une commande Power échouée
n'est pas immédiatement rejouée : l'état est relu avant une nouvelle action.
- Les répétitions de volume en attente expirent après une seconde ; la file est
limitée à huit événements. Les échecs ne sont pas rejoués.
- À l'arrêt/rechargement, les tâches sont annulées et les connexions fermées.
Les sockets utilisent `aiohttp`, déjà fourni par AppDaemon. Aucun thread de
réseau supplémentaire n'est créé. Les seules opérations envoyées à un executor
sont la lecture et l'écriture du petit fichier de token.
## Réglages facultatifs
Ces clés peuvent être ajoutées à l'app ; elles ne sont pas nécessaires au
basculement :
```yaml
network_timeout: 2 # secondes, connexion/autorisation et requêtes TV
reconnect_delay: 5 # attente initiale, doublée jusqu'à 60 secondes
transition_retry_delay: 2 # attente entre tentatives pendant une transition
fallback_refresh: 60 # intervalle de lecture de secours, en secondes
power_threshold: 35 # watts, même seuil que l'ancienne app
```
Le timeout s'applique à chaque opération, pas à une transition complète. Les
attentes de réveil/confirmation et l'appui long Power utilisent `asyncio` et
n'occupent pas les threads de callbacks AppDaemon.
## Vérification
```sh
python -m unittest discover -s tests -p 'test_samsungtheframe_async.py' -v
```
Les tests simulent les messages et sockets TV : persistance, notifications,
concurrence, délais, reconnexion, transitions et annulation. Ils ne contactent pas
la TV réelle. La réception des événements et les commandes doivent encore être
validées sur le téléviseur lors du basculement manuel.
Après allumage de la prise, la transition est revérifiée après 5 secondes ;
après une commande de réveil, après 10 secondes (au lieu de 60). Les délais
de confirmation après une touche et avant coupure électrique sont conservés.
+10
View File
@@ -0,0 +1,10 @@
# Diagnostic utility loop — retiré
Toute l’instrumentation ajoutée a été retirée le 4 novembre 2026.
Voir [l’état actuel et la vérification du core](appdaemon-local-changes.md).
L’ancien document, les scripts et les sauvegardes sont dans
`tools/archive/appdaemon-diagnostics-retired-20261104.tar.gz`.
Ne plus utiliser les anciennes commandes install/upgrade/restore : ces scripts
ne sont plus actifs. Un redémarrage AppDaemon est nécessaire pour décharger
l’instrumentation restée en mémoire.
+144
View File
@@ -0,0 +1,144 @@
from contextlib import nullcontext
import datetime
import pickle
import tempfile
from pathlib import Path
import sys
import unittest
from unittest.mock import Mock, patch
sys.path[:0] = [str(Path(__file__).resolve().parents[1] / 'apps'),
str(Path(__file__).resolve().parents[1] / 'apps/ad_toolbox')]
from alarmclock import AlarmClock
class Harness(AlarmClock):
def __init__(self):
self.calendar = ['previous calendar']
self.calendar_retry_handle = None
self.calendar_retry_attempt = 0
self.dataset = {}
self.load_cal_from_ics = Mock(return_value=False)
self.run_in = Mock(side_effect=lambda callback, delay: f'timer-{delay}')
self.timer_running = Mock(return_value=True)
self.cancel_timer = Mock()
self.log_info = Mock()
self.log_warning = Mock()
self.log_error = Mock()
self.log_group = Mock(side_effect=lambda name: nullcontext())
self.update_workday_status = Mock()
self.update_wakeup_time = Mock()
class CalendarRefreshTests(unittest.TestCase):
def test_last_success_survives_dataset_save_and_restore(self):
app = Harness()
app.calendar = []
app.load_cal_from_ics.return_value = True
app.refresh_cal()
last_success = app.calendar_last_success
self.assertIsNotNone(last_success)
with tempfile.TemporaryDirectory() as folder:
dataset_path = Path(folder) / 'alarm_clock.dataset'
app.get_dataset_name = Mock(return_value=str(dataset_path))
app.terminate()
restored = Harness()
with dataset_path.open('rb') as stream:
restored.dataset = pickle.load(stream)
self.assertEqual(restored.calendar_last_success, last_success)
restored._log_calendar_failure('request timed out')
restored.log_info.assert_called_once()
restored.log_warning.assert_not_called()
restored.log_error.assert_not_called()
def test_dataset_without_timestamp_has_no_known_success(self):
app = Harness()
app.dataset = {'other_data': 42}
self.assertIsNone(app.calendar_last_success)
app._log_calendar_failure('request timed out')
app.log_error.assert_called_once()
self.assertEqual(app.dataset, {'other_data': 42})
def test_failure_preserves_calendar_and_schedules_two_retries(self):
app = Harness()
self.assertFalse(app.refresh_cal())
self.assertEqual(app.calendar, ['previous calendar'])
app.run_in.assert_called_once_with(app.on_calendar_retry, 120)
app.on_calendar_retry({})
self.assertEqual(app.run_in.call_args_list[-1].args, (app.on_calendar_retry, 300))
app.on_calendar_retry({})
self.assertEqual(app.run_in.call_count, 2)
def test_failure_level_depends_on_last_success(self):
now = datetime.datetime(2026, 11, 4, 12, tzinfo=datetime.timezone.utc)
for hours, level in ((0, 'info'), (23.999, 'info'), (24, 'warning'),
(47.999, 'warning'), (48, 'error'), (72, 'error'),
(None, 'error')):
with self.subTest(hours=hours):
app = Harness()
last_success = None if hours is None else now - datetime.timedelta(hours=hours)
app.calendar_last_success = last_success
with patch('alarmclock.datetime.datetime') as clock:
clock.now.return_value = now
app._log_calendar_failure('request timed out')
getattr(app, f'log_{level}').assert_called_once()
for other in {'info', 'warning', 'error'} - {level}:
getattr(app, f'log_{other}').assert_not_called()
self.assertEqual(app.calendar_last_success, last_success)
message = getattr(app, f'log_{level}').call_args.args[0]
self.assertIn('request timed out', message)
if last_success:
self.assertIn(last_success.isoformat(), message)
def test_success_updates_timestamp_even_when_calendar_is_unchanged(self):
app = Harness()
app.calendar = []
app.calendar_last_success = datetime.datetime(2026, 11, 1, tzinfo=datetime.timezone.utc)
app.calendar_retry_attempt = 2
app.calendar_retry_handle = 'pending-timer'
app.load_cal_from_ics.return_value = True
now = datetime.datetime(2026, 11, 4, tzinfo=datetime.timezone.utc)
with patch('alarmclock.datetime.datetime') as clock:
clock.now.return_value = now
self.assertFalse(app.refresh_cal())
app.cancel_timer.assert_called_once_with('pending-timer')
self.assertEqual(app.calendar_last_success, now)
self.assertEqual(app.calendar_retry_attempt, 0)
@patch('alarmclock.get')
def test_timeout_uses_freshness_level_and_preserves_calendar(self, get):
import requests
app = Harness()
app.calendar_last_success = datetime.datetime.now(datetime.timezone.utc)
last_success = app.calendar_last_success
app.load_cal_from_ics = lambda calendar: AlarmClock.load_cal_from_ics(app, calendar)
get.side_effect = requests.exceptions.ReadTimeout()
self.assertFalse(app.refresh_cal())
app.log_warning.assert_not_called()
app.log_error.assert_not_called()
self.assertTrue(any('ReadTimeout' in call.args[0] for call in app.log_info.call_args_list))
self.assertEqual(app.calendar_last_success, last_success)
self.assertEqual(app.calendar, ['previous calendar'])
app.run_in.assert_called_once_with(app.on_calendar_retry, 120)
@patch('alarmclock.icalendar.Calendar.from_ical')
@patch('alarmclock.get')
def test_http_request_uses_short_timeouts_and_validates_status(self, get, from_ical):
response = Mock(content=b'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n')
get.return_value = response
from_ical.return_value.walk.return_value = []
app = Harness()
self.assertTrue(AlarmClock.load_cal_from_ics(app, []))
self.assertEqual(get.call_args.kwargs['timeout'], (3, 5))
response.raise_for_status.assert_called_once_with()
from_ical.assert_called_once_with(response.content)
if __name__ == '__main__':
unittest.main()
+523
View File
@@ -0,0 +1,523 @@
import json
from datetime import datetime, timedelta, timezone
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
import unittest
import yaml
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps'))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox'))
from boilerregulator import BoilerRegulator, setmode
from boilerclimate import BoilerClimate, SmartCondition
from boilerwatchdog import BoilerWatchdog
class RegulatorTests(unittest.TestCase):
def test_equilibrium_retains_heat_at_target(self):
r = BoilerRegulator()
v = r.update(18, 18, 5, 60)
self.assertTrue(v.enabled)
self.assertEqual(v.flow, 47.5)
def test_colder_outdoors_and_room_increase_water_request(self):
mild = BoilerRegulator().update(18, 18, 10, 0)
cold = BoilerRegulator().update(18, 18, 5, 0)
cold_room = BoilerRegulator().update(18, 17, 10, 0)
self.assertGreater(cold.flow, mild.flow)
self.assertGreater(cold_room.flow, mild.flow)
def test_persistent_error_integrates_in_water_degrees(self):
r = BoilerRegulator(max_flow=65)
v = r.update(18, 17.5, 10, 3600)
self.assertAlmostEqual(v.integral, 0.1)
def test_upper_saturation_does_not_wind_up(self):
r = BoilerRegulator()
for _ in range(1000):
v = r.update(18, 12, -10, 60)
self.assertEqual(v.flow, 50)
self.assertEqual(v.integral, 0)
def test_overheat_and_low_demand_stop_with_hysteresis(self):
r = BoilerRegulator()
self.assertTrue(r.update(18, 18, 5, 0).enabled)
self.assertFalse(r.update(18, 18.6, 5, 60).enabled)
self.assertFalse(r.update(18, 18.2, 5, 60).enabled)
self.assertTrue(r.update(18, 18, 5, 60).enabled)
self.assertFalse(r.update(18, 18, 20, 60).enabled)
def test_payload_preserves_sanitary_fields(self):
a, b = setmode(40, True), setmode(0, False)
self.assertEqual(a['disablehc'], 0)
self.assertEqual(b['disablehc'], 1)
for p in [a, b]:
self.assertIsNone(p['hwctempdesired'])
self.assertIsNone(p['hwcflowtempdesired'])
self.assertEqual(p['disablehwctapping'], 0)
self.assertEqual(p['disablehwcload'], 0)
def test_rejects_nonfinite_inputs(self):
for value in [float('nan'), float('inf')]:
with self.assertRaises(ValueError):
BoilerRegulator().update(18, value, 5, 60)
class ClimateTests(unittest.TestCase):
def app(self):
# Test the actual app callbacks without starting an AppDaemon server.
app = object.__new__(BoilerClimate)
app.base, app.boiler = 'appdaemon/test', 'boiler'
app.entity_id = 'climate.radiator_heating'
app.mqtt_namespace = 'mqtt'
app.connection_topic = app.base + '/connection'
app.mode, app.target = 'heat', 18
app.indoor, app.outdoor = 'sensor.indoor', 'sensor.outdoor'
app.outdoors = (app.outdoor,)
app.selected_outdoor = None
app.min_target, app.max_target = 10, 23
app.interval = 60
app.controller = BoilerRegulator()
app.send_commands = True
app.last_payload = None
app.last_send = app.previous_time = 1000
app.last_command_at = app.last_echo_at = None
app.telemetry, app.last_status_time = {}, 0
app._publish = Mock()
app._save = Mock()
app.log = Mock()
app.entity_exists = Mock(return_value=True)
app.get_state = Mock(return_value=18)
app.initialize_logger_interface(app, ascii_encode=False)
readings = {app.indoor: 18, app.outdoor: 10}
app._sensor_value = lambda entity: readings[entity]
return app
def writes(self, app):
return [c for c in app._publish.call_args_list if c.args[0] == 'boiler/bai/SetMode/set']
def test_mqtt_transport_uses_plugin_namespace(self):
a = self.app()
a.call_service = Mock()
BoilerClimate._publish(a, 'boiler/bai/SetMode/set', setmode(40, True))
call = a.call_service.call_args
self.assertEqual(call.args, ('mqtt/publish',))
self.assertEqual(call.kwargs['namespace'], 'mqtt')
self.assertFalse(call.kwargs['retain'])
self.assertEqual(json.loads(call.kwargs['payload']), setmode(40, True))
def test_discovery_requires_app_and_connection_online(self):
a = self.app()
a.uid, a.title = 'test_boiler', 'Test boiler'
a._discovery()
config = a._publish.call_args_list[0].args[1]
self.assertNotIn('availability_topic', config)
self.assertEqual(config['availability_mode'], 'all')
self.assertEqual(config['availability'], [{'topic': a.base + '/availability'},
{'topic': a.connection_topic}])
def test_mqtt_reconnect_renews_discovery_and_command(self):
a = self.app()
a._discovery = Mock()
a._mqtt_event('MQTT_MESSAGE', {'state': 'Connected', 'topic': None}, {})
a._discovery.assert_called_once()
self.assertEqual(len(self.writes(a)), 1)
def test_mqtt_disconnect_does_not_publish_or_advance_controller(self):
a = self.app()
a._mqtt_event('MQTT_MESSAGE', {'state': 'Disconnected', 'topic': None}, {})
a._publish.assert_not_called()
self.assertEqual(a.previous_time, 1000)
def test_single_change_and_periodic_refresh_only(self):
a = self.app()
with patch('boilerclimate.time.monotonic', return_value=1000):
a._control()
self.assertEqual(len(self.writes(a)), 1)
with patch('boilerclimate.time.monotonic', return_value=1030):
a._control()
self.assertEqual(len(self.writes(a)), 1)
with patch('boilerclimate.time.monotonic', return_value=1060):
a._control()
self.assertEqual(len(self.writes(a)), 2)
for call in self.writes(a):
self.assertFalse(call.args[2])
def test_lowest_outdoor_reading_controls_regulation(self):
a = self.app()
a.target = 23
a.outdoors = ('sensor.front', 'sensor.back')
readings = {a.indoor: 22.6, 'sensor.front': 30.2, 'sensor.back': 23.7}
a._sensor_value = lambda entity: readings[entity]
a._control()
self.assertEqual(a._state['outdoor'], 23.7)
self.assertEqual(a.selected_outdoor, 'sensor.back')
self.assertTrue(a._state['heating_requested'])
readings['sensor.front'] = 20
a._sensor_changed('sensor.front', 'all', None, None, {})
self.assertEqual(a._state['outdoor'], 20)
self.assertEqual(a.selected_outdoor, 'sensor.front')
def test_invalid_second_outdoor_sensor_still_inhibits_heat(self):
a = self.app()
a.outdoors = ('sensor.front', 'sensor.back')
def reading(entity):
if entity == 'sensor.back':
raise ValueError('sensor.back: unavailable')
return 18 if entity == a.indoor else 5
a._sensor_value = reading
a._control()
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
self.assertEqual(a._state['reason'], 'invalid_sensor')
self.assertIn('sensor.back', a.sensor_error)
def test_invalid_sensor_explicitly_inhibits_heat(self):
a = self.app()
a._sensor_value = Mock(side_effect=ValueError('unavailable'))
a._control()
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
self.assertEqual(a.controller.integral, 0)
def test_observation_mode_never_writes_boiler(self):
a = self.app()
a.send_commands = False
a._control()
self.assertEqual(self.writes(a), [])
def test_room_target_rejects_nan_and_out_of_bounds(self):
a = self.app()
for p in ['nan', 'inf', '30', 'garbage']:
a._mqtt_event('', dict(topic=a.base + '/command/temperature', payload=p), {})
self.assertEqual(a.target, 18)
self.assertFalse(a._save.called)
self.assertEqual(self.writes(a), [])
def test_off_command_sends_stop(self):
a = self.app()
a._mqtt_event('', dict(topic=a.base + '/command/mode', payload='off'), {})
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
def test_smart_conditions_without_configuration_keep_manual_mode(self):
a = self.app()
a.args = {}
a._initialize_smart_conditions()
self.assertIsNone(a.smart_conditions_evaluator)
self.assertEqual(a.mode, 'heat')
a._publish.assert_not_called()
def test_smart_condition_evaluates_initial_state_and_sensor_changes(self):
a = self.app()
a.args = {'smart_conditions': {'trigger_conditions': ['binary_sensor.test_heating']}}
a.entity_exists = Mock(return_value=True)
a.get_state = Mock(return_value='off')
a.listen_state = Mock(return_value='condition_callback')
a.cancel_listen_state = Mock()
a._initialize_smart_conditions()
self.assertEqual(a.mode, 'off')
self.assertEqual(self.writes(a)[-1].args[1]['disablehc'], 1)
callback = a.listen_state.call_args.args[0]
a.get_state.return_value = 'on'
callback('binary_sensor.test_heating', 'state', 'off', 'on', {})
self.assertEqual(a.mode, 'heat')
self.assertEqual(self.writes(a)[-1].args[1]['disablehc'], 0)
self.assertEqual(a.target, 18)
a.get_state.return_value = 'off'
callback('binary_sensor.test_heating', 'state', 'on', 'off', {})
self.assertEqual(a.mode, 'off')
a.terminate()
a.cancel_listen_state.assert_called_once_with('condition_callback')
def test_smart_condition_disabled_preserves_manual_mode(self):
a = self.app()
a.on_smart_conditions_change(None, SmartCondition.Result.Disabled)
self.assertEqual(a.mode, 'heat')
a._publish.assert_not_called()
def test_smart_condition_does_not_bypass_invalid_sensor(self):
a = self.app()
a.mode = 'off'
a._sensor_value = Mock(side_effect=ValueError('unavailable'))
a.on_smart_conditions_change(None, SmartCondition.Result.Succeeded)
self.assertEqual(a.mode, 'heat')
self.assertEqual(self.writes(a)[-1].args[1]['disablehc'], 1)
def test_condition_cleanup_failure_does_not_block_shutdown(self):
a = self.app()
a.smart_conditions_evaluator = Mock()
a.smart_conditions_evaluator.deactivate.side_effect = RuntimeError('cleanup failed')
a.terminate()
self.assertEqual(self.writes(a)[-1].args[1]['disablehc'], 1)
a._save.assert_called_once()
def test_configured_presence_weather_and_window_rule(self):
config = yaml.safe_load((Path(__file__).resolve().parents[1] /
'apps/global/heating.yaml').read_text())['radiator_heating']
baseline = {'binary_sensor.someone_home': 'on',
'sensor.ext_front_temperature': '30', 'sensor.ext_back_temperature': '10',
'binary_sensor.group_windows': 'off',
'binary_sensor.kitchen_french_door': 'off',
'binary_sensor.living_room_french_door': 'off',
'binary_sensor.bedroom_french_door': 'off'}
cases = [({}, 'heat'),
({'binary_sensor.someone_home': 'off'}, 'off'),
({'sensor.ext_back_temperature': '18'}, 'off'),
({'sensor.ext_front_temperature': '10', 'sensor.ext_back_temperature': '30'}, 'heat')]
for entity in ['binary_sensor.group_windows', 'binary_sensor.kitchen_french_door',
'binary_sensor.living_room_french_door', 'binary_sensor.bedroom_french_door']:
cases.append(({entity: 'on'}, 'off'))
for changes, expected in cases:
with self.subTest(changes=changes):
a = self.app()
a.mode = 'off'
a.args = config
states = {**baseline, **changes}
a.entity_exists = lambda entity: entity in states or entity == a.entity_id
a.get_state = lambda entity, **kwargs: a.target if entity == a.entity_id else states[entity]
a.listen_state = Mock(return_value='condition_callback')
a._initialize_smart_conditions()
self.assertEqual(a.mode, expected)
listened = {c.args[1] for c in a.listen_state.call_args_list}
self.assertTrue(set(baseline).issubset(listened))
def test_target_change_is_evaluated_when_ha_attribute_updates(self):
a = self.app()
a.args = {'smart_conditions': {
'trigger_conditions': ['sensor.outside < self.temperature']}}
a.entity_exists = Mock(return_value=True)
states = {a.entity_id: 18, 'sensor.outside': 20}
a.get_state = lambda entity, **kwargs: states[entity]
a.listen_state = Mock(return_value='condition_callback')
a.cancel_listen_state = Mock()
a._initialize_smart_conditions()
self.assertEqual(a.mode, 'off') # 20 outside >= target 18.
evaluator = a.smart_conditions_evaluator
callback = next(c.args[0] for c in a.listen_state.call_args_list
if c.args[1] == a.entity_id and c.kwargs.get('attribute') == 'temperature')
for target, mode in [(23, 'heat'), (20, 'off'), (21, 'heat')]:
previous_mode = a.mode
old_target = states[a.entity_id]
a._mqtt_event('', dict(topic=a.base + '/command/temperature', payload=str(target)), {})
self.assertEqual(a.mode, previous_mode) # Wait for HA's attribute update.
states[a.entity_id] = target
callback(a.entity_id, 'temperature', old_target, target, {})
self.assertEqual(a.mode, mode)
self.assertEqual(a.target, target)
self.assertIs(a.smart_conditions_evaluator, evaluator)
a.cancel_listen_state.assert_not_called()
def test_smart_conditions_wait_for_discovered_entity_and_temperature(self):
a = self.app()
a.mode, a.target = 'off', 23
a.args = {'smart_conditions': {'trigger_conditions': ['sensor.outside < self.temperature']}}
a.entity_exists = Mock(return_value=False)
a.get_state = Mock(return_value=None)
a.run_in = Mock()
a.listen_state = Mock(return_value='condition_callback')
a._initialize_smart_conditions()
self.assertEqual(a.mode, 'off')
self.assertIsNone(a.smart_conditions_evaluator)
a.run_in.assert_called_once_with(a._initialize_smart_conditions, 1)
a.entity_exists.return_value = True
a._initialize_smart_conditions({}) # Entity exists but its target is not ready.
self.assertIsNone(a.smart_conditions_evaluator)
a.get_state = lambda entity, **kwargs: 20 if entity == 'sensor.outside' else 23
a._initialize_smart_conditions()
self.assertEqual(a.mode, 'heat')
def test_native_ha_template_payload_is_accepted(self):
a = self.app()
a._mqtt_event('', dict(topic='boiler/bai/Statenumber', payload={'value': 30}), {})
self.assertEqual(a.telemetry['Statenumber'], {'value': 30})
self.assertEqual(self.writes(a), [])
def reading_app(self, state='18', age=0):
a = self.app()
stamp = (datetime.now(timezone.utc) - timedelta(seconds=age)).isoformat()
a.get_state = Mock(return_value={
'state': state,
'attributes': {'unit_of_measurement': '°C', 'friendly_name': 'Couloir Température'},
'last_updated': stamp,
})
return a
def test_unchanged_temperature_is_valid_even_after_days(self):
a = self.reading_app(age=86400 * 3)
self.assertEqual(BoilerClimate._sensor_value(a, a.indoor), 18)
def test_unavailable_is_invalid_without_waiting_two_hours(self):
a = self.reading_app(state='unavailable', age=1)
with self.assertRaisesRegex(ValueError, r'sensor.indoor.*invalid state.*unavailable'):
BoilerClimate._sensor_value(a, a.indoor)
def test_missing_timestamp_does_not_invalidate_temperature(self):
a = self.reading_app()
a.get_state.return_value.pop('last_updated')
self.assertEqual(BoilerClimate._sensor_value(a, a.indoor), 18)
def test_fault_logs_error_once_then_logs_recovery(self):
a = self.app()
a._sensor_value = Mock(side_effect=ValueError('sensor.indoor (Couloir): unavailable'))
a._control()
a._control()
errors = [c for c in a.log.call_args_list
if c.kwargs.get('level') == 'ERROR' and c.kwargs.get('log') == 'main_log']
self.assertEqual(len(errors), 1)
self.assertIn('Couloir', errors[0].args[0])
self.assertFalse(errors[0].kwargs['ascii_encode'])
self.assertTrue(any(c.kwargs.get('log') == 'error_log' for c in a.log.call_args_list))
self.assertIn('sensor.indoor', a.sensor_error)
a._sensor_value = lambda entity: 18 if entity == a.indoor else 10
a._control()
self.assertIsNone(a.sensor_error)
self.assertTrue(any('recovered' in c.args[0] for c in a.log.call_args_list))
def test_telemetry_does_not_refresh_command(self):
a = self.app()
a._mqtt_event('', dict(topic='boiler/bai/SetMode', payload=json.dumps(setmode(40, True))), {})
self.assertEqual(self.writes(a), [])
def test_save_failure_does_not_block_off(self):
a = self.app()
a._save.side_effect = OSError('disk full')
a._mqtt_event('', dict(topic=a.base + '/command/mode', payload='off'), {})
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
self.assertTrue(any('disk full' in c.args[0] for c in a.log.call_args_list))
def test_save_failure_does_not_block_shutdown(self):
a = self.app()
a._save.side_effect = OSError('disk full')
a.terminate()
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
a._publish.assert_any_call(a.base + '/availability', 'offline', True)
def test_shutdown_publish_failure_still_saves_and_marks_offline(self):
a = self.app()
def publish(topic, *args):
if topic == 'boiler/bai/SetMode/set':
raise RuntimeError('service unavailable')
a._publish.side_effect = publish
a.terminate()
a._save.assert_called_once()
a._publish.assert_any_call(a.base + '/availability', 'offline', True)
def test_invalid_persisted_state_starts_off(self):
with tempfile.TemporaryDirectory() as folder:
for saved in [{'mode': 'off', 'target': 'invalid'},
{'mode': 'heat', 'target': 30},
{'target': 18}, [], None]:
with self.subTest(saved=saved):
a = self.app()
a.state_file = Path(folder) / 'state.json'
a.state_file.write_text(json.dumps(saved))
a._restore()
self.assertEqual(a.mode, 'off')
self.assertEqual(a.target, 18)
self.assertTrue(any(c.kwargs.get('level') == 'WARNING'
for c in a.log.call_args_list))
def test_restore_valid_state_and_first_start(self):
with tempfile.TemporaryDirectory() as folder:
a = self.app()
a.state_file = Path(folder) / 'state.json'
a._restore()
self.assertEqual(a.mode, 'heat')
a.state_file.write_text(json.dumps({'mode': 'off', 'target': 19.5}))
a._restore()
self.assertEqual((a.mode, a.target), ('off', 19.5))
def test_corrupt_or_unreadable_state_starts_off(self):
for contents in ['{broken', OSError('permission denied')]:
with self.subTest(contents=contents):
a = self.app()
a.state_file = Mock()
if isinstance(contents, OSError):
a.state_file.read_text.side_effect = contents
else:
a.state_file.read_text.return_value = contents
a._restore()
self.assertEqual(a.mode, 'off')
def test_standard_log_pipeline_preserves_unicode_and_error_routing(self):
a = self.app()
a.log_info('Demande à 40 °C')
a.log_warning('État invalide')
a.log_error('Échec chaudière')
a.log.assert_any_call('Demande à 40 °C', level='INFO', log='main_log', ascii_encode=False)
a.log.assert_any_call('État invalide', level='WARNING', log='main_log', ascii_encode=False)
a.log.assert_any_call('Échec chaudière', level='ERROR', log='error_log', ascii_encode=False)
def test_boiler_status_updates_action_without_advancing_regulation(self):
a = self.app()
a._control()
a._publish.reset_mock()
previous_time, integral = a.previous_time, a.controller.integral
for status, action in [(4, 'heating'), (14, 'idle'), (0, 'idle')]:
a._mqtt_event('', dict(topic='boiler/bai/Statenumber', payload={'value': status}), {})
states = [c.args[1] for c in a._publish.call_args_list if c.args[0] == a.base + '/state']
self.assertEqual(states[-1]['action'], action)
self.assertEqual(self.writes(a), [])
self.assertEqual(a.previous_time, previous_time)
self.assertEqual(a.controller.integral, integral)
class WatchdogTests(unittest.TestCase):
def app(self):
a = object.__new__(BoilerWatchdog)
a.base, a.mqtt_namespace, a.timeout = 'appdaemon/test', 'mqtt', 180
a.last_heartbeat, a.fault = None, False
a.waiting_since, a.warned = 1000, False
a.call_service, a.log = Mock(), Mock()
a.initialize_logger_interface(a, ascii_encode=False)
return a
def test_missing_heartbeat_publishes_offline_once(self):
a = self.app()
with patch('boilerwatchdog.time.monotonic', return_value=1000):
a._check()
a._check()
a.call_service.assert_called_once_with('mqtt/publish', namespace='mqtt',
topic=a.base + '/availability', payload='offline', qos=0, retain=True)
a.log.assert_not_called()
a._mqtt_event('', {'topic': a.base + '/heartbeat'}, {})
a.log.assert_not_called()
def test_first_heartbeat_missing_beyond_timeout_warns_once(self):
a = self.app()
with patch('boilerwatchdog.time.monotonic', return_value=1000):
a._check()
with patch('boilerwatchdog.time.monotonic', return_value=1180):
a._check()
a._check()
self.assertEqual(a.log.call_count, 1)
self.assertEqual(a.log.call_args.kwargs['level'], 'WARNING')
def test_timeout_and_recovery_do_not_override_sensor_availability(self):
a = self.app()
with patch('boilerwatchdog.time.monotonic', return_value=1000):
a._mqtt_event('', {'topic': a.base + '/heartbeat', 'payload': 'timestamp'}, {})
with patch('boilerwatchdog.time.monotonic', return_value=1179):
a._check()
a.call_service.assert_not_called()
with patch('boilerwatchdog.time.monotonic', return_value=1180):
a._check()
self.assertTrue(a.fault)
self.assertTrue(a.warned)
a.call_service.reset_mock()
a._mqtt_event('', {'topic': a.base + '/heartbeat'}, {})
self.assertFalse(a.fault)
a.call_service.assert_not_called() # The climate alone decides online/offline for sensor faults.
def test_reconnection_waits_for_new_heartbeat(self):
a = self.app()
a.last_heartbeat = 1000
a._mqtt_event('', {'topic': None, 'state': 'Connected'}, {})
self.assertIsNone(a.last_heartbeat)
self.assertTrue(a.fault)
self.assertEqual(a.call_service.call_args.kwargs['payload'], 'offline')
a.log.assert_not_called()
if __name__ == '__main__':
unittest.main()
+93
View File
@@ -0,0 +1,93 @@
from pathlib import Path
import sys
import io
import json
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 informationscollector import InformationsCollector
class WarningCollectorTests(unittest.TestCase):
def setUp(self):
self.app = object.__new__(InformationsCollector)
self.app.warning_list = []
self.app.warning_count_sensor = Mock()
self.app.warning_count_sensor.get_state.return_value = '2'
def test_warning_is_counted_and_exposed_in_attributes(self):
self.app.on_log_warning(
'example', None, 'WARNING', None, 'Something happened', {}
)
self.app.warning_count_sensor.set_state.assert_called_once_with(
state=3,
attributes={
'last_warning': 'Something happened',
'warning_list': ['Something happened'],
},
)
def test_warning_reset_clears_count_and_attributes(self):
self.app.warning_list = ['Old warning']
self.app.on_reset_warning_count(None, None, None, None, {})
self.assertEqual(self.app.warning_list, [])
self.app.warning_count_sensor.set_state.assert_called_once_with(
state=0,
attributes={'last_warning': '', 'warning_list': []},
)
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__':
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()
+149
View File
@@ -0,0 +1,149 @@
import sys
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps'))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox'))
from samsungtheframe import SamsungTheFrame
import requests
import websocket
class SamsungTheFrameTests(unittest.TestCase):
def app(self):
app = object.__new__(SamsungTheFrame)
app.args = {'ip_address': '192.0.2.1'}
app.AD = SimpleNamespace(app_dir='/tmp')
app.update_sensors_handle = None
app.update_sensors_is_retry = False
app.requested_state_confirm_cb_handle = None
app.volume_connection = None
app.power_plug = 'switch.tv'
app.power_plug_power = 'sensor.tv_power'
app.get_state = Mock(side_effect=lambda e: 'on' if e == app.power_plug else '60')
for name in ('is_on_entity', 'is_art_mode_entity', 'tv_auto_shutdown_entity'):
entity = Mock()
entity.get_state.return_value = 'on'
setattr(app, name, entity)
app.log_info = Mock()
app.log = Mock()
app.run_in = Mock(side_effect=lambda *a, **kw: object())
app.cancel_timer = Mock()
app.turn_off = Mock()
return app
def test_timeout_propagates_to_art_and_rest(self):
a = self.app()
tv = a.connect()
self.assertEqual(tv.timeout, 2)
self.assertEqual(tv.art().timeout, 2)
self.assertEqual(tv._get_rest_api().timeout, 2)
def test_sensor_burst_only_schedules_one_read(self):
a = self.app()
a.connect = Mock()
for _ in range(10):
a.update_sensors_cb()
a.run_in.assert_called_once_with(a.refresh_sensors_cb, 1)
a.connect.assert_not_called()
def test_failed_refresh_has_one_retry_replaced_by_new_reading(self):
a = self.app()
a.update_sensors = Mock(return_value=(False, 30))
a.refresh_sensors_cb({})
retry = a.update_sensors_handle
a.schedule_sensor_update(30, retry=True)
self.assertEqual(a.run_in.call_count, 1)
a.update_sensors_cb()
a.cancel_timer.assert_called_once_with(retry)
self.assertEqual(a.run_in.call_count, 2)
self.assertFalse(a.update_sensors_is_retry)
def test_read_errors_preserve_states_and_close_both_connections(self):
errors = [websocket.WebSocketTimeoutException('timeout'),
ConnectionRefusedError(), requests.Timeout(), KeyError('value')]
for error in errors:
with self.subTest(error=type(error).__name__):
a = self.app()
tv, art = Mock(), Mock()
tv.art.return_value = art
art.get_artmode.side_effect = error
a.connect = Mock(return_value=tv)
self.assertEqual(a.update_sensors(), (False, 30))
art.close.assert_called_once()
tv.close.assert_called_once()
a.is_on_entity.set_state.assert_not_called()
a.is_art_mode_entity.set_state.assert_not_called()
def test_invalid_readings_do_not_mark_tv_off(self):
for power in ('unavailable', None, 'nan', 'inf'):
with self.subTest(power=power):
a = self.app()
a.get_state.side_effect = lambda e: 'on' if e == a.power_plug else power
a.connect = Mock()
self.assertEqual(a.update_sensors(), (False, 30))
a.connect.assert_not_called()
a.is_on_entity.set_state.assert_not_called()
def test_successful_read_consumes_queued_refresh(self):
a = self.app()
a.schedule_sensor_update()
pending = a.update_sensors_handle
tv = Mock()
tv.art.return_value.get_artmode.return_value = 'off'
a.connect = Mock(return_value=tv)
self.assertEqual(a.update_sensors(), (True, 0))
a.cancel_timer.assert_called_once_with(pending)
self.assertIsNone(a.update_sensors_handle)
a.is_art_mode_entity.set_state.assert_called_once_with(state='off')
tv.art.return_value.close.assert_called_once()
def test_art_selection_reuses_one_connection(self):
a = self.app()
tv, art = Mock(), Mock()
tv.art.return_value = art
art.available.return_value = [{'content_id': 'image'}]
a.connect = Mock(return_value=tv)
self.assertTrue(a.select_random_art())
tv.art.assert_called_once()
art.select_image.assert_called_once_with('image', show=True)
art.close.assert_called_once()
def test_command_timeout_closes_connection_and_schedules_confirmation(self):
a = self.app()
a.requested_state = 'tv_off'
a.update_sensors = Mock(return_value=(True, 0))
tv = Mock()
tv.shortcuts.return_value.power.side_effect = websocket.WebSocketTimeoutException()
a.connect = Mock(return_value=tv)
self.assertFalse(a._process_state_request())
tv.close.assert_called_once()
a.run_in.assert_called_once_with(a.confirm_change_state_cb, 30)
def test_power_off_wait_is_scheduled_and_cancelled_by_new_request(self):
a = self.app()
a.requested_state = 'power_off'
a.is_on_entity.get_state.return_value = 'off'
a.update_sensors = Mock(return_value=(True, 0))
a.select_random_art = Mock(return_value=True)
self.assertFalse(a._process_state_request())
a.turn_off.assert_not_called()
a.run_in.assert_called_once_with(a.power_off_cb, 5)
pending = a.requested_state_confirm_cb_handle
a.request_tv_state('manual')
a.cancel_timer.assert_called_once_with(pending)
a.power_off_cb({})
a.turn_off.assert_not_called()
def test_power_off_timer_cuts_power_and_schedules_confirmation(self):
a = self.app()
a.requested_state = 'power_off'
a.power_off_cb({})
a.turn_off.assert_called_once_with(a.power_plug)
a.run_in.assert_called_once_with(a.confirm_change_state_cb, 5)
if __name__ == '__main__':
unittest.main()
+436
View File
@@ -0,0 +1,436 @@
import asyncio
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps'))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox'))
from samsungtheframeasync import SamsungTheFrame, TVError, _TVConnection
def message(data):
return SimpleNamespace(type=aiohttp.WSMsgType.TEXT, data=json.dumps(data))
class FakeSocket:
def __init__(self, ready=True):
self.messages = asyncio.Queue()
self.closed = False
self.sent = []
self.respond = True
self.messages.put_nowait(message({'event': 'ms.channel.connect', 'data': {}}))
if ready:
self.messages.put_nowait(message({'event': 'ms.channel.ready'}))
async def receive(self):
return await self.messages.get()
def __aiter__(self):
return self
async def __anext__(self):
msg = await self.receive()
if msg.type == aiohttp.WSMsgType.CLOSED:
raise StopAsyncIteration
return msg
async def send_json(self, payload):
self.sent.append(payload)
if payload['method'] == 'ms.channel.emit' and self.respond:
request = json.loads(payload['params']['data'])
self.push({'event': 'artmode_status', 'value': 'on',
'request_id': request['request_id']})
def push(self, data):
self.messages.put_nowait(message({'event': 'd2d_service_message',
'data': json.dumps(data)}))
async def close(self):
self.closed = True
self.messages.put_nowait(SimpleNamespace(type=aiohttp.WSMsgType.CLOSED))
class ConnectionTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temp = tempfile.TemporaryDirectory()
self.ws = FakeSocket()
self.session = Mock()
self.session.ws_connect = AsyncMock(return_value=self.ws)
self.events = Mock()
self.client = _TVConnection(self.session, '192.0.2.1',
Path(self.temp.name) / 'token', self.events, .05)
async def asyncTearDown(self):
await self.client.close()
self.temp.cleanup()
async def test_art_requests_reuse_socket_and_reader(self):
first = await self.client.art_request('get_artmode_status')
reader = self.client.readers['art']
second = await self.client.art_request('get_artmode_status')
self.assertEqual(first['value'], 'on')
self.assertEqual(second['value'], 'on')
self.session.ws_connect.assert_awaited_once()
self.assertIs(self.client.readers['art'], reader)
self.assertFalse(reader.done())
self.assertEqual(self.client.pending, {})
self.events.assert_not_called() # Query responses applied by refresh.
async def test_unsolicited_notifications_delivered_while_idle(self):
await self.client.connect('art')
self.ws.push({'event': 'art_mode_changed', 'status': 'off'})
await asyncio.sleep(.01)
self.events.assert_called_once_with({'event': 'art_mode_changed', 'status': 'off'})
async def test_wrong_request_id_cannot_satisfy_request(self):
self.ws.respond = False
task = asyncio.create_task(self.client.art_request('get_artmode_status'))
await asyncio.sleep(.01)
self.ws.push({'event': 'artmode_status', 'value': 'off', 'id': 'wrong'})
with self.assertRaises(TimeoutError):
await task
self.assertEqual(self.client.pending, {})
self.assertTrue(self.ws.closed)
async def test_disconnect_fails_pending_request_without_waiting_for_timeout(self):
self.ws.respond = False
task = asyncio.create_task(self.client.art_request('get_artmode_status'))
await asyncio.sleep(.01)
await self.ws.close()
with self.assertRaises(TVError):
await task
self.assertEqual(self.client.pending, {})
async def test_request_rejection_is_not_mistaken_for_state(self):
self.ws.respond = False
task = asyncio.create_task(self.client.art_request('get_artmode_status'))
await asyncio.sleep(.01)
request = json.loads(self.ws.sent[0]['params']['data'])
self.ws.push({'event': 'error', 'request_id': request['request_id'], 'error_code': -1})
with self.assertRaises(TVError):
await task
async def test_cancel_request_removes_pending_future(self):
self.ws.respond = False
task = asyncio.create_task(self.client.art_request('get_artmode_status'))
await asyncio.sleep(.01)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
self.assertEqual(self.client.pending, {})
async def test_timeout_covers_handshake_and_does_not_block_loop(self):
self.ws = FakeSocket(ready=False)
self.session.ws_connect.return_value = self.ws
task = asyncio.create_task(self.client.connect('art'))
await asyncio.sleep(.01)
self.assertFalse(task.done())
with self.assertRaises(TimeoutError):
await task
self.assertTrue(self.ws.closed)
async def test_unauthorized_handshake_closes_socket(self):
self.ws.messages = asyncio.Queue()
self.ws.messages.put_nowait(message({'event': 'ms.channel.unauthorized'}))
with self.assertRaises(TVError):
await self.client.connect('art')
self.assertTrue(self.ws.closed)
async def test_remote_reuse_and_cancellation_releases_held_power_key(self):
task = asyncio.create_task(self.client.send_key('KEY_POWER', hold=4))
await asyncio.sleep(.01)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
await self.client.send_key('KEY_VOLUP')
self.assertEqual([v['params']['Cmd'] for v in self.ws.sent],
['Press', 'Release', 'Click'])
self.session.ws_connect.assert_awaited_once()
async def test_close_cancels_reader(self):
await self.client.connect('art')
reader = self.client.readers['art']
await self.client.close()
self.assertTrue(reader.done())
self.assertEqual(self.client.readers, {})
self.assertEqual(self.client.sockets, {})
async def test_corrupt_frame_is_contained_and_requests_fail(self):
self.ws.respond = False
task = asyncio.create_task(self.client.art_request('get_artmode_status'))
await asyncio.sleep(.01)
self.ws.messages.put_nowait(SimpleNamespace(type=aiohttp.WSMsgType.TEXT, data='{bad'))
with self.assertRaises(TVError):
await task
self.assertTrue(self.ws.closed)
async def test_reconnect_creates_one_fresh_socket(self):
await self.client.art_request('get_artmode_status')
await self.ws.close()
await asyncio.sleep(.01)
fresh = FakeSocket()
self.session.ws_connect.return_value = fresh
await self.client.art_request('get_artmode_status')
self.assertEqual(self.session.ws_connect.await_count, 2)
self.assertTrue(self.client.alive('art'))
def fake_entity(value):
entity = Mock()
state = {'value': value}
entity.get_state = AsyncMock(side_effect=lambda: state['value'])
async def set_state(*, state):
state_holder['value'] = state
state_holder = state
entity.set_state = AsyncMock(side_effect=set_state)
return entity
class ApplicationTests(unittest.IsolatedAsyncioTestCase):
def app(self):
app = object.__new__(SamsungTheFrame)
app.AD = SimpleNamespace(app_dir='/tmp')
app.args = {'ip_address': '192.0.2.1', 'reconnect_delay': .05}
app._loop = asyncio.get_running_loop()
app._stopped = False
app._wake = asyncio.Event()
app._ready = asyncio.Event()
app._ready.set()
app._publish_lock = asyncio.Lock()
app._volume = asyncio.Queue(maxsize=8)
app._tasks = set()
app._target_task = None
app._generation = 0
app._observed = (False, True)
app._observation_revision = 0
app._refresh_at = 0
app._retry_at = 0
app._backoff = .05
app.requested_state = None
app.power_plug, app.power_plug_power = 'switch.tv', 'sensor.power'
app.get_state = AsyncMock(side_effect=lambda e: 'on' if e == app.power_plug else '60')
app.is_on_entity = fake_entity('off')
app.is_art_mode_entity = fake_entity('on')
app.tv_auto_shutdown_entity = fake_entity('off')
app.turn_on = AsyncMock()
app.turn_off = AsyncMock()
app.call_service = AsyncMock()
app.log_info = Mock()
app.log_warning = Mock()
app._client = Mock()
for method in ('art_request', 'send_key', 'app_status', 'close', 'close_channel', 'load_token'):
setattr(app._client, method, AsyncMock())
app._client.art_request.return_value = {'event': 'artmode_status', 'value': 'on'}
app._client.remote_lock = asyncio.Lock()
app.app_list = []
return app
async def test_fresh_target_shortens_idle_reconnect_delay(self):
app = self.app()
app._retry_at = app._loop.time() + 60
app._transition = AsyncMock(return_value=None)
app._set_target('hdmi')
self.assertLessEqual(app._retry_at - app._loop.time(), 2)
await app._target_task
async def test_wake_rechecks_after_ten_seconds(self):
app = self.app()
app.args['wake_up_service'] = 'esphome/wake_tv'
app.get_state = AsyncMock(side_effect=lambda e: 'on' if e == app.power_plug else '0')
self.assertEqual(await app._transition('hdmi'), 10)
app.call_service.assert_awaited_once_with('esphome/wake_tv')
app._client.send_key.assert_not_awaited()
async def asyncTearDown(self):
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def test_existing_yaml_contract_initializes_without_tv_io(self):
app = self.app()
app.args.update({
'input_sensors': {'power_plug': 'switch.tv', 'power_plug_power': 'sensor.power'},
'output_sensors': {'is_on': 'binary_sensor.tv_on',
'is_art_mode': 'binary_sensor.art',
'tv_auto_shutdown': 'binary_sensor.shutdown'},
'tv_state': 'sensor.target', 'wake_up_service': 'esphome/wake_tv',
'volume_up_events': ['volume_up'], 'volume_down_events': ['volume_down']})
app.AD.loop = app._loop
app.get_state = Mock(return_value='off')
app.create_entity = Mock(side_effect=lambda *a, **kw: Mock())
app.listen_state = Mock()
app._post = Mock()
app.create_task = Mock(side_effect=lambda coro: coro.close())
with patch('samsungtheframeasync.EventHandler') as handler:
app.on_initialize_smart_object()
self.assertEqual(app.listen_state.call_count, 3)
self.assertEqual(handler.call_count, 2)
self.assertEqual(app.create_entity.call_count, 3)
app.call_service.assert_not_called()
self.assertEqual(type(app).__name__, 'SamsungTheFrame')
async def test_callbacks_only_post_work_to_loop(self):
app = self.app()
app._loop = Mock()
app.update_sensors_cb()
app.request_tv_state('hdmi')
app.on_volume_event('volume', {}, 'up')
self.assertEqual(app._loop.call_soon_threadsafe.call_count, 3)
app._client.send_key.assert_not_called()
app._client.art_request.assert_not_called()
async def test_notification_updates_state_and_disconnect_preserves_it(self):
app = self.app()
app._on_art_event({'event': 'art_mode_changed', 'status': 'off'})
self.assertEqual(app._observed, (True, False))
self.assertTrue(app._wake.is_set())
app._on_art_event({'event': 'connection_lost', 'channel': 'art'})
self.assertEqual(app._observed, (True, False))
app._on_art_event({'event': 'go_to_standby'})
self.assertEqual(app._observed, (False, False))
async def test_late_query_cannot_overwrite_newer_standby_notification(self):
app = self.app()
async def request(*args):
app._on_art_event({'event': 'go_to_standby'})
return {'value': 'on'}
app._client.art_request.side_effect = request
await app._refresh()
self.assertEqual(app._observed, (False, False))
async def test_concurrent_queries_apply_latest_response(self):
app = self.app()
calls = 0
async def request(*args):
nonlocal calls
calls += 1
value = 'on' if calls == 1 else 'off'
await asyncio.sleep(.01 if value == 'on' else .02)
return {'value': value}
app._client.art_request.side_effect = request
await asyncio.gather(app._refresh(), app._refresh())
self.assertEqual(app._observed, (True, False))
async def test_unknown_consumption_is_not_off(self):
app = self.app()
for invalid in ('unavailable', None, 'nan', 'inf'):
app.get_state.side_effect = lambda e: 'on' if e == app.power_plug else invalid
self.assertEqual(await app._power(), (True, None))
async def test_hdmi_shutdown_flag_survives_initial_art_mode_read(self):
app = self.app()
await app.tv_auto_shutdown_entity.set_state(state='on')
await app._publish(False, True)
self.assertEqual(await app.tv_auto_shutdown_entity.get_state(), 'on')
app.is_on_entity = fake_entity('on')
await app._publish(False, True)
self.assertEqual(await app.tv_auto_shutdown_entity.get_state(), 'off')
async def test_power_modes_issue_expected_commands(self):
for target, mode, hold in [('art_mode', 'off', 0), ('tv_off', 'on', 4),
('tv_off', 'off', 0), ('hdmi', 'on', 0)]:
with self.subTest(target=target, mode=mode):
app = self.app()
app._client.art_request.return_value = {'value': mode}
self.assertIn(await app._transition(target), (5, 10))
if hold:
app._client.send_key.assert_awaited_once_with('KEY_POWER', hold=4)
else:
app._client.send_key.assert_awaited_once_with('KEY_POWER')
async def test_hdmi_preserves_app_visibility_check(self):
app = self.app()
app.app_list = ['netflix', 'prime']
app._client.art_request.return_value = {'value': 'off'}
app._client.app_status.side_effect = [{'visible': False}, {'visible': True}]
self.assertEqual(await app._transition('hdmi'), 5)
app._client.send_key.assert_awaited_once_with('KEY_HDMI')
async def test_off_plug_wakes_for_art_but_satisfies_power_off(self):
app = self.app()
app.get_state.return_value = 'off'
app.get_state.side_effect = None
self.assertIsNone(await app._transition('power_off'))
app.turn_on.assert_not_called()
self.assertEqual(await app._transition('art_mode'), 5)
app.turn_on.assert_awaited_once_with(app.power_plug)
app._client.art_request.assert_not_called()
async def test_manual_cancels_pending_power_cut(self):
app = self.app()
app._select_random_art = AsyncMock()
app._set_target('power_off')
await asyncio.sleep(.02)
app._select_random_art.assert_awaited_once()
app._set_target('manual')
await asyncio.sleep(.02)
app.turn_off.assert_not_called()
self.assertIsNone(app.requested_state)
self.assertFalse(app._tasks)
async def test_volume_queue_bounded_and_old_events_dropped(self):
app = self.app()
app._observed = (True, False)
for _ in range(30):
app._queue_volume('up')
self.assertEqual(app._volume.qsize(), 8)
for _ in range(8):
app._volume.get_nowait()
app._volume.put_nowait(('up', app._loop.time() - 5))
app._volume.put_nowait(('down', app._loop.time()))
task = asyncio.create_task(app._volume_worker())
await asyncio.sleep(.01)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
app._client.send_key.assert_awaited_once_with('KEY_VOLDOWN')
async def test_monitor_coalesces_sensor_bursts_and_shutdown_closes_client(self):
app = self.app()
client = app._client
with patch('samsungtheframeasync._TVConnection', return_value=client):
task = asyncio.create_task(app._run())
await asyncio.sleep(.02)
for _ in range(30):
app.update_sensors_cb()
await asyncio.sleep(.02)
client.art_request.assert_awaited_once()
app._on_art_event({'event': 'art_mode_changed', 'status': 'off'})
await asyncio.sleep(.02)
self.assertEqual(await app.is_on_entity.get_state(), 'on')
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
client.close.assert_awaited_once()
self.assertFalse(app._tasks)
async def test_power_events_cannot_bypass_reconnect_backoff(self):
app = self.app()
client = app._client
app._backoff = .5
app.args['reconnect_delay'] = .5
client.alive.return_value = False
client.art_request.side_effect = TVError('offline')
with patch('samsungtheframeasync._TVConnection', return_value=client):
task = asyncio.create_task(app._run())
await asyncio.sleep(.01)
for _ in range(10):
app._wake.set()
await asyncio.sleep(.001)
self.assertEqual(client.art_request.await_count, 1)
await asyncio.sleep(.6)
self.assertGreaterEqual(client.art_request.await_count, 2)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,81 @@
"""Exercise the real FIFO and Telnet code without an AppDaemon installation."""
import ast
from functools import wraps
from pathlib import Path
import queue
import threading
import types
import unittest
source = Path(__file__).resolve().parents[1] / 'apps/smartprojector_benq_over_telnet.py'
tree = ast.parse(source.read_text())
tree.body = [node for node in tree.body if isinstance(node, (ast.ClassDef, ast.FunctionDef))]
namespace = dict(SmartSwitch=object, queue=queue, threading=threading, wraps=wraps)
exec(compile(tree, str(source), 'exec'), namespace)
SmartProjector = namespace['SmartProjector']
class ProjectorQueueTests(unittest.TestCase):
def setUp(self):
self.app = SmartProjector()
self.app._command_queue = queue.Queue()
self.app._command_stop = threading.Event()
self.app._command_worker = threading.Thread(target=self.app._process_commands)
self.app.telnet_ip, self.app.telnet_port = 'projector', 8000
self.app.log_info = self.app.log_warning = self.app.log_error = lambda *a, **k: None
self.app._command_worker.start()
def tearDown(self):
self.app._command_stop.set()
self.app._command_queue.put(None)
self.app._command_worker.join(2)
self.assertFalse(self.app._command_worker.is_alive())
def test_enqueue_returns_while_network_blocked_and_retries_stay_before_off(self):
entered, release = threading.Event(), threading.Event()
sent = []
class Session:
def __init__(self, *args, **kwargs):
assert kwargs['timeout'] == 2
def __enter__(self): return self
def __exit__(self, *args): pass
def write(self, data): sent.append(data)
def read_until(self, *args, **kwargs):
if len(sent) == 1:
entered.set()
if not release.wait(2): raise AssertionError('test gate timed out')
return b''
return b'*POW=ON#' if sent[-1] == b'*pow=on#' else b'*POW=OFF#'
namespace['telnetlib'] = types.SimpleNamespace(Telnet=Session)
self.assertIsNone(self.app.send_cmd('pow=on'))
self.assertTrue(entered.wait(2))
self.assertIsNone(self.app.send_cmd('pow=off'))
self.assertEqual(sent, [b'*pow=on#'])
release.set()
self.app._command_queue.join()
self.assertEqual(sent, [b'*pow=on#', b'*pow=on#', b'*pow=off#'])
def test_failed_operation_does_not_stop_queue(self):
completed = threading.Event()
def fail(app): raise RuntimeError('test failure')
self.app._command_queue.put((fail, (), {}))
self.app._command_queue.put((lambda app: completed.set(), (), {}))
self.assertTrue(completed.wait(2))
def test_nested_commands_execute_within_current_operation(self):
order = []
self.app._send_cmd = lambda cmd, *args: order.append(cmd)
def compound(app):
app.send_cmd('appmod=?')
app.send_cmd('appmod=cinema')
self.app._command_queue.put((compound, (), {}))
self.app.send_cmd('pow=off')
self.app._command_queue.join()
self.assertEqual(order, ['*appmod=?#', '*appmod=cinema#', '*pow=off#'])
if __name__ == '__main__':
unittest.main()
+906
View File
@@ -0,0 +1,906 @@
from pathlib import Path
import socket
import sys
import threading
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 vertex2 import Vertex2
import ad_toolbox.smartcondition as SmartCondition
class Harness(Vertex2):
def __init__(self, plugged=True):
self.plugged = plugged
self._power_allowed = True
self.power_plug_entity = 'switch.vertex2'
self._interrupt_stop = threading.Event()
self._status_requested = threading.Event()
self._last_logged_status = None
self._input_5v_states = {}
self._cancel_input_5v_refresh()
self._schedule_input_5v_refresh = Mock()
self._connection_lock = threading.Lock()
self._interrupt_connection = None
self.startup_delay = 30
self._startup_deadline = 0
self._startup_commands = []
self._command_processing_lock = threading.Lock()
self._command_processing_deadline = 123
self._command_processing_handle = 'timer'
self.command_processing_timeout = 30
self.command_processing_sensor = Mock()
self.plus5_sensors = {}
self.audio_out_signal_sensor = Mock()
self.unreachable_sensor = Mock()
self._update_output_video_sensors = Mock()
self.turn_on = Mock()
self.turn_off = Mock()
self.cancel_timer = Mock()
self.log_warning = Mock()
self.log_info = Mock()
self.run_in = Mock()
self.vertex2_tcp_port = 2220
@property
def args(self):
return {'vertex2_address': 'http://192.0.2.1'}
@property
def name(self):
return 'test_vertex'
def get_state(self, entity):
return 'on' if self.plugged else 'off'
class PowerTests(unittest.TestCase):
def test_condition_closes_socket_before_cutting_power(self):
app = Harness()
connection = Mock()
app._interrupt_connection = connection
app.turn_off.side_effect = lambda entity: connection.close.assert_called_once()
app.on_change_power_plug_conditions(None, SmartCondition.Result.Failed)
connection.shutdown.assert_called_once_with(socket.SHUT_RDWR)
app.turn_off.assert_called_once_with('switch.vertex2')
self.assertFalse(app._vertex2_powered())
app.cancel_timer.assert_called_once_with('timer', silent=True)
self.assertIsNone(app._command_processing_deadline)
app.command_processing_sensor.set_state.assert_called_once_with(state='off')
app.unreachable_sensor.set_state.assert_called_once_with(state='off')
def test_off_blocks_http_commands(self):
for plugged, allowed in [(False, True), (True, False)]:
app = Harness(plugged)
app._power_allowed = allowed
with patch('vertex2.get') as get:
app.send_cmd('reboot')
get.assert_not_called()
def test_no_plug_preserves_connectivity(self):
app = Harness(False)
app.power_plug_entity = None
self.assertTrue(app._vertex2_powered())
def test_delayed_status_is_ignored_after_power_off(self):
app = Harness(False)
app._apply_vertex2_status({})
app.log_info.assert_not_called()
app.run_in.assert_not_called()
def test_external_plug_off_suspends_connection(self):
app = Harness(False)
connection = Mock()
app._interrupt_connection = connection
app.on_power_plug_state_change('switch.vertex2', 'state', 'on', 'off', {})
connection.close.assert_called_once()
app._update_output_video_sensors.assert_called_once_with('', '')
def test_plug_on_requests_fresh_status(self):
app = Harness()
app.on_power_plug_state_change('switch.vertex2', 'state', 'off', 'on', {})
self.assertTrue(app._status_requested.is_set())
self.assertFalse(app._vertex2_ready())
def test_boot_commands_wait_and_are_replayed(self):
app = Harness()
with patch('vertex2.time.monotonic', return_value=100):
app._begin_startup()
with patch('vertex2.get') as get:
app.send_cmd('tx1plus5', 'on')
app.send_cmd('edid', 'audiotx1 on')
get.assert_not_called()
self.assertFalse(app._vertex2_ready())
with patch('vertex2.time.monotonic', return_value=130):
self.assertTrue(app._vertex2_ready())
with patch('vertex2.get') as get:
app._flush_startup_commands()
self.assertEqual(get.call_count, 2)
self.assertEqual(app._startup_commands, [])
def test_http_failure_after_boot_still_warns(self):
from requests.exceptions import ConnectionError
app = Harness()
app._startup_deadline = 130
with patch('vertex2.time.monotonic', return_value=130):
with patch('vertex2.get', side_effect=ConnectionError):
app.send_cmd('tx1plus5', 'on')
app.log_warning.assert_called_once()
def test_power_cut_discards_queued_commands(self):
app = Harness()
app._begin_startup()
app.send_cmd('tx1plus5', 'on')
app._suspend_vertex2()
self.assertEqual(app._startup_commands, [])
def test_listener_does_not_connect_during_boot(self):
app = Harness()
app._begin_startup()
worker = threading.Thread(target=app._interrupt_listener, args=(app._interrupt_stop,))
with patch('vertex2.socket.create_connection') as connect:
worker.start()
try:
self.assertFalse(app._interrupt_stop.wait(.1))
connect.assert_not_called()
finally:
app._interrupt_stop.set()
worker.join(2)
self.assertFalse(worker.is_alive())
def test_cut_power_unblocks_recv_without_warning(self):
app = Harness()
receiving = threading.Event()
closed = threading.Event()
connection = Mock()
connection.shutdown.side_effect = lambda how: closed.set()
def recv(size):
receiving.set()
closed.wait(2)
return b''
connection.recv.side_effect = recv
context = Mock(__enter__=Mock(return_value=connection), __exit__=Mock(return_value=False))
worker = threading.Thread(target=app._interrupt_listener, args=(app._interrupt_stop,))
with patch('vertex2.socket.create_connection', return_value=context):
worker.start()
try:
self.assertTrue(receiving.wait(2))
app.on_change_power_plug_conditions(None, SmartCondition.Result.Failed)
self.assertTrue(closed.is_set())
finally:
app._interrupt_stop.set()
closed.set()
worker.join(2)
self.assertFalse(worker.is_alive())
app.log_warning.assert_not_called()
def test_listener_waits_off_then_reconnects_when_on(self):
app = Harness(False)
attempted = threading.Event()
def connect(*args, **kwargs):
attempted.set()
app._interrupt_stop.set()
raise OSError('test stop')
worker = threading.Thread(target=app._interrupt_listener, args=(app._interrupt_stop,))
with patch('vertex2.socket.create_connection', side_effect=connect) as create:
worker.start()
try:
self.assertFalse(attempted.wait(.1))
create.assert_not_called()
app.plugged = True
self.assertTrue(attempted.wait(2))
finally:
app._interrupt_stop.set()
worker.join(2)
self.assertFalse(worker.is_alive())
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):
def test_reconnect_reapplies_current_plus5_and_selections(self):
app = Harness()
app.power_plug_conditions_evaluator = Mock()
app.power_plug_conditions_evaluator.evaluate.return_value = SmartCondition.Result.Succeeded
app.input_selection_evaluators = [Mock()]
app.output_selection_evaluators = [Mock()]
app.on_update_input_selection = Mock()
app.on_update_output_selection = Mock()
tx0, tx1 = Mock(), Mock()
tx0.evaluate.return_value = SmartCondition.Result.Failed
tx1.evaluate.return_value = SmartCondition.Result.Succeeded
app.output_config_conditions = {0: {'force_plus5': tx0}, 1: {'force_plus5': tx1}}
app.force_tx0_plus5 = Mock()
app.force_tx1_plus5 = Mock()
app._reconfigure_vertex2()
app.power_plug_conditions_evaluator.evaluate.assert_called_once_with(False)
app.on_update_input_selection.assert_called_once_with()
app.on_update_output_selection.assert_called_once_with()
app.force_tx0_plus5.assert_called_once_with(False)
app.force_tx1_plus5.assert_called_once_with(True)
for evaluator in (tx0, tx1):
evaluator.evaluate.assert_called_once_with(False)
def test_reconnect_stops_if_power_condition_no_longer_allows_device(self):
app = Harness()
app.power_plug_conditions_evaluator = Mock()
app.power_plug_conditions_evaluator.evaluate.return_value = SmartCondition.Result.Failed
app.on_update_output_selection = Mock()
app._reconfigure_vertex2()
app.turn_off.assert_called_once_with('switch.vertex2')
app.on_update_output_selection.assert_not_called()
def test_input_selection_without_matching_condition_does_not_send_command(self):
app = Harness()
evaluator = Mock()
evaluator.evaluate.return_value = SmartCondition.Result.Failed
app.input_selection_evaluators = [(evaluator, 'AppleTV')]
app.select_input = Mock()
app.on_update_input_selection()
app.select_input.assert_not_called()
def test_only_first_complete_status_requests_reconfiguration(self):
app = Harness()
fields = ('rx0', 'rx1', 'tx0', 'tx1', 'tx0sink', 'tx1sink',
'aud0', 'aud1', 'audout', 'spd0', 'spd1')
response = ('\n'.join(f'{field.upper()}: no signal' for field in fields)
+ '\ninsel 0 0\n').encode('ascii')
connection = Mock()
responses = iter([response, response])
def recv(size):
try:
return next(responses)
except StopIteration:
app._interrupt_stop.set()
return b''
connection.recv.side_effect = recv
app.run_in.side_effect = lambda *args, **kwargs: app._status_requested.set()
context = Mock(__enter__=Mock(return_value=connection), __exit__=Mock(return_value=False))
with patch('vertex2.socket.create_connection', return_value=context):
app._interrupt_listener(app._interrupt_stop)
self.assertEqual([call.kwargs['reconfigure'] for call in app.run_in.call_args_list],
[True, False])
def test_status_reconfigures_before_readback(self):
app = Harness()
app.input_selected_sensors = {i: Mock() for i in range(2)}
app.generate_input_selected_sensor_name = lambda i: i
app.get_input_config = Mock(return_value={'name': 'AppleTV'})
app.output_names = {}
app.output_selected_sensor = Mock()
app._refresh_selected_input_sensor = Mock()
sequence = Mock()
app._flush_startup_commands = sequence.flush
app._reconfigure_vertex2 = sequence.reconfigure
app._refresh_plus5_sensors = sequence.readback
app._apply_vertex2_status(dict(rx0='', rx1='', tx0='', tx1='', audout='',
insel='0 0', reconfigure=True))
self.assertEqual([call[0] for call in sequence.mock_calls],
['flush', 'reconfigure', 'readback'])
sequence.reset_mock()
app._apply_vertex2_status(dict(rx0='', rx1='', tx0='', tx1='', audout='', insel='0 0'))
sequence.reconfigure.assert_not_called()
class Plus5Tests(unittest.TestCase):
def setUp(self):
self.app = Harness()
self.app.plus5_sensors = {i: Mock() for i in range(2)}
def test_reads_actual_settings_independently(self):
with patch('vertex2.get') as get:
get.return_value.json.return_value = {'tx0plus5': '0', 'tx1plus5': '1'}
self.app._refresh_plus5_sensors()
self.app.plus5_sensors[0].set_state.assert_called_once_with(state='off')
self.app.plus5_sensors[1].set_state.assert_called_once_with(state='on')
def test_unknown_value_does_not_confirm_off(self):
with patch('vertex2.get') as get:
get.return_value.json.return_value = {'tx0plus5': 'unexpected'}
self.app._refresh_plus5_sensors()
for sensor in self.app.plus5_sensors.values():
sensor.set_state.assert_called_once_with(state='unavailable')
def test_failed_or_malformed_read_invalidates_old_settings(self):
from requests.exceptions import HTTPError, Timeout
for error in (HTTPError('503'), Timeout(), ValueError('invalid JSON')):
with self.subTest(error=error):
for sensor in self.app.plus5_sensors.values():
sensor.reset_mock()
with patch('vertex2.get', side_effect=error):
self.app._refresh_plus5_sensors()
for sensor in self.app.plus5_sensors.values():
sensor.set_state.assert_called_once_with(state='unavailable')
def test_power_off_during_read_discards_response(self):
with patch('vertex2.get') as get:
def result():
self.app.plugged = False
return {'tx0plus5': '1', 'tx1plus5': '1'}
get.return_value.json.side_effect = result
self.app._refresh_plus5_sensors()
for sensor in self.app.plus5_sensors.values():
sensor.set_state.assert_called_once_with(state='unavailable')
def test_command_requests_readback_without_optimistic_confirmation(self):
with patch('vertex2.get'):
self.app.send_cmd('tx0plus5', 'on')
self.assertTrue(self.app._status_requested.is_set())
for sensor in self.app.plus5_sensors.values():
sensor.set_state.assert_called_once_with(state='unavailable')
def test_power_cut_invalidates_settings(self):
self.app._suspend_vertex2()
for sensor in self.app.plus5_sensors.values():
sensor.set_state.assert_called_once_with(state='unavailable')
class StatusChangeTests(unittest.TestCase):
def setUp(self):
self.app = app = Harness()
app.input_selected_sensors = {i: Mock() for i in range(2)}
app.generate_input_selected_sensor_name = lambda i: i
app.get_input_config = Mock(return_value={'name': 'AppleTV'})
app.output_names = {}
app.output_selected_sensor = Mock()
app._refresh_selected_input_sensor = Mock()
app._flush_startup_commands = Mock()
app._reconfigure_vertex2 = Mock()
app._refresh_plus5_sensors = Mock()
self.status = dict(rx0='no signal', rx1='no signal', tx0='', tx1='',
audout='', insel='0 4')
def test_input_5v_refreshes_on_interrupt_even_when_video_status_is_unchanged(self):
app = self.app
app.input_5v_sensors = {}
app._schedule_input_5v_refresh = lambda: Vertex2._schedule_input_5v_refresh(app)
app._refresh_input_5v_sensors = Mock()
for metadata in ({'reconfigure': True}, {'interrupt_at': 100}):
app.run_in.reset_mock()
app._refresh_input_5v_sensors.reset_mock()
app._apply_vertex2_status(dict(self.status, **metadata))
app._refresh_input_5v_sensors.assert_not_called()
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.kwargs)
app._refresh_input_5v_sensors.assert_called_once_with()
def test_identical_status_is_silent_but_still_applied(self):
app = self.app
app._apply_vertex2_status(self.status)
app._apply_vertex2_status(dict(self.status, reconfigure=True))
app.log_info.assert_called_once()
app.run_in.assert_not_called()
self.assertEqual(app._update_output_video_sensors.call_count, 2)
app._reconfigure_vertex2.assert_called_once()
def test_changed_status_extends_active_window_only_once(self):
app = self.app
app._apply_vertex2_status(self.status)
changed = dict(self.status, tx0='4K50')
with patch('vertex2.time.monotonic', return_value=200):
app._apply_vertex2_status(changed)
self.assertEqual(app._command_processing_deadline, 230)
app._apply_vertex2_status(changed)
self.assertEqual(app.log_info.call_count, 2)
app.run_in.assert_called_once()
app._on_command_processing_timeout({'deadline': 123})
self.assertEqual(app._command_processing_deadline, 230)
app._on_command_processing_timeout({'deadline': 230})
app.command_processing_sensor.set_state.assert_called_with(state='off')
def test_change_without_active_command_logs_without_starting_window(self):
app = self.app
app._command_processing_deadline = None
app._apply_vertex2_status(self.status)
app._apply_vertex2_status(dict(self.status, audout='L-PCM'))
self.assertEqual(app.log_info.call_count, 2)
app.run_in.assert_not_called()
app.command_processing_sensor.set_state.assert_not_called()
class CommandProcessingTests(unittest.TestCase):
def test_interrupt_spam_does_not_extend_command_deadline(self):
app = Harness()
app.command_processing_sensor.get_state.return_value = 'off'
with patch('vertex2.time.monotonic', return_value=100), patch('vertex2.get'):
app.send_cmd('mutetx0audio', 'on')
self.assertEqual(app._command_processing_deadline, 130)
app.run_in.reset_mock()
app.cancel_timer.reset_mock()
connection = Mock()
chunks = iter([b'int\n'] * 20)
def recv(size):
try:
return next(chunks)
except StopIteration:
app._interrupt_stop.set()
return b''
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):
app._interrupt_listener(app._interrupt_stop)
self.assertEqual(app._command_processing_deadline, 130)
app.cancel_timer.assert_not_called()
app.run_in.assert_not_called()
app._on_command_processing_timeout({'deadline': 130})
app.command_processing_sensor.set_state.assert_called_with(state='off')
def test_new_command_extends_window_and_old_callback_is_ignored(self):
app = Harness()
with patch('vertex2.time.monotonic', return_value=100), patch('vertex2.get'):
app.send_cmd('mutetx0audio', 'on')
with patch('vertex2.time.monotonic', return_value=110), patch('vertex2.get'):
app.send_cmd('mutetx0audio', 'off')
self.assertEqual(app._command_processing_deadline, 140)
app.command_processing_sensor.set_state.reset_mock()
app._on_command_processing_timeout({'deadline': 130})
app.command_processing_sensor.set_state.assert_not_called()
app._on_command_processing_timeout({'deadline': 140})
app.command_processing_sensor.set_state.assert_called_once_with(state='off')
def test_queued_command_does_not_start_window_before_boot(self):
app = Harness()
app._begin_startup()
with patch('vertex2.get') as get:
app.send_cmd('mutetx0audio', 'on')
get.assert_not_called()
app.command_processing_sensor.set_state.assert_not_called()
app.run_in.assert_not_called()
class SpamSensorTests(unittest.TestCase):
def setUp(self):
self.app = app = Harness()
app.no_signal_spam_sensor = Mock()
self.state = 'off'
app.no_signal_spam_sensor.get_state.side_effect = lambda: self.state
app.no_signal_spam_sensor.set_state.side_effect = lambda **kw: setattr(self, 'state', kw['state'])
app._spam_lock = threading.RLock()
app._spam_last_interrupt = None
app._spam_snapshot = None
app._spam_count = 0
app._spam_timer = None
app._spam_deadline = None
self.status = dict(rx0='no signal', rx1='no signal', tx0='', tx1='', audout='', insel='0 4')
def observe(self, at, interrupt=True, **changes):
status = dict(self.status, **changes)
if interrupt:
status['interrupt_at'] = at
with patch('vertex2.time.monotonic', return_value=at):
self.app._observe_no_signal_spam(status)
def test_stop_cancels_timers_without_publishing_spam_state(self):
app = self.app
self.observe(100)
self.observe(103)
self.observe(106)
self.assertEqual(self.state, 'on')
app._spam_timer = 'spam_timer'
app.no_signal_spam_sensor.reset_mock()
app.cancel_timer.reset_mock()
app._stop_interrupt_listener()
self.assertTrue(app._interrupt_stop.is_set())
self.assertIsNone(app._spam_timer)
self.assertIsNone(app._spam_deadline)
app.cancel_timer.assert_any_call('spam_timer', silent=True)
app.cancel_timer.assert_any_call('timer', silent=True)
app.no_signal_spam_sensor.set_state.assert_not_called()
# An already queued expiry callback must also leave the state alone.
app._expire_no_signal_spam({'deadline': 110})
app.no_signal_spam_sensor.set_state.assert_not_called()
def test_connection_loss_still_marks_spam_sensor_unavailable(self):
self.app._reset_no_signal_spam('unavailable')
self.assertEqual(self.state, 'unavailable')
def test_three_identical_interrupts_detect_and_silence_clears(self):
self.observe(100)
self.observe(103)
self.assertEqual(self.state, 'off')
self.observe(106)
self.assertEqual(self.state, 'on')
self.app._expire_no_signal_spam({'deadline': 107})
self.assertEqual(self.state, 'on')
self.app._expire_no_signal_spam({'deadline': 110})
self.assertEqual(self.state, 'off')
def test_polls_do_not_count_and_changed_poll_breaks_streak(self):
for at in range(100, 110):
self.observe(at, interrupt=False)
self.assertEqual(self.state, 'off')
self.observe(112)
self.observe(115)
self.observe(116, interrupt=False, insel='1 4')
self.observe(118)
self.assertEqual(self.state, 'off')
def test_normal_transitions_and_nonperiodic_events_do_not_trigger(self):
self.observe(100)
self.observe(103, audout='L-PCM')
self.observe(106)
self.assertEqual(self.state, 'off')
self.observe(107)
self.observe(113)
self.assertEqual(self.state, 'off')
def test_signal_return_or_power_loss_clears(self):
for at in (100, 103, 106):
self.observe(at)
self.observe(107, tx0='4K50')
self.assertEqual(self.state, 'off')
for at in (110, 113, 116):
self.observe(at)
self.app._suspend_vertex2()
self.assertEqual(self.state, 'off')
self.app._expire_no_signal_spam({'deadline': 120})
self.assertEqual(self.state, 'off')
def test_unknown_input_is_not_no_signal(self):
for at in (100, 103, 106):
self.observe(at, rx0='unavailable')
self.assertEqual(self.state, 'off')
if __name__ == '__main__':
unittest.main()
class SelectHarness(Harness):
@property
def args(self):
return dict(super().args,
input_selected_sensor='sensor.input', unreachable_sensor='binary_sensor.unreachable',
input_select={'entity': 'select.vertex2_input', 'unique_id': 'vertex2_input',
'connection_topic': 'appdaemon/connection'},
input_config={0: {'name': 'AppleTV', 'icon': 'mdi:apple'},
1: {'name': 'Xbox', 'icon': 'mdi:microsoft-xbox'},
'default': {'name': 'None', 'icon': 'mdi:television-off'}})
class InputSelectTests(unittest.TestCase):
def setUp(self):
self.app = app = SelectHarness()
app.input_names = {'AppleTV': 0, 'Xbox': 1}
app.input_selected_sensor = Mock()
app.input_selected_sensor.get_state.return_value = 'AppleTV'
app.unreachable_sensor.get_state.return_value = 'off'
app.call_service = Mock()
app.get_plugin_config = Mock(return_value={'will_topic': 'transport/connection'})
app.listen_state = Mock()
app.listen_event = Mock()
app.send_cmd = Mock()
app._initialize_input_select()
def published(self, suffix):
return [c.kwargs['payload'] for c in self.app.call_service.call_args_list
if c.args[0] == 'mqtt/publish'
and c.kwargs['topic'] == self.app._select_topic + suffix][-1]
def test_discovery_options_and_initial_icon(self):
import json
discovery = next(c for c in self.app.call_service.call_args_list
if c.kwargs.get('topic') == 'homeassistant/select/vertex2_input/config')
config = json.loads(discovery.kwargs['payload'])
self.assertEqual(config['options'], ['AppleTV', 'Xbox'])
self.assertFalse(config['optimistic'])
self.assertEqual(config['availability'][1]['topic'], 'appdaemon/connection')
self.assertEqual(json.loads(self.published('/attributes'))['icon'], 'mdi:apple')
def test_user_command_sets_both_tx_without_changing_confirmed_state(self):
app = self.app
app.call_service.reset_mock()
app._on_input_select_mqtt('MQTT_MESSAGE',
{'topic': app._select_topic + '/set', 'payload': 'Xbox'}, {})
app.send_cmd.assert_called_once_with('insel', '1 1')
self.assertTrue(app._status_requested.is_set())
app.input_selected_sensor.set_state.assert_not_called()
app.call_service.assert_not_called()
def test_unknown_or_powered_off_command_is_rejected(self):
app = self.app
for plugged, value in ((True, 'invalid'), (False, 'Xbox')):
app.plugged = plugged
app._on_input_select_mqtt('MQTT_MESSAGE',
{'topic': app._select_topic + '/set', 'payload': value}, {})
app.send_cmd.assert_not_called()
def test_state_and_icon_follow_input_on_active_output(self):
import json
app = self.app
state = {'name': 'AppleTV'}
app.input_selected_sensor.get_state.side_effect = lambda: state['name']
app.input_selected_sensor.set_state.side_effect = lambda **kw: state.update(name=kw.get('state', state['name']))
app.input_selected_sensors = {
app.generate_input_selected_sensor_name(0): Mock(),
app.generate_input_selected_sensor_name(1): Mock(),
}
for index, name in enumerate(('AppleTV', 'Xbox')):
app.input_selected_sensors[app.generate_input_selected_sensor_name(index)].get_state.return_value = name
for index, expected, icon in ((1, 'Xbox', 'mdi:microsoft-xbox'), (0, 'AppleTV', 'mdi:apple')):
app._refresh_selected_input_sensor(index)
app._on_input_select_state(None, None, None, None, {})
self.assertEqual(self.published('/state'), expected)
self.assertEqual(json.loads(self.published('/attributes'))['icon'], icon)
self.assertEqual(app._select_discovery_payload['icon'], icon)
app._refresh_selected_input_sensor(None)
app._sync_input_select()
self.assertEqual(self.published('/availability'), 'offline')
self.assertEqual(self.published('/state'), 'None')
def test_unreachable_and_power_off_mark_select_unavailable(self):
app = self.app
app.unreachable_sensor.get_state.return_value = 'on'
app._sync_input_select()
self.assertEqual(self.published('/availability'), 'offline')
app.unreachable_sensor.get_state.return_value = 'off'
app.plugged = False
app._sync_input_select()
self.assertEqual(self.published('/availability'), 'offline')
def test_reconnect_resubscribes_and_republishes_discovery(self):
app = self.app
for data in ({'state': 'Connected'}, {'topic': 'homeassistant/status', 'payload': 'online'}):
app.call_service.reset_mock()
app._on_input_select_mqtt('MQTT_MESSAGE', data, {})
subscriptions = [c.kwargs['topic'] for c in app.call_service.call_args_list
if c.args[0] == 'mqtt/subscribe']
self.assertIn(app._select_topic + '/set', subscriptions)
self.assertTrue(any(c.kwargs.get('topic', '').endswith('/config')
for c in app.call_service.call_args_list))
def test_connection_topic_is_derived_from_shared_plugin(self):
import json
app = self.app
app._input_select_config.pop('connection_topic')
app.get_plugin_config.return_value = {
'will_topic': 'transport/connection', 'birth_payload': 'ready', 'will_payload': 'gone'}
app.call_service.reset_mock()
app._discover_input_select()
discovery = next(c for c in app.call_service.call_args_list
if c.kwargs.get('topic') == 'homeassistant/select/vertex2_input/config')
config = json.loads(discovery.kwargs['payload'])
self.assertEqual(config['availability'][1], {
'topic': 'transport/connection', 'payload_available': 'ready', 'payload_not_available': 'gone'})
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):
self.app = app = SelectHarness()
app.input_names = {'AppleTV': 0, 'Xbox': 1}
app.create_entity = Mock(side_effect=lambda *a, **kw: Mock())
app.run_every = Mock()
app._initialize_input_5v_sensors()
def test_names_and_icons_come_from_input_configuration(self):
calls = self.app.create_entity.call_args_list
self.assertEqual(calls[0].args[0], 'binary_sensor.input_appletv_5v')
self.assertEqual(calls[0].kwargs['friendly_name'], 'AppleTV +5 V')
self.assertEqual(calls[0].kwargs['icon'], 'mdi:apple')
self.assertEqual(calls[1].kwargs['icon'], 'mdi:microsoft-xbox')
self.app.run_every.assert_not_called()
def test_reads_each_input_even_without_video_signal(self):
with patch('vertex2.get') as get:
get.return_value.json.return_value = {'RX0': 'no signal', 'rx0in5v': '2', 'rx1in5v': '1'}
self.app._refresh_input_5v_sensors()
self.app.input_5v_sensors[0].set_state.assert_called_once_with(state='on')
self.app.input_5v_sensors[1].set_state.assert_called_once_with(state='off')
def test_invalid_or_missing_values_are_unavailable(self):
with patch('vertex2.get') as get:
get.return_value.json.return_value = {'rx0in5v': 'unexpected'}
self.app._refresh_input_5v_sensors()
for sensor in self.app.input_5v_sensors.values():
sensor.set_state.assert_called_once_with(state='unavailable')
def test_http_failure_invalidates_old_values(self):
from requests.exceptions import Timeout
with patch('vertex2.get', side_effect=Timeout):
self.app._refresh_input_5v_sensors()
for sensor in self.app.input_5v_sensors.values():
sensor.set_state.assert_called_once_with(state='unavailable')
def test_no_read_while_off_or_booting(self):
for plugged, deadline in ((False, 0), (True, float('inf'))):
self.app.plugged = plugged
self.app._startup_deadline = deadline
with patch('vertex2.get') as get:
self.app._refresh_input_5v_sensors()
get.assert_not_called()
def test_power_cut_during_request_discards_result(self):
def result():
self.app.plugged = False
return {'rx0in5v': '2', 'rx1in5v': '2'}
with patch('vertex2.get') as get:
get.return_value.json.side_effect = result
self.app._refresh_input_5v_sensors()
for sensor in self.app.input_5v_sensors.values():
sensor.set_state.assert_called_once_with(state='unavailable')
def test_5v_edges_start_or_extend_command_processing_once_per_read(self):
app = self.app
for previous, raw in (('off', '2'), ('on', '1')):
app._input_5v_states = {index: previous for index in range(4)}
for sensor in app.input_5v_sensors.values():
sensor.get_state.return_value = previous
app.command_processing_sensor.get_state.return_value = 'off'
app.command_processing_sensor.set_state.reset_mock()
app.run_in.reset_mock()
with patch('vertex2.time.monotonic', return_value=200), patch('vertex2.get') as get:
get.return_value.json.return_value = {'rx0in5v': raw, 'rx1in5v': raw}
app._refresh_input_5v_sensors()
app.command_processing_sensor.set_state.assert_called_once_with(state='on')
self.assertEqual(app._command_processing_deadline, 230)
app.run_in.assert_called_once()
app._on_command_processing_timeout({'deadline': 230})
app.command_processing_sensor.set_state.assert_called_with(state='off')
def test_initial_unavailable_and_unchanged_5v_do_not_start_processing(self):
app = self.app
app._start_command_processing = Mock()
for previous, raw in (('unavailable', '2'), ('unknown', '1'), ('on', '2'),
('off', '1'), ('on', None)):
app._input_5v_states = {index: previous for index in range(4)}
for sensor in app.input_5v_sensors.values():
sensor.get_state.return_value = previous
with patch('vertex2.get') as get:
get.return_value.json.return_value = {'rx0in5v': raw, 'rx1in5v': raw}
app._refresh_input_5v_sensors()
app._start_command_processing.assert_not_called()
def test_processing_is_identical_with_all_some_or_no_input_sensors(self):
app = self.app
sensors = app.input_5v_sensors.copy()
for exposed in (sensors, {0: sensors[0]}, {}):
with self.subTest(exposed=list(exposed)):
app.input_5v_sensors = exposed
app._input_5v_states = {}
app._start_command_processing = Mock()
with patch('vertex2.get') as get:
# All physical ports are tracked even without input_config entries.
for values in (('1', '1', '1', '1'), ('1', '1', '1', '2'),
('1', '1', '1', '2'), ('1', '1', '1', '1')):
get.return_value.json.return_value = {
f'rx{index}in5v': value for index, value in enumerate(values)}
app._refresh_input_5v_sensors()
self.assertEqual(app._start_command_processing.call_count, 2)
self.assertEqual(app._input_5v_states[3], 'off')
def test_ha_sensor_state_cannot_create_or_hide_a_transition(self):
app = self.app
app._start_command_processing = Mock()
app._input_5v_states = {0: 'off', 1: 'off'}
for sensor in app.input_5v_sensors.values():
sensor.get_state.return_value = 'on'
with patch('vertex2.get') as get:
get.return_value.json.return_value = {'rx0in5v': '1', 'rx1in5v': '1'}
app._refresh_input_5v_sensors()
app._start_command_processing.assert_not_called()
get.return_value.json.return_value = {'rx0in5v': '2', 'rx1in5v': '2'}
app._refresh_input_5v_sensors()
app._start_command_processing.assert_called_once()
def test_read_failure_resets_internal_baseline_without_processing(self):
from requests.exceptions import Timeout
app = self.app
app.input_5v_sensors = {}
app._input_5v_states = {0: 'on'}
app._start_command_processing = Mock()
with patch('vertex2.get', side_effect=Timeout):
app._refresh_input_5v_sensors()
self.assertEqual(app._input_5v_states, {})
with patch('vertex2.get') as get:
get.return_value.json.return_value = {'rx0in5v': '1'}
app._refresh_input_5v_sensors()
app._start_command_processing.assert_not_called()
+116
View File
@@ -0,0 +1,116 @@
from pathlib import Path
import sys
import unittest
from unittest.mock import Mock
APPS = Path(__file__).resolve().parents[1] / "apps"
sys.path[:0] = [str(APPS), str(APPS / "ad_toolbox")]
from vertexcontent import VertexContent
class Harness(VertexContent):
def __init__(self, states, sources):
self.states = states
self.sources = sources
self.input_selected = "sensor.vertex2_input_selected"
self.has_signal = "binary_sensor.vertex2_has_input_signal"
self.output = "sensor.vertex2_current_content"
@property
def name(self):
return "vertex_content"
def get_state(self, entity, attribute=None):
data = self.states.get(entity)
if attribute == "all":
return data
return data.get("state") if data else None
APPLE = {
"AppleTV": {
"label": "Apple TV",
"platform": "Apple TV",
"entity": "media_player.apple_tv",
"pause_states": ["paused"],
"pause_supported_apps": ["Netflix"],
}
}
class VertexContentTests(unittest.TestCase):
def states(self, player_state="playing", attributes=None, signal="on"):
return {
"sensor.vertex2_input_selected": {"state": "AppleTV", "attributes": {}},
"binary_sensor.vertex2_has_input_signal": {"state": signal, "attributes": {}},
"media_player.apple_tv": {
"state": player_state,
"attributes": attributes or {},
},
}
def test_uses_title_before_application(self):
app = Harness(self.states(attributes={
"media_title": "Dune",
"app_name": "Netflix",
"media_content_type": "movie",
}), APPLE)
state, attributes = app.build_content()
self.assertEqual(state, "Dune")
self.assertEqual(attributes["application"], "Netflix")
self.assertEqual(attributes["content_type"], "movie")
self.assertFalse(attributes["is_paused"])
self.assertTrue(attributes["is_pause_supported"])
def test_falls_back_to_application(self):
app = Harness(self.states(attributes={"app_name": "Netflix"}), APPLE)
state, attributes = app.build_content()
self.assertEqual(state, "Netflix")
self.assertIsNone(attributes["title"])
def test_pause_requires_paused_state_and_supported_app(self):
supported = Harness(self.states("paused", {"app_name": "Netflix"}), APPLE)
unsupported = Harness(self.states("paused", {"app_name": "Photos"}), APPLE)
self.assertTrue(supported.build_content()[1]["is_paused"])
self.assertFalse(unsupported.build_content()[1]["is_paused"])
self.assertFalse(unsupported.build_content()[1]["is_pause_supported"])
def test_no_signal_is_idle(self):
app = Harness(self.states(signal="off"), APPLE)
state, attributes = app.build_content()
self.assertEqual(state, "idle")
self.assertFalse(attributes["available"])
self.assertFalse(attributes["is_paused"])
def test_console_uses_activity_state(self):
states = {
"sensor.vertex2_input_selected": {"state": "Playstation", "attributes": {}},
"binary_sensor.vertex2_has_input_signal": {"state": "on", "attributes": {}},
"sensor.ps5_136_activity": {"state": "Astro Bot", "attributes": {}},
}
sources = {"Playstation": {
"label": "PlayStation 5",
"platform": "PlayStation 5",
"entity": "sensor.ps5_136_activity",
"state_as_title": True,
"content_type": "game",
}}
state, attributes = Harness(states, sources).build_content()
self.assertEqual(state, "Astro Bot")
self.assertEqual(attributes["content_type"], "game")
self.assertFalse(attributes["is_pause_supported"])
def test_updates_through_created_entity_handle(self):
app = Harness(self.states(attributes={"app_name": "Netflix"}), APPLE)
app.output_entity = Mock()
app.output_entity.get_state.side_effect = ["old", {"attributes": {}}]
app.update_content()
app.output_entity.set_state.assert_called_once()
update = app.output_entity.set_state.call_args.kwargs
self.assertEqual(update["state"], "Netflix")
self.assertEqual(update["attributes"]["ad_app"], "vertex_content")
if __name__ == "__main__":
unittest.main()