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

Use descriptors for default status command cli output #1684

Merged
merged 10 commits into from
Jan 26, 2023
1 change: 1 addition & 0 deletions docs/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ The status container should inherit :class:`~miio.devicestatus.DeviceStatus`.
Doing so ensures that a developer-friendly :meth:`~miio.devicestatus.DeviceStatus.__repr__` based on the defined
properties is there to help with debugging.
Furthermore, it allows defining meta information about properties that are especially interesting for end users.
The ``miiocli`` tool will automatically use the defined information to generate a user-friendly output.

.. note::

Expand Down
9 changes: 7 additions & 2 deletions miio/click_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,13 @@ def wrap(*args, **kwargs):
msg = msg_fmt.format(**kwargs)
if msg:
echo(msg.strip())
kwargs["result"] = func(*args, **kwargs)
if result_msg_fmt:
result = kwargs["result"] = func(*args, **kwargs)
if (
not callable(result_msg_fmt)
and getattr(result, "__cli_output__", None) is not None
):
echo(result.__cli_output__)
elif result_msg_fmt:
if callable(result_msg_fmt):
result_msg = result_msg_fmt(**kwargs)
else:
Expand Down
31 changes: 31 additions & 0 deletions miio/devicestatus.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def __repr__(self):
s = f"<{self.__class__.__name__}"
for prop_tuple in props:
name, prop = prop_tuple
if name.startswith("_"): # skip internals
continue
try:
# ignore deprecation warnings
with warnings.catch_warnings(record=True):
Expand Down Expand Up @@ -134,6 +136,32 @@ def __dir__(self) -> Iterable[str]:
+ list(self._settings)
)

@property
def __cli_output__(self) -> str:
"""Return a CLI formatted output of the status."""
out = ""
for entry in list(self.sensors().values()) + list(self.settings().values()):
try:
value = getattr(self, entry.property)
except KeyError:
continue # skip missing properties

if value is None: # skip none values
_LOGGER.debug("Skipping %s because it's None", entry.name)
continue

if isinstance(entry, SettingDescriptor):
out += "[RW] "

out += f"{entry.name}: {value}"

if entry.unit is not None:
out += f" {entry.unit}"

out += "\n"

return out

def __getattr__(self, item):
"""Overridden to lookup properties from embedded containers."""
if item.startswith("__") and item.endswith("__"):
Expand All @@ -142,6 +170,9 @@ def __getattr__(self, item):
if item in self._embedded:
return self._embedded[item]

if "__" not in item:
return super().__getattribute__(item)

embed, prop = item.split("__", maxsplit=1)
if not embed or not prop:
return super().__getattribute__(item)
Expand Down
21 changes: 1 addition & 20 deletions miio/integrations/fan/zhimi/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,26 +219,7 @@ class Fan(Device):

_supported_models = list(AVAILABLE_PROPERTIES.keys())

@command(
default_output=format_output(
"",
"Power: {result.power}\n"
"Battery: {result.battery} %\n"
"AC power: {result.ac_power}\n"
"Temperature: {result.temperature} °C\n"
"Humidity: {result.humidity} %\n"
"LED: {result.led}\n"
"LED brightness: {result.led_brightness}\n"
"Buzzer: {result.buzzer}\n"
"Child lock: {result.child_lock}\n"
"Speed: {result.speed}\n"
"Natural speed: {result.natural_speed}\n"
"Direct speed: {result.direct_speed}\n"
"Oscillate: {result.oscillate}\n"
"Power-off time: {result.delay_off_countdown}\n"
"Angle: {result.angle}\n",
)
)
@command()
def status(self) -> FanStatus:
"""Retrieve properties."""
properties = AVAILABLE_PROPERTIES[self.model]
Expand Down
60 changes: 30 additions & 30 deletions miio/integrations/genericmiot/genericmiot.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,35 +20,6 @@
_LOGGER = logging.getLogger(__name__)


