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
161 changes: 157 additions & 4 deletions custom_components/ds_air/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry

from .const import CONF_GW, DEFAULT_GW, DOMAIN
from homeassistant.helpers import device_registry as dr, entity_registry as er

from .const import (
CONF_GW,
DEFAULT_GW,
DOMAIN,
MANUFACTURER,
)
from .descriptions import SENSOR_DESCRIPTORS
from .ds_air_service import Config, Service
from .ds_air_service.dao import migrate_legacy_sensor_links, migrate_legacy_unique_id

_LOGGER = logging.getLogger(__name__)

Expand All @@ -21,6 +28,141 @@
]


def _migrate_entity_registry_unique_ids(
hass: HomeAssistant, entry: ConfigEntry, gateway_id: str
) -> None:
entity_registry = er.async_get(hass)

for entity_entry in er.async_entries_for_config_entry(
entity_registry, entry.entry_id
):
if not isinstance(entity_entry.unique_id, str):
continue

new_unique_id = migrate_legacy_unique_id(
entity_entry.unique_id, gateway_id, SENSOR_DESCRIPTORS
)
if new_unique_id is None:
continue

conflict_entity_id = entity_registry.async_get_entity_id(
entity_entry.domain, entity_entry.platform, new_unique_id
)
if conflict_entity_id and conflict_entity_id != entity_entry.entity_id:
_LOGGER.warning(
"Unable to migrate entity %s unique_id to %s: already used by %s",
entity_entry.entity_id,
new_unique_id,
conflict_entity_id,
)
continue

entity_registry.async_update_entity(
entity_entry.entity_id, new_unique_id=new_unique_id
)


def _migrate_device_registry_identifiers(
hass: HomeAssistant, entry: ConfigEntry, gateway_id: str
) -> None:
device_registry = dr.async_get(hass)

for device_entry in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
identifiers = set(device_entry.identifiers)
new_identifiers = set()

for domain, identifier in identifiers:
if domain != DOMAIN:
new_identifiers.add((domain, identifier))
continue

new_identifier = migrate_legacy_unique_id(
identifier, gateway_id, SENSOR_DESCRIPTORS
)
if new_identifier is not None:
conflict_device = device_registry.async_get_device(
identifiers={(domain, new_identifier)}
)
if conflict_device and conflict_device.id != device_entry.id:
_LOGGER.warning(
"Unable to migrate device %s identifier to %s: already used by %s",
device_entry.id,
new_identifier,
conflict_device.id,
)
new_identifier = None
new_identifiers.add((domain, new_identifier or identifier))

if new_identifiers != identifiers:
device_registry.async_update_device(
device_entry.id, new_identifiers=new_identifiers
)


def _migrate_options_unique_ids(options: dict, gateway_id: str) -> tuple[dict, bool]:
links = options.get("link")
if not isinstance(links, list):
return options, False

changed = False
migrated_links = []
for link in links:
if not isinstance(link, dict):
migrated_links.append(link)
continue

migrated_link = dict(link)
climate_id = migrated_link.get("climate")
if isinstance(climate_id, str):
new_climate_id = migrate_legacy_unique_id(climate_id, gateway_id)
if new_climate_id is not None:
migrated_link["climate"] = new_climate_id
changed = True
migrated_links.append(migrated_link)

if not changed:
return options, False
return {**options, "link": migrated_links}, True


def _migrate_legacy_sensor_links(
hass: HomeAssistant, entry: ConfigEntry, service: Service
) -> None:
links = entry.options.get("link")
if not isinstance(links, list):
return

migrated_links, changed = migrate_legacy_sensor_links(links, service.get_aircons())
if changed:
hass.config_entries.async_update_entry(
entry, options={**entry.options, "link": migrated_links}
)


