Skip to content

Commit cb4dd19

Browse files
committed
Add metadata support for genericmiot
1 parent 03880f0 commit cb4dd19

File tree

7 files changed

+388
-22
lines changed

7 files changed

+388
-22
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ repos:
66
- id: end-of-file-fixer
77
- id: check-docstring-first
88
- id: check-yaml
9+
# unsafe to workaround '!include' syntax
10+
args: ['--unsafe']
911
- id: check-json
1012
- id: check-toml
1113
- id: debug-statements

miio/integrations/genericmiot/genericmiot.py

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
from miio.miot_device import MiotMapping
2020
from miio.miot_models import DeviceModel, MiotAction, MiotProperty, MiotService
2121

22+
from .meta import Metadata
23+
2224
_LOGGER = logging.getLogger(__name__)
2325

2426

25-
def pretty_status(result: "GenericMiotStatus"):
27+
def pretty_status(result: "GenericMiotStatus", verbose=False):
2628
"""Pretty print status information."""
2729
out = ""
2830
props = result.property_dict()
@@ -46,6 +48,9 @@ def pretty_status(result: "GenericMiotStatus"):
4648
f" (min: {prop.range[0]}, max: {prop.range[1]}, step: {prop.range[2]})"
4749
)
4850

51+
if verbose:
52+
out += f" ({prop.full_name})"
53+
4954
out += "\n"
5055

5156
return out
@@ -131,6 +136,8 @@ class GenericMiot(MiotDevice):
131136
"*"
132137
] # we support all devices, if not, it is a responsibility of caller to verify that
133138

