From 32a2c5d5dbdeacbeeb58e62458bbb40bee07d945 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 19 Aug 2021 10:11:20 +0200 Subject: [PATCH] Add support for Swedish smart electricity meters to DSMR (#54630) * Add support for Swedish smart electricity meters to DSMR * Use Swedish protocol support from dsmr_parser * Update tests * Bump dsmr_parser to 0.30 * Remove last_reset attribute from Swedish energy sensors --- homeassistant/components/dsmr/config_flow.py | 21 ++++--- homeassistant/components/dsmr/const.py | 31 ++++++++++ homeassistant/components/dsmr/manifest.json | 2 +- homeassistant/components/dsmr/sensor.py | 9 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dsmr/conftest.py | 6 ++ tests/components/dsmr/test_config_flow.py | 24 ++++++++ tests/components/dsmr/test_sensor.py | 65 ++++++++++++++++++++ 9 files changed, 149 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 72e854fe43a..9670aab21cf 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -28,6 +28,7 @@ from .const import ( CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE, DOMAIN, + DSMR_VERSIONS, LOGGER, ) @@ -70,6 +71,10 @@ class DSMRConnection: if self._equipment_identifier in telegram: self._telegram = telegram transport.close() + # Swedish meters have no equipment identifier + if self._dsmr_version == "5S" and obis_ref.P1_MESSAGE_TIMESTAMP in telegram: + self._telegram = telegram + transport.close() if self._host is None: reader_factory = partial( @@ -119,7 +124,7 @@ async def _validate_dsmr_connection( equipment_identifier_gas = conn.equipment_identifier_gas() # Check only for equipment identifier in case no gas meter is connected - if equipment_identifier is None: + if equipment_identifier is None and data[CONF_DSMR_VERSION] != "5S": raise CannotCommunicate return { @@ -203,7 +208,7 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): { vol.Required(CONF_HOST): str, vol.Required(CONF_PORT): int, - vol.Required(CONF_DSMR_VERSION): vol.In(["2.2", "4", "5", "5B", "5L"]), + vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), } ) return self.async_show_form( @@ -247,7 +252,7 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): schema = vol.Schema( { vol.Required(CONF_PORT): vol.In(list_of_ports), - vol.Required(CONF_DSMR_VERSION): vol.In(["2.2", "4", "5", "5B", "5L"]), + vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), } ) return self.async_show_form( @@ -288,8 +293,9 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data = {**data, **info} - await self.async_set_unique_id(info[CONF_SERIAL_ID]) - self._abort_if_unique_id_configured() + if info[CONF_SERIAL_ID]: + await self.async_set_unique_id(info[CONF_SERIAL_ID]) + self._abort_if_unique_id_configured() except CannotConnect: errors["base"] = "cannot_connect" except CannotCommunicate: @@ -316,8 +322,9 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): name = f"{host}:{port}" if host is not None else port data = {**import_config, **info} - await self.async_set_unique_id(info[CONF_SERIAL_ID]) - self._abort_if_unique_id_configured(data) + if info[CONF_SERIAL_ID]: + await self.async_set_unique_id(info[CONF_SERIAL_ID]) + self._abort_if_unique_id_configured(data) return self.async_create_entry(title=name, data=data) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 6c392526ee3..ba90fa9b697 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -44,6 +44,8 @@ DATA_TASK = "task" DEVICE_NAME_ENERGY = "Energy Meter" DEVICE_NAME_GAS = "Gas Meter" +DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S"} + SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.CURRENT_ELECTRICITY_USAGE, @@ -62,11 +64,13 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_ACTIVE_TARIFF, name="Power Tariff", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, icon="mdi:flash", ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_USED_TARIFF_1, name="Energy Consumption (tarif 1)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=DEVICE_CLASS_ENERGY, force_update=True, state_class=STATE_CLASS_TOTAL_INCREASING, @@ -74,6 +78,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_USED_TARIFF_2, name="Energy Consumption (tarif 2)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, @@ -81,6 +86,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, name="Energy Production (tarif 1)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, @@ -88,6 +94,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, name="Energy Production (tarif 2)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, @@ -137,45 +144,53 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.SHORT_POWER_FAILURE_COUNT, name="Short Power Failure Count", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:flash-off", ), DSMRSensorEntityDescription( key=obis_references.LONG_POWER_FAILURE_COUNT, name="Long Power Failure Count", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:flash-off", ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SAG_L1_COUNT, name="Voltage Sags Phase L1", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SAG_L2_COUNT, name="Voltage Sags Phase L2", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SAG_L3_COUNT, name="Voltage Sags Phase L3", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SWELL_L1_COUNT, name="Voltage Swells Phase L1", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SWELL_L2_COUNT, name="Voltage Swells Phase L2", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SWELL_L3_COUNT, name="Voltage Swells Phase L3", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", ), @@ -237,6 +252,22 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + DSMRSensorEntityDescription( + key=obis_references.SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL, + name="Energy Consumption (total)", + dsmr_versions={"5S"}, + force_update=True, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, + name="Energy Production (total)", + dsmr_versions={"5S"}, + force_update=True, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_IMPORTED_TOTAL, name="Energy Consumption (total)", diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index df738724ac0..fbbfac55959 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dsmr", "name": "DSMR Slimme Meter", "documentation": "https://www.home-assistant.io/integrations/dsmr", - "requirements": ["dsmr_parser==0.29"], + "requirements": ["dsmr_parser==0.30"], "codeowners": ["@Robbie1221", "@frenck"], "config_flow": true, "iot_class": "local_push" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index dbc29144719..1b38b2695ec 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -44,6 +44,7 @@ from .const import ( DEVICE_NAME_ENERGY, DEVICE_NAME_GAS, DOMAIN, + DSMR_VERSIONS, LOGGER, SENSORS, ) @@ -54,7 +55,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( - cv.string, vol.In(["5L", "5B", "5", "4", "2.2"]) + cv.string, vol.In(DSMR_VERSIONS) ), vol.Optional(CONF_RECONNECT_INTERVAL, default=DEFAULT_RECONNECT_INTERVAL): int, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), @@ -118,7 +119,7 @@ async def async_setup_entry( create_tcp_dsmr_reader, entry.data[CONF_HOST], entry.data[CONF_PORT], - entry.data[CONF_DSMR_VERSION], + dsmr_version, update_entities_telegram, loop=hass.loop, keep_alive_interval=60, @@ -127,7 +128,7 @@ async def async_setup_entry( reader_factory = partial( create_dsmr_reader, entry.data[CONF_PORT], - entry.data[CONF_DSMR_VERSION], + dsmr_version, update_entities_telegram, loop=hass.loop, ) @@ -217,6 +218,8 @@ class DSMREntity(SensorEntity): if entity_description.is_gas: device_serial = entry.data[CONF_SERIAL_ID_GAS] device_name = DEVICE_NAME_GAS + if device_serial is None: + device_serial = entry.entry_id self._attr_device_info = { "identifiers": {(DOMAIN, device_serial)}, diff --git a/requirements_all.txt b/requirements_all.txt index 96b6d6757b7..d5adf797311 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -532,7 +532,7 @@ doorbirdpy==2.1.0 dovado==0.4.1 # homeassistant.components.dsmr -dsmr_parser==0.29 +dsmr_parser==0.30 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7ac2def786..ef1e3ca0fc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -307,7 +307,7 @@ directv==0.4.0 doorbirdpy==2.1.0 # homeassistant.components.dsmr -dsmr_parser==0.29 +dsmr_parser==0.30 # homeassistant.components.dynalite dynalite_devices==0.1.46 diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index ab7b3a4d479..9ef6bccfab5 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -7,6 +7,7 @@ from dsmr_parser.obis_references import ( EQUIPMENT_IDENTIFIER, EQUIPMENT_IDENTIFIER_GAS, LUXEMBOURG_EQUIPMENT_IDENTIFIER, + P1_MESSAGE_TIMESTAMP, ) from dsmr_parser.objects import CosemObject import pytest @@ -44,6 +45,7 @@ async def dsmr_connection_send_validate_fixture(hass): protocol.telegram = { EQUIPMENT_IDENTIFIER: CosemObject([{"value": "12345678", "unit": ""}]), EQUIPMENT_IDENTIFIER_GAS: CosemObject([{"value": "123456789", "unit": ""}]), + P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), } async def connection_factory(*args, **kwargs): @@ -57,6 +59,10 @@ async def dsmr_connection_send_validate_fixture(hass): [{"value": "123456789", "unit": ""}] ), } + if args[1] == "5S": + protocol.telegram = { + P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), + } return (transport, protocol) diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 006893a81e8..d56cd3f2eb8 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -13,6 +13,7 @@ from homeassistant.components.dsmr import DOMAIN, config_flow from tests.common import MockConfigEntry SERIAL_DATA = {"serial_id": "12345678", "serial_id_gas": "123456789"} +SERIAL_DATA_SWEDEN = {"serial_id": None, "serial_id_gas": None} def com_port(): @@ -482,6 +483,29 @@ async def test_import_luxembourg(hass, dsmr_connection_send_validate_fixture): assert result["data"] == {**entry_data, **SERIAL_DATA} +async def test_import_sweden(hass, dsmr_connection_send_validate_fixture): + """Test we can import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5S", + "precision": 4, + "reconnect_interval": 30, + } + + with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=entry_data, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "/dev/ttyUSB0" + assert result["data"] == {**entry_data, **SERIAL_DATA_SWEDEN} + + def test_get_serial_by_id_no_dir(): """Test serial by id conversion if there's no /dev/serial/by-id.""" p1 = patch("os.path.isdir", MagicMock(return_value=False)) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 6d40437d87a..6accf7c40da 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -536,6 +536,71 @@ async def test_belgian_meter_low(hass, dsmr_connection_fixture): assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" +async def test_swedish_meter(hass, dsmr_connection_fixture): + """Test if v5 meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, + SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL, + ) + from dsmr_parser.objects import CosemObject + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5S", + "precision": 4, + "reconnect_interval": 30, + "serial_id": None, + "serial_id_gas": None, + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL: CosemObject( + [{"value": Decimal(123.456), "unit": ENERGY_KILO_WATT_HOUR}] + ), + SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL: CosemObject( + [{"value": Decimal(654.321), "unit": ENERGY_KILO_WATT_HOUR}] + ), + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to update + await asyncio.sleep(0) + + power_tariff = hass.states.get("sensor.energy_consumption_total") + assert power_tariff.state == "123.456" + assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert power_tariff.attributes.get(ATTR_ICON) is None + assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert ( + power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + ) + + power_tariff = hass.states.get("sensor.energy_production_total") + assert power_tariff.state == "654.321" + assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert ( + power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + ) + + async def test_tcp(hass, dsmr_connection_fixture): """If proper config provided TCP connection should be made.""" (connection_factory, transport, protocol) = dsmr_connection_fixture