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 support for chuangmi.remote.h102a03 and chuangmi.remote.v2 #1021

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .readthedocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ python:
version: 3.7
pip_install: true
extra_requirements:
- docs
- dev
4 changes: 2 additions & 2 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ stages:

- script: |
python -m pip install --upgrade pip poetry
poetry install --extras docs
poetry install --extras dev
displayName: 'Install dependencies'
- script: |
Expand Down Expand Up @@ -122,7 +122,7 @@ stages:

- script: |
python -m pip install --upgrade pip poetry
poetry install
poetry install --extras dev
displayName: 'Install dependencies'
- script: |
Expand Down
2 changes: 1 addition & 1 deletion miio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from miio.aqaracamera import AqaraCamera
from miio.ceil import Ceil
from miio.chuangmi_camera import ChuangmiCamera
from miio.chuangmi_ir import ChuangmiIr
from miio.chuangmi_ir import ChuangmiIr, ChuangmiRemote, ChuangmiRemoteV2
from miio.chuangmi_plug import ChuangmiPlug, Plug, PlugV1, PlugV3
from miio.cooker import Cooker
from miio.curtain_youpin import CurtainMiot
Expand Down
114 changes: 103 additions & 11 deletions miio/chuangmi_ir.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import base64
import re
from typing import List, Tuple

import click

try:
import heatshrink2
except Exception:
heatshrink2 = None

