hass-core/homeassistant/components/modbus/climate.py
jan iversen ffdfc521b9
Allow discovery configuration of modbus platforms (#46591)
* Change modbus configuration to new style.

The old (frozen) configuration is still supported, but when detected a big
warning is issued that it will soon be removed. This allows users to change
their configuration at their pace.

Clean configuration SCHEMAs and move common modbus parts
to MODBUS_SCHEMA (renamed from BASE_SCHEMA).

Add BASE_COMPONENT_SCHEMA to ensure common configuration of components.
All component define e.g. NAME, move these to a common schema.
change components (binary_sensor, sensor, switch) to new config

Add test set for modbus itself (old config and discovery_info).
Add test of devices discovery_info configuration

* Update discovery_info configuration for binary_sensor.

* Update discovery_info configuration for sensor.

* Update discovery_info configuration for switch.

* Review comments.

* update due to change in core

* flake8 problem.

* Correct log message.

* add should_poll property.

* Fix polling for Modbus binary sensor

* Fix polling for Modbus sensor

* Fix polling for Modbus switch

* Fix switch.

* Fix pytest errors.

* Update homeassistant/components/modbus/binary_sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/modbus/binary_sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/modbus/modbus.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/modbus/sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/modbus/sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/modbus/sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/modbus/switch.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/modbus/switch.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/modbus/switch.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* ToogleEntity -> SwitchEntity and add abastract

* Update homeassistant/components/modbus/switch.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update tests/components/modbus/test_init.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* removed if/else in test.

* Remove other if.

Co-authored-by: Vladimir Zahradnik <vladimir@zahradnik.io>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2021-03-27 22:48:06 +01:00

284 lines
8.5 KiB
Python

"""Support for Generic Modbus Thermostats."""
from __future__ import annotations
from datetime import timedelta
import logging
import struct
from typing import Any
from pymodbus.exceptions import ConnectionException, ModbusException
from pymodbus.pdu import ExceptionResponse
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
HVAC_MODE_AUTO,
SUPPORT_TARGET_TEMPERATURE,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_NAME,
CONF_OFFSET,
CONF_SCAN_INTERVAL,
CONF_SLAVE,
CONF_STRUCTURE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import (
ConfigType,
DiscoveryInfoType,
HomeAssistantType,
)
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_MAX_TEMP,
CONF_MIN_TEMP,
CONF_PRECISION,
CONF_SCALE,
CONF_STEP,
CONF_TARGET_TEMP,
CONF_UNIT,
DATA_TYPE_CUSTOM,
DEFAULT_STRUCT_FORMAT,
MODBUS_DOMAIN,
)
from .modbus import ModbusHub
_LOGGER = logging.getLogger(__name__)
async def async_setup_platform(
hass: HomeAssistantType,
config: ConfigType,
async_add_entities,
discovery_info: DiscoveryInfoType | None = 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]
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
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,
)
continue
entity[CONF_STRUCTURE] = structure
entities.append(ModbusThermostat(hub, entity))
async_add_entities(entities)
class ModbusThermostat(ClimateEntity):
"""Representation of a Modbus Thermostat."""
def __init__(
self,
hub: ModbusHub,
config: dict[str, Any],
):
"""Initialize the modbus thermostat."""
self._hub: ModbusHub = hub
self._name = config[CONF_NAME]
self._slave = config.get(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 = 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
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
)
@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
@property
def hvac_mode(self):
"""Return the current HVAC mode."""
return HVAC_MODE_AUTO
@property
def hvac_modes(self):
"""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."""
return self._name
@property
def current_temperature(self):
"""Return the current temperature."""
return self._current_temperature
@property
def target_temperature(self):
"""Return the target temperature."""
return self._target_temperature
@property
def temperature_unit(self):
"""Return the unit of measurement."""
return TEMP_FAHRENHEIT if self._unit == "F" else TEMP_CELSIUS
@property
def min_temp(self):
"""Return the minimum temperature."""
return self._min_temp
@property
def max_temp(self):
"""Return the maximum temperature."""
return self._max_temp
@property
def target_temperature_step(self):
"""Return the supported step of target temperature."""
return self._temp_step
def set_temperature(self, **kwargs):
"""Set new target temperature."""
target_temperature = int(
(kwargs.get(ATTR_TEMPERATURE) - self._offset) / self._scale
)
if target_temperature is None:
return
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) -> float | None:
"""Read register using the Modbus hub slave."""
try:
if register_type == CALL_TYPE_REGISTER_INPUT:
result = self._hub.read_input_registers(
self._slave, register, self._count
)
else:
result = self._hub.read_holding_registers(
self._slave, register, self._count
)
except ConnectionException:
self._available = False
return
if isinstance(result, (ModbusException, ExceptionResponse)):
self._available = False
return
byte_string = b"".join(
[x.to_bytes(2, byteorder="big") for x in result.registers]
)
val = struct.unpack(self._structure, byte_string)
if len(val) != 1 or not isinstance(val[0], (float, int)):
_LOGGER.error(
"Unable to parse result as a single int or float value; adjust your configuration. Result: %s",
str(val),
)
return -1
val = val[0]
register_value = format(
(self._scale * val) + self._offset, f".{self._precision}f"
)
register_value = float(register_value)
self._available = True
return register_value
def _write_register(self, register, value):
"""Write holding register using the Modbus hub slave."""
try:
self._hub.write_registers(self._slave, register, [value, 0])
except ConnectionException:
self._available = False
return
self._available = True