pax_global_header00006660000000000000000000000064141536477160014530gustar00rootroot0000000000000052 comment=1781b4e29ccd836789f648bf98d63acc0f641067 smappee-pysmappee-1781b4e/000077500000000000000000000000001415364771600155205ustar00rootroot00000000000000smappee-pysmappee-1781b4e/.gitignore000066400000000000000000000000671415364771600175130ustar00rootroot00000000000000*.pyc .eggs/ .idea/* *egg-info/ build/ dist/ eggs/ env/smappee-pysmappee-1781b4e/LICENSE000066400000000000000000000020501415364771600165220ustar00rootroot00000000000000MIT License Copyright (c) 2020 Smappee Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. smappee-pysmappee-1781b4e/README.md000066400000000000000000000037661415364771600170130ustar00rootroot00000000000000Official Smappee Python Library =============================== Python Library for the Smappee dev API (v3) and MQTT interface. Used as a wrapper dependency in the [Home Assistant integration](https://www.home-assistant.io/integrations/smappee). Version ------- 0.2.29 Installation ------------ The recommended way to install is via [pip](https://pypi.org/) $ pip3 install pysmappee Changelog --------- 0.0.1 * Initial commit 0.0.2 * Rename smappee directory 0.0.3 * Sync dev API 0.0.{4, 5, 6} * Actuator connection state * Platform option * Measurement index check * Location details source change 0.0.{7, 8, 9} * Support comfort plug state change * Add locations without active device * Disable IO modules * Align connection state values 0.1.{0, 1} * Refactor api to work with implicit account linking * Only keep farm variable in API class 0.1.{2, 3} * Only use local MQTT for 20- and 50-series * 11-series do have solar production 0.1.4 * Extend service location class with voltage and reactive bools * Extend model mapping 0.1.5 * Catch expired token as an HTTPError 0.2.{0, .., 9} * Implement standalone local API * Only create objects if the serialnumber is known * Review local API exception handling 0.2.10 * Phase 2 Local API (support Smappee Pro/Plus) * Local API improvements (Switch current status, cache load) 0.2.11 * Activate IO modules 0.2.{12, 13} * Move requirements to setup.py file 0.2.14 * Exclude test package 0.2.{15, 16, 17} * Review consumption and production indices for solar series * Fix caching for local polling 0.2.{18, ..., 25} * Prepare local Smappee Genius support (local mqtt) * Remove smart device support 0.2.{26, 27} * Review tracking schedule 0.2.{28, 29} * Review MQTT connection Support ------- If you find a bug, have any questions about how to use PySmappee or have suggestions for improvements then feel free to file an issue on the GitHub project page [https://github.com/smappee/pysmappee](https://github.com/smappee/pysmappee). License ------- (MIT License) smappee-pysmappee-1781b4e/examples/000077500000000000000000000000001415364771600173365ustar00rootroot00000000000000smappee-pysmappee-1781b4e/examples/example.py000066400000000000000000000000001415364771600213310ustar00rootroot00000000000000smappee-pysmappee-1781b4e/pyproject.toml000066400000000000000000000001471415364771600204360ustar00rootroot00000000000000[build-system] requires = [ "setuptools>=42", "wheel" ] build-backend = "setuptools.build_meta"smappee-pysmappee-1781b4e/pysmappee/000077500000000000000000000000001415364771600175235ustar00rootroot00000000000000smappee-pysmappee-1781b4e/pysmappee/__init__.py000066400000000000000000000001111415364771600216250ustar00rootroot00000000000000"""Smappee API and MQTT wrapper package.""" from .smappee import Smappee smappee-pysmappee-1781b4e/pysmappee/actuator.py000066400000000000000000000041261415364771600217220ustar00rootroot00000000000000"""Support for all kinds of Smappee plugs.""" class SmappeeActuator: """Representation of a Smappee Comfort Plug, Switch and IO module.""" def __init__(self, id, name, serialnumber, state_values, connection_state, type): # configuration details self._id = id self._name = name self._serialnumber = serialnumber self._state_values = state_values self._type = type # states self._connection_state = connection_state self._state = None # extract current state and possible values from state_values self._state_options = [] for state_value in self._state_values: self._state_options.append(state_value.get('id')) if state_value.get('current'): self._state = state_value.get('id') # aggregated values (only for Smappee Switch) self._consumption_today = None @property def id(self): return self._id @property def name(self): return self._name @property def serialnumber(self): return self._serialnumber @serialnumber.setter def serialnumber(self, serialnumber): self._serialnumber = serialnumber @property def state_values(self): return self._state_values @property def type(self): return self._type @property def connection_state(self): return self._connection_state @connection_state.setter def connection_state(self, connection_state): self._connection_state = connection_state @property def state(self): return self._state @state.setter def state(self, state): if state in ['ON', 'OFF']: # backwards compatibility (retained MQTT) state = f'{state}_{state}' self._state = state @property def state_options(self): return self._state_options @property def consumption_today(self): return self._consumption_today @consumption_today.setter def consumption_today(self, consumption_today): self._consumption_today = consumption_today smappee-pysmappee-1781b4e/pysmappee/api.py000066400000000000000000000315621415364771600206550ustar00rootroot00000000000000"""Support for cloud and local Smappee API.""" import datetime as dt import functools import numbers import pytz import requests from cachetools import TTLCache from requests.exceptions import HTTPError, ConnectTimeout, ReadTimeout, \ ConnectionError as RequestsConnectionError from requests_oauthlib import OAuth2Session from .config import config from .helper import urljoin def authenticated(func): # Decorator to refresh expired access tokens @functools.wraps(func) def wrapper(*args, **kwargs): self = args[0] try: return func(*args, **kwargs) except HTTPError as e: if e.response.status_code == 401: self._oauth.token = self.refresh_tokens() return func(*args, **kwargs) return wrapper class SmappeeApi: """Public Smappee cloud API wrapper.""" def __init__( self, client_id, client_secret, redirect_uri=None, token=None, token_updater=None, farm=1 ): self._client_id = client_id self._client_secret = client_secret self._token_updater = token_updater self._farm = farm extra = {"client_id": self._client_id, "client_secret": self._client_secret} self._oauth = OAuth2Session( client_id=client_id, token=token, redirect_uri=redirect_uri, auto_refresh_kwargs=extra, token_updater=token_updater, ) @property def farm(self): return self._farm @property def headers(self): return {"Authorization": f"Bearer {self._oauth.access_token}"} @authenticated def get_service_locations(self): r = requests.get(config['API_URL'][self._farm]['servicelocation_url'], headers=self.headers) r.raise_for_status() return r.json() @authenticated def get_metering_configuration(self, service_location_id): url = urljoin( config['API_URL'][self._farm]['servicelocation_url'], service_location_id, "meteringconfiguration" ) r = requests.get(url, headers=self.headers) r.raise_for_status() return r.json() @authenticated def get_service_location_info(self, service_location_id): url = urljoin( config['API_URL'][self._farm]['servicelocation_url'], service_location_id, "info" ) r = requests.get(url, headers=self.headers) r.raise_for_status() return r.json() @authenticated def get_consumption(self, service_location_id, start, end, aggregation): """ aggregation : int 1 = 5 min values (only available for the last 14 days) 2 = hourly values 3 = daily values 4 = monthly values 5 = quarterly values 6 = ... 7 = ... 8 = ... """ url = urljoin( config['API_URL'][self._farm]['servicelocation_url'], service_location_id, "consumption" ) d = self._get_consumption(url=url, start=start, end=end, aggregation=aggregation) for block in d['consumptions']: if 'alwaysOn' not in block.keys(): break block.update({'alwaysOn': block.get('alwaysOn') / 12}) return d @authenticated def get_sensor_consumption(self, service_location_id, sensor_id, start, end, aggregation): url = urljoin( config['API_URL'][self._farm]['servicelocation_url'], service_location_id, "sensor", sensor_id, "consumption" ) return self._get_consumption(url=url, start=start, end=end, aggregation=aggregation) @authenticated def get_switch_consumption(self, service_location_id, switch_id, start, end, aggregation): url = urljoin( config['API_URL'][self._farm]['servicelocation_url'], service_location_id, "switch", switch_id, "consumption" ) return self._get_consumption(url=url, start=start, end=end, aggregation=aggregation) def _get_consumption(self, url, start, end, aggregation): start, end = self._to_milliseconds(start), self._to_milliseconds(end) params = { "aggregation": aggregation, "from": start, "to": end } r = requests.get(url, headers=self.headers, params=params) r.raise_for_status() return r.json() @authenticated def get_events(self, service_location_id, appliance_id, start, end, max_number=None): start, end = self._to_milliseconds(start), self._to_milliseconds(end) url = urljoin( config['API_URL'][self._farm]['servicelocation_url'], service_location_id, "events" ) params = { "from": start, "to": end, "applianceId": appliance_id, "maxNumber": max_number } r = requests.get(url, headers=self.headers, params=params) r.raise_for_status() return r.json() @authenticated def get_actuator_state(self, service_location_id, actuator_id): url = urljoin( config['API_URL'][self._farm]['servicelocation_url'], service_location_id, "actuator", actuator_id, "state" ) r = requests.get(url, headers=self.headers) r.raise_for_status() return r.text @authenticated def set_actuator_state(self, service_location_id, actuator_id, state_id, duration=None): url = urljoin( config['API_URL'][self._farm]['servicelocation_url'], service_location_id, "actuator", actuator_id, state_id ) data = {} if duration is None else {"duration": duration} r = requests.post(url, headers=self.headers, json=data) r.raise_for_status() return r @authenticated def get_actuator_connection_state(self, service_location_id, actuator_id): url = urljoin( config['API_URL'][self._farm]['servicelocation_url'], service_location_id, "actuator", actuator_id, "connectionstate" ) r = requests.get(url, headers=self.headers) r.raise_for_status() return r.text def _to_milliseconds(self, time): if isinstance(time, dt.datetime): if time.tzinfo is None: time = time.replace(tzinfo=pytz.UTC) return int(time.timestamp() * 1e3) elif isinstance(time, numbers.Number): return time else: raise NotImplementedError("Time format not supported. Use milliseconds since epoch,\ Datetime or Pandas Datetime") def get_authorization_url(self, state): return self._oauth.authorization_url(config['API_URL'][self._farm]['authorize_url'], state) def request_token(self, authorization_response, code): return self._oauth.fetch_token( token_url=config['API_URL'][self._farm]['token_url'], authorization_response=authorization_response, code=code, client_secret=self.client_secret, ) def refresh_tokens(self): token = self._oauth.refresh_token(token_url=config['API_URL'][self._farm]['token_url']) if self.token_updater is not None: self.token_updater(token) return token class SmappeeLocalApi: """Smappee local API wrapper.""" def __init__( self, ip ): self._ip = ip self.session = requests.Session() # default indices for Smappee Energy and Solar self.consumption_indices = ['phase0ActivePower', 'phase1ActivePower', 'phase2ActivePower'] self.production_indices = ['phase3ActivePower', 'phase4ActivePower', 'phase5ActivePower'] # cache instantaneous load self.load_cache = TTLCache(maxsize=2, ttl=5) @property def host(self): return f'http://{self._ip}/gateway/apipublic' @property def headers(self): return {"Content-Type": "application/json"} def _post(self, url, data=None, retry=False): try: _url = urljoin(self.host, url) r = self.session.post(_url, data=data, headers=self.headers, timeout=2) r.raise_for_status() msg = r.json() if not retry and 'error' in msg \ and msg['error'] == 'Error not authenticated. Use Logon first!': self.logon() return self._post(url=url, data=data, retry=True) return msg except (ConnectTimeout, ReadTimeout, RequestsConnectionError, HTTPError): return None def logon(self): return self._post(url='logon', data='admin') def load_advanced_config(self): return self._post(url='advancedConfigPublic', data='load') def load_channels_config(self): # Method only available on Smappee2-series devices # reset consumption and production indices self.consumption_indices, self.production_indices = [], [] cc = self._post(url='channelsConfigPublic', data='load') for input_channel in cc['inputChannels']: if input_channel['inputChannelConnection'] == 'GRID': if input_channel['inputChannelType'] == 'CONSUMPTION': self.consumption_indices.append(f'phase{input_channel["ctInput"]}ActivePower') elif input_channel['inputChannelType'] == 'PRODUCTION': self.production_indices.append(f'phase{input_channel["ctInput"]}ActivePower') return cc def load_config(self): c = self._post(url='configPublic', data='load') # get emeterConfiguration to decide cons and prod indices for solar series (11) emeterConfiguration = None for conf in c: if 'key' in conf and conf['key'] == 'emeterConfiguration' and 'value' in conf: emeterConfiguration = conf['value'] # three phase grid and solar if emeterConfiguration == "11": pass # use default ones # single phase grid and solar elif emeterConfiguration == "17": self.consumption_indices = ['phase0ActivePower'] self.production_indices = ['phase1ActivePower'] # three phase grid, no solar elif emeterConfiguration == "4": self.production_indices = [] # single phase grid, no solar elif emeterConfiguration == "0": self.consumption_indices = ['phase0ActivePower'] self.production_indices = [] # dual phase grid and solar elif emeterConfiguration == "16": self.consumption_indices = ['phase0ActivePower', 'phase1ActivePower'] self.production_indices = ['phase2ActivePower', 'phase3ActivePower'] return c def load_command_control_config(self): return self._post(url='commandControlPublic', data='load') def load_instantaneous(self): return self._post(url='instantaneous', data='loadInstantaneous') def active_power(self, solar=False): """ Get the current active power consumption or solar production. Result is cached. :param solar: :return: """ if solar and 'instantaneous_solar' in self.load_cache: return self.load_cache['instantaneous_solar'] elif not solar and 'instantaneous_load' in self.load_cache: return self.load_cache['instantaneous_load'] inst = self.load_instantaneous() if inst is None: return None power_keys = self.production_indices if solar else self.consumption_indices values = [float(i['value']) for i in inst if i['key'] in power_keys] if values: power = int(sum(values) / 1000) else: power = 0 if solar: self.load_cache['instantaneous_solar'] = power else: self.load_cache['instantaneous_load'] = power return power def set_actuator_state(self, service_location_id, actuator_id, state_id, duration=None): if state_id == 'ON_ON': return self.on_command_control(val_id=actuator_id) elif state_id == 'OFF_OFF': return self.off_command_control(val_id=actuator_id) def on_command_control(self, val_id): data = "control,{\"controllableNodeId\":\"" + str(val_id) + "\",\"action\":\"ON\"}" return self._post(url='commandControlPublic', data=data) def off_command_control(self, val_id): data = "control,{\"controllableNodeId\":\"" + str(val_id) + "\",\"action\":\"OFF\"}" return self._post(url='commandControlPublic', data=data) smappee-pysmappee-1781b4e/pysmappee/appliance.py000066400000000000000000000014111415364771600220260ustar00rootroot00000000000000class SmappeeAppliance: def __init__(self, id, name, type, source_type): self._id = id self._name = name self._type = type self._source_type = source_type self._state = False self._power = None @property def id(self): return self._id @property def name(self): return self._name @property def type(self): return self._type @property def source_type(self): return self._source_type @property def state(self): return self._state @state.setter def state(self, state): self._state = state @property def power(self): return self._power @power.setter def power(self, power): self._power = power smappee-pysmappee-1781b4e/pysmappee/config.py000066400000000000000000000017651415364771600213530ustar00rootroot00000000000000config = dict() # api base urls config['API_URL'] = { 1: { 'authorize_url': 'https://app1pub.smappee.net/dev/v1/oauth2/authorize', 'token_url': 'https://app1pub.smappee.net/dev/v3/oauth2/token', 'servicelocation_url': 'https://app1pub.smappee.net/dev/v3/servicelocation', }, 2: { 'token_url': 'https://farm2pub.smappee.net/dev/v3/oauth2/token', 'servicelocation_url': 'https://farm2pub.smappee.net/dev/v3/servicelocation', }, 3: { 'token_url': 'https://farm3pub.smappee.net/dev/v3/oauth2/token', 'servicelocation_url': 'https://farm3pub.smappee.net/dev/v3/servicelocation', }, } config['MQTT'] = { 1: { 'host': 'mqtt.smappee.net', 'port': 443, }, 2: { 'host': 'mqtttest.smappee.net', 'port': 443, }, 3: { 'host': 'mqttdev.smappee.net', 'port': 443, }, 'local': { # only accessible from same network 'port': 1883, }, 'discovery': False, } smappee-pysmappee-1781b4e/pysmappee/helper.py000066400000000000000000000016011415364771600213520ustar00rootroot00000000000000"""Helper methods.""" def urljoin(*parts): """Join all url parts.""" # first strip extra forward slashes (except http:// and the likes) and create list part_list = [] for part in parts: str_part = str(part) if str_part.endswith('//'): str_part = str_part[0:-1] else: str_part = str_part.strip('/') part_list.append(str_part) # join everything together url = '/'.join(part_list) return url def is_smappee_energy(serialnumber: str): return serialnumber.startswith('10') def is_smappee_solar(serialnumber: str): return serialnumber.startswith('11') def is_smappee_plus(serialnumber: str): return serialnumber.startswith('2') def is_smappee_genius(serialnumber: str): return serialnumber.startswith('50') def is_smappee_connect(serialnumber: str): return serialnumber.startswith('51') smappee-pysmappee-1781b4e/pysmappee/measurement.py000066400000000000000000000044071415364771600224270ustar00rootroot00000000000000"""Support for all kinds of Smappee measurements.""" class SmappeeMeasurement: """Representation of a Smappee measurement.""" def __init__(self, id, name, type, subcircuit_type, channels): # configuration details self._id = id self._name = name self._type = type self._subcircuit_type = subcircuit_type self._channels = channels # live states self._active_total = None self._reactive_total = None self._current_total = None for c in self.channels: c['active'] = None c['reactive'] = None c['current'] = None @property def id(self): return self._id @property def name(self): return self._name @property def type(self): return self._type @property def subcircuit_type(self): return self._subcircuit_type @property def channels(self): return self._channels @property def active_total(self): return self._active_total def update_active(self, active, source='CENTRAL'): index = 'powerTopicIndex' if source == 'CENTRAL' else 'consumptionIndex' for channel in self.channels: if index in channel: channel['active'] = active[channel.get(index)] self._active_total = sum([c.get('active') for c in self.channels if index in c]) @property def reactive_total(self): return self._reactive_total def update_reactive(self, reactive, source='CENTRAL'): index = 'powerTopicIndex' if source == 'CENTRAL' else 'consumptionIndex' for channel in self.channels: if index in channel: channel['reactive'] = reactive[channel.get(index)] self._reactive_total = sum([c.get('reactive') for c in self.channels if index in c]) @property def current_total(self): return self._current_total def update_current(self, current, source='CENTRAL'): index = 'powerTopicIndex' if source == 'CENTRAL' else 'consumptionIndex' for channel in self.channels: if index in channel: channel['current'] = current[channel.get(index)] self._current_total = sum([c.get('current') for c in self.channels if index in c]) smappee-pysmappee-1781b4e/pysmappee/mqtt.py000066400000000000000000000437161415364771600210750ustar00rootroot00000000000000"""Support for cloud and local Smappee MQTT.""" import json import threading import socket import time import traceback import schedule import uuid from functools import wraps import paho.mqtt.client as mqtt from .config import config TRACKING_INTERVAL = 60 * 5 HEARTBEAT_INTERVAL = 60 * 1 def tracking(func): # Decorator to reactivate trackers @wraps(func) def wrapper(*args, **kwargs): self = args[0] if self._kind == 'central': if time.time() - self._last_tracking > TRACKING_INTERVAL: self._publish_tracking() if time.time() - self._last_heartbeat > HEARTBEAT_INTERVAL: self._publish_heartbeat() return func(*args, **kwargs) return wrapper class SmappeeMqtt(threading.Thread): """Smappee MQTT wrapper.""" def __init__(self, service_location, kind, farm): self._client = None self._service_location = service_location self._kind = kind self._farm = farm self._client_id = f"pysmappee-{self._service_location.service_location_uuid}-{self._kind}-{uuid.uuid4()}" self._last_tracking = 0 self._last_heartbeat = 0 threading.Thread.__init__( self, name=f'SmappeeMqttListener_{self._service_location.service_location_uuid}' ) @property def topic_prefix(self): return f'servicelocation/{self._service_location.service_location_uuid}' @tracking def _on_connect(self, client, userdata, flags, rc): if self._kind == 'local': self._client.subscribe(topic='#') else: self._client.subscribe(topic=f'{self.topic_prefix}/#') self._schedule_tracking_and_heartbeat() def _schedule_tracking_and_heartbeat(self): schedule.every(60).seconds.do(lambda: self._publish_tracking()) schedule.every(60).seconds.do(lambda: self._publish_heartbeat()) def _publish_tracking(self): # turn OFF current tracking and restore self._client.publish( topic=f"{self.topic_prefix}/tracking", payload=json.dumps({ "value": "OFF", "clientId": self._client_id, "serialNumber": self._service_location.device_serial_number, "type": "RT_VALUES", }) ) time.sleep(2) self._client.publish( topic=f"{self.topic_prefix}/tracking", payload=json.dumps({ "value": "ON", "clientId": self._client_id, "serialNumber": self._service_location.device_serial_number, "type": "RT_VALUES", }) ) self._last_tracking = time.time() def _publish_heartbeat(self): self._client.publish( topic=f"{self.topic_prefix}/homeassistant/heartbeat", payload=json.dumps({ "serviceLocationId": self._service_location.service_location_id, }) ) self._last_heartbeat = time.time() def _on_disconnect(self, client, userdata, rc): pass @tracking def _on_message(self, client, userdata, message): try: #print('{0} - Processing {1} MQTT message from topic {2} with value {3}'.format(self._service_location.service_location_id, self._kind, message.topic, message.payload)) # realtime central power values if message.topic == f'{self.topic_prefix}/power': power_data = json.loads(message.payload) self._service_location._update_power_data(power_data=power_data) # realtime local power values elif message.topic == f'{self.topic_prefix}/realtime': realtime_data = json.loads(message.payload) self._service_location._update_realtime_data(realtime_data=realtime_data) # powerquality elif message.topic == f'{self.topic_prefix}/powerquality': pass # tracking and heartbeat elif message.topic == f'{self.topic_prefix}/tracking': pass elif message.topic == f'{self.topic_prefix}/homeassistant/heartbeat': pass # config topics elif message.topic == f'{self.topic_prefix}/config': config_details = json.loads(message.payload) self._service_location.firmware_version = config_details.get('firmwareVersion') self._service_location._service_location_uuid = config_details.get('serviceLocationUuid') self._service_location._service_location_id = config_details.get('serviceLocationId') elif message.topic == f'{self.topic_prefix}/sensorConfig': pass elif message.topic == f'{self.topic_prefix}/homeControlConfig': pass # aggregated consumption values elif message.topic == f'{self.topic_prefix}/aggregated': pass # presence topic elif message.topic == f'{self.topic_prefix}/presence': presence = json.loads(message.payload) self._service_location.is_present = presence.get('value') # trigger topic elif message.topic == f'{self.topic_prefix}/trigger': pass elif message.topic == f'{self.topic_prefix}/trigger/appliance': pass elif message.topic == f'{self.topic_prefix}/triggerpush': pass elif message.topic == f'{self.topic_prefix}/triggervalue': pass # harmonic vectors elif message.topic == f'{self.topic_prefix}/h1vector': pass # nilm elif message.topic == f'{self.topic_prefix}/nilm': pass # controllable nodes (general messages) elif message.topic == f'{self.topic_prefix}': msg = json.loads(message.payload) # turn ON/OFF comfort plug if msg.get('messageType') == 1283: id = msg['content']['controllableNodeId'] plug_state = msg['content']['action'] plug_state_since = int(msg['content']['timestamp'] / 1000) self._service_location.set_actuator_state(id=id, state=plug_state, since=plug_state_since, api=False) # smart device and ETC topics elif message.topic.startswith(f'{self.topic_prefix}/etc/'): pass # specific HASS.io topics elif message.topic == f'{self.topic_prefix}/homeassistant/event': pass elif message.topic == f'{self.topic_prefix}/homeassistant/trigger/etc': pass elif message.topic.startswith(f'{self.topic_prefix}/outputmodule/'): pass elif message.topic == f'{self.topic_prefix}/scheduler': pass # actuator topics elif message.topic.startswith(f'{self.topic_prefix}/plug/'): plug_id = int(message.topic.split('/')[-2]) payload = json.loads(message.payload) plug_state, plug_state_since = payload.get('value'), payload.get('since') state_type = message.topic.split('/')[-1] if state_type == 'state' and self._kind == 'central': # todo: remove and condition self._service_location.set_actuator_state(id=plug_id, state=plug_state, since=plug_state_since, api=False) elif state_type == 'connectionState': self._service_location.set_actuator_connection_state(id=plug_id, connection_state=plug_state, since=plug_state_since) elif config['MQTT']['discovery']: print(message.topic, message.payload) except Exception: traceback.print_exc() def start(self): self._client = mqtt.Client(client_id=self._client_id) if self._kind == 'central': self._client.username_pw_set(username=self._service_location.service_location_uuid, password=self._service_location.service_location_uuid) self._client.on_connect = lambda client, userdata, flags, rc: self._on_connect(client, userdata, flags, rc) self._client.on_message = lambda client, userdata, message: self._on_message(client, userdata, message) self._client.on_disconnect = lambda client, userdata, rc: self._on_disconnect(client, userdata, rc) # self._client.tls_set(None, cert_reqs=ssl.CERT_NONE, tls_version=ssl.PROTOCOL_TLSv1) if self._kind == 'central': self._client.tls_set() self._client.connect(host=config['MQTT'][self._farm]['host'], port=config['MQTT'][self._farm]['port']) elif self._kind == 'local': try: self._client.connect(host=f'smappee{self._service_location.device_serial_number}.local', port=config['MQTT']['local']['port']) except socket.gaierror as _: # unable to connect to local Smappee device (host unavailable) return except socket.timeout as _: return self._client.loop_start() def stop(self): self._client.loop_stop() class SmappeeLocalMqtt(threading.Thread): """Smappee local MQTT wrapper.""" def __init__(self, serial_number=None): self._client = None self.service_location = None self._serial_number = serial_number self._service_location_id = None self._service_location_uuid = None threading.Thread.__init__( self, name=f'SmappeeLocalMqttListener_{self._serial_number}' ) self.realtime = {} self.phase_type = None self.measurements = {} self.switch_sensors = [] self.smart_plugs = [] self.actuators_connection_state = {} self.actuators_state = {} self._timezone = None @property def topic_prefix(self): return f'servicelocation/{self._service_location_uuid}' def _on_connect(self, client, userdata, flags, rc): self._client.subscribe(topic='#') def _on_disconnect(self, client, userdata, rc): pass def _get_client_id(self): return f"smappeeLocalMQTT-{self._serial_number}" def _on_message(self, client, userdata, message): try: # realtime local power values if message.topic.endswith('/realtime'): self.realtime = json.loads(message.payload) if self.service_location is not None: self.service_location._update_realtime_data(realtime_data=self.realtime) elif message.topic.endswith('/config'): c = json.loads(message.payload) self._timezone = c.get('timeZone') self._service_location_id = c.get('serviceLocationId') self._service_location_uuid = c.get('serviceLocationUuid') self._serial_number = c.get('serialNumber') elif message.topic.endswith('channelConfig'): pass elif message.topic.endswith('/channelConfigV2'): self._channel_config = json.loads(message.payload) self.phase_type = self._channel_config.get('dataProcessingSpecification', {}).get('phaseType', None) # extract measurements from channelConfigV2 measurements_dict = {} for m in self._channel_config.get('dataProcessingSpecification', {}).get('measurements', []): if m.get('flow') == 'CONSUMPTION' and m.get('connectionType') == 'SUBMETER': if not m['name'] in measurements_dict.keys(): measurements_dict[m['name']] = [] measurements_dict[m['name']].append(m['publishIndex']) elif m.get('flow') == 'CONSUMPTION' and m.get('connectionType') == 'GRID': if not 'Grid' in measurements_dict.keys(): measurements_dict['Grid'] = [] measurements_dict['Grid'].append(m['publishIndex']) elif m.get('flow') == 'PRODUCTION' and m.get('connectionType') == 'GRID': if not 'Solar' in measurements_dict.keys(): measurements_dict['Solar'] = [] measurements_dict['Solar'].append(m['publishIndex']) self.measurements = {} for m_name, m_index in measurements_dict.items(): self.measurements[m_name] = list(set(m_index)) elif message.topic.endswith('/sensorConfig'): pass elif message.topic.endswith('/homeControlConfig'): # switches switches = json.loads(message.payload).get('switchActuators', []) for switch in switches: if switch['serialNumber'].startswith('4006'): self.switch_sensors.append({ 'nodeId': switch['nodeId'], 'name': switch['name'], 'serialNumber': switch['serialNumber'] }) # plugs plugs = json.loads(message.payload).get('smartplugActuators', []) for plug in plugs: self.smart_plugs.append({ 'nodeId': plug['nodeId'], 'name': plug['name'] }) elif message.topic.endswith('/presence'): pass elif message.topic.endswith('/aggregated'): pass elif message.topic.endswith('/aggregatedGW'): pass elif message.topic.endswith('/aggregatedSwitch'): pass elif message.topic.endswith('/etc/measuredvalues'): pass elif message.topic.endswith('/networkstatistics'): pass elif message.topic.endswith('/scheduler'): pass elif message.topic.endswith('/devices'): pass elif message.topic.endswith('/action/setcurrent'): pass elif message.topic.endswith('/trigger'): pass elif message.topic.endswith('/connectionState'): actuator_id = int(message.topic.split('/')[-2]) self.actuators_connection_state[actuator_id] = json.loads(message.payload).get('value') elif message.topic.endswith('/state'): actuator_id = int(message.topic.split('/')[-2]) self.actuators_state[actuator_id] = json.loads(message.payload).get('value') if self.service_location is not None: self.service_location.set_actuator_state( id=actuator_id, state='{0}_{0}'.format(self.actuators_state[actuator_id]), api=False ) elif message.topic.endswith('/setstate'): actuator_id = int(message.topic.split('/')[-2]) p = str(message.payload.decode('utf-8')).replace("\'", "\"") self.actuators_state[actuator_id] = json.loads(p).get('value') elif config['MQTT']['discovery']: print('Processing MQTT message from topic {0} with value {1}'.format(message.topic, message.payload)) except Exception: traceback.print_exc() def set_actuator_state(self, service_location_id, actuator_id, state_id): state = None if state_id == 'ON_ON': state = 'ON' elif state_id == 'OFF_OFF': state = 'OFF' if state is not None: self._client.publish( topic="servicelocation/{0}/plug/{1}/setstate".format(self._service_location_uuid, actuator_id), payload=json.dumps({"value": state}) ) def is_config_ready(self, timeout=60, interval=5): c = 0 while c < timeout: if self.phase_type is not None and self._serial_number is not None: return self._serial_number c += interval time.sleep(interval) def start_and_wait_for_config(self): self.start() return self.is_config_ready() def start_attempt(self): client = mqtt.Client(client_id='smappeeLocalMqttConnectionAttempt') try: client.connect(host=f'smappee{self._serial_number}.local', port=config['MQTT']['local']['port']) except Exception: return False return True def start(self): self._client = mqtt.Client(client_id=self._get_client_id()) self._client.on_connect = lambda client, userdata, flags, rc: self._on_connect(client, userdata, flags, rc) self._client.on_message = lambda client, userdata, message: self._on_message(client, userdata, message) self._client.on_disconnect = lambda client, userdata, rc: self._on_disconnect(client, userdata, rc) # self._client.tls_set(None, cert_reqs=ssl.CERT_NONE, tls_version=ssl.PROTOCOL_TLSv1) try: self._client.connect(host=f'smappee{self._serial_number}.local', port=config['MQTT']['local']['port']) except socket.gaierror as _: # unable to connect to local Smappee device (host unavailable) return except socket.timeout as _: return self._client.loop_start() def stop(self): self._client.loop_stop() smappee-pysmappee-1781b4e/pysmappee/sensor.py000066400000000000000000000024561415364771600214150ustar00rootroot00000000000000class SmappeeSensor: def __init__(self, id, name, channels): # configuration details self._id = id self._name = name # list of dicts with keys name, ppu, uom, enabled, type (water/gas), channel (id) self._channels = channels for c in self.channels: c['value_today'] = 0 # aggregated value # states self._temperature = None self._humidity = None self._battery = None @property def id(self): return self._id @property def name(self): return self._name @property def channels(self): return self._channels def update_today_values(self, record): for channel in self._channels: channel['value_today'] = record[f"value{channel.get('channel')}"] / channel.get('ppu') @property def temperature(self): return self._temperature @temperature.setter def temperature(self, temperature): self._temperature = temperature @property def humidity(self): return self._humidity @humidity.setter def humidity(self, humidity): self._humidity = humidity @property def battery(self): return self._battery @battery.setter def battery(self, battery): self._battery = battery smappee-pysmappee-1781b4e/pysmappee/servicelocation.py000066400000000000000000000664211415364771600232770ustar00rootroot00000000000000from datetime import datetime, timedelta from .mqtt import SmappeeMqtt from .actuator import SmappeeActuator from .appliance import SmappeeAppliance from .helper import is_smappee_solar, is_smappee_genius, is_smappee_connect, is_smappee_plus from .measurement import SmappeeMeasurement from .sensor import SmappeeSensor from cachetools import TTLCache class SmappeeServiceLocation(object): def __init__(self, device_serial_number, smappee_api, service_location_id=None, local_polling=False): # service location details self._service_location_id = service_location_id self._device_serial_number = device_serial_number self._phase_type = None self._has_solar_production = False self._has_voltage_values = False self._has_reactive_value = False self._firmware_version = None # api instance to (re)load consumption data self.smappee_api = smappee_api self._local_polling = local_polling # mqtt connections self.mqtt_connection_central = None self.mqtt_connection_local = None # coordinates self._latitude = None self._longitude = None self._timezone = None # presence self._presence = None # dicts to hold appliances, smart switches and ct details by id self._appliances = {} self._actuators = {} self._sensors = {} self._measurements = {} # realtime values self._realtime_values = { 'total_power': None, 'total_reactive_power': None, 'solar_power': None, 'alwayson': None, 'phase_voltages': None, 'phase_voltages_h3': None, 'phase_voltages_h5': None, 'line_voltages': None, 'line_voltages_h3': None, 'line_voltages_h5': None, } # extracted consumption values self._aggregated_values = { 'power_today': None, 'power_current_hour': None, 'power_last_5_minutes': None, 'solar_today': None, 'solar_current_hour': None, 'solar_last_5_minutes': None, 'alwayson_today': None, 'alwayson_current_hour': None, 'alwasyon_last_5_minutes': None } self._cache = TTLCache(maxsize=100, ttl=300) self.load_configuration() self.update_trends_and_appliance_states() def load_configuration(self, refresh=False): # Set solar production on 11-series (no measurements config available on non 50-series) if is_smappee_solar(serialnumber=self._device_serial_number): self.has_solar_production = True # Set voltage values on 5-series if is_smappee_genius(serialnumber=self._device_serial_number) or is_smappee_connect(serialnumber=self._device_serial_number): self.has_voltage_values = True if self.local_polling: self._service_location_name = f'Smappee {self.device_serial_number} local' self._service_location_uuid = 0 if is_smappee_genius(serialnumber=self._device_serial_number): # Prepare incoming mqtt messages self.smappee_api.service_location = self self._has_reactive_value = True self._phase_type = self.smappee_api.phase_type # Load Smappee switches for switch in self.smappee_api.switch_sensors: current_state = False if self.smappee_api.actuators_state.get(switch['nodeId']) == 'ON': current_state = True self._add_actuator( id=switch['nodeId'], name=switch['name'], serialnumber=switch['serialNumber'], state_values=[ {'id': 'ON_ON', 'name': 'on', 'current': True if current_state else False}, {'id': 'OFF_OFF', 'name': 'off', 'current': False if current_state else True} ], connection_state=self.smappee_api.actuators_connection_state.get(switch['nodeId'], 'CONNECTED'), actuator_type='SWITCH' ) # Load Smappee comfort plugs for plug in self.smappee_api.smart_plugs: current_state = False if self.smappee_api.actuators_state.get(plug['nodeId']) == 'ON': current_state = True self._add_actuator( id=plug['nodeId'], name=plug['name'], serialnumber=None, state_values=[ {'id': 'ON_ON', 'name': 'on', 'current': True if current_state else False}, {'id': 'OFF_OFF', 'name': 'off', 'current': False if current_state else True} ], connection_state='CONNECTED', actuator_type='COMFORT_PLUG' ) # Load all CT measurements for measurement_name, measurement_index in self.smappee_api.measurements.items(): self._add_measurement( id=min(measurement_index), name=measurement_name, type='CT', subcircuitType=None, channels=[{'consumptionIndex': m} for m in measurement_index] ) else: # Load actuators self.smappee_api.logon() command_control_config = self.smappee_api.load_command_control_config() if command_control_config is not None: for ccc in command_control_config: if ccc.get('type') == '2': at = 'COMFORT_PLUG' elif ccc.get('type') == '3': at = 'SWITCH' else: # Unknown actuator type continue self._add_actuator(id=int(ccc.get('key')), name=ccc.get('value'), serialnumber=ccc.get('serialNumber'), state_values=[ {'id': 'ON_ON', 'name': 'on', 'current': ccc.get('relayStatus') is True}, {'id': 'OFF_OFF', 'name': 'off', 'current': ccc.get('relayStatus') is False}], connection_state=ccc.get('connectionStatus').upper() if 'connectionStatus' in ccc else None, actuator_type=at) # Load channels config pro Smappee11 and 2-series and only if is_smappee_solar(serialnumber=self._device_serial_number): self.smappee_api.load_config() elif is_smappee_plus(serialnumber=self._device_serial_number): channels_config = self.smappee_api.load_channels_config() for input_channel in channels_config['inputChannels']: if input_channel['inputChannelType'] == 'PRODUCTION' and input_channel['inputChannelConnection'] == 'GRID': self.has_solar_production = True else: # Collect metering configuration sl_metering_configuration = self.smappee_api.get_metering_configuration(service_location_id=self.service_location_id) # Service location details self._service_location_name = sl_metering_configuration.get('name') self._service_location_uuid = sl_metering_configuration.get('serviceLocationUuid') # Set coordinates and timezone self.latitude = sl_metering_configuration.get('lat') self.longitude = sl_metering_configuration.get('lon') self.timezone = sl_metering_configuration.get('timezone') # Load appliances for appliance in sl_metering_configuration.get('appliances'): if appliance.get('type') != 'Find me' and appliance.get('sourceType') == 'NILM': self._add_appliance(id=appliance.get('id'), name=appliance.get('name'), type=appliance.get('type'), source_type=appliance.get('sourceType')) # Load actuators (Smappee Switches, Comfort Plugs, IO modules) for actuator in sl_metering_configuration.get('actuators'): self._add_actuator(id=actuator.get('id'), name=actuator.get('name'), serialnumber=actuator.get('serialNumber') if 'serialNumber' in actuator else None, state_values=actuator.get('states'), connection_state=actuator.get('connectionState'), actuator_type=actuator.get('type')) # Load sensors (Smappee Gas and Water) for sensor in sl_metering_configuration.get('sensors'): self._add_sensor(id=sensor.get('id'), name=sensor.get('name'), channels=sensor.get('channels')) # Set phase type self.phase_type = sl_metering_configuration.get('phaseType') if 'phaseType' in sl_metering_configuration else None # Load channel configuration if 'measurements' in sl_metering_configuration: for measurement in sl_metering_configuration.get('measurements'): self._add_measurement(id=measurement.get('id'), name=measurement.get('name'), type=measurement.get('type'), subcircuitType=measurement.get('subcircuitType') if 'subcircuitType' in measurement else None, channels=measurement.get('channels')) if measurement.get('type') == 'PRODUCTION': self.has_solar_production = True # Setup MQTT connection if not refresh: self.mqtt_connection_central = self.load_mqtt_connection(kind='central') # Only use a local MQTT broker for 20# or 50# series monitors if is_smappee_plus(serialnumber=self._device_serial_number) or is_smappee_genius(serialnumber=self._device_serial_number): self.mqtt_connection_local = self.load_mqtt_connection(kind='local') self.has_reactive_value = True # reactive only available through local MQTT @property def service_location_id(self): return self._service_location_id @property def service_location_uuid(self): return self._service_location_uuid @property def service_location_name(self): return self._service_location_name @service_location_name.setter def service_location_name(self, name): self._service_location_name = name @property def device_serial_number(self): return self._device_serial_number @property def device_model(self): model_mapping = { '10': 'Energy', '11': 'Solar', '20': 'Pro/Plus', '50': 'Genius', '5100': 'Wi-Fi Connect', '5110': 'Wi-Fi Connect', '5130': 'Ethernet Connect', '5140': '4G Connect', '57': 'P1S1 module', } if self.device_serial_number is None: return 'Smappee deactivated' elif self.device_serial_number[:2] in model_mapping: return f'Smappee {model_mapping[self.device_serial_number[:2]]}' elif self.device_serial_number[:4] in model_mapping: return f'Smappee {model_mapping[self.device_serial_number[:4]]}' else: 'Smappee' @property def phase_type(self): return self._phase_type @phase_type.setter def phase_type(self, phase_type): self._phase_type = phase_type @property def has_solar_production(self): return self._has_solar_production @has_solar_production.setter def has_solar_production(self, has_solar_production): self._has_solar_production = has_solar_production @property def has_voltage_values(self): return self._has_voltage_values @has_voltage_values.setter def has_voltage_values(self, has_voltage_values): self._has_voltage_values = has_voltage_values @property def has_reactive_value(self): return self._has_reactive_value @has_reactive_value.setter def has_reactive_value(self, has_reactive_value): self._has_reactive_value = has_reactive_value @property def local_polling(self): return self._local_polling @property def latitude(self): return self._latitude @latitude.setter def latitude(self, lat): self._latitude = lat @property def longitude(self): return self._longitude @longitude.setter def longitude(self, lon): self._longitude = lon @property def timezone(self): return self._timezone @timezone.setter def timezone(self, timezone): self._timezone = timezone @property def firmware_version(self): return self._firmware_version @firmware_version.setter def firmware_version(self, firmware_version): self._firmware_version = firmware_version @property def is_present(self): return self._presence @is_present.setter def is_present(self, presence): self._presence = presence @property def appliances(self): return self._appliances def _add_appliance(self, id, name, type, source_type): self.appliances[id] = SmappeeAppliance(id=id, name=name, type=type, source_type=source_type) def update_appliance_state(self, id, delta=1440): if f"appliance_{id}" in self._cache: return end = datetime.utcnow() start = end - timedelta(minutes=delta) events = self.smappee_api.get_events(service_location_id=self.service_location_id, appliance_id=id, start=start, end=end) self._cache[f"appliance_{id}"] = events if events: power = abs(events[0].get('activePower')) self.appliances[id].power = power if 'state' in events[0]: # program appliance self.appliances[id].state = events[0].get('state') > 0 else: # delta appliance self.appliances[id].state = events[0].get('activePower') > 0 @property def actuators(self): return self._actuators def _add_actuator(self, id, name, serialnumber, state_values, connection_state, actuator_type): self.actuators[id] = SmappeeActuator(id=id, name=name, serialnumber=serialnumber, state_values=state_values, connection_state=connection_state, type=actuator_type) if not self.local_polling: # Get actuator state state = self.smappee_api.get_actuator_state(service_location_id=self.service_location_id, actuator_id=id) self.actuators.get(id).state = state # Get actuator connection state (COMFORT_PLUG is always UNREACHABLE) connection_state = self.smappee_api.get_actuator_connection_state(service_location_id=self.service_location_id, actuator_id=id) connection_state = connection_state.replace('"', '') self.actuators.get(id).connection_state = connection_state def set_actuator_state(self, id, state, since=None, api=True): if id in self.actuators: if api: self.smappee_api.set_actuator_state(service_location_id=self.service_location_id, actuator_id=id, state_id=state) self.actuators.get(id).state = state def set_actuator_connection_state(self, id, connection_state, since=None): if id in self.actuators: self.actuators.get(id).connection_state = connection_state @property def sensors(self): return self._sensors def _add_sensor(self, id, name, channels): self.sensors[id] = SmappeeSensor(id, name, channels) @property def measurements(self): return self._measurements def _add_measurement(self, id, name, type, subcircuitType, channels): self.measurements[id] = SmappeeMeasurement(id=id, name=name, type=type, subcircuit_type=subcircuitType, channels=channels) @property def total_power(self): return self._realtime_values.get('total_power') @total_power.setter def total_power(self, value): self._realtime_values['total_power'] = value @property def total_reactive_power(self): return self._realtime_values.get('total_reactive_power') @total_reactive_power.setter def total_reactive_power(self, value): self._realtime_values['total_reactive_power'] = value @property def solar_power(self): return self._realtime_values.get('solar_power') @solar_power.setter def solar_power(self, value): self._realtime_values['solar_power'] = value @property def alwayson(self): return self._realtime_values.get('alwayson') @alwayson.setter def alwayson(self, value): self._realtime_values['alwayson'] = value @property def phase_voltages(self): return self._realtime_values.get('phase_voltages') @phase_voltages.setter def phase_voltages(self, values): self._realtime_values['phase_voltages'] = values @property def phase_voltages_h3(self): return self._realtime_values.get('phase_voltages_h3') @phase_voltages_h3.setter def phase_voltages_h3(self, values): self._realtime_values['phase_voltages_h3'] = values @property def phase_voltages_h5(self): return self._realtime_values.get('phase_voltages_h5') @phase_voltages_h5.setter def phase_voltages_h5(self, values): self._realtime_values['phase_voltages_h5'] = values @property def line_voltages(self): return self._realtime_values.get('line_voltages') @line_voltages.setter def line_voltages(self, values): self._realtime_values['line_voltages'] = values @property def line_voltages_h3(self): return self._realtime_values.get('line_voltages_h3') @line_voltages_h3.setter def line_voltages_h3(self, values): self._realtime_values['line_voltages_h3'] = values @property def line_voltages_h5(self): return self._realtime_values.get('line_voltages_h5') @line_voltages_h5.setter def line_voltages_h5(self, values): self._realtime_values['line_voltages_h5'] = values def load_mqtt_connection(self, kind): mqtt_connection = SmappeeMqtt(service_location=self, kind=kind, farm=self.smappee_api.farm) mqtt_connection.start() return mqtt_connection def _update_power_data(self, power_data): # use incoming power data (through central MQTT connection) self.total_power = power_data.get('consumptionPower') self.solar_power = power_data.get('solarPower') self.alwayson = power_data.get('alwaysOn') if 'phaseVoltageData' in power_data: self.phase_voltages = [pv / 10 for pv in power_data.get('phaseVoltageData')] self.phase_voltages_h3 = power_data.get('phaseVoltageH3Data') self.phase_voltages_h5 = power_data.get('phaseVoltageH5Data') if 'lineVoltageData' in power_data: self.line_voltages = [lv / 10 for lv in power_data.get('lineVoltageData')] self.line_voltages_h3 = power_data.get('lineVoltageH3Data') self.line_voltages_h5 = power_data.get('lineVoltageH5Data') if 'activePowerData' in power_data: active_power_data = power_data.get('activePowerData') for _, measurement in self.measurements.items(): measurement.update_active(active=active_power_data) if 'reactivePowerData' in power_data: reactive_power_data = power_data.get('reactivePowerData') for _, measurement in self.measurements.items(): measurement.update_reactive(reactive=reactive_power_data) if 'currentData' in power_data: current_data = power_data.get('currentData') for _, measurement in self.measurements.items(): measurement.update_current(current=current_data) def _update_realtime_data(self, realtime_data): # Use incoming realtime data (through local MQTT connection) self.total_power = realtime_data.get('totalPower') self.total_reactive_power = realtime_data.get('totalReactivePower') self.phase_voltages = [v.get('voltage', 0) for v in realtime_data.get('voltages')] active_power_data, current_data = {}, {} for channel_power in realtime_data.get('channelPowers'): active_power_data[channel_power.get('publishIndex')] = channel_power.get('power') current_data[channel_power.get('publishIndex')] = channel_power.get('current') / 10 # update channel data for _, measurement in self.measurements.items(): measurement.update_active(active=active_power_data, source='LOCAL') measurement.update_current(current=current_data, source='LOCAL') @property def aggregated_values(self): return self._aggregated_values def update_active_consumptions(self, trend='today'): params = { 'today': {'aggtype': 3, 'delta': 1440}, 'current_hour': {'aggtype': 2, 'delta': 60}, 'last_5_minutes': {'aggtype': 1, 'delta': 9} } if f'total_consumption_{trend}' in self._cache: return end = datetime.utcnow() start = end - timedelta(minutes=params.get(trend).get('delta')) consumption_result = self.smappee_api.get_consumption(service_location_id=self.service_location_id, start=start, end=end, aggregation=params.get(trend).get('aggtype')) self._cache[f'total_consumption_{trend}'] = consumption_result if consumption_result['consumptions']: self.aggregated_values[f'power_{trend}'] = consumption_result.get('consumptions')[0].get('consumption') self.aggregated_values[f'solar_{trend}'] = consumption_result.get('consumptions')[0].get('solar') self.aggregated_values[f'alwayson_{trend}'] = consumption_result.get('consumptions')[0].get('alwaysOn') * 12 def update_todays_actuator_consumptions(self, aggtype=3, delta=1440): end = datetime.utcnow() start = end - timedelta(minutes=delta) for id, actuator in self.actuators.items(): if f'actuator_{id}_consumption_today' in self._cache: continue consumption_result = self.smappee_api.get_switch_consumption(service_location_id=self.service_location_id, switch_id=id, start=start, end=end, aggregation=aggtype) self._cache[f'actuator_{id}_consumption_today'] = consumption_result if consumption_result['records']: actuator.consumption_today = consumption_result.get('records')[0].get('active') def update_todays_sensor_consumptions(self, aggtype=3, delta=1440): end = datetime.utcnow() start = end - timedelta(minutes=delta) for id, sensor in self.sensors.items(): if f'sensor_{id}_consumption_today' in self._cache: continue consumption_result = self.smappee_api.get_sensor_consumption(service_location_id=self.service_location_id, sensor_id=id, start=start, end=end, aggregation=aggtype) self._cache[f'sensor_{id}_consumption_today'] = consumption_result if consumption_result['records']: sensor.update_today_values(record=consumption_result.get('records')[0]) if 'temperature' in consumption_result.get('records')[0]: sensor.temperature = consumption_result.get('records')[0].get('temperature') if 'humidity' in consumption_result.get('records')[0]: sensor.humidity = consumption_result.get('records')[0].get('humidity') if 'battery' in consumption_result.get('records')[0]: sensor.battery = consumption_result.get('records')[0].get('battery') def update_trends_and_appliance_states(self, ): if self.local_polling and is_smappee_genius(serialnumber=self._device_serial_number): pass elif self.local_polling: # Active power tp = self.smappee_api.active_power() if tp is not None: self._realtime_values['total_power'] = tp # Solar power if self.has_solar_production: sp = self.smappee_api.active_power(solar=True) if sp is not None: self._realtime_values['solar_power'] = sp else: # update trend consumptions self.update_active_consumptions(trend='today') self.update_active_consumptions(trend='current_hour') self.update_active_consumptions(trend='last_5_minutes') self.update_todays_sensor_consumptions() self.update_todays_actuator_consumptions() # update appliance states for appliance_id, _ in self.appliances.items(): self.update_appliance_state(id=appliance_id) smappee-pysmappee-1781b4e/pysmappee/smappee.py000066400000000000000000000042071415364771600215320ustar00rootroot00000000000000from .servicelocation import SmappeeServiceLocation class Smappee: def __init__(self, api, serialnumber=None): """ :param api: :param serialNumber: """ # shared api instance self.smappee_api = api # serialnumber (LOCAL env only) self._serialnumber = serialnumber self._local_polling = serialnumber is not None # service locations accessible from user self._service_locations = {} def load_service_locations(self, refresh=False): locations = self.smappee_api.get_service_locations() for service_location in locations['serviceLocations']: if service_location.get('serviceLocationId') in self._service_locations: # refresh the configuration sl = self.service_locations.get(service_location.get('serviceLocationId')) sl.load_configuration(refresh=refresh) elif 'deviceSerialNumber' in service_location: # Create service location object if the serialnumber is known sl = SmappeeServiceLocation(service_location_id=service_location.get('serviceLocationId'), device_serial_number=service_location.get('deviceSerialNumber'), smappee_api=self.smappee_api) # Add sl object self.service_locations[service_location.get('serviceLocationId')] = sl def load_local_service_location(self): # Create service location object sl = SmappeeServiceLocation(device_serial_number=self._serialnumber, smappee_api=self.smappee_api, local_polling=self._local_polling) # Add sl object self.service_locations[sl.service_location_id] = sl @property def local_polling(self): return self._local_polling @property def service_locations(self): return self._service_locations def update_trends_and_appliance_states(self): for _, sl in self.service_locations.items(): sl.update_trends_and_appliance_states() smappee-pysmappee-1781b4e/setup.py000066400000000000000000000015761415364771600172430ustar00rootroot00000000000000import setuptools with open("README.md", "r") as fh: long_description = fh.read() setuptools.setup( name="pysmappee", version="0.2.29", author="Smappee", author_email="support@smappee.com", description="Offical Smappee dev API and MQTT python wrapper", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/smappee/pysmappee", packages=setuptools.find_packages(exclude=['test']), license='MIT', classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], python_requires='>=3.7', install_requires=[ "cachetools>=4.0.0", "paho-mqtt>=1.5.0", "pytz>=2019.3", "requests>=2.23.0", "requests-oauthlib>=1.3.0", "schedule>=1.1.0", ], ) smappee-pysmappee-1781b4e/test/000077500000000000000000000000001415364771600164775ustar00rootroot00000000000000smappee-pysmappee-1781b4e/test/__init__.py000066400000000000000000000000001415364771600205760ustar00rootroot00000000000000