Metadata-Version: 2.4
Name: aiopowerwall
Version: 0.2.0
Summary: Async Tesla Powerwall 3 client over the TEDAPI v1r RSA-signed LAN protocol.
Project-URL: Homepage, https://github.com/Teslemetry/aiopowerwall
Project-URL: Repository, https://github.com/Teslemetry/aiopowerwall
Project-URL: Issues, https://github.com/Teslemetry/aiopowerwall/issues
Project-URL: Changelog, https://github.com/Teslemetry/aiopowerwall/releases
Author-email: Teslemetry <hello@teslemetry.com>
License-Expression: MIT
License-File: LICENSE
Keywords: aiohttp,asyncio,home-assistant,powerwall,tedapi,tesla
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Home Automation
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: aiohttp>=3.9
Requires-Dist: cryptography>=41
Requires-Dist: protobuf>=4.25
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Requires-Dist: types-protobuf; extra == 'dev'
Description-Content-Type: text/markdown

# aiopowerwall

An async Tesla Powerwall 3 client built on `aiohttp`, written for Home
Assistant and any other asyncio code.

## Status

**Beta (0.x).** The wire protocol and the public Python API may change
between minor versions until 1.0. Pin a tight version range if you depend
on this library in production.

This library speaks the Powerwall's **TEDAPI v1r** protocol — RSA-signed
protobuf messages directly to your Powerwall. It is intentionally
scoped to:

- Powerwall 3, and updated Powerwall 2 (untested)
- Local LAN access only (no cloud telemetry)
- Read + control commands (status, config, firmware, components,
  max-backup, islanding, curtailment)

The RSA key pair used for v1r authentication must be **registered with the
gateway out-of-band**, typically via the Tesla Fleet API. This library
consumes an already-paired private key — it does not implement registration.

### Multi-Powerwall systems

This client maintains a single v1r connection to the **leader** gateway and
signs every request with the leader's DIN — which is correct, since the RSA
key is only registered on the leader. On a multi-unit system the leader
returns whole-site aggregate data, but **per-follower** vitals are not
available over v1r: the leader ignores the `recipient.din` of a per-device
query and echoes its own data, so iterating over followers would yield
duplicates rather than per-unit readings. Reading individual follower units
requires a separate WiFi-side TEDAPI connection to `192.168.91.1`, which this
library does not implement.

## Install

```bash
pip install aiopowerwall
```

## Quick start

```python
import asyncio
from pathlib import Path
from aiopowerwall import PowerwallClient, current_power

async def main() -> None:
    pem = Path("tedapi_rsa_private.pem").read_bytes()
    async with PowerwallClient(
        host="192.168.91.1",
        gateway_password="<gateway-password>",
        rsa_private_key_pem=pem,
    ) as pw:
        await pw.connect()
        print("DIN:", pw.din)
        print("Battery SoC:", await pw.get_battery_soe(), "%")
        print("Grid:", await pw.get_grid_status())
        status = await pw.get_status()
        print("Power:", current_power(status))

asyncio.run(main())
```

## No caching, no coalescing

**Every method on `PowerwallClient` issues a fresh request to the gateway.**
The library does not cache responses, deduplicate concurrent calls, or
batch reads — the only state it holds across calls is the v1r session
(login + DIN, established once by `connect()`).

This keeps the library predictable but means callers are responsible for
freshness control:

- If you need several values from the same payload, fetch the payload
  once and pass it to the pure helper functions (see below) rather than
  calling multiple `get_*` methods.
- If you poll on a fixed interval, do the polling in your own code; the
  client will not throttle you.
- If two coroutines call the same `get_*` method concurrently, the
  gateway sees two requests.

`connect()` is the single exception: it is idempotent and lock-protected,
so concurrent callers share one login.

## API surface

