diff --git a/miio/__init__.py b/miio/__init__.py index 8e50b2aba..0ad453210 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -39,6 +39,7 @@ from miio.integrations.chuangmi.plug import ChuangmiPlug from miio.integrations.chuangmi.remote import ChuangmiIr from miio.integrations.chunmi.cooker import Cooker +from miio.integrations.chunmi.cooker_multi import MultiCooker from miio.integrations.deerma.humidifier import AirHumidifierJsqs, AirHumidifierMjjsq from miio.integrations.dmaker.airfresh import AirFreshA1, AirFreshT2017 from miio.integrations.dmaker.fan import Fan1C, FanMiot, FanP5 diff --git a/miio/data/cooker_profiles.json b/miio/data/cooker_profiles.json index 4df1b2447..7ffbf452d 100644 --- a/miio/data/cooker_profiles.json +++ b/miio/data/cooker_profiles.json @@ -191,5 +191,27 @@ "description": "70 minutes cooking to preserve taste of the food", "profile": "0104E1010A0000000000800200A00069030102780000085A020000EB006B040102780000085A0400012D006E0501027D0000065A0400FFFF00700601027D0000065A0400052D0A0F3C0A1E91FF820E01FF05FF78826EFF10FF786E02690F1EFF826EFF691400FF826EFF69100069FF5AFF00000000000042A7" } + ], + "MODEL_MULTI": [ + { + "title": "Jingzhu", + "description": "60 minutes cooking for tasty rice", + "profile": "02010000000001e101000000000000800101050814000000002091827d7800050091822d781c0a0091823c781c1e0091ff827820ffff91828278000500ffff8278ffffff91828278000d00ff828778ff000091827d7800000091827d7800ffff91826078ff0100490366780701086c0078090301af540266780801086c00780a02023c5701667b0e010a71007a0d02ffff5701667b0f010a73007d0d032005000000000000000000000000000000cf53" + }, + { + "title": "Kuaizhu", + "description": "Quick 40 minutes cooking", + "profile": "02010100000002e100280000000000800101050614000000002091827d7800000091823c7820000091823c781c1e0091ff827820ffff91828278000500ffff8278ffffff91828278000d00ff828778ff000082827d7800000091827d7800ffff91826078ff0164490366780701086c007409030200540266780801086c00760a0202785701667b0e010a7100780a02ffff5701667b0f010a73007b0a032005000000000000000000000000000000ddba" + }, + { + "title": "Zhuzhou", + "description": "Cooking on slow fire from 40 minutes to 4 hours", + "profile": "02010200000003e2011e0400002800800101050614000000002091827d7800000091827d7800000091827d78001e0091ff877820ffff91827d78001e0091ff8278ffffff91828278001e0091828278060f0091827d7804000091827d7800000091827d780001f54e0255261802062a0482030002eb4e0255261802062a04820300032d4e0252261802062c04820501ffff4e0152241802062c0482050120000000000000000000000000000000009ce2" + }, + { + "title": "Baowen", + "description": "Keeping warm at 73 degrees", + "profile": "020103000000040c00001800000100800100000000000000002091827d7800000091827d7800000091827d78000000915a7d7820000091827d7800000091826e78ff000091827d7800000091826e7810000091826e7810000091827d7800000091827d780000a082007882140010871478030000eb820078821400108714780300012d8200788214001087147a0501ffff8200788214001087147d0501200000000000000000000000000000000090e5" + } ] } diff --git a/miio/integrations/chunmi/cooker_multi/__init__.py b/miio/integrations/chunmi/cooker_multi/__init__.py new file mode 100644 index 000000000..7ab85890a --- /dev/null +++ b/miio/integrations/chunmi/cooker_multi/__init__.py @@ -0,0 +1,3 @@ +from .cooker_multi import MultiCooker + +__all__ = ["MultiCooker"] diff --git a/miio/integrations/chunmi/cooker_multi/cooker_multi.py b/miio/integrations/chunmi/cooker_multi/cooker_multi.py new file mode 100644 index 000000000..bad85e7d7 --- /dev/null +++ b/miio/integrations/chunmi/cooker_multi/cooker_multi.py @@ -0,0 +1,400 @@ +import enum +import logging +import math +from collections import defaultdict +from typing import List + +import click +import crcmod + +from miio.click_common import command, format_output +from miio.device import Device, DeviceStatus +from miio.devicestatus import sensor + +_LOGGER = logging.getLogger(__name__) + +MODEL_MULTI = "chunmi.cooker.eh1" + +COOKING_STAGES = { + 1: { + "name": "Quickly preheat", + "description": "Increase temperature in a controlled manner to soften rice", + }, + 2: { + "name": "Absorb water at moderate temp.", + "description": "Increase temperature steadily and let rice absorb enough water to provide full grains and a taste of fragrance and sweetness.", + }, + 3: { + "name": "Operate at full load to boil rice", + "description": "Keep heating at high temperature. Let rice to receive thermal energy uniformly.", + }, + 4: { + "name": "Operate at full load to boil rice", + "description": "Keep heating at high temperature. Let rice to receive thermal energy uniformly.", + }, + 5: { + "name": "Operate at full load to boil rice", + "description": "Keep heating at high temperature. Let rice to receive thermal energy uniformly.", + }, + 6: { + "name": "Operate at full load to boil rice", + "description": "Keep heating at high temperature. Let rice to receive thermal energy uniformly.", + }, + 7: { + "name": "Ultra high", + "description": "High-temperature steam generates crystal clear rice grains and saves its original sweet taste.", + }, + 9: { + "name": "Cook rice over a slow fire", + "description": "Keep rice warm uniformly to lock lateral heat inside. So the rice will get gelatinized sufficiently.", + }, + 10: { + "name": "Cook rice over a slow fire", + "description": "Keep rice warm uniformly to lock lateral heat inside. So the rice will get gelatinized sufficiently.", + }, +} + +COOKING_MENUS = { + "0000000000000000000000000000000000000001": "Fine Rice", + "0101000000000000000000000000000000000002": "Quick Rice", + "0202000000000000000000000000000000000003": "Congee", + "0303000000000000000000000000000000000004": "Keep warm", +} + + +class OperationMode(enum.Enum): + Waiting = 1 + Running = 2 + AutoKeepWarm = 3 + PreCook = 4 + + Unknown = "unknown" + + @classmethod + def _missing_(cls, value): + return OperationMode.Unknown + + +class TemperatureHistory(DeviceStatus): + def __init__(self, data: str): + """Container of temperatures recorded every 10-15 seconds while cooking. + + Example values: + + Status waiting: + 0 + + 2 minutes: + 161515161c242a3031302f2eaa2f2f2e2f + + 12 minutes: + 161515161c242a3031302f2eaa2f2f2e2f2e302f2e2d302f2f2e2f2f2f2f343a3f3f3d3e3c3d3c3f3d3d3d3f3d3d3d3d3e3d3e3c3f3f3d3e3d3e3e3d3f3d3c3e3d3d3e3d3f3e3d3f3e3d3c + + 32 minutes: + 161515161c242a3031302f2eaa2f2f2e2f2e302f2e2d302f2f2e2f2f2f2f343a3f3f3d3e3c3d3c3f3d3d3d3f3d3d3d3d3e3d3e3c3f3f3d3e3d3e3e3d3f3d3c3e3d3d3e3d3f3e3d3f3e3d3c3f3e3d3c3f3e3d3c3f3f3d3d3e3d3d3f3f3d3d3f3f3e3d3d3d3e3e3d3daa3f3f3f3f3f414446474a4e53575e5c5c5b59585755555353545454555554555555565656575757575858585859595b5b5c5c5c5c5d5daa5d5e5f5f606061 + + 55 minutes: + 161515161c242a3031302f2eaa2f2f2e2f2e302f2e2d302f2f2e2f2f2f2f343a3f3f3d3e3c3d3c3f3d3d3d3f3d3d3d3d3e3d3e3c3f3f3d3e3d3e3e3d3f3d3c3e3d3d3e3d3f3e3d3f3e3d3c3f3e3d3c3f3e3d3c3f3f3d3d3e3d3d3f3f3d3d3f3f3e3d3d3d3e3e3d3daa3f3f3f3f3f414446474a4e53575e5c5c5b59585755555353545454555554555555565656575757575858585859595b5b5c5c5c5c5d5daa5d5e5f5f60606161616162626263636363646464646464646464646464646464646464646364646464646464646464646464646464646464646464646464646464aa5a59585756555554545453535352525252525151515151 + + Data structure: + + Octet 1 (16): First temperature measurement in hex (22 °C) + Octet 2 (15): Second temperature measurement in hex (21 °C) + Octet 3 (15): Third temperature measurement in hex (21 °C) + ... + """ + if not len(data) % 2: + self.data = [int(data[i : i + 2], 16) for i in range(0, len(data), 2)] + else: + self.data = [] + + @property + def temperatures(self) -> List[int]: + return self.data + + @property + def raw(self) -> str: + return "".join([f"{value:02x}" for value in self.data]) + + def __str__(self) -> str: + return str(self.data) + + +class MultiCookerProfile: + """This class can be used to modify and validate an existing cooking profile.""" + + def __init__( + self, profile_hex: str, duration: int, schedule: int, auto_keep_warm: bool + ): + if len(profile_hex) < 5: + raise ValueError("Invalid profile") + else: + self.checksum = bytearray.fromhex(profile_hex)[-2:] + self.profile_bytes = bytearray.fromhex(profile_hex)[:-2] + + if not self.is_valid(): + raise ValueError("Profile checksum error") + + if duration is not None: + self.set_duration(duration) + if schedule is not None: + self.set_schedule_enabled(True) + self.set_schedule_duration(schedule) + if auto_keep_warm is not None: + self.set_auto_keep_warm_enabled(auto_keep_warm) + + def is_set_duration_allowed(self): + return ( + self.profile_bytes[10] != self.profile_bytes[12] + or self.profile_bytes[11] != self.profile_bytes[13] + ) + + def get_duration(self): + """Get the duration in minutes.""" + return (self.profile_bytes[8] * 60) + self.profile_bytes[9] + + def set_duration(self, minutes): + """Set the duration in minutes if the profile allows it.""" + if not self.is_set_duration_allowed(): + return + + max_minutes = (self.profile_bytes[10] * 60) + self.profile_bytes[11] + min_minutes = (self.profile_bytes[12] * 60) + self.profile_bytes[13] + + if minutes < min_minutes or minutes > max_minutes: + return + + self.profile_bytes[8] = math.floor(minutes / 60) + self.profile_bytes[9] = minutes % 60 + + self.update_checksum() + + def is_schedule_enabled(self): + return (self.profile_bytes[14] & 0x80) == 0x80 + + def set_schedule_enabled(self, enabled): + if enabled: + self.profile_bytes[14] |= 0x80 + else: + self.profile_bytes[14] &= 0x7F + + self.update_checksum() + + def set_schedule_duration(self, duration): + """Set the schedule time (delay before cooking) in minutes.""" + if duration > 1440: + return + + schedule_flag = self.profile_bytes[14] & 0x80 + self.profile_bytes[14] = math.floor(duration / 60) & 0xFF + self.profile_bytes[14] |= schedule_flag + self.profile_bytes[15] = (duration % 60 | self.profile_bytes[15] & 0x80) & 0xFF + + self.update_checksum() + + def is_auto_keep_warm_enabled(self): + return (self.profile_bytes[15] & 0x80) == 0x80 + + def set_auto_keep_warm_enabled(self, enabled): + if enabled: + self.profile_bytes[15] |= 0x80 + else: + self.profile_bytes[15] &= 0x7F + + self.update_checksum() + + def calc_checksum(self): + crc = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0, xorOut=0x0)( + self.profile_bytes + ) + checksum = bytearray(2) + checksum[0] = (crc >> 8) & 0xFF + checksum[1] = crc & 0xFF + return checksum + + def update_checksum(self): + self.checksum = self.calc_checksum() + + def is_valid(self): + return len(self.profile_bytes) == 174 and self.checksum == self.calc_checksum() + + def get_profile_hex(self): + return (self.profile_bytes + self.checksum).hex() + + +class CookerStatus(DeviceStatus): + def __init__(self, data): + self.data = data + + @property + @sensor("Operation Mode") + def mode(self) -> OperationMode: + """Current operation mode.""" + return OperationMode(self.data["status"]) + + @property + @sensor("Menu ID") + def menu(self) -> str: + """Selected menu id.""" + try: + return COOKING_MENUS[self.data["menu"]] + except KeyError: + return "Unknown menu" + + @property + @sensor("Cooking stage") + def stage(self) -> str: + """Current stage if cooking.""" + try: + return COOKING_STAGES[self.data["phase"]]["name"] + except KeyError: + return "Unknown stage" + + @property + @sensor("Current temperature", unit="C") + def temperature(self) -> int: + """Current temperature, if idle. + + Example values: 29 + """ + return self.data["temp"] + + @property + @sensor("Cooking process time remaining in minutes") + def remaining(self) -> int: + """Remaining minutes of the cooking process. Includes optional precook phase.""" + + if self.mode != OperationMode.PreCook and self.mode != OperationMode.Running: + return 0 + + remaining_minutes = int(self.data["t_left"] / 60) + if self.mode == OperationMode.PreCook: + remaining_minutes = int(self.data["t_pre"]) + + return remaining_minutes + + @property + @sensor( + "Cooking process delay time remaining in minutes (precook phase time remaining)" + ) + def delay_remaining(self) -> int: + """Remaining minutes of the cooking delay (precook phase).""" + + return max(0, self.remaining - self.duration) + + @property + @sensor("Cooking duration in minutes") + def duration(self) -> int: + """Duration of the cooking process. Does not include optional precook phase.""" + return int(self.data["t_cook"]) + + @property + @sensor("Keep warm after cooking enabled") + def keep_warm(self) -> bool: + """Keep warm after cooking?""" + return self.data["akw"] == 1 + + @property + @sensor("Taste ID") + def taste(self) -> None: + """Taste id.""" + return self.data["taste"] + + @property + @sensor("Rice ID") + def rice(self) -> None: + """Rice id.""" + return self.data["rice"] + + @property + @sensor("Selected favorite recipe") + def favorite(self) -> None: + """Favored recipe id.""" + return self.data["favs"] + + +class MultiCooker(Device): + """Main class representing the multi cooker.""" + + _supported_models = [MODEL_MULTI] + + @command() + def status(self) -> CookerStatus: + """Retrieve properties.""" + properties = [ + "status", + "phase", + "menu", + "t_cook", + "t_left", + "t_pre", + "t_kw", + "taste", + "temp", + "rice", + "favs", + "akw", + "t_start", + "t_finish", + "version", + "setting", + "code", + "en_warm", + "t_congee", + "t_love", + "boil", + ] + + values = [] + for prop in properties: + values.append(self.send("get_prop", [prop])[0]) + + properties_count = len(properties) + values_count = len(values) + if properties_count != values_count: + _LOGGER.debug( + "Count (%s) of requested properties does not match the " + "count (%s) of received values.", + properties_count, + values_count, + ) + + return CookerStatus(defaultdict(lambda: None, zip(properties, values))) + + @command( + click.argument("profile", type=str, required=True), + click.option("--duration", type=int, required=False), + click.option("--schedule", type=int, required=False), + click.option("--auto-keep-warm", type=bool, required=False), + default_output=format_output("Cooking profile started"), + ) + def start(self, profile: str, duration: int, schedule: int, auto_keep_warm: bool): + """Start cooking a profile.""" + cookerProfile = MultiCookerProfile(profile, duration, schedule, auto_keep_warm) + self.send("set_start", [cookerProfile.get_profile_hex()]) + + @command(default_output=format_output("Cooking stopped")) + def stop(self): + """Stop cooking.""" + self.send("cancel_cooking", []) + + @command( + click.argument("profile", type=str), + click.option("--duration", type=int, required=False), + click.option("--schedule", type=int, required=False), + click.option("--auto-keep-warm", type=bool, required=False), + default_output=format_output("Setting menu to {profile}"), + ) + def menu(self, profile: str, duration: int, schedule: int, auto_keep_warm: bool): + """Select one of the default(?) cooking profiles.""" + cookerProfile = MultiCookerProfile(profile, duration, schedule, auto_keep_warm) + self.send("set_menu", [cookerProfile.get_profile_hex()]) + + @command(default_output=format_output("", "Temperature history: {result}\n")) + def get_temperature_history(self) -> TemperatureHistory: + """Retrieves a temperature history. + + The temperature is only available while cooking. Approx. six data points per + minute. + """ + return TemperatureHistory(self.send("get_temp_history")[0]) diff --git a/miio/integrations/chunmi/cooker_multi/test_cooker_multi.py b/miio/integrations/chunmi/cooker_multi/test_cooker_multi.py new file mode 100644 index 000000000..43cf12730 --- /dev/null +++ b/miio/integrations/chunmi/cooker_multi/test_cooker_multi.py @@ -0,0 +1,255 @@ +from unittest import TestCase + +import pytest + +from miio.tests.dummies import DummyDevice + +from .cooker_multi import MultiCooker, OperationMode + +COOKING_PROFILES = { + "Fine rice": "02010000000001e101000000000000800101050814000000002091827d7800050091822d781c0a0091823c781c1e0091ff827820ffff91828278000500ffff8278ffffff91828278000d00ff828778ff000091827d7800000091827d7800ffff91826078ff0100490366780701086c0078090301af540266780801086c00780a02023c5701667b0e010a71007a0d02ffff5701667b0f010a73007d0d032005000000000000000000000000000000cf53", + "Quick rice": "02010100000002e100280000000000800101050614000000002091827d7800000091823c7820000091823c781c1e0091ff827820ffff91828278000500ffff8278ffffff91828278000d00ff828778ff000082827d7800000091827d7800ffff91826078ff0164490366780701086c007409030200540266780801086c00760a0202785701667b0e010a7100780a02ffff5701667b0f010a73007b0a032005000000000000000000000000000000ddba", + "Congee": "02010200000003e2011e0400002800800101050614000000002091827d7800000091827d7800000091827d78001e0091ff877820ffff91827d78001e0091ff8278ffffff91828278001e0091828278060f0091827d7804000091827d7800000091827d780001f54e0255261802062a0482030002eb4e0255261802062a04820300032d4e0252261802062c04820501ffff4e0152241802062c0482050120000000000000000000000000000000009ce2", + "Keep warm": "020103000000040c00001800000100800100000000000000002091827d7800000091827d7800000091827d78000000915a7d7820000091827d7800000091826e78ff000091827d7800000091826e7810000091826e7810000091827d7800000091827d780000a082007882140010871478030000eb820078821400108714780300012d8200788214001087147a0501ffff8200788214001087147d0501200000000000000000000000000000000090e5", +} + +TEST_CASES = { + # Fine rice; Schedule 75; auto-keep-warm True + "test_case_0": { + "expected_profile": "02010000000001e1010000000000818f0101050814000000002091827d7800050091822d781c0a0091823c781c1e0091ff827820ffff91828278000500ffff8278ffffff91828278000d00ff828778ff000091827d7800000091827d7800ffff91826078ff0100490366780701086c0078090301af540266780801086c00780a02023c5701667b0e010a71007a0d02ffff5701667b0f010a73007d0d032005000000000000000000000000000000b557", + "cooker_state": { + "status": 4, + "phase": 0, + "menu": "0000000000000000000000000000000000000001", + "t_cook": 60, + "t_left": 3600, + "t_pre": 75, + "t_kw": 0, + "taste": 8, + "temp": 24, + "rice": 261, + "favs": "00000afe", + "akw": 1, + "t_start": 0, + "t_finish": 0, + "version": 13, + "setting": 0, + "code": 0, + "en_warm": 9, + "t_congee": 90, + "t_love": 60, + "boil": 0, + }, + }, + # Fine rice; Schedule 75; auto-keep-warm False + "test_case_1": { + "expected_profile": "02010000000001e1010000000000810f0101050814000000002091827d7800050091822d781c0a0091823c781c1e0091ff827820ffff91828278000500ffff8278ffffff91828278000d00ff828778ff000091827d7800000091827d7800ffff91826078ff0100490366780701086c0078090301af540266780801086c00780a02023c5701667b0e010a71007a0d02ffff5701667b0f010a73007d0d03200500000000000000000000000000000049ee", + "cooker_state": { + "status": 4, + "phase": 0, + "menu": "0000000000000000000000000000000000000001", + "t_cook": 60, + "t_left": 3600, + "t_pre": 75, + "t_kw": 0, + "taste": 8, + "temp": 24, + "rice": 261, + "favs": "00000afe", + "akw": 2, + "t_start": 0, + "t_finish": 0, + "version": 13, + "setting": 0, + "code": 0, + "en_warm": 8, + "t_congee": 90, + "t_love": 60, + "boil": 0, + }, + }, + # Quick rice; Schedule 75; auto-keep-warm False + "test_case_2": { + "expected_profile": "02010100000002e1002800000000810f0101050614000000002091827d7800000091823c7820000091823c781c1e0091ff827820ffff91828278000500ffff8278ffffff91828278000d00ff828778ff000082827d7800000091827d7800ffff91826078ff0164490366780701086c007409030200540266780801086c00760a0202785701667b0e010a7100780a02ffff5701667b0f010a73007b0a0320050000000000000000000000000000005b07", + "cooker_state": { + "status": 4, + "phase": 0, + "menu": "0101000000000000000000000000000000000002", + "t_cook": 40, + "t_left": 2400, + "t_pre": 75, + "t_kw": 0, + "taste": 6, + "temp": 24, + "rice": 261, + "favs": "00000afe", + "akw": 2, + "t_start": 0, + "t_finish": 0, + "version": 13, + "setting": 0, + "code": 0, + "en_warm": 8, + "t_congee": 90, + "t_love": 60, + "boil": 0, + }, + }, + # Congee; auto-keep-warm False + "test_case_3": { + "expected_profile": "02010200000003e2011e0400002800000101050614000000002091827d7800000091827d7800000091827d78001e0091ff877820ffff91827d78001e0091ff8278ffffff91828278001e0091828278060f0091827d7804000091827d7800000091827d780001f54e0255261802062a0482030002eb4e0255261802062a04820300032d4e0252261802062c04820501ffff4e0152241802062c048205012000000000000000000000000000000000605b", + "cooker_state": { + "status": 2, + "phase": 0, + "menu": "0202000000000000000000000000000000000003", + "t_cook": 90, + "t_left": 5396, + "t_pre": 75, + "t_kw": 0, + "taste": 6, + "temp": 24, + "rice": 261, + "favs": "00000afe", + "akw": 2, + "t_start": 0, + "t_finish": 0, + "version": 13, + "setting": 0, + "code": 0, + "en_warm": 8, + "t_congee": 90, + "t_love": 60, + "boil": 0, + }, + }, + # Keep warm; Duration 55 + "test_case_4": { + "expected_profile": "020103000000040c00371800000100800100000000000000002091827d7800000091827d7800000091827d78000000915a7d7820000091827d7800000091826e78ff000091827d7800000091826e7810000091826e7810000091827d7800000091827d780000a082007882140010871478030000eb820078821400108714780300012d8200788214001087147a0501ffff8200788214001087147d050120000000000000000000000000000000001ab9", + "cooker_state": { + "status": 2, + "phase": 0, + "menu": "0303000000000000000000000000000000000004", + "t_cook": 55, + "t_left": 5, + "t_pre": 75, + "t_kw": 0, + "taste": 0, + "temp": 24, + "rice": 0, + "favs": "00000afe", + "akw": 1, + "t_start": 0, + "t_finish": 0, + "version": 13, + "setting": 0, + "code": 0, + "en_warm": 8, + "t_congee": 90, + "t_love": 60, + "boil": 0, + }, + }, +} + +DEFAULT_STATE = { + "status": 1, # Waiting + "phase": 0, # Current cooking phase: Unknown / No stage + "menu": "0000000000000000000000000000000000000001", # Menu: Fine Rice + "t_cook": 60, # Total cooking time for the menu + "t_left": 3600, # Remaining cooking time for the menu + "t_pre": 0, # Remaining pre cooking time + "t_kw": 0, # Keep warm time after finish cooking the menu + "taste": 8, # Taste setting + "temp": 24, # Current temperature + "rice": 261, # Rice setting + "favs": "00000afe", # Current favorite menu configured + "akw": 0, # Keep warm enabled + "t_start": 0, + "t_finish": 0, + "version": 13, + "setting": 0, + "code": 0, + "en_warm": 15, + "t_congee": 90, + "t_love": 60, + "boil": 0, +} + + +class DummyMultiCooker(DummyDevice, MultiCooker): + def __init__(self, *args, **kwargs): + self.state = DEFAULT_STATE + self.return_values = { + "get_prop": self._get_state, + "set_start": lambda x: self.set_start(x), + "cancel_cooking": lambda _: self.cancel_cooking(), + } + super().__init__(args, kwargs) + + def set_start(self, profile): + state = DEFAULT_STATE + + for test_case in TEST_CASES: + if profile == [TEST_CASES[test_case]["expected_profile"]]: + state = TEST_CASES[test_case]["cooker_state"] + + for prop in state: + self._set_state(prop, [state[prop]]) + + def cancel_cooking(self): + for prop in DEFAULT_STATE: + self._set_state(prop, [DEFAULT_STATE[prop]]) + + +@pytest.fixture(scope="class") +def multicooker(request): + request.cls.device = DummyMultiCooker() + + +@pytest.mark.usefixtures("multicooker") +class TestMultiCooker(TestCase): + def test_case_0(self): + self.device.start(COOKING_PROFILES["Fine rice"], None, 75, True) + status = self.device.status() + assert status.mode == OperationMode.PreCook + assert status.menu == "Fine Rice" + assert status.delay_remaining == 75 - 60 + assert status.remaining == 75 + assert status.keep_warm is True + self.device.stop() + + def test_case_1(self): + self.device.start(COOKING_PROFILES["Fine rice"], None, 75, False) + status = self.device.status() + assert status.mode == OperationMode.PreCook + assert status.menu == "Fine Rice" + assert status.delay_remaining == 75 - 60 + assert status.remaining == 75 + assert status.keep_warm is False + self.device.stop() + + def test_case_2(self): + self.device.start(COOKING_PROFILES["Quick rice"], None, 75, False) + status = self.device.status() + assert status.mode == OperationMode.PreCook + assert status.menu == "Quick Rice" + assert status.delay_remaining == 75 - 40 + assert status.remaining == 75 + assert status.keep_warm is False + self.device.stop() + + def test_case_3(self): + self.device.start(COOKING_PROFILES["Congee"], None, None, False) + status = self.device.status() + assert status.mode == OperationMode.Running + assert status.menu == "Congee" + assert status.keep_warm is False + self.device.stop() + + def test_case_4(self): + self.device.start(COOKING_PROFILES["Keep warm"], 55, None, None) + status = self.device.status() + assert status.mode == OperationMode.Running + assert status.menu == "Keep warm" + assert status.duration == 55 + self.device.stop() diff --git a/pyproject.toml b/pyproject.toml index b7ad896a6..d8f918ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ myst-parser = { version = "*", optional = true } # optionals netifaces = { version = "^0", optional = true } android_backup = { version = "^0", optional = true } +crcmod = { version = "^1.7", optional = true } [tool.poetry.extras] docs = ["sphinx", "sphinx_click", "sphinxcontrib-apidoc", "sphinx_rtd_theme", "myst-parser"]