Compare commits
54
Commits
bcfaa83f10
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -4,3 +4,5 @@ __pycache__/
|
||||
*.pyc
|
||||
logs/
|
||||
apps/data/
|
||||
secrets.yaml
|
||||
.codex/
|
||||
@@ -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
|
||||
exclude_dirs:
|
||||
- unit_tests
|
||||
- vscode-appdaemon
|
||||
- node_modules
|
||||
plugins:
|
||||
HASS:
|
||||
type: hass
|
||||
@@ -13,6 +15,29 @@ appdaemon:
|
||||
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiZThhYWI2ZTlkNmQ0NWU2YTk1ODU5OWJjOWM3MWJkYiIsImlhdCI6MTc3NjEwOTc0NCwiZXhwIjoyMDkxNDY5NzQ0fQ.BorkjFjWlWCZqnUa9-NMUxGsDiupDoRZ3cEgsmeSofM
|
||||
cert_verify: True
|
||||
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:
|
||||
url: http://0.0.0.0:5050
|
||||
admin:
|
||||
|
||||
+1
-1
Submodule apps/ad_toolbox updated: 191e69eb1d...54c525c843.
+130
-49
@@ -32,13 +32,25 @@ class CalendarEvent:
|
||||
|
||||
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):
|
||||
if self.dataset is None:
|
||||
self.dataset = {}
|
||||
self.switch_alarmclock_main = self.args['switch_alarmclock_main']
|
||||
self.switch_alarmclock_secondary = self.args['switch_alarmclock_secondary']
|
||||
self.inputdate_alarmclock_main = self.args['inputdate_alarmclock_main']
|
||||
self.inputdate_alarmclock_secondary = self.args['inputdate_alarmclock_secondary']
|
||||
self.input_text_wakeup_time = self.args['input_text_wakeup_time']
|
||||
#self.sensor_wakeup_datetime = self.args['sensor_wakeup_datetime']
|
||||
self.output_workday_sensor = self.args['output_workday_sensor']
|
||||
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.lock = Lock()
|
||||
@@ -46,6 +58,8 @@ class AlarmClock(SmartObject):
|
||||
self.handle_program_next_wakueup_cb = None
|
||||
self.handle_prewakueup_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_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)
|
||||
@@ -53,27 +67,74 @@ class AlarmClock(SmartObject):
|
||||
|
||||
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")
|
||||
#sensor_wakeup_datetime_entity = self.get_entity(self.sensor_wakeup_datetime)
|
||||
#if not sensor_wakeup_datetime_entity.exists():
|
||||
# sensor_wakeup_datetime_entity.add()
|
||||
self.sensor_wakeup_datetime_entity = self.create_entity(self.sensor_wakeup_datetime_sensor, icon="mdi:alarm")
|
||||
self.workday_entity = self.create_entity(self.output_workday_sensor, icon="mdi:briefcase-check")
|
||||
|
||||
self.calendar = list()
|
||||
self.refresh_cal()
|
||||
self.update_workday_status()
|
||||
|
||||
self.update_wakeup_time()
|
||||
|
||||
def refresh_cal(self):
|
||||
new_cal = list()
|
||||
#if self.load_cal_from_google_calendar(new_cal):
|
||||
if self.load_cal_from_ics(new_cal):
|
||||
if not self.are_cal_identical(self.calendar,new_cal):
|
||||
self.calendar = new_cal
|
||||
self.log("Calendar has been updated!")
|
||||
self.print_cal(self.calendar)
|
||||
return True
|
||||
with self.log_group("refresh_cal"):
|
||||
new_cal = list()
|
||||
#if self.load_cal_from_google_calendar(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):
|
||||
self.calendar = new_cal
|
||||
self.log_info("Calendar has been updated!")
|
||||
self.print_cal(self.calendar)
|
||||
return True
|
||||
|
||||
return False
|
||||
else:
|
||||
self._schedule_calendar_retry()
|
||||
|
||||
return False
|
||||
|
||||
def _log_calendar_failure(self, reason):
|
||||
last_success = self.calendar_last_success
|
||||
if last_success is None:
|
||||
log = self.log_error
|
||||
freshness = 'no successful refresh recorded'
|
||||
else:
|
||||
age = datetime.datetime.now(datetime.timezone.utc) - last_success
|
||||
if age < datetime.timedelta(hours=24):
|
||||
log = self.log_info
|
||||
elif age < datetime.timedelta(hours=48):
|
||||
log = self.log_warning
|
||||
else:
|
||||
log = self.log_error
|
||||
freshness = (f'last_success={last_success.isoformat()}, '
|
||||
f'age={age.total_seconds() / 3600:.2f}h')
|
||||
log(f"Couldn't load calendar: {reason}; {freshness}")
|
||||
|
||||
def _schedule_calendar_retry(self):
|
||||
if self.calendar_retry_handle is not None:
|
||||
return
|
||||
if self.calendar_retry_attempt >= len(self.CALENDAR_RETRY_DELAYS):
|
||||
return
|
||||
delay = self.CALENDAR_RETRY_DELAYS[self.calendar_retry_attempt]
|
||||
self.calendar_retry_attempt += 1
|
||||
self.calendar_retry_handle = self.run_in(self.on_calendar_retry, delay)
|
||||
self.log_info(f"Calendar refresh retry scheduled in {delay // 60} minutes")
|
||||
|
||||
def _cancel_calendar_retry(self):
|
||||
if self.calendar_retry_handle is None:
|
||||
return
|
||||
if self.timer_running(self.calendar_retry_handle):
|
||||
self.cancel_timer(self.calendar_retry_handle)
|
||||
self.calendar_retry_handle = None
|
||||
|
||||
def on_calendar_retry(self, kwargs):
|
||||
self.calendar_retry_handle = None
|
||||
calendar_changed = self.refresh_cal()
|
||||
self.update_workday_status()
|
||||
if calendar_changed:
|
||||
self.update_wakeup_time()
|
||||
|
||||
def are_cal_identical(self,calendar1,calendar2):
|
||||
if len(calendar1) != len(calendar2): return False
|
||||
@@ -86,29 +147,30 @@ class AlarmClock(SmartObject):
|
||||
|
||||
def print_cal(self,calendar):
|
||||
for event in calendar:
|
||||
self.log(f"{event}")
|
||||
self.log_info(f"{event}")
|
||||
|
||||
def load_cal_from_ics(self,calendar):
|
||||
calendar.clear()
|
||||
|
||||
try:
|
||||
self.log(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)
|
||||
#self.log(f"Processing Calendar")
|
||||
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=(3, 5))#, headers=headers, verify=False)
|
||||
r.raise_for_status()
|
||||
#self.log_info(f"Processing Calendar")
|
||||
|
||||
today = datetime.datetime.combine(datetime.date.today(), datetime.time(0,0, 0))
|
||||
start_load_date = today - datetime.timedelta(days=1)
|
||||
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():
|
||||
if component.name == "VEVENT":
|
||||
event_summary = component.get('summary')
|
||||
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 == "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
|
||||
|
||||
start_datetime = component.get('dtstart').dt
|
||||
@@ -135,25 +197,45 @@ class AlarmClock(SmartObject):
|
||||
if not outside_of_loading_period:
|
||||
if 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:
|
||||
self.log_error("Couldn't load calendar")
|
||||
except requests.exceptions.Timeout as error:
|
||||
self._log_calendar_failure(f"request timed out ({type(error).__name__})")
|
||||
return False
|
||||
except (requests.exceptions.ConnectionError,requests.exceptions.ReadTimeout):
|
||||
self.log("Couldn't load calendar, request Timeout",level = "WARNING")
|
||||
except requests.exceptions.ConnectionError:
|
||||
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
|
||||
|
||||
#self.log(f"Calendar loaded")
|
||||
#self.log_info(f"Calendar loaded")
|
||||
return True
|
||||
|
||||
def on_alarm_clock_set(self, entity, attribute, old, new, kwargs):
|
||||
self.update_wakeup_time()
|
||||
|
||||
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()
|
||||
|
||||
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):
|
||||
self.handle_program_next_wakueup_cb = None
|
||||
|
||||
@@ -161,12 +243,12 @@ class AlarmClock(SmartObject):
|
||||
|
||||
#le reveil principal se reactive chaque matin
|
||||
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
|
||||
self.turn_on(self.switch_alarmclock_main)
|
||||
#le reveil secondaire ne sonne qu'une fois, il est donc desactivé chaque matin
|
||||
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
|
||||
self.turn_off(self.switch_alarmclock_secondary)
|
||||
|
||||
@@ -185,7 +267,7 @@ class AlarmClock(SmartObject):
|
||||
|
||||
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)
|
||||
#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
|
||||
|
||||
self.log(wakeup_message)
|
||||
self.log_info(wakeup_message)
|
||||
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)
|
||||
|
||||
@@ -237,11 +319,11 @@ class AlarmClock(SmartObject):
|
||||
wake_up_datetime = busy_event.start_time - datetime.timedelta(minutes=30)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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_datetime = self.generate_wakeup_datetime_from_string(new_wakeup_time)
|
||||
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 = "--:--"
|
||||
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)
|
||||
else:
|
||||
new_wakeup_time = self.offset_wakeup_time_for_meeting(new_wakeup_datetime)
|
||||
@@ -279,15 +361,15 @@ class AlarmClock(SmartObject):
|
||||
with self.lock:
|
||||
#first, cancel all previous callbacks
|
||||
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.handle_program_next_wakueup_cb = None
|
||||
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.handle_prewakueup_cb = None
|
||||
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.handle_wakueup_cb = None
|
||||
|
||||
@@ -300,7 +382,7 @@ class AlarmClock(SmartObject):
|
||||
if datetime.datetime.now().hour >= 4:
|
||||
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.sensor_wakeup_datetime_entity.set_state(state = 'unavailable')
|
||||
else:
|
||||
@@ -315,19 +397,18 @@ class AlarmClock(SmartObject):
|
||||
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.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.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.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.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):
|
||||
self.register_callbacks(self.calculate_wakeup_time())
|
||||
|
||||
|
||||
def update_wakeup_time(self):
|
||||
with self.log_group("update_wakeup_time"):
|
||||
self.register_callbacks(self.calculate_wakeup_time())
|
||||
@@ -18,6 +18,10 @@ informations_collector:
|
||||
reset_error_count_button: input_button.reset_error_count
|
||||
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:
|
||||
temperature_sensors:
|
||||
- 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:
|
||||
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:
|
||||
attributes:
|
||||
unit_of_measurement: °C
|
||||
@@ -14,7 +16,7 @@ heating_sensors:
|
||||
values:
|
||||
winter_mode:
|
||||
conditions: binary_sensor.heatpump_winter_mode
|
||||
value: 18
|
||||
value: input_number.heatpump_temperature_winter
|
||||
default:
|
||||
conditions: True
|
||||
value: input_number.heatpump_temperature
|
||||
@@ -31,10 +33,11 @@ smart_heating_templates_library:
|
||||
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 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 self and room_temperature < target_temperature
|
||||
- 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 + 1
|
||||
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 binary_sensor.heatpump_winter_mode
|
||||
- not room_occupancy and not (sleeping_in_the_room or (occupied_tonight and binary_sensor.day_interval_night))
|
||||
- not binary_sensor.someone_home
|
||||
- window_open
|
||||
@@ -112,7 +115,7 @@ bedroom_heatpump:
|
||||
auto: binary_sensor.heatpump_winter_mode or room_temperature > target_temperature + 1
|
||||
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'"
|
||||
fan_mode: sensor.bedroom_heatpump_fan_mode
|
||||
|
||||
@@ -147,7 +150,7 @@ living_room_heatpump:
|
||||
|
||||
constants:
|
||||
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_occupancy: binary_sensor.living_room_occupancy
|
||||
target_temperature: sensor.target_temperature_heatpump
|
||||
@@ -157,7 +160,7 @@ living_room_heatpump:
|
||||
occupied_tonight: input_boolean.living_room_occupied_tonight
|
||||
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'"
|
||||
fan_mode: sensor.living_room_heatpump_fan_mode
|
||||
|
||||
@@ -216,4 +219,55 @@ guestroom_heatpump:
|
||||
sensor: input_select.guestroom_heatpump_override
|
||||
reset_events:
|
||||
- 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
|
||||
+123
-36
@@ -1,5 +1,5 @@
|
||||
samsung_the_frame:
|
||||
module: samsungtheframe
|
||||
module: samsungtheframeasync
|
||||
class: SamsungTheFrame
|
||||
|
||||
volume_up_events:
|
||||
@@ -25,7 +25,7 @@ samsung_the_frame:
|
||||
power_off:
|
||||
trigger_conditions:
|
||||
- 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
|
||||
manual: binary_sensor.is_tv_on and not binary_sensor.tv_auto_shutdown
|
||||
tv_off: not binary_sensor.living_room_occupancy
|
||||
@@ -74,7 +74,7 @@ w4000i:
|
||||
time: 30
|
||||
continuous_condition.has_allm_signal_for_a_while:
|
||||
conditions: binary_sensor.vertex2_allm
|
||||
time: 5
|
||||
time: 10
|
||||
|
||||
attributes_override:
|
||||
icon: sensor.projector_icon
|
||||
@@ -121,11 +121,18 @@ bravia_theatre:
|
||||
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:
|
||||
module: smartshutter
|
||||
class: SmartShutter
|
||||
entity: cover.living_room_projection_screen
|
||||
close_position: 22
|
||||
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:
|
||||
@@ -137,22 +144,33 @@ projection_screen:
|
||||
new_state:
|
||||
state: "on"
|
||||
|
||||
# close_conditions: # close mean screen down
|
||||
# # 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
|
||||
projection_screen:
|
||||
module: smartshutter
|
||||
class: SmartShutter
|
||||
entity: cover.living_room_projection_screen
|
||||
close_position: 22
|
||||
|
||||
open_conditions: # close mean screen up
|
||||
trigger_conditions: binary_sensor.projector_off_for_a_while
|
||||
close_conditions: input_boolean.projection_screen
|
||||
|
||||
vertex_2:
|
||||
module: vertex2
|
||||
class: Vertex2
|
||||
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:
|
||||
default:
|
||||
name: None
|
||||
@@ -160,7 +178,6 @@ vertex_2:
|
||||
0:
|
||||
name: AppleTV
|
||||
icon: mdi:apple
|
||||
#reboot_when_leaving: True
|
||||
1:
|
||||
name: Xbox
|
||||
icon: mdi:microsoft-xbox
|
||||
@@ -171,6 +188,13 @@ vertex_2:
|
||||
name: 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:
|
||||
default:
|
||||
name: None
|
||||
@@ -178,43 +202,43 @@ vertex_2:
|
||||
0:
|
||||
name: TV
|
||||
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:
|
||||
name: Projector
|
||||
icon: mdi:projector
|
||||
use_audio_out_audio_flags: True
|
||||
#force_plus5: switch.living_room_projector_plug
|
||||
|
||||
# vertex2_power_plug:
|
||||
# entity: switch.fibaro_vertex_plug_51
|
||||
# # power_consumption: sensor.fibaro_vertex_plug_51_power
|
||||
# conditions:
|
||||
# trigger_conditions: binary_sensor.someone_home
|
||||
# blocking_conditions:
|
||||
# - binary_sensor.is_everybody_sleeping or binary_sensor.is_salon_sleeping
|
||||
# - binary_sensor.vertex2_unreachable and not binary_sensor.is_tv_on and not binary_sensor.projector_on
|
||||
|
||||
mutetx0_conditions: input_boolean.use_bravia_theater_for_the_frame
|
||||
vertex2_power_plug:
|
||||
entity: switch.vertex2_power_plug
|
||||
# power_consumption: sensor.fibaro_vertex_plug_51_power
|
||||
conditions:
|
||||
trigger_conditions: binary_sensor.someone_home #and (input_boolean.projection_screen or binary_sensor.the_frame_is_plugged_for_a_while)
|
||||
blocking_conditions: (binary_sensor.is_everybody_sleeping or binary_sensor.is_living_room_sleeping) and not binary_sensor.vertex2_has_input_signal
|
||||
|
||||
|
||||
#filmmaker_mode_conditions: sensor.vertex2_input_select_tx0 == 'AppleTV'
|
||||
|
||||
output_selection:
|
||||
Projector: binary_sensor.projector_screen_down
|
||||
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
|
||||
TV: True
|
||||
|
||||
vertex2_address: http://10.0.0.33
|
||||
vertex2_tcp_port: 2220
|
||||
startup_delay: 30 # seconds after the power plug turns on
|
||||
|
||||
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
|
||||
output_selected_sensor: sensor.vertex2_output_selected
|
||||
input_selected_sensor: sensor.vertex2_input_selected
|
||||
no_signal_spam_sensor: binary_sensor.vertex2_no_signal_spam
|
||||
unreachable_sensor: binary_sensor.vertex2_unreachable
|
||||
command_processing_sensor: binary_sensor.vertex2_command_processing
|
||||
command_processing_timeout: 20
|
||||
|
||||
use_bravia_theater_for_the_frame:
|
||||
module: smartswitch
|
||||
@@ -280,3 +304,66 @@ homecinema_volume:
|
||||
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:
|
||||
content: Good Bye
|
||||
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:
|
||||
binary_sensor.someone_home: off
|
||||
binary_sensor.projector_on: off
|
||||
binary_sensor.is_tv_on: off
|
||||
|
||||
sensors:
|
||||
binary_sensor.living_room_occupancy:
|
||||
trigger_conditions:
|
||||
- sensor.living_room_last_motion <= 5
|
||||
- binary_sensor.projector_on
|
||||
- binary_sensor.projector_on or binary_sensor.is_tv_on
|
||||
binary_sensor.kitchen_occupancy:
|
||||
trigger_conditions: sensor.kitchen_last_motion <= 5
|
||||
binary_sensor.garage_occupancy:
|
||||
@@ -59,10 +60,6 @@ occupancy_sensors:
|
||||
binary_sensor.guestroom_occupancy:
|
||||
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
|
||||
retain_condition.desk_occupancy:
|
||||
retain_time: 5 * 60
|
||||
conditions:
|
||||
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
|
||||
binary_sensor.desk_occupancy:
|
||||
trigger_conditions: sensor.desk_last_motion <= 5 or binary_sensor.is_desk_sleeping
|
||||
blocking_conditions: not binary_sensor.someone_home
|
||||
@@ -1,7 +1,7 @@
|
||||
sleep_sensors:
|
||||
module: virtualsensors
|
||||
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:
|
||||
binary_sensor.is_bedroom_sleeping: input_boolean.bedroom_sleeping or input_boolean.nap_switch
|
||||
@@ -17,4 +17,4 @@ sleep_sensors:
|
||||
- binary_sensor.is_bedroom_sleeping and input_boolean.bedroom_occupied_tonight
|
||||
- binary_sensor.is_living_room_sleeping and input_boolean.living_room_occupied_tonight
|
||||
- binary_sensor.is_desk_sleeping and input_boolean.desk_occupied_tonight
|
||||
- binary_sensor.is_guestroom_sleeping and input_boolean.guestroom_occupied_tonight
|
||||
- binary_sensor.is_guestroom_sleeping and input_boolean.guestroom_occupied_tonight
|
||||
@@ -9,6 +9,71 @@ virtual_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.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:
|
||||
retain_time: 120
|
||||
activation_events:
|
||||
@@ -17,6 +82,8 @@ virtual_sensors:
|
||||
event_data:
|
||||
entity_id: sensor.shelly_button_red_signal_strength
|
||||
|
||||
binary_sensor.main_power_off: sensor.ups_code_d_etat != 'OL'
|
||||
|
||||
# living room sensors
|
||||
value_selector.living_room_ambiance_light:
|
||||
values:
|
||||
@@ -34,7 +101,6 @@ virtual_sensors:
|
||||
|
||||
# home cinema sensors
|
||||
binary_sensor.projector_on: 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 < 90
|
||||
continuous_condition.projector_off_for_a_while:
|
||||
time: 90
|
||||
default_value: on
|
||||
@@ -46,10 +112,10 @@ virtual_sensors:
|
||||
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_time: 15
|
||||
retain_time: 20 #20 seems like the sweet spot to handle transition between source (AppleTV -> Xbox on projector)
|
||||
conditions:
|
||||
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)
|
||||
sensor_attributes:
|
||||
icon: sensor.vertex2_input_selected_icon
|
||||
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'
|
||||
@@ -73,7 +139,7 @@ virtual_sensors:
|
||||
binary_sensor.p1p_is_active:
|
||||
trigger_conditions:
|
||||
- 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
|
||||
blocking_conditions: not switch.garage_p1p_plug
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@ from importlib import metadata
|
||||
import json
|
||||
import pickle
|
||||
import os
|
||||
import re
|
||||
from urllib.request import urlopen
|
||||
|
||||
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):
|
||||
super().on_initialize_smart_object()
|
||||
@@ -26,7 +29,7 @@ class InformationsCollector(SmartObject):
|
||||
sensor_name,
|
||||
state='unknown',
|
||||
attributes={
|
||||
'latest_official_version': self.dataset.get('appdaemon_latest_official_version'),
|
||||
'latest_official_version': self.dataset.get('appdaemon_latest_docker_version'),
|
||||
'update_available': None,
|
||||
},
|
||||
icon='mdi:package-up'
|
||||
@@ -51,6 +54,24 @@ class InformationsCollector(SmartObject):
|
||||
else:
|
||||
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:
|
||||
for sensor in self.args['min_max_temp_sensors']['temperature_sensors']:
|
||||
self.listen_state(self.on_temperature_change, sensor)
|
||||
@@ -125,9 +146,9 @@ class InformationsCollector(SmartObject):
|
||||
latest_official_version = self._get_latest_official_appdaemon_version()
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -150,11 +171,22 @@ class InformationsCollector(SmartObject):
|
||||
|
||||
def _get_latest_official_appdaemon_version(self):
|
||||
try:
|
||||
with urlopen(self.APPDAEMON_PYPI_URL, timeout=10) as response:
|
||||
payload = json.load(response)
|
||||
return payload.get('info', {}).get('version')
|
||||
url = self.APPDAEMON_DOCKER_TAGS_URL
|
||||
versions = {}
|
||||
while url:
|
||||
with urlopen(url, timeout=10) as response:
|
||||
payload = json.load(response)
|
||||
for tag in payload['results']:
|
||||
name = tag.get('name', '')
|
||||
# Only stable version tags with published container images.
|
||||
if re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', name) and tag.get('images'):
|
||||
versions[tuple(map(int, name.split('.')))] = name
|
||||
url = payload.get('next')
|
||||
if not versions:
|
||||
raise ValueError("No stable AppDaemon Docker image found")
|
||||
return versions[max(versions)]
|
||||
except Exception as err:
|
||||
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
|
||||
|
||||
def _is_appdaemon_update_available(self, installed_version, latest_official_version):
|
||||
@@ -218,6 +250,21 @@ class InformationsCollector(SmartObject):
|
||||
self.error_list.clear()
|
||||
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):
|
||||
self.update_group_collectors()
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import appdaemon.exceptions as ad_exception
|
||||
import ad_toolbox.smartcondition as SmartCondition
|
||||
import re
|
||||
import uuid
|
||||
from expressionparser import ParsingException
|
||||
from ad_toolbox.expressionparser import ParsingException
|
||||
from ad_toolbox.logger_interface import LoggerInterface
|
||||
|
||||
class Notification:
|
||||
ENTITY_PLACEHOLDER_PATTERN = re.compile(r"\{([^{}]+)\}")
|
||||
@@ -18,7 +19,7 @@ class Notification:
|
||||
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)
|
||||
except ParsingException as e:
|
||||
self.log_error(str(e))
|
||||
self.ad_api.log_error(str(e))
|
||||
continue
|
||||
|
||||
def on_conditon_fail(self, recipient):
|
||||
@@ -42,9 +43,9 @@ class Notification:
|
||||
data = { 'tag' : 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)
|
||||
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]
|
||||
if isinstance(recipient_yaml['services'], str):
|
||||
@@ -65,11 +66,11 @@ class Notification:
|
||||
try:
|
||||
state = self.ad_api.get_state(entity_id)
|
||||
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)
|
||||
|
||||
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 str(state)
|
||||
@@ -96,9 +97,9 @@ class Notification:
|
||||
self.ad_api.run_in(self.on_timeout_expired,timeout,tag = data['tag'],recipient = recipient)
|
||||
|
||||
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)
|
||||
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]
|
||||
if isinstance(recipient_yaml['services'], str):
|
||||
@@ -107,17 +108,13 @@ class Notification:
|
||||
for service in recipient_yaml['services']:
|
||||
send_notification(service)
|
||||
|
||||
def log_error(self,message,stop_app = False):
|
||||
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):
|
||||
class NotificationsManager(hass.Hass, LoggerInterface):
|
||||
|
||||
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()
|
||||
|
||||
if "notifications" in self.args:
|
||||
for notification in self.args["notifications"]:
|
||||
self.notifications[notification] = Notification(self,self.args["notifications"][notification],notification)
|
||||
self.notifications[notification] = Notification(self,self.args["notifications"][notification],notification)
|
||||
@@ -175,10 +175,13 @@ alarm_clock:
|
||||
module: alarmclock
|
||||
class: AlarmClock
|
||||
|
||||
priority: 5
|
||||
|
||||
switch_alarmclock_main: input_boolean.main_alarmclock
|
||||
switch_alarmclock_secondary: input_boolean.secondary_alarmclock
|
||||
inputdate_alarmclock_main: input_datetime.main_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
|
||||
|
||||
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
|
||||
@@ -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:
|
||||
unavaibility_result: Disabled
|
||||
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:
|
||||
module: smartswitch
|
||||
|
||||
@@ -49,6 +49,13 @@ light_bookshelves:
|
||||
25: sensor.living_room_ambiance_light == 1
|
||||
100: True
|
||||
|
||||
light_christmas_tree:
|
||||
module: smartswitch
|
||||
class: SmartSwitch
|
||||
entity: switch.chrismas_tree
|
||||
|
||||
smart_conditions: sensor.living_room_ambiance_light >= 1
|
||||
|
||||
light_tree:
|
||||
module: smartlight
|
||||
class: SmartLight
|
||||
@@ -102,7 +109,7 @@ mezzanine_curtains:
|
||||
|
||||
close_conditions:
|
||||
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:
|
||||
module: smartswitch
|
||||
|
||||
+49
-48
@@ -15,7 +15,7 @@ class SamsungTheFrame(SmartObject):
|
||||
def on_initialize_smart_object(self):
|
||||
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.volume_connection = None
|
||||
self.requested_state = None
|
||||
@@ -23,12 +23,13 @@ class SamsungTheFrame(SmartObject):
|
||||
|
||||
self.power_plug = self.args['input_sensors']['power_plug']
|
||||
self.power_plug_power = self.args['input_sensors']['power_plug_power']
|
||||
self.is_on_sensor = self.args['output_sensors']['is_on']
|
||||
self.is_art_mode_sensor = self.args['output_sensors']['is_art_mode']
|
||||
self.tv_auto_shutdown = self.args['output_sensors']['tv_auto_shutdown']
|
||||
|
||||
if self.get_state(self.tv_auto_shutdown) == None:
|
||||
self.set_state(self.tv_auto_shutdown,state = 'off')
|
||||
self.is_on_entity = self.create_entity(self.args['output_sensors']['is_on'])
|
||||
self.is_art_mode_entity = self.create_entity(self.args['output_sensors']['is_art_mode'])
|
||||
auto_shutdown_state = self.get_state(self.args['output_sensors']['tv_auto_shutdown'])
|
||||
self.tv_auto_shutdown_entity = self.create_entity(
|
||||
self.args['output_sensors']['tv_auto_shutdown'],
|
||||
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']
|
||||
else: self.app_list = []
|
||||
@@ -85,7 +86,7 @@ class SamsungTheFrame(SmartObject):
|
||||
super().terminate()
|
||||
|
||||
def on_volume_event(self, event_name, event_data, direction):
|
||||
if self.get_state(self.is_on_sensor) != 'on':
|
||||
if self.is_on_entity.get_state() != 'on':
|
||||
self.close_volume_connection()
|
||||
return
|
||||
try:
|
||||
@@ -98,7 +99,7 @@ class SamsungTheFrame(SmartObject):
|
||||
key_press_delay=0)
|
||||
except (OSError, tv_exceptions.ConnectionFailure, tv_exceptions.ResponseError,
|
||||
websocket.WebSocketException) as error:
|
||||
self.log(f"Volume {direction} failed: {error}", level="WARNING")
|
||||
self.log_warning(f"Volume {direction} failed: {error}")
|
||||
# Reconnect on the next press, without replaying a failed command.
|
||||
self.close_volume_connection()
|
||||
|
||||
@@ -108,10 +109,10 @@ class SamsungTheFrame(SmartObject):
|
||||
def wake_up(self):
|
||||
if 'wake_up_service' in self.args:
|
||||
service = self.args['wake_up_service']
|
||||
self.log(f"Calling service {service}")
|
||||
self.log_info(f"Calling service {service}")
|
||||
self.call_service(service)
|
||||
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'])
|
||||
if 'mac_address' in self.args:
|
||||
wakeonlan.send_magic_packet(self.args['mac_address'])
|
||||
@@ -128,23 +129,23 @@ class SamsungTheFrame(SmartObject):
|
||||
selected_art = random.choice(available_art)
|
||||
tv.art().select_image(selected_art['content_id'], show=True)
|
||||
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:
|
||||
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):
|
||||
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}"
|
||||
|
||||
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':
|
||||
self.log(f"setting {self.tv_auto_shutdown} to 'on'")
|
||||
self.set_state(self.tv_auto_shutdown,state = 'on')
|
||||
if requested_state == 'hdmi' and self.is_on_entity.get_state() == 'off':
|
||||
self.log_info(f"setting {self.tv_auto_shutdown_entity.entity_id} to 'on'")
|
||||
self.tv_auto_shutdown_entity.set_state(state = 'on')
|
||||
|
||||
#if we already have a confirm callback, let's cancel it
|
||||
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.requested_state_confirm_cb_handle = None
|
||||
|
||||
@@ -153,7 +154,7 @@ class SamsungTheFrame(SmartObject):
|
||||
else:
|
||||
self.requested_state = requested_state
|
||||
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):
|
||||
try:
|
||||
@@ -161,32 +162,32 @@ class SamsungTheFrame(SmartObject):
|
||||
if not success:
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, wait_time)
|
||||
return False
|
||||
is_art_mode = self.get_state(self.is_art_mode_sensor) == 'on'
|
||||
is_on = self.get_state(self.is_on_sensor) == 'on'
|
||||
is_art_mode = self.is_art_mode_entity.get_state() == 'on'
|
||||
is_on = self.is_on_entity.get_state() == 'on'
|
||||
is_plugged = self.get_state(self.power_plug) == 'on'
|
||||
|
||||
if self.requested_state == 'tv_off':
|
||||
if not is_plugged:
|
||||
self.log("Powering on TV")
|
||||
self.log_info("Powering on TV")
|
||||
self.turn_on(self.power_plug)
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60)
|
||||
return False
|
||||
elif is_on:
|
||||
tv = self.connect()
|
||||
self.log("Switching to art mode")
|
||||
self.log_info("Switching to art mode")
|
||||
tv.shortcuts().power()
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 10)
|
||||
return False
|
||||
elif is_art_mode:
|
||||
tv = self.connect()
|
||||
self.log("Turning off TV")
|
||||
self.log_info("Turning off TV")
|
||||
tv.hold_key('KEY_POWER',4)
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 10)
|
||||
return False
|
||||
elif self.requested_state == 'art_mode':
|
||||
if not is_art_mode:
|
||||
if not is_plugged:
|
||||
self.log("Powering on TV")
|
||||
self.log_info("Powering on TV")
|
||||
self.turn_on(self.power_plug)
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60)
|
||||
return False
|
||||
@@ -204,18 +205,18 @@ class SamsungTheFrame(SmartObject):
|
||||
|
||||
elif self.requested_state == "hdmi":
|
||||
if not is_plugged:
|
||||
self.log("Powering on TV")
|
||||
self.log_info("Powering on TV")
|
||||
self.turn_on(self.power_plug)
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60)
|
||||
return False
|
||||
elif not self.has_power():
|
||||
self.log("Waking up TV")
|
||||
self.log_info("Waking up TV")
|
||||
self.wake_up()
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60)
|
||||
return False
|
||||
elif not is_on:
|
||||
tv = self.connect()
|
||||
self.log("Turning on TV")
|
||||
self.log_info("Turning on TV")
|
||||
tv.shortcuts().power()
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 5)
|
||||
return False
|
||||
@@ -225,8 +226,8 @@ class SamsungTheFrame(SmartObject):
|
||||
# {'id': '3201907018807', 'name': 'Netflix', 'running': True, 'version': '5.2.59020', 'visible': True}
|
||||
app_status = tv.rest_app_status(str(app))
|
||||
if app_status['visible']:
|
||||
self.log(f"{app_status['name']} is active")
|
||||
self.log("Switching to Hdmi")
|
||||
self.log_info(f"{app_status['name']} is active")
|
||||
self.log_info("Switching to Hdmi")
|
||||
tv.send_key('KEY_HDMI')
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 5)
|
||||
return False
|
||||
@@ -240,14 +241,14 @@ class SamsungTheFrame(SmartObject):
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 10)
|
||||
return False
|
||||
elif not self.has_power():
|
||||
self.log("Waking up TV")
|
||||
self.log_info("Waking up TV")
|
||||
self.wake_up()
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 60)
|
||||
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
|
||||
self.select_random_art()
|
||||
time.sleep(5)
|
||||
self.log("Powering off TV")
|
||||
self.log_info("Powering off TV")
|
||||
self.turn_off(self.power_plug)
|
||||
self.requested_state_confirm_cb_handle = self.run_in(self.confirm_change_state_cb, 5)
|
||||
return False
|
||||
@@ -267,7 +268,7 @@ class SamsungTheFrame(SmartObject):
|
||||
# if result == SmartCondition.Result.Succeeded:
|
||||
# self.request_tv_state('art_mode')
|
||||
# else:
|
||||
# if self.get_state(self.is_art_mode_sensor) == 'on':
|
||||
# if self.is_art_mode_entity.get_state() == 'on':
|
||||
# self.select_random_art()
|
||||
# #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')
|
||||
@@ -290,23 +291,23 @@ class SamsungTheFrame(SmartObject):
|
||||
else:
|
||||
is_on = "on"
|
||||
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
|
||||
|
||||
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
|
||||
except ConnectionRefusedError:
|
||||
self.log(f"update sensors failed with exception ConnectionRefusedError")
|
||||
self.log_info(f"update sensors failed with exception ConnectionRefusedError")
|
||||
return False , 30
|
||||
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
|
||||
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
|
||||
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
|
||||
# except:
|
||||
# import traceback
|
||||
@@ -315,19 +316,19 @@ class SamsungTheFrame(SmartObject):
|
||||
# self.log(f"update sensors failed with exception {exception_string}")
|
||||
# return False , 5
|
||||
|
||||
if self.get_state(self.is_art_mode_sensor) != is_art_mode:
|
||||
self.log(f"Updating sensor: {self.is_art_mode_sensor} = '{is_art_mode}'")
|
||||
self.set_state(self.is_art_mode_sensor,state = is_art_mode)
|
||||
if self.is_art_mode_entity.get_state() != is_art_mode:
|
||||
self.log_info(f"Updating sensor: {self.is_art_mode_entity.entity_id} = '{is_art_mode}'")
|
||||
self.is_art_mode_entity.set_state(state = is_art_mode)
|
||||
|
||||
if is_on == 'off':
|
||||
self.close_volume_connection()
|
||||
|
||||
if self.get_state(self.is_on_sensor) != is_on:
|
||||
self.log(f"Updating sensor: {self.is_on_sensor} = '{is_on}'")
|
||||
self.set_state(self.is_on_sensor,state = is_on)
|
||||
if is_on == 'off' and self.get_state(self.tv_auto_shutdown) != 'off':
|
||||
self.log(f"setting {self.tv_auto_shutdown} to 'off'")
|
||||
self.set_state(self.tv_auto_shutdown,state = 'off')
|
||||
if self.is_on_entity.get_state() != is_on:
|
||||
self.log_info(f"Updating sensor: {self.is_on_entity.entity_id} = '{is_on}'")
|
||||
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
|
||||
@@ -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):
|
||||
transition_time = self.args["on_events_with_transition"][event_category]["transition_time"]
|
||||
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, 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.
|
||||
def switch_on(self):
|
||||
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)
|
||||
else:
|
||||
super().switch_on()
|
||||
|
||||
@@ -3,15 +3,35 @@ from smartswitch import SmartSwitch
|
||||
from ad_toolbox.eventhandler import EventHandler
|
||||
import ad_toolbox.smartcondition as SmartCondition
|
||||
import telnetlib
|
||||
import time
|
||||
import queue
|
||||
import threading
|
||||
from functools import wraps
|
||||
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):
|
||||
|
||||
WARM_UP_TIME = 35
|
||||
def get_default_entity_state(self): return 'off'
|
||||
|
||||
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()
|
||||
self.telnet_ip = self.args['telnet_connection']['ip']
|
||||
self.telnet_port = self.args['telnet_connection']['port']
|
||||
@@ -63,6 +83,7 @@ class SmartProjector(SmartSwitch):
|
||||
self.reset_update_state_cb()
|
||||
#self.entity.listen_state(self.on_state_change)
|
||||
self.update_state()
|
||||
self._command_worker.start()
|
||||
|
||||
def on_power_change(self, entity, attribute, old, new, kwargs):
|
||||
self.update_state()
|
||||
@@ -91,6 +112,7 @@ class SmartProjector(SmartSwitch):
|
||||
|
||||
def is_available(self): return self.entity.get_state() != 'unavailable'
|
||||
|
||||
@queued_operation
|
||||
def switch_on(self):
|
||||
if self.is_available():
|
||||
self.log(f"Turn on {self.entity_id}")
|
||||
@@ -111,6 +133,7 @@ class SmartProjector(SmartSwitch):
|
||||
else:
|
||||
self.log_warning("Turn On ignored as the projector is unavailable")
|
||||
|
||||
@queued_operation
|
||||
def switch_off(self):
|
||||
if self.is_available():
|
||||
self.log(f"Turn off {self.entity_id}")
|
||||
@@ -140,6 +163,7 @@ class SmartProjector(SmartSwitch):
|
||||
else: raise
|
||||
else: return 'off'
|
||||
|
||||
@queued_operation
|
||||
def on_update_picture_mode(self):
|
||||
for evaluator in self.picture_mode_evaluators:
|
||||
if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
|
||||
@@ -155,6 +179,7 @@ class SmartProjector(SmartSwitch):
|
||||
self.entity.set_state(attributes = self.attributes)
|
||||
break
|
||||
|
||||
@queued_operation
|
||||
def on_update_lamp_mode(self):
|
||||
for evaluator in self.lamp_mode_evaluators:
|
||||
if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
|
||||
@@ -163,6 +188,7 @@ class SmartProjector(SmartSwitch):
|
||||
result = self.send_cmd(f"lampm={evaluator[1]}",True)
|
||||
break
|
||||
|
||||
@queued_operation
|
||||
def update_state(self,kwargs = None):
|
||||
new_state = self.get_projector_state()
|
||||
|
||||
@@ -171,7 +197,7 @@ class SmartProjector(SmartSwitch):
|
||||
|
||||
if new_state == 'on':
|
||||
# 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=?")
|
||||
if cmd_result: self.attributes['picture_mode'] = cmd_result.split("=")[1]
|
||||
else: self.attributes['picture_mode'] = "unavailable"
|
||||
@@ -193,6 +219,7 @@ class SmartProjector(SmartSwitch):
|
||||
|
||||
self.attributes['warming_up'] = new_value
|
||||
|
||||
@queued_operation
|
||||
def on_warm_up_done(self,*kwargs):
|
||||
self.warming_up_done_cb_handle = None
|
||||
self.attributes['warming_up'] = False
|
||||
@@ -201,48 +228,70 @@ class SmartProjector(SmartSwitch):
|
||||
def on_press_enter_event(self, event_name, data, event_context):
|
||||
self.press_enter()
|
||||
|
||||
@queued_operation
|
||||
def press_enter(self):
|
||||
if self.entity.get_state() == 'on':
|
||||
self.send_cmd("enter")
|
||||
|
||||
@queued_operation
|
||||
def send_cmd(self,cmd, log_command = False):
|
||||
cmd = f"*{cmd}#"
|
||||
return self._send_cmd(cmd,log_command,3)
|
||||
|
||||
def _send_cmd(self,cmd,log_command,try_count):
|
||||
invalid_result = [ "*Unsupported Item#", "*Block Item#", "*Illegal format#" ]
|
||||
def _process_commands(self):
|
||||
while not self._command_stop.is_set():
|
||||
operation = self._command_queue.get()
|
||||
try:
|
||||
if operation is None or self._command_stop.is_set():
|
||||
return
|
||||
method, args, kwargs = operation
|
||||
try:
|
||||
method(self, *args, **kwargs)
|
||||
except Exception as error:
|
||||
self.log_error(f"Projector operation {method.__name__} failed: {error}", dump_stack=True)
|
||||
finally:
|
||||
self._command_queue.task_done()
|
||||
|
||||
should_retry = False
|
||||
with telnetlib.Telnet(self.telnet_ip,self.telnet_port) as session:
|
||||
if log_command: self.log_info(f'Sending command "{cmd}"')
|
||||
try:
|
||||
session.write(bytes(cmd,'utf-8'))
|
||||
output = session.read_until(b"#",timeout = 5).decode('ascii')
|
||||
except (ConnectionResetError,EOFError):
|
||||
self.log_warning(f"Connection Reset While sending command {cmd}. {try_count} try left")
|
||||
if try_count <= 0:
|
||||
self.log_error(f"Could not send command {cmd}",dump_stack = True)
|
||||
return None
|
||||
else: should_retry = True
|
||||
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()
|
||||
|
||||
|
||||
# opening session recursivly inside the with doesn't sound like a good idea
|
||||
if should_retry:
|
||||
if try_count == 1:
|
||||
# I know calling sleep() is in callback is evil
|
||||
# but this is a very small sleep time and working with event would make the code much much more complexe
|
||||
time.sleep(1)
|
||||
return self._send_cmd(cmd,log_command,try_count - 1)
|
||||
|
||||
if log_command: self.log_info(f"Result = {output}")
|
||||
if output in invalid_result:
|
||||
self.log_error(f"Command rejected. {cmd} returned {output}",dump_stack = True)
|
||||
return None
|
||||
if not output:
|
||||
if try_count > 0:
|
||||
self.log_warning(f"Command output is empty. {cmd}. {try_count} try left")
|
||||
return self._send_cmd(cmd,log_command,try_count - 1)
|
||||
else:
|
||||
self.log_error(f"Command output is empty. {cmd}. {try_count} try left",dump_stack = True)
|
||||
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 output.strip("*").rstrip("#")
|
||||
try:
|
||||
with telnetlib.Telnet(self.telnet_ip, self.telnet_port, timeout=2) as session:
|
||||
if log_command:
|
||||
self.log_info(f'Sending command "{cmd}"')
|
||||
session.write(cmd.encode('utf-8'))
|
||||
output = session.read_until(b"#", timeout=5).decode('ascii').strip()
|
||||
if self._command_stop.is_set():
|
||||
return None
|
||||
if log_command:
|
||||
self.log_info(f"Result = {output}")
|
||||
if output in invalid_result:
|
||||
self.log_error(f"Command rejected. {cmd} returned {output}", dump_stack=True)
|
||||
return None
|
||||
# read_until may return a partial response when its timeout expires.
|
||||
if output.endswith("#"):
|
||||
return output.strip("*").rstrip("#")
|
||||
reason = f"empty or incomplete response: {output!r}"
|
||||
except (OSError, EOFError) as error:
|
||||
if self._command_stop.is_set():
|
||||
return None
|
||||
if isinstance(error, OSError) and error.errno == 113:
|
||||
raise
|
||||
reason = str(error)
|
||||
remaining = try_count - attempt
|
||||
self.log_info(f"Command {cmd} failed ({reason}); {remaining} retries left")
|
||||
if remaining and self._command_stop.wait(1):
|
||||
return None
|
||||
self.log_error(f"Could not send command {cmd}", dump_stack=True)
|
||||
return None
|
||||
+13
-13
@@ -104,7 +104,7 @@ class SmartSwitch(SmartObject):
|
||||
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)
|
||||
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)
|
||||
self.event_handlers = []
|
||||
@@ -120,12 +120,12 @@ class SmartSwitch(SmartObject):
|
||||
self.auto_switch_cb_handle = None
|
||||
if 'auto_switch_on_after' in self.args:
|
||||
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.listen_state(self.on_state_change,self.entity_id, old = 'on',new = 'off')
|
||||
if 'auto_switch_off_after' in self.args:
|
||||
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.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
|
||||
# timer then arms a new one for the configured delay.
|
||||
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 self.auto_switch_cb_handle != None:
|
||||
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:
|
||||
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)
|
||||
if new == 'off' and 'auto_switch_on_after' in self.args:
|
||||
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)
|
||||
|
||||
# Timer callback: applies the deferred state set by auto_switch_*_after.
|
||||
def on_auto_switch_after(self, kwargs):
|
||||
self.auto_switch_cb_handle = None
|
||||
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':
|
||||
self.switch_on()
|
||||
else:
|
||||
@@ -167,7 +167,7 @@ class SmartSwitch(SmartObject):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
# not needed yet
|
||||
# if self.smart_conditions_evaluator:
|
||||
@@ -176,7 +176,7 @@ class SmartSwitch(SmartObject):
|
||||
# self.off_conditions_evaluator.force_last_evaluation_result(SmartCondition.Result.Succeeded)
|
||||
|
||||
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)
|
||||
# not needed yet
|
||||
# if self.smart_conditions_evaluator:
|
||||
@@ -186,10 +186,10 @@ class SmartSwitch(SmartObject):
|
||||
|
||||
def on_toggle_event(self, event_name, data, kwargs):
|
||||
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()
|
||||
else:
|
||||
self.log(f"Toggled on by event {event_name}")
|
||||
self.log_info(f"Toggled on by event {event_name}")
|
||||
self.switch_on()
|
||||
|
||||
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):
|
||||
self.log(f"Turned off by event {event_name}")
|
||||
self.log_info(f"Turned off by event {event_name}")
|
||||
self.switch_off()
|
||||
|
||||
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()
|
||||
|
||||
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.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'
|
||||
is_playing_podcast_sensor_entity = self.controller.get_entity(self.is_playing_podcast_sensor)
|
||||
if not is_playing_podcast_sensor_entity.exists():
|
||||
is_playing_podcast_sensor_entity.add()
|
||||
self.is_playing_podcast_entity = self.controller.create_entity(
|
||||
f'binary_sensor.{player_name}_is_playing_podcast',
|
||||
)
|
||||
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.update_is_playing_podcast_sensor()
|
||||
@@ -401,16 +400,16 @@ class SonosPlayer:
|
||||
self.update_is_playing_podcast_sensor()
|
||||
|
||||
def update_is_playing_podcast_sensor(self):
|
||||
if self.is_playing_podcast_sensor and self.is_playing():
|
||||
prev_state = self.controller.get_state(self.is_playing_podcast_sensor)
|
||||
if self.is_playing_podcast_entity and self.is_playing():
|
||||
prev_state = self.is_playing_podcast_entity.get_state()
|
||||
if self.controller.get_state(self.entity_id,attribute = "media_artist") == None:
|
||||
if prev_state != 'on':
|
||||
self.log(f"setting {self.is_playing_podcast_sensor} to on")
|
||||
self.controller.set_state(self.is_playing_podcast_sensor,state = "on")
|
||||
self.log(f"setting {self.is_playing_podcast_entity.entity_id} to on")
|
||||
self.is_playing_podcast_entity.set_state(state = "on")
|
||||
else:
|
||||
if prev_state != 'off':
|
||||
self.log(f"setting {self.is_playing_podcast_sensor} to off")
|
||||
self.controller.set_state(self.is_playing_podcast_sensor,state = "off")
|
||||
self.log(f"setting {self.is_playing_podcast_entity.entity_id} to off")
|
||||
self.is_playing_podcast_entity.set_state(state = "off")
|
||||
|
||||
def on_update_off_conditions(self):
|
||||
if self.off_conditions_evaluator.evaluate(False) == SmartCondition.Result.Succeeded:
|
||||
|
||||
+672
-118
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,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,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,45 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
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': []},
|
||||
)
|
||||
|
||||
|
||||
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,800 @@
|
||||
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._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 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._refresh_input_5v_sensors, 0.1)
|
||||
callback = app.run_in.call_args.args[0]
|
||||
callback({})
|
||||
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 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