diff --git a/services/core/PlatformDriverAgent/platform_driver/agent.py b/services/core/PlatformDriverAgent/platform_driver/agent.py index 91ca805d42..314634861d 100644 --- a/services/core/PlatformDriverAgent/platform_driver/agent.py +++ b/services/core/PlatformDriverAgent/platform_driver/agent.py @@ -58,7 +58,7 @@ utils.setup_logging() _log = logging.getLogger(__name__) -__version__ = '4.3.4' +__version__ = '4.4.0' PROMETHEUS_METRICS_FILE = "/opt/packages/prometheus_exporter/scrape_files/scrape_metrics.prom" diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/solark/__init__.py b/services/core/PlatformDriverAgent/platform_driver/interfaces/solark/__init__.py new file mode 100644 index 0000000000..dc814436ea --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/solark/__init__.py @@ -0,0 +1,129 @@ +# Copyright (c) 2024, ACE IoT Solutions LLC. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# The views and conclusions contained in the software and documentation are those +# of the authors and should not be interpreted as representing official policies, +# either expressed or implied, of the FreeBSD Project. + +""" +The Sol Ark Driver allows monitoring of Sol Ark data via an HTTP API +""" + +import logging +import time + +from volttron.platform.agent import utils +from platform_driver.interfaces import BaseRegister, BaseInterface, BasicRevert +from volttron.platform.vip.agent import Agent, Core, RPC, PubSub + +from .solark import fetch_bearer_token, get_plant_realtime + +_log = logging.getLogger("solark") + +DEFAULT_POINTS = ["id", "status", "pac", "etoday", "etotal", "type", "emonth", "eyear", "income", "efficiency"] + +class Register(BaseRegister): + """ + Generic class for containing information about the points exposed by the Sol Ark API + + + :param register_type: Type of the register. Either "bit" or "byte". Usually "byte". + :param pointName: Name of the register. + :param units: Units of the value of the register. + :param description: Description of the register. + + :type register_type: str + :type pointName: str + :type units: str + :type description: str + """ + + def __init__(self, volttron_point_name, units, description): + super(Register, self).__init__( + "byte", True, volttron_point_name, units, description=description + ) + + +class Interface(BasicRevert, BaseInterface): + """ + Create an interface for the Sol Ark API using the standard BaseInterface convention + """ + + def __init__(self, **kwargs): + super(Interface, self).__init__(**kwargs) + self.device_path = kwargs.get("device_path") + self.logger = _log + + def configure(self, config_dict, registry_config_str): + """ + Configure method called by the platform driver with configuration + stanza and registry config file + """ + self.username = config_dict["username"] + self.password = config_dict["password"] + self.api_key = config_dict["api_key"] + self.client_id = config_dict["client_id"] + self.plant_id = config_dict["plant_id"] + _log.info("setting up solark interface") + # _log.debug(f"{user_exists(self.username)}") + self.token = fetch_bearer_token(self.api_key, self.username, self.password) + _log.debug(f"...{self.token[-3:]}") + # self.token = fetch_bearer_token( + # self.api_key, self.username, self.password, grant_type="password", client_id=self.client_id + # ) + + for entry in DEFAULT_POINTS: + _log.debug(f"inserting register {entry=}") + self.insert_register(Register(entry, "", "")) + + def _create_registers(self, ted_config): + """ + Processes the config scraped from the device and generates + register for each available parameter + """ + return + + def _set_points(self, points): + pass + + def _set_point(self, point_name, value): + return self._set_points({point_name: value}) + + def get_point(self, point_name): + points = self._scrape_all() + return points.get(point_name) + + def filter_valid_points(self, points): + new_points = {} + _log.debug(f"filtering points: {points}") + for point, value in points.items(): + if point not in DEFAULT_POINTS: + continue + new_points[point] = value + return new_points + + def _scrape_all(self): + output = get_plant_realtime(self.plant_id, self.api_key, self.token) + data = self.filter_valid_points(output) + _log.debug(f"scraping solark: {data}") + return data diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/solark/solark.config b/services/core/PlatformDriverAgent/platform_driver/interfaces/solark/solark.config new file mode 100644 index 0000000000..890eb2f85b --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/solark/solark.config @@ -0,0 +1,11 @@ +{ + "driver_type": "solark", + "interval": 300, + "driver_config": { + "username": "user", + "password": "password", + "api_key": "", + "client_id": "csp-web", + "plant_id": 000000 + } +} \ No newline at end of file diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/solark/solark.py b/services/core/PlatformDriverAgent/platform_driver/interfaces/solark/solark.py new file mode 100644 index 0000000000..85388cf8ca --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/solark/solark.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +""" +Created on Tue Sep 12 17:57:03 2023 + +@author: wendell + +Modified by cristian@aceiotsolutions.com + +""" +import logging +import grequests + +_log = logging.getLogger("solarklib") + +def _grequests_exception_handler(request, exception): + trace = exception.__traceback__ + _log.error(f"grequests error: {exception}: {trace.tb_frame.f_code.co_filename}:{trace.tb_lineno}") + +#%% HTTP functions +def user_exists(username): + """ + Check if a user exists in the database. + + Args: + - username (str): The username to check. + + Returns: + - bool: True if the user exists, False otherwise. + """ + # Make a request to the database to check if the user exists + url = f'https://openapi.mysolark.com/v1/anonymous/checkAccount' + request = grequests.get(url, params={'username': username}) + result = grequests.map([request])[0] + return result.json() + +# 3.1 Get Token ( bearer) +def fetch_bearer_token(key, username, password, grant_type="password", client_id="csp-web"): + """ + Fetches the bearer token using provided credentials. + + Args: + - key (str): The API key for the request. + - username (str): The username for authentication. + - password (str): The password for authentication. + - grant_type (str, optional): The grant type for OAuth. Defaults to 'password'. + - client_id (str, optional): The client ID. Defaults to 'csp-web'. + + Returns: + - str: The bearer token. + """ + # URL and headers + url = 'https://openapi.mysolark.com/v1/oauth/token' + headers = { + 'x-api-key': key, + 'Content-Type': 'application/json', + } + + # Request body data + data = { + "username": username, + "password": password, + "grant_type": grant_type, + "client_id": client_id + } + + request = grequests.post(url, headers=headers, json=data) + result = grequests.map([request], exception_handler=_grequests_exception_handler)[0] + + # Check if response was successful and is JSON + if result.status_code == 200 and 'application/json' in result.headers['Content-Type']: + token_data = result.json() + return 'Bearer ' + token_data['data']['access_token'] + else: + _log.error("Error fetching bearer token") + _log.debug(result.status_code) + _log.debug(result.text) + return None + + + + +# 3.2.1 Get Plant List +def get_plant_list(key, bearer, page=1, limit=20): + """ + Fetches a list of plants. + + Args: + - key (str): The API key for the request. + - bearer (str): The bearer token for authorization. + - page (int, optional): The page number for pagination. Defaults to 1. + - limit (int, optional): The number of results per page. Defaults to 20. + + Returns: + - dict: A dictionary containing plant data. + """ + # Construct the URL with page and limit parameters + url = f'https://openapi.mysolark.com/v1/plants?page={page}&limit={limit}' + + # Set up the headers + headers = { + 'x-api-key': key, + 'Content-Type': 'application/json', + 'Authorization': bearer + } + + # Make the GET request + request = grequests.get(url, headers=headers) + result = grequests.map([request])[0] + + # Check the response + if result.status_code == 200 and 'application/json' in result.headers['Content-Type']: + return result.json()['data']['infos'] + else: + _log.error("Error fetching plant list") + _log.debug(result.status_code) + _log.debug(result.text) + return None + + + +# 3.2.3 Get plant realtime +# Get plant realtime Photovoltaic (PV) energy production over various intervals. +def get_plant_realtime(plant_id, key, bearer): + # Construct the URL using the given plant_id + url = f'https://openapi.mysolark.com/v1/plant/{plant_id}/realtime' + + # Set up the headers using the given key and bearer + headers = { + 'x-api-key': key, + 'Content-Type': 'application/json', + 'Authorization': bearer + } + + # Make the GET request + request = grequests.get(url, headers=headers) + result = grequests.map([request])[0] + + # Return the response in case you want to process it further + if result.status_code == 200 and 'application/json' in result.headers['Content-Type']: + return result.json()['data'] + else: + _log.error("Error fetching plant realtime data") + _log.debug(result.status_code) + _log.debug(result.text) + return None + + + +# 3.2.4 Get plant flow +def get_plant_flow(plant_id, key, bearer): + """ + Fetches the flow data for a specific plant. + + Args: + - plant_id (int): The ID of the plant for which the flow data is needed. + - key (str): The API key for the request. + - bearer (str): The bearer token for authorization. + + Returns: + - dict: A dictionary containing plant flow data. + """ + # Construct the URL using the given plant_id + url = f'https://openapi.mysolark.com/v1/plant/energy/{plant_id}/flow' + + # Set up the headers using the given key and bearer + headers = { + 'x-api-key': key, + 'Content-Type': 'application/json', + 'Authorization': bearer + } + + # Make the GET request + request = grequests.get(url, headers=headers) + result = grequests.map([request])[0] + + # Check the response + if result.status_code == 200 and 'application/json' in result.headers['Content-Type']: + return result.json()['data'] + else: + _log.error("Error fetching plant flow data") + _log.debug(result.status_code) + _log.debug(result.text) + return None + + + + +# 3.2.7 Get energy day chart +def get_energy_day_chart(plant_id, date, key, bearer, lan="en"): + """ + Fetches the energy day chart data for a specific plant on a specific date. + + Args: + - plant_id (int): The ID of the plant for which the data is needed. + - date (str): The date for which the energy data is required in the format 'YYYY-MM-DD'. + - key (str): The API key for the request. + - bearer (str): The bearer token for authorization. + - lan (str, optional): The language parameter. Defaults to 'en'. + + Returns: + - dict: A dictionary containing the energy day chart data. + """ + + # Construct the URL using the given plant_id and date + url = f'https://openapi.mysolark.com/v1/plant/energy/{plant_id}/day?date={date}&lan={lan}' + + # Set up the headers using the given key and bearer + headers = { + 'x-api-key': key, + 'Content-Type': 'application/json', + 'Authorization': bearer + } + + # Make the GET request + request = grequests.get(url, headers=headers) + result = grequests.map([request])[0] + + # Check the response + if result.status_code == 200 and 'application/json' in result.headers['Content-Type']: + return result.json() + else: + _log.error("Error fetching energy day chart data") + _log.debug(result.status_code) + _log.debug(result.text) + return None + + +# 4.1 Get param setting +def get_param_settings(sn_inverter, key, bearer): + """ + Get Inverter Parameters Settings + + Args: + - sn_inverter (int): The serie number (SN) for the Inverter + - key (str): The API key for the request. + - bearer (str): The bearer token for authorization. + + Returns: + - dict: A dictionary containing the energy day chart data. + """ + url = f'https://openapi.mysolark.com/v1/dy/store/{sn_inverter}/read' + + headers = { + "x-api-key": key, + "Content-Type": "application/json", + "Authorization": bearer + } + + request = grequests.get(url, headers=headers) + result = grequests.map([request])[0] + + if result.status_code == 200: + return result.json()['data'] + else: + result.raise_for_status() # This will raise an error if the HTTP status code is not 200 + +# 4.2 Work model setting +def set_param_settings(key, bearer, sn_inverter, data): + url = f"https://mysolark.com:443/api/v1/dy/store/{sn_inverter}/setting/workMode" + + headers = { + "Authorization": bearer, + "x-api-key": key, + "Content-Type": "application/json" + } + + request = grequests.post(url, headers=headers, json=data) + result = grequests.map([request])[0] + + if result.status_code == 200: + return result.json() + else: + result.raise_for_status() # This will raise an error if the HTTP status code is not 200 +