From 85a182ce8df19eb631ecb1fb3bc201232da7a0f6 Mon Sep 17 00:00:00 2001 From: Bas Veeling Date: Sat, 23 Oct 2021 13:36:24 +0200 Subject: [PATCH] Refactored dir structure. --- miio/__init__.py | 2 +- miio/discovery.py | 18 +- miio/integrations/ihcooker/__init__.py | 62 +++ .../integrations/ihcooker/custom_construct.py | 84 +++++ .../ihcooker}/data/ihcooker_profiles.json | 0 .../ihcooker/data/ihcooker_rice_recipe.json} | 0 miio/{ => integrations/ihcooker}/ihcooker.py | 353 ++---------------- miio/integrations/ihcooker/recipe_profile.py | 175 +++++++++ miio/integrations/ihcooker/tests/__init__.py | 0 .../ihcooker}/tests/test_ihcooker.py | 48 ++- 10 files changed, 391 insertions(+), 351 deletions(-) create mode 100644 miio/integrations/ihcooker/__init__.py create mode 100644 miio/integrations/ihcooker/custom_construct.py rename miio/{ => integrations/ihcooker}/data/ihcooker_profiles.json (100%) rename miio/{data/ihcooker_recipe.json => integrations/ihcooker/data/ihcooker_rice_recipe.json} (100%) rename miio/{ => integrations/ihcooker}/ihcooker.py (60%) create mode 100644 miio/integrations/ihcooker/recipe_profile.py create mode 100644 miio/integrations/ihcooker/tests/__init__.py rename miio/{ => integrations/ihcooker}/tests/test_ihcooker.py (79%) diff --git a/miio/__init__.py b/miio/__init__.py index 4b967bcf2..556f8f772 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -42,7 +42,7 @@ from miio.heater import Heater from miio.heater_miot import HeaterMiot from miio.huizuo import Huizuo, HuizuoLampFan, HuizuoLampHeater, HuizuoLampScene -from miio.ihcooker import IHCooker +from miio.integrations.ihcooker.ihcooker import IHCooker from miio.integrations.yeelight import Yeelight from miio.miot_device import MiotDevice from miio.philips_bulb import PhilipsBulb, PhilipsWhiteBulb diff --git a/miio/discovery.py b/miio/discovery.py index e69f5ab2d..52fdc849a 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -8,6 +8,16 @@ import zeroconf +from miio import IHCooker +from miio.integrations.ihcooker import ( + MODEL_EG1, + MODEL_EXP1, + MODEL_FW, + MODEL_HK1, + MODEL_KOREA1, + MODEL_TW1, + MODEL_V1, +) from miio.integrations.yeelight import Yeelight from . import ( @@ -101,7 +111,6 @@ _LOGGER = logging.getLogger(__name__) - DEVICE_MAP: Dict[str, Union[Type[Device], partial]] = { "rockrobo-vacuum-v1": Vacuum, "roborock-vacuum-s5": Vacuum, @@ -174,6 +183,13 @@ "chunmi-cooker-normal3": Cooker, "chunmi-cooker-normal4": Cooker, "chunmi-cooker-normal5": Cooker, + MODEL_EXP1: IHCooker, + MODEL_FW: IHCooker, + MODEL_TW1: IHCooker, + MODEL_KOREA1: IHCooker, + MODEL_HK1: IHCooker, + MODEL_V1: IHCooker, + MODEL_EG1: IHCooker, "lumi-acpartner-v1": partial(AirConditioningCompanion, model=MODEL_ACPARTNER_V1), "lumi-acpartner-v2": partial(AirConditioningCompanion, model=MODEL_ACPARTNER_V2), "lumi-acpartner-v3": partial(AirConditioningCompanion, model=MODEL_ACPARTNER_V3), diff --git a/miio/integrations/ihcooker/__init__.py b/miio/integrations/ihcooker/__init__.py new file mode 100644 index 000000000..3ef4ad070 --- /dev/null +++ b/miio/integrations/ihcooker/__init__.py @@ -0,0 +1,62 @@ +import enum +import logging + +from miio.exceptions import DeviceException + +_LOGGER = logging.getLogger(__name__) + +MODEL_EG1 = "chunmi.ihcooker.eg1" +MODEL_EXP1 = "chunmi.ihcooker.exp1" +MODEL_FW = "chunmi.ihcooker.chefnic" +MODEL_HK1 = "chunmi.ihcooker.hk1" +MODEL_KOREA1 = "chunmi.ihcooker.korea1" +MODEL_TW1 = "chunmi.ihcooker.tw1" +MODEL_V1 = "chunmi.ihcooker.v1" + +MODEL_VERSION1 = [MODEL_V1, MODEL_FW, MODEL_HK1, MODEL_TW1] +MODEL_VERSION2 = [MODEL_EG1, MODEL_EXP1, MODEL_KOREA1] +SUPPORTED_MODELS = MODEL_VERSION1 + MODEL_VERSION2 + +DEVICE_ID = { + MODEL_EG1: 4, + MODEL_EXP1: 4, + MODEL_FW: 7, + MODEL_HK1: 2, + MODEL_KOREA1: 5, + MODEL_TW1: 3, + MODEL_V1: 1, +} + + +class IHCookerException(DeviceException): + pass + + +class OperationMode(enum.Enum): + """Global mode the induction cooker is currently in.""" + + Error = "error" + Finish = "finish" + Offline = "offline" + Pause = "pause" + TimerPaused = "pause_time" + Precook = "precook" + Running = "running" + SetClock = "set01" + SetStartTime = "set02" + SetCookingTime = "set03" + Shutdown = "shutdown" + Timing = "timing" + Waiting = "waiting" + + +class StageMode(enum.IntEnum): + """Mode for current stage of recipe.""" + + FireMode = 0 + TemperatureMode = 2 + Unknown4 = 4 + TempAutoSmallPot = 8 # TODO: verify this is the right behaviour. + Unknown10 = 10 + TempAutoBigPot = 24 # TODO: verify this is the right behaviour. + Unknown16 = 16 diff --git a/miio/integrations/ihcooker/custom_construct.py b/miio/integrations/ihcooker/custom_construct.py new file mode 100644 index 000000000..bbbeb500f --- /dev/null +++ b/miio/integrations/ihcooker/custom_construct.py @@ -0,0 +1,84 @@ +import construct as c + + +class ArrayDefault(c.Array): + r""" + Homogenous array of elements, similar to C# generic T[]. + + Parses into a ListContainer (a list). Parsing and building processes an exact amount of elements. If given list has less than count elements, the array is padded with the default element. More elements raises RangeError. Size is defined as count multiplied by subcon size, but only if subcon is fixed size. + + Operator [] can be used to make Array instances (recommended syntax). + + :param count: integer or context lambda, strict amount of elements + :param subcon: Construct instance, subcon to process individual elements + :param default: default element to pad array with. + :param discard: optional, bool, if set then parsing returns empty list + + :raises StreamError: requested reading negative amount, could not read enough bytes, requested writing different amount than actual data, or could not write all bytes + :raises RangeError: specified count is not valid + :raises RangeError: given object has different length than specified count + + Can propagate any exception from the lambdas, possibly non-ConstructError. + + Example:: + + >>> d = ArrayDefault(5, Byte, 0) or Byte[5] + >>> d.build(range(3)) + b'\x00\x01\x02\x00\x00' + >>> d.parse(_) + [0, 1, 2, 0, 0] + """ + + def __init__(self, count, subcon, default, discard=False): + super(ArrayDefault, self).__init__(count, subcon, discard) + self.default = default + + def _build(self, obj, stream, context, path): + count = self.count + if callable(count): + count = count(context) + if not 0 <= count: + raise c.RangeError("invalid count %s" % (count,), path=path) + if len(obj) > count: + raise c.RangeError( + "expected %d elements, found %d" % (count, len(obj)), path=path + ) + retlist = c.ListContainer() + + for i, e in enumerate(obj): + context._index = i + buildret = self.subcon._build(e, stream, context, path) + retlist.append(buildret) + for i in range(len(obj), count): + context._index = i + buildret = self.subcon._build(self.default, stream, context, path) + retlist.append(buildret) + return retlist + + +class RebuildStream(c.Rebuild): + r""" + Field where building does not require a value, because the value gets recomputed when needed. Comes handy when building a Struct from a dict with missing keys. Useful for length and count fields when :class:`~construct.core.Prefixed` and :class:`~construct.core.PrefixedArray` cannot be used. + + Parsing defers to subcon. Building is defered to subcon, but it builds from a value provided by the stream until now. Size is the same as subcon, unless it raises SizeofError. + + Difference between Rebuild and RebuildStream, is that RebuildStream provides the current datastream to func. + + :param subcon: Construct instance + :param func: lambda that works with streamed bytes up to this point. + + :raises StreamError: requested reading negative amount, could not read enough bytes, requested writing different amount than actual data, or could not write all bytes + + Can propagate any exception from the lambda, possibly non-ConstructError. + """ + + def __init__(self, subcon, func): + super(RebuildStream, self).__init__(subcon, func) + + def _build(self, obj, stream, context, path): + fallback = c.stream_tell(stream, path) + c.stream_seek(stream, 0, 0, path) + data = stream.read(fallback) + obj = self.func(data) if callable(self.func) else self.func + ret = self.subcon._build(obj, stream, context, path) + return ret diff --git a/miio/data/ihcooker_profiles.json b/miio/integrations/ihcooker/data/ihcooker_profiles.json similarity index 100% rename from miio/data/ihcooker_profiles.json rename to miio/integrations/ihcooker/data/ihcooker_profiles.json diff --git a/miio/data/ihcooker_recipe.json b/miio/integrations/ihcooker/data/ihcooker_rice_recipe.json similarity index 100% rename from miio/data/ihcooker_recipe.json rename to miio/integrations/ihcooker/data/ihcooker_rice_recipe.json diff --git a/miio/ihcooker.py b/miio/integrations/ihcooker/ihcooker.py similarity index 60% rename from miio/ihcooker.py rename to miio/integrations/ihcooker/ihcooker.py index 6a9e2e96c..a5943e92a 100644 --- a/miio/ihcooker.py +++ b/miio/integrations/ihcooker/ihcooker.py @@ -1,7 +1,4 @@ -import enum import json -import logging -import random import warnings from collections import defaultdict from typing import Optional, Union @@ -9,320 +6,30 @@ import click import construct as c -from .click_common import command, format_output -from .device import Device, DeviceStatus -from .exceptions import DeviceException - -_LOGGER = logging.getLogger(__name__) - -MODEL_EG1 = "chunmi.ihcooker.eg1" -MODEL_EXP1 = "chunmi.ihcooker.exp1" -MODEL_FW = "chunmi.ihcooker.chefnic" -MODEL_HK1 = "chunmi.ihcooker.hk1" -MODEL_KOREA1 = "chunmi.ihcooker.korea1" -MODEL_TW1 = "chunmi.ihcooker.tw1" -MODEL_V1 = "chunmi.ihcooker.v1" - -MODEL_VERSION1 = [MODEL_V1, MODEL_FW, MODEL_HK1, MODEL_TW1] -MODEL_VERSION2 = [MODEL_EG1, MODEL_EXP1, MODEL_KOREA1] -SUPPORTED_MODELS = MODEL_VERSION1 + MODEL_VERSION2 - -DEVICE_ID = { - MODEL_EG1: 4, - MODEL_EXP1: 4, - MODEL_FW: 7, - MODEL_HK1: 2, - MODEL_KOREA1: 5, - MODEL_TW1: 3, - MODEL_V1: 1, -} - -RECIPE_NAME_MAX_LEN_V1 = 13 -RECIPE_NAME_MAX_LEN_V2 = 28 -DEFAULT_FIRE_ON_OFF = 20 -DEFAULT_THRESHOLD_CELCIUS = 249 -DEFAULT_TEMP_TARGET_CELCIUS = 229 -DEFAULT_FIRE_LEVEL = 45 -DEFAULT_PHASE_MINUTES = 50 - - -def crc16(data: bytes, offset=0, length=None): - """Computes 16bit CRC for IHCooker recipe profiles. - - Based on variant by Amin Saidani posted on https://stackoverflow.com/a/55850496. - """ - if length is None: - length = len(data) - if ( - data is None - or offset < 0 - or offset > len(data) - 1 - and offset + length > len(data) - ): - return 0 - crc = 0x0000 - for i in range(0, length): - crc ^= data[offset + i] << 8 - for _ in range(0, 8): - if (crc & 0x8000) > 0: - crc = (crc << 1) ^ 0x1021 - else: - crc = crc << 1 - return crc & 0xFFFF - - -class IHCookerException(DeviceException): - pass - - -class StageMode(enum.IntEnum): - """Mode for current stage of recipe.""" - - FireMode = 0 - TemperatureMode = 2 - Unknown4 = 4 - TempAutoSmallPot = 8 # TODO: verify this is the right behaviour. - Unknown10 = 10 - TempAutoBigPot = 24 # TODO: verify this is the right behaviour. - Unknown16 = 16 - - -class OperationMode(enum.Enum): - """Global mode the induction cooker is currently in.""" - - Error = "error" - Finish = "finish" - Offline = "offline" - Pause = "pause" - TimerPaused = "pause_time" - Precook = "precook" - Running = "running" - SetClock = "set01" - SetStartTime = "set02" - SetCookingTime = "set03" - Shutdown = "shutdown" - Timing = "timing" - Waiting = "waiting" - - -class ArrayDefault(c.Array): - r""" - Homogenous array of elements, similar to C# generic T[]. - - Parses into a ListContainer (a list). Parsing and building processes an exact amount of elements. If given list has less than count elements, the array is padded with the default element. More elements raises RangeError. Size is defined as count multiplied by subcon size, but only if subcon is fixed size. - - Operator [] can be used to make Array instances (recommended syntax). - - :param count: integer or context lambda, strict amount of elements - :param subcon: Construct instance, subcon to process individual elements - :param default: default element to pad array with. - :param discard: optional, bool, if set then parsing returns empty list - - :raises StreamError: requested reading negative amount, could not read enough bytes, requested writing different amount than actual data, or could not write all bytes - :raises RangeError: specified count is not valid - :raises RangeError: given object has different length than specified count - - Can propagate any exception from the lambdas, possibly non-ConstructError. - - Example:: - - >>> d = ArrayDefault(5, Byte, 0) or Byte[5] - >>> d.build(range(3)) - b'\x00\x01\x02\x00\x00' - >>> d.parse(_) - [0, 1, 2, 0, 0] - """ - - def __init__(self, count, subcon, default, discard=False): - super(ArrayDefault, self).__init__(count, subcon, discard) - self.default = default - - def _build(self, obj, stream, context, path): - count = self.count - if callable(count): - count = count(context) - if not 0 <= count: - raise c.RangeError("invalid count %s" % (count,), path=path) - if len(obj) > count: - raise c.RangeError( - "expected %d elements, found %d" % (count, len(obj)), path=path - ) - retlist = c.ListContainer() - - for i, e in enumerate(obj): - context._index = i - buildret = self.subcon._build(e, stream, context, path) - retlist.append(buildret) - for i in range(len(obj), count): - context._index = i - buildret = self.subcon._build(self.default, stream, context, path) - retlist.append(buildret) - return retlist - - -class RebuildStream(c.Rebuild): - r""" - Field where building does not require a value, because the value gets recomputed when needed. Comes handy when building a Struct from a dict with missing keys. Useful for length and count fields when :class:`~construct.core.Prefixed` and :class:`~construct.core.PrefixedArray` cannot be used. - - Parsing defers to subcon. Building is defered to subcon, but it builds from a value provided by the stream until now. Size is the same as subcon, unless it raises SizeofError. - - Difference between Rebuild and RebuildStream, is that RebuildStream provides the current datastream to func. - - :param subcon: Construct instance - :param func: lambda that works with streamed bytes up to this point. - - :raises StreamError: requested reading negative amount, could not read enough bytes, requested writing different amount than actual data, or could not write all bytes - - Can propagate any exception from the lambda, possibly non-ConstructError. - """ - - def __init__(self, subcon, func): - super(RebuildStream, self).__init__(subcon, func) - - def _build(self, obj, stream, context, path): - fallback = c.stream_tell(stream, path) - c.stream_seek(stream, 0, 0, path) - data = stream.read(fallback) - obj = self.func(data) if callable(self.func) else self.func - ret = self.subcon._build(obj, stream, context, path) - return ret - - -# Some public v2 recipes have device_version set to 1, so estimating the profile version is non-trivial, plus one might want to convert between versions. -def profile_base(is_v1, recipe_name_encoding="GBK"): - return c.Struct( - c.Const(3, c.Int8un), - "device_version" / c.Default(c.Enum(c.Int8ub, **DEVICE_ID), 1 if is_v1 else 2), - "menu_location" - / c.Default(c.ExprValidator(c.Int8ub, lambda o, _: 0 <= o < 10), 9), - "recipe_name" - / c.Default( - c.ExprAdapter( - c.StringEncoded( # PaddedString wrapper does not support GBK encoding. - c.FixedSized( - RECIPE_NAME_MAX_LEN_V1 if is_v1 else RECIPE_NAME_MAX_LEN_V2, - c.NullStripped(c.GreedyBytes), - ), - recipe_name_encoding, - ), - lambda x, _: x.replace("\n", " "), - lambda x, _: x.replace(" ", "\n"), - ), - "Unnamed", - ), - c.Padding(1) if is_v1 else c.Padding(2), - "recipe_id" / c.Default(c.Int32ub, lambda _: random.randint(0, 2 ** 32 - 1)), - "menu_settings" - / c.Default( - c.BitStruct( # byte 37 - "save_recipe" / c.Default(c.Flag, 0), - "confirm_start" / c.Default(c.Flag, 0), - "menu_unknown3" / c.Default(c.Flag, 0), - "menu_unknown4" / c.Default(c.Flag, 0), - "menu_unknown5" / c.Default(c.Flag, 0), - "menu_unknown6" / c.Default(c.Flag, 0), - "menu_unknown7" / c.Default(c.Flag, 0), - "menu_unknown8" / c.Default(c.Flag, 0), - ), - {}, - ), - "duration_hours" - / c.Rebuild( - c.Int8ub, lambda ctx: ctx.get("duration_minutes", 0) // 60 - ), # byte 38 - "duration_minutes" - / c.Default( - c.ExprAdapter( - c.Int8ub, lambda obj, ctx: obj + ctx.duration_hours * 60, c.obj_ % 60 - ), - 60, - ), # byte 39 - "duration_max_hours" - / c.Rebuild( - c.Int8ub, lambda ctx: ctx.get("duration_max_minutes", 0) // 60 - ), # byte 40 - "duration_max_minutes" - / c.Default( - c.ExprAdapter( - c.Int8ub, - lambda obj, ctx: obj + ctx.duration_max_hours * 60, - c.obj_ % 60, - ), - 0, - ), # byte 41 - "duration_min_hours" - / c.Rebuild( - c.Int8ub, lambda ctx: ctx.get("duration_min_minutes", 0) // 60 - ), # byte 42 - "duration_min_minutes" - / c.Default( - c.ExprAdapter( - c.Int8ub, - lambda obj, ctx: obj + ctx.duration_min_hours * 60, - c.obj_ % 60, - ), - 0, - ), # byte 43 - c.Padding(2), # byte 44, 45 - "unknown_46" / c.Default(c.Byte, 1), # byte 46, should be set to 1 - c.Padding(7) if is_v1 else c.Padding(1), - "stages" - / c.Default( - ArrayDefault( - 15, - c.Struct( # byte 48-168 - "mode" / c.Default(c.Enum(c.Byte, StageMode), StageMode.FireMode), - "hours" - / c.Rebuild( - c.Int8ub, lambda ctx: (ctx.get("minutes", 0) // 60) + 128 - ), - "minutes" - / c.Default( - c.ExprAdapter( - c.Int8ub, - decoder=lambda obj, ctx: obj + (ctx.hours - 128) * 60, - encoder=c.obj_ % 60, - ), - DEFAULT_PHASE_MINUTES, - ), - "temp_threshold" / c.Default(c.Int8ub, DEFAULT_THRESHOLD_CELCIUS), - "temp_target" / c.Default(c.Int8ub, DEFAULT_TEMP_TARGET_CELCIUS), - "power" / c.Default(c.Int8ub, DEFAULT_FIRE_LEVEL), - "fire_off" / c.Default(c.Int8ub, DEFAULT_FIRE_ON_OFF), - "fire_on" / c.Default(c.Int8ub, DEFAULT_FIRE_ON_OFF), - ), - default=dict( - mode=StageMode.FireMode, - minutes=DEFAULT_PHASE_MINUTES, - temp_threshold=DEFAULT_THRESHOLD_CELCIUS, - temp_target=DEFAULT_TEMP_TARGET_CELCIUS, - power=DEFAULT_FIRE_LEVEL, - fire_off=DEFAULT_FIRE_ON_OFF, - fire_on=DEFAULT_FIRE_ON_OFF, - ), - ), - [], - ), - c.Padding(16) if is_v1 else c.Padding(6), # byte 169-174 - "unknown175" / c.Default(c.Int8ub, 0), - "unknown176" / c.Default(c.Int8ub, 0), - "unknown177" / c.Default(c.Int8ub, 0), - "crc" # byte 178-179 - / RebuildStream( - c.Bytes(2), crc16 - ), # Default profiles have invalid crc, c.Checksum() raises undesired error when parsed. - ) - - -profile_v1 = profile_base(is_v1=True) -profile_example = dict(profile_v1.parse(profile_v1.build(dict()))) -profile_keys = profile_example.keys() -stage_keys = dict(profile_example["stages"][0]).keys() -menu_keys = dict(profile_example["menu_settings"]).keys() - -profile_v2 = profile_base(is_v1=False) - -profile_korea = profile_base(is_v1=False, recipe_name_encoding="euc-kr") +from miio import Device, DeviceStatus +from miio.click_common import command, format_output + +from . import ( + DEVICE_ID, + MODEL_KOREA1, + MODEL_VERSION1, + MODEL_VERSION2, + SUPPORTED_MODELS, + IHCookerException, + OperationMode, + StageMode, +) +from .recipe_profile import ( + DEFAULT_FIRE_LEVEL, + RECIPE_NAME_MAX_LEN_V1, + RECIPE_NAME_MAX_LEN_V2, + menu_keys, + profile_keys, + profile_korea, + profile_v1, + profile_v2, + stage_keys, +) class IHCookerStatus(DeviceStatus): @@ -643,7 +350,7 @@ def start(self, profile: Union[str, c.Container, dict], skip_confirmation=False) click.argument("skip_confirmation", type=bool, default=False), click.argument("minutes", type=int, default=60), click.argument("power", type=int, default=DEFAULT_FIRE_LEVEL), - click.argument("menu_location", type=int, default=9), + click.argument("menu_location", type=int, default=None), default_output=format_output("Cooking with temperature requested."), ) def start_temp( @@ -652,7 +359,7 @@ def start_temp( minutes=60, power=DEFAULT_FIRE_LEVEL, skip_confirmation=False, - menu_location=9, + menu_location=None, ): """Start cooking at a fixed temperature and duration. @@ -677,7 +384,7 @@ def start_temp( ) profile = self._prepare_profile(profile) - if menu_location != 9: + if menu_location is not None: self.set_menu(profile, menu_location, True) else: self.start(profile, skip_confirmation) @@ -799,9 +506,9 @@ def set_menu( - skip_confirmation, if True, request confirmation to start recipe as well. """ profile = self._prepare_profile(profile) - print(profile) - if location >= 9 or location < 1: - raise IHCookerException("location %d must be in [1,8]." % location) + + if location >= 9 or location < 0: + raise IHCookerException("location %d must be in [0,9]." % location) profile.menu_settings.save_recipe = True profile.confirm_start = confirm_start profile.menu_location = location diff --git a/miio/integrations/ihcooker/recipe_profile.py b/miio/integrations/ihcooker/recipe_profile.py new file mode 100644 index 000000000..8abc9f20c --- /dev/null +++ b/miio/integrations/ihcooker/recipe_profile.py @@ -0,0 +1,175 @@ +import random + +import construct as c + +from . import DEVICE_ID, StageMode +from .custom_construct import ArrayDefault, RebuildStream + +RECIPE_NAME_MAX_LEN_V1 = 13 +RECIPE_NAME_MAX_LEN_V2 = 28 +DEFAULT_FIRE_ON_OFF = 20 +DEFAULT_THRESHOLD_CELCIUS = 249 +DEFAULT_TEMP_TARGET_CELCIUS = 229 +DEFAULT_FIRE_LEVEL = 45 +DEFAULT_PHASE_MINUTES = 50 + + +def crc16(data: bytes, offset=0, length=None): + """Computes 16bit CRC for IHCooker recipe profiles. + + Based on variant by Amin Saidani posted on https://stackoverflow.com/a/55850496. + """ + if length is None: + length = len(data) + if ( + data is None + or offset < 0 + or offset > len(data) - 1 + and offset + length > len(data) + ): + return 0 + crc = 0x0000 + for i in range(0, length): + crc ^= data[offset + i] << 8 + for _ in range(0, 8): + if (crc & 0x8000) > 0: + crc = (crc << 1) ^ 0x1021 + else: + crc = crc << 1 + return crc & 0xFFFF + + +def profile_base(is_v1, recipe_name_encoding="GBK"): + """Build a Construct for IHCooker recipes based on version and name encoding.""" + return c.Struct( + c.Const(3, c.Int8un), + "device_version" / c.Default(c.Enum(c.Int8ub, **DEVICE_ID), 1 if is_v1 else 2), + "menu_location" + / c.Default(c.ExprValidator(c.Int8ub, lambda o, _: 0 <= o < 10), 9), + "recipe_name" + / c.Default( + c.ExprAdapter( + c.StringEncoded( # PaddedString wrapper does not support GBK encoding. + c.FixedSized( + RECIPE_NAME_MAX_LEN_V1 if is_v1 else RECIPE_NAME_MAX_LEN_V2, + c.NullStripped(c.GreedyBytes), + ), + recipe_name_encoding, + ), + lambda x, _: x.replace("\n", " "), + lambda x, _: x.replace(" ", "\n"), + ), + "Unnamed", + ), + c.Padding(1) if is_v1 else c.Padding(2), + "recipe_id" / c.Default(c.Int32ub, lambda _: random.randint(0, 2 ** 32 - 1)), + "menu_settings" + / c.Default( + c.BitStruct( # byte 37 + "save_recipe" / c.Default(c.Flag, 0), + "confirm_start" / c.Default(c.Flag, 0), + "menu_unknown3" / c.Default(c.Flag, 0), + "menu_unknown4" / c.Default(c.Flag, 0), + "menu_unknown5" / c.Default(c.Flag, 0), + "menu_unknown6" / c.Default(c.Flag, 0), + "menu_unknown7" / c.Default(c.Flag, 0), + "menu_unknown8" / c.Default(c.Flag, 0), + ), + {}, + ), + "duration_hours" + / c.Rebuild( + c.Int8ub, lambda ctx: ctx.get("duration_minutes", 0) // 60 + ), # byte 38 + "duration_minutes" + / c.Default( + c.ExprAdapter( + c.Int8ub, lambda obj, ctx: obj + ctx.duration_hours * 60, c.obj_ % 60 + ), + 60, + ), # byte 39 + "duration_max_hours" + / c.Rebuild( + c.Int8ub, lambda ctx: ctx.get("duration_max_minutes", 0) // 60 + ), # byte 40 + "duration_max_minutes" + / c.Default( + c.ExprAdapter( + c.Int8ub, + lambda obj, ctx: obj + ctx.duration_max_hours * 60, + c.obj_ % 60, + ), + 0, + ), # byte 41 + "duration_min_hours" + / c.Rebuild( + c.Int8ub, lambda ctx: ctx.get("duration_min_minutes", 0) // 60 + ), # byte 42 + "duration_min_minutes" + / c.Default( + c.ExprAdapter( + c.Int8ub, + lambda obj, ctx: obj + ctx.duration_min_hours * 60, + c.obj_ % 60, + ), + 0, + ), # byte 43 + c.Padding(2), # byte 44, 45 + "unknown_46" / c.Default(c.Byte, 1), # byte 46, should be set to 1 + c.Padding(7) if is_v1 else c.Padding(1), + "stages" + / c.Default( + ArrayDefault( + 15, + c.Struct( # byte 48-168 + "mode" / c.Default(c.Enum(c.Byte, StageMode), StageMode.FireMode), + "hours" + / c.Rebuild( + c.Int8ub, lambda ctx: (ctx.get("minutes", 0) // 60) + 128 + ), + "minutes" + / c.Default( + c.ExprAdapter( + c.Int8ub, + decoder=lambda obj, ctx: obj + (ctx.hours - 128) * 60, + encoder=c.obj_ % 60, + ), + DEFAULT_PHASE_MINUTES, + ), + "temp_threshold" / c.Default(c.Int8ub, DEFAULT_THRESHOLD_CELCIUS), + "temp_target" / c.Default(c.Int8ub, DEFAULT_TEMP_TARGET_CELCIUS), + "power" / c.Default(c.Int8ub, DEFAULT_FIRE_LEVEL), + "fire_off" / c.Default(c.Int8ub, DEFAULT_FIRE_ON_OFF), + "fire_on" / c.Default(c.Int8ub, DEFAULT_FIRE_ON_OFF), + ), + default=dict( + mode=StageMode.FireMode, + minutes=DEFAULT_PHASE_MINUTES, + temp_threshold=DEFAULT_THRESHOLD_CELCIUS, + temp_target=DEFAULT_TEMP_TARGET_CELCIUS, + power=DEFAULT_FIRE_LEVEL, + fire_off=DEFAULT_FIRE_ON_OFF, + fire_on=DEFAULT_FIRE_ON_OFF, + ), + ), + [], + ), + c.Padding(16) if is_v1 else c.Padding(6), # byte 169-174 + "unknown175" / c.Default(c.Int8ub, 0), + "unknown176" / c.Default(c.Int8ub, 0), + "unknown177" / c.Default(c.Int8ub, 0), + "crc" # byte 178-179 + / RebuildStream( + c.Bytes(2), crc16 + ), # Default profiles have invalid crc, c.Checksum() raises undesired error when parsed. + ) + + +profile_v1 = profile_base(is_v1=True) +profile_v2 = profile_base(is_v1=False) +profile_korea = profile_base(is_v1=False, recipe_name_encoding="euc-kr") + +_profile_example = dict(profile_v1.parse(profile_v1.build(dict()))) +profile_keys = _profile_example.keys() +stage_keys = dict(_profile_example["stages"][0]).keys() +menu_keys = dict(_profile_example["menu_settings"]).keys() diff --git a/miio/integrations/ihcooker/tests/__init__.py b/miio/integrations/ihcooker/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/miio/tests/test_ihcooker.py b/miio/integrations/ihcooker/tests/test_ihcooker.py similarity index 79% rename from miio/tests/test_ihcooker.py rename to miio/integrations/ihcooker/tests/test_ihcooker.py index e92175ac5..8448c147e 100644 --- a/miio/tests/test_ihcooker.py +++ b/miio/integrations/ihcooker/tests/test_ihcooker.py @@ -3,20 +3,16 @@ import pytest -from miio import IHCooker, ihcooker +from miio import IHCooker +from miio.tests.dummies import DummyDevice -# V2 state from @aquarat -# ['running', '01484f54504f540000000000000000000000000000000000000000000000000001', '00306300b9', '0200013b00000000', '000a1404', '0000000100000002000000030000000400000005000000000000000000000000', '01', '0000ea4a6330303400' -from ..ihcooker import StageMode -from .dummies import DummyDevice - -# V1 state from @EUA -# ['waiting', '02537465616d2f626f696c000000000002', '0013000000', '0100010000000000', '001b0a04', '00000001000000020000000300000004000000050000a4e000000dcc00000000', '00', '0000e60a0017130f00'] +from .. import MODEL_EXP1, MODEL_V1, OperationMode, StageMode +from ..recipe_profile import profile_v1, profile_v2 class DummyIHCookerV2(DummyDevice, IHCooker): def __init__(self, *args, **kwargs): - self._model = ihcooker.MODEL_EXP1 + self._model = MODEL_EXP1 self.state = [ "running", @@ -49,7 +45,7 @@ def _validate_profile(profile): class DummyIHCookerV1(DummyIHCookerV2): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) - self._model = ihcooker.MODEL_V1 + self._model = MODEL_V1 self.state = [ "waiting", @@ -129,10 +125,10 @@ def test_construct(self): "00f93e001414000000000000000000df87" ) - res = ihcooker.profile_v2.parse(bytes.fromhex(recipe)) - self.assertEqual(ihcooker.profile_v2.parse(ihcooker.profile_v2.build(res)), res) - self.assertEqual(len(ihcooker.profile_v2.build(res)), len(recipe) // 2) - self.assertEqual(str(ihcooker.profile_v2.build(res).hex()), recipe) + res = profile_v2.parse(bytes.fromhex(recipe)) + self.assertEqual(profile_v2.parse(profile_v2.build(res)), res) + self.assertEqual(len(profile_v2.build(res)), len(recipe) // 2) + self.assertEqual(str(profile_v2.build(res).hex()), recipe) def test_phases(self): profile = dict( @@ -183,24 +179,24 @@ def test_phases(self): crc=0, ) - bytes_recipe = ihcooker.profile_v2.build(profile) + bytes_recipe = profile_v2.build(profile) hex_recipe = bytes_recipe.hex() self.assertEqual( - "030405546573740a52656369706500000000000000000000000000000000000000000003ea8001370000000000000100008137323c111113028006333d121012188007343e130f11188007343e130f11008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d14140000000000000000000a6b", + "030405546573740a52656369706500000000000000000000000000000000000000000003ea8001370000000000000100008137323c111113028006333d121012188007343e130f11188007343e130f11008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414000000000000000000386d", hex_recipe, ) self.assertEqual( hex_recipe, - ihcooker.profile_v2.build(ihcooker.profile_v2.parse(bytes_recipe)).hex(), + profile_v2.build(profile_v2.parse(bytes_recipe)).hex(), ) - self.assertEqual(115, ihcooker.profile_v2.parse(bytes_recipe).duration_minutes) + self.assertEqual(115, profile_v2.parse(bytes_recipe).duration_minutes) def test_crc(self): recipe = {} self.device.start(recipe) def test_mode(self): - self.assertEqual(ihcooker.OperationMode.Running, self.state().mode) + self.assertEqual(OperationMode.Running, self.state().mode) def test_temperature(self): self.assertEqual(60, self.state().temperature) @@ -239,9 +235,9 @@ def test_start_temp(self): def test_construct(self): recipe = "030405546573740a52656369706500000000000000000000000000000000000000000000000000000000000000000100008005323c111113028006333d121012188007343e130f11000000f93e001414000000f93e001414000000f93e001414000000f93e001414000000f93e001414000000f93e001414000000f93e001414000000f93e001414000000f93e001414000000f93e001414000000f93e000000000000000000000000000000000000000071ba" - res = ihcooker.profile_v1.parse(bytes.fromhex(recipe)) - self.assertEqual(len(ihcooker.profile_v1.build(res)), len(recipe) // 2) - self.assertEqual(str(ihcooker.profile_v1.build(res).hex()), recipe) + res = profile_v1.parse(bytes.fromhex(recipe)) + self.assertEqual(len(profile_v1.build(res)), len(recipe) // 2) + self.assertEqual(str(profile_v1.build(res).hex()), recipe) def test_phases(self): profile = dict( @@ -292,19 +288,19 @@ def test_phases(self): crc=0, ) - bytes_recipe = ihcooker.profile_v1.build(profile) + bytes_recipe = profile_v1.build(profile) hex_recipe = bytes_recipe.hex() self.assertEqual( - "030405546573740a526563697065000000000003ea8001370000000000000100000000000000008137323c111113028006333d121012188007343e130f11188007343e130f11008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d1414008000f9e52d14140000000000000000000000000000000000000005ce", + "030405546573740a526563697065000000000003ea8001370000000000000100000000000000008137323c111113028006333d121012188007343e130f11188007343e130f11008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414008032f9e52d1414000000000000000000000000000000000000002c57", hex_recipe, ) self.assertEqual( hex_recipe, - ihcooker.profile_v1.build(ihcooker.profile_v1.parse(bytes_recipe)).hex(), + profile_v1.build(profile_v1.parse(bytes_recipe)).hex(), ) def test_mode(self): - self.assertEqual(ihcooker.OperationMode.Waiting, self.state().mode) + self.assertEqual(OperationMode.Waiting, self.state().mode) def test_temperature(self): self.assertEqual(19, self.state().temperature)