diff --git a/AGENTS.md b/AGENTS.md index 38585b9..ed684cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,7 @@ Keep the `tesla-protocol` floor at `>=0.5.0`; earlier releases have generated `. - **Cross-transport parity (cloud REST `VehicleFleet` vs BLE `Commands`)**: the same-named command on both paths should build a semantically equivalent instruction from identical args — a divergence there is a bug, but response *bodies* legitimately differ (REST JSON dict vs decoded protobuf) and are not. `tests/test_cross_transport_parity.py` locks the equivalence in with mocked-both-transports tests. Known **non-bug FORM differences** (do not "fix"): `set_scheduled_departure`'s `preconditioning_enabled`/`off_peak_charging_enabled` (no proto fields), `window_control` lat/lon and `navigation_sc_request` `id` (no proto fields), `navigation_request`'s `type`/`locale`/`timestamp_ms` (REST share-intent framing), `media_volume_up` (no Tesla REST endpoint — BLE-only; cloud raises volume via `adjust_volume`), and `clear_pin_to_drive_admin`'s `pin` param (no proto field on `VehicleControlResetPinToDriveAdminAction` — cloud still sends it in the REST body, BLE ignores it). Both transports default `navigation_gps_request`'s `order` to `0` (`REMOTE_NAV_TRIP_ORDER_UNKNOWN`) when the caller omits it. - **Per-command debug logging chokepoints and the `command=` name it derives**: `LOGGER.debug` lines of the form `command= transport= result=...` are emitted from exactly four places — `Commands._sendVehicleSecurity`/`_getVehicleSecurity`/`_sendInfotainment`/`_getInfotainment` (`commands.py`, covers both BLE and Fleet-signed) and `TeslaFleetApi._request` (`fleet.py`, covers Fleet/Teslemetry/Tessie REST). `transport` comes from a `_transport_name` `ClassVar` set per concrete class (`"bluetooth"`/`"fleet"`/`"teslemetry"`/`"tessie"`), mirroring the `_auth_method` pattern — add that ClassVar to any new `Commands`/`TeslaFleetApi` subclass. For BLE/Fleet-signed, `command` is **not** the Python method name; it's derived from the populated protobuf oneof field (`vcsec_command_name`/`infotainment_command_name` in `commands.py`), e.g. `door_lock()` logs as `RKE_ACTION_LOCK` and `set_charge_limit()` as `chargingSetLimitAction`. `VehicleBluetooth`'s `verify_commands` resolution logs a second, separate line (`verify_commands=resolved`/`unresolved`). `Router._dispatch` (`router/base.py`) logs `command=... backend= result=...` per backend tried. See `docs/bluetooth_vehicles.md`'s "Troubleshooting: Enable Debug Logging" section for the user-facing format; `tests/test_command_logging.py` locks in the exact line shapes. - **`_log_request_result` (`fleet.py`) must tolerate any JSON-legal REST body, not just dicts**: it runs after the HTTP request already succeeded, so it's a logging convenience only — a non-dict body (`null`, a list, a bare scalar) must never raise there. It guards with `isinstance(data, dict)` before calling `.get()`, logging `result=success` and returning for anything else. Regression tests in `tests/test_command_logging.py`. -- **Typed accessor pattern for undocumented raw-dict responses**: `TeslemetryEnergySite.find_authorized_clients()` and `find_gateway_address()` (`teslemetry/energysite.py`) are frozen-dataclass typed wrappers over raw `dict[str, Any]`/`None`/`list` REST responses, so API-parsing logic (envelope unwrap, field lookup, shape validation, enum typing) lives in the library instead of each consumer reimplementing it. Any future typed accessor over an undocumented response shape should keep two rules: (1) field lookup must check key presence (`key in payload`), never `payload.get(key) or default` — a legal falsy value is not "missing"; (2) a `None` body and an unrecognized response shape are malformed data, not "empty" — raise `InvalidResponse` (`exceptions.py`) rather than collapsing to an empty/default result; only a genuinely well-formed-but-empty response should parse to an empty result without raising. `find_authorized_clients()`'s envelope unwrap accepts `{"response": {"authorized_clients": [...]}}` or `{"response": {"clients": [...]}}`, or a bare list. `find_gateway_address()` decodes `networking_status.ipv4_config.address` as a raw big-endian uint32 (`struct.pack(">I", ...)`, not little-endian), considers only `eth`/`wifi` (never `gsm`), preferring whichever has `active_route` set and a decodable address; `0`/`0xFFFFFFFF` are treated as undecodable. Tesla has not published an OpenAPI schema for these endpoints, so `const.py`'s enums are the schema of record; widen modeled fields only against a further live sample, not speculatively. Untyped escape-hatch methods (e.g. `list_authorized_clients()`) remain available alongside. Tests: `tests/test_teslemetry_authorized_clients.py`, `tests/test_teslemetry_gateway_address.py`. +- **Typed accessor pattern for undocumented raw-dict responses**: `TeslemetryEnergySite.find_authorized_clients()` and `find_gateway_address()` (`teslemetry/energysite.py`) are frozen-dataclass typed wrappers over raw `dict[str, Any]`/`None`/`list` REST responses, so API-parsing logic (envelope unwrap, field lookup, shape validation, enum typing) lives in the library instead of each consumer reimplementing it. Any future typed accessor over an undocumented response shape should keep two rules: (1) field lookup must check key presence (`key in payload`), never `payload.get(key) or default` — a legal falsy value is not "missing"; (2) a `None` body and an unrecognized response shape are malformed data, not "empty" — raise `InvalidResponse` (`exceptions.py`) rather than collapsing to an empty/default result; only a genuinely well-formed-but-empty response should parse to an empty result without raising. `find_authorized_clients()`'s envelope unwrap accepts `{"response": {"authorized_clients": [...]}}` or `{"response": {"clients": [...]}}`, or a bare list. `find_gateway_address()` decodes `networking_status.ipv4_config.address` as either a raw big-endian uint32 (`struct.pack(">I", ...)`, not little-endian) or a dotted-quad string — the API has been observed serving both forms — considers only `eth`/`wifi` (never `gsm`), preferring whichever has `active_route` set and a decodable address; `0`/`0xFFFFFFFF` (and their string equivalents) are treated as undecodable. Tesla has not published an OpenAPI schema for these endpoints, so `const.py`'s enums are the schema of record; widen modeled fields only against a further live sample, not speculatively. Untyped escape-hatch methods (e.g. `list_authorized_clients()`) remain available alongside. Tests: `tests/test_teslemetry_authorized_clients.py`, `tests/test_teslemetry_gateway_address.py`. - **`_stream_sinks` peels subscription pushes off the command-reply queue before routing**: a `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; there is no public subscription API yet. 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 need a public lifecycle/iterator API atop the private `_stream_sinks` routing above) and `getVehicleImageState` (needs chunked binary-transfer paging). CarServer's `GetVehicleState` sub-state is exposed as `legacy_vehicle_state()` (`bluetooth.py`), matching the `VehicleData.legacy_vehicle_state` reply field name, to avoid confusion with `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. - **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. diff --git a/docs/teslemetry.md b/docs/teslemetry.md index 48e7d42..d3ae44f 100644 --- a/docs/teslemetry.md +++ b/docs/teslemetry.md @@ -596,9 +596,10 @@ Teslemetry energy sites expose the raw `get_networking_status` command, plus a typed `find_gateway_address` helper that discovers the gateway's LAN IPv4 address - for example to pre-fill the host for the signed local control path shown in [Energy: Local Control](energy_local_control.md). The `ipv4_config` -fields in a `networking_status` response are raw big-endian uint32 integers, -not dotted-quad strings; the helper decodes them and selects an interface for -you. Only the `eth` and `wifi` interfaces are considered (never `gsm` - +fields in a `networking_status` response have been observed as either raw +big-endian uint32 integers or dotted-quad strings; the helper decodes either +form and selects an interface for you. Only the `eth` and `wifi` interfaces +are considered (never `gsm` - cellular is not a LAN path): the helper prefers whichever has `active_route` set and a decodable address, then falls back to the first of the two (in `eth`, `wifi` order) with any decodable address. A null response body or an diff --git a/tesla_fleet_api.egg-info/PKG-INFO b/tesla_fleet_api.egg-info/PKG-INFO index e289166..474c4e5 100644 --- a/tesla_fleet_api.egg-info/PKG-INFO +++ b/tesla_fleet_api.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: tesla_fleet_api -Version: 1.7.7 +Version: 1.10.0 Summary: Tesla Fleet API library for Python Author-email: Brett Adams License-Expression: Apache-2.0 @@ -225,7 +225,10 @@ hierarchy. `VehicleBluetooth` can also register persistent BLE broadcast listeners for unsolicited VCSEC `VehicleStatus` updates. Use typed `listen_*` helpers for the decoded vehicle-status fields, or `listen_broadcast(domain, callback)` for raw -per-domain broadcast messages. +per-domain broadcast messages. Use `listen_connection_status(callback)` for +`True`/`False` BLE session transition notifications. See +[Bluetooth for Vehicles](docs/bluetooth_vehicles.md#connection-status-events) +for the connection-event contract. ### Routing and Failover diff --git a/tesla_fleet_api.egg-info/SOURCES.txt b/tesla_fleet_api.egg-info/SOURCES.txt index 4e872c8..ecdea15 100644 --- a/tesla_fleet_api.egg-info/SOURCES.txt +++ b/tesla_fleet_api.egg-info/SOURCES.txt @@ -56,9 +56,11 @@ tests/test_ble_broadcast_confirmation.py tests/test_ble_broadcast_listeners.py tests/test_ble_charging_commands.py tests/test_ble_charging_utility_commands.py +tests/test_ble_client_binding.py tests/test_ble_climate_commands.py tests/test_ble_command_verification.py tests/test_ble_confirmation_mode.py +tests/test_ble_connect_retry_budget.py tests/test_ble_connection_status.py tests/test_ble_connectivity_diagnostics_commands.py tests/test_ble_destructive_commands.py @@ -98,6 +100,8 @@ tests/test_tariff.py tests/test_tesla_private_key.py tests/test_teslemetry_authorized_clients.py tests/test_teslemetry_gateway_address.py +tests/test_teslemetry_register_client.py +tests/test_teslemetry_vehicle_custom_commands.py tests/test_teslemetry_wait_until_paired.py tests/test_tessie_vehicle_params.py tests/test_vehicle_image_state.py diff --git a/tesla_fleet_api/teslemetry/energysite.py b/tesla_fleet_api/teslemetry/energysite.py index f95ec2c..3bc0d06 100644 --- a/tesla_fleet_api/teslemetry/energysite.py +++ b/tesla_fleet_api/teslemetry/energysite.py @@ -2,6 +2,7 @@ import asyncio import base64 +import re import socket import struct from collections.abc import Awaitable, Callable @@ -207,26 +208,45 @@ def _parse_authorized_clients(payload: Any) -> AuthorizedClients: _GATEWAY_INTERFACES = ("eth", "wifi") +_DOTTED_QUAD_OCTET = r"(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])" +_DOTTED_QUAD_RE = re.compile( + rf"^{_DOTTED_QUAD_OCTET}\.{_DOTTED_QUAD_OCTET}\.{_DOTTED_QUAD_OCTET}\.{_DOTTED_QUAD_OCTET}$" +) + def _decode_ipv4(value: Any) -> str | None: - """Decode a raw big-endian uint32 into dotted-quad form. - - ``ipv4_config.address``/``subnet_mask``/``gateway`` in a - ``networking_status`` response are network-byte-order uint32 integers, - not strings - confirmed against a live Powerwall 3 capture where - ``3232235914`` decodes to ``192.168.1.138``. ``bool`` is excluded since - it subclasses ``int``; an out-of-range or non-int value returns - ``None`` rather than raising, since a single bad address shouldn't - fail the whole lookup. ``0`` and ``0xFFFFFFFF`` are also rejected - - ``0.0.0.0``/``255.255.255.255`` are never a usable host address, and an - unconfigured interface reporting ``address: 0`` must not shadow a real - address on another interface in the fallback selection. + """Decode a ``networking_status`` ipv4 field into dotted-quad form. + + The live API serves ``ipv4_config.address``/``subnet_mask``/``gateway`` + as a dotted-quad string directly (confirmed live 2026-08-22) - the + uint32 network-byte-order int form (e.g. ``3232235914`` decodes to + ``192.168.1.138``) is also accepted, defensively/for backward + compatibility, not because it was ever the primary wire format. ``bool`` + is excluded since it subclasses ``int``; a malformed string, out-of-range + int, or unsupported type returns ``None`` rather than raising, since a + single bad address shouldn't fail the whole lookup. The string form must + be a strict dotted-quad (exactly four 0-255 decimal octets, no leading + zeros beyond a bare ``0``, no surrounding whitespace, no hex/octal/bare- + decimal forms) - anything looser is treated as malformed and returns + ``None``. ``0.0.0.0`` and ``255.255.255.255`` are rejected in either + representation - never a usable host address, and an unconfigured + interface reporting the all-zero form must not shadow a real address on + another interface in the fallback selection. """ - if not isinstance(value, int) or isinstance(value, bool): - return None - if not 0 < value < 0xFFFFFFFF: + address: str | None = None + if isinstance(value, int) and not isinstance(value, bool): + if not 0 < value < 0xFFFFFFFF: + return None + address = socket.inet_ntoa(struct.pack(">I", value)) + elif isinstance(value, str): + if not _DOTTED_QUAD_RE.fullmatch(value): + return None + address = value + if address in ("0.0.0.0", "255.255.255.255"): + return None + else: return None - return socket.inet_ntoa(struct.pack(">I", value)) + return address def _interface_address(interface: Any) -> str | None: diff --git a/tests/test_teslemetry_gateway_address.py b/tests/test_teslemetry_gateway_address.py index ea54054..b695929 100644 --- a/tests/test_teslemetry_gateway_address.py +++ b/tests/test_teslemetry_gateway_address.py @@ -4,9 +4,10 @@ Only ``eth``/``wifi`` are considered (never ``gsm`` - cellular isn't a LAN path). An interface with ``active_route`` set wins; otherwise the first of ``eth``, ``wifi`` (in that order) with a decodable address is used. Address -fields on the wire are raw big-endian uint32 integers, not strings - see -``GatewayAddressRealCaptureTests`` for the real captured sample this decoding -is pinned against (see ``_parse_gateway_address`` in +fields on the wire have been observed as both raw big-endian uint32 integers +and dotted-quad strings - see ``GatewayAddressRealCaptureTests`` and +``GatewayAddressStringFormatTests`` for the real captured samples this +decoding is pinned against (see ``_decode_ipv4``/``_parse_gateway_address`` in ``tesla_fleet_api/teslemetry/energysite.py``). A null body or an unrecognized response shape is malformed data and must @@ -258,6 +259,243 @@ async def test_bare_body_without_response_envelope(self) -> None: self.assertEqual(result, "192.168.1.138") +class GatewayAddressStringFormatTests(IsolatedAsyncioTestCase): + """The API now also serves ipv4 fields as dotted-quad strings directly, + alongside the legacy uint32 int form - both must decode to the same + address. ``STRING_CAPTURE_RESPONSE`` is the real captured response body + (2026-08-22), with ``eth`` carrying a gateway-internal address and + ``wifi`` the real LAN address as the active route. + """ + + STRING_CAPTURE_RESPONSE = { + "response": { + "wifi_config": {"ssid": "ANONYMIZED_SSID"}, + "wifi": { + "mac_address": "ANONYMIZED_MAC_1", + "enabled": True, + "active_route": True, + "ipv4_config": { + "dhcp_enabled": True, + "address": "192.168.1.138", + "subnet_mask": "255.255.255.0", + "gateway": "192.168.1.1", + }, + "connectivity_status": { + "connected_physical": True, + "connected_internet": True, + "connected_tesla": True, + "rssi": {"signal_strength_percent": 45}, + }, + "device_state": 6, + "device_state_reason": 1, + }, + "eth": { + "mac_address": "ANONYMIZED_MAC_2", + "enabled": True, + "ipv4_config": { + "dhcp_enabled": True, + "address": "192.168.90.2", + "subnet_mask": "255.255.255.0", + }, + "connectivity_status": {"rssi": {}}, + }, + "gsm": { + "enabled": True, + "ipv4_config": { + "address": "10.17.123.246", + "subnet_mask": "255.255.255.255", + "gateway": "10.17.123.246", + }, + "connectivity_status": { + "connected_physical": True, + "connected_internet": True, + "connected_tesla": True, + "rssi": {"signal_strength_percent": 60}, + }, + }, + } + } + + async def test_string_captured_sample_selects_active_route_wifi(self) -> None: + site = _make_site(self.STRING_CAPTURE_RESPONSE) + + result = await site.find_gateway_address() + + self.assertEqual(result, "192.168.1.138") + + async def test_string_address_matches_equivalent_uint32_address(self) -> None: + string_site = _make_site( + {"response": {"eth": {"ipv4_config": {"address": "192.168.1.138"}}}} + ) + int_site = _make_site( + {"response": {"eth": {"ipv4_config": {"address": 3232235914}}}} + ) + + string_result = await string_site.find_gateway_address() + int_result = await int_site.find_gateway_address() + + self.assertEqual(string_result, "192.168.1.138") + self.assertEqual(string_result, int_result) + + async def test_string_zero_address_is_undecodable(self) -> None: + site = _make_site( + { + "response": { + "eth": { + "active_route": True, + "ipv4_config": {"address": "0.0.0.0"}, + }, + } + } + ) + + result = await site.find_gateway_address() + + self.assertIsNone(result) + + async def test_string_broadcast_address_is_undecodable(self) -> None: + site = _make_site( + { + "response": { + "eth": { + "active_route": True, + "ipv4_config": {"address": "255.255.255.255"}, + }, + } + } + ) + + result = await site.find_gateway_address() + + self.assertIsNone(result) + + async def test_malformed_string_address_is_undecodable(self) -> None: + site = _make_site( + { + "response": { + "eth": { + "active_route": True, + "ipv4_config": {"address": "not-an-ip"}, + }, + } + } + ) + + result = await site.find_gateway_address() + + self.assertIsNone(result) + + async def test_trailing_newline_address_is_undecodable(self) -> None: + """``$`` in Python regex matches before a trailing newline, so a + naive ``re.match`` (rather than ``fullmatch``/``\\Z``) would accept + ``"192.168.1.138\\n"`` as valid. It must be rejected like any other + malformed string. + """ + site = _make_site( + { + "response": { + "eth": { + "active_route": True, + "ipv4_config": {"address": "192.168.1.138\n"}, + }, + } + } + ) + + result = await site.find_gateway_address() + + self.assertIsNone(result) + + async def test_surrounding_whitespace_address_is_undecodable(self) -> None: + site = _make_site( + { + "response": { + "eth": { + "active_route": True, + "ipv4_config": {"address": " 192.168.1.138 "}, + }, + } + } + ) + + result = await site.find_gateway_address() + + self.assertIsNone(result) + + async def test_trailing_newline_zero_sentinel_falls_back_to_wifi(self) -> None: + """A trailing newline must not let ``"0.0.0.0\\n"`` slip past the + regex undetected - it must be treated as undecodable (not as a + validated ``0.0.0.0``) so the active-route ``eth`` interface is + skipped and selection properly falls back to ``wifi``'s real + address instead of either raising or returning the bad value. + """ + site = _make_site( + { + "response": { + "eth": { + "active_route": True, + "ipv4_config": {"address": "0.0.0.0\n"}, + }, + "wifi": {"ipv4_config": {"address": "192.168.1.138"}}, + } + } + ) + + result = await site.find_gateway_address() + + self.assertEqual(result, "192.168.1.138") + + async def test_trailing_newline_broadcast_sentinel_is_undecodable(self) -> None: + site = _make_site( + { + "response": { + "eth": { + "active_route": True, + "ipv4_config": {"address": "255.255.255.255\n"}, + }, + } + } + ) + + result = await site.find_gateway_address() + + self.assertIsNone(result) + + async def test_surrounding_whitespace_zero_sentinel_is_undecodable(self) -> None: + site = _make_site( + { + "response": { + "eth": { + "active_route": True, + "ipv4_config": {"address": " 0.0.0.0 "}, + }, + } + } + ) + + result = await site.find_gateway_address() + + self.assertIsNone(result) + + async def test_surrounding_whitespace_broadcast_sentinel_is_undecodable( + self, + ) -> None: + site = _make_site( + { + "response": { + "eth": { + "active_route": True, + "ipv4_config": {"address": " 255.255.255.255 "}, + }, + } + } + ) + + result = await site.find_gateway_address() + + self.assertIsNone(result) + + class GatewayAddressInvalidResponseTests(IsolatedAsyncioTestCase): async def test_null_body_raises_invalid_response(self) -> None: site = _make_site(None)