Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion pyintesishome/intesishome.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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.

Expand Down
76 changes: 75 additions & 1 deletion tests/test_pyintesishome.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for pyintesishome."""

import asyncio
import json
import re
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down