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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.