Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 0 additions & 87 deletions CHANGELOG.md

This file was deleted.

24 changes: 23 additions & 1 deletion pyintesishome/const.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
22 changes: 21 additions & 1 deletion pyintesishome/intesishome.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from .const import (
API_APP_NAME,
API_EXTRA_HEADERS,
API_OS,
API_OS_VERSION,
API_UA_SCALE,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions tests/test_pyintesishome.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading