From f65ceb513f6a9972d2223ace3ac42b59cf9f2b05 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 09:34:48 +1000 Subject: [PATCH 1/8] docs(energy): document the local control pairing flow with aiopowerwall The RSA key pairing (Tesla.get_rsa_private_key, EnergySite.add_authorized_client) and EnergySiteRouter composition with aiopowerwall's local signed transport were fully shipped but undocumented, which led a recent capability audit to mischaracterize the surface as dormant. Adds docs/energy_local_control.md covering the end-to-end flow and cross-links it from the README and the existing energy sites doc. --- AGENTS.md | 2 +- README.md | 5 + docs/energy_local_control.md | 178 +++++++++++++++++++++++++++++++++ docs/fleet_api_energy_sites.md | 2 + 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 docs/energy_local_control.md diff --git a/AGENTS.md b/AGENTS.md index 6fbceca..d4a03e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A `Router` (router.py) is an entity-agnostic composition wrapper (not part of the inheritance chain) that chains an ordered list of two-or-more backends sharing a common method surface and dispatches each method call down the chain with automatic per-command failover: it tries the first backend that has the method and, on any exception except `BluetoothUnconfirmedCommand`, retries the same call on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails, `AttributeError` only if none has the method). Non-callable attributes resolve to the first backend that has them. The constructor is `Router(primary, secondary, *more_backends, health=None)` — the two-argument form is fully backward compatible. The health check (`bool` | sync callable | async callable returning `bool`; omitted = attempt primary, fail over on exception with no probe) gates **only the primary** (the first backend); the rest of the chain is reached purely through per-command failover — there is deliberately no per-backend health matrix. Note the double-execution caveat: a non-idempotent command that fails mid-flight can be re-run on the next backend, except for `BluetoothUnconfirmedCommand`, which propagates without replay. -`VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses of `Router`. `VehicleRouter(bluetooth_primary, teslemetry_secondary)` pairs a `VehicleBluetooth` primary with a cloud (`TeslemetryVehicle`) secondary; `EnergySiteRouter(local_energysite, teslemetry_energysite)` pairs a duck-typed local `EnergySite`-shaped object (e.g. aiopowerwall's `PowerwallEnergySite`, no dependency added) with a cloud `TeslemetryEnergySite` fallback. All three live in the top-level `router/` package — `Router` (and shared helpers) in `router/base.py`, `VehicleRouter` in `router/vehicle.py`, `EnergySiteRouter` in `router/energysite.py`, re-exported from `router/__init__.py` (importable as `tesla_fleet_api.router.Router` etc.) and, for backward compatibility, also re-exported from `tesla/__init__.py` (`tesla_fleet_api.tesla.Router`). They have no factory on the `Vehicles`/`EnergySites` collections. +`VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses of `Router`. `VehicleRouter(bluetooth_primary, teslemetry_secondary)` pairs a `VehicleBluetooth` primary with a cloud (`TeslemetryVehicle`) secondary; `EnergySiteRouter(local_energysite, teslemetry_energysite)` pairs a duck-typed local `EnergySite`-shaped object (e.g. aiopowerwall's `PowerwallEnergySite`, no dependency added) with a cloud `TeslemetryEnergySite` fallback. All three live in the top-level `router/` package — `Router` (and shared helpers) in `router/base.py`, `VehicleRouter` in `router/vehicle.py`, `EnergySiteRouter` in `router/energysite.py`, re-exported from `router/__init__.py` (importable as `tesla_fleet_api.router.Router` etc.) and, for backward compatibility, also re-exported from `tesla/__init__.py` (`tesla_fleet_api.tesla.Router`). They have no factory on the `Vehicles`/`EnergySites` collections. This repo owns the RSA keypair lifecycle and cloud registration (`Tesla.get_rsa_private_key`, `EnergySite.add_authorized_client`) that aiopowerwall's local signed transport depends on but does not implement itself; see `docs/energy_local_control.md` for the end-to-end pairing + `EnergySiteRouter` composition flow, including why the cloud-only `set_island_mode`/`go_off_grid`/`reconnect_grid` methods don't actuate the contactor and why a success response from either transport doesn't prove that it did. ### Vehicle Collections diff --git a/README.md b/README.md index f51b278..7f1bc34 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ asyncio.run(main()) For more detailed examples, see [Fleet API for Energy Sites](docs/fleet_api_energy_sites.md). +To pair an energy gateway's RSA key over the cloud and compose the resulting +signed local LAN control (via the sibling `aiopowerwall` library) with a +cloud fallback through `EnergySiteRouter`, see [Energy: Local +Control](docs/energy_local_control.md). + ### Fleet API with Signed Vehicle Commands The `VehicleSigned` class provides methods to interact with the Fleet API using signed vehicle commands. Here's a basic example: diff --git a/docs/energy_local_control.md b/docs/energy_local_control.md new file mode 100644 index 0000000..3bb5f6f --- /dev/null +++ b/docs/energy_local_control.md @@ -0,0 +1,178 @@ +# Energy: Local Control (aiopowerwall + EnergySiteRouter) + +This library and the sibling [`aiopowerwall`](https://pypi.org/project/aiopowerwall/) +library are designed to be used together to give an energy gateway (Powerwall +2/3) both a local, signed LAN control path and a cloud fallback: + +- **This repo holds the keypair lifecycle.** `Tesla.get_rsa_private_key` / + `rsa_public_der_pkcs1` generate and persist the RSA key, and + `EnergySite.add_authorized_client` registers its public half with the + gateway over the cloud. `aiopowerwall` deliberately does **not** implement + registration - it only consumes an already-paired key. +- **aiopowerwall holds the local, RSA-signed transport.** Its + `PowerwallEnergySite` adapter mirrors this repo's `EnergySite` method-for- + method (same names, signatures, and `dict[str, Any]` return shapes) without + importing this package - it is duck-typed by design so it drops straight + into `EnergySiteRouter`. + +`aiopowerwall` is not a dependency of this project; nothing here imports it. +You add it to your own application alongside `tesla-fleet-api`. + +## 1. Generate or load the RSA private key + +`get_rsa_private_key(path)` (available on `Tesla` and everything that +inherits it - `TeslaFleetApi`, `Teslemetry`, `Tessie`) loads an existing RSA +private key or creates a new 4096-bit unencrypted PEM key file. This is the +key you will register with the gateway and later hand to `aiopowerwall`. + +## 2. Register the key with the gateway, over the cloud + +`EnergySite.add_authorized_client` registers the public half of that key with +the gateway. After registration the key sits in `PENDING`/ +`PENDING_VERIFICATION` state (`AuthorizedClientState`) until the gateway +confirms it - either auto-verified via cloud, or by a physical breaker toggle +at the gateway. + +Poll `list_authorized_clients()` (or, on `Teslemetry`, +`find_authorized_clients()`) until the key shows `VERIFIED`. Keep the wording +of that check resilient: Tesla's cloud endpoint for this 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 - +treat that as "not verified yet, keep polling," not as an error. +`TeslemetryEnergySite.find_authorized_clients()` already does this parsing +for you (null body, list vs. dict envelope, `state` typing) and always +returns a typed `AuthorizedClients` with `clients == []` rather than raising. +(`find_authorized_clients` is only on `TeslemetryEnergySite`. Plain +`TeslaFleetApi`/`Tessie` callers use the untyped `list_authorized_clients()` +and should apply the same "null/unrecognized shape means not-yet" tolerance +themselves.) + +Steps 1 and 2 together: + +```python +import asyncio +import aiohttp +from tesla_fleet_api import Teslemetry +from tesla_fleet_api.const import AuthorizedClientState + +async def pair_key(session: aiohttp.ClientSession, energy_site_id: int) -> None: + api = Teslemetry(access_token="", session=session) + + # Creates tedapi_rsa_private.pem (mode 0600) on first run, or loads + # the existing key on subsequent runs. + await api.get_rsa_private_key("tedapi_rsa_private.pem") + + energy_site = api.energySites.create(energy_site_id) + await energy_site.add_authorized_client( + api.rsa_public_der_pkcs1, + description="My local control client", + ) + + for _ in range(30): + result = await energy_site.find_authorized_clients() + verified = [ + c for c in result.clients + if c.state == AuthorizedClientState.VERIFIED + ] + if verified: + return + await asyncio.sleep(10) + raise TimeoutError("Key was not verified in time") + +async def main(): + async with aiohttp.ClientSession() as session: + await pair_key(session, energy_site_id=12345) # id from api.products() + +asyncio.run(main()) +``` + +## 3. Hand the paired key to aiopowerwall + +Once the key is verified, `aiopowerwall`'s `PowerwallClient` consumes the +*same* PEM file directly - it does not need anything else from this library +at this point. `local_energysite` then exposes the same method surface as +this repo's `EnergySite` (`get_system_info()`, `live_status()`, +`set_island_mode()`, etc.), but issues signed requests directly to the +gateway over the LAN: + +```python +from aiopowerwall import PowerwallClient +from aiopowerwall.energysite import PowerwallEnergySite + +async def make_local_energysite(session: aiohttp.ClientSession) -> PowerwallEnergySite: + with open("tedapi_rsa_private.pem", "rb") as f: + rsa_private_key_pem = f.read() + + powerwall_client = PowerwallClient( + host="192.168.91.1", # the gateway's local IP + gateway_password="", # local gateway password, not your Tesla account password + rsa_private_key_pem=rsa_private_key_pem, + session=session, + ) + return PowerwallEnergySite(powerwall_client) +``` + +## 4. Compose local + cloud with EnergySiteRouter + +```python +from tesla_fleet_api import Teslemetry +from tesla_fleet_api.router import EnergySiteRouter + +async def main(): + async with aiohttp.ClientSession() as session: + local_energysite = await make_local_energysite(session) + + api = Teslemetry(access_token="", session=session) + teslemetry_energysite = api.energySites.create(12345) + + router = EnergySiteRouter(local_energysite, teslemetry_energysite) + + status = await router.live_status() # tries local first, cloud on failure + print(status) + +asyncio.run(main()) +``` + +Commands go over the LAN when the gateway is reachable, and fail over to the +Teslemetry cloud otherwise - the same local-primary/cloud-fallback pattern +`VehicleRouter` uses for vehicles. See [the README's Routing and Failover +section](../README.md#routing-and-failover) for the general `Router` +semantics (per-command failover, the double-execution caveat, and the +`health` check). + +> **Warning: island mode / off-grid actuation is not guaranteed on either +> transport - always verify by state.** +> +> This repo's cloud `EnergySite.set_island_mode()` / `go_off_grid()` / +> `reconnect_grid()` send an **unsigned** `grpc_command`. Per their own +> docstrings and the commits that added them, the gateway accepts this +> command but does **not** physically operate the grid contactor over that +> transport - a plain cloud-only `EnergySite` (no router, no local backend) +> gets a silent no-op with an OK-looking response. +> +> Routing through `EnergySiteRouter` with an `aiopowerwall` local primary is +> the intended way to actually operate the contactor, since it sends a +> signed request over the LAN. But `aiopowerwall`'s own docs carry an +> unresolved, firmware-dependent caveat for its local `set_island_mode` +> too - some gateways acknowledge it without acting on it, and +> `trigger_islanding()` (the explicit black-start command) may be needed as +> a fallback. +> +> In both cases a success-shaped response does not prove the contactor +> moved, and `Router` has no way to detect that on its own - a call that +> returns without raising looks identical whether it actuated or not, so +> failover to the cloud secondary will not trigger. Always confirm the +> actual outcome with a status read (e.g. `live_status()`'s grid/island +> fields, or aiopowerwall's `get_status()` `islanding.contactorClosed`) +> before trusting either path's response for anything actuation-critical. + +## See also + +- [Fleet API for Energy Sites](fleet_api_energy_sites.md) - the cloud + `EnergySite` command surface (`get_rsa_private_key`, the gRPC "Device + Commands" section) on its own, without a local backend. +- [Routing and Failover](../README.md#routing-and-failover) - the general + `Router`/`EnergySiteRouter` dispatch and failover semantics. +- `aiopowerwall`'s own README for local-only concerns: gateway pairing + (breaker toggle / auto-verify), backup-reserve and SoC scaling, and + multi-Powerwall leader/follower behavior. diff --git a/docs/fleet_api_energy_sites.md b/docs/fleet_api_energy_sites.md index 2e710c2..e0d1f95 100644 --- a/docs/fleet_api_energy_sites.md +++ b/docs/fleet_api_energy_sites.md @@ -357,6 +357,8 @@ asyncio.run(main()) Energy gateways (Powerwalls, etc.) support gRPC commands sent via `POST /api/1/energy_sites/{id}/command`. These are undocumented Tesla API endpoints that communicate directly with the gateway hardware. All device command methods require the `energy_cmds` scope. +These commands are unsigned and cloud-only - the actuating `set_island_mode`/`go_off_grid`/`reconnect_grid` methods on `EnergySite` are documented as accepted by the gateway without physically taking effect for that reason. For registering a key and pairing it with a signed local LAN control path via the sibling `aiopowerwall` library, see [Energy: Local Control](energy_local_control.md). + `get_rsa_private_key(path)` loads an existing RSA private key for gateway client registration or creates a new unencrypted PEM key file. Newly created key files are created owner-readable and owner-writable only (`0600`) from the From eba42bd77e4e7493e988c38a3e5010d304406703 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 09:38:59 +1000 Subject: [PATCH 2/8] no-mistakes(review): Verify paired energy key by public key --- docs/energy_local_control.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/energy_local_control.md b/docs/energy_local_control.md index 3bb5f6f..cd4cf75 100644 --- a/docs/energy_local_control.md +++ b/docs/energy_local_control.md @@ -61,20 +61,21 @@ async def pair_key(session: aiohttp.ClientSession, energy_site_id: int) -> None: # Creates tedapi_rsa_private.pem (mode 0600) on first run, or loads # the existing key on subsequent runs. await api.get_rsa_private_key("tedapi_rsa_private.pem") + public_key_b64 = api.rsa_public_der_pkcs1_b64 energy_site = api.energySites.create(energy_site_id) await energy_site.add_authorized_client( - api.rsa_public_der_pkcs1, + public_key_b64, description="My local control client", ) for _ in range(30): result = await energy_site.find_authorized_clients() - verified = [ - c for c in result.clients - if c.state == AuthorizedClientState.VERIFIED - ] - if verified: + if any( + c.public_key == public_key_b64 + and c.state == AuthorizedClientState.VERIFIED + for c in result.clients + ): return await asyncio.sleep(10) raise TimeoutError("Key was not verified in time") From ef9c07dc2ed11f8b8ae0e00c92cff7429fe6576a Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 09:42:40 +1000 Subject: [PATCH 3/8] no-mistakes(review): Fix local energy doc imports --- docs/energy_local_control.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/energy_local_control.md b/docs/energy_local_control.md index cd4cf75..e488a17 100644 --- a/docs/energy_local_control.md +++ b/docs/energy_local_control.md @@ -97,6 +97,7 @@ this repo's `EnergySite` (`get_system_info()`, `live_status()`, gateway over the LAN: ```python +import aiohttp from aiopowerwall import PowerwallClient from aiopowerwall.energysite import PowerwallEnergySite @@ -116,6 +117,8 @@ async def make_local_energysite(session: aiohttp.ClientSession) -> PowerwallEner ## 4. Compose local + cloud with EnergySiteRouter ```python +import asyncio +import aiohttp from tesla_fleet_api import Teslemetry from tesla_fleet_api.router import EnergySiteRouter From 4e9ce59572afbcfafb0861908f119c06b18ad1e7 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 09:46:27 +1000 Subject: [PATCH 4/8] no-mistakes(review): Clarify local energy router fallback --- docs/energy_local_control.md | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/energy_local_control.md b/docs/energy_local_control.md index e488a17..b4e3f27 100644 --- a/docs/energy_local_control.md +++ b/docs/energy_local_control.md @@ -10,10 +10,10 @@ library are designed to be used together to give an energy gateway (Powerwall gateway over the cloud. `aiopowerwall` deliberately does **not** implement registration - it only consumes an already-paired key. - **aiopowerwall holds the local, RSA-signed transport.** Its - `PowerwallEnergySite` adapter mirrors this repo's `EnergySite` method-for- - method (same names, signatures, and `dict[str, Any]` return shapes) without - importing this package - it is duck-typed by design so it drops straight - into `EnergySiteRouter`. + `PowerwallEnergySite` adapter implements the local subset of this repo's + `EnergySite` surface with matching names, signatures, and `dict[str, Any]` + return shapes, without importing this package - it is duck-typed by design + so it drops straight into `EnergySiteRouter`. `aiopowerwall` is not a dependency of this project; nothing here imports it. You add it to your own application alongside `tesla-fleet-api`. @@ -91,10 +91,10 @@ asyncio.run(main()) Once the key is verified, `aiopowerwall`'s `PowerwallClient` consumes the *same* PEM file directly - it does not need anything else from this library -at this point. `local_energysite` then exposes the same method surface as -this repo's `EnergySite` (`get_system_info()`, `live_status()`, -`set_island_mode()`, etc.), but issues signed requests directly to the -gateway over the LAN: +at this point. `local_energysite` then exposes the locally implemented +`EnergySite`-compatible calls (`live_status()`, `operation()`, `backup()`, +`set_island_mode()`, `get_backup_events()`, and backup-event scheduling) +through signed requests directly to the gateway over the LAN: ```python import aiohttp @@ -137,12 +137,15 @@ async def main(): asyncio.run(main()) ``` -Commands go over the LAN when the gateway is reachable, and fail over to the -Teslemetry cloud otherwise - the same local-primary/cloud-fallback pattern -`VehicleRouter` uses for vehicles. See [the README's Routing and Failover -section](../README.md#routing-and-failover) for the general `Router` -semantics (per-command failover, the double-execution caveat, and the -`health` check). +Locally implemented commands go over the LAN when the gateway is reachable, +and fail over to the Teslemetry cloud otherwise - the same +local-primary/cloud-fallback pattern `VehicleRouter` uses for vehicles. Calls +that aiopowerwall does not implement locally yet, including `get_system_info()` +and most energy-device gRPC commands, raise `NotImplementedError` from the +local adapter and then fall through to the cloud backend. See [the README's +Routing and Failover section](../README.md#routing-and-failover) for the +general `Router` semantics (per-command failover, the double-execution caveat, +and the `health` check). > **Warning: island mode / off-grid actuation is not guaranteed on either > transport - always verify by state.** From 43870b71194c927822d0cc0b620818ccc18959c8 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 09:52:57 +1000 Subject: [PATCH 5/8] no-mistakes(document): Sync energy local docs --- docs/energy_local_control.md | 5 +++-- tesla_fleet_api/tesla/energysite.py | 33 +++++++++++++++-------------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/docs/energy_local_control.md b/docs/energy_local_control.md index b4e3f27..07efe4d 100644 --- a/docs/energy_local_control.md +++ b/docs/energy_local_control.md @@ -4,8 +4,9 @@ This library and the sibling [`aiopowerwall`](https://pypi.org/project/aiopowerw library are designed to be used together to give an energy gateway (Powerwall 2/3) both a local, signed LAN control path and a cloud fallback: -- **This repo holds the keypair lifecycle.** `Tesla.get_rsa_private_key` / - `rsa_public_der_pkcs1` generate and persist the RSA key, and +- **This repo holds the keypair lifecycle.** `Tesla.get_rsa_private_key` + generates or loads and persists the RSA key, `rsa_public_der_pkcs1` derives + the public key bytes for registration, and `EnergySite.add_authorized_client` registers its public half with the gateway over the cloud. `aiopowerwall` deliberately does **not** implement registration - it only consumes an already-paired key. diff --git a/tesla_fleet_api/tesla/energysite.py b/tesla_fleet_api/tesla/energysite.py index 841566b..a59bba4 100644 --- a/tesla_fleet_api/tesla/energysite.py +++ b/tesla_fleet_api/tesla/energysite.py @@ -122,7 +122,7 @@ async def add_authorized_client( Used to pair a local key (typically RSA-4096 in DER PKCS1 format) with a Powerwall so it can be used for the LAN TEDapi v1r protocol. After registration the key may be in PENDING or PENDING_VERIFICATION state - until the gateway confirms it — see ``AuthorizedClientState``. The + until the gateway confirms it - see ``AuthorizedClientState``. The gateway may auto-verify via cloud, otherwise a physical breaker toggle is required to confirm. Use ``list_authorized_clients`` to poll for VERIFIED state. @@ -133,7 +133,8 @@ async def add_authorized_client( base64-encoded string. description: Human-readable description of the client. key_type: The type of key being registered (default RSA). - authorized_client_type: The authorized client type (default LAN). + authorized_client_type: The authorized client type (default + CUSTOMER_MOBILE_APP for LAN clients). """ if isinstance(public_key, bytes): public_key_b64 = base64.b64encode(public_key).decode("ascii") @@ -206,21 +207,19 @@ async def set_island_mode( ) -> dict[str, Any]: """Set the island mode on the energy gateway. - Physically opens or closes the grid contactor on Powerwall 2/3. - Requires the command to be sent as a signed RoutableMessage via - the ``device_command`` endpoint — unsigned ``grpc_command`` calls - are accepted but do not physically operate the contactor. - - Confirmed working on PW2 (firmware 26.10.0) and PW3 (firmware - 26.2.1) when delivered as a signed ``routable_message`` with - ``force=True`` for off-grid. + This cloud method sends an unsigned ``grpc_command``. Gateways have + been observed accepting that request without physically operating + the grid contactor, so callers must verify the resulting state + rather than trusting a success-shaped response. For a signed local + LAN control path, pair an RSA key with ``add_authorized_client`` and + compose an aiopowerwall local backend with ``EnergySiteRouter``. Args: mode: EnergyIslandMode.OFF_GRID (6) to island, EnergyIslandMode.ON_GRID (1) to reconnect. force: Whether to force the contactor operation. Defaults to True for OFF_GRID, False for ON_GRID. Required for - off-grid — without force=True the gateway acknowledges + off-grid - without force=True the gateway acknowledges the command but does not physically open the contactor. """ if force is None: @@ -232,18 +231,20 @@ async def set_island_mode( ) async def go_off_grid(self) -> dict[str, Any]: - """Physically disconnect from the grid (open contactor). + """Request off-grid mode through the cloud ``grpc_command`` path. Convenience wrapper around set_island_mode(OFF_GRID, force=True). - Confirmed working on both Powerwall 2 and Powerwall 3 when sent - as a signed RoutableMessage via the device_command endpoint. + This may be accepted without physically opening the contactor; verify + state after the call. """ return await self.set_island_mode(EnergyIslandMode.OFF_GRID) async def reconnect_grid(self) -> dict[str, Any]: - """Reconnect to the grid (close contactor). + """Request on-grid mode through the cloud ``grpc_command`` path. - Convenience wrapper around set_island_mode(ON_GRID). + Convenience wrapper around set_island_mode(ON_GRID). This may be + accepted without physically closing the contactor; verify state + after the call. """ return await self.set_island_mode(EnergyIslandMode.ON_GRID) From 3b2e6b7cfadb2d0d69717366230145d08276a9ca Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 10:02:10 +1000 Subject: [PATCH 6/8] docs(energy): verify pairing by using the key, not by polling Polling the cloud list_authorized_clients endpoint alone is unreliable (Tesla currently returns a bare-null-200 body there, and that may recur). Make the primary verification method attempting a signed local read through aiopowerwall (retried with backoff, since PowerwallAuthenticationError before verification is expected) and demote the cloud poll to a secondary, best-effort check. --- docs/energy_local_control.md | 120 +++++++++++++++++++++-------------- 1 file changed, 71 insertions(+), 49 deletions(-) diff --git a/docs/energy_local_control.md b/docs/energy_local_control.md index 07efe4d..9d7e558 100644 --- a/docs/energy_local_control.md +++ b/docs/energy_local_control.md @@ -34,68 +34,33 @@ the gateway. After registration the key sits in `PENDING`/ confirms it - either auto-verified via cloud, or by a physical breaker toggle at the gateway. -Poll `list_authorized_clients()` (or, on `Teslemetry`, -`find_authorized_clients()`) until the key shows `VERIFIED`. Keep the wording -of that check resilient: Tesla's cloud endpoint for this 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 - -treat that as "not verified yet, keep polling," not as an error. -`TeslemetryEnergySite.find_authorized_clients()` already does this parsing -for you (null body, list vs. dict envelope, `state` typing) and always -returns a typed `AuthorizedClients` with `clients == []` rather than raising. -(`find_authorized_clients` is only on `TeslemetryEnergySite`. Plain -`TeslaFleetApi`/`Tessie` callers use the untyped `list_authorized_clients()` -and should apply the same "null/unrecognized shape means not-yet" tolerance -themselves.) - -Steps 1 and 2 together: - ```python -import asyncio import aiohttp from tesla_fleet_api import Teslemetry -from tesla_fleet_api.const import AuthorizedClientState -async def pair_key(session: aiohttp.ClientSession, energy_site_id: int) -> None: +async def register_key(session: aiohttp.ClientSession, energy_site_id: int): api = Teslemetry(access_token="", session=session) # Creates tedapi_rsa_private.pem (mode 0600) on first run, or loads # the existing key on subsequent runs. await api.get_rsa_private_key("tedapi_rsa_private.pem") - public_key_b64 = api.rsa_public_der_pkcs1_b64 energy_site = api.energySites.create(energy_site_id) await energy_site.add_authorized_client( - public_key_b64, + api.rsa_public_der_pkcs1_b64, description="My local control client", ) - - for _ in range(30): - result = await energy_site.find_authorized_clients() - if any( - c.public_key == public_key_b64 - and c.state == AuthorizedClientState.VERIFIED - for c in result.clients - ): - return - await asyncio.sleep(10) - raise TimeoutError("Key was not verified in time") - -async def main(): - async with aiohttp.ClientSession() as session: - await pair_key(session, energy_site_id=12345) # id from api.products() - -asyncio.run(main()) + return api, energy_site ``` ## 3. Hand the paired key to aiopowerwall -Once the key is verified, `aiopowerwall`'s `PowerwallClient` consumes the -*same* PEM file directly - it does not need anything else from this library -at this point. `local_energysite` then exposes the locally implemented -`EnergySite`-compatible calls (`live_status()`, `operation()`, `backup()`, -`set_island_mode()`, `get_backup_events()`, and backup-event scheduling) -through signed requests directly to the gateway over the LAN: +`aiopowerwall`'s `PowerwallClient` consumes the *same* PEM file directly - it +does not need anything else from this library at this point. `local_energysite` +then exposes the locally implemented `EnergySite`-compatible calls +(`live_status()`, `operation()`, `backup()`, `set_island_mode()`, +`get_backup_events()`, and backup-event scheduling) through signed requests +directly to the gateway over the LAN: ```python import aiohttp @@ -115,20 +80,77 @@ async def make_local_energysite(session: aiohttp.ClientSession) -> PowerwallEner return PowerwallEnergySite(powerwall_client) ``` -## 4. Compose local + cloud with EnergySiteRouter +## 4. Verify the key is paired by using it + +The gateway takes registration (step 2) and confirmation (auto-verify, or a +physical breaker toggle) 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. + +`get_system_info()`/`get_status()`-style reads are the natural choice for +this, but `PowerwallEnergySite` does not implement them locally yet (they +raise `NotImplementedError` and would tell you nothing about the key - see +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. + +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 +a bug**; treat it as "not yet, keep retrying" and back off between attempts: + +```python +import asyncio +from aiopowerwall import PowerwallAuthenticationError +from aiopowerwall.energysite import PowerwallEnergySite + +async def wait_until_verified( + local_energysite: PowerwallEnergySite, + attempts: int = 10, + initial_delay: float = 5.0, + max_delay: float = 60.0, +) -> None: + delay = initial_delay + for attempt in range(attempts): + try: + await local_energysite.live_status() + return # a successful signed read proves the key is VERIFIED + except PowerwallAuthenticationError: + if attempt == attempts - 1: + raise + await asyncio.sleep(delay) + 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 +`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. +`TeslemetryEnergySite.find_authorized_clients()` parses this defensively +(null body, list vs. dict envelope, `state` typing) and always returns a +typed `AuthorizedClients` with `clients == []` rather than raising, which +keeps a "not verified yet" read from looking like an error - but a `null` +response here still tells you nothing about whether the key actually works. + +## 5. Compose local + cloud with EnergySiteRouter ```python import asyncio import aiohttp -from tesla_fleet_api import Teslemetry from tesla_fleet_api.router import EnergySiteRouter async def main(): async with aiohttp.ClientSession() as session: + api, teslemetry_energysite = await register_key(session, energy_site_id=12345) local_energysite = await make_local_energysite(session) - - api = Teslemetry(access_token="", session=session) - teslemetry_energysite = api.energySites.create(12345) + await wait_until_verified(local_energysite) router = EnergySiteRouter(local_energysite, teslemetry_energysite) From 0a1592ed20f4d27c38d5366a5de3b4072a178440 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 10:06:26 +1000 Subject: [PATCH 7/8] no-mistakes(review): Align energy pairing verification docs --- tesla_fleet_api/tesla/energysite.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tesla_fleet_api/tesla/energysite.py b/tesla_fleet_api/tesla/energysite.py index a59bba4..e058fda 100644 --- a/tesla_fleet_api/tesla/energysite.py +++ b/tesla_fleet_api/tesla/energysite.py @@ -124,8 +124,9 @@ async def add_authorized_client( registration the key may be in PENDING or PENDING_VERIFICATION state until the gateway confirms it - see ``AuthorizedClientState``. The gateway may auto-verify via cloud, otherwise a physical breaker - toggle is required to confirm. Use ``list_authorized_clients`` to - poll for VERIFIED state. + toggle is required to confirm. Verify readiness with a signed local + read through the paired LAN client; ``list_authorized_clients`` is + only a secondary, best-effort cloud check. Args: public_key: The public key to register. Either raw DER PKCS1 From e160acdcae12daf7f521c504d28b546505e54fa9 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 13 Jul 2026 10:12:46 +1000 Subject: [PATCH 8/8] no-mistakes(document): Sync energy local docs --- README.md | 2 +- docs/energy_local_control.md | 6 +++--- docs/fleet_api_energy_sites.md | 9 ++++++++- docs/teslemetry.md | 5 +++++ tesla_fleet_api/router/energysite.py | 2 +- tesla_fleet_api/teslemetry/energysite.py | 6 ++++-- 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7f1bc34..79e7154 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ By default the router attempts the primary and fails over on any error, with no from tesla_fleet_api.router import EnergySiteRouter router = EnergySiteRouter(local_energysite, teslemetry_energysite) -await router.set_operation(...) # local first, cloud on failure +await router.operation(...) # local first, cloud on failure ``` `Router`, `VehicleRouter`, and `EnergySiteRouter` are all importable from `tesla_fleet_api.router` (and, for backward compatibility, from `tesla_fleet_api.tesla`). diff --git a/docs/energy_local_control.md b/docs/energy_local_control.md index 9d7e558..f41379f 100644 --- a/docs/energy_local_control.md +++ b/docs/energy_local_control.md @@ -192,9 +192,9 @@ and the `health` check). > moved, and `Router` has no way to detect that on its own - a call that > returns without raising looks identical whether it actuated or not, so > failover to the cloud secondary will not trigger. Always confirm the -> actual outcome with a status read (e.g. `live_status()`'s grid/island -> fields, or aiopowerwall's `get_status()` `islanding.contactorClosed`) -> before trusting either path's response for anything actuation-critical. +> actual outcome with a signed local status read (for example +> `live_status()`'s grid/island fields) before trusting either path's +> response for anything actuation-critical. ## See also diff --git a/docs/fleet_api_energy_sites.md b/docs/fleet_api_energy_sites.md index e0d1f95..4f6319e 100644 --- a/docs/fleet_api_energy_sites.md +++ b/docs/fleet_api_energy_sites.md @@ -359,6 +359,12 @@ Energy gateways (Powerwalls, etc.) support gRPC commands sent via `POST /api/1/e These commands are unsigned and cloud-only - the actuating `set_island_mode`/`go_off_grid`/`reconnect_grid` methods on `EnergySite` are documented as accepted by the gateway without physically taking effect for that reason. For registering a key and pairing it with a signed local LAN control path via the sibling `aiopowerwall` library, see [Energy: Local Control](energy_local_control.md). +Use `list_authorized_clients()` only as a secondary, best-effort cloud check +while pairing a local key. The reliable verification is a successful signed +local read through the paired LAN client; the cloud list endpoint can return +an empty or `null` response even when it is not useful for proving local key +readiness. + `get_rsa_private_key(path)` loads an existing RSA private key for gateway client registration or creates a new unencrypted PEM key file. Newly created key files are created owner-readable and owner-writable only (`0600`) from the @@ -373,7 +379,8 @@ reading the file that won the create race. | `get_networking_status()` | Common | WiFi, Ethernet, and cellular connectivity status | | `wifi_scan()` | Common | Scan for available WiFi networks | | `get_device_cert()` | Common | Device certificate (subject, issuer, validity) | -| `list_authorized_clients()` | Authorization | Paired keys with roles, state, and verification | +| `list_authorized_clients()` | Authorization | Best-effort cloud listing of paired keys; not authoritative for local key verification | +| `add_authorized_client()` | Authorization | Register a public key for local signed LAN control | | `get_signed_commands_public_key()` | Authorization | Gateway's public key for signed commands | | `get_backup_events()` | TEG | Backup event history (may timeout on some firmware) | | `schedule_backup_event()` | TEG | Schedule a manual backup event | diff --git a/docs/teslemetry.md b/docs/teslemetry.md index e641920..0f60c94 100644 --- a/docs/teslemetry.md +++ b/docs/teslemetry.md @@ -554,6 +554,11 @@ authorized clients" and return `[]`. `state` is typed as `AuthorizedClientState`. The raw response is still available on `raw` for anything not modeled. +Use these cloud helpers only as secondary, best-effort checks during local key +pairing. They are not authoritative verification that a key can make signed +LAN requests; the reliable proof is a successful signed local read through the +paired client, as shown in [Energy: Local Control](energy_local_control.md). + ```python async def main(): async with aiohttp.ClientSession() as session: diff --git a/tesla_fleet_api/router/energysite.py b/tesla_fleet_api/router/energysite.py index df14f85..e0be712 100644 --- a/tesla_fleet_api/router/energysite.py +++ b/tesla_fleet_api/router/energysite.py @@ -15,5 +15,5 @@ class EnergySiteRouter(Router[PrimaryT, SecondaryT]): Example:: router = EnergySiteRouter(local_energysite, teslemetry_energysite) - await router.set_operation(...) # local first, cloud on failure + await router.operation(...) # local first, cloud on failure """ diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index 46d7475..370ef28 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -202,8 +202,10 @@ async def find_authorized_clients(self) -> AuthorizedClients: Prefer this over :meth:`list_authorized_clients` for consumers that need to inspect the client list - it centralizes the response parsing (envelope unwrap, null-body handling, ``state`` typing) - here instead of in the caller. See :class:`AuthorizedClients` for - the exact semantics. + here instead of in the caller. Treat it as a secondary, best-effort + cloud check during local key pairing; a successful signed local read + through the paired LAN client is the authoritative verification. See + :class:`AuthorizedClients` for the exact parsing semantics. """ return _parse_authorized_clients(await self.list_authorized_clients())