diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a74b3675..8dc6faaad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,6 +6,8 @@ repos: - id: end-of-file-fixer - id: check-docstring-first - id: check-yaml + # unsafe to workaround '!include' syntax + args: ['--unsafe'] - id: check-json - id: check-toml - id: debug-statements diff --git a/miio/integrations/genericmiot/genericmiot.py b/miio/integrations/genericmiot/genericmiot.py index fa74ebfef..951889fd7 100644 --- a/miio/integrations/genericmiot/genericmiot.py +++ b/miio/integrations/genericmiot/genericmiot.py @@ -19,10 +19,12 @@ from miio.miot_device import MiotMapping from miio.miot_models import DeviceModel, MiotAction, MiotProperty, MiotService +from .meta import Metadata + _LOGGER = logging.getLogger(__name__) -def pretty_status(result: "GenericMiotStatus"): +def pretty_status(result: "GenericMiotStatus", verbose=False): """Pretty print status information.""" out = "" props = result.property_dict() @@ -46,6 +48,9 @@ def pretty_status(result: "GenericMiotStatus"): f" (min: {prop.range[0]}, max: {prop.range[1]}, step: {prop.range[2]})" ) + if verbose: + out += f" ({prop.full_name})" + out += "\n" return out @@ -131,6 +136,8 @@ class GenericMiot(MiotDevice): "*" ] # we support all devices, if not, it is a responsibility of caller to verify that + _meta = Metadata.load() + def __init__( self, ip: Optional[str] = None, @@ -171,8 +178,16 @@ def initialize_model(self): _LOGGER.debug("Initialized: %s", self._miot_model) self._create_descriptors() - @command(default_output=format_output(result_msg_fmt=pretty_status)) - def status(self) -> GenericMiotStatus: + @command( + click.option( + "-v", + "--verbose", + is_flag=True, + help="Output full property path for metadata ", + ), + default_output=format_output(result_msg_fmt=pretty_status), + ) + def status(self, verbose=False) -> GenericMiotStatus: """Return status based on the miot model.""" properties = [] for prop in self._properties: @@ -194,12 +209,36 @@ def status(self) -> GenericMiotStatus: return GenericMiotStatus(response, self) + def get_extras(self, miot_entity): + """Enriches descriptor with extra meta data from yaml definitions.""" + extras = miot_entity.extras + extras["urn"] = miot_entity.urn + extras["siid"] = miot_entity.siid + + # TODO: ugly way to detect the type + if getattr(miot_entity, "aiid", None): + extras["aiid"] = miot_entity.aiid + if getattr(miot_entity, "piid", None): + extras["piid"] = miot_entity.piid + + meta = self._meta.get_metadata(miot_entity) + if meta: + extras.update(meta) + else: + _LOGGER.warning( + "Unable to find extras for %s %s", + miot_entity.service, + repr(miot_entity.urn), + ) + + return extras + def _create_action(self, act: MiotAction) -> Optional[ActionDescriptor]: """Create action descriptor for miot action.""" if act.inputs: # TODO: need to figure out how to expose input parameters for downstreams _LOGGER.warning( - "Got inputs for action, skipping as handling is unknown: %s", act + "Got inputs for action, skipping %s for %s", act, act.service ) return None @@ -207,15 +246,13 @@ def _create_action(self, act: MiotAction) -> Optional[ActionDescriptor]: id_ = act.name - # TODO: move extras handling to the model - extras = act.extras - extras["urn"] = act.urn - extras["siid"] = act.siid - extras["aiid"] = act.aiid + extras = self.get_extras(act) + # TODO: ugly name override + name = extras.pop("description", act.description) return ActionDescriptor( id=id_, - name=act.description, + name=name, method=call_action, extras=extras, ) @@ -227,10 +264,9 @@ def _create_actions(self, serv: MiotService): if act_desc is None: # skip actions we cannot handle for now.. continue - if ( - act_desc.name in self._actions - ): # TODO: find a way to handle duplicates, suffix maybe? - _LOGGER.warning("Got used name name, ignoring '%s': %s", act.name, act) + # TODO: find a way to handle duplicates, suffix maybe? + if act_desc.name in self._actions: + _LOGGER.warning("Got a duplicate, ignoring '%s': %s", act.name, act) continue self._actions[act_desc.name] = act_desc @@ -254,7 +290,7 @@ def _create_sensors_and_settings(self, serv: MiotService): _LOGGER.debug("Skipping notify-only property: %s", prop) continue if "read" not in prop.access: # TODO: handle write-only properties - _LOGGER.warning("Skipping write-only: %s", prop) + _LOGGER.warning("Skipping write-only: %s for %s", prop, serv) continue desc = self._descriptor_for_property(prop) @@ -269,16 +305,18 @@ def _create_sensors_and_settings(self, serv: MiotService): def _descriptor_for_property(self, prop: MiotProperty): """Create a descriptor based on the property information.""" - name = prop.description + orig_name = prop.description property_name = prop.name setter = partial(self.set_property_by, prop.siid, prop.piid, name=property_name) - # TODO: move extras handling to the model - extras = prop.extras - extras["urn"] = prop.urn - extras["siid"] = prop.siid - extras["piid"] = prop.piid + extras = self.get_extras(prop) + + # TODO: ugly name override, refactor + name = extras.pop("description", orig_name) + prop.description = name + if name != orig_name: + _LOGGER.debug("Renamed %s to %s", orig_name, name) # Handle settable ranged properties if prop.range is not None: @@ -313,7 +351,7 @@ def _create_choices_setting( choices = Enum( prop.description, {c.description: c.value for c in prop.choices} ) - _LOGGER.debug("Created enum %s", choices) + _LOGGER.debug("Created enum %s for %s", choices, prop) except ValueError as ex: _LOGGER.error("Unable to create enum for %s: %s", prop, ex) raise diff --git a/miio/integrations/genericmiot/meta.py b/miio/integrations/genericmiot/meta.py new file mode 100644 index 000000000..85ca79cbd --- /dev/null +++ b/miio/integrations/genericmiot/meta.py @@ -0,0 +1,119 @@ +import logging +import os +from pathlib import Path +from typing import Dict, Optional + +import yaml +from pydantic import BaseModel + +_LOGGER = logging.getLogger(__name__) + + +class Loader(yaml.SafeLoader): + """Loader to implement !include command. + + From https://stackoverflow.com/a/9577670 + """ + + def __init__(self, stream): + self._root = os.path.split(stream.name)[0] + super().__init__(stream) + + def include(self, node): + filename = os.path.join(self._root, self.construct_scalar(node)) + + with open(filename) as f: + return yaml.load(f, Loader) # nosec + + +Loader.add_constructor("!include", Loader.include) + + +class MetaBase(BaseModel): + """Base class for metadata definitions.""" + + description: str + icon: Optional[str] = None + device_class: Optional[str] = None # homeassistant only + + class Config: + extra = "forbid" + + +class ActionMeta(MetaBase): + """Metadata for actions.""" + + +class PropertyMeta(MetaBase): + """Metadata for properties.""" + + +class ServiceMeta(MetaBase): + """Describes a service.""" + + action: Optional[Dict[str, ActionMeta]] + property: Optional[Dict[str, PropertyMeta]] + event: Optional[Dict] + + class Config: + extra = "forbid" + + +class Namespace(MetaBase): + fallback: Optional["Namespace"] = None # fallback + services: Optional[Dict[str, ServiceMeta]] + + +class Metadata(BaseModel): + namespaces: Dict[str, Namespace] + + @classmethod + def load(cls, file: Path = None): + if file is None: + datadir = Path(__file__).resolve().parent + file = datadir / "metadata" / "extras.yaml" + + _LOGGER.debug("Loading metadata file %s", file) + data = yaml.load(file.open(), Loader) # nosec + definitions = cls(**data) + + return definitions + + def get_metadata(self, desc): + extras = {} + urn = desc.extras["urn"] + ns_name = urn.namespace + service = desc.service.name + type_ = urn.type + ns = self.namespaces.get(ns_name) + full_name = f"{ns_name}:{service}:{type_}:{urn.name}" + _LOGGER.debug("Looking metadata for %s", full_name) + if ns is not None: + serv = ns.services.get(service) + if serv is None: + _LOGGER.warning("Unable to find service: %s", service) + return extras + + type_dict = getattr(serv, urn.type, None) + if type_dict is None: + _LOGGER.warning( + "Unable to find type for service %s: %s", service, urn.type + ) + return extras + + # TODO: implement fallback to parent? + extras = type_dict.get(urn.name) + if extras is None: + _LOGGER.warning( + "Unable to find extras for %s (%s)", urn.name, full_name + ) + else: + if extras.icon is None: + _LOGGER.warning("Icon missing for %s", full_name) + if extras.description is None: + _LOGGER.warning("Description missing for %s", full_name) + else: + _LOGGER.warning("Namespace not found: %s", ns_name) + # TODO: implement fallback? + + return extras diff --git a/miio/integrations/genericmiot/metadata/dreamespec.yaml b/miio/integrations/genericmiot/metadata/dreamespec.yaml new file mode 100644 index 000000000..9e30fb545 --- /dev/null +++ b/miio/integrations/genericmiot/metadata/dreamespec.yaml @@ -0,0 +1,83 @@ +description: Metadata for dreame-specific services +services: + vacuum-extend: + description: Extended vacuum services for dreame + action: + stop-clean: + description: Stop cleaning + icon: mdi:stop + position: + description: Locate robot + property: + work-mode: + description: Work mode + mop-mode: + description: Mop mode + waterbox-status: + description: Water box attached + icon: mdi:cup-water + cleaning-mode: + description: Cleaning mode + cleaning-time: + description: Cleaned time + icon: mdi:timer-sand + cleaning-area: + description: Cleaned area + icon: mdi:texture-box + serial-number: + description: Serial number + faults: + description: Error status + icon: mdi:alert + + do-not-disturb: + description: DnD for dreame + icon: mdi:minus-circle-off + property: + enable: + description: DnD enabled + icon: mdi:minus-circle-off + start-time: + description: DnD start + icon: mdi:minus-circle-off + end-time: + description: DnD end + icon: mdi:minus-circle-off + + audio: + description: Audio service for dreame + action: + position: + description: Find device + icon: mdi:target + play-sound: + description: Test sound level + icon: mdi:volume-medium + property: + volume: + description: Volume + icon: mdi:volume-medium + voice-packet-id: + description: Voice package id + icon: mdi:volume-medium + + clean-logs: + description: Cleaning logs for dreame + property: + first-clean-time: + description: First cleaned + total-clean-time: + description: Total cleaning time + icon: mdi:timer-sand + total-clean-times: + description: Total cleaning count + icon: mdi:counter + total-clean-area: + description: Total cleaned area + icon: mdi:texture-box + + time: + description: Time information for dreame + property: + time-zone: + description: Timezone diff --git a/miio/integrations/genericmiot/metadata/extras.yaml b/miio/integrations/genericmiot/metadata/extras.yaml new file mode 100644 index 000000000..406daee88 --- /dev/null +++ b/miio/integrations/genericmiot/metadata/extras.yaml @@ -0,0 +1,17 @@ +generic: + property: + cleaning-time: + description: Time cleaned + icon: mdi:timer-sand + cleaning-area: + description: Area cleaned + icon: mdi:texture-box + brightness: + description: Brightness + icon: mdi:brightness-6 + battery: + device_class: battery + icon: mdi:battery +namespaces: + miot-spec-v2: !include miotspec.yaml + dreame-spec: !include dreamespec.yaml diff --git a/miio/integrations/genericmiot/metadata/miotspec.yaml b/miio/integrations/genericmiot/metadata/miotspec.yaml new file mode 100644 index 000000000..3d697eb5e --- /dev/null +++ b/miio/integrations/genericmiot/metadata/miotspec.yaml @@ -0,0 +1,102 @@ +description: Metadata miot-spec-v2 namespace +services: + battery: + description: Battery service + icon: mdi:battery + action: + start-charge: + description: Go charging + property: + charging-state: + description: Charging status + battery-level: + description: Battery level + icon: mdi:battery + + filter: + description: Filter service + action: + reset-filter-life: + description: Reset filter life + icon: mdi:air-filter + property: + filter-life-level: + description: Filter life level + icon: mdi:air-filter + filter-left-time: + description: Filter time left + icon: mdi:air-filter + + brush-cleaner: + description: Brush service + action: + reset-brush-life: + description: Reset brush life level + icon: mdi:brush + property: + brush-life-level: + description: Brush life level + icon: mdi:brush + brush-left-time: + description: Brush time left + icon: mdi:brush + + identify: + description: Find device service + action: + identify: + description: Find device + icon: mdi:target + + light: + description: Light service + action: + toggle: + description: Toggle + icon: mdi:toggle-switch + brightness-down: + description: Decrease brightness + icon: mdi:brightness-3 + brightness-up: + description: Increase brightness + icon: mdi:brightness-4 + property: + brightness: + description: Brightness + icon: mdi:brightness-6 + + vacuum: + description: Vacuum service + icon: mdi:vacuum + action: + start-sweep: + description: Start cleaning + icon: mdi:play + stop-sweeping: + description: Stop cleaning + icon: mdi:stop + start-mop: + description: Start mopping + icon: mdi:play + start-sweep-mop: + description: Start cleaning and mopping + icon: mdi:play + start-charge: + description: Return home + icon: mdi:home + + property: + status: + description: Status + fault: + description: Error + icon: mdi:alert + mode: + description: Fan speed + icon: mdi:fan + cleaning-time: + description: Time cleaned + icon: mdi:timer-sand + cleaning-area: + description: Area cleaned + icon: mdi:texture-box diff --git a/miio/miot_models.py b/miio/miot_models.py index 6490334e1..ed1cede08 100644 --- a/miio/miot_models.py +++ b/miio/miot_models.py @@ -119,6 +119,11 @@ def name(self) -> str: return f"{self.service.name}:{self.urn.name}" # type: ignore return "unitialized" + @property + def full_name(self) -> str: + """Returns full name including the namespace and type.""" + return f"{self.urn.namespace}:{self.urn.type}:{self.name}" + class MiotAction(MiotBaseModel): """Action presentation for miot."""