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..79e7154 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: @@ -240,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 new file mode 100644 index 0000000..f41379f --- /dev/null +++ b/docs/energy_local_control.md @@ -0,0 +1,208 @@ +# 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` + 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. +- **aiopowerwall holds the local, RSA-signed transport.** Its + `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`. + +## 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. + +```python +import aiohttp +from tesla_fleet_api import Teslemetry + +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") + + energy_site = api.energySites.create(energy_site_id) + await energy_site.add_authorized_client( + api.rsa_public_der_pkcs1_b64, + description="My local control client", + ) + return api, energy_site +``` + +## 3. Hand the paired key to aiopowerwall + +`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 +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. 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.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) + await wait_until_verified(local_energysite) + + router = EnergySiteRouter(local_energysite, teslemetry_energysite) + + status = await router.live_status() # tries local first, cloud on failure + print(status) + +asyncio.run(main()) +``` + +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.** +> +> 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 signed local status read (for example +> `live_status()`'s grid/island fields) 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..4f6319e 100644 --- a/docs/fleet_api_energy_sites.md +++ b/docs/fleet_api_energy_sites.md @@ -357,6 +357,14 @@ 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). + +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 @@ -371,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/tesla/energysite.py b/tesla_fleet_api/tesla/energysite.py index 841566b..e058fda 100644 --- a/tesla_fleet_api/tesla/energysite.py +++ b/tesla_fleet_api/tesla/energysite.py @@ -122,10 +122,11 @@ 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. + 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 @@ -133,7 +134,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 +208,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 +232,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) 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())