Initial Home Assistant Configuration
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from typing import Optional, Dict
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from meross_iot.controller.device import BaseDevice, GenericSubDevice, HubDevice
|
||||
from meross_iot.controller.mixins.consumption import ConsumptionXMixin
|
||||
from meross_iot.controller.mixins.electricity import ElectricityMixin
|
||||
from meross_iot.controller.subdevice import Ms100Sensor, Mts100v3Valve
|
||||
from meross_iot.manager import MerossManager
|
||||
from meross_iot.model.enums import OnlineStatus
|
||||
from meross_iot.model.exception import CommandTimeoutError
|
||||
from meross_iot.model.http.device import HttpDeviceInfo
|
||||
|
||||
from homeassistant.components.sensor import SensorStateClass, SensorEntity, SensorDeviceClass
|
||||
from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfPower
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from . import MerossDevice
|
||||
from .common import (DOMAIN, MANAGER, log_exception, HA_SENSOR,
|
||||
HA_SENSOR_POLL_INTERVAL_SECONDS, invoke_method_or_property, DEVICE_LIST_COORDINATOR)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(seconds=HA_SENSOR_POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
class GenericSensorWrapper(MerossDevice, SensorEntity):
|
||||
"""Wrapper class to adapt the a generic Meross sensor into the Homeassistant platform"""
|
||||
|
||||
def __init__(self,
|
||||
sensor_class: str,
|
||||
measurement_unit: Optional[str],
|
||||
device_method_or_property: str,
|
||||
state_class: str,
|
||||
device: BaseDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]],
|
||||
channel: int = 0):
|
||||
super().__init__(
|
||||
device=device,
|
||||
channel=channel,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
platform=HA_SENSOR,
|
||||
supplementary_classifiers=[sensor_class, measurement_unit])
|
||||
|
||||
# Make sure the given device supports exposes the device_method_or_property passed as arg
|
||||
if not hasattr(device, device_method_or_property):
|
||||
_LOGGER.error("The device %s (%s) does not expose property %s", device.uuid, device.name,
|
||||
device_method_or_property)
|
||||
raise ValueError(f"The device {device} does not expose property {device_method_or_property}")
|
||||
|
||||
self._device_method_or_property = device_method_or_property
|
||||
self._attr_native_unit_of_measurement = measurement_unit
|
||||
self._attr_device_class = sensor_class
|
||||
self._attr_state_class = state_class
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the entity."""
|
||||
return invoke_method_or_property(self._device, self._device_method_or_property)
|
||||
|
||||
|
||||
class Ms100TemperatureSensorWrapper(GenericSensorWrapper):
|
||||
def __init__(self, device: Ms100Sensor, device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]],
|
||||
channel: int = 0):
|
||||
super().__init__(
|
||||
sensor_class=SensorDeviceClass.TEMPERATURE,
|
||||
measurement_unit=UnitOfTemperature.CELSIUS,
|
||||
device_method_or_property='last_sampled_temperature',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
|
||||
class Ms100HumiditySensorWrapper(GenericSensorWrapper):
|
||||
def __init__(self, device: Ms100Sensor, device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]],
|
||||
channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.HUMIDITY,
|
||||
measurement_unit=PERCENTAGE,
|
||||
device_method_or_property='last_sampled_humidity',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
|
||||
class Mts100TemperatureSensorWrapper(GenericSensorWrapper):
|
||||
_device: Mts100v3Valve
|
||||
|
||||
def __init__(self, device: Mts100v3Valve,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]]):
|
||||
super().__init__(sensor_class=SensorDeviceClass.TEMPERATURE,
|
||||
measurement_unit=UnitOfTemperature.CELSIUS,
|
||||
device_method_or_property='last_sampled_temperature',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
device=device)
|
||||
|
||||
async def async_update(self):
|
||||
if self._device.online_status == OnlineStatus.ONLINE:
|
||||
try:
|
||||
_LOGGER.debug(f"Refreshing instant metrics for device {self.name}")
|
||||
await self._device.async_get_temperature()
|
||||
except CommandTimeoutError as e:
|
||||
log_exception(logger=_LOGGER, device=self._device)
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class ElectricitySensorDevice(ElectricityMixin, BaseDevice):
|
||||
""" Helper type """
|
||||
pass
|
||||
|
||||
|
||||
class EnergySensorDevice(ConsumptionXMixin, BaseDevice):
|
||||
""" Helper type """
|
||||
pass
|
||||
|
||||
|
||||
class PowerSensorWrapper(GenericSensorWrapper):
|
||||
_device: ElectricitySensorDevice
|
||||
|
||||
def __init__(self, device: ElectricitySensorDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]], channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.POWER,
|
||||
measurement_unit=UnitOfPower.WATT,
|
||||
device_method_or_property='get_last_sample',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
# For ElectricityMixin devices we need to explicitly call the async_get_instant_metrics
|
||||
async def async_update(self):
|
||||
if self._device.online_status == OnlineStatus.ONLINE:
|
||||
try:
|
||||
# We only call the explicit method if the sampled value is older than 10 seconds.
|
||||
power_info = self._device.get_last_sample(channel=self._channel_id)
|
||||
now = datetime.utcnow()
|
||||
if power_info is None or (now - power_info.sample_timestamp).total_seconds() > 10:
|
||||
# Force device refresh
|
||||
_LOGGER.debug(f"Refreshing instant metrics for device {self.name}")
|
||||
await self._device.async_get_instant_metrics(channel=self._channel_id)
|
||||
else:
|
||||
# Use the cached value
|
||||
_LOGGER.debug("Skipping data refresh for %s as its value is recent enough", self.name)
|
||||
|
||||
except CommandTimeoutError as e:
|
||||
log_exception(logger=_LOGGER, device=self._device)
|
||||
pass
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
sample = self._device.get_last_sample(channel=self._channel_id)
|
||||
if sample is not None:
|
||||
return sample.power
|
||||
|
||||
|
||||
class CurrentSensorWrapper(GenericSensorWrapper):
|
||||
_device: ElectricitySensorDevice
|
||||
|
||||
def __init__(self, device: ElectricitySensorDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]], channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.CURRENT,
|
||||
measurement_unit="A",
|
||||
device_method_or_property='get_last_sample',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
# For ElectricityMixin devices we need to explicitly call the async_Get_instant_metrics
|
||||
async def async_update(self):
|
||||
if self._device.online_status == OnlineStatus.ONLINE:
|
||||
try:
|
||||
# We only call the explicit method if the sampled value is older than 10 seconds.
|
||||
power_info = self._device.get_last_sample(channel=self._channel_id)
|
||||
now = datetime.utcnow()
|
||||
if power_info is None or (now - power_info.sample_timestamp).total_seconds() > 10:
|
||||
# Force device refresh
|
||||
_LOGGER.debug(f"Refreshing instant metrics for device {self.name}")
|
||||
await self._device.async_get_instant_metrics(channel=self._channel_id)
|
||||
else:
|
||||
# Use the cached value
|
||||
_LOGGER.debug(f"Skipping data refresh for {self.name} as its value is recent enough")
|
||||
|
||||
except CommandTimeoutError as e:
|
||||
log_exception(logger=_LOGGER, device=self._device)
|
||||
pass
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
sample = self._device.get_last_sample(channel=self._channel_id)
|
||||
if sample is not None:
|
||||
return sample.current
|
||||
return 0
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class VoltageSensorWrapper(GenericSensorWrapper):
|
||||
_device: ElectricitySensorDevice
|
||||
|
||||
def __init__(self, device: ElectricitySensorDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]], channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.VOLTAGE,
|
||||
measurement_unit="V",
|
||||
device_method_or_property='get_last_sample',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
# For ElectricityMixin devices we need to explicitly call the async_Get_instant_metrics
|
||||
async def async_update(self):
|
||||
if self._device.online_status == OnlineStatus.ONLINE:
|
||||
try:
|
||||
# We only call the explicit method if the sampled value is older than 10 seconds.
|
||||
power_info = self._device.get_last_sample(channel=self._channel_id)
|
||||
now = datetime.utcnow()
|
||||
if power_info is None or (now - power_info.sample_timestamp).total_seconds() > 10:
|
||||
# Force device refresh
|
||||
_LOGGER.debug(f"Refreshing instant metrics for device {self.name}")
|
||||
await self._device.async_get_instant_metrics(channel=self._channel_id)
|
||||
else:
|
||||
# Use the cached value
|
||||
_LOGGER.debug(f"Skipping data refresh for {self.name} as its value is recent enough")
|
||||
|
||||
except CommandTimeoutError as e:
|
||||
log_exception(logger=_LOGGER, device=self._device)
|
||||
pass
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
sample = self._device.get_last_sample(channel=self._channel_id)
|
||||
if sample is not None:
|
||||
return sample.voltage
|
||||
return 0
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class EnergySensorWrapper(GenericSensorWrapper):
|
||||
_device: EnergySensorDevice
|
||||
|
||||
def __init__(self, device: EnergySensorDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]], channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.ENERGY,
|
||||
measurement_unit="kWh",
|
||||
device_method_or_property='async_get_daily_power_consumption',
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
# Device properties
|
||||
self._daily_consumption = None
|
||||
|
||||
# For ElectricityMixin devices we need to explicitly call the async_Get_instant_metrics
|
||||
async def async_update(self):
|
||||
if self.online:
|
||||
await super().async_update()
|
||||
|
||||
_LOGGER.debug(f"Refreshing instant metrics for device {self.name}")
|
||||
self._daily_consumption = await self._device.async_get_daily_power_consumption(channel=self._channel_id)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
if self._daily_consumption is not None:
|
||||
today = datetime.today()
|
||||
total = 0
|
||||
daystart = datetime(year=today.year, month=today.month, day=today.day, hour=0, second=0)
|
||||
for x in self._daily_consumption:
|
||||
if x['date'] == daystart:
|
||||
total = x['total_consumption_kwh']
|
||||
return total
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class BatterySensorWrapper(GenericSensorWrapper):
|
||||
_device: GenericSubDevice
|
||||
|
||||
def __init__(self, device: GenericSubDevice,
|
||||
device_list_coordinator: DataUpdateCoordinator[Dict[str, HttpDeviceInfo]], channel: int = 0):
|
||||
super().__init__(sensor_class=SensorDeviceClass.BATTERY,
|
||||
measurement_unit="%",
|
||||
device_method_or_property='async_get_battery_life',
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device=device,
|
||||
device_list_coordinator=device_list_coordinator,
|
||||
channel=channel)
|
||||
|
||||
# Device properties
|
||||
self._battery_percentage = None
|
||||
|
||||
async def async_update(self):
|
||||
if self.online:
|
||||
await super().async_update()
|
||||
|
||||
_LOGGER.debug(f"Refreshing battery state info for device {self.name}")
|
||||
self._battery_percentage = await self._device.async_get_battery_life()
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
if self._battery_percentage is not None:
|
||||
return self._battery_percentage.remaining_charge
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ----------------------------------------------
|
||||
# PLATFORM METHODS
|
||||
# ----------------------------------------------
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities):
|
||||
def entity_adder_callback():
|
||||
"""Discover and adds new Meross entities"""
|
||||
manager: MerossManager = hass.data[DOMAIN][MANAGER] # type
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
devices = manager.find_devices()
|
||||
|
||||
new_entities = []
|
||||
|
||||
# For now, we handle the following sensors:
|
||||
# -> Temperature-Humidity (Ms100Sensor)
|
||||
# -> Power-sensing smart plugs (Mss310)
|
||||
# -> MTS100 Valve temperature (MTS100V3)
|
||||
humidity_temp_sensors = filter(lambda d: isinstance(d, Ms100Sensor), devices)
|
||||
mts100_temp_sensors = filter(lambda d: isinstance(d, Mts100v3Valve), devices)
|
||||
power_sensors = filter(lambda d: isinstance(d, ElectricityMixin), devices)
|
||||
energy_sensors = filter(lambda d: isinstance(d, ConsumptionXMixin), devices)
|
||||
subdevs = filter(lambda d: isinstance(d, GenericSubDevice), devices)
|
||||
|
||||
# Add MS100 Temperature & Humidity sensors
|
||||
for d in humidity_temp_sensors:
|
||||
new_entities.append(Ms100HumiditySensorWrapper(device=d, device_list_coordinator=coordinator, channel=0))
|
||||
new_entities.append(Ms100TemperatureSensorWrapper(device=d, device_list_coordinator=coordinator, channel=0))
|
||||
|
||||
# Add MTS100Valve Temperature sensors
|
||||
for d in mts100_temp_sensors:
|
||||
new_entities.append(Mts100TemperatureSensorWrapper(device=d, device_list_coordinator=coordinator))
|
||||
|
||||
# Add Power Sensors
|
||||
for d in power_sensors:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
new_entities.append(
|
||||
PowerSensorWrapper(device=d, device_list_coordinator=coordinator, channel=channel_index))
|
||||
new_entities.append(
|
||||
CurrentSensorWrapper(device=d, device_list_coordinator=coordinator, channel=channel_index))
|
||||
new_entities.append(
|
||||
VoltageSensorWrapper(device=d, device_list_coordinator=coordinator, channel=channel_index))
|
||||
|
||||
# Add Energy Sensors
|
||||
for d in energy_sensors:
|
||||
channels = [c.index for c in d.channels] if len(d.channels) > 0 else [0]
|
||||
for channel_index in channels:
|
||||
new_entities.append(
|
||||
EnergySensorWrapper(device=d, device_list_coordinator=coordinator, channel=channel_index))
|
||||
|
||||
# Add battery level sensors for subdevices
|
||||
for s in subdevs:
|
||||
new_entities.append(BatterySensorWrapper(device=s, device_list_coordinator=coordinator, channel=0))
|
||||
|
||||
unique_new_devs = filter(lambda d: d.unique_id not in hass.data[DOMAIN]["ADDED_ENTITIES_IDS"], new_entities)
|
||||
async_add_entities(list(unique_new_devs), True)
|
||||
|
||||
coordinator = hass.data[DOMAIN][DEVICE_LIST_COORDINATOR]
|
||||
coordinator.async_add_listener(entity_adder_callback)
|
||||
# Run the entity adder a first time during setup
|
||||
entity_adder_callback()
|
||||
|
||||
|
||||
# TODO: Implement entry unload
|
||||
# TODO: Unload entry
|
||||
# TODO: Remove entry
|
||||
|
||||
|
||||
def setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
pass
|
||||
Reference in New Issue
Block a user