From 80c5b7ed376f17ac38ee94a238006f89bbe37233 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Tue, 16 Jul 2024 16:02:50 +0200 Subject: [PATCH 01/18] Add gitignore file --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ From 1a6dd27382148c49298aafa14bf37bbb0a8fa1c7 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Tue, 16 Jul 2024 16:11:50 +0200 Subject: [PATCH 02/18] Session: several minor changes Close session on exit Fill Logout function XSRF-TOKEN should be on response headers with new format instead of cookies. Try to get from headers, else from cookies. --- pyhfs/session.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pyhfs/session.py b/pyhfs/session.py index 5d5a7c6..4a9605c 100644 --- a/pyhfs/session.py +++ b/pyhfs/session.py @@ -48,11 +48,16 @@ def __enter__(self): @exceptions_sanity def __exit__(self, exc_type, exc_val, exc_tb): self.logout() + self.session.close() @exceptions_sanity def logout(self) -> None: '''Logout from base url''' - self.session = requests.session() + try: + self._raw_post('logout') + except exception._305_NotLogged: + # Expected to happen after logout + pass @exceptions_sanity def login(self) -> None: @@ -68,7 +73,7 @@ def login(self) -> None: 'userName': self.user, 'systemCode': self.password}) # Login succeeded, stores authentication token self.session.headers.update( - {'XSRF-TOKEN': response.cookies.get(name='XSRF-TOKEN')}) + {'XSRF-TOKEN': response.headers.get('XSRF-TOKEN') or response.cookies.get(name='XSRF-TOKEN')}) except exception._305_NotLogged: # Login failed can also be raised directly for 20001, 20002, 20003 failCodes. raise exception.LoginFailed() from None From 6f97fe92db837cd2c41748a24e67849fafe1ebbc Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Tue, 16 Jul 2024 16:32:18 +0200 Subject: [PATCH 03/18] Tests: allow without credentials If credentials are not provided (Environment variables not set), some tests are skipped. The tests with Mock remain. Remove unused imports and variables --- pyhfs/tests/test_client.py | 39 ++++++++++++++++++--------------- pyhfs/tests/test_mock_client.py | 17 +++++--------- pyhfs/tests/test_session.py | 20 ++++++++++------- pyhfs/tests/utils.py | 7 ++++++ 4 files changed, 46 insertions(+), 37 deletions(-) diff --git a/pyhfs/tests/test_client.py b/pyhfs/tests/test_client.py index 1597a87..ec39b87 100644 --- a/pyhfs/tests/test_client.py +++ b/pyhfs/tests/test_client.py @@ -1,11 +1,8 @@ -import os -import sys import datetime -import logging import unittest -from pyhfs.tests.utils import * +from pyhfs.tests.utils import credentials, no_credentials, frequency_limit import pyhfs @@ -16,23 +13,31 @@ class TestClient(unittest.TestCase): def setUpClass(cls): cls.invalid = 'Invalid93#!' - cls.user, cls.password = credentials() + if no_credentials(): + cls.user, cls.password = None, None + cls.session = None + else: + cls.user, cls.password = credentials() - # Create session and login - cls.session = pyhfs.Session(user=cls.user, password=cls.password) - cls.session.login() + # Create session and login + cls.session = pyhfs.Session(user=cls.user, password=cls.password) + cls.session.login() @classmethod @frequency_limit def tearDownClass(cls): - cls.session.logout() + if cls.session: + cls.session.logout() + @unittest.skipIf(no_credentials(), "Credentials not provided") + @frequency_limit def test_login_failed_request(self): - with self.assertRaises(pyhfs.LoginFailed) as context: + with self.assertRaises(pyhfs.LoginFailed): session = pyhfs.Session(user=self.invalid, password=self.invalid) with pyhfs.Client(session=session) as client: - plants = client.get_plant_list() + client.get_plant_list() + @unittest.skipIf(no_credentials(), "Credentials not provided") @frequency_limit def test_request(self): with pyhfs.Client(session=self.session) as client: @@ -47,23 +52,21 @@ def test_request(self): self.assertGreaterEqual(len(plants_code), len(realtime)) # Hourly data, with non existing - hourly = client.get_plant_hourly_data( + client.get_plant_hourly_data( plants_code + ['do_not_exist'], now) # Daily data, with a plants list bigger than 100 - daily = client.get_plant_daily_data( + client.get_plant_daily_data( list(map(str, range(46))) + plants_code + list(map(str, range(107))), now) # Monthly data - monthly = client.get_plant_monthly_data(plants_code, now) + client.get_plant_monthly_data(plants_code, now) # Yearly data - yearly = client.get_plant_yearly_data(plants_code, now) + client.get_plant_yearly_data(plants_code, now) # Alarms - alarms = client.get_alarms_list( - plants_code, datetime.datetime(2000, 1, 1), now) - pass + client.get_alarms_list(plants_code, datetime.datetime(2000, 1, 1), now) if __name__ == '__main__': diff --git a/pyhfs/tests/test_mock_client.py b/pyhfs/tests/test_mock_client.py index 3f040ef..35e8abf 100644 --- a/pyhfs/tests/test_mock_client.py +++ b/pyhfs/tests/test_mock_client.py @@ -1,10 +1,7 @@ -import os -import sys -import logging import unittest import datetime -from pyhfs.tests.mock_session import * +from pyhfs.tests.mock_session import MockSession import pyhfs @@ -29,21 +26,19 @@ def test(self): self.assertGreaterEqual(len(plants_code), len(realtime)) # Hourly data - hourly = self.client.get_plant_hourly_data(plants_code, now) + self.client.get_plant_hourly_data(plants_code, now) # Daily data - daily = self.client.get_plant_daily_data(plants_code, now) + self.client.get_plant_daily_data(plants_code, now) # Monthly data - monthly = self.client.get_plant_monthly_data(plants_code, now) + self.client.get_plant_monthly_data(plants_code, now) # Yearly data - yearly = self.client.get_plant_yearly_data(plants_code, now) + self.client.get_plant_yearly_data(plants_code, now) # Alarms - alarms = self.client.get_alarms_list( - plants_code, datetime.datetime(2000, 1, 1), now) - pass + self.client.get_alarms_list(plants_code, datetime.datetime(2000, 1, 1), now) if __name__ == '__main__': diff --git a/pyhfs/tests/test_session.py b/pyhfs/tests/test_session.py index d5c9385..3bc7331 100644 --- a/pyhfs/tests/test_session.py +++ b/pyhfs/tests/test_session.py @@ -1,35 +1,39 @@ -import os -import sys -import logging import unittest -import functools -from pyhfs.tests.utils import * +from pyhfs.tests.utils import credentials, no_credentials, frequency_limit import pyhfs class TestSession(unittest.TestCase): @classmethod + @frequency_limit def setUpClass(cls): cls.invalid = 'Invalid93#!' - cls.user, cls.password = credentials() + if no_credentials(): + cls.user, cls.password = None, None + else: + cls.user, cls.password = credentials() @classmethod def tearDownClass(cls): pass + @unittest.skipIf(no_credentials(), "Credentials not provided") + @frequency_limit def test_invalid_user(self): - with self.assertRaises(pyhfs.LoginFailed) as context: + with self.assertRaises(pyhfs.LoginFailed): with pyhfs.Session(user=self.invalid, password=self.invalid): pass + @unittest.skipIf(no_credentials(), "Credentials not provided") @frequency_limit def test_invalid_password(self): - with self.assertRaises(pyhfs.LoginFailed) as context: + with self.assertRaises(pyhfs.LoginFailed): with pyhfs.Session(user=self.user, password=self.invalid): pass + @unittest.skipIf(no_credentials(), "Credentials not provided") @frequency_limit def test_valid_login(self): with pyhfs.Session(user=self.user, password=self.password): diff --git a/pyhfs/tests/utils.py b/pyhfs/tests/utils.py index 54391e2..b2498aa 100644 --- a/pyhfs/tests/utils.py +++ b/pyhfs/tests/utils.py @@ -28,3 +28,10 @@ def credentials(): raise ValueError('Missing environment variable FUSIONSOLAR_PASSWORD') return user, password + +def no_credentials(): + """ + Return True if credential are not available + This will have the effect of skipping tests + """ + return ('FUSIONSOLAR_USER' not in os.environ) and ('FUSIONSOLAR_PASSWORD' not in os.environ) From a3e7305afe45104152fdd97d4df85e215d84efe4 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Tue, 16 Jul 2024 16:40:30 +0200 Subject: [PATCH 04/18] Fix lint warnings --- pyhfs/__init__.py | 7 ++++++- pyhfs/client.py | 5 +---- pyhfs/session.py | 1 - pyhfs/tests/mock_session.py | 2 -- pyhfs/tests/utils.py | 3 --- 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/pyhfs/__init__.py b/pyhfs/__init__.py index ddb9220..c56cb80 100644 --- a/pyhfs/__init__.py +++ b/pyhfs/__init__.py @@ -2,4 +2,9 @@ from .session import Session from .client import Client from .client import ClientSession -from .exception import * # Automatically imports all public exception +from .exception import Exception, LoginFailed, FrequencyLimit, Permission + +__all__ = [ + "Session", "Client", "ClientSession", + "Exception", "LoginFailed", "FrequencyLimit", "Permission" +] diff --git a/pyhfs/client.py b/pyhfs/client.py index ff4e28d..bdeec3e 100644 --- a/pyhfs/client.py +++ b/pyhfs/client.py @@ -1,10 +1,7 @@ -import logging -import os import itertools import datetime from . import session -from . import exception # Based on documentation iMaster NetEco V600R023C00 Northbound Interface Reference-V6(SmartPVMS) # https://support.huawei.com/enterprise/en/doc/EDOC1100261860/ @@ -113,7 +110,7 @@ def get_alarms_list(self, plants: list, begin: datetime.datetime, end: datetime. '''Get the current (active) alarm information of a device. Implementation wraps a call to the Device Alarm Interface. Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. - Language can be any of zh_CN (Chinese), en_US (English), ja_JP (Japanese), it_IT (Italian), + Language can be any of zh_CN (Chinese), en_US (English), ja_JP (Japanese), it_IT (Italian), nl_NL (Dutch), pt_BR (Portuguese), de_DE (German), fr_FR: French), es_ES (Spanish), pl_PL (Polish) ''' parameters = {'language': language, diff --git a/pyhfs/session.py b/pyhfs/session.py index 4a9605c..21fdfec 100644 --- a/pyhfs/session.py +++ b/pyhfs/session.py @@ -1,5 +1,4 @@ import logging -import os import requests import json import functools diff --git a/pyhfs/tests/mock_session.py b/pyhfs/tests/mock_session.py index 0a8c8e4..4ca4036 100644 --- a/pyhfs/tests/mock_session.py +++ b/pyhfs/tests/mock_session.py @@ -1,6 +1,4 @@ -import logging import os -import sys import json import pathlib diff --git a/pyhfs/tests/utils.py b/pyhfs/tests/utils.py index b2498aa..fedc8e6 100644 --- a/pyhfs/tests/utils.py +++ b/pyhfs/tests/utils.py @@ -1,11 +1,9 @@ import os -import sys import logging import functools import pyhfs - def frequency_limit(func): '''Handle frequency limits cases, which cannot ben considered as fails.''' @functools.wraps(func) @@ -17,7 +15,6 @@ def wrap(*args, **kwargs): 'Couldn\'t complete test due to exceeding frequency limits.') return wrap - def credentials(): user = os.environ.get('FUSIONSOLAR_USER') if user is None: From 45598e7d2162c32b78ca3fac7b55197f571b842c Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Wed, 17 Jul 2024 14:51:10 +0200 Subject: [PATCH 05/18] How to: raise exception if credentials are not provided --- how_to.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/how_to.py b/how_to.py index 597d3e6..d89d444 100644 --- a/how_to.py +++ b/how_to.py @@ -33,6 +33,8 @@ def how_to(user: str, password: str): if __name__ == '__main__': - user = os.environ.get('FUSIONSOLAR_USER', 'unknown_user') - password = os.environ.get('FUSIONSOLAR_PASSWORD', 'unknown_password') + user = os.environ.get('FUSIONSOLAR_USER', None) + password = os.environ.get('FUSIONSOLAR_PASSWORD', None) + if user is None or password is None: + raise Exception("Please set FUSIONSOLAR_USER and FUSION_SOLARPASSWORD to allow login") how_to(user=user, password=password) From 35feb0c20628f8c6cfafd692eb3e4a7e2f55f887 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Thu, 18 Jul 2024 18:40:31 +0200 Subject: [PATCH 06/18] Client, Session: add specific loggers --- pyhfs/client.py | 8 ++++++++ pyhfs/session.py | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pyhfs/client.py b/pyhfs/client.py index bdeec3e..8221b6b 100644 --- a/pyhfs/client.py +++ b/pyhfs/client.py @@ -1,11 +1,13 @@ import itertools import datetime +import logging from . import session # Based on documentation iMaster NetEco V600R023C00 Northbound Interface Reference-V6(SmartPVMS) # https://support.huawei.com/enterprise/en/doc/EDOC1100261860/ +logger = logging.getLogger(__name__) class Client: def __init__(self, session: session.Session): @@ -39,6 +41,7 @@ def get_plant_list(self) -> list: plants = [] for page in itertools.count(start=1): param = {'pageNo': page, 'pageSize': 100} + logger.debug(f"Get plant list for page {page}") response = self.session.post( endpoint='stations', parameters=param)['data'] plants = plants + response.get('list', []) @@ -64,6 +67,7 @@ def get_plant_realtime_data(self, plants: list) -> list: Implementation wraps a call to the Plant Data Interfaces, see 7.1.4.1 Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. ''' + logger.debug("Get realtime plant data") return self._get_plant_data('getStationRealKpi', plants) def _get_plant_timed_data(self, endpoint, plants: list, date: datetime.datetime) -> list: @@ -80,6 +84,7 @@ def get_plant_hourly_data(self, plants: list, date: datetime.datetime) -> list: Implementation wraps a call to the Plant Hourly Data Interfaces, see 7.1.4.2 Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. ''' + logger.debug("Get station hour data") return self._get_plant_timed_data('getKpiStationHour', plants=plants, date=date) def get_plant_daily_data(self, plants: list, date: datetime.datetime) -> list: @@ -88,6 +93,7 @@ def get_plant_daily_data(self, plants: list, date: datetime.datetime) -> list: Implementation wraps a call to the Plant Hourly Data Interfaces, see 7.1.4.3 Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. ''' + logger.debug("Get station daily data") return self._get_plant_timed_data('getKpiStationDay', plants=plants, date=date) def get_plant_monthly_data(self, plants: list, date: datetime.datetime) -> list: @@ -96,6 +102,7 @@ def get_plant_monthly_data(self, plants: list, date: datetime.datetime) -> list: Implementation wraps a call to the Plant Hourly Data Interfaces, see 7.1.4.4 Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. ''' + logger.debug("Get station monthly data") return self._get_plant_timed_data('getKpiStationMonth', plants=plants, date=date) def get_plant_yearly_data(self, plants: list, date: datetime.datetime) -> list: @@ -104,6 +111,7 @@ def get_plant_yearly_data(self, plants: list, date: datetime.datetime) -> list: Implementation wraps a call to the Plant Hourly Data Interfaces, see 7.1.4.5 Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. ''' + logger.debug("Get station yearly data") return self._get_plant_timed_data('getKpiStationYear', plants=plants, date=date) def get_alarms_list(self, plants: list, begin: datetime.datetime, end: datetime.datetime, language='en_US') -> list: diff --git a/pyhfs/session.py b/pyhfs/session.py index 21fdfec..4a08b7c 100644 --- a/pyhfs/session.py +++ b/pyhfs/session.py @@ -5,6 +5,8 @@ from . import exception +logger = logging.getLogger(__name__) + # Based on documentation iMaster NetEco V600R023C00 Northbound Interface Reference-V6(SmartPVMS) # https://support.huawei.com/enterprise/en/doc/EDOC1100261860 @@ -17,7 +19,7 @@ def wrap(*args, **kwargs): try: return func(*args, **kwargs) except exception._InternalException as e: - logging.exception( + logger.exception( 'Internal exceptions getting out of of the private code.') raise exception.Exception(e.args[0], e.args[1]) from None @@ -53,6 +55,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): def logout(self) -> None: '''Logout from base url''' try: + logger.debug('Logout request') self._raw_post('logout') except exception._305_NotLogged: # Expected to happen after logout @@ -64,10 +67,10 @@ def login(self) -> None: Login to base url See documentation: https://support.huawei.com/enterprise/en/doc/EDOC1100261860/9e1a18d2/login-interface ''' - try: # Posts login request self.session.cookies.clear() + logger.debug("Login request") response, body = self._raw_post(endpoint='login', parameters={ 'userName': self.user, 'systemCode': self.password}) # Login succeeded, stores authentication token From 03188fb188978d4a568628ebeca5774fdf657edb Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Fri, 19 Jul 2024 15:36:18 +0200 Subject: [PATCH 07/18] Configuration: add Ruff line length --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 450f94e..7d72eac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,4 +19,7 @@ classifiers = [ [project.urls] "Homepage" = "https://github.com/guillaumeblanc/pyhfs" -"Bug Tracker" = "https://github.com/guillaumeblanc/pyhfs/issues" \ No newline at end of file +"Bug Tracker" = "https://github.com/guillaumeblanc/pyhfs/issues" + +[tool.ruff] +line-length = 120 From 5435d064665c98d528ce19ddc39e234a1d0563c5 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Fri, 19 Jul 2024 15:39:55 +0200 Subject: [PATCH 08/18] Api: add classes to help get data from responses Add a class for each response with properties to access the fields --- pyhfs/__init__.py | 28 +- pyhfs/api/__init__.py | 0 pyhfs/api/alarm_data.py | 134 ++++++++ pyhfs/api/device_rpt_data.py | 148 +++++++++ pyhfs/api/device_rt_data.py | 596 +++++++++++++++++++++++++++++++++++ pyhfs/api/devices.py | 110 +++++++ pyhfs/api/plant_data.py | 275 ++++++++++++++++ pyhfs/api/plants.py | 86 +++++ pyhfs/api/util.py | 126 ++++++++ pyhfs/client.py | 349 ++++++++++++++++---- pyhfs/exception.py | 23 +- pyhfs/session.py | 56 ++-- 12 files changed, 1825 insertions(+), 106 deletions(-) create mode 100644 pyhfs/api/__init__.py create mode 100644 pyhfs/api/alarm_data.py create mode 100644 pyhfs/api/device_rpt_data.py create mode 100644 pyhfs/api/device_rt_data.py create mode 100644 pyhfs/api/devices.py create mode 100644 pyhfs/api/plant_data.py create mode 100644 pyhfs/api/plants.py create mode 100644 pyhfs/api/util.py diff --git a/pyhfs/__init__.py b/pyhfs/__init__.py index c56cb80..b9fe2e0 100644 --- a/pyhfs/__init__.py +++ b/pyhfs/__init__.py @@ -3,8 +3,32 @@ from .client import Client from .client import ClientSession from .exception import Exception, LoginFailed, FrequencyLimit, Permission +from .api.plants import Plant +from .api.devices import Device +from .api.plant_data import PlantRealTimeData, PlantHourlyData, PlantDailyData, PlantMonthlyData, PlantYearlyData +from .api.device_rt_data import DeviceRTData +from .api.device_rpt_data import DeviceRptData +from .api.alarm_data import AlarmData +from .api.util import from_timestamp, to_timestamp __all__ = [ - "Session", "Client", "ClientSession", - "Exception", "LoginFailed", "FrequencyLimit", "Permission" + "Session", + "Client", + "ClientSession", + "Exception", + "LoginFailed", + "FrequencyLimit", + "Permission", + "Plant", + "Device", + "PlantRealTimeData", + "PlantHourlyData", + "PlantDailyData", + "PlantMonthlyData", + "PlantYearlyData", + "DeviceRTData", + "DeviceRptData", + "AlarmData", + "from_timestamp", + "to_timestamp", ] diff --git a/pyhfs/api/__init__.py b/pyhfs/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyhfs/api/alarm_data.py b/pyhfs/api/alarm_data.py new file mode 100644 index 0000000..0e3462a --- /dev/null +++ b/pyhfs/api/alarm_data.py @@ -0,0 +1,134 @@ +import datetime +import logging + +from .plants import Plant +from .devices import Device +from .util import data_prop, from_timestamp + +logger = logging.getLogger(__name__) + + +class AlarmData: + """ + API for "Querying Active Alarms" + """ + + ALARM_TYPES = { + 0: "other alarms", + 1: "transposition signal", + 2: "exception alarm", + 3: "protection event", + 4: "notification status", + 5: "alarm information", + } + + LEVELS = { + 1: "critical", + 2: "major", + 3: "minor", + 4: "warning", + } + + STATUS = { + 1: "not processed (active)", + } + + def __init__(self, data: dict, plants: dict[str, Plant]): + """ + Initialize from JSON response + + Args: + data: response from the API for a Device + plant: dictionary of plants + """ + self._data = data + if self.station_code not in plants: + raise ValueError(f"Plant/Station {self.station_code} not found for device {self}") + self._plant = plants[self.station_code] + self._device = None + for dev in self._plant.devices: + if self.dev_name == dev.name: + self._device = dev + break + if self._device is None: + logger.warning("Did not find a device matching alarm {self}") + + @staticmethod + def from_list(data: list, plants: dict[str, "Plant"]) -> list["AlarmData"]: + """ + Create a list of alarms from a response + + Args: + data: list of devices from Api + plants: dictionary of plants + + Returns: + list: of alarms + """ + return [AlarmData(item, plants) for item in data] + + def __str__(self) -> str: + return f"""{self.plant} {self.dev_name}: {self.name} +{self.level.upper()} {self.cause_id}: {self.cause} - {self.type} +{self.repair_suggestion} + """ + + @property + def plant(self) -> Plant: + """ + Related plant/station + """ + return self._plant + + @property + def device(self) -> Plant: + """ + Related device + """ + return self._device + + station_code = data_prop("stationCode", "Plant ID, which uniquely identifies a plant (str)") + name = data_prop("alarmName", "Alarm name (str)") + dev_name = data_prop("devName", "Device name (str)") + repair_suggestion = data_prop("repairSuggestion", "") + dev_sn = data_prop("esnCode", "Device SN") + dev_type_id = data_prop("devTypeId", "Device type as integer (int)") + + @property + def dev_type(self) -> str: + """ + Device type as string + """ + return Device.DEVICE_TYPES.get(self.dev_type_id, Device.UNKNOWN_DEVICE) + + cause_id = data_prop("causeId", "Cause ID (int)") + cause = data_prop("alarmCause", "Cause (str)") + alarm_type_id = data_prop("alarmType", "Alarm type as integer (int)") + + @property + def alarm_type(self) -> str: + """ + Alarm type as string + """ + return self.ALARM_TYPES.get(self.alarm_type_id, "Unknown") + + raise_time = data_prop("raiseTime", "Raise time (datetime)", conv=from_timestamp) + id = data_prop("alarmId", "Alarm ID (int)") + station_name = data_prop("stationName", "Plant name (str)") + level_id = data_prop("lev", "Level as integer (int)") + + @property + def level(self) -> str: + """ + Alarm type as string + """ + return self.LEVELS.get(self.level_id, "Unknown") + + status_id = data_prop("status", "Alarm status as integer") + + @property + def status(self) -> str: + """ + Alarm status as string + """ + return self.STATUS.get(self.status_id, "Unknown") diff --git a/pyhfs/api/device_rpt_data.py b/pyhfs/api/device_rpt_data.py new file mode 100644 index 0000000..07832c2 --- /dev/null +++ b/pyhfs/api/device_rpt_data.py @@ -0,0 +1,148 @@ +import logging +from .devices import Device +from .util import data_prop, data_item_prop, from_timestamp, ffmt + +logger = logging.getLogger(__name__) + +# Registered derive classes for reporting queries +RPT_DEVICE_CLASSES = {} + + +def rpt_register(dev_type_id): + """ Register a class for realtime support""" + def _wrapper(cls): + global RPT_DEVICE_CLASSES + + if dev_type_id in RPT_DEVICE_CLASSES: + raise ValueError(f"Reporting data class for device {dev_type_id} already defined") + + RPT_DEVICE_CLASSES[dev_type_id] = cls + return cls + + return _wrapper + + +@rpt_register(-1) # Default if none found +class DeviceRptData: + """ + Base class for reporting Device Data API (Hourly, daily, monthly, yearly) + This class is derived by sub-classes depending on device type + """ + + def __init__(self, data: dict, devices: dict[str, Device]): + """ + Initialize from JSON response + """ + self._data = data + self._dev_id = data["devId"] + if self._dev_id not in devices: + raise ValueError(f"Device {self._dev_id} not found for device realtime data") + self._device = devices[self._dev_id] + + @classmethod + def supported_devices(self) -> list[int]: + """ + Return devices IDs that are supported for realtime data + + Returns: + list of int, see Device.DEVICE_TYPES + """ + return list(RPT_DEVICE_CLASSES.keys()) + + @staticmethod + def from_list(data: list, devices: dict[str, Device]) -> list["DeviceRptData"]: + """ + Parse reporting data from a response + + Args: + data: consumption data + devices: dictionary of devices + + Returns: + list: list of realtime data + """ + global RPT_DEVICE_CLASSES + + result = [] + for item in data: + dev_id = item["devId"] + if dev_id not in devices: + raise ValueError(f"Device {dev_id} not found for device reporting data") + device = devices[dev_id] + if device.dev_type_id not in RPT_DEVICE_CLASSES: + logging.error(f"Reporting data class for device {device} not implemented - using default") + DataCls = RPT_DEVICE_CLASSES[-1] + else: + DataCls = RPT_DEVICE_CLASSES[device.dev_type_id] + + result.append(DataCls(item, devices)) + + return result + + @property + def device(self) -> Device: + """ + Corresponding device + """ + return self._device + + @property + def data(self) -> dict: + """ + Data loaded from the response. + """ + return self._data + + collect_time = data_prop("collectTime", "Collection time (datetime)", conv=from_timestamp) + + def __str__(self) -> str: + return f"Device {self.device} - {self.run_state}" + + +@rpt_register(39) # Residential battery +class DeviceRptDataRBattery(DeviceRptData): + + charge_cap = data_item_prop("charge_cap", "Charged energy in kWh (float)") + discharge_cap = data_item_prop("discharge_cap", "Discharged energy in kWh (float)") + charge_time = data_item_prop("charge_time", "Charging duration in h (float)") + discharge_time = data_item_prop("discharge_time", "Discharging duration in h (float)") + + def __str__(self) -> str: + return f""" +{super().__str__()} + {ffmt(self.charge_cap)} kWh {ffmt(self.discharge_cap)} kWh + {ffmt(self.charge_time)} h {ffmt(self.discharge_time)} h + """ + + + +@rpt_register(1) # String inverter +class DeviceRptDataSInverter(DeviceRptData): + + installed_capacity = data_item_prop("installed_capacity", "Installed capacity in kWp (float)") + product_power = data_item_prop("product_power", "Yield in kWh (float)") + perpower_ratio = data_item_prop("perpower_ratio", "Specific energy in kWh/kWp (float)") + + def __str__(self) -> str: + return f""" +{super().__str__()} + {ffmt(self.product_power)} kWh {ffmt(self.charge_time)} h + """ + +@rpt_register(38) # Residential inverter +class DeviceRptDataRInverter(DeviceRptDataSInverter): + # Same as String inverter + pass + + +@rpt_register(41) # C&I and utility ESS +class DeviceRptDataCI(DeviceRptData): + + charge_cap = data_item_prop("charge_cap", "Charged energy in kWh (float)") + discharge_cap = data_item_prop("discharge_cap", "Discharged energy in kWh (float)") + + def __str__(self) -> str: + return f""" +{super().__str__()} + {ffmt(self.charge_cap)} kWh {ffmt(self.discharge_cap)} kWh + """ \ No newline at end of file diff --git a/pyhfs/api/device_rt_data.py b/pyhfs/api/device_rt_data.py new file mode 100644 index 0000000..7b16b8a --- /dev/null +++ b/pyhfs/api/device_rt_data.py @@ -0,0 +1,596 @@ +from typing import Optional +import logging +import datetime +from .devices import Device +from .util import data_item_prop, data_item_prop_opt, from_timestamp, ffmt + +logger = logging.getLogger(__name__) + +# Registered derive classes for realtime queries +RT_DEVICE_CLASSES = {} + + +def rt_register(dev_type_id): + """ Register a class for realtime support""" + def _wrapper(cls): + global RT_DEVICE_CLASSES + + if dev_type_id in RT_DEVICE_CLASSES: + raise ValueError(f"Realtime data class for device {dev_type_id} already defined") + + RT_DEVICE_CLASSES[dev_type_id] = cls + return cls + + return _wrapper + + +@rt_register(-1) # Default if none found +class DeviceRTData: + """ + Base class for Real-Time Device Data API + This class is derived by sub-classes depending on device type + """ + + RUN_STATES = { + 0: "Disconnected", + 1: "Connected", + } + + def __init__(self, data: dict, devices: dict[str, Device]): + """ + Initialize from JSON response + """ + self._data = data + self._dev_id = data["devId"] + if self._dev_id not in devices: + raise ValueError(f"Device {self._dev_id} not found for device realtime data") + self._device = devices[self._dev_id] + + @classmethod + def supported_devices(self) -> list[int]: + """ + Return devices IDs that are supported for realtime data + + Returns: + list of int, see Device.DEVICE_TYPES + """ + return list(RT_DEVICE_CLASSES.keys()) + + @staticmethod + def from_list(data: list, devices: dict[str, Device]) -> list["DeviceRTData"]: + """ + Parse real time data from a response + + Args: + data: consumption data + devices: dictionary of devices + + Returns: + list: list of realtime data + """ + global RT_DEVICE_CLASSES + + result = [] + for item in data: + dev_id = item["devId"] + if dev_id not in devices: + raise ValueError(f"Device {dev_id} not found for device realtime data") + device = devices[dev_id] + if device.dev_type_id not in RT_DEVICE_CLASSES: + logging.error(f"Realtime data class for device {device} not implemented - using default") + DataCls = RT_DEVICE_CLASSES[-1] + else: + DataCls = RT_DEVICE_CLASSES[device.dev_type_id] + + result.append(DataCls(item, devices)) + + return result + + @property + def device(self) -> Device: + """ + Corresponding device + """ + return self._device + + @property + def data(self) -> dict: + """ + Data loaded from the response. + """ + return self._data + + run_state_id = data_item_prop_opt("run_state", -1, "Run state as integer (int)") + + @property + def collect_time(self) -> datetime: + """ + Collect time as datetime. + + This is either: + + - Time of the request for real time data + - For historical data, collect time as provided + """ + if "collectTime" in self._data: + return from_timestamp(self._data["collectTime"]) + return datetime.datetime.now() + + @property + def run_state(self) -> int: + """ + Run state as integer + """ + return self.RUN_STATES.get(self.run_state_id, "Unknown") + + def __str__(self) -> str: + return f"Device {self.device} - {self.run_state}" + + +@rt_register(1) # String inverter +class DeviceRTDataSInverter(DeviceRTData): + INVERTER_STATES = { + 0: "Standby: initializing", + 1: "Standby: insulation resistance detecting", + 2: "Standby: irradiation detecting", + 3: "Standby: grid detecting", + 256: "Start", + 512: "Grid-connected", + 513: "Grid-connected: power limited", + 514: "Grid-connected: self-derating", + 768: "Shutdown: on fault", + 769: "Shutdown: on command", + 770: "Shutdown: OVGR", + 771: "Shutdown: communication interrupted", + 772: "Shutdown: power limited", + 773: "Shutdown: manual startup required", + 774: "Shutdown: DC switch disconnected", + 1025: "Grid scheduling: cosψ-P curve", + 1026: "Grid scheduling: Q-U curve", + 1280: "Ready for terminal test", + 1281: "Terminal testing...", + 1536: "Inspection in progress", + 1792: "AFCI self-check", + 2048: "I-V scanning", + 2304: "DC input detection", + 40960: "Standby: no irradiation", + 45056: "Communication interrupted", + 49152: "Loading...", + } + + inverter_state_id = data_item_prop("inverter_state", "Inverter state as integer (int)") + + @property + def inverter_state(self) -> str: + """ + Inverter state as string + """ + return self.INVERTER_STATES.get(self.inverter_state_id, "Unknown") + + @property + def diff_voltage(self) -> dict[str, Optional[float]]: + """ + line voltage difference of grid (V) + + AB: A-B + BC: B-C + CA: C-A + """ + return {diff.upper(): self._data["dataItemMap"].get(f"{diff}_u", None) for diff in ("ab", "bc", "ca")} + + @property + def voltage(self) -> dict[str, Optional[float]]: + """ + Voltage of lines A, B and C (V). + Example: {"A": 10, "B": 20, "C": None} + """ + return {phase.upper(): self._data["dataItemMap"].get(f"{phase}_u", None) for phase in "abc"} + + @property + def current(self) -> dict[str, Optional[float]]: + """ + Grid current of phases A, B and C (A). + Example: {"A": 10, "B": 20, "C": None} + """ + return {phase.upper(): self._data["dataItemMap"].get(f"{phase}_i", None) for phase in "abc"} + + efficiency = data_item_prop("efficiency", "Inverter conversion efficiency (manufacturer) in % (float)") + temperature = data_item_prop("temperature", "Internal temperature in °C (float)") + power_factor = data_item_prop("power_factor", "Power factor (float)") + elec_freq = data_item_prop("elec_freq", "Grid frequency in Hz (float)") + active_power = data_item_prop("active_power", "Active power in kW (float)") + reactive_power = data_item_prop("reactive_power", "Output reactive power in kVar (float)") + day_cap = data_item_prop("day_cap", "Yield today in kWh (float)") + mppt_power = data_item_prop("mppt_power", "MPPT total input power in kW (float)") + + @property + def pv_voltage(self) -> dict[int, float]: + """ + PVx input voltage in V for each PV + Example: {1: 10, 2: 10, 3: None} + """ + result = {} + for i in range(1, 29): + key = f"pv{i}_u" + if key in self._data["dataItemMap"]: + result[i] = self._data["dataItemMap"][key] + + return result + + @property + def pv_current(self) -> dict[int, float]: + """ + PVx input current in A for each PV + Example: {1: 10, 2: 10, 3: None} + """ + result = {} + for i in range(1, 29): + key = f"pv{i}_i" + if key in self._data["dataItemMap"]: + result[i] = self._data["dataItemMap"][key] + + return result + + total_cap = data_item_prop("total_cap", "Total yield in kWh (float)") + open_time = data_item_prop("open_time", "Inverter startup time (datetime)", conv=from_timestamp) + close_time = data_item_prop("close_time", "Inverter shutdown time in (datetime)", conv=from_timestamp) + + @property + def mppt_total_cap(self) -> float: + """ + Total DC input energy in kWh + """ + if "mppt_total_cap" in self._data["dataItemMap"]: + return self._data["dataItemMap"]["mppt_total_cap"] + # Not in residential inverter + return sum(self.mppt_cap) + + @property + def mppt_cap(self) -> dict[int, float]: + """ + MPPT 1 DC total yield in kWh + Example: {1: 10, 2: 10, 3: None} + """ + result = {} + for i in range(1, 29): + key = f"mppt_{i}_cap" + if key in self._data["dataItemMap"]: + result[i] = self._data["dataItemMap"][key] + + return result + + def __str__(self) -> str: + return f""" +{super().__str__()} + {self.inverter_state} {self.temperature} °C + Voltage A:{ffmt(self.voltage['A'])} V B:{ffmt(self.voltage['B'])} V C:{ffmt(self.voltage['C'])} V PV: {ffmt(self.pv_voltage)} V + Current A:{ffmt(self.current['A'])} A B:{ffmt(self.current['B'])} A C:{ffmt(self.current['C'])} A PV: {ffmt(self.pv_current)} A + """ + + +@rt_register(38) # Residential inverter - Same as String inverter +class DeviceRTDataRInverter(DeviceRTDataSInverter): + pass + + +@rt_register(10) # Environmental monitoring instrument (EMI) +class DeviceRTDataEMI(DeviceRTData): + temperature = data_item_prop("temperature", "Temperature in °C (float)") + pv_temperature = data_item_prop("pv_temperature", "PV temperature in °C (float)") + wind_speed = data_item_prop("wind_speed", "Wind speed in m/s (float)") + wind_direction = data_item_prop("wind_direction", "Wind direction in Degree (float)") + radiant_total = data_item_prop("radiant_total", "Daily irradiation in MJ/m2 (float)") + radiant_line = data_item_prop("radiant_line", "Irradiance in W/m2 (float)") + + def __str__(self) -> str: + return f""" +{super().__str__()} + Temperature: {ffmt(self.temperature)} °C PV Temperature: {ffmt(self.pv_temperature)} °C + Wind: {ffmt(self.wind_direction)} m/s {ffmt(self.wind_direction)} ° + """ + + +@rt_register(17) # Grid meter +class DeviceRTDataGMeter(DeviceRTData): + @property + def diff_voltage(self) -> dict[str, Optional[float]]: + """ + line voltage difference of grid (V) + + AB: A-B + BC: B-C + CA: C-A + """ + return {diff.upper(): self._data["dataItemMap"].get(f"{diff}_u", None) for diff in ("ab", "bc", "ca")} + + @property + def voltage(self) -> dict[str, Optional[float]]: + """ + Phase voltage of lanes A, B and C (AC output) in V + Example: {"A": 10, "B": 20, "C": None} + """ + return {phase.upper(): self._data["dataItemMap"].get(f"{phase}_u", None) for phase in "abc"} + + @property + def current(self) -> dict[str, Optional[float]]: + """ + Phase voltage of grid, lanes A, B and C in A + Example: {"A": 10, "B": 20, "C": None} + """ + return {phase.upper(): self._data["dataItemMap"].get(f"{phase}_i", None) for phase in "abc"} + + active_power = data_item_prop("active_power", "Active power in kW (float)") + power_factor = data_item_prop("power_factor", "Power factor in % (float)") + active_cap = data_item_prop("active_cap", "Active energy (positive active energy) in kWh (float)") + reactive_power = data_item_prop("reactive_power", "Reactive power in kVar (float)") + reverse_active_cap = data_item_prop("reverse_active_cap", "Negative active energy in kWh (float)") + forward_reactive_cap = data_item_prop("forward_reactive_cap", "Positive reactive energy in kWh (float)") + reverse_reactive_cap = data_item_prop("reverse_reactive_cap", "Negative reactive energy in kWh (float)") + + @property + def active_power_phase(self) -> dict[str, Optional[float]]: + """ + Active power of lanes A, B and C in kW + Example: {"A": 10, "B": 20, "C": None} + """ + return {phase.upper(): self._data["dataItemMap"].get(f"active_power_{phase}", None) for phase in "abc"} + + total_apparent_power = data_item_prop("total_apparent_power", "Total apparent power in kVA (float)") + + def __str__(self) -> str: + return f""" +{super().__str__()} + Voltage A:{ffmt(self.voltage['A'])} V B:{ffmt(self.voltage['B'])} V C:{ffmt(self.voltage['C'])} V + Current A:{ffmt(self.current['A'])} A B:{ffmt(self.current['B'])} A C:{ffmt(self.current['C'])} A + """ + + +@rt_register(47) # Power sensor +class DeviceRTDataPSensor(DeviceRTData): + METER_STATUS = {0: "Offline", 1: "Normal"} + + meter_status_id = data_item_prop("meter_status", "Meter state as integer (int)") + + @property + def meter_status(self) -> int: + """ + Meter state as string + """ + return DeviceRTDataPSensor.METER_STATUS.get(self.meter_status_id, "Unknown") + + voltage = data_item_prop("meter_u", "Phase A voltage (AC output) in V (float)") + current = data_item_prop("meter_i", "Phase A current of grid in A (float)") + active_power = data_item_prop("active_power", "Active power in W (float)") + reactive_power = data_item_prop("reactive_power", "Reactive power in Var (float)") + power_factor = data_item_prop("power_factor", "Power factor in % (float)") + grid_frequency = data_item_prop("grid_frequency", "Grid frequency in Hz") + active_cap = data_item_prop("active_cap", "Active energy (positive active energy) in kWh (float)") + reverse_active_cap = data_item_prop("reverse_active_cap", "Negative active energy in kWh (float)") + + @property + def diff_voltage(self) -> dict[str, Optional[float]]: + """ + line voltage difference of grid (V) + + AB: A-B + BC: B-C + CA: C-A + """ + return {diff.upper(): self._data.get(f"{diff}_u", None) for diff in ("ab", "bc", "ca")} + + @property + def voltage_phase(self) -> dict[str, Optional[float]]: + """ + Phase voltage of lanes A, B and C (AC output) in V + Example: {"A": 10, "B": 20, "C": None} + """ + return {phase.upper(): self._data.get(f"{phase}_u", None) for phase in "abc"} + + @property + def current_phase(self) -> dict[str, Optional[float]]: + """ + Phase voltage of grid, lanes A, B and C in A + Example: {"A": 10, "B": 20, "C": None} + """ + return {phase.upper(): self._data.get(f"{phase}_i", None) for phase in "abc"} + + @property + def active_power_phase(self) -> float: + """ + Active power, lanes A, B and C in kW + """ + return {phase.upper(): self._data.get(f"active_power_{phase}", None) for phase in "abc"} + + def __str__(self) -> str: + return f""" +{super().__str__()} + Voltage A:{ffmt(self.voltage_phase['A'])} V B:{ffmt(self.voltage_phase['B'])} V C:{ffmt(self.voltage_phase['C'])} V + Current A:{ffmt(self.current_phase['A'])} A B:{ffmt(self.current_phase['B'])} A C:{ffmt(self.current_phase['C'])} A + """ + + +@rt_register(39) +class DeviceRTDataRBattery(DeviceRTData): + BATTERY_STATUS = {0: "offline", 1: "standby", 2: "running", 3: "faulty", 4: "hibernating"} + + CHARGE_MODE = { + 0: "none", + 1: "forced charge/discharge", + 2: "time-of-use price", + 3: "fixed charge/discharge", + 4: "automatic charge/discharge", + 5: "fully fed to grid", + 6: "TOU", + 7: "remote scheduling–max. self-consumption", + 8: "remote scheduling–fully fed to grid", + 9: "remote scheduling–TOU", + 10: "EMMA", + } + + battery_status_id = data_item_prop("battery_status", "Battery running state as integer (int)") + + @property + def battery_status(self) -> str: + """ + Battery running state as string + """ + return self.BATTERY_STATUS.get(self.battery_status_id, "Unknown") + + def __str__(self) -> str: + return f""" +{super().__str__()} + {self.battery_status}, charge model {self.charge_mode} + Power {ffmt(self.ch_discharge_power)} W, soc {self.soc} %, soh {self.soh}% + Charge cap {ffmt(self.charge_cap)} kWh, discharge cap {ffmt(self.discharge_cap)} kWh + """ + + max_charge_power = data_item_prop("max_charge_power", "Maximum charge power in W (float)") + max_discharge_power = data_item_prop("max_discharge_power", "Maximum discharge power in W (float)") + ch_discharge_power = data_item_prop("ch_discharge_power", "Charge/Discharge power in W (float)") + voltage = data_item_prop("busbar_u", "Battery voltage in V (float)") + soc = data_item_prop("battery_soc", "Battery State of Charge (SOC) in %") + soh = data_item_prop( + "battery_soh", "Battery State of Health (SOH), supported only by LG batteries, in % (float)" + ) + charge_mode_id = data_item_prop("ch_discharge_model", "Charge/Discharge mode as integer (int)") + + @property + def charge_mode(self) -> str: + """ + Charge/Discharge mode as string + """ + return self.CHARGE_MODE.get(self.charge_mode_id, "Unknown") + + charge_cap = data_item_prop("charge_cap", "Charged energy in kWh (float)") + discharge_cap = data_item_prop("discharge_cap", "Discharged energy in kWh (float)") + + +@rt_register(41) # C&I and utility ESS +class DeviceRTDataCI(DeviceRTData): + ch_discharge_power = data_item_prop("ch_discharge_power", "Charge/Discharge power in W (float)") + soc = data_item_prop("battery_soc", "Battery State of Charge (SOC) in % (float)") + soh = data_item_prop("battery_soh", "Battery State of Health (SOH) in % (float)") + charge_cap = data_item_prop("charge_cap", "Charged energy in kWh (float)") + discharge_cap = data_item_prop("discharge_cap", "Discharged energy in kWh (float)") + + def __str__(self) -> str: + return f""" +{super().__str__()} + Power {ffmt(self.ch_discharge_power)} W, soc {self.soc} %, soh {self.soh}% + Charge cap {ffmt(self.charge_cap)} kWh, discharge cap {ffmt(self.discharge_cap)} kWh + """ + + +@rt_register(60001) # Mains (supported only in the Power-M scenario) +class DeviceRTDataMains(DeviceRTData): + MAINS_STATE = { + 0: "mains unavailable", + 1: "mains available", + } + + GRID_QUALITY = {0: "Unknown", 1: "Class 1", 2: "Class 2", 3: "Class 3", 4: "Class 4"} + + mains_state_id = data_item_prop("mains_state", "Mains status as integer (int)") + + @property + def mains_state(self) -> str: + """ + Mains status as string + """ + return self.MAINS_STATE.get(self.mains_state_id, "Unknown") + + ac_voltage = data_item_prop("ac_voltage", "AC voltage in V (float)") + ac_current = data_item_prop("ac_current", "AC current in A (float)") + active_power = data_item_prop("active_power", "Active power in kW (float)") + ac_frequency = data_item_prop("ac_frequency", "AC frequency in Hz (float)") + grid_quality_grade_id = data_item_prop("grid_quality_grade", "Power grid quality level as integer (int)") + + @property + def grid_quality_grade(self) -> str: + """ + Mains status as string + """ + return self.GRID_QUALITY.get(self.grid_quality_grade_id, "Unknown") + + total_energy_consumption = data_item_prop("total_energy_consumption", "Total energy consumption in kWh (float)") + supply_duration_per_total = data_item_prop("supply_duration_per_total", "Total power supply duration in h (float)") + + +@rt_register(60003) # Genset (supported only in the Power-M scenario) +class DeviceRTDataGenset(DeviceRTData): + RUN_STATES = {0: "unknown", 1: "stopped", 2: "running"} + + run_state_id = data_item_prop("running_state", "Running state as integer (int)") + output_power = data_item_prop("output_power", "Output power in kW (float)") + load_rate = data_item_prop("load_rate", "Load rate in % (float)") + + +@rt_register(60043) # SSU group (supported only in the Power-M scenario) +class DeviceRTDataSSUG(DeviceRTData): + total_output_current = data_item_prop("total_output_current", "Total output current in A (float)") + total_output_power = data_item_prop("total_output_power", "Total output power in W (float)") + + +@rt_register(60044) # SSU (supported only in the Power-M scenario) +class DeviceRTDataSSU(DeviceRTData): + RUN_STATES = {0: "on", 1: "off"} + + input_voltage = data_item_prop("input_voltage", "Input voltage in V (float)") + output_voltage = data_item_prop("output_voltage", "Output voltage in V (float)") + output_current = data_item_prop("output_current", "Output current in A (float)") + run_state_id = data_item_prop("on_off_state", "Power-on/off status as integer (int)") + + +@rt_register(60092) # Power converter (supported only in the Power-M scenario) +class DeviceRTDataPConv(DeviceRTData): + total_runtime = data_item_prop("total_runtime", "Total runtime in h (float)") + pv_input_voltage = data_item_prop("pv_input_voltage", "PV input voltage in V (float)") + pv_input_current = data_item_prop("pv_input_current", "PV input current in A (float)") + pv_input_power = data_item_prop("pv_input_power", "PV input power in kW (float)") + inverter_voltage = data_item_prop("inverter_voltage", "Inverter voltage in V (float)") + inverter_frequency = data_item_prop("inverter_frequency", "Inverter frequency in Hz (float)") + ac_output_voltage = data_item_prop("ac_output_voltage", "AC output voltage in V (float)") + ac_output_current = data_item_prop("ac_output_current", "AC output current in A (float)") + ac_output_frequency = data_item_prop("ac_output_frequency", "AC output frequency in kW") + ac_output_apparent_power = data_item_prop("ac_output_apparent_power", "AC output apparent power in kVA (float)") + + +@rt_register(60014) # Lithium battery rack (supported only in the Power-M scenario) +class DeviceRTDataLBat(DeviceRTData): + BATTERY_STATE = { + 0: "initial power-on", + 1: "power-off", + 2: "float charging", + 3: "boost charging", + 4: "discharging", + 5: "charging", + 6: "testing", + 7: "hibernation", + 8: "standby", + } + + battery_state_id = data_item_prop("battery_state", "Battery status as integer (int)") + + @property + def battery_state(self) -> str: + """ + Battery status as string + """ + return self.BATTERY_STATE.get(self.battery_state_id, "Unknown") + + soc = data_item_prop("soc", "State of Charge (SOC) in % (float)") + charge_discharge_power = data_item_prop("charge_discharge_power", "Charge/Discharge power in kW (float)") + total_discharge = data_item_prop("total_discharge", "Total energy discharged in kWh (float)") + voltage = data_item_prop("voltage", "Voltage in V (float)") + current = data_item_prop("current", "Current in A (float)") + remaining_backup_time = data_item_prop("remaining_backup_time", "Remaining power reserve duration in h (float)") + total_discharge_times = data_item_prop("total_discharge_times", "Total discharge times (float)") + total_capacity = data_item_prop("total_capacity", "Total capacity in kWh") + + +@rt_register(60010) # AC output power distribution (supported only in the Power-M scenario) +class DeviceRTDataACOut(DeviceRTData): + ac_voltage = data_item_prop("ac_voltage", "AC voltage in V (float)") + ac_current = data_item_prop("ac_current", "AC current in A (float)") + ac_frequency = data_item_prop("ac_frequency", "AC frequency in Hz (float)") + active_power = data_item_prop("active_power", "Active power in kW (float)") diff --git a/pyhfs/api/devices.py b/pyhfs/api/devices.py new file mode 100644 index 0000000..3f5cd00 --- /dev/null +++ b/pyhfs/api/devices.py @@ -0,0 +1,110 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .plants import Plant + +from .util import data_prop, data_prop_opt + + +class Device: + """ + API class for "Device List API" + """ + + DEVICE_TYPES = { + 1: "Inverter", + 2: "SmartLogger", + 8: "STS", + 10: "EMI", + 13: "protocol converter", + 16: "general device", + 17: "grid meter", + 22: "PID", + 37: "Pinnet data logger", + 38: "Residential inverter", + 39: "Battery", + 40: "Backup box", + 41: "ESS", + 45: "PLC", + 46: "Optimizer", + 47: "Power sensor", + 62: "Dongle", + 63: "Distributed SmartLogger", + 70: "Safety box", + 60001: "Mains", + 60003: "Genset", + 60043: "SSU group", + 60044: "SSU", + 60092: "Power converter", + 60014: "Lithium battery rack", + 60010: "AC output power distribution", + 23070: "EMMA", + } + + UNKNOWN_DEVICE = "Unknown" + + def __init__(self, data: dict, plants: dict[str, "Plant"]): + """ + Initialize from JSON response + + Args: + data: response from the API for a Device + plant: Plant linked to this device, calls add_device to this plant + """ + self._data = data + if self.station_code not in plants: + raise ValueError(f"Plant/Station {self.station_code} not found for device {self}") + self._plant = plants[self.station_code] + self._plant.add_device(self) + + @staticmethod + def from_list(data: list, plants: dict[str, "Plant"]) -> dict[str, "Device"]: + """ + Create a list of devices from a response + + Args: + data: list of devices from Api + plants: dictionary of plants + + Returns: + dict: dictionary device id -> Device + """ + devices = [Device(item, plants) for item in data] + return {device.id: device for device in devices} + + def __str__(self) -> str: + sw_version = f", SW version {self.software_version}" if self.software_version else "" + return f"{self.name} ({self.id}): {self.dev_type} ({self.dev_type_id}) {sw_version}" + + id = data_prop("id", "Device ID (int)") + unique_id = data_prop("devDn", "Unique device ID in the system (str)") + name = data_prop("devName", "Device name (str)") + station_code = data_prop("stationCode", "Plant ID (str)") + serial_number = data_prop("esnCode", "Device SN (str)") + dev_type_id = data_prop("devTypeId", "Device type as integer (int)") + software_version = data_prop("softwareVersion", "Software version (str)") + optimizers = data_prop_opt("optimizerNumber", None, "Quantity of optimizers (int)") + inverter_type = data_prop("invType", "Inverter model, only applicable to inverters (int)") + longitude = data_prop("longitude", "Plant longitude (float)") + latitude = data_prop("latitude", "Plant latitude (float)") + + @property + def dev_type(self) -> str: + """ + Device type (as string) + """ + return Device.DEVICE_TYPES.get(self.dev_type_id, Device.UNKNOWN_DEVICE) + + @property + def plant(self) -> "Plant": + """ + Plant/Station containing this device + """ + return self._plant + + @property + def data(self) -> dict: + """ + Raw data for this device + """ + return self._data diff --git a/pyhfs/api/plant_data.py b/pyhfs/api/plant_data.py new file mode 100644 index 0000000..a2d277f --- /dev/null +++ b/pyhfs/api/plant_data.py @@ -0,0 +1,275 @@ +from .plants import Plant +from .util import data_prop, data_item_prop, data_item_prop_opt, from_timestamp, ffmt + + +class PlantRealTimeData: + """ + API class for "Real-Time Plant Data API" + """ + + REAL_HEALTH_STATE = { + 1: "disconnected", + 2: "faulty", + 3: "healthy", + } + + UNKNOWN_HEALT_STATE = "Unknown" + + def __init__(self, data: dict, plants: dict[str, Plant]): + """ + Initialize from JSON response + """ + self._data = data + if "plant" in self._data: + # Reload from saved JSON file + self._plant = Plant(self._data["plant"]) + + else: + if self.station_code not in plants: + raise ValueError(f"Plant/Station {self.station_code} not found for plant data {self}") + self._plant = plants[self.station_code] + + @staticmethod + def from_list(data: list, plants: dict[str, "Plant"]) -> list["PlantRealTimeData"]: + """ + Parse real time data from a response + + Args: + data: consumption data + plants: dictionary of plants + + Returns: + list: list of realtime data + """ + return [PlantRealTimeData(item, plants) for item in data] + + def __str__(self) -> str: + return f""" +Station {self.plant} {self.health_state} + Power day: {ffmt(self.day_power)} kWh month: {ffmt(self.month_power)} kWh year: {ffmt(self.total_power)} + Income day: {ffmt(self.day_income)} total: {ffmt(self.total_income)} + """ + + station_code = data_prop("stationCode", "Station/plant code (str)") + day_power = data_item_prop("day_power", "Yield today in kWh (float)", conv=float) + month_power = data_item_prop("month_power", "Yield this month in kWh (float)", conv=float) + total_power = data_item_prop("total_power", "Total yield in kWh (float)", conv=float) + day_income = data_item_prop( + "day_income", "Revenue today, in the currency specified in the management system (float)", conv=float + ) + total_income = data_item_prop( + "total_income", "Total revenue, in the currency specified in the management system (float)", conv=float + ) + health_state_id = data_item_prop("real_health_state", "Plant health status as integer (int)", conv=int) + + @property + def plant(self) -> Plant: + """ + Plant related to this data + """ + return self._plant + + @property + def health_state(self) -> str: + """ + Plant health status as string + """ + return PlantRealTimeData.REAL_HEALTH_STATE.get(self.health_state_id, PlantRealTimeData.UNKNOWN_HEALT_STATE) + + @property + def data(self) -> dict: + """ + Return raw data with "plant" as an additional field + """ + self._data["plant"] = self._plant.data + return self._data + + +class PlantHourlyData: + """ + API class for "Hourly Plant Data API" + """ + + def __init__(self, data: dict, plants: dict[str, Plant]): + """ + Initialize from JSON response + """ + self._data = data + if "plant" in self._data: + # Reload from saved JSON file + self._plant = Plant(self._data["plant"]) + + else: + if self.station_code not in plants: + raise ValueError(f"Plant/Station {self.station_code} not found for plant data {self}") + self._plant = plants[self.station_code] + + @staticmethod + def from_list(data: list, plants: dict[str, "Plant"]) -> list["PlantHourlyData"]: + """ + Parse hourly data from a response + + Args: + data: consumption data + plants: dictionary of plants + + Returns: + list: list of hourly data inside the day + """ + return [PlantHourlyData(item, plants) for item in data] + + def __str__(self) -> str: + return f""" +{self.plant.name} - {self.collect_time} +Inverter power: {ffmt(self.inverter_power)} kWh On-Grid power: {ffmt(self.ongrid_power)} kWh + """ + + collect_time = data_prop("collectTime", "Collect time in milliseconds", conv=from_timestamp) + station_code = data_prop("stationCode", "Plant ID") + + @property + def plant(self) -> Plant: + """ + Related Plant/Station + """ + return self._plant + + radiation_intensity = data_item_prop("radiation_intensity", "Global irradiation in kWh/m² (float)") + theory_power = data_item_prop("theory_power", "Theoretical yield in kWh (float)") + inverter_power = data_item_prop("inverter_power", "Inverter yield in kWh (float)") + ongrid_power = data_item_prop("ongrid_power", "Feed-in energy in kWh (float)") + power_profit = data_item_prop("power_profit", "Revenue in currency specified in the management system (float)") + + # Not documented but can be present + charge_cap = data_item_prop_opt("chargeCap", 0, "Charged energy in kWh (float)") + discharge_cap = data_item_prop_opt("dischargeCap", 0, "Discharged energy in kWh (float)") + pv_yield = data_item_prop_opt("PVYield", 0, "PV energy in kWh (float)") + inverted_yield = data_item_prop_opt("inverterYield", 0, "Inverter energy in kWh (float)") + self_provide = data_item_prop_opt("selfProvide", 0, "Energy consumed from PV in kWh (float)") + + @property + def data(self) -> dict: + """ + Raw data + """ + return self._data + + +class PlantDailyData: + """ + API class for "Daily Plant Data API" + """ + + def __init__(self, data: dict, plants: dict[str, Plant]): + """ + Initialize from JSON response + """ + self._data = data + if "plant" in self._data: + # Reload from saved JSON file + self._plant = Plant(self._data["plant"]) + + else: + if self.station_code not in plants: + raise ValueError(f"Plant/Station {self.station_code} not found for plant data {self}") + self._plant = plants[self.station_code] + + @staticmethod + def from_list(data: list, plants: dict[str, "Plant"]) -> list["PlantDailyData"]: + """ + Parse daily data from a response + + Args: + data: consumption data + plants: dictionary of plants + + Returns: + list: list of daily data inside the month + """ + return [PlantDailyData(item, plants) for item in data] + + def __str__(self) -> str: + return f""" +{self.plant.name}: {self.installed_capacity} kWp - {self.collect_time} +Radiation intensity: {ffmt(self.radiation_intensity)} kWh/m² Theory power: {ffmt(self.theory_power)} kWh ({self.performance_ratio}%) +Inverter power: {ffmt(self.inverter_power)} kWh On-Grid power: {ffmt(self.ongrid_power)} kWh +Buy power: {ffmt(self.buy_power)} kWh Use power: {ffmt(self.self_use_power)} kWh Self provide: {ffmt(self.self_provide)} kWh + """ + + collect_time = data_prop("collectTime", "Collect time in milliseconds", conv=from_timestamp) + station_code = data_prop("stationCode", "Plant ID") + + @property + def plant(self) -> Plant: + """ + Related Plant/Station + """ + return self._plant + + installed_capacity = data_item_prop("installed_capacity", "Installed capacity in kWp (float)") + inverter_power = data_item_prop("inverter_power", "Inverter yield in kWh (float)") + perpower_ratio = data_item_prop("perpower_ratio", "Specific energy in kWh/kWp (float)") + reduction_total_co2 = data_item_prop("reduction_total_co2", "CO2 emission reduction in Ton (float)") + reduction_total_coal = data_item_prop("reduction_total_coal", "Standard coal saved in Ton (float)") + buy_power = data_item_prop_opt("buyPower", 0, "Energy from grid in kWh (float)") + charge_cap = data_item_prop_opt("chargeCap", 0, "Charged energy in kWh (float)") + discharge_cap = data_item_prop_opt("dischargeCap", 0, "Discharged energy in kWh (float)") + self_use_power = data_item_prop_opt("selfUsePower", 0, "Consumed PV energy in kWh (float)") + self_provide = data_item_prop_opt("selfProvide", 0, "Energy consumed from PV in kWh (float)") + + # Documented but absent + radiation_intensity = data_item_prop_opt("radiation_intensity", 0, "Global irradiation in kWh/m² (float)") + theory_power = data_item_prop_opt("theory_power", 0, "Theoretical yield in kWh (float)") + performance_ratio = data_item_prop_opt("performance_ratio", 0, "Performance ratio in % (float)") + ongrid_power = data_item_prop_opt("ongrid_power", 0, "Feed-in energy in kWh (float)") + power_profit = data_item_prop_opt("power_profit", 0, "Revenue in currency specified in the management system (float)") + + # Not documented but present + pv_yield = data_item_prop_opt("pv_yield", 0, "PV Yield in kWh (float)") + + @property + def data(self) -> dict: + """ + Raw data + """ + return self._data + + +class PlantMonthlyData(PlantDailyData): + """ + API class for "Monthly Plant Data API" + """ + + @staticmethod + def from_list(data: list, plants: dict[str, "Plant"]) -> list["PlantMonthlyData"]: + """ + Parse daily data from a response + + Args: + data: consumption data + plants: dictionary of plants + + Returns: + list: list of montly data inside the month + """ + return [PlantMonthlyData(item, plants) for item in data] + + +class PlantYearlyData(PlantDailyData): + """ + API class for "Yearly Plant Data API" + """ + + @staticmethod + def from_list(data: list, plants: dict[str, "Plant"]) -> list["PlantYearlyData"]: + """ + Parse daily data from a response + + Args: + data: consumption data + plants: dictionary of plants + + Returns: + list: list of montly data inside the month + """ + return [PlantYearlyData(item, plants) for item in data] diff --git a/pyhfs/api/plants.py b/pyhfs/api/plants.py new file mode 100644 index 0000000..6900744 --- /dev/null +++ b/pyhfs/api/plants.py @@ -0,0 +1,86 @@ +import datetime + +from .devices import Device +from .util import data_prop + + +class Plant: + """ + API class for "Plant List API" response + """ + + def __init__(self, data: dict): + """ + Initialize from JSON response + + Args: + data: response from the API for a Plant or from saved + data. + """ + self._data = data + self._devices: dict[str, Device] = {} + + # Only present if loading from a file + if "devices" in data: + for dev_data in data["devices"]: + # Calls self.add_device + Device(dev_data, {self.code: self}) + + @staticmethod + def from_list(data: list) -> dict[str, "Plant"]: + """ + Create a list of plants from a response + + Args: + data: list of plants from Api + + Returns: + dict: dictionary plant code -> Plant + """ + plants = [Plant(item) for item in data] + return {plant.code: plant for plant in plants} + + def __str__(self) -> str: + return f"{self.name} ({self.code}) - {self.capacity} kWp" + + def add_device(self, device: Device) -> None: + """ + Add a device to this station if it does not already exist + + args: + device: Device to add + """ + self._devices[device.name] = device + + code = data_prop("plantCode", "Plant code (str)") + name = data_prop("plantName", "Plant name (str)") + address = data_prop("plantAddress", "Detailed address of the plant (str)") + longitude = data_prop("longitude", "Plant longitude (float)") + latitude = data_prop("latitude", "Plant latitude (float)") + capacity = data_prop("capacity", "Total capacity in kWp (float)") + contact_person = data_prop("contactPerson", "Plant contact (str)") + contact_method = data_prop( + "contactMethod", + "Contact information of the plant contact, such as the mobile phone number or email address (str)", + ) + grid_connection_date = data_prop( + "gridConnectionDate", + "Grid connection time of the plant, including the time zone (datetime)", + conv=datetime.datetime.fromisoformat, + ) + + @property + def devices(self) -> list[Device]: + """ + List of devices linked to this station once it has been populated + """ + return list(self._devices.values()) + + @property + def data(self) -> dict: + """ + Return original data to be saved. If devices are present, it includes + data from the devices, so different from the original request. + """ + self._data["devices"] = [device.data for device in self.devices] + return self._data diff --git a/pyhfs/api/util.py b/pyhfs/api/util.py new file mode 100644 index 0000000..d1bff6f --- /dev/null +++ b/pyhfs/api/util.py @@ -0,0 +1,126 @@ +import datetime + +def from_timestamp(timestamp: int): + """ + Converts fusion solar timestamp to datetime + + Args: + timestamp: timestamp as an integer + + returns: + datetime: timestamp converted to datetime, with milliseconds ignored + """ + return datetime.datetime.fromtimestamp(timestamp // 1000) + +def to_timestamp(time: datetime.datetime) -> int: + """Converts datetime to fusion solar timestamp. + + Args: + time: time as datetime + + Returns: + int: time as integer + """ + return int(time.timestamp() * 1000) + +def data_prop(field: str, docstring=None, conv=None) -> property: + """ + Read-only property for `self._data[field]` with no + default value. `conv` can specify a conversion function, + for example `float` + + Args: + field: name of the field + docstring: documentation string + conv: optional conversion function, typically `int` or `float` + + returns: + property + """ + + def getter(self): + value = self._data[field] + if conv is not None: + value = conv(value) + return value + + return property(getter, doc=docstring) + + +def data_prop_opt(field: str, default, docstring=None, conv=None) -> property: + """ + Read-only property for `self._data[field]` with default value. + `conv` can specify a conversion function, for example `float` + + Args: + field: name of the field + default: default value if field does not exist + docstring: documentation string + conv: optional conversion function, typically `int` or `float` + + returns: + property + """ + + def getter(self): + value = self._data.get(field, default) + if conv is not None: + value = conv(value) + return value + + return property(getter, doc=docstring) + + +def data_item_prop(field: str, docstring=None, conv=None) -> property: + """ + Read-only property for `self._data["dataItemMap"][field]` with no + default value. `conv` can specify a conversion function, + for example `float` + + Args: + field: name of the field + docstring: documentation string + conv: optional conversion function, typically `int` or `float` + + returns: + property + """ + + def getter(self): + value = self._data["dataItemMap"][field] + if conv is not None: + value = conv(value) + return value + + return property(getter, doc=docstring) + + +def data_item_prop_opt(field: str, default, docstring=None, conv=None) -> property: + """ + Read-only property for `self._data["dataItemMap"][field]` with default value. + `conv` can specify a conversion function, for example `float` + + Args: + field: name of the field + default: default value if field does not exist + docstring: documentation string + conv: optional conversion function, typically `int` or `float` + + returns: + property + """ + + def getter(self): + value = self._data["dataItemMap"].get(field, default) + if conv is not None: + value = conv(value) + return value + + return property(getter, doc=docstring) + +def ffmt(value: float) -> str: + """ + Format a float to 5.2f, compatible with None + """ + s = "(none)" if value is None else f"{value:5.2f}" + return f"{s:7s}" \ No newline at end of file diff --git a/pyhfs/client.py b/pyhfs/client.py index 8221b6b..71ceda0 100644 --- a/pyhfs/client.py +++ b/pyhfs/client.py @@ -1,14 +1,34 @@ +import sys import itertools import datetime import logging +from typing import Iterable from . import session +from .api.plants import Plant +from .api.devices import Device +from .api.plant_data import PlantRealTimeData, PlantHourlyData, PlantDailyData, PlantMonthlyData, PlantYearlyData +from .api.device_rt_data import DeviceRTData +from .api.device_rpt_data import DeviceRptData +from .api.alarm_data import AlarmData +from .api.util import to_timestamp + # Based on documentation iMaster NetEco V600R023C00 Northbound Interface Reference-V6(SmartPVMS) # https://support.huawei.com/enterprise/en/doc/EDOC1100261860/ logger = logging.getLogger(__name__) +if sys.version_info < (3, 12): + # Added in version 3.12 + def batched(iterable: Iterable, n: int) -> Iterable: + if n < 1: + raise ValueError("n must be at least one") + iterator = iter(iterable) + while batch := tuple(itertools.islice(iterator, n)): + yield batch + + class Client: def __init__(self, session: session.Session): self.session = session @@ -19,112 +39,311 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): pass - @staticmethod - def from_timestamp(timestamp: int): - ''' - Converts fusion solar timestamp to datetime. - Even though documentation says time is in plant time zone, it's actually utc/gmt. - ''' - return datetime.datetime.fromtimestamp(timestamp / 1000., datetime.timezone.utc) - - @staticmethod - def to_timestamp(time: datetime.datetime): - '''Converts datetime to fusion solar timestamp.''' - return int(time.timestamp() * 1000) - - def get_plant_list(self) -> list: - ''' + def get_plant_list(self) -> dict[str, Plant]: + """ Get basic plants information. Implementation wraps a call to the Plant List Interface, see documentation 7.1.3 This implementation will query all available pages - ''' - plants = [] + + Returns: + dict[str,Plant]: dict of code->Plant + """ + plants = {} for page in itertools.count(start=1): - param = {'pageNo': page, 'pageSize': 100} + param = {"pageNo": page, "pageSize": 100} logger.debug(f"Get plant list for page {page}") - response = self.session.post( - endpoint='stations', parameters=param)['data'] - plants = plants + response.get('list', []) - if page >= response['pageCount']: + response = self.session.post(endpoint="stations", parameters=param)["data"] + plants.update(Plant.from_list(response.get("list", []))) + if page >= response["pageCount"]: return plants - def _get_plant_data(self, endpoint, plants: list, parameters={}, batch_size=100) -> list: - ''' + def _get_plant_data(self, endpoint, plants: dict[str, Plant], parameters=None, batch_size=100) -> list[dict]: + """ Batches calls to by groups of 'batch_size' plants. 100 is the usual limit for FusionSolar. - ''' + + Args: + endpoint: API endpoint + plants: dictionary code->Plant for the plants + parameters (Optional): dictionary of request parameters + batch_size (Optional): maximum size for the batch request + + Returns: + list[dict]: data per plant, not converted to class + """ data = [] - unique_plants = list(dict.fromkeys(plants)) # Remove duplicates - for batch in [unique_plants[i:i + batch_size] for i in range(0, len(unique_plants), batch_size)]: - parameters['stationCodes'] = ','.join(batch) - response = self.session.post( - endpoint=endpoint, parameters=parameters) - data = data + response.get('data', []) + parameters = parameters or {} + unique_plants = list(plants.values()) + for batch in batched(unique_plants, batch_size): + parameters["stationCodes"] = ",".join([plant.code for plant in batch]) + response = self.session.post(endpoint=endpoint, parameters=parameters) + data = data + response.get("data", []) return data - def get_plant_realtime_data(self, plants: list) -> list: - ''' + def get_plant_realtime_data(self, plants: dict[str, Plant]) -> list[PlantRealTimeData]: + """ Get real-time plant data by plant ID set. Implementation wraps a call to the Plant Data Interfaces, see 7.1.4.1 Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. - ''' + """ logger.debug("Get realtime plant data") - return self._get_plant_data('getStationRealKpi', plants) + return PlantRealTimeData.from_list(self._get_plant_data("getStationRealKpi", plants, batch_size=100), plants) def _get_plant_timed_data(self, endpoint, plants: list, date: datetime.datetime) -> list: - ''' + """ Internal function for getting plant data by plants ID set and date. - ''' + """ # Time is in milliseconds - parameters = {'collectTime': self.to_timestamp(date)} + parameters = {"collectTime": to_timestamp(date)} return self._get_plant_data(endpoint, plants, parameters) - def get_plant_hourly_data(self, plants: list, date: datetime.datetime) -> list: - ''' + def get_plant_hourly_data(self, plants: list, date: datetime.datetime) -> list[PlantHourlyData]: + """ Get hourly plant data by plants ID set. - Implementation wraps a call to the Plant Hourly Data Interfaces, see 7.1.4.2 - Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. - ''' + + Args: + plants: dict of code->Plant + date: datetime to query hour data inside this specific day + + returns: + list of PlantHourlyData + """ logger.debug("Get station hour data") - return self._get_plant_timed_data('getKpiStationHour', plants=plants, date=date) + return PlantHourlyData.from_list( + self._get_plant_timed_data("getKpiStationHour", plants=plants, date=date), plants + ) - def get_plant_daily_data(self, plants: list, date: datetime.datetime) -> list: - ''' + def get_plant_daily_data(self, plants: list, date: datetime.datetime) -> list[PlantDailyData]: + """ Get daily plant data by plants ID set. - Implementation wraps a call to the Plant Hourly Data Interfaces, see 7.1.4.3 - Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. - ''' + + Args: + plants: dict of code->Plant + date: datetime to query hour data inside this specific day + + returns: + list of PlantDailyData + """ logger.debug("Get station daily data") - return self._get_plant_timed_data('getKpiStationDay', plants=plants, date=date) + return PlantDailyData.from_list( + self._get_plant_timed_data("getKpiStationDay", plants=plants, date=date), plants + ) - def get_plant_monthly_data(self, plants: list, date: datetime.datetime) -> list: - ''' + def get_plant_monthly_data(self, plants: list, date: datetime.datetime) -> list[PlantMonthlyData]: + """ Get monthly plant data by plants ID set. Implementation wraps a call to the Plant Hourly Data Interfaces, see 7.1.4.4 Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. - ''' + """ logger.debug("Get station monthly data") - return self._get_plant_timed_data('getKpiStationMonth', plants=plants, date=date) + return PlantMonthlyData.from_list( + self._get_plant_timed_data("getKpiStationMonth", plants=plants, date=date), plants + ) def get_plant_yearly_data(self, plants: list, date: datetime.datetime) -> list: - ''' + """ Get yearly plant data by plants ID set. Implementation wraps a call to the Plant Hourly Data Interfaces, see 7.1.4.5 Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. - ''' + """ logger.debug("Get station yearly data") - return self._get_plant_timed_data('getKpiStationYear', plants=plants, date=date) + return PlantYearlyData.from_list( + self._get_plant_timed_data("getKpiStationYear", plants=plants, date=date), plants + ) + + def get_device_list(self, plants: dict[str, Plant]) -> dict[str, Device]: + """ + Get device list per plant + Implementation wraps a call to the Device List API + + Args: + plants: dict code->Plant + batch_size: maximum batch size for grouping the requests per Plant. + """ + data = {} + batch_size = 100 + unique_plants = list(plants.values()) + for batch in [unique_plants[i : i + batch_size] for i in range(0, len(unique_plants), batch_size)]: + parameters = {"stationCodes": ",".join([plant.code for plant in batch])} + logger.debug(f"Get device list for stations {parameters['stationCodes']}") + response = self.session.post(endpoint="getDevList", parameters=parameters) + data.update(Device.from_list(response.get("data", []), plants)) + return data + + def _get_device_data( + self, endpoint, devices: dict[str, Device], parameters=None, batch_size=100, device_filter=None + ) -> list[dict]: + """ + Return realtime data for a dictionary of devices + + Args: + endpoint: endpoint for the request + devices: dict dev_id->Device of devices + parameters: optional dict of parameters for the request + batch_size: maximum batch size + device_filter: list of devices supporting data request + + Returns: + list: response + """ + data = [] + parameters = parameters or {} + device_filter = device_filter or [] + sorted_devices = sorted(devices.values(), key=lambda d: d.dev_type_id) + for dev_type_id, devices_group in itertools.groupby(sorted_devices, key=lambda d: d.dev_type_id): + device_name = Device.DEVICE_TYPES.get(dev_type_id, Device.UNKNOWN_DEVICE) + if dev_type_id not in device_filter: + logger.debug(f"Ignoring device data request for {dev_type_id}: {device_name}") + else: + logger.debug(f"Requesting device data for {dev_type_id}: {device_name}") + for batch in batched(devices_group, batch_size): + parameters["devIds"] = ",".join([str(d.id) for d in batch]) + parameters["devTypeId"] = dev_type_id + response = self.session.post(endpoint=endpoint, parameters=parameters) + data = data + response.get("data", []) + return data + + def get_device_realtime_data(self, devices: dict[str, Device]) -> list[DeviceRTData]: + """ + Get realtime data for devices + + Args: + devices: dict dev_id->Device of devices + batch_size: Maximum batch size + + Returns: + list of DeviceRTData + """ + return DeviceRTData.from_list( + self._get_device_data( + "getDevRealKpi", + devices, + batch_size=100, + device_filter=DeviceRTData.supported_devices(), + ), + devices, + ) + + def get_device_history_data( + self, + devices: dict[str, Device], + begin: datetime.datetime, + end: datetime.datetime + ) -> list[DeviceRTData]: + """ + Get history of realtime data for devices (Max 3 days), from start to end + + Args: + devices: dict dev_id->Device of devices + begin: datetime of collection start + end: datetime of collection end + batch_size: Maximum batch size + + Returns: + list of DeviceRTData + """ + assert end > begin, "End time needs to be after begin time" + parameters = { + "startTime": to_timestamp(begin), + "endTime": to_timestamp(end) + } + return DeviceRTData.from_list( + self._get_device_data( + "getDevHistoryKpi", + devices, + parameters=parameters, + batch_size=10, + device_filter=DeviceRTData.supported_devices() + ), + devices + ) + + def get_device_daily_data(self, devices: dict[str, Device], date: datetime.datetime) -> list[DeviceRptData]: + """ + Get daily data for devices at selected date + + Args: + devices: dict dev_id->Device of devices + date: datetime for collection + + Returns: + list of DeviceRptData + """ + parameters = { + "collectTime": to_timestamp(date) + } + return DeviceRptData.from_list( + self._get_device_data( + "getDevKpiDay", + devices, + parameters=parameters, + batch_size=100, + device_filter=DeviceRptData.supported_devices() + ), + devices + ) + + def get_device_monthly_data(self, devices: dict[str, Device], date: datetime.datetime) -> list[DeviceRptData]: + """ + Get montly data for devices at selected date + + Args: + devices: dict dev_id->Device of devices + date: datetime for collection + + Returns: + list of DeviceRptData + """ + parameters = { + "collectTime": to_timestamp(date) + } + return DeviceRptData.from_list( + self._get_device_data( + "getDevKpiMonth", + devices, + parameters=parameters, + batch_size=100, + device_filter=DeviceRptData.supported_devices() + ), + devices + ) + + def get_device_yearly_data(self, devices: dict[str, Device], date: datetime.datetime) -> list[DeviceRptData]: + """ + Get yearly data for devices at selected date + + Args: + devices: dict dev_id->Device of devices + date: datetime for collection + + Returns: + list of DeviceRptData + """ + parameters = { + "collectTime": to_timestamp(date) + } + return DeviceRptData.from_list( + self._get_device_data( + "getDevKpiYear", + devices, + parameters=parameters, + batch_size=100, + device_filter=DeviceRptData.supported_devices() + ), + devices + ) + - def get_alarms_list(self, plants: list, begin: datetime.datetime, end: datetime.datetime, language='en_US') -> list: - '''Get the current (active) alarm information of a device. + def get_alarms_list( + self, plants: dict[str, Plant], begin: datetime.datetime, end: datetime.datetime, language="en_US" + ) -> list: + """Get the current (active) alarm information of a device. Implementation wraps a call to the Device Alarm Interface. Plant IDs can be obtained by querying get_plant_list, they are stationCode parameters. Language can be any of zh_CN (Chinese), en_US (English), ja_JP (Japanese), it_IT (Italian), - nl_NL (Dutch), pt_BR (Portuguese), de_DE (German), fr_FR: French), es_ES (Spanish), pl_PL (Polish) - ''' - parameters = {'language': language, - 'beginTime': self.to_timestamp(begin), - 'endTime': self.to_timestamp(end)} - return self._get_plant_data('getAlarmList', plants=plants, parameters=parameters) + nl_NL (Dutch), pt_BR (Portuguese), de_DE (German), fr_FR (French), es_ES (Spanish), pl_PL (Polish) + """ + parameters = {"language": language, "beginTime": to_timestamp(begin), "endTime": to_timestamp(end)} + return AlarmData.from_list(self._get_plant_data("getAlarmList", plants=plants, parameters=parameters), plants) class ClientSession(Client): diff --git a/pyhfs/exception.py b/pyhfs/exception.py index caafdcf..3931658 100644 --- a/pyhfs/exception.py +++ b/pyhfs/exception.py @@ -6,30 +6,32 @@ # Public API exception + class Exception(Exception): - '''Undefined Fusion exception''' + """Undefined Fusion exception""" class LoginFailed(Exception): - '''Login failed. Verify user and password of Northbound API account.''' + """Login failed. Verify user and password of Northbound API account.""" class FrequencyLimit(Exception): - '''(407) The interface access frequency is too high.''' + """(407) The interface access frequency is too high.""" class Permission(Exception): - '''(401) You do not have the related data interface permission.''' + """(401) You do not have the related data interface permission.""" # Internal exceptions, should not get out of module implementation + class _InternalException(Exception): - '''Undefined internal fusion exception''' + """Undefined internal fusion exception""" class _305_NotLogged(_InternalException): - '''You are not in the login state. You need to log in again.''' + """You are not in the login state. You need to log in again.""" def _FailCodeToException(body): @@ -52,8 +54,7 @@ def _FailCodeToException(body): } # Returns the exception matching failCode, or _InternalException by default - failCode = body.get('failCode', 0) - logging.debug('failCode ' + str(failCode) + - ' received with body: ' + str(body)) - message = body.get('message', None) - return switcher.get(failCode, _InternalException)(failCode, message if message else 'No error message.') + failCode = body.get("failCode", 0) + logging.debug("failCode " + str(failCode) + " received with body: " + str(body)) + message = body.get("message", None) + return switcher.get(failCode, _InternalException)(failCode, message if message else "No error message.") diff --git a/pyhfs/session.py b/pyhfs/session.py index 4a08b7c..7a7d0bd 100644 --- a/pyhfs/session.py +++ b/pyhfs/session.py @@ -12,34 +12,32 @@ def exceptions_sanity(func): - '''Ensures sanity of exceptions raised to the public API. No internal exception should get to public side.''' + """Ensures sanity of exceptions raised to the public API. No internal exception should get to public side.""" @functools.wraps(func) def wrap(*args, **kwargs): try: return func(*args, **kwargs) except exception._InternalException as e: - logger.exception( - 'Internal exceptions getting out of of the private code.') + logger.exception("Internal exceptions getting out of of the private code.") raise exception.Exception(e.args[0], e.args[1]) from None return wrap class Session: - ''' - Instantiate a session that'll login to Fusion Solar Northbound interface and allow to post requests. - Errors are reported as exceptions. See exception.py for all public exceptions. - Huawei Northbound interface address can be change to adapt to different location. Base address is https://eu5.fusionsolar.huawei.com. - ''' + """ + Instantiate a session that'll login to Fusion Solar Northbound interface and allow to post requests. + Errors are reported as exceptions. See exception.py for all public exceptions. + Huawei Northbound interface address can be change to adapt to different location. Base address is https://eu5.fusionsolar.huawei.com. + """ - def __init__(self, user: str, password: str, base_url='https://eu5.fusionsolar.huawei.com'): + def __init__(self, user: str, password: str, base_url="https://eu5.fusionsolar.huawei.com"): self.user = user self.password = password - self.base_url = base_url + '/thirdData/' + self.base_url = base_url + "/thirdData/" self.session = requests.session() - self.session.headers.update( - {'Connection': 'keep-alive', 'Content-Type': 'application/json'}) + self.session.headers.update({"Connection": "keep-alive", "Content-Type": "application/json"}) @exceptions_sanity def __enter__(self): @@ -53,29 +51,31 @@ def __exit__(self, exc_type, exc_val, exc_tb): @exceptions_sanity def logout(self) -> None: - '''Logout from base url''' + """Logout from base url""" try: - logger.debug('Logout request') - self._raw_post('logout') + logger.debug("Logout request") + self._raw_post("logout") except exception._305_NotLogged: # Expected to happen after logout pass @exceptions_sanity def login(self) -> None: - ''' + """ Login to base url See documentation: https://support.huawei.com/enterprise/en/doc/EDOC1100261860/9e1a18d2/login-interface - ''' + """ try: # Posts login request self.session.cookies.clear() logger.debug("Login request") - response, body = self._raw_post(endpoint='login', parameters={ - 'userName': self.user, 'systemCode': self.password}) + response, body = self._raw_post( + endpoint="login", parameters={"userName": self.user, "systemCode": self.password} + ) # Login succeeded, stores authentication token self.session.headers.update( - {'XSRF-TOKEN': response.headers.get('XSRF-TOKEN') or response.cookies.get(name='XSRF-TOKEN')}) + {"XSRF-TOKEN": response.headers.get("XSRF-TOKEN") or response.cookies.get(name="XSRF-TOKEN")} + ) except exception._305_NotLogged: # Login failed can also be raised directly for 20001, 20002, 20003 failCodes. raise exception.LoginFailed() from None @@ -86,14 +86,14 @@ def login(self) -> None: @exceptions_sanity # Must be the first decorator. def post(self, endpoint, parameters={}): - ''' + """ Executes a POST request for the current session. Automatically logs in the user if he's not logged in yet. Validates response is a success, otherwise throws a fusnic.Exception - ''' + """ login_again = False while True: - if (login_again): + if login_again: self.login() try: response, body = self._raw_post(endpoint, parameters) @@ -102,14 +102,14 @@ def post(self, endpoint, parameters={}): login_again = True def _raw_post(self, endpoint, parameters={}) -> requests.Response: - ''' + """ Executes a POST request for the current session. Validates response is a success, otherwise throws a fusnic.Exception - ''' - response = self.session.post( - url=self.base_url + endpoint, json=parameters) + """ + logger.debug(f"POST {self.base_url + endpoint}, parameters {json.dumps(parameters)}") + response = self.session.post(url=self.base_url + endpoint, json=parameters) response.raise_for_status() body = response.json() - if not body.get('success', False): + if not body.get("success", False): raise exception._FailCodeToException(body) return response, body From 679fb82f3e2bfd3d0b103bfd465e40ddc7c58fd2 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Fri, 19 Jul 2024 15:40:30 +0200 Subject: [PATCH 09/18] Tests: adapt tests and fixtures Adapts to new API classes --- pyhfs/tests/__init__.py | 3 +- pyhfs/tests/data/getAlarmList.json | 18 +- pyhfs/tests/data/getDevKpiDay-38.json | 53 +++ pyhfs/tests/data/getDevKpiDay-39.json | 57 +++ pyhfs/tests/data/getDevKpiDay.json | 31 ++ pyhfs/tests/data/getDevKpiMonth.json | 31 ++ pyhfs/tests/data/getDevKpiYear.json | 22 + pyhfs/tests/data/getDevList.json | 35 ++ pyhfs/tests/data/getDevRealKpi-38.json | 60 +++ pyhfs/tests/data/getDevRealKpi-39.json | 29 ++ pyhfs/tests/data/getDevRealKpi-47.json | 60 +++ pyhfs/tests/data/getDevRealKpi.json | 200 +++++++++ pyhfs/tests/data/getKpiStationDay.json | 76 ++-- pyhfs/tests/data/getKpiStationHour.json | 18 +- pyhfs/tests/data/getKpiStationMonth.json | 76 ++-- pyhfs/tests/data/getKpiStationYear.json | 76 ++-- pyhfs/tests/data/getStationRealKpi.json | 4 +- pyhfs/tests/data/stations.json | 61 ++- pyhfs/tests/mock_session.py | 10 +- pyhfs/tests/test_client.py | 15 +- pyhfs/tests/test_mock_client.py | 503 ++++++++++++++++++++++- pyhfs/tests/test_session.py | 5 +- pyhfs/tests/utils.py | 23 +- 23 files changed, 1254 insertions(+), 212 deletions(-) create mode 100644 pyhfs/tests/data/getDevKpiDay-38.json create mode 100644 pyhfs/tests/data/getDevKpiDay-39.json create mode 100644 pyhfs/tests/data/getDevKpiDay.json create mode 100644 pyhfs/tests/data/getDevKpiMonth.json create mode 100644 pyhfs/tests/data/getDevKpiYear.json create mode 100644 pyhfs/tests/data/getDevList.json create mode 100644 pyhfs/tests/data/getDevRealKpi-38.json create mode 100644 pyhfs/tests/data/getDevRealKpi-39.json create mode 100644 pyhfs/tests/data/getDevRealKpi-47.json create mode 100644 pyhfs/tests/data/getDevRealKpi.json diff --git a/pyhfs/tests/__init__.py b/pyhfs/tests/__init__.py index 218cbaa..f720168 100644 --- a/pyhfs/tests/__init__.py +++ b/pyhfs/tests/__init__.py @@ -2,5 +2,4 @@ import os # Allows tests files to import package -sys.path.insert(0, os.path.abspath( - os.path.join(os.path.dirname(__file__), '../..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) diff --git a/pyhfs/tests/data/getAlarmList.json b/pyhfs/tests/data/getAlarmList.json index 4c1cb72..160c8ab 100644 --- a/pyhfs/tests/data/getAlarmList.json +++ b/pyhfs/tests/data/getAlarmList.json @@ -6,14 +6,14 @@ "alarmName": "The device is abnormal.", "alarmType": 2, "causeId": 5, - "devName": "Inverter-1", - "devTypeId": 38, - "esnCode": "Inverter05", + "devName": "5fbfk4", + "devTypeId": 1, + "esnCode": "5fbfk4", "lev": 2, "raiseTime": 1667179861000, "repairSuggestion": "Turn off the AC and DC switches, wait for 5 minutes, and then turn on the AC and DC switches. If the fault persists, contact your dealer or technical support.", - "stationCode": "BA4372D08E014822AB065017416F254C", - "stationName": "hzhStation02", + "stationCode": "NE=12345678", + "stationName": "NMplant1", "status": 1 }, { @@ -22,14 +22,14 @@ "alarmName": "Battery expansion module undervoltage", "alarmType": 2, "causeId": 2, - "devName": "Inverter-2", + "devName": "6fbfk11", "devTypeId": 38, - "esnCode": "Inverter01", + "esnCode": "6fbfk11", "lev": 4, "raiseTime": 1665264943000, "repairSuggestion": "1. If the sunlight is sufficient or AC reverse charging is allowed, the Battery [CNo] battery expansion module [SNo] (in the fault location information) can be charged when the inverter is running.", - "stationCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5", - "stationName": "hzhStation01", + "stationCode": "NE=12345678", + "stationName": "NMplant1", "status": 1 } ], diff --git a/pyhfs/tests/data/getDevKpiDay-38.json b/pyhfs/tests/data/getDevKpiDay-38.json new file mode 100644 index 0000000..584392d --- /dev/null +++ b/pyhfs/tests/data/getDevKpiDay-38.json @@ -0,0 +1,53 @@ +{ + "success":true, + "data":[ + { + "devId": 123456, + "collectTime": 1719788400000, + "dataItemMap": { + "product_power": 15.970000000000027, + "perpower_ratio": 2.661666666666671, + "installed_capacity": 6.0 + }, + "sn": "123456" + }, + { + "devId": 123456, + "collectTime": 1719874800000, + "dataItemMap": { + "product_power": 31.539999999999964, + "perpower_ratio": 5.256666666666661, + "installed_capacity": 6.0 + }, + "sn": "123456" + }, + { + "devId": 123456, + "collectTime": 1719961200000, + "dataItemMap": { + "product_power": 24.450000000000045, + "perpower_ratio": 4.075000000000007, + "installed_capacity": 6.0 + }, + "sn": "123456" + }, + { + "devId": 123456, + "collectTime": 1720047600000, + "dataItemMap": { + "product_power": 35.90999999999997, + "perpower_ratio": 5.984999999999995, + "installed_capacity": 6.0 + }, + "sn": "123456" + } + ], + "failCode":0, + "params":{ + "devIds":"123456", + "devTypeId":38, + "collectTime":1501862400000, + "currentTime":1503046597854 + }, + "message":null +} \ No newline at end of file diff --git a/pyhfs/tests/data/getDevKpiDay-39.json b/pyhfs/tests/data/getDevKpiDay-39.json new file mode 100644 index 0000000..a38cee3 --- /dev/null +++ b/pyhfs/tests/data/getDevKpiDay-39.json @@ -0,0 +1,57 @@ +{ + "success":true, + "data":[ + { + "devId": 123456, + "collectTime": 1719788400000, + "dataItemMap": { + "charge_cap": 6.21, + "discharge_cap": 6.07, + "charge_time": 10.166666666666666, + "discharge_time": 5.5 + }, + "sn": "null" + }, + { + "devId": 123456, + "collectTime": 1719874800000, + "dataItemMap": { + "charge_cap": 5.55, + "discharge_cap": 4.23, + "charge_time": 5.75, + "discharge_time": 5.916666666666667 + }, + "sn": "null" + }, + { + "devId": 123456, + "collectTime": 1719961200000, + "dataItemMap": { + "charge_cap": 7.11, + "discharge_cap": 8.61, + "charge_time": 7.25, + "discharge_time": 6.0 + }, + "sn": "null" + }, + { + "devId": 123456, + "collectTime": 1720047600000, + "dataItemMap": { + "charge_cap": 5.31, + "discharge_cap": 2.77, + "charge_time": 4.666666666666667, + "discharge_time": 6.083333333333333 + }, + "sn": "null" + } + ], + "failCode":0, + "params":{ + "devIds":"123456", + "devTypeId":39, + "collectTime":1501862400000, + "currentTime":1503046597854 + }, + "message":null +} \ No newline at end of file diff --git a/pyhfs/tests/data/getDevKpiDay.json b/pyhfs/tests/data/getDevKpiDay.json new file mode 100644 index 0000000..cd2c9ed --- /dev/null +++ b/pyhfs/tests/data/getDevKpiDay.json @@ -0,0 +1,31 @@ +{ + "success":true, + "data":[ + { + "dataItemMap":{ + "installed_capacity":30.24, + "perpower_ratio":9.921, + "product_power":300 + }, + "devId":-214543629611879, + "collectTime":1501776000000 + }, + { + "dataItemMap":{ + "installed_capacity":30.24, + "perpower_ratio":0.543, + "product_power":16.43 + }, + "devId":-214543629611879, + "collectTime":1501776000000 + } + ], + "failCode":0, + "params":{ + "devIds":"-214543629611879", + "devTypeId":1, + "collectTime":1501862400000, + "currentTime":1503046597854 + }, + "message":null +} \ No newline at end of file diff --git a/pyhfs/tests/data/getDevKpiMonth.json b/pyhfs/tests/data/getDevKpiMonth.json new file mode 100644 index 0000000..7cda803 --- /dev/null +++ b/pyhfs/tests/data/getDevKpiMonth.json @@ -0,0 +1,31 @@ +{ + "success":true, + "data":[ + { + "dataItemMap":{ + "installed_capacity":30.24, + "perpower_ratio":null, + "product_power":300 + }, + "devId":-214543629611879, + "collectTime":1501516800000 + }, + { + "dataItemMap":{ + "installed_capacity":30.24, + "perpower_ratio":null, + "product_power":16.43 + }, + "devId":-214543629611879, + "collectTime":1501518800000 + } + ], + "failCode":0, + "params":{ + "devIds":"-214543629611879", + "devTypeId":1, + "collectTime":1501862400000, + "currentTime":1503046597854 + }, + "message":null +} \ No newline at end of file diff --git a/pyhfs/tests/data/getDevKpiYear.json b/pyhfs/tests/data/getDevKpiYear.json new file mode 100644 index 0000000..debd766 --- /dev/null +++ b/pyhfs/tests/data/getDevKpiYear.json @@ -0,0 +1,22 @@ +{ + "success":true, + "data":[ + { + "dataItemMap":{ + "installed_capacity":30.24, + "perpower_ratio":null, + "product_power":300 + }, + "devId":-214543629611879, + "collectTime":1501516800000 + } + ], + "failCode":0, + "params":{ + "devIds":"-214543629611879", + "devTypeId":1, + "collectTime":1501862400000, + "currentTime":1503046597854 + }, + "message":null +} \ No newline at end of file diff --git a/pyhfs/tests/data/getDevList.json b/pyhfs/tests/data/getDevList.json new file mode 100644 index 0000000..0bb5d68 --- /dev/null +++ b/pyhfs/tests/data/getDevList.json @@ -0,0 +1,35 @@ +{ + "success":true, + "data":[ + { + "id":-214543629611879, + "devDn":"NE=45112560", + "devName":"5fbfk4", + "stationCode":"NE=12345678", + "esnCode":"5fbfk4", + "devTypeId":1, + "softwareVersion":"V100R001PC666", + "invType":"SUN2000-17KTL", + "longitude":null, + "latitude":null + }, + { + "id":-214091680973855, + "devDn":"NE=4511256", + "devName":"6fbfk11", + "stationCode":"NE=12345678", + "esnCode":"6fbfk11", + "devTypeId":1, + "softwareVersion":"V100R001PC666", + "invType":"SUN2000-17KTL", + "longitude":null, + "latitude":null + } + ], + "failCode":0, + "params":{ + "stationCodes":"NE=12345678,NE=23456789", + "currentTime":1503046597854 + }, + "message":null +} \ No newline at end of file diff --git a/pyhfs/tests/data/getDevRealKpi-38.json b/pyhfs/tests/data/getDevRealKpi-38.json new file mode 100644 index 0000000..0f4b9a4 --- /dev/null +++ b/pyhfs/tests/data/getDevRealKpi-38.json @@ -0,0 +1,60 @@ +{ + "success":true, + "data":[ + { + "devId": 123456, + "dataItemMap": { + "pv1_u": 453.5, + "pv2_u": 0.0, + "pv3_u": 0.0, + "pv4_u": 0.0, + "pv5_u": 0.0, + "power_factor": 1.0, + "pv6_u": 0.0, + "pv7_u": 0.0, + "pv8_u": 0.0, + "inverter_state": 512.0, + "open_time": 1721537680000, + "a_i": 2.244, + "total_cap": 1482.36, + "c_i": 0.0, + "b_i": 0.0, + "mppt_3_cap": 0.0, + "a_u": 118.4, + "reactive_power": 0.0, + "c_u": 0.0, + "temperature": 41.9, + "bc_u": 0.0, + "b_u": 0.0, + "elec_freq": 49.99, + "mppt_4_cap": 0.0, + "efficiency": 100.0, + "day_cap": 10.13, + "mppt_power": 0.435, + "run_state": 1, + "close_time": "N/A", + "mppt_1_cap": 1569.24, + "pv1_i": 1.02, + "pv2_i": 0.0, + "pv3_i": 0.0, + "active_power": 0.518, + "pv4_i": 0.0, + "mppt_2_cap": 0.0, + "pv5_i": 0.0, + "ab_u": 238.6, + "ca_u": 0.0, + "pv6_i": 0.0, + "pv7_i": 0.0, + "pv8_i": 0.0 + }, + "sn": "HVaaaaaa" + } + ], + "failCode":0, + "params":{ + "devIds":"123456", + "devTypeId":"38", + "currentTime":1503046597854 + }, + "message":null +} \ No newline at end of file diff --git a/pyhfs/tests/data/getDevRealKpi-39.json b/pyhfs/tests/data/getDevRealKpi-39.json new file mode 100644 index 0000000..8ac40fe --- /dev/null +++ b/pyhfs/tests/data/getDevRealKpi-39.json @@ -0,0 +1,29 @@ +{ + "success":true, + "data":[ + { + "devId": 123456, + "dataItemMap": { + "max_discharge_power": 2500.0, + "max_charge_power": 2500.0, + "battery_soh": 0.0, + "busbar_u": 453.0, + "discharge_cap": 2.94, + "ch_discharge_power": -89.0, + "run_state": 1, + "battery_soc": 86.0, + "ch_discharge_model": 4.0, + "charge_cap": 5.41, + "battery_status": 2.0 + }, + "sn": "null" + } + ], + "failCode":0, + "params":{ + "devIds":"123456", + "devTypeId":"39", + "currentTime":1503046597854 + }, + "message":null +} \ No newline at end of file diff --git a/pyhfs/tests/data/getDevRealKpi-47.json b/pyhfs/tests/data/getDevRealKpi-47.json new file mode 100644 index 0000000..accf5ea --- /dev/null +++ b/pyhfs/tests/data/getDevRealKpi-47.json @@ -0,0 +1,60 @@ +{ + "success":true, + "data":[ + { + "devId": 123456, + "dataItemMap": { + "meter_status": 1.0, + "active_cap": 650.65, + "meter_i": 1.04, + "power_factor": 0.0, + "c_i": "N/A", + "meter_u": 238.5, + "b_i": "N/A", + "reverse_reactive_valley": null, + "positive_reactive_peak": null, + "reverse_reactive_peak": null, + "reverse_active_peak": null, + "positive_active_peak": null, + "reactive_power": 296.0, + "c_u": "N/A", + "total_apparent_power": null, + "bc_u": "N/A", + "b_u": "N/A", + "reverse_active_cap": 296.8, + "reverse_reactive_power": null, + "positive_reactive_top": null, + "active_power_b": "N/A", + "active_power_a": "N/A", + "positive_active_top": null, + "reverse_reactive_cap": null, + "positive_active_power": null, + "positive_active_valley": null, + "run_state": 1, + "reverse_reactive_top": null, + "reverse_active_power": null, + "reverse_active_top": null, + "reactive_power_a": null, + "forward_reactive_cap": null, + "reactive_power_b": null, + "reactive_power_c": null, + "reverse_active_valley": null, + "active_power": 0.0, + "ab_u": "N/A", + "ca_u": "N/A", + "positive_reactive_power": null, + "active_power_c": "N/A", + "grid_frequency": 49.99, + "positive_reactive_valley": null + }, + "sn": "null" + } + ], + "failCode":0, + "params":{ + "devIds":"123456", + "devTypeId":"47", + "currentTime":1503046597854 + }, + "message":null +} \ No newline at end of file diff --git a/pyhfs/tests/data/getDevRealKpi.json b/pyhfs/tests/data/getDevRealKpi.json new file mode 100644 index 0000000..306909c --- /dev/null +++ b/pyhfs/tests/data/getDevRealKpi.json @@ -0,0 +1,200 @@ +{ + "success":true, + "data":[ + { + "dataItemMap":{ + "pv7_u":7, + "pv1_u":1, + "b_u":1, + "c_u":2, + "pv6_u":6, + "temperature":10, + "open_time":1503046597854, + "b_i":4, + "bc_u":1, + "pv9_u":9, + "pv8_u":8, + "c_i":5, + "mppt_total_cap":10, + "pv9_i":9, + "mppt_3_cap":3, + "run_state":0, + "mppt_2_cap":2, + "inverter_state":0, + "pv8_i":8, + "mppt_1_cap":1, + "pv6_i":6, + "mppt_power":10, + "pv1_i":1, + "total_cap":10, + "ab_u":0, + "pv7_i":7, + "pv13_u":13, + "reactive_power":10, + "pv10_u":10, + "pv12_i":12, + "pv11_i":11, + "pv3_i":3, + "pv11_u":11, + "pv2_i":2, + "pv13_i":13, + "power_factor":0, + "pv12_u":12, + "pv5_i":5, + "active_power":10, + "elec_freq":10, + "pv10_i":10, + "pv4_i":4, + "mppt_4_cap":4, + "mppt_5_cap":5, + "mppt_6_cap":6, + "mppt_7_cap":7, + "mppt_8_cap":8, + "mppt_9_cap":9, + "mppt_10_cap":10, + "pv4_u":4, + "close_time":1503047597854, + "day_cap":10, + "ca_u":2, + "a_i":3, + "pv5_u":5, + "a_u":0, + "pv3_u":3, + "pv14_u":14, + "pv14_i":14, + "pv15_u":15, + "pv15_i":15, + "pv16_u":16, + "pv16_i":16, + "pv17_u":17, + "pv17_i":17, + "pv18_u":18, + "pv18_i":18, + "pv19_u":19, + "pv19_i":19, + "pv20_u":20, + "pv20_i":20, + "pv21_u":21, + "pv21_i":21, + "pv22_u":22, + "pv22_i":22, + "pv23_u":23, + "pv23_i":23, + "pv24_u":24, + "pv24_i":24, + "pv25_u":25, + "pv25_i":25, + "pv26_u":26, + "pv26_i":26, + "pv27_u":27, + "pv27_i":27, + "pv28_u":28, + "pv28_i":28, + "efficiency":10, + "pv2_u":2 + }, + "devId":-214543629611879 + }, + { + "dataItemMap":{ + "pv7_u":0, + "pv1_u":0, + "b_u":0, + "c_u":0, + "pv6_u":0, + "temperature":0, + "open_time":0, + "b_i":0, + "bc_u":0, + "pv9_u":0, + "pv8_u":0, + "c_i":0, + "mppt_total_cap":0, + "pv9_i":0, + "mppt_3_cap":0, + "run_state":0, + "mppt_2_cap":0, + "inverter_state":0, + "pv8_i":0, + "mppt_1_cap":0, + "pv6_i":0, + "mppt_power":0, + "pv1_i":0, + "total_cap":0, + "ab_u":0, + "pv7_i":0, + "pv13_u":0, + "reactive_power":0, + "pv10_u":0, + "pv12_i":0, + "pv11_i":0, + "pv3_i":0, + "pv11_u":0, + "pv2_i":0, + "pv13_i":0, + "power_factor":0, + "pv12_u":0, + "pv5_i":0, + "active_power":0, + "elec_freq":0, + "pv10_i":0, + "pv4_i":0, + "mppt_4_cap":0, + "mppt_5_cap":0, + "mppt_6_cap":0, + "mppt_7_cap":0, + "mppt_8_cap":0, + "mppt_9_cap":0, + "mppt_10_cap":0, + "pv4_u":0, + "close_time":0, + "day_cap":0, + "ca_u":0, + "a_i":0, + "pv5_u":0, + "a_u":0, + "pv3_u":0, + "pv14_u":0, + "pv14_i":0, + "pv15_u":0, + "pv15_i":0, + "pv16_u":0, + "pv16_i":0, + "pv17_u":0, + "pv17_i":0, + "pv18_u":0, + "pv18_i":0, + "pv19_u":0, + "pv19_i":0, + "pv20_u":0, + "pv20_i":0, + "pv21_u":0, + "pv21_i":0, + "pv22_u":0, + "pv22_i":0, + "pv23_u":0, + "pv23_i":0, + "pv24_u":0, + "pv24_i":0, + "pv25_u":0, + "pv25_i":0, + "pv26_u":0, + "pv26_i":0, + "pv27_u":0, + "pv27_i":0, + "pv28_u":0, + "pv28_i":0, + "efficiency":0, + "pv2_u":0 + }, + "devId":-214091680973855 + } + ], + "failCode":0, + "params":{ + "devIds":"-214543629611879,-214091680973855", + "devTypeId":"1", + "currentTime":1503046597854 + }, + "message":null +} \ No newline at end of file diff --git a/pyhfs/tests/data/getKpiStationDay.json b/pyhfs/tests/data/getKpiStationDay.json index a121812..73cd231 100644 --- a/pyhfs/tests/data/getKpiStationDay.json +++ b/pyhfs/tests/data/getKpiStationDay.json @@ -1,48 +1,48 @@ { - "success": true, - "data": [ + "success":true, + "data":[ { - "dataItemMap": { - "use_power": 231, - "radiation_intensity": 0.6968, - "reduction_total_co2": 18.275, - "reduction_total_coal": 7.332, - "theory_power": 659.36, - "ongrid_power": 623, - "power_profit": 3432, - "installed_capacity": 252, - "perpower_ratio": 0.727, - "inverter_power": 18330, - "reduction_total_tree": 999, - "performance_ratio": 89 + "dataItemMap":{ + "use_power":288760, + "radiation_intensity":0.6968, + "reduction_total_co2":18.275, + "reduction_total_coal":7.332, + "theory_power":17559.36, + "ongrid_power":18330, + "power_profit":34320, + "installed_capacity":25200, + "perpower_ratio":0.727, + "inverter_power":18330, + "reduction_total_tree":999, + "performance_ratio":89 }, - "stationCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5", - "collectTime": 1501776000000 + "stationCode":"NE=12345678", + "collectTime":1501776000000 }, { - "dataItemMap": { - "use_power": 451, - "radiation_intensity": 1.4123, - "reduction_total_co2": 0.897, - "reduction_total_coal": 0.36, - "theory_power": 1765.6, - "ongrid_power": 1567, - "power_profit": 20881, - "installed_capacity": 467.04, - "perpower_ratio": 1.927, - "inverter_power": 98330, - "reduction_total_tree": 49, - "performance_ratio": 91 + "dataItemMap":{ + "use_power":null, + "radiation_intensity":1.4123, + "reduction_total_co2":0.897, + "reduction_total_coal":0.36, + "theory_power":659.6, + "ongrid_power":null, + "power_profit":2088, + "installed_capacity":467.04, + "perpower_ratio":1.927, + "inverter_power":18330, + "reduction_total_tree":49, + "performance_ratio":89 }, - "stationCode": "BA4372D08E014822AB065017416F254C", - "collectTime": 1501776000000 + "stationCode":"NE=12345678", + "collectTime":1501776000000 } ], - "failCode": 0, - "params": { - "stationCodes": "BA4372D08E014822AB065017416F254C,5D02E8B40AD342159AC8D8A2BCD4FAB5", - "collectTime": 1501862400000, - "currentTime": 1503046597854 + "failCode":0, + "params":{ + "stationCodes":"NE=12345678", + "collectTime":1501862400000, + "currentTime":1503046597854 }, - "message": null + "message":null } \ No newline at end of file diff --git a/pyhfs/tests/data/getKpiStationHour.json b/pyhfs/tests/data/getKpiStationHour.json index f6af583..c186033 100644 --- a/pyhfs/tests/data/getKpiStationHour.json +++ b/pyhfs/tests/data/getKpiStationHour.json @@ -9,7 +9,7 @@ "ongrid_power": 18330, "power_profit": 34320 }, - "stationCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5", + "stationCode": "NE=12345678", "collectTime": 1501862400000 }, { @@ -20,7 +20,7 @@ "ongrid_power": 15330, "power_profit": 31320 }, - "stationCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5", + "stationCode": "NE=12345678", "collectTime": 1501866000000 }, { @@ -31,7 +31,7 @@ "ongrid_power": null, "power_profit": 2088 }, - "stationCode": "BA4372D08E014822AB065017416F254C", + "stationCode": "NE=12345678", "collectTime": 1501873200000 }, { @@ -42,7 +42,7 @@ "ongrid_power": 17330, "power_profit": 39320 }, - "stationCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5", + "stationCode": "NE=12345678", "collectTime": 1501876800000 }, { @@ -53,7 +53,7 @@ "ongrid_power": 15630, "power_profit": 37370 }, - "stationCode": "5D02E8B40AD342159AC8Ds8A2BCD4FAB5", + "stationCode": "NE=12345678", "collectTime": 1501880400000 }, { @@ -64,7 +64,7 @@ "ongrid_power": 16730, "power_profit": 38370 }, - "stationCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5", + "stationCode": "NE=12345678", "collectTime": 1501884000000 }, { @@ -75,7 +75,7 @@ "ongrid_power": 19730, "power_profit": 39370 }, - "stationCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5", + "stationCode": "NE=12345678", "collectTime": 1501887600000 }, { @@ -86,13 +86,13 @@ "ongrid_power": null, "power_profit": 2128 }, - "stationCode": "BA4372D08E014822AB065017416F254C", + "stationCode": "NE=12345678", "collectTime": 1501887600000 } ], "failCode": 0, "params": { - "stationCodes": "BA4372D08E014822AB065017416F254C,5D02E8B40AD342159AC8D8A2BCD4FAB5", + "stationCodes": "NE=12345678", "collectTime": 1501862400000, "currentTime": 1503046597854 }, diff --git a/pyhfs/tests/data/getKpiStationMonth.json b/pyhfs/tests/data/getKpiStationMonth.json index 3524ef3..5bb3426 100644 --- a/pyhfs/tests/data/getKpiStationMonth.json +++ b/pyhfs/tests/data/getKpiStationMonth.json @@ -1,48 +1,48 @@ { - "success": true, - "data": [ + "success":true, + "data":[ { - "dataItemMap": { - "use_power": 288760, - "radiation_intensity": 0.6968, - "reduction_total_co2": 18.275, - "reduction_total_coal": 7.332, - "inverter_power": null, - "theory_power": 17559.36, - "ongrid_power": 18330, - "power_profit": 34320, - "installed_capacity": 25200, - "perpower_ratio": 0.727, - "reduction_total_tree": 999, - "performance_ratio": 89 + "dataItemMap":{ + "use_power":288760, + "radiation_intensity":0.6968, + "reduction_total_co2":18.275, + "reduction_total_coal":7.332, + "inverter_power":null, + "theory_power":17559.36, + "ongrid_power":18330, + "power_profit":34320, + "installed_capacity":25200, + "perpower_ratio":0.727, + "reduction_total_tree":999, + "performance_ratio":89 }, - "stationCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5", - "collectTime": 1501516800000 + "stationCode":"NE=12345678", + "collectTime":1501516800000 }, { - "dataItemMap": { - "use_power": null, - "radiation_intensity": 1.4123, - "reduction_total_co2": 0.897, - "reduction_total_coal": 0.36, - "inverter_power": null, - "theory_power": 659.6, - "ongrid_power": null, - "power_profit": 2088, - "installed_capacity": 467.04, - "perpower_ratio": 1.927, - "reduction_total_tree": 49, - "performance_ratio": 89 + "dataItemMap":{ + "use_power":null, + "radiation_intensity":1.4123, + "reduction_total_co2":0.897, + "reduction_total_coal":0.36, + "inverter_power":null, + "theory_power":659.6, + "ongrid_power":null, + "power_profit":2088, + "installed_capacity":467.04, + "perpower_ratio":1.927, + "reduction_total_tree":49, + "performance_ratio":89 }, - "stationCode": "BA4372D08E014822AB065017416F254C", - "collectTime": 1501516800000 + "stationCode":"NE=12345678", + "collectTime":1501516800000 } ], - "failCode": 0, - "params": { - "stationCodes": "BA4372D08E014822AB065017416F254C,5D02E8B40AD342159AC8D8A2BCD4FAB5", - "collectTime": 1501862400000, - "currentTime": 1503046597854 + "failCode":0, + "params":{ + "stationCodes":"NE=12345678", + "collectTime":1501862400000, + "currentTime":1503046597854 }, - "message": null + "message":null } \ No newline at end of file diff --git a/pyhfs/tests/data/getKpiStationYear.json b/pyhfs/tests/data/getKpiStationYear.json index 88777c0..159622b 100644 --- a/pyhfs/tests/data/getKpiStationYear.json +++ b/pyhfs/tests/data/getKpiStationYear.json @@ -1,48 +1,48 @@ { - "success": true, - "data": [ + "success":true, + "data":[ { - "dataItemMap": { - "use_power": 288760, - "radiation_intensity": 0.6968, - "reduction_total_co2": 18.275, - "reduction_total_coal": 7.332, - "inverter_power": null, - "theory_power": 17559.36, - "ongrid_power": 18330, - "power_profit": 34320, - "installed_capacity": 25200, - "perpower_ratio": 0.727, - "reduction_total_tree": 999, - "performance_ratio": 89 + "dataItemMap":{ + "use_power":288760, + "radiation_intensity":0.6968, + "reduction_total_co2":18.275, + "reduction_total_coal":7.332, + "inverter_power":null, + "theory_power":17559.36, + "ongrid_power":18330, + "power_profit":34320, + "installed_capacity":25200, + "perpower_ratio":0.727, + "reduction_total_tree":999, + "performance_ratio":89 }, - "stationCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5", - "collectTime": 1483200000000 + "stationCode":"NE=12345678", + "collectTime":1483200000000 }, { - "dataItemMap": { - "use_power": null, - "radiation_intensity": 1.4123, - "reduction_total_co2": 0.897, - "reduction_total_coal": 0.36, - "inverter_power": null, - "theory_power": 659.6, - "ongrid_power": null, - "power_profit": 2088, - "installed_capacity": 467.04, - "perpower_ratio": 1.927, - "reduction_total_tree": 49, - "performance_ratio": 89 + "dataItemMap":{ + "use_power":null, + "radiation_intensity":1.4123, + "reduction_total_co2":0.897, + "reduction_total_coal":0.36, + "inverter_power":null, + "theory_power":659.6, + "ongrid_power":null, + "power_profit":2088, + "installed_capacity":467.04, + "perpower_ratio":1.927, + "reduction_total_tree":49, + "performance_ratio":89 }, - "stationCode": "BA4372D08E014822AB065017416F254C", - "collectTime": 1483200000000 + "stationCode":"NE=12345678", + "collectTime":1483200000000 } ], - "failCode": 0, - "params": { - "stationCodes": "BA4372D08E014822AB065017416F254C,5D02E8B40AD342159AC8D8A2BCD4FAB5", - "collectTime": 1501862400000, - "currentTime": 1503046597854 + "failCode":0, + "params":{ + "stationCodes":"NE=12345678", + "collectTime":1501862400000, + "currentTime":1503046597854 }, - "message": null + "message":null } \ No newline at end of file diff --git a/pyhfs/tests/data/getStationRealKpi.json b/pyhfs/tests/data/getStationRealKpi.json index f0b0bf6..73fac4f 100644 --- a/pyhfs/tests/data/getStationRealKpi.json +++ b/pyhfs/tests/data/getStationRealKpi.json @@ -10,7 +10,7 @@ "month_power": "4345732.000", "total_income": "2088.000" }, - "stationCode": "BA4372D08E014822AB065017416F254C" + "stationCode": "NE=12345678" }, { "dataItemMap": { @@ -21,7 +21,7 @@ "month_power": "9935100.000", "total_income": "6115.000" }, - "stationCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5" + "stationCode": "NE=23456789" } ], "failCode": 0, diff --git a/pyhfs/tests/data/stations.json b/pyhfs/tests/data/stations.json index 6d384b3..f1348e4 100644 --- a/pyhfs/tests/data/stations.json +++ b/pyhfs/tests/data/stations.json @@ -1,36 +1,35 @@ { + "success": true, "data": { - "list": [ - { - "plantCode": "5D02E8B40AD342159AC8D8A2BCD4FAB5", - "plantName": "Maternelle", - "plantAddress": "Grésy sur Aix", - "longitude": 45.72355641181104, - "latitude": 5.933697679413769, - "aidType": 2147483647, - "capacity": 25.8, - "combineType": null - }, - { - "plantCode": "BA4372D08E014822AB065017416F254C", - "plantName": "Puer", - "plantAddress": "Aix les Bains", - "longitude": 45.70218696025901, - "latitude": 5.892721502704196, - "aidType": 2147483647, - "capacity": 98.7, - "combineType": null - } - ], - "pageCount": 1, - "pageNo": 1, - "pageSize": 100, - "total": 2 + "list": [ + { + "plantCode": "NE=12345678", + "plantName": "NMplant1", + "plantAddress": null, + "longitude": 3.1415, + "latitude": null, + "capacity": 146.5, + "contactPerson": "", + "contactMethod": "", + "gridConnectionDate": "2022-11-21T16:23:00+08:00" + }, + { + "plantCode": "NE=23456789", + "plantName": "plant2", + "plantAddress": null, + "longitude": null, + "latitude": null, + "capacity": 123.3, + "contactPerson": "", + "contactMethod": "", + "gridConnectionDate": "2022-11-21T16:30:28-12:00" + } + ], + "pageCount": 1, + "pageNo": 1, + "pageSize": 100, + "total": 2 }, "failCode": 0, - "message": null, - "params": { - "currentTime": 1663851483997 - }, - "success": true + "message": "get plant list success" } \ No newline at end of file diff --git a/pyhfs/tests/mock_session.py b/pyhfs/tests/mock_session.py index 4ca4036..79aa627 100644 --- a/pyhfs/tests/mock_session.py +++ b/pyhfs/tests/mock_session.py @@ -22,8 +22,12 @@ def logout(self) -> None: def login(self) -> None: pass - def post(self, endpoint, parameters={}): + def post(self, endpoint, parameters): root = pathlib.Path(os.path.dirname(__file__)) - path = root / ('data/' + endpoint + '.json') - with path.open('rt') as json_file: + suffix = "" + if endpoint in ("getDevRealKpi", "getDevKpiDay", "getDevKpiMonth", "getDevKpiYear"): + if "devTypeId" in parameters and parameters["devTypeId"] != 1: + suffix = f"-{parameters['devTypeId']}" + path = root / f"data/{endpoint}{suffix}.json" + with path.open("rt") as json_file: return json.load(json_file) diff --git a/pyhfs/tests/test_client.py b/pyhfs/tests/test_client.py index ec39b87..6618a37 100644 --- a/pyhfs/tests/test_client.py +++ b/pyhfs/tests/test_client.py @@ -1,4 +1,3 @@ - import datetime import unittest @@ -7,12 +6,10 @@ class TestClient(unittest.TestCase): - @classmethod @frequency_limit def setUpClass(cls): - - cls.invalid = 'Invalid93#!' + cls.invalid = "Invalid93#!" if no_credentials(): cls.user, cls.password = None, None cls.session = None @@ -45,19 +42,17 @@ def test_request(self): plants = client.get_plant_list() # Extract the list of plants code - plants_code = [plant['plantCode'] for plant in plants] + plants_code = [plant["plantCode"] for plant in plants] # Query realtime KPIs realtime = client.get_plant_realtime_data(plants_code) self.assertGreaterEqual(len(plants_code), len(realtime)) # Hourly data, with non existing - client.get_plant_hourly_data( - plants_code + ['do_not_exist'], now) + client.get_plant_hourly_data(plants_code + ["do_not_exist"], now) # Daily data, with a plants list bigger than 100 - client.get_plant_daily_data( - list(map(str, range(46))) + plants_code + list(map(str, range(107))), now) + client.get_plant_daily_data(list(map(str, range(46))) + plants_code + list(map(str, range(107))), now) # Monthly data client.get_plant_monthly_data(plants_code, now) @@ -69,5 +64,5 @@ def test_request(self): client.get_alarms_list(plants_code, datetime.datetime(2000, 1, 1), now) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/pyhfs/tests/test_mock_client.py b/pyhfs/tests/test_mock_client.py index 35e8abf..4cdaf10 100644 --- a/pyhfs/tests/test_mock_client.py +++ b/pyhfs/tests/test_mock_client.py @@ -1,45 +1,506 @@ import unittest import datetime +import json +from io import StringIO from pyhfs.tests.mock_session import MockSession +from pyhfs.api.plants import Plant +from pyhfs.api.devices import Device +from pyhfs.api.device_rt_data import DeviceRTDataSInverter, DeviceRTDataRInverter, DeviceRTDataRBattery, DeviceRTDataPSensor +from pyhfs.api.device_rpt_data import DeviceRptDataRBattery, DeviceRptDataRInverter, DeviceRptDataSInverter, DeviceRptDataCI +from pyhfs.api.alarm_data import AlarmData +from pyhfs.api.plant_data import PlantHourlyData, PlantDailyData, PlantMonthlyData, PlantYearlyData import pyhfs class TestMockClient(unittest.TestCase): - @classmethod def setUpClass(cls): cls.client = pyhfs.Client(MockSession()) - def test(self): + def test_plan_list(self): + # All plants + plants_dict = self.client.get_plant_list() + plants = list(plants_dict.values()) + + self.assertEqual([plant.name for plant in plants], ["NMplant1", "plant2"]) + self.assertEqual(plants[0].code, "NE=12345678") + self.assertEqual(plants[0].address, None) + self.assertEqual(plants[0].longitude, 3.1415) + self.assertEqual(plants[0].latitude, None) + self.assertEqual(plants[0].capacity, 146.5) + self.assertEqual(plants[0].contact_person, "") + self.assertEqual(plants[0].contact_method, "") + + self.assertEqual( + plants[0].grid_connection_date.astimezone(datetime.timezone.utc), + datetime.datetime( + 2022, + 11, + 21, + 16 - 8, + 23, + 00, + tzinfo=datetime.timezone(datetime.timedelta(hours=0)), + ), + ) + + def test_devices_list(self): + plants_dict = self.client.get_plant_list() + devices_dict = self.client.get_device_list(plants_dict) + + plants = list(plants_dict.values()) + devices = list(devices_dict.values()) + + self.assertEqual([device.name for device in devices], ["5fbfk4", "6fbfk11"]) + self.assertEqual(devices[0].id, -214543629611879) + self.assertEqual(devices[0].unique_id, "NE=45112560") + self.assertEqual(devices[0].plant, plants[0]) + self.assertEqual(devices[0].station_code, "NE=12345678") + self.assertEqual(devices[0].serial_number, "5fbfk4") + self.assertEqual(devices[0].dev_type_id, 1) + self.assertEqual(devices[0].dev_type, "Inverter") + self.assertEqual(devices[0].software_version, "V100R001PC666") + self.assertEqual(devices[0].inverter_type, "SUN2000-17KTL") + self.assertEqual(devices[0].optimizers, None) + self.assertEqual(devices[0].longitude, None) + self.assertEqual(devices[0].latitude, None) + + # List of plants should be modified to contain devices + self.assertEqual(plants[0].devices, devices) + self.assertEqual(plants[1].devices, []) + + def test_save_devices(self): + plants_dict = self.client.get_plant_list() + self.client.get_device_list(plants_dict) + + plants = list(plants_dict.values()) + + # Save plants and devices + f = StringIO() + data = [plant.data for plant in plants] + json.dump(data, f) + + # Reload plants and devices + f.seek(0) + data = json.load(f) + + plants_dict = Plant.from_list(data) + plants = list(plants_dict.values()) + + self.assertEqual([plant.name for plant in plants], ["NMplant1", "plant2"]) + self.assertEqual([device.name for device in plants[0].devices], ["5fbfk4", "6fbfk11"]) + self.assertEqual([device.name for device in plants[1].devices], []) + + def test_plant_realtime_data(self): + plants = self.client.get_plant_list() + + data = self.client.get_plant_realtime_data(plants) + + self.assertEqual([item.plant.name for item in data], ["NMplant1", "plant2"]) + + self.assertEqual(data[0].day_power, 17543) + self.assertEqual(data[0].month_power, 4345732.000) + self.assertEqual(data[0].total_power, 345732.000) + self.assertEqual(data[0].day_income, 45.67) + self.assertEqual(data[0].total_income, 2088.000) + self.assertEqual(data[0].health_state_id, 3) + self.assertEqual(data[0].health_state, "healthy") + + def test_device_realtime_data(self): + plants = self.client.get_plant_list() + devices = self.client.get_device_list(plants) + + data = self.client.get_device_realtime_data(devices) + + self.assertEqual(len(data), 2) + + self.assertIsInstance(data[0], DeviceRTDataSInverter) + self.assertEqual(data[0].inverter_state, "Standby: initializing") + + def test_device_realtime_1(self): + plants = self.client.get_plant_list() + devices = self.client.get_device_list(plants) + data = self.client.get_device_realtime_data(devices) + + self.assertIsInstance(data[0], DeviceRTDataSInverter) + + d: DeviceRTDataSInverter = data[0] + self.assertEqual(d.run_state, "Disconnected") + self.assertEqual(d.inverter_state, "Standby: initializing") + self.assertEqual(d.device, devices[-214543629611879]) + self.assertEqual(d.diff_voltage, {"AB": 0, "BC": 1, "CA": 2}) + self.assertEqual(d.voltage, {"A": 0, "B": 1, "C": 2}) + self.assertEqual(d.current, {"A": 3, "B": 4, "C": 5}) + self.assertEqual(d.efficiency, 10) + self.assertEqual(d.temperature, 10) + self.assertEqual(d.power_factor, 0) + self.assertEqual(d.elec_freq, 10) + self.assertEqual(d.active_power, 10) + self.assertEqual(d.reactive_power, 10) + self.assertEqual(d.day_cap, 10) + self.assertEqual(d.mppt_power, 10) + self.assertEqual(d.pv_voltage, {i: i for i in range(1, 29)}) + self.assertEqual(d.pv_current, {i: i for i in range(1, 29)}) + self.assertEqual(d.total_cap, 10) + self.assertEqual(d.open_time, datetime.datetime(2017, 8, 18, 10, 56, 37)) + self.assertEqual(d.mppt_total_cap, 10) + self.assertEqual(d.mppt_cap, {i: i for i in range(1, 11)}) + + def get_default_devices(self, name: str, type_id: int): + plant = Plant({"plantCode": 123456}) + plants = {plant.code: plant} + device = Device( + {"stationCode": 123456, "id": 123456, "devName": name, "devTypeId": type_id, "softwareVersion": None}, + plants, + ) + return {device.id: device} + + def test_device_realtime_38(self): + devices = self.get_default_devices("Residential inverter", 38) + self.client.session.post_suffix = "-38" + data = self.client.get_device_realtime_data(devices) + + self.assertIsInstance(data[0], DeviceRTDataRInverter) + + d: DeviceRTDataRInverter = data[0] + + self.assertEqual(d.run_state, "Connected") + self.assertEqual(d.inverter_state, "Grid-connected") + self.assertEqual(d.device, devices[123456]) + self.assertEqual(d.diff_voltage, {"AB": 238.6, "BC": 0.0, "CA": 0.0}) + self.assertEqual(d.voltage, {"A": 118.4, "B": 0.0, "C": 0.0}) + self.assertEqual(d.current, {"A": 2.244, "B": 0.0, "C": 0.0}) + self.assertEqual(d.efficiency, 100.0) + self.assertEqual(d.temperature, 41.9) + self.assertEqual(d.power_factor, 1.0) + self.assertEqual(d.elec_freq, 49.99) + self.assertEqual(d.active_power, 0.518) + self.assertEqual(d.reactive_power, 0.0) + self.assertEqual(d.day_cap, 10.13) + self.assertEqual(d.mppt_power, 0.435) + self.assertEqual(d.pv_voltage, {1: 453.5, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0}) + self.assertEqual(d.pv_current, {1: 1.02, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0}) + self.assertEqual(d.total_cap, 1482.36) + self.assertEqual(d.open_time, datetime.datetime(2024, 7, 21, 6, 54, 40)) + self.assertEqual(d.mppt_total_cap, 10) + self.assertEqual(d.mppt_cap, {1: 1569.24, 2: 0.0, 3: 0.0, 4: 0.0}) + + def test_device_realtime_39(self): + devices = self.get_default_devices("Battery", 39) + self.client.session.post_suffix = "-39" + data = self.client.get_device_realtime_data(devices) + + self.assertIsInstance(data[0], DeviceRTDataRBattery) + + d: DeviceRTDataRBattery = data[0] + + self.assertEqual(d.run_state, "Connected") + self.assertEqual(d.battery_status, "running") + self.assertEqual(d.max_charge_power, 2500.0) + self.assertEqual(d.max_discharge_power, 2500.0) + self.assertEqual(d.ch_discharge_power, -89.0) + self.assertEqual(d.voltage, 453.0) + self.assertEqual(d.soc, 86.0) + self.assertEqual(d.soh, 0.0) + self.assertEqual(d.charge_mode, "automatic charge/discharge") + self.assertEqual(d.charge_cap, 5.41) + self.assertEqual(d.discharge_cap, 2.94) + + def test_device_realtime_47(self): + devices = self.get_default_devices("Power sensor", 47) + self.client.session.post_suffix = "-47" + data = self.client.get_device_realtime_data(devices) + + self.assertIsInstance(data[0], DeviceRTDataPSensor) + + d: DeviceRTDataPSensor = data[0] + + self.assertEqual(d.run_state, "Connected") + self.assertEqual(d.meter_status, "Normal") + self.assertEqual(d.voltage, 238.5) + self.assertEqual(d.current, 1.04) + self.assertEqual(d.active_power, 0.0) + self.assertEqual(d.reactive_power, 296.0) + self.assertEqual(d.power_factor, 0.0) + self.assertEqual(d.grid_frequency, 49.99) + self.assertEqual(d.active_cap, 650.65) + self.assertEqual(d.reverse_active_cap, 296.8) + self.assertEqual(d.diff_voltage, {"AB": None, "BC": None, "CA": None}) + self.assertEqual(d.voltage_phase, {"A": None, "B": None, "C": None}) + self.assertEqual(d.current_phase, {"A": None, "B": None, "C": None}) + self.assertEqual(d.active_power_phase, {"A": None, "B": None, "C": None}) + + def test_alarm(self): + plants = self.client.get_plant_list() + self.client.get_device_list(plants) + now = datetime.datetime.now() + data = self.client.get_alarms_list(plants, now, now - datetime.timedelta(days=1)) - # All plants - plants_raw = self.client.get_plant_list() + self.assertEqual(len(data), 2) + + self.assertIsInstance(data[0], AlarmData) + d: AlarmData = data[0] + + self.assertEqual(d.plant, plants["NE=12345678"]) + self.assertEqual(d.device, d.plant.devices[0]) + + self.assertEqual(d.station_code, "NE=12345678") + self.assertEqual(d.name, "The device is abnormal.") + self.assertEqual(d.dev_name, "5fbfk4") + self.assertEqual( + d.repair_suggestion, + "Turn off the AC and DC switches, wait for 5 minutes, and then turn on the AC and DC switches. If the fault persists, contact your dealer or technical support.", + ) + self.assertEqual(d.dev_sn, "5fbfk4") + self.assertEqual(d.dev_type, "Inverter") + self.assertEqual(d.cause, "An unrecoverable fault has occurred in the internal circuit of the device.") + self.assertEqual(d.alarm_type, "exception alarm") + self.assertEqual(d.raise_time, datetime.datetime(2022, 10, 31, 2, 31, 1)) + self.assertEqual(d.id, 2064) + self.assertEqual(d.station_name, "NMplant1") + self.assertEqual(d.level, "major") + self.assertEqual(d.status, "not processed (active)") + + def test_plant_hourly_data(self): + date = datetime.datetime(2024, 1, 1, 0, 0, 0) + + plants = self.client.get_plant_list() + data = self.client.get_plant_hourly_data(plants, date) + + self.assertEqual(len(data), 8) + self.assertIsInstance(data[0], PlantHourlyData) + + d: PlantHourlyData = data[0] + + self.assertEqual(d.station_code, "NE=12345678") + self.assertEqual(d.plant, plants["NE=12345678"]) + self.assertEqual(d.radiation_intensity, 0.6968) + self.assertEqual(d.theory_power, 17559.36) + self.assertEqual(d.inverter_power, 18330) + self.assertEqual(d.ongrid_power, 18330) + self.assertEqual(d.power_profit, 34320) + + self.assertEqual( + [d.collect_time for d in data], + [ + datetime.datetime(2017, 8, 4, 18, 0), + datetime.datetime(2017, 8, 4, 19, 0), + datetime.datetime(2017, 8, 4, 21, 0), + datetime.datetime(2017, 8, 4, 22, 0), + datetime.datetime(2017, 8, 4, 23, 0), + datetime.datetime(2017, 8, 5, 0, 0), + datetime.datetime(2017, 8, 5, 1, 0), + datetime.datetime(2017, 8, 5, 1, 0), + ], + ) + + def test_plant_daily_data(self): + date = datetime.datetime(2024, 1, 1, 0, 0, 0) + + plants = self.client.get_plant_list() + data = self.client.get_plant_daily_data(plants, date) + + self.assertEqual(len(data), 2) + self.assertIsInstance(data[0], PlantDailyData) + + d: PlantDailyData = data[0] + + self.assertEqual(d.installed_capacity, 25200) + self.assertEqual(d.radiation_intensity, 0.6968) + self.assertEqual(d.theory_power, 17559.36) + self.assertEqual(d.performance_ratio, 89) + self.assertEqual(d.inverter_power, 18330) + self.assertEqual(d.ongrid_power, 18330) + self.assertEqual(d.power_profit, 34320) + self.assertEqual(d.perpower_ratio, 0.727) + self.assertEqual(d.reduction_total_co2, 18.275) + self.assertEqual(d.reduction_total_coal, 7.332) + self.assertEqual(d.buy_power, 0) + self.assertEqual(d.charge_cap, 0) + self.assertEqual(d.discharge_cap, 0) + self.assertEqual(d.self_use_power, 0) + self.assertEqual(d.self_provide, 0) + + self.assertEqual( + [d.collect_time for d in data], [datetime.datetime(2017, 8, 3, 18, 0), datetime.datetime(2017, 8, 3, 18, 0)] + ) + + def test_plant_monthly_data(self): + date = datetime.datetime(2024, 1, 1, 0, 0, 0) + + plants = self.client.get_plant_list() + data = self.client.get_plant_monthly_data(plants, date) + + self.assertEqual(len(data), 2) + self.assertIsInstance(data[0], PlantMonthlyData) + + d: PlantMonthlyData = data[0] + + self.assertEqual(d.installed_capacity, 25200) + self.assertEqual(d.radiation_intensity, 0.6968) + self.assertEqual(d.theory_power, 17559.36) + self.assertEqual(d.performance_ratio, 89) + self.assertEqual(d.inverter_power, None) + self.assertEqual(d.ongrid_power, 18330) + self.assertEqual(d.power_profit, 34320) + self.assertEqual(d.perpower_ratio, 0.727) + self.assertEqual(d.reduction_total_co2, 18.275) + self.assertEqual(d.reduction_total_coal, 7.332) + self.assertEqual(d.buy_power, 0) + self.assertEqual(d.charge_cap, 0) + self.assertEqual(d.discharge_cap, 0) + self.assertEqual(d.self_use_power, 0) + self.assertEqual(d.self_provide, 0) + + self.assertEqual( + [d.collect_time for d in data], + [datetime.datetime(2017, 7, 31, 18, 0), datetime.datetime(2017, 7, 31, 18, 0)], + ) + + def test_plant_yearly_data(self): + date = datetime.datetime(2024, 1, 1, 0, 0, 0) + + plants = self.client.get_plant_list() + data = self.client.get_plant_yearly_data(plants, date) + + self.assertEqual(len(data), 2) + self.assertIsInstance(data[0], PlantYearlyData) + + d: PlantYearlyData = data[0] + + self.assertEqual(d.installed_capacity, 25200) + self.assertEqual(d.radiation_intensity, 0.6968) + self.assertEqual(d.theory_power, 17559.36) + self.assertEqual(d.performance_ratio, 89) + self.assertEqual(d.inverter_power, None) + self.assertEqual(d.ongrid_power, 18330) + self.assertEqual(d.power_profit, 34320) + self.assertEqual(d.perpower_ratio, 0.727) + self.assertEqual(d.reduction_total_co2, 18.275) + self.assertEqual(d.reduction_total_coal, 7.332) + self.assertEqual(d.buy_power, 0) + self.assertEqual(d.charge_cap, 0) + self.assertEqual(d.discharge_cap, 0) + self.assertEqual(d.self_use_power, 0) + self.assertEqual(d.self_provide, 0) + + self.assertEqual( + [d.collect_time for d in data], + [datetime.datetime(2016, 12, 31, 17, 0), datetime.datetime(2016, 12, 31, 17, 0)], + ) + + def test_device_daily_data(self): + date = datetime.datetime(2024, 1, 1, 0, 0, 0) + + plants = self.client.get_plant_list() + devices = self.client.get_device_list(plants) + data = self.client.get_device_daily_data(devices, date) + + self.assertEqual(len(data), 2) + self.assertIsInstance(data[0], DeviceRptDataSInverter) + + d: DeviceRptDataSInverter = data[0] + + self.assertEqual(d.installed_capacity, 30.24) + self.assertEqual(d.product_power, 300) + self.assertEqual(d.perpower_ratio, 9.921) + + self.assertEqual( + [d.collect_time for d in data], + [datetime.datetime(2017, 8, 3, 18, 0), datetime.datetime(2017, 8, 3, 18, 0)], + ) + + def test_device_daily_data_38(self): + devices = self.get_default_devices("Residential inverter", 38) + self.client.session.post_suffix = "-38" + data = self.client.get_device_daily_data(devices, datetime.datetime.now()) + + self.assertIsInstance(data[0], DeviceRptDataRInverter) + + d: DeviceRptDataRInverter = data[0] + + self.assertEqual(d.product_power, 15.970000000000027) + self.assertEqual(d.perpower_ratio, 2.661666666666671) + self.assertEqual(d.installed_capacity, 6.0) + + self.assertEqual( + [d.collect_time for d in data], + [ + datetime.datetime(2024, 7, 1, 1, 0), + datetime.datetime(2024, 7, 2, 1, 0), + datetime.datetime(2024, 7, 3, 1, 0), + datetime.datetime(2024, 7, 4, 1, 0) + ] + ) + + def test_device_daily_data_39(self): + devices = self.get_default_devices("Residential battery", 39) + self.client.session.post_suffix = "-39" + data = self.client.get_device_daily_data(devices, datetime.datetime.now()) + + self.assertIsInstance(data[0], DeviceRptDataRBattery) + + d: DeviceRptDataRBattery = data[0] + + self.assertEqual(d.charge_cap, 6.21) + self.assertEqual(d.discharge_cap, 6.07) + self.assertEqual(d.charge_time, 10.166666666666666) + self.assertEqual(d.discharge_time, 5.5) + + self.assertEqual( + [d.collect_time for d in data], + [ + datetime.datetime(2024, 7, 1, 1, 0), + datetime.datetime(2024, 7, 2, 1, 0), + datetime.datetime(2024, 7, 3, 1, 0), + datetime.datetime(2024, 7, 4, 1, 0) + ] + ) + + def test_device_monthly_data(self): + date = datetime.datetime(2024, 1, 1, 0, 0, 0) + + plants = self.client.get_plant_list() + devices = self.client.get_device_list(plants) + data = self.client.get_device_monthly_data(devices, date) + + self.assertEqual(len(data), 2) + self.assertIsInstance(data[0], DeviceRptDataSInverter) + + d: DeviceRptDataSInverter = data[0] + + self.assertEqual(d.installed_capacity, 30.24) + self.assertEqual(d.product_power, 300) + self.assertEqual(d.perpower_ratio, None) - # List of plant codes - plants_code = [plant['plantCode'] for plant in plants_raw] + self.assertEqual( + [d.collect_time for d in data], + [datetime.datetime(2017, 7, 31, 18, 0), datetime.datetime(2017, 7, 31, 18, 33, 20)], + ) - # Realtime KPIs (with fake station) - realtime = self.client.get_plant_realtime_data( - plants_code + ['UnknownStationCode']) - self.assertGreaterEqual(len(plants_code), len(realtime)) + def test_device_yearly_data(self): + date = datetime.datetime(2024, 1, 1, 0, 0, 0) - # Hourly data - self.client.get_plant_hourly_data(plants_code, now) + plants = self.client.get_plant_list() + devices = self.client.get_device_list(plants) + data = self.client.get_device_yearly_data(devices, date) - # Daily data - self.client.get_plant_daily_data(plants_code, now) + self.assertEqual(len(data), 1) + self.assertIsInstance(data[0], DeviceRptDataSInverter) - # Monthly data - self.client.get_plant_monthly_data(plants_code, now) + d: DeviceRptDataSInverter = data[0] - # Yearly data - self.client.get_plant_yearly_data(plants_code, now) + self.assertEqual(d.installed_capacity, 30.24) + self.assertEqual(d.product_power, 300) + self.assertEqual(d.perpower_ratio, None) - # Alarms - self.client.get_alarms_list(plants_code, datetime.datetime(2000, 1, 1), now) + self.assertEqual( + [d.collect_time for d in data], + [datetime.datetime(2017, 7, 31, 18, 0)], + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/pyhfs/tests/test_session.py b/pyhfs/tests/test_session.py index 3bc7331..e1478a4 100644 --- a/pyhfs/tests/test_session.py +++ b/pyhfs/tests/test_session.py @@ -5,11 +5,10 @@ class TestSession(unittest.TestCase): - @classmethod @frequency_limit def setUpClass(cls): - cls.invalid = 'Invalid93#!' + cls.invalid = "Invalid93#!" if no_credentials(): cls.user, cls.password = None, None else: @@ -40,5 +39,5 @@ def test_valid_login(self): pass -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/pyhfs/tests/utils.py b/pyhfs/tests/utils.py index fedc8e6..c0bbbc5 100644 --- a/pyhfs/tests/utils.py +++ b/pyhfs/tests/utils.py @@ -1,34 +1,41 @@ import os +import sys import logging import functools +import itertools +from typing import Iterable import pyhfs + def frequency_limit(func): - '''Handle frequency limits cases, which cannot ben considered as fails.''' + """Handle frequency limits cases, which cannot ben considered as fails.""" + @functools.wraps(func) def wrap(*args, **kwargs): try: return func(*args, **kwargs) except pyhfs.FrequencyLimit: - logging.warning( - 'Couldn\'t complete test due to exceeding frequency limits.') + logging.warning("Couldn't complete test due to exceeding frequency limits.") + return wrap + def credentials(): - user = os.environ.get('FUSIONSOLAR_USER') + user = os.environ.get("FUSIONSOLAR_USER") if user is None: - raise ValueError('Missing environment variable FUSIONSOLAR_USER') + raise ValueError("Missing environment variable FUSIONSOLAR_USER") - password = os.environ.get('FUSIONSOLAR_PASSWORD') + password = os.environ.get("FUSIONSOLAR_PASSWORD") if password is None: - raise ValueError('Missing environment variable FUSIONSOLAR_PASSWORD') + raise ValueError("Missing environment variable FUSIONSOLAR_PASSWORD") return user, password + def no_credentials(): """ Return True if credential are not available This will have the effect of skipping tests """ - return ('FUSIONSOLAR_USER' not in os.environ) and ('FUSIONSOLAR_PASSWORD' not in os.environ) + return ("FUSIONSOLAR_USER" not in os.environ) and ("FUSIONSOLAR_PASSWORD" not in os.environ) From 3a53982d935130d06f7c695bac51e85574819817 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Fri, 19 Jul 2024 15:42:57 +0200 Subject: [PATCH 10/18] How to: update and restructure Adapt How to test to API classes. Restructure to fetch list of plants and devices and save to a JSON file: as the number of requests is limited, it is better to save and not request again. --- .gitignore | 1 + how_to.py | 237 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 219 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index bee8a64..236218d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ __pycache__ +devices.json diff --git a/how_to.py b/how_to.py index d89d444..bf9c4f7 100644 --- a/how_to.py +++ b/how_to.py @@ -1,40 +1,239 @@ +#!/usr/bin/env python3 import pyhfs import os import logging -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta +import json +import argparse +from pathlib import Path + + +def get_devices(client: pyhfs.Client) -> dict[str, pyhfs.Plant]: + """ + Get dictionary of plants and devices per plant + + Args: + user: Username + password: Password + """ + plants = client.get_plant_list() + client.get_device_list(plants) + + return plants + + +def plant_data(client: pyhfs.Client, plants: dict[str, pyhfs.Plant], args: argparse.Namespace): + """ + Request data for all plants (Realtime, hourly, monthly or yearly) + """ + logging.debug(f"Requesting plant data ({args.plant_action})") + if args.plant_action == "real": + data = client.get_plant_realtime_data(plants) + + elif args.plant_action == "hourly": + data = client.get_plant_hourly_data(plants, datetime.now()) + + elif args.plant_action == "daily": + data = client.get_plant_daily_data(plants, datetime.now()) + + elif args.plant_action == "monthly": + data = client.get_plant_monthly_data(plants, datetime.now()) + + else: + data = client.get_plant_yearly_data(plants, datetime.now()) + + if args.save: + with args.save.open("w+") as f: + json.dump([item.data for item in data], f, indent=2) + + for item in data: + print(str(item)) + + +def device_data(client: pyhfs.Client, plants: dict[str, pyhfs.Plant], args: argparse.Namespace): + """ + Request data for all devices (Realtime, hourly, monthly or yearly) + """ + logging.debug(f"Requesting device data ({args.device_action})") + devices = {} + for plant in plants.values(): + devices.update({d.id: d for d in plant.devices}) + + if args.device_action == "real": + data = client.get_device_realtime_data(devices) + + elif args.device_action == "history": + if (args.end - args.start) > timedelta(days=3): + logging.error("No more than 3 days when requesting device historical data") + exit(-1) + data = client.get_device_history_data(devices, args.start, args.end) + + elif args.device_action == "daily": + data = client.get_device_daily_data(devices, datetime.now()) + + elif args.device_action == "monthly": + data = client.get_device_monthly_data(devices, datetime.now()) + + else: + data = client.get_device_yearly_data(devices, datetime.now()) + + if args.save: + with args.save.open("w+") as f: + json.dump([item.data for item in data], f, indent=2) + + for item in data: + print(str(item)) + + +def alarm_data(client: pyhfs.Client, plants: dict[str, pyhfs.Plant], args: argparse.Namespace): + """ + Request data for all devices (Realtime, hourly, monthly or yearly) + """ + logging.debug("Requesting alarm data") + data = client.get_alarms_list(plants, args.start, args.end) + + if args.save: + with args.save.open("w+") as f: + json.dump([item.data for item in data], f, indent=2) + + for item in data: + print(str(item)) def how_to(user: str, password: str): - ''' + """ Demonstrates how to log in FusionSolar, query plants list and hourly data. - ''' - + """ try: with pyhfs.ClientSession(user=user, password=password) as client: plants = client.get_plant_list() - print('Plants list:\n' + str(plants)) + client.get_device_list(plants) # Extract list of plant codes - plants_code = [plant['plantCode'] for plant in plants] + plants_code = pyhfs.get_plant_codes(plants) # Query latest hourly data for all plants - hourly = client.get_plant_hourly_data( - plants_code, datetime.now(timezone.utc)) - print('Hourly KPIs:\n' + str(hourly)) + hourly = client.get_plant_hourly_data(plants_code, datetime.now(timezone.utc)) + print("Hourly KPIs:\n" + json.dumps(hourly, indent=2)) + + # Real time plant data + data = client.get_plant_realtime_data(plants_code) + print("Realtime data: " + json.dumps(data, indent=2)) except pyhfs.LoginFailed: - logging.error( - 'Login failed. Verify user and password of Northbound API account.') + logger.error("Login failed. Verify user and password of Northbound API account.") except pyhfs.FrequencyLimit: - logging.error('FusionSolar interface access frequency is too high.') + logger.error("FusionSolar interface access frequency is too high.") except pyhfs.Permission: - logging.error( - 'Missing permission to access FusionSolar Northbound interface.') + logger.error("Missing permission to access FusionSolar Northbound interface.") + + +def parser(): + """ + Create parser and return arguments + """ + parser = argparse.ArgumentParser("Fusion Solar API example") + + parser.add_argument( + "--user", + help="Username - If not set, get it from FUSIONSOLAR_USER environment variable", + ) + parser.add_argument( + "--password", + help="Password - If not set, get it from FUSIONSOLAR_PASSWORD environment variable", + ) + parser.add_argument( + "--devices", + type=Path, + default=Path("devices.json"), + help="JSON file to save/restore device list", + ) + parser.add_argument("--debug", action="store_true", help="Enable debug logging") + parser.add_argument("--save", type=Path, help="Save action response to this file") + parser.add_argument( + "--start", + type=datetime.fromisoformat, + default=(datetime.now() - timedelta(days=3, minutes=-5)).isoformat(), + help="Start time for historical data (ISO format)", + ) + parser.add_argument( + "--end", + type=datetime.fromisoformat, + default=datetime.now().isoformat(), + help="End time for historical data (ISO format)", + ) + + subparsers = parser.add_subparsers(title="action", description="select an action") + + pparser = subparsers.add_parser("plant", help="Plant data") + pparser.add_argument( + "plant_action", choices=("real", "hourly", "daily", "monthly", "yearly"), help="Fetch data for all plants" + ) + pparser.set_defaults(func=plant_data) + + dparser = subparsers.add_parser("device", help="Device data") + dparser.add_argument( + "device_action", choices=("real", "history", "daily", "monthly", "yearly"), help="Fetch data for all devices" + ) + dparser.set_defaults(func=device_data) + + aparser = subparsers.add_parser("alarms", help="Get alarm data") + aparser.set_defaults(func=alarm_data) + + return parser.parse_args() + + +if __name__ == "__main__": + args = parser() + logging.basicConfig( + format="%(module)-20s %(levelname)-8s: %(message)s", + level=logging.DEBUG if args.debug else logging.INFO, + ) + logger = logging.getLogger(__name__) + + user = args.user or os.environ.get("FUSIONSOLAR_USER", None) + password = args.password or os.environ.get("FUSIONSOLAR_PASSWORD", None) -if __name__ == '__main__': - user = os.environ.get('FUSIONSOLAR_USER', None) - password = os.environ.get('FUSIONSOLAR_PASSWORD', None) if user is None or password is None: - raise Exception("Please set FUSIONSOLAR_USER and FUSION_SOLARPASSWORD to allow login") - how_to(user=user, password=password) + logger.fatal("Please set --user or FUSIONSOLAR_USER and --password or FUSION_SOLARPASSWORD to allow login") + exit(-1) + + try: + with pyhfs.ClientSession(user=user, password=password) as client: + plants = None + if args.devices.exists(): + logger.info(f"Reading list of devices from file {args.devices}") + logger.info("Remove this file if you want to refresh the list of devices") + try: + with args.devices.open("r") as f: + plants = pyhfs.Plant.from_list(json.load(f)) + except json.JSONDecodeError as e: + logger.error(f"Unable to read file {args.devices}: {e}") + + if plants is None: + logger.info("Requesting list of devices.") + plants = get_devices(client) + with args.devices.open("w+") as f: + data = [plant.data for plant in plants.values()] + json.dump(data, f, indent=2) + + print("Plants and devices:\n") + for plant in plants.values(): + print(f"{plant}") + for device in plant.devices: + print(f"- {device}") + print("") + + if hasattr(args, "func"): + args.func(client, plants, args) + + else: + logger.info("No action selected, exiting") + + except pyhfs.LoginFailed: + logger.error("Login failed. Verify user and password of Northbound API account.") + except pyhfs.FrequencyLimit: + logger.error("FusionSolar interface access frequency is too high.") + except pyhfs.Permission: + logger.error("Missing permission to access FusionSolar Northbound interface.") From d755bfaf656a6d33bf5231b98bb92a3617209248 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Fri, 26 Jul 2024 12:02:12 +0200 Subject: [PATCH 11/18] Update project version --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7d72eac..b78de94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,10 @@ build-backend = "hatchling.build" [project] name = "pyhfs" -version = "0.1.2" +version = "0.2.0" authors = [ { name="Guillaume Blanc", email="guillaumeblanc.sc@gmail.com" }, + { name="Cédric Airaud", email="cairaud@gmail.com" }, ] description = "Python client for Huawei FusionSolar SmartPVMS Northbound Interface" readme = "README.md" From 587660f50b8f56e06699a67c55d78072f0d161c9 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Fri, 26 Jul 2024 15:53:26 +0200 Subject: [PATCH 12/18] Add requests dependency --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b78de94..34d1c32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ authors = [ description = "Python client for Huawei FusionSolar SmartPVMS Northbound Interface" readme = "README.md" requires-python = ">=3.7" +dependencies = [ + "requests", +] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", From 5af4d5d0de5acbec7f7cb4d81e7ce0f075e311f9 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Fri, 26 Jul 2024 19:09:00 +0200 Subject: [PATCH 13/18] util: return None on from_timestamp if timestamp if 'N/A' --- pyhfs/api/util.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/pyhfs/api/util.py b/pyhfs/api/util.py index d1bff6f..2a7cea9 100644 --- a/pyhfs/api/util.py +++ b/pyhfs/api/util.py @@ -1,16 +1,30 @@ import datetime +from typing import Union, Optional +import logging -def from_timestamp(timestamp: int): +logger = logging.getLogger(__name__) + + +def from_timestamp(timestamp: Union[int,str]) -> Optional[datetime.datetime]: """ Converts fusion solar timestamp to datetime Args: - timestamp: timestamp as an integer + timestamp: timestamp as an integer or string. If 'N/A' string is found, + return None returns: - datetime: timestamp converted to datetime, with milliseconds ignored + datetime: timestamp converted to datetime, with milliseconds ignored, None + in case of conversion error. """ - return datetime.datetime.fromtimestamp(timestamp // 1000) + if isinstance(timestamp, str) and timestamp == "N/A": + return None + + try: + return datetime.datetime.fromtimestamp(timestamp // 1000) + except ValueError as e: + logger.debug(f"Cannot convert value {timestamp} to datetime: {e}") + return None def to_timestamp(time: datetime.datetime) -> int: """Converts datetime to fusion solar timestamp. From 39a405feeb328b9842f197568ba92030f6bb08ac Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Wed, 6 Nov 2024 21:13:33 +0100 Subject: [PATCH 14/18] [git] Ignore additional directories --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 236218d..fc93ed5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ __pycache__ devices.json +.envrc +.direnv +venv From 633f3373f8ac2eb8cc756ecae5f4e9187e8b8f28 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Wed, 6 Nov 2024 21:15:08 +0100 Subject: [PATCH 15/18] [vscode] Project --- .vscode/settings.json | 7 +++++++ pyhfs.code-workspace | 8 ++++++++ 2 files changed, 15 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 pyhfs.code-workspace diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3e99ede --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/pyhfs.code-workspace b/pyhfs.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/pyhfs.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file From 4204fdda0ec80eb9ffb9d385935d4203100401a4 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Wed, 6 Nov 2024 21:16:56 +0100 Subject: [PATCH 16/18] [bug] Correctly use batched for python 3.12 --- pyhfs/client.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyhfs/client.py b/pyhfs/client.py index 71ceda0..00f4ccd 100644 --- a/pyhfs/client.py +++ b/pyhfs/client.py @@ -13,13 +13,9 @@ from .api.alarm_data import AlarmData from .api.util import to_timestamp - -# Based on documentation iMaster NetEco V600R023C00 Northbound Interface Reference-V6(SmartPVMS) -# https://support.huawei.com/enterprise/en/doc/EDOC1100261860/ - -logger = logging.getLogger(__name__) - -if sys.version_info < (3, 12): +try: + from itertools import batched +except ImportError: # Added in version 3.12 def batched(iterable: Iterable, n: int) -> Iterable: if n < 1: @@ -28,6 +24,10 @@ def batched(iterable: Iterable, n: int) -> Iterable: while batch := tuple(itertools.islice(iterator, n)): yield batch +# Based on documentation iMaster NetEco V600R023C00 Northbound Interface Reference-V6(SmartPVMS) +# https://support.huawei.com/enterprise/en/doc/EDOC1100261860/ + +logger = logging.getLogger(__name__) class Client: def __init__(self, session: session.Session): From fc0171a2f920f2da561167dac5bd3d31bad62bc4 Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Wed, 6 Nov 2024 21:17:32 +0100 Subject: [PATCH 17/18] [bug] Change unit for ch_discharge_power Doc says kW but it is W. --- pyhfs/api/device_rt_data.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyhfs/api/device_rt_data.py b/pyhfs/api/device_rt_data.py index 7b16b8a..cff21bb 100644 --- a/pyhfs/api/device_rt_data.py +++ b/pyhfs/api/device_rt_data.py @@ -446,7 +446,8 @@ def __str__(self) -> str: max_charge_power = data_item_prop("max_charge_power", "Maximum charge power in W (float)") max_discharge_power = data_item_prop("max_discharge_power", "Maximum discharge power in W (float)") - ch_discharge_power = data_item_prop("ch_discharge_power", "Charge/Discharge power in W (float)") + # Note: documentation says W instead of kW + ch_discharge_power = data_item_prop("ch_discharge_power", "Charge/Discharge power in kW (float)") voltage = data_item_prop("busbar_u", "Battery voltage in V (float)") soc = data_item_prop("battery_soc", "Battery State of Charge (SOC) in %") soh = data_item_prop( From 3c11cc21c7f248384d471228894eea67e1749c1a Mon Sep 17 00:00:00 2001 From: Cedric Airaud Date: Wed, 6 Nov 2024 21:19:15 +0100 Subject: [PATCH 18/18] [api] Add information for devices Add information about which type of data is exported for each devices --- pyhfs/api/devices.py | 25 +++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/pyhfs/api/devices.py b/pyhfs/api/devices.py index 3f5cd00..ec59736 100644 --- a/pyhfs/api/devices.py +++ b/pyhfs/api/devices.py @@ -1,3 +1,4 @@ +import enum from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -41,6 +42,21 @@ class Device: 23070: "EMMA", } + class DEVICE_DATA_TYPES(enum.Flag): + NONE = 0, + PRODUCTION = 1 # Device contains production data, containing 'mppt_power' + METER = 2 # Device is a meter sensor, containing 'active_power' on the grid + BATTERY = 4 # Device is a battery, containing 'ch_discharge_power' + + DEVICE_DATA = { + 1: DEVICE_DATA_TYPES.PRODUCTION | DEVICE_DATA_TYPES.METER, # Inverter + 38: DEVICE_DATA_TYPES.PRODUCTION | DEVICE_DATA_TYPES.METER, # Residential inverter + 17: DEVICE_DATA_TYPES.METER, # Grid meter + 47: DEVICE_DATA_TYPES.METER, # Power sensor + 39: DEVICE_DATA_TYPES.BATTERY, # Residential battery + 41: DEVICE_DATA_TYPES.BATTERY, # C&I and utility ESS + } + UNKNOWN_DEVICE = "Unknown" def __init__(self, data: dict, plants: dict[str, "Plant"]): @@ -95,6 +111,15 @@ def dev_type(self) -> str: """ return Device.DEVICE_TYPES.get(self.dev_type_id, Device.UNKNOWN_DEVICE) + @property + def dev_data(self) -> DEVICE_DATA_TYPES: + """ + Device data type encoded as DEVICE_DATA_TYPES + + Indicates if device contains production data, meter data or battery + """ + return Device.DEVICE_DATA.get(self.dev_type_id, Device.DEVICE_DATA_TYPES.NONE) + @property def plant(self) -> "Plant": """ diff --git a/pyproject.toml b/pyproject.toml index 34d1c32..04b74a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyhfs" -version = "0.2.0" +version = "0.2.1" authors = [ { name="Guillaume Blanc", email="guillaumeblanc.sc@gmail.com" }, { name="Cédric Airaud", email="cairaud@gmail.com" },