Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
956ade4d03 | ||
|
|
c34dc7fdd3 | ||
|
|
f6f61ddc2e | ||
|
|
d6d470903c | ||
|
|
914d48ae55 | ||
|
|
264619565e | ||
|
|
896fa2aa6d | ||
|
|
1626fe83bf | ||
|
|
5dbfd2a805 |
No files matched your search
@@ -0,0 +1,114 @@
|
|||||||
|
# Bug AppDaemon 4.5.10 : KeyError au rechargement des apps
|
||||||
|
|
||||||
|
## Symptôme
|
||||||
|
Lors d'une modification de configuration YAML, le rechargement des apps
|
||||||
|
échoue dans la boucle utilitaire avec cette erreur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
check_app_config_files → utils.deep_compare(current_app, prev_app)
|
||||||
|
if isinstance(v, dict) and isinstance(data[k], dict):
|
||||||
|
KeyError: 'input_selection'
|
||||||
|
```
|
||||||
|
|
||||||
|
Le nom de la clé dépend de la configuration modifiée.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
`deep_compare()` accède à `data[k]` sans vérifier que la clé existe dans
|
||||||
|
l'ancienne configuration. L'ajout d'une clé contenant un dictionnaire peut
|
||||||
|
ainsi provoquer un `KeyError`. La fonction peut aussi ignorer les clés
|
||||||
|
supprimées, car elle parcourt uniquement la nouvelle configuration.
|
||||||
|
|
||||||
|
Le même problème a été signalé avec la clé `mutes` dans les commentaires de
|
||||||
|
[AppDaemon/appdaemon#2396](https://github.com/AppDaemon/appdaemon/issues/2396).
|
||||||
|
Le correctif du problème initial de ce ticket concerne les plugins personnalisés,
|
||||||
|
pas nécessairement ce problème de comparaison signalé en commentaire.
|
||||||
|
|
||||||
|
## Statut
|
||||||
|
- Patch local appliqué dans le conteneur sur AppDaemon **4.5.10**.
|
||||||
|
- Le code défectueux et son appel lors du reload ont également été vérifiés
|
||||||
|
dans la version **4.5.13** : cette mise à jour seule ne corrige pas ce bug.
|
||||||
|
- Source de référence :
|
||||||
|
[utils.py en 4.5.13](https://github.com/AppDaemon/appdaemon/blob/4.5.13/appdaemon/utils.py).
|
||||||
|
- La correction compare les ensembles de clés avant de comparer les valeurs,
|
||||||
|
y compris dans les dictionnaires imbriqués.
|
||||||
|
|
||||||
|
## Comment réappliquer le patch
|
||||||
|
|
||||||
|
Fichier concerné : `/usr/local/lib/python3.12/site-packages/appdaemon/utils.py`
|
||||||
|
|
||||||
|
Dans `deep_compare()`, juste après `data = data or {}`, ajouter :
|
||||||
|
|
||||||
|
```python
|
||||||
|
if check.keys() != data.keys():
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
Le début de la fonction devient :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def deep_compare(check: dict, data: dict) -> bool:
|
||||||
|
"""Compares 2 nested dictionaries of values"""
|
||||||
|
data = data or {} # Replaces a None value with an empty dict
|
||||||
|
|
||||||
|
if check.keys() != data.keys():
|
||||||
|
return False
|
||||||
|
|
||||||
|
for k, v in tuple(check.items()):
|
||||||
|
if isinstance(v, dict) and isinstance(data[k], dict):
|
||||||
|
```
|
||||||
|
|
||||||
|
La suite de la fonction reste inchangée. Avant de réappliquer sur une autre
|
||||||
|
version, vérifier son implémentation : ne pas remplacer aveuglément une
|
||||||
|
fonction qui aurait été corrigée en amont.
|
||||||
|
|
||||||
|
**Redémarrer AppDaemon après application.** Modifier le fichier sur disque
|
||||||
|
ne remplace pas la fonction déjà importée par le processus en cours ; un
|
||||||
|
simple reload d'app ne suffit pas.
|
||||||
|
|
||||||
|
Ce patch réside dans `site-packages`, hors de `/conf`. Il est perdu lors
|
||||||
|
d'une recréation du conteneur ou peut être écrasé par une réinstallation
|
||||||
|
ou mise à jour d'AppDaemon.
|
||||||
|
|
||||||
|
## Comment vérifier le patch
|
||||||
|
|
||||||
|
Exécuter dans le conteneur :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python - <<'PY'
|
||||||
|
from appdaemon.utils import deep_compare
|
||||||
|
|
||||||
|
assert deep_compare({'input_selection': {}}, {}) is False
|
||||||
|
assert deep_compare({}, {'input_selection': {}}) is False
|
||||||
|
assert deep_compare({'x': {'a': 1}}, {'x': {}}) is False
|
||||||
|
assert deep_compare({'x': {}}, {'x': {'a': 1}}) is False
|
||||||
|
assert deep_compare({'x': None}, {}) is False
|
||||||
|
assert deep_compare({'x': [1, 2]}, {'x': [1, 2]}) is True
|
||||||
|
assert deep_compare({'x': [1, 2]}, {'x': [2, 1]}) is False
|
||||||
|
assert deep_compare({'x': {'a': 1}}, {'x': {'a': 1}}) is True
|
||||||
|
assert deep_compare({'x': {}}, {'x': None}) is False
|
||||||
|
assert deep_compare({}, None) is True
|
||||||
|
assert deep_compare({'input_selection': {}}, None) is False
|
||||||
|
print('OK : 11 vérifications')
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces 11 vérifications ont réussi après application sur le module installé.
|
||||||
|
Elles vérifient la fonction dans un nouveau processus Python, pas le reload
|
||||||
|
complet dans le service en cours. Le service n'a pas été redémarré lors de
|
||||||
|
l'application de ce patch.
|
||||||
|
|
||||||
|
## Retour arrière
|
||||||
|
|
||||||
|
Une sauvegarde du fichier original a été créée dans le même répertoire :
|
||||||
|
`utils.py.before-deep-compare-fix.bak`.
|
||||||
|
|
||||||
|
Pour annuler le patch sur cette installation, avant toute autre modification
|
||||||
|
ou mise à jour de `utils.py` :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp /usr/local/lib/python3.12/site-packages/appdaemon/utils.py.before-deep-compare-fix.bak \
|
||||||
|
/usr/local/lib/python3.12/site-packages/appdaemon/utils.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Redémarrer ensuite AppDaemon. Ne pas restaurer cette sauvegarde sur une
|
||||||
|
version différente d'AppDaemon.
|
||||||
@@ -6,6 +6,8 @@ appdaemon:
|
|||||||
internal_function_timeout: 00:20:00
|
internal_function_timeout: 00:20:00
|
||||||
exclude_dirs:
|
exclude_dirs:
|
||||||
- unit_tests
|
- unit_tests
|
||||||
|
- vscode-appdaemon
|
||||||
|
- node_modules
|
||||||
plugins:
|
plugins:
|
||||||
HASS:
|
HASS:
|
||||||
type: hass
|
type: hass
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ informations_collector:
|
|||||||
reset_error_count_button: input_button.reset_error_count
|
reset_error_count_button: input_button.reset_error_count
|
||||||
output_sensor: sensor.ad_errors
|
output_sensor: sensor.ad_errors
|
||||||
|
|
||||||
|
warning_collector:
|
||||||
|
reset_warning_count_button: input_button.reset_error_count
|
||||||
|
output_sensor: sensor.ad_warnings
|
||||||
|
|
||||||
min_max_temp_sensors:
|
min_max_temp_sensors:
|
||||||
temperature_sensors:
|
temperature_sensors:
|
||||||
- sensor.ext_front_temperature
|
- sensor.ext_front_temperature
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ vertex_2:
|
|||||||
sensors:
|
sensors:
|
||||||
binary_sensor.projector_screen_down: input_boolean.projection_screen
|
binary_sensor.projector_screen_down: input_boolean.projection_screen
|
||||||
|
|
||||||
|
input_select:
|
||||||
|
entity: select.vertex2_input
|
||||||
|
mqtt_namespace: mqtt
|
||||||
|
mqtt_topic: appdaemon/vertex2/input
|
||||||
|
unique_id: vertex2_input
|
||||||
|
|
||||||
input_config:
|
input_config:
|
||||||
default:
|
default:
|
||||||
name: None
|
name: None
|
||||||
@@ -182,6 +188,13 @@ vertex_2:
|
|||||||
name: Switch
|
name: Switch
|
||||||
icon: mdi:nintendo-switch
|
icon: mdi:nintendo-switch
|
||||||
|
|
||||||
|
# input_selection:
|
||||||
|
# None: binary_sensor.vertex2_has_input_signal # this will prevent input change if the current input is still active
|
||||||
|
# Switch: binary_sensor.vertex2_switch_5v
|
||||||
|
# Playstation: binary_sensor.vertex2_playstation_5v
|
||||||
|
# Xbox: binary_sensor.vertex2_xbox_5v
|
||||||
|
# AppleTV: true
|
||||||
|
|
||||||
output_config:
|
output_config:
|
||||||
default:
|
default:
|
||||||
name: None
|
name: None
|
||||||
@@ -211,13 +224,6 @@ vertex_2:
|
|||||||
Projector: binary_sensor.projector_screen_down
|
Projector: binary_sensor.projector_screen_down
|
||||||
TV: True
|
TV: True
|
||||||
|
|
||||||
# input_selection:
|
|
||||||
# None: binary_sensor.vertex2_has_input_signal # this will prevent input change if the current input is still active
|
|
||||||
# Switch: binary_sensor.is_playing_switch
|
|
||||||
# AppleTV: not binary_sensor.is_appletv_standy
|
|
||||||
# Playstation: switch.ps5_136_power
|
|
||||||
# Xbox: True
|
|
||||||
|
|
||||||
vertex2_address: http://10.0.0.33
|
vertex2_address: http://10.0.0.33
|
||||||
vertex2_tcp_port: 2220
|
vertex2_tcp_port: 2220
|
||||||
startup_delay: 30 # seconds after the power plug turns on
|
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'
|
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:
|
data:
|
||||||
entity_id: media_player.living_room_sonos
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ virtual_sensors:
|
|||||||
binary_sensor.is_playstation_on: switch.ps5_136_power
|
binary_sensor.is_playstation_on: switch.ps5_136_power
|
||||||
sensor.vertex2_input_selected_icon: sensor.vertex2_input_selected.icon
|
sensor.vertex2_input_selected_icon: sensor.vertex2_input_selected.icon
|
||||||
retain_condition.vertex2_has_input_signal:
|
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:
|
conditions:
|
||||||
trigger_conditions: (sensor.vertex2_video_signal and sensor.vertex2_video_signal != 'no signal') or sensor.vertex2_audio_signal
|
trigger_conditions: (sensor.vertex2_video_signal and sensor.vertex2_video_signal != 'no signal') or sensor.vertex2_audio_signal
|
||||||
disable_conditions: self and (binary_sensor.vertex2_unreachable or binary_sensor.vertex2_command_processing)
|
disable_conditions: self and (binary_sensor.vertex2_unreachable or binary_sensor.vertex2_command_processing)
|
||||||
|
|||||||
@@ -51,6 +51,24 @@ class InformationsCollector(SmartObject):
|
|||||||
else:
|
else:
|
||||||
self.log_error("No output_sensor defined for error_collector")
|
self.log_error("No output_sensor defined for error_collector")
|
||||||
|
|
||||||
|
if 'warning_collector' in self.args:
|
||||||
|
if 'output_sensor' in self.args['warning_collector']:
|
||||||
|
sensor_name = self.args['warning_collector']['output_sensor']
|
||||||
|
self.warning_list = []
|
||||||
|
self.warning_log_handle = self.listen_log(self.on_log_warning, "WARNING", log="main_log")
|
||||||
|
self.warning_count_sensor = self.create_entity(
|
||||||
|
sensor_name, state=0,
|
||||||
|
attributes={"last_warning": "", "warning_list": []},
|
||||||
|
icon="mdi:alert-outline"
|
||||||
|
)
|
||||||
|
if 'reset_warning_count_button' in self.args['warning_collector']:
|
||||||
|
self.listen_state(
|
||||||
|
self.on_reset_warning_count,
|
||||||
|
self.args['warning_collector']['reset_warning_count_button']
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.log_error("No output_sensor defined for warning_collector")
|
||||||
|
|
||||||
if 'min_max_temp_sensors' in self.args:
|
if 'min_max_temp_sensors' in self.args:
|
||||||
for sensor in self.args['min_max_temp_sensors']['temperature_sensors']:
|
for sensor in self.args['min_max_temp_sensors']['temperature_sensors']:
|
||||||
self.listen_state(self.on_temperature_change, sensor)
|
self.listen_state(self.on_temperature_change, sensor)
|
||||||
@@ -218,6 +236,21 @@ class InformationsCollector(SmartObject):
|
|||||||
self.error_list.clear()
|
self.error_list.clear()
|
||||||
self.error_count_sensor.set_state(state=0, attributes={"last_error": "", "error_list": self.error_list})
|
self.error_count_sensor.set_state(state=0, attributes={"last_error": "", "error_list": self.error_list})
|
||||||
|
|
||||||
|
def on_log_warning(self, name, ts, level, type, message, kwargs):
|
||||||
|
warning_count = int(self.warning_count_sensor.get_state()) + 1
|
||||||
|
self.warning_list.append(message)
|
||||||
|
self.warning_count_sensor.set_state(
|
||||||
|
state=warning_count,
|
||||||
|
attributes={"last_warning": message, "warning_list": self.warning_list}
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_reset_warning_count(self, entity, attribute, old, new, kwargs):
|
||||||
|
self.warning_list.clear()
|
||||||
|
self.warning_count_sensor.set_state(
|
||||||
|
state=0,
|
||||||
|
attributes={"last_warning": "", "warning_list": self.warning_list}
|
||||||
|
)
|
||||||
|
|
||||||
def _on_group_condition_changed(self, prev_result,result):
|
def _on_group_condition_changed(self, prev_result,result):
|
||||||
self.update_group_collectors()
|
self.update_group_collectors()
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,35 @@ from smartswitch import SmartSwitch
|
|||||||
from ad_toolbox.eventhandler import EventHandler
|
from ad_toolbox.eventhandler import EventHandler
|
||||||
import ad_toolbox.smartcondition as SmartCondition
|
import ad_toolbox.smartcondition as SmartCondition
|
||||||
import telnetlib
|
import telnetlib
|
||||||
import time
|
import queue
|
||||||
|
import threading
|
||||||
|
from functools import wraps
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
def queued_operation(method):
|
||||||
|
"""Keep compound operations and their retries together in the FIFO worker."""
|
||||||
|
@wraps(method)
|
||||||
|
def enqueue(self, *args, **kwargs):
|
||||||
|
if self._command_stop.is_set():
|
||||||
|
return None
|
||||||
|
if threading.current_thread() is self._command_worker:
|
||||||
|
return method(self, *args, **kwargs)
|
||||||
|
self._command_queue.put((method, args, kwargs))
|
||||||
|
return None
|
||||||
|
return enqueue
|
||||||
|
|
||||||
|
|
||||||
class SmartProjector(SmartSwitch):
|
class SmartProjector(SmartSwitch):
|
||||||
|
|
||||||
WARM_UP_TIME = 35
|
WARM_UP_TIME = 35
|
||||||
def get_default_entity_state(self): return 'off'
|
def get_default_entity_state(self): return 'off'
|
||||||
|
|
||||||
def on_initialize_smart_object(self):
|
def on_initialize_smart_object(self):
|
||||||
|
# Queue callbacks during initialization; start only once setup is complete.
|
||||||
|
self._command_queue = queue.Queue()
|
||||||
|
self._command_stop = threading.Event()
|
||||||
|
self._command_worker = threading.Thread(
|
||||||
|
target=self._process_commands, name=f"{self.name}-telnet", daemon=True)
|
||||||
#those variable are use withing get_default_entity_state()
|
#those variable are use withing get_default_entity_state()
|
||||||
self.telnet_ip = self.args['telnet_connection']['ip']
|
self.telnet_ip = self.args['telnet_connection']['ip']
|
||||||
self.telnet_port = self.args['telnet_connection']['port']
|
self.telnet_port = self.args['telnet_connection']['port']
|
||||||
@@ -63,6 +83,7 @@ class SmartProjector(SmartSwitch):
|
|||||||
self.reset_update_state_cb()
|
self.reset_update_state_cb()
|
||||||
#self.entity.listen_state(self.on_state_change)
|
#self.entity.listen_state(self.on_state_change)
|
||||||
self.update_state()
|
self.update_state()
|
||||||
|
self._command_worker.start()
|
||||||
|
|
||||||
def on_power_change(self, entity, attribute, old, new, kwargs):
|
def on_power_change(self, entity, attribute, old, new, kwargs):
|
||||||
self.update_state()
|
self.update_state()
|
||||||
@@ -91,6 +112,7 @@ class SmartProjector(SmartSwitch):
|
|||||||
|
|
||||||
def is_available(self): return self.entity.get_state() != 'unavailable'
|
def is_available(self): return self.entity.get_state() != 'unavailable'
|
||||||
|
|
||||||
|
@queued_operation
|
||||||
def switch_on(self):
|
def switch_on(self):
|
||||||
if self.is_available():
|
if self.is_available():
|
||||||
self.log(f"Turn on {self.entity_id}")
|
self.log(f"Turn on {self.entity_id}")
|
||||||
@@ -111,6 +133,7 @@ class SmartProjector(SmartSwitch):
|
|||||||
else:
|
else:
|
||||||
self.log_warning("Turn On ignored as the projector is unavailable")
|
self.log_warning("Turn On ignored as the projector is unavailable")
|
||||||
|
|
||||||
|
@queued_operation
|
||||||
def switch_off(self):
|
def switch_off(self):
|
||||||
if self.is_available():
|
if self.is_available():
|
||||||
self.log(f"Turn off {self.entity_id}")
|
self.log(f"Turn off {self.entity_id}")
|
||||||
@@ -140,6 +163,7 @@ class SmartProjector(SmartSwitch):
|
|||||||
else: raise
|
else: raise
|
||||||
else: return 'off'
|
else: return 'off'
|
||||||
|
|
||||||
|
@queued_operation
|
||||||
def on_update_picture_mode(self):
|
def on_update_picture_mode(self):
|
||||||
for evaluator in self.picture_mode_evaluators:
|
for evaluator in self.picture_mode_evaluators:
|
||||||
if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
|
if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
|
||||||
@@ -155,6 +179,7 @@ class SmartProjector(SmartSwitch):
|
|||||||
self.entity.set_state(attributes = self.attributes)
|
self.entity.set_state(attributes = self.attributes)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
@queued_operation
|
||||||
def on_update_lamp_mode(self):
|
def on_update_lamp_mode(self):
|
||||||
for evaluator in self.lamp_mode_evaluators:
|
for evaluator in self.lamp_mode_evaluators:
|
||||||
if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
|
if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
|
||||||
@@ -163,6 +188,7 @@ class SmartProjector(SmartSwitch):
|
|||||||
result = self.send_cmd(f"lampm={evaluator[1]}",True)
|
result = self.send_cmd(f"lampm={evaluator[1]}",True)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
@queued_operation
|
||||||
def update_state(self,kwargs = None):
|
def update_state(self,kwargs = None):
|
||||||
new_state = self.get_projector_state()
|
new_state = self.get_projector_state()
|
||||||
|
|
||||||
@@ -171,7 +197,7 @@ class SmartProjector(SmartSwitch):
|
|||||||
|
|
||||||
if new_state == 'on':
|
if new_state == 'on':
|
||||||
# the projector is not responding during power on sequence
|
# the projector is not responding during power on sequence
|
||||||
if self.attributes['warming_up'] == 'off':
|
if not self.attributes['warming_up']:
|
||||||
cmd_result = self.send_cmd("appmod=?")
|
cmd_result = self.send_cmd("appmod=?")
|
||||||
if cmd_result: self.attributes['picture_mode'] = cmd_result.split("=")[1]
|
if cmd_result: self.attributes['picture_mode'] = cmd_result.split("=")[1]
|
||||||
else: self.attributes['picture_mode'] = "unavailable"
|
else: self.attributes['picture_mode'] = "unavailable"
|
||||||
@@ -193,6 +219,7 @@ class SmartProjector(SmartSwitch):
|
|||||||
|
|
||||||
self.attributes['warming_up'] = new_value
|
self.attributes['warming_up'] = new_value
|
||||||
|
|
||||||
|
@queued_operation
|
||||||
def on_warm_up_done(self,*kwargs):
|
def on_warm_up_done(self,*kwargs):
|
||||||
self.warming_up_done_cb_handle = None
|
self.warming_up_done_cb_handle = None
|
||||||
self.attributes['warming_up'] = False
|
self.attributes['warming_up'] = False
|
||||||
@@ -201,48 +228,70 @@ class SmartProjector(SmartSwitch):
|
|||||||
def on_press_enter_event(self, event_name, data, event_context):
|
def on_press_enter_event(self, event_name, data, event_context):
|
||||||
self.press_enter()
|
self.press_enter()
|
||||||
|
|
||||||
|
@queued_operation
|
||||||
def press_enter(self):
|
def press_enter(self):
|
||||||
if self.entity.get_state() == 'on':
|
if self.entity.get_state() == 'on':
|
||||||
self.send_cmd("enter")
|
self.send_cmd("enter")
|
||||||
|
|
||||||
|
@queued_operation
|
||||||
def send_cmd(self,cmd, log_command = False):
|
def send_cmd(self,cmd, log_command = False):
|
||||||
cmd = f"*{cmd}#"
|
cmd = f"*{cmd}#"
|
||||||
return self._send_cmd(cmd,log_command,3)
|
return self._send_cmd(cmd,log_command,3)
|
||||||
|
|
||||||
def _send_cmd(self,cmd,log_command,try_count):
|
def _process_commands(self):
|
||||||
invalid_result = [ "*Unsupported Item#", "*Block Item#", "*Illegal format#" ]
|
while not self._command_stop.is_set():
|
||||||
|
operation = self._command_queue.get()
|
||||||
should_retry = False
|
|
||||||
with telnetlib.Telnet(self.telnet_ip,self.telnet_port) as session:
|
|
||||||
if log_command: self.log_info(f'Sending command "{cmd}"')
|
|
||||||
try:
|
try:
|
||||||
session.write(bytes(cmd,'utf-8'))
|
if operation is None or self._command_stop.is_set():
|
||||||
output = session.read_until(b"#",timeout = 5).decode('ascii')
|
return
|
||||||
except (ConnectionResetError,EOFError):
|
method, args, kwargs = operation
|
||||||
self.log_warning(f"Connection Reset While sending command {cmd}. {try_count} try left")
|
try:
|
||||||
if try_count <= 0:
|
method(self, *args, **kwargs)
|
||||||
self.log_error(f"Could not send command {cmd}",dump_stack = True)
|
except Exception as error:
|
||||||
return None
|
self.log_error(f"Projector operation {method.__name__} failed: {error}", dump_stack=True)
|
||||||
else: should_retry = 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()
|
||||||
|
|
||||||
# opening session recursivly inside the with doesn't sound like a good idea
|
def _send_cmd(self, cmd, log_command, try_count):
|
||||||
if should_retry:
|
invalid_result = {"*Unsupported Item#", "*Block Item#", "*Illegal format#"}
|
||||||
if try_count == 1:
|
for attempt in range(try_count + 1):
|
||||||
# I know calling sleep() is in callback is evil
|
if self._command_stop.is_set():
|
||||||
# but this is a very small sleep time and working with event would make the code much much more complexe
|
|
||||||
time.sleep(1)
|
|
||||||
return self._send_cmd(cmd,log_command,try_count - 1)
|
|
||||||
|
|
||||||
if log_command: self.log_info(f"Result = {output}")
|
|
||||||
if output in invalid_result:
|
|
||||||
self.log_error(f"Command rejected. {cmd} returned {output}",dump_stack = True)
|
|
||||||
return None
|
|
||||||
if not output:
|
|
||||||
if try_count > 0:
|
|
||||||
self.log_warning(f"Command output is empty. {cmd}. {try_count} try left")
|
|
||||||
return self._send_cmd(cmd,log_command,try_count - 1)
|
|
||||||
else:
|
|
||||||
self.log_error(f"Command output is empty. {cmd}. {try_count} try left",dump_stack = True)
|
|
||||||
return None
|
return None
|
||||||
return output.strip("*").rstrip("#")
|
try:
|
||||||
|
with telnetlib.Telnet(self.telnet_ip, self.telnet_port, timeout=2) as session:
|
||||||
|
if log_command:
|
||||||
|
self.log_info(f'Sending command "{cmd}"')
|
||||||
|
session.write(cmd.encode('utf-8'))
|
||||||
|
output = session.read_until(b"#", timeout=5).decode('ascii').strip()
|
||||||
|
if self._command_stop.is_set():
|
||||||
|
return None
|
||||||
|
if log_command:
|
||||||
|
self.log_info(f"Result = {output}")
|
||||||
|
if output in invalid_result:
|
||||||
|
self.log_error(f"Command rejected. {cmd} returned {output}", dump_stack=True)
|
||||||
|
return None
|
||||||
|
# read_until may return a partial response when its timeout expires.
|
||||||
|
if output.endswith("#"):
|
||||||
|
return output.strip("*").rstrip("#")
|
||||||
|
reason = f"empty or incomplete response: {output!r}"
|
||||||
|
except (OSError, EOFError) as error:
|
||||||
|
if self._command_stop.is_set():
|
||||||
|
return None
|
||||||
|
if isinstance(error, OSError) and error.errno == 113:
|
||||||
|
raise
|
||||||
|
reason = str(error)
|
||||||
|
remaining = try_count - attempt
|
||||||
|
self.log_info(f"Command {cmd} failed ({reason}); {remaining} retries left")
|
||||||
|
if remaining and self._command_stop.wait(1):
|
||||||
|
return None
|
||||||
|
self.log_error(f"Could not send command {cmd}", dump_stack=True)
|
||||||
|
return None
|
||||||
+174
-3
@@ -4,6 +4,7 @@ from requests.exceptions import Timeout,ConnectionError,RequestException
|
|||||||
from ad_toolbox.smartobject import SmartObject
|
from ad_toolbox.smartobject import SmartObject
|
||||||
import ad_toolbox.smartcondition as SmartCondition
|
import ad_toolbox.smartcondition as SmartCondition
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import re
|
import re
|
||||||
@@ -16,6 +17,7 @@ class Vertex2(SmartObject):
|
|||||||
self._interrupt_stop = threading.Event()
|
self._interrupt_stop = threading.Event()
|
||||||
self._status_requested = threading.Event()
|
self._status_requested = threading.Event()
|
||||||
self._last_logged_status = None
|
self._last_logged_status = None
|
||||||
|
self._input_5v_states = {}
|
||||||
self._spam_lock = threading.RLock()
|
self._spam_lock = threading.RLock()
|
||||||
self._spam_last_interrupt = None
|
self._spam_last_interrupt = None
|
||||||
self._spam_snapshot = None
|
self._spam_snapshot = None
|
||||||
@@ -186,9 +188,171 @@ class Vertex2(SmartObject):
|
|||||||
name=f'{self.name}_interrupt_listener',
|
name=f'{self.name}_interrupt_listener',
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
|
self._initialize_input_5v_sensors()
|
||||||
|
self._initialize_input_select()
|
||||||
self.update_vertex2_states()
|
self.update_vertex2_states()
|
||||||
self._interrupt_thread.start()
|
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 _schedule_input_5v_refresh(self):
|
||||||
|
# Device monitoring must run even when no HA input sensors are exposed.
|
||||||
|
self.run_in(self._refresh_input_5v_sensors, 0.1)
|
||||||
|
|
||||||
|
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()
|
||||||
|
self.log_warning(f'Vertex2 input +5V readback failed: {error}')
|
||||||
|
return
|
||||||
|
if not self._vertex2_ready():
|
||||||
|
self._invalidate_input_5v_sensors()
|
||||||
|
return
|
||||||
|
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'):
|
def _reset_no_signal_spam(self, state='off'):
|
||||||
if getattr(self, 'no_signal_spam_sensor', None) is None:
|
if getattr(self, 'no_signal_spam_sensor', None) is None:
|
||||||
return
|
return
|
||||||
@@ -200,7 +364,7 @@ class Vertex2(SmartObject):
|
|||||||
self._spam_last_interrupt = None
|
self._spam_last_interrupt = None
|
||||||
self._spam_snapshot = None
|
self._spam_snapshot = None
|
||||||
self._spam_count = 0
|
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)
|
self.no_signal_spam_sensor.set_state(state=state)
|
||||||
|
|
||||||
def _observe_no_signal_spam(self, status):
|
def _observe_no_signal_spam(self, status):
|
||||||
@@ -342,6 +506,7 @@ class Vertex2(SmartObject):
|
|||||||
continue
|
continue
|
||||||
self._reset_no_signal_spam('unavailable')
|
self._reset_no_signal_spam('unavailable')
|
||||||
self._invalidate_plus5_sensors()
|
self._invalidate_plus5_sensors()
|
||||||
|
self._invalidate_input_5v_sensors()
|
||||||
self.unreachable_sensor.set_state(state='on')
|
self.unreachable_sensor.set_state(state='on')
|
||||||
self.video_tx_signal_sensor.set_state(state='unavailable')
|
self.video_tx_signal_sensor.set_state(state='unavailable')
|
||||||
for sensor in self.video_tx_signal_sensors.values():
|
for sensor in self.video_tx_signal_sensors.values():
|
||||||
@@ -370,10 +535,11 @@ class Vertex2(SmartObject):
|
|||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
def _stop_interrupt_listener(self):
|
def _stop_interrupt_listener(self):
|
||||||
self._reset_no_signal_spam('unavailable')
|
|
||||||
interrupt_stop = getattr(self, '_interrupt_stop', None)
|
interrupt_stop = getattr(self, '_interrupt_stop', None)
|
||||||
if interrupt_stop is not None:
|
if interrupt_stop is not None:
|
||||||
interrupt_stop.set()
|
interrupt_stop.set()
|
||||||
|
# Cancel spam tracking without emitting state changes during app teardown.
|
||||||
|
self._reset_no_signal_spam(state=None)
|
||||||
self._close_interrupt_connection()
|
self._close_interrupt_connection()
|
||||||
interrupt_thread = getattr(self, '_interrupt_thread', None)
|
interrupt_thread = getattr(self, '_interrupt_thread', None)
|
||||||
if interrupt_thread is not None:
|
if interrupt_thread is not None:
|
||||||
@@ -391,6 +557,8 @@ class Vertex2(SmartObject):
|
|||||||
self._command_processing_handle = None
|
self._command_processing_handle = None
|
||||||
|
|
||||||
def terminate(self):
|
def terminate(self):
|
||||||
|
if getattr(self, '_input_select_config', None):
|
||||||
|
self._publish_input_select('/availability', 'offline')
|
||||||
self._stop_interrupt_listener()
|
self._stop_interrupt_listener()
|
||||||
super().terminate()
|
super().terminate()
|
||||||
|
|
||||||
@@ -553,6 +721,7 @@ class Vertex2(SmartObject):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _suspend_vertex2(self):
|
def _suspend_vertex2(self):
|
||||||
|
self._invalidate_input_5v_sensors()
|
||||||
self._reset_no_signal_spam()
|
self._reset_no_signal_spam()
|
||||||
self._invalidate_plus5_sensors()
|
self._invalidate_plus5_sensors()
|
||||||
self._close_interrupt_connection()
|
self._close_interrupt_connection()
|
||||||
@@ -591,7 +760,7 @@ class Vertex2(SmartObject):
|
|||||||
self.turn_off(self.power_plug_entity)
|
self.turn_off(self.power_plug_entity)
|
||||||
|
|
||||||
def _start_command_processing(self, only_if_active=False):
|
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:
|
if self.command_processing_sensor is None:
|
||||||
return
|
return
|
||||||
with self._command_processing_lock:
|
with self._command_processing_lock:
|
||||||
@@ -905,3 +1074,5 @@ class Vertex2(SmartObject):
|
|||||||
if kwargs.get('reconfigure', False):
|
if kwargs.get('reconfigure', False):
|
||||||
self._reconfigure_vertex2()
|
self._reconfigure_vertex2()
|
||||||
self._refresh_plus5_sensors()
|
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()
|
||||||
@@ -19,6 +19,8 @@ class Harness(Vertex2):
|
|||||||
self._interrupt_stop = threading.Event()
|
self._interrupt_stop = threading.Event()
|
||||||
self._status_requested = threading.Event()
|
self._status_requested = threading.Event()
|
||||||
self._last_logged_status = None
|
self._last_logged_status = None
|
||||||
|
self._input_5v_states = {}
|
||||||
|
self._schedule_input_5v_refresh = Mock()
|
||||||
self._connection_lock = threading.Lock()
|
self._connection_lock = threading.Lock()
|
||||||
self._interrupt_connection = None
|
self._interrupt_connection = None
|
||||||
self.startup_delay = 30
|
self.startup_delay = 30
|
||||||
@@ -348,6 +350,21 @@ class StatusChangeTests(unittest.TestCase):
|
|||||||
self.status = dict(rx0='no signal', rx1='no signal', tx0='', tx1='',
|
self.status = dict(rx0='no signal', rx1='no signal', tx0='', tx1='',
|
||||||
audout='', insel='0 4')
|
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):
|
def test_identical_status_is_silent_but_still_applied(self):
|
||||||
app = self.app
|
app = self.app
|
||||||
app._apply_vertex2_status(self.status)
|
app._apply_vertex2_status(self.status)
|
||||||
@@ -454,6 +471,32 @@ class SpamSensorTests(unittest.TestCase):
|
|||||||
with patch('vertex2.time.monotonic', return_value=at):
|
with patch('vertex2.time.monotonic', return_value=at):
|
||||||
self.app._observe_no_signal_spam(status)
|
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')
|
||||||
|
app.cancel_timer.assert_any_call('timer')
|
||||||
|
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):
|
def test_three_identical_interrupts_detect_and_silence_clears(self):
|
||||||
self.observe(100)
|
self.observe(100)
|
||||||
self.observe(103)
|
self.observe(103)
|
||||||
@@ -504,3 +547,254 @@ class SpamSensorTests(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.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