diff --git a/docs/energy_local_control.md b/docs/energy_local_control.md index 5d34873..a02b673 100644 --- a/docs/energy_local_control.md +++ b/docs/energy_local_control.md @@ -114,15 +114,15 @@ gateway's LAN IP over the cloud with interface (or `None` when no usable interface is reported) - see [Teslemetry](teslemetry.md#energy-site-gateway-address). -## 4. Verify the key is paired by using it +## 4. Wait for the key to be paired The gateway takes registration (step 2) and physical confirmation as two separate events, and there is a window -between them where the key exists but is not yet usable. **The reliable way -to tell that window has closed is to attempt a signed local read through -`aiopowerwall` and retry until it succeeds** - a successful signed response -*is* proof the key is `VERIFIED`, because the gateway would otherwise reject -it. +between them where the key exists but is not yet usable. The typed Teslemetry +helper below uses the gateway's authorized-client state as its primary signal. +Where local network access is available, a successful signed read through +`aiopowerwall` can additionally confirm that the key is usable; the gateway +would reject that read before verification. `get_system_info()`/`get_status()`-style reads are the natural choice for this, but `PowerwallEnergySite` does not implement them locally yet (they @@ -131,6 +131,29 @@ the `EnergySiteRouter` note in step 5). Use `live_status()` instead: it is already implemented locally and, under the hood, issues a signed v1r request, so it fails exactly the way an unverified key would fail. +`TeslemetryEnergySite.wait_until_paired()` implements this combined check as +a library helper: it polls `find_authorized_clients()` for the registered +key's state, returning as soon as it is `VERIFIED`, and raises +`tesla_fleet_api.exceptions.AuthorizedClientPairingTimedOut` immediately if +the gateway reports the terminal `PENDING_VERIFICATION_TIMEOUT` state rather +than continuing to poll a dead registration - re-register the same key to +retry. Its own bounded overall wait (default 600s) raises +`tesla_fleet_api.exceptions.AuthorizedClientWaitExpired` instead if the +window is simply still open. Pass an async `verify_by_use` callable (e.g. +`local_energysite.live_status`) to additionally require a successful signed +local read before returning: + +```python +client = await teslemetry_energysite.wait_until_paired( + api.rsa_public_der_pkcs1_b64, + verify_by_use=local_energysite.live_status, +) +``` + +The manual polling loop below predates this helper and remains as a +reference for building a custom confirmation flow (e.g. against the base +Fleet API, which has no typed `find_authorized_clients()`). + Before verification, every signed request rejects with `aiopowerwall.PowerwallAuthenticationError` (the gateway's "unknown key id" or "authorization not verified" fault) - **that failure is expected and not @@ -159,14 +182,13 @@ async def wait_until_verified( delay = min(delay * 2, max_delay) ``` -Only fall back to polling the cloud `list_authorized_clients()` (or, on -`Teslemetry`, `find_authorized_clients()`) as a **secondary, best-effort** -check - for example while you have no local network path to the gateway yet. -Tesla's cloud endpoint for this is undocumented, and Teslemetry's +The base Fleet API has no typed equivalent of `wait_until_paired()`. Its +callers can poll `list_authorized_clients()` and combine that with the manual +signed-read loop above. Tesla's cloud endpoint is undocumented, and Teslemetry's `list_authorized_clients` in particular has been observed returning a bare JSON `null` with a `200` status rather than an envelope; that behavior may -recur, so do not treat this endpoint as authoritative, and never let it -override a signed local read that already succeeded or failed. +recur. Treat an unavailable or malformed cloud response as no signal, and +never let it override a successful signed local read. `TeslemetryEnergySite.find_authorized_clients()` parses the recognized shapes (list vs. dict envelope, `state` typing) into a typed `AuthorizedClients`, but raises diff --git a/docs/teslemetry.md b/docs/teslemetry.md index e6b96b6..7a99dc8 100644 --- a/docs/teslemetry.md +++ b/docs/teslemetry.md @@ -564,9 +564,9 @@ base64-encoded key string. Removal requires no physical presence proof, so any paired key can revoke every other key, including the owner's. These cloud helpers report the gateway's registered-client state as returned -by the Teslemetry API. Confirming that a key can actually make signed LAN -requests requires a successful signed local read through the paired client, -as shown in [Energy: Local Control](energy_local_control.md). +by the Teslemetry API. `wait_until_paired()` provides the bounded polling flow +and can optionally confirm the cloud state with a signed LAN read; see +[Energy: Local Control](energy_local_control.md). ```python async def main(): diff --git a/tesla_fleet_api.egg-info/SOURCES.txt b/tesla_fleet_api.egg-info/SOURCES.txt index 0c1bc32..9f37869 100644 --- a/tesla_fleet_api.egg-info/SOURCES.txt +++ b/tesla_fleet_api.egg-info/SOURCES.txt @@ -97,5 +97,6 @@ tests/test_tariff.py tests/test_tesla_private_key.py tests/test_teslemetry_authorized_clients.py tests/test_teslemetry_gateway_address.py +tests/test_teslemetry_wait_until_paired.py tests/test_tessie_vehicle_params.py tests/test_vehicle_image_state.py \ No newline at end of file diff --git a/tesla_fleet_api/exceptions.py b/tesla_fleet_api/exceptions.py index ec103c5..18794be 100644 --- a/tesla_fleet_api/exceptions.py +++ b/tesla_fleet_api/exceptions.py @@ -414,6 +414,37 @@ class SignedCommandRequired(TeslaFleetError): ) +class AuthorizedClientPairingTimedOut(TeslaFleetError): + """An energy gateway's presence-proof window expired before verification. + + The gateway itself reported the terminal ``PENDING_VERIFICATION_TIMEOUT`` + state (``AuthorizedClientState``) - the ~9-minute window to confirm the + key (typically via a physical breaker/switch toggle) closed with no + confirmation. The registration is dead; re-register the *same* public + key with ``add_authorized_client`` to reset the window and retry, rather + than generating a new key. + """ + + message = ( + "Authorized-client pairing timed out: the presence-proof window " + "expired (PENDING_VERIFICATION_TIMEOUT). Re-register the same " + "public key to reset the window and retry." + ) + + +class AuthorizedClientWaitExpired(TeslaFleetError): + """``wait_until_paired()``'s own bounded overall wait elapsed. + + Distinct from ``AuthorizedClientPairingTimedOut``: the gateway had not + reported a terminal state (the registration may still be alive, e.g. + still ``PENDING_VERIFICATION``, or the local ``verify_by_use`` check + kept failing) when the caller's ``timeout`` ran out. Retry by calling + ``wait_until_paired()`` again. + """ + + message = "Timed out waiting for authorized-client pairing to complete." + + class SessionInfoAuthenticationFault(TeslaFleetError): """A ``session_info`` reply failed local authentication and was discarded. diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index 056eebb..f95ec2c 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -1,8 +1,10 @@ from __future__ import annotations +import asyncio import base64 import socket import struct +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, cast @@ -14,9 +16,17 @@ AuthorizedVerificationType, Method, ) -from tesla_fleet_api.exceptions import InvalidResponse +from tesla_fleet_api.exceptions import ( + AuthorizedClientPairingTimedOut, + AuthorizedClientWaitExpired, + InvalidResponse, + TeslaFleetError, +) from tesla_fleet_api.tesla.energysite import EnergySite, EnergySites +DEFAULT_PAIRING_TIMEOUT = 600.0 +DEFAULT_PAIRING_POLL_INTERVAL = 5.0 + def _field(payload: dict[str, Any], *keys: str) -> Any: """Return the first present key's value. @@ -402,6 +412,131 @@ async def remove_authorized_client(self, public_key: bytes | str) -> dict[str, A json={"public_key": public_key_b64}, ) + async def wait_until_paired( + self, + public_key: bytes | str, + *, + verify_by_use: Callable[[], Awaitable[Any]] | None = None, + timeout: float = DEFAULT_PAIRING_TIMEOUT, + poll_interval: float = DEFAULT_PAIRING_POLL_INTERVAL, + ) -> AuthorizedClient: + """Wait for a key already registered with ``add_authorized_client`` to finish pairing. + + Polls :meth:`find_authorized_clients` for the entry matching + ``public_key`` and returns it as soon as its state is ``VERIFIED``. + + The gateway's presence-proof window is ~9 minutes; a physical + breaker/switch toggle typically confirms a key in well under a + minute (observed as fast as 59s), with no cloud auto-verify + observed. ``timeout`` (default 600s / 10 minutes) bounds the overall + wait so this never blocks indefinitely - comfortably above the + window, but still a hard ceiling. Two distinct failure modes: + + - If the window itself expires, the gateway reports the terminal + ``PENDING_VERIFICATION_TIMEOUT`` state and this raises + :class:`~tesla_fleet_api.exceptions.AuthorizedClientPairingTimedOut` + immediately rather than continuing to poll a dead registration. + The registration cannot recover on its own - the correct retry is + to call :meth:`add_authorized_client` again with the exact *same* + public key (never a newly generated one), which resets the window + without creating a duplicate record, then call this again. + - If ``timeout`` elapses first (e.g. nobody toggled the switch yet, + so the state is still ``PENDING_VERIFICATION``), this raises + :class:`~tesla_fleet_api.exceptions.AuthorizedClientWaitExpired` + instead. The registration is still alive at that point; call this + again, or with a longer timeout. + + Cancelling the awaiting task raises ``asyncio.CancelledError`` as + usual - no separate handling is needed or attempted here. + + Args: + public_key: The public key being paired, exactly as passed to + ``add_authorized_client`` (raw DER bytes, or an + already-base64-encoded string) - compared against listed + entries as base64, matching how the gateway reports keys. + verify_by_use: Optional async callable that performs a signed + local read (e.g. an ``aiopowerwall`` client's + ``live_status()``). Where the caller has local network + access, a successful call is definitive proof the key is + usable - the RSA key is the only signer the LAN TEDapi v1r + protocol accepts (never an ECC key; see + ``add_authorized_client``). When given, a ``VERIFIED`` cloud + state is combined with one confirming call before this + returns; a failing call is treated as "not yet confirmed" + and polling continues, since ``VERIFIED`` can be observed + slightly ahead of local usability. When omitted, the cloud + ``VERIFIED`` state alone is treated as success - the + captain's original shape, with authorized-client state as + the primary signal and verify-by-use as confirmation only + where available. + timeout: Overall bounded wait, in seconds (default 600s). + poll_interval: Delay between polls, in seconds (default 5s). + + Returns: + The matched :class:`AuthorizedClient` once paired and (if + ``verify_by_use`` was given) confirmed usable. + """ + target = ( + base64.b64encode(public_key).decode("ascii") + if isinstance(public_key, bytes) + else public_key + ) + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + last_state: AuthorizedClientState | int | str | None = None + + while True: + match: AuthorizedClient | None = None + remaining = deadline - loop.time() + if remaining <= 0: + raise AuthorizedClientWaitExpired( + {"public_key": target, "state": last_state} + ) + try: + clients = await asyncio.wait_for( + self.find_authorized_clients(), timeout=remaining + ) + match = next( + (c for c in clients.clients if c.public_key == target), None + ) + except asyncio.TimeoutError as exc: + raise AuthorizedClientWaitExpired( + {"public_key": target, "state": last_state} + ) from exc + except (TeslaFleetError, Exception): + match = None + + if match is not None: + last_state = match.state + if match.state == AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT: + raise AuthorizedClientPairingTimedOut( + {"public_key": target, "state": match.state} + ) + if match.state == AuthorizedClientState.VERIFIED: + if verify_by_use is None: + return match + remaining = deadline - loop.time() + if remaining <= 0: + raise AuthorizedClientWaitExpired( + {"public_key": target, "state": last_state} + ) + try: + await asyncio.wait_for(verify_by_use(), timeout=remaining) + return match + except asyncio.TimeoutError as exc: + raise AuthorizedClientWaitExpired( + {"public_key": target, "state": last_state} + ) from exc + except (TeslaFleetError, Exception): + pass + + remaining = deadline - loop.time() + if remaining <= 0: + raise AuthorizedClientWaitExpired( + {"public_key": target, "state": last_state} + ) + await asyncio.sleep(min(poll_interval, remaining)) + class TeslemetryEnergySites(EnergySites): """Class containing and creating Teslemetry energy sites.""" diff --git a/tests/test_teslemetry_wait_until_paired.py b/tests/test_teslemetry_wait_until_paired.py new file mode 100644 index 0000000..e7e987e --- /dev/null +++ b/tests/test_teslemetry_wait_until_paired.py @@ -0,0 +1,211 @@ +"""Tests for TeslemetryEnergySite.wait_until_paired(), the verify-by-use +pairing-completion helper described in docs/energy_local_control.md step 4. + +Polling is exercised against a mocked find_authorized_clients() rather than +real HTTP, with small timeout/poll_interval values so the tests run fast +while still exercising the real asyncio.sleep()-based polling loop. +""" + +from __future__ import annotations + +import asyncio +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, MagicMock + +from tesla_fleet_api.const import AuthorizedClientState +from tesla_fleet_api.exceptions import ( + AuthorizedClientPairingTimedOut, + AuthorizedClientWaitExpired, +) +from tesla_fleet_api.teslemetry.energysite import AuthorizedClient, AuthorizedClients +from tesla_fleet_api.teslemetry.teslemetry import Teslemetry + +PUBLIC_KEY_B64 = "MIIBCgKCAQEAsomeBase64EncodedRsaPublicKeyBytes==" + + +def _client(state: AuthorizedClientState) -> AuthorizedClient: + return AuthorizedClient( + public_key=PUBLIC_KEY_B64, + state=state, + roles=None, + verification=None, + raw={}, + ) + + +class WaitUntilPairedTests(IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.api = Teslemetry(session=MagicMock(), access_token="token") + self.site = self.api.energySites.create(12345) + + async def test_returns_immediately_on_verified(self) -> None: + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + return_value=AuthorizedClients( + clients=[_client(AuthorizedClientState.VERIFIED)], raw={} + ) + ) + result = await self.site.wait_until_paired( + PUBLIC_KEY_B64, timeout=5, poll_interval=0.01 + ) + self.assertEqual(result.state, AuthorizedClientState.VERIFIED) + self.site.find_authorized_clients.assert_awaited_once() + + async def test_polls_until_verified(self) -> None: + responses = [ + AuthorizedClients( + clients=[_client(AuthorizedClientState.PENDING_VERIFICATION)], raw={} + ), + AuthorizedClients( + clients=[_client(AuthorizedClientState.PENDING_VERIFICATION)], raw={} + ), + AuthorizedClients( + clients=[_client(AuthorizedClientState.VERIFIED)], raw={} + ), + ] + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + side_effect=responses + ) + result = await self.site.wait_until_paired( + PUBLIC_KEY_B64, timeout=5, poll_interval=0.01 + ) + self.assertEqual(result.state, AuthorizedClientState.VERIFIED) + self.assertEqual(self.site.find_authorized_clients.await_count, 3) + + async def test_raises_distinctly_on_pending_verification_timeout(self) -> None: + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + return_value=AuthorizedClients( + clients=[_client(AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT)], + raw={}, + ) + ) + with self.assertRaises(AuthorizedClientPairingTimedOut): + await self.site.wait_until_paired( + PUBLIC_KEY_B64, timeout=5, poll_interval=0.01 + ) + self.site.find_authorized_clients.assert_awaited_once() + + async def test_enforces_overall_timeout_while_pending(self) -> None: + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + return_value=AuthorizedClients( + clients=[_client(AuthorizedClientState.PENDING_VERIFICATION)], raw={} + ) + ) + with self.assertRaises(AuthorizedClientWaitExpired): + await self.site.wait_until_paired( + PUBLIC_KEY_B64, timeout=0.03, poll_interval=0.01 + ) + self.assertGreaterEqual(self.site.find_authorized_clients.await_count, 1) + + async def test_enforces_timeout_when_client_lookup_hangs(self) -> None: + never_returns = asyncio.Event() + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + side_effect=never_returns.wait + ) + + with self.assertRaises(AuthorizedClientWaitExpired): + await self.site.wait_until_paired( + PUBLIC_KEY_B64, timeout=0.03, poll_interval=0.01 + ) + + self.site.find_authorized_clients.assert_awaited_once() + + async def test_verify_by_use_confirms_after_verified_state(self) -> None: + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + return_value=AuthorizedClients( + clients=[_client(AuthorizedClientState.VERIFIED)], raw={} + ) + ) + verify_by_use = AsyncMock(return_value=None) + result = await self.site.wait_until_paired( + PUBLIC_KEY_B64, + verify_by_use=verify_by_use, + timeout=5, + poll_interval=0.01, + ) + self.assertEqual(result.state, AuthorizedClientState.VERIFIED) + verify_by_use.assert_awaited_once() + + async def test_verify_by_use_failure_keeps_polling_until_it_succeeds( + self, + ) -> None: + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + return_value=AuthorizedClients( + clients=[_client(AuthorizedClientState.VERIFIED)], raw={} + ) + ) + verify_by_use = AsyncMock(side_effect=[RuntimeError("not yet"), None]) + result = await self.site.wait_until_paired( + PUBLIC_KEY_B64, + verify_by_use=verify_by_use, + timeout=5, + poll_interval=0.01, + ) + self.assertEqual(result.state, AuthorizedClientState.VERIFIED) + self.assertEqual(verify_by_use.await_count, 2) + + async def test_verify_by_use_never_succeeding_raises_wait_expired(self) -> None: + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + return_value=AuthorizedClients( + clients=[_client(AuthorizedClientState.VERIFIED)], raw={} + ) + ) + verify_by_use = AsyncMock(side_effect=RuntimeError("never confirms")) + with self.assertRaises(AuthorizedClientWaitExpired): + await self.site.wait_until_paired( + PUBLIC_KEY_B64, + verify_by_use=verify_by_use, + timeout=0.03, + poll_interval=0.01, + ) + + async def test_enforces_timeout_when_verify_by_use_hangs(self) -> None: + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + return_value=AuthorizedClients( + clients=[_client(AuthorizedClientState.VERIFIED)], raw={} + ) + ) + never_returns = asyncio.Event() + verify_by_use = AsyncMock(side_effect=never_returns.wait) + + with self.assertRaises(AuthorizedClientWaitExpired): + await self.site.wait_until_paired( + PUBLIC_KEY_B64, + verify_by_use=verify_by_use, + timeout=0.03, + poll_interval=0.01, + ) + + verify_by_use.assert_awaited_once() + + async def test_no_matching_client_yet_keeps_polling(self) -> None: + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + return_value=AuthorizedClients(clients=[], raw={}) + ) + with self.assertRaises(AuthorizedClientWaitExpired): + await self.site.wait_until_paired( + PUBLIC_KEY_B64, timeout=0.03, poll_interval=0.01 + ) + + async def test_accepts_raw_public_key_bytes(self) -> None: + import base64 + + raw_key = b"raw-der-bytes" + b64 = base64.b64encode(raw_key).decode("ascii") + self.site.find_authorized_clients = AsyncMock( # type: ignore[method-assign] + return_value=AuthorizedClients( + clients=[ + AuthorizedClient( + public_key=b64, + state=AuthorizedClientState.VERIFIED, + roles=None, + verification=None, + raw={}, + ) + ], + raw={}, + ) + ) + result = await self.site.wait_until_paired( + raw_key, timeout=5, poll_interval=0.01 + ) + self.assertEqual(result.public_key, b64)