Skip to content

Commit 3565661

Browse files
committed
Add metadata support for genericmiot
1 parent db3c7ad commit 3565661

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
@@ -127,6 +132,8 @@ class GenericMiot(MiotDevice):
127132
"*"
128133
] # we support all devices, if not, it is a responsibility of caller to verify that
129134

135+
_meta = Metadata.load()
136+
130137
def __init__(
131138
self,
132139
ip: str = None,
@@ -167,8 +174,16 @@ def initialize_model(self):
167174
_LOGGER.debug("Initialized: %s", self._miot_model)
168175
self._create_descriptors()
169176

170-
@command(default_output=format_output(result_msg_fmt=pretty_status))
171-
def status(self) -> GenericMiotStatus:
177+
@command(
178+
click.option(
179+
"-v",
180+
"--verbose",
181+
is_flag=True,
182+
help="Output full property path for metadata ",
183+
),
184+
default_output=format_output(result_msg_fmt=pretty_status),
185+
)
186+
def status(self, verbose=False) -> GenericMiotStatus:
172187
"""Return status based on the miot model."""
173188
properties = []
174189
for prop in self._properties:
@@ -190,28 +205,50 @@ def status(self) -> GenericMiotStatus:
190205

191206
return GenericMiotStatus(response, self)
192207

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

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

204243
id_ = act.name
205244

206-
# TODO: move extras handling to the model
207-
extras = act.extras
208-
extras["urn"] = act.urn
209-
extras["siid"] = act.siid
210-
extras["aiid"] = act.aiid
245+
extras = self.get_extras(act)
246+
# TODO: ugly name override
247+
name = extras.pop("description", act.description)
211248

212249
return ActionDescriptor(
213250
id=id_,
214-
name=act.description,
251+
name=name,
215252
method=call_action,
216253
extras=extras,
217254
)
@@ -223,10 +260,9 @@ def _create_actions(self, serv: MiotService):
223260
if act_desc is None: # skip actions we cannot handle for now..
224261
continue
225262

226-
if (
227-
act_desc.name in self._actions
228-
): # TODO: find a way to handle duplicates, suffix maybe?
229-
_LOGGER.warning("Got used name name, ignoring '%s': %s", act.name, act)
263+
# TODO: find a way to handle duplicates, suffix maybe?
264+
if act_desc.name in self._actions:
265+
_LOGGER.warning("Got a duplicate, ignoring '%s': %s", act.name, act)
230266
continue
231267

232268
self._actions[act_desc.name] = act_desc
@@ -250,7 +286,7 @@ def _create_sensors_and_settings(self, serv: MiotService):
250286
_LOGGER.debug("Skipping notify-only property: %s", prop)
251287
continue
252288
if "read" not in prop.access: # TODO: handle write-only properties
253-
_LOGGER.warning("Skipping write-only: %s", prop)
289+
_LOGGER.warning("Skipping write-only: %s for %s", prop, serv)
254290
continue
255291

256292
desc = self._descriptor_for_property(prop)
@@ -266,16 +302,18 @@ def _create_sensors_and_settings(self, serv: MiotService):
266302
def _descriptor_for_property(self, prop: MiotProperty):
267303
"""Create a descriptor based on the property information."""
268304
desc: SettingDescriptor
269-
name = prop.description
305+
orig_name = prop.description
270306
property_name = prop.name
271307

272308
setter = partial(self.set_property_by, prop.siid, prop.piid, name=property_name)
273309

274-
# TODO: move extras handling to the model
275-
extras = prop.extras
276-
extras["urn"] = prop.urn
277-
extras["siid"] = prop.siid
278-
extras["piid"] = prop.piid
310+
extras = self.get_extras(prop)
311+
312+
# TODO: ugly name override, refactor
313+
name = extras.pop("description", orig_name)
314+
prop.description = name
315+
if name != orig_name:
316+
_LOGGER.debug("Renamed %s to %s", orig_name, name)
279317

280318
# Handle settable ranged properties
281319
if prop.range is not None:
@@ -310,7 +348,7 @@ def _create_choices_setting(
310348
choices = Enum(
311349
prop.description, {c.description: c.value for c in prop.choices}
312350
)
313-
_LOGGER.debug("Created enum %s", choices)
351+
_LOGGER.debug("Created enum %s for %s", choices, prop)
314352
except ValueError as ex:
315353
_LOGGER.error("Unable to create enum for %s: %s", prop, ex)
316354
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)