From 2baa81432e47a8341e0eb225e1d5cfc590401f19 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 29 Oct 2026 12:39:17 +1000 Subject: [PATCH 1/2] Raise on a gateway error_response in signed energy device replies A signed device_command that the gateway refuses (for example code 11 OUT_OF_RANGE on a dispatch above its 10,000 W limit) comes back as HTTP 200 with common.error_response in message_envelope_as_bytes, and was reported as success. Decode the reply in _signed_command: code 12 (UNIMPLEMENTED) raises InvalidCommand, any other code raises DeviceUnexpectedResponse, both with {code, message} as data. Correct the dispatch docs: a setpoint was seen to discharge below the backup reserve and to export regardless of the export setting, and the gateway refuses rather than clamps a request above its power limit. Make validate_dispatch_battery_power raise ValueError, not OverflowError, on an int too large for a float. --- docs/fleet_api_energy_sites.md | 11 +++- tesla_fleet_api/tesla/energysite.py | 76 +++++++++++++++++++---- tests/test_energysite_battery_dispatch.py | 1 + tests/test_energysite_signed_transport.py | 60 +++++++++++++++++- 4 files changed, 132 insertions(+), 16 deletions(-) diff --git a/docs/fleet_api_energy_sites.md b/docs/fleet_api_energy_sites.md index 637cfa4..8cd8a38 100644 --- a/docs/fleet_api_energy_sites.md +++ b/docs/fleet_api_energy_sites.md @@ -409,7 +409,7 @@ With a key, the site signs every device command except the four pre-pairing read The fleet REST routes (`backup`, `storm_mode`, `operation`, `grid_import_export` and the other non-device-command methods) are not device commands and never sign. -`None` means unsigned: there is no fallback to `api.rsa_private_key`. The signature expires after 30 seconds and has no counter, so the same bytes resent inside that window execute again; the library never retries a signed command. The reply is returned unchanged. A success-shaped reply does not prove the contactor moved: read `island_status` from `live_status()` after each step. +`None` means unsigned: there is no fallback to `api.rsa_private_key`. The signature expires after 30 seconds and has no counter, so the same bytes resent inside that window execute again; the library never retries a signed command. The reply is returned unchanged unless the gateway refused the command with `common.error_response` inside the HTTP 200: code 12 (UNIMPLEMENTED) then raises `InvalidCommand`, and any other code raises `DeviceUnexpectedResponse`. Both carry `{"code", "message"}` as `data`. A success-shaped reply does not prove the contactor moved: read `island_status` from `live_status()` after each step. On `Teslemetry`, a client key works the same way: every device command except the four pre-pairing reads is signed with that key and goes through Teslemetry's `device_command` route. `add_authorized_client()` with no `public_key` still calls Teslemetry's route, which registers Teslemetry's own key. Without a client key, `TeslemetryEnergySite` never raises `SignedCommandRequired`. It calls Teslemetry's routes instead (for example `POST command/set_island_mode`, `POST command/dispatch_battery_power`, `GET command/backup_events`, `GET command/config`), where Teslemetry signs with its own key. As of 2026-10-29 the `dispatch_battery_power`, `resume_battery_operation` and backup-event schedule/cancel routes still send unsigned and get Tesla's 400; a Teslemetry api change moves them to server signing. Teslemetry's key must be `VERIFIED` on the gateway, or a server-signed route answers 409 `energy_site_not_paired` and sends nothing. @@ -445,8 +445,13 @@ and the `PrivateKeyError` raised for an unusable existing key file. `dispatch_battery_power` holds the setpoint for `duration_seconds`, then the gateway returns to its configured operating mode by itself. Keep durations short and re-send to renew. The reply is an empty acknowledgement, not -readback: read `live_status()` to see what the battery does. The gateway still -applies its own backup reserve and grid limits. The method has the same name, +readback: read `live_status()` to see what the battery does. A setpoint was +seen to discharge below the backup reserve and to export with export set to +`pv_only`/`never` (Powerwall 2, Backup Gateway 1, firmware 26.34.0). The +gateway refuses (it does not clamp) a request above its power limit, seen at +10,000 W, which can be below the combined battery rating. On the signed path +that refusal raises `DeviceUnexpectedResponse` with code 11 (OUT_OF_RANGE). +The method has the same name, arguments, units, sign and limits as aiopowerwall's local `dispatch_battery_power`, so `EnergySiteRouter` can send it on either path. diff --git a/tesla_fleet_api/tesla/energysite.py b/tesla_fleet_api/tesla/energysite.py index 96f16c6..ea84075 100644 --- a/tesla_fleet_api/tesla/energysite.py +++ b/tesla_fleet_api/tesla/energysite.py @@ -2,9 +2,13 @@ import asyncio import base64 +import binascii import math from typing import TYPE_CHECKING, Any +from google.protobuf.message import DecodeError +from tesla_protocol.energy_device import transport_pb2 + from tesla_fleet_api.const import ( AuthorizedClientKeyType, AuthorizedClientType, @@ -15,7 +19,11 @@ Method, TeslaEnergyPeriod, ) -from tesla_fleet_api.exceptions import SignedCommandRequired +from tesla_fleet_api.exceptions import ( + DeviceUnexpectedResponse, + InvalidCommand, + SignedCommandRequired, +) from tesla_fleet_api.tesla.energy_signing import EnergyDeviceSigner if TYPE_CHECKING: @@ -44,11 +52,15 @@ def validate_dispatch_battery_power(power_watts: float, duration_seconds: int) -> None: """Raise ``ValueError`` on arguments aiopowerwall's local ``dispatch_battery_power`` also rejects, so every backend fails the same way.""" - if ( - isinstance(power_watts, bool) - or not isinstance(power_watts, (int, float)) # pyright: ignore[reportUnnecessaryIsInstance] - or not math.isfinite(power_watts) - ): + try: + finite = ( + not isinstance(power_watts, bool) + and isinstance(power_watts, (int, float)) # pyright: ignore[reportUnnecessaryIsInstance] + and math.isfinite(power_watts) + ) + except OverflowError: # an int too large for a float + finite = False + if not finite: raise ValueError("power_watts must be a finite number") if type(duration_seconds) is not int or not ( 1 <= duration_seconds <= MAX_DISPATCH_DURATION_SECONDS @@ -59,6 +71,40 @@ def validate_dispatch_battery_power(power_watts: float, duration_seconds: int) - ) +# google.rpc.Code UNIMPLEMENTED: the gateway firmware has no handler. +_UNIMPLEMENTED = 12 + + +def _raise_on_error_response(result: dict[str, Any]) -> None: + """Raise if a ``device_command`` reply holds ``common.error_response``. + + The gateway refuses a command inside an HTTP 200 reply, for example code + 11 (OUT_OF_RANGE) on a dispatch above its power limit. Code 12 raises + ``InvalidCommand``; any other code raises ``DeviceUnexpectedResponse``. + Both carry ``{"code", "message"}`` as ``data``. A reply without + decodable envelope bytes is left alone. + """ + response = result.get("response") + if not isinstance(response, dict): + return + encoded = response.get("message_envelope_as_bytes") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + if not isinstance(encoded, str): + return + envelope = transport_pb2.MessageEnvelope() + try: + envelope.ParseFromString(base64.b64decode(encoded, validate=True)) + except (binascii.Error, DecodeError): + return + # error_response sits outside CommonMessages' ``message`` oneof. + if not envelope.common.HasField("error_response"): + return + status = envelope.common.error_response.status + data: dict[str, Any] = {"code": status.code, "message": status.message} + if status.code == _UNIMPLEMENTED: + raise InvalidCommand(data) + raise DeviceUnexpectedResponse(data) + + class EnergySite: """Class describing the Tesla Fleet API partner endpoints @@ -160,8 +206,8 @@ async def _device_command( """Send a signed ``RoutableMessage`` to the energy device. Tesla requires the body wrapped in ``data`` and ``identifier_type`` as - an integer. The reply is returned unchanged: its shape has not been - captured live yet, so it is not decoded here. + an integer. The reply is returned unchanged; :meth:`_signed_command` + checks it for a gateway error. """ return await self._request( Method.POST, @@ -184,7 +230,8 @@ async def _signed_command( Takes the same arguments as :meth:`_command`. Raises ``SignedCommandRequired`` when the site has no signing key. A resend of the same signed bytes inside the TTL executes again, so this never - retries. + retries. Raises as :func:`_raise_on_error_response` does when the + gateway refuses the command. """ signer, din = self._signer, self.din if signer is None or din is None: @@ -192,7 +239,9 @@ async def _signed_command( envelope = signer.envelope(din, category, command, params) # RSA-4096 with SHA-512 takes about 10 ms; keep it off the event loop. routable_message = await asyncio.to_thread(signer.sign, envelope, din) - return await self._device_command(routable_message, din) + result = await self._device_command(routable_message, din) + _raise_on_error_response(result) + return result async def get_system_info(self) -> dict[str, Any]: """Get energy device system information including firmware version, device type, part number, serial number, and DIN.""" @@ -407,8 +456,11 @@ async def dispatch_battery_power( Keep durations short and re-send to renew, so a caller that stops cannot leave the battery on a stale setpoint. Call :meth:`resume_battery_operation` to release early. The reply is an - empty acknowledgement, not readback; the gateway still applies its - own reserve and grid limits. + empty acknowledgement, not readback. A setpoint was seen to discharge + below the backup reserve and to export with export set to + ``pv_only``/``never``. The gateway refuses (it does not clamp) a + request above its power limit, seen at 10,000 W: the signed path + raises ``DeviceUnexpectedResponse`` with code 11. Tesla's cloud refuses this command unsigned (HTTP 400), so it acts only on a site built with a signing key. diff --git a/tests/test_energysite_battery_dispatch.py b/tests/test_energysite_battery_dispatch.py index 49d2226..5c7d591 100644 --- a/tests/test_energysite_battery_dispatch.py +++ b/tests/test_energysite_battery_dispatch.py @@ -29,6 +29,7 @@ (math.nan, 60), (math.inf, 60), (-math.inf, 60), + (10**1000, 60), (True, 60), ("5000", 60), (None, 60), diff --git a/tests/test_energysite_signed_transport.py b/tests/test_energysite_signed_transport.py index eade3dd..3b22ed6 100644 --- a/tests/test_energysite_signed_transport.py +++ b/tests/test_energysite_signed_transport.py @@ -25,7 +25,11 @@ ) from tesla_fleet_api.const import EnergyIslandMode, Method -from tesla_fleet_api.exceptions import SignedCommandRequired +from tesla_fleet_api.exceptions import ( + DeviceUnexpectedResponse, + InvalidCommand, + SignedCommandRequired, +) from tesla_fleet_api.router import EnergySiteRouter from tesla_fleet_api.tesla.energy_signing import ( ENERGY_SIGNATURE_TTL_SECONDS, @@ -480,6 +484,60 @@ async def test_pre_pairing_routes_stay_unsigned_with_a_key(self) -> None: ) +def _reply(envelope: transport_pb2.MessageEnvelope) -> dict[str, Any]: + """A ``device_command`` reply, shaped as captured live on 2026-10-29.""" + return { + "response": { + "request_id": "546681d7ff2f1fd5c72a543415a8fe1f", + "message_envelope_as_bytes": base64.b64encode( + envelope.SerializeToString() + ).decode("ascii"), + } + } + + +def _error_reply(code: int, message: str) -> dict[str, Any]: + envelope = transport_pb2.MessageEnvelope() + envelope.common.error_response.status.code = code + envelope.common.error_response.status.message = message + return _reply(envelope) + + +class SignedReplyDecodeTests(IsolatedAsyncioTestCase): + async def test_out_of_range_refusal_raises(self) -> None: + for cls in (EnergySite, TeslemetryEnergySite): + with self.subTest(cls=cls.__name__): + site, request_mock = _make_site(cls) + request_mock.return_value = _error_reply( + 11, "power 11000.000000 exceeds discharge limit 10000" + ) + with self.assertRaises(DeviceUnexpectedResponse) as ctx: + await site.dispatch_battery_power(11000, 30) + self.assertEqual( + ctx.exception.data, + { + "code": 11, + "message": "power 11000.000000 exceeds discharge limit 10000", + }, + ) + + async def test_unimplemented_raises_invalid_command(self) -> None: + site, request_mock = _make_site(EnergySite) + request_mock.return_value = _error_reply(12, "") + with self.assertRaises(InvalidCommand) as ctx: + await site.get_teg_config() + self.assertEqual(ctx.exception.data, {"code": 12, "message": ""}) + + async def test_ack_is_returned_unchanged(self) -> None: + site, request_mock = _make_site(EnergySite) + envelope = transport_pb2.MessageEnvelope() + envelope.teg.dispatch_battery_power_response.SetInParent() + reply = _reply(envelope) + self.assertEqual(reply["response"]["message_envelope_as_bytes"], "KgOyAgA=") + request_mock.return_value = reply + self.assertIs(await site.dispatch_battery_power(-2000, 120), reply) + + class _LocalPowerwallSite: """A local backend that cannot reach the gateway.""" From 1bda70134ce62b19d28500b3d9e56799ddd9b8e1 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 29 Oct 2026 13:21:50 +1000 Subject: [PATCH 2/2] Check dispatch power with math.isfinite only No input schema exists for dispatch_battery_power, so keep the single isfinite check in the helper and drop the bool/isinstance chain, the OverflowError catch and their test cases. --- tesla_fleet_api/tesla/energysite.py | 13 ++----------- tests/test_energysite_battery_dispatch.py | 4 ---- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/tesla_fleet_api/tesla/energysite.py b/tesla_fleet_api/tesla/energysite.py index ea84075..01d7048 100644 --- a/tesla_fleet_api/tesla/energysite.py +++ b/tesla_fleet_api/tesla/energysite.py @@ -50,17 +50,8 @@ def validate_dispatch_battery_power(power_watts: float, duration_seconds: int) -> None: - """Raise ``ValueError`` on arguments aiopowerwall's local - ``dispatch_battery_power`` also rejects, so every backend fails the same way.""" - try: - finite = ( - not isinstance(power_watts, bool) - and isinstance(power_watts, (int, float)) # pyright: ignore[reportUnnecessaryIsInstance] - and math.isfinite(power_watts) - ) - except OverflowError: # an int too large for a float - finite = False - if not finite: + """Raise ``ValueError`` on a non-finite power or an out-of-range duration.""" + if not math.isfinite(power_watts): raise ValueError("power_watts must be a finite number") if type(duration_seconds) is not int or not ( 1 <= duration_seconds <= MAX_DISPATCH_DURATION_SECONDS diff --git a/tests/test_energysite_battery_dispatch.py b/tests/test_energysite_battery_dispatch.py index 5c7d591..06501cb 100644 --- a/tests/test_energysite_battery_dispatch.py +++ b/tests/test_energysite_battery_dispatch.py @@ -29,10 +29,6 @@ (math.nan, 60), (math.inf, 60), (-math.inf, 60), - (10**1000, 60), - (True, 60), - ("5000", 60), - (None, 60), (5000, 0), (5000, -1), (5000, MAX_DISPATCH_DURATION_SECONDS + 1),