From dbdd2eb96b52574910e46ca7587576166d990f14 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:27:33 +0100 Subject: [PATCH 01/12] Update version and add type and helper imports --- soliscloud_api/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/soliscloud_api/__init__.py b/soliscloud_api/__init__.py index 409ca8b..ad6ac06 100644 --- a/soliscloud_api/__init__.py +++ b/soliscloud_api/__init__.py @@ -7,7 +7,16 @@ from .client import SoliscloudError, SoliscloudHttpError # noqa: F401 from .client import SoliscloudTimeoutError, SoliscloudApiError # noqa: F401 from .entities import Plant, Inverter, Collector # noqa: F401 +from .types import ( # noqa: F401 + EntityType, + State, + InverterOfflineState, + InverterType, + PlantType, + CollectorState +) +from .helpers import Helpers # noqa: F401 # VERSION -VERSION = '1.3.0' +VERSION = '1.4.0' __version__ = VERSION From 2a3f353995045b779784e832b50ba52e4124dd34 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:29:23 +0100 Subject: [PATCH 02/12] Update pydoc --- soliscloud_api/client.py | 908 ++++++++++++++++++++++++++++++--------- 1 file changed, 697 insertions(+), 211 deletions(-) diff --git a/soliscloud_api/client.py b/soliscloud_api/client.py index 7a53f84..2df3bdc 100644 --- a/soliscloud_api/client.py +++ b/soliscloud_api/client.py @@ -53,12 +53,13 @@ ONLY_INV_ID_OR_SN_ERR = \ "Only pass one of inverter_id or inverter_sn as identifier" -INV_SN_ERR = "Pass inverter_sn as identifier" +INV_SN_ERR = "Cannot parse inverter serials, pass inverter_sn as int, str or list" # noqa: E501 ONLY_COL_ID_OR_SN_ERR = \ "Only pass one of collector_id or collector_sn as identifier" COL_SN_ERR = "Pass collector_sn as identifier" ONLY_STN_ID_OR_SN_ERR = \ "Only pass one of station_id or nmi_code as identifier" +EPM_SN_ERR = "Pass epm_sn as identifier" PAGE_SIZE_ERR = "page_size must be <= 100" WEATHER_SN_ERR = "Pass instrument_sn as identifier, \ containing weather instrument serial" @@ -104,10 +105,16 @@ def __str__(self): class SoliscloudAPI(): - """Class with functions for reading data from the Soliscloud Portal.""" + """Class with methods for reading data from the Soliscloud Portal. + All methods are asynchronous and require an aiohttp ClientSession. + Returned data is in JSON format as Python dict's.""" + DEFAULT_DOMAIN = 'https://www.soliscloud.com:13333' def __init__(self, domain: str, session: ClientSession) -> None: - self._domain = domain.rstrip("/") + if domain is None: + domain = SoliscloudAPI.DEFAULT_DOMAIN + else: + self._domain = domain.rstrip("/") self._session: ClientSession = session class DateFormat(Enum): @@ -117,17 +124,29 @@ class DateFormat(Enum): @property def domain(self) -> str: - """ Domain name.""" + """ Soliscloud domain URL. + + Returns: + str: Soliscloud domain URL. + """ return self._domain @property def session(self) -> ClientSession: - """ aiohttp client session ID.""" + """aiohttp client session ID. + + Returns: + ClientSession: aiohttp client session. + """ return self._session @property def spec_version(self) -> str: - """ supported version of the Soliscloud spec.""" + """Supported version of the Soliscloud spec. + + Returns: + str: Supported version of the Soliscloud spec. + """ return SUPPORTED_SPEC_VERSION # All methods take key and secret as positional arguments followed by @@ -137,12 +156,27 @@ async def user_station_list( page_no: int = 1, page_size: int = 20, nmi_code: str = None - ) -> dict[str, str]: - """Power station List""" - - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) - + ) -> dict[str, Any]: + """List of data of all Power stations under account. Results are paged. + + Args: + key_id (str): API key ID + secret (bytes): API secret + page_no (int, optional, keyword): Number of page to return. + Defaults to 1. + page_size (int, optional, keyword): Size of page. Defaults to 20. + Max 100. + nmi_code (str, optional, keyword): NMI code for AUS. Defaults to + None, only for AUS + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API containing data records\ + for all stations under account + """ + SoliscloudAPI._precondition_page_size(page_size) params: dict[str, Any] = {'pageNo': page_no, 'pageSize': page_size} if nmi_code is not None: params['nmiCode'] = nmi_code @@ -154,12 +188,27 @@ async def station_detail( self, key_id: str, secret: bytes, /, *, station_id: int, nmi_code: str = None - ) -> dict[str, str]: - """Power station details""" + ) -> dict[str, Any]: + """Power station details + + Args: + key_id (str): API key ID + secret (bytes): API secret + station_id (int, optional, keyword): Station ID. Defaults to None. + Either station ID or NMI code must be provided + nmi_code (str, optional, keyword): NMI code for AUS. Defaults to + None, only for AUS. Either station ID or NMI code must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API containing station details + """ - params: dict[str, Any] = {'id': station_id} - if nmi_code is not None: - params['nmiCode'] = nmi_code + params: dict[str, Any] =\ + SoliscloudAPI._precondition_station_or_nmi(station_id, nmi_code) return await self._get_data(STATION_DETAIL, key_id, secret, params) async def collector_list( @@ -168,12 +217,28 @@ async def collector_list( page_size: int = 20, station_id: int = None, nmi_code: str = None - ) -> dict[str, str]: - """Datalogger list""" - - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) - + ) -> dict[str, Any]: + """Datalogger list. Results are paged. Returns datalogger info under + station_id/nmi_code if given, else info for all dataloggers under + account. + + Args: + key_id (str): API key ID + secret (bytes): API secret + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Page size. Defaults to 20. + Max 100. + station_id (int, optional, keyword): Station ID. Defaults to None. + nmi_code (str, optional, keyword): NMI code for AUS. Defaults to + None, only for AUS + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ + SoliscloudAPI._precondition_page_size(page_size) params: dict[str, Any] = {'pageNo': page_no, 'pageSize': page_size} if station_id is not None: params['stationId'] = station_id @@ -185,16 +250,29 @@ async def collector_detail( self, key_id: str, secret: bytes, /, *, collector_sn: int = None, collector_id: str = None - ) -> dict[str, str]: - """Datalogger details""" - - params: dict[str, Any] = {} - if (collector_sn is not None and collector_id is None): - params['sn'] = collector_sn - elif (collector_sn is None and collector_id is not None): - params['id'] = collector_id - else: - raise SoliscloudError(ONLY_COL_ID_OR_SN_ERR) + ) -> dict[str, Any]: + """Datalogger details. Returns all datalogger details for given + serial number or datalogger ID. + + Args: + key_id (str): API key ID + secret (bytes): API secret + collector_sn (int, optional, keyword): Collector serial number. + Defaults to None. Either serial number or collector ID must be + provided. + collector_id (str, optional, keyword): Collector ID. + Defaults to None. Either serial number or collector ID must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ + params: dict[str, Any] =\ + SoliscloudAPI._precondition_collector_id_or_sn( + collector_id, collector_sn) return await self._get_data(COLLECTOR_DETAIL, key_id, secret, params) async def collector_day( @@ -202,9 +280,23 @@ async def collector_day( collector_sn: int = None, time: str, time_zone: int, - ) -> dict[str, str]: - """Datalogger day statistics""" + ) -> dict[str, Any]: + """Datalogger day statistics + + Args: + key_id (str): API key ID + secret (bytes): API secret + time (str): Date string in format YYYY-MM-DD + time_zone (int): Time zone offset from UTC in hours + collector_sn (int, optional, keyword): Collector serial number. + Defaults to None. + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API containing daily statistics + """ SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.DAY, time) params: dict[str, Any] = { 'time': time, @@ -223,12 +315,31 @@ async def inverter_list( page_size: int = 20, station_id: str = None, nmi_code: str = None - ) -> dict[str, str]: - """Inverter list""" - - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) - + ) -> dict[str, Any]: + """Inverter list + Returns data records for all inverters under station_id/nmi_code if + given, else all inverters under account. + + Args: + key_id (str): API key ID + secret (bytes): API secret + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Page size. Defaults to 20. + Max 100. + station_id (str, optional, keyword): Station ID. Defaults to None. + If neither stationID nor NMI code are provided then all + inverters under account are returned. + nmi_code (str, optional, keyword): NMI code for AUS. Defaults to + None,only for AUS. If neither stationID nor NMI code are + provided then all inverters under account are returned. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API containing inverter list + """ + SoliscloudAPI._precondition_page_size(page_size) params: dict[str, Any] = {'pageNo': page_no, 'pageSize': page_size} if station_id is not None: # If not specified all inverters for all stations for key_id are @@ -242,16 +353,27 @@ async def inverter_detail( self, key_id: str, secret: bytes, /, *, inverter_sn: int = None, inverter_id: str = None - ) -> dict[str, str]: - """Inverter details""" - - params: dict[str, Any] = {} - if (inverter_sn is not None and inverter_id is None): - params['sn'] = inverter_sn - elif (inverter_sn is None and inverter_id is not None): - params['id'] = inverter_id - else: - raise SoliscloudError(ONLY_INV_ID_OR_SN_ERR) + ) -> dict[str, Any]: + """Inverter details for specified inverter + + Args: + key_id (str): API key ID + secret (bytes): API secret + inverter_sn (int, optional, keyword): Inverter serial number. + Defaults to None. Either serial number or inverter ID must be + provided. + inverter_id (str, optional, keyword): Inverter ID. Defaults to + None. Either serial number or inverter ID must be provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from containing details for + specified inverter + """ + params: dict[str, Any] = SoliscloudAPI._precondition_inverter_id_or_sn( + inverter_id, inverter_sn) return await self._get_data(INVERTER_DETAIL, key_id, secret, params) async def station_day( @@ -261,23 +383,35 @@ async def station_day( time_zone: int, station_id: int = None, nmi_code=None - ) -> dict[str, str]: - """Station daily graph""" - + ) -> dict[str, Any]: + """Station daily graph containing records for specified day + + Args: + key_id (str): API key ID + secret (bytes): API secret + currency (str): Currency code, e.g. "USD" + time (str): Date string in format YYYY-MM-DD + time_zone (int): Time zone offset from UTC in hours + station_id (int, optional, keyword): Station ID. Defaults to None. + Either station ID or NMI code must be provided + nmi_code (str, optional, keyword): NMI code for AUS. Defaults to + None, only for AUS. Either station ID or NMI code must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.DAY, time) params: dict[str, Any] = { 'money': currency, 'time': time, 'timeZone': time_zone } - - if (station_id is not None and nmi_code is None): - params['id'] = station_id - elif (station_id is None and nmi_code is not None): - params['nmiCode'] = nmi_code - else: - raise SoliscloudError(ONLY_STN_ID_OR_SN_ERR) - + id = SoliscloudAPI._precondition_station_or_nmi(station_id, nmi_code) + params.update(id) return await self._get_data(STATION_DAY, key_id, secret, params) async def station_month( @@ -286,19 +420,30 @@ async def station_month( month: str, station_id: int = None, nmi_code=None - ) -> dict[str, str]: - """Station monthly graph""" - + ) -> dict[str, Any]: + """Station monthly graph containing records for specified month + + Args: + key_id (str): API key ID + secret (bytes): API secret + currency (str): Currency code, e.g. "USD" + month (str): Date string in format YYYY-MM + station_id (int, optional, keyword): Station ID. Defaults to None. + Either station ID or NMI code must be provided + nmi_code (str, optional, keyword): NMI code for AUS. Defaults to + None, only for AUS. Either station ID or NMI code must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.MONTH, month) params: dict[str, Any] = {'money': currency, 'month': month} - - if (station_id is not None and nmi_code is None): - params['id'] = station_id - elif (station_id is None and nmi_code is not None): - params['nmiCode'] = nmi_code - else: - raise SoliscloudError(ONLY_STN_ID_OR_SN_ERR) - + id = SoliscloudAPI._precondition_station_or_nmi(station_id, nmi_code) + params.update(id) return await self._get_data(STATION_MONTH, key_id, secret, params) async def station_year( @@ -307,19 +452,30 @@ async def station_year( year: str, station_id: int = None, nmi_code=None - ) -> dict[str, str]: - """Station yearly graph""" - + ) -> dict[str, Any]: + """Station yearly graph containing records for specified year + + Args: + key_id (str): API key ID + secret (bytes): API secret + currency (str): Currency code, e.g. "USD" + year (str): Date string in format YYYY + station_id (int, optional, keyword): Station ID. Defaults to None. + Either station ID or NMI code must be provided + nmi_code (str, optional, keyword): NMI code for AUS. Defaults to + None, only for AUS. Either station ID or NMI code must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.YEAR, year) params: dict[str, Any] = {'money': currency, 'year': year} - - if (station_id is not None and nmi_code is None): - params['id'] = station_id - elif (station_id is None and nmi_code is not None): - params['nmiCode'] = nmi_code - else: - raise SoliscloudError(ONLY_STN_ID_OR_SN_ERR) - + id = SoliscloudAPI._precondition_station_or_nmi(station_id, nmi_code) + params.update(id) return await self._get_data(STATION_YEAR, key_id, secret, params) async def station_all( @@ -327,17 +483,28 @@ async def station_all( currency: str, station_id: int = None, nmi_code: str = None - ) -> dict[str, str]: - """Station cumulative graph""" - + ) -> dict[str, Any]: + """Station cumulative graph + + Args: + key_id (str): API key ID + secret (bytes): API secret + currency (str): Currency code, e.g. "USD" + station_id (int, optional, keyword): Station ID. Defaults to None. + Either station ID or NMI code must be provided + nmi_code (str, optional, keyword): NMI code for AUS. Defaults to + None, only for AUS. Either station ID or NMI code must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ params: dict[str, Any] = {'money': currency} - if (station_id is not None and nmi_code is None): - params['id'] = station_id - elif (station_id is None and nmi_code is not None): - params['nmiCode'] = nmi_code - else: - raise SoliscloudError(ONLY_STN_ID_OR_SN_ERR) - + id = SoliscloudAPI._precondition_station_or_nmi(station_id, nmi_code) + params.update(id) return await self._get_data(STATION_ALL, key_id, secret, params) async def inverter_day( @@ -347,23 +514,36 @@ async def inverter_day( time_zone: int, inverter_id: int = None, inverter_sn: str = None - ) -> dict[str, str]: - """Inverter daily graph""" - + ) -> dict[str, Any]: + """Inverter daily graph containing records for specified day + + Args: + key_id (str): API key ID + secret (bytes): API secret + currency (str): Currency code, e.g. "USD" + time (str): Date string in format YYYY-MM-DD + time_zone (int): Time zone offset from UTC in hours + inverter_id (str, optional, keyword): Inverter ID. Defaults to + None. Either serial number or inverter ID must be provided. + inverter_sn (int, optional, keyword): Inverter serial number. + Defaults to None. Either serial number or inverter ID must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.DAY, time) params: dict[str, Any] = { 'money': currency, 'time': time, 'timeZone': time_zone } - - if (inverter_id is not None and inverter_sn is None): - params['id'] = inverter_id - elif (inverter_id is None and inverter_sn is not None): - params['sn'] = inverter_sn - else: - raise SoliscloudError(ONLY_INV_ID_OR_SN_ERR) - + id = SoliscloudAPI._precondition_inverter_id_or_sn( + inverter_id, inverter_sn) + params.update(id) return await self._get_data(INVERTER_DAY, key_id, secret, params) async def inverter_month( @@ -372,19 +552,32 @@ async def inverter_month( month: str, inverter_id: int = None, inverter_sn: str = None - ) -> dict[str, str]: - """Inverter monthly graph""" - + ) -> dict[str, Any]: + """Inverter monthly graph containing records for specified month + + Args: + key_id (str): API key ID + secret (bytes): API secret + currency (str): Currency code, e.g. "USD" + month (str): Date string in format YYYY-MM + inverter_id (str, optional, keyword): Inverter ID. Defaults to + None. Either serial number or inverter ID must be provided. + inverter_sn (int, optional, keyword): Inverter serial number. + Defaults to None. Either serial number or inverter ID must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.MONTH, month) params: dict[str, Any] = {'money': currency, 'month': month} - if (inverter_id is not None and inverter_sn is None): - params['id'] = inverter_id - elif (inverter_id is None and inverter_sn is not None): - params['sn'] = inverter_sn - else: - raise SoliscloudError(ONLY_INV_ID_OR_SN_ERR) - + id = SoliscloudAPI._precondition_inverter_id_or_sn( + inverter_id, inverter_sn) + params.update(id) return await self._get_data(INVERTER_MONTH, key_id, secret, params) async def inverter_year( @@ -393,19 +586,31 @@ async def inverter_year( year: str, inverter_id: int = None, inverter_sn: str = None - ) -> dict[str, str]: - """Inverter yearly graph""" - + ) -> dict[str, Any]: + """Inverter yearly graph containing records for specified year + + Args: + key_id (str): API key ID + secret (bytes): API secret + currency (str): Currency code, e.g. "USD" + year (str): Date string in format YYYY + inverter_id (str, optional, keyword): Inverter ID. Defaults to + None. Either serial number or inverter ID must be provided. + inverter_sn (int, optional, keyword): Inverter serial number. + Defaults to None. Either serial number or inverter ID must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.YEAR, year) params: dict[str, Any] = {'money': currency, 'year': year} - - if (inverter_id is not None and inverter_sn is None): - params['id'] = inverter_id - elif (inverter_id is None and inverter_sn is not None): - params['sn'] = inverter_sn - else: - raise SoliscloudError(ONLY_INV_ID_OR_SN_ERR) - + id = SoliscloudAPI._precondition_inverter_id_or_sn( + inverter_id, inverter_sn) + params.update(id) return await self._get_data(INVERTER_YEAR, key_id, secret, params) async def inverter_all( @@ -413,17 +618,29 @@ async def inverter_all( currency: str, inverter_id: int = None, inverter_sn: str = None - ) -> dict[str, str]: - """Inverter cumulative graph""" - + ) -> dict[str, Any]: + """The cumulative chart for the corresponding inverter. + + Args: + key_id (str): API key ID + secret (bytes): API secret + currency (str): Currency code, e.g. "USD" + inverter_id (str, optional, keyword): Inverter ID. Defaults to + None. Either serial number or inverter ID must be provided. + inverter_sn (int, optional, keyword): Inverter serial number. + Defaults to None. Either serial number or inverter ID must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ params: dict[str, Any] = {'money': currency} - if (inverter_id is not None and inverter_sn is None): - params['id'] = inverter_id - elif (inverter_id is None and inverter_sn is not None): - params['sn'] = inverter_sn - else: - raise SoliscloudError(ONLY_INV_ID_OR_SN_ERR) - + id = SoliscloudAPI._precondition_inverter_id_or_sn( + inverter_id, inverter_sn) + params.update(id) return await self._get_data(INVERTER_ALL, key_id, secret, params) async def inverter_shelf_time( @@ -431,19 +648,41 @@ async def inverter_shelf_time( page_no: int = 1, page_size: int = 20, inverter_sn: str = None - ) -> dict[str, str]: - """Inverter warranty information""" - - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) - if inverter_sn is None: - raise SoliscloudError(INV_SN_ERR) - + ) -> dict[str, Any]: + """Inverter warranty information + + Args: + key_id (str): API key ID + secret (bytes): API secret + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Size of page. Defaults to 20. + Max 100. + inverter_sn (Any, optional, keyword): One or inverter serial + numbers. Defaults to None. + Accepts: int, str, list[int], list[str] + in case of string the serial numbers should be comma-separated. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ + SoliscloudAPI._precondition_page_size(page_size) params: dict[str, Any] = { 'pageNo': page_no, - 'pageSize': page_size, - 'sn': inverter_sn} - + 'pageSize': page_size} + sn = "" + if type(inverter_sn) is int: + sn = str(inverter_sn) + elif type(inverter_sn) is str: + sn = inverter_sn + elif isinstance(inverter_sn, list): + sn = ",".join(str(i) for i in inverter_sn) + else: + raise SoliscloudError(INV_SN_ERR) + if inverter_sn is not None: + params['sn'] = sn return await self._get_records( INVERTER_SHELF_TIME, key_id, secret, params) @@ -456,12 +695,32 @@ async def alarm_list( begintime: str = None, endtime: str = None, nmi_code: str = None - ) -> dict[str, str]: - """Alarm check""" - - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) - + ) -> dict[str, Any]: + """Alarm check + + Args: + key_id (str): API key ID + secret (bytes): API secret + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Size of page. Defaults to 20. + Max 100. + station_id (int, optional, keyword): Station ID. Defaults to None. + device_sn (str, optional, keyword): Device serial number. + Defaults to None. + begintime (str, optional, keyword): Start date format YYYY-MM-DD. + Defaults to None. + endtime (str, optional, keyword): End date format YYYY-MM-DD. + Defaults to None. + nmi_code (str, optional, keyword): NMI code for AUS. Defaults to + None, only for AUS. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ + SoliscloudAPI._precondition_page_size(page_size) params: dict[str, Any] = {'pageNo': page_no, 'pageSize': page_size} if station_id is not None and device_sn is None: params['stationId'] = station_id @@ -482,13 +741,25 @@ async def station_detail_list( self, key_id: str, secret: bytes, /, *, page_no: int = 1, page_size: int = 20 - ) -> dict[str, str]: - """Batch acquire station details""" + ) -> dict[str, Any]: + """Batch acquire station details of all stations under account. + Paged results. - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) - params: dict[str, Any] = {'pageNo': page_no, 'pageSize': page_size} + Args: + key_id (str): API key ID + secret (bytes): API secret + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Size of page. Defaults to 20. + Max 100. + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ + SoliscloudAPI._precondition_page_size(page_size) + params: dict[str, Any] = {'pageNo': page_no, 'pageSize': page_size} return await self._get_records( STATION_DETAIL_LIST, key_id, secret, params) @@ -496,13 +767,25 @@ async def inverter_detail_list( self, key_id: str, secret: bytes, /, *, page_no: int = 1, page_size: int = 20 - ) -> dict[str, str]: - """Batch acquire inverter details""" + ) -> dict[str, Any]: + """Batch acquire inverter details of all inverters under account. + Paged results. - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) - params: dict[str, Any] = {'pageNo': page_no, 'pageSize': page_size} + Args: + key_id (str): API key ID + secret (bytes): API secret + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Size of page. Defaults to 20. + Max 100. + + Raises: + SoliscloudError: Any error during call + Returns: + dict[str, Any]: JSON response from API + """ + SoliscloudAPI._precondition_page_size(page_size) + params: dict[str, Any] = {'pageNo': page_no, 'pageSize': page_size} return await self._get_records( INVERTER_DETAIL_LIST, key_id, secret, params) @@ -511,11 +794,25 @@ async def station_day_energy_list( page_no: int = 1, page_size: int = 20, time: str - ) -> dict[str, str]: - """Batch acquire station daily generation""" - - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) + ) -> dict[str, Any]: + """Batch acquire station daily generation of all stations under + account. Paged results. + + Args: + key_id (str): API key ID + secret (bytes): API secret + time (str): Date string in format YYYY-MM-DD + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Size of page. Defaults to 20. + Max 100. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ + SoliscloudAPI._precondition_page_size(page_size) SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.DAY, time) params: dict[str, Any] = { 'pageNo': page_no, @@ -531,11 +828,25 @@ async def station_month_energy_list( page_no: int = 1, page_size: int = 20, month: str - ) -> dict[str, str]: - """Batch acquire station monthly generation""" - - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) + ) -> dict[str, Any]: + """Batch acquire station monthly generation of all stations under + account. Paged results. + + Args: + key_id (str): API key ID + secret (bytes): API secret + month (str): Date string in format YYYY-MM + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Size of page. Defaults to 20. + Max 100. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ + SoliscloudAPI._precondition_page_size(page_size) SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.MONTH, month) params: dict[str, Any] = { 'pageNo': page_no, @@ -551,11 +862,25 @@ async def station_year_energy_list( page_no: int = 1, page_size: int = 20, year: str - ) -> dict[str, str]: - """Batch acquire station yearly generation""" - - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) + ) -> dict[str, Any]: + """Batch acquire station yearly generation of all stations under + account. Paged results. + + Args: + key_id (str): API key ID + secret (bytes): API secret + year (str): Date string in format YYYY + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Size of page. Defaults to 20. + Max 100. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ + SoliscloudAPI._precondition_page_size(page_size) SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.YEAR, year) params: dict[str, Any] = { 'pageNo': page_no, @@ -571,11 +896,25 @@ async def epm_list( page_no: int = 1, page_size: int = 20, station_id: str = None - ) -> dict[str, str]: - """EPM list""" - - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) + ) -> dict[str, Any]: + """List of all EPMs for given station ID or all EPMs under account. + Paged results. + + Args: + key_id (str): API key ID + secret (bytes): API secret + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Size of page. Defaults to 20. + Max 100. + station_id (int, optional, keyword): Station ID. Defaults to None. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ + SoliscloudAPI._precondition_page_size(page_size) params: dict[str, Any] = {'pageNo': page_no, 'pageSize': page_size} if station_id is not None: params['stationId'] = station_id @@ -585,9 +924,19 @@ async def epm_list( async def epm_detail( self, key_id: str, secret: bytes, /, *, epm_sn: str - ) -> dict[str, str]: - """EPM details""" + ) -> dict[str, Any]: + """EPM details for given EPM serial number + Args: + key_id (str): API key ID + secret (bytes): API secret + epm_sn (str): Serial number of EPM + + Returns: + dict[str, Any]: JSON response from API + """ + if not epm_sn: + raise SoliscloudError(EPM_SN_ERR) params: dict[str, Any] = {'sn': epm_sn} return await self._get_data(EPM_DETAIL, key_id, secret, params) @@ -598,9 +947,37 @@ async def epm_day( epm_sn: str, time: str, time_zone: int - ) -> dict[str, str]: - """EPM daily graph""" - + ) -> dict[str, Any]: + """EPM daily graph + + Args: + key_id (str): API key ID + secret (bytes): API secret + searchinfo (str): Query fields, separated by commas for + multiple queries: + * u_ac1=VoltageU + * u_ac2=VoltageV + * u_ac3=VoltageW + * i_ac1=CurrentU + * i_ac2=CurrentV + * i_ac3=currentW + * p_ac1=PowerU + * p_ac2=PowerV + * p_ac3=powerW + * power_factor=grid power factor + * fac_meter=Grid frequency(Meter) + * p_load=total power of the load + * e_total_inverter=total output of the inverter + * e_total_load=total power consumption of the load + * e_total_buy=total electricity purchased + * e_total_sell=total electricity sold + epm_sn (str): Serial number of EPM + time (str): Date string in format YYYY-MM-DD + time_zone (int): Time zone offset from UTC in hours + + Returns: + dict[str, Any]: JSON response from API + """ SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.DAY, time) params: dict[str, Any] = { 'searchinfo': searchinfo, @@ -614,9 +991,18 @@ async def epm_month( self, key_id: str, secret: bytes, /, *, epm_sn: str, month: str, - ) -> dict[str, str]: - """EPM monthly graph""" + ) -> dict[str, Any]: + """EPM monthly graph + + Args: + key_id (str): API key ID + secret (bytes): API secret + epm_sn (str): Serial number of EPM + month (str): Date string in format YYYY-MM + Returns: + dict[str, Any]: JSON response from API + """ SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.MONTH, month) params: dict[str, Any] = {'sn': epm_sn, 'month': month} @@ -626,9 +1012,18 @@ async def epm_year( self, key_id: str, secret: bytes, /, *, epm_sn: str, year: str - ) -> dict[str, str]: - """EPM yearly graph""" + ) -> dict[str, Any]: + """EPM yearly graph + + Args: + key_id (str): API key ID + secret (bytes): API secret + epm_sn (str): Serial number of EPM + year (str): Date string in format YYYY + Returns: + dict[str, Any]: JSON response from API + """ SoliscloudAPI._verify_date(SoliscloudAPI.DateFormat.YEAR, year) params: dict[str, Any] = {'sn': epm_sn, 'year': year} @@ -637,9 +1032,17 @@ async def epm_year( async def epm_all( self, key_id: str, secret: bytes, /, *, epm_sn: str - ) -> dict[str, str]: - """EPM cumulative graph""" + ) -> dict[str, Any]: + """EPM cumulative graph + Args: + key_id (str): API key ID + secret (bytes): API secret + epm_sn (str): Serial number of EPM + + Returns: + dict[str, Any]: JSON response from API + """ params: dict[str, Any] = {'sn': epm_sn} return await self._get_data(EPM_ALL, key_id, secret, params) @@ -650,16 +1053,31 @@ async def weather_list( page_size: int = 20, station_id: str = None, nmi_code: str = None - ) -> dict[str, str]: - """Weather list""" - - if page_size > 100: - raise SoliscloudError(PAGE_SIZE_ERR) - + ) -> dict[str, Any]: + """List of weather data records per station. Returns records for all + stations under station_id/nmi_code if given, else all stations under + account. 'id' in each record is instrument ID. Paged results. + + Args: + key_id (str): API key ID + secret (bytes): API secret + page_no (int, optional, keyword): Page number. Defaults to 1. + page_size (int, optional, keyword): Size of page. Defaults to 20. + Max 100. + station_id (int, optional, keyword): Station ID. Defaults to None. + nmi_code (str, optional, keyword): NMI code for AUS. Defaults to + None, only for AUS. Either station ID or NMI code must be + provided. + + Raises: + SoliscloudError: Any error during call + + Returns: + dict[str, Any]: JSON response from API + """ + SoliscloudAPI._precondition_page_size(page_size) params: dict[str, Any] = {'pageNo': page_no, 'pageSize': page_size} if station_id is not None: - # If not specified all inverters for all stations for key_id are - # returned params['stationId'] = station_id if nmi_code is not None: params['nmiCode'] = nmi_code @@ -668,9 +1086,22 @@ async def weather_list( async def weather_detail( self, key_id: str, secret: bytes, /, *, instrument_sn: str = None - ) -> dict[str, str]: - """Inverter details""" + ) -> dict[str, Any]: + """Weather details for given instrument SN. + + Args: + key_id (str): API key ID + secret (bytes): API secret + instrument_sn (str, optional, keyword): Instrument serial. Spec is + unclear, but it looks like instrument_sn is equal to + collector_sn. Defaults to None. + + Raises: + SoliscloudError: Any error during call + Returns: + dict[str, Any]: JSON response from API + """ params: dict[str, Any] = {} if instrument_sn is None: raise SoliscloudError(WEATHER_SN_ERR) @@ -681,11 +1112,21 @@ async def weather_detail( async def _get_records( self, canonicalized_resource: str, key_id: str, secret: bytes, params: dict[str, Any] - ): - """ - Return all records from call - """ + ) -> dict[str, Any]: + """API call that returns records + + Args: + canonicalized_resource (str): API endpoint without domain + key_id (str): API key ID + secret (bytes): API secret + params (dict[str, Any]): dict of parameters + Raises: + SoliscloudApiError: Any error during call. + + Returns: + dict[str, Any]: return JSON records from API call + """ header: dict[str, str] = SoliscloudAPI._prepare_header( key_id, secret, params, canonicalized_resource) @@ -703,10 +1144,17 @@ async def _get_data( self, canonicalized_resource: str, key_id: str, secret: bytes, params: dict[str, Any] ): - """ - Return data from call - """ + """API call that returns one record + + Args: + canonicalized_resource (str): API endpoint without domain + key_id (str): API key ID + secret (bytes): API secret + params (dict[str, Any]): dict of parameters + Returns: + dict[str, Any]: return JSON data from API call + """ header: dict[str, str] = SoliscloudAPI._prepare_header( key_id, secret, params, canonicalized_resource) @@ -822,3 +1270,41 @@ def _verify_date(format: SoliscloudAPI.DateFormat, date: str): if not rex.match(date): raise err return + + @staticmethod + def _precondition_station_or_nmi(station_id: int, nmi_code: str): + params: dict[str, Any] = {} + if (station_id is not None and nmi_code is None): + params['id'] = station_id + elif (station_id is None and nmi_code is not None): + params['nmiCode'] = nmi_code + else: + raise SoliscloudError(ONLY_STN_ID_OR_SN_ERR) + return params + + @staticmethod + def _precondition_page_size(page_size: int): + if page_size > 100: + raise SoliscloudError(PAGE_SIZE_ERR) + return + + @staticmethod + def _precondition_inverter_id_or_sn(inverter_id: str, inverter_sn: int): + params: dict[str, Any] = {} + if (inverter_sn is not None and inverter_id is None): + params['sn'] = inverter_sn + elif (inverter_sn is None and inverter_id is not None): + params['id'] = inverter_id + else: + raise SoliscloudError(ONLY_INV_ID_OR_SN_ERR) + return params + + def _precondition_collector_id_or_sn(collector_id: str, collector_sn: int): + params: dict[str, Any] = {} + if (collector_sn is not None and collector_id is None): + params['sn'] = collector_sn + elif (collector_sn is None and collector_id is not None): + params['id'] = collector_id + else: + raise SoliscloudError(ONLY_COL_ID_OR_SN_ERR) + return params From d60a1e014e9f1a2945183dd69016a32dd0ecd791 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:30:43 +0100 Subject: [PATCH 03/12] Refactor SolisEntity and related classes Refactor methods and improve docstrings for clarity. --- soliscloud_api/entities.py | 182 +++++++++++++++++++++++++++---------- 1 file changed, 134 insertions(+), 48 deletions(-) diff --git a/soliscloud_api/entities.py b/soliscloud_api/entities.py index 1df1a4b..28dfcba 100644 --- a/soliscloud_api/entities.py +++ b/soliscloud_api/entities.py @@ -22,12 +22,24 @@ def _normalize_to_list(data): class SolisEntity(object): + """Base class with common methods for SolisCloud entities.""" def __init__( self, type: EntityType, data: dict[str, Any] = None, whitelist: list[str] = None ): + """Base class constructor. This is not meant to be called and there + are no checks on data passed. Use from_data() or from_session() + class methods on derived classes instead. + + Args: + type (EntityType): Type of entity (e.g., EntityType.PLANT) + data (dict[str, Any], optional): Data for this entity as received + from Soliscloud API call. Defaults to None. + whitelist (list[str], optional): Not yet used, ignore. + Defaults to None. + """ self._data = None if data is not None: self._data = SolisDataFactory.create(type, data) @@ -48,10 +60,12 @@ def data(self) -> dict: @property def data_timestamp(self) -> datetime: - return datetime.fromtimestamp(int(self._data['data_timestamp']) / 1e3) + if self._data is None or 'data_timestamp' not in self._data: + return None + return self._data['data_timestamp'].timestamp() @classmethod - def initialize_from_data( + def from_data( cls, data: dict[str, Any] | list[dict[str, Any]], /, **filters @@ -59,6 +73,12 @@ def initialize_from_data( """ Generic method to parse data and return entity objects. Filters can be passed as keyword arguments (e.g., plantId=..., id=...). + Args: + data (dict[str, Any] | list[dict[str, Any]]): + response from Soliscloud API call. + **filters: key-value pairs to filter records. + Returns: + list[SolisEntity]: list of zero or more entity objects. """ return cls._do_initialize_from_data(data, **filters) @@ -89,6 +109,14 @@ def _do_initialize_from_data( class Collector(SolisEntity): def __init__(self, data: dict[str, Any]) -> None: + """Constructor. Do not use directly. Use from_data or from_session + + Args: + data (dict[str, Any]): Data from API response belonging to entity. + + Raises: + ValueError: Raised when data does not pass minimal conditions. + """ if not isinstance(data, dict): raise ValueError("Collector data must be a dict") if 'id' not in data: @@ -104,17 +132,18 @@ def collector_id(self): '''Alias for 'id' attribute ''' try: return self._data['id'] - except AttributeError: - return None + except KeyError: + raise AttributeError("Collector data has no 'id' attribute") @classmethod - async def initialize_from_session( + async def from_session( cls, session: ClientSession, key_id: str, secret: bytes, /, *, plant_id: int = None, + nmi_code: str = None, collector_sn: str = None, collector_id: int = None ) -> list[Collector]: @@ -126,12 +155,17 @@ async def initialize_from_session( First evaluates plant_id, then collector_id, then collector_sn. Args: - session (ClientSession): _description_ - key_id (str): _description_ - secret (bytes): _description_ - plant_id (int, optional): _description_. Defaults to None. - collector_sn (str, optional): _description_. Defaults to None. - collector_id (int, optional): _description_. Defaults to None. + session (ClientSession): http session to use + key_id (str): API key + secret (bytes): API secret + plant_id (int, optional): Plant ID to filter collectors by. + Defaults to None. + nmi_code (str, optional): NMI code to filter by. Only used + in AUS. Defaults to None. + collector_sn (str, optional): collector serial number to filter by. + Defaults to None. + collector_id (int, optional): collector ID to filter by. + Defaults to None. Returns: list[Collector]: Returns a list of zero or more Collector objects @@ -139,12 +173,13 @@ async def initialize_from_session( collectors: list[Collector] = [] try: soliscloud = SoliscloudAPI( - 'https://soliscloud.com:13333', session) + SoliscloudAPI.DEFAULT_DOMAIN, session) collector_data = [] if plant_id is not None: collector_data = await soliscloud.collector_list( key_id, secret, - station_id=plant_id) + station_id=plant_id, + nmi_code=nmi_code) elif collector_id is not None: collector_data.append(await soliscloud.collector_detail( key_id, secret, @@ -154,7 +189,8 @@ async def initialize_from_session( key_id, secret, collector_sn=collector_sn)) else: - return collectors + collector_data = await soliscloud.collector_list( + key_id, secret) for record in collector_data: if plant_id is None or record['stationId'] == plant_id: collector = cls(record) @@ -164,7 +200,7 @@ async def initialize_from_session( return collectors @classmethod - def initialize_from_data( + def from_data( cls, data: dict[str, Any] | list[dict[str, Any]], /, *, @@ -178,7 +214,11 @@ def initialize_from_data( Args: data (dict[str, Any] | list[dict[str, Any]]): response from collector_detail() or collector_list() call. - collector_id (str, optional): _description_. Defaults to None. + collector_id (str, optional): Only collectors for matching + collector_id will be created. Defaults to None. + station_id (str, optional): Only collectors for matching + station_id will be created. + Defaults to None. Returns: list[Collector]: list of zero or more Collector objects @@ -189,19 +229,45 @@ def initialize_from_data( class Inverter(SolisEntity): def __init__(self, data: dict[str, Any]) -> None: + """Constructor. Do not use directly. Use from_data or from_session + + Args: + data (dict[str, Any]): Data from API response belonging to entity. + + Raises: + ValueError: Raised when data does not pass minimal conditions. + """ if not isinstance(data, dict): raise ValueError("Inverter data must be a dict") if 'id' not in data: raise ValueError("Inverter data must include 'id'") super().__init__(EntityType.INVERTER, data) - self._collector = None + self._collectors = list() + + def add_collectors(self, collectors: list[Collector]) -> None: + """Register list of collectors under inverter + + Args: + collectors (list[Collector]): List of collector entities to + register + """ + self._collectors = collectors def add_collector(self, collector: Collector) -> None: - self._collector = collector + """Add collector entity to list of registered collectors + + Args: + collector (Collector): Collector entity to add + """ + self._collectors.append(collector) def __str__(self): - out = f" inverter id: {self._data['id']}, " - out += f"collector id: {self._collector.collector_id}" + out = f"inverter id: {self._data['id']}, collector id's: [" + for collector in self._collectors: + out += f"{collector.collector_id}, " + if len(self._collectors) > 0: + out = out[:-2] + out += "]" return out @property @@ -209,11 +275,20 @@ def inverter_id(self): '''Alias for 'id' attribute ''' try: return self._data['id'] - except AttributeError: - return None + except KeyError: + raise AttributeError("Inverter data has no 'id' attribute") + + @property + def collectors(self) -> list[Collector]: + """Return list of collectors registered under inverter + + Returns: + list[Collector]: List of Collector objects + """ + return self._collectors @classmethod - async def initialize_from_session( + async def from_session( cls, session: ClientSession, key_id: str, @@ -245,7 +320,7 @@ async def initialize_from_session( inverters: list[Inverter] = [] try: soliscloud = SoliscloudAPI( - 'https://soliscloud.com:13333', session) + SoliscloudAPI.DEFAULT_DOMAIN, session) inverter_data = [] if inverter_id is not None: inverter_data.append(await soliscloud.inverter_detail( @@ -258,17 +333,17 @@ async def initialize_from_session( if inverter_id is None or record['id'] == inverter_id: if plant_id is None or record['stationId'] == plant_id: inverter = cls(record) - collectors = await Collector.initialize_from_session( + collectors = await Collector.from_session( session, key_id, secret, collector_id=inverter.data["collector_id"]) - inverter.add_collector(collectors[0]) + inverter._collectors = collectors inverters.append(inverter) except SoliscloudError: pass return inverters @classmethod - def initialize_from_data( + def from_data( cls, data: dict[str, Any] | list[dict[str, Any]], /, *, @@ -298,6 +373,14 @@ def initialize_from_data( class Plant(SolisEntity): def __init__(self, data: dict[str, Any]) -> None: + """Constructor. Do not use directly. Use from_data or from_session + + Args: + data (dict[str, Any]): Data from API response belonging to entity. + + Raises: + ValueError: Raised when data does not pass minimal conditions. + """ if not isinstance(data, dict): raise ValueError("Plant data must be a dict") if 'id' not in data: @@ -308,15 +391,29 @@ def __init__(self, data: dict[str, Any]) -> None: def __str__(self): out = f"plant id: {self.plant_id}\n" out += super().__str__() - out += "\ninverters: [\n" + str(*self.inverters) + "\n]\n" - if out[-4:] == "\n\n]\n": - out = out[:-4] + "]\n" + out += "\ninverters: [\n" + for i in self.inverters: + out += " " + str(i) + ",\n" + if out[-2:] == ",\n": + out = out[:-2] + "\n]" + elif out[-2:] == "[\n": + out = out[:-1] + "]" return out def add_inverters(self, inverters: list[Inverter]) -> None: + """Register list of inverters under plant + + Args: + inverters (list[Inverter]): List of inverter entities to register + """ self._inverters = inverters def add_inverter(self, inverter: Inverter) -> None: + """Add inverter entity to list of registered inverters + + Args: + inverter (Inverter): Inverter entity to add + """ self._inverters.append(inverter) @property @@ -324,15 +421,15 @@ def plant_id(self): '''Alias for 'id' attribute ''' try: return self._data['id'] - except AttributeError: - return None + except KeyError: + raise AttributeError("Plant data has no 'id' attribute") @property def inverters(self) -> list[Inverter]: return self._inverters @classmethod - async def initialize_from_session( + async def from_session( cls, session: ClientSession, key_id: str, secret: bytes, @@ -360,7 +457,7 @@ async def initialize_from_session( plants: list[Plant] = [] plant_data: list[dict[str, Any]] = [] soliscloud = SoliscloudAPI( - 'https://soliscloud.com:13333', session) + SoliscloudAPI.DEFAULT_DOMAIN, session) if plant_id is None: plant_data = await soliscloud.station_detail_list( key_id, secret) @@ -371,7 +468,7 @@ async def initialize_from_session( for plant_record in plant_data: plant = cls(plant_record) - plant.add_inverters(await Inverter.initialize_from_session( + plant.add_inverters(await Inverter.from_session( session, key_id, secret, plant_id=plant.plant_id)) @@ -381,7 +478,7 @@ async def initialize_from_session( return plants @classmethod - def initialize_from_data( + def from_data( cls, data: dict[str, Any] | list[dict[str, Any]], /, *, @@ -400,15 +497,4 @@ def initialize_from_data( Returns: list[Plant]: A list of zero or more Plant objects """ - cls._do_initialize_from_data(data, id=plant_id) -# plants: list[Plant] = [] -# plant_data = _normalize_to_list(data) -# for record in plant_data: -# if not isinstance(record, dict): -# raise ValueError("Each plant/station record must be a dict") -# if 'id' not in record: -# raise ValueError("Plant/station record missing 'id'") -# if plant_id is None or record['id'] == plant_id: -# plant = cls(record) -# plants.append(plant) -# return plants + return cls._do_initialize_from_data(data, id=plant_id) From 1ae7ff13a5b120c4abec3953b2e271a4f7230510 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:31:29 +0100 Subject: [PATCH 04/12] Refactor Helpers class for type hints and error handling Updated type hints and improved error handling in helper methods. --- soliscloud_api/helpers.py | 76 ++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/soliscloud_api/helpers.py b/soliscloud_api/helpers.py index c866de3..c3db9c7 100644 --- a/soliscloud_api/helpers.py +++ b/soliscloud_api/helpers.py @@ -3,6 +3,7 @@ For more information: https://github.com/hultenvp/soliscloud_api """ from __future__ import annotations +from typing import Any from soliscloud_api import SoliscloudAPI, SoliscloudError @@ -11,53 +12,79 @@ class Helpers(): @staticmethod async def get_station_ids( - api: SoliscloudAPI, key, secret, - nmi=None - ) -> tuple: - """ - Calls user_station_list and returns all station id's from the response - as a tuple of int's or None on error. + api: SoliscloudAPI, key_id: str, secret: bytes, + nmi: str = None + ) -> tuple[int]: + """Calls user_station_list and returns all station id's from the + response. If nmi is given then the nmi_code parameter is used in the call (Australian accounts require nmi) + + Args: + api (SoliscloudAPI): instance of SoliscloudAPI + key (str): API key + secret (bytes): API secret + nmi (str, optional): NMI code for AUS. Defaults to None. + + Returns: + tuple[int]: tuple of Station IDs or None on error """ try: response = await api.user_station_list( - key, secret, page_no=1, page_size=100, nmi_code=nmi) + key_id, secret, page_no=1, page_size=100, nmi_code=nmi) return Helpers.get_station_ids_from_response(response) except SoliscloudError: return None @staticmethod def get_station_ids_from_response( - response: dict[str, str], - ) -> tuple: - """ - Takes response from user_station_list and returns all station id's + response: dict[str, Any], + ) -> tuple[int]: + """Takes response from user_station_list and returns all station id's from the response as a tuple of int's. + + Args: + response (dict[str, Any]): api response from user_station_list + + Returns: + tuple[int]: tuple of Station IDs """ if response is None: return None stations = () for element in response: - stations = stations + (int(element['id']),) + try: + stations = stations + (int(element['id']),) + except KeyError: + continue return stations @staticmethod async def get_inverter_ids( - api: SoliscloudAPI, key, secret, + api: SoliscloudAPI, key_id: str, secret: bytes, station_id: int = None, - nmi=None - ) -> tuple: - """ - Calls inverter_list and returns all inverter id's from response as a + nmi: str = None + ) -> tuple[int]: + """Calls inverter_list and returns all inverter id's from response as a tuple of int's or None on error. (Australian accounts require nmi) - If a station_id is given then a list of inverters for that station_id + is returned, else all inverter id's for all stations + + Args: + api (SoliscloudAPI): instance of SoliscloudAPI + key (str): API key + secret (bytes): API secret + station_id (int, optional): If a station_id is given then a list + of inverters for that station_id. Defaults to None. + nmi (str, optional): NMI code for AUS. Defaults to None. + + Returns: + tuple[int]: tuple of Inverter IDs or None on error """ try: response = await api.inverter_list( - key, secret, page_no=1, page_size=100, + key_id, secret, page_no=1, page_size=100, station_id=station_id, nmi_code=nmi) return Helpers.get_inverter_ids_from_response(response) except SoliscloudError: @@ -66,16 +93,23 @@ async def get_inverter_ids( @staticmethod def get_inverter_ids_from_response( response: dict[str, str], - ) -> tuple: + ) -> tuple[int]: """ Takes response from inverter_list and returns all inverter id's from response as a tuple of int's. If a station_id is given then a list of inverters for that station_id is returned, else all inverter id's for all stations + Args: + response (dict[str, str]): api response from inverter_list + Returns: + tuple[int]: tuple of Inverter IDs """ if response is None: return None inverters = () for element in response: - inverters = inverters + (int(element['id']),) + try: + inverters = inverters + (int(element['id']),) + except KeyError: + continue return inverters From e3a5877d90e1cfae75179a0a6b594afb4f553469 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:32:59 +0100 Subject: [PATCH 05/12] Refactor types.py for SI unit support and normalization Refactor types.py to introduce SI unit types and improve normalization logic. Update various dimensioned types to inherit from the new SI base class, enhancing unit handling and conversions. --- soliscloud_api/types.py | 480 +++++++++++++++++++++------------------- 1 file changed, 252 insertions(+), 228 deletions(-) diff --git a/soliscloud_api/types.py b/soliscloud_api/types.py index c2e1f22..f260e86 100644 --- a/soliscloud_api/types.py +++ b/soliscloud_api/types.py @@ -6,7 +6,7 @@ from enum import IntEnum from collections import UserDict, UserList from typing import Any -from datetime import datetime, tzinfo, timezone, timedelta +from datetime import datetime, timezone, timedelta class EntityType(IntEnum): @@ -31,6 +31,19 @@ class InverterType(IntEnum): STORAGE = 2 +class InverterModel(IntEnum): + GRID_TYPE = 1 + GRID_AND_LOAD_SIDE_METER = 2 + GRID_CONNECTED_AND_GRID_SIDE_ELECTRICITY_METER = 3 + ENERGY_STORAGE_AND_LOAD_SIDE_METER = 4 + ENERGY_STORAGE_AND_GRID_SIDE_METER = 5 + RESERVE = 6 + OFF_GRID_ENERGY_STORAGE = 7 + GRID_CONNECTED_ENERGY_STORAGE_DUAL_METER = 8 + AC_COUPLE_WITHOUT_CT = 1001 + AC_COUPLE_WITH_CT = 1002 + + class PlantType(IntEnum): GRID = 0 ENERGY_STORAGE = 1 @@ -51,6 +64,11 @@ class CollectorState(IntEnum): OFFLINE = 2 +class AcOutputType(IntEnum): + SINGLE_PHASE = 0 + THREE_PHASE = 1 + + class UnitError(Exception): """ Exception raised for wrong unit assignment. @@ -63,14 +81,9 @@ def __init__(self, unit, allowed_units): class DimensionedType(UserDict, ABC): - def __init__(self, value, unit=None): - try: - if unit not in self.__class__.UNITS: - raise UnitError(unit, self.__class__.UNITS) - except AttributeError: - pass - super().__init__({'value': value, 'unit': unit}) + super().__init__({'value': float(value), 'unit': unit}) + self._normalize() def __setitem__(self, key, value): super().__setitem__(key, value) @@ -87,6 +100,15 @@ def __eq__(self, other): else: return False + def _round_to_int(self): + if self['value'] == int(self['value']): + self['value'] = int(self['value']) + try: + if self['original_value'] == int(self['original_value']): + self['original_value'] = int(self['original_value']) + except KeyError: + pass + def original(self): d = {} try: @@ -94,7 +116,7 @@ def original(self): 'value': self['original_value'], 'unit': self['original_unit'] } - except AttributeError: + except KeyError: d = {'value': self['value'], 'unit': self['unit']} return d @@ -102,14 +124,14 @@ def original(self): def original_value(self): try: return self['original_value'] - except AttributeError: + except KeyError: return self['value'] @property def original_unit(self): try: return self['original_unit'] - except AttributeError: + except KeyError: return self['unit'] @property @@ -126,196 +148,140 @@ def _normalize(self): class GenericType(DimensionedType): - def __init__(self, value, unit): + def __init__(self, value, unit=None): super().__init__(value, unit) - self._normalize() def _normalize(self): - pass + self._round_to_int() -class EnergyType(DimensionedType): - UNITS = ('Wh', 'kWh', 'MWh') - DEFAULT = UNITS[1] +class SiType(DimensionedType): + ''' Base class for types expressed in SI units''' + PREFIXES = ('T', 'G', 'M', 'k', '', 'm', 'µ') + MULTIPLIERS = {p: 10**(3 * (4 - i)) for i, p in enumerate(PREFIXES)} + DEFAULT_PREFIX = PREFIXES[4] # no prefix + DEFAULT_MULTIPLIER = MULTIPLIERS[DEFAULT_PREFIX] + BASE_UNIT = None # to be defined in subclass + UNITS = () # to be defined in subclass - def __init__(self, value, unit): - super().__init__(value, unit) - self._normalize() - - @property - def value_mwh(self): - return self['value']/1000 + @classmethod + def units(cls, base_unit=None): + return None if base_unit is None else\ + tuple(f"{p}{base_unit}" for p in cls.PREFIXES) - @property - def value_wh(self): - return self['value']*1000 + def __init__(self, value, unit=None): + if len(self.__class__.UNITS) == 0: + if self.__class__.BASE_UNIT is None: + raise AttributeError( + "BASE_UNIT not defined in subclass") + self.__class__.UNITS =\ + tuple(f"{p}{self.__class__.BASE_UNIT}" for p in self.__class__.PREFIXES) # noqa: E501 + if unit is not None and unit not in self.__class__.UNITS: + raise UnitError(unit, self.__class__.UNITS) + super().__init__(value, unit) def _normalize(self): - if self['unit'] == EnergyType.UNITS[1]: - return + if self['unit'] is None: + self['unit'] =\ + self.__class__.DEFAULT_PREFIX + self.__class__.BASE_UNIT + prefix = self['unit'][:-len(self.__class__.BASE_UNIT)] + if prefix not in self.__class__.PREFIXES: + raise UnitError(self['unit'], self.__class__.PREFIXES) self['original_value'] = self['value'] self['original_unit'] = self['unit'] - if self['unit'] == EnergyType.UNITS[0]: - self['value'] = self['value']/1000 - elif self['unit'] == EnergyType.UNITS[2]: - self['value'] = self['value']*1000 - else: - pass - self['unit'] = EnergyType.UNITS[1] - - -class VoltageType(DimensionedType): - UNITS = ('V', 'kV') - DEFAULT = UNITS[0] + self['value'] = self['value'] * (self.__class__.MULTIPLIERS[prefix] / self.__class__.DEFAULT_MULTIPLIER) # noqa: E501 + self._round_to_int() + self['unit'] = self.__class__.DEFAULT_PREFIX + self.__class__.BASE_UNIT + + def to_unit(self, unit): + if unit not in self.__class__.UNITS: + raise UnitError(unit, self.__class__.UNITS) + prefix = unit[:-len(self.__class__.BASE_UNIT)] + value = self['value'] * (self.__class__.DEFAULT_MULTIPLIER / self.__class__.MULTIPLIERS[prefix]) # noqa: E501 + if value == int(value): + value = int(value) + return value + + +class EnergyType(SiType): + BASE_UNIT = 'Wh' + DEFAULT_PREFIX = SiType.PREFIXES[3] # k + DEFAULT_MULTIPLIER = SiType.MULTIPLIERS[DEFAULT_PREFIX] + UNITS = SiType.units(BASE_UNIT) + DEFAULT = f"{DEFAULT_PREFIX}{BASE_UNIT}" def __init__(self, value, unit): super().__init__(value, unit) - self._normalize() - - @property - def value_kv(self): - return self['value']/1000 - - def _normalize(self): - if self['unit'] == VoltageType.UNITS[0]: - return - self['original_value'] = self['value'] - self['original_unit'] = self['unit'] - if self['unit'] == VoltageType.UNITS[1]: - self['value'] = self['value']*1000 - else: - pass - self['unit'] = VoltageType.UNITS[0] -class CurrentType(DimensionedType): - UNITS = ('mA', 'A', 'kA') - DEFAULT = UNITS[1] +class VoltageType(SiType): + BASE_UNIT = 'V' + UNITS = SiType.units(BASE_UNIT) + DEFAULT = f"{SiType.DEFAULT_PREFIX}{BASE_UNIT}" def __init__(self, value, unit): super().__init__(value, unit) - @property - def value_a(self): - return self['value'] - @property - def value_ka(self): - return self['value']/1000 +class CurrentType(SiType): + BASE_UNIT = 'A' + UNITS = SiType.units(BASE_UNIT) + DEFAULT = f"{SiType.DEFAULT_PREFIX}{BASE_UNIT}" - def _normalize(self): - if self['unit'] == CurrentType.UNITS[1]: - return - self['original_value'] = self['value'] - self['original_unit'] = self['unit'] - if self['unit'] == CurrentType.UNITS[2]: - self['value'] = self['value']*1000 - elif self['unit'] == CurrentType.UNITS[0]: - self['value'] = self['value']/1000 - else: - pass - self['unit'] = CurrentType.UNITS[1] + def __init__(self, value, unit): + super().__init__(value, unit) -class PowerType(DimensionedType): - UNITS = ['mW', 'W', 'kW'] - DEFAULT = UNITS[1] +class PowerType(SiType): + BASE_UNIT = 'W' + UNITS = SiType.units(BASE_UNIT) + DEFAULT = f"{SiType.DEFAULT_PREFIX}{BASE_UNIT}" def __init__(self, value, unit): super().__init__(value, unit) self._normalize() - @property - def value_w(self): - return self['value'] - @property - def value_kw(self): - return self['value']/1000 - - def _normalize(self): - if self['unit'] == PowerType.UNITS[1]: - return - self['original_value'] = self['value'] - self['original_unit'] = self['unit'] - if self['unit'] == PowerType.UNITS[2]: - self['value'] = self['value']*1000 - elif self['unit'] == PowerType.UNITS[0]: - self['value'] = self['value']/1000 - else: - pass - self['unit'] = PowerType.UNITS[1] - - -class ReactivePowerType(DimensionedType): - UNITS = ('var', 'kvar') - DEFAULT = UNITS[0] +class ReactivePowerType(SiType): + BASE_UNIT = 'var' + UNITS = SiType.units(BASE_UNIT) + DEFAULT = f"{SiType.DEFAULT_PREFIX}{BASE_UNIT}" def __init__(self, value, unit): super().__init__(value, unit) self._normalize() - def _normalize(self): - if self['unit'] == ReactivePowerType.UNITS[0]: - return - self['original_value'] = self['value'] - self['original_unit'] = self['unit'] - if self['unit'] == ReactivePowerType.UNITS[1]: - self['value'] = self['value']*1000 - else: - pass - self['unit'] = ReactivePowerType.UNITS[0] - -class ApparentPowerType(DimensionedType): - UNITS = ('VA', 'kVA') - DEFAULT = UNITS[0] +class ApparentPowerType(SiType): + BASE_UNIT = 'VA' + UNITS = SiType.units(BASE_UNIT) + DEFAULT = f"{SiType.DEFAULT_PREFIX}{BASE_UNIT}" def __init__(self, value, unit): super().__init__(value, unit) self._normalize() - def _normalize(self): - if self['unit'] == ApparentPowerType.UNITS[0]: - return - self['original_value'] = self['value'] - self['original_unit'] = self['unit'] - if self['unit'] == ApparentPowerType.UNITS[1]: - self['value'] = self['value']*1000 - else: - pass - self['unit'] = ApparentPowerType.UNITS[0] - -class FrequencyType(DimensionedType): - UNITS = ('Hz', 'kHz') - DEFAULT = UNITS[0] +class FrequencyType(SiType): + BASE_UNIT = 'Hz' + UNITS = SiType.units(BASE_UNIT) + DEFAULT = f"{SiType.DEFAULT_PREFIX}{BASE_UNIT}" def __init__(self, value, unit): super().__init__(value, unit) self._normalize() - @property - def value_hz(self): - return self['value'] - - def _normalize(self): - if self['unit'] == FrequencyType.UNITS[0]: - return - self['original_value'] = self['value'] - self['original_unit'] = self['unit'] - if self['unit'] == FrequencyType.UNITS[1]: - self['value'] = self['value']*1000 - else: - pass - self['unit'] = FrequencyType.UNITS[0] - class TemperatureType(DimensionedType): UNITS = ('℃', 'F', 'K') DEFAULT = UNITS[0] + KELVIN = 273.15 def __init__(self, value, unit): + if unit == "C": + unit = '℃' + if unit not in TemperatureType.UNITS: + raise UnitError(unit, TemperatureType.UNITS) super().__init__(value, unit) self._normalize() @@ -329,18 +295,22 @@ def value_fahrenheit(self): @property def value_kelvin(self): - return float(self['value'] + 273.15) + return float(self['value'] + self.KELVIN) def _normalize(self): - if self['unit'] == TemperatureType.UNITS[0]: - return self['original_value'] = self['value'] self['original_unit'] = self['unit'] - if self['unit'] == TemperatureType.UNITS[1]: - self['value'] = float(self['value'] - 32) * 5 / 9 - elif self['unit'] == TemperatureType.UNITS[2]: - self['value'] = float(self['value']) - 273.15 - + v = self['value'] + if self['unit'] != TemperatureType.UNITS[0]: + if self['unit'] == TemperatureType.UNITS[1]: + v = float(self['value'] - 32) * 5 / 9 + elif self['unit'] == TemperatureType.UNITS[2]: + v = float(self['value']) - self.KELVIN + + if self['value'] < -273.15: + raise ValueError(f"Temperature {self['value']} ℃ below absolute zero") # noqa: E501 + self['value'] = v + self._round_to_int() self['unit'] = TemperatureType.UNITS[0] @@ -353,48 +323,44 @@ def _normalize(self): pass -class DateTimeType(): - def __init__(self, value, t_z: tzinfo = None): - self._timestamp = value - self._datetime = datetime.fromtimestamp(int(value) / 1e3, t_z) - - def date(self): - return self._datetime.date - - def time(self): - return self._datetime.time - - def timestamp(self): - return self._datetime.timestamp +class EnumType(UserDict): - def timezone(self): - return self._datetime.tzname + def __init__(self, value: IntEnum): + if isinstance(value, IntEnum): + super().__init__( + {'value': value.value, 'name': value.name.title()}) + else: + raise TypeError(f"{value} not of type IntEnum") def __str__(self): - return str(self._datetime) + f" ({self.__class__.__name__})" + s = f"{self['value']}" + if self['name'] is not None: + s += f" {self['name']}" + return s + f" ({self.__class__.__name__})" def __eq__(self, other): if isinstance(other, self.__class__): - return self._datetime == other._datetime + return self.value == other.value and self.name == other.name + elif isinstance(other, int): + return self.value == other + elif isinstance(other, str): + return self.name == other else: return False + @property + def value(self): + return self['value'] -class EnumType(UserDict): - - def __init__(self, value: IntEnum): - if isinstance(value, IntEnum): - super().__init__( - {'value': value.value, 'name': value.name.title()}) - else: - raise TypeError(f"{value} not of type IntEnum") + @property + def name(self): + return self['name'] class ListType(UserList): def __init__(self, value): if type(value) is list: super().__init__(value) - self._normalize() else: raise TypeError(f"{value} not of type list") @@ -412,17 +378,25 @@ def __str__(self): out += ']' return out - def _normalize(self): - for d in self.data: - if '_normalize' in dir(d): - d._normalize() - class DictType(UserDict): + def __init__(self, value=None): + if value is None or type(value) is dict: + super().__init__(value) + else: + raise TypeError(f"{value} not of type dict") + def __str__(self) -> str: - out = "{\n" + out = "{" for k in self.keys(): - out += f" {k}: {self[k]}\n" + out += '\n' + r = f"{k}: {self[k]}" + # indent all lines with 2 extra spaces + r = re.sub(r'(?<=^)(.)', r' \1', r, flags=re.MULTILINE) + out += r + ',' + # Remove trailing comma and newline from last element in list + if out[-1:] == ',': + out = out[0:-1] + '\n' out += "}" return out @@ -432,7 +406,7 @@ class SolisDataFactory: Keys get normalized to snake_case Dimensioned values get parsed, converted and/or normalized Unused k/v pairs get dropped - Values may be again be dict or list + Values may again be dict or list """ @staticmethod @@ -452,6 +426,11 @@ def create(type: EntityType, input: dict[str, Any]) -> dict[str, Any]: (?<=[0-9]) # Preceded by digit (?=[A-Z]) # Followed by uppercase letter """, re.VERBOSE) + nr_of_strings = 0 + try: + nr_of_strings = input['dcInputtype'] + 1 + except KeyError: + pass for key in keys: new_key = pattern.sub('_', key).lower() # Fix 'InCome' wrongly converted into 'in_come' @@ -459,35 +438,68 @@ def create(type: EntityType, input: dict[str, Any]) -> dict[str, Any]: if not SolisDataFactory._key_is_unit(key): value = input[key] - nr_of_strings = 0 unit = None if key+'Str' in keys: unit = input[key+'Str'] elif key+'Unit' in keys: unit = input[key+'Unit'] - try: - if new_key[-6:] == 'income': + elif re.search(r"(?<=(Power|rrent|ltage))(A|B|C|Original)$", key) is not None: # noqa: E501 + # Catch backupLookedPowerOriginal, backupLookedPowerA etc. + # to use unit from backupLookedPowerStr + u_str = re.sub(r"(?<=(Power|rrent|ltage))(A|B|C|Original)$", '', key) + 'Str' # noqa: E501 + if u_str in keys: + unit = input[u_str] + elif key.endswith('owerOrigin') or key.endswith('owerOriginV2'): # noqa: E501 + if 'powerStr' in keys: + unit = input['powerStr'] + elif key.endswith('V2'): + # Catch keys ending with V2 to use unit ending with StrV2 + u_str = key[:-2] + 'StrV2' + if u_str in keys: + unit = input[u_str] + u_str = key[:-2] + 'UnitV2' + if u_str in keys: + unit = input[u_str] + elif key.endswith('Origin'): + # Catch keys ending with Origin to use unit without Origin + u_str = key[:-6] + 'Str' + if u_str in keys: + unit = input[u_str] + u_str = key[:-6] + 'Unit' + if u_str in keys: + unit = input[u_str] + elif re.search(r"(Min|Max)$", key) is not None: + # Catch keys ending with Min or Max to use unit without + # Min/Max + u_str = re.sub(r"(Min|Max)$", '', key) + 'Str' + if u_str in keys: + unit = input[u_str] + u_str = re.sub(r"(Min|Max)$", '', key) + 'Unit' + if u_str in keys: + unit = input[u_str] + elif key == 'price': + if 'money' in keys: unit = input['money'] - except KeyError: - pass + elif 'unit' in keys: + unit = input['unit'] try: - if unit is None: - if new_key[-4:] == 'time': - unit = input['timeZoneStr'] + if re.search(r"income", new_key, re.IGNORECASE) is not None: # noqa: E501 + unit = input['money'] except KeyError: pass try: + # Some timestamps are superceded by timezone if new_key[-9:] == 'timestamp': s = re.split(r'/s', value) value = s[0] - if len(s) > 1: - unit = s[-1] - except KeyError: - pass - try: - nr_of_strings = input['dcInputtype'] + 1 except KeyError: pass + if new_key[-4:] == 'temp' or new_key[-11:] == 'temperature': + try: + if unit is None: + unit = input['tmpUnit'] + except KeyError: + pass d = SolisDataFactory._create_value( type, new_key, value, unit) # only create properties for available strings @@ -508,6 +520,7 @@ def _key_is_unit(key: str) -> bool: is_unit = False is_unit |= key[-3:] == 'Str' is_unit |= re.search('unit', key, re.IGNORECASE) is not None + is_unit |= key.endswith('StrV2') return is_unit @staticmethod @@ -536,8 +549,10 @@ def _create_typed_value( unit: str = None ) -> Any: p = None + re_datetime = r'_time$|_timestamp$|_date$' + re_temperature = r'temperature$|_temp$|tmp_' + re_apparent_power = r'looked_power$|apparent_power$' if unit is not None: - re_datetime = '_time$|_timestamp$|__date$' match(unit): case unit if unit in EnergyType.UNITS: p = EnergyType(value, unit) @@ -547,15 +562,15 @@ def _create_typed_value( p = CurrentType(value, unit) case unit if unit in PowerType.UNITS: p = PowerType(value, unit) - case unit if unit in ReactivePowerType.UNITS: + case unit if unit.lower() in ReactivePowerType.UNITS: p = ReactivePowerType(value, unit.lower()) case unit if unit in ApparentPowerType.UNITS: p = ApparentPowerType(value, unit) case unit if unit in FrequencyType.UNITS: p = FrequencyType(value, unit) - case _ if key[-11:] == 'temperature': + case _ if unit in TemperatureType.UNITS: p = TemperatureType(value, unit) - case _ if re.search('_income', key, re.IGNORECASE) is not None: + case _ if re.search(r"(_income|price)", key, re.IGNORECASE) is not None: # noqa: E501 p = CurrencyType(value, unit) case _ if re.search(re_datetime, key, re.IGNORECASE)\ is not None: @@ -564,28 +579,34 @@ def _create_typed_value( sign, hours, minutes = re.match(regex, unit).groups() sign = -1 if sign == '-' else 1 hours, minutes = int(hours), int(minutes) - tzinfo = timezone(sign * timedelta(hours=hours, minutes=minutes)) # noqa: E501 - p = DateTimeType(value, tzinfo) + tz = timezone(sign * timedelta(hours=hours, minutes=minutes)) # noqa: E501 + p = datetime.fromtimestamp(int(value) / 1e3, tz) except AttributeError: - p = DateTimeType(value) + p = datetime.fromtimestamp(int(value) / 1e3) case _: p = GenericType(value, unit) else: match(key): - case 'state': - p = EnumType(State(value)) + case 'state' | 'current_state': + p = EnumType(State(int(value))) case 'state_exception_flag': p = EnumType(InverterOfflineState(value)) + case 'ac_output_type': + p = EnumType(AcOutputType(0 if int(value) == 0 else 1)) + case 'inverter_meter_model': + p = EnumType(InverterModel(int(value))) + case 'station_type': + p = EnumType(PlantType(int(value))) case 'type': match(type): case EntityType.PLANT: - p = EnumType(PlantType(value)) + p = EnumType(PlantType(int(value))) case EntityType.INVERTER: - p = EnumType(InverterType(value)) + p = EnumType(InverterType(int(value))) case _: - p = value - case _ if re.search('_pec', key, re.IGNORECASE) is not None: - p = value + p = int(value) + case _ if re.search('pec$|percent$', key, re.IGNORECASE) is not None: # noqa: E501 + p = GenericType(int(value)*100, '%') case _ if re.search('voltage', key, re.IGNORECASE) is not None: p = VoltageType(value, VoltageType.DEFAULT) case _ if re.search('upv', key, re.IGNORECASE) is not None: @@ -600,24 +621,27 @@ def _create_typed_value( p = CurrentType(value, CurrentType.DEFAULT) case _ if key in ('p_a', 'p_b', 'p_c'): p = PowerType(value, PowerType.DEFAULT) - case _ if re.search('looked_power', key, re.IGNORECASE)\ + case _ if re.search(re_apparent_power, key, re.IGNORECASE)\ is not None: p = ApparentPowerType(value, ApparentPowerType.DEFAULT) case _ if re.search('reactive_power', key, re.IGNORECASE)\ is not None: p = ReactivePowerType(value, ReactivePowerType.DEFAULT) - case _ if re.search(r"power_?\d{1,2}$", key, re.IGNORECASE)\ - is not None: + case _ if re.search(r"powe{0,1}r{0,1}_?\d{1,2}$", key, re.IGNORECASE) is not None: # noqa: E501 p = PowerType(value, PowerType.DEFAULT) - case _ if re.search(r"_pow$", key, re.IGNORECASE) is not None: + case _ if re.search(r"powe{0,1}r{0,1}$", key, re.IGNORECASE)\ + is not None: p = PowerType(value, PowerType.DEFAULT) - case _ if re.search(r"_time$", key, re.IGNORECASE) is not None: - p = DateTimeType(value) - case _ if re.search(r"_timestamp$", key, re.IGNORECASE)\ + case _ if re.search(r"energy$", key, re.IGNORECASE)\ + is not None: + p = EnergyType(value, EnergyType.DEFAULT) + case _ if re.search(r"_time", key) is not None: + p = datetime.fromtimestamp(int(value) / 1e3) + case _ if re.search(r"_date", key) is not None: + p = datetime.fromtimestamp(int(value) / 1e3) + case _ if re.search(re_temperature, key, re.IGNORECASE)\ is not None: - p = DateTimeType(value) - case _ if re.search(r"_date$", key, re.IGNORECASE) is not None: - p = DateTimeType(value) + p = TemperatureType(value, TemperatureType.DEFAULT) case _: if unit is not None: p = GenericType(value, unit) From 60b78c8e6ca7156dff46843767214a82ca32c346 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:34:52 +0100 Subject: [PATCH 06/12] Rename methods for entity creation Updated method names in documentation and code to reflect changes from 'initialize_from_data' to 'from_data' and 'initialize_from_session' to 'from_session'. Adjusted API calls to use the new method names for creating entity instances. --- example_entities.py | 50 ++++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/example_entities.py b/example_entities.py index 4fab25a..19d1e5a 100644 --- a/example_entities.py +++ b/example_entities.py @@ -7,13 +7,13 @@ attributes of the plant, inverters and collectors. There are 2 ways to create the entity classes: -1. Use the class method 'initialize_from_data' to create entity instances +1. Use the class method 'from_data' to create entity instances from data retrieved from the API. It will create one or more entity instances from the data passed in. This method will not retrieve any data from the API, so you need to do that yourself first. It will not recursively create all entities, i.e. plants, inverters and collectors. You need to call it separately for each entity class. -2. Use the class method 'initialize_from_session' to create entity instances +2. Use the class method 'from_session' to create entity instances by passing in the aiohttp session and API credentials. This method will retrieve the data from the API and create the entity instances. It will recursively create all entities, i.e. plants, inverters @@ -51,24 +51,30 @@ async def main(): async with ClientSession() as websession: try: - the_api = api('https://soliscloud.com:13333', websession) - data = await the_api.inverter_list( + the_api = api(api.DEFAULT_DOMAIN, websession) + plants = await Plant.from_session( + websession, api_key, api_secret) + # Whole entity tree is now created, print it out + print("*** from_session ***") + print(plants[0]) + # Alternatively, get started by getting data from API + # and then create entity instances from that data. + plant_data = await the_api.station_detail_list( api_key, api_secret, page_no=1, page_size=100) - # use plant_id of your own plant here to get inverters for that plant + plants = Plant.from_data(plant_data) + # use plant_id of your own plant here to get inverters + # for that plant. # use inverter_id to only get one specific inverter - inverters = Inverter.initialize_from_data( - data, plant_id='your plant id goes here') - plants = await Plant.initialize_from_session( - websession, api_key, api_secret) - except ( - SoliscloudError, - SoliscloudHttpError, - SoliscloudTimeoutError, - SoliscloudApiError, - ) as error: - print(f"Error: {error}") - else: + inverter_data = await the_api.inverter_list( + api_key, api_secret, page_no=1, page_size=100) + inverters = Inverter.from_data( + inverter_data, plant_id=plants[0].plant_id) + plants[0].add_inverters(inverters) + print("\n*** from_data ***") + print(plants[0]) p = plants[0] + # Now access some attributes of the entities + print("\n*** Accessing attributes ***") print(f"Plant id: {p.plant_id}") print(f"Plant name: {p.data['station_name']}") print(f"Number of inverters: {len(p.inverters)}") @@ -77,9 +83,19 @@ async def main(): # The attributes if the inverter are in inv.data # If an attribute has a unit then the value is of # dimensioned type + print(f"Inverter state: {inv.data['state']}") + # Print full energy type attribute print(f"Total energy: {inv.data['etotal']}") + # print value and unit separately print(f"Total energy value: {inv.data['etotal'].value}") print(f"Total energy unit: {inv.data['etotal'].unit}") + except ( + SoliscloudError, + SoliscloudHttpError, + SoliscloudTimeoutError, + SoliscloudApiError, + ) as error: + print(f"Error: {error}") loop = asyncio.new_event_loop() loop.run_until_complete(main()) loop.close() From e2a01bc1765bb426d5df428c21c500159509427e Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:35:39 +0100 Subject: [PATCH 07/12] Use default domain for SoliscloudAPI --- example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example.py b/example.py index af799c7..8db5178 100644 --- a/example.py +++ b/example.py @@ -34,7 +34,7 @@ async def main(): async with ClientSession() as websession: try: soliscloud = SoliscloudAPI( - 'https://soliscloud.com:13333', websession) + SoliscloudAPI.DEFAULT_DOMAIN, websession) # Retrieve list of Stations, a.k.a. plants station_list = await soliscloud.user_station_list( From 9c81efdb0af4ede185075090c473fcdf06e9f594 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:37:36 +0100 Subject: [PATCH 08/12] Bump version from 1.3.0 to 1.4.0 --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index be170e1..af82d25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "soliscloud-api" -version = "1.3.0" +version = "1.4.0" authors = [ { name="Peter van Hulten", email="peter.vanhulten@gmx.net" }, ] @@ -33,3 +33,9 @@ exclude = ["soliscloud_api.tests*"] [tool.setuptools.packages.find] exclude = ["soliscloud_api.tests*"] + +[tool.pytest.ini_options] +#addopts = ["--disable-warnings"] +testpaths = ["test"] +asyncio_default_fixture_loop_scope = "function" + From 3d8d6f91857b545a678e8a5b002ea3b801a948d5 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:39:27 +0100 Subject: [PATCH 09/12] Remove api_instance fixture from test_exceptions.py Removed the api_instance fixture from test_exceptions.py. --- test/test_exceptions.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/test_exceptions.py b/test/test_exceptions.py index 3c3eb0a..301c020 100644 --- a/test/test_exceptions.py +++ b/test/test_exceptions.py @@ -1,5 +1,3 @@ -import pytest -from soliscloud_api import SoliscloudAPI from soliscloud_api import ( SoliscloudError, SoliscloudApiError, @@ -9,12 +7,6 @@ from datetime import datetime -@pytest.fixture -def api_instance(): - instance = SoliscloudAPI('https://soliscloud_test.com:13333/', 1) - return instance - - def test_soliscloud_error(mocker): err = SoliscloudError() assert f"{err}" == '' From 6adfaa306b14cfeccc03afedbc48d70f764f8ef8 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:45:49 +0100 Subject: [PATCH 10/12] Extend test coverage --- test/test_entities.py | 309 +++++++++++++++++++++++++++++++++++ test/test_helpers.py | 79 +++++++++ test/test_types.py | 274 +++++++++++++++++++++++++++++++ test/test_types_factory.py | 266 ++++++++++++++++++++++++++++++ test/testdata_collector.json | 1 + test/testdata_inverter.json | 1 + test/testdata_station.json | 1 + 7 files changed, 931 insertions(+) create mode 100644 test/test_entities.py create mode 100644 test/test_helpers.py create mode 100644 test/test_types.py create mode 100644 test/test_types_factory.py create mode 100644 test/testdata_collector.json create mode 100644 test/testdata_inverter.json create mode 100644 test/testdata_station.json diff --git a/test/test_entities.py b/test/test_entities.py new file mode 100644 index 0000000..5587115 --- /dev/null +++ b/test/test_entities.py @@ -0,0 +1,309 @@ +import pytest +import json +from soliscloud_api import Plant, Inverter, Collector +from .const import ( + KEY, + SECRET, +) + + +def validate_plant(plant: Plant, station_data: dict): + assert plant.plant_id == station_data['id'] + assert plant.data_timestamp == int(station_data['dataTimestamp'])/1e3 + # Some random fields that have to be parsed correctly + assert plant.data["station_name"] == "TestName" + assert plant.data["year_energy"].value == 1152 + assert plant.data["year_energy"].unit == "kWh" + + +def validate_inverter(inverter: Inverter, inverter_data: dict): + assert inverter.inverter_id == inverter_data['id'] + assert inverter.data_timestamp == int(inverter_data['dataTimestamp'])/1e3 + assert inverter.data["state"].value == 1 + assert inverter.data["state"].name == "Online" + assert inverter.data["power"].value == 1500 + assert inverter.data["power"].unit == "W" + + +def validate_collector(collector: Collector, collector_data: dict): + assert collector.collector_id == collector_data['id'] + assert collector.data_timestamp == int(collector_data['dataTimestamp'])/1e3 + assert collector.data["model"] == "WIFI" + + +def test_plant_creation_from_data(mocker): + with open('test/testdata_station.json', 'r') as f: + station_data = json.load(f) + station_data2 = station_data.copy() + # first test filtering by wrong plant ID + plants = Plant.from_data(station_data, plant_id=0) + assert len(plants) == 0 + # test with single station data + plants = Plant.from_data(station_data) + validate_plant(plants[0], station_data) + # test with two identical stations in a list + station_data_list = [station_data, station_data2] + plants = Plant.from_data(station_data_list) + validate_plant(plants[0], station_data) + validate_plant(plants[1], station_data2) + assert plants[0] == plants[1] + # test string representation + assert str(plants[0]) == str(plants[1]) + # test with two different stations in a list + station_data2['id'] = '1010101010' + station_data_list = [station_data, station_data2] + plants = Plant.from_data(station_data_list) + validate_plant(plants[0], station_data) + validate_plant(plants[1], station_data2) + assert plants[0] != plants[1] + # test string representation + assert str(plants[0]) != str(plants[1]) + + +def test_plant_constructor(): + with open('test/testdata_station.json', 'r') as f: + station_data = json.load(f) + plant = Plant(station_data) + validate_plant(plant, station_data) + # test with missing required id field + station_data.pop('id') + with pytest.raises(ValueError): + _ = Plant(station_data) + # test with invalid data type + with pytest.raises(ValueError): + _ = Plant(list(station_data)) + + +def test_fail_plant_creation_from_data(): + with open('test/testdata_station.json', 'r') as f: + station_data = json.load(f) + station_data.pop('id') + # test with missing required id field + with pytest.raises(ValueError): + _ = Plant.from_data(station_data) + # test with invalid data type + station_data = list(station_data) + with pytest.raises(ValueError): + _ = Plant.from_data(station_data) + + +def test_plant_creation_from_data_with_inverters(): + with open('test/testdata_station.json', 'r') as f, \ + open('test/testdata_inverter.json', 'r') as g: + station_data = json.load(f) + inverter_data = json.load(g) + inverter_data2 = inverter_data.copy() + inverter_data2['id'] = '1010101010' + # test with adding multiple inverters to a plant in one go + plants = Plant.from_data(station_data) + inverter_data_list = [inverter_data] + inverter_data_list.append(inverter_data2) + inverters = Inverter.from_data(inverter_data_list) + plants[0].add_inverters(inverters) + validate_inverter(plants[0].inverters[0], inverter_data) + validate_inverter(plants[0].inverters[1], inverter_data2) + assert plants[0].inverters[0] != plants[0].inverters[1] + # test with adding inverters one by one + plants = Plant.from_data(station_data) + inverter_data_list = [inverter_data] + inverter_data_list.append(inverter_data2) + inverters = Inverter.from_data(inverter_data_list) + plants[0].add_inverter(inverters[0]) + plants[0].add_inverter(inverters[1]) + validate_inverter(plants[0].inverters[0], inverter_data) + validate_inverter(plants[0].inverters[1], inverter_data2) + assert plants[0].inverters[0] != plants[0].inverters[1] + # test inequality with invalid type + assert plants[0].inverters[0] != 3 + # remove timestamp from plant data and test None + plants[0]._data.pop('data_timestamp') + assert plants[0].data_timestamp is None + # remove id and test attribute error + plants[0]._data.pop('id') + with pytest.raises(AttributeError): + _ = plants[0].plant_id + + +def test_inverter_constructor(): + with open('test/testdata_inverter.json', 'r') as f: + inverter_data = json.load(f) + inverter = Inverter(inverter_data) + validate_inverter(inverter, inverter_data) + # test attribute error for missing id + inverter.data.pop('id') + with pytest.raises(AttributeError): + _ = inverter.inverter_id + # test with missing required id field + inverter_data.pop('id') + with pytest.raises(ValueError): + _ = Inverter(inverter_data) + # test with invalid data type + with pytest.raises(ValueError): + _ = Inverter(list(inverter_data)) + + +def test_inverter_creation_from_data(): + with open('test/testdata_inverter.json', 'r') as f: + inverter_data = json.load(f) + inverter_data2 = inverter_data.copy() + inverters = Inverter.from_data(inverter_data) + validate_inverter(inverters[0], inverter_data) + # test with two identical inverters in a list + inverter_data_list = [inverter_data, inverter_data2] + inverters = Inverter.from_data(inverter_data_list) + validate_inverter(inverters[0], inverter_data) + validate_inverter(inverters[1], inverter_data2) + assert inverters[0] == inverters[1] + # test string representation + assert str(inverters[0]) == str(inverters[1]) + # test with two different inverters in a list + inverter_data2['id'] = '1010101010' + inverter_data_list = [inverter_data, inverter_data2] + inverters = Inverter.from_data(inverter_data_list) + validate_inverter(inverters[0], inverter_data) + validate_inverter(inverters[1], inverter_data2) + assert inverters[0] != inverters[1] + # test string representation + assert str(inverters[0]) != str(inverters[1]) + + +def test_inverter_creation_from_data_with_collectors(): + with open('test/testdata_inverter.json', 'r') as f, \ + open('test/testdata_collector.json', 'r') as g: + inverter_data = json.load(f) + collector_data = json.load(g) + collector_data2 = collector_data.copy() + collector_data2['id'] = '1010101010' + collector_data_list = [collector_data, collector_data2] + collectors = Collector.from_data(collector_data_list) + # test with adding multiple collectors to an inverter in one go + inverters = Inverter.from_data(inverter_data) + inverter = inverters[0] + inverter.add_collectors(collectors) + validate_collector(inverter.collectors[0], collector_data) + validate_collector(inverter.collectors[1], collector_data2) + assert inverter.collectors[0] != inverter.collectors[1] + # test with adding collectors one by one + inverters = Inverter.from_data(inverter_data) + inverter2 = inverters[0] + inverter2.add_collector(collectors[0]) + inverter2.add_collector(collectors[1]) + validate_collector(inverter2.collectors[0], collector_data) + validate_collector(inverter2.collectors[1], collector_data2) + assert inverter2.collectors[0] != inverter2.collectors[1] + assert inverter == inverter2 + assert str(inverter) == str(inverter2) + + +def test_collector_constructor(): + with open('test/testdata_collector.json', 'r') as f: + collector_data = json.load(f) + collector = Collector(collector_data) + validate_collector(collector, collector_data) + collector2 = Collector(collector_data) + assert collector == collector2 + assert str(collector) == str(collector2) + # test with missing required id field + collector_data.pop('id') + with pytest.raises(ValueError): + _ = Collector(collector_data) + # test with invalid data type + with pytest.raises(ValueError): + _ = Collector(list(collector_data)) + # collector id attribute error + collector.data.pop('id') + with pytest.raises(AttributeError): + _ = collector.collector_id + + +@pytest.mark.asyncio +async def test_plant_creation_from_session(mocker): + with open('test/testdata_station.json', 'r') as f, \ + open('test/testdata_inverter.json', 'r') as g, \ + open('test/testdata_collector.json', 'r') as h: + station_data = json.load(f) + inverter_data = json.load(g) + collector_data = json.load(h) + session = 1234567890 + mocker.patch('soliscloud_api.client.SoliscloudAPI.station_detail', return_value=station_data) # noqa: E501 + mocker.patch('soliscloud_api.client.SoliscloudAPI.station_detail_list', return_value=[station_data]) # noqa: E501 + mocker.patch('soliscloud_api.client.SoliscloudAPI.inverter_detail', return_value=inverter_data) # noqa: E501 + mocker.patch('soliscloud_api.client.SoliscloudAPI.inverter_detail_list', return_value=[inverter_data]) # noqa: E501 + mocker.patch('soliscloud_api.client.SoliscloudAPI.collector_detail', return_value=collector_data) # noqa: E501 + mocker.patch('soliscloud_api.client.SoliscloudAPI.collector_list', return_value=[collector_data]) # noqa: E501 + # First test without specifying station ID + plants = await Plant.from_session(session, KEY, SECRET) # noqa: E501 + validate_plant(plants[0], station_data) + inverter = plants[0].inverters[0] + validate_inverter(inverter, inverter_data) + collector = inverter.collectors[0] + validate_collector(collector, collector_data) + # Now test with specifying station ID + plants = await Plant.from_session(session, KEY, SECRET, station_data['id']) # noqa: E501 + validate_plant(plants[0], station_data) + inverter = plants[0].inverters[0] + validate_inverter(inverter, inverter_data) + collector = inverter.collectors[0] + validate_collector(collector, collector_data) + + +@pytest.mark.asyncio +async def test_inverter_creation_from_session(mocker): + with open('test/testdata_station.json', 'r') as f, \ + open('test/testdata_inverter.json', 'r') as g, \ + open('test/testdata_collector.json', 'r') as h: + station_data = json.load(f) + inverter_data = json.load(g) + collector_data = json.load(h) + session = 1234567890 + mocker.patch('soliscloud_api.client.SoliscloudAPI.inverter_detail', return_value=inverter_data) # noqa: E501 + mocker.patch('soliscloud_api.client.SoliscloudAPI.inverter_detail_list', return_value=[inverter_data]) # noqa: E501 + mocker.patch('soliscloud_api.client.SoliscloudAPI.collector_detail', return_value=collector_data) # noqa: E501 + mocker.patch('soliscloud_api.client.SoliscloudAPI.collector_list', return_value=[collector_data]) # noqa: E501 + # Test inverter without specifying plant ID + inverters = await Inverter.from_session(session, KEY, SECRET) # noqa: E501 + inverter = inverters[0] + validate_inverter(inverter, inverter_data) + collector = inverter.collectors[0] + validate_collector(collector, collector_data) + # Test inverter with specifying plant ID + inverters = await Inverter.from_session(session, KEY, SECRET, plant_id=station_data['id']) # noqa: E501 + inverter = inverters[0] + inverter = inverters[0] + validate_inverter(inverter, inverter_data) + collector = inverter.collectors[0] + validate_collector(collector, collector_data) + # Test inverter with specifying inverter ID + inverters = await Inverter.from_session(session, KEY, SECRET, inverter_id=inverter_data['id']) # noqa: E501 + inverter = inverters[0] + inverter = inverters[0] + validate_inverter(inverter, inverter_data) + collector = inverter.collectors[0] + validate_collector(collector, collector_data) + + +@pytest.mark.asyncio +async def test_collector_creation_from_session(mocker): + with open('test/testdata_station.json', 'r') as f, \ + open('test/testdata_collector.json', 'r') as h: + station_data = json.load(f) + collector_data = json.load(h) + session = 1234567890 + mocker.patch('soliscloud_api.client.SoliscloudAPI.collector_detail', return_value=collector_data) # noqa: E501 + mocker.patch('soliscloud_api.client.SoliscloudAPI.collector_list', return_value=[collector_data]) # noqa: E501 + # Test collector without specifying plant ID + collectors = await Collector.from_session(session, KEY, SECRET) # noqa: E501 + collector = collectors[0] + validate_collector(collector, collector_data) + # Test collector with specifying plant ID + collectors = await Collector.from_session(session, KEY, SECRET, plant_id=station_data['id']) # noqa: E501 + collector = collectors[0] + validate_collector(collector, collector_data) + # Test collector with specifying collector ID + collectors = await Collector.from_session(session, KEY, SECRET, collector_id=collector_data['id']) # noqa: E501 + collector = collectors[0] + validate_collector(collector, collector_data) + # test collector with specifying collector SN + collectors = await Collector.from_session(session, KEY, SECRET, collector_sn=collector_data['sn']) # noqa: E501 + collector = collectors[0] + validate_collector(collector, collector_data) diff --git a/test/test_helpers.py b/test/test_helpers.py new file mode 100644 index 0000000..6cd3886 --- /dev/null +++ b/test/test_helpers.py @@ -0,0 +1,79 @@ +import pytest +from soliscloud_api import Helpers +import soliscloud_api as api +from .const import ( + KEY, + SECRET, +) + +VALID_STATION_DATA = [ + {'id': 123456789, 'name': 'Station 1'}, + {'id': 987654321, 'name': 'Station 2'} +] + +INVALID_STATION_DATA = [ + {'station_id': 123456789, 'name': 'Station 1'}, + {'station_id': 987654321, 'name': 'Station 2'} +] + +VALID_INVERTER_DATA = [ + {'id': 123456789, 'name': 'Inverter 1'}, + {'id': 987654321, 'name': 'Inverter 2'} +] + +INVALID_INVERTER_DATA = [ + {'inverter_id': 123456789, 'name': 'Inverter 1'}, + {'inverter_id': 987654321, 'name': 'Inverter 2'} +] + + +@pytest.fixture +def api_instance(): + instance = api.SoliscloudAPI('https://soliscloud_test.com:13333/', 1) + return instance + + +@pytest.mark.asyncio +async def test_helper_station_ids(api_instance, mocker): + mocked_class = mocker.create_autospec(api.SoliscloudAPI) + mocker.patch.object(mocked_class, 'user_station_list', + return_value=VALID_STATION_DATA) + mocker.patch.object(api_instance, 'user_station_list', mocked_class.user_station_list) # noqa: E501 + station_ids = await Helpers.get_station_ids( + api_instance, KEY, SECRET) + assert station_ids == (123456789, 987654321) + mocker.patch.object(mocked_class, 'user_station_list', + return_value=INVALID_STATION_DATA) + mocker.patch.object(api_instance, 'user_station_list', mocked_class.user_station_list) # noqa: E501 + station_ids = await Helpers.get_station_ids( + api_instance, KEY, SECRET) + assert station_ids == () + mocker.patch.object(mocked_class, 'user_station_list', + return_value=None) + mocker.patch.object(api_instance, 'user_station_list', mocked_class.user_station_list) # noqa: E501 + station_ids = await Helpers.get_station_ids( + api_instance, KEY, SECRET) + assert station_ids is None + + +@pytest.mark.asyncio +async def test_helper_inverter_ids(api_instance, mocker): + mocked_class = mocker.create_autospec(api.SoliscloudAPI) + mocker.patch.object(mocked_class, 'inverter_list', + return_value=VALID_INVERTER_DATA) + mocker.patch.object(api_instance, 'inverter_list', mocked_class.inverter_list) # noqa: E501 + inverter_ids = await Helpers.get_inverter_ids( + api_instance, KEY, SECRET) + assert inverter_ids == (123456789, 987654321) + mocker.patch.object(mocked_class, 'inverter_list', + return_value=INVALID_INVERTER_DATA) + mocker.patch.object(api_instance, 'inverter_list', mocked_class.inverter_list) # noqa: E501 + inverter_ids = await Helpers.get_inverter_ids( + api_instance, KEY, SECRET) + assert inverter_ids == () + mocker.patch.object(mocked_class, 'inverter_list', + return_value=None) + mocker.patch.object(api_instance, 'inverter_list', mocked_class.inverter_list) # noqa: E501 + inverter_ids = await Helpers.get_inverter_ids( + api_instance, KEY, SECRET) + assert inverter_ids is None diff --git a/test/test_types.py b/test/test_types.py new file mode 100644 index 0000000..cd9df41 --- /dev/null +++ b/test/test_types.py @@ -0,0 +1,274 @@ +import pytest +import math +from soliscloud_api.types import ( + GenericType, + SiType, + EnergyType, + VoltageType, + CurrentType, + PowerType, + ReactivePowerType, + ApparentPowerType, + FrequencyType, + TemperatureType, + CurrencyType, + EnumType, + ListType, + DictType, + State, + UnitError +) + + +@pytest.fixture +def dimensioned_instance(): + instance = GenericType() + return instance + + +def test_generic_type_unit_none(mocker): + gt = GenericType(3) + assert gt.value == 3 + assert gt.unit is None + + +def test_generic_type_unit(mocker): + gt = GenericType(3, 'W') + assert gt.value == 3 + assert gt.unit == 'W' + assert f"{gt}" == "3 W (GenericType)" + assert gt.original() == {'unit': 'W', 'value': 3} + + +def test_generic_type_original(mocker): + gt = GenericType(3, 'W') + assert gt.original_unit == 'W' + assert gt.original_value == 3 + + +def test_si_type_unit_none(mocker): + with pytest.raises(AttributeError): + si = SiType(3) # noqa: F841 + + +def test_si_type_normalizing(mocker): + class FakeSiType(SiType): + BASE_UNIT = 'Fake' + + si = FakeSiType(3, 'Fake') + assert si.value == 3 + assert si.unit == 'Fake' + assert f"{si}" == "3 Fake (FakeSiType)" + assert si.original_unit == 'Fake' + assert si.original_value == 3 + si = FakeSiType(3, 'kFake') + assert si.value == 3000 + assert si.unit == 'Fake' + assert f"{si}" == "3000 Fake (FakeSiType)" + assert si.original_unit == 'kFake' + assert si.original_value == 3 + si = FakeSiType(3000, 'mFake') + assert si.value == 3 + assert si.unit == 'Fake' + assert f"{si}" == "3 Fake (FakeSiType)" + assert si.original_unit == 'mFake' + assert si.original_value == 3000 + si = FakeSiType(1) + assert si.value == 1 + assert si.unit == 'Fake' + assert f"{si}" == "1 Fake (FakeSiType)" + assert si.original_unit == 'Fake' + assert si.original_value == 1 + with pytest.raises(UnitError): + si.to_unit('X') + with pytest.raises(UnitError): + si = FakeSiType(0.005, 'xFake') + + +def test_si_type_unit_conversions(mocker): + class FakeSiType(SiType): + BASE_UNIT = 'Fake' + si = FakeSiType(5000, 'mFake') + assert si.value == 5 + assert si.unit == 'Fake' + assert f"{si}" == "5 Fake (FakeSiType)" + assert si.original_unit == 'mFake' + assert si.original_value == 5000 + assert math.isclose(si.to_unit('GFake'), 0.000000005) + assert math.isclose(si.to_unit('MFake'), 0.000005) + assert math.isclose(si.to_unit('kFake'), 0.005) + assert si.to_unit('Fake') == 5 + assert si.to_unit('mFake') == 5000 + assert si.to_unit('µFake') == 5000000 + assert si.original() == {'unit': 'mFake', 'value': 5000} + with pytest.raises(UnitError): + si = FakeSiType(3, 'xFake') # noqa: F841 + with pytest.raises(UnitError): + si = FakeSiType(3, 'X') # noqa: F841 + + +def test_si_type_equality(mocker): + class FakeSiType(SiType): + BASE_UNIT = 'Fake' + + class AnotherFakeSiType(SiType): + BASE_UNIT = 'AnotherFake' + si1 = FakeSiType(3000, 'mFake') + si2 = FakeSiType(3, 'Fake') + si3 = FakeSiType(0.003, 'kFake') + si4 = FakeSiType(4, 'Fake') + si5 = AnotherFakeSiType(3, 'AnotherFake') + assert si1 == si2 + assert si1 == si3 + assert si2 == si3 + assert si1 != si4 + assert si2 != si4 + assert si3 != si4 + assert si1 != si5 + + +def test_energy_type_unit(mocker): + et = EnergyType(3.5, 'kWh') + assert et.value == 3.5 + assert et.unit == 'kWh' + assert f"{et}" == "3.5 kWh (EnergyType)" + + +def test_voltage_type_unit(mocker): + et = VoltageType(3.5, 'V') + assert et.value == 3.5 + assert et.unit == 'V' + assert f"{et}" == "3.5 V (VoltageType)" + + +def test_current_type_unit(mocker): + ct = CurrentType(3.5, 'A') + assert ct.value == 3.5 + assert ct.unit == 'A' + assert f"{ct}" == "3.5 A (CurrentType)" + + +def test_power_type_unit(mocker): + pt = PowerType(3.5, 'W') + assert pt.value == 3.5 + assert pt.unit == 'W' + assert f"{pt}" == "3.5 W (PowerType)" + + +def test_reactive_power_type_unit(mocker): + rpt = ReactivePowerType(3.5, 'var') + assert rpt.value == 3.5 + assert rpt.unit == 'var' + assert f"{rpt}" == "3.5 var (ReactivePowerType)" + + +def test_apparent_power_type_unit(mocker): + apt = ApparentPowerType(3.5, 'VA') + assert apt.value == 3.5 + assert apt.unit == 'VA' + assert f"{apt}" == "3.5 VA (ApparentPowerType)" + + +def test_frequency_type_unit(mocker): + ft = FrequencyType(3.5, 'Hz') + assert ft.value == 3.5 + assert ft.unit == 'Hz' + assert f"{ft}" == "3.5 Hz (FrequencyType)" + + +def test_temperature_type_unit(mocker): + tt = TemperatureType(25, 'C') + assert tt.value == 25 + assert tt.unit == '℃' + tt = TemperatureType(25, '℃') + assert tt.value == 25 + assert tt.unit == '℃' + assert f"{tt}" == "25 ℃ (TemperatureType)" + assert math.isclose(tt.value_kelvin, 298.15) + tt = TemperatureType(77, 'F') + assert math.isclose(tt.value, 25) + assert tt.unit == '℃' + assert f"{tt}" == "25 ℃ (TemperatureType)" + assert math.isclose(tt.value_kelvin, 298.15) + tt = TemperatureType(298.15, 'K') + assert math.isclose(tt.value, 25) + assert tt.unit == '℃' + assert f"{tt}" == "25 ℃ (TemperatureType)" + assert math.isclose(tt.value_kelvin, 298.15) + assert math.isclose(tt.value_fahrenheit, 77) + assert math.isclose(tt.value_celsius, 25) + with pytest.raises(ValueError): + tt = TemperatureType(-300, '℃') # noqa: F841 + with pytest.raises(ValueError): + tt = TemperatureType(-10, 'K') # noqa: F841 + with pytest.raises(ValueError): + tt = TemperatureType(-500, 'F') # noqa: F841 + with pytest.raises(UnitError): + tt = TemperatureType(25, 'X') # noqa: F841 + + +def test_currency_type_unit(mocker): + ct = CurrencyType(3.5, 'EUR') + assert ct.value == 3.5 + assert ct.unit == 'EUR' + assert f"{ct}" == "3.5 EUR (CurrencyType)" + + +def test_enum_type(mocker): + et = EnumType(State(State.ONLINE)) + assert et.value == State.ONLINE + assert f"{et}" == "1 Online (EnumType)" + et = EnumType(State(State.OFFLINE)) + assert et.value == State.OFFLINE + assert f"{et}" == "2 Offline (EnumType)" + with pytest.raises(TypeError): + et = EnumType(3) # noqa: F841 + with pytest.raises(TypeError): + et = EnumType("ONLINE") # noqa: F841 + + +def test_enum_type_equality(mocker): + et1 = EnumType(State(State.ONLINE)) + et2 = EnumType(State(State.ONLINE)) + et3 = EnumType(State(State.OFFLINE)) + et4 = State(State.ONLINE) + et5 = 1 + et6 = "Online" + assert et1 == et2 + assert et1 != et3 + assert et2 != et3 + assert et1 == et4 + assert et3 != et4 + assert et1 == et5 + assert et1 == et6 + assert et1 != 0.003 + + +def test_list_type(mocker): + lt = ListType([1, 2, 3]) + assert lt.data == [1, 2, 3] + assert f"{lt}" == "[\n 1,\n 2,\n 3\n]" + lt = ListType([EnergyType(1, 'kWh'), EnergyType(2000, 'Wh')]) + assert isinstance(lt.data[0], EnergyType) + assert isinstance(lt.data[1], EnergyType) + assert lt.data[0].value == 1 + assert lt.data[1].value == 2 + assert f"{lt}" == "[\n 1 kWh (EnergyType),\n 2 kWh (EnergyType)\n]" + with pytest.raises(TypeError): + lt = ListType(3) # noqa: F841 + + +def test_dict_type(mocker): + dt = DictType({'a': 1, 'b': 2}) + assert dt.data == {'a': 1, 'b': 2} + assert f"{dt}" == "{\n a: 1,\n b: 2\n}" + lt = ListType([EnergyType(1, 'kWh'), EnergyType(2000, 'Wh')]) + dt = DictType({'energy_values': lt, 'status': EnumType(State(State.ONLINE))}) # noqa: E501 + assert isinstance(dt.data['energy_values'], ListType) + assert isinstance(dt.data['status'], EnumType) + assert dt.data['energy_values'].data[0].value == 1 + assert dt.data['energy_values'].data[1].value == 2 + assert dt.data['status'].value == State.ONLINE + assert f"{dt}" == "{\n energy_values: [\n 1 kWh (EnergyType),\n 2 kWh (EnergyType)\n ],\n status: 1 Online (EnumType)\n}" # noqa: E501 + with pytest.raises(TypeError): + dt = DictType(3) # noqa: F841 diff --git a/test/test_types_factory.py b/test/test_types_factory.py new file mode 100644 index 0000000..20704d9 --- /dev/null +++ b/test/test_types_factory.py @@ -0,0 +1,266 @@ +import json + +from soliscloud_api.types import ( # noqa: F401 + EntityType, + SolisDataFactory, + GenericType, + TemperatureType, + PowerType, + EnergyType, + CurrencyType, + EnumType, + FrequencyType, +) +from datetime import datetime + +mocked_inverter_data_units = { + "dcInputtype": 2, + "pow1": 400, + "pow1Str": "W", + "pow2": 410, + "pow2Str": "W", + "pow3": 420, + "pow3Str": "W", + "pow4": 430, + "pow4Str": "W", + "YearInCome": 5000, + "money": "EUR", + "state": 1, + "inverterId": 12345, + "inverterName": "Test Inverter", + "acOutputPower": 3000, + "acOutputPowerUnit": "W", + "data_timestamp": "1687924800000", + "timezoneStr": "UTC+0", + "stateExceptionFlag": 0, + "type": "1", + "frequency": "50", + "frequencyStr": "Hz", + "voltage": "230", + "voltageStr": "V", + "current": "10", + "currentStr": "A", + "power": "2300", + "powerStr": "W", + "energy": "5", + "energyStr": "kWh", + "temperature": "45", + "temperatureStr": "℃", + "reactivePower": "500", + "reactivePowerStr": "VAr", + "apparentPower": "2500", + "apparentPowerStr": "VA", + "uA": "400", + "uAStr": "V", + "iA": "8", + "iAStr": "A", + "pA": "2000", + "pAStr": "W", + "powerPec": "1", + "maxUpv1": "600", +} + +mocked_inverter_data_no_units = { + "dcInputtype": 2, + "pow1": 400, + "pow2": 410, + "pow3": 420, + "pow4": 430, + "YearInCome": 5000, + "money": "EUR", + "state": 1, + "inverterId": 12345, + "inverterName": "Test Inverter", + "acOutputPower": 3000, + "data_timestamp": "1687924800000", + "timezoneStr": "UTC+0", + "stateExceptionFlag": 0, + "type": "1", + "voltage": "230", + "current": "10", + "power": "2300", + "energy": "5", + "temperature": "45", + "reactivePower": "500", + "apparentPower": "2500", + "uA": "400", + "iA": "8", + "pA": "2000", + "powerPec": "1", + "maxUpv1": "600", +} + +mocked_inverter_nested = { + "state": 1, + "inverterId": 12345, + "inverterName": "Test Inverter", + "data_timestamp": "1687924800000", + "timezoneStr": "UTC+0", + "type": "1", + "nestedList": [ + { + "inverterId": 1, + "inverterName": "Inverter 1", + }, + { + "inverterId": 2, + "inverterName": "Inverter 2", + }, + ], + "nestedDict": { + "frequency": "50", + "frequencyStr": "Hz", + "voltage": "230", + "voltageStr": "V", + }, +} + + +def test_solis_data_factory_inverter_nested(mocker): + inverter = SolisDataFactory.create( + EntityType.INVERTER, mocked_inverter_nested) + assert inverter["state"].value == 1 + assert inverter["state"].name == "Online" + assert inverter["inverter_id"] == 12345 + assert inverter["inverter_name"] == "Test Inverter" + assert inverter["data_timestamp"] == datetime.fromtimestamp(1687924800) + assert inverter["type"].value == 1 + assert inverter["type"].name == "Grid" + nested_list = inverter["nested_list"] + assert nested_list[0]["inverter_id"] == 1 + assert nested_list[0]["inverter_name"] == "Inverter 1" + assert nested_list[1]["inverter_id"] == 2 + assert nested_list[1]["inverter_name"] == "Inverter 2" + nested_dict = inverter["nested_dict"] + assert nested_dict["frequency"].value == 50 + assert nested_dict["frequency"].unit == "Hz" + assert nested_dict["voltage"].value == 230 + assert nested_dict["voltage"].unit == "V" + + +# def test_solis_data_factory_station(mocker): +# inverter = SolisDataFactory.create( +# EntityType.PLANT, mocked_inverter_nested) +# assert inverter["data_timestamp"] == datetime.fromtimestamp(1687924800) +# assert inverter["type"].value == 1 +# assert inverter["type"].name == "Energy_Storage" + + +def test_solis_data_factory_collector(mocker): + collector = SolisDataFactory.create( + EntityType.COLLECTOR, mocked_inverter_nested) + assert collector["data_timestamp"] == datetime.fromtimestamp(1687924800) + assert type(collector["type"]) is int + assert collector["type"] == 1 + + +def test_solis_data_factory_inverter_no_units(mocker): + inverter = SolisDataFactory.create( + EntityType.INVERTER, mocked_inverter_data_no_units) + assert inverter["pow_1"].value == 400 + assert inverter["pow_1"].unit == 'W' + assert inverter["pow_2"].value == 410 + assert inverter["pow_2"].unit == 'W' + assert inverter["pow_3"].value == 420 + assert inverter["pow_3"].unit == 'W' + assert not hasattr(inverter, 'pow4') + assert inverter["year_income"].value == 5000 + assert inverter["year_income"].unit == "EUR" + assert inverter["money"] == "EUR" + assert inverter["state"].value == 1 + assert inverter["state"].name == "Online" + assert inverter["inverter_id"] == 12345 + assert inverter["inverter_name"] == "Test Inverter" + assert inverter["ac_output_power"].value == 3000 + assert inverter["ac_output_power"].unit == "W" + assert inverter["data_timestamp"] == datetime.fromtimestamp(1687924800) + assert inverter["state_exception_flag"].value == 0 + assert inverter["state_exception_flag"].name == "Normal_Offline" + assert inverter["type"].value == 1 + assert inverter["type"].name == "Grid" + assert inverter["voltage"].value == 230 + assert inverter["voltage"].unit == "V" + assert inverter["current"].value == 10 + assert inverter["current"].unit == "A" + assert inverter["power"].value == 2300 + assert inverter["power"].unit == "W" + assert inverter["energy"].value == 5 + assert inverter["energy"].unit == "kWh" + assert inverter["temperature"].value == 45 + assert inverter["temperature"].unit == "℃" + assert inverter["reactive_power"].value == 500 + assert inverter["reactive_power"].unit == "var" + assert inverter["apparent_power"].value == 2500 + assert inverter["apparent_power"].unit == "VA" + assert inverter["u_a"].value == 400 + assert inverter["u_a"].unit == "V" + assert inverter["i_a"].value == 8 + assert inverter["i_a"].unit == "A" + assert inverter["p_a"].value == 2000 + assert inverter["p_a"].unit == "W" + assert inverter["power_pec"].value == 100 + assert inverter["max_upv_1"].value == 600 + + +def test_solis_data_factory_station(mocker): + station = None + with open('test/testdata_station.json', 'r') as f: + station_data = json.load(f) + station = SolisDataFactory.create( + EntityType.PLANT, station_data) + assert station["id"] == '666666' + assert station["station_name"] == "TestName" + assert station["region"].value == 144880 + assert station["region"].unit == "Noord-Brabant" + assert station["capacity"].value == 1.62 + assert station["capacity"].unit == "kWp" + assert station["data_timestamp"] == datetime.fromtimestamp(1761467806517/1e3) # noqa: E501 + assert station["year_energy"].value == 1152 + assert station["year_energy"].unit == "kWh" + assert station["year_income"].value == 115.21 + assert station["year_income"].unit == "EUR" + assert station["tmp_min"].value == 8 + assert station["tmp_min"].unit == "℃" + assert station["price"].value == 0.1 + assert station["price"].unit == "EUR" + assert station["sys_grid_price_list"][0]["price"].value == 0.1 + assert station["sys_grid_price_list"][0]["price"].unit == "EUR" + assert isinstance(station["all_income_1"], CurrencyType) + assert isinstance(station["temp"], TemperatureType) + assert isinstance(station["tmp_max"], TemperatureType) + assert isinstance(station["battery_power_origin_v2"], PowerType) + assert isinstance(station["battery_capacity_energy_origin"], EnergyType) + + +def test_solis_data_factory_inverter(mocker): + inverter = None + with open('test/testdata_inverter.json', 'r') as f: + inverter_data = json.load(f) + inverter = SolisDataFactory.create( + EntityType.INVERTER, inverter_data) + assert inverter["pow_1"].value == 0 + assert inverter["pow_1"].unit == 'W' + assert not hasattr(inverter, 'pow2') + assert isinstance(inverter["current_state"], EnumType) + assert inverter["year_income"].value == 115.21 + assert inverter["year_income"].unit == "EUR" + assert inverter["money"] == "EUR" + assert inverter["state"].value == 1 + assert inverter["state"].name == "Online" + assert inverter["id"] == '111111' + assert isinstance(inverter["data_timestamp"], datetime) + assert inverter["state_exception_flag"].value == 0 + assert inverter["state_exception_flag"].name == "Normal_Offline" + # TODO: type is missing in real data. + # assert inverter["type"].value == 1 + # assert inverter["type"].name == "Grid" + assert isinstance(inverter["fac"], FrequencyType) + assert inverter["fac"].value == 49.97 + assert inverter["fac"].unit == "Hz" + assert inverter["u_ac_1"].value == 234.9 + assert inverter["u_ac_1"].unit == "V" + assert inverter["i_ac_1"].value == 0.3 + assert inverter["i_ac_1"].unit == "A" + assert inverter["power"].value == 1500 + assert inverter["power"].unit == "W" + assert inverter["power_pec"].value == 100 diff --git a/test/testdata_collector.json b/test/testdata_collector.json new file mode 100644 index 0000000..af07781 --- /dev/null +++ b/test/testdata_collector.json @@ -0,0 +1 @@ +{"id": "555555", "sn": "444444", "userId": "222222", "name": "", "model": "WIFI", "stationName": "TestName", "stationId": "666666", "version": "2", "actualNumber": 0, "maximumNumber": 1, "connectionOperator": "", "iccid": "", "state": 1, "factoryTime": "0", "dataUploadCycle": 300, "currentWorkingTime": "15", "totalWorkingTime": "1", "gprsPackage": "A", "dataTimestamp": "1761467180000", "rssiLevel": 5, "timeZone": 1.0, "daylight": 0, "timeZoneStr": "UTC+01:00"} \ No newline at end of file diff --git a/test/testdata_inverter.json b/test/testdata_inverter.json new file mode 100644 index 0000000..ca7d979 --- /dev/null +++ b/test/testdata_inverter.json @@ -0,0 +1 @@ +{"chartAllParams": "name,sn,model,productModel,power,machine,stationName,collectorSn,fisTime,shelfState,updateShelfEndTime,infoRemark,nationalStandardstr,afciVer,g100v2State,version,rs485ComAddr,afciTypeStr,u_pv1,i_pv1,pow1,mppt_upv1,mppt_ipv1,mppt_pow1,e_today,e_total,energyAnalyse,dcAnalyse,u_ac1,i_ac1,u_ac2,i_ac2,u_ac3,i_ac3,acAnalyse,fac,pac,reactive_power,inverter_temperature,SIR_realtime", "id": "111111", "userId": "222222", "sn": "AAA333", "inverterMeterModel": 1, "collectorsn": "444444", "collectorId": "555555", "state": 1, "stateExceptionFlag": 0, "mpptSwitch": 0, "collectorState": 1, "collectorModel": "WIFI", "collectorName": "", "simFlowState": -5, "fullHour": 0.0, "fullHourStr": "h", "currentState": "3", "alarmState": 1, "warningInfoData": 0, "shelfBeginTime": 1533484800000, "shelfEndTime": 1691251200000, "updateShelfEndTime": 1691251200000, "updateShelfEndTimeStr": "2023-08-06", "shelfState": "1", "timeZone": 1.0, "timeZoneStr": "UTC+01:00", "daylight": 0, "daylightSwitch": 1, "model": "E2", "productModel": "E2", "isS5": 0, "isSeparateLoad": 0, "isShowPowerFactor": 0, "isShowInternalBatteryI": 0, "ctrlCommand": 1, "inverterType": 0, "nationalStandards": "14", "nationalStandardstr": "EN50549NL", "inverterTemperature": 23.9, "inverterTemperatureUnit": "\u2103", "inverterTemperature2": 0.0, "inverterTemperatureUnit2": "\u2103", "temp": 150.0, "tempName": "IGBT Temperature", "stationName": "TestName", "stationType": 0, "stationTypeNew": 0, "epmType": 0, "synchronizationType": 0, "gridSwitch1": 0, "sno": "123ABC", "money": "EUR", "stationId": "666666", "version": "0F000F", "version2": "000000", "acOutputType": 1, "dcInputtype": 0, "rs485ComAddr": "101", "dataTimestamp": "1761466869000", "timeStr": "2025-10-26 09:21:09", "tag": "YingZhen", "reactivePower": 70.0, "apparentPower": 70.0, "dcPac": 0.0, "uInitGnd": 0, "uInitGndStr": "V", "dcBus": 0.0, "dcBusStr": "V", "dcBusHalf": 0.0, "dcBusHalfStr": "V", "power": 1.5, "powerStr": "kW", "powerPec": "1", "porwerPercent": 0.0, "pac": 0.0, "pacStr": "kW", "pacPec": "1", "oneSelf": 0.0, "eToday": 0.0, "eTodayStr": "kWh", "eMonth": 40.0, "eMonthStr": "kWh", "eYear": 1.152, "eYearStr": "MWh", "eTotal": 8.824, "eTotalStr": "MWh", "dayInCome": 0.0, "monthInCome": 4.0, "yearInCome": 115.21, "allInCome": 882.4, "uPv1": 182.6, "uPv1Str": "V", "iPv1": 0.0, "iPv1Str": "A", "uPv2": 0, "uPv2Str": "V", "iPv2": 0, "iPv2Str": "A", "uPv3": 0, "uPv3Str": "V", "iPv3": 0, "iPv3Str": "A", "uPv4": 0, "uPv4Str": "V", "iPv4": 0, "iPv4Str": "A", "uPv5": 0, "uPv5Str": "V", "iPv5": 0, "iPv5Str": "A", "uPv6": 0, "uPv6Str": "V", "iPv6": 0, "iPv6Str": "A", "uPv7": 0, "uPv7Str": "V", "iPv7": 0, "iPv7Str": "A", "uPv8": 0, "uPv8Str": "V", "iPv8": 0, "iPv8Str": "A", "uPv9": 0, "uPv9Str": "V", "iPv9": 0, "iPv9Str": "A", "uPv10": 0, "uPv10Str": "V", "iPv10": 0, "iPv10Str": "A", "uPv11": 0, "uPv11Str": "V", "iPv11": 0, "iPv11Str": "A", "uPv12": 0, "uPv12Str": "V", "iPv12": 0, "iPv12Str": "A", "uPv13": 0, "uPv13Str": "V", "iPv13": 0, "iPv13Str": "A", "uPv14": 0, "uPv14Str": "V", "iPv14": 0, "iPv14Str": "A", "uPv15": 0, "uPv15Str": "V", "iPv15": 0, "iPv15Str": "A", "uPv16": 0, "uPv16Str": "V", "iPv16": 0, "iPv16Str": "A", "uPv17": 0, "uPv17Str": "V", "iPv17": 0, "iPv17Str": "A", "uPv18": 0, "uPv18Str": "V", "iPv18": 0, "iPv18Str": "A", "uPv19": 0, "uPv19Str": "V", "iPv19": 0, "iPv19Str": "A", "uPv20": 0, "uPv20Str": "V", "iPv20": 0, "iPv20Str": "A", "uPv21": 0, "uPv21Str": "V", "iPv21": 0, "iPv21Str": "A", "uPv22": 0, "uPv22Str": "V", "iPv22": 0, "iPv22Str": "A", "uPv23": 0, "uPv23Str": "V", "iPv23": 0, "iPv23Str": "A", "uPv24": 0, "uPv24Str": "V", "iPv24": 0, "iPv24Str": "A", "uPv25": 0, "uPv25Str": "V", "iPv25": 0, "iPv25Str": "A", "uPv26": 0, "uPv26Str": "V", "iPv26": 0, "iPv26Str": "A", "uPv27": 0, "uPv27Str": "V", "iPv27": 0, "iPv27Str": "A", "uPv28": 0, "uPv28Str": "V", "iPv28": 0, "iPv28Str": "A", "uPv29": 0, "uPv29Str": "V", "iPv29": 0, "iPv29Str": "A", "uPv30": 0, "uPv30Str": "V", "iPv30": 0, "iPv30Str": "A", "uPv31": 0, "uPv31Str": "V", "iPv31": 0, "iPv31Str": "A", "uPv32": 0, "uPv32Str": "V", "iPv32": 0, "iPv32Str": "A", "pow1": 0, "pow1Str": "W", "pow2": 0, "pow2Str": "W", "pow3": 0, "pow3Str": "W", "pow4": 0, "pow4Str": "W", "pow5": 0, "pow5Str": "W", "pow6": 0, "pow6Str": "W", "pow7": 0, "pow7Str": "W", "pow8": 0, "pow8Str": "W", "pow9": 0, "pow9Str": "W", "pow10": 0, "pow10Str": "W", "pow11": 0, "pow11Str": "W", "pow12": 0, "pow12Str": "W", "pow13": 0, "pow13Str": "W", "pow14": 0, "pow14Str": "W", "pow15": 0, "pow15Str": "W", "pow16": 0, "pow16Str": "W", "pow17": 0, "pow17Str": "W", "pow18": 0, "pow18Str": "W", "pow19": 0, "pow19Str": "W", "pow20": 0, "pow20Str": "W", "pow21": 0, "pow21Str": "W", "pow22": 0, "pow22Str": "W", "pow23": 0, "pow23Str": "W", "pow24": 0, "pow24Str": "W", "pow25": 0, "pow25Str": "W", "pow26": 0, "pow26Str": "W", "pow27": 0, "pow27Str": "W", "pow28": 0, "pow28Str": "W", "pow29": 0, "pow29Str": "W", "pow30": 0, "pow30Str": "W", "pow31": 0, "pow31Str": "W", "pow32": 0, "pow32Str": "W", "uAc1": 234.9, "uAc1Str": "V", "iAc1": 0.3, "iAc1Str": "A", "uAc2": 0.0, "uAc2Str": "V", "iAc2": 0.0, "iAc2Str": "A", "uAc3": 0.0, "uAc3Str": "V", "iAc3": 0.0, "iAc3Str": "A", "powerFactor": 0.0, "batteryDischargeEnergy": 0.0, "batteryDischargeEnergyStr": "kWh", "batteryChargeEnergy": 0.0, "batteryChargeEnergyStr": "kWh", "homeLoadEnergy": 0.0, "homeLoadEnergyStr": "kWh", "gridPurchasedEnergy": 0.0, "gridPurchasedEnergyStr": "kWh", "gridSellEnergy": 0.0, "gridSellEnergyStr": "kWh", "fac": 49.97, "facStr": "Hz", "batteryPower": 0.0, "batteryPowerStr": "kW", "batteryPowerPec": "1", "batteryPowerZheng": 0.0, "batteryPowerFu": 0, "storageBatteryVoltage": 0.0, "storageBatteryVoltageStr": "V", "storageBatteryCurrent": 0.0, "storageBatteryCurrentStr": "A", "batteryCapacitySoc": 0.0, "batteryHealthSoh": 0.0, "batteryVoltage": 0.0, "batteryVoltageStr": "V", "bstteryCurrent": 0.0, "bstteryCurrentStr": "A", "batteryPowerBms": 0.0, "batteryPowerBmsStr": "kW", "internalBatteryI": 0.0, "batteryChargingCurrent": 0.0, "batteryChargingCurrentStr": "A", "batteryDischargeLimiting": 0.0, "batteryDischargeLimitingStr": "A", "batteryFailureInformation01": "0", "batteryFailureInformation02": "0", "batteryTotalChargeEnergy": 0.0, "batteryTotalChargeEnergyStr": "kWh", "batteryTodayChargeEnergy": 0.0, "batteryTodayChargeEnergyStr": "kWh", "batteryMonthChargeEnergy": 0.0, "batteryMonthChargeEnergyStr": "kWh", "batteryYearChargeEnergy": 0.0, "batteryYearChargeEnergyStr": "kWh", "batteryYesterdayChargeEnergy": 0.0, "batteryYesterdayChargeEnergyStr": "kWh", "batteryTotalDischargeEnergy": 0.0, "batteryTotalDischargeEnergyStr": "kWh", "batteryTodayDischargeEnergy": 0.0, "batteryTodayDischargeEnergyStr": "kWh", "batteryMonthDischargeEnergy": 0.0, "batteryMonthDischargeEnergyStr": "kWh", "batteryYearDischargeEnergy": 0.0, "batteryYearDischargeEnergyStr": "kWh", "batteryYesterdayDischargeEnergy": 0.0, "batteryYesterdayDischargeEnergyStr": "kWh", "gridPurchasedTotalEnergy": 0.0, "gridPurchasedTotalEnergyStr": "kWh", "gridPurchasedYearEnergy": 0.0, "gridPurchasedYearEnergyStr": "kWh", "gridPurchasedMonthEnergy": 0.0, "gridPurchasedMonthEnergyStr": "kWh", "gridPurchasedTodayEnergy": 0.0, "gridPurchasedTodayEnergyStr": "kWh", "gridPurchasedYesterdayEnergy": 0.0, "gridPurchasedYesterdayEnergyStr": "kWh", "gridSellTotalEnergy": 0.0, "gridSellTotalEnergyStr": "kWh", "gridSellYearEnergy": 0.0, "gridSellYearEnergyStr": "kWh", "gridSellMonthEnergy": 0.0, "gridSellMonthEnergyStr": "kWh", "gridSellTodayEnergy": 0.0, "gridSellTodayEnergyStr": "kWh", "gridSellYesterdayEnergy": 0.0, "gridSellYesterdayEnergyStr": "kWh", "homeLoadTodayEnergy": 0.0, "homeLoadTodayEnergyStr": "kWh", "homeLoadMonthEnergy": 0.0, "homeLoadMonthEnergyStr": "kWh", "homeLoadYearEnergy": 0.0, "homeLoadYearEnergyStr": "kWh", "homeLoadTotalEnergy": 0.0, "homeLoadTotalEnergyStr": "kWh", "totalLoadPower": 0.0, "totalLoadPowerStr": "kW", "homeLoadYesterdayEnergy": 0.0, "homeLoadYesterdayEnergyStr": "kWh", "familyLoadPower": 0.0, "familyLoadPowerStr": "kW", "familyLoadPercent": 0, "homeGridYesterdayEnergy": 0.0, "homeGridYesterdayEnergyStr": "kWh", "homeGridTodayEnergy": 0.0, "homeGridTodayEnergyStr": "kWh", "homeGridMonthEnergy": 0.0, "homeGridMonthEnergyStr": "kWh", "homeGridYearEnergy": 0.0, "homeGridYearEnergyStr": "kWh", "homeGridTotalEnergy": 0.0, "homeGridTotalEnergyStr": "kWh", "bypassLoadPower": 0.0, "bypassLoadPowerStr": "kW", "backupYesterdayEnergy": 0.0, "backupYesterdayEnergyStr": "kWh", "backupTodayEnergy": 0.0, "backupTodayEnergyStr": "kWh", "backupMonthEnergy": 0.0, "backupMonthEnergyStr": "kWh", "backupYearEnergy": 0.0, "backupYearEnergyStr": "kWh", "backupTotalEnergy": 0.0, "backupTotalEnergyStr": "kWh", "bypassAcVoltage": 0.0, "bypassAcVoltageB": 0.0, "bypassAcVoltageC": 0.0, "bypassAcCurrent": 0.0, "bypassAcCurrentB": 0.0, "bypassAcCurrentC": 0.0, "pLimitSet": 0.0, "pFactorLimitSet": 0.0, "pReactiveLimitSet": 0.0, "batteryType": "0.0", "socDischargeSet": 0.0, "socChargingSet": 0.0, "pEpmSet": 0.0, "pEpmSetStr": "kW", "epmFailSafe": 0.0, "epmSafe": 0, "pEpm": 0.0, "pEpmStr": "kW", "psumCalPec": "1", "insulationResistance": 0.0, "dispersionRate": 0.0, "sirRealtime": 0, "iLeakLimt": 0, "upvTotal": 0, "upvTotalStr": "V", "ipvTotal": 0, "ipvTotalStr": "A", "powTotal": 0, "powTotalStr": "W", "parallelStatus": 0, "parallelAddr": 0, "parallelPhase": 0, "parallelBattery": 0, "batteryAlarm": "0", "bypassAcOnoffSet": 0.0, "bypassAcVoltageSet": 0.0, "bypassAcCurrentSet": 0.0, "batteryCDEnableSet": 0.0, "batteryCDSet": 0.0, "batteryCDISet": 0.0, "batteryCMaxiSet": 0.0, "batteryDMaxiSet": 0.0, "batteryUvpSet": 0.0, "batteryFcvSet": 0.0, "batteryAcvSet": 0.0, "batteryOvpSet": 0.0, "batteryOlvEnableSet": 0.0, "batteryLaTemp": 0.0, "offGridDDepth": 0.0, "epsDDepth": 0.0, "epsSwitchTime": "0", "isGrouped": 0, "bmsState": 0, "isShow": false, "isShowBattery": 0, "acInType": 0, "energyStorageControl": "0", "dsp14Ver": "", "meter1Type": 0, "meter2Type": 0, "meter1SiteHigh": 0, "meter2SiteHigh": 0, "meter1TypeLow": 0, "meter2TypeLow": 0, "meter43073": "0", "meter4307313": 0, "generatorPower": 0.0, "generatorPowerStr": "kW", "generatorPowerPec": "1", "generatorTodayEnergy": 0.0, "generatorTodayEnergyStr": "kWh", "generatorTodayEnergyPec": "1", "generatorMonthEnergy": 0.0, "generatorMonthEnergyStr": "kWh", "generatorMonthEnergyPec": "1", "generatorYearEnergy": 0.0, "generatorYearEnergyStr": "kWh", "generatorYearEnergyPec": "1", "generatorTotalEnergy": 0.0, "generatorTotalEnergyStr": "kWh", "generatorTotalEnergyPec": "1", "generatorWarning": "0", "generatorWarningMsg": "none", "generatorSet": "0", "generatorSet01": 0.0, "backup2AcVoltageA": 0.0, "backup2AcVoltageB": 0.0, "backup2AcVoltageC": 0.0, "acCoupledAcVoltageA": 0.0, "acCoupledAcVoltageB": 0.0, "acCoupledAcVoltageC": 0.0, "generatorAcVoltageA": 0.0, "generatorAcVoltageB": 0.0, "generatorAcVoltageC": 0.0, "backup2AcCurrentA": 0.0, "backup2AcCurrentB": 0.0, "backup2AcCurrentC": 0.0, "acCoupledAcCurrentA": 0.0, "acCoupledAcCurrentB": 0.0, "acCoupledAcCurrentC": 0.0, "generatorAcCurrentA": 0.0, "generatorAcCurrentB": 0.0, "generatorAcCurrentC": 0.0, "parallelOnoff": "0", "parallelOnoff01": 0.0, "parallelOnoff02": 0.0, "parallelNumber": 0.0, "parallelOnline": 0.0, "tempFlag": 0, "iA": 0, "uA": 0, "pA": 0, "iB": 0, "uB": 0, "pB": 0, "iC": 0, "uC": 0, "pC": 0, "aReactivePower": 0, "aLookedPower": 0, "aPhasePowerFactor": 0, "bReactivePower": 0, "bLookedPower": 0, "bPhasePowerFactor": 0, "cReactivePower": 0, "cLookedPower": 0, "cPhasePowerFactor": 0, "averagePowerFactor": 0, "pvShow": 1, "mpptShow": 1, "mpptIpv1": 0.0, "mpptUpv1": 182.6, "mpptPow1": 0, "mpptIpv2": 0, "mpptUpv2": 0, "mpptPow2": 0, "mpptIpv3": 0, "mpptUpv3": 0, "mpptPow3": 0, "mpptIpv4": 0, "mpptPow4": 0, "mpptUpv4": 0, "mpptIpv5": 0, "mpptUpv5": 0, "mpptPow5": 0, "mpptIpv6": 0, "mpptUpv6": 0, "mpptPow6": 0, "mpptIpv7": 0, "mpptUpv7": 0, "mpptPow7": 0, "mpptIpv8": 0, "mpptUpv8": 0, "mpptPow8": 0, "mpptIpv9": 0, "mpptUpv9": 0, "mpptPow9": 0, "mpptIpv10": 0, "mpptUpv10": 0, "mpptPow10": 0, "mpptIpv11": 0, "mpptUpv11": 0, "mpptPow11": 0, "mpptIpv12": 0, "mpptUpv12": 0, "mpptPow12": 0, "mpptIpv13": 0, "mpptUpv13": 0, "mpptPow13": 0, "mpptIpv14": 0, "mpptUpv14": 0, "mpptPow14": 0, "mpptIpv15": 0, "mpptUpv15": 0, "mpptPow15": 0, "mpptIpv16": 0, "mpptUpv16": 0, "mpptPow16": 0, "mpptIpv17": 0, "mpptUpv17": 0, "mpptPow17": 0, "mpptIpv18": 0, "mpptUpv18": 0, "mpptPow18": 0, "mpptIpv19": 0, "mpptUpv19": 0, "mpptPow19": 0, "mpptIpv20": 0, "mpptUpv20": 0, "mpptPow20": 0, "dcInputTypeMppt": 0, "afciType": "0", "afciTypeStr": "AFCI0", "afciVer": "0", "fisTimeStr": "2021-06-29", "fisGenerateTime": 1624941894000, "fisGenerateTimeStr": "2021-06-29", "outDateStr": "2018-02-06", "g100v2State": 0, "faultCodeDesc": "Generating", "machine": "Solis-mini-1500-4G", "batteryState": 0, "secondDataSupport": 0, "sphSet": 0, "sphSn": "", "dcAcPower": 0.0, "invGridTotalPower": 0.0, "backupLookedPower": 0.0, "backupLookedPowerStr": "kVA", "backupLookedPowerOriginal": 0.0, "backupLookedPowerA": 0.0, "backupLookedPowerB": 0.0, "backupLookedPowerC": 0.0, "batteryNum": 0, "batteryType2": 0, "batteryList": [], "hmilcdVer": "0", "afciDataFlag": 0, "backup2Power": 0.0, "backup2PowerStr": "kW", "backup2PowerA": 0.0, "backup2PowerB": 0.0, "backup2PowerC": 0.0, "acCoupledPower": 0.0, "acCoupledPowerA": 0.0, "acCoupledPowerB": 0.0, "acCoupledPowerC": 0.0, "backup2LookedPower": 0.0, "backup2LookedPowerStr": "kVA", "backup2LookedPowerOriginal": 0.0, "backup2LookedPowerA": 0.0, "backup2LookedPowerB": 0.0, "backup2LookedPowerC": 0.0, "backup2TodayEnergy": 0.0, "backup2TodayEnergyStr": "kWh", "backup2MonthEnergy": 0.0, "backup2MonthEnergyStr": "kWh", "backup2YearEnergy": 0.0, "backup2YearEnergyStr": "kWh", "backup2TotalEnergy": 0.0, "backup2TotalEnergyStr": "kWh", "acCoupledTodayEnergy": 0.0, "acCoupledTodayEnergyStr": "kWh", "acCoupledMonthEnergy": 0.0, "acCoupledMonthEnergyStr": "kWh", "acCoupledYearEnergy": 0.0, "acCoupledYearEnergyStr": "kWh", "acCoupledTotalEnergy": 0.0, "acCoupledTotalEnergyStr": "kWh", "energyControl": "0", "energyControl00": 0, "energyControl01": 0, "pumpControl": "0", "pumpControl00": 0, "smartPortDeviceType": 0, "gridPortDeviceType": 0, "smartSupport": 0, "generatorSupport": 0, "smartShow": 0, "cpldVer": "0", "hmiVersionAll": "0f00", "dspmVersionAll": "0f00", "dspsVersionAll": "0000", "hmilcdVersionAll": "0000", "cpldVersionAll": "0000", "sphVersionAll": "0000", "afciVersionAll": "0000", "bat1BmsVer": "", "bat1DcdcVer": "", "bat2BmsVer": "", "bat2DcdcVer": "", "humanMachineParam": {"showChipEvent": false, "showChipWave": false, "showDebugParam": false, "showMaintainParam": false}, "dataTimestampStr": "2025-10-26 09:21:09 (UTC+01:00)", "isESV2": false, "existEpm": false, "model3P3W": 0, "model2P2W": 0, "isShowApparent": 0, "isSupportReadHisEnergy": 1, "readHisEnergyTimeOut": "300000", "isSupportEpmIn": 0, "epmInAddr": 0, "afciSupportedSec": 0, "parallelValid": "0", "buckUpShowTips": 0.0, "wrongVersion": 0, "oldEnergyMachine": 0, "lcdHasScreen": 0, "afciSAT": 6, "showNationalStandard": 1, "picUrl": "https://ginlong-it-test.oss-eu-central-1.aliyuncs.com/picDefault/inverter_default.png?Expires=1761535023&OSSAccessKeyId=LTAI5tDfhhsnNuC3fr5HU1rK&Signature=LyhH8E3f7U3TIg4zxVZqNaGOVnI%3D", "pvAndAcCoupledPower": 0.0, "totalAndSmartLoadPower": 0.0, "outputType": 1, "batteryPercent": 0.0, "allEnergyOriginal": 8824.0, "psum": 0.0, "psumCal": 0.0, "dcPacStr": "kW", "bypassLoadPowerOriginal": 0.0, "reactivePowerStr": "Var", "apparentPowerStr": "VA", "backup2PowerOriginal": 0.0, "acCoupledPowerOriginal": 0.0, "psumStr": "kW", "psumCalStr": "kW", "batteryTodayChargeEnergyUnit": "kWh", "batteryTodayDischargeEnergyUnit": "kWh", "sscCurrentFlowMap": {"gridToLoad": 0, "batteryToLoad": 0, "batteryToGrid": 0, "pvToBattery": 0}, "totalAndSmartLoadPowerStr": "kW", "pvAndAcCoupledPowerStr": "kW", "psumV2": 0.0, "psumStrV2": "kW", "batteryPowerV2": 0.0, "batteryPowerStrV2": "kW", "familyLoadPowerPec": "1", "generatorTodayEnergyV2": 0.0, "generatorTodayEnergyUnitV2": "kWh", "acCoupledPowerStr": "kW"} \ No newline at end of file diff --git a/test/testdata_station.json b/test/testdata_station.json new file mode 100644 index 0000000..9ce7589 --- /dev/null +++ b/test/testdata_station.json @@ -0,0 +1 @@ +{"id": "666666", "dataTimestamp": "1761467806517", "fullHour": 0.0, "monthCarbonDioxide": 24.69, "stationName": "TestName", "userId": "222222", "userEmail": "foo.bar@acme.com", "sno": "123ABC", "country": "149", "countryStr": "Netherlands", "region": "144880", "regionStr": "Noord-Brabant", "city": "999999", "cityStr": "Anywhere", "countyStr": "AnywhereCounty", "state": 1, "dip": 30.0, "azimuth": 0.0, "power": 0.032, "timeZone": 1.0, "timeZoneName": "(UTC+01:00)Europe/Amsterdam", "timeZoneStr": "(UTC+01:00)", "timeZoneId": "48", "daylight": 0, "powerStr": "kW", "price": 0.1, "capacity": 1.62, "capacityStr": "kWp", "capacityPercent": 0, "capacity1": 0, "dayEnergy": 0.0, "dayEnergyStr": "kWh", "monthEnergy": 40.0, "monthEnergyStr": "kWh", "yearEnergy": 1.152, "yearEnergyStr": "MWh", "allEnergy": 8.824, "allEnergyStr": "MWh", "allEnergy1": 8824.0, "updateDate": 1760434532000, "type": 0, "synchronizationType": 0, "epmType": 0, "gridSwitch": 0, "shareProcess": 1, "dcInputType": 0, "stationTypeNew": 0, "batteryCapacityEnergy": 0, "batteryTotalDischargeEnergy": 0, "batteryTotalChargeEnergy": 0, "gridPurchasedTotalEnergy": 0.0, "gridSellTotalEnergy": 0.0, "homeLoadTotalEnergy": 8.824, "oneSelf": 32.0, "batteryTodayDischargeEnergy": 0, "batteryTodayChargeEnergy": 0, "homeLoadTodayEnergy": 0.0, "money": "EUR", "condTxtD": "Light Rain", "condTxtN": "Light Rain", "condCodeD": "305", "condCodeN": "305", "simFlowState": -1, "jxbType": 0, "createDate": 1661005481000, "createDateStr": "2022-08-20 16:24:41 (UTC+01:00)", "connectTime": 1660924800000, "accessTime": 1660924800000, "fisPowerTime": 1660924800000, "fisPowerTimeStr": "2022-08-19 18:00:00 (UTC+01:00)", "fisGenerateTime": 1660924800000, "fisGenerateTimeStr": "2022-08-19 18:00:00 (UTC+01:00)", "generateDays": 712, "generateDaysContinuous": 438, "inverterCount": 1, "inverterOnlineCount": 0, "inverterStateOrder": 0, "epmCount": 0, "chargerCount": 0, "alarmCount": 0, "visitorCount": 0, "timeZoneStandardId": "Europe/Amsterdam", "daylightSwitch": 1, "daylightType": 1, "fullHourStr": "h", "capacityPec": "1", "dipStr": "30.0\u00b0", "azimuthStr": "0.0\u00b0", "dateTime": "1661005481000", "offset": 0, "offsetStr": "kWh", "dayInCome": 0.0, "dayInComeUnit": "EUR", "monthInCome": 4.0, "monthInComeUnit": "EUR", "yearInCome": 115.21, "yearInComeUnit": "EUR", "allInCome": 882.4, "allInCome1": 882.4, "allInComeUnit": "EUR", "powerStationNumTree": 4.89, "powerStationNumTreeUnit": "", "powerStationAvoidedCo2": 8797.528, "powerStationAvoidedTce": 3529.6, "powerPec": "1", "porwerPercent": 0.0198, "batteryPower": 0.0, "batteryPowerStr": "kW", "batteryPowerPec": "1", "batteryPowerZheng": 0, "batteryPowerFu": 0, "batteryPercent": 0, "batteryDirection": 4, "familyLoadPercent": 0, "psum": 0.0, "psumStr": "kW", "psumPec": "1", "psumZheng": 0, "psumFu": 0, "gridPurchasedTotalEnergyStr": "kWh", "gridSellTotalEnergyStr": "kWh", "gridPurchasedEnergy": 0.0, "gridPurchasedEnergyStr": "kWh", "gridSellEnergy": 0.0, "gridSellEnergyStr": "kWh", "gridPurchasedDayEnergy": 0.0, "gridPurchasedDayEnergyStr": "kWh", "gridSellDayEnergy": 0.0, "gridSellDayEnergyStr": "kWh", "gridPurchasedMonthEnergy": 0.0, "gridPurchasedMonthEnergyStr": "kWh", "gridSellMonthEnergy": 0.0, "gridSellMonthEnergyStr": "kWh", "gridPurchasedYearEnergy": 0.0, "gridPurchasedYearEnergyStr": "kWh", "gridSellYearEnergy": 0.0, "gridSellYearEnergyStr": "kWh", "batteryDischargeEnergy": 0.0, "batteryDischargeEnergyStr": "kWh", "batteryChargeEnergy": 0.0, "batteryChargeEnergyStr": "kWh", "batteryDischargeMonthEnergy": 0.0, "batteryDischargeMonthEnergyStr": "kWh", "batteryChargeMonthEnergy": 0.0, "batteryChargeMonthEnergyStr": "kWh", "batteryDischargeYearEnergy": 0.0, "batteryDischargeYearEnergyStr": "kWh", "batteryChargeYearEnergy": 0.0, "batteryChargeYearEnergyStr": "kWh", "batteryDischargeTotalEnergy": 0.0, "batteryDischargeTotalEnergyStr": "kWh", "batteryChargeTotalEnergy": 0.0, "batteryChargeTotalEnergyStr": "kWh", "familyLoadPower": 0.0, "familyLoadPowerStr": "kW", "familyLoadPowerPec": "1", "homeGridTodayEnergy": 0.0, "homeGridTodayEnergyStr": "kWh", "homeGridMonthEnergy": 0.0, "homeGridMonthEnergyStr": "kWh", "homeGridYearEnergy": 0.0, "homeGridYearEnergyStr": "kWh", "homeGridTotalEnergy": 0, "homeGridTotalEnergyStr": "kWh", "backupTodayEnergy": 0.0, "backupTodayEnergyStr": "kWh", "backupMonthEnergy": 0.0, "backupMonthEnergyStr": "kWh", "backupYearEnergy": 0.0, "backupYearEnergyStr": "kWh", "backupTotalEnergy": 0, "backupTotalEnergyStr": "kWh", "totalLoadPower": 0.0, "totalLoadPowerStr": "kW", "bypassLoadPower": 0.0, "bypassLoadPowerStr": "kW", "homeLoadEnergy": 0.0, "homeLoadEnergyStr": "kWh", "homeLoadTodayEnergyStr": "kWh", "homeLoadMonthEnergy": 40.0, "homeLoadYearEnergy": 1.152, "picUrl": "https://ginlong-it-test.oss-eu-central-1.aliyuncs.com/picDefault/STATION_default_user.png", "weather": "\u65e5\u51fa:08:13 \u65e5\u843d:18:29 8 - 10 Anywhere Rain", "sr": "08:13", "ss": "18:29", "tmpMax": "10", "tmpMin": "8", "tmpUnit": "\u2103", "hum": "87.1", "weatherUpdateDate": "1761102085000", "weatherUpdateDateStr": "2025-10-22 05:01:25 (UTC+01:00)", "pcpn": "100", "pres": "1000.6", "windSpd": "17.3", "windDir": "W", "weatherType": 0, "windSpeed": 0, "windDirection": 0, "humidity": 0, "temp": 0, "rainfall": 0, "airPressure": 0, "contribution": 0, "screenMap": 0, "screenGuideState": 0, "storedInverterType": 0, "countryShortName": "NL", "inverterPower": 1.5, "bypassAcOnoffSet": 0, "priceMap": {"sell": "0.1000", "buy": "0"}, "sysGridPriceList": [{"unit": "EUR", "type": 0, "source": 0, "sellBuy": 0, "refId": "777777", "price": 0.1}, {"unit": "EUR", "type": 0, "source": 0, "sellBuy": 1, "refId": "888888"}], "generatorPower": 0, "generatorPowerStr": "kW", "generatorPowerPec": "1", "generatorTodayEnergy": 0.0, "generatorTodayEnergyStr": "kWh", "generatorTodayEnergyPec": "1", "generatorMonthEnergy": 0, "generatorMonthEnergyStr": "kWh", "generatorMonthEnergyPec": "1", "generatorYearEnergy": 0, "generatorYearEnergyStr": "kWh", "generatorYearEnergyPec": "1", "generatorTotalEnergy": 0, "generatorTotalEnergyStr": "kWh", "generatorTotalEnergyPec": "1", "weatherCount": 0, "powerAmmeter2": 0.0, "hourEnergyAmmeter2": 0, "dayEnergyAmmeter2": 0.0, "dayIncomeAmmeter2": 0, "monthEnergyAmmeter2": 0.0, "monthIncomeAmmeter2": 0, "yearEnergyAmmeter2": 0.0, "yearIncomeAmmeter2": 0, "totalEnergyAmmeter2": 0.0, "totalIncomeAmmeter2": 0, "doubleAmmeterStoragePower": 0.032, "doubleAmmeterStorageDayEnergy": 0.0, "totalInvAcE": 0, "totalGridBatteryE": 0, "oneSelfVersion": 0, "touscdSwitch": 0, "dischargeOffsetPriceType": 0, "chargeOffsetPriceType": 0, "backup2Power": 0, "backup2PowerStr": "kW", "acCoupledPower": 0, "acCoupledPowerStr": "kW", "backup2TodayEnergy": 0, "backup2TodayEnergyStr": "kWh", "backup2MonthEnergy": 0, "backup2MonthEnergyStr": "kWh", "backup2YearEnergy": 0, "backup2YearEnergyStr": "kWh", "backup2TotalEnergy": 0, "backup2TotalEnergyStr": "kWh", "acCoupledTodayEnergy": 0, "acCoupledTodayEnergyStr": "kWh", "acCoupledMonthEnergy": 0, "acCoupledMonthEnergyStr": "kWh", "acCoupledYearEnergy": 0, "acCoupledYearEnergyStr": "kWh", "acCoupledTotalEnergy": 0, "acCoupledTotalEnergyStr": "kWh", "parallelOnoffValid": false, "epmCostationSwitch": 0, "touscdVersion": 0, "extraInfo": "{\"invs\":[{\"m\":\"e2\",\"sn\":\"AAA333\"}]}", "dnspRegisterFlag": false, "touscdPageVersion": 2, "hybridInverterPower": 0, "chargerPower": 0, "totalAndSmartLoadPower": 0, "roofType": 0, "pvAndAcCoupledPower": 0, "sscCurrentFlowMap": {"gridToLoad": 0, "batteryToLoad": 0, "batteryToGrid": 0, "pvToBattery": 0}, "pvStorageGridInvPower": 0, "pvStorageGridInvTodayEnergy": 0, "pvStorageGridInvMonthEnergy": 0, "pvStorageGridInvYearEnergy": 0, "pvStorageGridInvTotalEnergy": 0, "powerStorage": 0, "dayEnergyStorage": 0, "acCoupledPowerOrigin": 0, "batteryTodayChargeEnergyUnit": "kWh", "batteryTodayDischargeEnergyUnit": "kWh", "batteryTotalChargeEnergyUnit": "kWh", "batteryTotalDischargeEnergyUnit": "kWh", "batteryCapacityEnergyStr": "kWh", "batteryCapacityEnergyOrigin": 0, "pvStorageGridInvPowerStr": "kW", "powerStorageStr": "kW", "pvStorageGridInvTodayEnergyStr": "kWh", "dayEnergyStorageStr": "kWh", "pvStorageGridInvMonthEnergyStr": "kWh", "pvStorageGridInvYearEnergyStr": "kWh", "pvStorageGridInvTotalEnergyStr": "kWh", "compatibleCityStr": "Anywhere", "batteryCapacityEnergyUnit": "kWh", "inverterPowerStr": "kW", "hybridInverterPowerStr": "kW", "chargerPowerStr": "kW", "totalAndSmartLoadPowerStr": "kW", "powerV2": 0.032, "powerStrV2": "kW", "pvAndAcCoupledPowerStr": "kW", "pvAndAcCoupledPowerOrigin": 0, "psumV2": 0.0, "psumStrV2": "kW", "batteryPowerV2": 0.0, "batteryPowerStrV2": "kW", "generatorPowerV2": 0, "generatorPowerStrV2": "kW", "generatorTodayEnergyV2": 0.0, "generatorTodayEnergyUnitV2": "kWh", "powerAmmeter2Str": "kW", "powerAmmeter2Pec": "1", "dayEnergyAmmeter2Str": "kWh", "dayEnergyAmmeter2Pec": "1", "monthEnergyAmmeter2Str": "kWh", "monthEnergyAmmeter2Pec": "1", "yearEnergyAmmeter2Str": "kWh", "yearEnergyAmmeter2Pec": "1", "totalEnergyAmmeter2Str": "kWh", "totalEnergyAmmeter2Pec": "1", "doubleAmmeterStoragePowerStr": "kW", "doubleAmmeterStoragePowerPec": "1", "doubleAmmeterStorageDayEnergyStr": "kWh", "doubleAmmeterStorageDayEnergyPec": "1", "homeLoadMonthEnergyStr": "kWh", "homeLoadYearEnergyStr": "MWh", "homeLoadTotalEnergyStr": "MWh", "familyLoadPowerOrigin": 0, "bypassLoadPowerOrigin": 0, "powerOriginV2": 32.0, "generatorPowerOrigin": 0, "batteryPowerOrigin": 0, "psumOrgin": 0, "backup2PowerOrigin": 0, "totalLoadPowerOrigin": 0.0, "batteryPowerOriginV2": 0, "pvStorageGridInvPowerOrigin": 0, "power1": 0, "dayEnergy1": 0, "monthEnergy1": 0, "yearEnergy1": 0} \ No newline at end of file From 571048547f87f878daf5b4b868eb5a64add2cd88 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:48:00 +0100 Subject: [PATCH 11/12] Refactor station_detail tests for invalid parameters Removed a test case for station_detail with all arguments filled and added a test case for invalid parameters. --- test/test_public_methods_station.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test/test_public_methods_station.py b/test/test_public_methods_station.py index d650c37..ed11298 100644 --- a/test/test_public_methods_station.py +++ b/test/test_public_methods_station.py @@ -107,15 +107,14 @@ async def test_station_detail_valid(api_instance, patched_api): patched_api._get_data.assert_called_with( STATION_DETAIL, KEY, SECRET, {'id': 1000}) - # All arguments filled - result = await api_instance.station_detail( - KEY, SECRET, - station_id=1000, nmi_code=NMI) - assert result == VALID_RESPONSE - patched_api._get_data.assert_called_with( - STATION_DETAIL, - KEY, SECRET, - {'id': 1000, 'nmiCode': 'nmi_code'}) + +@pytest.mark.asyncio +async def test_station_detail_invalid_params(api_instance): + # ID and SN together + with pytest.raises(SoliscloudError): + await api_instance.station_detail( + KEY, SECRET, + station_id=1000, nmi_code=NMI) @pytest.mark.asyncio From 752deb2c5ed1beb0733d6aee2b1211383cccc628 Mon Sep 17 00:00:00 2001 From: hultenvp <61835400+hultenvp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:49:26 +0100 Subject: [PATCH 12/12] Add pytest-cov to testing requirements --- requirements-test.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-test.txt b/requirements-test.txt index db14b1b..b723570 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,4 +2,5 @@ asyncio aiohttp pytest-mock pytest-asyncio +pytest-cov throttler