2024-12-10 17:36:32 +08:00

1281 lines
55 KiB
Python

# -*- coding: utf-8 -*-
"""
Copyright (C) 2024 Xiaomi Corporation.
The ownership and intellectual property rights of Xiaomi Home Assistant
Integration and related Xiaomi cloud service API interface provided under this
license, including source code and object code (collectively, "Licensed Work"),
are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi
hereby grants you a personal, limited, non-exclusive, non-transferable,
non-sublicensable, and royalty-free license to reproduce, use, modify, and
distribute the Licensed Work only for your use of Home Assistant for
non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize
you to use the Licensed Work for any other purpose, including but not limited
to use Licensed Work to develop applications (APP), Web services, and other
forms of software.
You may reproduce and distribute copies of the Licensed Work, with or without
modifications, whether in source or object form, provided that you must give
any other recipients of the Licensed Work a copy of this License and retain all
copyright and disclaimers.
Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied, including, without
limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR
OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or
FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible
for any direct, indirect, special, incidental, or consequential damages or
losses arising from the use or inability to use the Licensed Work.
Xiaomi reserves all rights not expressly granted to you in this License.
Except for the rights expressly granted by Xiaomi under this License, Xiaomi
does not authorize you in any form to use the trademarks, copyrights, or other
forms of intellectual property rights of Xiaomi and its affiliates, including,
without limitation, without obtaining other written permission from Xiaomi, you
shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that
may make the public associate with Xiaomi in any form to publicize or promote
the software or hardware devices that use the Licensed Work.
Xiaomi has the right to immediately terminate all your authorization under this
License in the event:
1. You assert patent invalidation, litigation, or other claims against patents
or other intellectual property rights of Xiaomi or its affiliates; or,
2. You make, have made, manufacture, sell, or offer to sell products that knock
off Xiaomi or its affiliates' products.
Config flow for Xiaomi Home.
"""
import asyncio
import hashlib
import json
import secrets
import traceback
from typing import Optional
from aiohttp import web
from aiohttp.hdrs import METH_GET
import voluptuous as vol
import logging
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.components.zeroconf import HaAsyncZeroconf
from homeassistant.components.webhook import (
async_register as webhook_async_register,
async_unregister as webhook_async_unregister,
async_generate_path as webhook_async_generate_path
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
import homeassistant.helpers.config_validation as cv
from .miot.const import (
DEFAULT_CLOUD_SERVER,
DEFAULT_CTRL_MODE,
DEFAULT_INTEGRATION_LANGUAGE,
DEFAULT_NICK_NAME,
DOMAIN,
OAUTH2_CLIENT_ID,
CLOUD_SERVERS,
OAUTH_REDIRECT_URL,
INTEGRATION_LANGUAGES,
SUPPORT_CENTRAL_GATEWAY_CTRL,
NETWORK_REFRESH_INTERVAL,
MIHOME_CERT_EXPIRE_MARGIN
)
from .miot.miot_cloud import MIoTHttpClient, MIoTOauthClient
from .miot.miot_storage import MIoTStorage, MIoTCert
from .miot.miot_mdns import MipsService
from .miot.web_pages import oauth_redirect_page
from .miot.miot_error import MIoTConfigError, MIoTError, MIoTOauthError
from .miot.miot_i18n import MIoTI18n
from .miot.miot_network import MIoTNetwork
from .miot.miot_client import MIoTClient, get_miot_instance_async
from .miot.miot_spec import MIoTSpecParser
from .miot.miot_lan import MIoTLan
_LOGGER = logging.getLogger(__name__)
class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Xiaomi Home config flow."""
# pylint: disable=unused-argument
VERSION = 1
MINOR_VERSION = 1
_main_loop: asyncio.AbstractEventLoop
_mips_service: Optional[MipsService]
_miot_storage: Optional[MIoTStorage]
_miot_network: Optional[MIoTNetwork]
_miot_i18n: Optional[MIoTI18n]
_integration_language: Optional[str]
_storage_path: Optional[str]
_virtual_did: Optional[str]
_uid: Optional[str]
_uuid: Optional[str]
_ctrl_mode: Optional[str]
_area_name_rule: Optional[str]
_action_debug: bool
_hide_non_standard_entities: bool
_auth_info: Optional[dict]
_nick_name: Optional[str]
_home_selected: Optional[dict]
_home_info_buffer: Optional[dict[str, str | dict[str, dict]]]
_home_list: Optional[dict]
_cloud_server: Optional[str]
_oauth_redirect_url: Optional[str]
_miot_oauth: Optional[MIoTOauthClient]
_miot_http: Optional[MIoTHttpClient]
_user_cert_state: bool
_oauth_auth_url: Optional[str]
_task_oauth: Optional[asyncio.Task[None]]
_config_error_reason: Optional[str]
_fut_oauth_code: Optional[asyncio.Future]
def __init__(self) -> None:
self._main_loop = asyncio.get_running_loop()
self._mips_service = None
self._miot_storage = None
self._miot_network = None
self._miot_i18n = None
self._integration_language = None
self._storage_path = None
self._virtual_did = None
self._uid = None
self._uuid = None # MQTT client id
self._ctrl_mode = None
self._area_name_rule = None
self._action_debug = False
self._hide_non_standard_entities = False
self._auth_info = None
self._nick_name = None
self._home_selected = {}
self._home_info_buffer = None
self._home_list = None
self._cloud_server = None
self._oauth_redirect_url = None
self._miot_oauth = None
self._miot_http = None
self._user_cert_state = False
self._oauth_auth_url = None
self._task_oauth = None
self._config_error_reason = None
self._fut_oauth_code = None
async def async_step_user(self, user_input=None):
self.hass.data.setdefault(DOMAIN, {})
loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
if self._virtual_did is None:
self._virtual_did = str(secrets.randbits(64))
self.hass.data[DOMAIN].setdefault(self._virtual_did, {})
if self._storage_path is None:
self._storage_path = self.hass.config.path('.storage', DOMAIN)
# MIoT network
self._miot_network = self.hass.data[DOMAIN].get('miot_network', None)
if self._miot_network is None:
self._miot_network = MIoTNetwork(loop=loop)
self.hass.data[DOMAIN]['miot_network'] = self._miot_network
await self._miot_network.init_async(
refresh_interval=NETWORK_REFRESH_INTERVAL)
_LOGGER.info('async_step_user, create miot network')
# Mips server
self._mips_service = self.hass.data[DOMAIN].get('mips_service', None)
if self._mips_service is None:
aiozc: HaAsyncZeroconf = await zeroconf.async_get_async_instance(
self.hass)
self._mips_service = MipsService(aiozc=aiozc, loop=loop)
self.hass.data[DOMAIN]['mips_service'] = self._mips_service
await self._mips_service.init_async()
_LOGGER.info('async_step_user, create mips service')
# MIoT storage
self._miot_storage = self.hass.data[DOMAIN].get('miot_storage', None)
if self._miot_storage is None:
self._miot_storage = MIoTStorage(
root_path=self._storage_path, loop=loop)
self.hass.data[DOMAIN]['miot_storage'] = self._miot_storage
_LOGGER.info(
'async_step_user, create miot storage, %s', self._storage_path)
# Check network
if not await self._miot_network.get_network_status_async(timeout=5):
raise AbortFlow(reason='network_connect_error',
description_placeholders={})
return await self.async_step_eula(user_input)
async def async_step_eula(self, user_input=None):
if user_input:
if user_input.get('eula', None) is True:
return await self.async_step_auth_config()
return await self.__display_eula('eula_not_agree')
return await self.__display_eula('')
async def __display_eula(self, reason: str):
return self.async_show_form(
step_id='eula',
data_schema=vol.Schema({
vol.Required('eula', default=False): bool,
}),
last_step=False,
errors={'base': reason},
)
async def async_step_auth_config(self, user_input=None):
if user_input:
self._cloud_server = user_input.get(
'cloud_server', DEFAULT_CLOUD_SERVER)
self._integration_language = user_input.get(
'integration_language', DEFAULT_INTEGRATION_LANGUAGE)
self._miot_i18n = MIoTI18n(
lang=self._integration_language, loop=self._main_loop)
await self._miot_i18n.init_async()
webhook_path = webhook_async_generate_path(
webhook_id=self._virtual_did)
self._oauth_redirect_url = (
f'{user_input.get("oauth_redirect_url")}{webhook_path}')
return await self.async_step_oauth(user_input)
# Generate default language from HomeAssistant config (not user config)
default_language: str = self.hass.config.language
if default_language not in INTEGRATION_LANGUAGES:
if default_language.split('-', 1)[0] not in INTEGRATION_LANGUAGES:
default_language = DEFAULT_INTEGRATION_LANGUAGE
else:
default_language = default_language.split('-', 1)[0]
return self.async_show_form(
step_id='auth_config',
data_schema=vol.Schema({
vol.Required(
'cloud_server',
default=DEFAULT_CLOUD_SERVER): vol.In(CLOUD_SERVERS),
vol.Required(
'integration_language',
default=default_language): vol.In(INTEGRATION_LANGUAGES),
vol.Required(
'oauth_redirect_url',
default=OAUTH_REDIRECT_URL): vol.In([OAUTH_REDIRECT_URL]),
}),
last_step=False,
)
async def async_step_oauth(self, user_input=None):
# 1: Init miot_oauth, generate auth url
try:
if self._miot_oauth is None:
_LOGGER.info(
'async_step_oauth, redirect_url: %s',
self._oauth_redirect_url)
miot_oauth = MIoTOauthClient(
client_id=OAUTH2_CLIENT_ID,
redirect_url=self._oauth_redirect_url,
cloud_server=self._cloud_server
)
state = str(secrets.randbits(64))
self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state
self._oauth_auth_url = miot_oauth.gen_auth_url(
redirect_url=self._oauth_redirect_url, state=state)
_LOGGER.info(
'async_step_oauth, oauth_url: %s', self._oauth_auth_url)
webhook_async_unregister(
self.hass, webhook_id=self._virtual_did)
webhook_async_register(
self.hass,
domain=DOMAIN,
name='oauth redirect url webhook',
webhook_id=self._virtual_did,
handler=handle_oauth_webhook,
allowed_methods=(METH_GET,),
)
self._fut_oauth_code = self.hass.data[DOMAIN][
self._virtual_did].get('fut_oauth_code', None)
if self._fut_oauth_code is None:
self._fut_oauth_code = self._main_loop.create_future()
self.hass.data[DOMAIN][self._virtual_did][
'fut_oauth_code'] = self._fut_oauth_code
_LOGGER.info(
'async_step_oauth, webhook.async_register: %s',
self._virtual_did)
self._miot_oauth = miot_oauth
except Exception as err: # pylint: disable=broad-exception-caught
_LOGGER.error(
'async_step_oauth, %s, %s', err, traceback.format_exc())
return self.async_show_progress_done(next_step_id='oauth_error')
# 2: show OAuth2 loading page
if self._task_oauth is None:
self._task_oauth = self.hass.async_create_task(
self.__check_oauth_async())
if self._task_oauth.done():
if (error := self._task_oauth.exception()):
_LOGGER.error('task_oauth exception, %s', error)
self._config_error_reason = str(error)
return self.async_show_progress_done(next_step_id='oauth_error')
return self.async_show_progress_done(next_step_id='devices_filter')
return self.async_show_progress(
step_id='oauth',
progress_action='oauth',
description_placeholders={
'link_left':
f'<a href="{self._oauth_auth_url}" target="_blank">',
'link_right': '</a>'
},
progress_task=self._task_oauth,
)
async def __check_oauth_async(self) -> None:
# TASK 1: Get oauth code
oauth_code: Optional[str] = await self._fut_oauth_code
# TASK 2: Get access_token and user_info from miot_oauth
if not self._auth_info:
try:
auth_info = await self._miot_oauth.get_access_token_async(
code=oauth_code)
self._miot_http = MIoTHttpClient(
cloud_server=self._cloud_server,
client_id=OAUTH2_CLIENT_ID,
access_token=auth_info['access_token'])
self._auth_info = auth_info
# Gen uuid
self._uuid = hashlib.sha256(
f'{self._virtual_did}.{auth_info["access_token"]}'.encode(
'utf-8')
).hexdigest()[:32]
try:
self._nick_name = (
await self._miot_http.get_user_info_async() or {}
).get('miliaoNick', DEFAULT_NICK_NAME)
except (MIoTOauthError, json.JSONDecodeError):
self._nick_name = DEFAULT_NICK_NAME
_LOGGER.error('get nick name failed')
except Exception as err:
_LOGGER.error(
'get_access_token, %s, %s', err, traceback.format_exc())
raise MIoTConfigError('get_token_error') from err
# TASK 3: Get home info
try:
self._home_info_buffer = (
await self._miot_http.get_devices_async())
_LOGGER.info('get_homeinfos response: %s', self._home_info_buffer)
self._uid = self._home_info_buffer['uid']
if self._uid == self._nick_name:
self._nick_name = DEFAULT_NICK_NAME
except Exception as err:
_LOGGER.error(
'get_homeinfos error, %s, %s', err, traceback.format_exc())
raise MIoTConfigError('get_homeinfo_error') from err
# TASK 4: Abort if unique_id configured
# Each MiHome account can only configure one instance
await self.async_set_unique_id(f'{self._cloud_server}{self._uid}')
self._abort_if_unique_id_configured()
# TASK 5: Query mdns info
mips_list = None
if self._cloud_server in SUPPORT_CENTRAL_GATEWAY_CTRL:
try:
mips_list = self._mips_service.get_services()
except Exception as err:
_LOGGER.error(
'async_update_services error, %s, %s',
err, traceback.format_exc())
raise MIoTConfigError('mdns_discovery_error') from err
# TASK 6: Generate devices filter
home_list = {}
tip_devices = self._miot_i18n.translate(key='config.other.devices')
# home list
for home_id, home_info in self._home_info_buffer[
'homes']['home_list'].items():
# i18n
tip_central = ''
group_id = home_info.get('group_id', None)
dev_list = {
device['did']: device
for device in list(self._home_info_buffer['devices'].values())
if device.get('home_id', None) == home_id}
if (
mips_list
and group_id in mips_list
and mips_list[group_id].get('did', None) in dev_list
):
# i18n
tip_central = self._miot_i18n.translate(
key='config.other.found_central_gateway')
home_info['central_did'] = mips_list[group_id].get('did', None)
home_list[home_id] = (
f'{home_info["home_name"]} '
f'[{len(dev_list)} {tip_devices}{tip_central}]')
self._home_list = dict(sorted(home_list.items()))
# TASK 7: Get user's MiHome certificate
if self._cloud_server in SUPPORT_CENTRAL_GATEWAY_CTRL:
miot_cert = MIoTCert(
storage=self._miot_storage,
uid=self._uid, cloud_server=self._cloud_server)
if not self._user_cert_state:
try:
if await miot_cert.user_cert_remaining_time_async(
did=self._virtual_did) < MIHOME_CERT_EXPIRE_MARGIN:
user_key = await miot_cert.load_user_key_async()
if user_key is None:
user_key = miot_cert.gen_user_key()
if not await miot_cert.update_user_key_async(
key=user_key):
raise MIoTError('update_user_key_async failed')
csr_str = miot_cert.gen_user_csr(
user_key=user_key, did=self._virtual_did)
crt_str = await self._miot_http.get_central_cert_async(
csr_str)
if not await miot_cert.update_user_cert_async(
cert=crt_str):
raise MIoTError('update_user_cert_async failed')
self._user_cert_state = True
_LOGGER.info(
'get mihome cert success, %s, %s',
self._uid, self._virtual_did)
except Exception as err:
_LOGGER.error(
'get user cert error, %s, %s',
err, traceback.format_exc())
raise MIoTConfigError('get_cert_error') from err
# Auth success, unregister oauth webhook
webhook_async_unregister(self.hass, webhook_id=self._virtual_did)
_LOGGER.info(
'__check_oauth_async, webhook.async_unregister: %s',
self._virtual_did)
# Show setup error message
async def async_step_oauth_error(self, user_input=None):
if self._config_error_reason is None:
return await self.async_step_oauth()
if self._config_error_reason.startswith('Flow aborted: '):
raise AbortFlow(
reason=self._config_error_reason.replace('Flow aborted: ', ''))
error_reason = self._config_error_reason
self._config_error_reason = None
return self.async_show_form(
step_id='oauth_error',
data_schema=vol.Schema({}),
last_step=False,
errors={'base': error_reason},
)
async def async_step_devices_filter(self, user_input=None):
_LOGGER.debug('async_step_devices_filter')
try:
if user_input is None:
return await self.display_device_filter_form('')
home_selected: list = user_input.get('home_infos', [])
if not home_selected:
return await self.display_device_filter_form(
'no_family_selected')
self._ctrl_mode = user_input.get('ctrl_mode')
for home_id, home_info in self._home_info_buffer[
'homes']['home_list'].items():
if home_id in home_selected:
self._home_selected[home_id] = home_info
self._area_name_rule = user_input.get('area_name_rule')
self._action_debug = user_input.get(
'action_debug', self._action_debug)
self._hide_non_standard_entities = user_input.get(
'hide_non_standard_entities', self._hide_non_standard_entities)
# Storage device list
devices_list: dict[str, dict] = {
did: dev_info
for did, dev_info in self._home_info_buffer['devices'].items()
if dev_info['home_id'] in home_selected}
if not devices_list:
return await self.display_device_filter_form('no_devices')
devices_list_sort = dict(sorted(
devices_list.items(), key=lambda item:
item[1].get('home_id', '')+item[1].get('room_id', '')))
if not await self._miot_storage.save_async(
domain='miot_devices',
name=f'{self._uid}_{self._cloud_server}',
data=devices_list_sort):
_LOGGER.error(
'save devices async failed, %s, %s',
self._uid, self._cloud_server)
return await self.display_device_filter_form(
'devices_storage_failed')
if not (await self._miot_storage.update_user_config_async(
uid=self._uid, cloud_server=self._cloud_server, config={
'auth_info': self._auth_info
})):
raise MIoTError('miot_storage.update_user_config_async error')
return self.async_create_entry(
title=(
f'{self._nick_name}: {self._uid} '
f'[{CLOUD_SERVERS[self._cloud_server]}]'),
data={
'virtual_did': self._virtual_did,
'uuid': self._uuid,
'integration_language': self._integration_language,
'storage_path': self._storage_path,
'uid': self._uid,
'nick_name': self._nick_name,
'cloud_server': self._cloud_server,
'oauth_redirect_url': self._oauth_redirect_url,
'ctrl_mode': self._ctrl_mode,
'home_selected': self._home_selected,
'area_name_rule': self._area_name_rule,
'action_debug': self._action_debug,
'hide_non_standard_entities':
self._hide_non_standard_entities,
})
except Exception as err:
_LOGGER.error(
'async_step_devices_filter, %s, %s',
err, traceback.format_exc())
raise AbortFlow(
reason='config_flow_error',
description_placeholders={
'error': f'config_flow error, {err}'}
) from err
async def display_device_filter_form(self, reason: str):
return self.async_show_form(
step_id='devices_filter',
data_schema=vol.Schema({
vol.Required('ctrl_mode', default=DEFAULT_CTRL_MODE): vol.In(
self._miot_i18n.translate(key='config.control_mode')),
vol.Required('home_infos'): cv.multi_select(self._home_list),
vol.Required('area_name_rule', default='room'): vol.In(
self._miot_i18n.translate(key='config.room_name_rule')),
vol.Required('action_debug', default=self._action_debug): bool,
vol.Required(
'hide_non_standard_entities',
default=self._hide_non_standard_entities): bool,
}),
errors={'base': reason},
description_placeholders={
'nick_name': self._nick_name,
},
last_step=False,
)
@ staticmethod
@ callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Xiaomi MiHome options flow."""
# pylint: disable=unused-argument
_config_entry: config_entries.ConfigEntry
_main_loop: asyncio.AbstractEventLoop
_miot_client: Optional[MIoTClient]
_miot_network: Optional[MIoTNetwork]
_miot_storage: Optional[MIoTStorage]
_mips_service: Optional[MipsService]
_miot_oauth: Optional[MIoTOauthClient]
_miot_http: Optional[MIoTHttpClient]
_miot_i18n: Optional[MIoTI18n]
_miot_lan: Optional[MIoTLan]
_entry_data: dict
_virtual_did: Optional[str]
_uid: Optional[str]
_storage_path: Optional[str]
_cloud_server: Optional[str]
_oauth_redirect_url: Optional[str]
_integration_language: Optional[str]
_ctrl_mode: Optional[str]
_nick_name: Optional[str]
_home_selected_list: Optional[list]
_action_debug: bool
_hide_non_standard_entities: bool
_auth_info: Optional[dict]
_home_selected_dict: Optional[dict]
_home_info_buffer: Optional[dict[str, str | dict[str, dict]]]
_home_list: Optional[dict]
_device_list: Optional[dict[str, dict]]
_devices_add: list[str]
_devices_remove: list[str]
_oauth_auth_url: Optional[str]
_task_oauth: Optional[asyncio.Task[None]]
_config_error_reason: Optional[str]
_fut_oauth_code: Optional[asyncio.Future]
# Config options
_lang_new: Optional[str]
_nick_name_new: Optional[str]
_action_debug_new: bool
_hide_non_standard_entities_new: bool
_update_user_info: bool
_update_devices: bool
_update_trans_rules: bool
_update_lan_ctrl_config: bool
_trans_rules_count: int
_trans_rules_count_success: int
_need_reload: bool
def __init__(self, config_entry: config_entries.ConfigEntry):
self._config_entry = config_entry
self._main_loop = None
self._miot_client = None
self._miot_network = None
self._miot_storage = None
self._mips_service = None
self._miot_oauth = None
self._miot_http = None
self._miot_i18n = None
self._miot_lan = None
self._entry_data = dict(config_entry.data)
self._virtual_did = self._entry_data['virtual_did']
self._uid = self._entry_data['uid']
self._storage_path = self._entry_data['storage_path']
self._cloud_server = self._entry_data['cloud_server']
self._oauth_redirect_url = self._entry_data['oauth_redirect_url']
self._ctrl_mode = self._entry_data['ctrl_mode']
self._integration_language = self._entry_data['integration_language']
self._nick_name = self._entry_data['nick_name']
self._action_debug = self._entry_data.get('action_debug', False)
self._hide_non_standard_entities = self._entry_data.get(
'hide_non_standard_entities', False)
self._home_selected_list = list(
self._entry_data['home_selected'].keys())
self._auth_info = None
self._home_selected_dict = {}
self._home_info_buffer = None
self._home_list = None
self._device_list = None
self._devices_add = []
self._devices_remove = []
self._oauth_auth_url = None
self._task_oauth = None
self._config_error_reason = None
self._fut_oauth_code = None
self._lang_new = None
self._nick_name_new = None
self._action_debug_new = False
self._hide_non_standard_entities_new = False
self._update_user_info = False
self._update_devices = False
self._update_trans_rules = False
self._update_lan_ctrl_config = False
self._trans_rules_count = 0
self._trans_rules_count_success = 0
self._need_reload = False
_LOGGER.info(
'options init, %s, %s, %s, %s', config_entry.entry_id,
config_entry.unique_id, config_entry.data, config_entry.options)
async def async_step_init(self, user_input=None):
self.hass.data.setdefault(DOMAIN, {})
self.hass.data[DOMAIN].setdefault(self._virtual_did, {})
try:
# main loop
self._main_loop = asyncio.get_running_loop()
# MIoT client
self._miot_client: MIoTClient = await get_miot_instance_async(
hass=self.hass, entry_id=self._config_entry.entry_id)
if not self._miot_client:
raise MIoTConfigError('invalid miot client')
# MIoT network
self._miot_network = self._miot_client.miot_network
if not self._miot_network:
raise MIoTConfigError('invalid miot network')
# MIoT storage
self._miot_storage = self._miot_client.miot_storage
if not self._miot_storage:
raise MIoTConfigError('invalid miot storage')
# Mips service
self._mips_service = self._miot_client.mips_service
if not self._mips_service:
raise MIoTConfigError('invalid mips service')
# MIoT oauth
self._miot_oauth = self._miot_client.miot_oauth
if not self._miot_oauth:
raise MIoTConfigError('invalid miot oauth')
# MIoT http
self._miot_http = self._miot_client.miot_http
if not self._miot_http:
raise MIoTConfigError('invalid miot http')
self._miot_i18n = self._miot_client.miot_i18n
if not self._miot_i18n:
raise MIoTConfigError('invalid miot i18n')
self._miot_lan = self._miot_client.miot_lan
if not self._miot_lan:
raise MIoTConfigError('invalid miot lan')
# Check token
if not await self._miot_client.refresh_oauth_info_async():
# Check network
if not await self._miot_network.get_network_status_async(
timeout=3):
raise AbortFlow(
reason='network_connect_error',
description_placeholders={})
self._need_reload = True
return await self.async_step_auth_config()
return await self.async_step_config_options()
except MIoTConfigError as err:
raise AbortFlow(
reason='options_flow_error',
description_placeholders={'error': str(err)}
) from err
except AbortFlow as err:
raise err
except Exception as err:
_LOGGER.error(
'async_step_init error, %s, %s',
err, traceback.format_exc())
raise AbortFlow(
reason='re_add',
description_placeholders={'error': str(err)},
) from err
async def async_step_auth_config(self, user_input=None):
if user_input:
webhook_path = webhook_async_generate_path(
webhook_id=self._virtual_did)
self._oauth_redirect_url = (
f'{user_input.get("oauth_redirect_url")}{webhook_path}')
return await self.async_step_oauth(user_input)
return self.async_show_form(
step_id='auth_config',
data_schema=vol.Schema({
vol.Required(
'oauth_redirect_url',
default=OAUTH_REDIRECT_URL): vol.In([OAUTH_REDIRECT_URL]),
}),
description_placeholders={
'cloud_server': CLOUD_SERVERS[self._cloud_server],
},
last_step=False,
)
async def async_step_oauth(self, user_input=None):
try:
if self._task_oauth is None:
state = str(secrets.randbits(64))
self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state
self._miot_oauth.set_redirect_url(
redirect_url=self._oauth_redirect_url)
self._oauth_auth_url = self._miot_oauth.gen_auth_url(
redirect_url=self._oauth_redirect_url, state=state)
_LOGGER.info(
'async_step_oauth, oauth_url: %s',
self._oauth_auth_url)
webhook_async_unregister(
self.hass, webhook_id=self._virtual_did)
webhook_async_register(
self.hass,
domain=DOMAIN,
name='oauth redirect url webhook',
webhook_id=self._virtual_did,
handler=handle_oauth_webhook,
allowed_methods=(METH_GET,),
)
self._fut_oauth_code = self.hass.data[DOMAIN][
self._virtual_did].get('fut_oauth_code', None)
if self._fut_oauth_code is None:
self._fut_oauth_code = self._main_loop.create_future()
self.hass.data[DOMAIN][self._virtual_did][
'fut_oauth_code'] = self._fut_oauth_code
self._task_oauth = self.hass.async_create_task(
self.__check_oauth_async())
_LOGGER.info(
'async_step_oauth, webhook.async_register: %s',
self._virtual_did)
if self._task_oauth.done():
if (error := self._task_oauth.exception()):
_LOGGER.error('task_oauth exception, %s', error)
self._config_error_reason = str(error)
self._task_oauth = None
return self.async_show_progress_done(
next_step_id='oauth_error')
return self.async_show_progress_done(
next_step_id='config_options')
except Exception as err: # pylint: disable=broad-exception-caught
_LOGGER.error(
'async_step_oauth error, %s, %s',
err, traceback.format_exc())
self._config_error_reason = str(err)
return self.async_show_progress_done(next_step_id='oauth_error')
return self.async_show_progress(
step_id='oauth',
progress_action='oauth',
description_placeholders={
'link_left':
f'<a href="{self._oauth_auth_url}" target="_blank">',
'link_right': '</a>'
},
progress_task=self._task_oauth,
)
async def __check_oauth_async(self) -> None:
# Get oauth code
oauth_code: Optional[str] = await self._fut_oauth_code
_LOGGER.debug('options flow __check_oauth_async, %s', oauth_code)
# Get access_token and user_info from miot_oauth
if self._auth_info is None:
auth_info: dict = None
try:
auth_info = await self._miot_oauth.get_access_token_async(
code=oauth_code)
except Exception as err:
_LOGGER.error(
'get_access_token, %s, %s', err, traceback.format_exc())
raise MIoTConfigError('get_token_error') from err
# Check uid
m_http: MIoTHttpClient = MIoTHttpClient(
cloud_server=self._cloud_server,
client_id=OAUTH2_CLIENT_ID,
access_token=auth_info['access_token'],
loop=self._main_loop)
if await m_http.get_uid_async() != self._uid:
raise AbortFlow('inconsistent_account')
del m_http
self._miot_http.update_http_header(
access_token=auth_info['access_token'])
if not await self._miot_storage.update_user_config_async(
uid=self._uid,
cloud_server=self._cloud_server,
config={'auth_info': auth_info}):
raise AbortFlow('storage_error')
self._auth_info = auth_info
# Auth success, unregister oauth webhook
webhook_async_unregister(self.hass, webhook_id=self._virtual_did)
_LOGGER.info(
'__check_oauth_async, webhook.async_unregister: %s',
self._virtual_did)
# Show setup error message
async def async_step_oauth_error(self, user_input=None):
if self._config_error_reason is None:
return await self.async_step_oauth()
if self._config_error_reason.startswith('Flow aborted: '):
raise AbortFlow(
reason=self._config_error_reason.replace('Flow aborted: ', ''))
error_reason = self._config_error_reason
self._config_error_reason = None
return self.async_show_form(
step_id='oauth_error',
data_schema=vol.Schema({}),
last_step=False,
errors={'base': error_reason},
)
async def async_step_config_options(self, user_input=None):
if not user_input:
return self.async_show_form(
step_id='config_options',
data_schema=vol.Schema({
vol.Required(
'integration_language',
default=self._integration_language
): vol.In(INTEGRATION_LANGUAGES),
vol.Required(
'update_user_info',
default=self._update_user_info): bool,
vol.Required(
'update_devices', default=self._update_devices): bool,
vol.Required(
'action_debug', default=self._action_debug): bool,
vol.Required(
'hide_non_standard_entities',
default=self._hide_non_standard_entities): bool,
vol.Required(
'update_trans_rules',
default=self._update_trans_rules): bool,
vol.Required(
'update_lan_ctrl_config',
default=self._update_lan_ctrl_config): bool
}),
errors={},
description_placeholders={
'nick_name': self._nick_name,
'uid': self._uid,
'cloud_server': CLOUD_SERVERS[self._cloud_server]
},
last_step=False,
)
# Check network
if not await self._miot_network.get_network_status_async(timeout=3):
raise AbortFlow(
reason='network_connect_error', description_placeholders={})
self._lang_new = user_input.get(
'integration_language', self._integration_language)
self._update_user_info = user_input.get(
'update_user_info', self._update_user_info)
self._update_devices = user_input.get(
'update_devices', self._update_devices)
self._action_debug_new = user_input.get(
'action_debug', self._action_debug)
self._hide_non_standard_entities_new = user_input.get(
'hide_non_standard_entities', self._hide_non_standard_entities)
self._update_trans_rules = user_input.get(
'update_trans_rules', self._update_trans_rules)
self._update_lan_ctrl_config = user_input.get(
'update_lan_ctrl_config', self._update_lan_ctrl_config)
return await self.async_step_update_user_info()
async def async_step_update_user_info(self, user_input=None):
if not self._update_user_info:
return await self.async_step_devices_filter()
if not user_input:
nick_name_new = (
await self._miot_http.get_user_info_async() or {}).get(
'miliaoNick', DEFAULT_NICK_NAME)
return self.async_show_form(
step_id='update_user_info',
data_schema=vol.Schema({
vol.Required('nick_name', default=nick_name_new): str
}),
description_placeholders={
'nick_name': self._nick_name
},
last_step=False
)
self._nick_name_new = user_input.get('nick_name')
return await self.async_step_devices_filter()
async def async_step_devices_filter(self, user_input=None):
if not self._update_devices:
return await self.async_step_update_trans_rules()
if not user_input:
# Query mdns info
try:
mips_list = self._mips_service.get_services()
except Exception as err:
_LOGGER.error(
'async_update_services error, %s, %s',
err, traceback.format_exc())
raise MIoTConfigError('mdns_discovery_error') from err
# Get home info
try:
self._home_info_buffer = (
await self._miot_http.get_devices_async())
except Exception as err:
_LOGGER.error(
'get_homeinfos error, %s, %s', err, traceback.format_exc())
raise MIoTConfigError('get_homeinfo_error') from err
# Generate devices filter
home_list = {}
tip_devices = self._miot_i18n.translate(key='config.other.devices')
# home list
for home_id, home_info in self._home_info_buffer[
'homes']['home_list'].items():
# i18n
tip_central = ''
group_id = home_info.get('group_id', None)
did_list = {
device['did']: device for device in list(
self._home_info_buffer['devices'].values())
if device.get('home_id', None) == home_id}
if (
group_id in mips_list
and mips_list[group_id].get('did', None) in did_list
):
# i18n
tip_central = self._miot_i18n.translate(
key='config.other.found_central_gateway')
home_info['central_did'] = mips_list[group_id].get(
'did', None)
home_list[home_id] = (
f'{home_info["home_name"]} '
f'[ {len(did_list)} {tip_devices}{tip_central}]')
# Remove deleted item
self._home_selected_list = [
home_id for home_id in self._home_selected_list
if home_id in home_list]
self._home_list = dict(sorted(home_list.items()))
return await self.display_device_filter_form('')
self._home_selected_list = user_input.get('home_infos', [])
if not self._home_selected_list:
return await self.display_device_filter_form('no_family_selected')
self._ctrl_mode = user_input.get('ctrl_mode')
self._home_selected_dict = {}
for home_id, home_info in self._home_info_buffer[
'homes']['home_list'].items():
if home_id in self._home_selected_list:
self._home_selected_dict[home_id] = home_info
# Get device list
self._device_list: dict[str, dict] = {
did: dev_info
for did, dev_info in self._home_info_buffer['devices'].items()
if dev_info['home_id'] in self._home_selected_list}
if not self._device_list:
return await self.display_device_filter_form('no_devices')
# Statistics devices changed
self._devices_add = []
self._devices_remove = []
local_devices = await self._miot_storage.load_async(
domain='miot_devices',
name=f'{self._uid}_{self._cloud_server}',
type_=dict) or {}
self._devices_add = [
did for did in self._device_list.keys() if did not in local_devices]
self._devices_remove = [
did for did in local_devices.keys() if did not in self._device_list]
_LOGGER.debug(
'devices update, add->%s, remove->%s',
self._devices_add, self._devices_remove)
return await self.async_step_update_trans_rules()
async def display_device_filter_form(self, reason: str):
return self.async_show_form(
step_id='devices_filter',
data_schema=vol.Schema({
vol.Required(
'ctrl_mode', default=self._ctrl_mode
): vol.In(self._miot_i18n.translate(key='config.control_mode')),
vol.Required(
'home_infos',
default=self._home_selected_list
): cv.multi_select(self._home_list),
}),
errors={'base': reason},
description_placeholders={
'nick_name': self._nick_name
},
last_step=False
)
async def async_step_update_trans_rules(self, user_input=None):
if not self._update_trans_rules:
return await self.async_step_update_lan_ctrl_config()
urn_list: list[str] = list({
info['urn']
for info in list(self._miot_client.device_list.values())
if 'urn' in info})
self._trans_rules_count = len(urn_list)
if not user_input:
return self.async_show_form(
step_id='update_trans_rules',
data_schema=vol.Schema({
vol.Required('confirm', default=False): bool
}),
description_placeholders={
'urn_count': self._trans_rules_count,
},
last_step=False
)
if user_input.get('confirm', False):
# Update trans rules
if urn_list:
spec_parser: MIoTSpecParser = MIoTSpecParser(
lang=self._lang_new, storage=self._miot_storage)
await spec_parser.init_async()
self._trans_rules_count_success = (
await spec_parser.refresh_async(urn_list=urn_list))
await spec_parser.deinit_async()
else:
# SKIP update trans rules
self._update_trans_rules = False
return await self.async_step_update_lan_ctrl_config()
async def async_step_update_lan_ctrl_config(self, user_input=None):
if not self._update_lan_ctrl_config:
return await self.async_step_config_confirm()
if not user_input:
notice_net_dup: str = ''
lan_ctrl_config = await self._miot_storage.load_user_config_async(
'global_config', 'all', ['net_interfaces', 'enable_subscribe'])
selected_if = lan_ctrl_config.get('net_interfaces', [])
enable_subscribe = lan_ctrl_config.get('enable_subscribe', False)
net_unavailable = self._miot_i18n.translate(
key='config.lan_ctrl_config.net_unavailable')
net_if = {
if_name: f'{if_name}: {net_unavailable}'
for if_name in selected_if}
net_info = await self._miot_network.get_network_info_async()
net_segs = set()
for if_name, info in net_info.items():
net_if[if_name] = (
f'{if_name} ({info.ip}/{info.netmask})')
net_segs.add(info.net_seg)
if len(net_segs) != len(net_info):
notice_net_dup = self._miot_i18n.translate(
key='config.lan_ctrl_config.notice_net_dup')
return self.async_show_form(
step_id='update_lan_ctrl_config',
data_schema=vol.Schema({
vol.Required(
'net_interfaces', default=selected_if
): cv.multi_select(net_if),
vol.Required(
'enable_subscribe', default=enable_subscribe): bool
}),
description_placeholders={
'notice_net_dup': notice_net_dup,
},
last_step=False
)
selected_if_new: list = user_input.get('net_interfaces', [])
enable_subscribe_new: bool = user_input.get('enable_subscribe', False)
lan_ctrl_config = await self._miot_storage.load_user_config_async(
'global_config', 'all', ['net_interfaces', 'enable_subscribe'])
selected_if = lan_ctrl_config.get('net_interfaces', [])
enable_subscribe = lan_ctrl_config.get('enable_subscribe', False)
if (
set(selected_if_new) != set(selected_if)
or enable_subscribe_new != enable_subscribe
):
if not await self._miot_storage.update_user_config_async(
'global_config', 'all', {
'net_interfaces': selected_if_new,
'enable_subscribe': enable_subscribe_new}
):
raise AbortFlow(
reason='storage_error',
description_placeholders={
'error': 'Update net config error'})
await self._miot_lan.update_net_ifs_async(net_ifs=selected_if_new)
await self._miot_lan.update_subscribe_option(
enable_subscribe=enable_subscribe_new)
return await self.async_step_config_confirm()
async def async_step_config_confirm(self, user_input=None):
if not user_input or not user_input.get('confirm', False):
enable_text = self._miot_i18n.translate(
key='config.option_status.enable')
disable_text = self._miot_i18n.translate(
key='config.option_status.disable')
return self.async_show_form(
step_id='config_confirm',
data_schema=vol.Schema({
vol.Required('confirm', default=False): bool
}),
description_placeholders={
'nick_name': self._nick_name,
'lang_new': INTEGRATION_LANGUAGES[self._lang_new],
'nick_name_new': self._nick_name_new,
'devices_add': len(self._devices_add),
'devices_remove': len(self._devices_remove),
'trans_rules_count': self._trans_rules_count,
'trans_rules_count_success':
self._trans_rules_count_success,
'action_debug': (
enable_text if self._action_debug_new
else disable_text),
'hide_non_standard_entities': (
enable_text if self._hide_non_standard_entities_new
else disable_text),
},
errors={'base': 'not_confirm'} if user_input else {},
last_step=True
)
self._entry_data['oauth_redirect_url'] = self._oauth_redirect_url
if self._lang_new != self._integration_language:
self._entry_data['integration_language'] = self._lang_new
self._need_reload = True
if self._update_user_info:
self._entry_data['nick_name'] = self._nick_name_new
if self._update_devices:
self._entry_data['ctrl_mode'] = self._ctrl_mode
self._entry_data['home_selected'] = self._home_selected_dict
devices_list_sort = dict(sorted(
self._device_list.items(), key=lambda item:
item[1].get('home_id', '')+item[1].get('room_id', '')))
if not await self._miot_storage.save_async(
domain='miot_devices',
name=f'{self._uid}_{self._cloud_server}',
data=devices_list_sort):
_LOGGER.error(
'save devices async failed, %s, %s',
self._uid, self._cloud_server)
raise AbortFlow(
reason='storage_error', description_placeholders={
'error': 'save user devices error'})
self._need_reload = True
if self._update_trans_rules:
self._need_reload = True
if self._action_debug_new != self._action_debug:
self._entry_data['action_debug'] = self._action_debug_new
self._need_reload = True
if (
self._hide_non_standard_entities_new !=
self._hide_non_standard_entities
):
self._entry_data['hide_non_standard_entities'] = (
self._hide_non_standard_entities_new)
self._need_reload = True
if (
self._devices_remove
and not await self._miot_storage.update_user_config_async(
uid=self._uid,
cloud_server=self._cloud_server,
config={'devices_remove': self._devices_remove})
):
raise AbortFlow(
reason='storage_error',
description_placeholders={'error': 'Update user config error'})
entry_title = (
f'{self._nick_name_new or self._nick_name}: '
f'{self._uid} [{CLOUD_SERVERS[self._cloud_server]}]')
# Update entry config
self.hass.config_entries.async_update_entry(
self._config_entry, title=entry_title, data=self._entry_data)
# Reload later
if self._need_reload:
self._main_loop.call_later(
0, lambda: self._main_loop.create_task(
self.hass.config_entries.async_reload(
entry_id=self._config_entry.entry_id)))
return self.async_create_entry(title='', data={})
async def handle_oauth_webhook(hass, webhook_id, request):
try:
data = dict(request.query)
if data.get('code', None) is None or data.get('state', None) is None:
raise MIoTConfigError('invalid oauth code')
if data['state'] != hass.data[DOMAIN][webhook_id]['oauth_state']:
raise MIoTConfigError(
f'invalid oauth state, '
f'{hass.data[DOMAIN][webhook_id]["oauth_state"]}, '
f'{data["state"]}')
fut_oauth_code: asyncio.Future = hass.data[DOMAIN][webhook_id].pop(
'fut_oauth_code', None)
fut_oauth_code.set_result(data['code'])
_LOGGER.info('webhook code: %s', data['code'])
return web.Response(
body=oauth_redirect_page(
hass.config.language, 'success'), content_type='text/html')
except MIoTConfigError:
return web.Response(
body=oauth_redirect_page(hass.config.language, 'fail'),
content_type='text/html')