Compare commits

...
24 Commits
Author SHA1 Message Date
Pierre 9802982d73 fix(alarmclock): fiabiliser le rafraîchissement du calendrier ICS
- Ajouter deux tentatives de reprise après 2 puis 5 minutes en cas d’échec.
- Mémoriser le dernier succès et adapter le niveau des logs à l’ancienneté du calendrier.
- Distinguer les erreurs réseau, HTTP et les données ICS invalides.
- Définir les délais de connexion et de lecture et vérifier le statut HTTP.
- Actualiser le statut du jour travaillé et l’heure du réveil après une reprise.
2026-11-10 19:31:13 +01:00
Pierre ff39f4cb2f fix(vertex2): fiabiliser les lectures +5V et enrichir les diagnostics TCP
- Regrouper les demandes de lecture +5V et ignorer les callbacks obsolètes.
- Retenter la lecture après 5 secondes au premier échec et journaliser le rétablissement.
- Annuler les lectures planifiées à la réinitialisation, à la suspension et à l’arrêt.
- Détailler les timeouts TCP : champs reçus et manquants, octets reçus, interruptions et dernières lignes.
2026-11-10 19:30:18 +01:00
Pierre b76f107f01 fix christmas tree commit 2026-11-10 19:14:27 +01:00
Pierre f5a586c379 upgrade notificaiton manager to use LoggerInterface 2026-11-10 19:13:47 +01:00
Pierre 94754e6285 New christmas tree 2026-11-10 19:12:06 +01:00
Pierre 10c5b9e043 fixed error caused by sensor change in the last P1P update 2026-11-10 19:11:28 +01:00
Pierre 9f32a666c3 temperature tweak 2026-11-10 19:10:32 +01:00
Pierre 3130df69ce latest appdaemon version came now from docker image 2026-11-08 10:48:50 +01:00
Pierre 35856b2ba9 ignore code stuff from git 2026-11-08 10:48:03 +01:00
Pierre a5258b1985 Tweaked desk presence 2026-11-06 21:55:24 +01:00
Pierre 42ae8f4bc4 add main power off notification 2026-11-05 22:33:40 +01:00
Pierre 2c87c6a6f3 added a notification for commut rain 2026-11-05 18:45:37 +01:00
Pierre d8e604d64d Heatpump now works during the winter as well 2026-11-03 19:19:48 +01:00
Pierre bed50f1d09 fixed a few timer warning 2026-11-03 19:16:30 +01:00
Pierre 07abff7054 New aquarium light automation 2026-10-25 20:41:33 +01:00
Pierre 956ade4d03 Added warning to information collector 2026-10-23 19:19:24 +02:00
Pierre c34dc7fdd3 exclude a few folder from file watching 2026-10-23 19:18:40 +02:00
Pierre f6f61ddc2e tweaked vertex2_has_input_signal retain time 2026-10-22 22:12:54 +02:00
Pierre d6d470903c new vertex2 content sensor 2026-10-22 22:11:39 +02:00
Pierre 914d48ae55 added +5v sensor to vertex2 2026-10-22 22:10:32 +02:00
Pierre 264619565e clean up 2026-10-22 22:08:04 +02:00
Pierre 896fa2aa6d Removed the timeout warning of the smartprojector and pusshed all the command into a queue. 2026-10-22 22:07:48 +02:00
Pierre 1626fe83bf Reload bug fix 2026-10-18 13:19:24 +02:00
Pierre 5dbfd2a805 Added a select entity to manually switch input. 2026-10-18 00:09:10 +02:00
24 changed files with 1429 additions and 99 deletions

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ __pycache__/
logs/
apps/data/
secrets.yaml
.codex/
+114
View File
@@ -0,0 +1,114 @@
# Bug AppDaemon 4.5.10 : KeyError au rechargement des apps
## Symptôme
Lors d'une modification de configuration YAML, le rechargement des apps
échoue dans la boucle utilitaire avec cette erreur :
```text
check_app_config_files → utils.deep_compare(current_app, prev_app)
if isinstance(v, dict) and isinstance(data[k], dict):
KeyError: 'input_selection'
```
Le nom de la clé dépend de la configuration modifiée.
## Cause
`deep_compare()` accède à `data[k]` sans vérifier que la clé existe dans
l'ancienne configuration. L'ajout d'une clé contenant un dictionnaire peut
ainsi provoquer un `KeyError`. La fonction peut aussi ignorer les clés
supprimées, car elle parcourt uniquement la nouvelle configuration.
Le même problème a été signalé avec la clé `mutes` dans les commentaires de
[AppDaemon/appdaemon#2396](https://github.com/AppDaemon/appdaemon/issues/2396).
Le correctif du problème initial de ce ticket concerne les plugins personnalisés,
pas nécessairement ce problème de comparaison signalé en commentaire.
## Statut
- Patch local appliqué dans le conteneur sur AppDaemon **4.5.10**.
- Le code défectueux et son appel lors du reload ont également été vérifiés
dans la version **4.5.13** : cette mise à jour seule ne corrige pas ce bug.
- Source de référence :
[utils.py en 4.5.13](https://github.com/AppDaemon/appdaemon/blob/4.5.13/appdaemon/utils.py).
- La correction compare les ensembles de clés avant de comparer les valeurs,
y compris dans les dictionnaires imbriqués.
## Comment réappliquer le patch
Fichier concerné : `/usr/local/lib/python3.12/site-packages/appdaemon/utils.py`
Dans `deep_compare()`, juste après `data = data or {}`, ajouter :
```python
if check.keys() != data.keys():
return False
```
Le début de la fonction devient :
```python
def deep_compare(check: dict, data: dict) -> bool:
"""Compares 2 nested dictionaries of values"""
data = data or {} # Replaces a None value with an empty dict
if check.keys() != data.keys():
return False
for k, v in tuple(check.items()):
if isinstance(v, dict) and isinstance(data[k], dict):
```
La suite de la fonction reste inchangée. Avant de réappliquer sur une autre
version, vérifier son implémentation : ne pas remplacer aveuglément une
fonction qui aurait été corrigée en amont.
**Redémarrer AppDaemon après application.** Modifier le fichier sur disque
ne remplace pas la fonction déjà importée par le processus en cours ; un
simple reload d'app ne suffit pas.
Ce patch réside dans `site-packages`, hors de `/conf`. Il est perdu lors
d'une recréation du conteneur ou peut être écrasé par une réinstallation
ou mise à jour d'AppDaemon.
## Comment vérifier le patch
Exécuter dans le conteneur :
```sh
python - <<'PY'
from appdaemon.utils import deep_compare
assert deep_compare({'input_selection': {}}, {}) is False
assert deep_compare({}, {'input_selection': {}}) is False
assert deep_compare({'x': {'a': 1}}, {'x': {}}) is False
assert deep_compare({'x': {}}, {'x': {'a': 1}}) is False
assert deep_compare({'x': None}, {}) is False
assert deep_compare({'x': [1, 2]}, {'x': [1, 2]}) is True
assert deep_compare({'x': [1, 2]}, {'x': [2, 1]}) is False
assert deep_compare({'x': {'a': 1}}, {'x': {'a': 1}}) is True
assert deep_compare({'x': {}}, {'x': None}) is False
assert deep_compare({}, None) is True
assert deep_compare({'input_selection': {}}, None) is False
print('OK : 11 vérifications')
PY
```
Ces 11 vérifications ont réussi après application sur le module installé.
Elles vérifient la fonction dans un nouveau processus Python, pas le reload
complet dans le service en cours. Le service n'a pas été redémarré lors de
l'application de ce patch.
## Retour arrière
Une sauvegarde du fichier original a été créée dans le même répertoire :
`utils.py.before-deep-compare-fix.bak`.
Pour annuler le patch sur cette installation, avant toute autre modification
ou mise à jour de `utils.py` :
```sh
cp /usr/local/lib/python3.12/site-packages/appdaemon/utils.py.before-deep-compare-fix.bak \
/usr/local/lib/python3.12/site-packages/appdaemon/utils.py
```
Redémarrer ensuite AppDaemon. Ne pas restaurer cette sauvegarde sur une
version différente d'AppDaemon.
+2
View File
@@ -6,6 +6,8 @@ appdaemon:
internal_function_timeout: 00:20:00
exclude_dirs:
- unit_tests
- vscode-appdaemon
- node_modules
plugins:
HASS:
type: hass
+79 -6
View File
@@ -32,7 +32,19 @@ 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']
@@ -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)
@@ -67,14 +81,61 @@ class AlarmClock(SmartObject):
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
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
@@ -93,7 +154,8 @@ class AlarmClock(SmartObject):
try:
self.log_info(f"Retrieving calengar from outlook.office365.com")
r = get("https://outlook.office365.com/owa/calendar/0c64359c70eb41cd9713fe92c470d3df@ubisoft.com/5edb3fb5150f4203b66d9cf8db3184be11626033572842691749/calendar.ics", timeout=10)#, headers=headers, verify=False)
r = get("https://outlook.office365.com/owa/calendar/0c64359c70eb41cd9713fe92c470d3df@ubisoft.com/5edb3fb5150f4203b66d9cf8db3184be11626033572842691749/calendar.ics", timeout=(3, 5))#, headers=headers, verify=False)
r.raise_for_status()
#self.log_info(f"Processing Calendar")
today = datetime.datetime.combine(datetime.date.today(), datetime.time(0,0, 0))
@@ -101,7 +163,7 @@ class AlarmClock(SmartObject):
end_load_date = today + datetime.timedelta(days=2)
#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')
@@ -137,11 +199,20 @@ class AlarmClock(SmartObject):
calendar.append(CalendarEvent(start_datetime,end_datetime,event_type))
else: self.log_warning(f"[Unknow event : {event_summary}]Start at {start_datetime},Finish at {end_datetime}")
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_warning("Couldn't load calendar, request Timeout")
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_info(f"Calendar loaded")
@@ -151,6 +222,8 @@ class AlarmClock(SmartObject):
self.update_wakeup_time()
def on_refresh_cal(self, kwargs):
self._cancel_calendar_retry()
self.calendar_retry_attempt = 0
calendar_changed = self.refresh_cal()
self.update_workday_status()
if calendar_changed:
+4
View File
@@ -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
+10 -7
View File
@@ -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
+76 -7
View File
@@ -165,6 +165,12 @@ vertex_2:
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
@@ -182,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
@@ -211,13 +224,6 @@ vertex_2:
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
vertex2_address: http://10.0.0.33
vertex2_tcp_port: 2220
startup_delay: 30 # seconds after the power plug turns on
@@ -298,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
+24
View File
@@ -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
+3 -7
View File
@@ -60,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
+4 -2
View File
@@ -82,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:
@@ -110,7 +112,7 @@ 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: 20
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: self and (binary_sensor.vertex2_unreachable or binary_sensor.vertex2_command_processing)
@@ -137,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
+54 -7
View File
@@ -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:
url = self.APPDAEMON_DOCKER_TAGS_URL
versions = {}
while url:
with urlopen(url, timeout=10) as response:
payload = json.load(response)
return payload.get('info', {}).get('version')
for tag in payload['results']:
name = tag.get('name', '')
# Only stable version tags with published container images.
if re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', name) and tag.get('images'):
versions[tuple(map(int, name.split('.')))] = name
url = payload.get('next')
if not versions:
raise ValueError("No stable AppDaemon Docker image found")
return versions[max(versions)]
except Exception as err:
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()
+12 -15
View File
@@ -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,15 +108,11 @@ 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:
+1 -1
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
light_aquarium:
module: smartswitch
class: SmartSwitch
entity: switch.aquarium_light
virtual_sensors:
sensors:
time_period.aquarium_light_schedule:
start: input_datetime.aquarium_light_start
end: input_datetime.aquarium_light_stop
smart_conditions: binary_sensor.aquarium_light_schedule
+1 -1
View File
@@ -15,7 +15,7 @@ p1p_light:
smart_conditions:
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
+8 -1
View File
@@ -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
+82 -33
View File
@@ -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#" ]
should_retry = False
with telnetlib.Telnet(self.telnet_ip,self.telnet_port) as session:
if log_command: self.log_info(f'Sending command "{cmd}"')
def _process_commands(self):
while not self._command_stop.is_set():
operation = self._command_queue.get()
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)
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()
def terminate(self):
# Discard pending work and interrupt retry delays. An active exchange is
# bounded by the connection and read timeouts (2 + 5 seconds).
self._command_stop.set()
self._command_queue.put(None)
if self._command_worker.is_alive():
self._command_worker.join(timeout=8)
super().terminate()
def _send_cmd(self, cmd, log_command, try_count):
invalid_result = {"*Unsupported Item#", "*Block Item#", "*Illegal format#"}
for attempt in range(try_count + 1):
if self._command_stop.is_set():
return None
else: should_retry = True
# 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}")
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
if not output:
if try_count > 0:
self.log_warning(f"Command output is empty. {cmd}. {try_count} try left")
return self._send_cmd(cmd,log_command,try_count - 1)
else:
self.log_error(f"Command output is empty. {cmd}. {try_count} try left",dump_stack = True)
return None
# 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
+238 -10
View File
@@ -4,8 +4,10 @@ from requests.exceptions import Timeout,ConnectionError,RequestException
from ad_toolbox.smartobject import SmartObject
import ad_toolbox.smartcondition as SmartCondition
import time
import json
import socket
import threading
from collections import deque
import re
from urllib.parse import urlparse
@@ -13,9 +15,11 @@ class Vertex2(SmartObject):
def on_initialize_smart_object(self):
# HASS can call initialize() again without calling terminate() first.
self._stop_interrupt_listener()
self._cancel_input_5v_refresh()
self._interrupt_stop = threading.Event()
self._status_requested = threading.Event()
self._last_logged_status = None
self._input_5v_states = {}
self._spam_lock = threading.RLock()
self._spam_last_interrupt = None
self._spam_snapshot = None
@@ -186,21 +190,218 @@ class Vertex2(SmartObject):
name=f'{self.name}_interrupt_listener',
daemon=True,
)
self._initialize_input_5v_sensors()
self._initialize_input_select()
self.update_vertex2_states()
self._interrupt_thread.start()
def _initialize_input_5v_sensors(self):
self.input_5v_sensors = {}
prefix = self.args['input_selected_sensor'].split('.', 1)[1].removesuffix('_input_selected')
used_ids = set()
for name, index in self.input_names.items():
if index not in range(4):
continue
slug = re.sub(r'[^a-z0-9]+', '_', str(name).lower()).strip('_') or f'input_{index}'
entity_id = f'binary_sensor.{prefix}_{slug}_5v'
if entity_id in used_ids:
entity_id = f'binary_sensor.{prefix}_{slug}_{index}_5v'
used_ids.add(entity_id)
config = self.get_input_config(index)
self.input_5v_sensors[index] = self.create_entity(
entity_id, state='unavailable', friendly_name=f'{name} +5 V',
icon=config.get('icon'), device_class='power',
)
def _invalidate_input_5v_sensors(self):
self._input_5v_states = {}
for sensor in getattr(self, 'input_5v_sensors', {}).values():
if sensor.get_state() != 'unavailable':
sensor.set_state(state='unavailable')
def _cancel_input_5v_refresh(self):
handle = getattr(self, '_input_5v_refresh_handle', None)
if handle is not None:
self.cancel_timer(handle, silent=True)
self._input_5v_refresh_handle = None
self._input_5v_refresh_generation = getattr(self, '_input_5v_refresh_generation', 0) + 1
self._input_5v_failure_since = None
self._input_5v_failures = 0
def _schedule_input_5v_refresh(self, delay=1):
# Coalesce bursts without postponing a pending retry or starving reads.
if self._input_5v_refresh_handle is not None:
return
self._input_5v_refresh_generation += 1
self._input_5v_refresh_handle = self.run_in(
self._on_input_5v_refresh, delay, generation=self._input_5v_refresh_generation)
def _on_input_5v_refresh(self, kwargs):
if kwargs['generation'] != self._input_5v_refresh_generation:
return
self._input_5v_refresh_handle = None
self._refresh_input_5v_sensors()
def _refresh_input_5v_sensors(self, kwargs=None):
if not self._vertex2_ready():
self._invalidate_input_5v_sensors()
return
try:
response = get(f'{self.args["vertex2_address"].rstrip("/")}/ssi/infopage.ssi', timeout=5)
response.raise_for_status()
status = response.json()
if not isinstance(status, dict):
raise ValueError('expected an information object')
except (RequestException, ValueError) as error:
self._invalidate_input_5v_sensors()
if not self._vertex2_ready():
self._cancel_input_5v_refresh()
return
self._input_5v_failures += 1
if self._input_5v_failure_since is None:
self._input_5v_failure_since = time.monotonic()
log = self.log_warning if self._input_5v_failures == 2 else self.log_info
log(f'Vertex2 input +5V readback failed '
f'(attempt={self._input_5v_failures}, {type(error).__name__}): {error}')
if self._input_5v_failures == 1:
self._schedule_input_5v_refresh(delay=5)
return
if not self._vertex2_ready():
self._invalidate_input_5v_sensors()
return
if self._input_5v_failure_since is not None:
self.log_info(
f'Vertex2 input +5V readback restored after '
f'{time.monotonic() - self._input_5v_failure_since:.1f}s '
f'({self._input_5v_failures} failed attempts)')
self._cancel_input_5v_refresh()
states = {}
for index in range(4):
# Firmware uses 2 for present and 1 for absent, not boolean 1/0.
states[index] = {'2': 'on', '1': 'off'}.get(
str(status.get(f'rx{index}in5v')), 'unavailable')
previous_states = self._input_5v_states
self._input_5v_states = states
input_changed = any(
previous_states.get(index) in ('on', 'off')
and state in ('on', 'off')
and previous_states[index] != state
for index, state in states.items()
)
if input_changed:
self._start_command_processing()
# HA sensors only expose the monitored state; they do not drive detection.
for index, sensor in getattr(self, 'input_5v_sensors', {}).items():
state = states[index]
if sensor.get_state() != state:
sensor.set_state(state=state)
def _initialize_input_select(self):
"""Expose a real HA select, with device-confirmed state, through MQTT."""
self._input_select_config = self.args.get('input_select')
if not self._input_select_config:
return
config = self._input_select_config
self._select_namespace = config.get('mqtt_namespace', 'mqtt')
self._select_topic = config.get('mqtt_topic', f'appdaemon/{self.name}/input').rstrip('/')
self._select_uid = config.get('unique_id', f'{self.name}_input')
self.listen_event(self._on_input_select_mqtt, 'MQTT_MESSAGE',
namespace=self._select_namespace)
for entity in (self.args['input_selected_sensor'], self.args['unreachable_sensor']):
self.listen_state(self._on_input_select_state, entity, attribute='all')
if self.power_plug_entity:
self.listen_state(self._on_input_select_state, self.power_plug_entity)
self._discover_input_select()
def _publish_input_select(self, suffix, payload):
if not isinstance(payload, str):
payload = json.dumps(payload, ensure_ascii=False)
self.call_service('mqtt/publish', topic=self._select_topic + suffix,
payload=payload, retain=True, namespace=self._select_namespace)
def _discover_input_select(self):
config = self._input_select_config
for topic in (self._select_topic + '/set', 'homeassistant/status'):
self.call_service('mqtt/subscribe', topic=topic, namespace=self._select_namespace)
availability = [{'topic': self._select_topic + '/availability'}]
# The shared plugin's connection topic belongs to the MQTT transport,
# regardless of the name originally chosen for that plugin.
mqtt_config = self.get_plugin_config(namespace=self._select_namespace) or {}
connection_topic = config.get('connection_topic') or mqtt_config.get('will_topic')
if connection_topic:
availability.append(dict(
topic=connection_topic,
payload_available=mqtt_config.get('birth_payload', 'online'),
payload_not_available=mqtt_config.get('will_payload', 'offline'),
))
payload = dict(
name='Input', unique_id=self._select_uid,
default_entity_id=config.get('entity', 'select.vertex2_input'),
device=dict(identifiers=[f'appdaemon_{self.name}'], name='Vertex2',
manufacturer='HDFury', model='Vertex2'),
options=list(self.input_names), optimistic=False,
command_topic=self._select_topic + '/set',
state_topic=self._select_topic + '/state',
json_attributes_topic=self._select_topic + '/attributes',
availability=availability, availability_mode='all',
)
self._select_discovery_payload = payload
self._sync_input_select(force_discovery=True)
self.log_info(f'Published MQTT discovery for {payload["default_entity_id"]}')
def _on_input_select_state(self, entity, attribute, old, new, kwargs):
self._sync_input_select()
def _sync_input_select(self, force_discovery=False):
if not getattr(self, '_input_select_config', None):
return
name = self.input_selected_sensor.get_state()
valid = name in self.input_names
config = self.get_input_config(self.input_names[name] if valid else None)
icon = config.get('icon') or 'mdi:video-input-hdmi'
# HA's MQTT JSON attributes do not override the entity's icon property.
# Update discovery only when the icon changes (or on reconnect).
discovery = self._select_discovery_payload
if force_discovery or discovery.get('icon') != icon:
discovery['icon'] = icon
self.call_service('mqtt/publish',
topic=f'homeassistant/select/{self._select_uid}/config',
payload=json.dumps(discovery), retain=True,
namespace=self._select_namespace)
self._publish_input_select('/attributes', {'icon': icon})
self._publish_input_select('/state', name if valid else 'None')
available = (valid and self._vertex2_powered()
and self.unreachable_sensor.get_state() == 'off')
self._publish_input_select('/availability', 'online' if available else 'offline')
def _on_input_select_mqtt(self, event, data, kwargs):
topic, payload = data.get('topic'), data.get('payload')
if ((topic is None and data.get('state') == 'Connected')
or (topic == 'homeassistant/status' and payload == 'online')):
self._discover_input_select()
elif topic == self._select_topic + '/set':
if payload not in self.input_names:
self.log_warning(f'Rejected unknown Vertex2 input: {payload!r}')
return
if not self._vertex2_ready():
self.log_warning('Vertex2 input command ignored: device is not ready')
return
# insel sets both TX ports; only a subsequent device status updates HA.
self.select_input(payload)
self.update_vertex2_states()
def _reset_no_signal_spam(self, state='off'):
if getattr(self, 'no_signal_spam_sensor', None) is None:
return
with self._spam_lock:
if self._spam_timer is not None:
self.cancel_timer(self._spam_timer)
self.cancel_timer(self._spam_timer, silent=True)
self._spam_timer = None
self._spam_deadline = None
self._spam_last_interrupt = None
self._spam_snapshot = None
self._spam_count = 0
if self.no_signal_spam_sensor.get_state() != state:
if state is not None and self.no_signal_spam_sensor.get_state() != state:
self.no_signal_spam_sensor.set_state(state=state)
def _observe_no_signal_spam(self, status):
@@ -235,7 +436,7 @@ class Vertex2(SmartObject):
if self.no_signal_spam_sensor.get_state() != state:
self.no_signal_spam_sensor.set_state(state=state)
if self._spam_timer is not None:
self.cancel_timer(self._spam_timer)
self.cancel_timer(self._spam_timer, silent=True)
self._spam_deadline = at + 4
self._spam_timer = self.run_in(
self._expire_no_signal_spam, max(0, self._spam_deadline - now),
@@ -278,11 +479,26 @@ class Vertex2(SmartObject):
status_started = None
reconfigure = True
pending_interrupt_at = None
recent_lines = deque(maxlen=12)
received_bytes = 0
interrupt_count = 0
def status_timeout_error():
return OSError(
'Vertex2 TCP status response timed out '
f'(elapsed={time.monotonic() - status_started:.2f}s, limit=5s, '
f'received={sorted(status)}, missing={sorted(expected - status.keys())}, '
f'bytes={received_bytes}, interrupts={interrupt_count}, '
f'reconfigure={reconfigure}, '
f'recent_lines={list(recent_lines)!r}, partial_line={buffer[-256:]!r})')
while not interrupt_stop.is_set() and self._vertex2_powered():
if status_started is None and self._status_requested.is_set():
self._status_requested.clear()
status = {}
recent_lines.clear()
received_bytes = 0
interrupt_count = 0
status_started = time.monotonic()
request_interrupt_at = pending_interrupt_at
pending_interrupt_at = None
@@ -293,7 +509,7 @@ class Vertex2(SmartObject):
data = connection.recv(1024)
except socket.timeout:
if status_started is not None and time.monotonic() - status_started > 5:
raise OSError('Vertex2 TCP status response timed out')
raise status_timeout_error()
continue
if interrupt_stop.is_set() or not self._vertex2_powered():
@@ -301,6 +517,7 @@ class Vertex2(SmartObject):
if not data:
raise OSError('Vertex2 closed the IP INTERRUPT connection')
received_bytes += len(data)
buffer += data.decode('ascii', errors='ignore')
if len(buffer) > 4096:
raise OSError('Vertex2 TCP response exceeded buffer limit')
@@ -308,7 +525,9 @@ class Vertex2(SmartObject):
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
recent_lines.append(line[:256])
if line == 'int':
interrupt_count += 1
pending_interrupt_at = time.monotonic()
self._status_requested.set()
elif status_started is not None:
@@ -333,7 +552,7 @@ class Vertex2(SmartObject):
status_started = None
if status_started is not None and time.monotonic() - status_started > 5:
raise OSError('Vertex2 TCP status response timed out')
raise status_timeout_error()
except (OSError, ValueError) as error:
if interrupt_stop.is_set():
@@ -342,6 +561,7 @@ class Vertex2(SmartObject):
continue
self._reset_no_signal_spam('unavailable')
self._invalidate_plus5_sensors()
self._invalidate_input_5v_sensors()
self.unreachable_sensor.set_state(state='on')
self.video_tx_signal_sensor.set_state(state='unavailable')
for sensor in self.video_tx_signal_sensors.values():
@@ -370,10 +590,11 @@ class Vertex2(SmartObject):
connection.close()
def _stop_interrupt_listener(self):
self._reset_no_signal_spam('unavailable')
interrupt_stop = getattr(self, '_interrupt_stop', None)
if interrupt_stop is not None:
interrupt_stop.set()
# Cancel spam tracking without emitting state changes during app teardown.
self._reset_no_signal_spam(state=None)
self._close_interrupt_connection()
interrupt_thread = getattr(self, '_interrupt_thread', None)
if interrupt_thread is not None:
@@ -387,10 +608,13 @@ class Vertex2(SmartObject):
with processing_lock:
self._command_processing_deadline = None
if self._command_processing_handle is not None:
self.cancel_timer(self._command_processing_handle)
self.cancel_timer(self._command_processing_handle, silent=True)
self._command_processing_handle = None
def terminate(self):
self._cancel_input_5v_refresh()
if getattr(self, '_input_select_config', None):
self._publish_input_select('/availability', 'offline')
self._stop_interrupt_listener()
super().terminate()
@@ -553,6 +777,8 @@ class Vertex2(SmartObject):
)
def _suspend_vertex2(self):
self._cancel_input_5v_refresh()
self._invalidate_input_5v_sensors()
self._reset_no_signal_spam()
self._invalidate_plus5_sensors()
self._close_interrupt_connection()
@@ -566,7 +792,7 @@ class Vertex2(SmartObject):
with self._command_processing_lock:
self._command_processing_deadline = None
if self._command_processing_handle is not None:
self.cancel_timer(self._command_processing_handle)
self.cancel_timer(self._command_processing_handle, silent=True)
self._command_processing_handle = None
if self.command_processing_sensor is not None:
self.command_processing_sensor.set_state(state='off')
@@ -591,14 +817,14 @@ class Vertex2(SmartObject):
self.turn_off(self.power_plug_entity)
def _start_command_processing(self, only_if_active=False):
# Only commands start a window; changed status may extend an active one.
# Commands and input +5V transitions start a window; status may extend it.
if self.command_processing_sensor is None:
return
with self._command_processing_lock:
if only_if_active and self._command_processing_deadline is None:
return
if self._command_processing_handle is not None:
self.cancel_timer(self._command_processing_handle)
self.cancel_timer(self._command_processing_handle, silent=True)
self._command_processing_deadline = time.monotonic() + self.command_processing_timeout
if self.command_processing_sensor.get_state() != 'on':
self.command_processing_sensor.set_state(state='on')
@@ -905,3 +1131,5 @@ class Vertex2(SmartObject):
if kwargs.get('reconfigure', False):
self._reconfigure_vertex2()
self._refresh_plus5_sensors()
# Let the firmware settle before HTTP readback, including initial connection.
self._schedule_input_5v_refresh()
+166
View File
@@ -0,0 +1,166 @@
from ad_toolbox.smartobject import SmartObject
EMPTY_VALUES = {None, "", "unknown", "unavailable", "none", "null"}
def useful(value):
"""Return a displayable Home Assistant value, or None."""
if isinstance(value, str):
value = value.strip()
if value.lower() in EMPTY_VALUES:
return None
elif value in EMPTY_VALUES:
return None
return value
class VertexContent(SmartObject):
"""Expose normalized metadata for the source currently selected on a Vertex2."""
def on_initialize_smart_object(self):
super().on_initialize_smart_object()
self.output = self.args.get("output", "sensor.vertex2_current_content")
self.input_selected = self.args.get(
"input_selected", "sensor.vertex2_input_selected"
)
self.has_signal = self.args.get(
"has_signal", "binary_sensor.vertex2_has_input_signal"
)
self.sources = self.args.get("sources", {})
state, attributes = self.build_content()
self.output_entity = self.create_entity(
self.output, state=state, attributes=attributes
)
self.listen_state(self._state_changed, self.input_selected)
self.listen_state(self._state_changed, self.has_signal)
# attribute="all" is important: a media title can change while the
# media_player state remains "playing".
listened = set()
for config in self.sources.values():
for entity in (config.get("entity"), config.get("artwork_entity")):
if entity and entity not in listened:
self.listen_state(self._state_changed, entity, attribute="all")
listened.add(entity)
def _state_changed(self, entity, attribute, old, new, kwargs):
self.update_content()
def _entity_data(self, entity):
if not entity:
return None, {}
data = self.get_state(entity, attribute="all") or {}
return data.get("state"), data.get("attributes", {}) or {}
@staticmethod
def _first_attribute(attributes, names):
for name in names:
value = useful(attributes.get(name))
if value is not None:
return value
return None
def _artwork(self, config, attributes):
artwork = self._first_attribute(
attributes, config.get("artwork_attributes", ["entity_picture"])
)
if artwork is not None:
return artwork
_, artwork_attributes = self._entity_data(config.get("artwork_entity"))
return useful(artwork_attributes.get("entity_picture"))
@staticmethod
def _pause_supported(config, application, source_state):
pause_states = config.get("pause_states", [])
if not pause_states or useful(source_state) is None:
return False
supported_apps = config.get("pause_supported_apps")
if supported_apps is None:
return True
return application in supported_apps
def build_content(self):
selected = useful(self.get_state(self.input_selected))
has_signal = self.get_state(self.has_signal) == "on"
base = {
"friendly_name": "Contenu Vertex2",
"icon": "mdi:television-play",
"source": selected,
"platform": None,
"content_type": None,
"title": None,
"series_title": None,
"application": None,
"status": "idle",
"artwork": None,
"entity_picture": None,
"available": False,
"is_paused": False,
"is_pause_supported": False,
}
if not has_signal or selected is None:
base["source"] = selected
base["icon"] = "mdi:television-off"
return "idle", base
config = self.sources.get(str(selected), {})
label = config.get("label", selected)
source_state, attributes = self._entity_data(config.get("entity"))
application = self._first_attribute(
attributes, config.get("application_attributes", ["app_name"])
)
title = self._first_attribute(
attributes, config.get("title_attributes", ["media_title"])
)
series_title = self._first_attribute(
attributes, config.get("series_title_attributes", ["media_series_title"])
)
if title is None and config.get("state_as_title"):
title = useful(source_state)
artwork = self._artwork(config, attributes)
pause_supported = self._pause_supported(config, application, source_state)
pause_states = config.get("pause_states", [])
is_paused = pause_supported and source_state in pause_states
content_type = self._first_attribute(
attributes, config.get("content_type_attributes", ["media_content_type"])
)
if content_type is None:
content_type = config.get("content_type")
if content_type is None and application and title is None:
content_type = "application"
display = title or application or label
base.update({
"icon": config.get("icon", base["icon"]),
"platform": config.get("platform", label),
"content_type": content_type,
"title": title,
"series_title": series_title,
"application": application,
"status": useful(source_state) or "active",
"artwork": artwork,
"entity_picture": artwork,
"available": True,
"is_paused": is_paused,
"is_pause_supported": pause_supported,
})
return str(display)[:255], base
def update_content(self):
state, attributes = self.build_content()
current_state = self.output_entity.get_state()
current_attributes = self.output_entity.get_state(attribute="all") or {}
if isinstance(current_attributes, dict) and "attributes" in current_attributes:
current_attributes = current_attributes["attributes"]
expected_attributes = dict(attributes)
expected_attributes["ad_app"] = self.name
if current_state != state or current_attributes != expected_attributes:
self.output_entity.set_state(state=state, attributes=expected_attributes)
+45
View File
@@ -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,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()
+295 -1
View File
@@ -19,6 +19,8 @@ class Harness(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
@@ -63,7 +65,7 @@ class PowerTests(unittest.TestCase):
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')
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')
@@ -348,6 +350,21 @@ class StatusChangeTests(unittest.TestCase):
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)
@@ -454,6 +471,32 @@ class SpamSensorTests(unittest.TestCase):
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)
@@ -504,3 +547,254 @@ class SpamSensorTests(unittest.TestCase):
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()
+116
View File
@@ -0,0 +1,116 @@
from pathlib import Path
import sys
import unittest
from unittest.mock import Mock
APPS = Path(__file__).resolve().parents[1] / "apps"
sys.path[:0] = [str(APPS), str(APPS / "ad_toolbox")]
from vertexcontent import VertexContent
class Harness(VertexContent):
def __init__(self, states, sources):
self.states = states
self.sources = sources
self.input_selected = "sensor.vertex2_input_selected"
self.has_signal = "binary_sensor.vertex2_has_input_signal"
self.output = "sensor.vertex2_current_content"
@property
def name(self):
return "vertex_content"
def get_state(self, entity, attribute=None):
data = self.states.get(entity)
if attribute == "all":
return data
return data.get("state") if data else None
APPLE = {
"AppleTV": {
"label": "Apple TV",
"platform": "Apple TV",
"entity": "media_player.apple_tv",
"pause_states": ["paused"],
"pause_supported_apps": ["Netflix"],
}
}
class VertexContentTests(unittest.TestCase):
def states(self, player_state="playing", attributes=None, signal="on"):
return {
"sensor.vertex2_input_selected": {"state": "AppleTV", "attributes": {}},
"binary_sensor.vertex2_has_input_signal": {"state": signal, "attributes": {}},
"media_player.apple_tv": {
"state": player_state,
"attributes": attributes or {},
},
}
def test_uses_title_before_application(self):
app = Harness(self.states(attributes={
"media_title": "Dune",
"app_name": "Netflix",
"media_content_type": "movie",
}), APPLE)
state, attributes = app.build_content()
self.assertEqual(state, "Dune")
self.assertEqual(attributes["application"], "Netflix")
self.assertEqual(attributes["content_type"], "movie")
self.assertFalse(attributes["is_paused"])
self.assertTrue(attributes["is_pause_supported"])
def test_falls_back_to_application(self):
app = Harness(self.states(attributes={"app_name": "Netflix"}), APPLE)
state, attributes = app.build_content()
self.assertEqual(state, "Netflix")
self.assertIsNone(attributes["title"])
def test_pause_requires_paused_state_and_supported_app(self):
supported = Harness(self.states("paused", {"app_name": "Netflix"}), APPLE)
unsupported = Harness(self.states("paused", {"app_name": "Photos"}), APPLE)
self.assertTrue(supported.build_content()[1]["is_paused"])
self.assertFalse(unsupported.build_content()[1]["is_paused"])
self.assertFalse(unsupported.build_content()[1]["is_pause_supported"])
def test_no_signal_is_idle(self):
app = Harness(self.states(signal="off"), APPLE)
state, attributes = app.build_content()
self.assertEqual(state, "idle")
self.assertFalse(attributes["available"])
self.assertFalse(attributes["is_paused"])
def test_console_uses_activity_state(self):
states = {
"sensor.vertex2_input_selected": {"state": "Playstation", "attributes": {}},
"binary_sensor.vertex2_has_input_signal": {"state": "on", "attributes": {}},
"sensor.ps5_136_activity": {"state": "Astro Bot", "attributes": {}},
}
sources = {"Playstation": {
"label": "PlayStation 5",
"platform": "PlayStation 5",
"entity": "sensor.ps5_136_activity",
"state_as_title": True,
"content_type": "game",
}}
state, attributes = Harness(states, sources).build_content()
self.assertEqual(state, "Astro Bot")
self.assertEqual(attributes["content_type"], "game")
self.assertFalse(attributes["is_pause_supported"])
def test_updates_through_created_entity_handle(self):
app = Harness(self.states(attributes={"app_name": "Netflix"}), APPLE)
app.output_entity = Mock()
app.output_entity.get_state.side_effect = ["old", {"attributes": {}}]
app.update_content()
app.output_entity.set_state.assert_called_once()
update = app.output_entity.set_state.call_args.kwargs
self.assertEqual(update["state"], "Netflix")
self.assertEqual(update["attributes"]["ad_app"], "vertex_content")
if __name__ == "__main__":
unittest.main()