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 inital support for mmgg.feeder.petfeeder #1210

Closed
wants to merge 11 commits into from
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ Supported devices
- Qingping Air Monitor Lite (cgllc.airm.cgdn1)
- Xiaomi Walkingpad A1 (ksmb.walkingpad.v3)
- Xiaomi Smart Pet Water Dispenser (mmgg.pet_waterer.s1, s4)
- Xiaomi Smart Pet Food Dispenser (mmgg.feeder.petfeeder)


*Feel free to create a pull request to add support for new devices as
Expand Down
1 change: 1 addition & 0 deletions miio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from miio.heater_miot import HeaterMiot
from miio.huizuo import Huizuo, HuizuoLampFan, HuizuoLampHeater, HuizuoLampScene
from miio.integrations.petwaterdispenser import PetWaterDispenser
from miio.integrations.petfooddispenser import PetFoodDispenser
from miio.integrations.vacuum.dreame.dreamevacuum_miot import DreameVacuumMiot
from miio.integrations.vacuum.mijia import G1Vacuum
from miio.integrations.vacuum.roborock import RoborockVacuum, Vacuum, VacuumException
Expand Down
2 changes: 2 additions & 0 deletions miio/integrations/petfooddispenser/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# flake8: noqa
from .device import PetFoodDispenser
111 changes: 111 additions & 0 deletions miio/integrations/petfooddispenser/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import logging
from typing import Any, Dict, List

from collections import defaultdict
import click

from miio.click_common import EnumType, command, format_output
from miio import Device


from .status import PetFoodDispenserStatus

_LOGGER = logging.getLogger(__name__)

MODEL_MMGG_FEEDER_PETFEEDER = "mmgg.feeder.petfeeder"

SUPPORTED_MODELS: List[str] = [MODEL_MMGG_FEEDER_PETFEEDER]

AVAILABLE_PROPERTIES: Dict[str, List[str]] = {
MODEL_MMGG_FEEDER_PETFEEDER: [
"food_status",
"feed_plan",
"door_status",
"feed_today",
"clean_days",
"power_status",
"dryer_days",
"food_portion",
"wifi_led",
"key_lock",
"country_code",
],
}

class PetFoodDispenser(Device):
"""Main class representing the Pet Feeder / Smart Pet Food Dispenser. """

_supported_models = SUPPORTED_MODELS

@command(
default_output=format_output(
"",
"Power source: {result.power_status}\n"
"Food level: {result.food_status}\n"
"Automatic feeding: {result.feed_plan}\n"
"Food bin lid: {result.door_status}\n"
"Dispense button lock: {result.key_lock}\n"
"Days until clean: {result.clean_days}\n"
"Desiccant life: {result.dryer_days}\n"
"WiFi LED: {result.wifi_led}\n",
Comment on lines +43 to +50
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would make sense to avoid using the property names from the protocol itself, but use more developer friendly names (similar to use for these printouts).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah I was going to ask about that.

Some names I've taken from miot spec for another feeder by mmgg (one that's actually compliant... 😠 ), but obviously some things don't translate overly well and wasn't sure on the preferred approach.

So you're suggesting (for example) "door_status" > "food_bin_lid" or something along those lines?

Copy link
Owner

@rytilahti rytilahti Dec 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. It makes sense to separate the property names from what is being internally used, as most of the times they are not clear in their meaning. bin_lid_open, maybe?

)
)
def status(self) -> PetFoodDispenserStatus:
"""Retrieve properties."""
properties = AVAILABLE_PROPERTIES.get(
self.model, AVAILABLE_PROPERTIES[MODEL_MMGG_FEEDER_PETFEEDER]
)
values = self.send('getprops')
return PetFoodDispenserStatus(defaultdict(lambda: None, zip(properties, values)))

@command(
click.argument("amount", type=int),
default_output=format_output("Dispensing {amount} units)"),
)
def dispense_food(self, amount: int):
"""Dispense food.
:param amount: in units (1 unit ~= 5g)
"""
return self.send("outfood", [amount])

@command(default_output=format_output("Resetting clean time"))
def reset_clean_time(self) -> bool:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this returns a bool? And same for reset_dryer_time?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, it returns a list with a string of "ok".
That said, it returns "ok" literally no matter what you send to this device 🤦

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's common on commands, coming likely from the SDK. You could either check for the "ok" in the payload or simply return it as it is, there is no real consistency in the library at the moment on that.

"""Reset clean time."""
return self.send("resetclean")

@command(default_output=format_output("Resetting dryer time"))
def reset_dryer_time(self) -> bool:
"""Reset dryer time."""
return self.send("resetdryer")

