From dccd95ec71b68f2db02265d55bf87e91333a2c27 Mon Sep 17 00:00:00 2001 From: Justin Maiuto <8307789+justinmaiuto@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:15:21 +1100 Subject: [PATCH] Fall back to the web portal when a socket SET is never acknowledged The command server at 212.92.41.143:5210 has been reachable again since early October 2026, but in a half-alive state: TCP connects, the SET is written, and no set_ack ever comes back. The portal fallback from #90 only covers a socket that cannot be opened, so in this state every command fails ("not acknowledged within 5.0s") while the portal would still have delivered it. Use the same portal fallback after an ack timeout. Portal SETs carry absolute values, so repeating a SET that did arrive is harmless. use_socket=False is unaffected: it never reaches the ack wait. Co-Authored-By: Claude Opus 5.5 (1M context) --- pyintesishome/intesishome.py | 18 ++++++++- tests/test_pyintesishome.py | 76 +++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/pyintesishome/intesishome.py b/pyintesishome/intesishome.py index 979953b..f5257d3 100644 --- a/pyintesishome/intesishome.py +++ b/pyintesishome/intesishome.py @@ -675,6 +675,13 @@ async def _set_value(self, device_id, uid, value) -> bool: controller effectively read-only, and it says so rather than hanging until the ack timeout. + The same fallback applies when the socket opens but the SET is + never acknowledged. The command server has been seen in that + half-alive state (TCP connects, no set_ack ever comes back) while + the portal kept delivering commands. Portal SETs carry absolute + values, so a SET that did arrive and is then repeated through the + portal is harmless. + ``use_socket=False`` is exempt: that is a deliberate read-only controller rather than a broken socket, so it does not fall back. """ @@ -725,10 +732,19 @@ async def _set_value(self, device_id, uid, value) -> bool: seq, self._set_ack_timeout, ) - return False finally: self._pending_set_acks.pop(seq, None) + if await self._portal_set_value(device_id, uid, value): + _LOGGER.warning( + "SET uid=%s value=%s delivered via the web portal after " + "the socket did not acknowledge it", + uid, + value, + ) + return True + return False + async def _portal_set_value(self, device_id, uid, value) -> bool: """Send a SET through the brand's web portal. diff --git a/tests/test_pyintesishome.py b/tests/test_pyintesishome.py index d93540c..b215ea9 100644 --- a/tests/test_pyintesishome.py +++ b/tests/test_pyintesishome.py @@ -1,6 +1,7 @@ """Tests for pyintesishome.""" import asyncio +import json import re from datetime import datetime, timedelta, timezone from http.cookies import SimpleCookie @@ -703,7 +704,10 @@ async def fake_ensure(): return True with patch.object(cloud_controller, "_ensure_socket", fake_ensure): - with patch.object(cloud_controller, "_send_command", return_value=None): + with ( + patch.object(cloud_controller, "_send_command", return_value=None), + patch.object(cloud_controller, "_portal_set_value", return_value=False), + ): cloud_controller._set_ack_timeout = 0.05 await cloud_controller.set_power_on(MOCK_DEVICE_ID) await cloud_controller.set_power_off(MOCK_DEVICE_ID) @@ -1057,6 +1061,76 @@ def _mock_working_portal(mock_aioresponse): # noqa: F811 ) +@pytest.mark.asyncio +async def test_unacknowledged_set_falls_back_to_portal( + cloud_controller, + mock_aioresponse, # noqa: F811 +): + """A socket that opens but never acks a SET must not strand the command. + + The command server has been seen half-alive: TCP connects, the SET is + written, no set_ack ever comes back. The portal still delivers. + """ + _mock_working_portal(mock_aioresponse) + + async def fake_ensure(): + cloud_controller._connected = True + return True + + with ( + patch.object(cloud_controller, "_ensure_socket", fake_ensure), + patch.object(cloud_controller, "_send_command", return_value=None), + ): + cloud_controller._set_ack_timeout = 0.05 + assert await cloud_controller._set_value(MOCK_DEVICE_ID, 1, 1) is True + + assert cloud_controller._portal_user_id == "4242" + assert not cloud_controller._pending_set_acks + + +@pytest.mark.asyncio +async def test_unacknowledged_set_fails_when_portal_also_fails( + cloud_controller, + mock_aioresponse, # noqa: F811 +): + """No ack and no portal means the SET is reported failed, not raised.""" + portal = PORTAL_URL[DEVICE_INTESISHOME] + mock_aioresponse.get(f"{portal}/login", body="maintenance page", repeat=True) + + async def fake_ensure(): + cloud_controller._connected = True + return True + + with ( + patch.object(cloud_controller, "_ensure_socket", fake_ensure), + patch.object(cloud_controller, "_send_command", return_value=None), + ): + cloud_controller._set_ack_timeout = 0.05 + assert await cloud_controller._set_value(MOCK_DEVICE_ID, 1, 1) is False + + +@pytest.mark.asyncio +async def test_acknowledged_set_does_not_touch_the_portal(cloud_controller): + """An acked SET is done; the portal is only a fallback.""" + + async def fake_ensure(): + cloud_controller._connected = True + return True + + async def fake_send(message, wait_for_response=False): + seq = json.loads(message)["data"]["seqNo"] + cloud_controller._pending_set_acks[seq].future.set_result(True) + + with ( + patch.object(cloud_controller, "_ensure_socket", fake_ensure), + patch.object(cloud_controller, "_send_command", fake_send), + patch.object(cloud_controller, "_portal_set_value") as portal_set, + ): + assert await cloud_controller._set_value(MOCK_DEVICE_ID, 1, 1) is True + + portal_set.assert_not_called() + + @pytest.mark.asyncio async def test_use_socket_false_does_not_use_the_portal_fallback( mock_aioresponse, # noqa: F811