Files
HomeAssistant/custom_components/meross_cloud/sensor.py
T
2025-09-11 10:47:34 +03:00

397 lines
17 KiB
Python

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