@command(
click.argument("state", type=int),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
click.argument("state", type=int),
click.argument("state", type=bool),

Use bool for boolean values across the board. This should work directly for the cli input values so no need for explicit conversions either.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used bool initially but seemingly then gets sent as literal "true" or "false" (not 1 or 0) which the device does not understand. miot spec for other mmgg products have these defined as ints rather than bools.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, then casting is needed; int(boolvalue) will fix that.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦
Given I wrote it at 3am I'm amazed there's not more glaring issues. I'll sort that.

default_output=format_output(
lambda state: "Turning on WiFi LED" if state else "Turning off WiFi LED"
),
)
def set_wifi_led(self, state: int):
rytilahti marked this conversation as resolved.
Show resolved Hide resolved
"""Enable / Disable the wifi status led."""
return self.send("wifiledon", [state])

@command(
click.argument("state", type=int),
default_output=format_output(
lambda state: "Enabling key lock for dispense button" if state else "Disabling key lock for dispense button"
),
)
def set_key_lock(self, state: int):
"""Enable / Disable the key lock for the manual dispense button."""
return self.send("keylock", [state ^ 1])

@command(
click.argument("state", type=int),
default_output=format_output(
lambda state: "Enabling automatic feeding schedule" if state else "Disabling automatic feeding schedule"
),
)
def set_feed_state(self, state: int):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_automated_feeding <-> automated_feeding in state, maybe?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming stuff is the hardest part of all this, honestly 😆

In mihome the toggle is "automatic feeding schedule".

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, you can run the linting tests locally by executing tox -e lint or pre-commit run -a (or alternatively, you can do pre-commit install and it will perform the checks prior commiting).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must admit I didn't with this commit, I will in future 👼

"""Enable / Disable the automatic feeding schedule."""
return self.send("stopfeed", [state])


67 changes: 67 additions & 0 deletions miio/integrations/petfooddispenser/status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import enum
from typing import Any, Dict

from miio import DeviceStatus

class FoodStatus(enum.Enum):
Normal = 0
Low = 1
Empty = 2

class PowerState(enum.Enum):
Mains = 0
Battery = 1

class PetFoodDispenserStatus(DeviceStatus):
"""Container for status reports from the Pet Feeder / Smart Pet Food Dispenser."""

def __init__(self, data: Dict[str, Any]) -> None:
"""
Response from pet feeder (mmgg.feeder.petfeeder):

{'food_status': 0, 'feed_plan': 1, 'door_status': 0,
'feed_today': 0, 'clean_time': 7, 'power_status': 0,
'dryer_days': 6, 'food_portion': 0, 'wifi_led': 1,
'key_lock': 1, 'country_code': 255,}
"""
self.data = data
Wh1terat marked this conversation as resolved.
Show resolved Hide resolved

@property
def food_status(self) -> FoodStatus:
"""Current food status / level."""
return FoodStatus(self.data["food_status"])

@property
def feed_plan(self) -> bool:
"""Automatic feeding status."""
return bool(self.data["feed_plan"])

@property
def door_status(self) -> bool:
"""Food bin door status."""
return bool(self.data["door_status"])

@property
def clean_days(self) -> int:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use timedelta for this and the dryer one, this makes it consistent with other integrations.
Also, use naming scheme <variable>_left.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, was just about to ask about this.
"dryer_days" > "desiccant_left"

Can't quite think how to phrase "clean_days".
It's the number of days until the unit requires cleaning.
Suggestions?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a good term for that either, time_until_clean_left is sounds quite verbose.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's what I was trying to avoid, even though it's very pythonic to make it as long as is needed to convey it's use.

"""Number of days until the unit requires cleaning."""
return self.data['clean_days']

@property
def power_status(self) -> str:
"""Power status."""
return PowerState(self.data["power_status"])

@property
def dryer_days(self) -> int:
"""Number of days until the desiccant disc requires replacing."""
return self.data['dryer_days']

@property
def wifi_led(self) -> bool:
"""WiFi LED Status."""
return bool(self.data["wifi_led"])

@property
def key_lock(self) -> bool:
"""Key lock status for manual dispense button."""
return bool(self.data["key_lock"])
Empty file.
31 changes: 31 additions & 0 deletions miio/integrations/petfooddispenser/tests/test_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from ..status import FoodStatus, PowerState, PetFoodDispenserStatus

data = {
"food_status": 0,
"feed_plan": 1,
"door_status": 0,
"feed_today": 0,
"clean_time": 7,
"power_status": 0,
"dryer_days": 6,
"food_portion": 0,
"wifi_led": 1,
"key_lock": 1,
"country_code": 255,
}

def test_status():
status = PetFoodDispenserStatus(data)

assert status.food_status == FoodStatus(0)
assert status.feed_plan is True
assert status.door_status is False
assert status.feed_today == 0
assert status.clean_time == 7
assert status.power_status == PowerState(0)
assert status.dryer_days == 6
assert status.food_portion == 0
assert status.wifi_led is True
assert status.key_lock is False
assert status.country_code == 255