An async Tesla Powerwall 3 client built on aiohttp, written for Home
Assistant and any other asyncio code.
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.
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.
pip install aiopowerwallimport 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.
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.
| 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 |
| 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_clienttakes either raw DER bytes or the base64 string exactly aslist_authorized_clientsreports 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 aVERIFIEDrecord. Removing the key you are signing with will lock this client out. The gateway's acknowledgement carries no fields, so calllist_authorized_clientsafterwards if you need positive confirmation.
Islanding.
set_island_mode/go_off_grid/reconnect_gridpost a local v1r command that operates the grid contactor. Verified on a Powerwall 3:go_off_grid()opened the contactor (islanding.contactorClosed→false) andreconnect_grid()closed it again (→true). The PowerSync project has reported firmwares that acknowledge the command without actuating, so verifyget_status().islanding.contactorClosedbefore relying on it.trigger_islandingissues 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_enabledis absent from the gateway'sconfig.json, and a local v1rwrite_configof 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 noset_storm_modemethod; toggle it through the Fleet API (storm_mode(enabled)) instead and read the setting back fromsite_info.storm_mode_enabled. (live_status.storm_mode_activeis 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)setssite_info.customer_preferred_export_rule—battery_ok(export solar and battery),pv_only(export solar only) ornever(no export). It is a plain string with no scaling, andnet_meter_modeis a separate key that is deliberately left untouched (verified independent on PW3: writing the export rule never movesnet_meter_mode).set_export_ruleandset_grid_chargingare single-purpose wrappers overset_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)setssite_info.on_grid_solar_curtailment_enabled(boolean, no scaling). The gateway only stores the key while enabled: after enabling,get_configshows the keytrue; after disabling, the key is absent (the gateway drops it rather than storingfalse) — 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 inversesite_info.disallow_charge_from_grid_with_solar_installedflag: enabling grid charging removes the key (absent = allowed, the default), disabling it sets the keytrue. Treat a missing key as "grid charging allowed".
Time-of-use mode (local-only, unvalidated).
set_tou_mode(mode)writesstrategy.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 localconfig.json.
Site import/export limits.
set_import_limit(kw)andset_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) andmin_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_reservetakes the user-facing value and applies that conversion for you;set_backup_reserve_rawwrites the raw value verbatim. Use thescaled_to_raw_reserve/raw_to_scaled_reservehelpers 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/soereaderget_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 thescaled_to_raw_soc/raw_to_scaled_sochelpers to convert explicitly.
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 % |
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 underresponse. - 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, andlist_authorized_clients. Use theISLAND_MODE_OFF_GRID(6) /ISLAND_MODE_ON_GRID(1) constants withset_island_mode. list_authorized_clientsandremove_authorized_clientboth run over the localAuthorizationMessagesv1r command — no cloud round-trip.add_authorized_clientis 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_eventacceptsstart_time/priorityfor signature parity but does not honour them — the local event always starts now at max priority.live_statusis best-effort from meters aggregates, the gateway status query, and grid status.percentage_charged,energy_left, andtotal_pack_energyall come from oneget_status()read — the user-facing SoC viabattery_level(), and the Wh figures straight fromcontrol.systemStatus. Cloud keys with no local v1r equivalent (backup_capable,grid_services_*,storm_mode_active,timestamp,wall_connectors) are returned asNonerather than guessed.connect_if_neededis an extra (not part of the cloudEnergySitesurface): it delegates toPowerwallClient.connectand serves as the router's health signal.site_infois 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 raiseNotImplementedError, so a per-command-failover router cleanly falls back to the cloud until the local path lands.
All errors are subclasses of PowerwallError:
PowerwallConnectionError— transport failure / timeoutPowerwallAuthenticationError— bad password or unregistered RSA keyPowerwallRateLimitError— gateway returned 429/503PowerwallFaultError— signed-message fault (key inactive, expired, etc.)PowerwallProtocolError— malformed response
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.
MIT (see LICENSE). Original pypowerwall copyright and license notice are
retained in LICENSE.