aiosolaredge-one 0.2.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,66 @@
1
+ """Async client for the SolarEdge ONE (Monitoring v2) API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .budget import (
6
+ BudgetPlan,
7
+ backoff_interval,
8
+ compute_interval,
9
+ plan_interval,
10
+ project_month_end_usage,
11
+ )
12
+ from .client import SolarEdgeOneClient
13
+ from .const import (
14
+ DEFAULT_BASE_URL,
15
+ RESOLUTION_DAY,
16
+ RESOLUTION_HOUR,
17
+ RESOLUTION_QUARTER_HOUR,
18
+ )
19
+ from .exceptions import (
20
+ SolarEdgeApiError,
21
+ SolarEdgeAuthError,
22
+ SolarEdgeConnectionError,
23
+ SolarEdgeError,
24
+ SolarEdgeNotFoundError,
25
+ SolarEdgeRateLimitError,
26
+ )
27
+ from .models import (
28
+ ConsumptionOverview,
29
+ Device,
30
+ ProductionOverview,
31
+ Site,
32
+ SiteOverview,
33
+ TimeSeries,
34
+ TimeValue,
35
+ )
36
+ from .ratelimit import CreditLedger, RateLimit
37
+
38
+ __version__ = "0.2.0"
39
+
40
+ __all__ = [
41
+ "SolarEdgeOneClient",
42
+ "BudgetPlan",
43
+ "backoff_interval",
44
+ "compute_interval",
45
+ "plan_interval",
46
+ "project_month_end_usage",
47
+ "DEFAULT_BASE_URL",
48
+ "RESOLUTION_DAY",
49
+ "RESOLUTION_HOUR",
50
+ "RESOLUTION_QUARTER_HOUR",
51
+ "SolarEdgeError",
52
+ "SolarEdgeApiError",
53
+ "SolarEdgeAuthError",
54
+ "SolarEdgeConnectionError",
55
+ "SolarEdgeNotFoundError",
56
+ "SolarEdgeRateLimitError",
57
+ "Site",
58
+ "SiteOverview",
59
+ "ProductionOverview",
60
+ "ConsumptionOverview",
61
+ "Device",
62
+ "TimeSeries",
63
+ "TimeValue",
64
+ "CreditLedger",
65
+ "RateLimit",
66
+ ]
@@ -0,0 +1,145 @@
1
+ """Credit-budget pacing math (HA-agnostic, pure functions).
2
+
3
+ The SolarEdge ONE API exposes only a per-minute rate limit; the monthly credit
4
+ quota (e.g. 2000/month) is tracked locally via :class:`CreditLedger`. This module
5
+ turns "credits remaining this month" into "how long to wait before the next poll"
6
+ so a caller spends its budget evenly instead of burning it early.
7
+
8
+ The core function, :func:`compute_interval`, is *remaining-budget aware*: it
9
+ paces against the credits still available for the rest of the month, so it
10
+ self-corrects. If earlier cycles overspent, later intervals lengthen; if the
11
+ budget is nearly exhausted it backs off to ``max_interval``. This doubles as the
12
+ budget guard — no separate "are we over budget?" branch is needed for pacing.
13
+
14
+ All functions are pure (no clock, no I/O) so they can be unit-tested by feeding
15
+ in ``remaining_credits`` / ``seconds_until_reset`` directly.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ # Sensible defaults; the HA integration overrides these from config/options.
23
+ DEFAULT_SAFETY_FACTOR = 0.7
24
+ DEFAULT_NIGHT_FACTOR = 3.0
25
+ DEFAULT_MIN_INTERVAL = 60.0 # seconds — never poll faster than once a minute
26
+ DEFAULT_MAX_INTERVAL = 6 * 60 * 60.0 # seconds — poll at least every 6h
27
+
28
+
29
+ def _clamp(value: float, low: float, high: float) -> float:
30
+ if low > high:
31
+ # A pathological config (floor above ceiling); prefer respecting the
32
+ # per-minute floor over the ceiling to avoid violating the hard limit.
33
+ return low
34
+ return max(low, min(high, value))
35
+
36
+
37
+ def cpm_floor(credits_per_cycle: int, calls_per_minute: int) -> float:
38
+ """Minimum seconds between cycles so we never exceed the per-minute limit.
39
+
40
+ A cycle issues ``credits_per_cycle`` calls; spacing cycles at least this far
41
+ apart keeps the average call rate at or below ``calls_per_minute``.
42
+ """
43
+ cpm = max(1, calls_per_minute)
44
+ return 60.0 * max(1, credits_per_cycle) / cpm
45
+
46
+
47
+ @dataclass(slots=True, frozen=True)
48
+ class BudgetPlan:
49
+ """User-configurable pacing parameters for one config entry."""
50
+
51
+ monthly_budget: int
52
+ calls_per_minute: int = 10
53
+ credits_per_cycle: int = 2
54
+ safety_factor: float = DEFAULT_SAFETY_FACTOR
55
+ night_factor: float = DEFAULT_NIGHT_FACTOR
56
+ min_interval: float = DEFAULT_MIN_INTERVAL
57
+ max_interval: float = DEFAULT_MAX_INTERVAL
58
+
59
+ @property
60
+ def target_credits(self) -> float:
61
+ """Credits we actually aim to spend per month (budget × safety)."""
62
+ return self.monthly_budget * self.safety_factor
63
+
64
+
65
+ def compute_interval(
66
+ *,
67
+ remaining_credits: float,
68
+ seconds_until_reset: float,
69
+ credits_per_cycle: int,
70
+ calls_per_minute: int,
71
+ min_interval: float = DEFAULT_MIN_INTERVAL,
72
+ max_interval: float = DEFAULT_MAX_INTERVAL,
73
+ is_night: bool = False,
74
+ night_factor: float = DEFAULT_NIGHT_FACTOR,
75
+ ) -> float:
76
+ """Seconds to wait before the next poll, paced to the remaining budget.
77
+
78
+ ``remaining_credits`` should already have the safety factor applied (i.e. it
79
+ is the *target* remaining, not the raw quota). When it drops below one
80
+ cycle's cost — or the month has effectively ended — this returns
81
+ ``max_interval`` so polling backs off instead of overspending.
82
+ """
83
+ floor = max(min_interval, cpm_floor(credits_per_cycle, calls_per_minute))
84
+ if remaining_credits < credits_per_cycle or seconds_until_reset <= 0:
85
+ return max(max_interval, floor)
86
+
87
+ cycles_left = remaining_credits / max(1, credits_per_cycle)
88
+ interval = seconds_until_reset / cycles_left
89
+ if is_night:
90
+ interval *= night_factor
91
+ return _clamp(interval, floor, max_interval)
92
+
93
+
94
+ def plan_interval(
95
+ plan: BudgetPlan,
96
+ *,
97
+ used_this_month: int,
98
+ seconds_until_reset: float,
99
+ is_night: bool = False,
100
+ ) -> float:
101
+ """Convenience wrapper: interval for a :class:`BudgetPlan` given usage."""
102
+ remaining = max(0.0, plan.target_credits - used_this_month)
103
+ return compute_interval(
104
+ remaining_credits=remaining,
105
+ seconds_until_reset=seconds_until_reset,
106
+ credits_per_cycle=plan.credits_per_cycle,
107
+ calls_per_minute=plan.calls_per_minute,
108
+ min_interval=plan.min_interval,
109
+ max_interval=plan.max_interval,
110
+ is_night=is_night,
111
+ night_factor=plan.night_factor,
112
+ )
113
+
114
+
115
+ def project_month_end_usage(
116
+ *, used_this_month: int, elapsed_seconds: float, total_seconds: float
117
+ ) -> float:
118
+ """Linear projection of end-of-month spend from usage so far.
119
+
120
+ Used to decide whether to raise a "you'll blow the budget" repair issue.
121
+ Returns the current usage unchanged until a little time has elapsed (avoids
122
+ wild extrapolation from the first few seconds of the month).
123
+ """
124
+ if elapsed_seconds <= 0 or total_seconds <= 0:
125
+ return float(used_this_month)
126
+ return used_this_month * total_seconds / elapsed_seconds
127
+
128
+
129
+ def backoff_interval(
130
+ attempt: int,
131
+ *,
132
+ base: float = 60.0,
133
+ cap: float = 60 * 60.0,
134
+ retry_after: float | None = None,
135
+ ) -> float:
136
+ """Exponential backoff after a 429, honouring ``Retry-After`` when present.
137
+
138
+ ``attempt`` is 1-based (first failure → ``base``). The result is capped at
139
+ ``cap`` and never shorter than a supplied ``Retry-After``.
140
+ """
141
+ exp = base * (2.0 ** max(0, attempt - 1))
142
+ interval = min(exp, cap)
143
+ if retry_after is not None:
144
+ interval = max(interval, retry_after)
145
+ return interval
@@ -0,0 +1,203 @@
1
+ """Async client for the SolarEdge ONE (Monitoring v2) API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any, cast
7
+
8
+ import aiohttp
9
+
10
+ from .const import (
11
+ DEFAULT_BASE_URL,
12
+ DEFAULT_TIMEOUT,
13
+ HEADER_ACCOUNT_KEY,
14
+ HEADER_API_KEY,
15
+ )
16
+ from .exceptions import (
17
+ SolarEdgeApiError,
18
+ SolarEdgeAuthError,
19
+ SolarEdgeConnectionError,
20
+ SolarEdgeNotFoundError,
21
+ SolarEdgeRateLimitError,
22
+ )
23
+ from .models import (
24
+ Device,
25
+ Site,
26
+ SiteOverview,
27
+ TimeSeries,
28
+ parse_device_list,
29
+ parse_site_list,
30
+ )
31
+ from .ratelimit import CreditLedger, RateLimit
32
+
33
+
34
+ def _fmt_time(value: str | datetime | None) -> str | None:
35
+ if value is None:
36
+ return None
37
+ if isinstance(value, datetime):
38
+ return value.isoformat()
39
+ return value
40
+
41
+
42
+ class SolarEdgeOneClient:
43
+ """Thin async wrapper over the SolarEdge ONE v2 REST API.
44
+
45
+ The caller injects an ``aiohttp.ClientSession``. Only endpoints confirmed in
46
+ Phase 0 are implemented; query-param names for the time-series endpoints are
47
+ best-effort and may need adjustment once the docs are captured.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ session: aiohttp.ClientSession,
53
+ api_key: str,
54
+ *,
55
+ account_key: str | None = None,
56
+ base_url: str = DEFAULT_BASE_URL,
57
+ timeout: float = DEFAULT_TIMEOUT,
58
+ ledger: CreditLedger | None = None,
59
+ ) -> None:
60
+ self._session = session
61
+ self._api_key = api_key
62
+ self._account_key = account_key
63
+ self._base_url = base_url.rstrip("/")
64
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
65
+ self.ledger = ledger
66
+ self.rate_limit: RateLimit | None = None
67
+
68
+ # -- internals -----------------------------------------------------------
69
+ def _headers(self) -> dict[str, str]:
70
+ headers = {"Accept": "application/json", HEADER_API_KEY: self._api_key}
71
+ if self._account_key:
72
+ headers[HEADER_ACCOUNT_KEY] = self._account_key
73
+ return headers
74
+
75
+ async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
76
+ url = f"{self._base_url}{path}"
77
+ clean_params = {k: v for k, v in (params or {}).items() if v is not None}
78
+ try:
79
+ async with self._session.get(
80
+ url,
81
+ headers=self._headers(),
82
+ params=clean_params or None,
83
+ timeout=self._timeout,
84
+ ) as resp:
85
+ self.rate_limit = RateLimit.from_headers(resp.headers)
86
+ await self._raise_for_status(resp)
87
+ data = await resp.json()
88
+ except aiohttp.ClientError as err:
89
+ raise SolarEdgeConnectionError(f"Request to {path} failed: {err}") from err
90
+
91
+ # Count the credit only on a successful call (1 call = 1 credit).
92
+ if self.ledger is not None:
93
+ self.ledger.record(now=datetime.now(UTC))
94
+ return data
95
+
96
+ async def _raise_for_status(self, resp: aiohttp.ClientResponse) -> None:
97
+ if resp.status < 400:
98
+ return
99
+ payload: Any = None
100
+ try:
101
+ payload = await resp.json()
102
+ except (aiohttp.ContentTypeError, ValueError):
103
+ payload = await resp.text()
104
+
105
+ if resp.status in (401, 403):
106
+ raise SolarEdgeAuthError(
107
+ "Authentication failed — check the API key",
108
+ status=resp.status,
109
+ payload=payload,
110
+ )
111
+ if resp.status == 404:
112
+ raise SolarEdgeNotFoundError(
113
+ f"Resource not found: {resp.url.path}",
114
+ status=resp.status,
115
+ payload=payload,
116
+ )
117
+ if resp.status == 429:
118
+ retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
119
+ raise SolarEdgeRateLimitError(
120
+ "Rate limit exceeded",
121
+ retry_after=retry_after,
122
+ status=resp.status,
123
+ payload=payload,
124
+ )
125
+ raise SolarEdgeApiError(
126
+ f"Unexpected API error (HTTP {resp.status})",
127
+ status=resp.status,
128
+ payload=payload,
129
+ )
130
+
131
+ # -- endpoints -----------------------------------------------------------
132
+ async def get_sites(self) -> list[Site]:
133
+ """``GET /sites`` — all sites visible to the token."""
134
+ return parse_site_list(await self._get("/sites"))
135
+
136
+ async def get_site_overview(self, site_id: int | str) -> SiteOverview:
137
+ """``GET /sites/{id}/overview`` — cumulative production/consumption."""
138
+ return SiteOverview.from_dict(await self._get(f"/sites/{site_id}/overview"))
139
+
140
+ async def get_devices(self, site_id: int | str) -> list[Device]:
141
+ """``GET /sites/{id}/devices`` — site inventory."""
142
+ return parse_device_list(await self._get(f"/sites/{site_id}/devices"))
143
+
144
+ async def get_energy(
145
+ self,
146
+ site_id: int | str,
147
+ *,
148
+ date_from: str | datetime | None = None,
149
+ date_to: str | datetime | None = None,
150
+ resolution: str | None = None,
151
+ ) -> TimeSeries:
152
+ """``GET /sites/{id}/energy`` — energy time series (Wh per interval)."""
153
+ params = {
154
+ "from": _fmt_time(date_from),
155
+ "to": _fmt_time(date_to),
156
+ "resolution": resolution,
157
+ }
158
+ return TimeSeries.from_dict(await self._get(f"/sites/{site_id}/energy", params))
159
+
160
+ async def get_power(
161
+ self,
162
+ site_id: int | str,
163
+ *,
164
+ date_from: str | datetime | None = None,
165
+ date_to: str | datetime | None = None,
166
+ resolution: str | None = None,
167
+ ) -> TimeSeries:
168
+ """``GET /sites/{id}/power`` — power time series (W, 15-min average)."""
169
+ params = {
170
+ "from": _fmt_time(date_from),
171
+ "to": _fmt_time(date_to),
172
+ "resolution": resolution,
173
+ }
174
+ return TimeSeries.from_dict(await self._get(f"/sites/{site_id}/power", params))
175
+
176
+ async def get_alerts(self, site_id: int | str) -> list[dict[str, Any]]:
177
+ """``GET /sites/{id}/alerts`` — returns a list (empty when none)."""
178
+ data = await self._get(f"/sites/{site_id}/alerts")
179
+ if isinstance(data, dict):
180
+ for key in ("alerts", "alert", "data", "items"):
181
+ value = data.get(key)
182
+ if isinstance(value, list):
183
+ return cast("list[dict[str, Any]]", value)
184
+ return []
185
+ if isinstance(data, list):
186
+ return cast("list[dict[str, Any]]", data)
187
+ return []
188
+
189
+ async def validate(self) -> list[Site]:
190
+ """Cheapest credential check for a config flow: list sites.
191
+
192
+ Raises ``SolarEdgeAuthError`` on bad credentials.
193
+ """
194
+ return await self.get_sites()
195
+
196
+
197
+ def _parse_retry_after(value: str | None) -> float | None:
198
+ if value is None:
199
+ return None
200
+ try:
201
+ return float(value)
202
+ except ValueError:
203
+ return None
@@ -0,0 +1,20 @@
1
+ """Constants for the SolarEdge ONE (Monitoring v2) API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ DEFAULT_BASE_URL = "https://monitoringapi.solaredge.com/v2"
6
+ DEFAULT_TIMEOUT = 30.0
7
+
8
+ # Auth headers (v2 uses headers, not a bearer token).
9
+ HEADER_API_KEY = "X-API-Key"
10
+ HEADER_ACCOUNT_KEY = "X-Account-Key"
11
+
12
+ # Rate-limit response headers observed in Phase 0. Only per-minute is exposed;
13
+ # the monthly credit quota must be tracked locally (see CreditLedger).
14
+ HEADER_RATELIMIT_LIMIT = "x-ratelimit-limit-minute"
15
+ HEADER_RATELIMIT_REMAINING = "x-ratelimit-remaining-minute"
16
+
17
+ # Resolutions accepted by the time-series endpoints (values seen: QUARTER_HOUR).
18
+ RESOLUTION_QUARTER_HOUR = "QUARTER_HOUR"
19
+ RESOLUTION_HOUR = "HOUR"
20
+ RESOLUTION_DAY = "DAY"
@@ -0,0 +1,38 @@
1
+ """Exceptions raised by the SolarEdge ONE client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SolarEdgeError(Exception):
7
+ """Base class for all SolarEdge ONE client errors."""
8
+
9
+
10
+ class SolarEdgeConnectionError(SolarEdgeError):
11
+ """Network-level failure talking to the API (timeout, DNS, connection)."""
12
+
13
+
14
+ class SolarEdgeApiError(SolarEdgeError):
15
+ """The API returned an unexpected error status."""
16
+
17
+ def __init__(self, message: str, *, status: int | None = None,
18
+ payload: object | None = None) -> None:
19
+ super().__init__(message)
20
+ self.status = status
21
+ self.payload = payload
22
+
23
+
24
+ class SolarEdgeAuthError(SolarEdgeApiError):
25
+ """Authentication failed (HTTP 401/403) — invalid or missing API key."""
26
+
27
+
28
+ class SolarEdgeNotFoundError(SolarEdgeApiError):
29
+ """Requested resource or endpoint does not exist (HTTP 404)."""
30
+
31
+
32
+ class SolarEdgeRateLimitError(SolarEdgeApiError):
33
+ """Rate limit exceeded (HTTP 429)."""
34
+
35
+ def __init__(self, message: str, *, retry_after: float | None = None,
36
+ status: int | None = 429, payload: object | None = None) -> None:
37
+ super().__init__(message, status=status, payload=payload)
38
+ self.retry_after = retry_after
@@ -0,0 +1,235 @@
1
+ """Typed models for SolarEdge ONE (Monitoring v2) responses.
2
+
3
+ Shapes are derived from Phase 0 live captures (see ../prd fixtures). All models
4
+ are tolerant of missing/null fields — the API returns nulls freely on sites
5
+ without meters or storage.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+
14
+ def _as_float(value: Any) -> float | None:
15
+ if value is None:
16
+ return None
17
+ try:
18
+ return float(value)
19
+ except (TypeError, ValueError):
20
+ return None
21
+
22
+
23
+ def _as_int(value: Any) -> int | None:
24
+ if value is None:
25
+ return None
26
+ try:
27
+ return int(value)
28
+ except (TypeError, ValueError):
29
+ return None
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class Site:
34
+ """A site as returned by ``GET /sites``."""
35
+
36
+ site_id: int
37
+ name: str | None = None
38
+ peak_power: float | None = None
39
+ installation_date: str | None = None
40
+ location: str | None = None
41
+ activation_status: str | None = None
42
+ note: str | None = None
43
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
44
+
45
+ @property
46
+ def is_active(self) -> bool:
47
+ return (self.activation_status or "").upper() == "ACTIVE"
48
+
49
+ @classmethod
50
+ def from_dict(cls, data: dict[str, Any]) -> Site:
51
+ return cls(
52
+ site_id=int(data["siteId"]),
53
+ name=data.get("name"),
54
+ peak_power=_as_float(data.get("peakPower")),
55
+ installation_date=data.get("installationDate"),
56
+ location=data.get("location"),
57
+ activation_status=data.get("activationStatus"),
58
+ note=data.get("note"),
59
+ raw=data,
60
+ )
61
+
62
+
63
+ def parse_site_list(data: dict[str, Any]) -> list[Site]:
64
+ """Parse the nested ``{"sites": {"count": N, "site": [...]}}`` envelope."""
65
+ container = data.get("sites", data)
66
+ if isinstance(container, dict):
67
+ items = container.get("site") or container.get("sites") or []
68
+ elif isinstance(container, list):
69
+ items = container
70
+ else:
71
+ items = []
72
+ return [Site.from_dict(item) for item in items if isinstance(item, dict)]
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class ProductionOverview:
77
+ total: float | None = None
78
+ unit: str | None = None
79
+ to_self_consumption: float | None = None
80
+ to_storage: float | None = None
81
+ to_grid: float | None = None
82
+
83
+ @classmethod
84
+ def from_dict(cls, data: dict[str, Any] | None) -> ProductionOverview:
85
+ data = data or {}
86
+ return cls(
87
+ total=_as_float(data.get("total")),
88
+ unit=data.get("unit"),
89
+ to_self_consumption=_as_float(data.get("toSelfConsumption")),
90
+ to_storage=_as_float(data.get("toStorage")),
91
+ to_grid=_as_float(data.get("toGrid")),
92
+ )
93
+
94
+
95
+ @dataclass(slots=True)
96
+ class ConsumptionOverview:
97
+ total: float | None = None
98
+ unit: str | None = None
99
+ from_pv: float | None = None
100
+ from_storage: float | None = None
101
+ from_grid: float | None = None
102
+
103
+ @classmethod
104
+ def from_dict(cls, data: dict[str, Any] | None) -> ConsumptionOverview:
105
+ data = data or {}
106
+ return cls(
107
+ total=_as_float(data.get("total")),
108
+ unit=data.get("unit"),
109
+ from_pv=_as_float(data.get("fromPv")),
110
+ from_storage=_as_float(data.get("fromStorage")),
111
+ from_grid=_as_float(data.get("fromGrid")),
112
+ )
113
+
114
+
115
+ @dataclass(slots=True)
116
+ class SiteOverview:
117
+ """Cumulative lifetime totals from ``GET /sites/{id}/overview``."""
118
+
119
+ site_id: int | None
120
+ production: ProductionOverview
121
+ consumption: ConsumptionOverview
122
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
123
+
124
+ @classmethod
125
+ def from_dict(cls, data: dict[str, Any]) -> SiteOverview:
126
+ return cls(
127
+ site_id=_as_int(data.get("siteId")),
128
+ production=ProductionOverview.from_dict(data.get("production")),
129
+ consumption=ConsumptionOverview.from_dict(data.get("consumption")),
130
+ raw=data,
131
+ )
132
+
133
+
134
+ @dataclass(slots=True)
135
+ class Device:
136
+ """A device from ``GET /sites/{id}/devices`` (inverter, meter, battery...)."""
137
+
138
+ type: str | None = None
139
+ serial_number: str | None = None
140
+ manufacturer: str | None = None
141
+ part_number: str | None = None
142
+ created_at: str | None = None
143
+ firmware_version: str | None = None
144
+ active: bool | None = None
145
+ name: str | None = None
146
+ communication_type: str | None = None
147
+ firmware: str | None = None
148
+ connected_optimizers: int | None = None
149
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
150
+
151
+ @classmethod
152
+ def from_dict(cls, data: dict[str, Any]) -> Device:
153
+ return cls(
154
+ type=data.get("type"),
155
+ serial_number=data.get("serialNumber"),
156
+ manufacturer=data.get("manufacturer"),
157
+ part_number=data.get("partNumber"),
158
+ created_at=data.get("createdAt"),
159
+ firmware_version=data.get("firmwareVersion"),
160
+ active=data.get("active"),
161
+ name=data.get("name"),
162
+ communication_type=data.get("communicationType"),
163
+ firmware=data.get("firmware"),
164
+ connected_optimizers=_as_int(data.get("connectedOptimizers")),
165
+ raw=data,
166
+ )
167
+
168
+
169
+ def parse_device_list(data: Any) -> list[Device]:
170
+ """Devices may be a bare list or wrapped; be liberal."""
171
+ if isinstance(data, dict):
172
+ for key in ("devices", "device", "data", "items"):
173
+ if isinstance(data.get(key), list):
174
+ data = data[key]
175
+ break
176
+ if not isinstance(data, list):
177
+ return []
178
+ return [Device.from_dict(item) for item in data if isinstance(item, dict)]
179
+
180
+
181
+ @dataclass(slots=True)
182
+ class TimeValue:
183
+ timestamp: str
184
+ value: float | None
185
+
186
+ @classmethod
187
+ def from_dict(cls, data: dict[str, Any]) -> TimeValue:
188
+ return cls(timestamp=data.get("timestamp", ""), value=_as_float(data.get("value")))
189
+
190
+
191
+ @dataclass(slots=True)
192
+ class TimeSeries:
193
+ """Response shape of ``/energy`` and ``/power`` (time series of values)."""
194
+
195
+ period_from: str | None
196
+ period_to: str | None
197
+ unit: str | None
198
+ resolution: str | None
199
+ values: list[TimeValue] = field(default_factory=list)
200
+
201
+ @property
202
+ def non_null_values(self) -> list[TimeValue]:
203
+ return [v for v in self.values if v.value is not None]
204
+
205
+ @property
206
+ def latest_value(self) -> float | None:
207
+ """Most recent non-null value (use for 'current power')."""
208
+ for v in reversed(self.values):
209
+ if v.value is not None:
210
+ return v.value
211
+ return None
212
+
213
+ @property
214
+ def latest(self) -> TimeValue | None:
215
+ for v in reversed(self.values):
216
+ if v.value is not None:
217
+ return v
218
+ return None
219
+
220
+ @property
221
+ def total(self) -> float:
222
+ """Sum of non-null values (e.g. energy accumulated over the period)."""
223
+ return sum(v.value for v in self.values if v.value is not None)
224
+
225
+ @classmethod
226
+ def from_dict(cls, data: dict[str, Any]) -> TimeSeries:
227
+ period = data.get("period") or {}
228
+ raw_values = data.get("values") or []
229
+ return cls(
230
+ period_from=period.get("from"),
231
+ period_to=period.get("to"),
232
+ unit=data.get("unit"),
233
+ resolution=data.get("resolution"),
234
+ values=[TimeValue.from_dict(v) for v in raw_values if isinstance(v, dict)],
235
+ )
File without changes
@@ -0,0 +1,93 @@
1
+ """Rate-limit + credit tracking helpers.
2
+
3
+ Phase 0 finding: the API exposes only a *per-minute* limit via response headers
4
+ (``x-ratelimit-limit-minute`` / ``x-ratelimit-remaining-minute``). The monthly
5
+ credit quota (e.g. 2000/month) is NOT exposed, so it must be tracked locally.
6
+ ``CreditLedger`` provides that local accounting and is JSON-serialisable so the
7
+ Home Assistant integration can persist it across restarts.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+ from typing import Any
16
+
17
+ from .const import HEADER_RATELIMIT_LIMIT, HEADER_RATELIMIT_REMAINING
18
+
19
+
20
+ def _month_key(now: datetime) -> str:
21
+ return f"{now.year:04d}-{now.month:02d}"
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class RateLimit:
26
+ """Per-minute rate-limit snapshot parsed from response headers."""
27
+
28
+ limit_minute: int | None = None
29
+ remaining_minute: int | None = None
30
+
31
+ @classmethod
32
+ def from_headers(cls, headers: Mapping[str, str]) -> RateLimit:
33
+ # Header names are case-insensitive; aiohttp's CIMultiDict handles that,
34
+ # but fall back to a manual lower-cased lookup for plain dicts.
35
+ def _get(name: str) -> str | None:
36
+ if name in headers:
37
+ return headers[name]
38
+ lowered = {k.lower(): v for k, v in headers.items()}
39
+ return lowered.get(name.lower())
40
+
41
+ def _int(value: str | None) -> int | None:
42
+ if value is None:
43
+ return None
44
+ try:
45
+ return int(value)
46
+ except ValueError:
47
+ return None
48
+
49
+ return cls(
50
+ limit_minute=_int(_get(HEADER_RATELIMIT_LIMIT)),
51
+ remaining_minute=_int(_get(HEADER_RATELIMIT_REMAINING)),
52
+ )
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class CreditLedger:
57
+ """Local monthly-credit accounting (the API does not expose monthly quota).
58
+
59
+ Assumes 1 API call = 1 credit (Phase 0 finding). Resets automatically when
60
+ the calendar month changes.
61
+ """
62
+
63
+ monthly_budget: int
64
+ used: int = 0
65
+ month: str | None = None
66
+
67
+ def record(self, cost: int = 1, *, now: datetime | None = None) -> None:
68
+ now = now or datetime.now(UTC)
69
+ key = _month_key(now)
70
+ if self.month != key:
71
+ self.month = key
72
+ self.used = 0
73
+ self.used += cost
74
+
75
+ def remaining(self, *, now: datetime | None = None) -> int:
76
+ now = now or datetime.now(UTC)
77
+ if self.month != _month_key(now):
78
+ return self.monthly_budget
79
+ return max(0, self.monthly_budget - self.used)
80
+
81
+ def would_exceed(self, cost: int = 1, *, now: datetime | None = None) -> bool:
82
+ return self.remaining(now=now) < cost
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ return {"monthly_budget": self.monthly_budget, "used": self.used, "month": self.month}
86
+
87
+ @classmethod
88
+ def from_dict(cls, data: Mapping[str, Any]) -> CreditLedger:
89
+ return cls(
90
+ monthly_budget=int(data.get("monthly_budget", 0)),
91
+ used=int(data.get("used", 0)),
92
+ month=data.get("month"),
93
+ )
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.5
2
+ Name: aiosolaredge-one
3
+ Version: 0.2.0
4
+ Summary: Async client for the SolarEdge ONE (Monitoring v2) API
5
+ Project-URL: Homepage, https://github.com/CathalOConnorRH/solaredge-v2
6
+ Project-URL: Repository, https://github.com/CathalOConnorRH/solaredge-v2
7
+ Project-URL: Issues, https://github.com/CathalOConnorRH/solaredge-v2/issues
8
+ Project-URL: Changelog, https://github.com/CathalOConnorRH/solaredge-v2/blob/main/CHANGELOG.md
9
+ Author: aiosolaredge-one contributors
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 aiosolaredge-one contributors
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: async,home-assistant,monitoring,solar,solaredge
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Framework :: AsyncIO
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Programming Language :: Python :: 3.13
39
+ Classifier: Typing :: Typed
40
+ Requires-Python: >=3.11
41
+ Requires-Dist: aiohttp>=3.9
42
+ Provides-Extra: dev
43
+ Requires-Dist: mypy>=1.8; extra == 'dev'
44
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
45
+ Requires-Dist: pytest>=8.0; extra == 'dev'
46
+ Requires-Dist: ruff>=0.4; extra == 'dev'
47
+ Provides-Extra: publish
48
+ Requires-Dist: build>=1.2; extra == 'publish'
49
+ Requires-Dist: twine>=5.0; extra == 'publish'
50
+ Description-Content-Type: text/markdown
51
+
52
+ # aiosolaredge-one
53
+
54
+ Async Python client for the **SolarEdge ONE** (Monitoring API v2) platform. It
55
+ powers the
56
+ [SolarEdge ONE Home Assistant integration](https://github.com/CathalOConnorRH/ha-solaredge-one),
57
+ which lives in its own repo and depends on this package.
58
+
59
+ Base URL: `https://monitoringapi.solaredge.com/v2` · Auth: `X-API-Key` header
60
+ (plus optional `X-Account-Key` for Fleet accounts).
61
+
62
+ > Status: **alpha**. Endpoint coverage and models are derived from Phase 0 live
63
+ > captures. Some query-param names and the storage/battery/environmental paths
64
+ > are still unconfirmed pending docs.
65
+
66
+ ## Install (dev)
67
+
68
+ ```bash
69
+ python3 -m venv .venv && source .venv/bin/activate
70
+ pip install -e ".[dev]"
71
+ pytest
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ```python
77
+ import aiohttp
78
+ from aiosolaredge_one import SolarEdgeOneClient
79
+
80
+ async with aiohttp.ClientSession() as session:
81
+ client = SolarEdgeOneClient(session, api_key="...")
82
+ sites = await client.get_sites()
83
+ overview = await client.get_site_overview(sites[0].site_id)
84
+ print(overview.production.total, overview.production.unit)
85
+ power = await client.get_power(sites[0].site_id)
86
+ print("current power:", power.latest_value, power.unit)
87
+ ```
88
+
89
+ ## What it covers (Phase 0-confirmed endpoints)
90
+
91
+ | Method | Endpoint |
92
+ |--------|----------|
93
+ | `get_sites()` | `GET /sites` |
94
+ | `get_site_overview(id)` | `GET /sites/{id}/overview` |
95
+ | `get_devices(id)` | `GET /sites/{id}/devices` |
96
+ | `get_energy(id, ...)` | `GET /sites/{id}/energy` |
97
+ | `get_power(id, ...)` | `GET /sites/{id}/power` |
98
+ | `get_alerts(id)` | `GET /sites/{id}/alerts` |
99
+
100
+ Rate limiting: the client exposes `client.rate_limit` (per-minute limit +
101
+ remaining, parsed from response headers) and an optional `CreditLedger` for
102
+ tracking the monthly quota locally (not exposed by the API).
@@ -0,0 +1,12 @@
1
+ aiosolaredge_one/__init__.py,sha256=hF3GdTNHqhqT2T9k4e0svUnSnvjbLFXI87YQZToEHHI,1377
2
+ aiosolaredge_one/budget.py,sha256=P2-1nS4IGtMrsebxYgSPq40iVrQ0B_sknclIkkHnmUU,5443
3
+ aiosolaredge_one/client.py,sha256=5zZ8ApUAsP1sBdWy7nSWQeGD5nihbOX1NDNLJKDXPjE,6957
4
+ aiosolaredge_one/const.py,sha256=RVaY1-Nc6X21uIGDyCTnQyYgLmqIxhHXSyLy51FUhu8,741
5
+ aiosolaredge_one/exceptions.py,sha256=h1b4jwk4LWvsuw2yM8p0rv3h9Fr1-e1E4SrDtcabbXs,1230
6
+ aiosolaredge_one/models.py,sha256=iCGZTLEyH7gu3f5G8jcLv1GpicsrvniMUbC7HpgDYQU,7408
7
+ aiosolaredge_one/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ aiosolaredge_one/ratelimit.py,sha256=FPWOV8TlGRyloDsjtJT9H07KZmffYtM3aJFPlwkKuTE,3113
9
+ aiosolaredge_one-0.2.0.dist-info/METADATA,sha256=E_mbp10GSkVkndRwc7aOIcpNh5GgeHFS7BSZ-AnUihk,4301
10
+ aiosolaredge_one-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ aiosolaredge_one-0.2.0.dist-info/licenses/LICENSE,sha256=lxPKwJaSIe0P5wsDiL_2UEbcw2VTCmU-cop198q9N5c,1086
12
+ aiosolaredge_one-0.2.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 aiosolaredge-one contributors
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.