139+
_meta = Metadata.load()
140+
134141
def __init__(
135142
self,
136143
ip: Optional[str] = None,
@@ -171,8 +178,16 @@ def initialize_model(self):
171178
_LOGGER.debug("Initialized: %s", self._miot_model)
172179
self._create_descriptors()
173180

174-
@command(default_output=format_output(result_msg_fmt=pretty_status))
175-
def status(self) -> GenericMiotStatus:
181+
@command(
182+
click.option(
183+
"-v",
184+
"--verbose",
185+
is_flag=True,
186+
help="Output full property path for metadata ",
187+
),
188+
default_output=format_output(result_msg_fmt=pretty_status),
189+
)
190+
def status(self, verbose=False) -> GenericMiotStatus:
176191
"""Return status based on the miot model."""
177192
properties = []
178193
for prop in self._properties:
@@ -194,28 +209,50 @@ def status(self) -> GenericMiotStatus:
194209

195210
return GenericMiotStatus(response, self)
196211

212+
def get_extras(self, miot_entity):
213+
"""Enriches descriptor with extra meta data from yaml definitions."""
214+
extras = miot_entity.extras
215+
extras["urn"] = miot_entity.urn
216+
extras["siid"] = miot_entity.siid
217+
218+
# TODO: ugly way to detect the type
219+
if getattr(miot_entity, "aiid", None):
220+
extras["aiid"] = miot_entity.aiid
221+
if getattr(miot_entity, "piid", None):
222+
extras["piid"] = miot_entity.piid
223+
224+
meta = self._meta.get_metadata(miot_entity)
225+
if meta:
226+
extras.update(meta)
227+
else:
228+
_LOGGER.warning(
229+
"Unable to find extras for %s %s",
230+
miot_entity.service,
231+
repr(miot_entity.urn),
232+
)
233+
234+
return extras
235+
197236
def _create_action(self, act: MiotAction) -> Optional[ActionDescriptor]:
198237
"""Create action descriptor for miot action."""
199238
if act.inputs:
200239
# TODO: need to figure out how to expose input parameters for downstreams
201240
_LOGGER.warning(
202-
"Got inputs for action, skipping as handling is unknown: %s", act
241+
"Got inputs for action, skipping %s for %s", act, act.service
203242
)
204243
return None
205244

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

208247
id_ = act.name
209248

210-
# TODO: move extras handling to the model
211-
extras = act.extras
212-
extras["urn"] = act.urn
213-
extras["siid"] = act.siid
214-
extras["aiid"] = act.aiid
249+
extras = self.get_extras(act)
250+
# TODO: ugly name override
251+
name = extras.pop("description", act.description)
215252

216253
return ActionDescriptor(
217254
id=id_,
218-
name=act.description,
255+
name=name,
219256
method=call_action,
220257
extras=extras,
221258
)
@@ -227,10 +264,9 @@ def _create_actions(self, serv: MiotService):
227264
if act_desc is None: # skip actions we cannot handle for now..
228265
continue
229266

230-
if (
231-
act_desc.name in self._actions
232-
): # TODO: find a way to handle duplicates, suffix maybe?
233-
_LOGGER.warning("Got used name name, ignoring '%s': %s", act.name, act)
267+
# TODO: find a way to handle duplicates, suffix maybe?
268+
if act_desc.name in self._actions:
269+
_LOGGER.warning("Got a duplicate, ignoring '%s': %s", act.name, act)
234270
continue
235271

236272
self._actions[act_desc.name] = act_desc
@@ -254,7 +290,7 @@ def _create_sensors_and_settings(self, serv: MiotService):
254290
_LOGGER.debug("Skipping notify-only property: %s", prop)
255291
continue
256292
if "read" not in prop.access: # TODO: handle write-only properties
257-
_LOGGER.warning("Skipping write-only: %s", prop)
293+
_LOGGER.warning("Skipping write-only: %s for %s", prop, serv)
258294
continue
259295

260296
desc = self._descriptor_for_property(prop)
@@ -269,16 +305,18 @@ def _create_sensors_and_settings(self, serv: MiotService):
269305

270306
def _descriptor_for_property(self, prop: MiotProperty):
271307
"""Create a descriptor based on the property information."""
272-
name = prop.description
308+
orig_name = prop.description
273309
property_name = prop.name
274310

275311
setter = partial(self.set_property_by, prop.siid, prop.piid, name=property_name)
276312

277-
# TODO: move extras handling to the model
278-
extras = prop.extras
279-
extras["urn"] = prop.urn
280-
extras["siid"] = prop.siid
281-
extras["piid"] = prop.piid
313+
extras = self.get_extras(prop)
314+
315+
# TODO: ugly name override, refactor
316+
name = extras.pop("description", orig_name)
317+
prop.description = name
318+
if name != orig_name:
319+
_LOGGER.debug("Renamed %s to %s", orig_name, name)
282320

283321
# Handle settable ranged properties
284322
if prop.range is not None:
@@ -313,7 +351,7 @@ def _create_choices_setting(
313351
choices = Enum(
314352
prop.description, {c.description: c.value for c in prop.choices}
315353
)
316-
_LOGGER.debug("Created enum %s", choices)
354+
_LOGGER.debug("Created enum %s for %s", choices, prop)
317355
except ValueError as ex:
318356
_LOGGER.error("Unable to create enum for %s: %s", prop, ex)
319357
raise
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import logging
2+
import os
3+
from pathlib import Path
4+
from typing import Dict, Optional
5+
6+
import yaml
7+
from pydantic import BaseModel
8+
9+
_LOGGER = logging.getLogger(__name__)
10+
11+
12+
class Loader(yaml.SafeLoader):
13+
"""Loader to implement !include command.
14+
15+
From https://stackoverflow.com/a/9577670
16+
"""
17+
18+
def __init__(self, stream):
19+
self._root = os.path.split(stream.name)[0]
20+
super().__init__(stream)
21+
22+
def include(self, node):
23+
filename = os.path.join(self._root, self.construct_scalar(node))
24+
25+
with open(filename) as f:
26+
return yaml.load(f, Loader) # nosec
27+
28+
29+
Loader.add_constructor("!include", Loader.include)
30+
31+
32+
class MetaBase(BaseModel):
33+
"""Base class for metadata definitions."""
34+
35+
description: str
36+
icon: Optional[str] = None
37+
device_class: Optional[str] = None # homeassistant only
38+
39+
class Config:
40+
extra = "forbid"
41+
42+
43+
class ActionMeta(MetaBase):
44+
"""Metadata for actions."""
45+
46+
47+
class PropertyMeta(MetaBase):
48+
"""Metadata for properties."""
49+
50+
51+
class ServiceMeta(MetaBase):
52+
"""Describes a service."""
53+
54+
action: Optional[Dict[str, ActionMeta]]
55+
property: Optional[Dict[str, PropertyMeta]]
56+
event: Optional[Dict]
57+
58+
class Config:
59+
extra = "forbid"
60+
61+
62+
class Namespace(MetaBase):
63+
fallback: Optional["Namespace"] = None # fallback
64+
services: Optional[Dict[str, ServiceMeta]]
65+
66+
67+
class Metadata(BaseModel):
68+
namespaces: Dict[str, Namespace]
69+
70+
@classmethod
71+
def load(cls, file: Path = None):
72+
if file is None:
73+
datadir = Path(__file__).resolve().parent
74+
file = datadir / "metadata" / "extras.yaml"
75+
76+
_LOGGER.debug("Loading metadata file %s", file)
77+
data = yaml.load(file.open(), Loader) # nosec
78+
definitions = cls(**data)
79+
80+
return definitions
81+
82+
def get_metadata(self, desc):
83+
extras = {}
84+
urn = desc.extras["urn"]
85+
ns_name = urn.namespace
86+
service = desc.service.name
87+
type_ = urn.type
88+
ns = self.namespaces.get(ns_name)
89+
full_name = f"{ns_name}:{service}:{type_}:{urn.name}"
90+
_LOGGER.debug("Looking metadata for %s", full_name)
91+
if ns is not None:
92+
serv = ns.services.get(service)
93+
if serv is None:
94+
_LOGGER.warning("Unable to find service: %s", service)
95+
return extras
96+
97+
type_dict = getattr(serv, urn.type, None)
98+
if type_dict is None:
99+
_LOGGER.warning(
100+
"Unable to find type for service %s: %s", service, urn.type
101+
)
102+
return extras
103+
104+
# TODO: implement fallback to parent?
105+
extras = type_dict.get(urn.name)
106+
if extras is None:
107+
_LOGGER.warning(
108+
"Unable to find extras for %s (%s)", urn.name, full_name
109+
)
110+
else:
111+
if extras.icon is None:
112+
_LOGGER.warning("Icon missing for %s", full_name)
113+
if extras.description is None:
114+
_LOGGER.warning("Description missing for %s", full_name)
115+
else:
116+
_LOGGER.warning("Namespace not found: %s", ns_name)
117+
# TODO: implement fallback?
118+
119+
return extras
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
description: Metadata for dreame-specific services
2+
services:
3+
vacuum-extend:
4+
description: Extended vacuum services for dreame
5+
action:
6+
stop-clean:
7+
description: Stop cleaning
8+
icon: mdi:stop
9+
position:
10+
description: Locate robot
11+
property:
12+
work-mode:
13+
description: Work mode
14+
mop-mode:
15+
description: Mop mode
16+
waterbox-status:
17+
description: Water box attached
18+
icon: mdi:cup-water
19+
cleaning-mode:
20+
description: Cleaning mode
21+
cleaning-time:
22+
description: Cleaned time
23+
icon: mdi:timer-sand
24+
cleaning-area:
25+
description: Cleaned area
26+
icon: mdi:texture-box
27+
serial-number:
28+
description: Serial number
29+
faults:
30+
description: Error status
31+
icon: mdi:alert
32+
33+
do-not-disturb:
34+
description: DnD for dreame
35+
icon: mdi:minus-circle-off
36+
property:
37+
enable:
38+
description: DnD enabled
39+
icon: mdi:minus-circle-off
40+
start-time:
41+
description: DnD start
42+
icon: mdi:minus-circle-off
43+
end-time:
44+
description: DnD end
45+
icon: mdi:minus-circle-off
46+
47+
audio:
48+
description: Audio service for dreame
49+
action:
50+
position:
51+
description: Find device
52+
icon: mdi:target
53+
play-sound:
54+
description: Test sound level
55+
icon: mdi:volume-medium
56+
property:
57+
volume:
58+
description: Volume
59+
icon: mdi:volume-medium
60+
voice-packet-id:
61+
description: Voice package id
62+
icon: mdi:volume-medium
63+
64+
clean-logs:
65+
description: Cleaning logs for dreame
66+
property:
67+
first-clean-time:
68+
description: First cleaned
69+
total-clean-time:
70+
description: Total cleaning time
71+
icon: mdi:timer-sand
72+
total-clean-times:
73+
description: Total cleaning count
74+
icon: mdi:counter
75+
total-clean-area:
76+
description: Total cleaned area
77+
icon: mdi:texture-box
78+
79+
time:
80+
description: Time information for dreame
81+
property:
82+
time-zone:
83+
description: Timezone
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
generic:
2+
property:
3+
cleaning-time:
4+
description: Time cleaned
5+
icon: mdi:timer-sand
6+
cleaning-area:
7+
description: Area cleaned
8+
icon: mdi:texture-box
9+
brightness:
10+
description: Brightness
11+
icon: mdi:brightness-6
12+
battery:
13+
device_class: battery
14+
icon: mdi:battery
15+
namespaces:
16+
miot-spec-v2: !include miotspec.yaml
17+
dreame-spec: !include dreamespec.yaml

0 commit comments

Comments
 (0)