def pretty_status(result: "GenericMiotStatus"):
"""Pretty print status information."""
out = ""
props = result.property_dict()
service = None
for _name, prop in props.items():
miot_prop: MiotProperty = prop.extras["miot_property"]
if service is None or miot_prop.siid != service.siid:
service = miot_prop.service
out += f"Service [bold]{service.description} ({service.name})[/bold]\n" # type: ignore # FIXME

out += f"\t{prop.description} ({prop.name}, access: {prop.pretty_access}): {prop.pretty_value}"

if MiotAccess.Write in miot_prop.access:
out += f" ({prop.format}"
if prop.pretty_input_constraints is not None:
out += f", {prop.pretty_input_constraints}"
out += ")"

if result.device._debug > 1:
out += "\n\t[bold]Extras[/bold]\n"
for extra_key, extra_value in prop.extras.items():
out += f"\t\t{extra_key} = {extra_value}\n"

out += "\n"

return out


def pretty_actions(result: Dict[str, ActionDescriptor]):
"""Pretty print actions."""
out = ""
Expand Down Expand Up @@ -154,6 +125,35 @@ def property_dict(self) -> Dict[str, MiotProperty]:

return res

@property
def __cli_output__(self):
"""Return a CLI printable status."""
out = ""
props = self.property_dict()
service = None
for _name, prop in props.items():
miot_prop: MiotProperty = prop.extras["miot_property"]
if service is None or miot_prop.siid != service.siid:
service = miot_prop.service
out += f"Service [bold]{service.description} ({service.name})[/bold]\n" # type: ignore # FIXME

out += f"\t{prop.description} ({prop.name}, access: {prop.pretty_access}): {prop.pretty_value}"

if MiotAccess.Write in miot_prop.access:
out += f" ({prop.format}"
if prop.pretty_input_constraints is not None:
out += f", {prop.pretty_input_constraints}"
out += ")"

if self.device._debug > 1:
out += "\n\t[bold]Extras[/bold]\n"
for extra_key, extra_value in prop.extras.items():
out += f"\t\t{extra_key} = {extra_value}\n"

out += "\n"

return out

def __repr__(self):
s = f"<{self.__class__.__name__}"
for name, value in self.property_dict().items():
Expand Down Expand Up @@ -207,7 +207,7 @@ def initialize_model(self):
_LOGGER.debug("Initialized: %s", self._miot_model)
self._create_descriptors()

@command(default_output=format_output(result_msg_fmt=pretty_status))
@command()
def status(self) -> GenericMiotStatus:
"""Return status based on the miot model."""
properties = []
Expand Down
23 changes: 1 addition & 22 deletions miio/integrations/humidifier/zhimi/airhumidifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,28 +326,7 @@ class AirHumidifier(Device):

_supported_models = SUPPORTED_MODELS

@command(
default_output=format_output(
"",
"Power: {result.power}\n"
"Mode: {result.mode}\n"
"Temperature: {result.temperature} °C\n"
"Humidity: {result.humidity} %\n"
"LED brightness: {result.led_brightness}\n"
"Buzzer: {result.buzzer}\n"
"Child lock: {result.child_lock}\n"
"Target humidity: {result.target_humidity} %\n"
"Trans level: {result.trans_level}\n"
"Speed: {result.motor_speed}\n"
"Depth: {result.depth}\n"
"Water Level: {result.water_level} %\n"
"Water tank attached: {result.water_tank_attached}\n"
"Dry: {result.dry}\n"
"Use time: {result.use_time}\n"
"Hardware version: {result.hardware_version}\n"
"Button pressed: {result.button_pressed}\n",
)
)
@command()
def status(self) -> AirHumidifierStatus:
"""Retrieve properties."""

Expand Down
25 changes: 1 addition & 24 deletions miio/integrations/vacuum/mijia/pro2vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,30 +272,7 @@ class Pro2Vacuum(MiotDevice, VacuumInterface):

_mappings = _MAPPINGS

