diff --git a/README.rst b/README.rst index d06e98555..820359afc 100644 --- a/README.rst +++ b/README.rst @@ -12,6 +12,7 @@ Supported devices - :doc:`Xiaomi Mi Robot Vacuum ` (:class:`miio.vacuum`) - Xiaomi Mi Home Air Conditioner Companion (:class:`miio.airconditioningcompanion`) - Xiaomi Mi Air Purifier (:class:`miio.airpurifier`) +- Xiaomi Aqara Camera (:class:`miia.aqaracamera`) - :doc:`Xiaomi Mi Smart WiFi Socket ` (:class:`miio.chuangmi_plug`) - :doc:`Xiaomi Chuangmi Plug V1 (1 Socket, 1 USB Port) ` (:class:`miio.chuangmi_plug`) - :doc:`Xiaomi Chuangmi Plug V3 (1 Socket, 2 USB Ports) ` (:class:`miio.chuangmi_plug`) diff --git a/docs/miio.rst b/docs/miio.rst index 70b33edb7..4f5b289ab 100644 --- a/docs/miio.rst +++ b/docs/miio.rst @@ -45,6 +45,15 @@ miio\.airqualitymonitor module :show-inheritance: :undoc-members: +miio\.aqaracamera module +------------------------ + +.. automodule:: miio.aqaracamera + :members: + :show-inheritance: + :undoc-members: + + miio\.ceil module ----------------- diff --git a/miio/__init__.py b/miio/__init__.py index 1b4247011..14d9f2a6c 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -23,5 +23,6 @@ from miio.wifirepeater import WifiRepeater from miio.wifispeaker import WifiSpeaker from miio.yeelight import Yeelight +from miio.aqaracamera import AqaraCamera from miio.discovery import Discovery diff --git a/miio/aqaracamera.py b/miio/aqaracamera.py new file mode 100644 index 000000000..b8a352f9b --- /dev/null +++ b/miio/aqaracamera.py @@ -0,0 +1,373 @@ +"""Aqara camera support. + +Support for lumi.camera.aq1 + +TODO: add alarm/sound parts (get_music_info, {get,set}_alarming_volume, + set_default_music, play_music_new, set_sound_playing) +TODO: add sdcard status & fix all TODOS +TODO: add tests +""" +import attr +import logging +from typing import Any, Dict +from enum import IntEnum + +import click + +from .click_common import command, format_output +from .device import Device, DeviceException + +_LOGGER = logging.getLogger(__name__) + + +class CameraException(DeviceException): + pass + + +@attr.s +class CameraOffset: + """Container for camera offset data.""" + x = attr.ib() + y = attr.ib() + radius = attr.ib() + + +@attr.s +class ArmStatus: + """Container for arm statuses.""" + is_armed = attr.ib(converter=bool) + arm_wait_time = attr.ib(converter=int) + alarm_volume = attr.ib(converter=int) + + +class SDCardStatus(IntEnum): + """State of the SD card.""" + NoCardInserted = 0 + Ok = 1 + FormatRequired = 2 + Formating = 3 + + +class MotionDetectionSensitivity(IntEnum): + """'Default' values for md sensitivity. + Currently unused as the value can also be set arbitrarily. + """ + High = 6000000 + Medium = 10000000 + Low = 11000000 + + +class CameraStatus: + """Container for status reports from the Aqara Camera.""" + + def __init__(self, data: Dict[str, Any]) -> None: + """ + Response of a lumi.camera.aq1: + + {"p2p_id":"#################","app_type":"celing", + "offset_x":"0","offset_y":"0","offset_radius":"0", + "md_status":1,"video_state":1,"fullstop":0, + "led_status":1,"ir_status":1,"mdsensitivity":6000000, + "channel_id":0,"flip_state":0, + "avID":"####","avPass":"####","id":65001} + """ + self.data = data + + @property + def type(self) -> str: + """TODO: Type of the camera? Name?""" + return self.data["app_type"] + + @property + def video_status(self) -> bool: + """Video state.""" + return bool(self.data["video_state"]) + + @property + def is_on(self) -> bool: + """True if device is currently on.""" + return self.video_status == 1 + + @property + def md(self) -> bool: + """Motion detection state.""" + return bool(self.data["md_status"]) + + @property + def md_sensitivity(self): + """Motion detection sensitivity.""" + return self.data["mdsensitivity"] + + @property + def ir(self): + """IR mode.""" + return bool(self.data["ir_status"]) + + @property + def led(self): + """LED status.""" + return bool(self.data["led_status"]) + + @property + def flipped(self) -> bool: + """TODO: If camera is flipped?""" + return self.data["flip_state"] + + @property + def offsets(self) -> CameraOffset: + """Camera offset information.""" + return CameraOffset(x=self.data["offset_x"], + y=self.data["offset_y"], + radius=self.data["offset_radius"]) + + @property + def channel_id(self) -> int: + """TODO: Zigbee channel?""" + return self.data["channel_id"] + + @property + def fullstop(self) -> bool: + """Is alarm triggered by MD.""" + return self.data["fullstop"] != 0 + + @property + def p2p_id(self) -> str: + """P2P ID for video and audio.""" + return self.data["p2p_id"] + + @property + def av_id(self) -> str: + """TODO: What is this? ID for the cloud?""" + return self.data["avID"] + + @property + def av_password(self) -> str: + """TODO: What is this? Password for the cloud?""" + return self.data["avPass"] + + def __repr__(self) -> str: + s = "" \ + % (self.is_on, + self.type, + self.offsets, + self.ir, + self.md, + self.md_sensitivity, + self.led, + self.flipped, + self.fullstop + ) + return s + + def __json__(self): + return self.data + + +class AqaraCamera(Device): + """Main class representing the Xiaomi Aqara Camera.""" + + @command( + default_output=format_output( + "", + "Type: {result.type}\n" + "Video: {result.is_on}\n" + "Offsets: {result.offsets}\n" + "IR: {result.ir_status} %\n" + "MD: {result.md_status} (sensitivity: {result.md_sensitivity}\n" + "LED: {result.led}\n" + "Flipped: {result.flipped}\n" + "Full stop: {result.fullstop}\n" + "P2P ID: {result.p2p_id}\n" + "AV ID: {result.av_id}\n" + "AV password: {result.av_password}\n" + "\n" + ) + ) + def status(self) -> CameraStatus: + """Camera status.""" + return CameraStatus(self.send("get_ipcprop", ["all"])) + + @command( + default_output=format_output("Camera on"), + ) + def on(self): + """Camera on.""" + return self.send("set_video", ["on"]) + + @command( + default_output=format_output("Camera off"), + ) + def off(self): + """Camera off.""" + return self.send("set_video", ["off"]) + + @command( + default_output=format_output("IR on") + ) + def ir_on(self): + """IR on.""" + return self.send("set_ir", ["on"]) + + @command( + default_output=format_output("IR off") + ) + def ir_off(self): + """IR off.""" + return self.send("set_ir", ["off"]) + + @command( + default_output=format_output("MD on") + ) + def md_on(self): + """IR on.""" + return self.send("set_md", ["on"]) + + @command( + default_output=format_output("MD off") + ) + def md_off(self): + """MD off.""" + return self.send("set_md", ["off"]) + + @command( + click.argument("sensitivity", type=int, required=False) + ) + def md_sensitivity(self, sensitivity): + """Get or set the motion detection sensitivity.""" + if sensitivity: + click.echo("Setting MD sensitivity to %s" % sensitivity) + return self.send("set_mdsensitivity", [sensitivity])[0] == 'ok' + else: + return self.send("get_mdsensitivity") + + @command( + default_output=format_output("LED on") + ) + def led_on(self): + """LED on.""" + return self.send("set_led", ["on"]) + + @command( + default_output=format_output("LED off") + ) + def led_off(self): + """LED off.""" + return self.send("set_led", ["off"]) + + @command( + default_output=format_output("Flip on") + ) + def flip_on(self): + """Flip on.""" + return self.send("set_flip", ["on"]) + + @command( + default_output=format_output("Flip off") + ) + def flip_off(self): + """Flip off.""" + return self.send("set_flip", ["off"]) + + @command( + default_output=format_output("Fullstop on") + ) + def fullstop_on(self): + """Fullstop on.""" + return self.send("set_fullstop", ["on"]) + + @command( + default_output=format_output("Fullstop off") + ) + def fullstop_off(self): + """Fullstop off.""" + return self.send("set_fullstop", ["off"]) + + @command( + click.argument("time", type=int, default=30), + default_output=format_output( + "Start pairing for {time} seconds") + ) + def pair(self, timeout: int): + """Start (or stop with "0") pairing.""" + if timeout < 0: + raise CameraException("Invalid timeout: %s" % timeout) + + return self.send("start_zigbee_join", [timeout]) + + @command() + def sd_status(self): + """SD card status.""" + return SDCardStatus(self.send("get_sdstatus")) + + @command() + def sd_format(self): + """Format the SD card. + + Returns True when formating has started successfully. + """ + return bool(self.send("sdformat")) + + @command() + def arm_status(self): + """Return arming information.""" + is_armed = self.send("get_arming") + arm_wait_time = self.send("get_arm_wait_time") + alarm_volume = self.send("get_alarming_volume") + + return ArmStatus(is_armed=bool(is_armed), + arm_wait_time=arm_wait_time, + alarm_volume=alarm_volume) + + @command( + click.argument("volume", type=int, default=100), + default_output=format_output( + "Setting alarm volume to {volume}" + ) + ) + def set_alarm_volume(self, volume): + """Set alarm volume.""" + if volume < 0 or volume > 100: + raise CameraException("Volume has to be [0,100], was %s" % volume) + return self.send("set_alarming_volume", [volume])[0] == 'ok' + + @command( + click.argument("sound_id", type=str, required=False, default=None) + ) + def alarm_sound(self, sound_id): + """List or set the alarm sound.""" + if id is None: + sound_status = self.send("get_music_info", [0]) + + # TODO: make a list out from this. + @attr.s + class SoundList: + default = attr.ib() + total = attr.ib(type=int) + sounds = attr.ib(type=list) + + return sound_status + + click.echo("Setting alarm sound to %s" % sound_id) + return self.send("set_default_music", [0, sound_id])[0] == 'ok' + + @command( + default_output=format_output("Arming") + ) + def arm(self): + """Arm the camera?""" + return self.send("set_arming", ["on"]) + + @command( + default_output=format_output("Disarming") + ) + def disarm(self): + """Disarm the camera?""" + return self.send("set_arming", ["off"]) diff --git a/miio/discovery.py b/miio/discovery.py index bb9f2ca93..01086efba 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -10,7 +10,7 @@ from . import (Device, Vacuum, ChuangmiPlug, PowerStrip, AirPurifier, AirFresh, Ceil, PhilipsBulb, PhilipsEyecare, PhilipsMoonlight, ChuangmiIr, AirHumidifier, WaterPurifier, WifiSpeaker, WifiRepeater, - Yeelight, Fan, Cooker, AirConditioningCompanion, AirQualityMonitor) + Yeelight, Fan, Cooker, AirConditioningCompanion, AirQualityMonitor, AqaraCamera) from .airconditioningcompanion import (MODEL_ACPARTNER_V1, MODEL_ACPARTNER_V2, MODEL_ACPARTNER_V3, ) from .airhumidifier import (MODEL_HUMIDIFIER_CA1, MODEL_HUMIDIFIER_V1, ) @@ -69,6 +69,7 @@ "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), + "lumi-camera-aq2": AqaraCamera, "yeelink-light-": Yeelight, "zhimi-fan-v2": partial(Fan, model=MODEL_FAN_V2), "zhimi-fan-v3": partial(Fan, model=MODEL_FAN_V3),