diff --git a/README.rst b/README.rst index 3447527a3..786a5bf5c 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/miio/__init__.py b/miio/__init__.py index a78538e75..a5b0cefb7 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -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 diff --git a/miio/integrations/petfooddispenser/__init__.py b/miio/integrations/petfooddispenser/__init__.py new file mode 100644 index 000000000..017346fb9 --- /dev/null +++ b/miio/integrations/petfooddispenser/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa +from .device import PetFoodDispenser diff --git a/miio/integrations/petfooddispenser/device.py b/miio/integrations/petfooddispenser/device.py new file mode 100644 index 000000000..653d08369 --- /dev/null +++ b/miio/integrations/petfooddispenser/device.py @@ -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", + ) + ) + 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: + """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), + default_output=format_output( + lambda state: "Turning on WiFi LED" if state else "Turning off WiFi LED" + ), + ) + def set_wifi_led(self, state: int): + """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): + """Enable / Disable the automatic feeding schedule.""" + return self.send("stopfeed", [state]) + + diff --git a/miio/integrations/petfooddispenser/status.py b/miio/integrations/petfooddispenser/status.py new file mode 100644 index 000000000..6695fd2e8 --- /dev/null +++ b/miio/integrations/petfooddispenser/status.py @@ -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 + + @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: + """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"]) diff --git a/miio/integrations/petfooddispenser/tests/__init__.py b/miio/integrations/petfooddispenser/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/integrations/petfooddispenser/tests/test_status.py b/miio/integrations/petfooddispenser/tests/test_status.py new file mode 100755 index 000000000..873eef8f8 --- /dev/null +++ b/miio/integrations/petfooddispenser/tests/test_status.py @@ -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 +