Shorter reconnection delay

This commit is contained in:
Pierre committed 2026-10-17 22:46:44 +02:00
1 parent 8a8cb02296
commit 8cc2ae32df
4 files changed
+64 -25

No files matched your search

+22 -14
View File
@@ -26,7 +26,6 @@ samsung_the_frame:
trigger_conditions: trigger_conditions:
- binary_sensor.is_everybody_sleeping or binary_sensor.is_living_room_sleeping or not binary_sensor.someone_home - binary_sensor.is_everybody_sleeping or binary_sensor.is_living_room_sleeping or not binary_sensor.someone_home
- binary_sensor.projector_screen_down - binary_sensor.projector_screen_down
#hdmi: binary_sensor.vertex2_tx0_plus5_for_a_while and binary_sensor.vertex2_has_input_signal and not binary_sensor.projector_screen_down
hdmi: binary_sensor.vertex2_has_input_signal and not binary_sensor.projector_screen_down hdmi: binary_sensor.vertex2_has_input_signal and not binary_sensor.projector_screen_down
manual: binary_sensor.is_tv_on and not binary_sensor.tv_auto_shutdown manual: binary_sensor.is_tv_on and not binary_sensor.tv_auto_shutdown
tv_off: not binary_sensor.living_room_occupancy tv_off: not binary_sensor.living_room_occupancy
@@ -127,6 +126,14 @@ projection_screen_switch:
class: SmartSwitch class: SmartSwitch
entity: input_boolean.projection_screen entity: input_boolean.projection_screen
virtual_sensors:
sensors:
continuous_condition.projection_screen_on_for_a_while:
time: 300
conditions: input_boolean.projection_screen
off_conditions: binary_sensor.projection_screen_on_for_a_while and binary_sensor.projector_off_for_a_while
toggle_events: toggle_events:
apple_tv_mute_long_press: apple_tv_mute_long_press:
event_name: state_changed event_name: state_changed
@@ -153,6 +160,7 @@ vertex_2:
virtual_sensors: virtual_sensors:
default_values: default_values:
binary_sensor.is_tv_on: false binary_sensor.is_tv_on: false
binary_sensor.someone_home: false
binary_sensor.vertex2_has_input_signal: false binary_sensor.vertex2_has_input_signal: false
sensors: sensors:
binary_sensor.projector_screen_down: input_boolean.projection_screen binary_sensor.projector_screen_down: input_boolean.projection_screen
@@ -164,7 +172,6 @@ vertex_2:
0: 0:
name: AppleTV name: AppleTV
icon: mdi:apple icon: mdi:apple
#reboot_when_leaving: True
1: 1:
name: Xbox name: Xbox
icon: mdi:microsoft-xbox icon: mdi:microsoft-xbox
@@ -183,23 +190,20 @@ vertex_2:
name: TV name: TV
icon: mdi:television icon: mdi:television
use_audio_out_audio_flags: input_boolean.use_bravia_theater_for_the_frame use_audio_out_audio_flags: input_boolean.use_bravia_theater_for_the_frame
force_plus5: force_plus5: not binary_sensor.vertex2_no_signal_spam
trigger_conditions: binary_sensor.is_tv_on or binary_sensor.vertex2_has_input_signal
blocking_conditions: not switch.the_frame_power_plug
1: 1:
name: Projector name: Projector
icon: mdi:projector icon: mdi:projector
use_audio_out_audio_flags: True use_audio_out_audio_flags: True
force_plus5: switch.living_room_projector_plug #force_plus5: switch.living_room_projector_plug
# vertex2_power_plug: vertex2_power_plug:
# entity: switch.fibaro_vertex_plug_51 entity: switch.vertex2_power_plug
# # power_consumption: sensor.fibaro_vertex_plug_51_power # power_consumption: sensor.fibaro_vertex_plug_51_power
# conditions: conditions:
# trigger_conditions: binary_sensor.someone_home trigger_conditions: binary_sensor.someone_home #and (input_boolean.projection_screen or binary_sensor.the_frame_is_plugged_for_a_while)
# blocking_conditions: blocking_conditions: (binary_sensor.is_everybody_sleeping or binary_sensor.is_living_room_sleeping) and not binary_sensor.vertex2_has_input_signal
# - binary_sensor.is_everybody_sleeping or binary_sensor.is_salon_sleeping
# - binary_sensor.vertex2_unreachable and not binary_sensor.is_tv_on and not binary_sensor.projector_on
#filmmaker_mode_conditions: sensor.vertex2_input_select_tx0 == 'AppleTV' #filmmaker_mode_conditions: sensor.vertex2_input_select_tx0 == 'AppleTV'
@@ -216,12 +220,16 @@ vertex_2:
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
video_tx_signal_sensor: sensor.vertex2_video_signal video_tx_signal_sensor: sensor.vertex2_video_signal
allm_sensor: binary_sensor.vertex2_allm allm_sensor: binary_sensor.vertex2_allm
#tx0_plus5_sensor: binary_sensor.vertex2_tx0_plus5
#tx1_plus5_sensor: binary_sensor.vertex2_tx1_plus5
audio_out_signal_sensor: sensor.vertex2_audio_signal audio_out_signal_sensor: sensor.vertex2_audio_signal
output_selected_sensor: sensor.vertex2_output_selected output_selected_sensor: sensor.vertex2_output_selected
input_selected_sensor: sensor.vertex2_input_selected input_selected_sensor: sensor.vertex2_input_selected
no_signal_spam_sensor: binary_sensor.vertex2_no_signal_spam
unreachable_sensor: binary_sensor.vertex2_unreachable unreachable_sensor: binary_sensor.vertex2_unreachable
command_processing_sensor: binary_sensor.vertex2_command_processing command_processing_sensor: binary_sensor.vertex2_command_processing
command_processing_timeout: 20 command_processing_timeout: 20
+13 -5
View File
@@ -308,6 +308,8 @@ class SamsungTheFrame(SmartObject):
if self._stopped: if self._stopped:
return return
self._generation += 1 self._generation += 1
# A fresh target must not inherit a long idle reconnection delay.
self._retry_at = min(self._retry_at, self._loop.time() + self._transition_retry_delay())
self.requested_state = None if state == 'manual' else state self.requested_state = None if state == 'manual' else state
previous = self._target_task previous = self._target_task
if previous is not None and not previous.cancelling(): if previous is not None and not previous.cancelling():
@@ -422,7 +424,10 @@ class SamsungTheFrame(SmartObject):
await self._publish(*self._observed) await self._publish(*self._observed)
except NETWORK_ERRORS as error: except NETWORK_ERRORS as error:
self.log_info(f'TV refresh failed: {type(error).__name__}: {error}') self.log_info(f'TV refresh failed: {type(error).__name__}: {error}')
self._retry_at = self._loop.time() + self._backoff retry_delay = self._backoff
if self.requested_state is not None:
retry_delay = min(retry_delay, self._transition_retry_delay())
self._retry_at = self._loop.time() + retry_delay
self._backoff = min(60, self._backoff * 2) self._backoff = min(60, self._backoff * 2)
# One refresh worker coalesces power events. WebSocket # One refresh worker coalesces power events. WebSocket
# notifications wake it immediately; no network polling # notifications wake it immediately; no network polling
@@ -490,6 +495,9 @@ class SamsungTheFrame(SmartObject):
selected = random.choice(available) selected = random.choice(available)
await self._client.art_request('select_image', content_id=selected['content_id'], show=True) await self._client.art_request('select_image', content_id=selected['content_id'], show=True)
def _transition_retry_delay(self):
return max(0.2, float(self.args.get('transition_retry_delay', 2)))
async def _target_runner(self, previous, target, generation): async def _target_runner(self, previous, target, generation):
if previous is not None: if previous is not None:
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
@@ -510,7 +518,7 @@ class SamsungTheFrame(SmartObject):
return return
except NETWORK_ERRORS + (KeyError, TypeError) as error: except NETWORK_ERRORS + (KeyError, TypeError) as error:
self.log_info(f'TV transition failed: {type(error).__name__}: {error}') self.log_info(f'TV transition failed: {type(error).__name__}: {error}')
delay = 30 delay = self._transition_retry_delay()
# Cancellation interrupts both network waits and confirmation delays. # Cancellation interrupts both network waits and confirmation delays.
await asyncio.sleep(delay) await asyncio.sleep(delay)
self._refresh_at = 0 self._refresh_at = 0
@@ -519,17 +527,17 @@ class SamsungTheFrame(SmartObject):
async def _transition(self, target): async def _transition(self, target):
plugged, powered = await self._power() plugged, powered = await self._power()
if plugged is None or powered is None: if plugged is None or powered is None:
return 30 return self._transition_retry_delay()
if not plugged: if not plugged:
if target == 'power_off': if target == 'power_off':
return None return None
await self.turn_on(self.power_plug) await self.turn_on(self.power_plug)
return 60 return 5
if not powered: if not powered:
if target == 'tv_off': if target == 'tv_off':
return None return None
await self._wake_up() await self._wake_up()
return 60 return 10
if self._loop.time() < self._retry_at: if self._loop.time() < self._retry_at:
return max(1, self._retry_at - self._loop.time()) return max(1, self._retry_at - self._loop.time())
# Always confirm the current state before a toggle. Serialize Art reads # Always confirm the current state before a toggle. Serialize Art reads
+8 -1
View File
@@ -43,7 +43,9 @@ lors de l'ajout de ce module.
connexions. Une consommation indisponible n'est pas assimilée à un arrêt. connexions. Une consommation indisponible n'est pas assimilée à un arrêt.
- Après un échec de lecture, reconnexion avec attente progressive : 5, 10, 20, - Après un échec de lecture, reconnexion avec attente progressive : 5, 10, 20,
40, puis 60 secondes. Les événements de consommation ne contournent pas cette 40, puis 60 secondes. Les événements de consommation ne contournent pas cette
attente. Les transitions échouées sont revérifiées après 30 secondes. attente en surveillance seule. Pendant une transition, cette attente est plafonnée
à 2 secondes, y compris après une erreur de transition. Une nouvelle consigne
raccourcit aussi une attente de reconnexion déjà en cours.
- Les cinq consignes sont conservées : `manual`, `art_mode`, `tv_off`, `hdmi`, - Les cinq consignes sont conservées : `manual`, `art_mode`, `tv_off`, `hdmi`,
`power_off`. Une nouvelle consigne annule la transition précédente, y compris `power_off`. Une nouvelle consigne annule la transition précédente, y compris
l'attente de 5 secondes avant coupure électrique. Une commande Power échouée l'attente de 5 secondes avant coupure électrique. Une commande Power échouée
@@ -64,6 +66,7 @@ basculement :
```yaml ```yaml
network_timeout: 2 # secondes, connexion/autorisation et requêtes TV network_timeout: 2 # secondes, connexion/autorisation et requêtes TV
reconnect_delay: 5 # attente initiale, doublée jusqu'à 60 secondes reconnect_delay: 5 # attente initiale, doublée jusqu'à 60 secondes
transition_retry_delay: 2 # attente entre tentatives pendant une transition
fallback_refresh: 60 # intervalle de lecture de secours, en secondes fallback_refresh: 60 # intervalle de lecture de secours, en secondes
power_threshold: 35 # watts, même seuil que l'ancienne app power_threshold: 35 # watts, même seuil que l'ancienne app
``` ```
@@ -82,3 +85,7 @@ Les tests simulent les messages et sockets TV : persistance, notifications,
concurrence, délais, reconnexion, transitions et annulation. Ils ne contactent pas concurrence, délais, reconnexion, transitions et annulation. Ils ne contactent pas
la TV réelle. La réception des événements et les commandes doivent encore être la TV réelle. La réception des événements et les commandes doivent encore être
validées sur le téléviseur lors du basculement manuel. validées sur le téléviseur lors du basculement manuel.
Après allumage de la prise, la transition est revérifiée après 5 secondes ;
après une commande de réveil, après 10 secondes (au lieu de 60). Les délais
de confirmation après une touche et avant coupure électrique sont conservés.
+21 -5
View File
@@ -11,7 +11,7 @@ import aiohttp
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps')) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps'))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox')) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox'))
from SamsungTheFrameAsync import SamsungTheFrame, TVError, _TVConnection from samsungtheframeasync import SamsungTheFrame, TVError, _TVConnection
def message(data): def message(data):
@@ -231,6 +231,22 @@ class ApplicationTests(unittest.IsolatedAsyncioTestCase):
app.app_list = [] app.app_list = []
return app return app
async def test_fresh_target_shortens_idle_reconnect_delay(self):
app = self.app()
app._retry_at = app._loop.time() + 60
app._transition = AsyncMock(return_value=None)
app._set_target('hdmi')
self.assertLessEqual(app._retry_at - app._loop.time(), 2)
await app._target_task
async def test_wake_rechecks_after_ten_seconds(self):
app = self.app()
app.args['wake_up_service'] = 'esphome/wake_tv'
app.get_state = AsyncMock(side_effect=lambda e: 'on' if e == app.power_plug else '0')
self.assertEqual(await app._transition('hdmi'), 10)
app.call_service.assert_awaited_once_with('esphome/wake_tv')
app._client.send_key.assert_not_awaited()
async def asyncTearDown(self): async def asyncTearDown(self):
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for task in tasks: for task in tasks:
@@ -252,7 +268,7 @@ class ApplicationTests(unittest.IsolatedAsyncioTestCase):
app.listen_state = Mock() app.listen_state = Mock()
app._post = Mock() app._post = Mock()
app.create_task = Mock(side_effect=lambda coro: coro.close()) app.create_task = Mock(side_effect=lambda coro: coro.close())
with patch('SamsungTheFrameAsync.EventHandler') as handler: with patch('samsungtheframeasync.EventHandler') as handler:
app.on_initialize_smart_object() app.on_initialize_smart_object()
self.assertEqual(app.listen_state.call_count, 3) self.assertEqual(app.listen_state.call_count, 3)
self.assertEqual(handler.call_count, 2) self.assertEqual(handler.call_count, 2)
@@ -343,7 +359,7 @@ class ApplicationTests(unittest.IsolatedAsyncioTestCase):
app.get_state.side_effect = None app.get_state.side_effect = None
self.assertIsNone(await app._transition('power_off')) self.assertIsNone(await app._transition('power_off'))
app.turn_on.assert_not_called() app.turn_on.assert_not_called()
self.assertEqual(await app._transition('art_mode'), 60) self.assertEqual(await app._transition('art_mode'), 5)
app.turn_on.assert_awaited_once_with(app.power_plug) app.turn_on.assert_awaited_once_with(app.power_plug)
app._client.art_request.assert_not_called() app._client.art_request.assert_not_called()
@@ -379,7 +395,7 @@ class ApplicationTests(unittest.IsolatedAsyncioTestCase):
async def test_monitor_coalesces_sensor_bursts_and_shutdown_closes_client(self): async def test_monitor_coalesces_sensor_bursts_and_shutdown_closes_client(self):
app = self.app() app = self.app()
client = app._client client = app._client
with patch('SamsungTheFrameAsync._TVConnection', return_value=client): with patch('samsungtheframeasync._TVConnection', return_value=client):
task = asyncio.create_task(app._run()) task = asyncio.create_task(app._run())
await asyncio.sleep(.02) await asyncio.sleep(.02)
for _ in range(30): for _ in range(30):
@@ -402,7 +418,7 @@ class ApplicationTests(unittest.IsolatedAsyncioTestCase):
app.args['reconnect_delay'] = .5 app.args['reconnect_delay'] = .5
client.alive.return_value = False client.alive.return_value = False
client.art_request.side_effect = TVError('offline') client.art_request.side_effect = TVError('offline')
with patch('SamsungTheFrameAsync._TVConnection', return_value=client): with patch('samsungtheframeasync._TVConnection', return_value=client):
task = asyncio.create_task(app._run()) task = asyncio.create_task(app._run())
await asyncio.sleep(.01) await asyncio.sleep(.01)
for _ in range(10): for _ in range(10):