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: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ Keep the `tesla-protocol` floor at `>=0.5.0`; earlier releases have generated `.
- **`networking_status`'s `ipv4_config` fields are raw big-endian uint32 ints, not strings**: the wire format applies to all of `ipv4_config.address`/`subnet_mask`/`gateway`, but `TeslemetryEnergySite.find_gateway_address()` (`teslemetry/energysite.py`, second typed accessor after `find_authorized_clients()`, same rules) decodes only `address` - confirmed against a live Powerwall 3 capture where `3232235914` decodes network-byte-order (`struct.pack(">I", ...)`) to `192.168.1.138`, not little-endian. It considers only `eth`/`wifi` (never `gsm` - cellular isn't a LAN path), preferring whichever has `active_route` set and a decodable address, else falling back to the first of the two (in that order) with any decodable address; `0`/`0xFFFFFFFF` are treated as undecodable so an unconfigured interface never shadows a real one with `0.0.0.0`. A `{"response": null}` envelope raises `InvalidResponse` (the endpoint's known intermittent malformed mode), while a well-formed response with no usable interface returns `None` (not a raise). Tests: `tests/test_teslemetry_gateway_address.py`.
- **`_stream_sinks` peels subscription pushes off the command-reply queue before routing**: a live `vehicleDataSubscription`'s pushes arrive addressed to us on the same domain queue (`_queues`) an ordinary command's reply uses, correlated by the subscribe request's own `request_uuid`. `_on_message` (`bluetooth.py`) checks `self._stream_sinks.get(msg.request_uuid)` before touching `_queues` - a match routes into that subscription's own bounded, drop-oldest `_StreamSink` instead, so `_send`'s pre-send drain can never discard a push and `_await_response` can never return one as an unrelated command's reply. `_register_stream_sink`/`_unregister_stream_sink` are the only entry points into the registry today; there is no public subscription API yet (`VehicleDataSubscription`/`createStreamSession`/`cancelVehicleDataSubscription` remain unwrapped, see the proto-coverage entry below) - this is dispatch-layer plumbing for that future work. Tests: `tests/test_ble_stream_sink.py`.
- **`VehicleAction`/`GetVehicleData` proto coverage is locked by test, not just by convention**: `tests/test_proto_coverage_lock.py` walks both descriptors and fails if any field has no wrapper (`commands.py`) or reader (`bluetooth.py`) and isn't on one of its two small, reasoned allowlists - keep that test in sync with any future `tesla-protocol` bump rather than special-casing new fields elsewhere. The only fields deliberately left unwrapped today are the 7-field push-style subscription/streaming family (`createStreamSession`/`streamMessage`/`vehicleDataSubscription`/`vehicleDataAck`/`vitalsSubscription`/`vitalsAck`/`cancelVehicleDataSubscription`, which still need a public lifecycle/iterator API atop the private `_stream_sinks` routing described above) and `getVehicleImageState` (needs chunked binary-transfer paging) - both are separate, unscoped design work, not oversights. CarServer's `GetVehicleState` sub-state is exposed as `legacy_vehicle_state()` (`bluetooth.py`), matching the `VehicleData.legacy_vehicle_state` reply field name, specifically to avoid confusion with the pre-existing `vehicle_state()` (VCSEC `VehicleStatus`, a different message/domain). `set_rate_tariff`/`add_managed_charging_site` (`commands.py`) take `tesla_protocol` message types directly for their deeply-nested arguments rather than a parallel flattened dataclass API. `pii_key_request`/`pseudonym_sync_request`/`tesla_auth_response`/`setup_cloud_profile_with_local_profile_uuid`/`get_local_profiles_for_vault_uuid` are wrapped with no known third-party consumer use case, purely for full-proto-coverage completeness.
- **Energy-gateway authorized-client pairing has security- and protocol-specific constraints**: use RSA for LAN TEDapi v1r, treat `PENDING_VERIFICATION_TIMEOUT` as terminal, and account for presence-free key removal. The authoritative pairing, retry, encoding, and removal guidance is in `docs/energy_local_control.md`; enum values and API contracts live in `const.py` and the relevant method docstrings.

## Maintaining this file

Keep this file for knowledge useful to almost every future agent session in this project.
Expand Down
26 changes: 20 additions & 6 deletions docs/energy_local_control.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,17 @@ the non-blocking and fully validated behavior described above.
## 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.
the gateway. After registration the key sits in `PENDING_VERIFICATION` state
(`AuthorizedClientState`) during a roughly nine-minute presence-proof window.
A physical breaker toggle confirms it (observed in as little as 59 seconds);
cloud auto-verification was not observed. If the window expires, the key moves
to the terminal `PENDING_VERIFICATION_TIMEOUT` state. Re-register the same key
to reset the window and retry; this does not create a duplicate record.

The gateway can also register an ECC public key when it is encoded as DER
SubjectPublicKeyInfo (`Tesla.ec_public_der_spki`), but ECC cannot authenticate
the LAN TEDapi v1r protocol because that protocol has no ECDSA signature
variant. Use the RSA/PKCS1 path shown here for local control.

```python
import aiohttp
Expand Down Expand Up @@ -109,8 +116,8 @@ interface (or `None` when no usable interface is reported) - see

