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
46 changes: 34 additions & 12 deletions docs/energy_local_control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/teslemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
1 change: 1 addition & 0 deletions tesla_fleet_api.egg-info/SOURCES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions tesla_fleet_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
137 changes: 136 additions & 1 deletion tesla_fleet_api/teslemetry/energysite.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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
Comment on lines +502 to +505

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep polling after per-attempt timeouts

When find_authorized_clients() raises its own TimeoutError before the overall deadline—for example, due to a transient HTTP timeout—asyncio.wait_for() propagates that same exception into this handler, which incorrectly reports that the entire pairing wait expired instead of retrying. The identical issue affects verify_by_use(), where a transient signed-read timeout is explicitly supposed to mean “not yet confirmed.” Distinguish expiry of the outer deadline from an inner operation timeout and continue polling while time remains.

AGENTS.md reference: AGENTS.md:L151-L151

Useful? React with 👍 / 👎.

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."""
Expand Down
Loading
Loading