from construct import (
Adapter,
Array,
Expand Down Expand Up @@ -87,33 +94,45 @@ def play_pronto(self, pronto: str, repeats: int = 1):
return self.play_raw(*self.pronto_to_raw(pronto, repeats))

@classmethod
def pronto_to_raw(cls, pronto: str, repeats: int = 1):
"""Play a Pronto Hex encoded IR command. Supports only raw Pronto format,
starting with 0000.
def _parse_pronto(
cls, pronto: str
) -> Tuple[List["ProntoBurstPair"], List["ProntoBurstPair"], int]:
"""Parses Pronto Hex encoded IR command and returns a tuple containing a list of
intro pairs, a list of repeat pairs and a signal carrier frequency."""
try:
pronto_data = Pronto.parse(bytearray.fromhex(pronto))
except Exception as ex:
raise ChuangmiIrException("Invalid Pronto command") from ex

return pronto_data.intro, pronto_data.repeat, int(round(pronto_data.frequency))

@classmethod
def pronto_to_raw(cls, pronto: str, repeats: int = 1) -> Tuple[str, int]:
"""Takes a Pronto Hex encoded IR command and number of repeats and returns a
tuple containing a string encoded IR signal accepted by controller and
frequency. Supports only raw Pronto format, starting with 0000.

:param str pronto: Pronto Hex string.
:param int repeats: Number of extra signal repeats.
"""

if repeats < 0:
raise ChuangmiIrException("Invalid repeats value")

try:
pronto_data = Pronto.parse(bytearray.fromhex(pronto))
except Exception as ex:
raise ChuangmiIrException("Invalid Pronto command") from ex
intro_pairs, repeat_pairs, frequency = cls._parse_pronto(pronto)

if len(pronto_data.intro) == 0:
if len(intro_pairs) == 0:
repeats += 1

times = set()
for pair in pronto_data.intro + pronto_data.repeat * (1 if repeats else 0):
for pair in intro_pairs + repeat_pairs * (1 if repeats else 0):
times.add(pair.pulse)
times.add(pair.gap)

times = sorted(times)
times_map = {t: idx for idx, t in enumerate(times)}
edge_pairs = []
for pair in pronto_data.intro + pronto_data.repeat * repeats:
for pair in intro_pairs + repeat_pairs * repeats:
edge_pairs.append(
{"pulse": times_map[pair.pulse], "gap": times_map[pair.gap]}
)
Expand All @@ -127,7 +146,7 @@ def pronto_to_raw(cls, pronto: str, repeats: int = 1):
)
).decode()

return signal_code, int(round(pronto_data.frequency))
return signal_code, frequency

@command(
click.argument("command", type=str),
Expand Down Expand Up @@ -185,6 +204,79 @@ def get_indicator_led(self):
return self.send("get_indicatorLamp")


class ChuangmiRemote(ChuangmiIr):
"""Class representing new type of Chuangmi IR Remote Controller identified by model
"chuangmi-remote-h102a03_".
Comment on lines +208 to +209
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
"""Class representing new type of Chuangmi IR Remote Controller identified by model
"chuangmi-remote-h102a03_".
"""Class representing Chuangmi IR Remote Controller (chuangmi-remote-h102a03_).

Is info() working fine for this device? If yes, could you also add that here. The long term goal is to separate the meta information to separate files to allow creating instances based on the model information.

Copy link
Author

Choose a reason for hiding this comment

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

I can only confirm that it works for chuangmi-remote-v2, and it does. What should be added? I didn't get. Do you wish to add that info() works for the device in the class description?

Copy link
Owner

Choose a reason for hiding this comment

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

The info will deliver the model of the device, could you check with miiocli device info what is it for your device & add that to the corresponding class?

The reason why I'm pushing towards a single class is that it makes it simpler for downstreams like homeassistant to support all supported remotes as long as 1) info is working or 2) the user provides the wanted model as a parameter so that the class can adjust itself accordingly.

Copy link
Author

@oblitum oblitum Apr 24, 2021

Choose a reason for hiding this comment

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

I get:

Model: chuangmi.remote.v2
Hardware version: esp32

Firmware version: 2.0.6_0006

The ChuangmiRemoteV2 child class already states to cover "chuangmi-remote-v2". Do you wish it changed to "chuangmi.remote.v2" according to the model output above? (The default hostname on the network doesn't use periods though, but the actual model name is with periods). I can't confirm anything for the h102a03 model.

Copy link
Owner

Choose a reason for hiding this comment

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

Ah, yes, it's also in the ttile of the PR, sorry... The one reported by the info is the one to use for identification (as not all devices support the mDNS, which is used for the hostname).


The new controller uses different format for learned IR commands, which actually is
the old format but with additional layer of compression.
"""

@classmethod
def pronto_to_raw(cls, pronto: str, repeats: int = 1) -> Tuple[str, int]:
"""Takes a Pronto Hex encoded IR command and number of repeats and returns a
tuple containing a string encoded IR signal accepted by controller and
frequency. Supports only raw Pronto format, starting with 0000.
Comment on lines +217 to +219
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
"""Takes a Pronto Hex encoded IR command and number of repeats and returns a
tuple containing a string encoded IR signal accepted by controller and
frequency. Supports only raw Pronto format, starting with 0000.
"""Convert pronto to device-expected format.
Takes a Pronto Hex encoded IR command and number of repeats and returns a
tuple containing a string encoded IR signal accepted by controller and
frequency. Supports only raw Pronto format, starting with 0000.


:raises ChuangmiIrException if heatshrink2 package is not installed.

:param str pronto: Pronto Hex string.
:param int repeats: Number of extra signal repeats.
"""

if heatshrink2 is None:
raise ChuangmiIrException("heatshrink2 library is missing")
raw, frequency = super().pronto_to_raw(pronto, repeats)
Copy link
Owner

Choose a reason for hiding this comment

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

The docstring above mentions that only prontos starting with 0000 are supported. I think an exception should also be raised if the input is in invalid format.

Copy link
Owner

Choose a reason for hiding this comment

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

Also, the another class expects that repeats >= 0, that should also be the case here?

return (
base64.b64encode(
heatshrink2.encode("learn{}".format(raw).encode())
).decode(),
frequency,
)


class ChuangmiRemoteV2(ChuangmiIr):
"""Class representing new type of Chuangmi IR Remote Controller identified by model
"chuangmi-remote-v2".
Comment on lines +239 to +240
Copy link
Owner

Choose a reason for hiding this comment

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

See my comment above on simplifying the docstring & adding the model info.


The new controller uses different format for learned IR commands, which compresses
an ASCII list of comma separated edge timings.
"""

@classmethod
def pronto_to_raw(cls, pronto: str, repeats: int = 1) -> Tuple[str, int]:
"""Takes a Pronto Hex encoded IR command and number of repeats and returns a
tuple containing a string encoded IR signal accepted by controller and
frequency. Supports only raw Pronto format, starting with 0000.

:raises ChuangmiIrException if heatshrink package is not installed.

:param str pronto: Pronto Hex string.
:param int repeats: Number of extra signal repeats.
"""

if heatshrink2 is None:
raise ChuangmiIrException("heatshrink2 library is missing")

if repeats < 0:
raise ChuangmiIrException("Invalid repeats value")

intro_pairs, repeat_pairs, frequency = cls._parse_pronto(pronto)

if len(intro_pairs) == 0:
repeats += 1

timings = []
for pair in intro_pairs + repeat_pairs * repeats:
timings.append(pair.pulse)
timings.append(pair.gap)
timings[-1] = 0

timings = "{}\0".format(",".join(map(str, timings))).encode()

return base64.b64encode(heatshrink2.encode(timings)).decode(), frequency


class ProntoPulseAdapter(Adapter):
def _decode(self, obj, context, path):
return int(obj * context._.modulation_period)
Expand Down
5 changes: 4 additions & 1 deletion miio/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
ChuangmiCamera,
ChuangmiIr,
ChuangmiPlug,
ChuangmiRemote,
ChuangmiRemoteV2,
Cooker,
Device,
Fan,
Expand Down Expand Up @@ -134,7 +136,8 @@
"chuangmi-camera-ipc009": ChuangmiCamera,
"chuangmi-camera-ipc019": ChuangmiCamera,
"chuangmi-ir-v2": ChuangmiIr,
"chuangmi-remote-h102a03_": ChuangmiIr,
"chuangmi-remote-h102a03_": ChuangmiRemote,
"chuangmi-remote-v2": ChuangmiRemoteV2,
"zhimi-humidifier-v1": partial(AirHumidifier, model=MODEL_HUMIDIFIER_V1),
"zhimi-humidifier-ca1": partial(AirHumidifier, model=MODEL_HUMIDIFIER_CA1),
"zhimi-humidifier-cb1": partial(AirHumidifier, model=MODEL_HUMIDIFIER_CB1),
Expand Down
152 changes: 152 additions & 0 deletions miio/tests/test_chuangmi_ir.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,157 @@
0
]
}
],
"test_pronto_ok_chuangmi_remote": [
{
"desc": "NEC1, Dev 0, Subdev 127, Function 10 - with spaces",
"in": [
"0000 006C 0022 0002 015B 00AD 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0041 0016 0041 0016 0041 0016 0041 0016 0041 0016 0041 0016 0041 0016 0016 0016 0016 0016 0041 0016 0016 0016 0041 0016 0016 0016 0016 0016 0016 0016 0016 0016 0041 0016 0016 0016 0041 0016 0016 0016 0041 0016 0041 0016 0041 0016 0041 0016 0622 015B 0057 0016 0E6C"
],
"out": [
"tllsNyt1am1WpFBokwodBoNDslCs9BoMys4AhUq51Kg0GhVGk3eg0G81qcUGg02jTOg1GggAeAB4AHzAAuqjRaEAGoBZ0GhAKLRQC1AXKhVGlUegz0A=",
38381
]
},
{
"desc": "NEC1, Dev 0, Subdev 127, Function 10 - without spaces",
"in": [
"0000006C00220002015B00AD001600160016001600160016001600160016001600160016001600160016001600160041001600410016004100160041001600410016004100160041001600160016001600160041001600160016004100160016001600160016001600160016001600410016001600160041001600160016004100160041001600410016004100160622015B005700160E6C"
],
"out": [
"tllsNyt1am1WpFBokwodBoNDslCs9BoMys4AhUq51Kg0GhVGk3eg0G81qcUGg02jTOg1GggAeAB4AHzAAuqjRaEAGoBZ0GhAKLRQC1AXKhVGlUegz0A=",
38381
]
},
{
"desc": "NEC1, Dev 0, Subdev 127, Function 10 - 0 repeat frames",
"in": [
"0000006C00220002015B00AD001600160016001600160016001600160016001600160016001600160016001600160041001600410016004100160041001600410016004100160041001600160016001600160041001600160016004100160016001600160016001600160016001600410016001600160041001600160016004100160041001600410016004100160622015B005700160E6C",
0
],
"out": [
"tllsNyt1am1WolBokwodBoNDslCs9BoNtvFFoNBo1BtVBoNEpVuu9BAA8ADwAPAAsEMgAIqNFoQAagFnQaEAooNJAFmAuVCoIA==",
38381
]
},
{
"desc": "NEC1, Dev 0, Subdev 127, Function 10 - 2 repeat frames",
"in": [
"0000006C00220002015B00AD001600160016001600160016001600160016001600160016001600160016001600160041001600410016004100160041001600410016004100160041001600160016001600160041001600160016004100160016001600160016001600160016001600410016001600160041001600160016004100160041001600410016004100160622015B005700160E6C",
2
],
"out": [
"tllsNyt1am1WplBokwodBoNDslCs9BoMys4AhUq51Kg0GhVGk3eg0G81qcUGg02jTOg1GggAeAB4AHzAAuqjRaEAGoBZ0GhAKLRQC1AXKhVGlUeg2us0Gez0",
38381
]
},
{
"desc": "Sony20, Dev 0, Subdev 0, Function 1",
"in": [
"00000068000000150060001800300018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001802D0"
],
"out": [
"tllsNyt1am1WqlBo1vodBoNDmFCoNBoNhoNroNBpVCo4BRAAeAB4AHgAfQ6DUQEvAAaiAN+A3k9A",
39857
]
},
{
"desc": "Sony20, Dev 0, Subdev 0, Function 1 - 0 repeats",
"in": [
"00000068000000150060001800300018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001802D0",
0
],
"out": [
"tllsNyt1am1VuFBo1vodBoNDmFCoNBoNhoNroNBpVCo4BRAAeAB4AHgAfQ6DUQEvAAaiUGeg",
39857
]
},
{
"desc": "Sony20, Dev 0, Subdev 0, Function 1 - 2 repeats",
"in": [
"00000068000000150060001800300018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001802D0",
2
],
"out": [
"tllsNyt1am1WnNBo1vodBoNDmFCoNBoNhoNroNBpVCo4BRAAeAB4AHgAfQ6DUQEvAAaiAN+A34CngNxPQA==",
39857
]
}
],
"test_pronto_ok_chuangmi_remote_v2": [
{
"desc": "NEC1, Dev 0, Subdev 127, Function 10 - with spaces",
"in": [
"0000 006C 0022 0002 015B 00AD 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0041 0016 0041 0016 0041 0016 0041 0016 0041 0016 0041 0016 0041 0016 0016 0016 0016 0016 0041 0016 0016 0016 0041 0016 0016 0016 0016 0016 0016 0016 0016 0016 0041 0016 0016 0016 0041 0016 0016 0016 0041 0016 0041 0016 0041 0016 0041 0016 0622 015B 0057 0016 0E6C"
],
"out": [
"nMwmkwlk0mswm8sms3mYAfgB+AH4AfyyYzacgEeAR4BHgEWAH4DHgIeAH4FHgIeAh4BHgEfNJhOZhNZYJEkymU2mwCaTCAA=",
38381
]
},
{
"desc": "NEC1, Dev 0, Subdev 127, Function 10 - without spaces",
"in": [
"0000006C00220002015B00AD001600160016001600160016001600160016001600160016001600160016001600160041001600410016004100160041001600410016004100160041001600160016001600160041001600160016004100160016001600160016001600160016001600410016001600160041001600160016004100160041001600410016004100160622015B005700160E6C"
],
"out": [
"nMwmkwlk0mswm8sms3mYAfgB+AH4AfyyYzacgEeAR4BHgEWAH4DHgIeAH4FHgIeAh4BHgEfNJhOZhNZYJEkymU2mwCaTCAA=",
38381
]
},
{
"desc": "NEC1, Dev 0, Subdev 127, Function 10 - 0 repeat frames",
"in": [
"0000006C00220002015B00AD001600160016001600160016001600160016001600160016001600160016001600160041001600410016004100160041001600410016004100160041001600160016001600160041001600160016004100160016001600160016001600160016001600410016001600160041001600160016004100160041001600410016004100160622015B005700160E6C",
0
],
"out": [
"nMwmkwlk0mswm8sms3mYAfgB+AH4AfyyYzacgEeAR4BHgEWAH4DHgIeAH4FHgIeAh4BHgEfMIAA=",
38381
]
},
{
"desc": "NEC1, Dev 0, Subdev 127, Function 10 - 2 repeat frames",
"in": [
"0000006C00220002015B00AD001600160016001600160016001600160016001600160016001600160016001600160041001600410016004100160041001600410016004100160041001600160016001600160041001600160016004100160016001600160016001600160016001600410016001600160041001600160016004100160041001600410016004100160622015B005700160E6C",
2
],
"out": [
"nMwmkwlk0mswm8sms3mYAfgB+AH4AfyyYzacgEeAR4BHgEWAH4DHgIeAH4FHgIeAh4BHgEfNJhOZhNZYJEkymU2mwCaTmbTEB0gE9mEA",
38381
]
},
{
"desc": "Sony20, Dev 0, Subdev 0, Function 1",
"in": [
"00000068000000150060001800300018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001802D0"
],
"out": [
"mU0mE4lk2mEylkxmUwmgBCAB+AH4AfgB+AH4AfgB+AH4AfhOJOJhNppLAq/An8APwA/AD8APwA/AD8APwA/ADWYQAA==",
39857
]
},
{
"desc": "Sony20, Dev 0, Subdev 0, Function 1 - 0 repeats",
"in": [
"00000068000000150060001800300018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001802D0",
0
],
"out": [
"mU0mE4lk2mEylkxmUwmgBCAB+AH4AfgB+AH4AfgB+AH4AfgBnMIA",
39857
]
},
{
"desc": "Sony20, Dev 0, Subdev 0, Function 1 - 2 repeats",
"in": [
"00000068000000150060001800300018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001800180018001802D0",
2
],
"out": [
"mU0mE4lk2mEylkxmUwmgBCAB+AH4AfgB+AH4AfgB+AH4AfhOJOJhNppLAq/An8APwA/AD8APwA/AD8APwA/Cr8KvwA/AD8APwA/AD8APwA/AD8AP5lLJhAA=",
39857
]
}
]
}
Loading