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/xiaomi_miot/sensor.py
"""Support for Xiaomi sensors."""
import logging
import time
import json
from typing import cast
from datetime import datetime, timedelta
from functools import cmp_to_key, cached_property

from homeassistant.const import STATE_UNKNOWN
from homeassistant.components.sensor import (
    DOMAIN as ENTITY_DOMAIN,
    SensorEntity as BaseEntity,
    SensorDeviceClass,
    SensorStateClass,
)
from homeassistant.helpers.restore_state import RestoreEntity, RestoredExtraData
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from . import (
    DOMAIN,
    CONF_MODEL,
    XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA,  # noqa: F401
    HassEntry,
    XEntity,
    MiotEntity,
    BaseSubEntity,
    MiCoordinatorEntity,
    MiotCloud,
    async_setup_config_entry,
    bind_services_to_entries,
)
from .core.miot_spec import (
    MiotSpec,
    MiotService,
)
from .core.utils import local_zone, get_translation

_LOGGER = logging.getLogger(__name__)
DATA_KEY = f'{ENTITY_DOMAIN}.{DOMAIN}'

SERVICE_TO_METHOD = {}


async def async_setup_entry(hass, config_entry, async_add_entities):
    entry = HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities)
    cloud = await entry.get_cloud()

    if cloud:
        if not entry.get_config('disable_message'):
            hass.data[DOMAIN]['accounts'].setdefault(cloud.user_id, {})

            if not hass.data[DOMAIN]['accounts'][cloud.user_id].get('messenger'):
                entity = MihomeMessageSensor(hass, cloud)
                await entity.coordinator.async_config_entry_first_refresh()
                hass.data[DOMAIN]['accounts'][cloud.user_id]['messenger'] = entity
                async_add_entities([entity], update_before_add=False)

        if not entry.get_config('disable_scene_history'):
            homes = await cloud.async_get_homerooms()
            for home in homes:
                home_id = home.get('id')
                if hass.data[DOMAIN]['accounts'][cloud.user_id].get(f'scene_history_{home_id}'):
                    continue

                entity = MihomeSceneHistorySensor(hass, cloud, home_id, home.get('uid'))
                await entity.coordinator.async_config_entry_first_refresh()
                hass.data[DOMAIN]['accounts'][cloud.user_id][f'scene_history_{home_id}'] = entity
                async_add_entities([entity], update_before_add=False)

    await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN)


async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
    hass.data.setdefault(DATA_KEY, {})
    hass.data[DOMAIN]['add_entities'][ENTITY_DOMAIN] = async_add_entities
    config['hass'] = hass
    model = str(config.get(CONF_MODEL) or '')
    spec = hass.data[DOMAIN]['miot_specs'].get(model)
    entities = []
    if isinstance(spec, MiotSpec):
        for srv in spec.get_services(
            'switch_sensor',
            'lock', 'door', 'bed', 'walking_pad', 'treadmill',
            'fridge_chamber', 'germicidal_lamp', 'vital_signs',
            'sterilizer', 'steriliser', 'chair', 'dryer', 'clothes_dryer',
        ):
            if srv.name in ['lock']:
                if not srv.get_property('operation_method', 'operation_id'):
                    continue
            elif srv.name in ['door']:
                if spec.get_service('lock'):
                    continue
            elif not srv.mapping():
                continue
            entities.append(MiotSensorEntity(config, srv))
    for entity in entities:
        hass.data[DOMAIN]['entities'][entity.unique_id] = entity
    async_add_entities(entities, update_before_add=False)
    bind_services_to_entries(hass, SERVICE_TO_METHOD)


def datetime_with_tzinfo(value):
    if isinstance(value, datetime):
        pass
    elif isinstance(value, str):
        value = datetime.fromisoformat(value)
    elif isinstance(value, (int, float)):
        value = datetime.fromtimestamp(value)
    else:
        value = cast(datetime, value)
    if value.tzinfo is None:
        value = value.replace(tzinfo=local_zone())
    return value


