From 8c04892ceaf0c8d0091f80a8fa60a16129726e16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 00:08:24 +0000 Subject: [PATCH 1/5] Match cloud poll cmd and headers to iOS app packet capture - INTESIS_CMD_STATUS now requests all five blocks (status, config, permissions, scenes, patterns, error) the official AC Cloud 3.3.3 iOS app always sends, byte-for-byte matching a Proxyman capture. - Add API_EXTRA_HEADERS (Accept, Accept-Encoding, Accept-Language) sent on every cloud poll, matching the same capture. These are set explicitly because aiohttp's own Accept-Encoding default depends on which optional codec packages happen to be installed, and it sends no Accept-Language at all. --- pyintesishome/const.py | 24 +++++++++++++++++++++++- pyintesishome/intesishome.py | 3 ++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/pyintesishome/const.py b/pyintesishome/const.py index c971460..ae723c8 100644 --- a/pyintesishome/const.py +++ b/pyintesishome/const.py @@ -1,6 +1,16 @@ """Constants for pyintesishome""" -INTESIS_CMD_STATUS = '{"status":{"hash":"x"},"config":{"hash":"x"}}' +# Sent as the "cmd" form field on every cloud HTTP poll. Matches a Proxyman +# capture of AC Cloud 3.3.3 (iOS) byte-for-byte: the official app always +# requests all five blocks, not just status/config, and always includes the +# empty "permissions" string and the error block's "culture". We don't parse +# scenes/patterns/permissions/error today, but sending the same shape as the +# real app keeps our traffic indistinguishable from it. +INTESIS_CMD_STATUS = ( + '{"status":{"hash":"x"},"config":{"hash":"x"},"permissions":"",' + '"scenes":{"hash":"x"},"patterns":{"hash":"x"},' + '"error":{"hash":"x","culture":"en"}}' +) INTESIS_NULL = 32768 DEVICE_INTESISHOME = "IntesisHome" @@ -447,6 +457,18 @@ DEVICE_INTESISHOME: "AC Cloud", } +# Extra headers sent alongside User-Agent on every cloud HTTP poll, captured +# from the same iOS traffic. Set explicitly rather than left to aiohttp's +# defaults: aiohttp's own Accept-Encoding varies with which optional codec +# packages (brotli, zstd) happen to be installed, and it sends no +# Accept-Language of its own at all, so either would otherwise drift from +# what the real app sends depending on the environment. +API_EXTRA_HEADERS = { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "en;q=1, de;q=0.9, fr;q=0.8, it;q=0.7, es;q=0.6, ca;q=0.5", +} + LOCAL_CMD_LOGIN = "login" LOCAL_CMD_GET_INFO = "getinfo" LOCAL_CMD_SET_DP_VALUE = "setdatapointvalue" diff --git a/pyintesishome/intesishome.py b/pyintesishome/intesishome.py index fb2c479..febbdd5 100644 --- a/pyintesishome/intesishome.py +++ b/pyintesishome/intesishome.py @@ -12,6 +12,7 @@ from .const import ( API_APP_NAME, + API_EXTRA_HEADERS, API_OS, API_OS_VERSION, API_UA_SCALE, @@ -520,7 +521,7 @@ async def _poll_status(self, sendcallback=False): async with self._web_session.post( url=self._api_url, data=get_status, - headers={"User-Agent": user_agent}, + headers={"User-Agent": user_agent, **API_EXTRA_HEADERS}, cookies=self._cookies, ) as resp: # Sent explicitly above rather than left to the session's own From 55d5c113036ec067b6fbe79b504a90c5929912ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 01:08:16 +0000 Subject: [PATCH 2/5] Bump version to 2.4.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 40cd169..d5965f9 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="pyintesishome", - version="2.3.0", + version="2.4.0", description="A python3 library for running asynchronus communications with IntesisHome Smart AC Controllers", long_description=long_description, long_description_content_type="text/markdown", From 64027331c596f9d84d2a9e424178b037072f6d6a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 01:16:26 +0000 Subject: [PATCH 3/5] Fix shutdown hanging when a disconnect races stop() stop() sets _stopping, then cancels the poll task and awaits it. That await could never return: on Python < 3.12 asyncio.wait_for discards a cancellation delivered while the future it is waiting on has already completed, and _handle_disconnect sets _poll_wakeup, which completes exactly that future. A disconnect landing in the same loop iteration as stop() left the poller looping on, having absorbed the only cancel it was ever sent. The poller now checks _stopping after each wait, so it retires on the flag rather than relying on a cancel that may not arrive. 3.12 rewrote wait_for on top of asyncio.timeout and propagates the cancel correctly; the check is harmless there and the fix holds on 3.10 through 3.13. This is what hung test_disconnect_does_not_reconnect in teardown - the test woke the poller and the fixture then called stop(). The suite now runs clean with nothing deselected, in 13s rather than timing out. --- pyintesishome/intesishome.py | 14 ++++++++++++++ tests/test_pyintesishome.py | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/pyintesishome/intesishome.py b/pyintesishome/intesishome.py index febbdd5..390a1e3 100644 --- a/pyintesishome/intesishome.py +++ b/pyintesishome/intesishome.py @@ -255,6 +255,20 @@ async def _run_poller(self): pass self._poll_wakeup.clear() + if self._stopping: + # stop() sets _stopping before cancelling this task, and + # that cancel does not always arrive. On Python < 3.12 + # asyncio.wait_for discards a cancellation delivered + # while the future it is waiting on has already + # completed - which is exactly what stop() racing a + # disconnect produces, since _handle_disconnect sets + # _poll_wakeup. Relying on the cancel alone, the loop + # would carry on and the await in + # _cancel_task_if_exists would never return, hanging + # shutdown for good. + _LOGGER.debug("Stopping the %s poller", self._device_type) + return + if self._connected: # Push is carrying state; nothing to fetch. continue diff --git a/tests/test_pyintesishome.py b/tests/test_pyintesishome.py index cc4e0f1..d036412 100644 --- a/tests/test_pyintesishome.py +++ b/tests/test_pyintesishome.py @@ -629,6 +629,33 @@ async def test_disconnect_does_not_reconnect(cloud_controller): assert cloud_controller.is_available is True +@pytest.mark.asyncio +async def test_stop_completes_when_a_disconnect_races_it(cloud_controller): + """stop() must not wait on a poller that outlived its cancellation. + + _handle_disconnect() wakes the poller, and stop() cancels it in the same + loop iteration. On Python < 3.12 asyncio.wait_for discards a cancel + delivered while the future it was waiting on has already completed, so + the poller has to notice _stopping itself - otherwise stop() awaits a + task that never finishes and shutdown hangs for good. + """ + # Exactly what stop() does, in one loop iteration and with no await + # between: a disconnect has already woken the poller, and the cancel + # lands before it has run. + cloud_controller._stopping = True + cloud_controller._poll_wakeup.set() + cloud_controller._poll_task.cancel() + + for _ in range(10): + if cloud_controller._poll_task.done(): + break + await asyncio.sleep(0) + + assert cloud_controller._poll_task.done(), ( + "the poller survived the single cancel() that stop() relies on" + ) + + @pytest.mark.asyncio async def test_opening_a_socket_starts_a_keepalive(cloud_controller): """A live socket is kept healthy: the keepalive is what turns a From a818567b5ea9152a1588fbe773679ab2a2d41139 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 01:27:03 +0000 Subject: [PATCH 4/5] Declare python_requires >=3.12 and test the range in CI The package declared no floor, so pip would install it on any Python - including 3.10 and 3.11, where asyncio.wait_for discards a cancellation delivered while the future it is waiting on has already completed, and the poller's shutdown hung. Nothing tested those versions, which is how that survived. Sets the floor at 3.12, where wait_for is built on asyncio.timeout and propagates cancellation correctly, and widens the CI matrix to 3.12, 3.13 and 3.14 so every version claimed is a version tested. Classifiers updated to match. Reframes the poller's _stopping check accordingly: on the supported range it is belt-and-braces rather than a workaround, kept so shutdown does not rest on the cancellation semantics of one await inside the loop. --- .github/workflows/main.yml | 2 +- pyintesishome/intesishome.py | 25 +++++++++++++++---------- setup.py | 7 +++++++ tests/test_pyintesishome.py | 9 +++++---- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 332fa8b..fd91229 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,7 +42,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.14"] + python-version: ["3.12", "3.13", "3.14"] needs: - ruff - pylint diff --git a/pyintesishome/intesishome.py b/pyintesishome/intesishome.py index 390a1e3..01cf98c 100644 --- a/pyintesishome/intesishome.py +++ b/pyintesishome/intesishome.py @@ -256,16 +256,21 @@ async def _run_poller(self): self._poll_wakeup.clear() if self._stopping: - # stop() sets _stopping before cancelling this task, and - # that cancel does not always arrive. On Python < 3.12 - # asyncio.wait_for discards a cancellation delivered - # while the future it is waiting on has already - # completed - which is exactly what stop() racing a - # disconnect produces, since _handle_disconnect sets - # _poll_wakeup. Relying on the cancel alone, the loop - # would carry on and the await in - # _cancel_task_if_exists would never return, hanging - # shutdown for good. + # Retire on the flag stop() sets, rather than trusting + # the cancel it sends straight after to arrive. On + # Python < 3.12 it does not always: asyncio.wait_for + # discarded a cancellation delivered while the future + # it was waiting on had already completed, which is + # what stop() racing a disconnect produces, since + # _handle_disconnect sets _poll_wakeup. The poller + # absorbed its only cancel, looped on, and the await in + # _cancel_task_if_exists never returned. + # + # 3.12 rewrote wait_for over asyncio.timeout and + # propagates the cancel correctly, so on the versions + # this package supports the check is belt-and-braces. + # It is kept because shutdown should not rest on the + # cancellation semantics of one await deep in the loop. _LOGGER.debug("Stopping the %s poller", self._device_type) return diff --git a/setup.py b/setup.py index d5965f9..450fb1e 100644 --- a/setup.py +++ b/setup.py @@ -18,9 +18,16 @@ author_email="james@nimmo.net.nz", license="MIT", install_requires=["aiohttp>=3.7.4,<4"], + # 3.12 rewrote asyncio.wait_for on top of asyncio.timeout. Earlier + # versions discard a cancellation delivered while the future being + # waited on has already completed, which the poller's shutdown path + # would otherwise have to keep working around untested. + python_requires=">=3.12", packages=["pyintesishome"], classifiers=[ "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator", "Topic :: Home Automation", diff --git a/tests/test_pyintesishome.py b/tests/test_pyintesishome.py index d036412..8fd83e8 100644 --- a/tests/test_pyintesishome.py +++ b/tests/test_pyintesishome.py @@ -634,10 +634,11 @@ async def test_stop_completes_when_a_disconnect_races_it(cloud_controller): """stop() must not wait on a poller that outlived its cancellation. _handle_disconnect() wakes the poller, and stop() cancels it in the same - loop iteration. On Python < 3.12 asyncio.wait_for discards a cancel - delivered while the future it was waiting on has already completed, so - the poller has to notice _stopping itself - otherwise stop() awaits a - task that never finishes and shutdown hangs for good. + loop iteration. Pinning the invariant _cancel_task_if_exists rests on: + one cancel() has to reap the task. Python < 3.12 broke it outright - + wait_for discarded a cancel delivered while the future it was waiting on + had already completed - and while 3.12 propagates it correctly, nothing + about the loop guarantees that on its own. """ # Exactly what stop() does, in one loop iteration and with no await # between: a disconnect has already woken the poller, and the cancel From 75d590230871471f61f688afd492bc690e9f256e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 01:30:00 +0000 Subject: [PATCH 5/5] Remove CHANGELOG.md in favour of GitHub releases The file was redundant and behind: it carried Unreleased and 2.3.0, while the GitHub releases cover v2.0.0 through v2.3.0 with fuller notes for each. Nothing referenced it - setup.py builds long_description from the README alone, and there is no MANIFEST.in - so it was a second place to remember to update, kept in step by hand. Its Unreleased section (the web portal command fallback and SOCKET_CONNECT_TIMEOUT) is the basis for the 2.4.0 release notes, and stays available in history at 27a897d:CHANGELOG.md. --- CHANGELOG.md | 87 ---------------------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index dca768d..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,87 +0,0 @@ -# Changelog - -## Unreleased - -### Added - -**Web portal command fallback.** When the command socket cannot be opened, -the cloud (`IntesisHome`) controller now sends SETs the way the -accloud.intesis.com web portal does: a form login over HTTPS followed by -`POST /device/setVal` with the same uid/value datapoints the socket protocol -uses. Motivated by the August 2026 outage, where the socket server was -unreachable for days while both the HTTP API and the portal kept working -([hass-intesishome#68](https://github.com/jnimmo/hass-intesishome/issues/68)), -leaving the integration read-only. The portal session lives in its own -`aiohttp` session (closed by `stop()`, along with the cached login), logs in -lazily on first use, retries once with a fresh login when its session -expires, and is bounded by `PORTAL_TIMEOUT` (20s) so a stalled portal cannot -hold up the commands queued behind it. Normal socket operation resumes -automatically as soon as the socket can be opened again. - -`use_socket=False` is exempt. That setting means "read-only controller", so -it disables the portal fallback too rather than quietly regaining the -ability to send commands over HTTPS. - -**Bounded socket connect (`SOCKET_CONNECT_TIMEOUT`, 5s).** Opening the -command socket previously paid a full kernel TCP timeout (~130s) when the -port was filtered, and every SET waited it out before failing. The attempt -is now capped, so commands fall back (or fail) in seconds. - -## 2.3.0 - -Reworks the cloud (`IntesisHome`) controller so that state comes from HTTPS -polling, and the TCP socket is opened only by commands rather than held open -permanently. While a socket is open it still carries status pushes, and -polling stands down for the duration. This fixes the availability flapping -reported in -[hass-intesishome#50](https://github.com/jnimmo/hass-intesishome/issues/50), -where entities went unavailable every time the socket timed out even though -the REST endpoint remained perfectly healthy. - -### Breaking changes - -**`connect()` no longer opens the socket.** It authenticates, loads state and -starts the poller. The socket is opened lazily by the first command. Start-up -therefore no longer depends on outbound access to the cloud's high port. - -**`is_connected` is now `False` in normal operation.** It reports raw socket -state, and the socket only exists around commands. Consumers that gate entity -availability on it will show everything as unavailable. - -> Migrate to **`is_available`** (new in this release), which is true when a -> recent poll succeeded *or* a socket is up. This is the property to gate -> availability on. - -**Automatic reconnection has been removed.** A dropped socket is no longer an -error to recover from: polling continues to carry state, and the next command -opens a fresh socket. `_reconnect_loop` and the backoff settings are gone. - -### Added - -- `is_available` — whether the controller has a working path to the device. -- `use_socket` (default `True`) — set `False` to never open a socket, making - the controller read-only. -- `poll_interval` (default `120`, floor `60`) — seconds between state polls. - Pass `0`/`None` to disable polling. - -### Changed - -- Polling stands down while a socket is up, so a healthy socket costs no extra - API traffic. The keepalive bounds how long a half-dead socket can suppress - polling, and a disconnect triggers an immediate poll so availability does not - dip in the gap. -- `poll_status()` now fires one update callback per device rather than only for - the last device in the status list, so multi-device installations no longer - leave devices showing stale state. -- `poll_status()` is serialised, so a poll can no longer consume the single-use - socket token that a concurrent connect is about to present. -- `poll_status()` tolerates a response with no `config` block. -- A poll that returns no device state no longer counts as a successful update. - An account whose devices are all offline answers `200` with an empty status - list; treating that as success pinned availability true while serving state - that never refreshed. - -### Added (diagnostics) - -- `examples/probe_cloud_set.py` — standalone, dependency-free probe for whether - the cloud HTTPS API accepts writes. See its header for findings to date.