@command(
default_output=format_output(
"",
"State: {result.state}\n"
"Error: {result.error}\n"
"Battery: {result.battery}%\n"
"Sweep Mode: {result.sweep_mode}\n"
"Sweep Type: {result.sweep_type}\n"
"Mop State: {result.mop_state}\n"
"Fan speed: {result.fan_speed}\n"
"Water level: {result.water_level}\n"
"Main Brush Life Level: {result.main_brush_life_level}%\n"
"Main Brush Life Time: {result.main_brush_time_left}h\n"
"Side Brush Life Level: {result.side_brush_life_level}%\n"
"Side Brush Life Time: {result.side_brush_time_left}h\n"
"Filter Life Level: {result.filter_life_level}%\n"
"Filter Life Time: {result.filter_time_left}h\n"
"Mop Life Level: {result.mop_life_level}%\n"
"Mop Life Time: {result.mop_time_left}h\n"
"Clean Area: {result.clean_area} m^2\n"
"Clean Time: {result.clean_time} mins\n"
"Current Language: {result.current_language}\n",
)
)
@command()
def status(self) -> Pro2Status:
"""Retrieve properties."""
return Pro2Status(
Expand Down
35 changes: 2 additions & 33 deletions miio/integrations/vacuum/viomi/viomivacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@

import click

from miio.click_common import EnumType, command, format_output
from miio.click_common import EnumType, command
from miio.device import Device
from miio.devicestatus import action, sensor, setting
from miio.exceptions import DeviceException
Expand Down Expand Up @@ -614,38 +614,7 @@ def __init__(
self.manual_seqnum = -1
self._cache: Dict[str, Any] = {"edge_state": None, "rooms": {}, "maps": {}}

@command(
default_output=format_output(
"\n",
"General\n"
"=======\n\n"
"Hardware version: {result.hw_info}\n"
"State: {result.state}\n"
"Working: {result.is_on}\n"
"Battery status: {result.error}\n"
"Battery: {result.battery}\n"
"Charging: {result.charging}\n"
"Box type: {result.bin_type}\n"
"Fan speed: {result.fanspeed}\n"
"Water grade: {result.water_grade}\n"
"Mop attached: {result.mop_attached}\n"
"Vacuum along the edges: {result.edge_state}\n"
"Mop route pattern: {result.route_pattern}\n"
"Secondary Cleanup: {result.repeat_cleaning}\n"
"Sound Volume: {result.sound_volume}\n"
"Clean time: {result.clean_time}\n"
"Clean area: {result.clean_area} m²\n"
"LED state: {result.led_state}\n"
"\n"
"Map\n"
"===\n\n"
"Current map ID: {result.current_map_id}\n"
"Remember map: {result.remember_map}\n"
"Has map: {result.has_map}\n"
"Has new map: {result.has_new_map}\n"
"Number of maps: {result.map_number}\n",
)
)
@command()
def status(self) -> ViomiVacuumStatus:
"""Retrieve properties."""

Expand Down
38 changes: 37 additions & 1 deletion miio/tests/test_devicestatus.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def return_none(self):
assert repr(NoneStatus()) == "<NoneStatus return_none=None>"


def test_get_attribute(mocker):
def test_get_attribute():
"""Make sure that __get_attribute__ works as expected."""

class TestStatus(DeviceStatus):
Expand Down Expand Up @@ -285,3 +285,39 @@ def sub_sensor(self):
# Test that __dir__ is implemented correctly
assert "SubStatus" in dir(main)
assert "SubStatus__sub_sensor" in dir(main)


def test_cli_output():
"""Test the cli output string."""

class Status(DeviceStatus):
@property
@sensor("sensor_without_unit")
def sensor_without_unit(self) -> int:
return 1

@property
@sensor("sensor_with_unit", unit="V")
def sensor_with_unit(self) -> int:
return 2

@property
@setting("setting_without_unit", setter_name="dummy")
def setting_without_unit(self):
return 3

@property
@setting("setting_with_unit", unit="V", setter_name="dummy")
def setting_with_unit(self):
return 4

@property
@sensor("none_sensor")
def sensor_returning_none(self):
return None

status = Status()
assert (
status.__cli_output__
== "sensor_without_unit: 1\nsensor_with_unit: 2 V\n[RW] setting_without_unit: 3\n[RW] setting_with_unit: 4 V\n"
)