diff --git a/README.md b/README.md index 13c19b3..eec4a58 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,27 @@ If you like to access the data of your Tibber Pulse directly (instead via the de a simple approach to read the data directly from the Tibber Pulse Bridge. There are alternative solutions via an additional MQTT - but why should the data go through such a proxy, if it can be read directly. +## Tibber Invitation link +If you want to join Tibber (become a customer), you might consider using my personal invitation link. When you use this +link, Tibber will we grant you and me a Bonus of 50,-€ for each of us, that then can be used in the Tibber store (not +for your power bill) - e.g. to but a Tibber Bridge. I am fully aware, that when you are here in this repository the +chances are very high, that you are already a Tibber customer and have already a Tibber Pulse. But I was asked by a user +if I could provide my link - so I do: + +[My personal Tibber invitation link](https://invite.tibber.com/6o0kqvzf) + ## Know Issues - No Logo/Icons (Tibber) for the integration (yet) +- The Tibber Pulse Bridge supporting different communication modes (when fetching data from electricity meter). Here + I need your help! Obviously I have one electricity meter here at home. This meter is communicating via a protocol + called SML 1.04 and this is currently the __only__ one that is supported/implemented. + + The Tibber Bridge supporting also the modes: AutoScanMode, IEC-62056.21, Logarex and Impressions (Blinks / kwh) using + ambient or IR sensors. In order to support these other modes I would need sample data from you. If your Tibber Pulse + using one of these communications protocols, please be so kind and create here an issue in github - TIA! + - Sometimes the Pulse deliver a data-package that does not contain valid data (looks like the build in webserver have a response buffer issue?). These invalid packages can't be read with the [python SML-Lib](https://github.com/spacemanspiff2007/SmlLib) and you will find then in the HA-log some `Bytes missing...` or `CRC while parse data...` messages. (when logging on diff --git a/custom_components/tibber_local/__init__.py b/custom_components/tibber_local/__init__.py index 8a73985..df2ef40 100644 --- a/custom_components/tibber_local/__init__.py +++ b/custom_components/tibber_local/__init__.py @@ -1,26 +1,32 @@ import asyncio import logging +import re import voluptuous as vol from datetime import timedelta from smllib import SmlStreamReader from smllib.errors import CrcError +from smllib.sml import SmlListEntry, ObisCode +from smllib.const import UNITS from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ID, CONF_HOST, CONF_SCAN_INTERVAL, CONF_PASSWORD -from homeassistant.core import HomeAssistant, Event +from homeassistant.const import CONF_ID, CONF_HOST, CONF_SCAN_INTERVAL, CONF_PASSWORD, CONF_MODE +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import EntityDescription, Entity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - from .const import ( DOMAIN, MANUFACTURE, DEFAULT_HOST, - DEFAULT_SCAN_INTERVAL + DEFAULT_SCAN_INTERVAL, + ENUM_MODES, + MODE_UNKNOWN, + MODE_3_SML_1_04, + MODE_99_PLAINTEXT, ) _LOGGER = logging.getLogger(__name__) @@ -45,7 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): session = async_get_clientsession(hass) coordinator = TibberLocalDataUpdateCoordinator(hass, session, config_entry) - await coordinator.async_refresh() if not coordinator.last_update_success: @@ -62,10 +67,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): class TibberLocalDataUpdateCoordinator(DataUpdateCoordinator): - def __init__(self, hass: HomeAssistant, session, config_entry, lang=None): + def __init__(self, hass: HomeAssistant, session, config_entry): self._host = config_entry.options.get(CONF_HOST, config_entry.data[CONF_HOST]) the_pwd = config_entry.options.get(CONF_PASSWORD, config_entry.data[CONF_PASSWORD]) - self.bridge = TibberLocalBridge(host=self._host, pwd=the_pwd, websession=session, options=None) + + # the communication_mode is not "adjustable" via the options - it will be only set during the + # initial configuration phase - so we read it from the config_entry.data ONLY! + com_mode = int(config_entry.data.get(CONF_MODE, MODE_3_SML_1_04)) + + self.bridge = TibberLocalBridge(host=self._host, pwd=the_pwd, websession=session, com_mode=com_mode, options=None) self.name = config_entry.title self._config_entry = config_entry super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) @@ -157,53 +167,162 @@ def should_poll(self) -> bool: return False +class IntBasedObisCode: + # This is for sure a VERY STUPID Python class - but I am a NOOB - would be cool, if someone could teach me + # how I could fast convert my number array to the required format... + def __init__(self, obis_src: list): + _a = int(obis_src[1]) + _b = int(obis_src[2]) + _c = int(obis_src[3]) + _d = int(obis_src[4]) + _e = int(obis_src[5]) + _f = int(obis_src[6]) + # self.obis_code = f'{_a}-{_b}:{_c}.{_d}.{_e}*{_f}' + # self.obis_short = f'{_c}.{_d}.{_e}' + self.obis_hex = f'{self.get_as_two_digit_hex(_a)}{self.get_as_two_digit_hex(_b)}{self.get_as_two_digit_hex(_c)}{self.get_as_two_digit_hex(_d)}{self.get_as_two_digit_hex(_e)}{self.get_as_two_digit_hex(_f)}' + + @staticmethod + def get_as_two_digit_hex(input: int) -> str: + out = f'{input:x}' + if len(out) == 1: + return '0' + out + else: + return out; + class TibberLocalBridge: - def __init__(self, host, pwd, websession, options: dict = None): - _LOGGER.info(f"restarting TibberLocalBridge integration... for host: '{host}' with options: {options}") - self.websession = websession - self.url = f"http://admin:{pwd}@{host}/data.json?node_id=1" + # _communication_mode 'MODE_3_SML_1_04' is the initial implemented mode (reading binary sml data)... + # 'all' other modes have to be implemented... also it could be, that the bridge does + # not return a value for param_id=27 + def __init__(self, host, pwd, websession, com_mode: int = MODE_3_SML_1_04, options: dict = None): + if websession is not None: + _LOGGER.info(f"restarting TibberLocalBridge integration... for host: '{host}' with options: {options}") + self.websession = websession + self.url_data = f"http://admin:{pwd}@{host}/data.json?node_id=1" + self.url_mode = f"http://admin:{pwd}@{host}/node_params.json?node_id=1" + self._com_mode = com_mode self._obis_values = {} + async def detect_com_mode(self): + await self.detect_com_mode_from_node_param27() + # if we can't read the mode from the properties (or the mode is not in the ENUM_MODES) + # we want to check, if we can read plaintext?! + if self._com_mode == MODE_UNKNOWN: + await self.read_tibber_local(MODE_99_PLAINTEXT, False) + if len(self._obis_values) > 0: + self._com_mode = MODE_99_PLAINTEXT + + async def detect_com_mode_from_node_param27(self): + # {'param_id': 27, 'name': 'meter_mode', 'size': 1, 'type': 'uint8', 'help': '0:IEC 62056-21, 1:Count impressions', 'value': [3]} + self._com_mode = MODE_UNKNOWN + async with self.websession.get(self.url_mode, ssl=False) as res: + res.raise_for_status() + if res.status == 200: + json_resp = await res.json() + for a_parm_obj in json_resp: + if 'param_id' in a_parm_obj and a_parm_obj['param_id'] == 27 or \ + 'name' in a_parm_obj and a_parm_obj['name'] == 'meter_mode': + if 'value' in a_parm_obj: + self._com_mode = a_parm_obj['value'][0] + # check for known modes in the UI (http://YOUR-IP-HERE/nodes/1/config) + if self._com_mode not in ENUM_MODES: + self._com_mode = MODE_UNKNOWN + break + async def update(self): - await self.read_tibber_local(retry=True) + await self.read_tibber_local(mode=self._com_mode, retry=True) - async def read_tibber_local(self, retry: bool): - async with self.websession.get(self.url, ssl=False) as res: + async def read_tibber_local(self, mode: int, retry: bool): + async with self.websession.get(self.url_data, ssl=False) as res: res.raise_for_status() self._obis_values = {} if res.status == 200: - payload = await res.read() - # for what ever reason the data that can be read from the TibberPulse Webserver is - # not always valid! [I guess there is a issue with an internal buffer in the webserver - # implementation] - in any case the bytes received contain sometimes invalid characters - # so the 'stream.get_frame()' method will not be able to parse the data... - stream = SmlStreamReader() - stream.add(payload) - try: - sml_frame = stream.get_frame() - if sml_frame is None: - _LOGGER.info(f"Bytes missing - payload: {payload}") - if retry: - await asyncio.sleep(1.5) - await self.read_tibber_local(retry=False) - else: - # Shortcut to extract all values without parsing the whole frame - for entry in sml_frame.get_obis(): - self._obis_values[entry.obis] = entry - except CrcError as crc: - _LOGGER.info(f"CRC while parse data - payload: {payload}") - if retry: - await asyncio.sleep(1.5) - await self.read_tibber_local(retry=False) - except Exception as exc: - _LOGGER.warning(f"Exception {exc} while parse data - payload: {payload}") - if retry: - await asyncio.sleep(1.5) - await self.read_tibber_local(retry=False) + if mode == MODE_3_SML_1_04: + await self.read_sml(await res.read(), retry) + elif mode == MODE_99_PLAINTEXT: + await self.read_plaintext(await res.text(), retry) else: _LOGGER.warning(f"access to bridge failed with code {res.status}") + async def read_plaintext(self, plaintext: str, retry: bool): + try: + for a_line in plaintext.splitlines(): + # obis pattern is 'a-b:c.d.e*f' + parts = re.split('(.*?)-(.*?):(.*?)\\.(.*?)\\.(.*?)\\*(.*?)\\((.*?)\\)', a_line) + if len(parts) == 9: + int_obc = IntBasedObisCode(parts) + value = parts[7] + unit = None + if '*' in value: + val_with_unit = value.split("*") + if '.' in val_with_unit[0]: + value = float(val_with_unit[0]) + # converting any "kilo" unit to base unit... + # so kWh will be converted to Wh - or kV will be V + if val_with_unit[1].lower()[0] == 'k': + value = value * 1000; + val_with_unit[1] = val_with_unit[1][1:] + unit = self.find_unit_int_from_string(val_with_unit[1]) + + # creating finally the "right" object from the parsed information + entry = SmlListEntry() + entry.obis = ObisCode(int_obc.obis_hex) + entry.value = value + entry.unit = unit + + self._obis_values[int_obc.obis_hex] = entry + else: + if parts[0] == '!': + break; + elif parts[0][0] != '/': + print('unknown:' + parts[0]) + # else: + # print('ignore '+ parts[0]) + + except Exception as exc: + _LOGGER.warning(f"Exception {exc} while process data - plaintext: {plaintext}") + if retry: + await asyncio.sleep(1.5) + await self.read_tibber_local(mode=MODE_99_PLAINTEXT, retry=False) + + @staticmethod + def find_unit_int_from_string(unit_str: str): + for aUnit in UNITS.items(): + if aUnit[1] == unit_str: + return aUnit[0] + return None + + async def read_sml(self, payload: bytes, retry: bool): + # for what ever reason the data that can be read from the TibberPulse Webserver is + # not always valid! [I guess there is a issue with an internal buffer in the webserver + # implementation] - in any case the bytes received contain sometimes invalid characters + # so the 'stream.get_frame()' method will not be able to parse the data... + stream = SmlStreamReader() + stream.add(payload) + try: + sml_frame = stream.get_frame() + if sml_frame is None: + _LOGGER.info(f"Bytes missing - payload: {payload}") + if retry: + await asyncio.sleep(1.5) + await self.read_tibber_local(mode=MODE_3_SML_1_04, retry=False) + else: + # Shortcut to extract all values without parsing the whole frame + for entry in sml_frame.get_obis(): + self._obis_values[entry.obis] = entry + + except CrcError as crc: + _LOGGER.info(f"CRC while parse data - payload: {payload}") + if retry: + await asyncio.sleep(1.5) + await self.read_tibber_local(mode=MODE_3_SML_1_04, retry=False) + + except Exception as exc: + _LOGGER.warning(f"Exception {exc} while parse data - payload: {payload}") + if retry: + await asyncio.sleep(1.5) + await self.read_tibber_local(mode=MODE_3_SML_1_04, retry=False) + def _get_value_internal(self, key, divisor: int = 1): if key in self._obis_values: a_obis = self._obis_values.get(key) @@ -246,6 +365,8 @@ def _get_str_internal(self, key): def serial(self) -> str: # XYZ-123a4567 if self.get010060320101 is not None: return f"{self.get010060320101}-{self.get0100605a0201}" + elif self.get0100600100ff is not None: + return f"{self.get0100600100ff}" @property def get010060320101(self) -> str: # XYZ diff --git a/custom_components/tibber_local/config_flow.py b/custom_components/tibber_local/config_flow.py index 1eb7e82..0558357 100644 --- a/custom_components/tibber_local/config_flow.py +++ b/custom_components/tibber_local/config_flow.py @@ -8,7 +8,7 @@ from aiohttp import ClientResponseError from homeassistant import config_entries -from homeassistant.const import CONF_ID, CONF_HOST, CONF_NAME, CONF_SCAN_INTERVAL, CONF_PASSWORD +from homeassistant.const import CONF_ID, CONF_HOST, CONF_NAME, CONF_SCAN_INTERVAL, CONF_PASSWORD, CONF_MODE from homeassistant.core import HomeAssistant, callback from .const import ( @@ -17,6 +17,7 @@ DEFAULT_HOST, DEFAULT_PWD, DEFAULT_SCAN_INTERVAL, + ENUM_IMPLEMENTATIONS, ) _LOGGER = logging.getLogger(__name__) @@ -48,6 +49,20 @@ async def _test_connection_tibber_local(self, host, pwd): websession = self.hass.helpers.aiohttp_client.async_get_clientsession() try: bridge = TibberLocalBridge(host=host, pwd=pwd, websession=websession) + await bridge.detect_com_mode() + if bridge._com_mode in ENUM_IMPLEMENTATIONS: + self._con_mode = bridge._com_mode + return await self._test_data_available(bridge, host) + else: + self._errors[CONF_HOST] = "unknown_mode" + + except (OSError, HTTPError, Timeout, ClientResponseError): + self._errors[CONF_HOST] = "cannot_connect" + _LOGGER.warning("Could not connect to local Tibber Pulse Bridge at %s, check host/ip address", host) + return False + + async def _test_data_available(self, bridge:TibberLocalBridge, host:str) -> bool: + try: await bridge.update() _data_available = len(bridge._obis_values.keys()) > 0 if _data_available: @@ -69,10 +84,9 @@ async def _test_connection_tibber_local(self, host, pwd): except (OSError, HTTPError, Timeout, ClientResponseError): self._errors[CONF_HOST] = "cannot_connect" - _LOGGER.warning("Could not connect to local Tibber Pulse Bridge at %s, check host/ip address", host) + _LOGGER.warning("Could not read data from local Tibber Pulse Bridge at %s, check host/ip address", host) return False - async def async_step_user(self, user_input=None): self._errors = {} if user_input is not None: @@ -96,7 +110,8 @@ async def async_step_user(self, user_input=None): CONF_HOST: host, CONF_PASSWORD: pwd, CONF_SCAN_INTERVAL: scan, - CONF_ID: self._serial} + CONF_ID: self._serial, + CONF_MODE: self._con_mode} return self.async_create_entry(title=name, data=a_data) diff --git a/custom_components/tibber_local/const.py b/custom_components/tibber_local/const.py index 10a2a47..38ba9b8 100644 --- a/custom_components/tibber_local/const.py +++ b/custom_components/tibber_local/const.py @@ -24,6 +24,19 @@ DEFAULT_PWD = "" DEFAULT_SCAN_INTERVAL = 10 +MODE_UNKNOWN = -1 +MODE_0_AutoScanMode = 0 +MODE_1_IEC_62056_21 = 1 +MODE_2_Logarex = 2 +MODE_3_SML_1_04 = 3 +MODE_10_ImpressionsAmbient = 10 +MODE_11_ImpressionsIR = 11 +MODE_99_PLAINTEXT = 99 +ENUM_MODES = [MODE_0_AutoScanMode, MODE_1_IEC_62056_21, MODE_2_Logarex, MODE_3_SML_1_04, MODE_10_ImpressionsAmbient, + MODE_11_ImpressionsIR] + +ENUM_IMPLEMENTATIONS = [MODE_3_SML_1_04, MODE_99_PLAINTEXT] + SENSOR_TYPES = [ # Zählerstand Total diff --git a/custom_components/tibber_local/manifest.json b/custom_components/tibber_local/manifest.json index 92b7320..e673f0d 100644 --- a/custom_components/tibber_local/manifest.json +++ b/custom_components/tibber_local/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/marq24/ha-tibber-pulse-local/issues", "requirements": ["smllib==1.2"], - "version": "1.0.4" + "version": "1.0.5" } diff --git a/custom_components/tibber_local/sensor.py b/custom_components/tibber_local/sensor.py index 6b83bdb..a017fda 100644 --- a/custom_components/tibber_local/sensor.py +++ b/custom_components/tibber_local/sensor.py @@ -16,9 +16,18 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities): coordinator = hass.data[DOMAIN][config_entry.entry_id] entities = [] + + available_sensors = None + if hasattr(coordinator, 'bridge' ): + if hasattr(coordinator.bridge, '_obis_values'): + if len(coordinator.bridge._obis_values) > 0: + available_sensors = coordinator.bridge._obis_values.keys() + _LOGGER.info(f"available sensors found: {available_sensors}") + for description in SENSOR_TYPES: - entity = TibberLocalSensor(coordinator, description) - entities.append(entity) + if available_sensors is None or description.key in available_sensors: + entity = TibberLocalSensor(coordinator, description) + entities.append(entity) async_add_entities(entities) diff --git a/custom_components/tibber_local/strings.json b/custom_components/tibber_local/strings.json index 0bdd461..e020adc 100644 --- a/custom_components/tibber_local/strings.json +++ b/custom_components/tibber_local/strings.json @@ -14,7 +14,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "no_data": "[%key:common::config_flow::error::no_data%]" + "no_data": "[%key:common::config_flow::error::no_data%]", + "unknown_mode": "[%key:common::config_flow::error::unknown_mode%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/custom_components/tibber_local/translations/de.json b/custom_components/tibber_local/translations/de.json index 3220f7e..1516e4a 100644 --- a/custom_components/tibber_local/translations/de.json +++ b/custom_components/tibber_local/translations/de.json @@ -7,7 +7,8 @@ "login_failed": "Login fehlgeschlagen - bitte Host und/oder Passwort prüfen", "cannot_connect": "Keine Verbindung möglich", "unknown": "Unbekannter Fehler", - "no_data": "Es konnten keine Daten abgerufen werden" + "no_data": "Es konnten keine Daten abgerufen werden", + "unknown_mode": "Der Tibber Pulse kommuniziert über ein noch-nicht-implementiertes Protokoll – bitte öffne ein Issue@github – ich benötige Beispieldaten – TIA" }, "step": { "user": { diff --git a/custom_components/tibber_local/translations/en.json b/custom_components/tibber_local/translations/en.json index 7d8cdf3..aa2f2e7 100644 --- a/custom_components/tibber_local/translations/en.json +++ b/custom_components/tibber_local/translations/en.json @@ -7,7 +7,8 @@ "login_failed": "Login failed - please check host and/or password", "cannot_connect": "Failed to connect", "unknown": "Unknown error", - "no_data": "Could not read any data from device" + "no_data": "Could not read any data from device", + "unknown_mode": "The Tibber Pulse is communicating via an not-yet-implemented protocol - please open an issue@github - I need sample data - TIA" }, "step": { "user": { @@ -33,5 +34,69 @@ } } } + }, + "entity": { + "sensor": { + "0100010800ff": { + "name": "Import total" + }, + "0100020800ff": { + "name": "Export total" + }, + "0100010800ff_in_k": { + "name": "Import total (kWh)" + }, + "0100020800ff_in_k": { + "name": "Export total (kWh)" + }, + "0100100700ff": { + "name": "Power (actual)" + }, + "0100240700ff": { + "name": "Power L1" + }, + "0100380700ff": { + "name": "Power L2" + }, + "01004c0700ff": { + "name": "Power L3" + }, + "0100200700ff": { + "name": "Potential L1" + }, + "0100340700ff": { + "name": "Potential L2" + }, + "0100480700ff": { + "name": "Potential L3" + }, + "01001f0700ff": { + "name": "Current L1" + }, + "0100330700ff": { + "name": "Current L2" + }, + "0100470700ff": { + "name": "Current L3" + }, + "01000e0700ff": { + "name": "Net frequency" + }, + "0100510701ff": { + "name": "Potential Phase deviation L1/L2" + }, + "0100510702ff": { + "name": "Potential Phase deviation L1/L3" + }, + "0100510704ff": { + "name": "Current/Potential L1 Phase deviation" + }, + "010051070fff": { + "name": "Current/Potential L2 Phase deviation" + }, + "010051071aff": { + "name": "Current/Potential L3 Phase deviation" + } + } } }