New async version of samsungtheframe

This commit is contained in:
Pierre committed 2026-10-14 19:01:30 +02:00
1 parent 327c0b2558
commit 9a58cc5296
5 files changed
+1231 -1

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
import sys
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps'))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox'))
from samsungtheframe import SamsungTheFrame
import requests
import websocket
class SamsungTheFrameTests(unittest.TestCase):
def app(self):
app = object.__new__(SamsungTheFrame)
app.args = {'ip_address': '192.0.2.1'}
app.AD = SimpleNamespace(app_dir='/tmp')
app.update_sensors_handle = None
app.update_sensors_is_retry = False
app.requested_state_confirm_cb_handle = None
app.volume_connection = None
app.power_plug = 'switch.tv'
app.power_plug_power = 'sensor.tv_power'
app.get_state = Mock(side_effect=lambda e: 'on' if e == app.power_plug else '60')
for name in ('is_on_entity', 'is_art_mode_entity', 'tv_auto_shutdown_entity'):
entity = Mock()
entity.get_state.return_value = 'on'
setattr(app, name, entity)
app.log_info = Mock()
app.log = Mock()
app.run_in = Mock(side_effect=lambda *a, **kw: object())
app.cancel_timer = Mock()
app.turn_off = Mock()
return app
def test_timeout_propagates_to_art_and_rest(self):
a = self.app()
tv = a.connect()
self.assertEqual(tv.timeout, 2)
self.assertEqual(tv.art().timeout, 2)
self.assertEqual(tv._get_rest_api().timeout, 2)
def test_sensor_burst_only_schedules_one_read(self):
a = self.app()
a.connect = Mock()
for _ in range(10):
a.update_sensors_cb()
a.run_in.assert_called_once_with(a.refresh_sensors_cb, 1)
a.connect.assert_not_called()
def test_failed_refresh_has_one_retry_replaced_by_new_reading(self):
a = self.app()
a.update_sensors = Mock(return_value=(False, 30))
a.refresh_sensors_cb({})
retry = a.update_sensors_handle
a.schedule_sensor_update(30, retry=True)
self.assertEqual(a.run_in.call_count, 1)
a.update_sensors_cb()
a.cancel_timer.assert_called_once_with(retry)
self.assertEqual(a.run_in.call_count, 2)
self.assertFalse(a.update_sensors_is_retry)
def test_read_errors_preserve_states_and_close_both_connections(self):
errors = [websocket.WebSocketTimeoutException('timeout'),
ConnectionRefusedError(), requests.Timeout(), KeyError('value')]
for error in errors:
with self.subTest(error=type(error).__name__):
a = self.app()
tv, art = Mock(), Mock()
tv.art.return_value = art
art.get_artmode.side_effect = error
a.connect = Mock(return_value=tv)
self.assertEqual(a.update_sensors(), (False, 30))
art.close.assert_called_once()
tv.close.assert_called_once()
a.is_on_entity.set_state.assert_not_called()
a.is_art_mode_entity.set_state.assert_not_called()
def test_invalid_readings_do_not_mark_tv_off(self):
for power in ('unavailable', None, 'nan', 'inf'):
with self.subTest(power=power):
a = self.app()
a.get_state.side_effect = lambda e: 'on' if e == a.power_plug else power
a.connect = Mock()
self.assertEqual(a.update_sensors(), (False, 30))
a.connect.assert_not_called()
a.is_on_entity.set_state.assert_not_called()
def test_successful_read_consumes_queued_refresh(self):
a = self.app()
a.schedule_sensor_update()
pending = a.update_sensors_handle
tv = Mock()
tv.art.return_value.get_artmode.return_value = 'off'
a.connect = Mock(return_value=tv)
self.assertEqual(a.update_sensors(), (True, 0))
a.cancel_timer.assert_called_once_with(pending)
self.assertIsNone(a.update_sensors_handle)
a.is_art_mode_entity.set_state.assert_called_once_with(state='off')
tv.art.return_value.close.assert_called_once()
def test_art_selection_reuses_one_connection(self):
a = self.app()
tv, art = Mock(), Mock()
tv.art.return_value = art
art.available.return_value = [{'content_id': 'image'}]
a.connect = Mock(return_value=tv)
self.assertTrue(a.select_random_art())
tv.art.assert_called_once()
art.select_image.assert_called_once_with('image', show=True)
art.close.assert_called_once()
def test_command_timeout_closes_connection_and_schedules_confirmation(self):
a = self.app()
a.requested_state = 'tv_off'
a.update_sensors = Mock(return_value=(True, 0))
tv = Mock()
tv.shortcuts.return_value.power.side_effect = websocket.WebSocketTimeoutException()
a.connect = Mock(return_value=tv)
self.assertFalse(a._process_state_request())
tv.close.assert_called_once()
a.run_in.assert_called_once_with(a.confirm_change_state_cb, 30)
def test_power_off_wait_is_scheduled_and_cancelled_by_new_request(self):
a = self.app()
a.requested_state = 'power_off'
a.is_on_entity.get_state.return_value = 'off'
a.update_sensors = Mock(return_value=(True, 0))
a.select_random_art = Mock(return_value=True)
self.assertFalse(a._process_state_request())
a.turn_off.assert_not_called()
a.run_in.assert_called_once_with(a.power_off_cb, 5)
pending = a.requested_state_confirm_cb_handle
a.request_tv_state('manual')
a.cancel_timer.assert_called_once_with(pending)
a.power_off_cb({})
a.turn_off.assert_not_called()
def test_power_off_timer_cuts_power_and_schedules_confirmation(self):
a = self.app()
a.requested_state = 'power_off'
a.power_off_cb({})
a.turn_off.assert_called_once_with(a.power_plug)
a.run_in.assert_called_once_with(a.confirm_change_state_cb, 5)
if __name__ == '__main__':
unittest.main()
+420
View File
@@ -0,0 +1,420 @@
import asyncio
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps'))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox'))
from SamsungTheFrameAsync import SamsungTheFrame, TVError, _TVConnection
def message(data):
return SimpleNamespace(type=aiohttp.WSMsgType.TEXT, data=json.dumps(data))
class FakeSocket:
def __init__(self, ready=True):
self.messages = asyncio.Queue()
self.closed = False
self.sent = []
self.respond = True
self.messages.put_nowait(message({'event': 'ms.channel.connect', 'data': {}}))
if ready:
self.messages.put_nowait(message({'event': 'ms.channel.ready'}))
async def receive(self):
return await self.messages.get()
def __aiter__(self):
return self
async def __anext__(self):
msg = await self.receive()
if msg.type == aiohttp.WSMsgType.CLOSED:
raise StopAsyncIteration
return msg
async def send_json(self, payload):
self.sent.append(payload)
if payload['method'] == 'ms.channel.emit' and self.respond:
request = json.loads(payload['params']['data'])
self.push({'event': 'artmode_status', 'value': 'on',
'request_id': request['request_id']})
def push(self, data):
self.messages.put_nowait(message({'event': 'd2d_service_message',
'data': json.dumps(data)}))
async def close(self):
self.closed = True
self.messages.put_nowait(SimpleNamespace(type=aiohttp.WSMsgType.CLOSED))
class ConnectionTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temp = tempfile.TemporaryDirectory()
self.ws = FakeSocket()
self.session = Mock()
self.session.ws_connect = AsyncMock(return_value=self.ws)
self.events = Mock()
self.client = _TVConnection(self.session, '192.0.2.1',
Path(self.temp.name) / 'token', self.events, .05)
async def asyncTearDown(self):
await self.client.close()
self.temp.cleanup()
async def test_art_requests_reuse_socket_and_reader(self):
first = await self.client.art_request('get_artmode_status')
reader = self.client.readers['art']
second = await self.client.art_request('get_artmode_status')
self.assertEqual(first['value'], 'on')
self.assertEqual(second['value'], 'on')
self.session.ws_connect.assert_awaited_once()
self.assertIs(self.client.readers['art'], reader)
self.assertFalse(reader.done())
self.assertEqual(self.client.pending, {})
self.events.assert_not_called() # Query responses applied by refresh.
async def test_unsolicited_notifications_delivered_while_idle(self):
await self.client.connect('art')
self.ws.push({'event': 'art_mode_changed', 'status': 'off'})
await asyncio.sleep(.01)
self.events.assert_called_once_with({'event': 'art_mode_changed', 'status': 'off'})
async def test_wrong_request_id_cannot_satisfy_request(self):
self.ws.respond = False
task = asyncio.create_task(self.client.art_request('get_artmode_status'))
await asyncio.sleep(.01)
self.ws.push({'event': 'artmode_status', 'value': 'off', 'id': 'wrong'})
with self.assertRaises(TimeoutError):
await task
self.assertEqual(self.client.pending, {})
self.assertTrue(self.ws.closed)
async def test_disconnect_fails_pending_request_without_waiting_for_timeout(self):
self.ws.respond = False
task = asyncio.create_task(self.client.art_request('get_artmode_status'))
await asyncio.sleep(.01)
await self.ws.close()
with self.assertRaises(TVError):
await task
self.assertEqual(self.client.pending, {})
async def test_request_rejection_is_not_mistaken_for_state(self):
self.ws.respond = False
task = asyncio.create_task(self.client.art_request('get_artmode_status'))
await asyncio.sleep(.01)
request = json.loads(self.ws.sent[0]['params']['data'])
self.ws.push({'event': 'error', 'request_id': request['request_id'], 'error_code': -1})
with self.assertRaises(TVError):
await task
async def test_cancel_request_removes_pending_future(self):
self.ws.respond = False
task = asyncio.create_task(self.client.art_request('get_artmode_status'))
await asyncio.sleep(.01)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
self.assertEqual(self.client.pending, {})
async def test_timeout_covers_handshake_and_does_not_block_loop(self):
self.ws = FakeSocket(ready=False)
self.session.ws_connect.return_value = self.ws
task = asyncio.create_task(self.client.connect('art'))
await asyncio.sleep(.01)
self.assertFalse(task.done())
with self.assertRaises(TimeoutError):
await task
self.assertTrue(self.ws.closed)
async def test_unauthorized_handshake_closes_socket(self):
self.ws.messages = asyncio.Queue()
self.ws.messages.put_nowait(message({'event': 'ms.channel.unauthorized'}))
with self.assertRaises(TVError):
await self.client.connect('art')
self.assertTrue(self.ws.closed)
async def test_remote_reuse_and_cancellation_releases_held_power_key(self):
task = asyncio.create_task(self.client.send_key('KEY_POWER', hold=4))
await asyncio.sleep(.01)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
await self.client.send_key('KEY_VOLUP')
self.assertEqual([v['params']['Cmd'] for v in self.ws.sent],
['Press', 'Release', 'Click'])
self.session.ws_connect.assert_awaited_once()
async def test_close_cancels_reader(self):
await self.client.connect('art')
reader = self.client.readers['art']
await self.client.close()
self.assertTrue(reader.done())
self.assertEqual(self.client.readers, {})
self.assertEqual(self.client.sockets, {})
async def test_corrupt_frame_is_contained_and_requests_fail(self):
self.ws.respond = False
task = asyncio.create_task(self.client.art_request('get_artmode_status'))
await asyncio.sleep(.01)
self.ws.messages.put_nowait(SimpleNamespace(type=aiohttp.WSMsgType.TEXT, data='{bad'))
with self.assertRaises(TVError):
await task
self.assertTrue(self.ws.closed)
async def test_reconnect_creates_one_fresh_socket(self):
await self.client.art_request('get_artmode_status')
await self.ws.close()
await asyncio.sleep(.01)
fresh = FakeSocket()
self.session.ws_connect.return_value = fresh
await self.client.art_request('get_artmode_status')
self.assertEqual(self.session.ws_connect.await_count, 2)
self.assertTrue(self.client.alive('art'))
def fake_entity(value):
entity = Mock()
state = {'value': value}
entity.get_state = AsyncMock(side_effect=lambda: state['value'])
async def set_state(*, state):
state_holder['value'] = state
state_holder = state
entity.set_state = AsyncMock(side_effect=set_state)
return entity
class ApplicationTests(unittest.IsolatedAsyncioTestCase):
def app(self):
app = object.__new__(SamsungTheFrame)
app.AD = SimpleNamespace(app_dir='/tmp')
app.args = {'ip_address': '192.0.2.1', 'reconnect_delay': .05}
app._loop = asyncio.get_running_loop()
app._stopped = False
app._wake = asyncio.Event()
app._ready = asyncio.Event()
app._ready.set()
app._publish_lock = asyncio.Lock()
app._volume = asyncio.Queue(maxsize=8)
app._tasks = set()
app._target_task = None
app._generation = 0
app._observed = (False, True)
app._observation_revision = 0
app._refresh_at = 0
app._retry_at = 0
app._backoff = .05
app.requested_state = None
app.power_plug, app.power_plug_power = 'switch.tv', 'sensor.power'
app.get_state = AsyncMock(side_effect=lambda e: 'on' if e == app.power_plug else '60')
app.is_on_entity = fake_entity('off')
app.is_art_mode_entity = fake_entity('on')
app.tv_auto_shutdown_entity = fake_entity('off')
app.turn_on = AsyncMock()
app.turn_off = AsyncMock()
app.call_service = AsyncMock()
app.log_info = Mock()
app.log_warning = Mock()
app._client = Mock()
for method in ('art_request', 'send_key', 'app_status', 'close', 'close_channel', 'load_token'):
setattr(app._client, method, AsyncMock())
app._client.art_request.return_value = {'event': 'artmode_status', 'value': 'on'}
app._client.remote_lock = asyncio.Lock()
app.app_list = []
return app
async def asyncTearDown(self):
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def test_existing_yaml_contract_initializes_without_tv_io(self):
app = self.app()
app.args.update({
'input_sensors': {'power_plug': 'switch.tv', 'power_plug_power': 'sensor.power'},
'output_sensors': {'is_on': 'binary_sensor.tv_on',
'is_art_mode': 'binary_sensor.art',
'tv_auto_shutdown': 'binary_sensor.shutdown'},
'tv_state': 'sensor.target', 'wake_up_service': 'esphome/wake_tv',
'volume_up_events': ['volume_up'], 'volume_down_events': ['volume_down']})
app.AD.loop = app._loop
app.get_state = Mock(return_value='off')
app.create_entity = Mock(side_effect=lambda *a, **kw: Mock())
app.listen_state = Mock()
app._post = Mock()
app.create_task = Mock(side_effect=lambda coro: coro.close())
with patch('SamsungTheFrameAsync.EventHandler') as handler:
app.on_initialize_smart_object()
self.assertEqual(app.listen_state.call_count, 3)
self.assertEqual(handler.call_count, 2)
self.assertEqual(app.create_entity.call_count, 3)
app.call_service.assert_not_called()
self.assertEqual(type(app).__name__, 'SamsungTheFrame')
async def test_callbacks_only_post_work_to_loop(self):
app = self.app()
app._loop = Mock()
app.update_sensors_cb()
app.request_tv_state('hdmi')
app.on_volume_event('volume', {}, 'up')
self.assertEqual(app._loop.call_soon_threadsafe.call_count, 3)
app._client.send_key.assert_not_called()
app._client.art_request.assert_not_called()
async def test_notification_updates_state_and_disconnect_preserves_it(self):
app = self.app()
app._on_art_event({'event': 'art_mode_changed', 'status': 'off'})
self.assertEqual(app._observed, (True, False))
self.assertTrue(app._wake.is_set())
app._on_art_event({'event': 'connection_lost', 'channel': 'art'})
self.assertEqual(app._observed, (True, False))
app._on_art_event({'event': 'go_to_standby'})
self.assertEqual(app._observed, (False, False))
async def test_late_query_cannot_overwrite_newer_standby_notification(self):
app = self.app()
async def request(*args):
app._on_art_event({'event': 'go_to_standby'})
return {'value': 'on'}
app._client.art_request.side_effect = request
await app._refresh()
self.assertEqual(app._observed, (False, False))
async def test_concurrent_queries_apply_latest_response(self):
app = self.app()
calls = 0
async def request(*args):
nonlocal calls
calls += 1
value = 'on' if calls == 1 else 'off'
await asyncio.sleep(.01 if value == 'on' else .02)
return {'value': value}
app._client.art_request.side_effect = request
await asyncio.gather(app._refresh(), app._refresh())
self.assertEqual(app._observed, (True, False))
async def test_unknown_consumption_is_not_off(self):
app = self.app()
for invalid in ('unavailable', None, 'nan', 'inf'):
app.get_state.side_effect = lambda e: 'on' if e == app.power_plug else invalid
self.assertEqual(await app._power(), (True, None))
async def test_hdmi_shutdown_flag_survives_initial_art_mode_read(self):
app = self.app()
await app.tv_auto_shutdown_entity.set_state(state='on')
await app._publish(False, True)
self.assertEqual(await app.tv_auto_shutdown_entity.get_state(), 'on')
app.is_on_entity = fake_entity('on')
await app._publish(False, True)
self.assertEqual(await app.tv_auto_shutdown_entity.get_state(), 'off')
async def test_power_modes_issue_expected_commands(self):
for target, mode, hold in [('art_mode', 'off', 0), ('tv_off', 'on', 4),
('tv_off', 'off', 0), ('hdmi', 'on', 0)]:
with self.subTest(target=target, mode=mode):
app = self.app()
app._client.art_request.return_value = {'value': mode}
self.assertIn(await app._transition(target), (5, 10))
if hold:
app._client.send_key.assert_awaited_once_with('KEY_POWER', hold=4)
else:
app._client.send_key.assert_awaited_once_with('KEY_POWER')
async def test_hdmi_preserves_app_visibility_check(self):
app = self.app()
app.app_list = ['netflix', 'prime']
app._client.art_request.return_value = {'value': 'off'}
app._client.app_status.side_effect = [{'visible': False}, {'visible': True}]
self.assertEqual(await app._transition('hdmi'), 5)
app._client.send_key.assert_awaited_once_with('KEY_HDMI')
async def test_off_plug_wakes_for_art_but_satisfies_power_off(self):
app = self.app()
app.get_state.return_value = 'off'
app.get_state.side_effect = None
self.assertIsNone(await app._transition('power_off'))
app.turn_on.assert_not_called()
self.assertEqual(await app._transition('art_mode'), 60)
app.turn_on.assert_awaited_once_with(app.power_plug)
app._client.art_request.assert_not_called()
async def test_manual_cancels_pending_power_cut(self):
app = self.app()
app._select_random_art = AsyncMock()
app._set_target('power_off')
await asyncio.sleep(.02)
app._select_random_art.assert_awaited_once()
app._set_target('manual')
await asyncio.sleep(.02)
app.turn_off.assert_not_called()
self.assertIsNone(app.requested_state)
self.assertFalse(app._tasks)
async def test_volume_queue_bounded_and_old_events_dropped(self):
app = self.app()
app._observed = (True, False)
for _ in range(30):
app._queue_volume('up')
self.assertEqual(app._volume.qsize(), 8)
for _ in range(8):
app._volume.get_nowait()
app._volume.put_nowait(('up', app._loop.time() - 5))
app._volume.put_nowait(('down', app._loop.time()))
task = asyncio.create_task(app._volume_worker())
await asyncio.sleep(.01)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
app._client.send_key.assert_awaited_once_with('KEY_VOLDOWN')
async def test_monitor_coalesces_sensor_bursts_and_shutdown_closes_client(self):
app = self.app()
client = app._client
with patch('SamsungTheFrameAsync._TVConnection', return_value=client):
task = asyncio.create_task(app._run())
await asyncio.sleep(.02)
for _ in range(30):
app.update_sensors_cb()
await asyncio.sleep(.02)
client.art_request.assert_awaited_once()
app._on_art_event({'event': 'art_mode_changed', 'status': 'off'})
await asyncio.sleep(.02)
self.assertEqual(await app.is_on_entity.get_state(), 'on')
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
client.close.assert_awaited_once()
self.assertFalse(app._tasks)
async def test_power_events_cannot_bypass_reconnect_backoff(self):
app = self.app()
client = app._client
app._backoff = .5
app.args['reconnect_delay'] = .5
client.alive.return_value = False
client.art_request.side_effect = TVError('offline')
with patch('SamsungTheFrameAsync._TVConnection', return_value=client):
task = asyncio.create_task(app._run())
await asyncio.sleep(.01)
for _ in range(10):
app._wake.set()
await asyncio.sleep(.001)
self.assertEqual(client.art_request.await_count, 1)
await asyncio.sleep(.6)
self.assertGreaterEqual(client.art_request.await_count, 2)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
if __name__ == '__main__':
unittest.main()