| Method | Returns |
| --- | --- |
| `connect()` | DIN string (idempotent; required before other calls) |
| `get_din()` | DIN string (calls `connect()` if needed) |
| `get_config()` | `config.json` (dict) |
| `get_status()` | DeviceController query (narrow) |
| `get_device_controller()` | DeviceController query (extended) |
| `get_components()` | Powerwall 3 component data |
| `get_firmware_details()` | Firmware details dict |
| `get_meters_aggregates()` | `/api/meters/aggregates` |
| `get_battery_soe()` | Battery SoC on the **user-facing** scale (Tesla app / Fleet API) |
| `get_battery_soe_raw()` | Battery SoC percentage (**raw** physical scale) |
| `get_grid_status()` | Grid status string |
| `get_backup_events()` | Active and scheduled backup events |

### Writes and commands

| Method | Effect |
| --- | --- |
| `write_config(updates)` | Patch `config.json` (dotted-path mapping) |
| `set_operation_mode(mode)` | Set `default_real_mode` (`self_consumption`/`autonomous`/`backup`) |
| `set_backup_reserve(percent)` | Set backup reserve on the **user-facing** scale (Tesla app / Fleet API) |
| `set_backup_reserve_raw(percent)` | Set backup reserve as the **raw** `config.json` value |
| `schedule_max_backup(seconds)` | Schedule a manual max-backup event |
| `cancel_max_backup()` | Cancel the active manual backup event |
| `set_island_mode(off_grid=, force=, …)` | Send `setIslandModeRequest` |
| `go_off_grid(force=True)` | Convenience wrapper around `set_island_mode` |
| `reconnect_grid()` | Convenience wrapper around `set_island_mode` |
| `trigger_islanding()` | Send `triggerIslandingBlackStartRequest` |
| `curtail(reserve_percent=100)` | Stop export via `backup` mode + reserve |
| `restore_from_curtailment()` | Restore mode + reserve captured by `curtail` |
| `curtailment_active` (property) | True between `curtail` and `restore_from_curtailment` |

> **Islanding caveat.** `set_island_mode` / `go_off_grid` post a local v1r
> command that the gateway acknowledges but, on some firmwares, does not
> actually act on — only the Fleet-API cloud relay path is known to
> operate the contactor. Verify with `get_status()`
> (`islanding.contactorClosed`) before relying on it. `trigger_islanding`
> issues the explicit black-start command if the mode-only request is a
> no-op on your gateway.

> **Backup-reserve scaling.** The gateway stores the reserve on a *raw*
> scale that differs from what the Tesla app and Fleet API show: the bottom
> 5% is an inaccessible buffer, so `raw = scaled * 0.95 + 5` (e.g. app-20%
> is raw-24%, app-0% is raw-5%). `set_backup_reserve` takes the
> **user-facing** value and applies that conversion for you;
> `set_backup_reserve_raw` writes the raw value verbatim. Use the
> `scaled_to_raw_reserve` / `raw_to_scaled_reserve` helpers to convert
> explicitly.

> **SoC scaling.** `battery_level(status)` returns the **user-facing** SoC
> the Tesla app and Fleet API (`live_status.percentage_charged`) show. The
> gateway reports SoC locally on a *raw* physical scale that includes the
> bottom-5% buffer and so reads higher; `battery_level_raw(status)` and the
> `/api/system_status/soe` reader `get_battery_soe_raw()` expose that raw
> value. The transform is identical to reserve: `scaled = (raw - 5) / 0.95`
> (verified on PW3: local raw 52.78% == Fleet 50.29%). Use the
> `scaled_to_raw_soc` / `raw_to_scaled_soc` helpers to convert explicitly.

### Pure helpers

These operate on an already-fetched status payload — fetch once with
`get_status()`, then call as many helpers as you need.

