Skip to content

Commit

Permalink
Add metadata support for genericmiot
Browse files Browse the repository at this point in the history
  • Loading branch information
rytilahti committed Jan 6, 2023
1 parent 03880f0 commit cb4dd19
Show file tree
Hide file tree
Showing 7 changed files with 388 additions and 22 deletions.
2 changes: 2 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 60 additions & 22 deletions miio/integrations/genericmiot/genericmiot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -194,28 +209,50 @@ 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

call_action = partial(self.call_action_by, act.siid, act.aiid)

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,
)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions miio/integrations/genericmiot/meta.py
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions miio/integrations/genericmiot/metadata/dreamespec.yaml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions miio/integrations/genericmiot/metadata/extras.yaml
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit cb4dd19

Please sign in to comment.