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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`).
Expand Down
208 changes: 208 additions & 0 deletions docs/energy_local_control.md
Original file line number Diff line number Diff line change
@@ -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="<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="<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.
11 changes: 10 additions & 1 deletion docs/fleet_api_energy_sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions docs/teslemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tesla_fleet_api/router/energysite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Loading
Loading