Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 19 additions & 17 deletions docs/energy_local_control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/fleet_api_energy_sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions tesla_fleet_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down
39 changes: 14 additions & 25 deletions tesla_fleet_api/tesla/energysite.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Method,
TeslaEnergyPeriod,
)
from tesla_fleet_api.exceptions import SignedCommandRequired

if TYPE_CHECKING:
from tesla_fleet_api.tesla.fleet import TeslaFleetApi
Expand Down Expand Up @@ -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)

Expand Down
46 changes: 46 additions & 0 deletions tests/test_energysite_island_mode.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading