diff --git a/README.md b/README.md index 424754b..6596321 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ _BeHome(原巴法云)智能家居设备的 Home Assistant 集成。_ - 媒体播放器(电视) - 空气净化器 - 传感器 -- **自动同步设备**:自动导入账号下所有支持的设备 +- **设备同步模式**:默认自动导入账号下所有支持的设备,也可改为手动选择同步设备 - **实时状态更新**:每 5 秒轮询设备状态 - **区域集成**:自动映射到 Home Assistant 区域 @@ -77,13 +77,15 @@ BeHome 支持三种登录方式: 3. 选择 BeHome 集成 4. 选择 **微信扫码登录**、**OAuth2** 或 **手动输入用户私钥** 5. 按照所选登录方式完成授权 -6. 您的 BeHome 设备将被自动发现并添加 +6. 您的 BeHome 设备将按同步设置被发现并添加 ### 设备同步 -身份验证完成后,账号下所有支持的 BeHome 设备都会自动导入并配置。配置流程不会让用户选择设备;如果用户不想在 Home Assistant 中使用某个设备,可以在 Home Assistant 中禁用对应设备或实体。 +身份验证完成后,集成默认会自动导入账号下所有支持的 BeHome 设备。 -后续如果用户在 BeHome 账号中新增了支持的设备,集成会在轮询时自动发现并创建对应实体。 +如需阻止未选择的设备同步到 Home Assistant,可进入 **设置** -> **设备与服务** -> **BeHome** -> **配置**,将同步模式改为 **手动选择同步设备**,然后只勾选需要同步的设备。手动模式下,未勾选且此前由本集成创建的设备和实体会从 Home Assistant 注册表删除;不勾选任何设备时,会删除本集成创建的所有设备和实体。此清理只匹配当前 BeHome 配置项创建的设备,不会删除用户原有的 Home Assistant 设备。 + +自动模式下,后续如果用户在 BeHome 账号中新增了支持的设备,集成会在轮询时自动发现并创建对应实体。手动模式下,新设备只有被勾选后才会同步。 集成将: - 根据设备类型为每个设备创建实体 diff --git a/README_en.md b/README_en.md index 1c07345..9f058ea 100644 --- a/README_en.md +++ b/README_en.md @@ -26,7 +26,7 @@ English | [简体中文](README.md) - Media players (TVs) - Air purifiers - Sensors -- **Automatic Device Sync**: All supported devices in the account are imported automatically +- **Device Sync Modes**: Import all supported devices automatically by default, or manually select which devices to sync - **Real-time Updates**: Device state polling every 5 seconds - **Area Integration**: Automatic mapping to Home Assistant areas @@ -77,13 +77,15 @@ Only OAuth2 login requires this step. WeChat QR code login and private key login 3. Select the BeHome integration 4. Choose **WeChat QR code login**, **OAuth2**, or **manual private key entry** 5. Complete the selected login flow -6. Your BeHome devices will be automatically discovered and added +6. Your BeHome devices will be discovered and added according to the sync settings ### Device Sync -Once authenticated, all supported BeHome devices in the account will be automatically imported and configured. The setup flow does not ask users to select devices. If users do not want to use a device in Home Assistant, they can disable the corresponding device or entity in Home Assistant. +Once authenticated, all supported BeHome devices in the account will be imported automatically by default. -When new supported devices are added to the BeHome account later, the integration will discover them during polling and create the corresponding entities automatically. +To prevent unselected devices from syncing to Home Assistant, go to **Settings** -> **Devices & Services** -> **BeHome** -> **Configure**, change the sync mode to **manual device selection**, and select only the devices you want to sync. In manual mode, unselected devices and entities previously created by this integration are removed from the Home Assistant registries. If no devices are selected, all devices and entities created by this integration are removed. The cleanup only matches devices created by the current BeHome config entry and does not delete the user's original Home Assistant devices. + +In automatic mode, when new supported devices are added to the BeHome account later, the integration will discover them during polling and create the corresponding entities automatically. In manual mode, new devices sync only after they are selected. The integration will: - Create entities for each device based on their type diff --git a/custom_components/behome/__init__.py b/custom_components/behome/__init__.py index d3e02a8..e9c5a13 100644 --- a/custom_components/behome/__init__.py +++ b/custom_components/behome/__init__.py @@ -5,9 +5,16 @@ import logging import time +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow, area_registry, config_validation as cv +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import ( + area_registry, + config_entry_oauth2_flow, + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -15,14 +22,18 @@ DOMAIN, PLATFORMS, CONF_PRIVATE_KEY, + CONF_SELECTED_DEVICES, + CONF_SYNC_MODE, OAUTH2_CLIENT_ID, OAUTH2_AUTHORIZE_URL, OAUTH2_TOKEN_URL, + SYNC_MODE_MANUAL, ) from .api import BemfaAPI SCAN_INTERVAL = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +_DEFAULT_ENTRY_TITLES = {"BeHome", "BeHome (WeChat)", "BeHome (Manual)"} # This integration can only be configured via config entries CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -130,21 +141,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False unique_id = _unique_id_from_private_key(private_key) - if entry.data.get(CONF_PRIVATE_KEY) != private_key or entry.unique_id != unique_id: + entry_updates = {} + if entry.data.get(CONF_PRIVATE_KEY) != private_key: + entry_updates["data"] = {CONF_PRIVATE_KEY: private_key} + if entry.unique_id != unique_id: + entry_updates["unique_id"] = unique_id + if entry.title in _DEFAULT_ENTRY_TITLES: + entry_title = _entry_title_from_private_key(private_key) + if entry.title != entry_title: + entry_updates["title"] = entry_title + + if entry_updates: hass.config_entries.async_update_entry( entry, - data={CONF_PRIVATE_KEY: private_key}, - unique_id=unique_id, + **entry_updates, ) session = async_get_clientsession(hass) api = BemfaAPI(private_key, session) + async def _async_get_filtered_devices(): + """Fetch devices and apply the configured sync mode.""" + devices = await api.get_devices() + return _filter_devices_for_entry(entry, devices) + coordinator = SmartDataUpdateCoordinator( hass, _LOGGER, name="behome_devices", - update_method=api.get_devices, + update_method=_async_get_filtered_devices, update_interval=SCAN_INTERVAL, ) @@ -157,6 +182,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _schedule_remove_unselected_manual_devices(hass, entry) + entry.async_on_unload(entry.add_update_listener(_async_reload_entry)) return True @@ -168,6 +195,107 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def _async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the config entry when options change.""" + await hass.config_entries.async_reload(entry.entry_id) + + +def _filter_devices_for_entry( + entry: ConfigEntry, devices: list[dict] +) -> list[dict]: + """Filter BeHome devices according to the configured sync mode.""" + if entry.options.get(CONF_SYNC_MODE) != SYNC_MODE_MANUAL: + return devices + + selected_devices = entry.options.get(CONF_SELECTED_DEVICES, []) + if not isinstance(selected_devices, list): + return [] + + selected_device_ids = {str(device_id) for device_id in selected_devices} + return [ + device + for device in devices + if str(device.get("deviceID", "")) in selected_device_ids + ] + + +def _remove_unselected_manual_devices( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Remove BeHome registry entries that are not selected in manual mode.""" + if entry.options.get(CONF_SYNC_MODE) != SYNC_MODE_MANUAL: + return + + selected_devices = entry.options.get(CONF_SELECTED_DEVICES, []) + if not isinstance(selected_devices, list): + selected_devices = [] + selected_device_ids = {str(device_id) for device_id in selected_devices} + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + stale_device_entry_ids = set() + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + behome_device_id = _behome_device_id_from_device_entry(device_entry) + if behome_device_id is None or behome_device_id in selected_device_ids: + continue + stale_device_entry_ids.add(device_entry.id) + + stale_entity_ids = [ + entity_entry.entity_id + for entity_entry in er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if ( + entity_entry.platform == DOMAIN + and entity_entry.device_id in stale_device_entry_ids + ) + ] + for entity_id in stale_entity_ids: + entity_registry.async_remove(entity_id) + + for device_entry_id in stale_device_entry_ids: + device_registry.async_remove_device(device_entry_id) + + if stale_entity_ids or stale_device_entry_ids: + _LOGGER.info( + "Removed %d unselected BeHome entities and %d devices from registry", + len(stale_entity_ids), + len(stale_device_entry_ids), + ) + + +def _behome_device_id_from_device_entry(device_entry: dr.DeviceEntry) -> str | None: + """Return the BeHome cloud device ID from a device registry entry.""" + for identifier_domain, identifier_value in device_entry.identifiers: + if identifier_domain == DOMAIN: + return str(identifier_value) + return None + + +def _schedule_remove_unselected_manual_devices( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Remove unselected devices after HA startup registry writes are stable.""" + if entry.options.get(CONF_SYNC_MODE) != SYNC_MODE_MANUAL: + return + + if hass.is_running: + _remove_unselected_manual_devices(hass, entry) + return + + def _async_remove_after_start(_event: Event) -> None: + _remove_unselected_manual_devices(hass, entry) + + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, _async_remove_after_start + ) + ) + + def _private_key_from_entry(entry: ConfigEntry) -> str | None: """Extract the private key from a current or legacy config entry.""" private_key = entry.data.get(CONF_PRIVATE_KEY) @@ -185,3 +313,8 @@ def _private_key_from_entry(entry: ConfigEntry) -> str | None: def _unique_id_from_private_key(private_key: str) -> str: """Return a stable, non-secret unique ID for a private key.""" return hashlib.sha256(private_key.encode("utf-8")).hexdigest() + + +def _entry_title_from_private_key(private_key: str) -> str: + """Return the config entry title for a BeHome account.""" + return f"BeHome ({private_key[-6:]})" diff --git a/custom_components/behome/config_flow.py b/custom_components/behome/config_flow.py index f91a1a1..0521644 100644 --- a/custom_components/behome/config_flow.py +++ b/custom_components/behome/config_flow.py @@ -9,12 +9,24 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from .api import BemfaAPI from .const import ( DOMAIN, CONF_PRIVATE_KEY, + CONF_SELECTED_DEVICES, + CONF_SYNC_MODE, + SYNC_MODE_AUTO, + SYNC_MODE_MANUAL, WECHAT_LOGIN_POLL_URL, WECHAT_QR_IMAGE_URL, WECHAT_QR_URL, @@ -33,6 +45,12 @@ class BeHomeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domai DOMAIN = DOMAIN VERSION = 1 + @staticmethod + @callback + def async_get_options_flow(config_entry: config_entries.ConfigEntry): + """Create the options flow.""" + return BeHomeOptionsFlow(config_entry) + def __init__(self) -> None: """Initialize the config flow.""" self._wechat_sid: str | None = None @@ -50,7 +68,7 @@ async def async_oauth_create_entry(self, data: dict) -> dict: if not private_key: return self.async_abort(reason="invalid_token") - return await self._async_create_private_key_entry(private_key, "BeHome") + return await self._async_create_private_key_entry(private_key) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -98,9 +116,7 @@ async def async_step_wechat_done( if not private_key: return self.async_abort(reason="wechat_not_scanned") - return await self._async_create_private_key_entry( - private_key, "BeHome (WeChat)" - ) + return await self._async_create_private_key_entry(private_key) async def async_step_oauth( self, user_input: dict[str, Any] | None = None @@ -117,9 +133,7 @@ async def async_step_manual( if user_input is not None: private_key = str(user_input.get(CONF_PRIVATE_KEY, "")).strip() if private_key: - return await self._async_create_private_key_entry( - private_key, "BeHome (Manual)" - ) + return await self._async_create_private_key_entry(private_key) errors["base"] = "empty_key" return self.async_show_form( @@ -224,15 +238,156 @@ def _private_key_from_oauth_data(data: dict[str, Any]) -> str | None: private_key = access_token[4:-4] return private_key if _UID_RE.match(private_key) else None - async def _async_create_private_key_entry( - self, private_key: str, title: str - ) -> dict[str, Any]: + async def _async_create_private_key_entry(self, private_key: str) -> dict[str, Any]: """Create a config entry for a Bemfa private key.""" unique_id = hashlib.sha256(private_key.encode("utf-8")).hexdigest() await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return self.async_create_entry( - title=title, + title=_entry_title_from_private_key(private_key), data={CONF_PRIVATE_KEY: private_key}, ) + + +class BeHomeOptionsFlow(config_entries.OptionsFlow): + """Handle BeHome options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize the options flow.""" + self.config_entry = config_entry + self._sync_mode = config_entry.options.get(CONF_SYNC_MODE, SYNC_MODE_AUTO) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Manage BeHome sync options.""" + return self.async_show_menu( + step_id="init", + menu_options=["auto_sync", "manual_devices"], + ) + + async def async_step_auto_sync( + self, user_input: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Enable automatic device sync.""" + return self._async_create_options_entry( + SYNC_MODE_AUTO, + self.config_entry.options.get(CONF_SELECTED_DEVICES, []), + ) + + async def async_step_manual_devices( + self, user_input: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Select devices to sync in manual mode.""" + private_key = _private_key_from_entry_data(self.config_entry.data) + if not private_key: + return self.async_abort(reason="invalid_key") + + if user_input is not None: + return self._async_create_options_entry( + SYNC_MODE_MANUAL, + user_input.get(CONF_SELECTED_DEVICES, []), + ) + + current_selected_devices = self.config_entry.options.get( + CONF_SELECTED_DEVICES, [] + ) + if not isinstance(current_selected_devices, list): + current_selected_devices = [] + current_selected_devices = _normalize_device_ids(current_selected_devices) + + devices = await self._async_get_devices(private_key) + device_options = _device_multi_select_options( + devices, current_selected_devices + ) + + schema = vol.Schema( + { + vol.Optional( + CONF_SELECTED_DEVICES, + default=current_selected_devices, + ): SelectSelector( + SelectSelectorConfig( + options=device_options, + mode=SelectSelectorMode.LIST, + multiple=True, + ) + ), + } + ) + + return self.async_show_form( + step_id="manual_devices", + data_schema=schema, + ) + + def _async_create_options_entry( + self, sync_mode: str, selected_devices: Any + ) -> dict[str, Any]: + """Create an options entry.""" + return self.async_create_entry( + title="", + data={ + CONF_SYNC_MODE: sync_mode, + CONF_SELECTED_DEVICES: _normalize_device_ids(selected_devices), + }, + ) + + async def _async_get_devices(self, private_key: str) -> list[dict[str, Any]]: + """Fetch devices for the options form.""" + session = async_get_clientsession(self.hass) + api = BemfaAPI(private_key, session) + return await api.get_devices() + + +def _private_key_from_entry_data(data: dict[str, Any]) -> str | None: + """Extract the private key from current or legacy config entry data.""" + private_key = data.get(CONF_PRIVATE_KEY) + if isinstance(private_key, str) and private_key: + return private_key + + token = data.get("token") + access_token = token.get("access_token") if isinstance(token, dict) else None + if isinstance(access_token, str) and len(access_token) > 8: + return access_token[4:-4] + + return None + + +def _entry_title_from_private_key(private_key: str) -> str: + """Return the config entry title for a BeHome account.""" + return f"BeHome ({private_key[-6:]})" + + +def _normalize_device_ids(value: Any) -> list[str]: + """Normalize selected device IDs from config flow data.""" + if not isinstance(value, list): + return [] + return [str(device_id) for device_id in value] + + +def _device_multi_select_options( + devices: list[dict[str, Any]], selected_device_ids: list[str] +) -> list[SelectOptionDict]: + """Build labels for the device multi-select field.""" + options: dict[str, SelectOptionDict] = {} + for device in devices: + device_id = str(device.get("deviceID") or "") + if not device_id: + continue + + name = str(device.get("name") or device.get("topic") or device_id) + topic = str(device.get("topic") or device_id) + options[device_id] = SelectOptionDict( + value=device_id, + label=f"{name} ({topic})", + ) + + for device_id in selected_device_ids: + options.setdefault( + device_id, + SelectOptionDict(value=device_id, label=f"已选择设备 ({device_id})"), + ) + + return list(options.values()) diff --git a/custom_components/behome/const.py b/custom_components/behome/const.py index d0487bd..7195c6a 100644 --- a/custom_components/behome/const.py +++ b/custom_components/behome/const.py @@ -44,3 +44,8 @@ # Configuration CONF_PRIVATE_KEY = "private_key" +CONF_SYNC_MODE = "sync_mode" +CONF_SELECTED_DEVICES = "selected_devices" + +SYNC_MODE_AUTO = "auto" +SYNC_MODE_MANUAL = "manual" diff --git a/custom_components/behome/manifest.json b/custom_components/behome/manifest.json index 9c0a121..855c43c 100644 --- a/custom_components/behome/manifest.json +++ b/custom_components/behome/manifest.json @@ -10,5 +10,5 @@ "issue_tracker": "https://github.com/bemfa/behome/issues", "loggers": ["custom_components.behome"], "requirements": [], - "version": "1.1.5" + "version": "1.2.0" } diff --git a/custom_components/behome/strings.json b/custom_components/behome/strings.json index 4eb1620..c9c904e 100644 --- a/custom_components/behome/strings.json +++ b/custom_components/behome/strings.json @@ -31,5 +31,27 @@ "error": { "empty_key": "用户私钥不能为空。" } + }, + "options": { + "step": { + "init": { + "title": "设备同步设置", + "description": "选择设备同步模式。自动模式会同步账号下所有支持的 BeHome 设备;手动模式会在下一步选择要同步的设备。", + "menu_options": { + "auto_sync": "自动同步所有设备", + "manual_devices": "手动选择同步设备" + } + }, + "manual_devices": { + "title": "选择同步设备", + "description": "只选择需要从 BeHome 同步到 Home Assistant 的设备。未选择且此前由本集成创建的设备和实体会被删除;不选择任何设备时会删除本集成创建的所有设备和实体。", + "data": { + "selected_devices": "手动同步设备" + } + } + }, + "abort": { + "invalid_key": "当前配置项中没有有效的用户私钥,无法读取设备列表。" + } } } diff --git a/custom_components/behome/translations/zh-Hans.json b/custom_components/behome/translations/zh-Hans.json index 4eb1620..c9c904e 100644 --- a/custom_components/behome/translations/zh-Hans.json +++ b/custom_components/behome/translations/zh-Hans.json @@ -31,5 +31,27 @@ "error": { "empty_key": "用户私钥不能为空。" } + }, + "options": { + "step": { + "init": { + "title": "设备同步设置", + "description": "选择设备同步模式。自动模式会同步账号下所有支持的 BeHome 设备;手动模式会在下一步选择要同步的设备。", + "menu_options": { + "auto_sync": "自动同步所有设备", + "manual_devices": "手动选择同步设备" + } + }, + "manual_devices": { + "title": "选择同步设备", + "description": "只选择需要从 BeHome 同步到 Home Assistant 的设备。未选择且此前由本集成创建的设备和实体会被删除;不选择任何设备时会删除本集成创建的所有设备和实体。", + "data": { + "selected_devices": "手动同步设备" + } + } + }, + "abort": { + "invalid_key": "当前配置项中没有有效的用户私钥,无法读取设备列表。" + } } }