Skip to content

Match cloud poll cmd and headers to the iOS app capture, and fix a shutdown hang - #91

Merged
jnimmo merged 5 commits into
masterfrom
claude/intesishome-headers-capture
Aug 24, 2026
Merged

jnimmo merged 5 commits into
masterfrom
claude/intesishome-headers-capture

Conversation

@jnimmo

@jnimmo jnimmo commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

Makes the cloud HTTP poll look like the official app's, from a Proxyman capture of AC Cloud 3.3.3 on iOS — plus a supported-Python floor and a shutdown-path hardening found while chasing a hanging test.

The cmd field

The app always asks for all five blocks, not just status/config, and always includes the empty permissions string and the error block's culture:

{"status":{"hash":"x"},"config":{"hash":"x"},"permissions":"","scenes":{"hash":"x"},"patterns":{"hash":"x"},"error":{"hash":"x","culture":"en"}}

Verified byte-for-byte against the captured request. We don't parse scenes/patterns/permissions/error, but sending the app's shape keeps our traffic indistinguishable from it.

os=ios / osVersion=26.6 already matched, so they're unchanged.

Request headers

Adds API_EXTRA_HEADERS — Accept, Accept-Encoding, Accept-Language — sent alongside User-Agent on every poll, matching the same capture.

Accept and Accept-Encoding happen to coincide with aiohttp's defaults today, but they're set explicitly anyway: aiohttp builds Accept-Encoding from whichever optional codec packages are installed, so it silently drops br in an environment without brotli. What our traffic looks like shouldn't depend on that. aiohttp sends no Accept-Language at all.

Content-Type: application/x-www-form-urlencoded and Connection: keep-alive already match — aiohttp sends both for a dict-form POST over a persistent session (confirmed in its source), so neither needs an override.

Verified by constructing a real aiohttp.ClientRequest and comparing the prepared header set against the capture:

Host: user.intesishome.com
User-Agent: AC Cloud/3.3.3 (iPhone; iOS 26.6; Scale/3.00)
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
Content-Length: 295
Content-Type: application/x-www-form-urlencoded

python_requires and CI coverage

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:

try:
    await waiter
except exceptions.CancelledError:
    if fut.done():
        return fut.result()      # cancellation dropped here

stop() sets _stopping, cancels the poll task and awaits it, while _handle_disconnect() sets _poll_wakeup — completing exactly that future. A disconnect landing in the same loop iteration as stop() left the poller having absorbed its only cancel, looping on, with _cancel_task_if_exists waiting forever.

Nothing tested those versions, which is how it survived: CI ran 3.14 only.

This 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.

Poller shutdown check

_run_poller now retires on the _stopping flag after each wait rather than depending solely on the cancel arriving.

To be clear about its status: on the supported range this is belt-and-braces, not a live bug fix. I verified on a real 3.12 interpreter that the tests pass with the check removed — the hang reproduces only on 3.10/3.11. It's kept because shutdown shouldn't rest on the cancellation semantics of one await deep inside the loop, and test_stop_completes_when_a_disconnect_races_it pins the invariant _cancel_task_if_exists actually relies on: one cancel() must reap the poller.

This is also what had been hanging test_disconnect_does_not_reconnect for 120s in fixture teardown on pre-3.12 interpreters.

Verification

99 tests pass with nothing deselected, on both 3.11 and 3.12, in ~13s rather than timing out. ruff and pylint clean (10.00/10).

Version

Minor bump to 2.4.0.

Response hash tracking — sending the server's returned hashes back so it can skip resending unchanged blocks — builds on this and is deliberately left for a follow-up PR, since its response-parsing side rests on a protocol assumption worth soaking separately.

claude added 3 commits August 24, 2026 00:08
- 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.
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.
@jnimmo jnimmo changed the title Match cloud poll cmd and headers to the iOS app packet capture Match cloud poll cmd and headers to the iOS app capture, and fix a shutdown hang Aug 24, 2026
claude added 2 commits August 24, 2026 01:27
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.
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.
@jnimmo
jnimmo merged commit 6413b12 into master Aug 24, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants