Add custom data type support into Modbus climate (#32439)

pull/41440/head
Vladimír Záhradník 2020-10-07 22:43:16 +02:00 committed by GitHub
parent 3d9d90c4c8
commit ba84d0bf5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 173 additions and 127 deletions

View File

@ -20,6 +20,7 @@ from homeassistant.const import (
CONF_PORT, CONF_PORT,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
CONF_SLAVE, CONF_SLAVE,
CONF_STRUCTURE,
CONF_TIMEOUT, CONF_TIMEOUT,
CONF_TYPE, CONF_TYPE,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
@ -37,19 +38,38 @@ from .const import (
CALL_TYPE_REGISTER_INPUT, CALL_TYPE_REGISTER_INPUT,
CONF_BAUDRATE, CONF_BAUDRATE,
CONF_BYTESIZE, CONF_BYTESIZE,
CONF_CLIMATES,
CONF_CURRENT_TEMP,
CONF_CURRENT_TEMP_REGISTER_TYPE,
CONF_DATA_COUNT,
CONF_DATA_TYPE,
CONF_INPUT_TYPE, CONF_INPUT_TYPE,
CONF_MAX_TEMP,
CONF_MIN_TEMP,
CONF_OFFSET,
CONF_PARITY, CONF_PARITY,
CONF_PRECISION,
CONF_REGISTER, CONF_REGISTER,
CONF_SCALE,
CONF_STATE_CLOSED, CONF_STATE_CLOSED,
CONF_STATE_CLOSING, CONF_STATE_CLOSING,
CONF_STATE_OPEN, CONF_STATE_OPEN,
CONF_STATE_OPENING, CONF_STATE_OPENING,
CONF_STATUS_REGISTER, CONF_STATUS_REGISTER,
CONF_STATUS_REGISTER_TYPE, CONF_STATUS_REGISTER_TYPE,
CONF_STEP,
CONF_STOPBITS, CONF_STOPBITS,
CONF_TARGET_TEMP,
CONF_UNIT,
DATA_TYPE_CUSTOM,
DATA_TYPE_FLOAT,
DATA_TYPE_INT,
DATA_TYPE_UINT,
DEFAULT_HUB, DEFAULT_HUB,
DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,
DEFAULT_SLAVE, DEFAULT_SLAVE,
DEFAULT_STRUCTURE_PREFIX,
DEFAULT_TEMP_UNIT,
MODBUS_DOMAIN as DOMAIN, MODBUS_DOMAIN as DOMAIN,
SERVICE_WRITE_COIL, SERVICE_WRITE_COIL,
SERVICE_WRITE_REGISTER, SERVICE_WRITE_REGISTER,
@ -59,6 +79,33 @@ _LOGGER = logging.getLogger(__name__)
BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string}) 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( COVERS_SCHEMA = vol.All(
cv.has_at_least_one_key(CALL_TYPE_COIL, CONF_REGISTER), cv.has_at_least_one_key(CALL_TYPE_COIL, CONF_REGISTER),
vol.Schema( vol.Schema(
@ -94,6 +141,7 @@ SERIAL_SCHEMA = BASE_SCHEMA.extend(
vol.Required(CONF_STOPBITS): vol.Any(1, 2), vol.Required(CONF_STOPBITS): vol.Any(1, 2),
vol.Required(CONF_TYPE): "serial", vol.Required(CONF_TYPE): "serial",
vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, 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]), 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.Required(CONF_TYPE): vol.Any("tcp", "udp", "rtuovertcp"),
vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout,
vol.Optional(CONF_DELAY, default=0): cv.positive_int, 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]), 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) hub_collect[conf_hub[CONF_NAME]] = ModbusHub(conf_hub)
# load platforms # 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: if conf_key in conf_hub:
load_platform(hass, component, DOMAIN, conf_hub, config) load_platform(hass, component, DOMAIN, conf_hub, config)

View File

@ -1,13 +1,13 @@
"""Support for Generic Modbus Thermostats.""" """Support for Generic Modbus Thermostats."""
from datetime import timedelta
import logging import logging
import struct import struct
from typing import Optional from typing import Any, Dict, Optional
from pymodbus.exceptions import ConnectionException, ModbusException from pymodbus.exceptions import ConnectionException, ModbusException
from pymodbus.pdu import ExceptionResponse 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 ( from homeassistant.components.climate.const import (
HVAC_MODE_AUTO, HVAC_MODE_AUTO,
SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE,
@ -15,20 +15,28 @@ from homeassistant.components.climate.const import (
from homeassistant.const import ( from homeassistant.const import (
ATTR_TEMPERATURE, ATTR_TEMPERATURE,
CONF_NAME, CONF_NAME,
CONF_SCAN_INTERVAL,
CONF_SLAVE, CONF_SLAVE,
CONF_STRUCTURE,
TEMP_CELSIUS, TEMP_CELSIUS,
TEMP_FAHRENHEIT, 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 ( from .const import (
CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_HOLDING,
CALL_TYPE_REGISTER_INPUT, CALL_TYPE_REGISTER_INPUT,
CONF_CLIMATES,
CONF_CURRENT_TEMP, CONF_CURRENT_TEMP,
CONF_CURRENT_TEMP_REGISTER_TYPE, CONF_CURRENT_TEMP_REGISTER_TYPE,
CONF_DATA_COUNT, CONF_DATA_COUNT,
CONF_DATA_TYPE, CONF_DATA_TYPE,
CONF_HUB,
CONF_MAX_TEMP, CONF_MAX_TEMP,
CONF_MIN_TEMP, CONF_MIN_TEMP,
CONF_OFFSET, CONF_OFFSET,
@ -37,82 +45,61 @@ from .const import (
CONF_STEP, CONF_STEP,
CONF_TARGET_TEMP, CONF_TARGET_TEMP,
CONF_UNIT, CONF_UNIT,
DATA_TYPE_FLOAT, DATA_TYPE_CUSTOM,
DATA_TYPE_INT, DEFAULT_STRUCT_FORMAT,
DATA_TYPE_UINT,
DEFAULT_HUB,
MODBUS_DOMAIN, MODBUS_DOMAIN,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(
{ hass: HomeAssistantType,
vol.Required(CONF_CURRENT_TEMP): cv.positive_int, config: ConfigType,
vol.Required(CONF_NAME): cv.string, async_add_entities,
vol.Required(CONF_SLAVE): cv.positive_int, discovery_info: Optional[DiscoveryInfoType] = None,
vol.Required(CONF_TARGET_TEMP): cv.positive_int, ):
vol.Optional(CONF_DATA_COUNT, default=2): cv.positive_int, """Read configuration and create Modbus climate."""
vol.Optional( if discovery_info is None:
CONF_CURRENT_TEMP_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING return
): 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,
}
)
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): if data_type != DATA_TYPE_CUSTOM:
"""Set up the Modbus Thermostat Platform.""" try:
name = config[CONF_NAME] structure = f">{DEFAULT_STRUCT_FORMAT[data_type][count]}"
modbus_slave = config[CONF_SLAVE] except KeyError:
target_temp_register = config[CONF_TARGET_TEMP] _LOGGER.error(
current_temp_register = config[CONF_CURRENT_TEMP] "Climate %s: Unable to find a data type matching count value %s, try a custom type",
current_temp_register_type = config[CONF_CURRENT_TEMP_REGISTER_TYPE] name,
data_type = config[CONF_DATA_TYPE] count,
count = config[CONF_DATA_COUNT] )
precision = config[CONF_PRECISION] continue
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]
add_entities( try:
[ size = struct.calcsize(structure)
ModbusThermostat( except struct.error as err:
hub, _LOGGER.error("Error in sensor %s structure: %s", name, err)
name, continue
modbus_slave,
target_temp_register, if count * 2 != size:
current_temp_register, _LOGGER.error(
current_temp_register_type, "Structure size (%d bytes) mismatch registers count (%d words)",
data_type, size,
count, count,
precision,
scale,
offset,
unit,
max_temp,
min_temp,
temp_step,
) )
], continue
True,
) entity[CONF_STRUCTURE] = structure
entities.append(ModbusThermostat(hub, entity))
async_add_entities(entities)
class ModbusThermostat(ClimateEntity): class ModbusThermostat(ClimateEntity):
@ -120,65 +107,54 @@ class ModbusThermostat(ClimateEntity):
def __init__( def __init__(
self, self,
hub, hub: ModbusHub,
name, config: Dict[str, Any],
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,
): ):
"""Initialize the unit.""" """Initialize the modbus thermostat."""
self._hub = hub self._hub: ModbusHub = hub
self._name = name self._name = config[CONF_NAME]
self._slave = modbus_slave self._slave = config[CONF_SLAVE]
self._target_temperature_register = target_temp_register self._target_temperature_register = config[CONF_TARGET_TEMP]
self._current_temperature_register = current_temp_register self._current_temperature_register = config[CONF_CURRENT_TEMP]
self._current_temperature_register_type = current_temp_register_type self._current_temperature_register_type = config[
CONF_CURRENT_TEMP_REGISTER_TYPE
]
self._target_temperature = None self._target_temperature = None
self._current_temperature = None self._current_temperature = None
self._data_type = data_type self._data_type = config[CONF_DATA_TYPE]
self._count = int(count) self._structure = config[CONF_STRUCTURE]
self._precision = precision self._count = config[CONF_DATA_COUNT]
self._scale = scale self._precision = config[CONF_PRECISION]
self._offset = offset self._scale = config[CONF_SCALE]
self._unit = unit self._scan_interval = timedelta(seconds=config[CONF_SCAN_INTERVAL])
self._max_temp = max_temp self._offset = config[CONF_OFFSET]
self._min_temp = min_temp self._unit = config[CONF_UNIT]
self._temp_step = temp_step self._max_temp = config[CONF_MAX_TEMP]
self._structure = ">f" self._min_temp = config[CONF_MIN_TEMP]
self._temp_step = config[CONF_STEP]
self._available = True self._available = True
data_types = { async def async_added_to_hass(self):
DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}, """Handle entity which will be added."""
DATA_TYPE_UINT: {1: "H", 2: "I", 4: "Q"}, async_track_time_interval(
DATA_TYPE_FLOAT: {1: "e", 2: "f", 4: "d"}, 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 @property
def supported_features(self): def supported_features(self):
"""Return the list of supported features.""" """Return the list of supported features."""
return SUPPORT_TARGET_TEMPERATURE 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 @property
def hvac_mode(self): def hvac_mode(self):
"""Return the current HVAC mode.""" """Return the current HVAC mode."""
@ -189,6 +165,11 @@ class ModbusThermostat(ClimateEntity):
"""Return the possible HVAC modes.""" """Return the possible HVAC modes."""
return [HVAC_MODE_AUTO] 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 @property
def name(self): def name(self):
"""Return the name of the climate device.""" """Return the name of the climate device."""
@ -234,12 +215,24 @@ class ModbusThermostat(ClimateEntity):
byte_string = struct.pack(self._structure, target_temperature) byte_string = struct.pack(self._structure, target_temperature)
register_value = struct.unpack(">h", byte_string[0:2])[0] register_value = struct.unpack(">h", byte_string[0:2])[0]
self._write_register(self._target_temperature_register, register_value) self._write_register(self._target_temperature_register, register_value)
self._update()
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
return self._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]: def _read_register(self, register_type, register) -> Optional[float]:
"""Read register using the Modbus hub slave.""" """Read register using the Modbus hub slave."""
try: try:

View File

@ -55,6 +55,11 @@ CONF_ADDRESS = "address"
# sensor.py # sensor.py
# CONF_DATA_TYPE = "data_type" # 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 # switch.py
CONF_STATE_OFF = "state_off" CONF_STATE_OFF = "state_off"
@ -63,6 +68,7 @@ CONF_VERIFY_REGISTER = "verify_register"
CONF_VERIFY_STATE = "verify_state" CONF_VERIFY_STATE = "verify_state"
# climate.py # climate.py
CONF_CLIMATES = "climates"
CONF_TARGET_TEMP = "target_temp_register" CONF_TARGET_TEMP = "target_temp_register"
CONF_CURRENT_TEMP = "current_temp_register" CONF_CURRENT_TEMP = "current_temp_register"
CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type" CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type"
@ -72,6 +78,8 @@ CONF_UNIT = "temperature_unit"
CONF_MAX_TEMP = "max_temp" CONF_MAX_TEMP = "max_temp"
CONF_MIN_TEMP = "min_temp" CONF_MIN_TEMP = "min_temp"
CONF_STEP = "temp_step" CONF_STEP = "temp_step"
DEFAULT_STRUCTURE_PREFIX = ">f"
DEFAULT_TEMP_UNIT = "C"
# cover.py # cover.py
CONF_STATE_OPEN = "state_open" CONF_STATE_OPEN = "state_open"

View File

@ -37,6 +37,7 @@ from .const import (
DATA_TYPE_STRING, DATA_TYPE_STRING,
DATA_TYPE_UINT, DATA_TYPE_UINT,
DEFAULT_HUB, DEFAULT_HUB,
DEFAULT_STRUCT_FORMAT,
MODBUS_DOMAIN, MODBUS_DOMAIN,
) )
@ -99,21 +100,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def setup_platform(hass, config, add_entities, discovery_info=None): def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Modbus sensors.""" """Set up the Modbus sensors."""
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]: for register in config[CONF_REGISTERS]:
structure = ">i"
if register[CONF_DATA_TYPE] == DATA_TYPE_STRING: if register[CONF_DATA_TYPE] == DATA_TYPE_STRING:
structure = str(register[CONF_COUNT] * 2) + "s" structure = str(register[CONF_COUNT] * 2) + "s"
elif register[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM: elif register[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM:
try: try:
structure = ( structure = f">{DEFAULT_STRUCT_FORMAT[register[CONF_DATA_TYPE]][register[CONF_COUNT]]}"
f">{data_types[register[CONF_DATA_TYPE]][register[CONF_COUNT]]}"
)
except KeyError: except KeyError:
_LOGGER.error( _LOGGER.error(
"Unable to detect data type for %s sensor, try a custom type", "Unable to detect data type for %s sensor, try a custom type",