Metadata-Version: 2.5
Name: pyzonneplan
Version: 0.3.1
Summary: Asynchronous Python client for the Zonneplan API
Project-URL: Homepage, https://github.com/erwindouna/pyzonneplan
Project-URL: Repository, https://github.com/erwindouna/pyzonneplan
Project-URL: Documentation, https://erwindouna.github.io/pyzonneplan/
Project-URL: Bug Tracker, https://github.com/erwindouna/pyzonneplan/issues
Project-URL: Changelog, https://github.com/erwindouna/pyzonneplan/releases
Author-email: Erwin Douna <e.douna@gmail.com>
Maintainer-email: Erwin Douna <e.douna@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: api,async,battery,charge-point,client,pv,solar,zonneplan
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Natural Language :: English
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: <4,>=3.12
Requires-Dist: aiohttp>=3.0.0
Requires-Dist: mashumaro<4,>=3.17
Requires-Dist: orjson<4,>=3.10.16
Requires-Dist: tenacity>=9.0.0
Requires-Dist: yarl>=1.6.0
Description-Content-Type: text/markdown

# pyzonneplan

[![GitHub Release][releases-shield]][releases]
[![Python Versions][python-versions-shield]][pypi]
![Project Stage][project-stage-shield]
![Project Maintenance][maintenance-shield]
[![License][license-shield]](LICENSE)

[![GitHub Activity][commits-shield]][commits-url]
[![PyPI Downloads][downloads-shield]][downloads-url]
[![GitHub Last Commit][last-commit-shield]][commits-url]
[![Open in Dev Containers][devcontainer-shield]][devcontainer]

[![Build Status][build-shield]][build-url]
[![Typing Status][typing-shield]][typing-url]
[![Code Coverage][codecov-shield]][codecov-url]

Asynchronous Python client for the Zonneplan API.

## About

pyzonneplan is an async client for the [Zonneplan](https://www.zonneplan.nl/) API, focused on:

- Dynamic electricity and gas prices
- Electricity and gas consumption, also without a P1 meter
- Solar (PV) installation data
- Home battery monitoring and control modes
- EV charge point sessions and schedules

The library is under active development and endpoint coverage will keep expanding.

## Installation

```bash
pip install pyzonneplan
```

## Usage

Zonneplan authenticates with a one-time password (OTP) mailed to the account address instead of a password.
Log in once, then store the token and pass it back in later:

```python
import asyncio
import json
from datetime import date
from pathlib import Path

from pyzonneplan import Token, Zonneplan

TOKEN_FILE = Path("token.json")


async def main() -> None:
    """Log in (or restore the saved token) and read some data."""
    token = Token.from_dict(json.loads(TOKEN_FILE.read_text())) if TOKEN_FILE.exists() else None

    async with Zonneplan(email="you@example.com", token=token) as client:
        if token is None:
            challenge = await client.async_request_otp(source_name="pyzonneplan")
            await client.async_submit_otp(challenge, input("One-time password: "))

        account = await client.async_get_account()
        for connection in account.connections:
            print(connection.market_segment, connection.uuid)

        electricity = next(c for c in account.connections if c.market_segment == "electricity")
        summary = await client.async_get_summary(electricity.uuid)
        print("Tariff group now:", summary.usage.type)

        chart = await client.async_get_electricity_chart(electricity.uuid, date.today())
        if chart.group is not None and chart.group.has_data:
            print("Used today:", chart.group.delivered_kwh, "kWh")

        # The client refreshes the token when it nears expiry, which rotates it: save the latest one.
        if client.token is not None:
            TOKEN_FILE.write_text(json.dumps(client.token.as_dict()))


if __name__ == "__main__":
    asyncio.run(main())
```

`async_logout()` ends the session on the API and forgets the token, e.g. when the token isn't needed anymore.

### Available data

| Method | Returns |
|---|---|
| `async_get_account()` | The account, its addresses, connections and contracts |
| `async_get_consumer_prices(chart, *, connection_uuid)` | Electricity (hourly or quarter-hourly) or daily gas prices, with `price_at()` and cheapest/most expensive helpers; with `connection_uuid` the prices of that connection's contract |
| `async_get_summary(connection_uuid)` | Live usage (P1 only), the current tariff group and a price forecast of about two days |
| `async_get_electricity_chart(connection_uuid, day, interval)` | Electricity used and returned per hour, day or month |
| `async_get_gas_chart(connection_uuid, day, interval)` | Gas used per hour, day or month |
| `async_get_electricity_consumption_insight(connection_uuid, start, end)` / `async_get_gas_consumption_insight(...)` | Energy used (and electricity returned) per month between two dates, with the average price per kWh or m³ |
| `async_get_electricity_delivered(connection_uuid)` / `async_get_gas(connection_uuid)` | P1 totals and live readings (`meters`), or `None` without a P1 meter |
| `async_get_electricity(connection_uuid)` | The same electricity data from the endpoint the current app uses, or `None` without a P1 meter |
| `async_get_p1_installation(contract_uuid)` | The P1 reader's meter identifiers, DSMR version and main fuse, and whether it is offline, or `None` without one |
| `async_get_electricity_home_consumption(connection_uuid)` | The home's electricity consumption, in the shape of `async_get_electricity_delivered`, or `None` without a P1 meter |
| `async_get_recent_power(connection_uuid, duration)` | The net power the P1 meter measured over the last 12, 24 or 48 hours (`RecentPowerDuration`); empty without a P1 meter |
| `async_get_pv_installation(connection_uuid)` | Every solar inverter on the connection and today's yield, or `None` without solar panels |
| `async_get_battery(connection_uuid, contract_uuid)` | Home battery state, results and modes |
| `async_get_battery_status(contract_uuid)` | A lighter home battery status: state of charge, power, state, control mode and whether its modem is online |
| `async_get_battery_chart(contract_uuid, day, interval)` | Home battery results per day or month |
| `async_get_battery_activity_chart(contract_uuid)` | A home battery's recent state of charge and what steered it; with day-ahead arbitrage also the planned charging and the market prices |
| `async_get_battery_asset_costs_chart(contract_uuid, start, end, interval)` / `async_get_battery_cumulative_asset_costs_chart(contract_uuid)` | Home battery costs (negative = earned, see `earnings_euro`) per day or month, or as a running total, as the app's history screen shows them |
| `async_get_battery_self_sufficiency_chart(connection_uuid, year)` / `async_get_battery_self_sufficiency_stats(connection_uuid, year)` | How much solar power the home used itself and how much consumption it saved, per month of a year and on average |
| `async_get_battery_control_mode(contract_uuid)` / `async_get_battery_home_optimization(contract_uuid)` | The battery's control mode, and the charge and discharge power for home optimization with the range each accepts |
| `async_get_battery_day_ahead_arbitrage(contract_uuid)` | The price spread (EUR) and market time resolution day-ahead arbitrage trades with |
| `async_get_charge_point(connection_uuid, contract_uuid)` | Charge point state, schedules and vehicles |
| `async_get_charge_point_control_mode(contract_uuid)` | Which `ChargePointMode`s the charge point supports and which are enabled |
| `async_get_charge_point_charge_on_solar(contract_uuid)` | The charge on solar settings: surplus threshold (W), minimum charge time and allowed local hours |
| `async_set_locale(locale)` | Sets the language of the API's texts, e.g. `nl-NL` |
| `async_get_app_settings()` / `async_set_price_resolution(resolution)` | The settings the Zonneplan app syncs, and sets the `PriceResolution` the app shows prices in |

The contract UUIDs come from the account, e.g. `connection.contracts_of_type(ContractType.HOME_BATTERY)`
(constants and enums such as `ContractType`, `ChartInterval`, `BatteryMode`, `BatteryState`, `TariffGroup` and `ChargePointStatus` live in `pyzonneplan.const`; an enum field is `None` when the API sends a value the library doesn't know yet).
For a contract the account doesn't have, the battery and charge point methods raise `ZonneplanNotFoundError`.
Fields keep the API's raw units (1e-7 EUR, Wh, dm³, permille); properties such as `delivered_kwh`,
`electricity_price_euro` and `state_of_charge_percent` convert them.

Usage without a P1 meter arrives a day or more late from the grid operator: check `has_data` before trusting zeros,
and expect the last hours of a day to be revised when the next day arrives.

The PV, battery and charge point models follow the responses other projects have seen, but haven't been tested
against live hardware yet. Reports and captured (anonymised) responses are welcome.

### Actions

| Method | Does |
|---|---|
| `async_enable_battery_self_consumption(connection_uuid, contract_uuid)` / `async_disable_...` | Turns self consumption mode on or off |
| `async_enable_battery_home_optimization(connection_uuid, contract_uuid, *, max_charge_power_w, max_discharge_power_w)` / `async_disable_...` | Turns home optimization mode on (with new power limits, or the current ones) or off |
| `async_set_battery_backup_reserve(connection_uuid, contract_uuid, reserved_wh)` | Reserves energy for backup power |
| `async_enable_battery_day_ahead_arbitrage(contract_uuid, *, price_spread, market_time_resolution)` | Turns day-ahead arbitrage on: the battery trades when day-ahead prices differ at least `price_spread` (EUR) |
| `async_set_battery_control_mode(connection_uuid, contract_uuid, mode)` | Switches to a `BatteryMode`, turning the other modes off the way the app does |
| `async_start_charge_point_boost(connection_uuid, contract_uuid)` / `async_stop_charge_point(...)` | Starts charging now at full power, or stops charging |
| `async_stop_charge_point_boost(connection_uuid, contract_uuid)` | Stops a boost |
| `async_enable_charge_point_plug_and_charge(connection_uuid, contract_uuid)` / `async_disable_...` | Turns charging at full power on plug-in on or off |
| `async_enable_charge_point_always_flex(connection_uuid, contract_uuid)` / `async_disable_...` | Turns charging automatically on cheap prices on or off |
| `async_set_charge_point_start_mode(connection_uuid, contract_uuid, mode)` | Switches to a `ChargePointStartMode`, sending the action the app sends from the current mode |
| `async_resume_charge_point_auto_charging(connection_uuid, contract_uuid)` | Lets the charge point charge on cheap prices again after a manual stop |
| `async_start_charge_point_dynamic_session(connection_uuid, contract_uuid, end, *, kilometers or percentage, vehicle_uuid)` | Charges an amount by `end` at the cheapest prices |
| `async_set_charge_point_schedule(connection_uuid, contract_uuid, schedule, *, vehicle_uuid)` | Replaces the planned charging with `ChargeSchedule` windows; ended windows are dropped |
| `async_reset_charge_point_schedule(connection_uuid, contract_uuid)` | Clears the planned charging |
| `async_enable_charge_point_charge_on_solar(contract_uuid, *, solar_surplus_threshold_w, min_charge_duration, allowed_window_start, allowed_window_end)` / `async_disable_...` | Turns charging on solar surplus on (omitted settings keep their current value) or off |

A battery with both modes off trades on the dynamic prices (`BatteryMode.DYNAMIC_CHARGING`). The API doesn't
switch the other mode off for you; `async_set_battery_control_mode()` does, like the Zonneplan app. Day-ahead
arbitrage has no disable call: any other battery action except the backup reserve ends it. The battery
and charge point confirm a change asynchronously; until then their state reports `processing`.
Actions are sent once and never retried.

### Errors

Every error derives from `ZonneplanError`:

- `ZonneplanAuthenticationError`: the token is invalid or expired (log in again); `ZonneplanInvalidOtpError` when the OTP is rejected.
- `ZonneplanRateLimitError`: HTTP 429; `retry_after` holds the seconds to wait. It isn't retried.
- `ZonneplanRequestError`: the API rejected the request (HTTP 4xx), e.g. an unknown chart interval;
  `ZonneplanNotFoundError` for a 404, e.g. a device the account doesn't have. Not retried.
- `ZonneplanResponseError`: the response isn't JSON or doesn't have the expected shape.
- `ZonneplanConnectionError` / `ZonneplanTimeoutError`: a network error, a timeout or an HTTP 5xx.
  GET requests are retried with backoff first (`max_retries`, default 3); actions are never retried.

More examples can be found in the [examples](examples) directory.

## Documentation

Project documentation and API reference: https://erwindouna.github.io/pyzonneplan/

## Contributing

Contributions are welcome. Please open an issue or pull request.

For local development:

```bash
uv sync --all-groups && uv run prek install
```

Run checks:

```bash
uv run prek run --all-files
```

Run tests:

```bash
uv run pytest
```

## License

MIT License

Copyright (c) 2026 Erwin Douna

<!-- MARKDOWN LINKS & IMAGES -->

[build-shield]: https://github.com/erwindouna/pyzonneplan/actions/workflows/tests.yaml/badge.svg
[build-url]: https://github.com/erwindouna/pyzonneplan/actions/workflows/tests.yaml
[codecov-shield]: https://codecov.io/gh/erwindouna/pyzonneplan/branch/main/graph/badge.svg
[codecov-url]: https://codecov.io/gh/erwindouna/pyzonneplan
[commits-shield]: https://img.shields.io/github/commit-activity/y/erwindouna/pyzonneplan.svg
[commits-url]: https://github.com/erwindouna/pyzonneplan/commits/main
[devcontainer-shield]: https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode
[devcontainer]: https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/erwindouna/pyzonneplan
[downloads-shield]: https://img.shields.io/pypi/dm/pyzonneplan
[downloads-url]: https://pypistats.org/packages/pyzonneplan
[last-commit-shield]: https://img.shields.io/github/last-commit/erwindouna/pyzonneplan.svg
[license-shield]: https://img.shields.io/github/license/erwindouna/pyzonneplan.svg
[project-stage-shield]: https://img.shields.io/badge/project%20stage-experimental-yellow.svg
[maintenance-shield]: https://img.shields.io/maintenance/yes/2026.svg
[pypi]: https://pypi.org/project/pyzonneplan/
[python-versions-shield]: https://img.shields.io/pypi/pyversions/pyzonneplan
[releases-shield]: https://img.shields.io/github/release/erwindouna/pyzonneplan.svg
[releases]: https://github.com/erwindouna/pyzonneplan/releases
[typing-shield]: https://github.com/erwindouna/pyzonneplan/actions/workflows/typing.yaml/badge.svg
[typing-url]: https://github.com/erwindouna/pyzonneplan/actions/workflows/typing.yaml
