HEX
Server: Apache/2.4.63 (Unix)
System: Linux Synopilou92 4.4.302+ #72806 SMP Mon Jul 21 23:16:00 CST 2025 x86_64
User: pilou92 (1026)
PHP: 8.0.30
Disabled: NONE
Upload Files
File: /volume1/docker/homeassistant2025/custom_components/sonoff/sensor.py
import asyncio
import time
from typing import Optional

from homeassistant.components.sensor import (
    SensorDeviceClass,
    SensorEntity,
    SensorStateClass,
)
from homeassistant.const import (
    PERCENTAGE,
    SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
    UnitOfElectricCurrent,
    UnitOfElectricPotential,
    UnitOfEnergy,
    UnitOfPower,
    UnitOfTemperature,
    UnitOfVolume,
)
from homeassistant.util import dt

from .core.const import DOMAIN
from .core.entity import XEntity
from .core.ewelink import SIGNAL_ADD_ENTITIES, XRegistry

PARALLEL_UPDATES = 0  # fix entity_platform parallel_updates Semaphore


async def async_setup_entry(hass, config_entry, add_entities):
    ewelink: XRegistry = hass.data[DOMAIN][config_entry.entry_id]
    ewelink.dispatcher_connect(
        SIGNAL_ADD_ENTITIES,
        lambda x: add_entities([e for e in x if isinstance(e, SensorEntity)]),
    )


DEVICE_CLASSES = {
    "battery": SensorDeviceClass.BATTERY,
    "battery_voltage": SensorDeviceClass.VOLTAGE,
    "current": SensorDeviceClass.CURRENT,
    "humidity": SensorDeviceClass.HUMIDITY,
    "outdoor_temp": SensorDeviceClass.TEMPERATURE,
    "power": SensorDeviceClass.POWER,
    "rssi": SensorDeviceClass.SIGNAL_STRENGTH,
    "temperature": SensorDeviceClass.TEMPERATURE,
    "voltage": SensorDeviceClass.VOLTAGE,
}

UNITS = {
    "battery": PERCENTAGE,
    "battery_voltage": UnitOfElectricPotential.VOLT,
    "current": UnitOfElectricCurrent.AMPERE,
    "humidity": PERCENTAGE,
    "outdoor_temp": UnitOfTemperature.CELSIUS,
    "power": UnitOfPower.WATT,
    "rssi": SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
    "temperature": UnitOfTemperature.CELSIUS,
    "voltage": UnitOfElectricPotential.VOLT,
    "water": UnitOfVolume.LITERS,
}


class XSensor(XEntity, SensorEntity):
    """Class can convert string sensor value to float, multiply it and round if
    needed. Also class can filter incoming values using zigbee-like reporting
    logic: min report interval, max report interval, reportable change value.
    """

    multiply: float = None
    round: int = None

    report_ts = None
    report_mint = None
    report_maxt = None
    report_delta = None
    report_value = None

    def __init__(self, ewelink: XRegistry, device: dict):
        if self.param and self.uid is None:
            self.uid = self.param

        # remove tailing _1 _2 _3 _4
        default_class = self.uid.rstrip("_01234")

        if device["params"].get(self.param) in ("on", "off"):
            default_class = None

        if device_class := DEVICE_CLASSES.get(default_class):
            self._attr_device_class = device_class

        if default_class in UNITS:
            # by default all sensors with units is measurement sensors
            self._attr_state_class = SensorStateClass.MEASUREMENT
            self._attr_native_unit_of_measurement = UNITS[default_class]

        XEntity.__init__(self, ewelink, device)

        reporting = device.get("reporting", {}).get(self.uid)
        if reporting:
            self.report_mint, self.report_maxt, self.report_delta = reporting
            self.report_ts = time.time()
            self._attr_should_poll = True

    def set_state(self, params: dict = None, value: float = None):
        if params:
            value = params[self.param]
            if self.native_unit_of_measurement and isinstance(value, str):
                try:
                    # https://github.com/AlexxIT/SonoffLAN/issues/1061
                    value = float(value)
                except Exception:
                    return
            if self.multiply:
                value *= self.multiply
            if self.round is not None:
                # convert to int when round is zero
                value = round(value, self.round or None)

        if self.report_ts is not None:
            ts = time.time()

            try:
                if (ts - self.report_ts < self.report_mint) or (
                    ts - self.report_ts < self.report_maxt
                    and abs(value - self.native_value) <= self.report_delta
                ):
                    self.report_value = value
                    return

                self.report_value = None
            except Exception:
                pass

            self.report_ts = ts

        self._attr_native_value = value

    async def async_update(self):
        if self.report_value is not None:
            XSensor.set_state(self, value=self.report_value)