## 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
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
Expand Down Expand Up @@ -171,6 +178,13 @@ a genuinely empty client list. Catch `InvalidResponse` (or
will not catch it. Either way, a `null` response here tells you nothing
about whether the key actually works.

To revoke a key, call `remove_authorized_client(public_key)` with its DER bytes
or the base64 string returned by `list_authorized_clients()`. Removal does not
require physical presence proof: any paired key can revoke every other key,
including the owner's. The base Fleet API command route is inferred and has
not been hardware-verified; only removal over the local v1r transport has been
verified.

## 5. Compose local + cloud with EnergySiteRouter

```python
Expand Down
1 change: 1 addition & 0 deletions docs/fleet_api_energy_sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ reading the file that won the create race.
| `get_device_cert()` | Common | Device certificate (subject, issuer, validity) |
| `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 |
| `remove_authorized_client()` | Authorization | Remove a paired public key; Fleet API route is inferred and not hardware-verified |
| `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
17 changes: 11 additions & 6 deletions docs/teslemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,14 +549,19 @@ consumers that need to inspect the client list. The helper returns an
endpoint, so the typed helper only unwraps the confirmed envelope shape - the
client list may arrive under either the `authorized_clients` key or the
`clients` key (the latter observed live from Tesla Release 953) - and models
the two client fields (`public_key`, `state`) confirmed by the endpoint's own
known consumer; `clients` is always a list. Only an explicitly empty list
the confirmed `public_key`, `state`, `roles`, and `verification` fields;
`clients` is always a list. `state`, each role, and verification are typed as
`AuthorizedClientState`, `AuthorizationRole`, and
`AuthorizedVerificationType`, while unknown values are preserved. Only an explicitly empty list
under either accepted key parses to `clients == []`; a null response body or
an unrecognized response shape raises
`tesla_fleet_api.exceptions.InvalidResponse` instead, so malformed data is
never mistaken for "no authorized clients". `state` is typed as
`AuthorizedClientState`. The raw response is still available on `raw` for
anything not modeled.
never mistaken for "no authorized clients". The raw response is still
available on `raw` for anything not modeled.

`remove_authorized_client(public_key)` accepts raw DER bytes or an already
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
Expand All @@ -575,7 +580,7 @@ async def main():

result = await energy_site.find_authorized_clients()
for client in result.clients:
print(client.public_key, client.state)
print(client.public_key, client.state, client.roles, client.verification)

# The untyped response is still available when callers need the exact
# Teslemetry payload.
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 @@ -83,6 +83,7 @@ tests/test_ble_write_timeout_router.py
tests/test_command_counter_lock.py
tests/test_command_logging.py
tests/test_cross_transport_parity.py
tests/test_energysite_authorized_clients.py
tests/test_energysite_island_mode.py
tests/test_find_vehicle_scan_filter.py
tests/test_firmware_at_least.py
Expand Down
45 changes: 38 additions & 7 deletions tesla_fleet_api/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,14 @@ class EnergyDeviceIdentifierType(IntEnum):
class AuthorizedClientKeyType(IntEnum):
"""Key type for energy gateway authorized clients.

Note: Tesla has not published the full ``key_type`` enum body. The RSA
value below is empirically known to work for registering an RSA-4096
key via ``add_authorized_client_request``; other values may exist but
are not publicly documented.
Sourced from the gateway's ``AUTHORIZED_KEY_TYPE_*`` protobuf enum;
both RSA and ECC are live-verified to register and list back with the
key type intact.
"""

INVALID = 0
RSA = 1
ECC = 2


class AuthorizedClientType(IntEnum):
Expand All @@ -252,11 +253,41 @@ class AuthorizedClientType(IntEnum):


class AuthorizedClientState(IntEnum):
"""State of an authorized client registered on an energy gateway."""
"""State of an authorized client registered on an energy gateway.

BREAKING (as of the release introducing this docstring): the previous
``PENDING``/``PENDING_VERIFICATION`` names were mislabelled against the
gateway's actual enum and are renamed here - ``PENDING`` is now
``PENDING_VERIFICATION`` and the old ``PENDING_VERIFICATION`` (value 2)
is now ``PENDING_VERIFICATION_TIMEOUT``. Value 2 is a **terminal**
failure state (the ~9-minute presence-proof window expired) - a
register-then-poll pairing flow that treats it as still-in-progress
hangs forever. ``INVALID``/``REMOVED`` were previously unmodeled.
"""

PENDING = 1
PENDING_VERIFICATION = 2
INVALID = 0
PENDING_VERIFICATION = 1
PENDING_VERIFICATION_TIMEOUT = 2
VERIFIED = 3
REMOVED = 4


class AuthorizationRole(IntEnum):
"""Role granted to an authorized client on an energy gateway."""

INVALID = 0
CUSTOMER = 1
VEHICLE = 2


class AuthorizedVerificationType(IntEnum):
"""How an authorized client's presence was verified on an energy gateway."""

INVALID = 0
PRESENCE_PROOF = 1
BLE = 2
SIGNED = 3
HERMES_COMMAND = 4


class ClosureState(StrEnum):
Expand Down
68 changes: 57 additions & 11 deletions tesla_fleet_api/tesla/energysite.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,33 @@ async def add_authorized_client(
) -> dict[str, Any]:
"""Register an authorized client (public key) with the energy gateway.

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
gateway may auto-verify via cloud, otherwise a physical breaker
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.
Used to pair a local key with a Powerwall so it can be used for the
LAN TEDapi v1r protocol. The public key encoding is per ``key_type``:
RSA is raw PKCS1 ``RSAPublicKey`` DER (``Tesla.rsa_public_der_pkcs1``);
ECC is DER SubjectPublicKeyInfo (SPKI), not a raw X9.62 uncompressed
point (``Tesla.ec_public_der_spki``) - the gateway rejects a raw
point with an asn1 structure error. Do not offer ECC as an
alternative to RSA for TEDapi v1r signing: a fully VERIFIED ECC key
cannot authenticate that protocol (its ``SignatureData`` oneof has no
ECDSA member), even though ECC registers and lists back fine.

After registration the key is PENDING_VERIFICATION until the
gateway confirms it within a ~9-minute presence-proof window, a
physical breaker toggle (verified in as little as 59s) - no
cloud auto-verify was observed. A window that elapses without
confirmation moves the key to the terminal
PENDING_VERIFICATION_TIMEOUT state - see ``AuthorizedClientState``;
a register-then-poll helper must surface that state rather than
wait indefinitely. Re-registering the same public key resets the
window without creating a second record, and is the correct retry
for a missed window. 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
bytes (which will be base64-encoded), or an already
base64-encoded string.
public_key: The public key to register. Either raw DER bytes in
the encoding matching ``key_type`` (which will be
base64-encoded), or an already 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
Expand All @@ -153,6 +167,38 @@ async def add_authorized_client(
},
)

async def remove_authorized_client(self, public_key: bytes | str) -> dict[str, Any]:
"""Remove an authorized client (public key) from the energy gateway.

[UNVERIFIED]: removal was live-verified only over the local v1r
transport; this Fleet-API ``_command`` route is inferred from
``add_authorized_client`` using the same mechanism and has not been
confirmed against hardware. The response message is empty, so
callers must not assert on response fields - some firmware may
return no body at all.

Security note: unlike adding a client, removal requires no physical
presence proof - an authenticated session is sufficient, including
to remove a VERIFIED record. Any paired key can therefore revoke
every other key, including the owner's.

Args:
public_key: The public key to remove, exactly as reported by
``list_authorized_clients`` - either raw DER bytes (which
will be base64-encoded) or an already base64-encoded string,
so a listed record round-trips to removal with no
re-encoding.
"""
if isinstance(public_key, bytes):
public_key_b64 = base64.b64encode(public_key).decode("ascii")
else:
public_key_b64 = public_key
return await self._command(
"authorization",
"remove_authorized_client_request",
{"public_key": public_key_b64},
)
Comment on lines +196 to +200

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept empty successful removal responses

When the gateway returns an empty body—the new method contract explicitly allows this for some firmware—this call still passes through TeslaFleetApi._request, which rejects non-JSON responses at tesla/fleet.py:208-210 and attempts JSON decoding otherwise. A successful removal with a 204 or empty response will therefore raise ResponseError instead of returning success; normalize an empty successful response to {} before completing this command.

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

Useful? React with 👍 / 👎.


async def get_signed_commands_public_key(self) -> dict[str, Any]:
"""Get the energy gateway's public key for signed commands."""
return await self._command(
Expand Down
21 changes: 21 additions & 0 deletions tesla_fleet_api/tesla/tesla.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,27 @@ def rsa_public_der_pkcs1_b64(self) -> str:
"""Return the RSA public key in base64-encoded DER PKCS1 format."""
return base64.b64encode(self.rsa_public_der_pkcs1).decode("ascii")

@property
def ec_public_der_spki(self) -> bytes:
"""Return the EC public key in DER SubjectPublicKeyInfo (SPKI) format.

This is the format the Tesla energy gateway expects when
registering an ECC authorized client - a raw X9.62 uncompressed
point (the vehicle-BLE form) is rejected with an asn1 structure
error.
"""
if self.private_key is None:
raise ValueError("EC private key is not set")
return self.private_key.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

@property
def ec_public_der_spki_b64(self) -> str:
"""Return the EC public key in base64-encoded DER SPKI format."""
return base64.b64encode(self.ec_public_der_spki).decode("ascii")

@property
def rsa_public_pem(self) -> str:
"""Get the RSA public key in PEM (SubjectPublicKeyInfo) format."""
Expand Down
Loading
Loading