From ba84d0bf5cfa7925b85b167d842143247b96da21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Z=C3=A1hradn=C3=ADk?= Date: Wed, 7 Oct 2020 22:43:16 +0200 Subject: [PATCH] Add custom data type support into Modbus climate (#32439) --- homeassistant/components/modbus/__init__.py | 54 ++++- homeassistant/components/modbus/climate.py | 227 ++++++++++---------- homeassistant/components/modbus/const.py | 8 + homeassistant/components/modbus/sensor.py | 11 +- 4 files changed, 173 insertions(+), 127 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 822000cb56a..77e9b6f7ca9 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_PORT, CONF_SCAN_INTERVAL, CONF_SLAVE, + CONF_STRUCTURE, CONF_TIMEOUT, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, @@ -37,19 +38,38 @@ from .const import ( CALL_TYPE_REGISTER_INPUT, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_CLIMATES, + CONF_CURRENT_TEMP, + CONF_CURRENT_TEMP_REGISTER_TYPE, + CONF_DATA_COUNT, + CONF_DATA_TYPE, CONF_INPUT_TYPE, + CONF_MAX_TEMP, + CONF_MIN_TEMP, + CONF_OFFSET, CONF_PARITY, + CONF_PRECISION, CONF_REGISTER, + CONF_SCALE, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OPEN, CONF_STATE_OPENING, CONF_STATUS_REGISTER, CONF_STATUS_REGISTER_TYPE, + CONF_STEP, CONF_STOPBITS, + CONF_TARGET_TEMP, + CONF_UNIT, + DATA_TYPE_CUSTOM, + DATA_TYPE_FLOAT, + DATA_TYPE_INT, + DATA_TYPE_UINT, DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, DEFAULT_SLAVE, + DEFAULT_STRUCTURE_PREFIX, + DEFAULT_TEMP_UNIT, MODBUS_DOMAIN as DOMAIN, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, @@ -59,6 +79,33 @@ _LOGGER = logging.getLogger(__name__) BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string}) +CLIMATE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CURRENT_TEMP): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SLAVE): cv.positive_int, + vol.Required(CONF_TARGET_TEMP): cv.positive_int, + vol.Optional(CONF_DATA_COUNT, default=2): cv.positive_int, + vol.Optional( + CONF_CURRENT_TEMP_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING + ): vol.In([CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]), + vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT): vol.In( + [DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT, DATA_TYPE_CUSTOM] + ), + vol.Optional(CONF_PRECISION, default=1): cv.positive_int, + vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, lambda value: value.total_seconds() + ), + vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_int, + vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_int, + vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), + vol.Optional(CONF_STRUCTURE, default=DEFAULT_STRUCTURE_PREFIX): cv.string, + vol.Optional(CONF_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, + } +) + COVERS_SCHEMA = vol.All( cv.has_at_least_one_key(CALL_TYPE_COIL, CONF_REGISTER), vol.Schema( @@ -94,6 +141,7 @@ SERIAL_SCHEMA = BASE_SCHEMA.extend( vol.Required(CONF_STOPBITS): vol.Any(1, 2), vol.Required(CONF_TYPE): "serial", vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, + vol.Optional(CONF_CLIMATES): vol.All(cv.ensure_list, [CLIMATE_SCHEMA]), vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), } ) @@ -105,6 +153,7 @@ ETHERNET_SCHEMA = BASE_SCHEMA.extend( vol.Required(CONF_TYPE): vol.Any("tcp", "udp", "rtuovertcp"), vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, vol.Optional(CONF_DELAY, default=0): cv.positive_int, + vol.Optional(CONF_CLIMATES): vol.All(cv.ensure_list, [CLIMATE_SCHEMA]), vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), } ) @@ -150,7 +199,10 @@ def setup(hass, config): hub_collect[conf_hub[CONF_NAME]] = ModbusHub(conf_hub) # load platforms - for component, conf_key in (("cover", CONF_COVERS),): + for component, conf_key in ( + ("climate", CONF_CLIMATES), + ("cover", CONF_COVERS), + ): if conf_key in conf_hub: load_platform(hass, component, DOMAIN, conf_hub, config) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 5498ed61738..d57d0f761c6 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -1,13 +1,13 @@ """Support for Generic Modbus Thermostats.""" +from datetime import timedelta import logging import struct -from typing import Optional +from typing import Any, Dict, Optional from pymodbus.exceptions import ConnectionException, ModbusException from pymodbus.pdu import ExceptionResponse -import voluptuous as vol -from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity +from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( HVAC_MODE_AUTO, SUPPORT_TARGET_TEMPERATURE, @@ -15,20 +15,28 @@ from homeassistant.components.climate.const import ( from homeassistant.const import ( ATTR_TEMPERATURE, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_SLAVE, + CONF_STRUCTURE, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ( + ConfigType, + DiscoveryInfoType, + HomeAssistantType, +) +from . import ModbusHub from .const import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_CLIMATES, CONF_CURRENT_TEMP, CONF_CURRENT_TEMP_REGISTER_TYPE, CONF_DATA_COUNT, CONF_DATA_TYPE, - CONF_HUB, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_OFFSET, @@ -37,82 +45,61 @@ from .const import ( CONF_STEP, CONF_TARGET_TEMP, CONF_UNIT, - DATA_TYPE_FLOAT, - DATA_TYPE_INT, - DATA_TYPE_UINT, - DEFAULT_HUB, + DATA_TYPE_CUSTOM, + DEFAULT_STRUCT_FORMAT, MODBUS_DOMAIN, ) _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_CURRENT_TEMP): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_SLAVE): cv.positive_int, - vol.Required(CONF_TARGET_TEMP): cv.positive_int, - vol.Optional(CONF_DATA_COUNT, default=2): cv.positive_int, - vol.Optional( - CONF_CURRENT_TEMP_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING - ): vol.In([CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]), - vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT): vol.In( - [DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT] - ), - vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, - vol.Optional(CONF_PRECISION, default=1): cv.positive_int, - vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), - vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), - vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_int, - vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_int, - vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), - vol.Optional(CONF_UNIT, default="C"): cv.string, - } -) +async def async_setup_platform( + hass: HomeAssistantType, + config: ConfigType, + async_add_entities, + discovery_info: Optional[DiscoveryInfoType] = None, +): + """Read configuration and create Modbus climate.""" + if discovery_info is None: + return + entities = [] + for entity in discovery_info[CONF_CLIMATES]: + hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + count = entity[CONF_DATA_COUNT] + data_type = entity[CONF_DATA_TYPE] + name = entity[CONF_NAME] + structure = entity[CONF_STRUCTURE] -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Modbus Thermostat Platform.""" - name = config[CONF_NAME] - modbus_slave = config[CONF_SLAVE] - target_temp_register = config[CONF_TARGET_TEMP] - current_temp_register = config[CONF_CURRENT_TEMP] - current_temp_register_type = config[CONF_CURRENT_TEMP_REGISTER_TYPE] - data_type = config[CONF_DATA_TYPE] - count = config[CONF_DATA_COUNT] - precision = config[CONF_PRECISION] - scale = config[CONF_SCALE] - offset = config[CONF_OFFSET] - unit = config[CONF_UNIT] - max_temp = config[CONF_MAX_TEMP] - min_temp = config[CONF_MIN_TEMP] - temp_step = config[CONF_STEP] - hub_name = config[CONF_HUB] - hub = hass.data[MODBUS_DOMAIN][hub_name] + if data_type != DATA_TYPE_CUSTOM: + try: + structure = f">{DEFAULT_STRUCT_FORMAT[data_type][count]}" + except KeyError: + _LOGGER.error( + "Climate %s: Unable to find a data type matching count value %s, try a custom type", + name, + count, + ) + continue - add_entities( - [ - ModbusThermostat( - hub, - name, - modbus_slave, - target_temp_register, - current_temp_register, - current_temp_register_type, - data_type, + try: + size = struct.calcsize(structure) + except struct.error as err: + _LOGGER.error("Error in sensor %s structure: %s", name, err) + continue + + if count * 2 != size: + _LOGGER.error( + "Structure size (%d bytes) mismatch registers count (%d words)", + size, count, - precision, - scale, - offset, - unit, - max_temp, - min_temp, - temp_step, ) - ], - True, - ) + continue + + entity[CONF_STRUCTURE] = structure + entities.append(ModbusThermostat(hub, entity)) + + async_add_entities(entities) class ModbusThermostat(ClimateEntity): @@ -120,65 +107,54 @@ class ModbusThermostat(ClimateEntity): def __init__( self, - hub, - name, - modbus_slave, - target_temp_register, - current_temp_register, - current_temp_register_type, - data_type, - count, - precision, - scale, - offset, - unit, - max_temp, - min_temp, - temp_step, + hub: ModbusHub, + config: Dict[str, Any], ): - """Initialize the unit.""" - self._hub = hub - self._name = name - self._slave = modbus_slave - self._target_temperature_register = target_temp_register - self._current_temperature_register = current_temp_register - self._current_temperature_register_type = current_temp_register_type + """Initialize the modbus thermostat.""" + self._hub: ModbusHub = hub + self._name = config[CONF_NAME] + self._slave = config[CONF_SLAVE] + self._target_temperature_register = config[CONF_TARGET_TEMP] + self._current_temperature_register = config[CONF_CURRENT_TEMP] + self._current_temperature_register_type = config[ + CONF_CURRENT_TEMP_REGISTER_TYPE + ] self._target_temperature = None self._current_temperature = None - self._data_type = data_type - self._count = int(count) - self._precision = precision - self._scale = scale - self._offset = offset - self._unit = unit - self._max_temp = max_temp - self._min_temp = min_temp - self._temp_step = temp_step - self._structure = ">f" + self._data_type = config[CONF_DATA_TYPE] + self._structure = config[CONF_STRUCTURE] + self._count = config[CONF_DATA_COUNT] + self._precision = config[CONF_PRECISION] + self._scale = config[CONF_SCALE] + self._scan_interval = timedelta(seconds=config[CONF_SCAN_INTERVAL]) + self._offset = config[CONF_OFFSET] + self._unit = config[CONF_UNIT] + self._max_temp = config[CONF_MAX_TEMP] + self._min_temp = config[CONF_MIN_TEMP] + self._temp_step = config[CONF_STEP] self._available = True - data_types = { - DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}, - DATA_TYPE_UINT: {1: "H", 2: "I", 4: "Q"}, - DATA_TYPE_FLOAT: {1: "e", 2: "f", 4: "d"}, - } + async def async_added_to_hass(self): + """Handle entity which will be added.""" + async_track_time_interval( + self.hass, lambda arg: self._update(), self._scan_interval + ) - self._structure = f">{data_types[self._data_type][self._count]}" + @property + def should_poll(self): + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + + # Handle polling directly in this entity + return False @property def supported_features(self): """Return the list of supported features.""" return SUPPORT_TARGET_TEMPERATURE - def update(self): - """Update Target & Current Temperature.""" - self._target_temperature = self._read_register( - CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register - ) - self._current_temperature = self._read_register( - self._current_temperature_register_type, self._current_temperature_register - ) - @property def hvac_mode(self): """Return the current HVAC mode.""" @@ -189,6 +165,11 @@ class ModbusThermostat(ClimateEntity): """Return the possible HVAC modes.""" return [HVAC_MODE_AUTO] + def set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + # Home Assistant expects this method. + # We'll keep it here to avoid getting exceptions. + @property def name(self): """Return the name of the climate device.""" @@ -234,12 +215,24 @@ class ModbusThermostat(ClimateEntity): byte_string = struct.pack(self._structure, target_temperature) register_value = struct.unpack(">h", byte_string[0:2])[0] self._write_register(self._target_temperature_register, register_value) + self._update() @property def available(self) -> bool: """Return True if entity is available.""" return self._available + def _update(self): + """Update Target & Current Temperature.""" + self._target_temperature = self._read_register( + CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register + ) + self._current_temperature = self._read_register( + self._current_temperature_register_type, self._current_temperature_register + ) + + self.schedule_update_ha_state() + def _read_register(self, register_type, register) -> Optional[float]: """Read register using the Modbus hub slave.""" try: diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index dc29dd626ae..e79b69bbb87 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -55,6 +55,11 @@ CONF_ADDRESS = "address" # sensor.py # CONF_DATA_TYPE = "data_type" +DEFAULT_STRUCT_FORMAT = { + DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}, + DATA_TYPE_UINT: {1: "H", 2: "I", 4: "Q"}, + DATA_TYPE_FLOAT: {1: "e", 2: "f", 4: "d"}, +} # switch.py CONF_STATE_OFF = "state_off" @@ -63,6 +68,7 @@ CONF_VERIFY_REGISTER = "verify_register" CONF_VERIFY_STATE = "verify_state" # climate.py +CONF_CLIMATES = "climates" CONF_TARGET_TEMP = "target_temp_register" CONF_CURRENT_TEMP = "current_temp_register" CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type" @@ -72,6 +78,8 @@ CONF_UNIT = "temperature_unit" CONF_MAX_TEMP = "max_temp" CONF_MIN_TEMP = "min_temp" CONF_STEP = "temp_step" +DEFAULT_STRUCTURE_PREFIX = ">f" +DEFAULT_TEMP_UNIT = "C" # cover.py CONF_STATE_OPEN = "state_open" diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 9b367292fcf..bd6b5e349c3 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -37,6 +37,7 @@ from .const import ( DATA_TYPE_STRING, DATA_TYPE_UINT, DEFAULT_HUB, + DEFAULT_STRUCT_FORMAT, MODBUS_DOMAIN, ) @@ -99,21 +100,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Modbus sensors.""" sensors = [] - data_types = { - DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}, - DATA_TYPE_UINT: {1: "H", 2: "I", 4: "Q"}, - DATA_TYPE_FLOAT: {1: "e", 2: "f", 4: "d"}, - } for register in config[CONF_REGISTERS]: - structure = ">i" if register[CONF_DATA_TYPE] == DATA_TYPE_STRING: structure = str(register[CONF_COUNT] * 2) + "s" elif register[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM: try: - structure = ( - f">{data_types[register[CONF_DATA_TYPE]][register[CONF_COUNT]]}" - ) + structure = f">{DEFAULT_STRUCT_FORMAT[register[CONF_DATA_TYPE]][register[CONF_COUNT]]}" except KeyError: _LOGGER.error( "Unable to detect data type for %s sensor, try a custom type",