class XTemperatureTH(XSensor):
    params = {"currentTemperature", "temperature"}
    uid = "temperature"

    def set_state(self, params: dict = None, value: float = None):
        try:
            # can be int, float, str or undefined
            value = params.get("currentTemperature") or params["temperature"]
            value = float(value)
            # filter zero values
            # https://github.com/AlexxIT/SonoffLAN/issues/110
            # filter wrong values
            # https://github.com/AlexxIT/SonoffLAN/issues/683
            if value != 0 and -270 < value < 270:
                XSensor.set_state(self, value=round(value, 1))
        except Exception:
            XSensor.set_state(self)


class XHumidityTH(XSensor):
    params = {"currentHumidity", "humidity"}
    uid = "humidity"

    def set_state(self, params: dict = None, value: float = None):
        try:
            value = params.get("currentHumidity") or params["humidity"]
            value = float(value)
            # filter zero values
            # https://github.com/AlexxIT/SonoffLAN/issues/110
            if value != 0:
                XSensor.set_state(self, value=value)
        except Exception:
            XSensor.set_state(self)


class XEnergySensor(XEntity, SensorEntity):
    get_params = None
    next_ts = 0

    _attr_device_class = SensorDeviceClass.ENERGY
    _attr_entity_registry_enabled_default = False
    _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
    _attr_state_class = SensorStateClass.TOTAL_INCREASING
    _attr_should_poll = True

    def __init__(self, ewelink: XRegistry, device: dict):
        XEntity.__init__(self, ewelink, device)
        reporting = device.get("reporting", {})
        self.report_dt, self.report_history = reporting.get(self.uid) or (3600, 0)

    @staticmethod
    def decode_energy(value: str) -> Optional[list]:
        try:
            return [
                round(
                    int(value[i : i + 2], 16)
                    + int(value[i + 3], 10) * 0.1
                    + int(value[i + 5], 10) * 0.01,
                    2,
                )
                for i in range(0, len(value), 6)
            ]
        except Exception:
            return None

    def set_state(self, params: dict):
        history = self.decode_energy(params[self.param])
        if not history:
            return

        self._attr_native_value = history[0]

        if self.report_history:
            self._attr_extra_state_attributes = {
                "history": history[0 : self.report_history]
            }

    async def async_update(self):
        ts = time.time()
        if ts < self.next_ts or not self.available or not self.ewelink.cloud.online:
            return
        ok = await self.ewelink.send_cloud(self.device, self.get_params, query=False)
        if ok == "online":
            self.next_ts = ts + self.report_dt


class XEnergySensorDualR3(XEnergySensor, SensorEntity):
    @staticmethod
    def decode_energy(value: str) -> Optional[list]:
        try:
            return [
                round(
                    int(value[i : i + 2], 16) + int(value[i + 2 : i + 4], 10) * 0.01, 2
                )
                for i in range(0, len(value), 4)
            ]
        except Exception:
            return None


class XEnergySensorPOWR3(XEnergySensor, SensorEntity):
    @staticmethod
    def decode_energy(value: str) -> Optional[list]:
        try:
            return [
                round(int(value[i], 16) + int(value[i + 1 : i + 3], 10) * 0.01, 2)
                for i in range(0, len(value), 3)
            ]
        except Exception:
            return None

    async def async_update(self):
        ts = time.time()
        if ts < self.next_ts or not self.available:
            return
        # POWR3 support LAN energy request (POST /zeroconf/getHoursKwh)
        ok = await self.ewelink.send(self.device, self.get_params, timeout_lan=5)
        if ok == "online":
            self.next_ts = ts + self.report_dt