async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entries to gateway-scoped unique IDs."""
if entry.version > 2:
return False
if entry.version == 2:
return True

gateway_id = entry.entry_id
_migrate_entity_registry_unique_ids(hass, entry, gateway_id)
_migrate_device_registry_identifiers(hass, entry, gateway_id)

options, options_changed = _migrate_options_unique_ids(
dict(entry.options), gateway_id
)
update = {"version": 2}
if options_changed:
update["options"] = options

hass.config_entries.async_update_entry(entry, **update)
return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
host = entry.data[CONF_HOST]
Expand All @@ -31,11 +173,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("%s:%s %s %s", host, port, gw, scan_interval)

config = Config()
config.gateway_id = entry.entry_id
config.is_c611 = gw == DEFAULT_GW

device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer=MANUFACTURER,
model=gw,
name=entry.title,
)

service = Service()
hass.data[DOMAIN][entry.entry_id] = service
await hass.async_add_executor_job(service.init, host, port, scan_interval, config)
_migrate_legacy_sensor_links(hass, entry, service)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))

Expand Down Expand Up @@ -63,7 +216,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:


async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
# reference: https://developers.home-assistant.io/docs/device_registry_index/#removing-devices
return True
11 changes: 6 additions & 5 deletions custom_components/ds_air/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
get_mode_name,
)
from .ds_air_service import AirCon, AirConStatus, EnumControl, Service, display
from .ds_air_service.dao import build_aircon_device_name

_SUPPORT_FLAGS = (
ClimateEntityFeature.TARGET_TEMPERATURE
Expand Down Expand Up @@ -77,14 +78,13 @@ async def async_setup_entry(
climates = [DsAir(service, aircon) for aircon in service.get_aircons()]
async_add_entities(climates)
link = entry.options.get("link")
climate_by_unique_id = {climate.unique_id: climate for climate in climates}
sensor_temp_map: dict[str, list[DsAir]] = {}
sensor_humi_map: dict[str, list[DsAir]] = {}
if link is not None:
for i in link:
climate_name = i.get("climate")
if climate := next(
c for c in climates if c._device_info.alias == climate_name
):
climate_id = i.get("climate")
if climate := climate_by_unique_id.get(climate_id):
Comment on lines +86 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

对于已经配置了传感器绑定的现有用户,其绑定关系中存储的是空调的 alias(名称)而不是新的 unique_id。升级到此版本后,由于 climate_by_unique_id.get(climate_id) 会返回 None,这些已有的绑定关系将会静默失效。建议在此处增加一个基于 alias 匹配的兼容逻辑,以确保向后兼容性。

Suggested change
climate_id = i.get("climate")
if climate := climate_by_unique_id.get(climate_id):
climate_id = i.get("climate")
climate = climate_by_unique_id.get(climate_id)
if not climate:
climate = next((c for c in climates if c._device_info.alias == climate_id), None)
if climate:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

当前为了避免代码过度复杂化,没有加向后兼容的逻辑。如果后续讨论认为有这个必要,可以再加入完整的向后兼容代码。

if temp_entity_id := i.get("sensor_temp"):
sensor_temp_map.setdefault(temp_entity_id, []).append(climate)
climate.linked_temp_entity_id = temp_entity_id
Expand Down Expand Up @@ -146,8 +146,9 @@ def __init__(self, service: Service, aircon: AirCon):

self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=aircon.alias if "空调" in aircon.alias else f"{aircon.alias} 空调",
name=build_aircon_device_name(aircon.alias),
manufacturer=MANUFACTURER,
via_device=(DOMAIN, aircon.gateway_id),
)

async def async_added_to_hass(self) -> None:
Expand Down
37 changes: 28 additions & 9 deletions custom_components/ds_air/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult

from .const import CONF_GW, DEFAULT_GW, DEFAULT_HOST, DEFAULT_PORT, DOMAIN, GW_LIST
from .const import (
CONF_GW,
DEFAULT_GW,
DEFAULT_HOST,
DEFAULT_PORT,
DOMAIN,
GW_LIST,
get_default_gateway_name,
)

_LOGGER = logging.getLogger(__name__)

Expand All @@ -30,7 +38,7 @@ def _log(s: str) -> None:


class DsAirFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
VERSION = 2

def __init__(self):
self.user_input = {}
Expand All @@ -42,7 +50,10 @@ async def async_step_user(
if user_input is not None:
self.user_input.update(user_input)
if not user_input.get(CONF_SENSORS) or user_input.get("temp") is not None:
return self.async_create_entry(title="金制空气", data=self.user_input)
return self.async_create_entry(
title=get_default_gateway_name(),
data=self.user_input,
)
Comment on lines +53 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在配置多个网关时,默认的集成标题都是 "金制空气"。建议在默认标题中附带主机 IP 地址(如 金制空气 (192.168.1.x)),这样用户在集成页面中可以非常直观地识别和管理不同的网关。

                host = self.user_input.get(CONF_HOST)
                title = f"{get_default_gateway_name()} ({host})" if host else get_default_gateway_name()
                return self.async_create_entry(
                    title=title,
                    data=self.user_input,
                )

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

使用IP地址作为实体名称有些奇怪,实际上最好的方案是用金制空气APP中的名称或者MAC地址,不过相关方法目前没有实现。我也想过改成“智能网关”作为默认名,不过还涉及到一些本地化的修改,就暂时没动。


return self.async_show_form(
step_id="user",
Expand Down Expand Up @@ -88,7 +99,8 @@ def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self._config_entry = config_entry
self._config_data = []
self._climates: list[str] = [] # set in async_step_init
self._climates: dict[str, str] = {} # set in async_step_init
self._climate_ids: list[str] = [] # set in async_step_init
self._len: int = 0 # set in async_step_init
self._sensors_temp: dict[str, str] = {}
self._sensors_humi: dict[str, str] = {}
Expand All @@ -100,8 +112,13 @@ async def async_step_init(
) -> FlowResult:
"""Manage the options."""
service = self.hass.data[DOMAIN][self._config_entry.entry_id]
self._climates = [state.alias for state in service.get_aircons()]
self._len = len(self._climates)
host = self._config_entry.data[CONF_HOST]
self._climates = {
state.unique_id: f"{state.alias} ({host} {state.room_id}-{state.unit_id:02d})"
for state in service.get_aircons()
}
self._climate_ids = list(self._climates)
self._len = len(self._climate_ids)

sensors = self.hass.states.async_all("sensor")
self._sensors_temp = {
Expand Down Expand Up @@ -201,18 +218,20 @@ async def async_step_bind_sensors(
self._cur = self._cur + 1
if self._cur > (self._len - 1):
return self.async_create_entry(title="", data={"link": self._config_data})
cur_climate: str = self._climates[self._cur]
cur_climate: str = self._climate_ids[self._cur]
cur_links = self._config_entry.options.get("link", [])
cur_link = next(
(link for link in cur_links if link["climate"] == cur_climate), None
(link for link in cur_links if link.get("climate") == cur_climate), None
)
Comment on lines 223 to 225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

与气候实体绑定类似,在编辑选项(OptionsFlow)时,cur_links 中已有的绑定关系可能仍使用空调的 alias。如果不做兼容处理,选项表单将无法正确回显(pre-populate)已有的传感器绑定。建议在此处增加对 alias 的兼容匹配。

        service = self.hass.data[DOMAIN][self._config_entry.entry_id]
        aircon = next((ac for ac in service.get_aircons() if ac.unique_id == cur_climate), None)
        cur_link = next(
            (
                link for link in cur_links
                if link.get("climate") == cur_climate
                or (aircon and link.get("climate") == aircon.alias)
            ),
            None
        )

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

当前为了避免代码过度复杂化,没有加向后兼容的逻辑。如果后续讨论认为有这个必要,可以再加入完整的向后兼容代码。

cur_sensor_temp = cur_link.get("sensor_temp") if cur_link else None
cur_sensor_humi = cur_link.get("sensor_humi") if cur_link else None
return self.async_show_form(
step_id="bind_sensors",
data_schema=vol.Schema(
{
vol.Required("climate", default=cur_climate): vol.In([cur_climate]),
vol.Required("climate", default=cur_climate): vol.In(
{cur_climate: self._climates[cur_climate]}
),
vol.Optional("sensor_temp", default=cur_sensor_temp): vol.In(
self._sensors_temp
),
Expand Down
5 changes: 5 additions & 0 deletions custom_components/ds_air/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@
DEFAULT_PORT = 8008
DEFAULT_GW = "DTA117C611"
GW_LIST = ["DTA117C611", "DTA117B611"]
CN_GATEWAY_NAME = "金制空气"

MANUFACTURER = "Daikin Industries, Ltd."


def get_default_gateway_name() -> str:
return CN_GATEWAY_NAME


_MODE_NAME_LIST = [
HVACMode.COOL,
HVACMode.DRY,
Expand Down
1 change: 1 addition & 0 deletions custom_components/ds_air/ds_air_service/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
class Config:
gateway_id: str = ""
is_new_version: bool = False
is_c611: bool = True # 金制空气c611 or ds-air b611
Loading
Loading