diff --git a/CHANGELOG.md b/CHANGELOG.md index faaa7bf..bbafb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.8.73 + +- 통합: Home Assistant 실행 중 약 30분마다 버튼·이진 센서·조명 등이 무더기로 사라지던 문제를 수정했습니다. Bridge의 주기 상태 재조정(`state_sync_watchdog`)이 전체 인벤토리를 다시 적용하고 명령 카탈로그는 비동기로 다시 만드는 동안, 통합의 레지스트리 정리가 잠깐 불완전한 인벤토리를 보고 엔티티를 지웠습니다. 플랫폼은 이미 만든 엔티티를 같은 실행 중에 다시 만들지 않으므로, 지워진 엔티티는 다음 재시작까지 돌아오지 않았습니다. +- 통합: 이제 엔티티와 기기 카드 삭제는 설정 직후(인벤토리가 준비된 첫 정리)에만 합니다. 실행 중 인벤토리 변경으로 다시 도는 정리는 이름 정리만 하고, 정말 사라진 기기의 정리는 다음 시작으로 미룹니다. +- Bridge 앱과 HACS 통합 버전은 1.8.73입니다. Bridge 동작은 바뀌지 않았습니다. + ## 1.8.72 - 통합: Home Assistant를 시작할 때마다 만들어졌다가 곧바로 레지스트리 정리에서 지워지던 엔티티 세 종류를 보존합니다. 플랫폼과 정리 단계의 판단 기준이 달랐습니다. diff --git a/addon/smartthings_web_bridge/CHANGELOG.md b/addon/smartthings_web_bridge/CHANGELOG.md index 4353520..0f497d0 100644 --- a/addon/smartthings_web_bridge/CHANGELOG.md +++ b/addon/smartthings_web_bridge/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.8.73 + +- 통합: Home Assistant 실행 중 약 30분마다 버튼·이진 센서·조명 등이 무더기로 사라지던 문제를 수정했습니다. Bridge의 주기 상태 재조정(`state_sync_watchdog`)이 전체 인벤토리를 다시 적용하고 명령 카탈로그는 비동기로 다시 만드는 동안, 통합의 레지스트리 정리가 잠깐 불완전한 인벤토리를 보고 엔티티를 지웠습니다. 플랫폼은 이미 만든 엔티티를 같은 실행 중에 다시 만들지 않으므로, 지워진 엔티티는 다음 재시작까지 돌아오지 않았습니다. +- 통합: 이제 엔티티와 기기 카드 삭제는 설정 직후(인벤토리가 준비된 첫 정리)에만 합니다. 실행 중 인벤토리 변경으로 다시 도는 정리는 이름 정리만 하고, 정말 사라진 기기의 정리는 다음 시작으로 미룹니다. +- Bridge 앱과 HACS 통합 버전은 1.8.73입니다. Bridge 동작은 바뀌지 않았습니다. + ## 1.8.72 - 통합: Home Assistant를 시작할 때마다 만들어졌다가 곧바로 레지스트리 정리에서 지워지던 엔티티 세 종류를 보존합니다. 플랫폼과 정리 단계의 판단 기준이 달랐습니다. diff --git a/addon/smartthings_web_bridge/config.yaml b/addon/smartthings_web_bridge/config.yaml index c5709f0..d0abed4 100644 --- a/addon/smartthings_web_bridge/config.yaml +++ b/addon/smartthings_web_bridge/config.yaml @@ -1,7 +1,7 @@ name: SmartThings Web Bridge slug: smartthings_web_bridge description: Headed Chromium bridge for observing SmartThings Web traffic from Home Assistant. -version: 1.8.72 +version: 1.8.73 url: https://github.com/1bobby-git/HA-SmartThings_Web startup: services boot: auto diff --git a/bridge/src/runtime.ts b/bridge/src/runtime.ts index fa6206c..ec396fe 100644 --- a/bridge/src/runtime.ts +++ b/bridge/src/runtime.ts @@ -118,7 +118,7 @@ type ObservableContext = BrowserContextLike & { newCDPSession?: (page: BrowserPageLike) => Promise; }; -const bridgeVersion = "1.8.72"; +const bridgeVersion = "1.8.73"; const SESSION_TOUCH_INTERVAL_MS = 5 * 60_000; const DETAIL_DISCOVERY_INTERVAL_MS = 15_000; const PROFILE_MAINTENANCE_REQUIRED_FILE = ".profile-maintenance-required"; diff --git a/bridge/tests/runtime.test.ts b/bridge/tests/runtime.test.ts index b531d12..5ed4169 100644 --- a/bridge/tests/runtime.test.ts +++ b/bridge/tests/runtime.test.ts @@ -1013,7 +1013,7 @@ describe("createBridgeRuntime", () => { await runtime.browserStartup; expect(log.info.mock.calls.slice(0, 14)).toEqual([ - ["bridge_init:version:1.8.72:home_monitor_direct"], + ["bridge_init:version:1.8.73:home_monitor_direct"], ["bridge_init:data_paths"], ["bridge_init:data_paths:data_dir"], ["bridge_init:data_paths:profile_dir"], diff --git a/custom_components/smartthings_web/__init__.py b/custom_components/smartthings_web/__init__.py index e8bb21b..e76a226 100644 --- a/custom_components/smartthings_web/__init__.py +++ b/custom_components/smartthings_web/__init__.py @@ -304,12 +304,22 @@ def _subscribe_entity_registry_migration( runtime = entry.runtime_data last_topology: tuple[object, ...] | None = None + # Removal is allowed until one pass has run on a ready inventory (the + # setup topology: its immediate pass and settled retry). Later topology + # changes come from live inventory updates and only repair names. + removal_allowed = True + ready_cleanup_done = False + def run_migration() -> None: - nonlocal scheduled + nonlocal scheduled, ready_cleanup_done scheduled = False if not active: return - _migrate_entity_registry(hass, entry, runtime.inventory) + _migrate_entity_registry( + hass, entry, runtime.inventory, allow_removal=removal_allowed + ) + if removal_allowed and runtime.inventory.ready: + ready_cleanup_done = True def schedule_migration() -> None: nonlocal scheduled @@ -327,6 +337,9 @@ def schedule_settled_migrations() -> None: ) if topology == last_topology: return + nonlocal removal_allowed + if ready_cleanup_done: + removal_allowed = False last_topology = topology for handle in delayed_handles: cancel = getattr(handle, "cancel", None) @@ -537,8 +550,16 @@ def _migrate_entity_registry( hass: HomeAssistant, entry: SmartThingsWebConfigEntry, inventory: BridgeInventory, + *, + allow_removal: bool = True, ) -> None: - """Repair only this integration's old unique IDs and binary switch artifacts.""" + """Repair only this integration's old unique IDs and binary switch artifacts. + + ``allow_removal`` is False for passes triggered by inventory changes after + setup: a periodic Bridge resync can publish a partial inventory for a few + seconds, and the platforms never re-add an entity they already created in + this run, so a removal then would last until the next start. + """ registry = er.async_get(hass) registry_entries = list(er.async_entries_for_config_entry(registry, entry.entry_id)) current_entity_ids = { @@ -721,7 +742,7 @@ def _migrate_entity_registry( def remove_registry_entity(entity_id: str) -> None: """Remove one row and release its ID for this same migration pass.""" - if not inventory.ready: + if not inventory.ready or not allow_removal: return if registry.async_get(entity_id) is None: return @@ -1102,7 +1123,10 @@ def owner_device_for(entity_entry: object) -> object | None: existing = registry.async_get_entity_id(entity_entry.domain, DOMAIN, new_unique_id) if existing is None and registry.async_get(registry_entity_id) is not None: registry.async_update_entity(registry_entity_id, new_unique_id=new_unique_id) - _remove_orphan_bridge_device_cards(hass, entry, inventory, registry_entries, removed_entity_ids) + if allow_removal: + _remove_orphan_bridge_device_cards( + hass, entry, inventory, registry_entries, removed_entity_ids + ) def _rename_generated_registry_entity( diff --git a/custom_components/smartthings_web/manifest.json b/custom_components/smartthings_web/manifest.json index 8aec9ac..c446b79 100644 --- a/custom_components/smartthings_web/manifest.json +++ b/custom_components/smartthings_web/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_push", "issue_tracker": "https://github.com/1bobby-git/HA-SmartThings_Web/issues", "requirements": [], - "version": "1.8.72" + "version": "1.8.73" } diff --git a/custom_components/smartthings_web/tests/test_init.py b/custom_components/smartthings_web/tests/test_init.py index 14f03b9..eb13233 100644 --- a/custom_components/smartthings_web/tests/test_init.py +++ b/custom_components/smartthings_web/tests/test_init.py @@ -788,6 +788,80 @@ def dev(device_id: str, name: str, device_type: str | None, *states: BridgeState # entity owns it. Everything else is what the platforms create. self.assertEqual(registry.removed, ["sensor.hub_firmware_state"]) + def test_runtime_inventory_change_never_removes_entities(self) -> None: + """A periodic Bridge resync can publish a partial inventory for a moment. + + Only the setup passes may remove rows; later topology changes defer + removal to the next start, because platforms never re-add an entity + they already created in this run. + """ + from smartthings_web.command_controls import command_suffix + from smartthings_web.models import BridgeCommandDescriptor + + button_command = BridgeCommandDescriptor( + "identifier_c9246e0ac435", "identifier_df9ead01127d", 1, "refresh", (), "advanced", + "accepted_receipt", "refresh", "capability", "main", + ) + state = BridgeState("main", "temperatureMeasurement", "temperature", 21, "C", "2026-11-10T00:00:00Z") + + def device(commands: tuple, device_type: str | None) -> BridgeDevice: + return BridgeDevice( + device_id="dev_344", location_id="loc_001", room_id=None, name="Counter", + device_type=device_type, online=True, states={state.key: state}, commands=commands, + ) + + def inventory(dev: BridgeDevice, sequence: int) -> BridgeInventory: + return BridgeInventory( + sequence=sequence, ready=True, bridge_version="1.8.72", protocol_version="6", + locations={"loc_001": "Home"}, rooms={}, devices={"dev_344": dev}, + ) + + button_uid = f"dev_344_{command_suffix('button', button_command)}" + registry = FakeRegistry( + [ + entity("button.counter_refresh", "button", button_uid), + entity("select.counter_gone", "select", "dev_344_select_gone"), + ] + ) + self.patch_registry(registry) + scheduled: list[Callable[[], None]] = [] + delayed: list[tuple[float, Callable[[], None]]] = [] + hass = SimpleNamespace( + loop=SimpleNamespace( + call_soon=lambda callback: scheduled.append(callback), + call_later=lambda delay, callback: ( + delayed.append((delay, callback)) or SimpleNamespace(cancel=lambda: None) + ), + ), + ) + callbacks: list[Callable[[], None]] = [] + runtime = SimpleNamespace( + inventory=inventory(device((button_command,), "counter"), 1), + subscribe=lambda callback: callbacks.append(callback) or (lambda: None), + ) + entry = SimpleNamespace( + entry_id="entry_001", data={CONF_LOCATION_ID: "loc_001"}, + runtime_data=runtime, async_on_unload=lambda callback: None, + ) + + _subscribe_entity_registry_migration(hass, entry) + scheduled.pop(0)() + delayed.pop(0)[1]() + scheduled.pop(0)() + # Setup passes still clean up rows the inventory no longer produces. + self.assertEqual(registry.removed, ["select.counter_gone"]) + + # Resync: same device, metadata and command catalog not rebuilt yet. + runtime.inventory = inventory(device((), None), 2) + callbacks[0]() + while scheduled or delayed: + if scheduled: + scheduled.pop(0)() + else: + delayed.pop(0)[1]() + + self.assertEqual(registry.removed, ["select.counter_gone"]) + def test_updates_control_number_unique_id_when_state_number_is_missing(self) -> None: registry = FakeRegistry( [ @@ -1388,7 +1462,7 @@ def test_dynamic_registry_migration_ignores_value_only_inventory_changes(self) - migrations: list[int] = [] original_migration = integration._migrate_entity_registry integration._migrate_entity_registry = ( - lambda _hass, _entry, inventory: migrations.append(inventory.sequence) + lambda _hass, _entry, inventory, **_kwargs: migrations.append(inventory.sequence) ) try: unsubscribe = _subscribe_entity_registry_migration(hass, entry) diff --git a/package-lock.json b/package-lock.json index 5bf4428..645791d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ha-smartthings-web", - "version": "1.8.72", + "version": "1.8.73", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ha-smartthings-web", - "version": "1.8.72", + "version": "1.8.73", "license": "MIT", "dependencies": { "playwright-core": "1.65.0" diff --git a/package.json b/package.json index 680922e..292a5aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ha-smartthings-web", - "version": "1.8.72", + "version": "1.8.73", "private": true, "type": "module", "packageManager": "npm@11.12.1", diff --git a/protocol/version.json b/protocol/version.json index f775f73..f3685eb 100644 --- a/protocol/version.json +++ b/protocol/version.json @@ -1,5 +1,5 @@ { - "bridge_version": "1.8.72", + "bridge_version": "1.8.73", "protocol_version": 6, "playwright_version": "1.65.0", "expected_chromium_version": "158.0.8127.18", diff --git a/release-notes/1.8.73.md b/release-notes/1.8.73.md new file mode 100644 index 0000000..4541150 --- /dev/null +++ b/release-notes/1.8.73.md @@ -0,0 +1,14 @@ +# 1.8.73 + +Home Assistant 실행 중 약 30분마다 버튼·이진 센서·조명 등이 한꺼번에 사라지던 문제를 수정했습니다. + +- Bridge는 30분마다 상태를 다시 맞추면서 인벤토리 전체를 다시 적용하고, 명령 카탈로그와 기기 정보는 그 뒤에 비동기로 채웁니다. 그 몇 초 사이 통합의 엔티티 레지스트리 정리가 불완전한 인벤토리를 보고 명령 버튼, 읽기 전용 가전 전원 이진 센서 등을 지웠습니다. +- 플랫폼은 같은 실행 중에 이미 만든 엔티티를 다시 만들지 않기 때문에, 지워진 엔티티는 Home Assistant를 다시 시작할 때까지 돌아오지 않았습니다. +- 이제 삭제는 설정 직후 인벤토리가 준비된 첫 정리에서만 합니다. 실행 중의 정리는 엔티티 ID 이름 정리만 하고, 정말로 사라진 기기·엔티티는 다음 시작 때 정리합니다. +- Bridge 동작은 바뀌지 않았습니다. 버전 정렬 규칙에 따라 Bridge 앱 버전도 1.8.73입니다. 프로토콜 계약은 6을 유지합니다. + +## 업데이트 + +1. HACS 통합을 1.8.73으로 업데이트하고 Home Assistant를 다시 시작합니다. +2. 재조정 때 지워졌던 엔티티는 재시작하면 같은 고유 ID로 다시 만들어지고, 이후 실행 중에는 지워지지 않습니다. +3. Bridge 앱 1.8.73은 버전 정렬용이며 동작 변경이 없습니다. diff --git a/tests/addon-config.test.ts b/tests/addon-config.test.ts index 3d1e8d1..c969778 100644 --- a/tests/addon-config.test.ts +++ b/tests/addon-config.test.ts @@ -10,22 +10,22 @@ const standaloneDockerfile = () => readText("docker/Dockerfile"); const composeConfig = () => YAML.parse(readText("docker/compose.example.yaml")) as Record; describe("Home Assistant add-on metadata", () => { - test("packages the start-flap entity registry fix as version 1.8.72", () => { + test("packages the runtime registry removal guard as version 1.8.73", () => { const config = addonConfig(); const packageMetadata = JSON.parse(readText("package.json")) as Record; const protocolMetadata = JSON.parse(readText("protocol/version.json")) as Record; const runtime = readText("bridge/src/runtime.ts"); const changelog = readText("addon/smartthings_web_bridge/CHANGELOG.md"); - expect(config.version).toBe("1.8.72"); + expect(config.version).toBe("1.8.73"); expect(config.homeassistant_api).toBe(true); expect(config.hassio_api).toBe(true); expect(config.discovery).toEqual(["smartthings_web"]); - expect(packageMetadata.version).toBe("1.8.72"); - expect(protocolMetadata.bridge_version).toBe("1.8.72"); + expect(packageMetadata.version).toBe("1.8.73"); + expect(protocolMetadata.bridge_version).toBe("1.8.73"); expect(protocolMetadata.protocol_version).toBe(6); - expect(runtime).toContain('const bridgeVersion = "1.8.72";'); - expect(changelog).toContain("## 1.8.72"); + expect(runtime).toContain('const bridgeVersion = "1.8.73";'); + expect(changelog).toContain("## 1.8.73"); expect(changelog).toContain("## 1.8.38"); expect(changelog).toContain("## 1.8.37"); expect(changelog).toContain("## 1.8.12"); diff --git a/tests/protocol-version-contract.test.ts b/tests/protocol-version-contract.test.ts index 17fe938..7ecb26c 100644 --- a/tests/protocol-version-contract.test.ts +++ b/tests/protocol-version-contract.test.ts @@ -12,9 +12,9 @@ import { import { bootstrapDataPaths } from "../bridge/src/security/data-paths.js"; describe("protocol version contract", () => { - test("keeps integration 1.8.72 compatible with Bridge 1.8.72 and protocol 6", () => { - const expectedBridgeVersion = "1.8.72"; - const expectedIntegrationVersion = "1.8.72"; + test("keeps integration 1.8.73 compatible with Bridge 1.8.73 and protocol 6", () => { + const expectedBridgeVersion = "1.8.73"; + const expectedIntegrationVersion = "1.8.73"; const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as { version?: string }; const packageLock = JSON.parse(readFileSync("package-lock.json", "utf8")) as { version?: string;