Compare commits
24
Commits
1fd4dab916
...
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 |
No files matched your search
@@ -5,3 +5,4 @@ __pycache__/
|
||||
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
|
||||
|
||||
+1
-1
Submodule apps/ad_toolbox updated: b97445ce92...54c525c843.
+79
-6
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
+238
-10
@@ -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()
|
||||
@@ -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,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
@@ -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()
|
||||
@@ -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