From 36b581de265ba0cdd7e01df86424d2c933c2119c Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Sun, 2 Aug 2026 17:31:37 +1000 Subject: [PATCH 1/3] fix(energysite): correct AuthorizedClientState enum, add ECC/removal support Live-verified against a real Powerwall 3: AuthorizedClientState was mislabelled (a terminal timeout state was named as in-progress, which hangs a poll-while-not-VERIFIED pairing flow forever). Corrects the enum and adds the missing INVALID/REMOVED members. BREAKING: AuthorizedClientState.PENDING -> PENDING_VERIFICATION, and the old PENDING_VERIFICATION (value 2) -> PENDING_VERIFICATION_TIMEOUT (a terminal failure, not in-progress). Also: - AuthorizedClientKeyType gains INVALID/ECC (both live-verified to register and list back correctly); drop the "not published" caveat. - Tesla.ec_public_der_spki/_b64 mirror the RSA public-key helpers - ECC keys must be SPKI DER, not a raw point, or the gateway rejects them with an asn1 structure error. - EnergySite.remove_authorized_client added to the base Fleet-API class (previously cloud-missing); TeslemetryEnergySite's variant is now typed to match add_authorized_client's encoding behavior. - New AuthorizationRole/AuthorizedVerificationType enums for typing list_authorized_clients output. - Docstrings document the ~9-minute presence-proof window, the removal security asymmetry (no presence proof required), and that ECC keys cannot authenticate the LAN TEDapi v1r protocol. --- AGENTS.md | 2 + tesla_fleet_api/const.py | 45 +++++- tesla_fleet_api/tesla/energysite.py | 68 +++++++-- tesla_fleet_api/tesla/tesla.py | 21 +++ tesla_fleet_api/teslemetry/energysite.py | 23 ++- tests/test_energysite_authorized_clients.py | 147 ++++++++++++++++++++ tests/test_teslemetry_authorized_clients.py | 13 +- 7 files changed, 290 insertions(+), 29 deletions(-) create mode 100644 tests/test_energysite_authorized_clients.py diff --git a/AGENTS.md b/AGENTS.md index f55e5fc..5da0abe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. +- **`AuthorizedClientState` was mislabelled and got a BREAKING rename**: the gateway's real enum is `INVALID=0, PENDING_VERIFICATION=1, PENDING_VERIFICATION_TIMEOUT=2 (terminal), VERIFIED=3, REMOVED=4` - live-verified on a real Powerwall 3 - not the library's old `PENDING=1, PENDING_VERIFICATION=2, VERIFIED=3`. Value 2 is a **terminal** failure (the ~9-minute presence-proof window expired, see `add_authorized_client`'s docstring in `tesla/energysite.py`), not an in-progress state; a register-then-poll pairing helper that doesn't check for it hangs forever. `AuthorizedClientKeyType` also gained `INVALID=0`/`ECC=2` (both key types register and list back correctly), plus new `AuthorizationRole`/`AuthorizedVerificationType` enums for typing `list_authorized_clients` output - all in `const.py`. ECC registers fine but **cannot** authenticate the LAN TEDapi v1r protocol (its `SignatureData` oneof has no ECDSA member) - never offer it as an RSA alternative in keygen/pairing helpers aimed at that protocol; RSA keys still need PKCS1 DER (`Tesla.rsa_public_der_pkcs1`), ECC keys need SPKI DER, not a raw point (`Tesla.ec_public_der_spki`, `tesla/tesla.py`). `EnergySite.remove_authorized_client` (`tesla/energysite.py`) was added to the base Fleet-API class via the same `_command("authorization", ...)` route as `add_authorized_client` - unlike add, this route is inferred, not hardware-verified (removal was only confirmed over local v1r) - and removal requires no physical presence proof, so any paired key can revoke every other key including the owner's. + ## Maintaining this file Keep this file for knowledge useful to almost every future agent session in this project. diff --git a/tesla_fleet_api/const.py b/tesla_fleet_api/const.py index c296774..3087ac3 100644 --- a/tesla_fleet_api/const.py +++ b/tesla_fleet_api/const.py @@ -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): @@ -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): diff --git a/tesla_fleet_api/tesla/energysite.py b/tesla_fleet_api/tesla/energysite.py index b70d439..41ff43e 100644 --- a/tesla_fleet_api/tesla/energysite.py +++ b/tesla_fleet_api/tesla/energysite.py @@ -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 @@ -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}, + ) + 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( diff --git a/tesla_fleet_api/tesla/tesla.py b/tesla_fleet_api/tesla/tesla.py index 5458d92..b2218d5 100644 --- a/tesla_fleet_api/tesla/tesla.py +++ b/tesla_fleet_api/tesla/tesla.py @@ -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.""" diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index 4d2adfc..ed8ec11 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -329,19 +329,30 @@ async def find_authorized_clients(self) -> AuthorizedClients: """ return _parse_authorized_clients(await self.list_authorized_clients()) - async def remove_authorized_client( - self, params: dict[str, Any] | None = None - ) -> dict[str, Any]: + async def remove_authorized_client(self, public_key: bytes | str) -> dict[str, Any]: """Remove an authorized client from the energy gateway via the Teslemetry custom endpoint. - Accepts raw protobuf request fields. Keys and nesting must match - Tesla's snake_case proto field names. + 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._request( Method.POST, f"api/1/energy_sites/{self.energy_site_id}/command/remove_authorized_client", - json=params or {}, + json={"public_key": public_key_b64}, ) diff --git a/tests/test_energysite_authorized_clients.py b/tests/test_energysite_authorized_clients.py new file mode 100644 index 0000000..0131131 --- /dev/null +++ b/tests/test_energysite_authorized_clients.py @@ -0,0 +1,147 @@ +"""Tests for energy-gateway authorized-client enums, key helpers, and removal. + +Enum values are pinned to the gateway's numbers - live-verified on a real +Powerwall 3 - so a future regression back to the old, mislabelled names +fails loudly. See ``AuthorizedClientState`` in ``tesla_fleet_api/const.py`` +for the breaking rename this locks in. +""" + +from __future__ import annotations + +import base64 +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec + +from tesla_fleet_api.const import ( + AuthorizationRole, + AuthorizedClientKeyType, + AuthorizedClientState, + AuthorizedVerificationType, +) +from tesla_fleet_api.tesla.energysite import EnergySite +from tesla_fleet_api.tesla.tesla import Tesla +from tesla_fleet_api.teslemetry.energysite import TeslemetryEnergySite + + +class AuthorizedClientStateValuesTests(IsolatedAsyncioTestCase): + async def test_state_values_match_the_gateway(self) -> None: + self.assertEqual(AuthorizedClientState.INVALID, 0) + self.assertEqual(AuthorizedClientState.PENDING_VERIFICATION, 1) + self.assertEqual(AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT, 2) + self.assertEqual(AuthorizedClientState.VERIFIED, 3) + self.assertEqual(AuthorizedClientState.REMOVED, 4) + + async def test_key_type_values_match_the_gateway(self) -> None: + self.assertEqual(AuthorizedClientKeyType.INVALID, 0) + self.assertEqual(AuthorizedClientKeyType.RSA, 1) + self.assertEqual(AuthorizedClientKeyType.ECC, 2) + + async def test_role_values_match_the_gateway(self) -> None: + self.assertEqual(AuthorizationRole.INVALID, 0) + self.assertEqual(AuthorizationRole.CUSTOMER, 1) + self.assertEqual(AuthorizationRole.VEHICLE, 2) + + async def test_verification_type_values_match_the_gateway(self) -> None: + self.assertEqual(AuthorizedVerificationType.INVALID, 0) + self.assertEqual(AuthorizedVerificationType.PRESENCE_PROOF, 1) + self.assertEqual(AuthorizedVerificationType.BLE, 2) + self.assertEqual(AuthorizedVerificationType.SIGNED, 3) + self.assertEqual(AuthorizedVerificationType.HERMES_COMMAND, 4) + + +class EcPublicDerSpkiTests(IsolatedAsyncioTestCase): + async def test_matches_cryptography_spki_encoding(self) -> None: + tesla = Tesla() + tesla.private_key = ec.generate_private_key(ec.SECP256R1()) + + der = tesla.ec_public_der_spki + + expected = tesla.private_key.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + self.assertEqual(der, expected) + # SPKI DER for a P-256 key is 91 bytes - distinct from the 65-byte + # raw X9.62 uncompressed point the gateway rejects (finding #3). + self.assertEqual(len(der), 91) + + async def test_b64_matches_base64_of_der(self) -> None: + tesla = Tesla() + tesla.private_key = ec.generate_private_key(ec.SECP256R1()) + + self.assertEqual( + tesla.ec_public_der_spki_b64, + base64.b64encode(tesla.ec_public_der_spki).decode("ascii"), + ) + + async def test_raises_without_a_private_key(self) -> None: + tesla = Tesla() + with self.assertRaises(ValueError): + _ = tesla.ec_public_der_spki + + +def _make_base_site() -> tuple[EnergySite, AsyncMock]: + request_mock: AsyncMock = AsyncMock(return_value={}) + parent = AsyncMock() + parent._request = request_mock + site = EnergySite(parent, 12345) + return site, request_mock + + +def _make_teslemetry_site() -> tuple[TeslemetryEnergySite, AsyncMock]: + request_mock: AsyncMock = AsyncMock(return_value={}) + parent = AsyncMock() + parent._request = request_mock + site = TeslemetryEnergySite(parent, 12345) + return site, request_mock + + +class BaseRemoveAuthorizedClientTests(IsolatedAsyncioTestCase): + async def test_bytes_public_key_is_base64_encoded(self) -> None: + site, request_mock = _make_base_site() + + await site.remove_authorized_client(b"raw-der-bytes") + + _, kwargs = request_mock.call_args + message = kwargs["json"]["command_properties"]["message"] + params = message["authorization"]["remove_authorized_client_request"] + self.assertEqual( + params["public_key"], + base64.b64encode(b"raw-der-bytes").decode("ascii"), + ) + + async def test_str_public_key_passes_through_unchanged(self) -> None: + site, request_mock = _make_base_site() + b64_key = "already-base64==" + + await site.remove_authorized_client(b64_key) + + _, kwargs = request_mock.call_args + message = kwargs["json"]["command_properties"]["message"] + params = message["authorization"]["remove_authorized_client_request"] + self.assertEqual(params["public_key"], b64_key) + + +class TeslemetryRemoveAuthorizedClientTests(IsolatedAsyncioTestCase): + async def test_bytes_public_key_is_base64_encoded(self) -> None: + site, request_mock = _make_teslemetry_site() + + await site.remove_authorized_client(b"raw-der-bytes") + + _, kwargs = request_mock.call_args + self.assertEqual( + kwargs["json"]["public_key"], + base64.b64encode(b"raw-der-bytes").decode("ascii"), + ) + + async def test_str_public_key_passes_through_unchanged(self) -> None: + site, request_mock = _make_teslemetry_site() + b64_key = "already-base64==" + + await site.remove_authorized_client(b64_key) + + _, kwargs = request_mock.call_args + self.assertEqual(kwargs["json"]["public_key"], b64_key) diff --git a/tests/test_teslemetry_authorized_clients.py b/tests/test_teslemetry_authorized_clients.py index 3aedfed..eb5dbbf 100644 --- a/tests/test_teslemetry_authorized_clients.py +++ b/tests/test_teslemetry_authorized_clients.py @@ -91,7 +91,9 @@ async def test_bare_list_payload_with_no_envelope(self) -> None: result = await site.find_authorized_clients() self.assertEqual(len(result.clients), 1) - self.assertEqual(result.clients[0].state, AuthorizedClientState.PENDING) + self.assertEqual( + result.clients[0].state, AuthorizedClientState.PENDING_VERIFICATION + ) async def test_camel_case_entry_fields_are_recognized(self) -> None: site = _make_site( @@ -133,16 +135,16 @@ async def test_unrecognized_present_state_is_preserved_not_dropped(self) -> None site = _make_site( { "response": { - "authorized_clients": [{"public_key": PUBLIC_KEY_B64, "state": 0}] + "authorized_clients": [{"public_key": PUBLIC_KEY_B64, "state": 99}] } } ) result = await site.find_authorized_clients() - # 0 is not a member of AuthorizedClientState, but it is a present + # 99 is not a member of AuthorizedClientState, but it is a present # value - it must not be coerced to None (which means "absent"). - self.assertEqual(result.clients[0].state, 0) + self.assertEqual(result.clients[0].state, 99) self.assertIsNotNone(result.clients[0].state) async def test_explicitly_empty_list_returns_typed_empty_list(self) -> None: @@ -263,7 +265,8 @@ async def test_real_captured_sample_parses_to_five_clients(self) -> None: ) self.assertEqual(result.clients[0].state, AuthorizedClientState.VERIFIED) self.assertEqual( - result.clients[1].state, AuthorizedClientState.PENDING_VERIFICATION + result.clients[1].state, + AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT, ) async def test_clients_key_variant_is_recognized(self) -> None: From 8ffc56b0b0738fe6b0cffb381c8ba1370b4990cf Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Sun, 2 Aug 2026 17:36:15 +1000 Subject: [PATCH 2/3] no-mistakes(review): Type authorized-client roles and verification --- tesla_fleet_api.egg-info/SOURCES.txt | 1 + tesla_fleet_api/teslemetry/energysite.py | 58 ++++++++++++++++++--- tests/test_teslemetry_authorized_clients.py | 45 +++++++++++++++- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/tesla_fleet_api.egg-info/SOURCES.txt b/tesla_fleet_api.egg-info/SOURCES.txt index 84d18b7..0c1bc32 100644 --- a/tesla_fleet_api.egg-info/SOURCES.txt +++ b/tesla_fleet_api.egg-info/SOURCES.txt @@ -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 diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index ed8ec11..9188017 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -7,9 +7,11 @@ from typing import Any, cast from tesla_fleet_api.const import ( + AuthorizationRole, AuthorizedClientKeyType, AuthorizedClientState, AuthorizedClientType, + AuthorizedVerificationType, Method, ) from tesla_fleet_api.exceptions import InvalidResponse @@ -58,10 +60,53 @@ def _normalize_state(value: Any) -> AuthorizedClientState | int | str | None: return value +def _normalize_role(value: Any) -> AuthorizationRole | int | str | None: + if value is None or isinstance(value, AuthorizationRole) or isinstance(value, bool): + return value + if isinstance(value, int): + try: + return AuthorizationRole(value) + except ValueError: + return value + if isinstance(value, str): + try: + return AuthorizationRole[value.strip().upper()] + except KeyError: + return value + return value + + +def _normalize_verification( + value: Any, +) -> AuthorizedVerificationType | int | str | None: + if ( + value is None + or isinstance(value, AuthorizedVerificationType) + or isinstance(value, bool) + ): + return value + if isinstance(value, int): + try: + return AuthorizedVerificationType(value) + except ValueError: + return value + if isinstance(value, str): + try: + return AuthorizedVerificationType[value.strip().upper()] + except KeyError: + return value + return value + + def _parse_client(payload: dict[str, Any]) -> AuthorizedClient: + roles = _field(payload, "roles") return AuthorizedClient( public_key=_field(payload, "public_key", "publicKey"), state=_normalize_state(_field(payload, "state", "authorized_client_state")), + roles=[_normalize_role(role) for role in roles] + if isinstance(roles, list) + else None, + verification=_normalize_verification(_field(payload, "verification")), raw=payload, ) @@ -70,16 +115,17 @@ def _parse_client(payload: dict[str, Any]) -> AuthorizedClient: class AuthorizedClient: """One entry from a Teslemetry ``list_authorized_clients`` response. - Only ``public_key`` and ``state`` are modeled - the two fields a - pairing flow needs to confirm a registered key. Tesla has not - published this response's schema, so anything else on an entry is - available via ``raw`` rather than guessed at. Each field accepts the - two key-name variants observed for it (``public_key``/``publicKey``, - ``state``/``authorized_client_state``). + ``public_key``, ``state``, ``roles``, and ``verification`` are modeled. + Tesla has not published this response's schema, so anything else on an + entry is available via ``raw`` rather than guessed at. Public key and + state accept the two key-name variants observed for them + (``public_key``/``publicKey``, ``state``/``authorized_client_state``). """ public_key: str | None state: AuthorizedClientState | int | str | None + roles: list[AuthorizationRole | int | str | None] | None + verification: AuthorizedVerificationType | int | str | None raw: dict[str, Any] diff --git a/tests/test_teslemetry_authorized_clients.py b/tests/test_teslemetry_authorized_clients.py index eb5dbbf..d3928fd 100644 --- a/tests/test_teslemetry_authorized_clients.py +++ b/tests/test_teslemetry_authorized_clients.py @@ -22,7 +22,11 @@ from unittest import IsolatedAsyncioTestCase from unittest.mock import AsyncMock, MagicMock -from tesla_fleet_api.const import AuthorizedClientState +from tesla_fleet_api.const import ( + AuthorizationRole, + AuthorizedClientState, + AuthorizedVerificationType, +) from tesla_fleet_api.exceptions import InvalidResponse from tesla_fleet_api.teslemetry.teslemetry import Teslemetry @@ -268,6 +272,45 @@ async def test_real_captured_sample_parses_to_five_clients(self) -> None: result.clients[1].state, AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT, ) + self.assertTrue( + all(c.roles == [AuthorizationRole.CUSTOMER] for c in result.clients) + ) + self.assertTrue( + all( + c.verification == AuthorizedVerificationType.PRESENCE_PROOF + for c in result.clients + ) + ) + + async def test_unrecognized_role_and_verification_are_preserved(self) -> None: + site = _make_site( + { + "response": { + "clients": [ + { + "public_key": PUBLIC_KEY_B64, + "roles": [99], + "verification": 99, + } + ] + } + } + ) + + result = await site.find_authorized_clients() + + self.assertEqual(result.clients[0].roles, [99]) + self.assertEqual(result.clients[0].verification, 99) + + async def test_missing_roles_and_verification_are_none(self) -> None: + site = _make_site( + {"response": {"clients": [{"public_key": PUBLIC_KEY_B64}]}} + ) + + result = await site.find_authorized_clients() + + self.assertIsNone(result.clients[0].roles) + self.assertIsNone(result.clients[0].verification) async def test_clients_key_variant_is_recognized(self) -> None: site = _make_site( From 6ec883ba5a4aafb402b099916b7fc9fc7d4ab333 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Sun, 2 Aug 2026 17:39:16 +1000 Subject: [PATCH 3/3] no-mistakes(document): Refresh authorized-client documentation and lint typing --- AGENTS.md | 2 +- docs/energy_local_control.md | 26 ++++++++++++++++++------ docs/fleet_api_energy_sites.md | 1 + docs/teslemetry.md | 17 ++++++++++------ tesla_fleet_api/teslemetry/energysite.py | 9 ++++---- 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5da0abe..c543da8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,7 @@ 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. -- **`AuthorizedClientState` was mislabelled and got a BREAKING rename**: the gateway's real enum is `INVALID=0, PENDING_VERIFICATION=1, PENDING_VERIFICATION_TIMEOUT=2 (terminal), VERIFIED=3, REMOVED=4` - live-verified on a real Powerwall 3 - not the library's old `PENDING=1, PENDING_VERIFICATION=2, VERIFIED=3`. Value 2 is a **terminal** failure (the ~9-minute presence-proof window expired, see `add_authorized_client`'s docstring in `tesla/energysite.py`), not an in-progress state; a register-then-poll pairing helper that doesn't check for it hangs forever. `AuthorizedClientKeyType` also gained `INVALID=0`/`ECC=2` (both key types register and list back correctly), plus new `AuthorizationRole`/`AuthorizedVerificationType` enums for typing `list_authorized_clients` output - all in `const.py`. ECC registers fine but **cannot** authenticate the LAN TEDapi v1r protocol (its `SignatureData` oneof has no ECDSA member) - never offer it as an RSA alternative in keygen/pairing helpers aimed at that protocol; RSA keys still need PKCS1 DER (`Tesla.rsa_public_der_pkcs1`), ECC keys need SPKI DER, not a raw point (`Tesla.ec_public_der_spki`, `tesla/tesla.py`). `EnergySite.remove_authorized_client` (`tesla/energysite.py`) was added to the base Fleet-API class via the same `_command("authorization", ...)` route as `add_authorized_client` - unlike add, this route is inferred, not hardware-verified (removal was only confirmed over local v1r) - and removal requires no physical presence proof, so any paired key can revoke every other key including the owner's. +- **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 diff --git a/docs/energy_local_control.md b/docs/energy_local_control.md index 515500c..5d34873 100644 --- a/docs/energy_local_control.md +++ b/docs/energy_local_control.md @@ -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 @@ -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 @@ -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 diff --git a/docs/fleet_api_energy_sites.md b/docs/fleet_api_energy_sites.md index b4bf231..53ea27e 100644 --- a/docs/fleet_api_energy_sites.md +++ b/docs/fleet_api_energy_sites.md @@ -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 | diff --git a/docs/teslemetry.md b/docs/teslemetry.md index 2b373ca..e6b96b6 100644 --- a/docs/teslemetry.md +++ b/docs/teslemetry.md @@ -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 @@ -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. diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index 9188017..056eebb 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -103,7 +103,7 @@ def _parse_client(payload: dict[str, Any]) -> AuthorizedClient: return AuthorizedClient( public_key=_field(payload, "public_key", "publicKey"), state=_normalize_state(_field(payload, "state", "authorized_client_state")), - roles=[_normalize_role(role) for role in roles] + roles=[_normalize_role(role) for role in cast(list[object], roles)] if isinstance(roles, list) else None, verification=_normalize_verification(_field(payload, "verification")), @@ -296,9 +296,10 @@ async def add_authorized_client( pre-populates the request with its own key details. 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`` (RSA PKCS1 or ECC SPKI; + bytes are base64-encoded), or an already base64-encoded + string. description: Human-readable description of the client. key_type: The type of key being registered. authorized_client_type: The authorized client type.