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.
- aiosolaredge_one/__init__.py +66 -0
- aiosolaredge_one/budget.py +145 -0
- aiosolaredge_one/client.py +203 -0
- aiosolaredge_one/const.py +20 -0
- aiosolaredge_one/exceptions.py +38 -0
- aiosolaredge_one/models.py +235 -0
- aiosolaredge_one/py.typed +0 -0
- aiosolaredge_one/ratelimit.py +93 -0
- aiosolaredge_one-0.2.0.dist-info/METADATA +102 -0
- aiosolaredge_one-0.2.0.dist-info/RECORD +12 -0
- aiosolaredge_one-0.2.0.dist-info/WHEEL +4 -0
- aiosolaredge_one-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|