diff --git a/Yasp/client.py b/Yasp/client.py new file mode 100644 index 0000000..c4c88d0 --- /dev/null +++ b/Yasp/client.py @@ -0,0 +1,95 @@ +import enum +import json +import logging +import os + +import requests +# Disable HTTPS warnings becasue of self-signed HTTPS certificate on the SMA inverter +from requests.packages.urllib3.exceptions import InsecureRequestWarning + +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + +logger = logging.getLogger(__name__) + + +class Value(enum.Enum): + PV_POWER = '6100_0046C200' + PV_CURRENT = '6380_40452100' + PV_VOLTAGE = '6380_40451F00' + TOTAL_YIELD = '6400_00260100' + DAILY_YIELD = '6400_00262200' + + +class Client: + def _fetch(self, keys): + raise NotImplementedError() + + def fetch_values(self, *keys): + for device_serial, data in self._fetch(keys).items(): + for key, value in _extract(keys, data): + yield {'external_id': device_serial, 'type': key, 'value': value} + + +def _extract(keys, data): + for key in keys: + values = data[key.value] + if '1' in values: + values = values['1'] + elif len(values) == 1: + values = next(iter(values.values())) + else: + continue + yield (key, values[0]['val']) + + +class WebConnect(Client): + def __init__(self, url, password): + self._url = url + self._password = password + self._session_id = '' + + def _fetch(self, keys): + while True: + endpoint = '{0}/dyn/getValues.json?sid={1}'.format(self._url, self._session_id) + response = requests.post(endpoint, + json={'destDev': [], 'keys': [k.value for k in keys]}, + timeout=10, + verify=False).json() + if response.get('err') == 401: + self._login() + continue + break + if 'result' not in response or len(response['result']) != 1: + raise RuntimeError('Unexpected response: {0}'.format(response)) + return response['result'] + + def _login(self): + endpoint = '{0}/dyn/login.json'.format(self._url) + response = requests.post(endpoint, + json={'right': 'usr', + 'pass': self._password}, + verify=False).json() + if 'result' in response and 'sid' in response['result']: + self._session_id = response['result']['sid'] + else: + error_code = response.get('err', 'unknown') + if error_code == 503: + raise RuntimeError('Maximum amount of sessions') + raise RuntimeError('Could not login: {0}'.format(error_code)) + return response + + +class DummyWebConnect(Client): + def __init__(self, path): + with open(os.path.abspath(os.path.join(__file__, '..', path))) as fd: + self._data = json.load(fd) + + def _fetch(self, keys): + return self._data['result'] + + +if __name__ == '__main__': + for path in ('data/single_phase.json', 'data/three_phase.json'): + client = DummyWebConnect(path) + for x in client.fetch_values(Value.TOTAL_YIELD): + print(x) diff --git a/Yasp/data/single_phase.json b/Yasp/data/single_phase.json new file mode 100644 index 0000000..e3a42a4 --- /dev/null +++ b/Yasp/data/single_phase.json @@ -0,0 +1,146 @@ +{ + "result": { + "012F-821BCB27": { + "6100_40463700": { + "1": [ + { + "val": null + } + ] + }, + "6400_0046C300": { + "1": [ + { + "val": 6754891 + } + ] + }, + "6100_00464800": { + "1": [ + { + "val": 23662 + } + ] + }, + "6100_00465700": { + "1": [ + { + "val": 5000 + } + ] + }, + "6400_00260100": { + "1": [ + { + "val": 6754891 + } + ] + }, + "6100_40263F00": { + "1": [ + { + "val": 777 + } + ] + }, + "6100_0046C200": { + "1": [ + { + "val": 777 + } + ] + }, + "6100_40465300": { + "1": [ + { + "val": 3297 + } + ] + }, + "6100_40463600": { + "1": [ + { + "val": null + } + ] + }, + "6400_00262200": { + "1": [ + { + "val": 3632 + } + ] + }, + "6380_40451F00": { + "1": [ + { + "val": 24527 + } + ] + }, + "6100_40465400": { + "1": [ + { + "val": null + } + ] + }, + "6380_40452100": { + "1": [ + { + "val": 3272 + } + ] + }, + "6100_40465500": { + "1": [ + { + "val": null + } + ] + }, + "6400_00462400": { + "1": [ + { + "val": null + } + ] + }, + "6400_00543A00": { + "1": [ + { + "val": 0 + } + ] + }, + "6100_00464900": { + "1": [ + { + "val": null + } + ] + }, + "6400_00462500": { + "1": [ + { + "val": null + } + ] + }, + "6100_00543100": { + "1": [ + { + "val": null + } + ] + }, + "6100_00464A00": { + "1": [ + { + "val": null + } + ] + } + } + } +} diff --git a/Yasp/data/three_phase.json b/Yasp/data/three_phase.json new file mode 100644 index 0000000..706bd09 --- /dev/null +++ b/Yasp/data/three_phase.json @@ -0,0 +1,220 @@ +{ + "result":{ + "01B8-xxxxx3A8":{ + "6400_00496700":{ + "9":[ + { + "val":0 + } + ] + }, + "6400_00496800":{ + "9":[ + { + "val":200 + } + ] + }, + "6100_00295A00":{ + "9":[ + { + "val":0 + } + ] + }, + "6180_08495E00":{ + "9":[ + { + "val":[ + { + "tag":303 + } + ] + } + ] + }, + "6100_00496900":{ + "9":[ + { + "val":0 + } + ] + }, + "6100_00496A00":{ + "9":[ + { + "val":0 + } + ] + }, + "6100_00696E00":{ + "9":[ + { + "val":0 + } + ] + }, + "6800_08A33A00":{ + "9":[ + { + "validVals":[ + 1129, + 1130 + ], + "val":[ + { + "tag":1129 + } + ] + } + ] + }, + "6800_008AA200":{ + "9":[ + { + "low":0, + "high":null, + "val":0 + } + ] + }, + "6100_40263F00":{ + "9":[ + { + "val":840 + } + ] + }, + "6100_0046C200":{ + "9":[ + { + "val":840 + } + ] + }, + "6800_00832A00":{ + "9":[ + { + "low":10000, + "high":10000, + "val":10000 + } + ] + }, + "6180_08214800":{ + "9":[ + { + "val":[ + { + "tag":307 + } + ] + } + ] + }, + "6180_08414900":{ + "9":[ + { + "val":[ + { + "tag":886 + } + ] + } + ] + }, + "6180_08412B00":{ + "9":[ + { + "val":[ + { + "tag":235 + } + ] + } + ] + }, + "6400_00462500":{ + "9":[ + { + "val":null + } + ] + }, + "6400_00462400":{ + "9":[ + { + "val":null + } + ] + }, + "6100_40463700":{ + "9":[ + { + "val":null + } + ] + }, + "6100_40463600":{ + "9":[ + { + "val":null + } + ] + }, + "6180_08412800":{ + "9":[ + { + "val":[ + { + "tag":569 + } + ] + } + ] + }, + "6400_00260100":{ + "9":[ + { + "val":47809 + } + ] + }, + "6800_08811F00":{ + "9":[ + { + "validVals":[ + 1129, + 1130 + ], + "val":[ + { + "tag":1129 + } + ] + } + ] + }, + "6400_00462E00":{ + "9":[ + + ] + }, + "6800_08839500":{ + "9":[ + { + "validVals":[ + 1129, + 1130 + ], + "val":[ + { + "tag":1130 + } + ] + } + ] + } + } + } +} diff --git a/Yasp/info.json b/Yasp/info.json new file mode 100644 index 0000000..678b930 --- /dev/null +++ b/Yasp/info.json @@ -0,0 +1,7 @@ +{ + "version" : "0.0.1", + "description" : "Yet another SMA plugin", + "metric_source" : "sma", + "metric_type" : "sma", + "python_version": 3 +} diff --git a/Yasp/main.py b/Yasp/main.py new file mode 100644 index 0000000..b53dab8 --- /dev/null +++ b/Yasp/main.py @@ -0,0 +1,106 @@ +import json +import logging +import time + +from plugins.base import (OMPluginBase, PluginConfigChecker, background_task, + om_expose) + +from .client import DummyWebConnect, WebConnect, Value + +logger = logging.getLogger(__name__) + + +class Yasp(OMPluginBase): + """ + Integrate an SMA inverter using it's WebConnect api + """ + name = 'SMA' + version = '0.0.1' + interfaces = [('config', '1.0')] + + default_config = { + 'dummy': False, # useful for local development + 'sample_rate': 60, + } + + config_description = [ + {'name': 'sample_rate', + 'type': 'int', + 'description': 'How frequent (every x seconds) to fetch the sensor data, Default: 30'}, + {'name': 'devices', + 'type': 'section', + 'description': 'List of all SMA devices.', + 'repeat': True, + 'min': 1, + 'content': [{'name': 'sma_inverter_ip', + 'type': 'str', + 'description': 'IP or hostname of the SMA inverter including the scheme (e.g. http:// or https://).'}, + {'name': 'password', + 'type': 'str', + 'description': 'The password of the `User` account'}]} + ] + + def __init__(self, webinterface, connector): + super().__init__(webinterface=webinterface, connector=connector) + + self._config = self.read_config(self.default_config) + self._config_checker = PluginConfigChecker(self.config_description) + self._read_config() + self._counters = {} + + def _read_config(self): + self._sample_rate = self._config['sample_rate'] + if self._config.get('dummy'): + self._sma_devices = [DummyWebConnect('data/single_phase.json'), + DummyWebConnect('data/three_phase.json')] + else: + self._sma_devices = [WebConnect(x['sma_inverter_ip'], x['password']) for x in self._config.get('devices', [])] + + @om_expose + def get_config_description(self): + return json.dumps(self.config_description) + + @om_expose + def get_config(self): + return json.dumps(self._config) + + @om_expose + def set_config(self, config): + config = json.loads(config) + self._config_checker.check_config(config) + self.write_config(config) + self._config = config + self._read_config() + return json.dumps({'success': True}) + + @background_task + def sample_measurements(self): + while True: + try: + for client in self._sma_devices: + for data in client.fetch_values(Value.TOTAL_YIELD, Value.PV_POWER): + if data['type'] == Value.TOTAL_YIELD: + counter = self._get_counter(data['external_id']) + self.connector.measurement_counter.report_counter_state(counter, total_consumed=0, total_injected=data['value']) + except Exception: + logger.exception('Failed to report measurements state') + time.sleep(self._sample_rate) + + @background_task + def sample_realtime(self): + while True: + try: + for client in self._sma_devices: + for data in client.fetch_values(Value.PV_POWER): + counter = self._counters.get(data['external_id']) + if counter: + self.connector.measurement_counter.report_realtime_state(counter, -data['value']) + except Exception: + logger.exception('Failed to report realtime state') + time.sleep(5) + + def _get_counter(self, external_id): + counter_type = self.connector.measurement_counter.Enums.Types.SOLAR + if external_id not in self._counters: + self._counters[external_id] = self.connector.measurement_counter.register_counter_electricity_wh(external_id, counter_type, 'SMA Inverter', has_realtime=True) + return self._counters[external_id]