Repository navigation
Raise on a gateway error_response in signed energy device replies #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -42,13 +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.""" | ||
| if ( | ||
| isinstance(power_watts, bool) | ||
| or not isinstance(power_watts, (int, float)) # pyright: ignore[reportUnnecessaryIsInstance] | ||
| or not math.isfinite(power_watts) | ||
| ): | ||
| """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 | ||
|
|
@@ -59,6 +62,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): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle non-ASCII envelope strings. If
|
||
| 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 +197,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,15 +221,18 @@ 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: | ||
| raise SignedCommandRequired | ||
| 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 +447,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. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Return non-object replies unchanged.
TeslaFleetApi._requestpermits JSONnull, lists, and scalars. Ifdevice_commandreturns one, this.get()raisesAttributeErrorafter the command was sent. Check thatresultis a dictionary before reading it. (raw.githubusercontent.com)