├── requirements.txt ├── docker-compose.yml ├── README.md ├── custom_components └── mojelektro │ ├── manifest.json │ ├── __init__.py │ ├── README.md │ ├── sensor.py │ └── moj_elektro_api.py ├── Dockerfile ├── .dockerignore └── .vscode ├── tasks.json └── launch.json /requirements.txt: -------------------------------------------------------------------------------- 1 | # To ensure app dependencies are ported from your virtual environment/host machine into your container, run 'pip freeze > requirements.txt' in the terminal to overwrite this file 2 | requests==2.25.1 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | mojelektro: 5 | image: mojelektro 6 | volumes: 7 | - "./custom_components/mojelektro/:/app/" 8 | build: 9 | context: . 10 | dockerfile: ./Dockerfile 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Integration of Moj Elektro electricity meter 2 | 3 | This is custom component for integrating electric utility meter data into Home Assistant. 4 | 5 | For instalation and more details see [README](/custom_components/mojelektro/README.md) 6 | -------------------------------------------------------------------------------- /custom_components/mojelektro/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "mojelektro", 3 | "name": "Mojelektro Load Platform", 4 | "documentation": "https://github.com/saso5/homeassistant-mojelektro", 5 | "dependencies": [], 6 | "codeowners": [], 7 | "requirements": ["requests==2.25.1"] 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-buster 2 | 3 | # Keeps Python from generating .pyc files in the container 4 | ENV PYTHONDONTWRITEBYTECODE=1 5 | 6 | # Turns off buffering for easier container logging 7 | ENV PYTHONUNBUFFERED=1 8 | 9 | COPY ./custom_components/mojelektro /app 10 | COPY ./requirements.txt /app 11 | 12 | WORKDIR /app 13 | RUN pip install -r requirements.txt 14 | 15 | CMD tail -f /dev/null 16 | 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.classpath 3 | **/.dockerignore 4 | **/.env 5 | **/.git 6 | **/.gitignore 7 | **/.project 8 | **/.settings 9 | **/.toolstarget 10 | **/.vs 11 | **/.vscode 12 | **/*.*proj.user 13 | **/*.dbmdl 14 | **/*.jfm 15 | **/azds.yaml 16 | **/bin 17 | **/charts 18 | **/docker-compose* 19 | **/compose* 20 | **/Dockerfile* 21 | **/node_modules 22 | **/npm-debug.log 23 | **/obj 24 | **/secrets.dev.yaml 25 | **/values.dev.yaml 26 | README.md 27 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "docker-build", 6 | "label": "docker-build", 7 | "platform": "python", 8 | "dockerBuild": { 9 | "tag": "mojelektro:latest", 10 | "dockerfile": "${workspaceFolder}/Dockerfile", 11 | "context": "${workspaceFolder}", 12 | "pull": true 13 | } 14 | }, 15 | { 16 | "type": "docker-run", 17 | "label": "docker-run: debug", 18 | "dependsOn": [ 19 | "docker-build" 20 | ] 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Python: Remote Attach", 5 | "type": "python", 6 | "request": "attach", 7 | "connect": { 8 | "host": "localhost", 9 | "port": 5678 10 | }, 11 | "pathMappings": [ 12 | { 13 | "localRoot": "${workspaceFolder}/custom_components/mojelektro", 14 | "remoteRoot": "/app" 15 | } 16 | ] 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /custom_components/mojelektro/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import voluptuous as vol 3 | from datetime import timedelta 4 | 5 | import homeassistant.helpers.config_validation as cv 6 | from homeassistant.helpers.event import track_time_interval 7 | 8 | from .moj_elektro_api import MojElektroApi 9 | 10 | """Example Load Platform integration.""" 11 | DOMAIN = 'mojelektro' 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | SCAN_INTERVAL = timedelta(minutes=15) 15 | 16 | CONF_USERNAME = 'username' 17 | CONF_PASSWORD = 'password' 18 | CONF_METER_ID = 'meter_id' 19 | 20 | ACCOUNT_SCHEMA = vol.Schema( 21 | { 22 | vol.Required(CONF_USERNAME): cv.string, 23 | vol.Required(CONF_PASSWORD): cv.string, 24 | vol.Required(CONF_METER_ID): cv.string 25 | } 26 | ) 27 | 28 | CONFIG_SCHEMA = vol.Schema({DOMAIN: ACCOUNT_SCHEMA}, extra=vol.ALLOW_EXTRA) 29 | 30 | def setup(hass, config): 31 | """Your controller/hub specific code.""" 32 | # Data that you want to share with your platforms 33 | hass.data[DOMAIN] = { } 34 | 35 | conf = config.get(DOMAIN) 36 | 37 | api = MojElektroApi(conf.get(CONF_USERNAME), conf.get(CONF_PASSWORD), conf.get(CONF_METER_ID)) 38 | 39 | hass.helpers.discovery.load_platform('sensor', DOMAIN, conf, config) 40 | 41 | def refresh(event_time): 42 | """Refresh""" 43 | _LOGGER.debug("Refreshing...") 44 | hass.data[DOMAIN] = api.getData() 45 | 46 | track_time_interval(hass, refresh, SCAN_INTERVAL) 47 | 48 | return True 49 | -------------------------------------------------------------------------------- /custom_components/mojelektro/README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Integration of Moj Elektro electricity meter 2 | 3 | This is custom component for integrating electric utility meter data into Home Assistant. 4 | As mojelektro.si requires 2 factor authentication, this solution uses key-cert login. 5 | Unfortunately self signed certs don't works as it needs to be signed by one of the following CA 6 | 7 | ``` 8 | Acceptable client certificate CA names 9 | C = SI, O = ACNLB 10 | C = SI, O = POSTA, OU = POSTArCA 11 | C = SI, O = Halcom, CN = Halcom CA FO 12 | C = SI, O = Halcom, CN = Halcom CA PO 2 13 | C = SI, O = Halcom d.d., CN = Halcom CA PO 3 14 | C = SI, O = OSI d.o.o., CN = OSI.SI Root CA 1 15 | C = si, O = state-institutions, OU = sigen-ca 16 | C = si, O = state-institutions, OU = sigov-ca 17 | C = SI, O = OSI d.o.o., CN = OSI.SI Private CA 1 18 | C = SI, O = NLB d.d., organizationIdentifier = VATSI-91132550, CN = ACNLB SubCA 19 | C = SI, O = NLB d.d., organizationIdentifier = VATSI-91132550, CN = ACNLB RootCA 20 | C = SI, O = Republika Slovenija, organizationIdentifier = VATSI-17659957, CN = SIGOV-CA 21 | C = SI, O = Republika Slovenija, organizationIdentifier = VATSI-17659957, CN = SIGEN-CA G2 22 | C = SI, O = Republika Slovenija, organizationIdentifier = VATSI-17659957, CN = SI-TRUST Root 23 | C = SI, O = POSTA SLOVENIJE d.o.o., organizationIdentifier = VATSI-25028022, CN = POSTArCA G2 24 | C = SI, O = POSTA SLOVENIJE d.o.o., organizationIdentifier = VATSI-25028022, CN = POSTArCA Root 25 | C = SI, O = Halcom d.d., organizationIdentifier = VATSI-43353126, CN = Halcom CA FO e-signature 1 26 | C = SI, O = Halcom d.d., organizationIdentifier = VATSI-43353126, CN = Halcom CA PO e-signature 1 27 | C = SI, O = Halcom d.d., organizationIdentifier = VATSI-43353126, CN = Halcom Root Certificate Authority 28 | ``` 29 | 30 | Also as the data is always reported for the previous day, and I couldn't find a way to tell HA to log sensor data to the past, data will always be one day off. 31 | 32 | (The code is based on https://github.com/home-assistant/example-custom-config/tree/master/custom_components/example_load_platform) 33 | 34 | ### Installation 35 | 36 | 1. Copy this folder to `/custom_components/mojelektro/`. 37 | 38 | 2. Copy your certificate key and cert to the folder location from 1. 39 | You have to name them `crt.pem` and `key.pem`. If you only have the .p12 file you can use the following command. 40 | ```shell script 41 | openssl pkcs12 -in path.p12 -out crt.pem -clcerts -nokeys 42 | openssl pkcs12 -in path.p12 -out key.pem -nocerts -nodes 43 | ``` 44 | 45 | 3. Add the following entry in your `configuration.yaml`: 46 | ```yaml 47 | mojelektro: 48 | username: 49 | password: 50 | meter_id: 51 | ``` 52 | -------------------------------------------------------------------------------- /custom_components/mojelektro/sensor.py: -------------------------------------------------------------------------------- 1 | """Platform for sensor integration.""" 2 | from homeassistant.const import DEVICE_CLASS_ENERGY 3 | from homeassistant.const import ENERGY_KILO_WATT_HOUR 4 | from homeassistant.helpers.entity import Entity, generate_entity_id 5 | from homeassistant.components.sensor import ENTITY_ID_FORMAT 6 | 7 | from . import DOMAIN, CONF_METER_ID 8 | 9 | from random import randint 10 | import logging 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | def setup_platform(hass, config, add_entities, discovery_info=None): 16 | """Set up the sensor platform.""" 17 | # We only want this platform to be set up via discovery. 18 | if discovery_info is None: 19 | return 20 | 21 | meter_id = discovery_info[CONF_METER_ID] 22 | 23 | add_entities([Mojelektro("meter_input", hass, meter_id)]) 24 | add_entities([Mojelektro("meter_input_peak", hass, meter_id)]) 25 | add_entities([Mojelektro("meter_input_offpeak", hass, meter_id)]) 26 | add_entities([Mojelektro("meter_output", hass, meter_id)]) 27 | add_entities([Mojelektro("meter_output_peak", hass, meter_id)]) 28 | add_entities([Mojelektro("meter_output_offpeak", hass, meter_id)]) 29 | 30 | add_entities([Mojelektro("daily_input", hass, meter_id)]) 31 | add_entities([Mojelektro("daily_input_peak", hass, meter_id)]) 32 | add_entities([Mojelektro("daily_input_offpeak", hass, meter_id)]) 33 | add_entities([Mojelektro("daily_output", hass, meter_id)]) 34 | add_entities([Mojelektro("daily_output_peak", hass, meter_id)]) 35 | add_entities([Mojelektro("daily_output_offpeak", hass, meter_id)]) 36 | 37 | add_entities([Mojelektro("15min_output", hass, meter_id)]) 38 | add_entities([Mojelektro("15min_input", hass, meter_id)]) 39 | 40 | 41 | class Mojelektro(Entity): 42 | """Representation of a sensor.""" 43 | 44 | type = None 45 | 46 | def __init__(self, type, hass, meter_id): 47 | """Initialize the sensor.""" 48 | super().__init__() 49 | 50 | self._state = None 51 | self.type = type 52 | self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, DOMAIN + "_" + type, hass=hass) 53 | self._unique_id = "{}-{}".format(meter_id, self.entity_id) 54 | 55 | @property 56 | def unique_id(self): 57 | """Return a unique ID.""" 58 | return self._unique_id 59 | 60 | @property 61 | def name(self): 62 | """Return the name of the sensor.""" 63 | return "MojElektro " + self.type 64 | 65 | @property 66 | def state(self): 67 | """Return the state of the sensor.""" 68 | return self._state 69 | 70 | @property 71 | def unit_of_measurement(self): 72 | """Return the unit of measurement.""" 73 | return ENERGY_KILO_WATT_HOUR 74 | 75 | @property 76 | def device_class(self): 77 | """Return the device class.""" 78 | return DEVICE_CLASS_ENERGY 79 | 80 | def update(self): 81 | """Fetch new state data for the sensor. 82 | 83 | This is the only method that should fetch new data for Home Assistant. 84 | """ 85 | self._state = self.hass.data[DOMAIN].get(self.type) 86 | -------------------------------------------------------------------------------- /custom_components/mojelektro/moj_elektro_api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import logging 3 | from datetime import datetime, timedelta 4 | import os 5 | import re 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | DIR = os.path.dirname(os.path.realpath(__file__)) 9 | 10 | class MojElektroApi: 11 | 12 | username = None 13 | password = None 14 | meter_id = None 15 | 16 | session = requests.Session() 17 | csrf = None 18 | token = None 19 | 20 | cache = None 21 | cacheDate = None 22 | 23 | def __init__(self, username, password, meter_id): 24 | self.username = username 25 | self.password = password 26 | self.meter_id = meter_id 27 | 28 | def updateOauthToken(self): 29 | if self.isTokenValid(): 30 | _LOGGER.debug("Token is valid") 31 | return 32 | 33 | try: 34 | self.initRekonoSession() 35 | self.rekonoLogin() 36 | self.confirmWithCert() 37 | except Exception as err_msg: 38 | _LOGGER.error("Error! %s", err_msg) 39 | raise 40 | 41 | def initRekonoSession(self): 42 | response = self.session.get("https://idp.rekono.si/openid-connect-server-webapp/authorize?response_type=code&client_id=SEDMPWEB&state=&redirect_uri=https%3A%2F%2Fmojelektro.si&scope=address%20phone%20openid%20profile%20email%20http%3A%2F%2Fidp.rekono.si%2Fopenid%2Ftaxnumber") 43 | 44 | assert response.status_code == 200 45 | 46 | _LOGGER.debug("JSession: " + self.session.cookies.get('JSESSIONID', path='/IdP-RM-Front')) 47 | _LOGGER.debug("Init redirect url: " + response.url) 48 | 49 | csrfSearch = re.search(r'_csrf.*value=\"([a-z0-9\-]*)\"', response.text) 50 | 51 | self.csrf = csrfSearch.group(1) 52 | 53 | def rekonoLogin(self): 54 | payload = {"_csrf": self.csrf, "doaction": "doaction", "username": self.username, "password": self.password} 55 | 56 | response = self.session.post('https://idp.rekono.si/IdP-RM-Front/chooselogin/rekono.htm', data=payload) 57 | 58 | assert response.status_code == 200 59 | 60 | def confirmWithCert(self): 61 | payload = {"_csrf": self.csrf, "mode": "certlogin"} 62 | response = self.session.post('https://idp.rekono.si/IdP-RM-Front/chooselogin/options.htm', 63 | allow_redirects=False, 64 | data=payload 65 | ) 66 | assert response.status_code == 302 67 | 68 | 69 | certRes = requests.Session().get('https://idp.rekono.si/IdP-RM-Front/certlogin.htm', 70 | cert=(DIR + '/crt.pem', DIR + '/key.pem'), 71 | cookies = {'JSESSIONID': self.session.cookies.get('JSESSIONID', path='/IdP-RM-Front')} 72 | ) 73 | 74 | assert certRes.status_code == 200 75 | token = re.search(r'token.*value=\"([a-z0-9\-]*)\"', certRes.text).group(1) 76 | 77 | 78 | response = self.session.get('https://idp.rekono.si/openid-connect-server-webapp/callback?token=' + token) 79 | 80 | assert response.status_code == 200 81 | 82 | #https://mojelektro.si?code=PKaKA6uPOpEU7Xryrx8255sGP83Hv3fG&state= 83 | code = re.search(r'code\=(.*?)&', response.url).group(1) 84 | 85 | 86 | payload = { 87 | "grant_type":"authorization_code", 88 | "code": code, 89 | "redirect_uri": "https://mojelektro.si", 90 | "client_id": "SEDMPWEB", 91 | "client_secret": "deacJn54-nQsjmfTvQ5As5odBs51docg8NUc6KxL4iSDhoOMIqv25BhP3xM8vlLidjfKr6bsTj9j12M3dJ2wuw" 92 | } 93 | 94 | response = self.session.post('https://idp.rekono.si/openid-connect-server-webapp/token', data=payload) 95 | assert response.status_code == 200 96 | 97 | self.token = response.json()['access_token'] 98 | _LOGGER.debug("Generted token: " + self.token) 99 | 100 | 101 | def get15MinIntervalData(self): 102 | self.updateOauthToken() 103 | 104 | dateFrom = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%dT23:59:00") 105 | dateTo = datetime.now().strftime("%Y-%m-%dT00:00:00") 106 | 107 | _LOGGER.debug("15min interval request range: " + dateFrom + " - " + dateTo) 108 | 109 | r=requests.get(f'https://api.mojelektro.si/NmcApiStoritve/nmc/v1/merilnamesta/{self.meter_id}/odbirki/15min', 110 | headers={"authorization": ("Bearer " + self.token)}, 111 | params={"datumCasOd": dateFrom, "datumCasDo": dateTo, "flat": "true"} 112 | ) 113 | assert r.json()['success'] == True 114 | 115 | # [{'datum': '2021-02-24T09:30:00+01:00', 'A+': 0, 'A-': 0.825},... ] 116 | 117 | return r.json()['data'] 118 | 119 | def getMeterData(self): 120 | self.updateOauthToken() 121 | 122 | dateFrom = (datetime.now()).strftime("%Y-%m-%dT00:00:00") 123 | dateTo = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00") 124 | 125 | _LOGGER.debug("Meter state request range: " + dateFrom + " - " + dateTo) 126 | 127 | r=requests.get(f'https://api.mojelektro.si/NmcApiStoritve/nmc/v1/merilnamesta/{self.meter_id}/odbirki/dnevnaStanja', 128 | headers={"authorization": ("Bearer " + self.token)}, 129 | params={"datumCasOd": dateFrom, "datumCasDo": dateTo, "flat": "true"} 130 | ) 131 | assert r.json()['success'] == True 132 | assert len(r.json()['data']) > 0 133 | 134 | # [{ "datum": "2021-02-28T00:00:00+01:00", 135 | # "PREJETA DELOVNA ENERGIJA ET": 2562, "PREJETA DELOVNA ENERGIJA VT": 1072, "PREJETA DELOVNA ENERGIJA MT": 1490, 136 | # "ODDANA DELOVNA ENERGIJA ET": 588, "ODDANA DELOVNA ENERGIJA VT": 410, "ODDANA DELOVNA ENERGIJA MT": 178 },... ] 137 | 138 | return r.json()['data'] 139 | 140 | 141 | def getDailyData(self): 142 | self.updateOauthToken() 143 | 144 | dateFrom = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%dT00:00:00") 145 | dateTo = datetime.now().strftime("%Y-%m-%dT00:00:00") 146 | 147 | _LOGGER.debug("Daily state request range: " + dateFrom + " - " + dateTo) 148 | 149 | r=requests.get(f'https://api.mojelektro.si/NmcApiStoritve/nmc/v1/merilnamesta/{self.meter_id}/odbirki/dnevnaPoraba', 150 | headers={"authorization": ("Bearer " + self.token)}, 151 | params={"datumCasOd": dateFrom, "datumCasDo": dateTo, "flat": "true"} 152 | ) 153 | 154 | assert r.json()['success'] == True 155 | 156 | # [{"datum":"2021-02-26T00:00:00+01:00", 157 | # "PREJETA DELOVNA ENERGIJA ET":14.94,"PREJETA DELOVNA ENERGIJA VT":8.47,"PREJETA DELOVNA ENERGIJA MT":6.47, 158 | # "ODDANA DELOVNA ENERGIJA ET":28.56,"ODDANA DELOVNA ENERGIJA VT":28.56,"ODDANA DELOVNA ENERGIJA MT":0.00}, ...] 159 | 160 | return r.json()['data'] 161 | 162 | 163 | def getData(self): 164 | cache = self.getCache() 165 | 166 | dMeter = cache.get("meter")[0] 167 | dDaily = cache.get("daily")[0] 168 | d15 = cache.get("15")[self.get15MinOffset()] 169 | 170 | return { 171 | "15min_input": d15['A+'], 172 | "15min_output": d15['A-'], 173 | 174 | "meter_input": dMeter['PREJETA DELOVNA ENERGIJA ET'], 175 | "meter_input_peak": dMeter['PREJETA DELOVNA ENERGIJA VT'], 176 | "meter_input_offpeak": dMeter['PREJETA DELOVNA ENERGIJA MT'], 177 | "meter_output": dMeter['ODDANA DELOVNA ENERGIJA ET'], 178 | "meter_output_peak": dMeter['ODDANA DELOVNA ENERGIJA VT'], 179 | "meter_output_offpeak": dMeter['ODDANA DELOVNA ENERGIJA MT'], 180 | 181 | "daily_input": dDaily['PREJETA DELOVNA ENERGIJA ET'], 182 | "daily_input_peak": dDaily['PREJETA DELOVNA ENERGIJA VT'], 183 | "daily_input_offpeak": dDaily['PREJETA DELOVNA ENERGIJA MT'], 184 | "daily_output": dDaily['ODDANA DELOVNA ENERGIJA ET'], 185 | "daily_output_peak": dDaily['ODDANA DELOVNA ENERGIJA VT'], 186 | "daily_output_offpeak": dDaily['ODDANA DELOVNA ENERGIJA MT'] 187 | } 188 | 189 | def getCache(self): 190 | _LOGGER.debug("Rerfresing cache") 191 | 192 | if self.cache is None or self.cacheDate != datetime.today().date(): 193 | self.cache = { 194 | "meter": self.getMeterData(), 195 | "daily": self.getDailyData(), 196 | "15" : self.get15MinIntervalData() 197 | } 198 | self.cacheDate = datetime.today().date() 199 | 200 | return self.cache 201 | 202 | def get15MinOffset(self): 203 | now = datetime.now() 204 | 205 | return int((now.hour * 60 + now.minute)/15) 206 | 207 | 208 | def isTokenValid(self): 209 | if self.token is None: 210 | return False 211 | 212 | #TODO: validate JWT token 213 | r = requests.get("https://api.mojelektro.si/NmcApiStoritve/nmc/v1/user/info", 214 | headers={"authorization":"Bearer " + self.token}) 215 | 216 | _LOGGER.debug(f'Validation response {r.status_code}') 217 | 218 | return r.status_code != 401 219 | 220 | --------------------------------------------------------------------------------