diff --git a/.flake8.ini b/.flake8.ini new file mode 100644 index 0000000..ed40209 --- /dev/null +++ b/.flake8.ini @@ -0,0 +1,5 @@ +[flake8] +exclude = .git,.tox,__pycache__ +max-line-length = 100 +select = C,E,F,W,B,B950 +ignore = E501,W503,E203 diff --git a/.hound.yml b/.hound.yml new file mode 100644 index 0000000..0b51966 --- /dev/null +++ b/.hound.yml @@ -0,0 +1,3 @@ +python: + enabled: true + config_file: .flake8.ini diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..da90577 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: +- repo: https://github.com/ambv/black + rev: stable + hooks: + - id: black + language_version: python3 diff --git a/README.md b/README.md index 0ea2b8e..69e8c1e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Xiaomi WiFi Plug +# Xiaomi Smart WiFi Sockets and Power Strips This is a custom component for Home Assistant to integrate the Xiaomi Smart WiFi Socket (called Plug), Xiaomi Smart Power Strip and Xiaomi Chuangmi Plug V1. @@ -7,23 +7,72 @@ Please follow the instructions on [Retrieving the Access Token](https://home-ass Credits: Thanks to [Rytilahti](https://github.com/rytilahti/python-miio) for all the work. ## Features -* On, Off -* USB on, off (Chuangmi Plug V1 only) -* Current state + +### Xiaomi Smart WiFi Socket + +Supported models: `chuangmi.plug.m1`, `chuangmi.plug.m3`, `chuangmi.plug.v2`, `chuangmi.plug.hmi205` + +* Power (on, off) * Attributes - Temperature - - Load (Power Strip only) + +### Xiaomi Chuangmi Plug V1 + +Supported models: `chuangmi.plug.v1` + +* Power (on, off) +* USB (on, off) +* Attributes + - Temperature + +### Xiaomi Chuangmi Plug V3 + +Supported models: `chuangmi.plug.v3` + +* Power (on, off) +* USB (on, off) +* Wifi LED (on, off) +* Attributes + - Temperature + - Load power + - Wifi LED + +### Xiaomi Smart Power Strip + +Supported models: `qmi.powerstrip.v1`, `zimi.powerstrip.v2` + +* Power (on, off) +* Wifi LED (on, off) +* Power Price (0...999) +* Power Mode (green, normal) (Power Strip V1 only) +* Attributes + - Temperature + - Current + - Load power + - Wifi LED + - Mode (Power Strip V1 only) + +### Xiaomi Air Conditioning Companion V3 + +Supported models: `lumi.acpartner.v3` (the socket of the acpartner.v1 and v2 isn't switchable!) + +* Power (on, off) +* Attributes + - Load power + # Setup ``` +# configuration.yaml + switch: - - platform: xiaomi_miio + - platform: xiaomi_miio_plug name: Original Xiaomi Mi Smart WiFi Socket host: 192.168.130.59 token: b7c4a758c251955d2c24b1d9e41ce47d model: chuangmi.plug.m1 - - platform: xiaomi_miio + - platform: xiaomi_miio_plug name: Xiaomi Mi Smart Power Strip host: 192.168.130.60 token: 0ed0fdccb2d0cd718108f18a447726a6 @@ -34,11 +83,36 @@ Configuration variables: - **host** (*Required*): The IP of your light. - **token** (*Required*): The API token of your light. - **name** (*Optional*): The name of your light. -- **model** (*Optional*): The model of your device. Valid values are `chuangmi.plug.v1`, `qmi.powerstrip.v1`, `zimi.powerstrip.v2`, `chuangmi.plug.m1` and `chuangmi.plug.v2`. This setting can be used to bypass the device model detection and is recommended if your device isn't always available. +- **model** (*Optional*): The model of your device. Valid values are `chuangmi.plug.v1`, `chuangmi.plug.v3`, `qmi.powerstrip.v1`, `zimi.powerstrip.v2`, `chuangmi.plug.m1`, `chuangmi.plug.m3`, `chuangmi.plug.v2`, `chuangmi.plug.hmi205` and `lumi.acpartner.v3`. This setting can be used to bypass the device model detection and is recommended if your device isn't always available. ## Platform services -#### Service switch/xiaomi_miio_set_power_mode (Power Strip only) +#### Service `xiaomi_miio_plug.switch_set_wifi_led_on` (Power Strip and Chuangmi Plug V3) + +Turn the wifi led on. + +| Service data attribute | Optional | Description | +|---------------------------|----------|---------------------------------------------------------| +| `entity_id` | yes | Only act on a specific air purifier. Else targets all. | + +#### Service `xiaomi_miio_plug.switch_set_wifi_led_off` (Power Strip and Chuangmi Plug V3) + +Turn the wifi led off. + +| Service data attribute | Optional | Description | +|---------------------------|----------|---------------------------------------------------------| +| `entity_id` | yes | Only act on a specific air purifier. Else targets all. | + +#### Service `xiaomi_miio_plug.switch_set_power_price` (Power Strip only) + +Set the power price. + +| Service data attribute | Optional | Description | +|---------------------------|----------|---------------------------------------------------------| +| `entity_id` | yes | Only act on a specific air purifier. Else targets all. | +| `price` | no | Power price, between 0 and 999. | + +#### Service `xiaomi_miio_plug.switch_set_power_mode` (Power Strip V1 only) Set the power mode. diff --git a/custom_components/switch/services.yaml b/custom_components/switch/services.yaml deleted file mode 100644 index 285ef89..0000000 --- a/custom_components/switch/services.yaml +++ /dev/null @@ -1,9 +0,0 @@ -xiaomi_miio_set_power_mode: - description: Set the power mode. - fields: - entity_id: - description: Name of the xiaomi miio entity. - example: 'fan.xiaomi_miio_device' - mode: - description: Power mode, valid values are 'normal' and 'green'. - example: 'green' diff --git a/custom_components/switch/xiaomi_miio.py b/custom_components/switch/xiaomi_miio.py deleted file mode 100644 index 192af66..0000000 --- a/custom_components/switch/xiaomi_miio.py +++ /dev/null @@ -1,379 +0,0 @@ -""" -Support for Xiaomi Smart WiFi Socket and Smart Power Strip. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/switch.xiaomi_miio/ -""" -import asyncio -from functools import partial -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA, - DOMAIN, ) -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, - ATTR_ENTITY_ID) -from homeassistant.exceptions import PlatformNotReady - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'Xiaomi Miio Switch' -PLATFORM = 'xiaomi_miio' - -CONF_MODEL = 'model' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MODEL, default=None): vol.In( - ['chuangmi.plug.v1', - 'qmi.powerstrip.v1', - 'zimi.powerstrip.v2', - 'chuangmi.plug.m1', - 'chuangmi.plug.v2', None]), - -}) - -REQUIREMENTS = ['python-miio>=0.3.5'] - -ATTR_POWER = 'power' -ATTR_TEMPERATURE = 'temperature' -ATTR_LOAD_POWER = 'load_power' -ATTR_MODEL = 'model' -ATTR_MODE = 'mode' -SUCCESS = ['ok'] - -SUPPORT_SET_POWER_MODE = 1 - -SERVICE_SET_POWER_MODE = 'xiaomi_miio_set_power_mode' - -SERVICE_SCHEMA_POWER_MODE = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_MODE): vol.All(vol.In(['green', 'normal'])), -}) - -# pylint: disable=unused-argument -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the switch from config.""" - from miio import Device, DeviceException - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} - - host = config.get(CONF_HOST) - name = config.get(CONF_NAME) - token = config.get(CONF_TOKEN) - model = config.get(CONF_MODEL) - - _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) - - devices = [] - - if model is None: - try: - miio_device = Device(host, token) - device_info = miio_device.info() - model = device_info.model - _LOGGER.info("%s %s %s detected", - model, - device_info.firmware_version, - device_info.hardware_version) - except DeviceException: - raise PlatformNotReady - - if model in ['chuangmi.plug.v1']: - from miio import PlugV1 - plug = PlugV1(host, token) - - # The device has two switchable channels (mains and a USB port). - # A switch device per channel will be created. - for channel_usb in [True, False]: - device = ChuangMiPlugV1Switch( - name, plug, model, channel_usb) - devices.append(device) - hass.data[PLATFORM][host] = device - - elif model in ['qmi.powerstrip.v1', - 'zimi.powerstrip.v2']: - from miio import PowerStrip - plug = PowerStrip(host, token) - device = XiaomiPowerStripSwitch(name, plug, model) - devices.append(device) - hass.data[PLATFORM][host] = device - elif model in ['chuangmi.plug.m1', - 'chuangmi.plug.v2']: - from miio import Plug - plug = Plug(host, token) - device = XiaomiPlugGenericSwitch(name, plug, model) - devices.append(device) - hass.data[PLATFORM][host] = device - else: - _LOGGER.error( - 'Unsupported device found! Please create an issue at ' - 'https://github.com/rytilahti/python-miio/issues ' - 'and provide the following data: %s', model) - return False - - async_add_devices(devices, update_before_add=True) - - @asyncio.coroutine - def async_service_handler(service): - """Map services to methods on XiaomiPlugGenericSwitch.""" - params = {key: value for key, value in service.data.items() - if key != ATTR_ENTITY_ID} - entity_ids = service.data.get(ATTR_ENTITY_ID) - if entity_ids: - devices = [device for device in hass.data[PLATFORM].values() if - device.entity_id in entity_ids] - else: - devices = hass.data[PLATFORM].values() - - update_tasks = [] - for device in devices: - yield from getattr(device, 'async_set_power_mode')(**params) - update_tasks.append(device.async_update_ha_state(True)) - - if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) - - hass.services.async_register( - DOMAIN, SERVICE_SET_POWER_MODE, async_service_handler, - schema=SERVICE_SCHEMA_POWER_MODE) - - -class XiaomiPlugGenericSwitch(SwitchDevice): - """Representation of a Xiaomi Plug Generic.""" - - def __init__(self, name, plug, model): - """Initialize the plug switch.""" - self._name = name - self._icon = 'mdi:power-socket' - self._model = model - - self._plug = plug - self._state = None - self._state_attrs = { - ATTR_TEMPERATURE: None, - ATTR_MODEL: self._model, - } - self._skip_update = False - - @property - def supported_features(self): - """Flag supported features.""" - return 0 - - @property - def should_poll(self): - """Poll the plug.""" - return True - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def icon(self): - """Return the icon to use for device if any.""" - return self._icon - - @property - def available(self): - """Return true when state is known.""" - return self._state is not None - - @property - def device_state_attributes(self): - """Return the state attributes of the device.""" - return self._state_attrs - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state - - @asyncio.coroutine - def _try_command(self, mask_error, func, *args, **kwargs): - """Call a plug command handling error messages.""" - from miio import DeviceException - try: - result = yield from self.hass.async_add_job( - partial(func, *args, **kwargs)) - - _LOGGER.debug("Response received from plug: %s", result) - - return result == SUCCESS - except DeviceException as exc: - _LOGGER.error(mask_error, exc) - return False - - @asyncio.coroutine - def async_turn_on(self, **kwargs): - """Turn the plug on.""" - result = yield from self._try_command( - "Turning the plug on failed.", self._plug.on) - - if result: - self._state = True - self._skip_update = True - - @asyncio.coroutine - def async_turn_off(self, **kwargs): - """Turn the plug off.""" - result = yield from self._try_command( - "Turning the plug off failed.", self._plug.off) - - if result: - self._state = False - self._skip_update = True - - @asyncio.coroutine - def async_update(self): - """Fetch state from the device.""" - from miio import DeviceException - - # On state change the device doesn't provide the new state immediately. - if self._skip_update: - self._skip_update = False - return - - try: - state = yield from self.hass.async_add_job(self._plug.status) - _LOGGER.debug("Got new state: %s", state) - - self._state = state.is_on - self._state_attrs.update({ - ATTR_TEMPERATURE: state.temperature - }) - - except DeviceException as ex: - self._state = None - _LOGGER.error("Got exception while fetching the state: %s", ex) - - @asyncio.coroutine - def async_set_power_mode(self, mode: str): - """Set the power mode.""" - return - - -class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch, SwitchDevice): - """Representation of a Xiaomi Power Strip.""" - - def __init__(self, name, plug, model): - """Initialize the plug switch.""" - XiaomiPlugGenericSwitch.__init__(self, name, plug, model) - - self._state_attrs.update({ - ATTR_LOAD_POWER: None, - }) - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_SET_POWER_MODE - - @asyncio.coroutine - def async_update(self): - """Fetch state from the device.""" - from miio import DeviceException - - # On state change the device doesn't provide the new state immediately. - if self._skip_update: - self._skip_update = False - return - - try: - state = yield from self.hass.async_add_job(self._plug.status) - _LOGGER.debug("Got new state: %s", state) - - self._state = state.is_on - self._state_attrs.update({ - ATTR_TEMPERATURE: state.temperature, - ATTR_LOAD_POWER: state.load_power - }) - - except DeviceException as ex: - self._state = None - _LOGGER.error("Got exception while fetching the state: %s", ex) - - @asyncio.coroutine - def async_set_power_mode(self, mode: str): - """Set the power mode.""" - if self.supported_features & SUPPORT_SET_POWER_MODE == 0: - return - - from miio.powerstrip import PowerMode - - yield from self._try_command( - "Setting the power mode of the power strip failed.", - self._plug.set_power_mode, PowerMode(mode)) - - -class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): - """Representation of a Chuang Mi Plug V1.""" - - def __init__(self, name, plug, model, channel_usb): - """Initialize the plug switch.""" - name = name + ' USB' if channel_usb else name - - XiaomiPlugGenericSwitch.__init__(self, name, plug, model) - self._channel_usb = channel_usb - - @asyncio.coroutine - def async_turn_on(self, **kwargs): - """Turn a channel on.""" - if self._channel_usb: - result = yield from self._try_command( - "Turning the plug on failed.", self._plug.usb_on) - else: - result = yield from self._try_command( - "Turning the plug on failed.", self._plug.on) - - if result: - self._state = True - self._skip_update = True - - @asyncio.coroutine - def async_turn_off(self, **kwargs): - """Turn a channel off.""" - if self._channel_usb: - result = yield from self._try_command( - "Turning the plug on failed.", self._plug.usb_off) - else: - result = yield from self._try_command( - "Turning the plug on failed.", self._plug.off) - - if result: - self._state = False - self._skip_update = True - - @asyncio.coroutine - def async_update(self): - """Fetch state from the device.""" - from miio import DeviceException - - # On state change the device doesn't provide the new state immediately. - if self._skip_update: - self._skip_update = False - return - - try: - state = yield from self.hass.async_add_job(self._plug.status) - _LOGGER.debug("Got new state: %s", state) - - if self._channel_usb: - self._state = state.usb_power - else: - self._state = state.is_on - - self._state_attrs.update({ - ATTR_TEMPERATURE: state.temperature - }) - - except DeviceException as ex: - self._state = None - _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/custom_components/xiaomi_miio_plug/__init__.py b/custom_components/xiaomi_miio_plug/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/xiaomi_miio_plug/manifest.json b/custom_components/xiaomi_miio_plug/manifest.json new file mode 100644 index 0000000..8cb4964 --- /dev/null +++ b/custom_components/xiaomi_miio_plug/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "xiaomi_miio_plug", + "name": "Xiaomi Mi Smart WiFi Socket", + "config_flow": false, + "documentation": "https://github.com/syssi/xiaomiplug", + "issue_tracker": "https://github.com/syssi/xiaomiplug/issues", + "requirements": [ + "construct==2.10.56", + "python-miio>=0.5.3" + ], + "dependencies": [], + "codeowners": [ + "@syssi" + ] +} diff --git a/custom_components/xiaomi_miio_plug/services.yaml b/custom_components/xiaomi_miio_plug/services.yaml new file mode 100644 index 0000000..87d7b54 --- /dev/null +++ b/custom_components/xiaomi_miio_plug/services.yaml @@ -0,0 +1,30 @@ +switch_set_wifi_led_on: + description: Turn the wifi led on. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'switch.xiaomi_miio_device' +switch_set_wifi_led_off: + description: Turn the wifi led off. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'switch.xiaomi_miio_device' +switch_set_power_price: + description: Set the power price. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'switch.xiaomi_miio_device' + mode: + description: Power price, between 0 and 999. + example: 31 +switch_set_power_mode: + description: Set the power mode. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'switch.xiaomi_miio_device' + mode: + description: Power mode, valid values are 'normal' and 'green'. + example: 'green' diff --git a/custom_components/xiaomi_miio_plug/switch.py b/custom_components/xiaomi_miio_plug/switch.py new file mode 100644 index 0000000..d5f83ad --- /dev/null +++ b/custom_components/xiaomi_miio_plug/switch.py @@ -0,0 +1,549 @@ +"""Support for Xiaomi Smart WiFi Socket and Smart Power Strip.""" +import asyncio +from functools import partial +import logging + +from miio import ( # pylint: disable=import-error + AirConditioningCompanionV3, + ChuangmiPlug, + Device, + DeviceException, + PowerStrip, +) +from miio.powerstrip import PowerMode # pylint: disable=import-error +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_MODE, + CONF_HOST, + CONF_NAME, + CONF_TOKEN, +) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Xiaomi Miio Switch" +DATA_KEY = "switch.xiaomi_miio_plug" +DOMAIN = "xiaomi_miio_plug" + +CONF_MODEL = "model" +MODEL_POWER_STRIP_V2 = "zimi.powerstrip.v2" +MODEL_PLUG_V3 = "chuangmi.plug.v3" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MODEL): vol.In( + [ + "chuangmi.plug.v1", + "qmi.powerstrip.v1", + "zimi.powerstrip.v2", + "chuangmi.plug.m1", + "chuangmi.plug.m3", + "chuangmi.plug.v2", + "chuangmi.plug.v3", + "chuangmi.plug.hmi205", + "chuangmi.plug.hmi206", + "chuangmi.plug.hmi208", + "lumi.acpartner.v3", + ] + ), + } +) + +ATTR_POWER = "power" +ATTR_TEMPERATURE = "temperature" +ATTR_LOAD_POWER = "load_power" +ATTR_MODEL = "model" +ATTR_POWER_MODE = "power_mode" +ATTR_WIFI_LED = "wifi_led" +ATTR_POWER_PRICE = "power_price" +ATTR_PRICE = "price" + +SUCCESS = ["ok"] + +FEATURE_SET_POWER_MODE = 1 +FEATURE_SET_WIFI_LED = 2 +FEATURE_SET_POWER_PRICE = 4 + +FEATURE_FLAGS_GENERIC = 0 + +FEATURE_FLAGS_POWER_STRIP_V1 = ( + FEATURE_SET_POWER_MODE | FEATURE_SET_WIFI_LED | FEATURE_SET_POWER_PRICE +) + +FEATURE_FLAGS_POWER_STRIP_V2 = FEATURE_SET_WIFI_LED | FEATURE_SET_POWER_PRICE + +FEATURE_FLAGS_PLUG_V3 = FEATURE_SET_WIFI_LED + +SERVICE_SET_WIFI_LED_ON = "switch_set_wifi_led_on" +SERVICE_SET_WIFI_LED_OFF = "switch_set_wifi_led_off" +SERVICE_SET_POWER_MODE = "switch_set_power_mode" +SERVICE_SET_POWER_PRICE = "switch_set_power_price" + +SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) + +SERVICE_SCHEMA_POWER_MODE = SERVICE_SCHEMA.extend( + {vol.Required(ATTR_MODE): vol.All(vol.In(["green", "normal"]))} +) + +SERVICE_SCHEMA_POWER_PRICE = SERVICE_SCHEMA.extend( + {vol.Required(ATTR_PRICE): vol.All(vol.Coerce(float), vol.Range(min=0))} +) + +SERVICE_TO_METHOD = { + SERVICE_SET_WIFI_LED_ON: {"method": "async_set_wifi_led_on"}, + SERVICE_SET_WIFI_LED_OFF: {"method": "async_set_wifi_led_off"}, + SERVICE_SET_POWER_MODE: { + "method": "async_set_power_mode", + "schema": SERVICE_SCHEMA_POWER_MODE, + }, + SERVICE_SET_POWER_PRICE: { + "method": "async_set_power_price", + "schema": SERVICE_SCHEMA_POWER_PRICE, + }, +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the switch from config.""" + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} + + host = config[CONF_HOST] + token = config[CONF_TOKEN] + name = config[CONF_NAME] + model = config.get(CONF_MODEL) + + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + + devices = [] + unique_id = None + + if model is None: + try: + miio_device = Device(host, token) + device_info = await hass.async_add_executor_job(miio_device.info) + model = device_info.model + unique_id = f"{model}-{device_info.mac_address}" + _LOGGER.info( + "%s %s %s detected", + model, + device_info.firmware_version, + device_info.hardware_version, + ) + except DeviceException as ex: + raise PlatformNotReady from ex + + if model in ["chuangmi.plug.v1", "chuangmi.plug.v3", "chuangmi.plug.hmi208"]: + plug = ChuangmiPlug(host, token, model=model) + + # The device has two switchable channels (mains and a USB port). + # A switch device per channel will be created. + for channel_usb in [True, False]: + device = ChuangMiPlugSwitch(name, plug, model, unique_id, channel_usb) + devices.append(device) + hass.data[DATA_KEY][host] = device + + elif model in ["qmi.powerstrip.v1", "zimi.powerstrip.v2"]: + plug = PowerStrip(host, token, model=model) + device = XiaomiPowerStripSwitch(name, plug, model, unique_id) + devices.append(device) + hass.data[DATA_KEY][host] = device + elif model in [ + "chuangmi.plug.m1", + "chuangmi.plug.m3", + "chuangmi.plug.v2", + "chuangmi.plug.hmi205", + "chuangmi.plug.hmi206", + ]: + plug = ChuangmiPlug(host, token, model=model) + device = XiaomiPlugGenericSwitch(name, plug, model, unique_id) + devices.append(device) + hass.data[DATA_KEY][host] = device + elif model in ["lumi.acpartner.v3"]: + plug = AirConditioningCompanionV3(host, token) + device = XiaomiAirConditioningCompanionSwitch(name, plug, model, unique_id) + devices.append(device) + hass.data[DATA_KEY][host] = device + else: + _LOGGER.error( + "Unsupported device found! Please create an issue at " + "https://github.com/rytilahti/python-miio/issues " + "and provide the following data: %s", + model, + ) + return False + + async_add_entities(devices, update_before_add=True) + + async def async_service_handler(service): + """Map services to methods on XiaomiPlugGenericSwitch.""" + method = SERVICE_TO_METHOD.get(service.service) + params = { + key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID + } + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + devices = [ + device + for device in hass.data[DATA_KEY].values() + if device.entity_id in entity_ids + ] + else: + devices = hass.data[DATA_KEY].values() + + update_tasks = [] + for device in devices: + if not hasattr(device, method["method"]): + continue + await getattr(device, method["method"])(**params) + update_tasks.append(device.async_update_ha_state(True)) + + if update_tasks: + await asyncio.wait(update_tasks) + + for plug_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[plug_service].get("schema", SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, plug_service, async_service_handler, schema=schema + ) + + +class XiaomiPlugGenericSwitch(SwitchEntity): + """Representation of a Xiaomi Plug Generic.""" + + def __init__(self, name, plug, model, unique_id): + """Initialize the plug switch.""" + self._name = name + self._plug = plug + self._model = model + self._unique_id = unique_id + + self._icon = "mdi:power-socket" + self._available = False + self._state = None + self._state_attrs = {ATTR_TEMPERATURE: None, ATTR_MODEL: self._model} + self._device_features = FEATURE_FLAGS_GENERIC + self._skip_update = False + + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def icon(self): + """Return the icon to use for device if any.""" + return self._icon + + @property + def available(self): + """Return true when state is known.""" + return self._available + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + async def _try_command(self, mask_error, func, *args, **kwargs): + """Call a plug command handling error messages.""" + try: + result = await self.hass.async_add_executor_job( + partial(func, *args, **kwargs) + ) + + _LOGGER.debug("Response received from plug: %s", result) + + # The Chuangmi Plug V3 returns 0 on success on usb_on/usb_off. + if func in ["usb_on", "usb_off"] and result == 0: + return True + + return result == SUCCESS + except DeviceException as exc: + if self._available: + _LOGGER.error(mask_error, exc) + self._available = False + + return False + + async def async_turn_on(self, **kwargs): + """Turn the plug on.""" + result = await self._try_command("Turning the plug on failed.", self._plug.on) + + if result: + self._state = True + self._skip_update = True + + async def async_turn_off(self, **kwargs): + """Turn the plug off.""" + result = await self._try_command("Turning the plug off failed.", self._plug.off) + + if result: + self._state = False + self._skip_update = True + + async def async_update(self): + """Fetch state from the device.""" + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = await self.hass.async_add_executor_job(self._plug.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + self._state = state.is_on + self._state_attrs[ATTR_TEMPERATURE] = state.temperature + + except DeviceException as ex: + if self._available: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + + async def async_set_wifi_led_on(self): + """Turn the wifi led on.""" + if self._device_features & FEATURE_SET_WIFI_LED == 0: + return + + await self._try_command( + "Turning the wifi led on failed.", self._plug.set_wifi_led, True + ) + + async def async_set_wifi_led_off(self): + """Turn the wifi led on.""" + if self._device_features & FEATURE_SET_WIFI_LED == 0: + return + + await self._try_command( + "Turning the wifi led off failed.", self._plug.set_wifi_led, False + ) + + async def async_set_power_price(self, price: int): + """Set the power price.""" + if self._device_features & FEATURE_SET_POWER_PRICE == 0: + return + + await self._try_command( + "Setting the power price of the power strip failed.", + self._plug.set_power_price, + price, + ) + + +class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): + """Representation of a Xiaomi Power Strip.""" + + def __init__(self, name, plug, model, unique_id): + """Initialize the plug switch.""" + super().__init__(name, plug, model, unique_id) + + if self._model == MODEL_POWER_STRIP_V2: + self._device_features = FEATURE_FLAGS_POWER_STRIP_V2 + else: + self._device_features = FEATURE_FLAGS_POWER_STRIP_V1 + + self._state_attrs[ATTR_LOAD_POWER] = None + + if self._device_features & FEATURE_SET_POWER_MODE == 1: + self._state_attrs[ATTR_POWER_MODE] = None + + if self._device_features & FEATURE_SET_WIFI_LED == 1: + self._state_attrs[ATTR_WIFI_LED] = None + + if self._device_features & FEATURE_SET_POWER_PRICE == 1: + self._state_attrs[ATTR_POWER_PRICE] = None + + async def async_update(self): + """Fetch state from the device.""" + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = await self.hass.async_add_executor_job(self._plug.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + self._state = state.is_on + self._state_attrs.update( + {ATTR_TEMPERATURE: state.temperature, ATTR_LOAD_POWER: state.load_power} + ) + + if self._device_features & FEATURE_SET_POWER_MODE == 1 and state.mode: + self._state_attrs[ATTR_POWER_MODE] = state.mode.value + + if self._device_features & FEATURE_SET_WIFI_LED == 1 and state.wifi_led: + self._state_attrs[ATTR_WIFI_LED] = state.wifi_led + + if ( + self._device_features & FEATURE_SET_POWER_PRICE == 1 + and state.power_price + ): + self._state_attrs[ATTR_POWER_PRICE] = state.power_price + + except DeviceException as ex: + if self._available: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + + async def async_set_power_mode(self, mode: str): + """Set the power mode.""" + if self._device_features & FEATURE_SET_POWER_MODE == 0: + return + + await self._try_command( + "Setting the power mode of the power strip failed.", + self._plug.set_power_mode, + PowerMode(mode), + ) + + +class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): + """Representation of a Chuang Mi Plug V1 and V3.""" + + def __init__(self, name, plug, model, unique_id, channel_usb): + """Initialize the plug switch.""" + name = f"{name} USB" if channel_usb else name + + if unique_id is not None and channel_usb: + unique_id = f"{unique_id}-usb" + + super().__init__(name, plug, model, unique_id) + self._channel_usb = channel_usb + + if self._model == MODEL_PLUG_V3: + self._device_features = FEATURE_FLAGS_PLUG_V3 + self._state_attrs[ATTR_WIFI_LED] = None + if self._channel_usb is False: + self._state_attrs[ATTR_LOAD_POWER] = None + + async def async_turn_on(self, **kwargs): + """Turn a channel on.""" + if self._channel_usb: + result = await self._try_command( + "Turning the plug on failed.", self._plug.usb_on + ) + else: + result = await self._try_command( + "Turning the plug on failed.", self._plug.on + ) + + if result: + self._state = True + self._skip_update = True + + async def async_turn_off(self, **kwargs): + """Turn a channel off.""" + if self._channel_usb: + result = await self._try_command( + "Turning the plug on failed.", self._plug.usb_off + ) + else: + result = await self._try_command( + "Turning the plug on failed.", self._plug.off + ) + + if result: + self._state = False + self._skip_update = True + + async def async_update(self): + """Fetch state from the device.""" + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = await self.hass.async_add_executor_job(self._plug.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + if self._channel_usb: + self._state = state.usb_power + else: + self._state = state.is_on + + self._state_attrs[ATTR_TEMPERATURE] = state.temperature + + if state.wifi_led: + self._state_attrs[ATTR_WIFI_LED] = state.wifi_led + + if self._channel_usb is False and state.load_power: + self._state_attrs[ATTR_LOAD_POWER] = state.load_power + + except DeviceException as ex: + if self._available: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + + +class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch): + """Representation of a Xiaomi AirConditioning Companion.""" + + def __init__(self, name, plug, model, unique_id): + """Initialize the acpartner switch.""" + super().__init__(name, plug, model, unique_id) + + self._state_attrs.update({ATTR_TEMPERATURE: None, ATTR_LOAD_POWER: None}) + + async def async_turn_on(self, **kwargs): + """Turn the socket on.""" + result = await self._try_command( + "Turning the socket on failed.", self._plug.socket_on + ) + + if result: + self._state = True + self._skip_update = True + + async def async_turn_off(self, **kwargs): + """Turn the socket off.""" + result = await self._try_command( + "Turning the socket off failed.", self._plug.socket_off + ) + + if result: + self._state = False + self._skip_update = True + + async def async_update(self): + """Fetch state from the device.""" + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = await self.hass.async_add_executor_job(self._plug.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + self._state = state.power_socket == "on" + self._state_attrs[ATTR_LOAD_POWER] = state.load_power + + except DeviceException as ex: + if self._available: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..0708f0e --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "Xiaomi Mi Smart WiFi Socket Integration", + "content_in_root": false, + "render_readme": true, + "iot_class": "local_polling" +}