907 lines
41 KiB
Python
907 lines
41 KiB
Python
from pathlib import Path
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
sys.path[:0] = [str(Path(__file__).resolve().parents[1] / 'apps'),
|
|
str(Path(__file__).resolve().parents[1] / 'apps/ad_toolbox')]
|
|
from vertex2 import Vertex2
|
|
import ad_toolbox.smartcondition as SmartCondition
|
|
|
|
|
|
class Harness(Vertex2):
|
|
def __init__(self, plugged=True):
|
|
self.plugged = plugged
|
|
self._power_allowed = True
|
|
self.power_plug_entity = 'switch.vertex2'
|
|
self._interrupt_stop = threading.Event()
|
|
self._status_requested = threading.Event()
|
|
self._last_logged_status = None
|
|
self._input_5v_states = {}
|
|
self._cancel_input_5v_refresh()
|
|
self._schedule_input_5v_refresh = Mock()
|
|
self._connection_lock = threading.Lock()
|
|
self._interrupt_connection = None
|
|
self.startup_delay = 30
|
|
self._startup_deadline = 0
|
|
self._startup_commands = []
|
|
self._command_processing_lock = threading.Lock()
|
|
self._command_processing_deadline = 123
|
|
self._command_processing_handle = 'timer'
|
|
self.command_processing_timeout = 30
|
|
self.command_processing_sensor = Mock()
|
|
self.plus5_sensors = {}
|
|
self.audio_out_signal_sensor = Mock()
|
|
self.unreachable_sensor = Mock()
|
|
self._update_output_video_sensors = Mock()
|
|
self.turn_on = Mock()
|
|
self.turn_off = Mock()
|
|
self.cancel_timer = Mock()
|
|
self.log_warning = Mock()
|
|
self.log_info = Mock()
|
|
self.run_in = Mock()
|
|
self.vertex2_tcp_port = 2220
|
|
|
|
@property
|
|
def args(self):
|
|
return {'vertex2_address': 'http://192.0.2.1'}
|
|
|
|
@property
|
|
def name(self):
|
|
return 'test_vertex'
|
|
|
|
def get_state(self, entity):
|
|
return 'on' if self.plugged else 'off'
|
|
|
|
|
|
class PowerTests(unittest.TestCase):
|
|
def test_condition_closes_socket_before_cutting_power(self):
|
|
app = Harness()
|
|
connection = Mock()
|
|
app._interrupt_connection = connection
|
|
app.turn_off.side_effect = lambda entity: connection.close.assert_called_once()
|
|
app.on_change_power_plug_conditions(None, SmartCondition.Result.Failed)
|
|
connection.shutdown.assert_called_once_with(socket.SHUT_RDWR)
|
|
app.turn_off.assert_called_once_with('switch.vertex2')
|
|
self.assertFalse(app._vertex2_powered())
|
|
app.cancel_timer.assert_called_once_with('timer', silent=True)
|
|
self.assertIsNone(app._command_processing_deadline)
|
|
app.command_processing_sensor.set_state.assert_called_once_with(state='off')
|
|
app.unreachable_sensor.set_state.assert_called_once_with(state='off')
|
|
|
|
def test_off_blocks_http_commands(self):
|
|
for plugged, allowed in [(False, True), (True, False)]:
|
|
app = Harness(plugged)
|
|
app._power_allowed = allowed
|
|
with patch('vertex2.get') as get:
|
|
app.send_cmd('reboot')
|
|
get.assert_not_called()
|
|
|
|
def test_no_plug_preserves_connectivity(self):
|
|
app = Harness(False)
|
|
app.power_plug_entity = None
|
|
self.assertTrue(app._vertex2_powered())
|
|
|
|
def test_delayed_status_is_ignored_after_power_off(self):
|
|
app = Harness(False)
|
|
app._apply_vertex2_status({})
|
|
app.log_info.assert_not_called()
|
|
app.run_in.assert_not_called()
|
|
|
|
def test_external_plug_off_suspends_connection(self):
|
|
app = Harness(False)
|
|
connection = Mock()
|
|
app._interrupt_connection = connection
|
|
app.on_power_plug_state_change('switch.vertex2', 'state', 'on', 'off', {})
|
|
connection.close.assert_called_once()
|
|
app._update_output_video_sensors.assert_called_once_with('', '')
|
|
|
|
def test_plug_on_requests_fresh_status(self):
|
|
app = Harness()
|
|
app.on_power_plug_state_change('switch.vertex2', 'state', 'off', 'on', {})
|
|
self.assertTrue(app._status_requested.is_set())
|
|
self.assertFalse(app._vertex2_ready())
|
|
|
|
def test_boot_commands_wait_and_are_replayed(self):
|
|
app = Harness()
|
|
with patch('vertex2.time.monotonic', return_value=100):
|
|
app._begin_startup()
|
|
with patch('vertex2.get') as get:
|
|
app.send_cmd('tx1plus5', 'on')
|
|
app.send_cmd('edid', 'audiotx1 on')
|
|
get.assert_not_called()
|
|
self.assertFalse(app._vertex2_ready())
|
|
with patch('vertex2.time.monotonic', return_value=130):
|
|
self.assertTrue(app._vertex2_ready())
|
|
with patch('vertex2.get') as get:
|
|
app._flush_startup_commands()
|
|
self.assertEqual(get.call_count, 2)
|
|
self.assertEqual(app._startup_commands, [])
|
|
|
|
def test_http_failure_after_boot_still_warns(self):
|
|
from requests.exceptions import ConnectionError
|
|
app = Harness()
|
|
app._startup_deadline = 130
|
|
with patch('vertex2.time.monotonic', return_value=130):
|
|
with patch('vertex2.get', side_effect=ConnectionError):
|
|
app.send_cmd('tx1plus5', 'on')
|
|
app.log_warning.assert_called_once()
|
|
|
|
def test_power_cut_discards_queued_commands(self):
|
|
app = Harness()
|
|
app._begin_startup()
|
|
app.send_cmd('tx1plus5', 'on')
|
|
app._suspend_vertex2()
|
|
self.assertEqual(app._startup_commands, [])
|
|
|
|
def test_listener_does_not_connect_during_boot(self):
|
|
app = Harness()
|
|
app._begin_startup()
|
|
worker = threading.Thread(target=app._interrupt_listener, args=(app._interrupt_stop,))
|
|
with patch('vertex2.socket.create_connection') as connect:
|
|
worker.start()
|
|
try:
|
|
self.assertFalse(app._interrupt_stop.wait(.1))
|
|
connect.assert_not_called()
|
|
finally:
|
|
app._interrupt_stop.set()
|
|
worker.join(2)
|
|
self.assertFalse(worker.is_alive())
|
|
|
|
def test_cut_power_unblocks_recv_without_warning(self):
|
|
app = Harness()
|
|
receiving = threading.Event()
|
|
closed = threading.Event()
|
|
connection = Mock()
|
|
connection.shutdown.side_effect = lambda how: closed.set()
|
|
def recv(size):
|
|
receiving.set()
|
|
closed.wait(2)
|
|
return b''
|
|
connection.recv.side_effect = recv
|
|
context = Mock(__enter__=Mock(return_value=connection), __exit__=Mock(return_value=False))
|
|
worker = threading.Thread(target=app._interrupt_listener, args=(app._interrupt_stop,))
|
|
with patch('vertex2.socket.create_connection', return_value=context):
|
|
worker.start()
|
|
try:
|
|
self.assertTrue(receiving.wait(2))
|
|
app.on_change_power_plug_conditions(None, SmartCondition.Result.Failed)
|
|
self.assertTrue(closed.is_set())
|
|
finally:
|
|
app._interrupt_stop.set()
|
|
closed.set()
|
|
worker.join(2)
|
|
self.assertFalse(worker.is_alive())
|
|
app.log_warning.assert_not_called()
|
|
|
|
def test_listener_waits_off_then_reconnects_when_on(self):
|
|
app = Harness(False)
|
|
attempted = threading.Event()
|
|
def connect(*args, **kwargs):
|
|
attempted.set()
|
|
app._interrupt_stop.set()
|
|
raise OSError('test stop')
|
|
worker = threading.Thread(target=app._interrupt_listener, args=(app._interrupt_stop,))
|
|
with patch('vertex2.socket.create_connection', side_effect=connect) as create:
|
|
worker.start()
|
|
try:
|
|
self.assertFalse(attempted.wait(.1))
|
|
create.assert_not_called()
|
|
app.plugged = True
|
|
self.assertTrue(attempted.wait(2))
|
|
finally:
|
|
app._interrupt_stop.set()
|
|
worker.join(2)
|
|
self.assertFalse(worker.is_alive())
|
|
app.log_warning.assert_not_called()
|
|
|
|
|
|
class StatusTimeoutDiagnosticsTests(unittest.TestCase):
|
|
def test_timeout_reports_missing_fields_and_partial_response(self):
|
|
for silent in (True, False):
|
|
with self.subTest(silent=silent):
|
|
app = Harness()
|
|
app.video_tx_signal_sensor = Mock()
|
|
app.video_tx_signal_sensors = {}
|
|
app.allm_sensor = Mock()
|
|
app.allm_sensors = {}
|
|
app._reset_no_signal_spam = Mock()
|
|
app.log_warning.side_effect = lambda message: app._interrupt_stop.set()
|
|
connection = Mock()
|
|
fields = ('rx0', 'rx1', 'tx0', 'tx1', 'tx0sink', 'tx1sink',
|
|
'aud0', 'aud1', 'audout', 'spd0')
|
|
response = ('\n'.join(f'{field.upper()}: no signal' for field in fields)
|
|
+ '\ninsel 0 0\nint\nSPD1: incom').encode('ascii')
|
|
clock = [100.0]
|
|
calls = [0]
|
|
|
|
def recv(size):
|
|
calls[0] += 1
|
|
if calls[0] == 1:
|
|
return response
|
|
clock[0] = 106.0
|
|
if silent:
|
|
raise socket.timeout()
|
|
return b'plete'
|
|
|
|
connection.recv.side_effect = recv
|
|
context = Mock(__enter__=Mock(return_value=connection),
|
|
__exit__=Mock(return_value=False))
|
|
with patch('vertex2.socket.create_connection', return_value=context), \
|
|
patch('vertex2.time.monotonic', side_effect=lambda: clock[0]):
|
|
app._interrupt_listener(app._interrupt_stop)
|
|
app.log_warning.assert_called_once()
|
|
message = app.log_warning.call_args.args[0]
|
|
self.assertIn("missing=['spd1']", message)
|
|
self.assertIn('elapsed=6.00s, limit=5s', message)
|
|
self.assertIn('interrupts=1', message)
|
|
self.assertIn(f'bytes={len(response) + (0 if silent else 5)}', message)
|
|
self.assertIn("'insel 0 0'", message)
|
|
partial = 'incom' if silent else 'incomplete'
|
|
self.assertIn(f"partial_line='SPD1: {partial}'", message)
|
|
self.assertIn('reconfigure=True', message)
|
|
app.run_in.assert_not_called()
|
|
app.unreachable_sensor.set_state.assert_called_with(state='on')
|
|
|
|
|
|
class ReconfigurationTests(unittest.TestCase):
|
|
def test_reconnect_reapplies_current_plus5_and_selections(self):
|
|
app = Harness()
|
|
app.power_plug_conditions_evaluator = Mock()
|
|
app.power_plug_conditions_evaluator.evaluate.return_value = SmartCondition.Result.Succeeded
|
|
app.input_selection_evaluators = [Mock()]
|
|
app.output_selection_evaluators = [Mock()]
|
|
app.on_update_input_selection = Mock()
|
|
app.on_update_output_selection = Mock()
|
|
tx0, tx1 = Mock(), Mock()
|
|
tx0.evaluate.return_value = SmartCondition.Result.Failed
|
|
tx1.evaluate.return_value = SmartCondition.Result.Succeeded
|
|
app.output_config_conditions = {0: {'force_plus5': tx0}, 1: {'force_plus5': tx1}}
|
|
app.force_tx0_plus5 = Mock()
|
|
app.force_tx1_plus5 = Mock()
|
|
app._reconfigure_vertex2()
|
|
app.power_plug_conditions_evaluator.evaluate.assert_called_once_with(False)
|
|
app.on_update_input_selection.assert_called_once_with()
|
|
app.on_update_output_selection.assert_called_once_with()
|
|
app.force_tx0_plus5.assert_called_once_with(False)
|
|
app.force_tx1_plus5.assert_called_once_with(True)
|
|
for evaluator in (tx0, tx1):
|
|
evaluator.evaluate.assert_called_once_with(False)
|
|
|
|
def test_reconnect_stops_if_power_condition_no_longer_allows_device(self):
|
|
app = Harness()
|
|
app.power_plug_conditions_evaluator = Mock()
|
|
app.power_plug_conditions_evaluator.evaluate.return_value = SmartCondition.Result.Failed
|
|
app.on_update_output_selection = Mock()
|
|
app._reconfigure_vertex2()
|
|
app.turn_off.assert_called_once_with('switch.vertex2')
|
|
app.on_update_output_selection.assert_not_called()
|
|
|
|
def test_input_selection_without_matching_condition_does_not_send_command(self):
|
|
app = Harness()
|
|
evaluator = Mock()
|
|
evaluator.evaluate.return_value = SmartCondition.Result.Failed
|
|
app.input_selection_evaluators = [(evaluator, 'AppleTV')]
|
|
app.select_input = Mock()
|
|
app.on_update_input_selection()
|
|
app.select_input.assert_not_called()
|
|
|
|
def test_only_first_complete_status_requests_reconfiguration(self):
|
|
app = Harness()
|
|
fields = ('rx0', 'rx1', 'tx0', 'tx1', 'tx0sink', 'tx1sink',
|
|
'aud0', 'aud1', 'audout', 'spd0', 'spd1')
|
|
response = ('\n'.join(f'{field.upper()}: no signal' for field in fields)
|
|
+ '\ninsel 0 0\n').encode('ascii')
|
|
connection = Mock()
|
|
responses = iter([response, response])
|
|
def recv(size):
|
|
try:
|
|
return next(responses)
|
|
except StopIteration:
|
|
app._interrupt_stop.set()
|
|
return b''
|
|
connection.recv.side_effect = recv
|
|
app.run_in.side_effect = lambda *args, **kwargs: app._status_requested.set()
|
|
context = Mock(__enter__=Mock(return_value=connection), __exit__=Mock(return_value=False))
|
|
with patch('vertex2.socket.create_connection', return_value=context):
|
|
app._interrupt_listener(app._interrupt_stop)
|
|
self.assertEqual([call.kwargs['reconfigure'] for call in app.run_in.call_args_list],
|
|
[True, False])
|
|
|
|
def test_status_reconfigures_before_readback(self):
|
|
app = Harness()
|
|
app.input_selected_sensors = {i: Mock() for i in range(2)}
|
|
app.generate_input_selected_sensor_name = lambda i: i
|
|
app.get_input_config = Mock(return_value={'name': 'AppleTV'})
|
|
app.output_names = {}
|
|
app.output_selected_sensor = Mock()
|
|
app._refresh_selected_input_sensor = Mock()
|
|
sequence = Mock()
|
|
app._flush_startup_commands = sequence.flush
|
|
app._reconfigure_vertex2 = sequence.reconfigure
|
|
app._refresh_plus5_sensors = sequence.readback
|
|
app._apply_vertex2_status(dict(rx0='', rx1='', tx0='', tx1='', audout='',
|
|
insel='0 0', reconfigure=True))
|
|
self.assertEqual([call[0] for call in sequence.mock_calls],
|
|
['flush', 'reconfigure', 'readback'])
|
|
sequence.reset_mock()
|
|
app._apply_vertex2_status(dict(rx0='', rx1='', tx0='', tx1='', audout='', insel='0 0'))
|
|
sequence.reconfigure.assert_not_called()
|
|
|
|
|
|
class Plus5Tests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.app = Harness()
|
|
self.app.plus5_sensors = {i: Mock() for i in range(2)}
|
|
|
|
def test_reads_actual_settings_independently(self):
|
|
with patch('vertex2.get') as get:
|
|
get.return_value.json.return_value = {'tx0plus5': '0', 'tx1plus5': '1'}
|
|
self.app._refresh_plus5_sensors()
|
|
self.app.plus5_sensors[0].set_state.assert_called_once_with(state='off')
|
|
self.app.plus5_sensors[1].set_state.assert_called_once_with(state='on')
|
|
|
|
def test_unknown_value_does_not_confirm_off(self):
|
|
with patch('vertex2.get') as get:
|
|
get.return_value.json.return_value = {'tx0plus5': 'unexpected'}
|
|
self.app._refresh_plus5_sensors()
|
|
for sensor in self.app.plus5_sensors.values():
|
|
sensor.set_state.assert_called_once_with(state='unavailable')
|
|
|
|
def test_failed_or_malformed_read_invalidates_old_settings(self):
|
|
from requests.exceptions import HTTPError, Timeout
|
|
for error in (HTTPError('503'), Timeout(), ValueError('invalid JSON')):
|
|
with self.subTest(error=error):
|
|
for sensor in self.app.plus5_sensors.values():
|
|
sensor.reset_mock()
|
|
with patch('vertex2.get', side_effect=error):
|
|
self.app._refresh_plus5_sensors()
|
|
for sensor in self.app.plus5_sensors.values():
|
|
sensor.set_state.assert_called_once_with(state='unavailable')
|
|
|
|
def test_power_off_during_read_discards_response(self):
|
|
with patch('vertex2.get') as get:
|
|
def result():
|
|
self.app.plugged = False
|
|
return {'tx0plus5': '1', 'tx1plus5': '1'}
|
|
get.return_value.json.side_effect = result
|
|
self.app._refresh_plus5_sensors()
|
|
for sensor in self.app.plus5_sensors.values():
|
|
sensor.set_state.assert_called_once_with(state='unavailable')
|
|
|
|
def test_command_requests_readback_without_optimistic_confirmation(self):
|
|
with patch('vertex2.get'):
|
|
self.app.send_cmd('tx0plus5', 'on')
|
|
self.assertTrue(self.app._status_requested.is_set())
|
|
for sensor in self.app.plus5_sensors.values():
|
|
sensor.set_state.assert_called_once_with(state='unavailable')
|
|
|
|
def test_power_cut_invalidates_settings(self):
|
|
self.app._suspend_vertex2()
|
|
for sensor in self.app.plus5_sensors.values():
|
|
sensor.set_state.assert_called_once_with(state='unavailable')
|
|
|
|
|
|
class StatusChangeTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.app = app = Harness()
|
|
app.input_selected_sensors = {i: Mock() for i in range(2)}
|
|
app.generate_input_selected_sensor_name = lambda i: i
|
|
app.get_input_config = Mock(return_value={'name': 'AppleTV'})
|
|
app.output_names = {}
|
|
app.output_selected_sensor = Mock()
|
|
app._refresh_selected_input_sensor = Mock()
|
|
app._flush_startup_commands = Mock()
|
|
app._reconfigure_vertex2 = Mock()
|
|
app._refresh_plus5_sensors = Mock()
|
|
self.status = dict(rx0='no signal', rx1='no signal', tx0='', tx1='',
|
|
audout='', insel='0 4')
|
|
|
|
def test_input_5v_refreshes_on_interrupt_even_when_video_status_is_unchanged(self):
|
|
app = self.app
|
|
app.input_5v_sensors = {}
|
|
app._schedule_input_5v_refresh = lambda: Vertex2._schedule_input_5v_refresh(app)
|
|
app._refresh_input_5v_sensors = Mock()
|
|
for metadata in ({'reconfigure': True}, {'interrupt_at': 100}):
|
|
app.run_in.reset_mock()
|
|
app._refresh_input_5v_sensors.reset_mock()
|
|
app._apply_vertex2_status(dict(self.status, **metadata))
|
|
app._refresh_input_5v_sensors.assert_not_called()
|
|
app.run_in.assert_called_once_with(app._on_input_5v_refresh, 1,
|
|
generation=app._input_5v_refresh_generation)
|
|
callback = app.run_in.call_args.args[0]
|
|
callback(app.run_in.call_args.kwargs)
|
|
app._refresh_input_5v_sensors.assert_called_once_with()
|
|
|
|
def test_identical_status_is_silent_but_still_applied(self):
|
|
app = self.app
|
|
app._apply_vertex2_status(self.status)
|
|
app._apply_vertex2_status(dict(self.status, reconfigure=True))
|
|
app.log_info.assert_called_once()
|
|
app.run_in.assert_not_called()
|
|
self.assertEqual(app._update_output_video_sensors.call_count, 2)
|
|
app._reconfigure_vertex2.assert_called_once()
|
|
|
|
def test_changed_status_extends_active_window_only_once(self):
|
|
app = self.app
|
|
app._apply_vertex2_status(self.status)
|
|
changed = dict(self.status, tx0='4K50')
|
|
with patch('vertex2.time.monotonic', return_value=200):
|
|
app._apply_vertex2_status(changed)
|
|
self.assertEqual(app._command_processing_deadline, 230)
|
|
app._apply_vertex2_status(changed)
|
|
self.assertEqual(app.log_info.call_count, 2)
|
|
app.run_in.assert_called_once()
|
|
app._on_command_processing_timeout({'deadline': 123})
|
|
self.assertEqual(app._command_processing_deadline, 230)
|
|
app._on_command_processing_timeout({'deadline': 230})
|
|
app.command_processing_sensor.set_state.assert_called_with(state='off')
|
|
|
|
def test_change_without_active_command_logs_without_starting_window(self):
|
|
app = self.app
|
|
app._command_processing_deadline = None
|
|
app._apply_vertex2_status(self.status)
|
|
app._apply_vertex2_status(dict(self.status, audout='L-PCM'))
|
|
self.assertEqual(app.log_info.call_count, 2)
|
|
app.run_in.assert_not_called()
|
|
app.command_processing_sensor.set_state.assert_not_called()
|
|
|
|
|
|
class CommandProcessingTests(unittest.TestCase):
|
|
def test_interrupt_spam_does_not_extend_command_deadline(self):
|
|
app = Harness()
|
|
app.command_processing_sensor.get_state.return_value = 'off'
|
|
with patch('vertex2.time.monotonic', return_value=100), patch('vertex2.get'):
|
|
app.send_cmd('mutetx0audio', 'on')
|
|
self.assertEqual(app._command_processing_deadline, 130)
|
|
app.run_in.reset_mock()
|
|
app.cancel_timer.reset_mock()
|
|
connection = Mock()
|
|
chunks = iter([b'int\n'] * 20)
|
|
def recv(size):
|
|
try:
|
|
return next(chunks)
|
|
except StopIteration:
|
|
app._interrupt_stop.set()
|
|
return b''
|
|
connection.recv.side_effect = recv
|
|
context = Mock(__enter__=Mock(return_value=connection), __exit__=Mock(return_value=False))
|
|
with patch('vertex2.socket.create_connection', return_value=context):
|
|
app._interrupt_listener(app._interrupt_stop)
|
|
self.assertEqual(app._command_processing_deadline, 130)
|
|
app.cancel_timer.assert_not_called()
|
|
app.run_in.assert_not_called()
|
|
app._on_command_processing_timeout({'deadline': 130})
|
|
app.command_processing_sensor.set_state.assert_called_with(state='off')
|
|
|
|
def test_new_command_extends_window_and_old_callback_is_ignored(self):
|
|
app = Harness()
|
|
with patch('vertex2.time.monotonic', return_value=100), patch('vertex2.get'):
|
|
app.send_cmd('mutetx0audio', 'on')
|
|
with patch('vertex2.time.monotonic', return_value=110), patch('vertex2.get'):
|
|
app.send_cmd('mutetx0audio', 'off')
|
|
self.assertEqual(app._command_processing_deadline, 140)
|
|
app.command_processing_sensor.set_state.reset_mock()
|
|
app._on_command_processing_timeout({'deadline': 130})
|
|
app.command_processing_sensor.set_state.assert_not_called()
|
|
app._on_command_processing_timeout({'deadline': 140})
|
|
app.command_processing_sensor.set_state.assert_called_once_with(state='off')
|
|
|
|
def test_queued_command_does_not_start_window_before_boot(self):
|
|
app = Harness()
|
|
app._begin_startup()
|
|
with patch('vertex2.get') as get:
|
|
app.send_cmd('mutetx0audio', 'on')
|
|
get.assert_not_called()
|
|
app.command_processing_sensor.set_state.assert_not_called()
|
|
app.run_in.assert_not_called()
|
|
|
|
|
|
class SpamSensorTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.app = app = Harness()
|
|
app.no_signal_spam_sensor = Mock()
|
|
self.state = 'off'
|
|
app.no_signal_spam_sensor.get_state.side_effect = lambda: self.state
|
|
app.no_signal_spam_sensor.set_state.side_effect = lambda **kw: setattr(self, 'state', kw['state'])
|
|
app._spam_lock = threading.RLock()
|
|
app._spam_last_interrupt = None
|
|
app._spam_snapshot = None
|
|
app._spam_count = 0
|
|
app._spam_timer = None
|
|
app._spam_deadline = None
|
|
self.status = dict(rx0='no signal', rx1='no signal', tx0='', tx1='', audout='', insel='0 4')
|
|
|
|
def observe(self, at, interrupt=True, **changes):
|
|
status = dict(self.status, **changes)
|
|
if interrupt:
|
|
status['interrupt_at'] = at
|
|
with patch('vertex2.time.monotonic', return_value=at):
|
|
self.app._observe_no_signal_spam(status)
|
|
|
|
def test_stop_cancels_timers_without_publishing_spam_state(self):
|
|
app = self.app
|
|
self.observe(100)
|
|
self.observe(103)
|
|
self.observe(106)
|
|
self.assertEqual(self.state, 'on')
|
|
app._spam_timer = 'spam_timer'
|
|
app.no_signal_spam_sensor.reset_mock()
|
|
app.cancel_timer.reset_mock()
|
|
|
|
app._stop_interrupt_listener()
|
|
|
|
self.assertTrue(app._interrupt_stop.is_set())
|
|
self.assertIsNone(app._spam_timer)
|
|
self.assertIsNone(app._spam_deadline)
|
|
app.cancel_timer.assert_any_call('spam_timer', silent=True)
|
|
app.cancel_timer.assert_any_call('timer', silent=True)
|
|
app.no_signal_spam_sensor.set_state.assert_not_called()
|
|
# An already queued expiry callback must also leave the state alone.
|
|
app._expire_no_signal_spam({'deadline': 110})
|
|
app.no_signal_spam_sensor.set_state.assert_not_called()
|
|
|
|
def test_connection_loss_still_marks_spam_sensor_unavailable(self):
|
|
self.app._reset_no_signal_spam('unavailable')
|
|
self.assertEqual(self.state, 'unavailable')
|
|
|
|
def test_three_identical_interrupts_detect_and_silence_clears(self):
|
|
self.observe(100)
|
|
self.observe(103)
|
|
self.assertEqual(self.state, 'off')
|
|
self.observe(106)
|
|
self.assertEqual(self.state, 'on')
|
|
self.app._expire_no_signal_spam({'deadline': 107})
|
|
self.assertEqual(self.state, 'on')
|
|
self.app._expire_no_signal_spam({'deadline': 110})
|
|
self.assertEqual(self.state, 'off')
|
|
|
|
def test_polls_do_not_count_and_changed_poll_breaks_streak(self):
|
|
for at in range(100, 110):
|
|
self.observe(at, interrupt=False)
|
|
self.assertEqual(self.state, 'off')
|
|
self.observe(112)
|
|
self.observe(115)
|
|
self.observe(116, interrupt=False, insel='1 4')
|
|
self.observe(118)
|
|
self.assertEqual(self.state, 'off')
|
|
|
|
def test_normal_transitions_and_nonperiodic_events_do_not_trigger(self):
|
|
self.observe(100)
|
|
self.observe(103, audout='L-PCM')
|
|
self.observe(106)
|
|
self.assertEqual(self.state, 'off')
|
|
self.observe(107)
|
|
self.observe(113)
|
|
self.assertEqual(self.state, 'off')
|
|
|
|
def test_signal_return_or_power_loss_clears(self):
|
|
for at in (100, 103, 106):
|
|
self.observe(at)
|
|
self.observe(107, tx0='4K50')
|
|
self.assertEqual(self.state, 'off')
|
|
for at in (110, 113, 116):
|
|
self.observe(at)
|
|
self.app._suspend_vertex2()
|
|
self.assertEqual(self.state, 'off')
|
|
self.app._expire_no_signal_spam({'deadline': 120})
|
|
self.assertEqual(self.state, 'off')
|
|
|
|
def test_unknown_input_is_not_no_signal(self):
|
|
for at in (100, 103, 106):
|
|
self.observe(at, rx0='unavailable')
|
|
self.assertEqual(self.state, 'off')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|
|
|
|
|
|
class SelectHarness(Harness):
|
|
@property
|
|
def args(self):
|
|
return dict(super().args,
|
|
input_selected_sensor='sensor.input', unreachable_sensor='binary_sensor.unreachable',
|
|
input_select={'entity': 'select.vertex2_input', 'unique_id': 'vertex2_input',
|
|
'connection_topic': 'appdaemon/connection'},
|
|
input_config={0: {'name': 'AppleTV', 'icon': 'mdi:apple'},
|
|
1: {'name': 'Xbox', 'icon': 'mdi:microsoft-xbox'},
|
|
'default': {'name': 'None', 'icon': 'mdi:television-off'}})
|
|
|
|
|
|
class InputSelectTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.app = app = SelectHarness()
|
|
app.input_names = {'AppleTV': 0, 'Xbox': 1}
|
|
app.input_selected_sensor = Mock()
|
|
app.input_selected_sensor.get_state.return_value = 'AppleTV'
|
|
app.unreachable_sensor.get_state.return_value = 'off'
|
|
app.call_service = Mock()
|
|
app.get_plugin_config = Mock(return_value={'will_topic': 'transport/connection'})
|
|
app.listen_state = Mock()
|
|
app.listen_event = Mock()
|
|
app.send_cmd = Mock()
|
|
app._initialize_input_select()
|
|
|
|
def published(self, suffix):
|
|
return [c.kwargs['payload'] for c in self.app.call_service.call_args_list
|
|
if c.args[0] == 'mqtt/publish'
|
|
and c.kwargs['topic'] == self.app._select_topic + suffix][-1]
|
|
|
|
def test_discovery_options_and_initial_icon(self):
|
|
import json
|
|
discovery = next(c for c in self.app.call_service.call_args_list
|
|
if c.kwargs.get('topic') == 'homeassistant/select/vertex2_input/config')
|
|
config = json.loads(discovery.kwargs['payload'])
|
|
self.assertEqual(config['options'], ['AppleTV', 'Xbox'])
|
|
self.assertFalse(config['optimistic'])
|
|
self.assertEqual(config['availability'][1]['topic'], 'appdaemon/connection')
|
|
self.assertEqual(json.loads(self.published('/attributes'))['icon'], 'mdi:apple')
|
|
|
|
def test_user_command_sets_both_tx_without_changing_confirmed_state(self):
|
|
app = self.app
|
|
app.call_service.reset_mock()
|
|
app._on_input_select_mqtt('MQTT_MESSAGE',
|
|
{'topic': app._select_topic + '/set', 'payload': 'Xbox'}, {})
|
|
app.send_cmd.assert_called_once_with('insel', '1 1')
|
|
self.assertTrue(app._status_requested.is_set())
|
|
app.input_selected_sensor.set_state.assert_not_called()
|
|
app.call_service.assert_not_called()
|
|
|
|
def test_unknown_or_powered_off_command_is_rejected(self):
|
|
app = self.app
|
|
for plugged, value in ((True, 'invalid'), (False, 'Xbox')):
|
|
app.plugged = plugged
|
|
app._on_input_select_mqtt('MQTT_MESSAGE',
|
|
{'topic': app._select_topic + '/set', 'payload': value}, {})
|
|
app.send_cmd.assert_not_called()
|
|
|
|
def test_state_and_icon_follow_input_on_active_output(self):
|
|
import json
|
|
app = self.app
|
|
state = {'name': 'AppleTV'}
|
|
app.input_selected_sensor.get_state.side_effect = lambda: state['name']
|
|
app.input_selected_sensor.set_state.side_effect = lambda **kw: state.update(name=kw.get('state', state['name']))
|
|
app.input_selected_sensors = {
|
|
app.generate_input_selected_sensor_name(0): Mock(),
|
|
app.generate_input_selected_sensor_name(1): Mock(),
|
|
}
|
|
for index, name in enumerate(('AppleTV', 'Xbox')):
|
|
app.input_selected_sensors[app.generate_input_selected_sensor_name(index)].get_state.return_value = name
|
|
for index, expected, icon in ((1, 'Xbox', 'mdi:microsoft-xbox'), (0, 'AppleTV', 'mdi:apple')):
|
|
app._refresh_selected_input_sensor(index)
|
|
app._on_input_select_state(None, None, None, None, {})
|
|
self.assertEqual(self.published('/state'), expected)
|
|
self.assertEqual(json.loads(self.published('/attributes'))['icon'], icon)
|
|
self.assertEqual(app._select_discovery_payload['icon'], icon)
|
|
app._refresh_selected_input_sensor(None)
|
|
app._sync_input_select()
|
|
self.assertEqual(self.published('/availability'), 'offline')
|
|
self.assertEqual(self.published('/state'), 'None')
|
|
|
|
def test_unreachable_and_power_off_mark_select_unavailable(self):
|
|
app = self.app
|
|
app.unreachable_sensor.get_state.return_value = 'on'
|
|
app._sync_input_select()
|
|
self.assertEqual(self.published('/availability'), 'offline')
|
|
app.unreachable_sensor.get_state.return_value = 'off'
|
|
app.plugged = False
|
|
app._sync_input_select()
|
|
self.assertEqual(self.published('/availability'), 'offline')
|
|
|
|
def test_reconnect_resubscribes_and_republishes_discovery(self):
|
|
app = self.app
|
|
for data in ({'state': 'Connected'}, {'topic': 'homeassistant/status', 'payload': 'online'}):
|
|
app.call_service.reset_mock()
|
|
app._on_input_select_mqtt('MQTT_MESSAGE', data, {})
|
|
subscriptions = [c.kwargs['topic'] for c in app.call_service.call_args_list
|
|
if c.args[0] == 'mqtt/subscribe']
|
|
self.assertIn(app._select_topic + '/set', subscriptions)
|
|
self.assertTrue(any(c.kwargs.get('topic', '').endswith('/config')
|
|
for c in app.call_service.call_args_list))
|
|
|
|
def test_connection_topic_is_derived_from_shared_plugin(self):
|
|
import json
|
|
app = self.app
|
|
app._input_select_config.pop('connection_topic')
|
|
app.get_plugin_config.return_value = {
|
|
'will_topic': 'transport/connection', 'birth_payload': 'ready', 'will_payload': 'gone'}
|
|
app.call_service.reset_mock()
|
|
app._discover_input_select()
|
|
discovery = next(c for c in app.call_service.call_args_list
|
|
if c.kwargs.get('topic') == 'homeassistant/select/vertex2_input/config')
|
|
config = json.loads(discovery.kwargs['payload'])
|
|
self.assertEqual(config['availability'][1], {
|
|
'topic': 'transport/connection', 'payload_available': 'ready', 'payload_not_available': 'gone'})
|
|
|
|
|
|
class Input5VTests(unittest.TestCase):
|
|
def test_bursts_share_one_read_and_retry_then_recover(self):
|
|
from requests.exceptions import ConnectTimeout
|
|
app = self.app
|
|
del app._schedule_input_5v_refresh
|
|
for _ in range(10):
|
|
app._schedule_input_5v_refresh()
|
|
self.assertEqual(app.run_in.call_count, 1)
|
|
first = app.run_in.call_args
|
|
with patch('vertex2.get', side_effect=ConnectTimeout('test')):
|
|
first.args[0](first.kwargs)
|
|
app.log_warning.assert_not_called()
|
|
self.assertEqual(app.run_in.call_count, 2)
|
|
retry = app.run_in.call_args
|
|
self.assertEqual(retry.args[1], 5)
|
|
for _ in range(10):
|
|
app._schedule_input_5v_refresh()
|
|
self.assertEqual(app.run_in.call_count, 2)
|
|
with patch('vertex2.get') as get:
|
|
get.return_value.json.return_value = {'rx0in5v': '2'}
|
|
retry.args[0](retry.kwargs)
|
|
get.assert_called_once()
|
|
self.assertEqual(app._input_5v_states[0], 'on')
|
|
self.assertEqual(app._input_5v_failures, 0)
|
|
self.assertIsNone(app._input_5v_refresh_handle)
|
|
app.log_warning.assert_not_called()
|
|
self.assertTrue(any('restored after' in c.args[0] for c in app.log_info.call_args_list))
|
|
|
|
def test_failed_retry_warns_once_without_an_endless_retry_loop(self):
|
|
from requests.exceptions import ConnectTimeout
|
|
app = self.app
|
|
del app._schedule_input_5v_refresh
|
|
with patch('vertex2.get', side_effect=ConnectTimeout('test')):
|
|
app._refresh_input_5v_sensors()
|
|
retry = app.run_in.call_args
|
|
retry.args[0](retry.kwargs)
|
|
app._refresh_input_5v_sensors()
|
|
app.log_warning.assert_called_once()
|
|
self.assertIn('attempt=2', app.log_warning.call_args.args[0])
|
|
self.assertEqual(app.run_in.call_count, 1)
|
|
|
|
def test_power_off_cancels_retry_and_discards_stale_callback(self):
|
|
from requests.exceptions import ConnectTimeout
|
|
app = self.app
|
|
del app._schedule_input_5v_refresh
|
|
with patch('vertex2.get', side_effect=ConnectTimeout('test')):
|
|
app._refresh_input_5v_sensors()
|
|
retry = app.run_in.call_args
|
|
handle = app._input_5v_refresh_handle
|
|
app._suspend_vertex2()
|
|
app.cancel_timer.assert_any_call(handle, silent=True)
|
|
# Even if power returns before a cancelled callback arrives, ignore it.
|
|
with patch('vertex2.get') as get:
|
|
retry.args[0](retry.kwargs)
|
|
get.assert_not_called()
|
|
self.assertEqual(app._input_5v_failures, 0)
|
|
|
|
def setUp(self):
|
|
self.app = app = SelectHarness()
|
|
app.input_names = {'AppleTV': 0, 'Xbox': 1}
|
|
app.create_entity = Mock(side_effect=lambda *a, **kw: Mock())
|
|
app.run_every = Mock()
|
|
app._initialize_input_5v_sensors()
|
|
|
|
def test_names_and_icons_come_from_input_configuration(self):
|
|
calls = self.app.create_entity.call_args_list
|
|
self.assertEqual(calls[0].args[0], 'binary_sensor.input_appletv_5v')
|
|
self.assertEqual(calls[0].kwargs['friendly_name'], 'AppleTV +5 V')
|
|
self.assertEqual(calls[0].kwargs['icon'], 'mdi:apple')
|
|
self.assertEqual(calls[1].kwargs['icon'], 'mdi:microsoft-xbox')
|
|
self.app.run_every.assert_not_called()
|
|
|
|
def test_reads_each_input_even_without_video_signal(self):
|
|
with patch('vertex2.get') as get:
|
|
get.return_value.json.return_value = {'RX0': 'no signal', 'rx0in5v': '2', 'rx1in5v': '1'}
|
|
self.app._refresh_input_5v_sensors()
|
|
self.app.input_5v_sensors[0].set_state.assert_called_once_with(state='on')
|
|
self.app.input_5v_sensors[1].set_state.assert_called_once_with(state='off')
|
|
|
|
def test_invalid_or_missing_values_are_unavailable(self):
|
|
with patch('vertex2.get') as get:
|
|
get.return_value.json.return_value = {'rx0in5v': 'unexpected'}
|
|
self.app._refresh_input_5v_sensors()
|
|
for sensor in self.app.input_5v_sensors.values():
|
|
sensor.set_state.assert_called_once_with(state='unavailable')
|
|
|
|
def test_http_failure_invalidates_old_values(self):
|
|
from requests.exceptions import Timeout
|
|
with patch('vertex2.get', side_effect=Timeout):
|
|
self.app._refresh_input_5v_sensors()
|
|
for sensor in self.app.input_5v_sensors.values():
|
|
sensor.set_state.assert_called_once_with(state='unavailable')
|
|
|
|
def test_no_read_while_off_or_booting(self):
|
|
for plugged, deadline in ((False, 0), (True, float('inf'))):
|
|
self.app.plugged = plugged
|
|
self.app._startup_deadline = deadline
|
|
with patch('vertex2.get') as get:
|
|
self.app._refresh_input_5v_sensors()
|
|
get.assert_not_called()
|
|
|
|
def test_power_cut_during_request_discards_result(self):
|
|
def result():
|
|
self.app.plugged = False
|
|
return {'rx0in5v': '2', 'rx1in5v': '2'}
|
|
with patch('vertex2.get') as get:
|
|
get.return_value.json.side_effect = result
|
|
self.app._refresh_input_5v_sensors()
|
|
for sensor in self.app.input_5v_sensors.values():
|
|
sensor.set_state.assert_called_once_with(state='unavailable')
|
|
|
|
def test_5v_edges_start_or_extend_command_processing_once_per_read(self):
|
|
app = self.app
|
|
for previous, raw in (('off', '2'), ('on', '1')):
|
|
app._input_5v_states = {index: previous for index in range(4)}
|
|
for sensor in app.input_5v_sensors.values():
|
|
sensor.get_state.return_value = previous
|
|
app.command_processing_sensor.get_state.return_value = 'off'
|
|
app.command_processing_sensor.set_state.reset_mock()
|
|
app.run_in.reset_mock()
|
|
with patch('vertex2.time.monotonic', return_value=200), patch('vertex2.get') as get:
|
|
get.return_value.json.return_value = {'rx0in5v': raw, 'rx1in5v': raw}
|
|
app._refresh_input_5v_sensors()
|
|
app.command_processing_sensor.set_state.assert_called_once_with(state='on')
|
|
self.assertEqual(app._command_processing_deadline, 230)
|
|
app.run_in.assert_called_once()
|
|
app._on_command_processing_timeout({'deadline': 230})
|
|
app.command_processing_sensor.set_state.assert_called_with(state='off')
|
|
|
|
def test_initial_unavailable_and_unchanged_5v_do_not_start_processing(self):
|
|
app = self.app
|
|
app._start_command_processing = Mock()
|
|
for previous, raw in (('unavailable', '2'), ('unknown', '1'), ('on', '2'),
|
|
('off', '1'), ('on', None)):
|
|
app._input_5v_states = {index: previous for index in range(4)}
|
|
for sensor in app.input_5v_sensors.values():
|
|
sensor.get_state.return_value = previous
|
|
with patch('vertex2.get') as get:
|
|
get.return_value.json.return_value = {'rx0in5v': raw, 'rx1in5v': raw}
|
|
app._refresh_input_5v_sensors()
|
|
app._start_command_processing.assert_not_called()
|
|
|
|
def test_processing_is_identical_with_all_some_or_no_input_sensors(self):
|
|
app = self.app
|
|
sensors = app.input_5v_sensors.copy()
|
|
for exposed in (sensors, {0: sensors[0]}, {}):
|
|
with self.subTest(exposed=list(exposed)):
|
|
app.input_5v_sensors = exposed
|
|
app._input_5v_states = {}
|
|
app._start_command_processing = Mock()
|
|
with patch('vertex2.get') as get:
|
|
# All physical ports are tracked even without input_config entries.
|
|
for values in (('1', '1', '1', '1'), ('1', '1', '1', '2'),
|
|
('1', '1', '1', '2'), ('1', '1', '1', '1')):
|
|
get.return_value.json.return_value = {
|
|
f'rx{index}in5v': value for index, value in enumerate(values)}
|
|
app._refresh_input_5v_sensors()
|
|
self.assertEqual(app._start_command_processing.call_count, 2)
|
|
self.assertEqual(app._input_5v_states[3], 'off')
|
|
|
|
def test_ha_sensor_state_cannot_create_or_hide_a_transition(self):
|
|
app = self.app
|
|
app._start_command_processing = Mock()
|
|
app._input_5v_states = {0: 'off', 1: 'off'}
|
|
for sensor in app.input_5v_sensors.values():
|
|
sensor.get_state.return_value = 'on'
|
|
with patch('vertex2.get') as get:
|
|
get.return_value.json.return_value = {'rx0in5v': '1', 'rx1in5v': '1'}
|
|
app._refresh_input_5v_sensors()
|
|
app._start_command_processing.assert_not_called()
|
|
get.return_value.json.return_value = {'rx0in5v': '2', 'rx1in5v': '2'}
|
|
app._refresh_input_5v_sensors()
|
|
app._start_command_processing.assert_called_once()
|
|
|
|
def test_read_failure_resets_internal_baseline_without_processing(self):
|
|
from requests.exceptions import Timeout
|
|
app = self.app
|
|
app.input_5v_sensors = {}
|
|
app._input_5v_states = {0: 'on'}
|
|
app._start_command_processing = Mock()
|
|
with patch('vertex2.get', side_effect=Timeout):
|
|
app._refresh_input_5v_sensors()
|
|
self.assertEqual(app._input_5v_states, {})
|
|
with patch('vertex2.get') as get:
|
|
get.return_value.json.return_value = {'rx0in5v': '1'}
|
|
app._refresh_input_5v_sensors()
|
|
app._start_command_processing.assert_not_called()
|