diff --git a/AGENTS.md b/AGENTS.md index 84737f4..d6197b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A `Router` (router.py) is an entity-agnostic composition wrapper (not part of the inheritance chain) that chains an ordered list of two-or-more backends sharing a common method surface and dispatches each method call down the chain with automatic per-command failover: it tries the first backend that has the method and, on any exception except `BluetoothUnconfirmedCommand`, retries the same call on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails, `AttributeError` only if none has the method). Non-callable attributes resolve to the first backend that has them. The constructor is `Router(primary, secondary, *more_backends, health=None)` — the two-argument form is fully backward compatible. The health check (`bool` | sync callable | async callable returning `bool`; omitted = attempt primary, fail over on exception with no probe) gates **only the primary** (the first backend); the rest of the chain is reached purely through per-command failover — there is deliberately no per-backend health matrix. Note the double-execution caveat: a non-idempotent command that fails mid-flight can be re-run on the next backend, except for `BluetoothUnconfirmedCommand`, which propagates without replay. -`VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses of `Router`. `VehicleRouter(bluetooth_primary, teslemetry_secondary)` pairs a `VehicleBluetooth` primary with a cloud (`TeslemetryVehicle`) secondary; `EnergySiteRouter(local_energysite, teslemetry_energysite)` pairs a duck-typed local `EnergySite`-shaped object (e.g. aiopowerwall's `PowerwallEnergySite`, no dependency added) with a cloud `TeslemetryEnergySite` fallback. All three live in the top-level `router/` package — `Router` (and shared helpers) in `router/base.py`, `VehicleRouter` in `router/vehicle.py`, `EnergySiteRouter` in `router/energysite.py`, re-exported from `router/__init__.py` (importable as `tesla_fleet_api.router.Router` etc.) and, for backward compatibility, also re-exported from `tesla/__init__.py` (`tesla_fleet_api.tesla.Router`). They have no factory on the `Vehicles`/`EnergySites` collections. This repo owns the RSA keypair lifecycle and cloud registration (`Tesla.get_rsa_private_key`, `EnergySite.add_authorized_client`) that aiopowerwall's local signed transport depends on but does not implement itself; see `docs/energy_local_control.md` for the end-to-end pairing + `EnergySiteRouter` composition flow, including why the cloud-only `set_island_mode`/`go_off_grid`/`reconnect_grid` methods don't actuate the contactor and why a success response from either transport doesn't prove that it did. +`VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses of `Router`. `VehicleRouter(bluetooth_primary, teslemetry_secondary)` pairs a `VehicleBluetooth` primary with a cloud (`TeslemetryVehicle`) secondary; `EnergySiteRouter(local_energysite, teslemetry_energysite)` pairs a duck-typed local `EnergySite`-shaped object (e.g. aiopowerwall's `PowerwallEnergySite`, no dependency added) with a cloud `TeslemetryEnergySite` fallback. All three live in the top-level `router/` package — `Router` (and shared helpers) in `router/base.py`, `VehicleRouter` in `router/vehicle.py`, `EnergySiteRouter` in `router/energysite.py`, re-exported from `router/__init__.py` (importable as `tesla_fleet_api.router.Router` etc.) and, for backward compatibility, also re-exported from `tesla/__init__.py` (`tesla_fleet_api.tesla.Router`). They have no factory on the `Vehicles`/`EnergySites` collections. This repo owns the RSA keypair lifecycle and cloud registration (`Tesla.get_rsa_private_key`, `EnergySite.add_authorized_client`) that aiopowerwall's local signed transport depends on but does not implement itself; see `docs/energy_local_control.md` for the end-to-end pairing + `EnergySiteRouter` composition flow. The cloud-only `set_island_mode`/`go_off_grid`/`reconnect_grid` (`tesla/energysite.py`) can only send an unsigned `grpc_command`, which gateways can acknowledge without actuating the contactor - rather than ship that as a silent no-op, they unconditionally raise `SignedCommandRequired` (`exceptions.py`); only the signed local path via `add_authorized_client` + `EnergySiteRouter` actually actuates, and a success response from that transport still doesn't prove the contactor moved - verify state after the call. ### Vehicle Collections diff --git a/docs/energy_local_control.md b/docs/energy_local_control.md index da1de32..08fca15 100644 --- a/docs/energy_local_control.md +++ b/docs/energy_local_control.md @@ -183,31 +183,33 @@ Routing and Failover section](../README.md#routing-and-failover) for the general `Router` semantics (per-command failover, the double-execution caveat, and the `health` check). -> **Warning: island mode / off-grid actuation is not guaranteed on either -> transport - always verify by state.** +> **Warning: island mode / off-grid actuation is not guaranteed, and the +> cloud path cannot do it at all.** > > This repo's cloud `EnergySite.set_island_mode()` / `go_off_grid()` / -> `reconnect_grid()` send an **unsigned** `grpc_command`. Per their own -> docstrings and the commits that added them, the gateway accepts this -> command but does **not** physically operate the grid contactor over that -> transport - a plain cloud-only `EnergySite` (no router, no local backend) -> gets a silent no-op with an OK-looking response. +> `reconnect_grid()` can only send an **unsigned** `grpc_command`, and +> gateways have been observed accepting that command without physically +> operating the grid contactor. Rather than ship that as a silent no-op with +> an OK-looking response, these three methods unconditionally raise +> `tesla_fleet_api.exceptions.SignedCommandRequired` - there is no cloud-only +> way to actuate the contactor. > > Routing through `EnergySiteRouter` with an `aiopowerwall` local primary is > the intended way to actually operate the contactor, since it sends a -> signed request over the LAN. But `aiopowerwall`'s own docs carry an -> unresolved, firmware-dependent caveat for its local `set_island_mode` -> too - some gateways acknowledge it without acting on it, and +> signed request over the LAN. If the local primary fails and `Router` fails +> over to the cloud secondary, the call now raises `SignedCommandRequired` +> from the cloud side instead of returning a false success - the router +> semantics don't change (the local backend is still tried first), but a +> total failure now surfaces as a clear error. `aiopowerwall`'s own docs also +> carry an unresolved, firmware-dependent caveat for its local +> `set_island_mode` - some gateways acknowledge it without acting on it, and > `trigger_islanding()` (the explicit black-start command) may be needed as > a fallback. > -> In both cases a success-shaped response does not prove the contactor -> moved, and `Router` has no way to detect that on its own - a call that -> returns without raising looks identical whether it actuated or not, so -> failover to the cloud secondary will not trigger. Always confirm the -> actual outcome with a signed local status read (for example -> `live_status()`'s grid/island fields) before trusting either path's -> response for anything actuation-critical. +> Even on the signed local path, a success-shaped response does not prove +> the contactor moved. Always confirm the actual outcome with a signed local +> status read (for example `live_status()`'s grid/island fields) before +> trusting the response for anything actuation-critical. ## See also diff --git a/docs/fleet_api_energy_sites.md b/docs/fleet_api_energy_sites.md index 4f6319e..ace360a 100644 --- a/docs/fleet_api_energy_sites.md +++ b/docs/fleet_api_energy_sites.md @@ -357,7 +357,7 @@ asyncio.run(main()) Energy gateways (Powerwalls, etc.) support gRPC commands sent via `POST /api/1/energy_sites/{id}/command`. These are undocumented Tesla API endpoints that communicate directly with the gateway hardware. All device command methods require the `energy_cmds` scope. -These commands are unsigned and cloud-only - the actuating `set_island_mode`/`go_off_grid`/`reconnect_grid` methods on `EnergySite` are documented as accepted by the gateway without physically taking effect for that reason. For registering a key and pairing it with a signed local LAN control path via the sibling `aiopowerwall` library, see [Energy: Local Control](energy_local_control.md). +These commands are unsigned and cloud-only. The actuating `set_island_mode`/`go_off_grid`/`reconnect_grid` methods on `EnergySite` raise `SignedCommandRequired` unconditionally - the gateway can accept that unsigned command without physically taking effect, so there is no cloud-only way to actuate it. For registering a key and pairing it with a signed local LAN control path via the sibling `aiopowerwall` library, see [Energy: Local Control](energy_local_control.md). Use `list_authorized_clients()` only as a secondary, best-effort cloud check while pairing a local key. The reliable verification is a successful signed diff --git a/tesla_fleet_api/exceptions.py b/tesla_fleet_api/exceptions.py index 2d5cc2b..ffeba3f 100644 --- a/tesla_fleet_api/exceptions.py +++ b/tesla_fleet_api/exceptions.py @@ -396,6 +396,24 @@ class LibraryError(Exception): """Errors related to this library.""" +class SignedCommandRequired(TeslaFleetError): + """The requested action requires a signed command; the unsigned cloud API cannot actuate it. + + Energy gateways have been observed acknowledging this class of unsigned + ``grpc_command`` without physically operating the grid contactor - see + ``EnergySite.set_island_mode``. Pair an RSA key with + ``EnergySite.add_authorized_client`` and issue the command through a + signed local LAN backend (composed via ``EnergySiteRouter``) instead. + """ + + message = ( + "This command cannot actuate via the unsigned cloud API - gateways " + "acknowledge it without operating the contactor. Pair a key with " + "add_authorized_client and issue it through a signed local control " + "path (EnergySiteRouter) instead." + ) + + class TeslaFleetInformationFault(TeslaFleetError): """Vehicle has responded with an error when sending a signed command""" diff --git a/tesla_fleet_api/tesla/energysite.py b/tesla_fleet_api/tesla/energysite.py index e058fda..b70d439 100644 --- a/tesla_fleet_api/tesla/energysite.py +++ b/tesla_fleet_api/tesla/energysite.py @@ -13,6 +13,7 @@ Method, TeslaEnergyPeriod, ) +from tesla_fleet_api.exceptions import SignedCommandRequired if TYPE_CHECKING: from tesla_fleet_api.tesla.fleet import TeslaFleetApi @@ -208,44 +209,32 @@ async def set_island_mode( ) -> dict[str, Any]: """Set the island mode on the energy gateway. - This cloud method sends an unsigned ``grpc_command``. Gateways have - been observed accepting that request without physically operating - the grid contactor, so callers must verify the resulting state - rather than trusting a success-shaped response. For a signed local - LAN control path, pair an RSA key with ``add_authorized_client`` and - compose an aiopowerwall local backend with ``EnergySiteRouter``. + Always raises ``SignedCommandRequired``: this cloud method can only + send an unsigned ``grpc_command``, and gateways have been observed + accepting that request without physically operating the grid + contactor. For a signed local LAN control path, pair an RSA key with + ``add_authorized_client`` and compose an aiopowerwall local backend + with ``EnergySiteRouter``. Args: - mode: EnergyIslandMode.OFF_GRID (6) to island, - EnergyIslandMode.ON_GRID (1) to reconnect. - force: Whether to force the contactor operation. Defaults to - True for OFF_GRID, False for ON_GRID. Required for - off-grid - without force=True the gateway acknowledges - the command but does not physically open the contactor. + mode: Unused - the call raises before inspecting it. Kept for + signature compatibility. + force: Unused - the call raises before inspecting it. Kept for + signature compatibility. """ - if force is None: - force = int(mode) == EnergyIslandMode.OFF_GRID - return await self._command( - "teg", - "set_island_mode_request", - {"mode": int(mode), "force": force}, - ) + raise SignedCommandRequired async def go_off_grid(self) -> dict[str, Any]: """Request off-grid mode through the cloud ``grpc_command`` path. - Convenience wrapper around set_island_mode(OFF_GRID, force=True). - This may be accepted without physically opening the contactor; verify - state after the call. + Always raises ``SignedCommandRequired`` - see ``set_island_mode``. """ return await self.set_island_mode(EnergyIslandMode.OFF_GRID) async def reconnect_grid(self) -> dict[str, Any]: """Request on-grid mode through the cloud ``grpc_command`` path. - Convenience wrapper around set_island_mode(ON_GRID). This may be - accepted without physically closing the contactor; verify state - after the call. + Always raises ``SignedCommandRequired`` - see ``set_island_mode``. """ return await self.set_island_mode(EnergyIslandMode.ON_GRID) diff --git a/tests/test_energysite_island_mode.py b/tests/test_energysite_island_mode.py new file mode 100644 index 0000000..afab6c5 --- /dev/null +++ b/tests/test_energysite_island_mode.py @@ -0,0 +1,46 @@ +"""Tests for EnergySite.set_island_mode/go_off_grid/reconnect_grid. + +These cloud-only methods can only send an unsigned ``grpc_command`` - +gateways have been observed acknowledging that request without physically +operating the grid contactor. They must raise +:class:`~tesla_fleet_api.exceptions.SignedCommandRequired` instead of +shipping a silent no-op; the signed local control path +(``add_authorized_client`` + ``EnergySiteRouter``) is unaffected. +""" + +from __future__ import annotations + +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock + +from tesla_fleet_api.const import EnergyIslandMode +from tesla_fleet_api.exceptions import SignedCommandRequired +from tesla_fleet_api.tesla.energysite import EnergySite + + +def _make_site() -> tuple[EnergySite, AsyncMock]: + request_mock: AsyncMock = AsyncMock() + parent = AsyncMock() + parent._request = request_mock + site = EnergySite(parent, 12345) + return site, request_mock + + +class IslandModeCloudNoOpTests(IsolatedAsyncioTestCase): + async def test_set_island_mode_raises(self) -> None: + site, request_mock = _make_site() + with self.assertRaises(SignedCommandRequired): + await site.set_island_mode(EnergyIslandMode.OFF_GRID) + request_mock.assert_not_called() + + async def test_go_off_grid_raises(self) -> None: + site, request_mock = _make_site() + with self.assertRaises(SignedCommandRequired): + await site.go_off_grid() + request_mock.assert_not_called() + + async def test_reconnect_grid_raises(self) -> None: + site, request_mock = _make_site() + with self.assertRaises(SignedCommandRequired): + await site.reconnect_grid() + request_mock.assert_not_called() diff --git a/uv.lock b/uv.lock index a48a120..af8d36c 100644 --- a/uv.lock +++ b/uv.lock @@ -728,7 +728,7 @@ wheels = [ [[package]] name = "tesla-fleet-api" -version = "1.7.4" +version = "1.7.5" source = { editable = "." } dependencies = [ { name = "aiofiles" },