class SensorEntity(XEntity, BaseEntity, RestoreEntity):
    def on_init(self):
        self._attr_state_class = self.custom_config('state_class')
        self._attr_native_unit_of_measurement = self.custom_config('unit_of_measurement')
        if self._miot_property:
            if not self._attr_icon:
                self._attr_icon = self._miot_property.entity_icon
            if not self._attr_device_class:
                self._attr_device_class = self._miot_property.device_class
            if not self._attr_state_class:
                self._attr_state_class = self._miot_property.state_class
            if not self._attr_native_unit_of_measurement:
                self._attr_native_unit_of_measurement = self._miot_property.unit_of_measurement

    def get_state(self) -> dict:
        return {self.attr: self._attr_native_value}

    def set_state(self, data: dict):
        value = self.conv.value_from_dict(data)
        prop = self._miot_property
        if prop and prop.value_range:
            if not prop.range_valid(value):
                value = None
        if value is not None:
            try:
                if ratio := self.custom_value_ratio:
                    value = round(float(value) * ratio, 3)
                elif self.state_class:
                    value = round(float(value), 3)
            except (TypeError, ValueError):
                value = None
            if self.device_class == SensorDeviceClass.TIMESTAMP:
                value = datetime_with_tzinfo(value)
            self._attr_native_value = value

    @cached_property
    def custom_value_ratio(self):
        return self.custom_config_number('value_ratio') or 0


XEntity.CLS[ENTITY_DOMAIN] = SensorEntity


class MiotSensorEntity(MiotEntity, BaseEntity):

    def __init__(self, config, miot_service: MiotService):
        super().__init__(miot_service, config=config, logger=_LOGGER)

        first_property = None
        if len(miot_service.properties) > 0:
            first_property = list(miot_service.properties.values() or [])[0].name
        self._prop_state = miot_service.get_property(
            'status', 'fault', first_property or 'status',
        )
        if miot_service.name in ['lock']:
            self._prop_state = miot_service.get_property('operation_method') or self._prop_state

        self._attr_icon = self._miot_service.entity_icon
        self._attr_state_class = None
        self._attr_native_unit_of_measurement = None
        if self._prop_state:
            self._name = f'{self.device_name} {self._prop_state.friendly_desc}'
            self._attr_icon = self._prop_state.entity_icon or self._attr_icon
            self._attr_state_class = self._prop_state.state_class

    async def async_added_to_hass(self):
        await super().async_added_to_hass()
        if prop := self.custom_config('state_property'):
            self._prop_state = self._miot_service.get_property_by_full_name(prop) or self._prop_state
        if self._prop_state:
            self._attr_icon = self._prop_state.entity_icon
            self._attr_device_class = self._prop_state.device_class
            self._attr_native_unit_of_measurement = self._prop_state.unit_of_measurement
            self._state_attrs.update({
                'state_property': self._prop_state.full_name,
            })

        cls = self.custom_config('state_class')
        if cls and cls in SensorStateClass:
            self._attr_state_class = SensorStateClass(cls)
        elif cls in ['', False]:
            self._attr_state_class = None

        if uom := self.custom_config('unit_of_measurement'):
            self._attr_native_unit_of_measurement = uom

    async def async_update(self):
        await super().async_update()
        if not self._available or not self._prop_state:
            return
        if self._miot_service.name in ['lock'] and self._prop_state.full_name not in self._state_attrs:
            if how := self._state_attrs.get('lock_method'):
                await self.async_update_attrs({
                    self._prop_state.full_name: get_translation(how, ['lock_method']),
                })
            elif edt := self._state_attrs.get('event.11', {}):
                if isinstance(edt, dict):
                    await self.async_update_attrs({
                        self._prop_state.full_name: edt.get('method'),
                    })
        self._prop_state.description_to_dict(self._state_attrs)

    @property
    def device_class(self):
        """Return the class of this entity."""
        return self.get_device_class(SensorDeviceClass)

    @property
    def native_value(self):
        prop = self._prop_state
        if not prop:
            return None
        key = f'{prop.full_name}_desc'
        if key in self._state_attrs:
            return f'{self._state_attrs[key]}'.lower()
        val = prop.from_device(self.device)
        if not prop.range_valid(val):
            val = None
        return val