class XEnergyTotal(XSensor):
    _attr_device_class = SensorDeviceClass.ENERGY
    _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
    _attr_state_class = SensorStateClass.TOTAL_INCREASING


def parse_float(v: int | float | str):
    return float(v) if isinstance(v, str) else v


class XTempCorrection(XSensor):
    params = {"temperature", "tempCorrection"}
    uid = "temperature"

    def set_state(self, params: dict = None, value: float = None):
        try:
            if (cache := self.device["params"]) != params:
                cache.update(params)
            value = parse_float(cache["temperature"])
            if v := cache.get("tempCorrection"):
                value += parse_float(v)
            XSensor.set_state(self, value=value)
        except:
            pass


class XHumCorrection(XSensor):
    params = {"humidity", "humCorrection"}
    uid = "humidity"

    def set_state(self, params: dict = None, value: float = None):
        try:
            if (cache := self.device["params"]) != params:
                cache.update(params)
            value = parse_float(cache["humidity"])
            if v := cache.get("humCorrection"):
                value += parse_float(v)
            XSensor.set_state(self, value=value)
        except:
            pass


class XOutdoorTempNS(XSensor):
    param = "HMI_outdoorTemp"
    uid = "outdoor_temp"

    # noinspection PyMethodOverriding
    def set_state(self, params: dict):
        try:
            value = params[self.param]
            self._attr_native_value = value["current"]

            mint, maxt = value["range"].split(",")
            self._attr_extra_state_attributes = {
                "temp_min": int(mint),
                "temp_max": int(maxt),
            }
        except Exception:
            pass


class XWiFiDoorBattery(XSensor):
    param = "battery"
    uid = "battery_voltage"

    def internal_available(self) -> bool:
        # device with buggy online status
        return self.ewelink.cloud.online


BUTTON_STATES = ["single", "double", "hold"]


class XEventSesor(XEntity, SensorEntity):
    event = True
    _attr_native_value = ""

    async def clear_state(self):
        await asyncio.sleep(0.5)
        self._attr_native_value = ""
        if self.hass:
            self._async_write_ha_state()


class XRemoteButton(XEventSesor):
    params = {"key"}

    def set_state(self, params: dict):
        button = params.get("outlet")
        key = BUTTON_STATES[params["key"]]
        self._attr_native_value = (
            f"button_{button + 1}_{key}" if button is not None else key
        )
        asyncio.create_task(self.clear_state())


class XT5Action(XEventSesor):
    params = {"triggerType", "slide"}
    uid = "action"

    def set_state(self, params: dict):
        # https://github.com/AlexxIT/SonoffLAN/issues/1373
        if "switches" in params and params.get("triggerType") == 2:
            self._attr_native_value = "touch"
            asyncio.create_task(self.clear_state())

        # fix https://github.com/AlexxIT/SonoffLAN/issues/1252
        if (slide := params.get("slide")) and len(params) == 1:
            self._attr_native_value = f"slide_{slide}"
            asyncio.create_task(self.clear_state())


class XUnknown(XEntity, SensorEntity):
    _attr_device_class = SensorDeviceClass.TIMESTAMP

    def internal_update(self, params: dict = None):
        self._attr_native_value = dt.utcnow()

        if params is not None:
            params.pop("bindInfos", None)
            self._attr_extra_state_attributes = params

        if self.hass:
            self._async_write_ha_state()


class XHexVoltageTRVZB(XSensor):
    _attr_device_class = SensorDeviceClass.VOLTAGE
    _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT

    def set_state(self, params: dict = None, value: float = None):
        try:
            value = int(params[self.param], 16) * 0.001

            if value != 0:
                XSensor.set_state(self, value=value)
        except Exception:
            XSensor.set_state(self)


class XTodayWaterUsage(XSensor):
    params = {"todayWaterUsage", "TodayWaterUsage"}
    uid = "water"

    def set_state(self, params: dict = None, value: float = None):
        # https://github.com/AlexxIT/SonoffLAN/issues/1497
        # https://github.com/AlexxIT/SonoffLAN/issues/1608
        value = next(params[k] for k in self.params if k in params)
        XSensor.set_state(self, value=value)