Skip to content
Merged
35 changes: 33 additions & 2 deletions miio/integrations/genericmiot/genericmiot.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
import logging
from functools import partial
from typing import TypeVar

import attr

from miio import MiotDevice
from miio.click_common import command
from miio.descriptors import AccessFlags, ActionDescriptor, PropertyDescriptor
from miio.miot_cloud import MiotCloud
from miio.miot_device import MiotMapping
from miio.miot_models import DeviceModel, MiotAccess, MiotAction, MiotService

from miio.miot_models import (
DeviceModel,
MiotAccess,
MiotAction,
MiotBaseModel,
MiotService,
)

from .meta import Metadata
from .status import GenericMiotStatus

_LOGGER = logging.getLogger(__name__)
_D = TypeVar("_D", ActionDescriptor, PropertyDescriptor)


class GenericMiot(MiotDevice):
# we support all devices, if not, it is a responsibility of caller to verify that
_supported_models = ["*"]
_meta = Metadata.load()

def __init__(
self,
Expand Down Expand Up @@ -70,11 +82,29 @@ def status(self) -> GenericMiotStatus:

return GenericMiotStatus(response, self)

def _enrich_with_metadata(self, entity: MiotBaseModel, desc: _D) -> _D:
"""Return an enriched copy of the descriptor with metadata applied.

The original descriptor is stored in extras['original'] so callers can
access the raw device-given name if needed.
"""
meta = self._meta.get_metadata(entity)
if meta is None or meta.description == desc.name:
return desc

_LOGGER.debug("Renamed %s to %s", desc.name, meta.description)
return attr.evolve(
desc,
name=meta.description,
extras={**desc.extras, "original": desc},
)

def _create_action(self, act: MiotAction) -> ActionDescriptor | None:
"""Create action descriptor for miot action."""
desc = act.get_descriptor()
call_action = partial(self.call_action_by, act.siid, act.aiid)
desc.method = call_action
desc = self._enrich_with_metadata(act, desc)

return desc

Expand All @@ -100,6 +130,7 @@ def _create_properties(self, serv: MiotService):
continue

desc = prop.get_descriptor()
desc = self._enrich_with_metadata(prop, desc)

# Add readable properties to the status query
if AccessFlags.Read in desc.access:
Expand Down
139 changes: 139 additions & 0 deletions miio/integrations/genericmiot/meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import logging
from pathlib import Path

import yaml

try:
from pydantic.v1 import BaseModel
except ImportError:
from pydantic import BaseModel

from miio.miot_models import MiotBaseModel

_LOGGER = logging.getLogger(__name__)
_ANY_SERVICE = "__ANY__"


class MetaBase(BaseModel):
"""Base metadata with description."""

description: str

class Config:
extra = "forbid"


class ActionMeta(MetaBase):
"""Metadata for actions."""


class PropertyMeta(MetaBase):
"""Metadata for properties."""


class ServiceMeta(MetaBase):
"""Metadata for a service, containing per-action and per-property metadata."""

description: str | None = None # type: ignore[assignment]
action: dict[str, ActionMeta] = {}
property: dict[str, PropertyMeta] = {}
event: dict = {}

class Config:
extra = "forbid"

def get(self, type_: str, name: str) -> MetaBase | None:
"""Return metadata for the given type and name, or None if not found."""
return getattr(self, type_).get(name)


class Namespace(MetaBase):
"""A namespace (e.g. miot-spec-v2) containing service definitions."""

fallback: str | None = None
services: dict[str, ServiceMeta] = {}


class Metadata(BaseModel):
"""Loads and provides access to YAML metadata for genericmiot entities.

Metadata provides human-readable descriptions that override the often-Chinese
or generic defaults from miotspec files.
"""

namespaces: dict[str, Namespace]

@classmethod
def load(cls, file: Path | None = None) -> "Metadata":
"""Load metadata from the default base.yaml or a custom file."""
if file is None:
file = Path(__file__).resolve().parent / "metadata" / "base.yaml"

_LOGGER.debug("Loading metadata from %s", file)
with file.open() as f:
data = yaml.safe_load(f)

for ns_name, ns_value in data["namespaces"].items():
if isinstance(ns_value, str):
ns_path = file.parent / ns_value
_LOGGER.debug("Loading namespace %s from %s", ns_name, ns_path)
with ns_path.open() as f:
data["namespaces"][ns_name] = yaml.safe_load(f)

return cls(**data)

def _lookup_in_namespace(
self, ns: "Namespace", service_name: str, type_: str, entity_name: str
) -> MetaBase | None:
"""Look up metadata within a single namespace, following fallback if needed."""
for svc_name in (service_name, _ANY_SERVICE):
if (serv := ns.services.get(svc_name)) and (
meta := serv.get(type_, entity_name)
):
return meta

common = self.namespaces.get("common")
fallback_ns = self.namespaces.get(ns.fallback or "common", common)

if fallback_ns is not None and fallback_ns is not ns:
return self._lookup_in_namespace(
fallback_ns, service_name, type_, entity_name
)

return None

def get_metadata(self, entity: MiotBaseModel) -> MetaBase | None:
"""Look up metadata for a miot entity (property or action).

Returns a MetaBase object, or None if no metadata was found.
"""
urn = entity.extras.get("urn")
if urn is None:
return None

if entity.service is None:
return None

ns_name: str = urn.namespace
service_name: str = entity.service.name
type_: str = urn.type
entity_name: str = urn.name

ns = self.namespaces.get(ns_name, self.namespaces["common"])

meta = self._lookup_in_namespace(ns, service_name, type_, entity_name)
if meta is None:
_LOGGER.debug(
"No metadata for %s:%s:%s:%s", ns_name, service_name, type_, entity_name
)
return None

_LOGGER.debug(
"Found metadata for %s:%s:%s:%s: %s",
ns_name,
service_name,
type_,
entity_name,
meta,
)
return meta
4 changes: 4 additions & 0 deletions miio/integrations/genericmiot/metadata/base.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespaces:
miot-spec-v2: miotspec.yaml
dreame-spec: dreamespec.yaml
common: common.yaml
24 changes: 24 additions & 0 deletions miio/integrations/genericmiot/metadata/common.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
description: Common cross-service definitions
services:
__ANY__:
property:
mac:
description: MAC Address
voltage:
description: Voltage
temperature:
description: Temperature
relative-humidity:
description: Relative humidity
co2-density:
description: CO2 density
pm2.5-density:
description: PM2.5 density
pm10-density:
description: PM10 density
brightness:
description: Brightness
cleaning-time:
description: Time cleaned
cleaning-area:
description: Area cleaned
68 changes: 68 additions & 0 deletions miio/integrations/genericmiot/metadata/dreamespec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
description: Metadata for dreame-specific services
fallback: miot-spec-v2
services:
vacuum-extend:
description: Extended vacuum services for dreame
action:
stop-clean:
description: Stop cleaning
position:
description: Locate robot
property:
work-mode:
description: Work mode
mop-mode:
description: Mop mode
waterbox-status:
description: Water box attached
cleaning-mode:
description: Cleaning mode
cleaning-time:
description: Cleaned time
cleaning-area:
description: Cleaned area
serial-number:
description: Serial number
faults:
description: Error status

do-not-disturb:
description: DnD for dreame
property:
enable:
description: DnD enabled
start-time:
description: DnD start
end-time:
description: DnD end

audio:
description: Audio service for dreame
action:
position:
description: Find device
play-sound:
description: Test sound level
property:
volume:
description: Volume
voice-packet-id:
description: Voice package id

clean-logs:
description: Cleaning logs for dreame
property:
first-clean-time:
description: First cleaned
total-clean-time:
description: Total cleaning time
total-clean-times:
description: Total cleaning count
total-clean-area:
description: Total cleaned area

time:
description: Time information for dreame
property:
time-zone:
description: Timezone
79 changes: 79 additions & 0 deletions miio/integrations/genericmiot/metadata/miotspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
description: Metadata for miot-spec-v2 namespace
services:
battery:
description: Battery service
action:
start-charge:
description: Go charging
property:
charging-state:
description: Charging status
battery-level:
description: Battery level

filter:
description: Filter service
action:
reset-filter-life:
description: Reset filter life
property:
filter-life-level:
description: Filter life level
filter-left-time:
description: Filter time left

brush-cleaner:
description: Brush service
action:
reset-brush-life:
description: Reset brush life level
property:
brush-life-level:
description: Brush life level
brush-left-time:
description: Brush time left

identify:
description: Find device service
action:
identify:
description: Find device

light:
description: Light service
action:
toggle:
description: Toggle
brightness-down:
description: Decrease brightness
brightness-up:
description: Increase brightness
property:
brightness:
description: Brightness

vacuum:
description: Vacuum service
action:
start-sweep:
description: Start cleaning
stop-sweeping:
description: Stop cleaning
start-mop:
description: Start mopping
start-sweep-mop:
description: Start cleaning and mopping
start-charge:
description: Return home

property:
status:
description: Status
fault:
description: Error
mode:
description: Fan speed
cleaning-time:
description: Time cleaned
cleaning-area:
description: Area cleaned
Loading
Loading