class BaseSensorSubEntity(BaseSubEntity, BaseEntity):
    def __init__(self, parent, attr, option=None, **kwargs):
        kwargs.setdefault('domain', ENTITY_DOMAIN)
        self._attr_state_class = None
        super().__init__(parent, attr, option, **kwargs)

    @property
    def native_value(self):
        value = self._attr_state
        if hasattr(self, '_attr_native_value') and self._attr_native_value is not None:
            value = self._attr_native_value
        value = get_translation(value, [self._attr])
        if self.device_class == SensorDeviceClass.TIMESTAMP:
            value = datetime_with_tzinfo(value)
        return value

    @property
    def state_class(self):
        return self._attr_state_class

    async def async_added_to_hass(self):
        await super().async_added_to_hass()
        cls = self.custom_config('state_class')
        if cls and cls in SensorStateClass:
            self._attr_state_class = SensorStateClass(cls)
        elif cls in ['', False]:
            self._attr_state_class = None

        if uom := self.custom_config('unit_of_measurement'):
            self._attr_native_unit_of_measurement = uom

    def update(self, data=None):
        old_state = self._attr_state
        super().update(data)

        now = datetime.now(tz=local_zone(self.hass))
        if self.state_class in ['total_increasing'] and old_state not in [None, '', STATE_UNKNOWN]:
            ptm = self._extra_attrs.get('updated_time') or now
            if now.strftime('%Y-%m-%d') == ptm.strftime('%Y-%m-%d'):
                try:
                    if (self._attr_state or 0) < old_state:
                        self._attr_state = old_state
                except (TypeError, ValueError) as exc:
                    _LOGGER.warning(
                        '%s: Total increasing sensor state error: %s',
                        self.name_model, [exc, self._attr_state, old_state],
                    )
        if self._attr_state != old_state:
            self._extra_attrs['updated_time'] = now


