Compare commits
64
Commits
ed6f218968
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3aefa8680 | ||
|
|
8863122afc | ||
|
|
13115ad1ba | ||
|
|
8cd2a414cd | ||
|
|
4d502c5911 | ||
|
|
545ebf2bd8 | ||
|
|
5ef7b27448 | ||
|
|
9802982d73 | ||
|
|
ff39f4cb2f | ||
|
|
b76f107f01 | ||
|
|
f5a586c379 | ||
|
|
94754e6285 | ||
|
|
10c5b9e043 | ||
|
|
9f32a666c3 | ||
|
|
3130df69ce | ||
|
|
35856b2ba9 | ||
|
|
a5258b1985 | ||
|
|
42ae8f4bc4 | ||
|
|
2c87c6a6f3 | ||
|
|
d8e604d64d | ||
|
|
bed50f1d09 | ||
|
|
07abff7054 | ||
|
|
956ade4d03 | ||
|
|
c34dc7fdd3 | ||
|
|
f6f61ddc2e | ||
|
|
d6d470903c | ||
|
|
914d48ae55 | ||
|
|
264619565e | ||
|
|
896fa2aa6d | ||
|
|
1626fe83bf | ||
|
|
5dbfd2a805 | ||
|
|
1fd4dab916 | ||
|
|
0de8352417 | ||
|
|
b499cb5a0d | ||
|
|
ce352ba7ef | ||
|
|
8cc2ae32df | ||
|
|
8a8cb02296 | ||
|
|
d79bba15b0 | ||
|
|
9a58cc5296 | ||
|
|
327c0b2558 | ||
|
|
40ce351fac | ||
|
|
b030645082 | ||
|
|
79dd13f31d | ||
|
|
6447d754ca | ||
|
|
a2f40f81ba | ||
|
|
c21291e9dd | ||
|
|
dd4335c811 | ||
|
|
6d2fd26f66 | ||
|
|
a734b5f780 | ||
|
|
486956cd1f | ||
|
|
0e64711c4f | ||
|
|
9bead746e8 | ||
|
|
842cfe6c6a | ||
|
|
5061e4395b | ||
|
|
3562e14c4c | ||
|
|
aafc932818 | ||
|
|
533785151e | ||
|
|
098e95b439 | ||
|
|
00f6b28342 | ||
|
|
acdd14aaeb | ||
|
|
18b6613864 | ||
|
|
bcfaa83f10 | ||
|
|
db12c81f6d | ||
|
|
ed59a72c3f |
No files matched your search
@@ -4,3 +4,5 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
logs/
|
logs/
|
||||||
apps/data/
|
apps/data/
|
||||||
|
secrets.yaml
|
||||||
|
llm/
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Repository instructions
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
Use French when communicating with the user unless they ask otherwise. All new
|
||||||
|
technical artifacts must be in English: code, identifiers, variable and
|
||||||
|
function names, Home Assistant entity and sensor names, log messages, comments,
|
||||||
|
test names, configuration keys, and technical documentation. Do not rename
|
||||||
|
existing French identifiers or entities unless explicitly requested.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This repository is an AppDaemon configuration for Home Assistant. Python apps
|
||||||
|
in `apps/` control real equipment, including heating, shutters, lighting, a
|
||||||
|
projector, and a television. Application configuration is in `apps/apps.yaml`,
|
||||||
|
supplemented by YAML files in `apps/global/` and `apps/rooms/`. Runtime
|
||||||
|
configuration is in `appdaemon.yaml`.
|
||||||
|
|
||||||
|
`apps/ad_toolbox` is a shared Git submodule. It provides the base classes,
|
||||||
|
conditions, virtual sensors, and logging used by the apps. Only change it when
|
||||||
|
the request explicitly concerns that component. Do not update its revision or
|
||||||
|
discard its local changes without approval.
|
||||||
|
|
||||||
|
## Security and operations
|
||||||
|
|
||||||
|
- Never display, copy into responses, or commit tokens, passwords,
|
||||||
|
`secrets.yaml`, `apps/theframe_token.txt`, or persisted data in `apps/data/`.
|
||||||
|
Refer to their paths and redact sensitive values in diagnostics.
|
||||||
|
- Do not start a second AppDaemon instance, rerun `dockerStart.sh`, or execute
|
||||||
|
`archiveLogs.sh` manually while the service is running. The archive script
|
||||||
|
moves active log files.
|
||||||
|
- Do not restart the container, send device commands, or change heating
|
||||||
|
setpoints unless the user explicitly requests it. To test heating logic, use
|
||||||
|
mocks first or `send_commands: false` where available.
|
||||||
|
- Local AppDaemon-core and startup-script patches may exist outside this
|
||||||
|
persistent repository. Read `docs/appdaemon-local-changes.md` and
|
||||||
|
`docs/appdaemon-upgrade-rollback-instructions.md` before upgrading or
|
||||||
|
changing the runtime. Do not overwrite or "clean up" these patches.
|
||||||
|
|
||||||
|
## Logs and diagnosis
|
||||||
|
|
||||||
|
When asked to investigate a warning, error, or runtime behavior, inspect
|
||||||
|
`/conf/logs` first:
|
||||||
|
|
||||||
|
1. `appdaemon.log`, `error.log`, and `virtualsensors.log` for the current run;
|
||||||
|
2. `archive/<timestamp>/` for previous runs;
|
||||||
|
3. `memory_usage.csv` when memory use is suspected.
|
||||||
|
|
||||||
|
Always inspect the lines following a warning: a successful reconnection or
|
||||||
|
recovery can make an incident transient. Distinguish application problems from
|
||||||
|
Home Assistant, MQTT, or hardware connectivity losses.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Follow the style and conventions of the app or module being changed. Apps
|
||||||
|
often inherit from `SmartObject` and use `SmartCondition`; do not bypass
|
||||||
|
these abstractions without a clear reason.
|
||||||
|
- Keep an app, its `apps/apps.yaml` entry, its Home Assistant entities, and any
|
||||||
|
related `apps/global/` or `apps/rooms/` configuration consistent.
|
||||||
|
- Preserve AppDaemon lifecycle behavior: cancel timers, tasks, listeners, and
|
||||||
|
sockets on reload or termination when a change introduces them.
|
||||||
|
- Do not overwrite or reorganize the user's existing work. Keep diffs limited
|
||||||
|
to the requested change.
|
||||||
|
- Update documentation in `docs/` when operational behavior, a hardware
|
||||||
|
protocol, or a deployment procedure changes.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
The project uses `unittest`; no formatter or linter is configured. After a
|
||||||
|
Python change, run the focused test when one exists, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m unittest discover -s tests -p 'test_vertex2.py' -v
|
||||||
|
python3 -m unittest discover -s tests -p 'test_boilerclimate.py' -v
|
||||||
|
python3 -m unittest discover -s tests -p 'test_samsungtheframe_async.py' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
For shared changes or before a broader delivery:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
python3 -m unittest discover -s apps/ad_toolbox/unit_tests -p 'test_*.py' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The legacy YAML test runner for `ad_toolbox` is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 apps/ad_toolbox/unittests.py
|
||||||
|
```
|
||||||
|
|
||||||
|
After a shell-script change, validate syntax at minimum with
|
||||||
|
`sh -n <script>`. Changes to the VS Code extension in
|
||||||
|
`apps/ad_toolbox/vscode-appdaemon/` must also follow its own `AGENTS.md`,
|
||||||
|
including compilation, packaging, and installation.
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|||||||
+1
-1
Submodule apps/ad_toolbox updated: 3e7ae7550b...86b5441603.
+120
-39
@@ -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,28 +67,75 @@ 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):
|
||||||
|
with self.log_group("refresh_cal"):
|
||||||
new_cal = list()
|
new_cal = list()
|
||||||
#if self.load_cal_from_google_calendar(new_cal):
|
#if self.load_cal_from_google_calendar(new_cal):
|
||||||
if self.load_cal_from_ics(new_cal):
|
if self.load_cal_from_ics(new_cal):
|
||||||
|
self.calendar_last_success = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
self.calendar_retry_attempt = 0
|
||||||
|
self._cancel_calendar_retry()
|
||||||
if not self.are_cal_identical(self.calendar,new_cal):
|
if not self.are_cal_identical(self.calendar,new_cal):
|
||||||
self.calendar = new_cal
|
self.calendar = new_cal
|
||||||
self.log("Calendar has been updated!")
|
self.log_info("Calendar has been updated!")
|
||||||
self.print_cal(self.calendar)
|
self.print_cal(self.calendar)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
else:
|
||||||
|
self._schedule_calendar_retry()
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _log_calendar_failure(self, reason):
|
||||||
|
last_success = self.calendar_last_success
|
||||||
|
if last_success is None:
|
||||||
|
log = self.log_error
|
||||||
|
freshness = 'no successful refresh recorded'
|
||||||
|
else:
|
||||||
|
age = datetime.datetime.now(datetime.timezone.utc) - last_success
|
||||||
|
if age < datetime.timedelta(hours=24):
|
||||||
|
log = self.log_info
|
||||||
|
elif age < datetime.timedelta(hours=48):
|
||||||
|
log = self.log_warning
|
||||||
|
else:
|
||||||
|
log = self.log_error
|
||||||
|
freshness = (f'last_success={last_success.isoformat()}, '
|
||||||
|
f'age={age.total_seconds() / 3600:.2f}h')
|
||||||
|
log(f"Couldn't load calendar: {reason}; {freshness}")
|
||||||
|
|
||||||
|
def _schedule_calendar_retry(self):
|
||||||
|
if self.calendar_retry_handle is not None:
|
||||||
|
return
|
||||||
|
if self.calendar_retry_attempt >= len(self.CALENDAR_RETRY_DELAYS):
|
||||||
|
return
|
||||||
|
delay = self.CALENDAR_RETRY_DELAYS[self.calendar_retry_attempt]
|
||||||
|
self.calendar_retry_attempt += 1
|
||||||
|
self.calendar_retry_handle = self.run_in(self.on_calendar_retry, delay)
|
||||||
|
self.log_info(f"Calendar refresh retry scheduled in {delay // 60} minutes")
|
||||||
|
|
||||||
|
def _cancel_calendar_retry(self):
|
||||||
|
if self.calendar_retry_handle is None:
|
||||||
|
return
|
||||||
|
if self.timer_running(self.calendar_retry_handle):
|
||||||
|
self.cancel_timer(self.calendar_retry_handle)
|
||||||
|
self.calendar_retry_handle = None
|
||||||
|
|
||||||
|
def on_calendar_retry(self, kwargs):
|
||||||
|
self.calendar_retry_handle = None
|
||||||
|
calendar_changed = self.refresh_cal()
|
||||||
|
self.update_workday_status()
|
||||||
|
if calendar_changed:
|
||||||
|
self.update_wakeup_time()
|
||||||
|
|
||||||
def are_cal_identical(self,calendar1,calendar2):
|
def are_cal_identical(self,calendar1,calendar2):
|
||||||
if len(calendar1) != len(calendar2): return False
|
if len(calendar1) != len(calendar2): return False
|
||||||
|
|
||||||
@@ -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):
|
||||||
|
with self.log_group("update_wakeup_time"):
|
||||||
self.register_callbacks(self.calculate_wakeup_time())
|
self.register_callbacks(self.calculate_wakeup_time())
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -217,3 +220,54 @@ guestroom_heatpump:
|
|||||||
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
|
||||||
+228
-39
@@ -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
|
||||||
|
no_signal_spam_sensor: binary_sensor.vertex2_no_signal_spam
|
||||||
unreachable_sensor: binary_sensor.vertex2_unreachable
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
sensor.vertex2_input_selected_icon: sensor.vertex2_input_selected.icon
|
||||||
retain_condition.vertex2_has_input_signal:
|
retain_condition.vertex2_has_input_signal:
|
||||||
retain_time: 10
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
versions = {}
|
||||||
|
while url:
|
||||||
|
with urlopen(url, timeout=10) as response:
|
||||||
payload = json.load(response)
|
payload = json.load(response)
|
||||||
return payload.get('info', {}).get('version')
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 +108,11 @@ 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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,5 +4,5 @@ light_ambiance:
|
|||||||
entity: light.desk_ambiance
|
entity: light.desk_ambiance
|
||||||
|
|
||||||
smart_conditions:
|
smart_conditions:
|
||||||
trigger_conditions: sensor.desk_last_motion < 3 and binary_sensor.is_dark_outside
|
trigger_conditions: binary_sensor.desk_occupancy and binary_sensor.is_dark_outside
|
||||||
blocking_conditions: binary_sensor.is_desk_sleeping
|
blocking_conditions: binary_sensor.is_desk_sleeping
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
light_aquarium:
|
||||||
|
module: smartswitch
|
||||||
|
class: SmartSwitch
|
||||||
|
entity: switch.aquarium_light
|
||||||
|
|
||||||
|
virtual_sensors:
|
||||||
|
sensors:
|
||||||
|
time_period.aquarium_light_schedule:
|
||||||
|
start: input_datetime.aquarium_light_start
|
||||||
|
end: input_datetime.aquarium_light_stop
|
||||||
|
|
||||||
|
smart_conditions: binary_sensor.aquarium_light_schedule
|
||||||
@@ -15,7 +15,7 @@ p1p_light:
|
|||||||
smart_conditions:
|
smart_conditions:
|
||||||
unavaibility_result: Disabled
|
unavaibility_result: Disabled
|
||||||
trigger_conditions: binary_sensor.garage_occupancy or binary_sensor.garage_p1p_timelapse or camera.garage_p1p_camera == 'streaming'
|
trigger_conditions: binary_sensor.garage_occupancy or binary_sensor.garage_p1p_timelapse or camera.garage_p1p_camera == 'streaming'
|
||||||
blocking_conditions: not binary_sensor.p1p_is_printing and not fan.garage_p1p_ventilateur_de_refroidissement and not fan.garage_p1p_ventilateur_de_la_chambre
|
blocking_conditions: not binary_sensor.p1p_is_printing and not sensor.garage_p1p_vitesse_du_ventilateur_de_refroidissement and not sensor.garage_p1p_vitesse_du_ventilateur_de_la_chambre
|
||||||
|
|
||||||
p1p_switch:
|
p1p_switch:
|
||||||
module: smartswitch
|
module: smartswitch
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ light_bookshelves:
|
|||||||
25: sensor.living_room_ambiance_light == 1
|
25: sensor.living_room_ambiance_light == 1
|
||||||
100: True
|
100: True
|
||||||
|
|
||||||
|
light_christmas_tree:
|
||||||
|
module: smartswitch
|
||||||
|
class: SmartSwitch
|
||||||
|
entity: switch.chrismas_tree
|
||||||
|
|
||||||
|
smart_conditions: sensor.living_room_ambiance_light >= 1
|
||||||
|
|
||||||
light_tree:
|
light_tree:
|
||||||
module: smartlight
|
module: smartlight
|
||||||
class: SmartLight
|
class: SmartLight
|
||||||
@@ -102,7 +109,7 @@ mezzanine_curtains:
|
|||||||
|
|
||||||
close_conditions:
|
close_conditions:
|
||||||
trigger_conditions: binary_sensor.projector_on or not binary_sensor.projector_off_for_a_while
|
trigger_conditions: binary_sensor.projector_on or not binary_sensor.projector_off_for_a_while
|
||||||
disable_conditions: self == 'closed' and (binary_sensor.is_dark_outside or binary_sensor.is_anybody_sleeping)
|
disable_conditions: binary_sensor.is_dark_outside or binary_sensor.is_anybody_sleeping
|
||||||
|
|
||||||
living_room_sleep_switch:
|
living_room_sleep_switch:
|
||||||
module: smartswitch
|
module: smartswitch
|
||||||
|
|||||||
+92
-48
@@ -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 = []
|
||||||
@@ -46,6 +49,13 @@ class SamsungTheFrame(SmartObject):
|
|||||||
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:
|
||||||
if state_evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
|
if state_evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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 on_press_enter_event(self, event_name, data, event_context):
|
||||||
|
self.press_enter()
|
||||||
|
|
||||||
|
@queued_operation
|
||||||
def press_enter(self):
|
def press_enter(self):
|
||||||
|
if self.entity.get_state() == 'on':
|
||||||
self.send_cmd("enter")
|
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()
|
||||||
should_retry = False
|
|
||||||
with telnetlib.Telnet(self.telnet_ip,self.telnet_port) as session:
|
|
||||||
if log_command: self.log_info(f'Sending command "{cmd}"')
|
|
||||||
try:
|
try:
|
||||||
session.write(bytes(cmd,'utf-8'))
|
if operation is None or self._command_stop.is_set():
|
||||||
output = session.read_until(b"#",timeout = 5).decode('ascii')
|
return
|
||||||
except (ConnectionResetError,EOFError):
|
method, args, kwargs = operation
|
||||||
self.log_warning(f"Connection Reset While sending command {cmd}. {try_count} try left")
|
try:
|
||||||
if try_count <= 0:
|
method(self, *args, **kwargs)
|
||||||
self.log_error(f"Could not send command {cmd}",dump_stack = True)
|
except Exception as error:
|
||||||
|
self.log_error(f"Projector operation {method.__name__} failed: {error}", dump_stack=True)
|
||||||
|
finally:
|
||||||
|
self._command_queue.task_done()
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
# Discard pending work and interrupt retry delays. An active exchange is
|
||||||
|
# bounded by the connection and read timeouts (2 + 5 seconds).
|
||||||
|
self._command_stop.set()
|
||||||
|
self._command_queue.put(None)
|
||||||
|
if self._command_worker.is_alive():
|
||||||
|
self._command_worker.join(timeout=8)
|
||||||
|
super().terminate()
|
||||||
|
|
||||||
|
def _send_cmd(self, cmd, log_command, try_count):
|
||||||
|
invalid_result = {"*Unsupported Item#", "*Block Item#", "*Illegal format#"}
|
||||||
|
for attempt in range(try_count + 1):
|
||||||
|
if self._command_stop.is_set():
|
||||||
return None
|
return None
|
||||||
else: should_retry = True
|
try:
|
||||||
|
with telnetlib.Telnet(self.telnet_ip, self.telnet_port, timeout=2) as session:
|
||||||
|
if log_command:
|
||||||
# opening session recursivly inside the with doesn't sound like a good idea
|
self.log_info(f'Sending command "{cmd}"')
|
||||||
if should_retry:
|
session.write(cmd.encode('utf-8'))
|
||||||
if try_count == 1:
|
output = session.read_until(b"#", timeout=5).decode('ascii').strip()
|
||||||
# I know calling sleep() is in callback is evil
|
if self._command_stop.is_set():
|
||||||
# but this is a very small sleep time and working with event would make the code much much more complexe
|
return None
|
||||||
time.sleep(1)
|
if log_command:
|
||||||
return self._send_cmd(cmd,log_command,try_count - 1)
|
self.log_info(f"Result = {output}")
|
||||||
|
|
||||||
if log_command: self.log_info(f"Result = {output}")
|
|
||||||
if output in invalid_result:
|
if output in invalid_result:
|
||||||
self.log_error(f"Command rejected. {cmd} returned {output}", dump_stack=True)
|
self.log_error(f"Command rejected. {cmd} returned {output}", dump_stack=True)
|
||||||
return None
|
return None
|
||||||
if not output:
|
# read_until may return a partial response when its timeout expires.
|
||||||
if try_count > 0:
|
if output.endswith("#"):
|
||||||
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 output.strip("*").rstrip("#")
|
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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
+764
-117
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
--- 4.5.10/plugin_management.py
|
||||||
|
+++ installed/plugin_management.py
|
||||||
|
@@ -490,7 +490,7 @@
|
||||||
|
ns = cfg.namespace
|
||||||
|
self.AD.state.update_namespace_state(ns, state)
|
||||||
|
finally:
|
||||||
|
- await self.refresh_update_time(plugin)
|
||||||
|
+ await self.refresh_update_time(plugin.name)
|
||||||
|
|
||||||
|
def required_meta_check(self):
|
||||||
|
OK = True
|
||||||
|
--- 4.5.10/utils.py
|
||||||
|
+++ installed/utils.py
|
||||||
|
@@ -398,6 +398,9 @@
|
||||||
|
def deep_compare(check: dict, data: dict) -> bool:
|
||||||
|
"""Compares 2 nested dictionaries of values"""
|
||||||
|
data = data or {} # Replaces a None value with an empty dict
|
||||||
|
+
|
||||||
|
+ if check.keys() != data.keys():
|
||||||
|
+ return False
|
||||||
|
|
||||||
|
for k, v in tuple(check.items()):
|
||||||
|
if isinstance(v, dict) and isinstance(data[k], dict):
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
{
|
||||||
|
"reference": "https://github.com/AppDaemon/appdaemon/tree/4.5.10",
|
||||||
|
"date": "2026-11-04",
|
||||||
|
"allowed_differences": [
|
||||||
|
"plugin_management.py",
|
||||||
|
"utils.py"
|
||||||
|
],
|
||||||
|
"sha256": {
|
||||||
|
"__init__.py": "0007f5d9141995768e07f5ba472a22ee19ad571677b1da7ab836d7a6ba8c9237",
|
||||||
|
"__main__.py": "2a5ba7b454eb8c306969f43968bd6d8068debed593a5db6ceb63d991a354f84f",
|
||||||
|
"adapi.py": "711d1d0542f746efa5872afb6cf0a2512bfb3f328fcac35ee38c610590e24545",
|
||||||
|
"adbase.py": "f884423fe0c275d9fb30242f6065250357a329dcd3ee0d7aed51941af0fd2c31",
|
||||||
|
"admin.py": "2991ddc31cd64abde2002ef0d987aaab57a8795b970a7457d2c9c37e01839e25",
|
||||||
|
"admin_loop.py": "25419606baaf3763009005c1d276c5115bc16f86e6042ae8a8426de7c64617ad",
|
||||||
|
"app_management.py": "75bec7c0998409d03957ff68e57142530faacccbce50ad8d1589c451638ad7f0",
|
||||||
|
"appdaemon.py": "75649168292a643c73ead23f6fb06be63b75f3cf9bc46c49de129ddb5491ad44",
|
||||||
|
"callbacks.py": "b664da26c08870dbfdc1a974f403edaaa4eb49d71e9d2b4ee3f6ce795db112f0",
|
||||||
|
"dashboard.py": "ac07dfb7528f485c747acfc14c1fd403e4b6ca9c2e79046df6b799db46c82c3d",
|
||||||
|
"dependency.py": "19be55c1d2781879b756f48c29e03b38fbb6b5fa7978c5d8d85962f4758d690d",
|
||||||
|
"dependency_manager.py": "845eed4f87a14eb711267eab05201f5bf2162f33d7f67a9169e58703da830936",
|
||||||
|
"entity.py": "76d15e924d8e0454c2bf5f1e8141b91df2e2b89a23f328d00ad2e1361645601b",
|
||||||
|
"events.py": "fff272c0236153d4163b0aa5d7504791812910bb4fb8a6f44c680f6e543aee26",
|
||||||
|
"exceptions.py": "12802e1a95bec30179946cf0df4539f6d263d52ba49da82efadfd6a1454fec5d",
|
||||||
|
"futures.py": "04613f65789706db264fb609bc89b9e7750c814ba714a87e83a7f3d867b68860",
|
||||||
|
"http.py": "cfc62804e3588151072b154fc66d1419b1d9a00b4546f457d06155b84355aebe",
|
||||||
|
"logging.py": "cae00ddbcdb7d713fa41821264b3eddc9c13366d68ce8b072225e53d7ea53c04",
|
||||||
|
"plugin_management.py": "89529671cfd1c5b5589e107f82c4b62fa5d48eff9b63ae7058c2d3770c5472c4",
|
||||||
|
"pyversions.py": "accaca278ca62c7d23bf19fb7386e15b8ab700afe69aa61155af8b3c3a80f4fc",
|
||||||
|
"scheduler.py": "d268d1f940b42c8dc8a210d1b9a471b193c3172d6728fd01df185bfd199ed0c5",
|
||||||
|
"sequences.py": "6bd113404a26735ec4a3c636cb29b60e3520ae4e8ae8d285b15de80d6df231d4",
|
||||||
|
"services.py": "3ef4dc986fa5c4f7ed579b4e1ca205da152d3f310b5fd574db6663829acfd4e9",
|
||||||
|
"state.py": "b8f0c7fc1a70d214493f88d2867518d56d48b5a98ee2b439d2529673d1ba0fca",
|
||||||
|
"thread_async.py": "1e99604cd9ec804b8d5b9f4a3bb927b2e4dffa1532bc928bd93fd6c1b96629c0",
|
||||||
|
"threads.py": "bcdea68ed11b42027427c8706c848be25df0d34a5d2b6b6a09a4ce4fe08fd401",
|
||||||
|
"utility_loop.py": "9723a23f622c97366294093b47bcba93218fd558c04326d7dddb30135a8a978f",
|
||||||
|
"utils.py": "43fcd79677bdf1cf06e47af9226f761e9b559a2db8d51c0fc7af732be5ebe9c4",
|
||||||
|
"version.py": "94c208125a4f88f014e7a18aa906215396966ade72afa3219a70420d79f33bbe",
|
||||||
|
"aui/index-prod.html": "193e86be7182e43a0c58b5f28ac8fea01f606653649d7966069a757ae532fe3c",
|
||||||
|
"models/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"plugins/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"stream/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"stream/adstream.py": "cd3a2a85130e126199125d26385954770fa604a52c3b5a470954e0f3c7cb339d",
|
||||||
|
"stream/socketio_handler.py": "f75c8409580393de7ba95fc527c60ee011686903162e8da244a7ae9a54b250db",
|
||||||
|
"stream/sockjs_handler.py": "58f67242277118fc11419f0a3991ffe9165909f085e5a9cf71a260f3cab4b9e2",
|
||||||
|
"stream/ws_handler.py": "ba6dda951c7d1fbab30e63913953b2ef909124d97393c404c21ee0d245d6131b",
|
||||||
|
"widgets/AdminLog.yaml": "36f2f3a85bd4103e26f6932875dbdd3162689934e200a9860b5a091bc5710ad6",
|
||||||
|
"widgets/AdminSummary.yaml": "bd525c531791ba7a397969919d47e7443828545252afe3f9a9e7bd666b1f2f3f",
|
||||||
|
"widgets/AdminTable.yaml": "1966c04efb764cf069a99e11e4ef4e7c0b8183e6b975339f7591fd5adc47b07e",
|
||||||
|
"widgets/alarm.yaml": "6f020ffd36fec1e9a6a7615cb8a51429002bd4e58a192f135bdc19c2d8df7f99",
|
||||||
|
"widgets/binary_sensor.yaml": "85cf49c6edffb3ac4854418a6814a65c082e19b1dff520eab3a43ae1c1ea44cf",
|
||||||
|
"widgets/camera.yaml": "ccdcd7f79cc824c4ac064cd36d1a3bfa785d6a12d264bb81341d2c56db0126f6",
|
||||||
|
"widgets/climate.yaml": "cfa3058bb5b5e6f9cd0bdad26612b7e270fc6764724d84662de3321f6a8a192f",
|
||||||
|
"widgets/clock.yaml": "0f86aa3c509e415bc79077375994b04f596a3cd4dabb90c6e6b528cab13db17d",
|
||||||
|
"widgets/cover.yaml": "4a1cd1580e935967b4903781adacb455b66f93180724a2550ee9e148ee6faf06",
|
||||||
|
"widgets/device_tracker.yaml": "d6c98b6e619bc4d9766213fb5c674413f107face43791d39a16c38a1ba3a5990",
|
||||||
|
"widgets/entitypicture.yaml": "9fae3c21e00aa9942228aca9b19530a1557e61a93a66ba3dfbf2808d8beccfa6",
|
||||||
|
"widgets/fan.yaml": "efa6ac5ab0280f2a2d4f978147fd6a80f5c3eb642da559ca9f9c1e9c4838f839",
|
||||||
|
"widgets/gauge.yaml": "833f595c5ba050cc34a8c9e14493b1b7b42879c95a14bd6a43c02319625126fc",
|
||||||
|
"widgets/group.yaml": "0afd0d4783ee54faa720c7b2e912b0bc2caefab985a89eecd3740717e327673c",
|
||||||
|
"widgets/heater.yaml": "490b95f8efe42cfd29038cbf8716e9fa6be4de25f1def6785efc28f98d860346",
|
||||||
|
"widgets/icon.yaml": "5e8f5b67219d7a8b9f36c2c20919120e3acc97f26d6b7c305a612ee39f87b16e",
|
||||||
|
"widgets/iframe.yaml": "77496c4300b8ab442743d5e58d09747f76875af7c42572c6cbe3e7b38b53449e",
|
||||||
|
"widgets/input_boolean.yaml": "7de2bd30dcc6e69270523fda0084e2e8393d873890f2f01bf563c1f3aad533a0",
|
||||||
|
"widgets/input_datetime.yaml": "7eca2be9e6ffa20b3b5176c62e809f3d7249f263f12e9d3a86a6db1609e465ca",
|
||||||
|
"widgets/input_number.yaml": "33f1f127fa69cc2e8f9f0e1eed2402ac22191a3d92d0f1fe8a43c281fe6aa30a",
|
||||||
|
"widgets/input_select.yaml": "a35aa8abb82ffeaefe83f27ea030b16bfeda9a14b233464b10d9af75667ba22a",
|
||||||
|
"widgets/input_slider.yaml": "fb1555d441f17aecd7ba3da1d2564a3487984aad1d637acc3ff402921c0dacc6",
|
||||||
|
"widgets/input_text.yaml": "3a9e428ff148de795adeea7ce0584dbc5f400fc3920cf811ddc24a3e0f4c2773",
|
||||||
|
"widgets/javascript.yaml": "952656bf90bd5c8adbd7b7330a6a6f3171e524caebb56fae89b1b1927069620e",
|
||||||
|
"widgets/label.yaml": "6d5694f2f01f3575d3b59154a3c651a6776ac751b518ce5a14cb1343f96ddfb2",
|
||||||
|
"widgets/light.yaml": "a49d98e912c36bd3d68f7d3689bcf8a1407ae8d5aab8dbd49c0d6478c20da904",
|
||||||
|
"widgets/lock.yaml": "e64f8fc90574d41bc7c04998759097633a5c08d6a15d8bd12342762ece8450af",
|
||||||
|
"widgets/london_underground.yaml": "ddea7cd7eafb2bc52ef2f75fd451490354dfbd247dab50bdb5212b6fa33e0a55",
|
||||||
|
"widgets/media_player.yaml": "8442b43d2767c3b35a72d829a781ddff6e64d87a5c8b0cae309a9be1c2516549",
|
||||||
|
"widgets/mode.yaml": "0f2ddcdbe445f4506f4b01a88bec3b2cc3447fcee167dce93333f8fee13d7a95",
|
||||||
|
"widgets/navigate.yaml": "fe07aca210cdf6f1432f2e2e2f793a8dd4046a83c31a1c234b0b17f9e51c6717",
|
||||||
|
"widgets/person.yaml": "892dcf64b8c43a63911479f20f7c8a98e2ed21b4c194bc9cbcf77c9087104fa7",
|
||||||
|
"widgets/pirateweather.yaml": "408b853117ef2e243a4e75a5752c4bb58100f09b370c2bcf15321f47d653c8d0",
|
||||||
|
"widgets/radial.yaml": "aca17401122a83cadc9788781e4a4040d10568d0dde90bdfe4edadd199a2c1fd",
|
||||||
|
"widgets/reload.yaml": "8408a90810c8d0cd064cd5ae675b8a67aea580ccd6b39d89d246d2f93a93d1c6",
|
||||||
|
"widgets/rss.yaml": "431fe8c22619da3962180e492893773296c8d8b3e4cd7e0b166b06b21831f714",
|
||||||
|
"widgets/scene.yaml": "0f61a077590c1de7b8c8a5ddde0e4084df612fdffa14ade7241e377ef644f040",
|
||||||
|
"widgets/script.yaml": "6d77a900434bcf94badaa03e8d975f5339afcbcd5759bb8f1206e9df88447bf6",
|
||||||
|
"widgets/sensor.yaml": "4b07cab76dd222528a96c24e6d88e0d474119f614dd8fa17c9d781c1f01949cd",
|
||||||
|
"widgets/sequence.yaml": "cf950e34f974f08f9eefc1a32a88570738e2b6b6e5e4a017046c12a536937666",
|
||||||
|
"widgets/switch.yaml": "2ec1863654c1c0f19703024e3990f5b30aeff9f18ddb9cbb1048cab447a38e58",
|
||||||
|
"widgets/temperature.yaml": "dc7a5536bf964be529b086e62d6b9b9f3cd65b1550cb0fe600e1b4b61b478740",
|
||||||
|
"widgets/text_sensor.yaml": "dfe72ca03524cca5db086d453b1b72f5b26c65b36ee0c3a4bff322fcfefb4a43",
|
||||||
|
"widgets/weather.yaml": "d16f02fb76545811a34fd5cc8ef03569c68f8982d32af58efabd13cf23ac56dd",
|
||||||
|
"widgets/weather_summary.yaml": "ec52da9a3a2178336768375814edfd7cbca03bb8bcfe6eefab0224c806f064b4",
|
||||||
|
"assets/aui/appdaemon.png": "252fa8d5415ac605181056e813caf902e18e13b376b3b4a8262edce19c0d5554",
|
||||||
|
"assets/aui/favicon.ico": "3d3f93c1cb1128905183270584d523168fbd35e937db712e9f6930e94a7a2cad",
|
||||||
|
"assets/aui/index.html": "e51891677050ef1aac795d4e65dc8b5189732aedfef129515bc322f4271fc6e0",
|
||||||
|
"assets/css/all.css": "462fc2981ff1e4df0af8902d356dd65726082c9a43d93b912419ce36568901eb",
|
||||||
|
"assets/css/climacons-font.css": "8619c9c4ca2ad2eee221a0b6df87338c249485f7c12862d74bedab732743f203",
|
||||||
|
"assets/css/fontawesome.min.css": "39ee099e0a46898ee5af78f65c8754a951fc72cce5b6d31596104bb6b3874dc9",
|
||||||
|
"assets/css/fonts.css": "16dd24ed9fb06a48a4db43684f27f2cceb5805c5435cec8bc383d876109b7bfb",
|
||||||
|
"assets/css/jquery-ui.css": "a7ac54f58ba507b13621ceb6fcf5fe879f5ac9bdcf049d16153110c6ad048c7c",
|
||||||
|
"assets/css/jquery.gridster.css": "d059374489ef40c069a8da9b798fb8a1bc7aa08f1f40b94d5bd4aafe2f352e6f",
|
||||||
|
"assets/css/main.css": "257719cff84654dc49b4968dd4964bb8ea44e8396dc175a2b4444ff442fed42c",
|
||||||
|
"assets/css/materialdesignicons.min.css": "b73dc5385b050b7e8f3383bf7ac8c38e808330b9c8092df8987f7d55a63e528d",
|
||||||
|
"assets/css/non_dashboard.css": "3eb54b421644bc1a9dfdf9c02a0d844e23bd99ada840d2a37a7d3be5a399f3ee",
|
||||||
|
"assets/css/rickshaw.min.css": "87ed7cdada83fcbf84b99cf585bf04567b041afed53326ecd4ecb6d88fede1bb",
|
||||||
|
"assets/css/v4-shims.min.css": "c939af78a3deaf2987bb2b6403888932b15fe072a7a4befe4a4e235f61f09e11",
|
||||||
|
"assets/fonts/TickingTimebombBB.ttf": "2944c6e24e20d7eebf3b75c213e399c68e2cfa004984713229f4df17be154f5e",
|
||||||
|
"assets/fonts/climacons-webfont.eot": "00a0ac962bb138510c6164685b44eaff2f75645a99ad5cfe650a92bec05ded29",
|
||||||
|
"assets/fonts/climacons-webfont.svg": "bef3ab62931cc54c7fe1815103ee27020e05b54338f6d5dc428df456935eb7c9",
|
||||||
|
"assets/fonts/climacons-webfont.ttf": "e59c14a9d1b33874af4c93d1faf78eb57a006558b713c8031ac2f010572705d4",
|
||||||
|
"assets/fonts/climacons-webfont.woff": "7d3cffc3e2dc69ec1b687e77e0f1e152b5eff1db1494b9b8e8e0fb03b0f5cb37",
|
||||||
|
"assets/fonts/digital-7-mono.eot": "3eb301951faa2552498717d73157698c0a6eb9b3a8bd9bcf7fc98d72a8004004",
|
||||||
|
"assets/fonts/digital-7-mono.ttf": "58a6983851a3e3c433bbad4d9ac97b01ec204ee81fb29a51222a9286a45598c2",
|
||||||
|
"assets/fonts/materialdesignicons-webfont.eot": "0b183104d2fc5f26196e773c77bdaf2e05509fd4259d2d15ecedca79b87e84f9",
|
||||||
|
"assets/fonts/materialdesignicons-webfont.ttf": "61e8aba5a4e981fe22cf7c8e8bcdbea00476e75c62c37f01bf7ee33361d68428",
|
||||||
|
"assets/fonts/materialdesignicons-webfont.woff": "a5928a0d5c2f624e46f98d9b15c2f60045377f7c594dd78a1759132ea3b463eb",
|
||||||
|
"assets/fonts/materialdesignicons-webfont.woff2": "662fefa8f2f8a95c18588d21774789c107c64e771cbe65a69af46291c4311afc",
|
||||||
|
"assets/fonts/repetition.ttf": "69a9f0671b9e86bf52519ebe54cc6d47bda1cabb8e28226834a7b241a1a32fe8",
|
||||||
|
"assets/images/Blank.gif": "4e0705327480ad2323cb03d9c450ffcae4a98bf3a5382fa0c7882145ed620e49",
|
||||||
|
"assets/images/android-chrome-192x192.png": "252fa8d5415ac605181056e813caf902e18e13b376b3b4a8262edce19c0d5554",
|
||||||
|
"assets/images/android-chrome-512x512.png": "9ba4aeffee60a5e784e9300d4135dfdbc6a9edfe5529db7ea7702ce2a58715bb",
|
||||||
|
"assets/images/apple-touch-icon.png": "3f632ff46def9612f365fbe4de8ec444ef159b1a0ab2e2c3adf42df7664e090e",
|
||||||
|
"assets/images/browserconfig.xml": "c348b0d6df3d900efef7ae4b4dfc8b62b1737b04d09d4584fef243211c266866",
|
||||||
|
"assets/images/favicon-16x16.png": "70ef6d0a8ff6d3c2290b390d06e3fcb37d0326c80e0366e22dff6158090cd7da",
|
||||||
|
"assets/images/favicon-32x32.png": "0cd943b8fe52fab2d231aff19ac10891e6a371eb7b9f4e5512d3eeccf567e220",
|
||||||
|
"assets/images/favicon.ico": "3d3f93c1cb1128905183270584d523168fbd35e937db712e9f6930e94a7a2cad",
|
||||||
|
"assets/images/manifest.json": "a471cc0ded86fb614911a6a9bed67c931f197c34fcb327709229aa0832848876",
|
||||||
|
"assets/images/mstile-144x144.png": "014f3854abf436024bafdbcf82cdd87467300ba58fb82a7f4788df1ad29d9f39",
|
||||||
|
"assets/images/mstile-150x150.png": "bc21b7beb73d3fcf66b7bbd4e36f7a4c96713735d855e4db4941b5b329560284",
|
||||||
|
"assets/images/mstile-310x150.png": "c5b2fdfe4fb8e5161bc8c917f0c3feaa2ac552a0863721a833a219f20af4556d",
|
||||||
|
"assets/images/mstile-310x310.png": "90c6cf90a99e0e2a041bf7c1ba7654004c425e3fd049e7147aac7c12ab41c697",
|
||||||
|
"assets/images/mstile-70x70.png": "de9d1301957625cf9f81e05bf421aec79ad02472077b2306933ad2cbb5aeeab8",
|
||||||
|
"assets/javascript/admin.js": "b7fa92e27a55ad4e0cdee94815300e0f122af0e9a4a843462fbc1eb91d99626c",
|
||||||
|
"assets/javascript/d3.layout.min.js": "8f25034da0ee3457e8a9a5fad9507576357698965791b7439bd553b7aa3aad18",
|
||||||
|
"assets/javascript/d3.min.js": "4ec5104fe7c61d5b87b25346af8f4b834b1f6164b1101514c692cde21dc0f550",
|
||||||
|
"assets/javascript/dashboard.js": "993e2d307ff994be25811ec44445297257f8ef36f0ea3e04e21e06240cb55b96",
|
||||||
|
"assets/javascript/gauge.min.js": "9f9f43e8b5ffc5d660cd47d802c89f8521c02d98be195bfb0ea0179d2b332ad6",
|
||||||
|
"assets/javascript/jquery-3.1.1.min.js": "119bad7b860ec25a136da766c4061dfdd7ff645c3f49980b3e01d98d2d924472",
|
||||||
|
"assets/javascript/jquery-ui.js": "e0e1c1f83cae13611b8ab09cc5c6dcb73465071831501e48aff2c3d9d1f1b795",
|
||||||
|
"assets/javascript/jquery.gridster.js": "8042c3522b3bfad7f5a545fc2b469a9f8288c94cb99aade199812c0245a19429",
|
||||||
|
"assets/javascript/justgage.js": "b3ea81d552a1a2ce2561c555f2122c679268158a46dc70b8249d47fc45944dcb",
|
||||||
|
"assets/javascript/knockout-3.4.1.js": "3d7f7359568809409e92573359a4bd0cb05bf6e18dee90a4093d0acb3d5e951a",
|
||||||
|
"assets/javascript/list.min.js": "fe1e2e056d0070d6ae1c3de0fb3423c53f6334d8cfd3c33d96a3f012032f02d2",
|
||||||
|
"assets/javascript/plotly.min.js": "37bdb72e5e71e4c1e5e4709589fabe8eae5b203000d659c30af867260e62d8d8",
|
||||||
|
"assets/javascript/raphael-2.1.4.min.js": "181ac9fcdb0f5fdf94e248bcf2d4fcc082125b1c0afbc75ddfcbe850112e20d1",
|
||||||
|
"assets/javascript/reconnecting-websocket.min.js": "03827095c0efa8ee095e9bc4b6f598d511fc24010cbb95b6d703fc1945cb50db",
|
||||||
|
"assets/javascript/rickshaw.min.js": "59ac7a8608c17bd1141a1a7f668fffed8ba8864a2ad109e1231c6d8a9b71c83a",
|
||||||
|
"assets/javascript/socket.io.js": "9c104866f002c8679b0b000cc8af1d1cc270fee443acf3ed425c299809d75e31",
|
||||||
|
"assets/javascript/sockjs.min.js": "cf870a4f7c85f9a7c89fc7855c973e9d1a46b707263440a5bdffa2920b19711a",
|
||||||
|
"assets/javascript/stream.js": "76cd8fa3e7c12451672425485ef89ff3f66e1b1a3b24e9525b5e6143d67b87ab",
|
||||||
|
"assets/templates/admin.jinja2": "d1b0ce0a9659db62aa3f743290c46cc08f9f195b3875cfa219b83fb3daf78da6",
|
||||||
|
"assets/templates/body_include.jinja2": "43bea5403dcec932ad43c235317fe3f8af6294c0175d80a2f80a6b2c626b8f3b",
|
||||||
|
"assets/templates/dashboard.jinja2": "c1a3f2cecdcaa291c0560ca35215640a44cd9ccd03d81459cf5f00d29f71d1fc",
|
||||||
|
"assets/templates/dashinit.jinja2": "9e3cee77b52a8754fa442334f1edc9caddbb1b438bce461fffbeaf3b4ab01c00",
|
||||||
|
"assets/templates/error.jinja2": "be9d8dc22aeb9eb6d4e684aaefb5e206163587484e5cf2563a237df9dc504738",
|
||||||
|
"assets/templates/head_include.jinja2": "a2d438f6c51e1fae2c598eb6ebeb3f01b560b5ab34392fa16780bcec673b150e",
|
||||||
|
"assets/templates/list.jinja2": "3649fd6eb7e5818f5a1a70c8e4f4d315678104dc3932476cbdb815f0bf4c297c",
|
||||||
|
"assets/templates/logon.jinja2": "67f2ea7a4b9c89304091c46c1df189503a59211f5a691eda789f8de571327abe",
|
||||||
|
"assets/webfonts/fa-brands-400.eot": "5d68cb5fc50d0d366f0a4999778a2905303a5b1f53bb1e9ff679c6537d56156c",
|
||||||
|
"assets/webfonts/fa-brands-400.svg": "6b9a88c4426c61a0aa2d2a6be434bdf5b5df9bba493896a2ff19aed4e5b61d4b",
|
||||||
|
"assets/webfonts/fa-brands-400.ttf": "3b8b359a8de499509dff8e39478fe04fbf3ebe244254b108e66816f048d0ecf8",
|
||||||
|
"assets/webfonts/fa-brands-400.woff": "f21ddd7e9eb9033d530a62766fd05269b5f0c2b1c2d005b872decd00c71ea5ab",
|
||||||
|
"assets/webfonts/fa-brands-400.woff2": "8386356e1d512f570aac1b4883b928fe4bfd649c1d54a4d0f618357359cd3b74",
|
||||||
|
"assets/webfonts/fa-regular-400.eot": "44165afb8b1984ee93ab86f358ca0c00cdf95f57731e85c5edb6a0bb8ec673d7",
|
||||||
|
"assets/webfonts/fa-regular-400.svg": "aa106eda9929cea6c5fe38eae44daf75f957f3ceba120dd45d4ce5a358434bab",
|
||||||
|
"assets/webfonts/fa-regular-400.ttf": "25a854973a8a66612f9f80c4600b906d82dde6530c2a080342fc4e8bbf8b894b",
|
||||||
|
"assets/webfonts/fa-regular-400.woff": "28a52249e8a966d0ebc168bc983c138f30f1c1ab83926d342a05da36f61f2f2f",
|
||||||
|
"assets/webfonts/fa-regular-400.woff2": "4d64f142cf22d40343bbf54ee0e0ec48b7b2bc654b9be78517d5634207286524",
|
||||||
|
"assets/webfonts/fa-solid-900.eot": "d338e920e4771513976e2ba92d260e4b61522b3ba21486232dddf63ef02a7bd7",
|
||||||
|
"assets/webfonts/fa-solid-900.svg": "dd2440abba3f5060a9da4390e954df253762bbec62a1fd6c3fccad2cc060374d",
|
||||||
|
"assets/webfonts/fa-solid-900.ttf": "4892669c1d80a9e837be9edea4db711e42260eccc6933e16ee449bcaaafd7e78",
|
||||||
|
"assets/webfonts/fa-solid-900.woff": "b7e697ebc01ea8d96bcda256f6fe634afaf5b8f1bf98522c3d7263382ba5bfc7",
|
||||||
|
"assets/webfonts/fa-solid-900.woff2": "a5587a9dca3673b604a8a0e144d268f3dcb180aac337e2b2e163704bc1fc508a",
|
||||||
|
"assets/aui/css/app.css": "96ad563f162727c8f444e00033a754cd96a82d3480110e6916dd4e3952709ac5",
|
||||||
|
"assets/aui/css/chunk-vendors.css": "ebb321a0638f4d962d3e28c9a904fd12ee13a4b007b562fd1db952405a8f498d",
|
||||||
|
"assets/aui/js/app.js": "3e4ee235a31ace6caa8933a59d3ee893eae3bb095990f5a94620e71be4d0898b",
|
||||||
|
"assets/aui/js/app.js.map": "e4cbc2c757f2f887b4b6b699d26ff0942f75d00da370c930c687ff1f0c1ad5f3",
|
||||||
|
"assets/aui/js/chunk-vendors.js": "353ad1516dd3789b994b4afd36217129c9cbc77455c6117b3a378b4398aff14a",
|
||||||
|
"assets/aui/js/chunk-vendors.js.map": "90de6e8efce85cc2714f4d4f3b42bd7bea69a76dd9c24f4dac58121a2d6fa827",
|
||||||
|
"assets/css/default/dashboard.css": "1c91e4afc8bd102e12891f2bb1bae5acb1754bc1c88806d9129616d7b52ce770",
|
||||||
|
"assets/css/default/variables.yaml": "f509281ce4456d3e1f0aa72e751f3506fed29b68e00eebc82e5b08a28d10e2e2",
|
||||||
|
"assets/css/glassic/dashboard.css": "6cc29bfbb702571431e833bc37b8d66d94bc57be7ceecb6d36989076f12c5093",
|
||||||
|
"assets/css/glassic/variables.yaml": "9e67612ad0da0c207a58290781da106bd27801fa181a26a4cc656fecf0a88df2",
|
||||||
|
"assets/css/images/ui-icons_444444_256x240.png": "eaf7c7ee2747275dda6c53e731a10db1a7b15f4fbb46e1b69d6bb2056bc9fd81",
|
||||||
|
"assets/css/images/ui-icons_555555_256x240.png": "5d04051dfd9d2d740355404f98a683d1ec0feb2fca7d76e533c1a6e5ce92dd2e",
|
||||||
|
"assets/css/images/ui-icons_777620_256x240.png": "9dbe4a0d03feed6f65eb2560a0a8b4ba4264545ee8fd31c1763a3b219d0328d6",
|
||||||
|
"assets/css/images/ui-icons_777777_256x240.png": "e75b27211e16fcf94715168001bb7055ca376d46f928110ba3d0825232452e5a",
|
||||||
|
"assets/css/images/ui-icons_cc0000_256x240.png": "02891575d4358e9edde3e42d94057f8e9f82a9d64376f71ee86cef149d30537e",
|
||||||
|
"assets/css/images/ui-icons_ffffff_256x240.png": "b6b06deef2b924cc3835d63f4883d47882734a33e71b212d917a68cede3b8e9d",
|
||||||
|
"assets/css/obsidian/dashboard.css": "67f3cda7f1c7efab50357dad051c2c6e7c28d53d6ac91e55ca23e2230b5e8e2b",
|
||||||
|
"assets/css/obsidian/variables.yaml": "ae315b1199ee65a7f37aa85edaf5ab6e9c3b728de483c32da1430eeafbbbcfad",
|
||||||
|
"assets/css/simplyred/dashboard.css": "6d685d5c836b54edd552247a10019a0931f0b4ad78bbdb245afb6f3851fec127",
|
||||||
|
"assets/css/simplyred/variables.yaml": "cca292500a3dbaf20dfe69c02aa269feaf13408bfabe46196dc731622313c8cc",
|
||||||
|
"assets/css/zen/dashboard.css": "be0e085329c6def90175322acf2e0a7ae5750a0255ff60c5a3b4aedd93feba2f",
|
||||||
|
"assets/css/zen/variables.yaml": "034998cc51f6a29fc4ee4af9334be79001d8fe8ba1f1fb693206d6a7e8c706c3",
|
||||||
|
"assets/css/glassic/img/carbon1.jpg": "44adafb6b35b20588e272c5cd7397388e884172e887152b7363d481d14e214dc",
|
||||||
|
"assets/css/glassic/img/carbon2.jpg": "926471d8e113c031715a3d14fd69c603e0c8d7d247af15bd5c02727bbd134896",
|
||||||
|
"assets/css/glassic/img/carbon3.jpg": "4251d187f88f55d8536d0505ddfc28fb4f323ed27088db9f27b436c31dd2da3e",
|
||||||
|
"assets/css/glassic/img/carbon4.jpg": "c8a0df27c3d991c8b8807a1b8016cf57eac770d957385158daa9efb5865ddc09",
|
||||||
|
"assets/css/glassic/img/glassic_bg.jpg": "3e1c5e037b3ebad3115f75e6ad07991b5b4c5857b2b6292583deaf90332307d8",
|
||||||
|
"assets/css/obsidian/img/obsidian_bg.jpg": "b8e6314886c56128beb728abc7b7b23c3ea7bb99fda982ae4dcc76d688b8a207",
|
||||||
|
"assets/css/obsidian/img/obsidian_w_bg.jpg": "d480a4635ff86ebdc794bfbdf62e5bc0a48b3b3e4c28c82441cff84b0edc288c",
|
||||||
|
"assets/css/obsidian/img/obsidianbg.jpg": "b8e6314886c56128beb728abc7b7b23c3ea7bb99fda982ae4dcc76d688b8a207",
|
||||||
|
"assets/css/obsidian/img/widgetbg.jpg": "d480a4635ff86ebdc794bfbdf62e5bc0a48b3b3e4c28c82441cff84b0edc288c",
|
||||||
|
"assets/css/simplyred/img/goldgradient.png": "79bcabeb9e10fcb3eae0411feef3d28aee06f7f5836e8172724c2def7ab6e640",
|
||||||
|
"assets/css/simplyred/img/goldtexture.jpg": "80310ad4546ebdc45e17575bdc25c6c0775af68eb63b00ba88ea2077261ba3c0",
|
||||||
|
"assets/css/simplyred/js/timer.js": "aa441f5479b7d76c72eb679ad1b6186760db4e307ccdd3e276f7aabbea94c2bc",
|
||||||
|
"assets/css/zen/img/zen_bg.jpg": "6111faf3252c15a350511bc88a4ebaa1f0b9b18bc82e43f53ff9c0e94bc58609",
|
||||||
|
"assets/css/zen/img/zen_bg2.jpg": "a724c70447a0471384b11048ac2ed52cb84cfd099890db6542a395e9622982a2",
|
||||||
|
"assets/css/zen/img/zen_weatherbg.jpg": "83fde892f0fda16d788afd46ed94ffd62be6701c66aa3943c3cbbf972c48aa6a",
|
||||||
|
"models/config/__init__.py": "19d3ad9c40382cf38ac6dde5ab88bbf3f3db5645edb5bdb1ff008ba8439fced9",
|
||||||
|
"models/config/api.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"models/config/app.py": "4d41c603190784d70d62614e3e5a25d922e2bd1c2f9a3ec2d0288e35e36ca05a",
|
||||||
|
"models/config/appdaemon.py": "320c19ea8276537581e61efadbfe64a510bc00823e3d396b81e24db7b3dd467d",
|
||||||
|
"models/config/common.py": "273b27bb751ac4d364cd5ace5b55b521142639b4a1515d75b784d81a6e8ca773",
|
||||||
|
"models/config/dashboard.py": "f1fc4927af62c7efb4bb348e4691a145ee7b50bd4afafff7cd9ca7ead96ae890",
|
||||||
|
"models/config/http.py": "a73a8429a232057a928cf8785d9376882a6c3a8ed4e04bb220fd009bb4ab512d",
|
||||||
|
"models/config/log.py": "7c043fc01ef5c755ff0d53b83ef27449523e22320addf068de12d4dfd059feaf",
|
||||||
|
"models/config/misc.py": "9e97bf8937677cfd9f74ed865e8bb834902e57aa13d8afa776ffd2bcda2be04c",
|
||||||
|
"models/config/plugin.py": "0d8076c34eae84e87f17c1cc741f9cdd38d660054d5b621f3e1446c2095b4151",
|
||||||
|
"models/config/sequence.py": "b98e3f14e0e971d52c2f3093c5b193b33f162d50e53cec3aa6f9f2d10a38dd1a",
|
||||||
|
"models/config/yaml.py": "b733c6b180108eaf1394d500505d240d6015042c15034310800afb2ab3fe8917",
|
||||||
|
"models/internal/__init__.py": "025052dfa21c98592410f3cbf77250c92618bfc9ea3f218caf530da6a92d544e",
|
||||||
|
"models/internal/app_management.py": "943b29ead0e04578463e1a4169dd99f7d07a926b1e25b7cec2a6ab53ae554da6",
|
||||||
|
"models/internal/file_check.py": "cc75fe5956a990da8675e117223615094ec1d6a34c769fe49d9a4edb5801b5b4",
|
||||||
|
"models/internal/plugin.py": "c5bb9956ed86e298524846450c5d1750746294ff18a92bd22672c347bd064510",
|
||||||
|
"models/internal/state.py": "152f40cdbed02ad2e7c430ed42086b35f3a9414b6392940c7c2127c2d7fff2f2",
|
||||||
|
"models/internal/threading.py": "38941ea25c9dcf2900c4c6395d1401015eb35de67fbf8999cdf21f4e144deb3b",
|
||||||
|
"models/notification/__init__.py": "1432554b95229c2a338bb0ece10ea141068df573bbbfa43adde2e3776257aea8",
|
||||||
|
"models/notification/android.py": "62d118cfe4c6fa50d685a0b160ab03301badc469019fb9c9d36ce733dbe4dd2d",
|
||||||
|
"models/notification/base.py": "8d05d671e2e893d78dad4f0c053f3d232861ac6e141195e2a60937fdbd80c2f8",
|
||||||
|
"models/notification/iOS.py": "61c756b984a0a26b28a771df220e368b8414f6efa5bb2733ff91b975f0bb3c91",
|
||||||
|
"plugins/dummy/dummyapi.py": "a92cf5e6cdc4b671e5fe040d75a077b9a2adc728f8d474cb55e8bbd66689340c",
|
||||||
|
"plugins/dummy/dummyplugin.py": "f448378c5aa59e7a82d1f205874c1cd347981f23d68daf822c3f9243aed8846b",
|
||||||
|
"plugins/hass/__init__.py": "7552f2a227213504a064605ce7e280c4bec244b218dd4dfad565b3f059233d13",
|
||||||
|
"plugins/hass/exceptions.py": "ec057567ac3fe16c57452cf3e5ce3992f4535e3eea40442db7c4ecfd251d8a16",
|
||||||
|
"plugins/hass/hassapi.py": "3bbca695ca78e055828f1b96d887d8ae017d12af499055cebf456e3fe73d34e3",
|
||||||
|
"plugins/hass/hassplugin.py": "0aab3aaa8e0f879904270152aac3c361dd86c6a494ed479bccfcdd8f74ad10fe",
|
||||||
|
"plugins/hass/models.py": "889178c31d3dc612f1344812a9d1248147e2c1ce45b967fa304911a82e277607",
|
||||||
|
"plugins/hass/notifications.py": "efd661d918294eb03213bac8d8fa674a006294dfe54b46ce4352368006ac2dfa",
|
||||||
|
"plugins/hass/utils.py": "c6f1d1fa0d53b1c2d858e50c4a0180cfdef9d1d2220fa30020a8840bd1b781f0",
|
||||||
|
"plugins/mqtt/__init__.py": "df5ae958839fdce576295b8668be07c5f97aac8081aefb292694ad3b562ae3a2",
|
||||||
|
"plugins/mqtt/mqttapi.py": "dda16cda56235564ce8af70334d403ce2a0f0059fda9439d9297d6f704a676b2",
|
||||||
|
"plugins/mqtt/mqttplugin.py": "da6216827adfb449d033137a30333028e4c00f4864d67d9951a4bf13d46cdf02",
|
||||||
|
"widgets/basealarm/basealarm.css": "2a1e61c42ebd49c915c539b922c16b3eb4ad3cf6eddda1776e344a04845b4d74",
|
||||||
|
"widgets/basealarm/basealarm.html": "7b9d436f4f05c7bb731b32df485ef2b127e5da6f3750aa037c745687d45816d9",
|
||||||
|
"widgets/basealarm/basealarm.js": "ebff1ed1de3a4db2eab28ce81509cd279a061450ebe5a8a92edbf361b36d3305",
|
||||||
|
"widgets/basecamera/basecamera.css": "06746a98f7098e2194084285d204c4ca21e1ea314a5b0b2cb7986004c9c9c34a",
|
||||||
|
"widgets/basecamera/basecamera.html": "d14808f4d3a9bf8785fced9992269ee82841421bc5f9b4df73990206136cb6f4",
|
||||||
|
"widgets/basecamera/basecamera.js": "6f81b1ea8faaefafcf30aba159308a844d1040bd730f4796c5cc7ce980a990e8",
|
||||||
|
"widgets/baseclimate/baseclimate.css": "c8fc911e355e83cd0d9e8ae9dabb36414cd71812d1cfcfdca5f4709ce1b1f559",
|
||||||
|
"widgets/baseclimate/baseclimate.html": "1e4083b2b92d8f141fa7c5a6bf3960305ffda8f77a2e7c97d9c17ae5b6489d5c",
|
||||||
|
"widgets/baseclimate/baseclimate.js": "5186e853da9e58b4882d82cc9b5356a60f31dc799a786f5ccba41ff2e5eed671",
|
||||||
|
"widgets/baseclock/baseclock.css": "f81cbca78857941b31923b21f68d8bc0d7a03011ac7d88862463b7b79c59dcb8",
|
||||||
|
"widgets/baseclock/baseclock.html": "9f988da94522d7440101ec045f2779fd946c2ce3b30a02239f67e2d8e8cb4dbb",
|
||||||
|
"widgets/baseclock/baseclock.js": "a7fbc2c815da0f17b02bb39430e88b07fa1ac5861cd4305f660b071a426d2a73",
|
||||||
|
"widgets/basedatetime/basedatetime.css": "b411d3f21af285092826029b5147cfcb6f4b046dee9162392922c9094fc254f7",
|
||||||
|
"widgets/basedatetime/basedatetime.html": "fba7f69c1446852c83af18102e938ec3b520ebb1f9672f125fd369d46c3bb337",
|
||||||
|
"widgets/basedatetime/basedatetime.js": "093ca34c8a464b7c1cad15bccfc2e4f564bd7e8335f941d559d8435c46e2a61f",
|
||||||
|
"widgets/basedisplay/basedisplay.css": "4ac1bdc04804b9fd76c68b807bc486555f437d0657d0ea5f57578d88be02dbc8",
|
||||||
|
"widgets/basedisplay/basedisplay.html": "2446e4bffdf9bbd946b8c8ab33f453848e1e92eed6ecdec283702ae55929b9cf",
|
||||||
|
"widgets/basedisplay/basedisplay.js": "990ef146432d26cc03e1a6ab8b25e88aba6f8820125cabcb84a5cec72e5d5cc8",
|
||||||
|
"widgets/baseentitypicture/baseentitypicture.css": "7930f8168dab7133a5831f34478f046f6649e9ca102edea29e0d3746c7250110",
|
||||||
|
"widgets/baseentitypicture/baseentitypicture.html": "ebac4368b209ebe733abca60d3d2da00d2cd515f82b79f16023247b73005d46c",
|
||||||
|
"widgets/baseentitypicture/baseentitypicture.js": "85981dbcc600e697547e78d76d06d886c379e461144b76e0b89ff941b121ba6a",
|
||||||
|
"widgets/baseerror/baseerror.css": "f997c5a1914c0bbebbcc12ba417b49cf857271d95df2e5cb4a7b080bbcbb0fc3",
|
||||||
|
"widgets/baseerror/baseerror.html": "549c6cfbeed80a60fc2309aa2c5ffde1ffc800b69027349a3e9403f1b29ed797",
|
||||||
|
"widgets/baseerror/baseerror.js": "ab33ee3b901f12bacf2aa13e0ab5d0ff6a975d3ffb40f3eaabf8c6e7e168d30f",
|
||||||
|
"widgets/basefan/basefan.css": "2e08ac65108cea6fa8651d0f7a0e2204e7214535c3aa85015e3a0625614da1cb",
|
||||||
|
"widgets/basefan/basefan.html": "aa0e34d7f9bcaac0f77616dda9640f452b1aea702d09aecf8e14ad657d15c2a1",
|
||||||
|
"widgets/basefan/basefan.js": "79184e6ca836325d4e2d704d45538ef90746ea701ffa7c153d99590ffb30dcfd",
|
||||||
|
"widgets/basegauge/basegauge.css": "ae2fdb3e66df2c586d187079bd83bca52bbda617003452c91ebd7e70c582ccbe",
|
||||||
|
"widgets/basegauge/basegauge.html": "6fd7ae8f200b6fff8699ef92912d5ba9688ec4c5e074c12883ab0d069c8a1a3b",
|
||||||
|
"widgets/basegauge/basegauge.js": "77d7e17d4f7e0fb138f17a52b90e5c08caf7e6ee58e944dffc2d62faccb666b0",
|
||||||
|
"widgets/baseheater/baseheater.css": "66e042528095583f6661fdf576b42e699f5f28e679281c67313d09feb8d745ca",
|
||||||
|
"widgets/baseheater/baseheater.html": "a64b2c49bc680b378402e55b2744a19387bf18705005fb7646c7b5937fb2387b",
|
||||||
|
"widgets/baseheater/baseheater.js": "0d2397338225cec9fa62bbb8c0f2c1b43ef004dba262af921b5ef11e58fbdfdb",
|
||||||
|
"widgets/baseicon/baseicon.css": "f4fcb35555a1eb66b88feed361bb4ede17c8dbe9fe0c2bde22d53d7afd26b2ec",
|
||||||
|
"widgets/baseicon/baseicon.html": "f6a2895ed41ea25df2aebb96c1215d7851e7bd13521cfd0f97cc28533274daca",
|
||||||
|
"widgets/baseicon/baseicon.js": "8f8c342717ee7bbac51e0659d1ee19e22892a033b2b65270d10a77749d88f779",
|
||||||
|
"widgets/baseiframe/baseiframe.css": "16580cf89459eb3b1e7660068d3c430bf5350b849507c15d1955fa150f625849",
|
||||||
|
"widgets/baseiframe/baseiframe.html": "18046647c99af56e6fde5b1b0aec0455beda89d08963f1636fba159ad92645f6",
|
||||||
|
"widgets/baseiframe/baseiframe.js": "74c5429a066ea1b15183ebd54fbb05c753dbd0d82a2c14fcd2fc9764a4986efb",
|
||||||
|
"widgets/baseinputnumber/baseinputnumber.css": "d55f605297675ba6404a966b16a9c01177a5e1be031e6e7497f2c42e9e5c8227",
|
||||||
|
"widgets/baseinputnumber/baseinputnumber.html": "38456d5bc91bba7ed6ccb9b92852c78013b92f21131e2ce1ed7ebb468ad59ba4",
|
||||||
|
"widgets/baseinputnumber/baseinputnumber.js": "7259915d555b87dc584176d824fed2d8c1d595ea762bf19ad34c6cbbb475cba9",
|
||||||
|
"widgets/basejavascript/basejavascript.css": "d17d4d54287f06de9369ac95bcaeac8de4cdefcf42f554a0cef3ded4eb97f58e",
|
||||||
|
"widgets/basejavascript/basejavascript.html": "b7f1d44e7e3fdc89ad5f3792c9ebae0155c6dde63e861e93fd392a2dc2222e0b",
|
||||||
|
"widgets/basejavascript/basejavascript.js": "99622aec3748b20970e0a83ae85e6c6b1407ef624bfae277baa2fa3974326a70",
|
||||||
|
"widgets/baselight/baselight.css": "a1b954ef193460cfd969292fac6d4f556c3e53867914730c88be01d0430f5d96",
|
||||||
|
"widgets/baselight/baselight.html": "a1baccd66519491c388d63bc0846e4ae35d6319affdbf364564bbc4e3b3f946f",
|
||||||
|
"widgets/baselight/baselight.js": "45e3ef822d398bbbb6aee90d298e0a4130473b3cb7302861ad0f1a4f137ac20f",
|
||||||
|
"widgets/basemedia/basemedia.css": "ec65b602cf58cdc1f01eb8c948356030307d789059dd1479673dc47f5cbcc665",
|
||||||
|
"widgets/basemedia/basemedia.html": "e61e2076fc34c1af20348c91e479b95c240ae024100a8ab13b261ffebd58e889",
|
||||||
|
"widgets/basemedia/basemedia.js": "c185da4d1debcfa72ef7876654aebb68a848bbb7ebb9d76ff5be863cfc087861",
|
||||||
|
"widgets/baseradial/baseradial.css": "c1e4406adf0147bc23858297c5c574a71da5c81e6e16fef84674f2d4ba373e0a",
|
||||||
|
"widgets/baseradial/baseradial.html": "5f7d96e1ba751be912657ef6d07660960d9bb3547b8041875944a63b04c4b1f3",
|
||||||
|
"widgets/baseradial/baseradial.js": "b2c30ec3c4cb72b8d7014abf22cab4bf58698c61a94b174428f712544800484f",
|
||||||
|
"widgets/baserss/baserss.css": "a7769b2363980bd935dc7975e37f1427311827803517cffd26969be0c06a1a87",
|
||||||
|
"widgets/baserss/baserss.html": "7e93dd7c6d7ade8fee6a56bd259de2fc66f591c7cbff20fafc9923f1fb31fa86",
|
||||||
|
"widgets/baserss/baserss.js": "adf3c55c6c8d7dc5ca5b777cb868bf102e33bb1d8683edcc695f0102f1d1bca0",
|
||||||
|
"widgets/baseselect/baseselect.css": "ef67b8844652931f6629a90cef7dd65a78b30dd78a86c169fa41eeced419705d",
|
||||||
|
"widgets/baseselect/baseselect.html": "67abc1bf525e59279f3b6ffad5ce3c35675d40446e30f341dfd68e5dc7a59420",
|
||||||
|
"widgets/baseselect/baseselect.js": "105ca03ee639477e56f906532d22cb652a6b0eef7448f34f8ed49dbe2ebdfaeb",
|
||||||
|
"widgets/baseslider/baseslider.css": "55b64db166666b3983fe600293882195ae911c3aa2ec2934180d6357bb30f4d0",
|
||||||
|
"widgets/baseslider/baseslider.html": "a463c6178c22f578bc211abba6df0c60eaf955166fb6d61c5d4efbdc989a8e53",
|
||||||
|
"widgets/baseslider/baseslider.js": "5126a3f32728f5b35bb5553e2c692054341bdbb8394953a38b4041dc1cb9975b",
|
||||||
|
"widgets/baseswitch/baseswitch.css": "b355c6bbc52db5cd923b53a9268f9fbf6157a5432c032a4b94369502b296cb75",
|
||||||
|
"widgets/baseswitch/baseswitch.html": "00cc2c9fc2dfa2299225e06c6f50bb13545b1f9237081862af6d51fc96160648",
|
||||||
|
"widgets/baseswitch/baseswitch.js": "cad8aa10d86683e7274b6da074be15a148697d95f3b15cd1ee2ccc3919cf8b35",
|
||||||
|
"widgets/basetemperature/basetemperature.css": "c59246676b8d6b0758ff67e008a18bdbc6d4debb8c363ddd8bf941b3fb0489a8",
|
||||||
|
"widgets/basetemperature/basetemperature.html": "5f7d96e1ba751be912657ef6d07660960d9bb3547b8041875944a63b04c4b1f3",
|
||||||
|
"widgets/basetemperature/basetemperature.js": "1b8db4c0b483fe06878a3ee147fa97bdccdfd58a7876bf7073663ad8cf4c97a5",
|
||||||
|
"widgets/basetext/basetext.css": "46c8bf64f98c4d34fc0baba4569a02b93ba5d1121654f007e7fa64c6a34b7356",
|
||||||
|
"widgets/basetext/basetext.html": "daf7d6c4de3097131704122a701fb789e59b56a314b3f8626fc3714020972ec9",
|
||||||
|
"widgets/basetext/basetext.js": "36a276e65536823bfe8f0099c48817a738ca6fd6557ecb1a75ec7fa45aed4d77",
|
||||||
|
"widgets/baseweather/baseweather.css": "7dd09c754a150d31513e35d4919dccdb62df276a14df254f7170fba78cc41b6d",
|
||||||
|
"widgets/baseweather/baseweather.html": "90b4efd2283938e5cc64817e3ebf736f9d7f4e7a74fe9d91b2d9f4925bab8eb9",
|
||||||
|
"widgets/baseweather/baseweather.js": "67acac0fa64944442aea0c9f776d67c1d4f49974f6d855068d8545142f76bea1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Modifications locales AppDaemon — état au 4 novembre 2026
|
||||||
|
|
||||||
|
Toute l’instrumentation des utility loops a été retirée sur disque : chronométrages,
|
||||||
|
profils de piles, décorateurs et messages de diagnostic. Le module ajouté
|
||||||
|
`_utility_diagnostics.py` et son cache ont été supprimés.
|
||||||
|
|
||||||
|
## Seuls correctifs conservés dans le core
|
||||||
|
|
||||||
|
- `plugin_management.py` : `refresh_update_time(plugin.name)`, décrit dans
|
||||||
|
[APPDAEMON_BANDWIDTH_BUG_FIX.md](../APPDAEMON_BANDWIDTH_BUG_FIX.md).
|
||||||
|
- `utils.py` : comparaison des ensembles de clés dans `deep_compare`, décrite dans
|
||||||
|
[APPDAEMON_RELOAD_BUG_FIX.md](../APPDAEMON_RELOAD_BUG_FIX.md).
|
||||||
|
|
||||||
|
Comparaison de **309 fichiers** au tag officiel
|
||||||
|
[4.5.10](https://github.com/AppDaemon/appdaemon/tree/4.5.10) : tous sont identiques
|
||||||
|
à l’octet près, sauf ces deux modifications exactes. Aucun fichier source supplémentaire.
|
||||||
|
Les caches Python générés sont exclus de cette comparaison.
|
||||||
|
Le fichier statique `aui/index-prod.html`, absent du paquet installé mais présent
|
||||||
|
au tag, a également été remis depuis ce tag pour respecter cette référence.
|
||||||
|
|
||||||
|
- [Diff intégral restant](appdaemon-core-4.5.10.diff)
|
||||||
|
- [Empreintes SHA-256 des fichiers vérifiés](appdaemon-core-audit.json)
|
||||||
|
|
||||||
|
## Optimisation applicative conservée
|
||||||
|
|
||||||
|
`apps/ad_toolbox/logger_interface.py` conserve le raccourci évitant l’inspection
|
||||||
|
inutile des piles pour les messages ordinaires. `adapi.py` est identique au tag.
|
||||||
|
Les sept tests `test_logger_interface_fast_path.py` passent après restauration.
|
||||||
|
Les onze vérifications de `deep_compare` du document du correctif passent aussi.
|
||||||
|
|
||||||
|
## Archive et retour arrière
|
||||||
|
|
||||||
|
Les anciens scripts, tests spécifiques au diagnostic, sauvegardes et documents
|
||||||
|
historiques sont conservés dans
|
||||||
|
`tools/archive/appdaemon-diagnostics-retired-20261104.tar.gz`.
|
||||||
|
Ils ne sont plus installés ni collectés par la suite de tests.
|
||||||
|
Les deux fichiers `.bak` du paquet ont été déplacés dans cette archive
|
||||||
|
(sous `site-packages-backups/`). Les anciens chemins de sauvegarde décrits dans
|
||||||
|
les documents historiques ne sont donc plus actifs.
|
||||||
|
|
||||||
|
**Un redémarrage AppDaemon reste nécessaire** pour décharger le code instrumenté
|
||||||
|
déjà en mémoire. Le service n’a pas été redémarré durant cette intervention.
|
||||||
|
Les réglages applicatifs et les exclusions de dossiers existantes n’ont pas été modifiés.
|
||||||
|
|
||||||
|
## Réapplication après rollback — 8 novembre 2026
|
||||||
|
|
||||||
|
Version vérifiée : **4.5.10**, avec `/usr/local/bin/python3` et le paquet
|
||||||
|
`/usr/local/lib/python3.12/site-packages/appdaemon`. PID 1 :
|
||||||
|
`python3 -m appdaemon -c /conf`.
|
||||||
|
|
||||||
|
Les trois changements absents ont été réappliqués :
|
||||||
|
|
||||||
|
- `plugin_management.py` : `refresh_update_time(plugin.name)`.
|
||||||
|
- `utils.py` : comparaison des clés au début de `deep_compare`.
|
||||||
|
- `/usr/src/app/dockerStart.sh` : un seul appel à
|
||||||
|
`sh "$CONF/archiveLogs.sh"`, avant le lancement AppDaemon.
|
||||||
|
|
||||||
|
Sauvegardes originales et manifeste (chemins absolus, version, SHA-256) :
|
||||||
|
`/conf/tools/backups/appdaemon-4.5.10-20261108T094103312482Z/manifest.json`.
|
||||||
|
Le lien `/conf/dockerStart.sh` pointe toujours vers le script ci-dessus.
|
||||||
|
Le raccourci applicatif de `LoggerInterface` était déjà présent et a été conservé.
|
||||||
|
Aucun changement aux apps, YAML, exclusions ou à `archiveLogs.sh`.
|
||||||
|
|
||||||
|
Validations exécutées : 11 cas `deep_compare` réussis, syntaxe des deux fichiers
|
||||||
|
Python valide, correctif HASS vérifié, syntaxe shell des deux scripts valide,
|
||||||
|
un seul appel d'archivage placé avant l'exec. Les 7 tests
|
||||||
|
`test_logger_interface_fast_path.py` passent, sans tests ignorés.
|
||||||
|
Sur 5 tests `test_logger_interface_grouping.py`, 2 passent et 3 échouent
|
||||||
|
sur les libellés/groupes (`test_log_group_takes_priority_over_condition_name`,
|
||||||
|
`test_smart_object_groups_its_own_lifecycle`,
|
||||||
|
`test_smartcondition_uses_smartobject_api_scope_without_logger_interface_argument`).
|
||||||
|
Le code applicatif concerné n'a pas été modifié pendant cette intervention.
|
||||||
|
|
||||||
|
**Redémarrage non effectué** : le service est PID 1 du conteneur, sans CLI Docker,
|
||||||
|
socket Docker ou superviseur disponible. Redémarrer le conteneur existant depuis
|
||||||
|
son gestionnaire habituel pour charger les correctifs Python et exécuter
|
||||||
|
l'archivage. Une recréation depuis l'image d'origine effacerait ces patchs.
|
||||||
|
Ne pas lancer le script de démarrage ou d'archivage dans le service actif.
|
||||||
|
Après redémarrage, restent à vérifier : maintien des patchs, démarrage et connexion
|
||||||
|
HASS, archivage et suivi mémoire unique, puis absence de rafraîchissements HASS
|
||||||
|
répétés après le délai configuré. Aucun résultat de validation du service
|
||||||
|
redémarré n'est affirmé ici. L'audit intégral et le diff historiques ci-dessus
|
||||||
|
n'ont pas été régénérés pour cette intervention.
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# Instructions après mise à jour ou rollback AppDaemon
|
||||||
|
|
||||||
|
Préparé le 8 novembre 2026 pour cette installation `/conf`.
|
||||||
|
À suivre par Codex après installation de **4.5.14**, ou retour en **4.5.10**.
|
||||||
|
Ce document prépare l'intervention : sa rédaction n'a appliqué aucun patch ni redémarré le service.
|
||||||
|
|
||||||
|
## 1. Identifier l'installation avant toute modification
|
||||||
|
|
||||||
|
Lire ce document et `docs/appdaemon-local-changes.md`. Conserver les modifications
|
||||||
|
applicatives existantes, les configurations YAML et les exclusions de dossiers.
|
||||||
|
Ne pas restaurer les anciennes instrumentations des utility loops : elles ont été
|
||||||
|
retirées volontairement et archivées dans `tools/archive/`.
|
||||||
|
|
||||||
|
Exécuter dans le conteneur, avec le Python réellement utilisé par AppDaemon :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 - <<'PY'
|
||||||
|
import inspect, sys
|
||||||
|
from importlib.metadata import version
|
||||||
|
import appdaemon
|
||||||
|
from appdaemon.utils import deep_compare
|
||||||
|
print('Python :', sys.executable)
|
||||||
|
print('Version :', version('appdaemon'))
|
||||||
|
print('Paquet :', appdaemon.__file__)
|
||||||
|
print('deep_compare :', inspect.getsourcefile(deep_compare))
|
||||||
|
print(inspect.getsource(deep_compare))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Si le processus utilise un autre environnement, employer son interpréteur pour
|
||||||
|
les inspections, modifications et tests. Ne pas déduire la version du seul tag
|
||||||
|
Docker. Vérifier aussi l'Entrypoint/Cmd du conteneur et le script effectivement
|
||||||
|
exécuté ; une image personnalisée peut différer de l'image officielle.
|
||||||
|
Pour une autre version que 4.5.10 ou 4.5.14, refaire la comparaison avec ses sources.
|
||||||
|
|
||||||
|
Avant chaque édition, sauvegarder le fichier original dans un dossier daté sous
|
||||||
|
`/conf/tools/backups/appdaemon-<version>-<date>/`, en notant son chemin absolu,
|
||||||
|
sa version et son empreinte SHA-256. Ne pas écraser une sauvegarde précédente.
|
||||||
|
N'appliquer chaque changement qu'une fois ; si déjà présent, le vérifier.
|
||||||
|
Ne jamais remplacer un fichier 4.5.14 par une sauvegarde 4.5.10.
|
||||||
|
|
||||||
|
## 2. Tableau des changements
|
||||||
|
|
||||||
|
| Élément | 4.5.10 (rollback) | 4.5.14 |
|
||||||
|
|---|---|---|
|
||||||
|
| Timestamp de rafraîchissement HASS | Appliquer le patch `plugin.name` | Déjà intégré ; vérifier seulement |
|
||||||
|
| Comparaison des clés dans `deep_compare` | Appliquer dans `utils.py` | Appliquer dans `utils/misc.py` |
|
||||||
|
| Optimisation des logs ordinaires | Conserver le raccourci de `LoggerInterface` | Optimisation intégrée au core ; conserver le wrapper existant si compatible |
|
||||||
|
| Archivage au démarrage Docker | Ajouter l'appel dans `dockerStart.sh` | Ajouter l'appel dans `/start.sh` pour l'image officielle |
|
||||||
|
|
||||||
|
## 3. Patch HASS : 4.5.10 seulement
|
||||||
|
|
||||||
|
Dans le paquet installé, fichier `plugin_management.py`, méthode
|
||||||
|
`update_plugin_state()`, remplacer uniquement cet appel dans le `finally` :
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- await self.refresh_update_time(plugin)
|
||||||
|
+ await self.refresh_update_time(plugin.name)
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela évite de redemander l'état complet de Home Assistant chaque seconde après
|
||||||
|
expiration du délai de rafraîchissement (10 minutes par défaut).
|
||||||
|
Le correctif est intégré depuis 4.5.12 et confirmé dans le tag 4.5.14.
|
||||||
|
En 4.5.14, contrôler la ligne, sans ajouter un second patch.
|
||||||
|
|
||||||
|
Référence locale : `APPDAEMON_BANDWIDTH_BUG_FIX.md`.
|
||||||
|
|
||||||
|
## 4. Patch deep_compare : les deux versions
|
||||||
|
|
||||||
|
Localiser le fichier avec `inspect.getsourcefile(deep_compare)` :
|
||||||
|
|
||||||
|
- 4.5.10 : `appdaemon/utils.py`.
|
||||||
|
- 4.5.14 : `appdaemon/utils/misc.py` (le module a été réorganisé).
|
||||||
|
|
||||||
|
Dans `deep_compare()`, ajouter immédiatement après `data = data or {}` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
if check.keys() != data.keys():
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
Conserver tout le reste de la fonction propre à la version installée.
|
||||||
|
En particulier, garder `current = data.get(k)` en 4.5.14.
|
||||||
|
Le début de fonction doit donc ressembler à ceci :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def deep_compare(check: dict, data: dict) -> bool:
|
||||||
|
"""Compares 2 nested dictionaries of values"""
|
||||||
|
data = data or {}
|
||||||
|
|
||||||
|
if check.keys() != data.keys():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Suite existante, différente selon la version : ne pas la remplacer.
|
||||||
|
```
|
||||||
|
|
||||||
|
La 4.5.14 évite le KeyError à l'ajout d'une clé, mais ne détecte toujours pas
|
||||||
|
certaines suppressions : la fonction officielle renvoie `True` pour
|
||||||
|
`deep_compare({}, {'input_selection': {}})`. Le patch corrige aussi les
|
||||||
|
suppressions imbriquées et distingue une clé absente d'une valeur `None`.
|
||||||
|
|
||||||
|
Vérifier dans un nouveau processus Python après modification :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 - <<'PY'
|
||||||
|
from appdaemon.utils import deep_compare
|
||||||
|
assert deep_compare({'input_selection': {}}, {}) is False
|
||||||
|
assert deep_compare({}, {'input_selection': {}}) is False
|
||||||
|
assert deep_compare({'x': {'a': 1}}, {'x': {}}) is False
|
||||||
|
assert deep_compare({'x': {}}, {'x': {'a': 1}}) is False
|
||||||
|
assert deep_compare({'x': None}, {}) is False
|
||||||
|
assert deep_compare({'x': [1, 2]}, {'x': [1, 2]}) is True
|
||||||
|
assert deep_compare({'x': [1, 2]}, {'x': [2, 1]}) is False
|
||||||
|
assert deep_compare({'x': {'a': 1}}, {'x': {'a': 1}}) is True
|
||||||
|
assert deep_compare({'x': {}}, {'x': None}) is False
|
||||||
|
assert deep_compare({}, None) is True
|
||||||
|
assert deep_compare({'input_selection': {}}, None) is False
|
||||||
|
print('OK : 11 vérifications deep_compare')
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Référence locale : `APPDAEMON_RELOAD_BUG_FIX.md`. Ses anciens chemins de
|
||||||
|
sauvegarde `.bak` ne sont plus actifs ; utiliser la sauvegarde de cette intervention.
|
||||||
|
|
||||||
|
## 5. LoggerInterface : conserver les fonctionnalités locales
|
||||||
|
|
||||||
|
Fichier persistant : `apps/ad_toolbox/logger_interface.py`, méthode `_write_log()`.
|
||||||
|
|
||||||
|
En **4.5.10**, conserver le raccourci existant : pour un message texte sans
|
||||||
|
`__module__`, `__line__` ou `__function__`, et avec les méthodes standard ADAPI,
|
||||||
|
appeler `api._log()` sans passer par l'inspection de pile. Garder le repli vers
|
||||||
|
`api.log()` pour les substitutions et les méthodes personnalisées. Préserver les
|
||||||
|
options d'encodage, le routage des logs et les groupes de messages.
|
||||||
|
Ne pas ajouter un autre patch dans `adapi.py` : il était volontairement revenu
|
||||||
|
au code officiel 4.5.10.
|
||||||
|
|
||||||
|
En **4.5.14**, `_sub_stack()` contient déjà le raccourci évitant `inspect.stack()`
|
||||||
|
sans marqueurs. Aucun patch core de logging n'est nécessaire. Conserver le
|
||||||
|
wrapper applicatif et ses fonctionnalités ; la suppression du raccourci local
|
||||||
|
redondant est facultative, pas un prérequis de migration. Tester sa compatibilité.
|
||||||
|
|
||||||
|
Tests existants :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m unittest discover -s /conf/apps/ad_toolbox/unit_tests -p 'test_logger_interface_fast_path.py' -v
|
||||||
|
python3 -m unittest discover -s /conf/apps/ad_toolbox/unit_tests -p 'test_logger_interface_grouping.py' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adaptation de test nécessaire en 4.5.14 :**
|
||||||
|
`test_direct_log_is_not_modified` attend actuellement `stack.assert_called_once()`
|
||||||
|
pour `api.log('Direct log')`. Cette attente correspond à 4.5.10. Pour 4.5.14,
|
||||||
|
attendre `stack.assert_not_called()`. Si le test doit couvrir les deux versions,
|
||||||
|
choisir explicitement l'attente selon la version installée, en conservant
|
||||||
|
l'assertion d'identité de `_sub_stack`. Ne pas modifier le core pour satisfaire
|
||||||
|
l'ancienne attente. Vérifier que les tests d'intégration ne sont pas ignorés
|
||||||
|
faute d'import AppDaemon.
|
||||||
|
|
||||||
|
## 6. Patch dockerStart / start.sh : les deux versions
|
||||||
|
|
||||||
|
### Comportement local à préserver
|
||||||
|
|
||||||
|
Avant le lancement d'AppDaemon, appeler une seule fois :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh "$CONF/archiveLogs.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce script persistant archive les anciens `*.log*` et `memory_usage.csv` dans
|
||||||
|
`/conf/logs/archive/<horodatage>/`, puis lance le suivi mémoire en arrière-plan.
|
||||||
|
Conserver le contenu actuel de `/conf/archiveLogs.sh`.
|
||||||
|
Ne pas lancer le script manuellement pendant qu'AppDaemon écrit ses logs pour
|
||||||
|
le « tester » : il déplacerait les fichiers ouverts. Ne pas lancer le script
|
||||||
|
de démarrage complet dans le conteneur déjà actif : cela lancerait un second AppDaemon.
|
||||||
|
|
||||||
|
### 4.5.10
|
||||||
|
|
||||||
|
Le script observé est `/usr/src/app/dockerStart.sh`.
|
||||||
|
`/conf/dockerStart.sh` est un **lien symbolique** vers ce fichier, pas une copie
|
||||||
|
persistante du patch. Après recréation du conteneur, la cible peut être remplacée.
|
||||||
|
Ajouter l'appel après les installations de dépendances, juste avant le lancement :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh "$CONF/archiveLogs.sh"
|
||||||
|
|
||||||
|
# Lets run it!
|
||||||
|
exec python3 -m appdaemon -c $CONF "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5.14 : changement de point d'entrée confirmé dans le tag officiel
|
||||||
|
|
||||||
|
Le Dockerfile copie `scripts/start.sh` vers **`/start.sh`** et définit
|
||||||
|
`ENTRYPOINT ["/start.sh"]`. Il n'utilise plus `dockerStart.sh`.
|
||||||
|
Dans cette image, patcher `/start.sh`, pas l'ancien lien `/conf/dockerStart.sh`.
|
||||||
|
Ajouter le même appel après l'installation des dépendances et avant l'exec :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh "$CONF/archiveLogs.sh"
|
||||||
|
|
||||||
|
# Lets run it!
|
||||||
|
exec uv run --frozen appdaemon -c $CONF "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
Conserver le lancement `uv`, les installations via `uv pip`, le `PYTHONPATH`
|
||||||
|
et le répertoire de configuration exemple `/opt/conf` propres à cette version.
|
||||||
|
Ne pas recopier l'ancien script 4.5.10 par-dessus `/start.sh`.
|
||||||
|
Si l'image déployée possède un autre point d'entrée, transposer cet appel dans
|
||||||
|
le script réellement utilisé et noter le chemin dans le compte rendu.
|
||||||
|
L'ancien lien peut rester inutilisé ; ne pas le prendre comme preuve du patch.
|
||||||
|
|
||||||
|
### Vérification sans exécution
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh -n /conf/archiveLogs.sh
|
||||||
|
# Choisir le script réellement utilisé :
|
||||||
|
sh -n /usr/src/app/dockerStart.sh # 4.5.10
|
||||||
|
# OU
|
||||||
|
sh -n /start.sh # 4.5.14 officielle
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspecter le script : exactement un appel actif à `archiveLogs.sh`, avant l'exec.
|
||||||
|
Ces modifications hors `/conf` sont perdues lors d'une recréation du conteneur,
|
||||||
|
comme les patchs Python. Les réappliquer à chaque nouvelle image, ou les intégrer
|
||||||
|
explicitement à une image dérivée si cela fait partie de l'intervention demandée.
|
||||||
|
|
||||||
|
## 7. Redémarrage et validation finale
|
||||||
|
|
||||||
|
Après application et vérifications, redémarrer le conteneur selon le mécanisme
|
||||||
|
habituel de cette installation, dans le périmètre de l'intervention demandée.
|
||||||
|
Un simple rechargement d'app ne remplace pas les fonctions core déjà importées
|
||||||
|
et ne réexécute pas le script de démarrage.
|
||||||
|
|
||||||
|
Vérifier après redémarrage :
|
||||||
|
|
||||||
|
1. Version réellement lancée et absence d'erreurs de démarrage/import.
|
||||||
|
2. Patch `deep_compare` toujours présent et 11 vérifications réussies dans le
|
||||||
|
même environnement que le service (une installation de dépendances au boot
|
||||||
|
pourrait avoir remplacé le paquet).
|
||||||
|
3. Message `[archiveLogs]`, archive créée si des anciens logs existaient,
|
||||||
|
nouveau `memory_usage.csv`, un seul processus `archiveLogs.sh --watch-memory`.
|
||||||
|
Vérifier l'ajout de mesures à l'intervalle configuré (300 secondes par défaut).
|
||||||
|
Avec `uv`, le PID 1 peut être uv : si les colonnes RSS AppDaemon sont vides,
|
||||||
|
vérifier le processus réel ; les métriques du conteneur restent distinctes.
|
||||||
|
4. Connexion Home Assistant et absence de rafraîchissements complets chaque
|
||||||
|
seconde après le délai de refresh configuré (10 minutes par défaut).
|
||||||
|
5. Tests du logger réussis avec l'attente adaptée à la version.
|
||||||
|
6. Si un essai de reload réel est nécessaire, utiliser une app de test isolée
|
||||||
|
sans action domotique ; vérifier ajout/suppression d'une clé imbriquée et
|
||||||
|
nettoyer l'essai. Ne pas modifier chauffage/volets pour ce test.
|
||||||
|
|
||||||
|
Mettre à jour `docs/appdaemon-local-changes.md` avec la version, les fichiers
|
||||||
|
réellement modifiés, les sauvegardes, résultats et statut du redémarrage.
|
||||||
|
Séparer les vérifications exécutées de celles restant à observer ; ne pas
|
||||||
|
annoncer une validation du service sur la seule base des tests unitaires.
|
||||||
|
|
||||||
|
## 8. Rollback vers 4.5.10
|
||||||
|
|
||||||
|
Revenir à l'image 4.5.10 prévue par le déploiement, puis reprendre les sections
|
||||||
|
1 à 7 pour **4.5.10** : patch HASS + clés `deep_compare` + raccourci applicatif
|
||||||
|
logger + appel d'archivage dans le vrai `dockerStart.sh`.
|
||||||
|
Les fichiers `/conf` et le sous-module `apps/ad_toolbox` doivent être conservés.
|
||||||
|
Si le test du logger a été rendu compatible avec les deux versions, le conserver.
|
||||||
|
Ne pas restaurer de fichiers core 4.5.14 dans 4.5.10, ni les anciennes sondes de
|
||||||
|
diagnostic archivées. Vérifier à nouveau après le redémarrage du rollback.
|
||||||
|
|
||||||
|
## Sources de référence
|
||||||
|
|
||||||
|
- Inventaire : [appdaemon-local-changes.md](appdaemon-local-changes.md).
|
||||||
|
- Diff core local 4.5.10 : [appdaemon-core-4.5.10.diff](appdaemon-core-4.5.10.diff).
|
||||||
|
- [4.5.14 : plugin_management.py](https://github.com/AppDaemon/appdaemon/blob/4.5.14/appdaemon/plugin_management.py).
|
||||||
|
- [4.5.14 : utils/misc.py](https://github.com/AppDaemon/appdaemon/blob/4.5.14/appdaemon/utils/misc.py).
|
||||||
|
- [4.5.14 : adapi.py](https://github.com/AppDaemon/appdaemon/blob/4.5.14/appdaemon/adapi.py).
|
||||||
|
- [4.5.14 : Dockerfile](https://github.com/AppDaemon/appdaemon/blob/4.5.14/Dockerfile).
|
||||||
|
- [4.5.14 : scripts/start.sh](https://github.com/AppDaemon/appdaemon/blob/4.5.14/scripts/start.sh).
|
||||||
|
- [4.5.10 : dockerStart.sh](https://github.com/AppDaemon/appdaemon/blob/4.5.10/dockerStart.sh).
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Diagnostic utility loop — retiré
|
||||||
|
|
||||||
|
Toute l’instrumentation ajoutée a été retirée le 4 novembre 2026.
|
||||||
|
Voir [l’état actuel et la vérification du core](appdaemon-local-changes.md).
|
||||||
|
|
||||||
|
L’ancien document, les scripts et les sauvegardes sont dans
|
||||||
|
`tools/archive/appdaemon-diagnostics-retired-20261104.tar.gz`.
|
||||||
|
Ne plus utiliser les anciennes commandes install/upgrade/restore : ces scripts
|
||||||
|
ne sont plus actifs. Un redémarrage AppDaemon est nécessaire pour décharger
|
||||||
|
l’instrumentation restée en mémoire.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from contextlib import nullcontext
|
||||||
|
import datetime
|
||||||
|
import pickle
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
sys.path[:0] = [str(Path(__file__).resolve().parents[1] / 'apps'),
|
||||||
|
str(Path(__file__).resolve().parents[1] / 'apps/ad_toolbox')]
|
||||||
|
|
||||||
|
from alarmclock import AlarmClock
|
||||||
|
|
||||||
|
|
||||||
|
class Harness(AlarmClock):
|
||||||
|
def __init__(self):
|
||||||
|
self.calendar = ['previous calendar']
|
||||||
|
self.calendar_retry_handle = None
|
||||||
|
self.calendar_retry_attempt = 0
|
||||||
|
self.dataset = {}
|
||||||
|
self.load_cal_from_ics = Mock(return_value=False)
|
||||||
|
self.run_in = Mock(side_effect=lambda callback, delay: f'timer-{delay}')
|
||||||
|
self.timer_running = Mock(return_value=True)
|
||||||
|
self.cancel_timer = Mock()
|
||||||
|
self.log_info = Mock()
|
||||||
|
self.log_warning = Mock()
|
||||||
|
self.log_error = Mock()
|
||||||
|
self.log_group = Mock(side_effect=lambda name: nullcontext())
|
||||||
|
self.update_workday_status = Mock()
|
||||||
|
self.update_wakeup_time = Mock()
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarRefreshTests(unittest.TestCase):
|
||||||
|
def test_last_success_survives_dataset_save_and_restore(self):
|
||||||
|
app = Harness()
|
||||||
|
app.calendar = []
|
||||||
|
app.load_cal_from_ics.return_value = True
|
||||||
|
app.refresh_cal()
|
||||||
|
last_success = app.calendar_last_success
|
||||||
|
self.assertIsNotNone(last_success)
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
dataset_path = Path(folder) / 'alarm_clock.dataset'
|
||||||
|
app.get_dataset_name = Mock(return_value=str(dataset_path))
|
||||||
|
app.terminate()
|
||||||
|
restored = Harness()
|
||||||
|
with dataset_path.open('rb') as stream:
|
||||||
|
restored.dataset = pickle.load(stream)
|
||||||
|
self.assertEqual(restored.calendar_last_success, last_success)
|
||||||
|
restored._log_calendar_failure('request timed out')
|
||||||
|
restored.log_info.assert_called_once()
|
||||||
|
restored.log_warning.assert_not_called()
|
||||||
|
restored.log_error.assert_not_called()
|
||||||
|
|
||||||
|
def test_dataset_without_timestamp_has_no_known_success(self):
|
||||||
|
app = Harness()
|
||||||
|
app.dataset = {'other_data': 42}
|
||||||
|
self.assertIsNone(app.calendar_last_success)
|
||||||
|
app._log_calendar_failure('request timed out')
|
||||||
|
app.log_error.assert_called_once()
|
||||||
|
self.assertEqual(app.dataset, {'other_data': 42})
|
||||||
|
|
||||||
|
def test_failure_preserves_calendar_and_schedules_two_retries(self):
|
||||||
|
app = Harness()
|
||||||
|
|
||||||
|
self.assertFalse(app.refresh_cal())
|
||||||
|
self.assertEqual(app.calendar, ['previous calendar'])
|
||||||
|
app.run_in.assert_called_once_with(app.on_calendar_retry, 120)
|
||||||
|
|
||||||
|
app.on_calendar_retry({})
|
||||||
|
self.assertEqual(app.run_in.call_args_list[-1].args, (app.on_calendar_retry, 300))
|
||||||
|
|
||||||
|
app.on_calendar_retry({})
|
||||||
|
self.assertEqual(app.run_in.call_count, 2)
|
||||||
|
|
||||||
|
def test_failure_level_depends_on_last_success(self):
|
||||||
|
now = datetime.datetime(2026, 11, 4, 12, tzinfo=datetime.timezone.utc)
|
||||||
|
for hours, level in ((0, 'info'), (23.999, 'info'), (24, 'warning'),
|
||||||
|
(47.999, 'warning'), (48, 'error'), (72, 'error'),
|
||||||
|
(None, 'error')):
|
||||||
|
with self.subTest(hours=hours):
|
||||||
|
app = Harness()
|
||||||
|
last_success = None if hours is None else now - datetime.timedelta(hours=hours)
|
||||||
|
app.calendar_last_success = last_success
|
||||||
|
with patch('alarmclock.datetime.datetime') as clock:
|
||||||
|
clock.now.return_value = now
|
||||||
|
app._log_calendar_failure('request timed out')
|
||||||
|
getattr(app, f'log_{level}').assert_called_once()
|
||||||
|
for other in {'info', 'warning', 'error'} - {level}:
|
||||||
|
getattr(app, f'log_{other}').assert_not_called()
|
||||||
|
self.assertEqual(app.calendar_last_success, last_success)
|
||||||
|
message = getattr(app, f'log_{level}').call_args.args[0]
|
||||||
|
self.assertIn('request timed out', message)
|
||||||
|
if last_success:
|
||||||
|
self.assertIn(last_success.isoformat(), message)
|
||||||
|
|
||||||
|
def test_success_updates_timestamp_even_when_calendar_is_unchanged(self):
|
||||||
|
app = Harness()
|
||||||
|
app.calendar = []
|
||||||
|
app.calendar_last_success = datetime.datetime(2026, 11, 1, tzinfo=datetime.timezone.utc)
|
||||||
|
app.calendar_retry_attempt = 2
|
||||||
|
app.calendar_retry_handle = 'pending-timer'
|
||||||
|
app.load_cal_from_ics.return_value = True
|
||||||
|
now = datetime.datetime(2026, 11, 4, tzinfo=datetime.timezone.utc)
|
||||||
|
with patch('alarmclock.datetime.datetime') as clock:
|
||||||
|
clock.now.return_value = now
|
||||||
|
self.assertFalse(app.refresh_cal())
|
||||||
|
app.cancel_timer.assert_called_once_with('pending-timer')
|
||||||
|
self.assertEqual(app.calendar_last_success, now)
|
||||||
|
self.assertEqual(app.calendar_retry_attempt, 0)
|
||||||
|
|
||||||
|
@patch('alarmclock.get')
|
||||||
|
def test_timeout_uses_freshness_level_and_preserves_calendar(self, get):
|
||||||
|
import requests
|
||||||
|
app = Harness()
|
||||||
|
app.calendar_last_success = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
last_success = app.calendar_last_success
|
||||||
|
app.load_cal_from_ics = lambda calendar: AlarmClock.load_cal_from_ics(app, calendar)
|
||||||
|
get.side_effect = requests.exceptions.ReadTimeout()
|
||||||
|
self.assertFalse(app.refresh_cal())
|
||||||
|
app.log_warning.assert_not_called()
|
||||||
|
app.log_error.assert_not_called()
|
||||||
|
self.assertTrue(any('ReadTimeout' in call.args[0] for call in app.log_info.call_args_list))
|
||||||
|
self.assertEqual(app.calendar_last_success, last_success)
|
||||||
|
self.assertEqual(app.calendar, ['previous calendar'])
|
||||||
|
app.run_in.assert_called_once_with(app.on_calendar_retry, 120)
|
||||||
|
|
||||||
|
@patch('alarmclock.icalendar.Calendar.from_ical')
|
||||||
|
@patch('alarmclock.get')
|
||||||
|
def test_http_request_uses_short_timeouts_and_validates_status(self, get, from_ical):
|
||||||
|
response = Mock(content=b'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n')
|
||||||
|
get.return_value = response
|
||||||
|
from_ical.return_value.walk.return_value = []
|
||||||
|
app = Harness()
|
||||||
|
|
||||||
|
self.assertTrue(AlarmClock.load_cal_from_ics(app, []))
|
||||||
|
|
||||||
|
self.assertEqual(get.call_args.kwargs['timeout'], (3, 5))
|
||||||
|
response.raise_for_status.assert_called_once_with()
|
||||||
|
from_ical.assert_called_once_with(response.content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user