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/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. 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..01cf98c 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, @@ -254,6 +255,25 @@ async def _run_poller(self): pass self._poll_wakeup.clear() + if self._stopping: + # 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 + if self._connected: # Push is carrying state; nothing to fetch. continue @@ -520,7 +540,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 diff --git a/setup.py b/setup.py index 40cd169..450fb1e 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", @@ -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 cc4e0f1..8fd83e8 100644 --- a/tests/test_pyintesishome.py +++ b/tests/test_pyintesishome.py @@ -629,6 +629,34 @@ 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. 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 + # 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