class MihomeMessageSensor(MiCoordinatorEntity, BaseEntity, RestoreEntity):
    _filter_homes = None
    _exclude_types = None
    _has_none_message = False

    def __init__(self, hass, cloud: MiotCloud):
        self.hass = hass
        self.cloud = cloud
        self.message = {}
        self.entity_id = f'{ENTITY_DOMAIN}.mi_{cloud.user_id}_message'
        self._attr_unique_id = f'{DOMAIN}-mihome-message-{cloud.user_id}'
        self._attr_name = f'Xiaomi {cloud.user_id} message'
        self._attr_icon = 'mdi:message'
        self._attr_should_poll = False
        self._attr_native_value = None
        self._attr_extra_state_attributes = {
            'entity_class': self.__class__.__name__,
        }
        self.coordinator = DataUpdateCoordinator(
            hass,
            _LOGGER,
            name=self._attr_unique_id,
            update_method=self.fetch_latest_message,
            update_interval=timedelta(seconds=15),
        )
        super().__init__(self.coordinator)

    async def async_added_to_hass(self):
        await super().async_added_to_hass()
        self.hass.data[DOMAIN]['entities'][self.entity_id] = self
        self._filter_homes = self.custom_config_list('filter_home') or []
        self._exclude_types = list(map(lambda x: int(x), self.custom_config_list('exclude_type', [13]) or []))
        if sec := self.custom_config_integer('interval_seconds'):
            self.coordinator.update_interval = timedelta(seconds=sec)

        if restored := await self.async_get_last_extra_data():
            self._attr_native_value = restored.as_dict().get('state')
            self._attr_extra_state_attributes.update(restored.as_dict().get('attrs', {}))

        self._attr_extra_state_attributes['filter_homes'] = self._filter_homes
        self._attr_extra_state_attributes['exclude_types'] = self._exclude_types

    async def async_will_remove_from_hass(self):
        """Run when entity will be removed from hass.
        To be extended by integrations.
        """
        await super().async_will_remove_from_hass()
        self.hass.data[DOMAIN]['accounts'].get(self.cloud.user_id, {}).pop('messenger', None)

    @property
    def extra_restore_state_data(self):
        """Return entity specific state data to be restored."""
        return RestoredExtraData({
            'state': self.native_value,
            'attrs': self._attr_extra_state_attributes,
        })

    async def async_set_message(self, msg):
        if msg == self.message:
            return
        if old := self._attr_native_value:
            self._attr_extra_state_attributes['prev_message'] = old
        con = msg.get('content')
        if tit := msg.get('title'):
            if con:
                self._attr_native_value = f'{con}: {tit}'
            else:
                self._attr_native_value = tit
            logger = _LOGGER.info if old != self._attr_native_value else _LOGGER.debug
            logger('New xiaomi message for %s: %s', self.cloud.user_id, self._attr_native_value)
        tim = msg.get('ctime')
        params = msg.get('params', {})
        body = params.get('body', {})
        self._attr_entity_picture = msg.get('img_url') or None
        self._attr_extra_state_attributes.update({
            'msg_id': msg.get('msg_id'),
            'is_new': msg.get('is_new'),
            'type': msg.get('type'),
            'title': tit,
            'content': con,
            'user_id': msg.get('uid'),
            'ctime': tim,
            'timestamp': datetime.fromtimestamp(tim, local_zone()) if tim else None,
            'model': params.get('model', body.get('model')),
            'device_id': msg.get('did', body.get('did')),
            'home_name': msg.get('homeName'),
            'room_name': msg.get('roomName'),
            'event': body.get('event'),
            'event_data': body.get('extra', body.get('value')),
        })

    async def fetch_latest_message(self):
        res = await self.cloud.async_request_api('v2/message/v2/typelist', data={}) or {}
        mls = (res.get('result') or {}).get('messages') or []
        mls.sort(key=lambda x: x.get('ctime', 0), reverse=False)
        prev_time = self._attr_extra_state_attributes.get('ctime')
        prev_mid = self._attr_extra_state_attributes.get('msg_id')
        msg = {}
        for m in mls:
            hre = m.get('params', {}).get('body', {}).get('homeRoomExtra', {})
            home = hre.get('homeName')
            if self._filter_homes and home and home not in self._filter_homes:
                continue
            typ = m.get('type', 0)
            if self._exclude_types and typ in self._exclude_types:
                continue
            tim = m.get('ctime', 0)
            mid = m.get('msg_id', 0)
            if prev_time and tim < prev_time:
                continue
            if prev_mid and mid <= prev_mid:
                continue
            m['homeName'] = home
            m['roomName'] = hre.get('roomName')
            msg = m
            break
        if not mls:
            if not self._has_none_message:
                # Only raise a warning if there was a failure obtaining the xiaomi message
                # Otherwise, a warning will show anytime that there are simply no messages waiting
                code = res.get('code')
                message = res.get('message')
                if code == 0 and message == 'ok':
                    _LOGGER.debug('Get xiaomi message for %s failed: %s', self.cloud.user_id, res)
                else:
                    _LOGGER.warning('Get xiaomi message for %s failed: %s', self.cloud.user_id, res)
            self._has_none_message = True
        if msg:
            await self.async_set_message(msg)
            self.message = msg
            self._has_none_message = False
        return msg


class MihomeSceneHistorySensor(MiCoordinatorEntity, BaseEntity, RestoreEntity):
    MESSAGE_TIMEOUT = 60
    UPDATE_INTERVAL = 15

    _has_none_message = False

    def __init__(self, hass, cloud: MiotCloud, home_id, owner_user_id):
        self.hass = hass
        self.cloud = cloud
        self.home_id = int(home_id)
        self.owner_user_id = int(owner_user_id)
        self.entity_id = f'{ENTITY_DOMAIN}.mi_{cloud.user_id}_{home_id}_scene_history'
        self._attr_unique_id = f'{DOMAIN}-mihome-scene-history-{cloud.user_id}_{home_id}'
        self._attr_name = f'Xiaomi {cloud.user_id}_{home_id} Scene History'
        self._attr_icon = 'mdi:message'
        self._attr_should_poll = False
        self._attr_native_value = None
        self._attr_extra_state_attributes = {
            'entity_class': self.__class__.__name__,
        }
        self.coordinator = DataUpdateCoordinator(
            hass,
            _LOGGER,
            name=self._attr_unique_id,
            update_method=self.fetch_latest_message,
            update_interval=timedelta(seconds=self.UPDATE_INTERVAL),
        )
        super().__init__(self.coordinator)

    async def async_added_to_hass(self):
        await super().async_added_to_hass()
        self.hass.data[DOMAIN]['entities'][self.entity_id] = self
        if sec := self.custom_config_integer('interval_seconds'):
            self.coordinator.update_interval = timedelta(seconds=sec)

        if restored := await self.async_get_last_extra_data():
            restored_dict = restored.as_dict()

            attrs = restored_dict.get('attrs', {})
            if ts := attrs.get('ts'):
                attrs['timestamp'] = datetime.fromtimestamp(ts, local_zone()) if ts else None

            _LOGGER.debug(
                'xiaomi scene history %s %d, async_added_to_hass restore state: state= %s attrs= %s',
                self.cloud.user_id, self.home_id, restored_dict.get('state'), attrs,
            )
            self._attr_native_value = restored_dict.get('state')
            self._attr_extra_state_attributes.update(attrs)

    async def async_will_remove_from_hass(self):
        """Run when entity will be removed from hass.
        To be extended by integrations.
        """
        await super().async_will_remove_from_hass()
        self.hass.data[DOMAIN]['accounts'].get(self.cloud.user_id, {}).pop(f'scene_history_{self.home_id}', None)

    @property
    def extra_restore_state_data(self):
        """Return entity specific state data to be restored."""
        return RestoredExtraData({
            'state': self.native_value,
            'attrs': self._attr_extra_state_attributes,
        })

    def trim_message(self, msg):
        ts = msg.get('time') or int(time.time())
        return {
            "from": msg.get('from'),
            "name": msg.get('name'),
            "ts": ts,
            'timestamp': datetime.fromtimestamp(ts, local_zone()),
            "scene_id": str(msg.get('userSceneId')),
            "targets": msg.get('msg', []),
        }

    @staticmethod
    def _cmp_message(a, b):
        a_ts, b_ts = a.get('ts', 0), b.get('ts', 0)
        if a_ts != b_ts:
            return a_ts - b_ts
        
        a_scene_id, b_scene_id = a.get('scene_id', 0), b.get('scene_id', 0)
        if a_scene_id < b_scene_id:
            return -1
        if a_scene_id > b_scene_id:
            return 1
        return 0

    async def async_set_message(self, msg):
        self._attr_native_value = msg.get('name')
        _LOGGER.debug('New xiaomi scene history for %s %d: %s', self.cloud.user_id, self.home_id, self._attr_native_value)

        old = self._attr_extra_state_attributes or {}
        self._attr_extra_state_attributes.update({**msg, 'prev_value': old.get('name'), 'prev_scene_id': old.get('scene_id')})

    async def fetch_latest_message(self):
        res = await self.cloud.async_request_api('scene/history', data={
            "home_id": self.home_id,
            "uid": int(self.cloud.user_id),
            "owner_uid": self.owner_user_id,
            "command": "history",
            "limit": 15,
        }) or {}

        messages = [self.trim_message(msg) for msg in (res.get('result') or {}).get('history') or []]
        if not messages:
            if not self._has_none_message:
                _LOGGER.info('Get xiaomi scene history for %s %d failed: %s', self.cloud.user_id, self.home_id, res)

            self._has_none_message = True
            return {}

        messages.sort(key=cmp_to_key(self._cmp_message), reverse=False)
        _LOGGER.debug(
            'Get xiaomi scene history for %s %d success: prev_timestamp= %d prev_scene_id= %s messages= %s,',
            self.cloud.user_id, self.home_id,
            self._attr_extra_state_attributes.get('ts') or 0,
            self._attr_extra_state_attributes.get('scene_id') or '',
            messages,
        )

        must_after = int(time.time()) - self.MESSAGE_TIMEOUT
        for msg in messages:
            if msg.get('ts') < must_after:
                continue

            if self._cmp_message(msg, self._attr_extra_state_attributes) <= 0:
                continue

            await self.async_set_message(msg)
            self._has_none_message = False
            return msg

        return {}


class XiaoaiConversationSensor(MiCoordinatorEntity, BaseSensorSubEntity):
    def __init__(self, parent, hass, option=None):
        BaseSensorSubEntity.__init__(self, parent, 'conversation', option)
        self.hass = hass
        self.conversation = {}
        self._available = True
        self._attr_native_value = None
        self._option.setdefault('icon', 'mdi:account-voice')
        self.coordinator = DataUpdateCoordinator(
            hass,
            _LOGGER,
            name=self.unique_id,
            update_method=self.fetch_latest_message,
            update_interval=timedelta(seconds=5),
        )
        super().__init__(self.coordinator)

    async def async_added_to_hass(self):
        await super().async_added_to_hass()
        self.hass.data[DOMAIN]['entities'][self.entity_id] = self
        await self.coordinator.async_refresh()
        if sec := self.custom_config_integer('interval_seconds'):
            self.coordinator.update_interval = timedelta(seconds=sec)

    async def fetch_latest_message(self):
        mic = self._parent.xiaoai_cloud
        dvc = self._parent.xiaoai_device or {}
        aid = dvc.get('deviceID')
        if not isinstance(mic, MiotCloud) or not aid:
            self._available = False
            return
        api = 'https://userprofile.mina.mi.com/device_profile/v2/conversation'
        dat = {
            'hardware': dvc.get('hardware', ''),
            'timestamp': int(time.time() * 1000),
            'limit': 3,
        }
        cks = {
            'deviceId': aid,
        }
        try:
            res = await mic.async_request_api(api, data=dat, method='GET', cookies=cks) or {}
            rdt = res.get('data', {})
            if not isinstance(rdt, dict):
                rdt = json.loads(rdt) or {}
        except (TypeError, ValueError, Exception) as exc:
            rdt = {}
            _LOGGER.warning(
                '%s: Got exception while fetch xiaoai conversation: %s',
                self.name_model, [aid, exc],
            )
        mls = rdt.get('records') or []
        msg = mls.pop(0) if mls else {}
        self.conversation = msg
        old = self._attr_native_value
        if con := msg.get('query'):
            self._state = con
            self._attr_native_value = con
            logger = _LOGGER.info if old != self._attr_native_value else _LOGGER.debug
            logger('%s: New xiaoai conversation: %s', self.name_model, self._attr_native_value)
        tim = msg.get('time')
        ans = []
        for v in msg.get('answers', []):
            if not isinstance(v, dict):
                continue
            typ = v.get('type', '').lower()
            v.pop('bitSet', None)
            v.get(typ, {}).pop('bitSet', None)
            ans.append(v)
        self._state_attrs.update({
            'content': con,
            'answers': ans,
            'history': [
                v.get('query')
                for v in mls
            ],
            'timestamp': datetime.fromtimestamp(tim / 1000, local_zone()) if tim else None,
        })
        return msg