diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 496931f3..67fc9b5e 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -31,7 +31,7 @@ See the cover docs for the details and the known limits. - **Setup**: when `myhome.yaml` has gateway sections but none under this gateway's MAC, a warning names the entry MAC and the section keys that match no configured gateway (#106). - **Expose Home Assistant to the bus**: a declarative `expose` engine projects HA entities and the clock onto free SCS addresses: virtual WHO 1 / WHO 2 actuators that a wall control can drive (mirroring HA state back to its LEDs), WHO 4 virtual temperature probes, WHO 18 virtual energy meters and the WHO 13 bus clock. Custom actions run as Home Assistant scripts (#750). - **Covers**: `class:` in `myhome.yaml` now reaches WHO 2 covers (a `curtain` is no longer shown as a shutter; #39); Venetian blind slat tilt as timed slat pulses (`slat_tilt`, `slat_time`) (#508); impulse garage and gate covers on WHO 1 relays with optional safety approval, door-entry strike locks and doorbell events (#667); native broadcast for general covers behind an interface (#679); a cover stop overtakes other queued frames (#694); cover kinematics split into pluggable strategies (#739). -- **Lighting**: 2-digit Area 0 point addresses `01`–`09` (#713); ON dimmers on a fresh install are probed for brightness support (#742); timed turn-on uses the OWNd builders (#659); lights the startup sweep already reported are not polled again (#701); area and group re-syncs only skip once every member has echoed, counting only the members' own echoes in the commanded state (#740, #749). +- **Lighting**: 2-digit Area 0 point addresses `01`–`09` (#713); `where: "0004"` in `myhome.yaml` is resolved to `04`, the spelling the gateways accept and report, with a one-time warning, and registry entries created under the 4-digit spelling are moved to it; the duplicate entity and device that bus discovery had created under `04` are removed, their area, labels, icon and custom name carried over to the configured ones where those have none, and a light that bus discovery had created at the address of a WHO 1 switch or impulse cover goes with its lock / unlock buttons; the `A` / `PL` state attributes of such a light, switch or cover read `0` / `4` instead of `00` / `04`, as on bus-discovered entities (#147; the `0809` of #135 is a point of area 8, not of area 0, and stays as typed); ON dimmers on a fresh install are probed for brightness support (#742); timed turn-on uses the OWNd builders (#659); lights the startup sweep already reported are not polled again (#701); area and group re-syncs only skip once every member has echoed, counting only the members' own echoes in the commanded state (#740, #749). - **Thermoregulation (WHO 4)**: each zone asks for its actuators (dimension 20) after the startup sweep and after a reconnect, so `hvac_action` is known at start; the new `actuators` attribute lists them (#25, #44); zone humidity is not polled on F450, F453, F453AV, F454, H4684, MH200, MH200N and MH202, where the emulated firmware did not forward `*#4**60##` (own-firmware-oracle `findings/thermo-zone-requests.md`, emulated firmware; an OWNd profile flag `supports_heating_humidity_request` overrides the table); season frames, a generic setpoint mode and the central unit's season (#657, #725); holiday and vacation program attributes (#689); zone humidity remembered and queried at startup (#712); F461 zones polled, AUTO on standalone zones, fan coils per season, season kept across restarts (#714, #715); `hvac_action` derived from the season when the actuator carries no heating flag (#721); standalone default follows the plant (#698); 10 s command timeouts on the F455 removed (#675); migrated to the `native_*` temperature API (#665). - **Sound system**: tuner frequency, preset and RDS read from WHO 22, with optional per-frequency pictures (#695); sound source commands and wall transport steps (#651); amplifiers no longer woken by volume status reports (#670); one decision engine for the power state (#683); an idle decoder can be claimed from a parked group (#723). - **Scenarios**: WHO 25 Scenario Plus commands and events (#671); WHO 17 scene events (#656). diff --git a/custom_components/myhome/cover_calibration.py b/custom_components/myhome/cover_calibration.py index 84a274b1..d2953576 100644 --- a/custom_components/myhome/cover_calibration.py +++ b/custom_components/myhome/cover_calibration.py @@ -88,10 +88,18 @@ async def async_stop_cover_calibration(hass: Any = None, gateway_mac: str | None def _stored_calibration(config_entry: Any, device_id: str) -> dict[str, Any] | None: - """Return the persisted calibration for a cover, if any.""" + """Return the persisted calibration for a cover, if any. + + Records are keyed by the canonical device id (`04`, `04#4#01`); a record a + migration could not move is still found under its 4-digit spelling (#147). + """ options = getattr(config_entry, "options", None) or {} stored = options.get(CONF_COVER_TRAVEL_TIMES) or {} entry = stored.get(str(device_id)) + if entry is None: + base, sep, rest = str(device_id).partition("#") + if len(base) == 2 and base[0] == "0" and base[1] != "0": + entry = stored.get(f"00{base}{sep}{rest}") return dict(entry) if isinstance(entry, dict) else None diff --git a/custom_components/myhome/legacy_yaml.py b/custom_components/myhome/legacy_yaml.py index 55a43be8..45374790 100644 --- a/custom_components/myhome/legacy_yaml.py +++ b/custom_components/myhome/legacy_yaml.py @@ -20,13 +20,17 @@ CONF_BUS_INTERFACE, CONF_EXPOSE, CONF_FILE_PATH, + CONF_MEMBERS, CONF_PLATFORMS, + CONF_WHERE, + CONF_WHO, CONF_ZONE, DOMAIN, LOGGER, PLATFORMS, ) from .repairs import async_create_legacy_yaml_imported_issue +from .where_grammar import canonical_point_address def _read_legacy_yaml( @@ -108,6 +112,55 @@ def _restore_shared_devices( return restored +def canonicalise_imported_platforms( + snapshot: dict[str, dict[str, Any]], +) -> tuple[dict[str, dict[str, Any]], list[str]]: + """The stored copy with WHO 1 / WHO 2 point addresses in their canonical spelling (#147). + + A copy taken by an earlier beta holds ``where: "0004"`` under the key ``1-0004``, + the spelling the validator rewrites to ``04`` today; applied as stored it would + recreate the ``0004`` entity next to the migrated ``04`` one on every boot. The + ``where``, the ``members`` and the device key are rewritten in a new copy (the + stored one is not touched). When the copy already has a device under the canonical + key, that one stays and the 4-digit twin is dropped with a WARNING. Returns the new + copy and one ``plat: old -> new`` line per rewritten device. + """ + rewritten: list[str] = [] + result: dict[str, dict[str, Any]] = {} + for plat, devices in snapshot.items(): + new_devices: dict[str, Any] = {} + for d_id, cfg in devices.items(): + who = str(cfg.get(CONF_WHO, "")) if isinstance(cfg, dict) else "" + if who not in ("1", "2") or not isinstance(cfg.get(CONF_WHERE), str): + new_devices[d_id] = cfg + continue + where = cfg[CONF_WHERE] + canonical = canonical_point_address(where) + members = cfg.get(CONF_MEMBERS) + new_members = ( + [canonical_point_address(str(m)) for m in members] if isinstance(members, list) else members + ) + if canonical == where and new_members == members: + new_devices[d_id] = cfg + continue + new_cfg = {**cfg, CONF_WHERE: canonical} + if new_members != members: + new_cfg[CONF_MEMBERS] = new_members + old_prefix = f"{who}-{where}" + new_id = f"{who}-{canonical}{d_id[len(old_prefix):]}" if d_id.startswith(old_prefix) else d_id + if new_id != d_id and (new_id in devices or new_id in new_devices): + LOGGER.warning( + "Imported copy of myhome.yaml, %s: dropping '%s' (where '%s'), the copy already " + "has '%s' under the canonical spelling", + plat, d_id, where, new_id, + ) + continue + new_devices[new_id] = new_cfg + rewritten.append(f"{plat}: {d_id} -> {new_id}") + result[plat] = new_devices + return result, rewritten + + def migrate_legacy_yaml_to_entry_options( hass: HomeAssistant, entry: ConfigEntry, @@ -120,7 +173,8 @@ def migrate_legacy_yaml_to_entry_options( keeps its latest content rather than the one of the upgrade. Returns whether the options changed. """ - snapshot = _snapshot(yaml_platforms) + # The validator already spells point addresses canonically; this guards the copy. + snapshot, _ = canonicalise_imported_platforms(_snapshot(yaml_platforms)) previous = entry.options.get(CONF_MIGRATED_YAML_PLATFORMS) if snapshot == previous or (previous is None and not any(snapshot.values())): return False @@ -139,13 +193,29 @@ def migrate_legacy_yaml_to_entry_options( def _apply_imported_copy( + hass: HomeAssistant, entry: ConfigEntry, configured_platforms: dict[str, dict[str, dict[str, Any]]], ) -> None: - """Fall back to the copy of myhome.yaml imported into the entry options.""" + """Fall back to the copy of myhome.yaml imported into the entry options. + + A copy with 4-digit area-0 points is moved to the canonical spelling before it + is applied, and the moved copy is stored, so the registry migration that follows + and the platforms see one and the same address (#147). + """ imported = entry.options.get(CONF_MIGRATED_YAML_PLATFORMS) if not isinstance(imported, dict) or not any(imported.values()): return + imported, rewritten = canonicalise_imported_platforms(imported) + if rewritten: + hass.config_entries.async_update_entry( + entry, options={**entry.options, CONF_MIGRATED_YAML_PLATFORMS: imported} + ) + LOGGER.info( + "Imported copy of myhome.yaml for gateway %s: %d point address(es) moved to the " + "canonical spelling (%s)", + entry.data.get(CONF_MAC), len(rewritten), "; ".join(rewritten), + ) _apply_yaml_platforms(_restore_shared_devices(imported), configured_platforms) LOGGER.info( "Using the imported copy of myhome.yaml for gateway %s: the file is missing, " @@ -215,7 +285,7 @@ async def load_legacy_myhome_yaml( if not await _load_live_yaml( hass, entry, configured_platforms, platforms, configured_expose ): - _apply_imported_copy(entry, configured_platforms) + _apply_imported_copy(hass, entry, configured_platforms) async def _load_live_yaml( diff --git a/custom_components/myhome/migrate.py b/custom_components/myhome/migrate.py index 01791b52..037c63f3 100644 --- a/custom_components/myhome/migrate.py +++ b/custom_components/myhome/migrate.py @@ -1,6 +1,7 @@ """Config entry, entity registry, and device registry migration and cleanup helpers.""" from __future__ import annotations +import re from typing import Any, Protocol from homeassistant.config_entries import ConfigEntry @@ -13,7 +14,15 @@ entity_registry as er, ) -from .const import DOMAIN, LOGGER +from .const import ( + CONF_COVER_TRAVEL_TIMES, + CONF_WHERE, + CONF_WHO, + DOMAIN, + LOGGER, + SUBENTRY_TYPE_EXPOSE, +) +from .where_grammar import canonical_point_address, canonical_point_spelling class GatewayProtocol(Protocol): @@ -295,6 +304,301 @@ def migrate_entry_and_registries( except ValueError: pass + try: + migrate_canonical_point_addresses(hass, entry) + except Exception as err: # pylint: disable=broad-except # a registry problem must not block the setup + LOGGER.warning("Could not migrate 4-digit point addresses to their canonical spelling: %s", err) + + +# Platforms whose unique ids may carry a WHO 1 / WHO 2 point address: +# {mac}-{who}-{where}[#4#{iface}][-{suffix}]. The WHO in the id is the predicate: a WHO 1 +# motion / illuminance sensor ({mac}-1-0004-motion) is a point, a WHO 25 CEN button +# ({mac}-25-0004) or a WHO 4 probe ({mac}-4-0004-temperature) is not and does not match. +_POINT_ADDRESS_DOMAINS = ("light", "switch", "cover", "button", "binary_sensor", "sensor") +_POINT_ADDRESS_ID = re.compile(r"^(?P[12])-(?P\d{4})(?P(?:[#-].*)?)$") +# A WHO 1 area-0 point already in its canonical spelling (1-04, 1-09#4#01). +_CANONICAL_AREA0_ID = re.compile(r"^1-0[1-9](?:#.*)?$") +# A WHO 1 motion / illuminance entity at an area-0 point, after the rename or already canonical +# (1-04-motion, 1-04#4#01-illuminance). Bus discovery spells the same sensor without the WHO: +# {mac}-04-motion / {mac}-04-illuminance (only its device identifier carries {mac}-1-04). +_WHO1_SENSOR_ID = re.compile(r"^1-(?P0[1-9](?:#[^-]*)?)(?P-.+)$") + + +def _canonical_point_id(after_mac: str) -> str | None: + """``1-0004`` -> ``1-04`` (also ``2-0009#4#01``, ``1-0004-disable``); None when nothing is rewritten.""" + if (match := _POINT_ADDRESS_ID.match(after_mac)) is None: + return None + # Silent: the registry spelling is not something the user can fix in myhome.yaml; + # validate.py warns once about the yaml spelling itself. + canonical = canonical_point_spelling(match["where"]) + if canonical == match["where"]: + return None + return f"{match['who']}-{canonical}{match['tail']}" + + +def migrate_canonical_point_addresses(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Move entities and devices of 4-digit point spellings (``0004``) to the canonical one (``04``). + + myhome.yaml now resolves ``0004`` to ``04`` (#147). The entity created under the old + spelling keeps its entity_id, name and area under the new unique id; a duplicate that bus + discovery created under the canonical spelling in this config entry is removed in its favour, + and only once the rename has succeeded (the registry refuses a rename to a unique id in use, + so the duplicate is taken out first and put back if the rename fails). A duplicate held by + another config entry is a collision: nothing is touched and a WARNING says so. Running the + migration again finds nothing to do. + """ + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + mac = dr.format_mac(entry.data[CONF_MAC]) + prefix = f"{mac}-" + + for reg_entry in er.async_entries_for_config_entry(entity_registry, entry.entry_id): + if reg_entry.domain not in _POINT_ADDRESS_DOMAINS or not reg_entry.unique_id.startswith(prefix): + continue + if (new_tail := _canonical_point_id(reg_entry.unique_id[len(prefix):])) is None: + # Already canonical (an earlier run renamed it, or the yaml said 04 from the start): + # the twin cleanups below still apply. + if reg_entry.domain in ("switch", "cover") and _CANONICAL_AREA0_ID.match(reg_entry.unique_id[len(prefix):]): + _remove_ghost_light(entity_registry, entry, reg_entry.entity_id, reg_entry.unique_id) + elif reg_entry.domain in ("binary_sensor", "sensor"): + _merge_bus_sensor_twin(entity_registry, entry, reg_entry.domain, reg_entry.entity_id, prefix, reg_entry.unique_id) + continue + new_unique_id = f"{prefix}{new_tail}" + duplicate_id = entity_registry.async_get_entity_id(reg_entry.domain, DOMAIN, new_unique_id) + duplicate = entity_registry.async_get(duplicate_id) if duplicate_id else None + if duplicate is not None and duplicate.config_entry_id != entry.entry_id: + LOGGER.warning( + "Not migrating %s to unique_id %s: %s of another config entry already holds it", + reg_entry.entity_id, new_unique_id, duplicate.entity_id, + ) + continue + if duplicate is not None: + # The bus-created twin of this config entry: out of the way for the rename. + entity_registry.async_remove(duplicate.entity_id) + try: + entity_registry.async_update_entity(reg_entry.entity_id, new_unique_id=new_unique_id) + except ValueError as err: + LOGGER.warning("Could not migrate %s to unique_id %s: %s", reg_entry.entity_id, new_unique_id, err) + if duplicate is not None: + # Put the twin back as it was (the registry restores a removed entity's + # entity_id, name, icon, area and labels on re-creation). + entity_registry.async_get_or_create( + duplicate.domain, + duplicate.platform, + duplicate.unique_id, + config_entry=entry, + device_id=duplicate.device_id, + original_name=duplicate.original_name, + ) + continue + LOGGER.info("Migrated %s to unique_id %s", reg_entry.entity_id, new_unique_id) + if duplicate is not None: + _carry_entity_customisations(entity_registry, reg_entry.entity_id, duplicate) + LOGGER.info("Removed %s, a duplicate of %s under the canonical address", duplicate.entity_id, reg_entry.entity_id) + if new_tail.startswith("1-") and reg_entry.domain in ("switch", "cover"): + _remove_ghost_light(entity_registry, entry, reg_entry.entity_id, new_unique_id) + elif reg_entry.domain in ("binary_sensor", "sensor"): + _merge_bus_sensor_twin(entity_registry, entry, reg_entry.domain, reg_entry.entity_id, prefix, new_unique_id) + + _migrate_expose_subentries(hass, entry) + _migrate_cover_travel_times(hass, entry) + + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + identifiers = set(device.identifiers) + renamed = { + ident: (DOMAIN, f"{prefix}{new_tail}") + for ident in identifiers + if ident[0] == DOMAIN + and str(ident[1]).startswith(prefix) + and (new_tail := _canonical_point_id(str(ident[1])[len(prefix):])) is not None + } + if not renamed: + continue + twins = [ + other + for new_ident in renamed.values() + if (other := _device_for_identifier(device_registry, entry, new_ident)) is not None + and other.id != device.id + ] + # The registry refuses an identifier another device holds, so the bus-created twin + # goes first: its entities move to the surviving device and it is removed, to come + # back as it was (same id, area, labels, user-given name) if the rename is refused. + handed_over: dict[str, list[str]] = {} + for other in twins: + handed_over[other.id] = [ + other_entity.entity_id + for other_entity in er.async_entries_for_device(entity_registry, other.id, include_disabled_entities=True) + ] + for entity_id in handed_over[other.id]: + entity_registry.async_update_entity(entity_id, device_id=device.id) + device_registry.async_remove_device(other.id) + try: + device_registry.async_update_device( + device.id, new_identifiers=(identifiers - set(renamed)) | set(renamed.values()) + ) + except Exception as err: # pylint: disable=broad-except + LOGGER.warning("Could not migrate device %s to the canonical address: %s", device.id, err) + for other in twins: + restored = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers=set(other.identifiers) + ) + for entity_id in handed_over[other.id]: + entity_registry.async_update_entity(entity_id, device_id=restored.id) + continue + for other in twins: + # The twin's area, labels and user-given name survive on the configured device. + carried: dict[str, Any] = {} + if device.area_id is None and other.area_id is not None: + carried["area_id"] = other.area_id + if device.name_by_user is None and other.name_by_user is not None: + carried["name_by_user"] = other.name_by_user + if set(other.labels) - set(device.labels): + carried["labels"] = set(device.labels) | set(other.labels) + if carried: + device_registry.async_update_device(device.id, **carried) + LOGGER.info("Removed device %s, a duplicate of %s under the canonical address", other.id, device.id) + + +def _carry_entity_customisations( + entity_registry: er.EntityRegistry, survivor_entity_id: str, removed: er.RegistryEntry +) -> None: + """The user's customisations of the working bus entity survive where the surviving one has none.""" + survivor = entity_registry.async_get(survivor_entity_id) + if survivor is None: + return + carried: dict[str, Any] = {} + if survivor.area_id is None and removed.area_id is not None: + carried["area_id"] = removed.area_id + if survivor.icon is None and removed.icon is not None: + carried["icon"] = removed.icon + if survivor.name is None and removed.name is not None: + carried["name"] = removed.name + if not survivor.labels and removed.labels: + carried["labels"] = set(removed.labels) + if carried: + entity_registry.async_update_entity(survivor_entity_id, **carried) + + +def _merge_bus_sensor_twin( + entity_registry: er.EntityRegistry, entry: ConfigEntry, domain: str, owner_entity_id: str, prefix: str, unique_id: str +) -> None: + """Remove this entry's bus-discovered twin of a WHO 1 motion / illuminance entity at an area-0 point. + + The yaml entity is `{mac}-1-04-motion` (the device key); the one bus discovery created for + the frames on `04` is `{mac}-04-motion` (binary_sensor.py `build_motion`, sensor.py + `build_illuminance`: the WHO only sits in its device identifier). Both answer to the + address `04`, so the platform's restore keeps whichever the registry lists first and + removes the other as a duplicate, which could be the user's configured entity. The yaml + entity is the one to keep; the twin's unset customisations are carried over first. + """ + if (match := _WHO1_SENSOR_ID.match(unique_id[len(prefix):])) is None: + return + twin_unique_id = f"{prefix}{match['where']}{match['suffix']}" + twin_id = entity_registry.async_get_entity_id(domain, DOMAIN, twin_unique_id) + twin = entity_registry.async_get(twin_id) if twin_id else None + if twin is None or twin.config_entry_id != entry.entry_id: + return + _carry_entity_customisations(entity_registry, owner_entity_id, twin) + entity_registry.async_remove(twin.entity_id) + LOGGER.info("Removed %s, the bus-discovered twin of %s (unique_id %s)", twin.entity_id, owner_entity_id, twin_unique_id) + + +def _remove_ghost_light( + entity_registry: er.EntityRegistry, entry: ConfigEntry, owner_entity_id: str, unique_id: str +) -> None: + """Remove this entry's light at the canonical address of a migrated WHO 1 switch or cover. + + The yaml device only knew `0004`, so frames on `04` discovered a light there, with + its lock / unlock buttons. A switch or cover address never holds a light (light.py + removes it at setup, but with `registry.async_remove`, which would leave the + buttons orphaned on the merged device); the same-domain duplicate lookup above + does not see it because unique ids are scoped per domain. + """ + ghost_id = entity_registry.async_get_entity_id("light", DOMAIN, unique_id) + ghost = entity_registry.async_get(ghost_id) if ghost_id else None + if ghost is None or ghost.config_entry_id != entry.entry_id: + return + # discovery imports myhome_device, which imports this module: resolved at call time. + from .discovery import COMPANION_SUFFIXES # noqa: PLC0415 + + entity_registry.async_remove(ghost.entity_id) + removed = 0 + for suffix in COMPANION_SUFFIXES: + button_id = entity_registry.async_get_entity_id("button", DOMAIN, f"{unique_id}{suffix}") + if button_id is not None: + entity_registry.async_remove(button_id) + removed += 1 + LOGGER.info( + "Removed %s and %d button(s): a light bus discovery created at the address of %s", + ghost.entity_id, removed, owner_entity_id, + ) + + +def _migrate_expose_subentries(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Move WHO 1 / WHO 2 ``expose`` subentries at ``0001``..``0009`` to ``01``..``09`` (#147). + + `normalize_expose_where()` spells them canonically at load time as well; the + stored data and the title follow so the UI shows the address the bus uses. + """ + expose_subentries = [ + subentry for subentry in getattr(entry, "subentries", {}).values() if subentry.subentry_type == SUBENTRY_TYPE_EXPOSE + ] + taken = { + (str(sub.data.get(CONF_WHO)), str(sub.data.get(CONF_WHERE))): sub + for sub in expose_subentries + if str(sub.data.get(CONF_WHO)) in ("1", "2") + } + for subentry in expose_subentries: + data = dict(subentry.data) + if str(data.get(CONF_WHO)) not in ("1", "2") or not isinstance(data.get(CONF_WHERE), str): + continue + canonical = canonical_point_address(data[CONF_WHERE]) + if canonical == data[CONF_WHERE]: + continue + if (holder := taken.get((str(data[CONF_WHO]), canonical))) is not None: + # Two bindings for one bus address: the canonical one is the one that worked. + LOGGER.warning( + "Removing expose subentry %s (%s): %s (%s) already exposes WHO %s WHERE %s", + subentry.subentry_id, subentry.title, holder.subentry_id, holder.title, data[CONF_WHO], canonical, + ) + hass.config_entries.async_remove_subentry(entry, subentry.subentry_id) + continue + title = subentry.title.replace(f"WHERE {data[CONF_WHERE]} ", f"WHERE {canonical} ") + hass.config_entries.async_update_subentry( + entry, subentry, data={**data, CONF_WHERE: canonical}, title=title + ) + LOGGER.info("Migrated expose subentry %s from WHERE %s to %s", subentry.subentry_id, data[CONF_WHERE], canonical) + + +def _migrate_cover_travel_times(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Re-key persisted cover calibrations from ``0004`` / ``0004#4#01`` to ``04`` / ``04#4#01`` (#147). + + `entry.options["cover_travel_times"]` is keyed by the cover's device id; once the + cover is addressed canonically its measured travel times and `slat_time` would be + orphaned. A record already stored under the canonical key belongs to the cover + that worked on the bus and wins; the 4-digit one is then dropped. + """ + stored = entry.options.get(CONF_COVER_TRAVEL_TIMES) + if not isinstance(stored, dict): + return + rekeyed = {key: record for key, record in stored.items() if canonical_point_address(str(key)) == str(key)} + changed = False + for key, record in stored.items(): + canonical = canonical_point_address(str(key)) + if canonical == str(key): + continue + changed = True + if canonical in rekeyed: + LOGGER.info( + "Dropping the cover calibration stored under %s: %s already has one under the canonical address", + key, canonical, + ) + continue + rekeyed[canonical] = record + LOGGER.info("Migrated the cover calibration of %s to the canonical address %s", key, canonical) + if changed: + hass.config_entries.async_update_entry(entry, options={**entry.options, CONF_COVER_TRAVEL_TIMES: rekeyed}) + def prune_stale_devices( hass: HomeAssistant, diff --git a/custom_components/myhome/validate.py b/custom_components/myhome/validate.py index fb55028e..387d527a 100644 --- a/custom_components/myhome/validate.py +++ b/custom_components/myhome/validate.py @@ -131,11 +131,13 @@ COVER_STRATEGIES, DEFAULT_MIN_MOTOR_PULSE, DEFAULT_SLAT_TIME, + LOGGER, MAX_MIN_POSITION_DELTA, MAX_SLAT_TIME, MIN_SLAT_TIME, TYPE_IMPULSE_RELAY, ) +from .where_grammar import canonical_point_spelling def format_mac(address: object) -> str: @@ -211,9 +213,37 @@ def __repr__(self): # type: ignore return "Where(%s, msg=%r)" % ("String", self.msg) +# 4-digit spellings already warned about, so each rewritten address is logged once. +_warned_point_rewrites: set[str] = set() + + +def canonical_point_where(where: str) -> str: + """The spelling of a point address that the gateways accept and report (#147). + + An area-0 point with PL 1..9 has a 2-digit form: ``0004`` is ``04``. Only the MH202 + accepts the 4-digit spelling of such a point (``*#1*0001##`` is refused everywhere + else) and every gateway reports it in two digits, so a light configured as ``0004`` + never sees its own status. Any other 4-digit input (``0015``, ``0109``, ``0209``, + ``1015``) is left as typed, as OWNd does (OWNd#123), and so is anything that is not + a 4-digit point; the ``0809`` of #135 (area 8, PL 9) is one of those, so #135 stays + open. A rewrite is logged once per address: this is the myhome.yaml path, and the + user can fix the spelling there. + """ + canonical = canonical_point_spelling(where) + if canonical != where and where not in _warned_point_rewrites: + _warned_point_rewrites.add(where) + LOGGER.warning( + "WHERE '%s' is the 4-digit spelling of the point A=%s PL=%s; using '%s', the form the " + "gateways accept and report. Write '%s' in myhome.yaml to silence this warning.", + where, where[1], where[3], canonical, canonical, + ) + return canonical + + class PointToPoint: - def __init__(self, msg: str | None = None) -> None: + def __init__(self, msg: str | None = None, canonical: bool = True) -> None: self.msg = msg + self.canonical = canonical def __call__(self, v: typing.Any) -> typing.Any: if isinstance(v, str) and v.isdigit(): @@ -222,7 +252,7 @@ def __call__(self, v: typing.Any) -> typing.Any: _a = v[0 : _length // 2] _pl = v[_length // 2 :] if int(_a) >= 0 and int(_a) <= 10 and int(_pl) >= 0 and int(_pl) <= 15: - return f"{_a}{_pl}" + return canonical_point_where(f"{_a}{_pl}") if self.canonical else f"{_a}{_pl}" else: raise Invalid(f"Invalid WHERE {v}, A must be [0-10] and PL must be [0-15].") else: @@ -376,6 +406,7 @@ def __call__(self, data): # type: ignore elif data[device][CONF_WHO] != "1": raise Invalid("invalid sensor class for selected who") if CONF_WHERE in data[device]: + _canonical_who1_point(data[device]) # the WHO default above is in by now interface = data[device].get(CONF_BUS_INTERFACE) routing = f"{BUS_ROUTING}{interface}" if interface is not None else "" _rekeyed_data[f"{data[device][CONF_WHO]}-{data[device][CONF_WHERE]}{routing}"] = data[device] @@ -619,9 +650,20 @@ def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.An cover_schema = All(cover_schema, _validate_cover_members, _validate_impulse_covers) # type: ignore[assignment] +def _canonical_who1_point(device: dict[str, typing.Any]) -> dict[str, typing.Any]: + """A WHO 1 sensor WHERE is a WHO 1 point: ``0004`` is ``04`` (#147). + + `SpecialWhere()` sees one field and cannot know the WHO; this runs on the + whole mapping once the WHO default is in. Other WHO keep their grammar. + """ + if str(device.get(CONF_WHO)) == "1" and isinstance(device.get(CONF_WHERE), str): + device[CONF_WHERE] = canonical_point_where(device[CONF_WHERE]) + return device + + binary_sensor_schema = MyHomeDeviceSchema( { - Required(str): { + Required(str): All({ Optional(CONF_WHO, default="25"): In(["1", "9", "25"]), Required(CONF_WHERE): All(Coerce(str), SpecialWhere()), # type: ignore Required(CONF_NAME): str, @@ -685,7 +727,7 @@ def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.An ), Optional(CONF_MANUFACTURER, default="BTicino S.p.A."): str, Optional(CONF_DEVICE_MODEL): Coerce(str), - } + }, _canonical_who1_point) } ) @@ -732,7 +774,7 @@ def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.An { Required(str): { Optional(CONF_WHO, default="5"): "5", - Required(CONF_WHERE): All(Coerce(str), Any(General(), Area(), Group(), PointToPoint(), SpecialWhere())), # type: ignore + Required(CONF_WHERE): All(Coerce(str), Any(General(), Area(), Group(), PointToPoint(canonical=False), SpecialWhere())), # type: ignore Required(CONF_NAME): str, Optional(CONF_ENTITY_NAME): str, Optional(CONF_MANUFACTURER, default="BTicino S.p.A."): str, diff --git a/custom_components/myhome/where_grammar.py b/custom_components/myhome/where_grammar.py index 22a1f590..282a8f3a 100644 --- a/custom_components/myhome/where_grammar.py +++ b/custom_components/myhome/where_grammar.py @@ -18,6 +18,9 @@ ``*4*4001#Z*0#N##`` (zone Z calls pump N) carries the zone in WHAT. The optional ``4-`` prefix (legacy ids) and ``#`` prefix are tolerated. + +The pure WHO 1 / WHO 2 point-spelling helpers (``0004`` -> ``04``, #147) live +here too, so every module that stores or compares an address spells it alike. """ from __future__ import annotations @@ -230,6 +233,23 @@ def is_pump(message: Any) -> bool: return str(getattr(message, "where", None)) == "0" and bool(where_param(message)) +def canonical_point_spelling(where: str) -> str: + """``0004`` -> ``04``, silently; anything else as typed. + + The pure rewrite behind :func:`canonical_point_where`, for stored spellings + (registry ids, entry options) that nobody typed in myhome.yaml. + """ + if len(where) == 4 and where.isdigit() and where[:3] == "000" and where[3] != "0": + return f"{where[1]}{where[3]}" + return where + + +def canonical_point_address(address: str) -> str: + """:func:`canonical_point_spelling` of the WHERE before an optional ``#4#II`` suffix.""" + base, sep, rest = address.partition("#") + return f"{canonical_point_spelling(base)}{sep}{rest}" + + def normalize_expose_where(who: int, where: str | int | None) -> str: """Return canonical WHERE string for an expose binding on ``who``.""" raw = str(where if where is not None else "").strip() @@ -242,7 +262,10 @@ def normalize_expose_where(who: int, where: str | int | None) -> str: return bare return clean if who in (1, 2): - return normalize_where(raw) or raw + # OWNd keeps `0004` as typed (OWNd#123); the gateways only accept and + # report an area-0 point in two digits, so the MyHOME boundary spells + # it `04` (#147). Silent: the yaml / UI value is validated elsewhere. + return canonical_point_address(normalize_where(raw) or raw) return raw diff --git a/docs/configuration/binary-sensors.md b/docs/configuration/binary-sensors.md index 22e1f332..a152f7d1 100644 --- a/docs/configuration/binary-sensors.md +++ b/docs/configuration/binary-sensors.md @@ -16,6 +16,7 @@ In v2, setup and management are **100% UI-first**: binary sensors are automatica ### 2. Motion / PIR Sensors (WHO = 1) * **Hardware**: Legrand `048822`, BTicino `BMSE1001` or standard SCS ceiling/wall motion sensors configured in scenario mode. * **Operation**: When movement is detected, the sensor broadcasts an event frame on WHO 1 that sets the binary sensor to `on` (Detected), returning to `off` (Clear) when timeout expires. +* **Address**: a WHO 1 point address; an area-0 point with PL 1..9 is spelled in two digits (`04`), so `where: "0004"` in `myhome.yaml` is resolved to `04` and a `…-1-0004-motion` entity is moved to `…-1-04-motion` at startup (#147). ### 3. Auxiliary Alarm Sensors (WHO = 9) * **Hardware**: Auxiliary sensors, technical transmitters (water leak, methane gas), or peripheral contacts connected to the burglar alarm central unit. diff --git a/docs/configuration/lights.md b/docs/configuration/lights.md index 0fe77f96..a37e80c6 100644 --- a/docs/configuration/lights.md +++ b/docs/configuration/lights.md @@ -11,7 +11,7 @@ In v2, setup and management are **100% UI-first**: lighting fixtures are automat When your gateway connects to Home Assistant: 1. **Dynamic Bus Discovery**: The integration listens to OpenWebNet `WHO = 1` frames and scans active addresses. -2. **Device & Entity Creation**: Each physical lighting actuator (`WHERE = 1..99` or area/point addresses `WHERE = 01..9999`) is registered as a Home Assistant `light` entity linked to the gateway device. +2. **Device & Entity Creation**: Each physical lighting actuator (`WHERE = 1..99` or area/point addresses `WHERE = 01..9999`) is registered as a Home Assistant `light` entity linked to the gateway device. An area-0 point with PL 1..9 is addressed in two digits (`04` for A=0 PL=4): the gateways refuse the 4-digit spelling `0004` and report the point as `04`, so `where: "0004"` in `myhome.yaml` is resolved to `04` with a one-time warning, and an entity created under `0004` is moved to `04` at startup (#147). Other 4-digit addresses (`0015`, `0209`, `1015`) are used as typed. 3. **UI Customization**: Rename lights, assign them to Areas (e.g. *Kitchen*, *Living Room*), or customize icons directly in the Home Assistant UI (**Settings → Devices & Services → Entities**). --- diff --git a/docs/configuration/runtime_behaviour.md b/docs/configuration/runtime_behaviour.md index bfa5c0cc..02ab812c 100644 --- a/docs/configuration/runtime_behaviour.md +++ b/docs/configuration/runtime_behaviour.md @@ -91,7 +91,7 @@ Every entity platform shares one setup skeleton (`custom_components/myhome/disco | **Discover** | The first frame from an unknown address creates the entity. | New actuators appear on their own; nothing needs a restart. | | **Route** | Every frame is delivered to the entities that own the address, through the gateway's frame router (`router.py`). | An entity is subscribed the moment it is created, under every spelling of its address, so no frame of a burst is lost while Home Assistant adds it. | -Addresses follow the OpenWebNet `WHERE` conventions - point-to-point `APL` (`12`), area `A` (`1`), group `#G` (`#5`), general `0` - plus the F422 bus-routing form `APL#4#` (`0311#4#01`). Area, group and general frames never create an entity: they are broadcasts, not devices (the alarm central unit is the one subsystem where `WHERE = 0` is a real device). Translation frames (`*1*1000#1*14##`) are ignored as well. +Addresses follow the OpenWebNet `WHERE` conventions - point-to-point `APL` (`12`), area `A` (`1`), group `#G` (`#5`), general `0` - plus the F422 bus-routing form `APL#4#` (`0311#4#01`). An area-0 point with PL 1..9 is spelled in two digits everywhere (`04`, not `0004`): `myhome.yaml` devices, `expose:` entries and the UI expose flow resolve `0004` to `04`, the form the gateways accept and report (#147). Area, group and general frames never create an entity: they are broadcasts, not devices (the alarm central unit is the one subsystem where `WHERE = 0` is a real device). Translation frames (`*1*1000#1*14##`) are ignored as well. Platforms only add what differs: which `WHO` they serve, how a device is built, and a few hooks - the light platform hands WHO 1 frames for configured switches and motion / illuminance sensors to those platforms instead of creating a light, the cover platform relays a general `*2*x*0##` to every cover, the media player maps stereo-module pseudo zones (`10x`-`14x`) to amplifier `x`, the climate platform reads the zone a heating frame concerns from its parameters (`*4*4001#5*0##` is about zone 5), and the sensor and binary-sensor platforms run one such cycle per subsystem they serve (energy meters, illuminance and temperature probes; dry contacts, auxiliary channels and motion sensors). A meter address owns one entity per measurement it reports. Contacts and probes remember every spelling of their address (`0021` and `21`) so a renamed entity is never duplicated after a restart. diff --git a/docs/configuration/sensors.md b/docs/configuration/sensors.md index b33ab10f..10a3f6ee 100644 --- a/docs/configuration/sensors.md +++ b/docs/configuration/sensors.md @@ -55,6 +55,7 @@ Light intensity sensors (e.g. Legrand `048822` ceiling detectors configured in s * **Entity**: `sensor._illuminance` * **Device Class**: `illuminance` (unit: `lx`) * **Use Case**: Drive automated curtain/blind closing when solar glare exceeds threshold, or trigger dusk lighting. +* **Address**: a WHO 1 point address; an area-0 point with PL 1..9 is spelled in two digits (`04`), so `where: "0004"` in `myhome.yaml` is resolved to `04` and a `…-1-0004-illuminance` entity is moved to `…-1-04-illuminance` at startup (#147). --- diff --git a/tests/test_canonical_point_where.py b/tests/test_canonical_point_where.py new file mode 100644 index 00000000..f05f193f --- /dev/null +++ b/tests/test_canonical_point_where.py @@ -0,0 +1,969 @@ +"""The 4-digit spelling of an area-0 point (``0004``) resolves to the 2-digit form the gateways use (#147).""" +from __future__ import annotations + +import json +import logging +from unittest.mock import MagicMock, patch + +import pytest +from homeassistant.components.light import ColorMode +from homeassistant.const import CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send +from OWNd.message import OWNEvent +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.myhome import validate +from custom_components.myhome.binary_sensor import MyHOMEMotionSensor +from custom_components.myhome.binary_sensor import async_setup_entry as binary_sensor_setup_entry +from custom_components.myhome.const import ( + CONF_COVER_TRAVEL_TIMES, + CONF_PLATFORMS, + CONF_WHERE, + DOMAIN, + PLATFORMS, + SUBENTRY_TYPE_EXPOSE, +) +from custom_components.myhome.cover_calibration import _stored_calibration +from custom_components.myhome.expose import ExposeBinding +from custom_components.myhome.legacy_yaml import ( + CONF_MIGRATED_YAML_PLATFORMS, + canonicalise_imported_platforms, + load_legacy_myhome_yaml, + migrate_legacy_yaml_to_entry_options, +) +from custom_components.myhome.light import async_setup_entry as light_setup_entry +from custom_components.myhome.migrate import ( + _carry_entity_customisations, + migrate_entry_and_registries, +) +from custom_components.myhome.sensor import MyHOMEIlluminanceSensor +from custom_components.myhome.sensor import async_setup_entry as sensor_setup_entry +from custom_components.myhome.validate import ( + PointToPoint, + binary_sensor_schema, + cover_schema, + expose_entry_schema, + light_schema, + sensor_schema, +) +from custom_components.myhome.where_grammar import ( + canonical_point_address, + canonical_point_spelling, + normalize_expose_where, +) +from tests.conftest import attach_runtime + +MAC = "00:03:50:00:01:47" + + +@pytest.fixture(autouse=True) +def _fresh_warnings(): + validate._warned_point_rewrites.clear() + yield + validate._warned_point_rewrites.clear() + + +@pytest.mark.parametrize( + ("where", "expected"), + [ + ("0004", "04"), + ("0209", "0209"), + ("0809", "0809"), + ("0101", "0101"), + ("0109", "0109"), + ("0015", "0015"), + ("0112", "0112"), + ("1015", "1015"), + ("1005", "1005"), + ("0010", "0010"), + ("0100", "0100"), + ("0000", "0000"), + ("04", "04"), + ("89", "89"), + ], +) +def test_point_to_point_canonical_spelling(where: str, expected: str) -> None: + assert PointToPoint()(where) == expected + + +@pytest.mark.parametrize( + ("address", "expected"), + [ + ("0004", "04"), + ("0009#4#01", "09#4#01"), + ("0001#4#15", "01#4#15"), + ("0000", "0000"), + ("0010", "0010"), + ("0015", "0015"), + ("0015#4#01", "0015#4#01"), + ("04", "04"), + ("04#4#01", "04#4#01"), + ("1015", "1015"), + ("#4", "#4"), + ("", ""), + ], +) +def test_silent_helpers_rewrite_only_area0_points(address: str, expected: str, caplog) -> None: + """The stored-spelling helpers rewrite ``000N`` (keeping ``#4#II``) and log nothing.""" + with caplog.at_level(logging.WARNING): + assert canonical_point_address(address) == expected + assert canonical_point_spelling(address.partition("#")[0]) == expected.partition("#")[0] + assert not [r for r in caplog.records if r.levelno == logging.WARNING] + + +def test_one_warning_per_rewritten_address(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + for _ in range(3): + PointToPoint()("0004") + PointToPoint()("0005") + PointToPoint()("0015") + PointToPoint()("0209") + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 2 + assert "'0004'" in warnings[0] and "'04'" in warnings[0] + assert "'0005'" in warnings[1] and "'05'" in warnings[1] + + +def test_yaml_device_keys_use_the_canonical_spelling() -> None: + lights = light_schema({"kitchen": {CONF_WHERE: "0004", CONF_NAME: "Kitchen"}}) + assert list(lights) == ["1-04"] + assert lights["1-04"][CONF_WHERE] == "04" + covers = cover_schema({"blind": {CONF_WHERE: "0005", CONF_NAME: "Blind"}}) + assert covers["2-05"][CONF_WHERE] == "05" + + +async def test_yaml_light_0004_follows_the_bus_frame_for_04(hass: HomeAssistant) -> None: + """`where: '0004'` is the light the gateway reports as 04: no duplicate, and its state follows.""" + gateway = MagicMock() + gateway.mac = MAC + gateway.log_id = "[test]" + hass.data = {DOMAIN: {MAC: {"entity": gateway, CONF_PLATFORMS: {"light": light_schema( + {"kitchen": {CONF_WHERE: "0004", CONF_NAME: "Kitchen"}} + )}}}} + config_entry = MagicMock() + config_entry.data = {"mac": MAC} + config_entry.entry_id = "test_entry" + added: list = [] + with patch("custom_components.myhome.discovery.er.async_entries_for_config_entry", return_value=[]), \ + patch("custom_components.myhome.discovery.er.async_get"): + attach_runtime(hass, config_entry) + await light_setup_entry(hass, config_entry, added.extend) + assert len(added) == 1 + light = added[0] + assert light.unique_id == f"{MAC}-1-04" + light.async_write_ha_state = MagicMock() + light.async_schedule_update_ha_state = MagicMock() + + async_dispatcher_send(hass, f"myhome_message_{MAC}", OWNEvent.parse("*1*1*04##")) + await hass.async_block_till_done() + assert len(added) == 1 # bus discovery found the configured light, not a new one + assert light.is_on is True + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + entry = MockConfigEntry(domain=DOMAIN, title="GW", data={CONF_MAC: MAC}, unique_id=MAC, entry_id="e1") + entry.add_to_hass(hass) + return entry + + +def test_registry_moves_0004_to_04_and_merges_the_bus_duplicate(hass: HomeAssistant, config_entry, caplog) -> None: + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + yaml_dev = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-0004")}) + bus_dev = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-04")}) + # The user had placed, named and labelled the bus-created device: all survive on the configured one. + dev_reg.async_update_device(bus_dev.id, area_id="kitchen", name_by_user="Kitchen ceiling", labels={"ceiling"}) + dev_reg.async_update_device(yaml_dev.id, labels={"yaml"}) + yaml_light = ent_reg.async_get_or_create( + "light", DOMAIN, f"{MAC}-1-0004", config_entry=config_entry, device_id=yaml_dev.id, suggested_object_id="kitchen" + ) + yaml_button = ent_reg.async_get_or_create( + "button", DOMAIN, f"{MAC}-1-0004-disable", config_entry=config_entry, device_id=yaml_dev.id + ) + bus_light = ent_reg.async_get_or_create( + "light", DOMAIN, f"{MAC}-1-04", config_entry=config_entry, device_id=bus_dev.id, suggested_object_id="light_04" + ) + # The user had customised the working bus entity; the yaml one (which never worked) has nothing set. + ent_reg.async_update_entity(bus_light.entity_id, area_id="kitchen", icon="mdi:ceiling-light", name="Ceiling", labels={"lamps"}) + # An illuminance sensor bus discovery hung on its device (its real unique id, without the WHO): + # nothing in the yaml claims it, so it is handed over to the surviving device. + bus_sensor = ent_reg.async_get_or_create( + "sensor", DOMAIN, f"{MAC}-04-illuminance", config_entry=config_entry, device_id=bus_dev.id + ) + keep = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-0015", config_entry=config_entry) + + migrate_entry_and_registries(hass, config_entry, {}) + + assert ent_reg.async_get(bus_light.entity_id) is None + assert ent_reg.async_get(bus_sensor.entity_id).device_id == yaml_dev.id + moved = ent_reg.async_get(yaml_light.entity_id) + assert moved is not None and moved.unique_id == f"{MAC}-1-04" + assert moved.entity_id == "light.kitchen" + assert (moved.area_id, moved.icon, moved.name, moved.labels) == ("kitchen", "mdi:ceiling-light", "Ceiling", {"lamps"}) + assert ent_reg.async_get(yaml_button.entity_id).unique_id == f"{MAC}-1-04-disable" + assert ent_reg.async_get(keep.entity_id).unique_id == f"{MAC}-1-0015" + assert dev_reg.async_get(bus_dev.id) is None + device = dev_reg.async_get(yaml_dev.id) + assert device.identifiers == {(DOMAIN, f"{MAC}-1-04")} + assert device.area_id == "kitchen" and device.name_by_user == "Kitchen ceiling" + assert device.labels == {"yaml", "ceiling"} + assert moved.device_id == yaml_dev.id + + # A second run finds nothing to migrate and changes nothing. + def snapshot(): + return ( + {e.entity_id: e for e in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)}, + {d.id: d for d in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)}, + ) + + before = snapshot() + caplog.clear() + with caplog.at_level(logging.INFO): + migrate_entry_and_registries(hass, config_entry, {}) + assert not [r for r in caplog.records if r.name == "custom_components.myhome" and "igrat" in r.getMessage()] + assert snapshot() == before + + +def test_registry_without_duplicate_is_renamed(hass: HomeAssistant, config_entry, caplog) -> None: + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-2-0009#4#01")}) + cover = ent_reg.async_get_or_create( + "cover", DOMAIN, f"{MAC}-2-0009#4#01", config_entry=config_entry, device_id=device.id + ) + with caplog.at_level(logging.WARNING): + migrate_entry_and_registries(hass, config_entry, {}) + assert ent_reg.async_get(cover.entity_id).unique_id == f"{MAC}-2-09#4#01" + assert dev_reg.async_get(device.id).identifiers == {(DOMAIN, f"{MAC}-2-09#4#01")} + # The registry spelling is rewritten silently: the "Write '09' in myhome.yaml" warning + # belongs to the yaml validator, and the file may already say '09' or not exist. + assert not [r for r in caplog.records if r.levelno == logging.WARNING] + assert "0009" not in validate._warned_point_rewrites + + +def test_registry_errors_are_logged_and_do_not_block_the_setup(hass: HomeAssistant, config_entry, caplog) -> None: + """A registry refusal (another entity or device already holds the id) is a WARNING, not a failed setup.""" + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-0004")}) + light = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-0004", config_entry=config_entry, device_id=device.id) + with caplog.at_level(logging.WARNING), \ + patch.object(er.EntityRegistry, "async_update_entity", side_effect=ValueError("id taken")), \ + patch.object(dr.DeviceRegistry, "async_update_device", side_effect=ValueError("id taken")): + migrate_entry_and_registries(hass, config_entry, {}) + assert ent_reg.async_get(light.entity_id).unique_id == f"{MAC}-1-0004" + assert dev_reg.async_get(device.id).identifiers == {(DOMAIN, f"{MAC}-1-0004")} + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert f"Could not migrate {light.entity_id} to unique_id {MAC}-1-04: id taken" in warnings + assert f"Could not migrate device {device.id} to the canonical address: id taken" in warnings + + +def test_a_refused_rename_puts_the_bus_duplicate_back(hass: HomeAssistant, config_entry, caplog) -> None: + """The duplicate is only gone once the rename succeeded: on a refusal it is restored as it was.""" + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + yaml_dev = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-0004")}) + bus_dev = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-04")}) + yaml_light = ent_reg.async_get_or_create( + "light", DOMAIN, f"{MAC}-1-0004", config_entry=config_entry, device_id=yaml_dev.id, suggested_object_id="kitchen" + ) + bus_light = ent_reg.async_get_or_create( + "light", DOMAIN, f"{MAC}-1-04", config_entry=config_entry, device_id=bus_dev.id, suggested_object_id="light_04" + ) + ent_reg.async_update_entity(bus_light.entity_id, name="Bus light", area_id="hall") + bus_sensor = ent_reg.async_get_or_create( + "sensor", DOMAIN, f"{MAC}-04-illuminance", config_entry=config_entry, device_id=bus_dev.id + ) + real_update_entity = er.EntityRegistry.async_update_entity + + def refuse_new_unique_id(self, entity_id, **kwargs): + if "new_unique_id" in kwargs: + raise ValueError("id taken") + return real_update_entity(self, entity_id, **kwargs) + + with caplog.at_level(logging.WARNING), \ + patch.object(er.EntityRegistry, "async_update_entity", refuse_new_unique_id), \ + patch.object(dr.DeviceRegistry, "async_update_device", side_effect=ValueError("id taken")): + migrate_entry_and_registries(hass, config_entry, {}) + + assert ent_reg.async_get(yaml_light.entity_id).unique_id == f"{MAC}-1-0004" + restored = ent_reg.async_get("light.light_04") + assert restored is not None and restored.unique_id == f"{MAC}-1-04" + assert restored.config_entry_id == config_entry.entry_id + assert restored.device_id == bus_dev.id + assert restored.name == "Bus light" and restored.area_id == "hall" + # The device rename was refused too, so the bus device and its entities are untouched. + assert dev_reg.async_get(bus_dev.id) is not None + assert ent_reg.async_get(bus_sensor.entity_id).device_id == bus_dev.id + assert dev_reg.async_get(yaml_dev.id).identifiers == {(DOMAIN, f"{MAC}-1-0004")} + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert f"Could not migrate {yaml_light.entity_id} to unique_id {MAC}-1-04: id taken" in warnings + assert f"Could not migrate device {yaml_dev.id} to the canonical address: id taken" in warnings + + +def test_a_canonical_id_held_by_another_config_entry_is_a_collision(hass: HomeAssistant, config_entry, caplog) -> None: + """Only this entry's duplicates are merged: another gateway's entity under 04 is left alone.""" + other_entry = MockConfigEntry(domain=DOMAIN, title="GW2", data={CONF_MAC: MAC}, unique_id="other", entry_id="e2") + other_entry.add_to_hass(hass) + ent_reg = er.async_get(hass) + mine = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-0004", config_entry=config_entry) + theirs = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-04", config_entry=other_entry) + with caplog.at_level(logging.WARNING): + migrate_entry_and_registries(hass, config_entry, {}) + assert ent_reg.async_get(mine.entity_id).unique_id == f"{MAC}-1-0004" + assert ent_reg.async_get(theirs.entity_id).config_entry_id == other_entry.entry_id + assert any( + f"Not migrating {mine.entity_id} to unique_id {MAC}-1-04: {theirs.entity_id} of another config entry" in r.getMessage() + for r in caplog.records if r.levelno == logging.WARNING + ) + + +# ── The imported copy of myhome.yaml (entry options, #733) ───────────────────────── + +LIGHT_0004 = { + "who": "1", "where": "0004", "name": "Kitchen", "dimmable": True, "manufacturer": "BTicino S.p.A.", + "model": None, "icon": None, "icon_on": None, "entity_name": None, "device_class": "switch", "entities": {}, +} + + +def _old_beta_snapshot() -> dict: + """What a 2.0 beta before this change stored for ``where: "0004"`` (light and its button alias).""" + return {"light": {"1-0004": dict(LIGHT_0004)}, "button": {"1-0004": dict(LIGHT_0004)}, "climate": {}} + + +def test_imported_copy_is_rewritten_to_the_canonical_spelling(caplog) -> None: + snapshot = _old_beta_snapshot() + snapshot["cover"] = { + "2-0005#4#01": {"who": "2", "where": "0005", "interface": "01", "name": "Blind", "entities": {}}, + "2-0009": {"who": "2", "where": "0009", "members": ["0001", "0015", "11"], "name": "All", "entities": {}}, + "2-0015": {"who": "2", "where": "0015", "name": "Fifteen", "entities": {}}, + } + snapshot["binary_sensor"] = {"25-0004": {"who": "25", "where": "0004", "name": "CEN", "entities": {}}} + snapshot["alarm_control_panel"] = {"5-0004": {"who": "5", "where": "0004", "name": "Zone 4", "entities": {}}} + before = json.dumps(snapshot, sort_keys=True) + with caplog.at_level(logging.WARNING): + result, rewritten = canonicalise_imported_platforms(snapshot) + assert json.dumps(snapshot, sort_keys=True) == before # the stored copy is not mutated + assert result["light"] == {"1-04": {**LIGHT_0004, "where": "04"}} + assert result["button"] == {"1-04": {**LIGHT_0004, "where": "04"}} + assert set(result["cover"]) == {"2-05#4#01", "2-09", "2-0015"} + assert result["cover"]["2-05#4#01"]["where"] == "05" + assert result["cover"]["2-09"]["members"] == ["01", "0015", "11"] + assert result["cover"]["2-0015"] is snapshot["cover"]["2-0015"] + # Only WHO 1 / WHO 2 points: a CEN button at 0004 and WHO 5 zone 0004 are other grammars. + assert result["binary_sensor"] == snapshot["binary_sensor"] + assert result["alarm_control_panel"] == snapshot["alarm_control_panel"] + assert result["climate"] == {} + assert rewritten == ["light: 1-0004 -> 1-04", "button: 1-0004 -> 1-04", "cover: 2-0005#4#01 -> 2-05#4#01", "cover: 2-0009 -> 2-09"] + assert not [r for r in caplog.records if r.levelno == logging.WARNING] + # Idempotent: the canonical copy is returned unchanged. + again, rewritten_again = canonicalise_imported_platforms(result) + assert again == result and rewritten_again == [] + + +def test_imported_copy_keeps_the_canonical_device_on_a_collision(caplog) -> None: + """A copy holding both `1-0004` and `1-04` keeps `1-04`; the twin is dropped, not overwritten.""" + snapshot = {"light": { + "1-0004": dict(LIGHT_0004), + "1-04": {**LIGHT_0004, "where": "04", "name": "Kitchen (bus)", "dimmable": False}, + }} + with caplog.at_level(logging.WARNING): + result, rewritten = canonicalise_imported_platforms(snapshot) + assert result["light"] == {"1-04": snapshot["light"]["1-04"]} + assert rewritten == [] + assert any( + "dropping '1-0004' (where '0004'), the copy already has '1-04'" in r.getMessage() + for r in caplog.records if r.levelno == logging.WARNING + ) + + +def test_new_snapshots_are_stored_canonically(hass: HomeAssistant, config_entry) -> None: + """The guard on the write path: a platform mapping with `0004` is stored as `04`.""" + assert migrate_legacy_yaml_to_entry_options(hass, config_entry, _old_beta_snapshot()) is True + assert list(config_entry.options[CONF_MIGRATED_YAML_PLATFORMS]["light"]) == ["1-04"] + + +async def test_imported_copy_with_0004_boots_twice_without_churn(hass: HomeAssistant, caplog) -> None: + """Snapshot with `0004`, no myhome.yaml: two boots give one `…-1-04` light that keeps `dimmable`. + + Before the fix, boot 1 renamed the registry entry to `…-1-04`, restored it with an empty + config (`config_for` missed `04` in the imported copy) and created `light.kitchen_2` for + `0004`; boot 2 deleted `…-1-04` as a duplicate and renamed `…-1-04` again, and so on. + The add is simulated by registering the created entities' unique ids as HA would. + """ + entry = MockConfigEntry( + domain=DOMAIN, title="GW", data={CONF_MAC: MAC}, unique_id=MAC, entry_id="e_imported", + options={CONF_MIGRATED_YAML_PLATFORMS: _old_beta_snapshot()}, + ) + entry.add_to_hass(hass) + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create(config_entry_id=entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-0004")}) + ent_reg.async_get_or_create( + "light", DOMAIN, f"{MAC}-1-0004", config_entry=entry, device_id=device.id, suggested_object_id="kitchen" + ) + gateway = MagicMock() + gateway.mac = MAC + gateway.log_id = "[test]" + + async def boot() -> list: + configured: dict = {platform: {} for platform in PLATFORMS} + await load_legacy_myhome_yaml(hass, entry, configured) # no myhome.yaml: the imported copy + migrate_entry_and_registries(hass, entry, configured) + hass.data.setdefault(DOMAIN, {})[MAC] = {"entity": gateway, CONF_PLATFORMS: configured} + attach_runtime(hass, entry) + added: list = [] + await light_setup_entry(hass, entry, added.extend) + for light in added: # what EntityPlatform does on add + ent_reg.async_get_or_create("light", DOMAIN, light.unique_id, config_entry=entry, device_id=device.id) + return added + + def lights_in_registry() -> dict[str, str]: + return { + e.entity_id: e.unique_id + for e in er.async_entries_for_config_entry(ent_reg, entry.entry_id) if e.domain == "light" + } + + with caplog.at_level(logging.INFO): + first = await boot() + assert [light.unique_id for light in first] == [f"{MAC}-1-04"] + assert ColorMode.BRIGHTNESS in first[0].supported_color_modes # `dimmable: true` kept + assert lights_in_registry() == {"light.kitchen": f"{MAC}-1-04"} + assert list(entry.options[CONF_MIGRATED_YAML_PLATFORMS]["light"]) == ["1-04"] + assert entry.options[CONF_MIGRATED_YAML_PLATFORMS]["light"]["1-04"]["where"] == "04" + assert any("2 point address(es) moved to the canonical spelling (light: 1-0004 -> 1-04; button: 1-0004 -> 1-04)" in r.getMessage() for r in caplog.records) + assert dev_reg.async_get(device.id).identifiers == {(DOMAIN, f"{MAC}-1-04")} + + caplog.clear() + with caplog.at_level(logging.INFO): + second = await boot() + assert [light.unique_id for light in second] == [f"{MAC}-1-04"] + assert ColorMode.BRIGHTNESS in second[0].supported_color_modes + assert lights_in_registry() == {"light.kitchen": f"{MAC}-1-04"} + assert ent_reg.async_get("light.kitchen").device_id == device.id + assert not [r for r in caplog.records if r.name == "custom_components.myhome" and "igrat" in r.getMessage()] + assert not [r for r in caplog.records if "canonical spelling" in r.getMessage()] + + +# ── expose (myhome.yaml `expose:` and the UI subentries) ───────────────────────── + + +@pytest.mark.parametrize( + ("who", "where", "expected"), + [ + (1, "0004", "04"), + (2, "0009#4#01", "09#4#01"), + (1, "04", "04"), + (1, "0015", "0015"), + (2, "0010", "0010"), + (1, "0000", "0000"), + (1, "1015", "1015"), + (4, "0004", "0004"), # a WHO 4 probe keeps the WHO 4 grammar + (18, "0004", "0004"), + ], +) +def test_expose_where_is_canonical_for_who_1_and_2(who: int, where: str, expected: str, caplog) -> None: + with caplog.at_level(logging.WARNING): + assert normalize_expose_where(who, where) == expected + assert not [r for r in caplog.records if r.levelno == logging.WARNING] + + +def test_expose_entry_with_0004_is_the_virtual_actuator_04() -> None: + """The yaml `expose:` entry and the UI flow share `expose_entry_schema`: both store `04`.""" + item = expose_entry_schema({"who": 1, "where": "0004", "entity_id": "light.kitchen"}) + assert item["where"] == "04" + routed = expose_entry_schema({"who": 2, "where": "0009#4#01", "entity_id": "cover.blind"}) + assert routed["where"] == "09#4#01" + binding = ExposeBinding.from_dict(item, source="yaml") + assert binding.key == (1, "04", None) + + +def test_expose_subentries_at_0004_are_migrated(hass: HomeAssistant, caplog) -> None: + entry = MockConfigEntry( + domain=DOMAIN, title="GW", data={CONF_MAC: MAC}, unique_id=MAC, entry_id="e_expose", + subentries_data=[ + { + "subentry_type": SUBENTRY_TYPE_EXPOSE, "subentry_id": "sub_0004", "unique_id": None, + "title": "WHO 1 / WHERE 0004 (light.kitchen)", + "data": {"who": 1, "where": "0004", "entity_id": "light.kitchen"}, + }, + { + "subentry_type": SUBENTRY_TYPE_EXPOSE, "subentry_id": "sub_routed", "unique_id": None, + "title": "WHO 2 / WHERE 0009#4#01 (cover.blind)", + "data": {"who": 2, "where": "0009#4#01", "entity_id": "cover.blind"}, + }, + { + "subentry_type": SUBENTRY_TYPE_EXPOSE, "subentry_id": "sub_keep", "unique_id": None, + "title": "WHO 1 / WHERE 0015 (light.hall)", + "data": {"who": 1, "where": "0015", "entity_id": "light.hall"}, + }, + { + "subentry_type": SUBENTRY_TYPE_EXPOSE, "subentry_id": "sub_probe", "unique_id": None, + "title": "WHO 4 / WHERE 0004 (sensor.t)", + "data": {"who": 4, "where": "0004", "entity_id": "sensor.t"}, + }, + { + "subentry_type": SUBENTRY_TYPE_EXPOSE, "subentry_id": "sub_clock", "unique_id": None, + "title": "Bus Clock Sync (WHO 13, dim 22)", "data": {"who": 13}, + }, + { + "subentry_type": "other", "subentry_id": "sub_other", "unique_id": None, + "title": "Not an expose subentry", "data": {"who": 1, "where": "0004"}, + }, + # Both spellings of one address: the canonical binding is the one that worked on the bus. + { + "subentry_type": SUBENTRY_TYPE_EXPOSE, "subentry_id": "sub_twin_old", "unique_id": None, + "title": "WHO 2 / WHERE 0007 (cover.old)", "data": {"who": 2, "where": "0007", "entity_id": "cover.old"}, + }, + { + "subentry_type": SUBENTRY_TYPE_EXPOSE, "subentry_id": "sub_twin", "unique_id": None, + "title": "WHO 2 / WHERE 07 (cover.new)", "data": {"who": 2, "where": "07", "entity_id": "cover.new"}, + }, + ], + ) + entry.add_to_hass(hass) + with caplog.at_level(logging.INFO): + migrate_entry_and_registries(hass, entry, {}) + subs = entry.subentries + assert subs["sub_other"].data == {"who": 1, "where": "0004"} + assert "sub_twin_old" not in subs and subs["sub_twin"].data["where"] == "07" + assert any( + "Removing expose subentry sub_twin_old (WHO 2 / WHERE 0007 (cover.old)): sub_twin (WHO 2 / WHERE 07 (cover.new)) already exposes WHO 2 WHERE 07" in r.getMessage() + for r in caplog.records if r.levelno == logging.WARNING + ) + assert subs["sub_0004"].data["where"] == "04" and subs["sub_0004"].title == "WHO 1 / WHERE 04 (light.kitchen)" + assert subs["sub_routed"].data["where"] == "09#4#01" and subs["sub_routed"].title == "WHO 2 / WHERE 09#4#01 (cover.blind)" + assert subs["sub_keep"].data["where"] == "0015" and subs["sub_keep"].title == "WHO 1 / WHERE 0015 (light.hall)" + assert subs["sub_probe"].data["where"] == "0004" + assert subs["sub_clock"].data == {"who": 13} + assert sum("Migrated expose subentry" in r.getMessage() for r in caplog.records) == 2 + + caplog.clear() + with caplog.at_level(logging.INFO): + migrate_entry_and_registries(hass, entry, {}) + assert not [r for r in caplog.records if "expose subentry" in r.getMessage()] + + +# ── WHO 1 binary_sensor (motion) and sensor (illuminance) ───────────────────────── + + +def test_who1_sensor_schemas_use_the_canonical_spelling(caplog) -> None: + with caplog.at_level(logging.WARNING): + motion = binary_sensor_schema({"pir": {"who": "1", "where": "0004", "name": "PIR", "class": "motion"}}) + lux = sensor_schema({"lux": {"where": "0004", "name": "Lux", "class": "illuminance"}}) + explicit = sensor_schema({"lux2": {"who": "1", "where": "0005", "name": "Lux 2", "class": "illuminance"}}) + assert list(motion) == ["1-04"] and motion["1-04"]["where"] == "04" + assert list(lux) == ["1-04"] and lux["1-04"]["where"] == "04" and lux["1-04"]["who"] == "1" + assert list(explicit) == ["1-05"] and explicit["1-05"]["where"] == "05" + # One yaml warning per address, as for lights. + assert [r.getMessage()[:14] for r in caplog.records if r.levelno == logging.WARNING] == ["WHERE '0004' i", "WHERE '0005' i"] + + +def test_other_who_sensor_schemas_keep_the_address() -> None: + """A CEN button, a WHO 9 input, a WHO 4 probe and a WHO 18 meter at 0004 are other grammars.""" + cen = binary_sensor_schema({"cen": {"where": "0004", "name": "CEN"}}) + assert list(cen) == ["25-0004"] and cen["25-0004"]["where"] == "0004" + aux = binary_sensor_schema({"aux": {"who": "9", "where": "0004", "name": "Aux"}}) + assert list(aux) == ["9-0004"] + probe = sensor_schema({"t": {"where": "0004", "name": "T", "class": "temperature"}}) + assert list(probe) == ["4-0004"] + meter = sensor_schema({"m": {"where": "0004", "name": "M", "class": "power"}}) + assert list(meter) == ["18-0004"] + kept = sensor_schema({"lux": {"where": "0015", "name": "Lux", "class": "illuminance"}}) + assert list(kept) == ["1-0015"] + + +def test_registry_moves_who1_sensors_and_merges_their_bus_twins(hass: HomeAssistant, config_entry, caplog) -> None: + """The yaml motion / illuminance entity is `{mac}-1-0004-`; its bus twin is `{mac}-04-`. + + Bus discovery never spells the WHO into a motion / illuminance unique id (`build_motion`, + `build_illuminance`), only into the device identifier `{mac}-1-04`. The twin is removed, + its customisations carried where the yaml entity has none, and other WHOs are untouched. + """ + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + yaml_dev = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-0004")}) + bus_dev = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-04")}) + motion = ent_reg.async_get_or_create( + "binary_sensor", DOMAIN, f"{MAC}-1-0004-motion", config_entry=config_entry, device_id=yaml_dev.id, + suggested_object_id="hall_motion", + ) + lux = ent_reg.async_get_or_create( + "sensor", DOMAIN, f"{MAC}-1-0004-illuminance", config_entry=config_entry, device_id=yaml_dev.id + ) + ent_reg.async_update_entity(lux.entity_id, icon="mdi:sun-wireless", labels={"mine"}) + bus_motion = ent_reg.async_get_or_create( + "binary_sensor", DOMAIN, f"{MAC}-04-motion", config_entry=config_entry, device_id=bus_dev.id, + suggested_object_id="motion_sensor_04", + ) + ent_reg.async_update_entity(bus_motion.entity_id, area_id="hall", icon="mdi:motion-sensor", name="Hall PIR", labels={"pir"}) + bus_lux = ent_reg.async_get_or_create( + "sensor", DOMAIN, f"{MAC}-04-illuminance", config_entry=config_entry, device_id=bus_dev.id + ) + ent_reg.async_update_entity(bus_lux.entity_id, area_id="hall", icon="mdi:brightness-5", name="Hall lux", labels={"lux"}) + cen = ent_reg.async_get_or_create("binary_sensor", DOMAIN, f"{MAC}-25-0004", config_entry=config_entry) + probe = ent_reg.async_get_or_create("sensor", DOMAIN, f"{MAC}-4-0004-temperature", config_entry=config_entry) + meter = ent_reg.async_get_or_create("sensor", DOMAIN, f"{MAC}-18-0004-power", config_entry=config_entry) + legacy = ent_reg.async_get_or_create("sensor", DOMAIN, f"{MAC}-0004-illuminance", config_entry=config_entry) + # The same twin held by another config entry is not this entry's to remove. + other_entry = MockConfigEntry(domain=DOMAIN, title="GW2", data={CONF_MAC: MAC}, unique_id="other", entry_id="e2") + other_entry.add_to_hass(hass) + ent_reg.async_get_or_create("binary_sensor", DOMAIN, f"{MAC}-1-0005-motion", config_entry=config_entry) + theirs = ent_reg.async_get_or_create("binary_sensor", DOMAIN, f"{MAC}-05-motion", config_entry=other_entry) + + with caplog.at_level(logging.INFO): + migrate_entry_and_registries(hass, config_entry, {}) + + moved_motion = ent_reg.async_get(motion.entity_id) + assert moved_motion.unique_id == f"{MAC}-1-04-motion" and moved_motion.entity_id == "binary_sensor.hall_motion" + assert (moved_motion.area_id, moved_motion.icon, moved_motion.name, moved_motion.labels) == ( + "hall", "mdi:motion-sensor", "Hall PIR", {"pir"}, + ) + moved_lux = ent_reg.async_get(lux.entity_id) + assert moved_lux.unique_id == f"{MAC}-1-04-illuminance" + # One way: what the yaml entity already had wins, the rest is carried. + assert (moved_lux.area_id, moved_lux.icon, moved_lux.name, moved_lux.labels) == ("hall", "mdi:sun-wireless", "Hall lux", {"mine"}) + assert ent_reg.async_get(bus_motion.entity_id) is None and ent_reg.async_get(bus_lux.entity_id) is None + assert ent_reg.async_get_entity_id("binary_sensor", DOMAIN, f"{MAC}-04-motion") is None + assert ent_reg.async_get_entity_id("sensor", DOMAIN, f"{MAC}-04-illuminance") is None + assert ent_reg.async_get(theirs.entity_id).unique_id == f"{MAC}-05-motion" + assert dev_reg.async_get(bus_dev.id) is None + assert dev_reg.async_get(yaml_dev.id).identifiers == {(DOMAIN, f"{MAC}-1-04")} + assert moved_motion.device_id == yaml_dev.id and moved_lux.device_id == yaml_dev.id + for untouched in (cen, probe, meter, legacy): + assert ent_reg.async_get(untouched.entity_id).unique_id == untouched.unique_id + assert sorted(r.getMessage() for r in caplog.records if "bus-discovered twin" in r.getMessage()) == [ + f"Removed {bus_motion.entity_id}, the bus-discovered twin of binary_sensor.hall_motion (unique_id {MAC}-04-motion)", + f"Removed {bus_lux.entity_id}, the bus-discovered twin of {lux.entity_id} (unique_id {MAC}-04-illuminance)", + ] + + # A second run finds nothing to do: no log line and no registry write. + caplog.clear() + with caplog.at_level(logging.INFO), \ + patch.object(er.EntityRegistry, "async_update_entity", side_effect=AssertionError("write")), \ + patch.object(er.EntityRegistry, "async_remove", side_effect=AssertionError("write")), \ + patch.object(er.EntityRegistry, "async_get_or_create", side_effect=AssertionError("write")), \ + patch.object(dr.DeviceRegistry, "async_update_device", side_effect=AssertionError("write")), \ + patch.object(dr.DeviceRegistry, "async_remove_device", side_effect=AssertionError("write")): + migrate_entry_and_registries(hass, config_entry, {}) + assert not [r for r in caplog.records if r.name == "custom_components.myhome" and "igrat" in r.getMessage()] + assert not [r for r in caplog.records if "twin" in r.getMessage()] + + +def test_an_already_canonical_who1_sensor_still_loses_its_bus_twin(hass: HomeAssistant, config_entry, caplog) -> None: + """Later boot (or a yaml that said `04` from the start): `{mac}-1-04-motion` next to `{mac}-04-motion`.""" + ent_reg = er.async_get(hass) + mine = ent_reg.async_get_or_create( + "binary_sensor", DOMAIN, f"{MAC}-1-04-motion", config_entry=config_entry, suggested_object_id="hall_motion" + ) + twin = ent_reg.async_get_or_create("binary_sensor", DOMAIN, f"{MAC}-04-motion", config_entry=config_entry) + ent_reg.async_update_entity(twin.entity_id, area_id="hall", labels={"pir"}) + routed = ent_reg.async_get_or_create("sensor", DOMAIN, f"{MAC}-1-09#4#01-illuminance", config_entry=config_entry) + routed_twin = ent_reg.async_get_or_create("sensor", DOMAIN, f"{MAC}-09#4#01-illuminance", config_entry=config_entry) + # Not area-0 points, or not WHO 1: no twin lookup. + kept = [ + ent_reg.async_get_or_create("binary_sensor", DOMAIN, f"{MAC}-1-15-motion", config_entry=config_entry), + ent_reg.async_get_or_create("binary_sensor", DOMAIN, f"{MAC}-15-motion", config_entry=config_entry), + ent_reg.async_get_or_create("sensor", DOMAIN, f"{MAC}-1-0015-illuminance", config_entry=config_entry), + ent_reg.async_get_or_create("sensor", DOMAIN, f"{MAC}-0015-illuminance", config_entry=config_entry), + ent_reg.async_get_or_create("sensor", DOMAIN, f"{MAC}-4-04-temperature", config_entry=config_entry), + ent_reg.async_get_or_create("sensor", DOMAIN, f"{MAC}-04-temperature", config_entry=config_entry), + ] + with caplog.at_level(logging.INFO): + migrate_entry_and_registries(hass, config_entry, {}) + assert ent_reg.async_get(twin.entity_id) is None and ent_reg.async_get(routed_twin.entity_id) is None + survivor = ent_reg.async_get(mine.entity_id) + assert survivor.unique_id == f"{MAC}-1-04-motion" and survivor.entity_id == "binary_sensor.hall_motion" + assert (survivor.area_id, survivor.labels) == ("hall", {"pir"}) + assert ent_reg.async_get(routed.entity_id).unique_id == f"{MAC}-1-09#4#01-illuminance" + for entry in kept: + assert ent_reg.async_get(entry.entity_id).unique_id == entry.unique_id + assert not [r for r in caplog.records if "Migrated" in r.getMessage()] + + caplog.clear() + with caplog.at_level(logging.INFO), \ + patch.object(er.EntityRegistry, "async_update_entity", side_effect=AssertionError("write")), \ + patch.object(er.EntityRegistry, "async_remove", side_effect=AssertionError("write")): + migrate_entry_and_registries(hass, config_entry, {}) + assert not [r for r in caplog.records if r.name == "custom_components.myhome" and ("twin" in r.getMessage() or "igrat" in r.getMessage())] + + +def test_carry_over_onto_a_vanished_survivor_is_a_no_op(hass: HomeAssistant, config_entry) -> None: + """Defensive: the survivor was renamed a moment ago, but a missing entry must not raise.""" + ent_reg = er.async_get(hass) + twin = ent_reg.async_get_or_create("binary_sensor", DOMAIN, f"{MAC}-04-motion", config_entry=config_entry) + ent_reg.async_update_entity(twin.entity_id, area_id="hall") + with patch.object(er.EntityRegistry, "async_update_entity", side_effect=AssertionError("write")): + _carry_entity_customisations(ent_reg, "binary_sensor.gone", ent_reg.async_get(twin.entity_id)) + + +def _seed_who1_sensor_twins(hass: HomeAssistant, config_entry, domain: str, device_class: str) -> tuple[str, str]: + """A bus twin `{mac}-04-` listed BEFORE the yaml entity `{mac}-1-0004-` in the registry. + + Returns ``(bus entity_id, yaml entity_id)``; the bus one carries the user's customisations. + """ + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + bus_dev = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-04")}) + bus = ent_reg.async_get_or_create( + domain, DOMAIN, f"{MAC}-04-{device_class}", config_entry=config_entry, device_id=bus_dev.id, + suggested_object_id=f"{device_class}_04", original_device_class=device_class, + ) + ent_reg.async_update_entity(bus.entity_id, area_id="hall", icon="mdi:eye", name="Hall", labels={"hall"}) + yaml_dev = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-0004")}) + mine = ent_reg.async_get_or_create( + domain, DOMAIN, f"{MAC}-1-0004-{device_class}", config_entry=config_entry, device_id=yaml_dev.id, + suggested_object_id=f"hall_{device_class}", original_device_class=device_class, + ) + entries = [e.unique_id for e in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) if e.domain == domain] + assert entries == [f"{MAC}-04-{device_class}", f"{MAC}-1-0004-{device_class}"] + return bus.entity_id, mine.entity_id + + +async def test_motion_sensor_setup_keeps_the_yaml_entity_when_its_bus_twin_is_listed_first(hass: HomeAssistant, config_entry, caplog) -> None: + """End to end through `binary_sensor.async_setup_entry`: the registry lists `{mac}-04-motion` first. + + Before the fix the migration renamed the yaml entity to `{mac}-1-04-motion` and left the twin + to the platform's restore, which keeps the first entry it meets for an address and removes the + other: here the device hand-over happened to re-list the twin last, so the twin went, with the + user's area, icon, name and labels on it; see the next test for the order that removed the + configured entity instead. + """ + ent_reg = er.async_get(hass) + bus_id, mine_id = _seed_who1_sensor_twins(hass, config_entry, "binary_sensor", "motion") + gateway = MagicMock() + gateway.mac = MAC + gateway.log_id = "[test]" + configured = {"binary_sensor": binary_sensor_schema( + {"hall_motion": {"who": "1", CONF_WHERE: "0004", CONF_NAME: "Hall motion", "class": "motion"}} + )} + assert list(configured["binary_sensor"]) == ["1-04"] + hass.data.setdefault(DOMAIN, {})[MAC] = {"entity": gateway, CONF_PLATFORMS: configured} + added: list = [] + with caplog.at_level(logging.INFO): + migrate_entry_and_registries(hass, config_entry, configured) + attach_runtime(hass, config_entry) + await binary_sensor_setup_entry(hass, config_entry, added.extend) + + assert [type(e) for e in added] == [MyHOMEMotionSensor] + assert added[0].unique_id == f"{MAC}-1-04-motion" + assert ent_reg.async_get(bus_id) is None + survivor = ent_reg.async_get(mine_id) + assert survivor is not None and survivor.unique_id == f"{MAC}-1-04-motion" + assert survivor.entity_id == "binary_sensor.hall_motion" + assert (survivor.area_id, survivor.icon, survivor.name, survivor.labels) == ("hall", "mdi:eye", "Hall", {"hall"}) + assert [e.unique_id for e in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) if e.domain == "binary_sensor"] == [ + f"{MAC}-1-04-motion" + ] + assert not [r for r in caplog.records if "removed stale registry entry" in r.getMessage()] + + # The configured entity follows the bus frames for 04 without a second entity being discovered. + motion = added[0] + motion.async_write_ha_state = MagicMock() + motion.async_schedule_update_ha_state = MagicMock() + async_dispatcher_send(hass, f"myhome_message_{MAC}", OWNEvent.parse("*1*34*04##")) + await hass.async_block_till_done() + assert len(added) == 1 + assert motion.is_on is True + + +async def test_motion_sensor_setup_keeps_an_already_canonical_yaml_entity_listed_after_its_twin(hass: HomeAssistant, config_entry, caplog) -> None: + """No rename and no device hand-over to reorder the registry: the twin stays listed first. + + `{mac}-04-motion` and the yaml `{mac}-1-04-motion` share the device `{mac}-1-04` (the yaml + said `04`, or an earlier boot already renamed it). Before the fix the platform restored the + twin first and removed `binary_sensor.hall_motion`, the user's configured entity, as the + duplicate; its entity_id, area and name were lost with it. + """ + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-04")}) + twin = ent_reg.async_get_or_create( + "binary_sensor", DOMAIN, f"{MAC}-04-motion", config_entry=config_entry, device_id=device.id, + suggested_object_id="motion_04", original_device_class="motion", + ) + ent_reg.async_update_entity(twin.entity_id, icon="mdi:eye") + mine = ent_reg.async_get_or_create( + "binary_sensor", DOMAIN, f"{MAC}-1-04-motion", config_entry=config_entry, device_id=device.id, + suggested_object_id="hall_motion", original_device_class="motion", + ) + ent_reg.async_update_entity(mine.entity_id, area_id="hall", name="Hall PIR") + gateway = MagicMock() + gateway.mac = MAC + gateway.log_id = "[test]" + configured = {"binary_sensor": binary_sensor_schema( + {"hall_motion": {"who": "1", CONF_WHERE: "04", CONF_NAME: "Hall motion", "class": "motion"}} + )} + hass.data.setdefault(DOMAIN, {})[MAC] = {"entity": gateway, CONF_PLATFORMS: configured} + added: list = [] + with caplog.at_level(logging.INFO): + migrate_entry_and_registries(hass, config_entry, configured) + attach_runtime(hass, config_entry) + await binary_sensor_setup_entry(hass, config_entry, added.extend) + + assert [e.unique_id for e in added] == [f"{MAC}-1-04-motion"] + assert ent_reg.async_get(twin.entity_id) is None + survivor = ent_reg.async_get(mine.entity_id) + assert survivor is not None and survivor.entity_id == "binary_sensor.hall_motion" + assert (survivor.area_id, survivor.icon, survivor.name) == ("hall", "mdi:eye", "Hall PIR") + assert not [r for r in caplog.records if "removed stale registry entry" in r.getMessage()] + assert not [r for r in caplog.records if "Migrated" in r.getMessage()] + + +async def test_illuminance_sensor_setup_keeps_the_yaml_entity_when_its_bus_twin_is_listed_first(hass: HomeAssistant, config_entry, caplog) -> None: + """End to end through `sensor.async_setup_entry`: the registry lists `{mac}-04-illuminance` first. + + Before the fix the platform's restore pruned both entries at the configured address and the + yaml device re-created `{mac}-1-04-illuminance` from scratch: the twin's area, icon, name and + labels were lost. + """ + ent_reg = er.async_get(hass) + bus_id, mine_id = _seed_who1_sensor_twins(hass, config_entry, "sensor", "illuminance") + gateway = MagicMock() + gateway.mac = MAC + gateway.log_id = "[test]" + configured = {"sensor": sensor_schema({"hall_illuminance": {CONF_WHERE: "0004", CONF_NAME: "Hall lux", "class": "illuminance"}})} + assert list(configured["sensor"]) == ["1-04"] + hass.data.setdefault(DOMAIN, {})[MAC] = {"entity": gateway, CONF_PLATFORMS: configured} + added: list = [] + with caplog.at_level(logging.INFO): + migrate_entry_and_registries(hass, config_entry, configured) + attach_runtime(hass, config_entry) + await sensor_setup_entry(hass, config_entry, added.extend) + for sensor in added: # what EntityPlatform does on add + ent_reg.async_get_or_create("sensor", DOMAIN, sensor.unique_id, config_entry=config_entry) + + assert [type(e) for e in added] == [MyHOMEIlluminanceSensor] + assert added[0].unique_id == f"{MAC}-1-04-illuminance" + assert ent_reg.async_get(bus_id) is None + survivor = ent_reg.async_get(mine_id) + assert survivor is not None and survivor.unique_id == f"{MAC}-1-04-illuminance" + assert (survivor.area_id, survivor.icon, survivor.name, survivor.labels) == ("hall", "mdi:eye", "Hall", {"hall"}) + assert [e.unique_id for e in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) if e.domain == "sensor"] == [ + f"{MAC}-1-04-illuminance" + ] + + +# ── persisted cover calibration (`cover_travel_times`) ─────────────────────────── + + +def test_cover_travel_times_are_rekeyed_to_the_canonical_address(hass: HomeAssistant, caplog) -> None: + measured = {"down": 18.4, "up": 20.1, "measured_at": "x"} + routed = {"slat_time": 1.2} + bus_measured = {"down": 17.0, "up": 19.0, "measured_at": "y"} + entry = MockConfigEntry( + domain=DOMAIN, title="GW", data={CONF_MAC: MAC}, unique_id=MAC, entry_id="e_calib", + options={CONF_COVER_TRAVEL_TIMES: { + "0004": measured, "0009#4#01": routed, "0015": {"down": 1.0}, "21": {"down": 2.0}, + "0005": {"down": 9.9}, "05": bus_measured, + }}, + ) + entry.add_to_hass(hass) + with caplog.at_level(logging.INFO): + migrate_entry_and_registries(hass, entry, {}) + assert entry.options[CONF_COVER_TRAVEL_TIMES] == { + "04": measured, "09#4#01": routed, "0015": {"down": 1.0}, "21": {"down": 2.0}, "05": bus_measured, + } + assert _stored_calibration(entry, "04") == measured + assert _stored_calibration(entry, "09#4#01") == routed + assert _stored_calibration(entry, "0004") is None + assert any("Migrated the cover calibration of 0004 to the canonical address 04" in r.getMessage() for r in caplog.records) + assert any("Dropping the cover calibration stored under 0005: 05 already has one" in r.getMessage() for r in caplog.records) + + # Idempotent: the second run writes nothing. + with patch.object(hass.config_entries, "async_update_entry") as update: + migrate_entry_and_registries(hass, entry, {}) + update.assert_not_called() + + +def test_stored_calibration_falls_back_to_the_4_digit_key() -> None: + """A record a migration could not move is still found under its old spelling.""" + entry = MagicMock() + entry.options = {CONF_COVER_TRAVEL_TIMES: {"0004": {"down": 1.0}, "0009#4#01": {"slat_time": 1.5}, "0015": {"down": 2.0}}} + assert _stored_calibration(entry, "04") == {"down": 1.0} + assert _stored_calibration(entry, "09#4#01") == {"slat_time": 1.5} + assert _stored_calibration(entry, "15") is None # 0015 is area 00 PL 15, not 15 + assert _stored_calibration(entry, "00") is None + + +# ── cross-domain twin: a WHO 1 impulse cover (or switch) whose bus twin is a light ── + + +def test_impulse_cover_migration_removes_the_ghost_light_and_its_buttons(hass: HomeAssistant, config_entry, caplog) -> None: + """`cover:` with `who: "1"`, `where: "0004"`: bus traffic on `04` had created a light and its buttons. + + The twin is in another domain, so the same-domain duplicate lookup does not see it. Without + this handling the device merge hands `light.light_04` and `…-1-04-disable` / `-enable` to the + cover's device; light.py then removes the light (a cover address never holds a light) with + `registry.async_remove`, which leaves the two buttons orphaned on the impulse cover's device. + """ + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + yaml_dev = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-0004")}) + bus_dev = dev_reg.async_get_or_create(config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MAC}-1-04")}) + gate = ent_reg.async_get_or_create( + "cover", DOMAIN, f"{MAC}-1-0004", config_entry=config_entry, device_id=yaml_dev.id, suggested_object_id="gate" + ) + ghost = ent_reg.async_get_or_create( + "light", DOMAIN, f"{MAC}-1-04", config_entry=config_entry, device_id=bus_dev.id, suggested_object_id="light_04" + ) + buttons = [ + ent_reg.async_get_or_create("button", DOMAIN, f"{MAC}-1-04{suffix}", config_entry=config_entry, device_id=bus_dev.id) + for suffix in ("-disable", "-enable") + ] + # A light of another config entry under the canonical id is not this entry's ghost. + other_entry = MockConfigEntry(domain=DOMAIN, title="GW2", data={CONF_MAC: MAC}, unique_id="other", entry_id="e2") + other_entry.add_to_hass(hass) + theirs = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-0005", config_entry=other_entry) + mine_switch = ent_reg.async_get_or_create("switch", DOMAIN, f"{MAC}-1-0005", config_entry=config_entry) + theirs_canonical = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-05", config_entry=other_entry) + + with caplog.at_level(logging.INFO): + migrate_entry_and_registries(hass, config_entry, {}) + + assert ent_reg.async_get(gate.entity_id).unique_id == f"{MAC}-1-04" + assert ent_reg.async_get(gate.entity_id).device_id == yaml_dev.id + assert ent_reg.async_get(ghost.entity_id) is None + assert all(ent_reg.async_get(button.entity_id) is None for button in buttons) + assert dev_reg.async_get(bus_dev.id) is None + assert dev_reg.async_get(yaml_dev.id).identifiers == {(DOMAIN, f"{MAC}-1-04")} + assert {e.entity_id for e in er.async_entries_for_device(ent_reg, yaml_dev.id, include_disabled_entities=True)} == {"cover.gate"} + assert any( + f"Removed {ghost.entity_id} and 2 button(s): a light bus discovery created at the address of cover.gate" in r.getMessage() + for r in caplog.records + ) + assert ent_reg.async_get(mine_switch.entity_id).unique_id == f"{MAC}-1-05" + assert ent_reg.async_get(theirs.entity_id).unique_id == f"{MAC}-1-0005" + assert ent_reg.async_get(theirs_canonical.entity_id).unique_id == f"{MAC}-1-05" + + # An owner already spelled canonically (an earlier run renamed it) still gets the cleanup. + late_ghost = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-04", config_entry=config_entry) + late_button = ent_reg.async_get_or_create("button", DOMAIN, f"{MAC}-1-04-enable", config_entry=config_entry) + switch_06 = ent_reg.async_get_or_create("switch", DOMAIN, f"{MAC}-1-06", config_entry=config_entry) + light_06 = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-06", config_entry=config_entry) + light_15 = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-15", config_entry=config_entry) + ent_reg.async_get_or_create("switch", DOMAIN, f"{MAC}-1-15", config_entry=config_entry) + migrate_entry_and_registries(hass, config_entry, {}) + assert ent_reg.async_get(late_ghost.entity_id) is None and ent_reg.async_get(late_button.entity_id) is None + assert ent_reg.async_get(light_06.entity_id) is None # a switch address never holds a light (light.py rule) + assert ent_reg.async_get(switch_06.entity_id) is not None + assert ent_reg.async_get(light_15.entity_id) is not None # not an area-0 point: not this migration's business + + +def test_the_surviving_entity_keeps_its_own_customisations(hass: HomeAssistant, config_entry) -> None: + """Carry-over is one way: what the yaml entity already has is not overwritten by the twin's.""" + ent_reg = er.async_get(hass) + mine = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-0004", config_entry=config_entry) + ent_reg.async_update_entity(mine.entity_id, area_id="hall", icon="mdi:lamp", name="Mine", labels={"a"}) + twin = ent_reg.async_get_or_create("light", DOMAIN, f"{MAC}-1-04", config_entry=config_entry) + ent_reg.async_update_entity(twin.entity_id, area_id="kitchen", icon="mdi:bulb", name="Twin", labels={"b"}) + with patch.object(er.EntityRegistry, "async_update_entity", wraps=ent_reg.async_update_entity) as update: + migrate_entry_and_registries(hass, config_entry, {}) + moved = ent_reg.async_get(mine.entity_id) + assert moved.unique_id == f"{MAC}-1-04" + assert (moved.area_id, moved.icon, moved.name, moved.labels) == ("hall", "mdi:lamp", "Mine", {"a"}) + assert all("area_id" not in call.kwargs for call in update.call_args_list) diff --git a/tests/test_validate.py b/tests/test_validate.py index a9b3dc44..cf83abd7 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -173,7 +173,7 @@ def test_point_to_point_validator(self): assert validator("11") == "11" assert validator("00") == "00" assert validator("99") == "99" - assert validator("0101") == "0101" + assert validator("0101") == "0101" # left as typed; only area 0 is rewritten (#147, OWNd#123) assert validator("1015") == "1015" assert validator("0000") == "0000" assert repr(validator) == "Where(String, msg='custom p2p msg')"