Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add metadata support for genericmiot #1618

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: Optional[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
Loading