Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

45 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

pip install aiopowerwall

Quick start

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="<full gateway/WiFi 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())

gateway_password is the full gateway/WiFi password - the same value used to join the Powerwall's own AP. The gateway's local login only accepts the last 5 characters of it; aiopowerwall derives that truncation for you, so pass the full password.

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
list_authorized_clients() Registered client keys, roles, and states

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_tou_mode(mode) Set time-of-use optimization mode (strategy.TOU_mode, local-only)
set_grid_import_export(…) Set export rule and/or grid-charging policy in one write
set_export_rule(rule) Set grid-export rule (battery_ok/pv_only/never)
set_on_grid_solar_curtailment(enabled) Enable/disable on-grid solar curtailment
set_grid_charging(enabled) Allow/disallow charging the battery from the grid
set_import_limit(kilowatts) Set max grid import power (kW)
set_export_limit(kilowatts) Set max grid export power (kW)
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
remove_authorized_client(public_key) Un-pair a client key, revoking its access

Removing a client key. remove_authorized_client takes either raw DER bytes or the base64 string exactly as list_authorized_clients reports it, so a record can be round-tripped straight out of that listing. Note the asymmetry: adding a key needs a physical presence proof on the gateway, while removing one needs only an authenticated v1r session — including for a VERIFIED record. Removing the key you are signing with will lock this client out. The gateway's acknowledgement carries no fields, so call list_authorized_clients afterwards if you need positive confirmation.

Islanding. set_island_mode / go_off_grid / reconnect_grid post a local v1r command that operates the grid contactor. Verified on a Powerwall 3: go_off_grid() opened the contactor (islanding.contactorClosedfalse) and reconnect_grid() closed it again (→ true). The PowerSync project has reported firmwares that acknowledge the command without actuating, so verify 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.

Storm mode is not locally settable. Storm Watch is a Tesla-cloud feature with no local representation: storm_mode_enabled is absent from the gateway's config.json, and a local v1r write_config of that key is silently dropped (the gateway acks the write but the key never persists). Verified bidirectionally on PW3 — a local write reaches neither the local config nor Fleet, and toggling the setting on Fleet leaves zero local trace. There is deliberately no set_storm_mode method; toggle it through the Fleet API (storm_mode(enabled)) instead and read the setting back from site_info.storm_mode_enabled. (live_status.storm_mode_active is a different field — it reports only whether a storm is currently being responded to, not whether the feature is enabled.)

Export rule. set_export_rule(rule) sets site_info.customer_preferred_export_rulebattery_ok (export solar and battery), pv_only (export solar only) or never (no export). It is a plain string with no scaling, and net_meter_mode is a separate key that is deliberately left untouched (verified independent on PW3: writing the export rule never moves net_meter_mode). set_export_rule and set_grid_charging are single-purpose wrappers over set_grid_import_export, which writes both settings in one atomic read-modify-write when you pass both.

On-grid solar curtailment. set_on_grid_solar_curtailment(enabled) sets site_info.on_grid_solar_curtailment_enabled (boolean, no scaling). The gateway only stores the key while enabled: after enabling, get_config shows the key true; after disabling, the key is absent (the gateway drops it rather than storing false) — treat a missing key as disabled.

Grid charging. set_grid_charging(enabled) controls whether the battery may charge from the grid. The gateway stores the inverse site_info.disallow_charge_from_grid_with_solar_installed flag: enabling grid charging removes the key (absent = allowed, the default), disabling it sets the key true. Treat a missing key as "grid charging allowed".

Time-of-use mode (local-only, unvalidated). set_tou_mode(mode) writes strategy.TOU_mode, which controls how the gateway optimizes battery dispatch against a TOU tariff. It is not exposed by the Tesla Fleet API, so a local write is the only way to change it. The gateway does not validate the value (verified on PW3: an arbitrary string persists verbatim), so this is a deliberate pass-through — "economic" is the only value confirmed in use. The TOU tariff schedule itself is not settable here: it lives in the Tesla cloud (tariff_content_v2, managed via the Fleet API or an aggregator) and never appears in the local config.json.

Site import/export limits. set_import_limit(kw) and set_export_limit(kw) cap grid power in kilowatts, matching the Tesla app (the Tesla One installer app shows the same figure in watts, ×1000 — no scaling in the config). They map to the site-meter power bounds: max_site_meter_power_ac (import, positive) and min_site_meter_power_ac (export, stored negative — pass a positive magnitude). Fractional kW are accepted (verified on PW3: export 2.5 persisted verbatim). Mapping confirmed on hardware — setting the import limit to 12 showed as the import limit in the app.

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.

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, list_authorized_clients) wrap their payload under response.
  • Implemented locally: operation, backup, grid_import_export, set_island_mode, go_off_grid, reconnect_grid, schedule_backup_event, cancel_backup_event, get_backup_events, live_status, and list_authorized_clients. Use the ISLAND_MODE_OFF_GRID (6) / ISLAND_MODE_ON_GRID (1) constants with set_island_mode.
  • list_authorized_clients and remove_authorized_client both run over the local AuthorizationMessages v1r command — no cloud round-trip. add_authorized_client is not wired up locally and still falls back to the cloud (registration also needs a physical presence proof, which the local path cannot provide).
  • 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, 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 by Jason Cox, distributed under the MIT 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.

About

An implementation of pypowerwall written with aiohttp for Home Assistant

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages