pyzonneplan 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyzonneplan/__init__.py +79 -0
- pyzonneplan/auth.py +85 -0
- pyzonneplan/const.py +46 -0
- pyzonneplan/exceptions.py +21 -0
- pyzonneplan/models/__init__.py +47 -0
- pyzonneplan/models/account.py +130 -0
- pyzonneplan/models/consumption.py +64 -0
- pyzonneplan/models/devices.py +224 -0
- pyzonneplan/models/prices.py +84 -0
- pyzonneplan/py.typed +0 -0
- pyzonneplan/pyzonneplan.py +312 -0
- pyzonneplan-0.1.0.dist-info/METADATA +152 -0
- pyzonneplan-0.1.0.dist-info/RECORD +15 -0
- pyzonneplan-0.1.0.dist-info/WHEEL +4 -0
- pyzonneplan-0.1.0.dist-info/licenses/LICENSE +21 -0
pyzonneplan/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Asynchronous Python client for the Zonneplan API."""
|
|
2
|
+
|
|
3
|
+
from .auth import OtpChallenge, Token, generate_pkce_pair
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
ZonneplanAuthenticationError,
|
|
6
|
+
ZonneplanConnectionError,
|
|
7
|
+
ZonneplanError,
|
|
8
|
+
ZonneplanInvalidOtpError,
|
|
9
|
+
ZonneplanTimeoutError,
|
|
10
|
+
)
|
|
11
|
+
from .models import (
|
|
12
|
+
Account,
|
|
13
|
+
Address,
|
|
14
|
+
AddressGroup,
|
|
15
|
+
Battery,
|
|
16
|
+
BatteryChart,
|
|
17
|
+
BatteryControlMode,
|
|
18
|
+
BatteryHomeOptimization,
|
|
19
|
+
ChargePoint,
|
|
20
|
+
ChargePointSession,
|
|
21
|
+
ChargeSchedule,
|
|
22
|
+
Connection,
|
|
23
|
+
ConsumerPrices,
|
|
24
|
+
Contract,
|
|
25
|
+
DynamicChargingConstraints,
|
|
26
|
+
ElectricityDelivered,
|
|
27
|
+
ElectricityMeasurementGroup,
|
|
28
|
+
Gas,
|
|
29
|
+
GasMeasurementGroup,
|
|
30
|
+
Money,
|
|
31
|
+
PriceChartData,
|
|
32
|
+
PricePoint,
|
|
33
|
+
PriceRange,
|
|
34
|
+
PriceSeries,
|
|
35
|
+
PvInverter,
|
|
36
|
+
PvTotals,
|
|
37
|
+
SustainabilityScore,
|
|
38
|
+
UserAccount,
|
|
39
|
+
)
|
|
40
|
+
from .pyzonneplan import Zonneplan
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Account",
|
|
44
|
+
"Address",
|
|
45
|
+
"AddressGroup",
|
|
46
|
+
"Battery",
|
|
47
|
+
"BatteryChart",
|
|
48
|
+
"BatteryControlMode",
|
|
49
|
+
"BatteryHomeOptimization",
|
|
50
|
+
"ChargePoint",
|
|
51
|
+
"ChargePointSession",
|
|
52
|
+
"ChargeSchedule",
|
|
53
|
+
"Connection",
|
|
54
|
+
"ConsumerPrices",
|
|
55
|
+
"Contract",
|
|
56
|
+
"DynamicChargingConstraints",
|
|
57
|
+
"ElectricityDelivered",
|
|
58
|
+
"ElectricityMeasurementGroup",
|
|
59
|
+
"Gas",
|
|
60
|
+
"GasMeasurementGroup",
|
|
61
|
+
"Money",
|
|
62
|
+
"OtpChallenge",
|
|
63
|
+
"PriceChartData",
|
|
64
|
+
"PricePoint",
|
|
65
|
+
"PriceRange",
|
|
66
|
+
"PriceSeries",
|
|
67
|
+
"PvInverter",
|
|
68
|
+
"PvTotals",
|
|
69
|
+
"SustainabilityScore",
|
|
70
|
+
"Token",
|
|
71
|
+
"UserAccount",
|
|
72
|
+
"Zonneplan",
|
|
73
|
+
"ZonneplanAuthenticationError",
|
|
74
|
+
"ZonneplanConnectionError",
|
|
75
|
+
"ZonneplanError",
|
|
76
|
+
"ZonneplanInvalidOtpError",
|
|
77
|
+
"ZonneplanTimeoutError",
|
|
78
|
+
"generate_pkce_pair",
|
|
79
|
+
]
|
pyzonneplan/auth.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Authentication value objects for the Zonneplan API.
|
|
2
|
+
|
|
3
|
+
The actual login and refresh calls live on :class:`pyzonneplan.Zonneplan`, so
|
|
4
|
+
they share its request handling (retries, timeouts, error mapping). This
|
|
5
|
+
module only holds the data carried through that flow:
|
|
6
|
+
|
|
7
|
+
1. POST /oauth/authorize-challenge {response_type, email, code_challenge, ...}
|
|
8
|
+
-> HTTP 403 with ``otp_required: true`` and an ``auth_session``.
|
|
9
|
+
The 403 is the success path, not an error.
|
|
10
|
+
2. POST /oauth/authorize-challenge {auth_session, otp}
|
|
11
|
+
-> ``authorization_code``
|
|
12
|
+
3. POST /oauth/token {grant_type: authorization_code, code, code_verifier}
|
|
13
|
+
-> access + refresh token
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import secrets
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import UTC, datetime, timedelta
|
|
21
|
+
from typing import Any, Self
|
|
22
|
+
|
|
23
|
+
# Refresh a little before the token actually expires.
|
|
24
|
+
_EXPIRY_MARGIN = timedelta(seconds=60)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
28
|
+
"""Return a fresh ``(code_verifier, code_challenge)`` pair."""
|
|
29
|
+
verifier = secrets.token_urlsafe(96)[:128]
|
|
30
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
31
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
32
|
+
return verifier, challenge
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class OtpChallenge:
|
|
37
|
+
"""State carried between requesting and submitting the one-time password."""
|
|
38
|
+
|
|
39
|
+
auth_session: str
|
|
40
|
+
code_verifier: str
|
|
41
|
+
email: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class Token:
|
|
46
|
+
"""An access token and its refresh token."""
|
|
47
|
+
|
|
48
|
+
access_token: str
|
|
49
|
+
refresh_token: str
|
|
50
|
+
expires_at: datetime
|
|
51
|
+
token_type: str = "Bearer" # noqa: S105 (a token type label, not a credential)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_expired(self) -> bool:
|
|
55
|
+
"""Return whether the access token needs refreshing."""
|
|
56
|
+
return datetime.now(UTC) >= self.expires_at - _EXPIRY_MARGIN
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_response(cls, payload: dict[str, Any]) -> Self:
|
|
60
|
+
"""Build a token from an /oauth/token response body."""
|
|
61
|
+
return cls(
|
|
62
|
+
access_token=payload["access_token"],
|
|
63
|
+
refresh_token=payload["refresh_token"],
|
|
64
|
+
expires_at=datetime.now(UTC) + timedelta(seconds=int(payload["expires_in"])),
|
|
65
|
+
token_type=payload.get("token_type", "Bearer"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def as_dict(self) -> dict[str, Any]:
|
|
69
|
+
"""Serialise for storage in a Home Assistant config entry."""
|
|
70
|
+
return {
|
|
71
|
+
"access_token": self.access_token,
|
|
72
|
+
"refresh_token": self.refresh_token,
|
|
73
|
+
"expires_at": self.expires_at.isoformat(),
|
|
74
|
+
"token_type": self.token_type,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict[str, Any]) -> Self:
|
|
79
|
+
"""Restore a token previously stored with :meth:`as_dict`."""
|
|
80
|
+
return cls(
|
|
81
|
+
access_token=data["access_token"],
|
|
82
|
+
refresh_token=data["refresh_token"],
|
|
83
|
+
expires_at=datetime.fromisoformat(data["expires_at"]),
|
|
84
|
+
token_type=data.get("token_type", "Bearer"),
|
|
85
|
+
)
|
pyzonneplan/const.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Constants for the Zonneplan API client."""
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
API_SCHEME: Final = "https"
|
|
7
|
+
API_URL: Final = "app-api.zonneplan.nl"
|
|
8
|
+
|
|
9
|
+
AUTHORIZE_CHALLENGE_PATH: Final = "oauth/authorize-challenge"
|
|
10
|
+
TOKEN_PATH: Final = "oauth/token" # noqa: S105 (a URL path, not a credential)
|
|
11
|
+
|
|
12
|
+
# Sent by the mobile app; the API rejects requests without them.
|
|
13
|
+
APP_VERSION: Final = "5.10.1"
|
|
14
|
+
APP_ENVIRONMENT: Final = "production"
|
|
15
|
+
|
|
16
|
+
# Every monetary "amount" in the API is expressed in 1e-7 EUR.
|
|
17
|
+
MONEY_FACTOR: Final = Decimal("0.0000001")
|
|
18
|
+
|
|
19
|
+
# Energy values are Wh, power values are W.
|
|
20
|
+
WH_TO_KWH: Final = Decimal("0.001")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ChartInterval:
|
|
24
|
+
"""Supported battery chart intervals."""
|
|
25
|
+
|
|
26
|
+
DAYS: Final = "days"
|
|
27
|
+
MONTHS: Final = "months"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PriceChart:
|
|
31
|
+
"""Supported consumer price charts."""
|
|
32
|
+
|
|
33
|
+
ELECTRICITY_HOURLY: Final = "electricity-hourly"
|
|
34
|
+
ELECTRICITY_QUARTER_HOURLY: Final = "electricity-quarter-hourly"
|
|
35
|
+
GAS_DAILY: Final = "gas-daily"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ContractType:
|
|
39
|
+
"""Contract types returned by /user-accounts/me."""
|
|
40
|
+
|
|
41
|
+
ELECTRICITY: Final = "electricity"
|
|
42
|
+
GAS: Final = "gas"
|
|
43
|
+
PV_INSTALLATION: Final = "pv_installation"
|
|
44
|
+
P1_INSTALLATION: Final = "p1_installation"
|
|
45
|
+
CHARGE_POINT: Final = "charge_point_installation"
|
|
46
|
+
HOME_BATTERY: Final = "home_battery_installation"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Asynchronous Python client for Zonneplan."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ZonneplanError(Exception):
|
|
5
|
+
"""Generic exception for Zonneplan errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ZonneplanConnectionError(ZonneplanError):
|
|
9
|
+
"""Exception raised for connection errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ZonneplanTimeoutError(ZonneplanError):
|
|
13
|
+
"""Exception raised for timeout errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ZonneplanAuthenticationError(ZonneplanError):
|
|
17
|
+
"""Exception raised for authentication errors."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ZonneplanInvalidOtpError(ZonneplanAuthenticationError):
|
|
21
|
+
"""Exception raised when the submitted one-time password is rejected."""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Typed models for the Zonneplan API."""
|
|
2
|
+
|
|
3
|
+
from .account import Account, Address, AddressGroup, Connection, Contract, UserAccount
|
|
4
|
+
from .consumption import ElectricityDelivered, ElectricityMeasurementGroup, Gas, GasMeasurementGroup
|
|
5
|
+
from .devices import (
|
|
6
|
+
Battery,
|
|
7
|
+
BatteryChart,
|
|
8
|
+
BatteryControlMode,
|
|
9
|
+
BatteryHomeOptimization,
|
|
10
|
+
ChargePoint,
|
|
11
|
+
ChargePointSession,
|
|
12
|
+
ChargeSchedule,
|
|
13
|
+
DynamicChargingConstraints,
|
|
14
|
+
PvInverter,
|
|
15
|
+
PvTotals,
|
|
16
|
+
)
|
|
17
|
+
from .prices import ConsumerPrices, Money, PriceChartData, PricePoint, PriceRange, PriceSeries, SustainabilityScore
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Account",
|
|
21
|
+
"Address",
|
|
22
|
+
"AddressGroup",
|
|
23
|
+
"Battery",
|
|
24
|
+
"BatteryChart",
|
|
25
|
+
"BatteryControlMode",
|
|
26
|
+
"BatteryHomeOptimization",
|
|
27
|
+
"ChargePoint",
|
|
28
|
+
"ChargePointSession",
|
|
29
|
+
"ChargeSchedule",
|
|
30
|
+
"Connection",
|
|
31
|
+
"ConsumerPrices",
|
|
32
|
+
"Contract",
|
|
33
|
+
"DynamicChargingConstraints",
|
|
34
|
+
"ElectricityDelivered",
|
|
35
|
+
"ElectricityMeasurementGroup",
|
|
36
|
+
"Gas",
|
|
37
|
+
"GasMeasurementGroup",
|
|
38
|
+
"Money",
|
|
39
|
+
"PriceChartData",
|
|
40
|
+
"PricePoint",
|
|
41
|
+
"PriceRange",
|
|
42
|
+
"PriceSeries",
|
|
43
|
+
"PvInverter",
|
|
44
|
+
"PvTotals",
|
|
45
|
+
"SustainabilityScore",
|
|
46
|
+
"UserAccount",
|
|
47
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Models for /user-accounts/me."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mashumaro.mixins.orjson import DataClassORJSONMixin
|
|
8
|
+
|
|
9
|
+
from pyzonneplan.const import ContractType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Contract(DataClassORJSONMixin):
|
|
14
|
+
"""A single product on a connection (PV, P1, battery, charge point, ...)."""
|
|
15
|
+
|
|
16
|
+
uuid: str
|
|
17
|
+
type: str
|
|
18
|
+
label: str | None = None
|
|
19
|
+
start_date: datetime | None = None
|
|
20
|
+
end_date: datetime | None = None
|
|
21
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_active(self) -> bool:
|
|
25
|
+
"""Return whether the contract has not ended yet."""
|
|
26
|
+
return self.end_date is None or self.end_date > datetime.now(self.end_date.tzinfo)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def model_name(self) -> str | None:
|
|
30
|
+
"""Return the hardware model, where the API exposes one."""
|
|
31
|
+
return self.meta.get("host_device_model_name") or self.meta.get("charge_point_model_name")
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def serial_number(self) -> str | None:
|
|
35
|
+
"""Return the hardware serial, where the API exposes one."""
|
|
36
|
+
return self.meta.get("serial_number") or self.meta.get("identifier")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Connection(DataClassORJSONMixin):
|
|
41
|
+
"""A grid connection and the contracts attached to it."""
|
|
42
|
+
|
|
43
|
+
uuid: str
|
|
44
|
+
ean: str | None = None
|
|
45
|
+
market_segment: str | None = None
|
|
46
|
+
contracts: list[Contract] = field(default_factory=list)
|
|
47
|
+
features: list[dict[str, Any]] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
def contracts_of_type(self, contract_type: str, *, active_only: bool = True) -> list[Contract]:
|
|
50
|
+
"""Return the contracts of a given type."""
|
|
51
|
+
return [contract for contract in self.contracts if contract.type == contract_type and (not active_only or contract.is_active)]
|
|
52
|
+
|
|
53
|
+
def has(self, contract_type: str) -> bool:
|
|
54
|
+
"""Return whether an active contract of this type exists."""
|
|
55
|
+
return bool(self.contracts_of_type(contract_type))
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def has_pv(self) -> bool:
|
|
59
|
+
"""Return whether a solar installation is present."""
|
|
60
|
+
return self.has(ContractType.PV_INSTALLATION)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def has_p1(self) -> bool:
|
|
64
|
+
"""Return whether a P1 reader is present."""
|
|
65
|
+
return self.has(ContractType.P1_INSTALLATION)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def has_battery(self) -> bool:
|
|
69
|
+
"""Return whether a home battery is present."""
|
|
70
|
+
return self.has(ContractType.HOME_BATTERY)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def has_charge_point(self) -> bool:
|
|
74
|
+
"""Return whether a charge point is present."""
|
|
75
|
+
return self.has(ContractType.CHARGE_POINT)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def has_gas_meter(self) -> bool:
|
|
79
|
+
"""Return whether any P1 contract has recently reported gas."""
|
|
80
|
+
return any(contract.meta.get("gas_last_measured_at") for contract in self.contracts_of_type(ContractType.P1_INSTALLATION))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Address(DataClassORJSONMixin):
|
|
85
|
+
"""Postal address of an address group."""
|
|
86
|
+
|
|
87
|
+
id: str | None = None
|
|
88
|
+
street: str | None = None
|
|
89
|
+
number: str | None = None
|
|
90
|
+
addition: str | None = None
|
|
91
|
+
zipcode: str | None = None
|
|
92
|
+
city: str | None = None
|
|
93
|
+
sunrise: datetime | None = None
|
|
94
|
+
sunset: datetime | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class AddressGroup(DataClassORJSONMixin):
|
|
99
|
+
"""One address, with one or more connections."""
|
|
100
|
+
|
|
101
|
+
uuid: str
|
|
102
|
+
connections: list[Connection] = field(default_factory=list)
|
|
103
|
+
address: Address | None = None
|
|
104
|
+
is_representative: bool = False
|
|
105
|
+
organization_uuid: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class UserAccount(DataClassORJSONMixin):
|
|
110
|
+
"""The authenticated Zonneplan account."""
|
|
111
|
+
|
|
112
|
+
uuid: str
|
|
113
|
+
email: str | None = None
|
|
114
|
+
first_name: str | None = None
|
|
115
|
+
full_name: str | None = None
|
|
116
|
+
initials: str | None = None
|
|
117
|
+
is_representative: bool = False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class Account(DataClassORJSONMixin):
|
|
122
|
+
"""Top level response of /user-accounts/me."""
|
|
123
|
+
|
|
124
|
+
user_account: UserAccount
|
|
125
|
+
address_groups: list[AddressGroup] = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def connections(self) -> list[Connection]:
|
|
129
|
+
"""Return every connection across every address group."""
|
|
130
|
+
return [connection for address_group in self.address_groups for connection in address_group.connections]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Models for the P1 electricity and gas consumption endpoints.
|
|
2
|
+
|
|
3
|
+
Shape reverse-engineered from the dotted sensor key-paths (e.g.
|
|
4
|
+
``measurement_groups.0.totals.d``) in fsaris/home-assistant-zonneplan-one's
|
|
5
|
+
``const.py``, which reads these against the live API. No raw response has
|
|
6
|
+
been captured yet, so the exact set of ``meta`` keys is not confirmed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mashumaro.mixins.orjson import DataClassORJSONMixin
|
|
14
|
+
|
|
15
|
+
from pyzonneplan.const import WH_TO_KWH
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ElectricityMeasurementGroup(DataClassORJSONMixin):
|
|
20
|
+
"""One measurement window (today / this month / this year) of electricity-delivered."""
|
|
21
|
+
|
|
22
|
+
date: str | None = None
|
|
23
|
+
totals: dict[str, int] = field(default_factory=dict)
|
|
24
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def delivered_kwh(self) -> Decimal | None:
|
|
28
|
+
"""Return electricity consumed from the grid, in kWh."""
|
|
29
|
+
value = self.totals.get("d")
|
|
30
|
+
return None if value is None else Decimal(value) * WH_TO_KWH
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def returned_kwh(self) -> Decimal | None:
|
|
34
|
+
"""Return electricity returned to the grid, in kWh."""
|
|
35
|
+
value = self.totals.get("p")
|
|
36
|
+
return None if value is None else Decimal(value) * WH_TO_KWH
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ElectricityDelivered(DataClassORJSONMixin):
|
|
41
|
+
"""Response of /connections/{uuid}/electricity-delivered."""
|
|
42
|
+
|
|
43
|
+
measurement_groups: list[ElectricityMeasurementGroup] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class GasMeasurementGroup(DataClassORJSONMixin):
|
|
48
|
+
"""One measurement window (today / this month / this year) of gas consumption."""
|
|
49
|
+
|
|
50
|
+
date: str | None = None
|
|
51
|
+
total: int | None = None
|
|
52
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def total_m3(self) -> Decimal | None:
|
|
56
|
+
"""Return gas consumed, in cubic meters."""
|
|
57
|
+
return None if self.total is None else Decimal(self.total) * WH_TO_KWH
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Gas(DataClassORJSONMixin):
|
|
62
|
+
"""Response of /connections/{uuid}/gas."""
|
|
63
|
+
|
|
64
|
+
measurement_groups: list[GasMeasurementGroup] = field(default_factory=list)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Models for the PV, battery and charge point endpoints."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mashumaro.mixins.orjson import DataClassORJSONMixin
|
|
9
|
+
|
|
10
|
+
from pyzonneplan.const import MONEY_FACTOR, WH_TO_KWH
|
|
11
|
+
|
|
12
|
+
from .account import Contract
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _euro(value: int | None) -> Decimal | None:
|
|
16
|
+
"""Convert a 1e-7 EUR amount to euro."""
|
|
17
|
+
return None if value is None else Decimal(value) * MONEY_FACTOR
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class PvInverter:
|
|
22
|
+
"""Typed view on a pv_installation contract's meta block."""
|
|
23
|
+
|
|
24
|
+
contract: Contract
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def uuid(self) -> str:
|
|
28
|
+
"""Return the installation UUID."""
|
|
29
|
+
return self.contract.uuid
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def model_name(self) -> str | None:
|
|
33
|
+
"""Return the inverter model."""
|
|
34
|
+
return self.contract.meta.get("inverter_model_name")
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def inverter_firmware_version(self) -> str | None:
|
|
38
|
+
"""Return the inverter firmware version."""
|
|
39
|
+
return self.contract.meta.get("inverter_firmware_version")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def module_firmware_version(self) -> str | None:
|
|
43
|
+
"""Return the module firmware version."""
|
|
44
|
+
return self.contract.meta.get("module_firmware_version")
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def installation_wp(self) -> int | None:
|
|
48
|
+
"""Return the total installed peak power."""
|
|
49
|
+
return self.contract.meta.get("installation_wp")
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def panel_wp(self) -> int | None:
|
|
53
|
+
"""Return the peak power per panel."""
|
|
54
|
+
return self.contract.meta.get("panel_wp")
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def panel_count(self) -> int | None:
|
|
58
|
+
"""Return the number of panels."""
|
|
59
|
+
return self.contract.meta.get("panel_count")
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def last_measured_power(self) -> int | None:
|
|
63
|
+
"""Return the last measured production, in W."""
|
|
64
|
+
return self.contract.meta.get("last_measured_power_value")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def total_power_measured(self) -> int | None:
|
|
68
|
+
"""Return lifetime production, in Wh."""
|
|
69
|
+
return self.contract.meta.get("total_power_measured")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def total_earned(self) -> Decimal | None:
|
|
73
|
+
"""Return lifetime earnings."""
|
|
74
|
+
return _euro(self.contract.meta.get("total_earned"))
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def total_day(self) -> Decimal | None:
|
|
78
|
+
"""Return today's earnings."""
|
|
79
|
+
return _euro(self.contract.meta.get("total_day"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class PvTotals(DataClassORJSONMixin):
|
|
84
|
+
"""Response of /connections/{uuid}/pv-installation."""
|
|
85
|
+
|
|
86
|
+
total_today: int | None = None
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def yield_today_kwh(self) -> Decimal | None:
|
|
90
|
+
"""Return combined yield today across all inverters, in kWh."""
|
|
91
|
+
return None if self.total_today is None else Decimal(self.total_today) * WH_TO_KWH
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Battery(DataClassORJSONMixin):
|
|
96
|
+
"""Response of /connections/{uuid}/home-battery-installation/{uuid}."""
|
|
97
|
+
|
|
98
|
+
battery_state: str | None = None
|
|
99
|
+
inverter_state: str | None = None
|
|
100
|
+
state_of_charge: int | None = None
|
|
101
|
+
power_ac: int | None = None
|
|
102
|
+
cycle_count: int | None = None
|
|
103
|
+
backup_power_usable_capacity_wh: int | None = None
|
|
104
|
+
first_measured_at: datetime | None = None
|
|
105
|
+
last_measured_at: datetime | None = None
|
|
106
|
+
total_earned: int | None = None
|
|
107
|
+
total_day: int | None = None
|
|
108
|
+
average_day: int | None = None
|
|
109
|
+
delivery_day: int | None = None
|
|
110
|
+
production_day: int | None = None
|
|
111
|
+
dynamic_charging_enabled: bool | None = None
|
|
112
|
+
dynamic_load_balancing_enabled: bool | None = None
|
|
113
|
+
dynamic_load_balancing_overload_active: bool | None = None
|
|
114
|
+
manual_control_enabled: bool | None = None
|
|
115
|
+
self_consumption_enabled: bool | None = None
|
|
116
|
+
home_optimization_enabled: bool | None = None
|
|
117
|
+
home_optimization_active: bool | None = None
|
|
118
|
+
grid_congestion_active: bool | None = None
|
|
119
|
+
backup_power_active: bool | None = None
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def earned_total(self) -> Decimal | None:
|
|
123
|
+
"""Return lifetime trading result."""
|
|
124
|
+
return _euro(self.total_earned)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def earned_today(self) -> Decimal | None:
|
|
128
|
+
"""Return today's trading result."""
|
|
129
|
+
return _euro(self.total_day)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def is_charging(self) -> bool | None:
|
|
133
|
+
"""Return whether the battery is currently charging."""
|
|
134
|
+
return None if self.power_ac is None else self.power_ac > 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class BatteryControlMode(DataClassORJSONMixin):
|
|
139
|
+
"""Response of /api/contracts/{uuid}/home-battery/control-mode."""
|
|
140
|
+
|
|
141
|
+
control_mode: str | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class BatteryHomeOptimization(DataClassORJSONMixin):
|
|
146
|
+
"""Response of the home_optimization control-mode endpoint."""
|
|
147
|
+
|
|
148
|
+
max_desired_charge_power_watts: int | None = None
|
|
149
|
+
max_desired_discharge_power_watts: int | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class ChargeSchedule(DataClassORJSONMixin):
|
|
154
|
+
"""A single planned charging window."""
|
|
155
|
+
|
|
156
|
+
start_time: datetime | None = None
|
|
157
|
+
end_time: datetime | None = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class DynamicChargingConstraints(DataClassORJSONMixin):
|
|
162
|
+
"""User constraints for a dynamic charging session."""
|
|
163
|
+
|
|
164
|
+
desired_distance_in_kilometers: int | None = None
|
|
165
|
+
desired_additional_battery_percentage: int | None = None
|
|
166
|
+
desired_end_time: datetime | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class ChargePointSession(DataClassORJSONMixin):
|
|
171
|
+
"""The currently running or last charging session."""
|
|
172
|
+
|
|
173
|
+
start_time: datetime | None = None
|
|
174
|
+
charged_distance_in_kilometers: int | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class ChargePoint(DataClassORJSONMixin):
|
|
179
|
+
"""Response of /connections/{uuid}/charge-points/{uuid}."""
|
|
180
|
+
|
|
181
|
+
state: str | None = None
|
|
182
|
+
start_mode: str | None = None
|
|
183
|
+
power_actual: int | None = None
|
|
184
|
+
energy_delivered_session: int | None = None
|
|
185
|
+
session_charging_cost_total: int | None = None
|
|
186
|
+
charging_cost_total: int | None = None
|
|
187
|
+
session_flex_result: int | None = None
|
|
188
|
+
session_average_cost_in_cents: int | None = None
|
|
189
|
+
dynamic_load_balancing_health: str | None = None
|
|
190
|
+
selected_vehicle: str | None = None
|
|
191
|
+
connectivity_state: bool | None = None
|
|
192
|
+
can_charge: bool | None = None
|
|
193
|
+
can_schedule: bool | None = None
|
|
194
|
+
charging_manually: bool | None = None
|
|
195
|
+
charging_automatically: bool | None = None
|
|
196
|
+
plug_and_charge: bool | None = None
|
|
197
|
+
overload_protection_active: bool | None = None
|
|
198
|
+
dynamic_charging_enabled: bool | None = None
|
|
199
|
+
charge_on_solar_enabled: bool | None = None
|
|
200
|
+
dynamic_charging_flex_enabled: bool | None = None
|
|
201
|
+
dynamic_charging_flex_suppressed: bool | None = None
|
|
202
|
+
charge_schedules: list[ChargeSchedule] = field(default_factory=list)
|
|
203
|
+
charge_point_session: ChargePointSession | None = None
|
|
204
|
+
dynamic_charging_user_constraints: DynamicChargingConstraints | None = None
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def session_cost(self) -> Decimal | None:
|
|
208
|
+
"""Return the cost of the running session."""
|
|
209
|
+
return _euro(self.session_charging_cost_total)
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def next_schedule(self) -> ChargeSchedule | None:
|
|
213
|
+
"""Return the next planned charging window."""
|
|
214
|
+
return self.charge_schedules[0] if self.charge_schedules else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class BatteryChart(DataClassORJSONMixin):
|
|
219
|
+
"""Response of the battery chart endpoints.
|
|
220
|
+
|
|
221
|
+
Shape still needs a captured payload; the raw body is kept meanwhile.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Models for the consumer price chart endpoint."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
from mashumaro.mixins.orjson import DataClassORJSONMixin
|
|
8
|
+
|
|
9
|
+
from pyzonneplan.const import MONEY_FACTOR
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Money(DataClassORJSONMixin):
|
|
14
|
+
"""A monetary amount, expressed in the API's 1e-7 EUR unit."""
|
|
15
|
+
|
|
16
|
+
amount: int
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def euro(self) -> Decimal:
|
|
20
|
+
"""Return the amount converted to euro."""
|
|
21
|
+
return Decimal(self.amount) * MONEY_FACTOR
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SustainabilityScore(DataClassORJSONMixin):
|
|
26
|
+
"""How sustainable the energy is during a price interval, in permille."""
|
|
27
|
+
|
|
28
|
+
permille: int
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def fraction(self) -> Decimal:
|
|
32
|
+
"""Return the score as a fraction between 0 and 1."""
|
|
33
|
+
return Decimal(self.permille) / 1000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PricePoint(DataClassORJSONMixin):
|
|
38
|
+
"""A single price interval within a chart.
|
|
39
|
+
|
|
40
|
+
``tariff_group`` is only present on the hourly electricity chart, not on
|
|
41
|
+
the quarter-hourly one.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
start_date: datetime
|
|
45
|
+
end_date: datetime
|
|
46
|
+
price_tax_included: Money
|
|
47
|
+
price_tax_excluded: Money
|
|
48
|
+
sustainability_score: SustainabilityScore | None = None
|
|
49
|
+
tariff_group: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class PriceRange(DataClassORJSONMixin):
|
|
54
|
+
"""The date range a chart covers."""
|
|
55
|
+
|
|
56
|
+
start_date: datetime
|
|
57
|
+
end_date: datetime
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class PriceSeries(DataClassORJSONMixin):
|
|
62
|
+
"""The list of price points making up a chart."""
|
|
63
|
+
|
|
64
|
+
prices: list[PricePoint] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class PriceChartData(DataClassORJSONMixin):
|
|
69
|
+
"""The ``chart`` object of a consumer price chart response."""
|
|
70
|
+
|
|
71
|
+
range: PriceRange
|
|
72
|
+
series: PriceSeries
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class ConsumerPrices(DataClassORJSONMixin):
|
|
77
|
+
"""Response of /api/consumer-prices/charts/{chart_name}."""
|
|
78
|
+
|
|
79
|
+
chart: PriceChartData
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def prices(self) -> list[PricePoint]:
|
|
83
|
+
"""Return the price points, flattened out of the nested chart/series structure."""
|
|
84
|
+
return self.chart.series.prices
|
pyzonneplan/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Asynchronous Python client for the Zonneplan API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import socket
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from http import HTTPStatus
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
from typing import Any, NoReturn, Self
|
|
11
|
+
|
|
12
|
+
import orjson
|
|
13
|
+
from aiohttp import ClientError, ClientResponseError, ClientSession
|
|
14
|
+
from aiohttp.hdrs import METH_GET, METH_POST
|
|
15
|
+
from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
16
|
+
from yarl import URL
|
|
17
|
+
|
|
18
|
+
from pyzonneplan.auth import OtpChallenge, Token, generate_pkce_pair
|
|
19
|
+
from pyzonneplan.const import (
|
|
20
|
+
API_SCHEME,
|
|
21
|
+
API_URL,
|
|
22
|
+
APP_ENVIRONMENT,
|
|
23
|
+
APP_VERSION,
|
|
24
|
+
AUTHORIZE_CHALLENGE_PATH,
|
|
25
|
+
TOKEN_PATH,
|
|
26
|
+
PriceChart,
|
|
27
|
+
)
|
|
28
|
+
from pyzonneplan.exceptions import (
|
|
29
|
+
ZonneplanAuthenticationError,
|
|
30
|
+
ZonneplanConnectionError,
|
|
31
|
+
ZonneplanInvalidOtpError,
|
|
32
|
+
ZonneplanTimeoutError,
|
|
33
|
+
)
|
|
34
|
+
from pyzonneplan.models.account import Account
|
|
35
|
+
from pyzonneplan.models.consumption import ElectricityDelivered, Gas
|
|
36
|
+
from pyzonneplan.models.prices import ConsumerPrices
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
VERSION = metadata.version(__package__)
|
|
40
|
+
except metadata.PackageNotFoundError: # pragma: no cover
|
|
41
|
+
VERSION = "DEV-0.0.0"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Zonneplan:
|
|
46
|
+
"""Asynchronous Python client for the Zonneplan API.
|
|
47
|
+
|
|
48
|
+
Zonneplan uses an OAuth2 authorization-code flow with PKCE, but the
|
|
49
|
+
user-facing step is a one-time password mailed to the account address
|
|
50
|
+
instead of a browser redirect. The flow is three calls:
|
|
51
|
+
|
|
52
|
+
1. POST /oauth/authorize-challenge {response_type, email, code_challenge, ...}
|
|
53
|
+
-> HTTP 403 with ``otp_required: true`` and an ``auth_session``.
|
|
54
|
+
The 403 is the success path, not an error.
|
|
55
|
+
2. POST /oauth/authorize-challenge {auth_session, otp}
|
|
56
|
+
-> ``authorization_code``
|
|
57
|
+
3. POST /oauth/token {grant_type: authorization_code, code, code_verifier}
|
|
58
|
+
-> access + refresh token
|
|
59
|
+
|
|
60
|
+
Every other request checks the current token before it goes out and
|
|
61
|
+
transparently refreshes it once it's within its expiry margin.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
request_timeout: float = 10.0
|
|
65
|
+
session: ClientSession | None = None
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
email: str,
|
|
70
|
+
request_timeout: float = 10.0,
|
|
71
|
+
session: ClientSession | None = None,
|
|
72
|
+
max_retries: int = 3,
|
|
73
|
+
token: Token | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Initialize the Zonneplan client.
|
|
76
|
+
|
|
77
|
+
Pass a previously-obtained ``token`` (see :attr:`token` and
|
|
78
|
+
:meth:`pyzonneplan.auth.Token.as_dict`) to restore a session without
|
|
79
|
+
going through the OTP flow again.
|
|
80
|
+
"""
|
|
81
|
+
self._email = email
|
|
82
|
+
self._session = session
|
|
83
|
+
self.request_timeout = request_timeout
|
|
84
|
+
self._close_session = session is None
|
|
85
|
+
self._token = token
|
|
86
|
+
self._max_retries = max_retries
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def token(self) -> Token | None:
|
|
90
|
+
"""Return the current token, or ``None`` before login."""
|
|
91
|
+
return self._token
|
|
92
|
+
|
|
93
|
+
async def _request(
|
|
94
|
+
self,
|
|
95
|
+
uri: str,
|
|
96
|
+
*,
|
|
97
|
+
method: str = METH_GET,
|
|
98
|
+
params: dict[str, Any] | None = None,
|
|
99
|
+
json_body: dict[str, Any] | None = None,
|
|
100
|
+
timeout: float | None = None,
|
|
101
|
+
allow_forbidden: bool = False,
|
|
102
|
+
authenticated: bool = True,
|
|
103
|
+
) -> Any:
|
|
104
|
+
"""Handle a request to the Zonneplan API.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
----
|
|
108
|
+
uri: Request URI, without the leading '/', for example 'user-accounts/me'.
|
|
109
|
+
method: HTTP method to use.
|
|
110
|
+
params: Query parameters to include in the request.
|
|
111
|
+
json_body: JSON body to include in the request.
|
|
112
|
+
timeout: Timeout for the request (in seconds).
|
|
113
|
+
allow_forbidden: Treat a 403 response as success instead of raising.
|
|
114
|
+
Zonneplan's OTP challenge endpoint returns 403 on the happy path.
|
|
115
|
+
authenticated: Whether this call should refresh an expiring token
|
|
116
|
+
and send it along. False for the OTP/token endpoints
|
|
117
|
+
themselves, which would otherwise recurse into a refresh.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
-------
|
|
121
|
+
The JSON-decoded response body, or ``None`` for an empty response.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
------
|
|
125
|
+
ZonneplanAuthenticationError: If the access token is invalid or missing.
|
|
126
|
+
ZonneplanConnectionError: On network errors.
|
|
127
|
+
ZonneplanTimeoutError: If the request times out.
|
|
128
|
+
|
|
129
|
+
"""
|
|
130
|
+
if authenticated and self._token is not None and self._token.is_expired:
|
|
131
|
+
await self.async_refresh_token()
|
|
132
|
+
|
|
133
|
+
url = URL.build(scheme=API_SCHEME, host=API_URL, path="/").join(URL(uri))
|
|
134
|
+
|
|
135
|
+
headers = {
|
|
136
|
+
"content-type": "application/json;charset=utf-8",
|
|
137
|
+
"user-agent": f"pyzonneplan/{VERSION}",
|
|
138
|
+
"x-app-version": APP_VERSION,
|
|
139
|
+
"x-app-environment": APP_ENVIRONMENT,
|
|
140
|
+
}
|
|
141
|
+
if self._token is not None:
|
|
142
|
+
headers["authorization"] = f"Bearer {self._token.access_token}"
|
|
143
|
+
|
|
144
|
+
if self._session is None:
|
|
145
|
+
self._session = ClientSession()
|
|
146
|
+
self._close_session = True
|
|
147
|
+
|
|
148
|
+
if timeout is None:
|
|
149
|
+
timeout = self.request_timeout
|
|
150
|
+
|
|
151
|
+
async for attempt in AsyncRetrying(
|
|
152
|
+
retry=retry_if_exception_type((ZonneplanConnectionError, ZonneplanTimeoutError)),
|
|
153
|
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
|
154
|
+
stop=stop_after_attempt(self._max_retries + 1),
|
|
155
|
+
reraise=True,
|
|
156
|
+
):
|
|
157
|
+
with attempt:
|
|
158
|
+
body = b""
|
|
159
|
+
try:
|
|
160
|
+
async with asyncio.timeout(timeout):
|
|
161
|
+
response = await self._session.request(
|
|
162
|
+
method,
|
|
163
|
+
url,
|
|
164
|
+
headers=headers,
|
|
165
|
+
params=params,
|
|
166
|
+
json=json_body,
|
|
167
|
+
)
|
|
168
|
+
# Read the body before raise_for_status(), which releases
|
|
169
|
+
# the response (and with it, the body stream) before raising.
|
|
170
|
+
body = await response.read()
|
|
171
|
+
if not (allow_forbidden and response.status == HTTPStatus.FORBIDDEN):
|
|
172
|
+
response.raise_for_status()
|
|
173
|
+
except TimeoutError as err:
|
|
174
|
+
msg = f"Timeout error while accessing {method} {url}: {err}"
|
|
175
|
+
raise ZonneplanTimeoutError(msg) from err
|
|
176
|
+
except ClientResponseError as err:
|
|
177
|
+
self._raise_for_client_response_error(method, url, err, body)
|
|
178
|
+
except (ClientError, socket.gaierror) as err:
|
|
179
|
+
msg = f"Unexpected error during {method} {url}: {err}"
|
|
180
|
+
raise ZonneplanConnectionError(msg) from err
|
|
181
|
+
|
|
182
|
+
if response.status in (204, 304):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
return orjson.loads(body)
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _raise_for_client_response_error(method: str, url: URL, err: ClientResponseError, body: bytes) -> NoReturn:
|
|
189
|
+
"""Map a failed HTTP response to the appropriate Zonneplan exception."""
|
|
190
|
+
body_text = body.decode(errors="replace")
|
|
191
|
+
match err.status:
|
|
192
|
+
case 400 | 401 | 403:
|
|
193
|
+
# Zonneplan returns a plain 400 (not 401/403) for a rejected or
|
|
194
|
+
# expired OTP/refresh grant, so treat it as an authentication
|
|
195
|
+
# failure too, not a connection error.
|
|
196
|
+
msg = f"Authentication failed for {method} {url}: {err} ({body_text})"
|
|
197
|
+
raise ZonneplanAuthenticationError(msg) from err
|
|
198
|
+
case _:
|
|
199
|
+
msg = f"Connection error for {method} {url}: {err} ({body_text})"
|
|
200
|
+
raise ZonneplanConnectionError(msg) from err
|
|
201
|
+
|
|
202
|
+
async def async_request_otp(self, source_name: str) -> OtpChallenge:
|
|
203
|
+
"""Start the login and have Zonneplan mail a one-time password."""
|
|
204
|
+
code_verifier, code_challenge = generate_pkce_pair()
|
|
205
|
+
payload = {
|
|
206
|
+
"response_type": "code",
|
|
207
|
+
"email": self._email,
|
|
208
|
+
"code_challenge": code_challenge,
|
|
209
|
+
"code_challenge_method": "S256",
|
|
210
|
+
"source_name": source_name[:255],
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
response = await self._request(
|
|
214
|
+
AUTHORIZE_CHALLENGE_PATH,
|
|
215
|
+
method=METH_POST,
|
|
216
|
+
json_body=payload,
|
|
217
|
+
allow_forbidden=True,
|
|
218
|
+
authenticated=False,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if not response.get("otp_required") or "auth_session" not in response:
|
|
222
|
+
msg = "Zonneplan did not return an OTP challenge"
|
|
223
|
+
raise ZonneplanAuthenticationError(msg)
|
|
224
|
+
|
|
225
|
+
return OtpChallenge(auth_session=response["auth_session"], code_verifier=code_verifier, email=self._email)
|
|
226
|
+
|
|
227
|
+
async def async_submit_otp(self, challenge: OtpChallenge, otp: str) -> Token:
|
|
228
|
+
"""Exchange the mailed one-time password for a token, and use it for further requests."""
|
|
229
|
+
try:
|
|
230
|
+
response = await self._request(
|
|
231
|
+
AUTHORIZE_CHALLENGE_PATH,
|
|
232
|
+
method=METH_POST,
|
|
233
|
+
json_body={"auth_session": challenge.auth_session, "otp": otp},
|
|
234
|
+
authenticated=False,
|
|
235
|
+
)
|
|
236
|
+
except ZonneplanAuthenticationError as err:
|
|
237
|
+
msg = "Zonneplan rejected the one-time password"
|
|
238
|
+
raise ZonneplanInvalidOtpError(msg) from err
|
|
239
|
+
|
|
240
|
+
authorization_code = response.get("authorization_code")
|
|
241
|
+
if not authorization_code:
|
|
242
|
+
msg = "Zonneplan rejected the one-time password"
|
|
243
|
+
raise ZonneplanInvalidOtpError(msg)
|
|
244
|
+
|
|
245
|
+
token = await self._async_request_token(
|
|
246
|
+
{
|
|
247
|
+
"grant_type": "authorization_code",
|
|
248
|
+
"code": authorization_code,
|
|
249
|
+
"code_verifier": challenge.code_verifier,
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
self._token = token
|
|
253
|
+
return token
|
|
254
|
+
|
|
255
|
+
async def async_refresh_token(self) -> Token:
|
|
256
|
+
"""Exchange the current refresh token for a new one, regardless of whether it has expired yet."""
|
|
257
|
+
if self._token is None:
|
|
258
|
+
msg = "Cannot refresh a token before logging in"
|
|
259
|
+
raise ZonneplanAuthenticationError(msg)
|
|
260
|
+
|
|
261
|
+
self._token = await self._async_request_token({"grant_type": "refresh_token", "refresh_token": self._token.refresh_token})
|
|
262
|
+
return self._token
|
|
263
|
+
|
|
264
|
+
async def _async_request_token(self, grant: dict[str, str]) -> Token:
|
|
265
|
+
"""Call the token endpoint and parse the result."""
|
|
266
|
+
response = await self._request(TOKEN_PATH, method=METH_POST, json_body=grant, authenticated=False)
|
|
267
|
+
return Token.from_response(response)
|
|
268
|
+
|
|
269
|
+
async def async_get_account(self) -> Account:
|
|
270
|
+
"""Fetch the authenticated Zonneplan account, its addresses and connections."""
|
|
271
|
+
response = await self._request("user-accounts/me")
|
|
272
|
+
return Account.from_dict(response["data"])
|
|
273
|
+
|
|
274
|
+
async def async_get_consumer_prices(self, chart: str = PriceChart.ELECTRICITY_HOURLY) -> ConsumerPrices:
|
|
275
|
+
"""Fetch a consumer price chart (see :class:`pyzonneplan.const.PriceChart` for valid ``chart`` values)."""
|
|
276
|
+
response = await self._request(f"api/consumer-prices/charts/{chart}")
|
|
277
|
+
return ConsumerPrices.from_dict(response["data"])
|
|
278
|
+
|
|
279
|
+
async def async_get_electricity_delivered(self, connection_uuid: str) -> ElectricityDelivered:
|
|
280
|
+
"""Fetch electricity delivery/production data (P1) for a connection."""
|
|
281
|
+
response = await self._request(f"connections/{connection_uuid}/electricity-delivered")
|
|
282
|
+
return ElectricityDelivered.from_dict(response["data"])
|
|
283
|
+
|
|
284
|
+
async def async_get_gas(self, connection_uuid: str) -> Gas:
|
|
285
|
+
"""Fetch gas consumption data (P1) for a connection."""
|
|
286
|
+
response = await self._request(f"connections/{connection_uuid}/gas")
|
|
287
|
+
return Gas.from_dict(response["data"])
|
|
288
|
+
|
|
289
|
+
async def close(self) -> None:
|
|
290
|
+
"""Close open client session."""
|
|
291
|
+
if self._session and self._close_session:
|
|
292
|
+
await self._session.close()
|
|
293
|
+
|
|
294
|
+
async def __aenter__(self) -> Self:
|
|
295
|
+
"""Async enter.
|
|
296
|
+
|
|
297
|
+
Returns
|
|
298
|
+
-------
|
|
299
|
+
The Zonneplan object.
|
|
300
|
+
|
|
301
|
+
"""
|
|
302
|
+
return self
|
|
303
|
+
|
|
304
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
305
|
+
"""Async exit.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
----
|
|
309
|
+
_exc_info: Exec type.
|
|
310
|
+
|
|
311
|
+
"""
|
|
312
|
+
await self.close()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyzonneplan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous Python client for the Zonneplan API
|
|
5
|
+
Project-URL: Homepage, https://github.com/erwindouna/pyzonneplan
|
|
6
|
+
Project-URL: Repository, https://github.com/erwindouna/pyzonneplan
|
|
7
|
+
Project-URL: Documentation, https://erwindouna.github.io/pyzonneplan/
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/erwindouna/pyzonneplan/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/erwindouna/pyzonneplan/releases
|
|
10
|
+
Author-email: Erwin Douna <e.douna@gmail.com>
|
|
11
|
+
Maintainer-email: Erwin Douna <e.douna@gmail.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: api,async,battery,charge-point,client,pv,solar,zonneplan
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Natural Language :: English
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Python: <4,>=3.12
|
|
25
|
+
Requires-Dist: aiohttp>=3.0.0
|
|
26
|
+
Requires-Dist: mashumaro<4,>=3.17
|
|
27
|
+
Requires-Dist: orjson<4,>=3.10.16
|
|
28
|
+
Requires-Dist: tenacity>=9.0.0
|
|
29
|
+
Requires-Dist: yarl>=1.6.0
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# pyzonneplan
|
|
33
|
+
|
|
34
|
+
[![GitHub Release][releases-shield]][releases]
|
|
35
|
+
[![Python Versions][python-versions-shield]][pypi]
|
|
36
|
+
![Project Stage][project-stage-shield]
|
|
37
|
+
![Project Maintenance][maintenance-shield]
|
|
38
|
+
[![License][license-shield]](LICENSE)
|
|
39
|
+
|
|
40
|
+
[![GitHub Activity][commits-shield]][commits-url]
|
|
41
|
+
[![PyPI Downloads][downloads-shield]][downloads-url]
|
|
42
|
+
[![GitHub Last Commit][last-commit-shield]][commits-url]
|
|
43
|
+
[![Open in Dev Containers][devcontainer-shield]][devcontainer]
|
|
44
|
+
|
|
45
|
+
[![Build Status][build-shield]][build-url]
|
|
46
|
+
[![Typing Status][typing-shield]][typing-url]
|
|
47
|
+
[![Code Coverage][codecov-shield]][codecov-url]
|
|
48
|
+
|
|
49
|
+
Asynchronous Python client for the Zonneplan API.
|
|
50
|
+
|
|
51
|
+
## About
|
|
52
|
+
|
|
53
|
+
pyzonneplan is an async client for the [Zonneplan](https://www.zonneplan.nl/) API, focused on:
|
|
54
|
+
|
|
55
|
+
- Solar (PV) installation data
|
|
56
|
+
- Dynamic pricing and energy consumption data
|
|
57
|
+
- Home battery monitoring and control modes
|
|
58
|
+
- EV charge point sessions and schedules
|
|
59
|
+
|
|
60
|
+
The library is under active development and endpoint coverage will keep expanding.
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install pyzonneplan
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
Zonneplan authenticates with a one-time password mailed to the account address, rather than a plain username/password login:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import asyncio
|
|
74
|
+
|
|
75
|
+
from aiohttp import ClientSession
|
|
76
|
+
from pyzonneplan import ZonneplanAuth
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def main() -> None:
|
|
80
|
+
async with ClientSession() as session:
|
|
81
|
+
auth = ZonneplanAuth(session=session)
|
|
82
|
+
challenge = await auth.async_request_otp("you@example.com", source_name="pyzonneplan")
|
|
83
|
+
|
|
84
|
+
otp = input("Enter the one-time password mailed to you: ")
|
|
85
|
+
token = await auth.async_submit_otp(challenge, otp)
|
|
86
|
+
|
|
87
|
+
print("Access token:", token.access_token)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
asyncio.run(main())
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Store `token.as_dict()` and restore it later with `Token.from_dict(...)`; refresh it
|
|
95
|
+
with `auth.async_refresh_token(token)` once it is close to `token.expires_at`.
|
|
96
|
+
|
|
97
|
+
More examples can be found in the examples directory.
|
|
98
|
+
|
|
99
|
+
## Documentation
|
|
100
|
+
|
|
101
|
+
Project documentation and API reference: https://erwindouna.github.io/pyzonneplan/
|
|
102
|
+
|
|
103
|
+
## Contributing
|
|
104
|
+
|
|
105
|
+
Contributions are welcome. Please open an issue or pull request.
|
|
106
|
+
|
|
107
|
+
For local development:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
uv sync --all-groups && uv run pre-commit install
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Run checks:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uv run pre-commit run --all-files
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Run tests:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
uv run pytest
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT License
|
|
128
|
+
|
|
129
|
+
Copyright (c) 2026 Erwin Douna
|
|
130
|
+
|
|
131
|
+
<!-- MARKDOWN LINKS & IMAGES -->
|
|
132
|
+
|
|
133
|
+
[build-shield]: https://github.com/erwindouna/pyzonneplan/actions/workflows/tests.yaml/badge.svg
|
|
134
|
+
[build-url]: https://github.com/erwindouna/pyzonneplan/actions/workflows/tests.yaml
|
|
135
|
+
[codecov-shield]: https://codecov.io/gh/erwindouna/pyzonneplan/branch/main/graph/badge.svg
|
|
136
|
+
[codecov-url]: https://codecov.io/gh/erwindouna/pyzonneplan
|
|
137
|
+
[commits-shield]: https://img.shields.io/github/commit-activity/y/erwindouna/pyzonneplan.svg
|
|
138
|
+
[commits-url]: https://github.com/erwindouna/pyzonneplan/commits/main
|
|
139
|
+
[devcontainer-shield]: https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode
|
|
140
|
+
[devcontainer]: https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/erwindouna/pyzonneplan
|
|
141
|
+
[downloads-shield]: https://img.shields.io/pypi/dm/pyzonneplan
|
|
142
|
+
[downloads-url]: https://pypistats.org/packages/pyzonneplan
|
|
143
|
+
[last-commit-shield]: https://img.shields.io/github/last-commit/erwindouna/pyzonneplan.svg
|
|
144
|
+
[license-shield]: https://img.shields.io/github/license/erwindouna/pyzonneplan.svg
|
|
145
|
+
[project-stage-shield]: https://img.shields.io/badge/project%20stage-experimental-yellow.svg
|
|
146
|
+
[maintenance-shield]: https://img.shields.io/maintenance/yes/2026.svg
|
|
147
|
+
[pypi]: https://pypi.org/project/pyzonneplan/
|
|
148
|
+
[python-versions-shield]: https://img.shields.io/pypi/pyversions/pyzonneplan
|
|
149
|
+
[releases-shield]: https://img.shields.io/github/release/erwindouna/pyzonneplan.svg
|
|
150
|
+
[releases]: https://github.com/erwindouna/pyzonneplan/releases
|
|
151
|
+
[typing-shield]: https://github.com/erwindouna/pyzonneplan/actions/workflows/typing.yaml/badge.svg
|
|
152
|
+
[typing-url]: https://github.com/erwindouna/pyzonneplan/actions/workflows/typing.yaml
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pyzonneplan/__init__.py,sha256=eMXcPFJclwHYcQDJY7QEFHJ5UsyGupBsTgXP0sKZREY,1671
|
|
2
|
+
pyzonneplan/auth.py,sha256=XRwGM5A6ELCGUbOQ5BhDjLGaV_SI2bNAxpahOO7Qlcc,2995
|
|
3
|
+
pyzonneplan/const.py,sha256=NvZBJoy8rskTTAFWUbg0OcLy5AhRk412GxE_0vPH-Es,1320
|
|
4
|
+
pyzonneplan/exceptions.py,sha256=nEP3DIACm-oo6u76LIUQSNbWnAhJC-3n3-QBnpxmXJg,576
|
|
5
|
+
pyzonneplan/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
pyzonneplan/pyzonneplan.py,sha256=VJ7TcRSreLrlETBgtYvmlr-sVNMUFHU1mC45_snrP1A,12291
|
|
7
|
+
pyzonneplan/models/__init__.py,sha256=p4y_46R9AHd7QUwyFSnBnaAH11HXirO-E8xR78PvM9k,1164
|
|
8
|
+
pyzonneplan/models/account.py,sha256=5du0_HxZNYD_m3bQbatql9xy0_AAES8xkTKpDOwUFwQ,4217
|
|
9
|
+
pyzonneplan/models/consumption.py,sha256=yse-RosHQI1QTSmjvmiC19DeXKQnk0gdR53sYYzfhgc,2152
|
|
10
|
+
pyzonneplan/models/devices.py,sha256=Cp8ZJK_kXKCFL_mMD2sA4520ronyaDo7_9cLB_yPDGw,7330
|
|
11
|
+
pyzonneplan/models/prices.py,sha256=6WM9Q5KAaOKi4rvO3xqvu9QzPsyu9FyqP8nRbB5CXp4,2087
|
|
12
|
+
pyzonneplan-0.1.0.dist-info/METADATA,sha256=_5WBqBAeAM0iDRjxgmMJ7Xwk0BODH56XpZ7G7vy8E-c,5447
|
|
13
|
+
pyzonneplan-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
pyzonneplan-0.1.0.dist-info/licenses/LICENSE,sha256=9_z2DWVpibayR1RoHyYPtK-gNWS7OIr1GjuqCT8mSqk,1068
|
|
15
|
+
pyzonneplan-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Erwin Douna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|