| Function | Returns |
| --- | --- |
| `battery_level(status)` | SoC from status on the **user-facing** scale (Tesla app / Fleet API) |
| `battery_level_raw(status)` | SoC from status on the **raw** physical scale |
| `current_power(status)` | `{location: realPowerW}` map |
| `backup_time_remaining(status)` | Hours of backup at current load |
| `scaled_to_raw_reserve(percent)` | User-facing reserve % → raw config value |
| `raw_to_scaled_reserve(percent)` | Raw config value → user-facing reserve % |
| `scaled_to_raw_soc(percent)` | User-facing SoC % → raw value |
| `raw_to_scaled_soc(percent)` | Raw SoC value → user-facing SoC % |

## EnergySite-compatible adapter

`PowerwallEnergySite` wraps a `PowerwallClient` to present the same surface as
the Tesla Fleet API `EnergySite` **by convention (duck typing)** — matching
method names, signatures, and `dict[str, Any]` return shapes without importing
or depending on `tesla_fleet_api`. This lets a primary/secondary energy router
use the local LAN path as primary and a cloud `EnergySite` as fallback.

```python
from aiopowerwall import PowerwallClient, PowerwallEnergySite

site = PowerwallEnergySite(pw)  # wraps an existing client
await site.connect_if_needed()  # router health signal → PowerwallClient.connect
await site.operation("autonomous")
await site.backup(20)           # user-facing reserve percent
status = await site.live_status()
```

Conventions:

- **Command return shape.** Implemented commands return the cloud energy
  command envelope `{"response": {"code": 201, "message": "", "result": True}}`.
  Data reads (`get_backup_events`, `live_status`) wrap their payload under
  `response`.
- **Implemented locally:** `operation`, `backup`, `set_island_mode`,
  `go_off_grid`, `reconnect_grid`, `schedule_backup_event`,
  `cancel_backup_event`, `get_backup_events`, and `live_status`. Use the
  `ISLAND_MODE_OFF_GRID` (6) / `ISLAND_MODE_ON_GRID` (1) constants with
  `set_island_mode`.
- **`schedule_backup_event`** accepts `start_time`/`priority` for signature
  parity but does **not** honour them — the local event always starts now at
  max priority.
- **`live_status`** is best-effort from meters aggregates, the gateway status
  query, and grid status. `percentage_charged`, `energy_left`, and
  `total_pack_energy` all come from one `get_status()` read — the user-facing
  SoC via `battery_level()`, and the Wh figures straight from
  `control.systemStatus`. Cloud keys with no local v1r equivalent
  (`backup_capable`, `grid_services_*`, `storm_mode_active`, `timestamp`,
  `wall_connectors`) are returned as `None` rather than guessed.
- **`connect_if_needed`** is an extra (not part of the cloud `EnergySite`
  surface): it delegates to `PowerwallClient.connect` and serves as the
  router's health signal.
- **`site_info` is intentionally absent** so the router falls through to the
  cloud for it. Every other command with no faithful local mapping yet
  (`storm_mode`, `grid_import_export`, `time_of_use_settings`, the history
  reads, the gRPC device commands, …) is scaffolded to raise
  `NotImplementedError`, so a per-command-failover router cleanly falls back to
  the cloud until the local path lands.

## Exceptions

All errors are subclasses of `PowerwallError`:

- `PowerwallConnectionError` — transport failure / timeout
- `PowerwallAuthenticationError` — bad password or unregistered RSA key
- `PowerwallRateLimitError` — gateway returned 429/503
- `PowerwallFaultError` — signed-message fault (key inactive, expired, etc.)
- `PowerwallProtocolError` — malformed response

## Acknowledgements

This project builds on the protocol research and reference implementation in
[`pypowerwall`](https://github.com/jasonacox/pypowerwall) by
[Jason Cox](https://github.com/jasonacox), distributed under the
[MIT License](https://github.com/jasonacox/pypowerwall/blob/main/LICENSE).
Huge thanks to Jason and the pypowerwall contributors for reverse-engineering
and documenting the TEDAPI protocol.

## License

MIT (see `LICENSE`). Original pypowerwall copyright and license notice are
retained in `LICENSE`.
