Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ _BeHome(原巴法云)智能家居设备的 Home Assistant 集成。_
- 媒体播放器(电视)
- 空气净化器
- 传感器
- **自动同步设备**:自动导入账号下所有支持的设备
- **设备同步模式**:默认自动导入账号下所有支持的设备,也可改为手动选择同步设备
- **实时状态更新**:每 5 秒轮询设备状态
- **区域集成**:自动映射到 Home Assistant 区域

Expand Down Expand Up @@ -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 账号中新增了支持的设备,集成会在轮询时自动发现并创建对应实体。手动模式下,新设备只有被勾选后才会同步。

集成将:
- 根据设备类型为每个设备创建实体
Expand Down
10 changes: 6 additions & 4 deletions README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
145 changes: 139 additions & 6 deletions custom_components/behome/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,35 @@
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

from .const import (
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)
Expand Down Expand Up @@ -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,
)

Expand All @@ -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


Expand All @@ -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)
Expand All @@ -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:]})"
Loading
Loading