hass-core/homeassistant/components/mysensors/climate.py
functionpointer c01e01f797
MySensors config flow (#45421)
* MySensors: Add type annotations

Adds a bunch of type annotations that were created
while understanding the code.

* MySensors: Change GatewayId to string

In preparation for config flow.
The GatewayId used to be id(gateway).

With config flows, every gateway will have its own
ConfigEntry. Every ConfigEntry has a unique id.
Thus we would have two separate but one-to-one related ID systems.

This commit removes this unneeded duplication by using the id of the ConfigEntry
as GatewayId.

* MySensors: Add unique_id to all entities

This allows entities to work well with the frontend.

* MySensors: Add device_info to all entities

Entities belonging to the same node_id will now by grouped as a device.

* MySensors: clean up device.py a bit

* MySensors: Add config flow support

With this change the MySensors can be fully configured from the GUI.

Legacy configuration.yaml configs will be migrated by reading them once.
Note that custom node names are not migrated. Users will have to re-enter
the names in the front-end.
Since there is no straight-forward way to configure global settings,
all previously global settings are now per-gateway. These settings include:
- MQTT retain
- optimistic
- persistence enable
- MySensors version

When a MySensors integration is loaded, it works as follows:
1. __init__.async_setup_entry is called
2. for every platform, async_forward_entry_setup is called
3. the platform's async_setup_entry is called
4. __init__.setup_mysensors_platform is called
5. the entity's constructor (e.g. MySensorsCover) is called
6. the created entity is stored in a dict in the hass object

* MySensors: Fix linter errors

* MySensors: Remove unused import

* MySensors: Feedback from @MartinHjelmare

* MySensors: Multi-step config flow

* MySensors: More feedback

* MySensors: Move all storage in hass object under DOMAIN

The integration now stores everything under hass.data["mysensors"]
instead of using several top level keys.

* MySensors: await shutdown of gateway instead of creating a task

* MySensors: Rename Ethernet to TCP

* MySensors: Absolute imports and cosmetic changes

* MySensors: fix gw_stop

* MySensors: Allow user to specify persistence file

* MySensors: Nicer log message

* MySensors: Add lots of unit tests

* MySensors: Fix legacy import of persistence file name

Turns out tests help to find bugs :D

* MySensors: Improve test coverage

* MySensors: Use json persistence files by default

* MySensors: Code style improvements

* MySensors: Stop adding attributes to existing objects

This commit removes the extra attributes that were being
added to the gateway objects from pymysensors.

Most attributes were easy to remove, except for the gateway id.
The MySensorsDevice class needs the gateway id as it is part of its DevId
as well as the unique_id and device_info.
Most MySensorsDevices actually end up being Entities.
Entities have access to their ConfigEntry via self.platform.config_entry.

However, the device_tracker platform does not become an Entity.
For this reason, the gateway id is not fetched from self.plaform but
given as an argument.

Additionally, MySensorsDevices expose the address of the gateway
(CONF_DEVICE). Entities can easily fetch this information via self.platform,
but the device_tracker cannot. This commit chooses to remove the gateway
address from device_tracker. While this could in theory break some automations,
the simplicity of this solution was deemed worth it.
The alternative of adding the entire ConfigEntry as an argument to MySensorsDevices
is not viable, because device_tracker is initialized by the async_setup_scanner function
that isn't supplied a ConfigEntry. It only gets discovery_info.
Adding the entire ConfigEntry doesn't seem appropriate for this edge case.

* MySensors: Fix gw_stop and the translations

* MySensors: Fix incorrect function calls

* MySensors: Fewer comments in const.py

* MySensors: Remove union from _get_gateway and remove id from try_connect

* MySensors: Deprecate nodes option in configuration.yaml

* MySensors: Use version parser from packaging

* MySensors: Remove prefix from unique_id and change some private property names

* MySensors: Change _get_gateway function signature

* MySensors: add packaging==20.8 for the version parser

* MySensors: Rename some stuff

* MySensors: use pytest.mark.parametrize

* MySensors: Clean up test cases

* MySensors: Remove unneeded parameter from devices

* Revert "MySensors: add packaging==20.8 for the version parser"

This reverts commit 6b200ee01a.

* MySensors: Use core interface for testing configuration.yaml import

* MySensors: Fix test_init

* MySensors: Rename a few variables

* MySensors: cosmetic changes

* MySensors: Update strings.json

* MySensors: Still more feedback from @MartinHjelmare

* MySensors: Remove unused strings

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

* MySensors: Fix typo and remove another unused string

* MySensors: More strings.json

* MySensors: Fix gateway ready handler

* MySensors: Add duplicate detection to config flows

* MySensors: Deal with non-existing topics and ports.

Includes unit tests for these cases.

* MySensors: Use awesomeversion instead of packaging

* Add string already_configured

* MySensors: Abort config flow when config is found to be invalid while importing

* MySensors: Copy all error messages to also be abort messages

All error strings may now also be used as an abort reason,
so the strings should be defined

* Use string references

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2021-02-05 22:13:57 +01:00

212 lines
7.3 KiB
Python

"""MySensors platform that offers a Climate (MySensors-HVAC) component."""
from typing import Callable
from homeassistant.components import mysensors
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
DOMAIN,
HVAC_MODE_AUTO,
HVAC_MODE_COOL,
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
SUPPORT_FAN_MODE,
SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
from homeassistant.components.mysensors import on_unload
from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import HomeAssistantType
DICT_HA_TO_MYS = {
HVAC_MODE_AUTO: "AutoChangeOver",
HVAC_MODE_COOL: "CoolOn",
HVAC_MODE_HEAT: "HeatOn",
HVAC_MODE_OFF: "Off",
}
DICT_MYS_TO_HA = {
"AutoChangeOver": HVAC_MODE_AUTO,
"CoolOn": HVAC_MODE_COOL,
"HeatOn": HVAC_MODE_HEAT,
"Off": HVAC_MODE_OFF,
}
FAN_LIST = ["Auto", "Min", "Normal", "Max"]
OPERATION_LIST = [HVAC_MODE_OFF, HVAC_MODE_AUTO, HVAC_MODE_COOL, HVAC_MODE_HEAT]
async def async_setup_entry(
hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
):
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
async def async_discover(discovery_info):
"""Discover and add a MySensors climate."""
mysensors.setup_mysensors_platform(
hass,
DOMAIN,
discovery_info,
MySensorsHVAC,
async_add_entities=async_add_entities,
)
await on_unload(
hass,
config_entry,
async_dispatcher_connect(
hass,
MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN),
async_discover,
),
)
class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity):
"""Representation of a MySensors HVAC."""
@property
def supported_features(self):
"""Return the list of supported features."""
features = 0
set_req = self.gateway.const.SetReq
if set_req.V_HVAC_SPEED in self._values:
features = features | SUPPORT_FAN_MODE
if (
set_req.V_HVAC_SETPOINT_COOL in self._values
and set_req.V_HVAC_SETPOINT_HEAT in self._values
):
features = features | SUPPORT_TARGET_TEMPERATURE_RANGE
else:
features = features | SUPPORT_TARGET_TEMPERATURE
return features
@property
def temperature_unit(self):
"""Return the unit of measurement."""
return TEMP_CELSIUS if self.hass.config.units.is_metric else TEMP_FAHRENHEIT
@property
def current_temperature(self):
"""Return the current temperature."""
value = self._values.get(self.gateway.const.SetReq.V_TEMP)
if value is not None:
value = float(value)
return value
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
set_req = self.gateway.const.SetReq
if (
set_req.V_HVAC_SETPOINT_COOL in self._values
and set_req.V_HVAC_SETPOINT_HEAT in self._values
):
return None
temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL)
if temp is None:
temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
return float(temp) if temp is not None else None
@property
def target_temperature_high(self):
"""Return the highbound target temperature we try to reach."""
set_req = self.gateway.const.SetReq
if set_req.V_HVAC_SETPOINT_HEAT in self._values:
temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL)
return float(temp) if temp is not None else None
@property
def target_temperature_low(self):
"""Return the lowbound target temperature we try to reach."""
set_req = self.gateway.const.SetReq
if set_req.V_HVAC_SETPOINT_COOL in self._values:
temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
return float(temp) if temp is not None else None
@property
def hvac_mode(self):
"""Return current operation ie. heat, cool, idle."""
return self._values.get(self.value_type)
@property
def hvac_modes(self):
"""List of available operation modes."""
return OPERATION_LIST
@property
def fan_mode(self):
"""Return the fan setting."""
return self._values.get(self.gateway.const.SetReq.V_HVAC_SPEED)
@property
def fan_modes(self):
"""List of available fan modes."""
return FAN_LIST
async def async_set_temperature(self, **kwargs):
"""Set new target temperature."""
set_req = self.gateway.const.SetReq
temp = kwargs.get(ATTR_TEMPERATURE)
low = kwargs.get(ATTR_TARGET_TEMP_LOW)
high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
heat = self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
cool = self._values.get(set_req.V_HVAC_SETPOINT_COOL)
updates = []
if temp is not None:
if heat is not None:
# Set HEAT Target temperature
value_type = set_req.V_HVAC_SETPOINT_HEAT
elif cool is not None:
# Set COOL Target temperature
value_type = set_req.V_HVAC_SETPOINT_COOL
if heat is not None or cool is not None:
updates = [(value_type, temp)]
elif all(val is not None for val in (low, high, heat, cool)):
updates = [
(set_req.V_HVAC_SETPOINT_HEAT, low),
(set_req.V_HVAC_SETPOINT_COOL, high),
]
for value_type, value in updates:
self.gateway.set_child_value(
self.node_id, self.child_id, value_type, value, ack=1
)
if self.assumed_state:
# Optimistically assume that device has changed state
self._values[value_type] = value
self.async_write_ha_state()
async def async_set_fan_mode(self, fan_mode):
"""Set new target temperature."""
set_req = self.gateway.const.SetReq
self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode, ack=1
)
if self.assumed_state:
# Optimistically assume that device has changed state
self._values[set_req.V_HVAC_SPEED] = fan_mode
self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode):
"""Set new target temperature."""
self.gateway.set_child_value(
self.node_id,
self.child_id,
self.value_type,
DICT_HA_TO_MYS[hvac_mode],
ack=1,
)
if self.assumed_state:
# Optimistically assume that device has changed state
self._values[self.value_type] = hvac_mode
self.async_write_ha_state()
async def async_update(self):
"""Update the controller with the latest value from a sensor."""
await super().async_update()
self._values[self.value_type] = DICT_MYS_TO_HA[self._values[self.value_type]]