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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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를 시작할 때마다 만들어졌다가 곧바로 레지스트리 정리에서 지워지던 엔티티 세 종류를 보존합니다. 플랫폼과 정리 단계의 판단 기준이 달랐습니다.
Expand Down
6 changes: 6 additions & 0 deletions addon/smartthings_web_bridge/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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를 시작할 때마다 만들어졌다가 곧바로 레지스트리 정리에서 지워지던 엔티티 세 종류를 보존합니다. 플랫폼과 정리 단계의 판단 기준이 달랐습니다.
Expand Down
2 changes: 1 addition & 1 deletion addon/smartthings_web_bridge/config.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion bridge/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ type ObservableContext = BrowserContextLike & {
newCDPSession?: (page: BrowserPageLike) => Promise<CdpSessionLike>;
};

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";
Expand Down
2 changes: 1 addition & 1 deletion bridge/tests/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
34 changes: 29 additions & 5 deletions custom_components/smartthings_web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion custom_components/smartthings_web/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
76 changes: 75 additions & 1 deletion custom_components/smartthings_web/tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ha-smartthings-web",
"version": "1.8.72",
"version": "1.8.73",
"private": true,
"type": "module",
"packageManager": "[email protected]",
Expand Down
2 changes: 1 addition & 1 deletion protocol/version.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 14 additions & 0 deletions release-notes/1.8.73.md
Original file line number Diff line number Diff line change
@@ -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은 버전 정렬용이며 동작 변경이 없습니다.
12 changes: 6 additions & 6 deletions tests/addon-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,22 @@ const standaloneDockerfile = () => readText("docker/Dockerfile");
const composeConfig = () => YAML.parse(readText("docker/compose.example.yaml")) as Record<string, unknown>;

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<string, unknown>;
const protocolMetadata = JSON.parse(readText("protocol/version.json")) as Record<string, unknown>;
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");
Expand Down
6 changes: 3 additions & 3 deletions tests/protocol-version-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading