fronius-solarapi 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,125 @@
1
+ """Async client for the Fronius Solar API."""
2
+
3
+ from .client import FroniusSolarApi
4
+ from .const import (
5
+ ApiVersion,
6
+ BatteryMode,
7
+ BydBatteryState,
8
+ DeviceClass,
9
+ InverterState,
10
+ LedColor,
11
+ LedState,
12
+ LgChemBatteryState,
13
+ MeterCategory,
14
+ MeterLocation,
15
+ OhmpilotState,
16
+ PowerFlowMeterLocation,
17
+ PowerFlowMode,
18
+ PowerFlowOhmpilotEcoState,
19
+ PowerFlowOhmpilotState,
20
+ SolarBatteryCellState,
21
+ StatusCode,
22
+ battery_controller_state,
23
+ inverter_state,
24
+ meter_location,
25
+ solar_battery_cell_states,
26
+ )
27
+ from .device_types import INVERTER_DEVICE_TYPES, InverterModel, inverter_model
28
+ from .exceptions import (
29
+ EndpointNotFoundError,
30
+ FroniusConnectionError,
31
+ FroniusError,
32
+ InvalidResponseError,
33
+ NotSupportedError,
34
+ RequestNotSupportedError,
35
+ StatusError,
36
+ )
37
+ from .models import (
38
+ ActiveDevice,
39
+ ActiveDeviceInfo,
40
+ ApiVersionInfo,
41
+ CommonInverterData,
42
+ CumulationInverterData,
43
+ DeviceDetails,
44
+ DeviceStatus,
45
+ InverterInfo,
46
+ LedStatus,
47
+ LoggerInfo,
48
+ LoggerLedInfo,
49
+ MeterData,
50
+ OhmpilotData,
51
+ PowerFlow,
52
+ PowerFlowInverter,
53
+ PowerFlowOhmpilot,
54
+ PowerFlowOhmpilotEco,
55
+ PowerFlowSecondaryMeter,
56
+ PowerFlowSite,
57
+ Response,
58
+ ResponseHead,
59
+ ResponseStatus,
60
+ StorageController,
61
+ StorageData,
62
+ StorageModule,
63
+ ThreePhaseInverterData,
64
+ )
65
+
66
+ __all__ = [
67
+ "INVERTER_DEVICE_TYPES",
68
+ "ActiveDevice",
69
+ "ActiveDeviceInfo",
70
+ "ApiVersion",
71
+ "ApiVersionInfo",
72
+ "BatteryMode",
73
+ "BydBatteryState",
74
+ "CommonInverterData",
75
+ "CumulationInverterData",
76
+ "DeviceClass",
77
+ "DeviceDetails",
78
+ "DeviceStatus",
79
+ "EndpointNotFoundError",
80
+ "FroniusConnectionError",
81
+ "FroniusError",
82
+ "FroniusSolarApi",
83
+ "InvalidResponseError",
84
+ "InverterInfo",
85
+ "InverterModel",
86
+ "InverterState",
87
+ "LedColor",
88
+ "LedState",
89
+ "LedStatus",
90
+ "LgChemBatteryState",
91
+ "LoggerInfo",
92
+ "LoggerLedInfo",
93
+ "MeterCategory",
94
+ "MeterData",
95
+ "MeterLocation",
96
+ "NotSupportedError",
97
+ "OhmpilotData",
98
+ "OhmpilotState",
99
+ "PowerFlow",
100
+ "PowerFlowInverter",
101
+ "PowerFlowMeterLocation",
102
+ "PowerFlowMode",
103
+ "PowerFlowOhmpilot",
104
+ "PowerFlowOhmpilotEco",
105
+ "PowerFlowOhmpilotEcoState",
106
+ "PowerFlowOhmpilotState",
107
+ "PowerFlowSecondaryMeter",
108
+ "PowerFlowSite",
109
+ "RequestNotSupportedError",
110
+ "Response",
111
+ "ResponseHead",
112
+ "ResponseStatus",
113
+ "SolarBatteryCellState",
114
+ "StatusCode",
115
+ "StatusError",
116
+ "StorageController",
117
+ "StorageData",
118
+ "StorageModule",
119
+ "ThreePhaseInverterData",
120
+ "battery_controller_state",
121
+ "inverter_model",
122
+ "inverter_state",
123
+ "meter_location",
124
+ "solar_battery_cell_states",
125
+ ]
@@ -0,0 +1,411 @@
1
+ """The Solar API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from collections.abc import Callable
9
+ from typing import Any, Final
10
+
11
+ import aiohttp
12
+
13
+ from .const import (
14
+ API_BASE_PATHS,
15
+ API_VERSION_PATH,
16
+ ApiVersion,
17
+ DeviceClass,
18
+ StatusCode,
19
+ )
20
+ from .exceptions import (
21
+ EndpointNotFoundError,
22
+ FroniusConnectionError,
23
+ InvalidResponseError,
24
+ NotSupportedError,
25
+ RequestNotSupportedError,
26
+ StatusError,
27
+ )
28
+ from .models import (
29
+ ActiveDeviceInfo,
30
+ ApiVersionInfo,
31
+ CommonInverterData,
32
+ CumulationInverterData,
33
+ InverterInfo,
34
+ LoggerInfo,
35
+ LoggerLedInfo,
36
+ MeterData,
37
+ OhmpilotData,
38
+ PowerFlow,
39
+ Response,
40
+ StorageData,
41
+ ThreePhaseInverterData,
42
+ )
43
+ from .parsers import (
44
+ JsonObject,
45
+ body_data,
46
+ parse_3p_inverter_data,
47
+ parse_active_device_info,
48
+ parse_active_devices,
49
+ parse_api_version,
50
+ parse_common_inverter_data,
51
+ parse_cumulation_inverter_data,
52
+ parse_head,
53
+ parse_inverter_info,
54
+ parse_logger_info,
55
+ parse_logger_led_info,
56
+ parse_meter_data,
57
+ parse_ohmpilot_data,
58
+ parse_power_flow,
59
+ parse_storage_data,
60
+ parse_system_inverter_data,
61
+ parse_system_meter_data,
62
+ parse_system_ohmpilot_data,
63
+ parse_system_storage_data,
64
+ )
65
+
66
+ _LOGGER = logging.getLogger(__name__)
67
+
68
+ ALL_VERSIONS: Final = frozenset(ApiVersion)
69
+ V1_ONLY: Final = frozenset({ApiVersion.V1})
70
+
71
+ # the spec allows two realtime requests in parallel (section 2.6)
72
+ MAX_CONCURRENT_REQUESTS: Final = 2
73
+ DEFAULT_TIMEOUT: Final = 10.0
74
+
75
+ # Datamanagers reach inverters, meters and storages over Solar Net, whose
76
+ # requests fail now and then and succeed when repeated (about 1 % of inverter
77
+ # requests on Symo devices end in LNRequestTimeout).
78
+ RETRY_STATUS_CODES: Final = frozenset(
79
+ {
80
+ StatusCode.LN_REQUEST_ERROR,
81
+ StatusCode.LN_REQUEST_TIMEOUT,
82
+ StatusCode.LN_PARSE_ERROR,
83
+ }
84
+ )
85
+ DEFAULT_STATUS_RETRIES: Final = 2
86
+ DEFAULT_RETRY_DELAY: Final = 0.5
87
+
88
+ _DEVICE_ID_PARAM: Final = {ApiVersion.V0: "DeviceIndex", ApiVersion.V1: "DeviceId"}
89
+ _V0_ACTIVE_DEVICE_CLASSES: Final = (
90
+ (DeviceClass.INVERTER, "inverters"),
91
+ (DeviceClass.SENSOR_CARD, "sensor_cards"),
92
+ (DeviceClass.STRING_CONTROL, "string_controls"),
93
+ )
94
+
95
+
96
+ class FroniusSolarApi:
97
+ """Client for the Solar API of one Fronius device.
98
+
99
+ The connection lifecycle stays with the caller: pass an
100
+ `aiohttp.ClientSession`. GEN24 firmware 1.35 and later redirects requests
101
+ from non-private IP addresses to HTTPS with a self-signed certificate, so a
102
+ session reaching such a device needs to be created with SSL verification
103
+ disabled.
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ session: aiohttp.ClientSession,
109
+ host: str,
110
+ *,
111
+ api_version: ApiVersion | None = None,
112
+ timeout: float | aiohttp.ClientTimeout = DEFAULT_TIMEOUT,
113
+ status_retries: int = DEFAULT_STATUS_RETRIES,
114
+ retry_delay: float = DEFAULT_RETRY_DELAY,
115
+ ) -> None:
116
+ """Initialize the client.
117
+
118
+ `host` is a host name, an IP address or a full URL. Without a scheme
119
+ plain HTTP is used, which is all most devices support. Without
120
+ `api_version` the highest version the device supports is used; it is
121
+ queried once on the first request.
122
+
123
+ A request answered with a transient Solar Net status code (see
124
+ `RETRY_STATUS_CODES`) is repeated up to `status_retries` times, after
125
+ waiting `retry_delay` seconds. Connection errors and timeouts are not
126
+ retried.
127
+ """
128
+ if status_retries < 0:
129
+ raise ValueError("status_retries must not be negative")
130
+ if retry_delay < 0:
131
+ raise ValueError("retry_delay must not be negative")
132
+ self._session = session
133
+ url = host.rstrip("/")
134
+ if "://" not in url:
135
+ url = f"http://{url}"
136
+ self._url = url
137
+ self._api_version = api_version
138
+ self._timeout = (
139
+ timeout
140
+ if isinstance(timeout, aiohttp.ClientTimeout)
141
+ else aiohttp.ClientTimeout(total=timeout)
142
+ )
143
+ self._semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
144
+ self._status_retries = status_retries
145
+ self._retry_delay = retry_delay
146
+
147
+ @property
148
+ def url(self) -> str:
149
+ """Return the base URL of the device, e.g. "http://192.168.0.10"."""
150
+ return self._url
151
+
152
+ @property
153
+ def api_version(self) -> ApiVersion | None:
154
+ """Return the API version in use, `None` before it has been resolved."""
155
+ return self._api_version
156
+
157
+ async def _get_json(self, path: str, params: dict[str, str] | None = None) -> Any:
158
+ url = f"{self._url}{path}"
159
+ _LOGGER.debug("Requesting %s %s", url, params or "")
160
+ try:
161
+ async with (
162
+ self._semaphore,
163
+ self._session.get(url, params=params, timeout=self._timeout) as res,
164
+ ):
165
+ if res.status == 404:
166
+ raise EndpointNotFoundError(f"{url} not found (HTTP 404)")
167
+ if res.status >= 400:
168
+ raise InvalidResponseError(f"{url} answered HTTP {res.status}")
169
+ text = await res.text()
170
+ except TimeoutError as err:
171
+ raise FroniusConnectionError(f"Request to {url} timed out") from err
172
+ except aiohttp.ClientError as err:
173
+ raise FroniusConnectionError(f"Request to {url} failed: {err}") from err
174
+ # the content type is text/javascript on some devices, so it is not checked
175
+ try:
176
+ return json.loads(text)
177
+ except json.JSONDecodeError as err:
178
+ raise InvalidResponseError(
179
+ f"{url} answered non-JSON: {text[:80]!r}"
180
+ ) from err
181
+
182
+ async def fetch_api_version(self) -> ApiVersionInfo:
183
+ """Query the highest API version the device supports.
184
+
185
+ Devices only serving API v0 answer with HTTP 404 (spec v0 section
186
+ 2.3.1), which is reported as version 0.
187
+ """
188
+ try:
189
+ raw = await self._get_json(API_VERSION_PATH)
190
+ except EndpointNotFoundError:
191
+ return ApiVersionInfo(
192
+ api_version=ApiVersion.V0,
193
+ base_url=API_BASE_PATHS[ApiVersion.V0],
194
+ compatibility_range=None,
195
+ )
196
+ return parse_api_version(raw)
197
+
198
+ async def _resolve_api_version(self) -> ApiVersion:
199
+ if self._api_version is None:
200
+ info = await self.fetch_api_version()
201
+ _LOGGER.debug(
202
+ "%s serves Solar API v%s (%s)",
203
+ self._url,
204
+ info.api_version,
205
+ info.compatibility_range,
206
+ )
207
+ self._api_version = info.api_version
208
+ return self._api_version
209
+
210
+ async def _request[T](
211
+ self,
212
+ endpoint: str,
213
+ parse: Callable[[JsonObject], T],
214
+ params: dict[str, str] | None = None,
215
+ *,
216
+ device_id: str | int | None = None,
217
+ versions: frozenset[ApiVersion] = ALL_VERSIONS,
218
+ ) -> Response[T]:
219
+ api_version = await self._resolve_api_version()
220
+ if api_version not in versions:
221
+ raise NotSupportedError(
222
+ f"{endpoint} is not available on Solar API v{api_version}"
223
+ )
224
+ query = dict(params or {})
225
+ if device_id is not None:
226
+ # the device id has to follow the scope parameter
227
+ query[_DEVICE_ID_PARAM[api_version]] = str(device_id)
228
+ if "DataCollection" in query:
229
+ query["DataCollection"] = query.pop("DataCollection")
230
+ path = f"{API_BASE_PATHS[api_version]}{endpoint}"
231
+ for retry in range(self._status_retries + 1):
232
+ if retry:
233
+ # the delay is spent outside the concurrency limit
234
+ await asyncio.sleep(self._retry_delay)
235
+ raw = await self._get_json(path, query)
236
+ head = parse_head(raw)
237
+ if head.status.code not in RETRY_STATUS_CODES:
238
+ break
239
+ _LOGGER.debug(
240
+ "%s answered status %s (%s), attempt %s of %s",
241
+ endpoint,
242
+ head.status.code,
243
+ head.status.reason,
244
+ retry + 1,
245
+ self._status_retries + 1,
246
+ )
247
+ if head.status.code != StatusCode.OKAY:
248
+ if head.status.code == StatusCode.NOT_SUPPORTED or (
249
+ head.status.code == StatusCode.UNKNOWN_ERROR
250
+ and "not supported" in head.status.reason.lower()
251
+ ):
252
+ raise RequestNotSupportedError(endpoint, head)
253
+ raise StatusError(endpoint, head)
254
+ return Response(head=head, data=parse(body_data(raw)))
255
+
256
+ async def inverter_info(self) -> Response[dict[str, InverterInfo]]:
257
+ """Return all inverters seen by the logging device, keyed by device id.
258
+
259
+ Inverters that have been offline for less than 24 hours are included.
260
+ """
261
+ return await self._request("GetInverterInfo.cgi", parse_inverter_info)
262
+
263
+ async def logger_info(self) -> Response[LoggerInfo]:
264
+ """Return information about the logging device.
265
+
266
+ GEN24/Tauro/Verto devices report the request as not supported.
267
+ """
268
+ return await self._request("GetLoggerInfo.cgi", parse_logger_info)
269
+
270
+ async def logger_led_info(self) -> Response[LoggerLedInfo]:
271
+ """Return the states of the LEDs of the logging device.
272
+
273
+ GEN24/Tauro/Verto devices report the request as not supported.
274
+ """
275
+ return await self._request(
276
+ "GetLoggerLEDInfo.cgi", parse_logger_led_info, versions=V1_ONLY
277
+ )
278
+
279
+ async def active_device_info(self) -> Response[ActiveDeviceInfo]:
280
+ """Return the devices that are currently online.
281
+
282
+ API v0 has no system scope, so one request per device class it knows
283
+ is made and the header of the first one is returned.
284
+ """
285
+ if await self._resolve_api_version() is ApiVersion.V1:
286
+ return await self._request(
287
+ "GetActiveDeviceInfo.cgi",
288
+ parse_active_device_info,
289
+ {"DeviceClass": DeviceClass.SYSTEM},
290
+ )
291
+ classes: dict[str, Any] = {
292
+ "meters": {},
293
+ "ohmpilots": {},
294
+ "storages": {},
295
+ }
296
+ head = None
297
+ for device_class, attribute in _V0_ACTIVE_DEVICE_CLASSES:
298
+ response = await self._request(
299
+ "GetActiveDeviceInfo.cgi",
300
+ parse_active_devices,
301
+ {"DeviceClass": device_class},
302
+ )
303
+ head = head or response.head
304
+ classes[attribute] = response.data
305
+ if head is None: # pragma: no cover - the loop always runs
306
+ raise InvalidResponseError("No active device info received")
307
+ return Response(head=head, data=ActiveDeviceInfo(**classes))
308
+
309
+ async def inverter_common_data(
310
+ self, device_id: str | int
311
+ ) -> Response[CommonInverterData]:
312
+ """Return the `CommonInverterData` collection of one inverter."""
313
+ return await self._request(
314
+ "GetInverterRealtimeData.cgi",
315
+ parse_common_inverter_data,
316
+ {"Scope": "Device", "DataCollection": "CommonInverterData"},
317
+ device_id=device_id,
318
+ )
319
+
320
+ async def inverter_3p_data(
321
+ self, device_id: str | int
322
+ ) -> Response[ThreePhaseInverterData]:
323
+ """Return the `3PInverterData` collection of one three-phase inverter."""
324
+ return await self._request(
325
+ "GetInverterRealtimeData.cgi",
326
+ parse_3p_inverter_data,
327
+ {"Scope": "Device", "DataCollection": "3PInverterData"},
328
+ device_id=device_id,
329
+ )
330
+
331
+ async def inverter_cumulation_data(
332
+ self, device_id: str | int
333
+ ) -> Response[CumulationInverterData]:
334
+ """Return the `CumulationInverterData` collection of one inverter."""
335
+ return await self._request(
336
+ "GetInverterRealtimeData.cgi",
337
+ parse_cumulation_inverter_data,
338
+ {"Scope": "Device", "DataCollection": "CumulationInverterData"},
339
+ device_id=device_id,
340
+ )
341
+
342
+ async def system_inverter_data(self) -> Response[dict[str, CumulationInverterData]]:
343
+ """Return the cumulation values of all inverters, keyed by device id."""
344
+ return await self._request(
345
+ "GetInverterRealtimeData.cgi",
346
+ parse_system_inverter_data,
347
+ {"Scope": "System"},
348
+ )
349
+
350
+ async def meter_data(self, device_id: str | int) -> Response[MeterData]:
351
+ """Return the data of one meter."""
352
+ return await self._request(
353
+ "GetMeterRealtimeData.cgi",
354
+ parse_meter_data,
355
+ {"Scope": "Device"},
356
+ device_id=device_id,
357
+ versions=V1_ONLY,
358
+ )
359
+
360
+ async def system_meter_data(self) -> Response[dict[str, MeterData]]:
361
+ """Return the data of all meters, keyed by device id."""
362
+ return await self._request(
363
+ "GetMeterRealtimeData.cgi",
364
+ parse_system_meter_data,
365
+ {"Scope": "System"},
366
+ versions=V1_ONLY,
367
+ )
368
+
369
+ async def storage_data(self, device_id: str | int) -> Response[StorageData]:
370
+ """Return the data of one storage."""
371
+ return await self._request(
372
+ "GetStorageRealtimeData.cgi",
373
+ parse_storage_data,
374
+ {"Scope": "Device"},
375
+ device_id=device_id,
376
+ versions=V1_ONLY,
377
+ )
378
+
379
+ async def system_storage_data(self) -> Response[dict[str, StorageData]]:
380
+ """Return the data of all storages, keyed by device id."""
381
+ return await self._request(
382
+ "GetStorageRealtimeData.cgi",
383
+ parse_system_storage_data,
384
+ {"Scope": "System"},
385
+ versions=V1_ONLY,
386
+ )
387
+
388
+ async def ohmpilot_data(self, device_id: str | int) -> Response[OhmpilotData]:
389
+ """Return the data of one Ohmpilot."""
390
+ return await self._request(
391
+ "GetOhmPilotRealtimeData.cgi",
392
+ parse_ohmpilot_data,
393
+ {"Scope": "Device"},
394
+ device_id=device_id,
395
+ versions=V1_ONLY,
396
+ )
397
+
398
+ async def system_ohmpilot_data(self) -> Response[dict[str, OhmpilotData]]:
399
+ """Return the data of all Ohmpilots, keyed by device id."""
400
+ return await self._request(
401
+ "GetOhmPilotRealtimeData.cgi",
402
+ parse_system_ohmpilot_data,
403
+ {"Scope": "System"},
404
+ versions=V1_ONLY,
405
+ )
406
+
407
+ async def power_flow(self) -> Response[PowerFlow]:
408
+ """Return the current power flow of the site."""
409
+ return await self._request(
410
+ "GetPowerFlowRealtimeData.fcgi", parse_power_flow, versions=V1_ONLY
411
+ )