diff --git a/custom_components/kamstrup_403/__init__.py b/custom_components/kamstrup_403/__init__.py index 8e79d18..9a38de0 100644 --- a/custom_components/kamstrup_403/__init__.py +++ b/custom_components/kamstrup_403/__init__.py @@ -4,7 +4,6 @@ For more details about this integration, please refer to https://github.com/custom-components/kamstrup_403 """ -import asyncio from datetime import timedelta import logging from typing import Any, List @@ -27,7 +26,7 @@ PLATFORMS, VERSION, ) -from .kamstrup import Kamstrup +from .pykamstrup.kamstrup import Kamstrup _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -55,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: client = Kamstrup(port, DEFAULT_BAUDRATE, timeout_seconds) - except (Exception) as exception: + except Exception as exception: _LOGGER.error("Can't establish a connection with %s", port) raise ConfigEntryNotReady() from exception @@ -136,36 +135,39 @@ async def _async_update_data(self) -> dict[int, Any]: _LOGGER.debug("Start update") data = {} - failed_counter = 0 + + try: + values = self.kamstrup.get_values(self._commands) + except serial.SerialException as exception: + _LOGGER.error( + "Device disconnected or multiple access on port? \nException: %e", + exception, + ) + except Exception as exception: + _LOGGER.error( + "Error reading multiple %s \nException: %s", self._commands, exception + ) + raise UpdateFailed() from exception + + failed_counter = len(self._commands) - len(values) for command in self._commands: - try: - value, unit = self.kamstrup.readvar(command) + if command in values: + value, unit = values[command] data[command] = {"value": value, "unit": unit} _LOGGER.debug( "New value for sensor %s, value: %s %s", command, value, unit ) - if value is None and unit is None: - failed_counter += 1 - - await asyncio.sleep(1) - except (serial.SerialException) as exception: - _LOGGER.error( - "Device disconnected or multiple access on port? \nException: %e", - exception, - ) - except (Exception) as exception: - _LOGGER.error("Error reading %s \nException: %s", command, exception) - raise UpdateFailed() from exception - if failed_counter == len(data): _LOGGER.error( "Finished update, No readings from the meter. Please check the IR connection" ) else: _LOGGER.debug( - "Finished update, %s/%s readings failed", failed_counter, len(data) + "Finished update, %s out of %s readings failed", + failed_counter, + len(data), ) return data diff --git a/custom_components/kamstrup_403/config_flow.py b/custom_components/kamstrup_403/config_flow.py index 653cdc0..3acc7ed 100644 --- a/custom_components/kamstrup_403/config_flow.py +++ b/custom_components/kamstrup_403/config_flow.py @@ -39,7 +39,7 @@ async def async_step_user(self, user_input=None): return self.async_create_entry( title=user_input[CONF_PORT], data=user_input ) - except (serial.SerialException): + except serial.SerialException: self._errors["base"] = "port" else: self._errors["base"] = "port" diff --git a/custom_components/kamstrup_403/kamstrup.py b/custom_components/kamstrup_403/kamstrup.py deleted file mode 100644 index 54c54e6..0000000 --- a/custom_components/kamstrup_403/kamstrup.py +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/python -# -# ---------------------------------------------------------------------------- -# "THE BEER-WARE LICENSE" (Revision 42): -# wrote this file. As long as you retain this notice you -# can do whatever you want with this stuff. If we meet some day, and you think -# this stuff is worth it, you can buy me a beer in return. Poul-Henning Kamp -# ---------------------------------------------------------------------------- -# -# Modified for Domotics and single request. -# -# Modified by Frank Reijn and Paul Bonnemaijers for Kamstrup Multical 402 -# -# Modified by Sander Gols to integrate in HA component - -import math - -import serial - -####################################################################### -# Units, provided by Erik Jensen - -units = { - 0: "", - 1: "Wh", - 2: "kWh", - 3: "MWh", - 4: "GWh", - 5: "J", - 6: "kj", - 7: "MJ", - 8: "GJ", - 9: "Cal", - 10: "kCal", - 11: "Mcal", - 12: "Gcal", - 13: "varh", - 14: "kvarh", - 15: "Mvarh", - 16: "Gvarh", - 17: "VAh", - 18: "kVAh", - 19: "MVAh", - 20: "GVAh", - 21: "kW", - 22: "kW", - 23: "MW", - 24: "GW", - 25: "kvar", - 26: "kvar", - 27: "Mvar", - 28: "Gvar", - 29: "VA", - 30: "kVA", - 31: "MVA", - 32: "GVA", - 33: "V", - 34: "A", - 35: "kV", - 36: "kA", - 37: "°C", - 38: "K", - 39: "l", - 40: "m³", - 41: "l/h", - 42: "m³/h", - 43: "m³xC", - 44: "ton", - 45: "ton/h", - 46: "h", - 47: "hh:mm:ss", - 48: "yy:mm:dd", - 49: "yyyy:mm:dd", - 50: "mm:dd", - 51: "", - 52: "bar", - 53: "RTC", - 54: "ASCII", - 55: "m³ x 10", - 56: "ton x 10", - 57: "GJ x 10", - 58: "minutes", - 59: "Bitfield", - 60: "s", - 61: "ms", - 62: "days", - 63: "RTC-Q", - 64: "Datetime", -} - -####################################################################### -# Kamstrup uses the "true" CCITT CRC-16 -# - - -def crc_1021(message): - poly = 0x1021 - reg = 0x0000 - for byte in message: - mask = 0x80 - while mask > 0: - reg <<= 1 - if byte & mask: - reg |= 1 - mask >>= 1 - if reg & 0x10000: - reg &= 0xFFFF - reg ^= poly - return reg - - -####################################################################### -# Byte values which must be escaped before transmission -# - -escapes = { - 0x06: True, - 0x0D: True, - 0x1B: True, - 0x40: True, - 0x80: True, -} - -####################################################################### -# And here we go.... -# - - -class Kamstrup(object): - def __init__(self, serial_port, baudrate, timeout): - - self.ser = serial.Serial(port=serial_port, baudrate=baudrate, timeout=timeout) - - def wr(self, b): - b = bytearray(b) - self.ser.write(b) - - def rd(self): - a = self.ser.read(1) - if len(a) == 0: - return None - b = bytearray(a)[0] - return b - - def send(self, pfx, msg): - b = bytearray(msg) - - b.append(0) - b.append(0) - c = crc_1021(b) - b[-2] = c >> 8 - b[-1] = c & 0xFF - - c = bytearray() - c.append(pfx) - for i in b: - if i in escapes: - c.append(0x1B) - c.append(i ^ 0xFF) - else: - c.append(i) - c.append(0x0D) - self.wr(c) - - def recv(self): - b = bytearray() - while True: - d = self.rd() - if d == None: - return None - if d == 0x40: - b = bytearray() - b.append(d) - if d == 0x0D: - break - c = bytearray() - i = 1 - while i < len(b) - 1: - if b[i] == 0x1B: - v = b[i + 1] ^ 0xFF - c.append(v) - i += 2 - else: - c.append(b[i]) - i += 1 - return c[:-2] - - def readvar(self, nbr): - # I wouldn't be surprised if you can ask for more than - # one variable at the time, given that the length is - # encoded in the response. Havn't tried. - - self.send(0x80, (0x3F, 0x10, 0x01, nbr >> 8, nbr & 0xFF)) - - b = self.recv() - if b == None: - return (None, None) - - if b[0] != 0x3F or b[1] != 0x10: - return (None, None) - - if b[2] != nbr >> 8 or b[3] != nbr & 0xFF: - return (None, None) - - if b[4] in units: - u = units[b[4]] - else: - u = None - - # Decode the mantissa - x = 0 - for i in range(0, b[5]): - x <<= 8 - x |= b[i + 7] - - # Decode the exponent - i = b[6] & 0x3F - if b[6] & 0x40: - i = -i - i = math.pow(10, i) - if b[6] & 0x80: - i = -i - x *= i - - return (x, u) diff --git a/custom_components/kamstrup_403/pykamstrup/README.md b/custom_components/kamstrup_403/pykamstrup/README.md new file mode 100644 index 0000000..e192066 --- /dev/null +++ b/custom_components/kamstrup_403/pykamstrup/README.md @@ -0,0 +1,24 @@ +# PyKamstrup +This is an implementation of the Kamstrup Meter Protocol (KMP) based +on reverse engineering of a traffic dump. + +## Contributors +This file has seen many modifications over the years, contributions have been made by: +| Author | Profile | Notes | Source | +|--|--|--|--| +| Poul-Henning Kamp | [@bsdphk](https://github.com/bsdphk) | Original author | https://github.com/bsdphk/PyKamstrup | +| Erik Jensen | | Provided units and exponents | | +| Frank Reijn | [@freijn](https://github.com/freijn) | | | +| Paul Bonnemaijers | | | | +| | [@adabrandt](https://github.com/adabrandt) | Support reading of multiple values at once | https://github.com/bsdphk/PyKamstrup/pull/6 | +| Sander Gols | [@golles](https://github.com/golles) | This component for Home Assistant | | + +There might be other significant contributors that I'm not aware off, feel free to add them in a PR. + +## License +`kamstrup.py` has it's own license, from the original author: +``` +"THE BEER-WARE LICENSE" (Revision 42): + + wrote this file. As long as you retain this notice you can do whatever you want with this stuff. If we meet some day, and you think this stuff is worth it, you can buy me a beer in return Poul-Henning Kamp +``` diff --git a/custom_components/kamstrup_403/pykamstrup/__init__.py b/custom_components/kamstrup_403/pykamstrup/__init__.py new file mode 100644 index 0000000..4abc77a --- /dev/null +++ b/custom_components/kamstrup_403/pykamstrup/__init__.py @@ -0,0 +1 @@ +"""PyKamstrup module.""" diff --git a/custom_components/kamstrup_403/pykamstrup/const.py b/custom_components/kamstrup_403/pykamstrup/const.py new file mode 100644 index 0000000..9a0503d --- /dev/null +++ b/custom_components/kamstrup_403/pykamstrup/const.py @@ -0,0 +1,79 @@ +"""Constants for PyKamstrup.""" + +from typing import Final + +ESCAPES: Final = { + 0x06: True, + 0x0D: True, + 0x1B: True, + 0x40: True, + 0x80: True, +} + +UNITS: Final = { + 0: "", + 1: "Wh", + 2: "kWh", + 3: "MWh", + 4: "GWh", + 5: "J", + 6: "kj", + 7: "MJ", + 8: "GJ", + 9: "Cal", + 10: "kCal", + 11: "Mcal", + 12: "Gcal", + 13: "varh", + 14: "kvarh", + 15: "Mvarh", + 16: "Gvarh", + 17: "VAh", + 18: "kVAh", + 19: "MVAh", + 20: "GVAh", + 21: "kW", + 22: "kW", + 23: "MW", + 24: "GW", + 25: "kvar", + 26: "kvar", + 27: "Mvar", + 28: "Gvar", + 29: "VA", + 30: "kVA", + 31: "MVA", + 32: "GVA", + 33: "V", + 34: "A", + 35: "kV", + 36: "kA", + 37: "°C", + 38: "K", + 39: "l", + 40: "m³", + 41: "l/h", + 42: "m³/h", + 43: "m³xC", + 44: "ton", + 45: "ton/h", + 46: "h", + 47: "hh:mm:ss", + 48: "yy:mm:dd", + 49: "yyyy:mm:dd", + 50: "mm:dd", + 51: "", + 52: "bar", + 53: "RTC", + 54: "ASCII", + 55: "m³ x 10", + 56: "ton x 10", + 57: "GJ x 10", + 58: "minutes", + 59: "Bitfield", + 60: "s", + 61: "ms", + 62: "days", + 63: "RTC-Q", + 64: "Datetime", +} diff --git a/custom_components/kamstrup_403/pykamstrup/kamstrup.py b/custom_components/kamstrup_403/pykamstrup/kamstrup.py new file mode 100644 index 0000000..ae90f58 --- /dev/null +++ b/custom_components/kamstrup_403/pykamstrup/kamstrup.py @@ -0,0 +1,199 @@ +"""Kamstrup Meter Protocol (KMP)""" + +# ---------------------------------------------------------------------------- +# "THE BEER-WARE LICENSE" (Revision 42): +# wrote this file. As long as you retain this notice you +# can do whatever you want with this stuff. If we meet some day, and you think +# this stuff is worth it, you can buy me a beer in return. Poul-Henning Kamp +# ---------------------------------------------------------------------------- + +import logging +import math + +import serial + +from .const import ESCAPES, UNITS + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class Kamstrup: + """Kamstrup Meter Protocol (KMP)""" + + def __init__(self, serial_port: str, baudrate: int, timeout: float): + """Initialize""" + self.ser = serial.Serial(port=serial_port, baudrate=baudrate, timeout=timeout) + + @classmethod + def _crc_1021(cls, message: tuple[int]) -> int: + """Kamstrup uses the "true" CCITT CRC-16""" + poly = 0x1021 + reg = 0x0000 + for byte in message: + mask = 0x80 + while mask > 0: + reg <<= 1 + if byte & mask: + reg |= 1 + mask >>= 1 + if reg & 0x10000: + reg &= 0xFFFF + reg ^= poly + return reg + + def _write(self, data: tuple[int]): + """Write directly to the meter""" + bytearray_data = bytearray(data) + self.ser.write(bytearray_data) + + def _read(self) -> (int | None): + """Read directly from the meter""" + data = self.ser.read(1) + if len(data) == 0: + _LOGGER.debug("Rx Timeout") + return None + bytearray_data = bytearray(data) + return bytearray_data[0] + + def _send(self, pfx: int, message: tuple[int]): + """Construct the message and send to the meter""" + bytearray_data = bytearray(message) + + bytearray_data.append(0) + bytearray_data.append(0) + data = self._crc_1021(bytearray_data) + bytearray_data[-2] = data >> 8 + bytearray_data[-1] = data & 0xFF + + data = bytearray() + data.append(pfx) + for i in bytearray_data: + if i in ESCAPES: + data.append(0x1B) + data.append(i ^ 0xFF) + else: + data.append(i) + data.append(0x0D) + self._write(data) + + def _receive(self) -> (bytearray | None): + """Receive data""" + # Skip first response, which is repetition of initial command, + # only break on 0x0d if it comes after 0x40. + bytearray_data = None + while True: + data = self._read() + if data is None: + return None + if data == 0x40: + bytearray_data = bytearray() + if bytearray_data is not None: + bytearray_data.append(data) + if data == 0x0D: + break + + response_data = bytearray() + i = 1 + while i < len(bytearray_data) - 1: + if bytearray_data[i] == 0x1B: + value = bytearray_data[i + 1] ^ 0xFF + if value not in ESCAPES: + _LOGGER.debug("Missing Escape %02x", value) + response_data.append(value) + i += 2 + else: + response_data.append(bytearray_data[i]) + i += 1 + if self._crc_1021(response_data): + _LOGGER.debug("CRC error") + return response_data[:-2] + + @classmethod + def _process_response(cls, nbr: int, data): + """Process a response""" + if data[0] != nbr >> 8 or data[1] != nbr & 0xFF: + _LOGGER.debug("NBR error") + return (None, None) + + if data[2] in UNITS: + unit = UNITS[data[2]] + else: + unit = None + + # Decode the mantissa. + value = 0 + for i in range(0, data[3]): + value <<= 8 + value |= data[i + 5] + + # Decode the exponent. + i = data[4] & 0x3F + if data[4] & 0x40: + i = -i + i = math.pow(10, i) + if data[4] & 0x80: + i = -i + value *= i + + return value, unit + + def get_value( + self, nbr: int + ) -> (tuple[None, None] | tuple[float | None, str | None]): + """Get a value from the meter""" + self._send(0x80, (0x3F, 0x10, 0x01, nbr >> 8, nbr & 0xFF)) + + bytearray_data = self._receive() + if bytearray_data is None: + return (None, None) + + if bytearray_data[0] != 0x3F or bytearray_data[1] != 0x10: + return (None, None) + + value, unit = self._process_response(nbr, bytearray_data[2:]) + + return (value, unit) + + def get_values( + self, multiple_nbr: list[int] + ) -> (tuple[None, None] | tuple[float | None, str | None] | dict): + """Get values from the meter""" + + # Construct the request. + req = bytearray() + req.append(0x3F) # destination address. + req.append(0x10) # CID. + req.append(len(multiple_nbr)) # number of nbrs. + for nbr in multiple_nbr: + req.append(nbr >> 8) + req.append(nbr & 0xFF) + + self._send(0x80, req) + + # Process response. + bytearray_data = self._receive() + if bytearray_data is None: + return (None, None) + + # Check destination address and CID. + if bytearray_data[0] != 0x3F or bytearray_data[1] != 0x10: + return (None, None) + + # Decode response data, containing multiple variables. + result = {} + remaining_data = bytearray_data[2:] + counter = 0 + + # Continue processing data until all variables have been processed. + while counter < (len(multiple_nbr)): + current_nbr = multiple_nbr[counter] + value, unit = self._process_response(current_nbr, remaining_data) + result[current_nbr] = (value, unit) + # length of current variable response data = + # nbr (2) + units (1) + length (1) + sigexp (1) (=5) + # + length of actual value. + len_current_nbr = 5 + remaining_data[3] + remaining_data = remaining_data[len_current_nbr:] + counter += 1 + + return result diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index a0a6ef0..213e719 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.kamstrup_403.const import DOMAIN, SENSOR +from custom_components.kamstrup_403.const import DOMAIN from .const import MOCK_CONFIG, MOCK_UPDATE_CONFIG @@ -27,6 +27,7 @@ def bypass_setup_fixture(): # Here we simiulate a successful config flow from the backend. # Note that we use the `bypass_get_data` fixture here because # we want the config flow validation to succeed during the test. +@pytest.mark.asyncio async def test_successful_config_flow(hass, bypass_get_data): """Test a successful config flow.""" # Initialize a config flow @@ -55,6 +56,7 @@ async def test_successful_config_flow(hass, bypass_get_data): # We use the `error_on_get_data` mock instead of `bypass_get_data` # (note the function parameters) to raise an Exception during # validation of the input config. +@pytest.mark.asyncio async def test_failed_config_flow(hass, error_on_get_data): """Test a failed config flow due to credential validation failure.""" result = await hass.config_entries.flow.async_init( @@ -73,6 +75,7 @@ async def test_failed_config_flow(hass, error_on_get_data): # Our config flow also has an options flow, so we must test it as well. +@pytest.mark.asyncio async def test_options_flow(hass): """Test an options flow.""" # Create a new MockConfigEntry and add to HASS (we're bypassing config diff --git a/tests/test_init.py b/tests/test_init.py index 4f55cd7..2f8c2ea 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -19,6 +19,7 @@ # Home Assistant using the pytest_homeassistant_custom_component plugin. # Assertions allow you to verify that the return value of whatever is on the left # side of the assertion matches with the right side. +@pytest.mark.asyncio async def test_setup_unload_and_reload_entry(hass, bypass_get_data): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow @@ -41,6 +42,7 @@ async def test_setup_unload_and_reload_entry(hass, bypass_get_data): assert config_entry.entry_id not in hass.data[DOMAIN] +@pytest.mark.asyncio async def test_setup_entry_exception(hass, error_on_get_data): """Test ConfigEntryNotReady when API raises an exception during entry setup.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 0000000..56ee176 --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,55 @@ +"""Tests sensor.""" + +from homeassistant.components.sensor import SensorEntityDescription +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.kamstrup_403 import async_setup_entry +from custom_components.kamstrup_403.const import DOMAIN +from custom_components.kamstrup_403.sensor import KamstrupGasSensor, KamstrupMeterSensor + +from .const import MOCK_CONFIG + + +@pytest.mark.asyncio +async def test_kamstrup_gas_sensor(hass, bypass_get_data): + """Test is_on function on base class.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + await async_setup_entry(hass, config_entry) + + sensor = KamstrupGasSensor( + hass.data[DOMAIN][config_entry.entry_id], + config_entry.entry_id, + SensorEntityDescription( + key="gas", + name="Heat Energy to Gas", + ), + ) + + # Mock data. + sensor.coordinator.data[60] = {"value": 1234, "unit": "GJ"} + + assert sensor.state == 1234 + + +@pytest.mark.asyncio +async def test_kamstrup_meter_sensor(hass, bypass_get_data): + """Test is_on function on base class.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + await async_setup_entry(hass, config_entry) + + sensor = KamstrupMeterSensor( + hass.data[DOMAIN][config_entry.entry_id], + config_entry.entry_id, + SensorEntityDescription( + key="60", + name="Heat Energy (E1)", + ), + ) + + # Mock data. + sensor.coordinator.data[60] = {"value": 1234, "unit": "GJ"} + + assert sensor.int_key == 60 + assert sensor.state == 1234 + assert sensor.native_unit_of_measurement == "GJ" diff --git a/tests/test_version.py b/tests/test_version.py index 899b6b9..fa85903 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,6 +1,8 @@ """Test for versions.""" import json +import pytest + from custom_components.kamstrup_403.const import VERSION with open( @@ -11,11 +13,13 @@ manifest = json.loads(data) +@pytest.mark.asyncio async def test_component_version(): """Verify that the version in the manifest and const.py are equal""" assert manifest["version"] == VERSION +@pytest.mark.asyncio async def test_component_requirements(): """Verify that all requirements in the manifest.json are defined as in the requirements files""" requirements_files = ["requirements_dev.txt", "requirements_test.txt"]