From 89c07ae6070fc4bfc8b6f1ba717be2dbc412e93f Mon Sep 17 00:00:00 2001 From: Jayesh Date: Mon, 25 May 2020 16:49:47 -0500 Subject: [PATCH] Added very first version --- .gitignore | 114 +++++++++++++++++++++++++++++++++++++ README.md | 7 +++ config-example.yaml | 21 +++++++ docker-compose.yaml | 12 ++++ dockerfile | 8 +++ helper_common.py | 92 ++++++++++++++++++++++++++++++ helper_ha.py | 78 ++++++++++++++++++++++++++ main.py | 132 +++++++++++++++++++++++++++++++++++++++++++ smt_reader.py | 133 ++++++++++++++++++++++++++++++++++++++++++++ version | 1 + 10 files changed, 598 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config-example.yaml create mode 100644 docker-compose.yaml create mode 100644 dockerfile create mode 100644 helper_common.py create mode 100644 helper_ha.py create mode 100644 main.py create mode 100644 smt_reader.py create mode 100644 version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62ea937 --- /dev/null +++ b/.gitignore @@ -0,0 +1,114 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# Ignore for PyCharm +.idea + +# Ignore for erxsyslog +logs/ + +# Ignore config.yaml +config.yaml +settings.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..75b6311 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# pysmtreader + +API for https://www.smartmetertexas.com + +# Notes: +1. Make sure to rename config-example.yaml to config.yaml and change "_REPLACE_" text to your values +2. Big thanks to https://github.com/keatontaylor/smartmetertexas-api for providing API documentation \ No newline at end of file diff --git a/config-example.yaml b/config-example.yaml new file mode 100644 index 0000000..ca72f33 --- /dev/null +++ b/config-example.yaml @@ -0,0 +1,21 @@ +logs: + level: debug # debug, info(default), warning, error, critical + log_file_name: pysmt # without extension, log extension will be added automatically + +health_check: + log_info_line_at: 30 # in minutes, 0: disable + +smartmetertexas: # smartmetertexas.com + base_url: https://smartmetertexas.com/api + username: _REPLACE_ # your user name for smartmetertexas.com + password: _REPLACE_ # your password for smartmetertexas.com + esiid: _REPLACE_ # your ESSID for smartmetertexas.com + meter_number: _REPLACE_ # your Meter Number for smartmetertexas.com + poll_interval_minutes: 60 # 0: disable, do set below 30 as smartmetertexas.com will not allow readming more than twice in an hour + wait_interval_before_ondemand_read_minutes: 5 + force_first_read: False # if true it will attempt to read Smart Meter Texas, otherwise at poll_interval + +home_assistant: # Home Assistant access details + base_url: _REPLACE_ # your Home Assistant URL/IP, no slash (/) at the end for example: http://192.168.1.149:8123 + access_token: _REPLACE_ # your Home Assistant access token + ha_entity: sensor.smt_reading # home assistnat entity name to be created diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..5df836b --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,12 @@ +version: "3" +services: + pysmtreader: + build: + context: . + dockerfile: ./dockerfile + image: pysmtreader:0.1 + container_name: pysmtreader + volumes: + - ./:/config + - /etc/localtime:/etc/localtime:ro + restart: unless-stopped diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..ee269ba --- /dev/null +++ b/dockerfile @@ -0,0 +1,8 @@ +FROM python:3.8 + +RUN pip3 install requests PyYAML + +COPY ./*.py ./*.yaml /bin/ +WORKDIR /bin + +CMD ["python", "-u", "./main.py"] diff --git a/helper_common.py b/helper_common.py new file mode 100644 index 0000000..6dcf621 --- /dev/null +++ b/helper_common.py @@ -0,0 +1,92 @@ +import datetime +import os +import traceback +import yaml + + +class CommonHelper: + log_level_debug = 1 + log_level_info = 2 + log_level_warning = 3 + log_level_error = 4 + log_level_critical = 5 + + def __init__(self, config_folder): + self.config_folder = config_folder + self.config = yaml.load(open(self.config_folder + 'config.yaml')) + + self.log_level = 2 + if self.config["logs"]["level"] == "debug": + self.log_level = 1 + elif self.config["logs"]["level"] == "info": + self.log_level = 2 + elif self.config["logs"]["level"] == "warning": + self.log_level = 3 + elif self.config["logs"]["level"] == "error": + self.log_level = 4 + elif self.config["logs"]["level"] == "critical": + self.log_level = 5 + + self.log_folder = self.config_folder + "logs/" + self.log_file_name = self.log_folder + \ + self.config["logs"]["log_file_name"] + + if not os.path.exists(self.log_folder): + os.makedirs(self.config_folder + "logs") + self.log_info( + "CommonHelper:__init__(): Creating log folder: " + self.log_folder) + + def get_seconds_till_next_minute(self): + cur_time = datetime.datetime.now() + next_minute = cur_time + \ + datetime.timedelta(seconds=59 - cur_time.second, + microseconds=999999 - cur_time.microsecond) + diff = next_minute - cur_time + diff_ms = (diff.seconds * 1000000 + diff.microseconds) / 1000000.0 + return diff_ms + + def log_debug(self, str_print): + self._log(self.log_level_debug, str_print) + + def log_info(self, str_print): + self._log(self.log_level_info, str_print) + + def log_warning(self, str_print): + self._log(self.log_level_warning, str_print) + + def log_error(self, str_print): + self._log(self.log_level_error, str_print) + + def log_critical(self, str_print): + self._log(self.log_level_critical, str_print) + + def log_data(self, str_print): + self._log(self.log_level_critical, str_print) + + def _log(self, log_level, str_print): + if log_level >= self.log_level: + try: + log_file_name = self.log_file_name + "-" + \ + datetime.datetime.now().strftime('%Y-%m-%d') + ".log" + log_str = datetime.datetime.now().strftime('%H:%M:%S.%f')[ + :-3] + self._get_log_level_to_string(log_level) + str_print + print(log_str) + with open(log_file_name, "a") as log_file: + log_file.write(log_str + "\n") + except Exception as e: + print(datetime.datetime.now().strftime('%H:%M:%S.%f')[ + :-3] + " : CommonHelper:log():Exception: " + str(e)) + + def _get_log_level_to_string(self, log_level): + log_level_str = ": " + if log_level == self.log_level_debug: + log_level_str = ":debug: " + elif log_level == self.log_level_info: + log_level_str = ":info: " + elif log_level == self.log_level_warning: + log_level_str = ":warn: " + elif log_level == self.log_level_error: + log_level_str = ":error: " + elif log_level == self.log_level_critical: + log_level_str = ":critical: " + return log_level_str diff --git a/helper_ha.py b/helper_ha.py new file mode 100644 index 0000000..d197a46 --- /dev/null +++ b/helper_ha.py @@ -0,0 +1,78 @@ +import json +from requests import post, get +import yaml +import traceback + + +class CustomHAHelper: + def __init__(self, config_folder): + try: + self.config = yaml.load(open(config_folder + 'config.yaml')) + self.base_url = self.config["home_assistant"]["base_url"] + self.access_token = self.config["home_assistant"]["access_token"] + except Exception as e: + exception_info = "\nException: {}\n Call Stack: {}".format( + str(e), str(traceback.format_exc())) + print("CustomHAHelper:__init__():Exception: " + exception_info) + + def ha_get_entity_state(self, entity_name): + state = None + response = self.ha_get_sensor(entity_name) + if response: + state = response.json()['state'] + return state + + def ha_get_entity_attribute(self, entity_name, attribute_name): + attribute_value = None + response = self.ha_get_sensor(entity_name) + if response: + attribute_value = response.json()['attributes'][attribute_name] + return attribute_value + + def ha_set_entity_state(self, entity_name, state_str=None, attributes=None, payload=None): + if payload == None: + payload = {"state": state_str} + if attributes: + payload['attributes'] = attributes + return self.ha_update_sensor(entity_name, payload) + + def ha_get_sensor(self, entity_name): + get_url = "{}/api/states/{}".format(self.base_url, entity_name) + headers = { + 'Authorization': "Bearer " + self.access_token + } + response = get(get_url, headers=headers) + return response + + def ha_update_sensor(self, entity_name, payload): + post_url = "{}/api/states/{}".format(self.base_url, entity_name) + headers = { + 'Authorization': "Bearer " + self.access_token + } + response = post(post_url, data=json.dumps(payload), headers=headers) + return response + + def ha_service_notify(self, message, whom): + post_url = "{}/api/services/notify/{}".format(self.base_url, whom) + headers = { + 'Authorization': "Bearer " + self.access_token + } + payload = { + "message": message + } + response = post(post_url, data=json.dumps(payload), headers=headers) + return response + + def ha_service_update_device_tracker(self, mac_address=None, status_str=None, payload=None): + post_url = "{}/api/services/device_tracker/see".format(self.base_url) + headers = { + 'Authorization': "Bearer " + self.access_token + } + if payload == None: + payload = { + "mac": mac_address, + "location_name": status_str, + "attributes": {"source_type": "script"} + } + response = post(post_url, data=json.dumps(payload), headers=headers) + return response diff --git a/main.py b/main.py new file mode 100644 index 0000000..308bd1c --- /dev/null +++ b/main.py @@ -0,0 +1,132 @@ +import time +import datetime +import json +import traceback +from smt_reader import SMTReader +from helper_common import CommonHelper +from helper_ha import CustomHAHelper + + +class MeterReadHelper: + + def __init__(self, host_mapped_folder): + self.__commonHelper = CommonHelper(host_mapped_folder) + self.__customHAHelper = CustomHAHelper(host_mapped_folder) + + self.__log_info_line_at = self.__commonHelper.config["health_check"]["log_info_line_at"] + self.__smt_poll_interval = int( + self.__commonHelper.config["smartmetertexas"]["poll_interval_minutes"]) + self.__smt_force_first_read = self.__commonHelper.config[ + "smartmetertexas"]["force_first_read"] + self.__smtreader = SMTReader(self.__commonHelper) + self.__commonHelper.log_info("SMTReader meter needs to be polled every {} minutes with force first is {}".format( + self.__smt_poll_interval, self.__smt_force_first_read)) + self.__ha_entity = self.__commonHelper.config["home_assistant"]["ha_entity"] + + def validate_config(self): + config_status = 0 + value = self.__commonHelper.config["smartmetertexas"]["username"] + if value == "_REPLACE_" or value == "": + config_status = 1 + self.__commonHelper.log_error( + "Config error: please make sure smartmetertexas username {} is valid".format(value)) + value = self.__commonHelper.config["smartmetertexas"]["password"] + if value == "_REPLACE_" or value == "": + config_status = 1 + self.__commonHelper.log_error( + "Config error: please make sure smartmetertexas password {} is valid".format(value)) + value = self.__commonHelper.config["smartmetertexas"]["esiid"] + if value == "_REPLACE_" or value == "": + config_status = 1 + self.__commonHelper.log_error( + "Config error: please make sure smartmetertexas esiid {} is valid".format(value)) + value = self.__commonHelper.config["smartmetertexas"]["meter_number"] + if value == "_REPLACE_" or value == "": + config_status = 1 + self.__commonHelper.log_error( + "Config error: please make sure smartmetertexas meter_number {} is valid".format(value)) + value = self.__commonHelper.config["home_assistant"]["base_url"] + if value == "_REPLACE_" or value == "": + config_status = 1 + self.__commonHelper.log_error( + "Config error: please make sure home_assistant base_url {} is valid".format(value)) + value = self.__commonHelper.config["home_assistant"]["access_token"] + if value == "_REPLACE_" or value == "": + config_status = 1 + self.__commonHelper.log_error( + "Config error: please make sure home_assistant access_token {} is valid".format(value)) + return config_status + + def start(self): + if self.validate_config() == 0: + if self.__smt_force_first_read: + self.__read_smt_meter() + + self.__commonHelper.log_info( + "Going to sleep until exact minute starts") + time.sleep(self.__commonHelper.get_seconds_till_next_minute()) + + self.__commonHelper.log_info( + "Now doing regular polling from config file interval") + while True: + cur_time = datetime.datetime.now() + minutes_since_day_start = cur_time.hour * 60 + cur_time.minute + + if self.__log_info_line_at != 0 and minutes_since_day_start % self.__log_info_line_at == 0: + self.__commonHelper.log_info( + "Health check info line, still active and working!") + + if self.__smt_poll_interval != 0 and minutes_since_day_start % self.__smt_poll_interval == 0: + self.__read_smt_meter() + + time.sleep(self.__commonHelper.get_seconds_till_next_minute()) + + def __read_smt_meter(self): + try: + status_code_read, meter_reading, odrusage = self.__smtreader.read_meter() + if status_code_read == 0: + self.__update_hass(meter_reading, odrusage) + self.__commonHelper.log_info("SMT reading: " + meter_reading) + except Exception as e: + error_msg = "__read_smt_meter(): Exception: {}\n Call Stack: {}".format( + str(e), traceback.format_exc()) + self.__commonHelper.log_critical(error_msg) + + def __update_hass(self, meter_reading, odrusage): + self.__commonHelper.log_debug("Updating homeAssistant") + response = self.__customHAHelper.ha_get_sensor(self.__ha_entity) + if response.text.find("attributes") != -1 and response.text.find("current_state") != -1: + prev_reading = response.json()['attributes']['current_state'] + self.__commonHelper.log_debug( + "Found previous reading on homeAssistant: {}".format(str(prev_reading))) + else: + prev_reading = meter_reading + + attributes = dict() + attributes['unit_of_measurement'] = "KW" + attributes['prev_state'] = prev_reading + attributes['current_state'] = meter_reading + attributes['odrusage'] = odrusage + attributes['last_timestamp'] = str( + datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]) + try: + attributes['difference'] = str( + round(float(meter_reading) - float(prev_reading), 3)) + except Exception as e: + error_msg = "update_hass(): Exception: {}\n Call Stack: {}".format( + str(e), traceback.format_exc()) + self.__commonHelper.log_critical(error_msg) + + payload = {"state": meter_reading, "attributes": attributes} + self.__commonHelper.log_debug( + "Updating homeAssistant: payload: {}".format(str(payload))) + response = self.__customHAHelper.ha_update_sensor( + self.__ha_entity, payload) + self.__commonHelper.log_debug(str(response.text)) + + +# This is mapped volume from docker compose file, under this folder everything will be persisted on host +host_mapped_folder = "/config/" +#host_mapped_folder = "./" +meter_read_helper = MeterReadHelper(host_mapped_folder) +meter_read_helper.start() diff --git a/smt_reader.py b/smt_reader.py new file mode 100644 index 0000000..85af463 --- /dev/null +++ b/smt_reader.py @@ -0,0 +1,133 @@ +import requests +import json +import time +import datetime +import traceback + + +class SMTReader: + + def __init__(self, commonHelper): + self.__commonHelper = commonHelper + + self.__base_url = self.__commonHelper.config["smartmetertexas"]["base_url"] + self.__username = self.__commonHelper.config["smartmetertexas"]["username"] + self.__password = self.__commonHelper.config["smartmetertexas"]["password"] + self.__esiid = self.__commonHelper.config["smartmetertexas"]["esiid"] + self.__meter_number = self.__commonHelper.config["smartmetertexas"]["meter_number"] + self.__wait_interval = self.__commonHelper.config["smartmetertexas"][ + "wait_interval_before_ondemand_read_minutes"] + + def read_meter(self): + status_code_read = -1 + meter_reading = "" + + self.__commonHelper.log_debug("About to read SMT meter") + status_code_auth, auth_token = self.__get_auth_token() + if status_code_auth == 0: + status_code_ondemand = self.__request_ondemand_read(auth_token) + + if status_code_ondemand == "0": + self.__commonHelper.log_debug( + "Starting timer for {} minutes".format(str(self.__wait_interval))) + time.sleep(self.__wait_interval * 60) + status_code_read, meter_reading, odrusage = self.__process_read_request( + auth_token) + + # if an error occured when getting the reading, wait another 5 minutes and then try to get the reading + if status_code_read == 1: + self.__commonHelper.log_error( + "Still pending, starting another timer for {} minutes".format(str(self.__wait_interval))) + time.sleep(self.__wait_interval * 60) + status_code_read, meter_reading, odrusage = self.__process_read_request( + auth_token) + elif status_code_ondemand == "5031": # 5031 represents too many requests in an hour + self.__commonHelper.log_error( + "Looks like too many requests have been sent, can't get the reading this hour") + else: # some other error occured calling the api + self.__commonHelper.log_error( + "There was a problem calling the rest api") + return status_code_read, meter_reading, odrusage + + # Will return 0 if a request was successfully + # Will return -1 if there was an error making a request to the server + def __get_auth_token(self): + self.__commonHelper.log_debug('Getting auth token') + status_code = -1 + auth_token = "" + + session = requests.Session() + payload = {'username': self.__username, + 'password': self.__password, 'rememberMe': True} + response = session.post(self.__base_url + '/user/authenticate', + data=payload, verify=False) + self.__commonHelper.log_debug( + "Authorization request response: {}".format(str(response.text))) + if response.ok: + json_data = json.loads(response.text) + auth_token = json_data['token'] + status_code = 0 + self.__commonHelper.log_info("Authorization successful") + else: + self.__commonHelper.log_error( + "Authorization request failed, response: {}".format(str(response.text))) + return status_code, auth_token + + # Will return 0 if a request was successfully sent and currently pending + # Will return 5031 if the request is not allowed due to the fact it is too soon + # Will return -1 if there was an error making a request to the server + def __request_ondemand_read(self, auth_token): + self.__commonHelper.log_debug('Requesting ondemand reading') + status_code = -1 + + header = {'Authorization': 'Bearer ' + auth_token} + payload = {'ESIID': self.__esiid, 'MeterNumber': self.__meter_number} + + session = requests.Session() + response = session.post(self.__base_url + '/ondemandread', data=payload, + headers=header, verify=False) + self.__commonHelper.log_debug( + "Ondemand request response: {}".format(str(response.text))) + if response.ok: + json_data = json.loads(response.text) + status_code = json_data['data']['statusCode'] + self.__commonHelper.log_info("Ondemand request sent successfully") + else: + self.__commonHelper.log_error( + "Ondemand request send failed, response: {}".format(str(response.text))) + return status_code + + # Will return 0 if a request was successfully sent and updated on home assistant + # Will return 1 if the request is still not ready, will try again in 5 minutes + # Will return -1 if there was an error making a request to the server + def __process_read_request(self, auth_token): + self.__commonHelper.log_debug("Starting read of the processed data") + status_code = -1 + meter_reading = "" + odrusage = "" + + header = {'Authorization': 'Bearer ' + auth_token} + payload = {'ESIID': self.__esiid} + + session = requests.Session() + response = session.post(self.__base_url + '/usage/latestodrread', data=payload, + headers=header, verify=False) + self.__commonHelper.log_debug( + "Read request response: {}".format(str(response.text))) + if response.ok: + json_data = json.loads(response.text) + status_string = json_data['data']['odrstatus'] + + if status_string == 'COMPLETED': + self.__commonHelper.log_info("Read request successful") + meter_reading = json_data['data']['odrread'] + odrusage = json_data['data']['odrusage'] + status_code = 0 + elif status_string == 'PENDING': + self.__commonHelper.log_info("Read request is still pending") + status_code = 1 + else: + self.__commonHelper.log_error( + "Read request failed: {}".format(str(response.text))) + status_code = 1 + return status_code, meter_reading, odrusage diff --git a/version b/version new file mode 100644 index 0000000..ceab6e1 --- /dev/null +++ b/version @@ -0,0 +1 @@ +0.1 \ No newline at end of file