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
8 changes: 7 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ discovery and broker security are documented in [MQTT.md](MQTT.md).
| `MQTT_HA_PREFIX` | `homeassistant` | Home Assistant discovery prefix. |
| `MQTT_CLIENT_ID` | `pypowerwall-server` | MQTT client id. |
| `MQTT_KEEPALIVE` | `60` | Keepalive in seconds. |
| `MQTT_CONTROLS` | `0` | Bitmask of MQTT controls: `1` reserve (and reserve hold), `2` mode, `4` grid charging, `8` grid export, `16` islanding; `31` = all. Anything other than an integer 0-31 means `0` (monitoring only). Controls also need `PW_CONTROL_SECRET`, `MQTT_USERNAME` and `MQTT_PASSWORD`. **Read the warning in [MQTT.md](MQTT.md#control-command-topics-opt-in-mqtt_controls) first.** |
| `MQTT_CONTROLS` | `0` | Bitmask of MQTT controls: `1` reserve (and reserve hold), `2` mode, `4` grid charging, `8` grid export, `16` islanding, `32` battery dispatch/resume; `63` = all. Existing `15` and `31` masks do not enable dispatch. Anything other than an integer 0-63 means `0` (monitoring only). Controls also need `PW_CONTROL_SECRET`, `MQTT_USERNAME` and `MQTT_PASSWORD`. **Read the warning in [MQTT.md](MQTT.md#control-command-topics-opt-in-mqtt_controls) first.** |

### Time-series and history

Expand Down Expand Up @@ -562,6 +562,12 @@ Without it, `POST /control/dispatch` returns 403 (forms: the proxy's
Console hides the panel. `POST /control/resume` works either way, so turning
the opt-in off never strands a running dispatch.

MQTT clients can use the same local operations with `MQTT_CONTROLS` bit `32`,
which also needs `PW_BATTERY_DISPATCH=yes`. Home Assistant discovers prepared
power/duration inputs and Start/Resume buttons. See [MQTT battery
dispatch](MQTT.md#battery-dispatch-bit-32) for command topics, atomic payloads
and the required acknowledgement of dispatch risks.

The Console's **Powerwall Control → Battery Dispatch** panel provides charge /
discharge selection, power in watts, a duration of up to 4 hours, **Start
dispatch** and **Resume normal operation**. It appears when dispatch is opted
Expand Down
89 changes: 87 additions & 2 deletions MQTT.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ All use `MQTT_` prefix (no `PW_` prefix — MQTT is not a Powerwall concept).
| `MQTT_HA_PREFIX` | `homeassistant` | HA discovery topic prefix |
| `MQTT_CLIENT_ID` | `pypowerwall-server` | MQTT client identifier |
| `MQTT_KEEPALIVE` | `60` | Broker keepalive interval (seconds) |
| `MQTT_CONTROLS` | `0` | Opt-in bitmask for Home Assistant controls (needs `PW_CONTROL_SECRET` and `MQTT_USERNAME`/`MQTT_PASSWORD`): `1` reserve (incl. `reserve_hold`), `2` mode, `4` grid_charging, `8` grid_export, `16` islanding. `15` = all but islanding, `31` = all, `0` = monitoring only. Any other value is logged as an error and treated as `0`. **Use at your own risk** (see the warning under *Control command topics*) |
| `MQTT_CONTROLS` | `0` | Opt-in bitmask for Home Assistant controls (needs `PW_CONTROL_SECRET` and `MQTT_USERNAME`/`MQTT_PASSWORD`): `1` reserve (incl. `reserve_hold`), `2` mode, `4` grid_charging, `8` grid_export, `16` islanding, `32` battery dispatch/resume. `15` = value controls, `31` = value controls + islanding, `63` = all, `0` = monitoring only. Existing `15`/`31` masks do not enable dispatch. Anything other than an integer 0-63 is logged as an error and treated as `0`. **Use at your own risk** (see the warning under *Control command topics*) |

Add to `app/config.py` Settings class:

Expand Down Expand Up @@ -192,6 +192,10 @@ is offline, and topics from before a server restart aren't tracked.
| `{prefix}/{gw}/control/grid_charging/set` | `4` | `{"value": true}` | `true`, `false` (JSON booleans) |
| `{prefix}/{gw}/control/grid_export/set` | `8` | `{"value": "battery_ok"}` | `battery_ok`, `pv_only`, `never` |
| `{prefix}/{gw}/control/islanding/set` | `16` | `{"action": "off_grid", "confirm": true}` | `off_grid`, `on_grid`, always with `"confirm": true` |
| `{prefix}/{gw}/control/dispatch_power/set` | `32` | `{"value": -500}` | Prepare finite signed watts; negative charges, positive discharges, zero idles; no battery write |
| `{prefix}/{gw}/control/dispatch_duration/set` | `32` | `{"value": 30}` | Prepare integer `1`-`86400` seconds; no battery write |
| `{prefix}/{gw}/control/dispatch/set` | `32` | `{"power_watts": -500, "duration_seconds": 30, "confirm": true}` | Start timed dispatch; omit both parameters to use prepared inputs; always `"confirm": true` |
| `{prefix}/{gw}/control/resume/set` | `32` | `{}` | Release dispatch early; empty JSON object only |

How commands run:

Expand All @@ -204,6 +208,81 @@ How commands run:
- **Releasing a hold.** `reserve_hold` sets the reserve once from a live poll. To release it, set the reserve back with `{prefix}/{gw}/control/reserve/set` or the Backup Reserve number. Tesla caps the backup reserve at 80%, so a hold above 80% charge sets 80%. Anyone with MQTT bit `1` gets the new **Hold Battery** button on upgrade.
- **Hybrid site.** If the Tesla account has more than one site, set `PW_SITEID` (or pick the site with `pypowerwall setup`); otherwise the hybrid cloud connection could point at another site, so MQTT controls don't use it.

#### Battery dispatch (bit `32`)

> **⚠️ USE AT YOUR OWN RISK. IT COULD VOID YOUR WARRANTY.**
> **Battery dispatch could void or limit your Tesla Powerwall warranty.** The
> Powerwall warranty quoted in [pypowerwall#450](https://github.com/jasonacox/pypowerwall/issues/450)
> gives unlimited cycles only for solar self-consumption, time-based control
> and backup; any other use is limited to 37.8 MWh of total energy throughput.
> Check your warranty first, and read the full warning in
> [API.md](API.md#timed-battery-dispatch-post-controldispatch-and-post-controlresume).

Set `MQTT_CONTROLS=32` for dispatch only, `47` for value controls plus dispatch,
or `63` for all controls. Existing masks such as `15` and `31` keep their previous
behavior. Broker credentials and `PW_CONTROL_SECRET` are still required; the
secret is never included in an MQTT payload. Dispatch also needs
`PW_BATTERY_DISPATCH=yes`, the same opt-in as HTTP dispatch. Without it, Start
and the prepared inputs are rejected and Home Assistant gets no dispatch
entities, but Resume still works, so turning the opt-in off can't strand a
running dispatch.

Dispatch and resume require the target gateway's **live local v1r connection**,
the same capability checked by HTTP dispatch. No cloud call or automatic retry
is made, including in hybrid mode or with `PW_LOCAL_FIRST=yes`. The shared write
lock orders MQTT and HTTP commands, including when a worker continues after a
timeout. The timeout is the greater of 10 seconds and `PW_TIMEOUT + 5` seconds.
Home Assistant removes the four entities when that connection loses v1r
capability, the bit is disabled or `PW_BATTERY_DISPATCH` is off, and restores
them when all three allow it again.

Home Assistant adds two prepared inputs and two buttons:

- **Dispatch Power (positive discharges)**: negative watts charge, positive watts
discharge, and zero requests idle. The number input offers -100000 to 100000 W
in 1 W steps; atomic MQTT requests also accept finite fractional watts, as the
HTTP API does. The gateway's hardware limits still apply.
- **Dispatch Duration**: 1 to 86400 whole seconds.
- **Start Battery Dispatch**: sends `{"confirm":true}` using those inputs.
- **Resume Battery Operation**: sends `{}` to release a dispatch early.

Input changes **never start or modify a battery operation**. Prepared values are
per gateway, default to -500 W for 30 seconds, survive broker reconnects and reset
on server restart. They are published at `{prefix}/{gw}/dispatch_power` and
`{prefix}/{gw}/dispatch_duration` with the configured retain/QoS settings. These
topics describe the **next prepared command**, not the active dispatch or actual
battery power. A direct request with its own parameters leaves prepared inputs
unchanged. A new dispatch replaces the gateway's existing power setpoint and
timer; expiry or Resume returns to the configured operating mode.

**Discharge can export despite Grid Export settings and drain below Backup
Reserve**, as observed on Powerwall 2. `confirm:true` acknowledges those risks;
Home Assistant's Start button includes it when pressed. There is no additional
confirmation dialog in Home Assistant. Review [battery dispatch behavior and
validation gaps](API.md#timed-battery-dispatch-post-controldispatch-and-post-controlresume)
before enabling it. An acknowledgement only confirms command acceptance: check
the Powerwall Power sensor or power flow for actual behavior.

For automations, send power and duration together so another client's prepared
inputs cannot change the command. Missing either field, booleans or strings in
numeric fields, non-finite power, and fractional/out-of-range durations are
rejected. Publish with `retain: false`:

```yaml
action:
- service: mqtt.publish
data:
topic: pypowerwall/default/control/dispatch/set
payload: '{"power_watts":-500,"duration_seconds":30,"confirm":true}'
qos: 1
retain: false
```

To release early, publish `{}` to `pypowerwall/default/control/resume/set` with
`retain: false`, or press Resume. Rejected/failed commands are logged at WARNING;
accepted operations are logged as **acknowledged**, without claiming measured
power or an active timer.

### Securing the broker (required for controls)

`PW_CONTROL_SECRET` is never sent over MQTT: anyone who can publish to `{prefix}/+/control/#` can operate every enabled control. The server checks that it connects with `MQTT_USERNAME`/`MQTT_PASSWORD`, but it can't see whether the broker rejects anonymous clients or limits who may publish there. The broker has to do both. A Mosquitto example:
Expand All @@ -229,7 +308,9 @@ topic write pypowerwall/+/control/+/set
topic readwrite homeassistant/#
```

Islanding (bit `16`) opens the grid contactor, so it needs its own bit: `MQTT_CONTROLS=15` enables everything else.
Islanding (bit `16`) opens the grid contactor and dispatch (bit `32`) overrides
battery operation, so each needs its own explicit bit. `MQTT_CONTROLS=15`
enables only reserve, mode and grid settings.

### Lifetime energy topics (Wh accumulators)

Expand Down Expand Up @@ -422,6 +503,10 @@ Controls (opt-in `MQTT_CONTROLS`, use at your own risk; only enabled bits the ga
| Grid Export Control | `8` | `select` | `battery_ok`, `pv_only`, `never` | `mdi:transmission-tower-export` |
| Go Off Grid | `16` | `button` | `{"action":"off_grid","confirm":true}`, v1r only | `mdi:transmission-tower-off` |
| Reconnect Grid | `16` | `button` | `{"action":"on_grid","confirm":true}`, v1r only | `mdi:transmission-tower` |
| Dispatch Power (positive discharges) | `32` | `number` | Prepared -100000 to 100000 W, step 1; live v1r only | `mdi:battery-charging` |
| Dispatch Duration | `32` | `number` | Prepared 1-86400 s, step 1; live v1r only | `mdi:timer-outline` |
| Start Battery Dispatch | `32` | `button` | `{"confirm":true}` uses prepared inputs; live v1r only | `mdi:battery-arrow-up` |
| Resume Battery Operation | `32` | `button` | `{}`; live v1r only | `mdi:battery-sync` |

Remote meter sensors (one set of five per CT, `entity_category: diagnostic`,
named e.g. `Remote Meter EM…B10BC CT0 (solar) Voltage`, unique ID
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,8 +690,24 @@ By default MQTT only publishes. Set `MQTT_CONTROLS` to let Home Assistant (or an
| `4` | Grid charging | switch |
| `8` | Grid export (`battery_ok`, `pv_only`, `never`) | select |
| `16` | Go off grid / reconnect (TEDAPI v1r only) | two buttons |

For example `MQTT_CONTROLS=15` enables everything except going off grid, which needs its own `16` (`31` = all). Controls also need `PW_CONTROL_SECRET`, `MQTT_USERNAME` and `MQTT_PASSWORD`; the log says which one is missing. Home Assistant only gets the controls your connection can run: reserve, mode and the grid settings need cloud, FleetAPI, hybrid cloud or TEDAPI v1r, and going off grid needs v1r.
| `32` | Timed battery dispatch / resume (TEDAPI v1r only; also needs `PW_BATTERY_DISPATCH=yes`). **Use at your own risk: it could void your warranty** | power and duration numbers, Start and Resume buttons |

For example `MQTT_CONTROLS=15` enables reserve, mode and the grid settings;
`31` also enables islanding. Battery dispatch needs its own `32` (`63` = all),
so existing masks do not enable it on upgrade. Controls also need
`PW_CONTROL_SECRET`, `MQTT_USERNAME` and `MQTT_PASSWORD`; the log says which one
is missing. Home Assistant only gets the controls your connection can run:
reserve, mode and the grid settings need cloud, FleetAPI, hybrid cloud or TEDAPI
v1r, and islanding and dispatch need v1r.

With bit `32`, set **Dispatch Power (positive discharges)** and **Dispatch
Duration**, then press **Start Battery Dispatch**. Changing either input only
prepares the next command. **Resume Battery Operation** releases it early.
Discharge can export despite Grid Export settings and drain below Backup
Reserve, and **battery dispatch could void or limit your Tesla Powerwall
warranty**; pressing Start acknowledges these risks. See
[MQTT battery dispatch](MQTT.md#battery-dispatch-bit-32) for details and atomic
commands for automations.

> **Secure your broker first.** The control secret is never sent over MQTT: anyone who can publish to `{MQTT_TOPIC_PREFIX}/+/control/#` (default prefix `pypowerwall`) on your broker can change these settings. Turn off anonymous access and limit who can publish to the control topics — see [Securing the broker](MQTT.md#securing-the-broker-required-for-controls) for a Mosquitto example. Topics, payloads and rules are in [MQTT.md](MQTT.md#control-command-topics-opt-in-mqtt_controls).

Expand Down
13 changes: 13 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## Version History

### [0.16.0] - Upcoming

**Added:**
- **MQTT battery dispatch and resume** with a separate `MQTT_CONTROLS` bit `32`.
Home Assistant gets prepared power/duration inputs and Start/Resume buttons,
and automations can send an atomic power/duration command. **Use at your own
risk: battery dispatch could void or limit your Tesla Powerwall warranty.**
Dispatch also needs `PW_BATTERY_DISPATCH=yes` (resume doesn't), requires
`confirm:true`, uses the gateway's live local v1r connection and shares the
HTTP write lock, including across timeouts. Existing masks are unchanged;
`63` enables all controls. Retained commands are ignored and cleared. Thanks
@longzheng, and @BJReplay for the early test run! (#184)

### [0.15.1] - 2026-11-09

pypowerwall 0.25.0 (a generator section in aggregates, and gateway uptime on
Expand Down
40 changes: 7 additions & 33 deletions app/api/legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@
"""
import asyncio
import logging
import math
import os
import time
from datetime import datetime, timedelta
Expand All @@ -71,7 +70,11 @@
from fastapi.responses import JSONResponse

from app.api.auth import bearer_token, token_matches, verify_control_token
from app.utils.control import is_error_result
from app.utils.control import (
dispatch_acknowledged,
is_error_result,
valid_dispatch_parameters,
)
from app.core.gateway_manager import (
IslandingCommandInProgressError,
IslandingCooldownError,
Expand Down Expand Up @@ -200,23 +203,6 @@ def powerwall_unit_labels(
return labels


def _dispatch_acknowledged(method: str, args: tuple, result: Any) -> bool:
"""True if result acknowledges this command: matching power and duration
for a dispatch, resume_battery_operation True for a resume."""
if not isinstance(result, dict) or is_error_result(result):
return False
if method == "dispatch_battery_power":
watts, seconds = result.get("power_watts"), result.get("duration_seconds")
# bool is an int in Python (False == 0, True == 1): not an ack
return (
not isinstance(watts, bool)
and not isinstance(seconds, bool)
and watts == args[0]
and seconds == args[1]
)
return result.get("resume_battery_operation") is True


@router.get("/control/status")
async def control_status() -> Dict[str, bool]:
"""Control availability for the Console (unauthenticated, booleans only).
Expand Down Expand Up @@ -653,19 +639,7 @@ async def _control_json(path: str, data: dict):
method = "resume_battery_operation"
if path == "dispatch":
watts, seconds = data.get("power_watts"), data.get("duration_seconds")
try:
valid_power = (
isinstance(watts, (int, float))
and not isinstance(watts, bool)
and math.isfinite(watts)
)
except OverflowError:
valid_power = False
if (
not valid_power
or type(seconds) is not int
or not 1 <= seconds <= 86400
):
if not valid_dispatch_parameters(watts, seconds):
raise HTTPException(
status_code=400,
detail="power_watts must be finite and duration_seconds "
Expand All @@ -684,7 +658,7 @@ async def _control_json(path: str, data: dict):
result = await gateway_manager.local_control(
gateway_id, method, *args, timeout=max(10.0, settings.timeout + 5.0)
)
if not _dispatch_acknowledged(method, args, result):
if not dispatch_acknowledged(method, args, result):
raise HTTPException(
status_code=503,
detail="Battery dispatch not confirmed by the gateway. Check "
Expand Down
20 changes: 14 additions & 6 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,18 +279,25 @@ def _scalar_kind(annotation):
MQTT_CONTROL_GRID_CHARGING = 4
MQTT_CONTROL_GRID_EXPORT = 8
MQTT_CONTROL_ISLANDING = 16
MQTT_CONTROLS_ALL = 31
MQTT_CONTROL_DISPATCH = 32
MQTT_CONTROLS_ALL = 63
MQTT_CONTROL_BITS = {
"reserve": MQTT_CONTROL_RESERVE,
"reserve_hold": MQTT_CONTROL_RESERVE,
"mode": MQTT_CONTROL_MODE,
"grid_charging": MQTT_CONTROL_GRID_CHARGING,
"grid_export": MQTT_CONTROL_GRID_EXPORT,
"islanding": MQTT_CONTROL_ISLANDING,
"dispatch": MQTT_CONTROL_DISPATCH,
"dispatch_power": MQTT_CONTROL_DISPATCH,
"dispatch_duration": MQTT_CONTROL_DISPATCH,
"resume": MQTT_CONTROL_DISPATCH,
}
# reserve_hold shares Bit 1; Bit 1 logs as "reserve".
# Each bit logs once, even when it enables several commands/entities.
_MQTT_CONTROL_NAMES = {
bit: name for name, bit in MQTT_CONTROL_BITS.items() if name != "reserve_hold"
bit: name
for name, bit in MQTT_CONTROL_BITS.items()
if name not in ("reserve_hold", "dispatch_power", "dispatch_duration", "resume")
}


Expand Down Expand Up @@ -489,8 +496,9 @@ class Settings(BaseSettings):
mqtt_controls: int = Field(
default=0, alias="MQTT_CONTROLS"
) # Bitmask opt-in for MQTT controls (0 = monitoring only; 1 reserve,
# 2 mode, 4 grid_charging, 8 grid_export, 16 islanding; e.g. 15 = all
# but islanding, 31 = all). Islanding needs its own explicit bit.
# 2 mode, 4 grid_charging, 8 grid_export, 16 islanding, 32 dispatch;
# 15 = value controls, 31 = value controls + islanding, 63 = all).
# Islanding and battery dispatch each need their own explicit bit.

@field_validator("*", mode="before")
@classmethod
Expand Down Expand Up @@ -538,7 +546,7 @@ def _lenient_bool_and_int(cls, v, info):
@field_validator("mqtt_controls", mode="before")
@classmethod
def _parse_control_bits(cls, v) -> int:
"""Fail closed: anything but an integer 0-31 means monitoring only.
"""Fail closed: anything but an integer 0-63 means monitoring only.

An invalid value must neither stop the server (monitoring keeps
running) nor enable controls the user didn't spell out.
Expand Down
Loading
Loading